Skip to content

fix(rest)!: export refuses an unserved format and clone refuses a non-object overrides - #27659

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-27500-rest-door-refuse-malformed
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-27500-rest-door-refuse-malformed

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Sales · project thread

Fixes #27500
Fixes #27489

Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — no skill teaches either door's malformed values, and content/docs/api/data-api.mdx describes only the accepted shapes, which are unchanged

维护者速读

  • 改了什么:导出接口 GET /data/OBJ/export 收到不支持的 format(如 pdf、xml、空值)时返回 400,不再悄悄给一份 CSV;克隆接口 POST /data/OBJ/ID/clone 收到不是对象的 overrides(字符串、数字、数组、null)时返回 400,不再悄悄当成普通复制。
  • 为什么改:调用方要的东西没给,却拿到 200/201 成功,脚本和集成无法察觉;数组 overrides 还会被按下标合并进新记录。
  • 风险与回滚:风险是曾经发送这类错误值并"侥幸成功"的调用方现在会收到 400;不传 format、传 csv/json/xlsx、传对象 overrides 或不传 body 的行为完全不变。回滚即还原 data-action-routes.ts 的两处读取。
  • 先看:packages/rest/src/data-action-routes.ts(导出的 readExportFormat 与克隆的 isFieldMap 守卫)、packages/rest/src/export-format-refusal.test.ts、packages/rest/src/clone-overrides-refusal.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/rest exec vitest run src/export-format-refusal.test.ts src/clone-overrides-refusal.test.ts,预期 2 个文件 28 条全部通过。
  • 需要你做的:无,席位落地。

What changes

One door, one reading per parameter, both in packages/rest/src/data-action-routes.ts; one commit per card.

  • rest: GET /data/OBJ/export silently answers an unknown format (pdf, xml, empty) with a 200 CSV instead of refusing it #27500, export format. readExportFormat reads the parameter: absent is csv; a present value, trimmed and case-insensitive (the reading the route's own template=true mode already gives format), must be one of the route's served set csv, json, xlsx. Anything else answers 400 through the shared nested writer (sendError from @objectstack/types): error.code: 'VALIDATION_ERROR', a message naming the value and the served set. The refusal comes before findData, so no row is read and no export header is set. The set is the route's own, not the spec's wider ExportFormat enum (jsonl, parquet and the rest were never served and are refused); packages/spec is not edited.
  • format= (empty) is decided: refused. An empty value is a value the caller wrote, the same reading this route already gives limit= (emptyIsAbsent: false) and that template=true gives format=.
  • data: POST /api/v1/data/:object/:id/clone accepts an overrides that is not an object (a string) and answers 201 with the overrides silently ignored #27489, clone overrides. The handler still accepts both { "overrides": {...} } and a bare field map. A nested overrides, or a bare body, that is not a JSON object (a string, number, boolean, null or an array) answers 400 VALIDATION_ERROR naming overrides, before cloneData is called. That is the triage's locus: at the REST clone door, before cloneData. packages/metadata-protocol is not edited.
  • "overrides": null is decided: refused, not read as absent. A bare null body stays what the transport already makes it (req.body ?? {}, a plain copy), and no body at all is a plain copy as before.
  • Envelope and code. Both refusals use the nested ADR-0112 envelope that this route's query gates already answer, through the shared writer, so check:route-envelope counts no new hand-built body. VALIDATION_ERROR is a StandardErrorCode already on the ledger; no code is minted.

Tests

Both suites drive the real HonoHttpServer (the adapter os serve mounts), so the query string and the JSON body are parsed by the production code.

  • packages/rest/src/export-format-refusal.test.ts: refusal for pdf, xml, empty, blank, and jsonl (in the spec enum, never served). Each asserts status 400, error.code, that the message names "format" and the set, that there is no X-Export-Format, and that findData was never called. Preservation: absent → csv with the row streamed; csv/CSV/json/JSON/json/xlsx/Xlsx each answer their own X-Export-Format.
  • packages/rest/src/clone-overrides-refusal.test.ts: refusal for nested string, number, true, null, array, empty array, and bare array, string and number bodies. Each asserts status 400, error.code, the message naming "overrides", and that cloneData was never called. Preservation: nested map, bare map, {"overrides":{}}, {} and bare null reach cloneData with the expected overrides, and no body at all is a 201 plain copy.

