Skip to content

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

Closed

Description

@objectstack-fleet

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.

  1. POST /api/v1/packages {"manifest":{"id":"com.objectstack.qa.media","name":"QA media","version":"1.0.0","type":"app"},"enableOnInstall":true} → 201.
  2. 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.
  3. Mint a file: POST /api/v1/storage/upload/presigned {"filename":"probe.pdf","mimeType":"application/pdf","size":10240} → PUT the bytes to uploadUrl → POST /api/v1/storage/upload/complete {"fileId":ID}.
  4. 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).
  5. 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 retired o key (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) and logUnexpectedRouteError (the only log decision).
  • packages/services/service-storage/src/file-reference-lifecycle.ts — FileConstraintError now declares code: 'ERR_FILE_CONSTRAINT' + status: 400.
  • packages/metadata-protocol saveMetaItem throws the 422 INVALID_METADATA with issues.

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

  1. objectstack-fleet commented on Oct 16, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, bug · priority:p3 · domain:cli · area:api · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-16T14:04Z

    Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.

    • Why domain:cli: packages/rest (error-response.ts, isExpectedRouteError) is domain:cli.
    • Why p3: the wire answer is right. Only the server log is wrong: a declared client refusal is logged at error level with a stack. The fleet's queue currently runs p1 and p2.
    • For the claimant: prefer reading the producer's declaration (the code is declared in the ADR-0112 ledger) over adding two more literals to the allow list, so the next declared refusal is not the next card.
  2. objectstack-fleet commented on Oct 16, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop, serial dispatch, p3 only (the maintainer to this seat, latest: 「当前任务处理完,后续只派p3卡」; #23744 landed as bb14934cda and this seat has nothing in flight). Label order among priority:p3 pm:queue bugs in domain: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 (400 ERR_FILE_CONSTRAINT on a record write, 422 INVALID_METADATA on a metadata save) stop being logged as [REST] Unhandled error with a stack, while an undeclared fault stays loud · 2026-10-16T19:31Z
    Session: session_01PFB9ZGRWYvjhTXsd8i6KBp
    Account: marchtian (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-23932-declared-client-refusal-log
    Worktree: objectstack-issue-23932
    Domain: domain:cli
    Seat: domain:cli#1 (seat post #6024)
    File surface (read on origin/main bb14934cda; line numbers move; stop on a breach and explain it in the report):


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 16, 2026

    @objectstack-fleet
    ContributorAuthor

    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

  4. objectstack-fleet commented on Oct 16, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24038 comment 6224570552 · domain:cli seat 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

  5. objectstack-fleet commented on Oct 16, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #24038 merged through the queue as 5c2ac83a23 · domain:cli seat 1 · marchtian · session session_01PFB9ZGRWYvjhTXsd8i6KBp · 2026-10-16T21:27Z.

    Reading Value
    PR #24038 merged: true, merged_at 2026-10-16T21:26:29Z; timeline added_to_merge_queue 2026-10-16T21:00:51Z
    origin/main 5c2ac83a23 is an ancestor (fetched in this act); single parent (squash)
    Queue branch gh-readonly-queue/main/pr-24038-*: 0 refs left
    Landed content function isDeclaredClientRefusal: 1 hit, and its one use !isDeclaredClientRefusal(error, resolved): 1 hit, in packages/rest/src/error-response.ts on origin/main

    State: #23932 closed completed by Fixes #23932, the PR body's only closing keyword; pm:dispatched stripped in this act (domain:cli, priority:p3, area:api, bug stay). This seat's hold on error-response.ts is 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

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

Metadata

Metadata

Assignees

Labels

area:apiThe API a customer can call, and integrations — REST, connectors, webhooks, jobsbugSomething isn't workingdomain:clipriority:p3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions