Repository navigation
rest: the record-share routes classify refusals by message.startsWith(CODE) and interpolate error.message into a hand-built 500 — a declared status/code is ignored and the sandbox wrapper reaches the client #11683
Description
Activity
Triage: lands in
packages/rest(record-share routes' refusal classification) →domain:cli, type Bug,pm:queue. Rationale: classifying bymessage.startsWith(CODE)while ignoring a declaredstatus/code, and interpolatingerror.messageinto a hand-built 500, is the same declared≠enforced error-mapping defect class the route family already settled elsewhere — plus it leaks the sandbox wrapper to the client.⚠️ Family / serial constraint: sibling of #11684 (analytics fallback arm, queued this round) and adjacent to in-flight #11588 (sandbox-wrapper leak abovemapDataError,domain:cli). Fold-or-serial must-answer at claim time: these three describe one seam from three routes; the likely economical shape is one dispatch after #11588 lands, folded per the five gates with per-card acceptance criteria (each route's refusal mapsstatus+codecorrectly, no wrapper text reaches the client).
Generated by Claude Code
Claim —
domain:clilane execution seat, session019siH5jDmk5hrayvfyojUqR, round R34.Branch:
claude/issue-11683-route-refusal-classification— shared with #11684 (see below).
Declared file surface:packages/rest/src/rest-server.ts(respondSharingError, the three share-route catches, and the/analytics/dataset/querycatch),packages/rest/src/error-response.tsifhandleRouteErrorneeds a seam, + tests + a changeset.Serial constraint discharged. Triage's constraint was "at minimum hard-serialize behind #11588 and re-measure on its merged ref". #11588 landed as #11687 (merged); the serial is released by the merge. The dev re-measures both routes on the current
origin/main, because #11687 may have moved part of this gap.Fold-or-serial, answered: FOLD with #11684. Both cards edit
packages/rest/src/rest-server.ts, so they are a hard same-file serial in any case; folding is the cheaper of the two legal shapes here, and it is the shape triage proposed on both cards. The load-bearing reason is not file adjacency but that the two cards share one question: "what status does an undeclared hook refusal deserve on a route that is not/data?" Routing the share catches throughhandleRouteError(this card's suggested repair) answers it for the share family; #11684 asks it directly for the analytics family. Answering it twice, in two PRs, is how the two doors came to disagree in the first place.Per-card acceptance criteria stay separate and are both required:
- rest: the record-share routes classify refusals by
message.startsWith(CODE)and interpolateerror.messageinto a hand-built 500 — a declaredstatus/codeis ignored and the sandbox wrapper reaches the client #11683 — each of the three share routes maps a declaredstatus/codeenvelope (e.g.{code:'RECORD_LOCKED', status:409}) to that status and code; no sandbox wrapper text (hook '' threw: Error:) reaches the client on any of the three. - rest: an UNDECLARED hook refusal answers 500 on
/analytics/dataset/querywhere the same refusal answers 400 on/data— the route's fallback arm treats a business refusal as a server fault #11684 — an undeclared hookthrowonPOST /api/v1/analytics/dataset/queryanswers the same status as the same throw onPOST /api/v1/data/:object. §8b ofrest-hook-refusal-message-parity.test.tscurrently pins the500as measured-and-wrong; that pin is inverted, not deleted, and its comment updated to say which door won.
⛔ Fork clause — binding on the fold. #11684's body names two candidate readings (ADR-0112's "the producer names the condition" ⇒
500is honest; the/datadoor's "a hookthrowis a business rule" ⇒400is honest). Establish which one the repo has already committed to, from its own pins and ADR text —classifyDataError's sandbox unwrap door andhook-error-format.dogfood.test.tsare the strongest in-repo evidence, and ADR-0112's own words govern. If both readings turn out to have live in-repo pins behind them, stop and report the fork back to this seat; ⛔ do not pick a direction, and ⛔ do not ship the half you are confident about while the fork is open — file the fork and land nothing on the analytics arm. The share half may still land alone in that case, with #11684 returned topm:queue.Clause ②: yes. Both halves change the status and/or code a shipped route answers with. Contract-review tier applies per the standing rule; the changeset must name, per route, the old answer and the new one.
Generated by Claude Code
- rest: the record-share routes classify refusals by
- added a commit that references this issue
on Aug 24, 2026 { "issues": [11683, 11684], "issue": 11683, "status": "done", "branch": "claude/issue-11683-route-refusal-classification", "pr": "https://github.com/objectstack-ai/objectstack/pull/11731", "pr_url": "https://github.com/objectstack-ai/objectstack/pull/11731", "premise_still_valid": true, "fork_status": "settled — the `/data` door's reading won; the fork clause did NOT fire and both halves land. The two readings do not compete, they govern different questions. (a) ADR-0112's 'the producer names the condition' is a rule about the CODE: D1-D9 and all five amendments rule on the code vocabulary, its closure and the `declaredCode` demote channel, and the phrase itself is not in the ADR at all — it is `error-response.ts` prose governing a DECLARED 5xx that carries no code ('a half-declaration is honoured for the half that was declared'). Nothing in ADR-0112 speaks to the status of an undeclared throw, and this PR contradicts it nowhere: the new arm invents no code either. (b) The `/data` door's reading rules the STATUS and is structural, not a preference: `classifyDataError`'s sandbox unwrap door answers `declaredHttpStatus(error) ?? 400` with the verbatim `.innerMessage` for a body that REPORTED, and the sanitised 500 for one that CRASHED (`isScriptFaultMessage`, #7543) — pinned end to end by `hook-error-format.dogfood.test.ts` ('DELETE blocked by a sandboxed hook returns ONLY the business message', 400) and in process by parity §3. So 'an undeclared throw is unclassified' was never the repo's rule for this class: a sandboxed body that reports has classified itself structurally, and only a CRASH is unclassified (still 500 on both faces). NO live in-repo pin was found on the ADR-0112 side of the question — §8b was the only one, and its own comment declared it a record of a defect left standing, not a ruling.", "share_half_status": "done — all three record-share routes. A declared `{code,status}` envelope (both `status` and `statusCode` spellings) is answered with that status and code instead of `500 SHARE_*_FAILED`; no sandbox wrapper text reaches the client on any of the three, including on the CRASH arm the classification door cannot reach (`sharingFaultMessage` withholds there, matching `/data`'s `UNCLASSIFIED_FAULT`). The five-prefix `CODE: message` idiom and the three `SHARE_*_FAILED` 500 codes are kept and re-pinned; the nested ADR-0112 D5 envelope (#8111) does not move, so `check:route-envelope`'s ratchet is untouched.", "analytics_half_status": "done — `POST /api/v1/analytics/dataset/query`. A new arm ①b between ① and ③ asks the same seam, so an UNDECLARED sandboxed hook refusal answers 400 with the hook's own sentence — the same status `POST /api/v1/data/:object` answers for the identical throw — and the `statusCode` spelling of a declared 4xx+code resolves too (#7525). Arm ① is untouched, so #5352's both-halves rule stands; arm ③ is untouched for a declared 5xx, a crashed body, a driver fault and anything unclassified. §8b is INVERTED, not deleted: its original text is quoted in the new comment above the evidence for which reading won.", "string_convention_producers_found": "YES — and problem 1 is nevertheless LIVE, not latent. Censused on `4ceae8ab0`: `packages/plugins/plugin-sharing/src/sharing-service.ts` has 11 throw sites, every one a bare `new Error('CODE: …')`, and the file contains no `code =` / `status =` / `statusCode =` assignment anywhere — so the prefix idiom is the only channel that service has and backward compatibility is required, kept, and re-pinned (5 cases). But TWO declared-envelope producers reach these same three catches today: `plugin-sharing`'s own write gate throws `{ code: 'FORBIDDEN', status: 403 }` (`sharing-plugin.ts`) and `FORBIDDEN` is not one of the five prefixes, so a refusal that declared 403 twice over was answered 500 — pinned as the live in-repo witness; and a sandboxed hook on the `sys_record_share` write, which is problem 2's producer. `sharing-service.ts` was READ ONLY (declared trigger file of on-hold #6736); nothing in this PR touches it.", "clause_2": "yes — declared in the PR body. The changeset names, per route, the old answer and the new one (status + code) in two tables, so a consumer reading release notes can tell whether their error handling breaks.", "files_touched": [ "packages/rest/src/error-response.ts (new export `classifiedRefusalAnswer`; no existing behaviour changed)", "packages/rest/src/rest-server.ts (analytics arm ①b; `respondSharingError` asks the door first; new `sharingFaultMessage` on the three 500 arms; one stale #11588 comment amended)", "packages/rest/src/rest-hook-refusal-message-parity.test.ts (§8 header amended, §8b INVERTED, §8e and §8f added)", "packages/rest/src/rest-share-refusal-classification.test.ts (new)", ".changeset/olive-donkeys-repeat.md (new)" ], "pins_added": [ { "pin": "share §1 — a declared `status`/`code` envelope is honoured on all three routes (3 cases: `RECORD_LOCKED`/409, the `statusCode` spelling, and plugin-sharing's own `FORBIDDEN`/403 write-gate throw)", "red_before": "3 red. `AssertionError: GET shares: {\"success\":false,\"error\":{\"code\":\"SHARES_LIST_FAILED\",\"message\":\"This account is locked while month-end close runs.\"}}: expected 500 to be 409` · `… \"message\":\"FORBIDDEN: insufficient privileges to delete account a1\"}}: expected 500 to be 403`", "green_after": "green in `Test Files 5 passed (5) · Tests 96 passed (96)`" }, { "pin": "share §2 — no sandbox wrapper reaches the client (business refusal, refusal+envelope, and a ⭐ control that a CRASHED body is NOT served as a refusal and has its text withheld)", "red_before": "3 red. `AssertionError: GET shares leaked the wrapper: {\"success\":false,\"error\":{\"code\":\"SHARES_LIST_FAILED\",\"message\":\"hook 'guard' threw: Error: Sharing is frozen until the quarterly access review closes.\"}}: expected … not to match /threw:|hook '/` · `AssertionError: GET shares: expected 'hook \\'guard\\' threw: Error: TypeErro…' to be 'Internal server error'`", "green_after": "green in the same run" }, { "pin": "share §3 — REGRESSION GUARD, green both sides by design: the five `CODE:` prefixes still map with the prefix stripped, an unclassified fault still leaves through this route's own 500 verbatim, and every arm still parses as `ApiErrorSchema`", "red_before": "green pre-fix (7 cases) — deliberately a control, not a defect pin. Its value is that it stayed green: the prefix idiom and the 500 terminal are what backward compatibility rests on.", "green_after": "green" }, { "pin": "share §4 — door-to-door: every classified refusal gets the same status at the share door and the `/data` door (5 error shapes × 3 routes)", "red_before": "1 red. `AssertionError: declared 409 + code @ GET shares: share door 500 vs /data door 409: expected 500 to be 409`", "green_after": "green" }, { "pin": "parity §8b — INVERTED (was: 'an UNDECLARED refusal reaches the 500 arm unwrapped — the status is NOT moved'). Now: answers 400 with the business sentence and no invented code", "red_before": "1 red. `AssertionError: expected 500 to be 400`", "green_after": "green" }, { "pin": "parity §8e — the analytics face and `POST /data/:object` answer one hook `throw` with ONE status; both REAL handlers driven in process, 4 shapes, and neither status is named so a future move on either side reddens", "red_before": "1 red, and the message reproduces #11684's own measurement table: `AssertionError: undeclared refusal: analytics 500 {\"code\":\"ANALYTICS_QUERY_FAILED\",\"error\":\"month-end close is in progress\"} vs /data 400 {\"error\":\"month-end close is in progress\",\"object\":\"crm_account\"}: expected 500 to be 400`", "green_after": "green" }, { "pin": "parity §8f — MEASURED AND NOT REPAIRED: the two faces still disagree in the 5xx band (declared 503 → `503 SERVICE_UNAVAILABLE` on `/data`, `500 ANALYTICS_QUERY_FAILED` here)", "red_before": "found by §8e's first draft, which included this case and reddened WITH the fix in place: `AssertionError: declared 5xx: analytics 500 … vs /data 503 {\"error\":\"Internal server error\",\"code\":\"SERVICE_UNAVAILABLE\"}: expected 500 to be 503`", "green_after": "green as an assertion OF the divergence — it pins the bound on §8e's parity claim rather than a repair. Filed as #11718." } ], "checks_run": "All on `b468109e1`, the final commit. (1) `pnpm --filter @objectstack/rest test` → `Test Files 144 passed (144) · Tests 2317 passed (2317)`. (2) `pnpm --filter @objectstack/rest typecheck` → `tsc --noEmit`, exit 0. (3) `pnpm lint` (WHOLE repo, `eslint . --no-inline-config`, not narrowed) → `VERDICT command-exit 0`. (4) The 18-gate family re-derived from the ACTUAL change set via `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (5 paths, merge base `4ceae8ab0`) → `AGGREGATE: FAIL=0`; it includes `check:route-envelope` (this file's dialect ratchet) and `check:dispatcher-error-vocabulary`, the latter reporting `OK — 21 unregistered code-stamping site(s), all classified`. (5) Ratchet family + the three pin files re-run AFTER the final commit on `b468109e1` → `Test Files 3 passed (3) · Tests 55 passed (55)`, gates OK. Exit codes captured before any pipe (`cmd > file 2>&1; EXIT=$?`), and every verdict quoted above is the gate's or vitest's own printed line, never a bare `$?`. Reverse verification ran from the COMMITTED fix: `git restore --source=origin/main` on the two source files (presence of `classifiedRefusalAnswer` grepped to 0 on disk both files), then `git restore --source=HEAD --staged --worktree` (grepped back to 5 and 1), `git diff HEAD` empty and `git status --porcelain` clean. 9 red predicted, 9 red measured — exact.", "summary": "Both halves landed on one branch and one draft PR. A single new seam in `error-response.ts` — `classifiedRefusalAnswer` — returns the `/data` door's classification WITHOUT choosing a dialect, for a refusal the producer classified: a declared 4xx `status`/`statusCode` plus a `code` (both halves, so #5352's ruling stands), or a sandboxed body's business `throw`. It hands a 5xx straight back, so each route's own fault terminal is untouched. The three record-share catches ask it before the `CODE:` prefix map and re-dress the answer in the nested ADR-0112 D5 envelope #8111 converted them onto, so the dialect does not move and `check:route-envelope`'s ratchet is untouched; their three 500 arms now withhold a sandbox error's text, which closes the wrapper leak on the CRASH arm the classification door cannot reach. The analytics catch gains arm ①b between ① and ③. The fork clause did not fire — see `fork_status`. Two things measured and deliberately not repaired are pinned (§8f) and filed rather than fixed.", "tests": "See `checks_run` for the command list and the verdict lines. Every pin was shown failing without the behaviour and passing with it, from the committed fix; the predicted/measured table is in the PR body. No ablation of a built artifact was involved — all pins drive `RestServer`'s real registered handlers in process from `src`, so there is no `dist/` leg to rebuild, and `check:test-source-alias` passes.", "open_questions": [], "out_of_scope_findings": [ "filed as #11718: a declared 5xx answers `500 ANALYTICS_QUERY_FAILED` on `/analytics/dataset/query` where `/data` answers the declared status and code (a 503 lifecycle outcome collapsed onto 500) — found by §8e's first draft reddening WITH the fix in place, pinned as §8f", "filed as #11719: `sendError`'s `extra` cannot carry `declaredCode`, so every nested-envelope route silently drops an unregistered producer's own code spelling, against ADR-0112's #9106/#9232 'every door' ruling — the repair is in `packages/types`, outside this card's declared surface" ], "notes_for_pm": [ "Coordinator addendum discharged: `packages/plugins/plugin-sharing/src/sharing-service.ts` (trigger file of on-hold #6736) was READ ONLY, for the producer census. The fix needed no edit there and the PR does not touch it, so #6736's opportunistic-restart clause did not fire.", "Declared file surface held exactly — nothing outside it was edited. Two repairs that WOULD have needed to widen it were filed instead (#11718, #11719).", "Findings were filed with the `finding` label per this dispatch's explicit instruction. Note the standing os-dev clause routes a CONCRETE defect unlabelled to triage and reserves `finding` for observational items; both of these are concrete defects, so the dispatch's instruction and the standing clause disagree here. Flagging rather than choosing silently — relabel if triage prefers the standing rule.", "⛔ Not armed, not marked ready, auto-merge not enabled." ] }
Generated by Claude Code
Contract review — PASS (folded pair #11683 + #11684) (fable seat, session
5213b871-5164-5bc3-8874-28b336bbcd40; fuse readingget_session→external_metadata.last_served_model=claude-fable-5, matchingCONTRACT_REVIEW_TIERread from origin/main atdispatch-gates.mjs:3070; authorization: maintainer 2026-08-23 「要不还是你挂个定时处理审核吧」, this sub-round fired by the maintainer's direct 「只等契约闸门」). Independence: dispatched by the cli seat — independent review. Fold verified on-card (claim 5395514237: hard same-file serial, one shared question, per-card acceptance kept separate); serial behind #11588 verified discharged (#11687 merged, both gaps re-measured on4ceae8ab0post-merge).Reviewed PR #11731 @
b468109e1against the actual diff.- The binding fork clause was executed exactly as written, and the settlement is measured, not argued: ADR-0112's "producer names the condition" was traced to its actual scope (the CODE vocabulary — the quoted sentence lives in
error-response.tsgoverning a declared 5xx with no code, which this PR honors: nothing invents a code); the/datadoor's classification of a reporting sandbox body as a 400-refusal is the reading with live end-to-end pins behind it (hook-error-format.dogfood.test.ts, parity §3), and no live counter-pin was found. §8b — the pin that recorded the old 500 as deliberate — is inverted with its original text quoted, which is precisely how a settled fork should leave a paper trail. - The seam is the right shape:
classifiedRefusalAnsweris a UNION of two already-ruled limbs (both-halves declared 4xx envelope per analytics 的 filter 拒收到不了调用方:service 侧多数拒收没有 ADR-0112 信封,REST 面又用 message 正则嗅探,一律答 500 #5352, bothstatus/statusCodespellings per [rest] Hook refusals carrying an explicitstatusCodeare not mapped by/api/v1/data— they leak as HTTP 500INTERNAL_ERRORwith no located guidance #7525; reporting sandbox body per A rawTypeError: not a functionleaks in the 400 envelope for{"title": 12345}— nocode, nofields[]#7543), refuses the 5xx band on both sides of resolution (a declared 5xx never reaches the heuristics; a resolvedUNCLASSIFIED_FAULT/DATA_STORE_FAULTis handed back), and deliberately does not decide DIALECT — the share family keeps its nested D5 envelope (route-envelope ratchet green), analytics keeps its flat face, per the finding:packages/rest's flatsendThrownErrorstill puts a thrown error'scodeon the wire un-narrowed — ADR-0112's closure does not reach that door #9232 amendment's vocabulary-vs-position split. The crash-vs-report boundary keepsInternal server errorsanitization for crashed bodies — the wrapper-leak half of rest: the record-share routes classify refusals bymessage.startsWith(CODE)and interpolateerror.messageinto a hand-built 500 — a declaredstatus/codeis ignored and the sandbox wrapper reaches the client #11683 closes without opening a new message channel. - rest: the record-share routes classify refusals by
message.startsWith(CODE)and interpolateerror.messageinto a hand-built 500 — a declaredstatus/codeis ignored and the sandbox wrapper reaches the client #11683's "not established" item was established before landing: 11/11 sharing-service throw sites re-censused as bareCODE:-prefix errors (so the prefix idiom is kept as that producer's only channel, re-pinned in §3), AND the live declared-envelope witness found (sharing-plugin.ts's{code:'FORBIDDEN', status:403}— a refusal that declared 403 twice over answering 500).sharing-service.tsitself read-only, respecting hold Authored RLS update-wideners are silently ineffective on the bulk write path — buildWriteFilter ANDs them away (fewer rows, no error) #6736's trigger file. - Anti-vacuity exact: 9 predicted red / 9 measured red from the committed fix with on-disk grep confirmation both ways; §8e pins the two doors EQUAL without naming a status, so a future move on either side reddens independently of §8b's literal; the crash control and arms ①/③ stay green as predicted. Real handlers driven in process on both faces.
- The two residuals are the honest kind: the 5xx-band third disagreement (declared 503 vs
ANALYTICS_QUERY_FAILED) measured, pinned as measured-and-not-repaired in §8f, filed rest: a declared 5xx answers500 ANALYTICS_QUERY_FAILEDon/analytics/dataset/querywhere/dataanswers the declared status and code #11718 — moving it would reopen analytics 的 filter 拒收到不了调用方:service 侧多数拒收没有 ADR-0112 信封,REST 面又用 message 正则嗅探,一律答 500 #5352/analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367//analytics/query仍把 RLS 策略字段名回显给调用方 —— read-scope 拒收的泄漏在姐妹面上没堵,#5367 只堵了 dataset 路由 #5811; the nested-envelopedeclaredCodedrop filed types:sendError'sextracannot carrydeclaredCode, so every nested-envelope route silently drops an unregistered producer's own code spelling #11719 (packages/types, outside surface). Changeset carries the per-route old→new tables; rest suite 144/2317 green; 18-gate family FAIL=0 with the dialect ratchet and dispatcher-vocabulary gates in it.
Verdict: PASS, both acceptance criteria met. Clearing
needs:contract-reviewon all carriers (#11683 + #11684 + PR #11731). Enqueue/flip belongs to the cli seat's landing window.
Generated by Claude Code
- The binding fork clause was executed exactly as written, and the settlement is measured, not argued: ADR-0112's "producer names the condition" was traced to its actual scope (the CODE vocabulary — the quoted sentence lives in
- added a commit that references this issue
on Sep 1, 2026
Filed unassigned while implementing #11588 (the sandbox debug wrapper on the bulk write routes). Out of scope there: #11588's table names six routes and this is a third branch again, with a different defect on top of the shared one.
Measured
packages/rest/src/rest-server.ts, the record-share family —GET/POST /api/v1/data/:object/:id/sharesandDELETE …/shares/:shareId. All three catches are:and
respondSharingErroris:Two independent problems, both on the same read of
error.message:A declared
status/codeis ignored entirely. These routes touch neitherclassifyDataError's unwrap door norresolveErrorResponse's declared-status passthrough. A producer that throwsObject.assign(new Error(msg), { code: 'RECORD_LOCKED', status: 409 })— the ADR-0112 envelope every other/dataface honours — is answered500 SHARE_GRANT_FAILED, because'RECORD_LOCKED'is not one of the five prefixes. This is the same "classification is a property of the message's wording" shape analytics 的 filter 拒收到不了调用方:service 侧多数拒收没有 ADR-0112 信封,REST 面又用 message 正则嗅探,一律答 500 #5352/analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 paid off on/analytics/dataset/query, still standing here.The sandbox debug wrapper reaches the client, which is rest: the sandbox debug wrapper
hook '<name>' threw: Error: …reaches the client on every write route that exits abovemapDataError's unwrap (batch, createMany, updateMany, deleteMany, clone, analytics) #11588's defect on a branch rest: the sandbox debug wrapperhook '<name>' threw: Error: …reaches the client on every write route that exits abovemapDataError's unwrap (batch, createMany, updateMany, deleteMany, clone, analytics) #11588 did not fix. A sandboxed hook refusal arrives with.message=hook '<name>' threw: Error: <business text>and.innerMessage= the business text.msg.startsWith(CODE)never matches (the wrapper is the prefix), so it falls to the500arm and the wrapper is interpolated verbatim.Why it was not fixed in #11588
#11588's dispatch fenced the card to the six routes its table measured, and this family is not among them. It is also a larger repair than the message read: the right answer is almost certainly to route these three catches through
handleRouteErrorlike every other/dataroute, which changes their status/code answers, not just their prose — a behaviour change that deserves its own card and its own pins.Not established here
code/statusenvelope, or whether all of them still use theCODE: messagestring convention these prefixes were written for. If the latter, problem 1 is latent rather than live — but the convention is the thing analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 removed elsewhere.Region:
packages/rest/src/rest-server.ts,respondSharingErrorand the three share-route catches only.