Acceptance notes

  • The changeset declares Clause-②: no (narrowing) where both claims say Clause-②: no. Both doors now refuse values they used to accept and answer as success, which is an accept-set narrowing (AGENTS.md Post-Task Checklist step 3). So the changeset carries the BREAKING banner, its migration, and the ADR-0087 disposition (not-required (no-migration-prescription)), at patch level because the kind is none. Seat: confirm or correct the arm.
  • Not changed, noted. A body carrying overrides and other top-level keys still reads only overrides and ignores the rest, as before. This is outside both cards.
  • Commit shape. There are two card commits, then test(rest): type the clone refusal test's parsed body (a cast that tsconfig.test.json needed, folded separately because rewriting the pushed branch was refused in this session), then the changeset commit, then one merge of origin/main.

Verification

All readings are at head 035ecdcf (after one merge of origin/main) unless a line says otherwise. Box seconds are shared-box seconds.

Package tests and typecheck: @objectstack/rest green
  • pnpm --filter @objectstack/rest build && pnpm --filter @objectstack/rest test && pnpm --filter @objectstack/rest typecheck under the verify lock: VERDICT command-exit 0. Test Files 337 passed (337), Tests 6615 passed and 326 skipped. tsc --noEmit was clean, and check:test-typecheck was OK with 0 files and 0 errors in the debt ledger.
  • New suites alone: Test Files 2 passed (2), Tests 28 passed (28).
  • eslint --no-inline-config packages/rest: exit 0.
Ablations: both negative pins go red with the old coercion put back, and are restored to blob == HEAD

Each leg used node scripts/ablation-replace.mjs in wrap mode, with a shell trap restoring HEAD. The tests import ./rest-server from src/, so no dist/ leg applies.

Derived gate families: 59 derived, 59 run, all exit 0
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 59 commands at 035ecdcf. Each was run with its exit code captured before any pipe, and all 59 exited 0.
  • --ran reconciliation: 59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED (a DERIVED zero — all 59 recorded an exit code and none of them is 3).
  • On the earlier pre-merge run, two families answered PREREQUISITE NOT MET (exit 3): check:dual-build-cjs-loads had no dist/ yet, and check-plugin-teardown-shape --self-test was on a shallow checkout. Both exited 0 on the final-head run above.
  • Open PRs touching the same files: 0 of 43, so no merge-tree probe was needed.
Dogfood battery (required by the narrowing arm): 2 timeouts, both unrelated and green on re-run
  • Under the lock, pnpm --filter '@objectstack/dogfood^...' build && pnpm --filter @objectstack/dogfood test at 035ecdcf: VERDICT command-exit 1, held 3349 s. Test Files: 2 failed, 349 passed, 3 skipped (354). Tests: 2 failed, 2820 passed, 20 skipped (2842).
  • The two failures were both Test timed out in 5000ms: object-designer-field-reorder.dogfood.test.ts (creates and publishes the object in its declared order) and wall-package-refusal.dogfood.test.ts (a package install, upgrade and uninstall round trip). Neither file calls the data export or data clone door.
  • Re-run of those two files alone, while another worktree's turbo build held the box: the same two tests timed out at about 5.4 s. With --testTimeout=30000: Test Files 2 passed (2), Tests 24 passed (24). Read: box load, not this diff.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F

GET /data/:object/export mapped every format other than json and xlsx to
csv, so format=pdf, format=xml and format= answered 200 with a csv file.
An absent format is still csv; any other value outside csv, json, xlsx
(the empty value included) now answers 400 VALIDATION_ERROR naming the
value and the served set, before any row is read.

Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F
Co-authored-by: Claude <noreply@anthropic.com>
POST /data/:object/:id/clone passed overrides on only when typeof said
object, so a string, number, boolean or null was dropped and the door
answered 201 with a plain copy, and an array reached cloneData and was
merged index-wise. A nested overrides or a bare body that is not a JSON
object now answers 400 VALIDATION_ERROR naming overrides, before
cloneData is called. An absent body or member is still a plain copy.

Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F
Co-authored-by: Claude <noreply@anthropic.com>
tsconfig.test.json reads Response.json() as unknown, so the body is cast
to any where the test reads error.code; no assertion changes.

Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 4d1f52c4458372169bf641f58db312734da5fb10.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 4d1f52c4458372169bf641f58db312734da5fb10 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8067c249758c56ad01bb350a62fb3f0f93a103af — the merge of head 035ecdcf0a8a46b4f47bdb7cd4b3ca12a0abb735 into base 4d1f52c4458372169bf641f58db312734da5fb10, 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 8067c249758c56ad01bb350a62fb3f0f93a103af && git checkout 8067c249758c56ad01bb350a62fb3f0f93a103af
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d1f52c4458372169bf641f58db312734da5fb10 035ecdcf0a8a46b4f47bdb7cd4b3ca12a0abb735 && git checkout -B drift-repro 4d1f52c4458372169bf641f58db312734da5fb10 && git merge --no-ff 035ecdcf0a8a46b4f47bdb7cd4b3ca12a0abb735

node scripts/docs-audit/affected-docs.mjs --json 4d1f52c4458372169bf641f58db312734da5fb10

⚠️ 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 4d1f52c4458372169bf641f58db312734da5fb10 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

Contract-review-claim: session_01L6bhgUCizoYWQVJWjhdR6F · head 035ecdc · 2026-10-29T16:42Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 035ecdcf0a8a46b4f47bdb7cd4b3ca12a0abb735
Local-runs: none
Scope: packages/rest/src/data-action-routes.ts, packages/rest/src/export-format-refusal.test.ts, packages/rest/src/clone-overrides-refusal.test.ts + packages/spec/src/api/export.zod.ts, packages/spec/src/migrations/entries/semantic/18.export-job-family-retired.ts, packages/spec/src/api/errors.zod.ts, packages/types/src/response-envelope.ts, packages/rest/src/query-allowlist.ts, packages/rest/src/query-multiplicity.ts, packages/rest/src/import-template.ts, packages/rest/src/rest-server.ts, packages/metadata-protocol/src/protocol.ts, packages/plugins/plugin-hono-server/src/request-body.ts, packages/client/src/index.ts, ui/packages/data-objectstack/src/index.ts, ui/packages/types/src/data.ts, ui/packages/plugin-report/src/ReportExport.tsx, ui/packages/plugin-report/src/LiveReportExporter.ts, content/docs/api/data-api.mdx, packages/rest/CHANGELOG.md, docs/adr/0112-error-code-vocabulary-and-ledger.md

Face: narrowing — the paths decide no face and the changeset's line-initial Clause-②: no (narrowing) decides it (record-recognisers.mjs says the same of this file list), so the floor is every path of the diff outside the prose faces; the .changeset is judged in ②.

Inputs: cards #27500 and #27489 (bodies, triage, claims, the os-dev-report posted 2026-10-29T16:41Z on #27500, the pointer on #27489), PR #27659 body and file list, git diff origin/main...pr27659 at this head (4 files, +288/−8, read whole), the cited code in the live tree, and the head's check-runs read at this record's writing. Not the dispatch order and not the dispatching seat's conclusions.

① Derived judgments

