Repository navigation
rest: a declared client refusal (400 ERR_FILE_CONSTRAINT on a record write, 422 INVALID_METADATA on a metadata save) is logged as "[REST] Unhandled error" with a full stack trace on every request #23932
Description
Filing gate ① — product defect with a named landing point and a live reproduction. Reach: public entry — the /api/v1/data and /api/v1/meta HTTP doors, measured on a stock showcase boot: every refused write prints an error-level [REST] Unhandled error: line plus a stack trace in the server log.
Reader who acts: the seat that owns packages/rest (error classification door), in the PR that extends isExpectedRouteError in packages/rest/src/error-response.ts.
What happens
logUnexpectedRouteError logs [REST] Unhandled error unless isExpectedRouteError(status, body) is true or the error is a declared 5xx refusal (declaredRefusalMessage). isExpectedRouteError admits only 403/404/409/502/503, the query-rejection codes (UNSUPPORTED_QUERY_PARAM, INVALID_FIELD, INVALID_REQUEST, INVALID_FILTER, INVALID_SORT, INVALID_QUERY) and VALIDATION_FAILED. Two coded, producer-declared client refusals fall outside that list, so each one is logged as a server fault:
| door | refusal on the wire | log |
|---|---|---|
POST/PATCH /api/v1/data/:object with a file reference violating a field's declared accept/maxSize |
400 {"error":"File type 'application/pdf' is not permitted by the accept list declared for 'poster' (image/png, image/jpeg)","code":"ERR_FILE_CONSTRAINT","object":"qa_media"} |
[REST] Unhandled error: FileConstraintError: … + 10-frame stack + { code: 'ERR_FILE_CONSTRAINT', status: 400, field: 'poster', constraint: 'accept' } |
PUT /api/v1/meta/:type/:name with a body that fails spec validation (e.g. a retired translation key) |
422 {"code":"INVALID_METADATA","issues":[…]} |
[REST] Unhandled error: Error: translation/NAME failed spec validation: … + stack + { code: 'INVALID_METADATA', status: 422, issues: […] } |
This is the drift class isExpectedQueryRejection's own docblock records ("the filter and sort codes shipped without joining the list, so every rejection they produced was ALSO logged as an unhandled error") and #4886 fixed for the metadata 404s: FileConstraintError gained a declared status: 400 (the wire now answers 400 with the prose, not the sanitized 500) without joining the predicate, and INVALID_METADATA 422 is the metadata door's per-item validation refusal — the same kind of answer as VALIDATION_FAILED, which is already listed. The consequence is operator-facing: a Studio author iterating on a translation or a form widget enforcing accept/maxSize produces one error-level stack per attempt, indistinguishable in the log from a genuine handler fault.
Reproduction
Stock showcase, isolated boot: OS_PORT=P objectstack dev --ui --seed-admin -p P -d file:DIR/data.db (main 2034975a), signed in as the seeded admin.
POST /api/v1/packages{"manifest":{"id":"com.objectstack.qa.media","name":"QA media","version":"1.0.0","type":"app"},"enableOnInstall":true}→ 201.PUT /api/v1/meta/object/qa_media?package=com.objectstack.qa.media{"name":"qa_media","label":"QA Media","sharingModel":"public_read_write","fields":{"name":{"type":"text","label":"Name","required":true},"poster":{"type":"image","label":"Poster","accept":["image/png","image/jpeg"],"maxSize":1048576}}}→ 200.- Mint a file:
POST /api/v1/storage/upload/presigned{"filename":"probe.pdf","mimeType":"application/pdf","size":10240}→ PUT the bytes touploadUrl→POST /api/v1/storage/upload/complete {"fileId":ID}. POST /api/v1/data/qa_media{"name":"accept-probe","poster":ID}→ 400 ERR_FILE_CONSTRAINT (correct) and the server log prints[REST] Unhandled error: FileConstraintError: …with a stack (wrong).PUT /api/v1/meta/translation/qa_strict_zh{"name":"qa_strict_zh","locale":"zh-CN","objects":{"showcase_task":{"label":"x"}},"o":{"x":1}}→ 422 INVALID_METADATA naming the retiredokey (correct) and the log prints[REST] Unhandled error: Error: translation/qa_strict_zh failed spec validation: …with a stack (wrong).
Expected: both refusals are expected client outcomes and print no [REST] Unhandled error line (at most the one-line withheld-fault note the door already uses). Actual: one error-level line + stack per request. Measured across two boots of the same DB: 5 FileConstraintError and 12 INVALID_METADATA stacks, one per refused request.
Mechanism
packages/rest/src/error-response.ts—isExpectedRouteError/isExpectedQueryRejection/isExpectedDataStatus(the allow-list) andlogUnexpectedRouteError(the only log decision).packages/services/service-storage/src/file-reference-lifecycle.ts—FileConstraintErrornow declarescode: 'ERR_FILE_CONSTRAINT'+status: 400.packages/metadata-protocolsaveMetaItemthrows the 422INVALID_METADATAwithissues.
A fix that keeps the predicate's stated narrowness ("deliberately NOT any 4xx") adds the two codes by name, with a pin that a refused accept/maxSize write and a refused metadata save each produce no [REST] Unhandled error line.
Dedupe
MCP search_issues on objectstack-ai/objectstack, closed included: REST Unhandled error logged with stack trace for declared 4xx refusal ERR_FILE_CONSTRAINT INVALID_METADATA isExpectedRouteError → 7 hits (#22764, #22885, #20603, #14656, #8016, #4886, #11588 — all closed, none covers these two codes); "Unhandled error" log noise expected client error 400 422 stack trace → 3 hits (#5437, #4886, #5489 — closed, different codes). No open card.
QA-source: #23929 · attachments-storage.field-accept-maxsize-server-enforced · clause 2
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 16, 2026
Triage: first grade,
bug·priority:p3·domain:cli·area:api·pm:queue· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-16T14:04ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
domain:cli:packages/rest(error-response.ts,isExpectedRouteError) isdomain:cli.objectstack-fleet commentedon Oct 16, 2026
Claim: PM loop, serial dispatch, p3 only (the maintainer to this seat, latest: 「当前任务处理完,后续只派p3卡」; #23744 landed as
bb14934cdaand this seat has nothing in flight). Label order amongpriority:p3pm:queuebugs indomain:cli, read in this act: #23932 is the only one left (#23898 and #23899 were claimed by seat 2 at 19:17Z). #23932: two producer-declared client refusals (400ERR_FILE_CONSTRAINTon a record write, 422INVALID_METADATAon a metadata save) stop being logged as[REST] Unhandled errorwith a stack, while an undeclared fault stays loud · 2026-10-16T19:31ZSession:
session_01PFB9ZGRWYvjhTXsd8i6KBpAccount:
marchtian(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-23932-declared-client-refusal-logWorktree:
objectstack-issue-23932Domain:
domain:cliSeat:
domain:cli#1(seat post #6024)File surface (read on
origin/mainbb14934cda; line numbers move; stop on a breach and explain it in the report):packages/rest/src/error-response.ts:isExpectedRouteErrorand its three conditions (about:826-:886), andlogUnexpectedRouteError(about:910) only.error-response/ route-log tests underpackages/rest/src/**, and one.changeset/23932-*.md.packages/rest/src/rest-server.ts([maintainer] refactor(rest): splitpackages/rest/src/rest-server.ts(13,701 lines, 70 commits in 30 days) along itsregister*Endpointsseams, one move-only slice per PR,/metaafter #22877 stage 2 #23050's split, PR refactor(rest): move registerUiEndpoints into ui-routes.ts (#23050 slice 10) #23990);packages/rest/src/meta-item-read-gate.ts(meta read: a view stored with nolabelis served withlabel= its qualified name (recheck_gate.default), invented by the read-side translator, so the console view tab shows the machine name #23967, PR fix(rest)!: the /meta item and list reads serve a view stored with no label without an invented label #24013);packages/types/src/thrown-http-error.tsanddeclaredRefusalMessage(read, not edited: the 5xx refusal channel stays as ruled); the producers (service-storage'sFileConstraintError,metadata-protocol'ssaveMetaItem); the wire envelope, status and code of either refusal;packages/spec(the ADR-0112 ledger is read, never edited).Container & model:
S(one log predicate at one door, two declared refusals, measured at the HTTP door),mode:subagent,model: TIER_DEFAULT(dispatch-gates --tier: no path-derived mandate)Clause-②: no
Basis for the Clause-② line: the server log line only; the wire body, status and code of every response are unchanged, and no export, flag or
packages/speckey changes. If the measured route needs a new export on a package entry, the dev stops and reports.Responsibility:
packages/rest's error-classification door, platform code in this lane | the drift its own docblock records (isExpectedQueryRejection: codes that shipped without joining the list);FileConstraintErrorgained a declaredstatus: 400without joining the predicate, and the 5xx-onlydeclaredRefusalMessagechannel does not reach a 4xx | an operator reading the server log of any deployment whose authors hit a field'saccept/maxSizeor save a metadata body that fails spec validation: one error-level stack per refused request (measured on a stock showcase boot, card body)Thread-read: 6217228580
Serial constraints cleared:
pm:dispatchedclaim nameserror-response.ts,isExpectedRouteErrororlogUnexpectedRouteError(the claims of every openpm:dispatchedcard read in this act); meta read: a view stored with nolabelis served withlabel= its qualified name (recheck_gate.default), invented by the read-side translator, so the console view tab shows the machine name #23967 declaresmeta-item-read-gate.tsand [maintainer] refactor(rest): splitpackages/rest/src/rest-server.ts(13,701 lines, 70 commits in 30 days) along itsregister*Endpointsseams, one move-only slice per PR,/metaafter #22877 stage 2 #23050 / Malformed JSON request body is silently parsed as {}: a truncated POST /data/:object/query answers 200 with the whole table, and a truncated PATCH answers 200 with an empty update #23914 declarerest-server.ts, other files. No open PR editserror-response.ts.Generated by Claude Code
objectstack-fleet commentedon Oct 16, 2026
os-dev-report
{
"issue": 23932,
"status": "done",
"branch": "claude/issue-23932-declared-client-refusal-log",
"pr": "#24038",
"session": "session_01PFB9ZGRWYvjhTXsd8i6KBp — the dispatching PM session's id (mode:subagent; this run shares it, identity is the branch)",
"premise_still_valid": true,
"summary": "Route (A), the declaration. logUnexpectedRouteError (packages/rest/src/error-response.ts) adds one condition beside the 5xx declaredRefusalMessage read: a module-private isDeclaredClientRefusal(error, resolved). It holds when the resolved answer is 4xx, the producer declared that status itself (resolveThrownHttpError(...).declaredStatus), and the producer's own code is the wire code. The last condition holds exactly when the code is in StandardErrorCode ∪ the ADR-0112 ledger, because any other spelling is demoted to declaredCode. Both reported refusals (400 ERR_FILE_CONSTRAINT, 422 INVALID_METADATA) now print no [REST] Unhandled error line and no stack. The fix also covers registered siblings the allow list never named (FILE_FIELD_BULK_WRITE_REFUSED, FILTER_TOKEN_UNKNOWN/UNRESOLVED, ANALYTICS_DATE_RANGE_UNRECOGNIZED). Still loud: a handler bug, an un-coded 4xx, a coded 4xx with an unregistered code, a registered code on a status the producer did not declare, a declared 5xx without refusal:true, and a sandbox crash. isExpectedRouteError's logic is unchanged (docblock only). The wire is byte-identical. Landing point is the claim's own; no producer, rest-server.ts, meta-item-read-gate.ts, thrown-http-error.ts or packages/spec edit. All three Zone-2 mechanism assumptions held when measured.",
"tests": "All at head 90fdfcc (base 9e797a8). (1) Positive control on unchanged code, uncommitted scratch harness, real producers through the real route catches. The real FileConstraintError class (service-storage src, own keys code=ERR_FILE_CONSTRAINT, status=400, field, constraint, no refusal) via POST /api/v1/data/qa_media answered 400 {error, code: ERR_FILE_CONSTRAINT, object: qa_media} and logged 1 [REST] Unhandled error line (FileConstraintError, with stack). The real saveMetaItem (ObjectStackProtocolImplementation over an in-memory better-sqlite3 engine, own keys code=INVALID_METADATA, status=422, issues) via PUT /api/v1/meta/translation/qa_strict_zh with retired key o answered 422 {error, code: INVALID_METADATA, issues} and logged 1 line (Error, with stack). resolved.body at the verdict equals the wire body; no declaredCode. After the fix, same harness: 0 lines each, wire byte-identical (md5 of both wire lines 5c1bb663798c2b29f44e0763c6b045be before and after). (2) New packages/rest/src/rest-declared-client-refusal-log.test.ts, 12 cases across both doors. On unchanged code: 'Tests 4 failed | 8 passed (12)', each failing on the log assertion after its wire assertions passed. After the fix, with rest-expected-error-logging.test.ts: 'Tests 20 passed (20)'. (3) pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2: 'Test Files 289 passed (289) · Tests 5953 passed | 326 skipped (6279)', VERDICT command-exit 0. (4) pnpm --filter @objectstack/rest typecheck: VERDICT command-exit 0; check:test-typecheck OK; tsc -p tsconfig.test.json --listFilesOnly lists the new test (1 hit). (5) Ablation on the committed fix via scripts/ablation-replace.mjs: anchor ' && !isDeclaredClientRefusal(error, resolved)' replaced by ' && true /* ABLATION-23932 /'. The tool reported 'ok mutation landed: anchor 1 → 0, blob 77862fd54605 → e14df9570d92' and in-mutation grep counted anchor 0, marker 1. Result 'Tests 4 failed | 8 passed (12)': the 4 quiet cases went red, including both fixed-shape pins. Restore proven: blob 77862fd546 == HEAD and git diff HEAD empty, by the tool and by the trap script. Expected direction red, observed red. Subject resolves via src (relative import), so no dist rebuild. The first attempt was a refused no-op (replacement text contained in the anchor, count 5 → 5) that ran no test; it was re-anchored once. (6) Overreach mutation in the opposite direction ('return true / OVERREACH-23932 / || ...'): exactly the four 4xx loud controls went red, and the three 5xx controls stayed green (band condition). Restore proven the same way.",
"gates": "Local subset at head 90fdfcc. Derived by 'node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack' (no paths; change set 3 paths vs merge base 9e797a8). All 64 derived commands exit 0, captured before any pipe: check-adr-0087-registration.mjs --base origin/main | check-adr-0087-registration.mjs --self-test | check-changeset-no-major.mjs --base origin/main | check-changeset-no-major.mjs --self-test | check-ci-filter-parity.mjs | check-closing-keyword-parity.mjs | check-closing-keyword-parity.mjs --self-test | check-comment-mask-adoption.mjs | check-comment-mask-adoption.mjs --self-test | check-comment-mask-corpus.mjs | check-dts-emitted.mjs --self-test | check-empty-changeset.mjs --base origin/main | check-empty-changeset.mjs --self-test | check-issue-citations.mjs | check-keyed-text-bounds.mjs | check-keyed-text-bounds.mjs --self-test | check-platform-object-tenancy-census.mjs | check-platform-object-tenancy-census.mjs --self-test | check-plugin-teardown-shape.mjs | check-plugin-teardown-shape.mjs --self-test | check-registry-log-declared.mjs | check-registry-log-declared.mjs --self-test | check-rest-log-spy-declared.mjs | check-rest-log-spy-declared.mjs --self-test | check-system-context-census.mjs | check-system-context-census.mjs --self-test | check-undeclared-dep-imports.mjs | check-undeclared-dep-imports.mjs --self-test | docs-audit/check-affected-docs.mjs | docs-audit/check-drift-comment.mjs | pm/release-rehearsal-clone.mjs --self-test | release-pending-publish.mjs --self-test | --filter @objectstack/spec run check:duration-unit-keys | check:changeset-gate-self-tests | check:cross-package-test-inputs | check:dispatcher-error-vocabulary | check:doc-authoring | check:driver-memory-census | check:dts-closure | check:dual-build-cjs-loads | check:engine-double-contract | check:error-status-conformance | check:gitlink-declared | check:issue-citations | check:lean-entry-closure | check:logger-receiver-detach | check:nul-bytes | check:objectql-double-limit | check:objectui-changeset | check:org-identifier | check:page-declaration-shape | check:pm-changeset-deadline-census | check:published-files | check:query-options-erasure | check:refd-timer-probe | check:route-envelope | check:slot-lookup | check:sourcemap-no-sources-content | check:test-source-alias | check:tier-file-adoption | check:type-check-coverage | check:type-check-debt | check:watch-hint-literal | check:where-matcher. Exception: check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET, workspace dist absent). check:type-check-debt's own locked closure build then populated the workspace dist, and the rerun exited 0; ran.list records the rerun. Also exit 0: the 4 artifact-roster gates whose roster sits under a directory this diff touches (check-changeset-fixed.mjs, check:authz-resolver, check:error-code-casing, check:filter-alias-parity). Lane blind spot: 'pnpm exec eslint' over both changed .ts files exit 0, no findings. --ran verdict, verbatim: '✓ dispatch-gates --ran: 64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED (a DERIVED zero — all 64 recorded an exit code and none of them is 3).' check-platform-checklist is not owed: no exported symbol renamed or deleted. CI on PR head 90fdfcc at report time (30 check-runs, not re-run locally): 'No other open PR may claim the same issue'=success, 'The card this PR closes must claim this branch'=success, 'Packed-tarball smoke (opt-in)'=skipped. All others in_progress or queued, including the five required contexts Lint & Repo Gates (as its Lint lanes), TypeScript Type Check (as Type Check lanes), Test Core (1-6/6 queued), Build Core (queued) and Governed Surface Queue Guard (in_progress). The verdict is not awaited, per the contract.",
"mcp_calls": "0",
"api_writes": "2 relay strokes, each one POST /repos/objectstack-ai/objectstack/dispatches from this session, executing 3 REST writes as objectstack-fleet[bot]. Stroke 1, pr_create: POST /repos/objectstack-ai/objectstack/pulls (draft) and POST /repos//issues/24038/assignees (marchtian). Stroke 2: POST /repos//issues/23932/comments (this os-dev-report, via scripts/pm/post-stamped.mjs). Plus 2 git pushes (empty branch, then 90fdfcc), which are not REST. Label writes: 0, because the dispatch named no label and skip-changeset does not apply (a patch changeset for a published package).",
"open_questions": [],
"out_of_scope_findings": [
"carrier: none · noted, not filed. The anonymous public-form submit route in rest-server.ts (a held-off file) asks isExpectedRouteError(mapped.status, mapped.body) directly and logs '[REST] Public form submit error:'. It reads neither the 4xx nor the 5xx declaration, so a declared refusal there still logs under that label. Reach not measured (an anonymous caller would need a file id for a file-class field), so it is not one of the filing classes. Recorded in the PR's Acceptance notes. Dedupe words: public form submit error log declared refusal isExpectedRouteError"
],
"line_budget": "n/a (no skills/* or governed ledger touched)",
"files_changed": [
".changeset/23932-declared-client-refusal-log.md",
"packages/rest/src/error-response.ts",
"packages/rest/src/rest-declared-client-refusal-log.test.ts"
],
"deviations": [
"Base: the worktree was cut from origin/main 9e797a8. The dispatch named bb14934, but origin/main had moved; the claimed functions were unaffected.",
"AGENTS §10 pre-PR pull of main was not performed. origin/main advanced 4 commits to 2b27460 before the PR opened, none touching packages/rest or packages/types (git diff --stat empty for both). The PR's CI runs on the merge ref.",
"The first full-suite launch was spelled 'pnpm --filter @objectstack/rest test -- --maxWorkers=2', the bare -- trap. It was stopped about 12 s in, by its own recorded process group (7429), before any result, and relaunched as 'exec vitest run --project local --maxWorkers=2'. No reading was taken from it.",
"The real-producer reproduction ran as an uncommitted scratch harness. The committed pins mirror the producers' measured shapes because @objectstack/rest does not depend on service-storage (adding it would touch package.json and the lockfile, outside the claimed surface). Importing metadata-protocol/objectql/driver-sql in a new test file would need a KNOWN_UNALIASED_TEST_IMPORTS entry in scripts/check-test-source-alias.mjs, also outside the surface.",
"Added beyond the dispatch: an overreach mutation in the opposite direction, so the 4xx loud controls are shown to bite.",
"test:repo (the vitest repo project) was not run locally: the derivation does not name it and the diff does not touch it. It belongs to CI's Test Core.",
"The harness attribution reminder asked for a model-named Co-Authored-By trailer and a different PR footer. AGENTS.md takes precedence, so the commit carries the model-free pair and the PR body the session-URL footer.",
"Cleanup: the worktree's node_modules is removed before this comment is posted. 'git worktree remove' (no --force) runs after this comment's read-back, as the last step."
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 16, 2026
ACCEPT → PR #24038 comment 6224570552 ·
domain:cliseat 1 ·marchtian· 2026-10-16T20:58Z. Nothing filed from the report (its one finding is an Acceptance note). Landing follows on this true-green head.Generated by Claude Code
objectstack-fleet commentedon Oct 16, 2026
Landed: PR #24038 merged through the queue as
5c2ac83a23·domain:cliseat 1 ·marchtian· sessionsession_01PFB9ZGRWYvjhTXsd8i6KBp· 2026-10-16T21:27Z.merged: true,merged_at2026-10-16T21:26:29Z; timelineadded_to_merge_queue2026-10-16T21:00:51Zorigin/main5c2ac83a23is an ancestor (fetched in this act); single parent (squash)gh-readonly-queue/main/pr-24038-*: 0 refs leftfunction isDeclaredClientRefusal: 1 hit, and its one use!isDeclaredClientRefusal(error, resolved): 1 hit, inpackages/rest/src/error-response.tsonorigin/mainState: #23932 closed
completedbyFixes #23932, the PR body's only closing keyword;pm:dispatchedstripped in this act (domain:cli,priority:p3,area:api,bugstay). This seat's hold onerror-response.tsis released. Nothing was filed from the report; its one finding (the public-form submit route's own log label) is an Acceptance note on the PR.Generated by Claude Code