Every accept-set and public-surface change the diff implies, judged against the live tree:

  1. Export: a present format outside csv, json, xlsx (any case, trimmed; the empty and blank value included) answers 400 instead of a csv file — RIGHT. Before (base blob 11546c50, lines 1074–1076) String(q.format ?? 'csv').toLowerCase() folded every non-json/xlsx value to csv. After, readExportFormat answers undefined for an unserved value and the handler refuses before findData and before any header. refuseRepeatedQueryParams lists format, so an array never reaches the reader; readTemplateMode (import-template.ts 333–338) already reads the same parameter trimmed and lower-cased and refuses a non-xlsx value under template=true with the same code, so the two readings of one parameter now agree in posture.
  2. Export: an absent format is still csv — RIGHT (preserved). raw === undefined answers 'csv'; pinned.
  3. Export: format= (empty) is refused rather than read as absent — RIGHT. The posture this route already gives limit= (readDeclaredQueryNumber, emptyIsAbsent: false, rest-server.ts 870) and template=true gives format. The card allowed either ("An absent format may still default to CSV"); the dev took the stricter, already-practised reading and declared it in the changeset.
  4. Export: the spec enum members jsonl and parquet are refused — RIGHT, and this PR owes no spec lane. packages/spec/src/api/export.zod.ts lines 64–66 state the served door reads "its own format read, not ExportFormat above", and the ADR-0087 D3 entry export-job-family-retired says the door "answers the file itself as CSV, JSON or XLSX. ExportFormat stays published (ExportImportTemplate still references it)". The refusal makes the spec's own statement real on the wire. Senders measured in the tree: the SDK's data.export types format?: 'csv' | 'json' | 'xlsx' (client/src/index.ts 7442); the Console's exportDownload folds ExportDownloadRequest.format ('csv' | 'json' | 'xlsx', ui/packages/types/src/data.ts 1303) to one of the three before it sets the query; ReportExport.tsx builds csv/xlsx in the browser and never calls the door; the 'pdf' default in LiveReportExporter.ts never reaches /data/:object/export (no fetch of it in that file); dogfood sends format=json only (import-user-field-target, second-object-exposure); no skill and no page under content/docs/ names an export format value for this route (the only jsonl/parquet mention is the generated references/api/export.mdx row for ExportImportTemplate.format, a template field). Zero senders of an unserved value.
  5. Clone: a nested overrides that is a string, number, boolean, null or array answers 400 instead of 201 — RIGHT. Before, overrides && typeof overrides === 'object' dropped primitives and null silently and forwarded arrays, which cloneData (protocol.ts 13788) Object.assigns index-wise. After, isFieldMap guards at the door, before cloneData. The protocol's own signature is overrides?: Record of string keys to any values — optional, not nullable — so the door now guarantees exactly the type it calls with. No caller in the tree sends a non-object: the SDK sends overrides ? { overrides } : {}, the Console has no record-clone caller, and every in-repo test sends {} or an object.
  6. Clone: a bare body that is an array, string or number answers 400 — RIGHT (the same guard, nested=false arm). The adapter already refuses a framed text/plain or octet body on read (readRequestBody), so the "got a string" arm is reached only by a JSON string literal.
  7. Clone: "overrides": null is refused while a bare null body, an empty body and no body stay a plain copy — DEFENSIBLE, decided and declared. The asymmetry is the pre-existing req.body ?? {} fold every data door shares, plus the adapter's own fold of an empty application/json body to {} (request-body.ts: whitespace-only text answers { body: {} }), so a header-only POST stays a plain copy — checked, no regression. Inside a JSON object, null is a value the author typed against a member declared optional-not-nullable, and letting it through would reach Object.assign(data, null) as a silent no-op, the silent-coercion shape both cards name. The route's readDeclaredQueryNumber does fold null to absent, but that arm serves the query string, where null cannot be sent. Residual, not a finding: an external typed client that serialises an unset optional as null now gets a 400 whose message names the fix; the changeset says so under "Who sees it".
  8. Public surface: nothing moves. isFieldMap, describeNonFieldMap, EXPORT_FORMATS, readExportFormat are module-private; packages/spec, packages/client and packages/types are untouched; check:api-surface has nothing to see. RIGHT.
  9. Error code and envelope — RIGHT. Both refusals go through sendError (@objectstack/types, the nested { success: false, error: { code, message } } writer the check:route-envelope ratchet converges on) with VALIDATION_ERROR, the standard catalog's 400 member and HttpStatusErrorCodeMap[400]. On this route that is the code the same parameter already answers (readTemplateMode's format refusal) and the code both query gates answer (refuseUnknownQueryParams, refuseRepeatedQueryParams). Recorded for the reader: the route's limit= refusal answers the producer-named VALIDATION_FAILED with fields[] (a declared-schema parse through validationFailure), and the object is required and filter must be JSON arms still write the flat { code, error } dialect — a pre-existing two-dialect state owned by the envelope ratchet, not widened here. INVALID_FORMAT (field-level semantics) and the ledgered INVALID_REQUEST (the flat dialect's code) would have been the wrong choices.
  10. Messages name the parameter, the value (export) or the kind (clone), and the served set; no tracker number in any runtime string. RIGHT.

② Semver level

  • .changeset/27500-27489-rest-door-refuse-malformed.md: '@objectstack/rest': patch; title fix(rest)!: …; line-initial Clause-②: no (narrowing) and Metadata-protocol: none; the BREAKING banner with before / now / unchanged / who sees it / remedy; the adr-0087 marker not-required (no-migration-prescription) with its why. The PR body carries the same two lines plus the Skills-docs: none — … line a (narrowing) arm owes, and its reason holds (no skill teaches either door's malformed values; data-api.mdx describes the clone body as { "overrides": { … } } only and says nothing about the export format set).
  • Clause-②: no (narrowing) is the right classification. The question the claim line answers is whether the card widens an accept set or enlarges the public surface — it does not, so the claims' no stands; the arm names what the diff does to a published door's observable accept set: values that answered 200/201 now answer 400 at two public REST doors. That is how this package has declared the identical shape before — the limit= refusal (CHANGELOG entry 95ab93f: Clause-②: no (narrowing) with the BREAKING banner) and the import doors' refusal of two request shapes (Clause-②: no (narrowing) — … Nothing is widened.). The claims' bare no is not contradicted; the arm is the dev's duty from the diff, and the report says the dispatch asked for the decision. Confirmed.
  • patch is right. Metadata-protocol: none, so the level follows the kind and a non-protocol breaking change ships patch with its banner and disposition (AGENTS.md Post-Task Checklist step 3); major is refused by check-changeset-no-major in any case.
  • The BREAKING text matches the diff, read line by line against the base blob and the head: format= (empty) did answer csv; arrays did reach the clone and merge index-wise; the SDK does send only accepted shapes; nothing authorable is removed or renamed, so no FROM → TO rewrite and no tombstone is owed, and the remedy paragraph is the one-line fix.
  • ADR-0087 marker not-required (no-migration-prescription): no schema key, export or stored shape changes; the body carries a remedy, not a rewrite prescription; the mechanical re-validation of this category (check-adr-0087-registration.mjs --base, run by the Check Changeset job) is success on this head.

③ Boundary flags

Dev flags (os-dev-report on #27500, deviations), each answered:

  1. Clause-② arm no (narrowing) where the claims say no — seat to confirm. Confirmed; see ②.
  2. Commit shape: a separate test(rest): type the clone refusal test's parsed body commit because folding by --force-with-lease was refused. No contract effect; accepted as declared.
  3. Lock narrowing: the first run of the two new suites and the two-file dogfood re-run ran without the verify lock. A process declaration; the final head's closure build, package test, typecheck and full dogfood battery ran under the lock. No contract effect; noted.
  4. Four writes against the three-write budget (the pointer on data: POST /api/v1/data/:object/:id/clone accepts an overrides that is not an object (a string) and answers 201 with the overrides silently ignored #27489 was dispatch-ordered). A seat-budget matter, not a contract one; noted.

open_questions: none declared.

out_of_scope_findings — a clone body carrying overrides plus other top-level keys reads only overrides and drops the rest, unchanged. Escalated to the seat, not a finding on this PR: it is the same silent-drop family as both cards and is measurable in minutes with the harness the new test boots; keeping it out of a two-card fold was right. Recommend a card, dedupe words as the report gives them.

Two further boundary notes for the seat, neither a finding on this diff:

  • The served set now has a refusal but still no spec-declared request schema for this door (the limit= comment's "No request schema is declared for this door" still holds), so the vocabulary lives in a route-local constant. Correct for this card — a packages/spec edit would be a schema face and a different lane — and the long-term home is a declared door schema.
  • ExportFormat keeps publishing jsonl and parquet that no route serves, and ExportImportTemplate, its last consumer, has zero importers outside packages/spec. Spec-lane ADR-0049 debt, pre-existing and disclaimed by the spec's own comment; not this PR's.

PR author is objectstack-fleet[bot]; no off-relay login to name.

Check-runs on this head at this record's writing (2026-10-29T16:56Z): Lint & Repo Gates, TypeScript Type Check, Build Core, Governed Surface Queue Guard, Check Changeset, Check PR Size, PR-body guards, Lint · ESLint, Type Check · workspace and every other Lint / Type Check sub-job, Dogfood Regression Gate, Test UI — success; Test Core shards 1, 2, 5, 6, 7 and 8 — success, shards 3 and 4 — in_progress; the UI and opt-in jobs — skipped by the path filter (no ui/ path in the diff). Nothing red. The landing precondition "every check green" is the seat's to re-read once the two shards conclude; this record does not wait for them.

④ Design judgment

Per in-scope file, against the code it cites and the scenario it is accepted against. Must-fix: none. Should-fix: none.

packages/rest/src/data-action-routes.ts

  • readExportFormat holds: undefined answers csv; otherwise String(raw).trim().toLowerCase() is tested against EXPORT_FORMATS. ?format= and ?format=%20 land on '' and ' ' and are refused (pinned); a bare ?format reads as '' under URLSearchParams semantics and lands on the same refusal (not separately pinned). Arrays are stopped one line earlier by refuseRepeatedQueryParams, which lists format, so String(raw) never sees one. The chosen value flows typed (ExportFormatServed) into the existing format === 'csv' | 'xlsx' | 'json' writer arms and into exportContentDisposition(…, ext: string).
  • Placement holds: after enforceAuth, enforceApiAccess('export') and enforceExportPermission (an unauthorised caller still gets its 401/403, not a format lecture), after the template=true branch (whose own format read is unchanged), before the limit read and before findData, so no row is read and no X-Export-Format or Content-Disposition is set on a refusal — the test asserts the header is absent.
  • The clone guard holds: nested = isFieldMap(body) && 'overrides' in body; overrides = nested ? body.overrides : body; refused iff present and not a field map. The pre-existing shapes reach cloneData byte-for-byte as before: { overrides: { … } } gives that map; { name: 'x' } gives the bare map; {}, a null body and no body give overrides: {} (the old typeof {} === 'object' arm forwarded {} too). Tried to break it with a header-only application/json POST: the adapter folds an empty body to {} before the handler, so it stays a plain copy. Tried text/plain with a body: the adapter refuses on read before the guard, unchanged.
  • The code comment "as limit= above is refused rather than read as absent" is accurate for the posture (emptyIsAbsent: false); the code limit= answers differs (①.9) and the comment does not claim otherwise.
  • No new hand-built res.json body: both refusals go through the shared writer, so the check:route-envelope count for this module does not move.

packages/rest/src/export-format-refusal.test.ts

  • Boots the real HonoHttpServer and RestServer with a mocked protocol, so the production query parser and allowlist run. The refusal half pins status, error.code, that the message names "format" and csv, json, xlsx, no X-Export-Format, and findData never called — for pdf, xml, empty, blank and jsonl (the spec-enum member). The preservation half pins absent → csv with the row streamed, and each served spelling → its own X-Export-Format with findData called. Both halves the route rule asks for are present; the format is not a findData argument, so the header is the right preservation witness.

packages/rest/src/clone-overrides-refusal.test.ts

  • The same real-adapter boot, so the JSON body is parsed by readRequestBody. Nine refusal shapes each pin 400, VALIDATION_ERROR, "overrides" in the message and cloneData never called; five preserved shapes pin 201 and the exact overrides that cloneData received (toMatchObject on the call's first argument — the arguments pin), plus the no-body case. The null-body row documents the asymmetry ①.7 names instead of leaving it implicit.

The narrowing against the published spec on main: it takes no declared capability away at any consumer — the SDK's declared format union and overrides parameter type, the Console's request type, the docs' { "overrides": { … } } body and the protocol's overrides?: Record already describe only what the doors now accept — and it makes the spec's own disclaimer of ExportFormat for this route real on the wire. Both cards' expected answers (a 400 naming the value and the served set; a 400 naming overrides, at the REST door, before cloneData) are met.

Implemented-by: claude/issue-27500-rest-door-refuse-malformed
Reviewed-by: session_01L6bhgUCizoYWQVJWjhdR6F

VERDICT: PASS

Posted 2026-10-29T16:56Z.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

维护者速读(席位终稿)

  • 改了什么:导出接口 GET /data/OBJ/export 收到不支持的 format(如 pdf、xml、空值)返回 400,不再悄悄给一份 CSV;克隆接口 POST /data/OBJ/ID/clone 收到不是对象的 overrides(字符串、数字、数组、null)返回 400,不再悄悄当普通复制。
  • 为什么改:调用方要的东西没给,却拿到 200/201 成功,脚本和集成察觉不到。
  • 风险与回滚:两个接口收窄(BREAKING,patch 级,和本包以前同类收窄一致);不带 format 仍是 CSV,对象形式的 overrides、裸字段表、空 body 照旧;SDK 与 Console 只发合法值(已核实)。回滚 = revert 本 PR。
  • 先看:packages/rest/src/data-action-routes.ts 的导出 format 读取和克隆 handler 里的 isFieldMap 判断。
  • 两分钟验证:pnpm --filter @objectstack/rest exec vitest run src/export-format-refusal.test.ts src/clone-overrides-refusal.test.ts。
  • 需要你做的:无。非受管面,契约审查已 PASS,席位落地。

席位意见: ACCEPT。两张卡同一文件合一个 PR;收窄已过契约审查,范围外的同类问题另开卡。

席位核对

ACCEPT · head 035ecdcf0a8a46b4f47bdb7cd4b3ca12a0abb735 · 2026-10-29T16:59Z

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/m tests tooling

Projects

None yet

1 participant