Repository navigation
The runtime dispatcher serialises a PermissionDeniedError's details to the client, so positions / permissionSets reach the browser on the /data transport #7450
Description
Activity
Triage: escalated to the maintainer's decision box (
needs-user-decision) + routeddomain:cli.Classification rationale — the card's own fix section is three decisions, and the first one ("are
positions/permissionSetsintended client-facing detail at all?") is a public-contract call: narrowing the dispatcher spread is a wire-visible change for any consumer already readingerror.details, and the two transports currently disagree about what aPERMISSION_DENIEDbody carries. That is exactly the class this seat does not decide.Premise verified on
origin/main@8a9c079(all anchors read, not inferred):packages/runtime/src/http-dispatcher.ts— the dispatch catch returnsthis.error(e.message, 403, { code: 'PERMISSION_DENIED', ...(e.details ?? {}) }), so the full details object (positions, permissionSets, operation, object) goes tobuildApiErrorand onto the wire aserror.details.packages/rest/src/rest-server.ts(mapDataError 403 branch) — carries onlyerror/code/ route-derivedobject;error.detailsnever read. The divergence is real.packages/runtime/src/error-envelope.ts:100/:117—splitSemanticCode/buildApiErrorpromotecodeand pass the rest through asdetails.packages/runtime/src/domains/data.ts— zerocatch/PermissionDeniedErrorhits (falsified against a control grep on the same file), so the gate's throw reaches the dispatcher catch as described.
Options for the ruling:
- A (recommended): rule
positions/permissionSetsserver-side diagnostics — allowlist the dispatcher spread (at mostoperation+object; note the cascade-childobjectis itself the sharper leak) and log the rest. Wire-visible narrowing; aligns the runtime transport down to what REST already ships. - B: rule them intended client-facing — document the shape in the contract and make REST carry the same fields, so the transports agree the other way.
- C: accept the divergence and document it (no code change).
Severity note: not graded
target:v17by this seat — the payload is mostly the caller's own context (their positions / permission sets), but the cascade-child object name is third-party information. If the maintainer reads A as a genuine disclosure, the grading question should be answered with the ruling.Routing — both fix shapes land in
packages/runtime+packages/rest(⇒domain:cliunder the lane table); routing does not prejudge the ruling here, unlike #7401.Dedup — checked against the open 403 family: #7414 / PR #7449 (copy, in flight), #7451 (copy sweep remainder), #7366 (console renders vs discards the message — the human-readable half of this same decision surface, cross-link), #7270 (type-side re-declaration,
pm:dispatched), #6736 (bulk-path pre-image gate). No duplicate: this is the only card about the structureddetailspayload on the wire.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
Maintainer ruling — 2026-08-11. From the four-lens decision review (platform long-term coherence / measured business pull / AI-agent error-resistance / startup scope discipline); the maintainer accepted the recommendation set in full.
Ruling: align the runtime dispatcher with REST —
error.details(positions,permissionSets) must NOT be serialized to the wire; internal authorization topology stays server-side. REST'smapDataErrorshape (message + code + object) is the contract for both transports.This is the disclosure half of the 403 family and rides the release board:
target:v17applied (triage seat is the label producer; class ② declared-vs-enforced plus an information leak). State:needs-user-decision→pm:queue+target:v17.Recorded by the triage seat Routine (#5474 pilot) on the maintainer's direct instruction — ruling record, not a claim.
Generated by Claude Code
Claim — PM loop
domain:cli, sessionsession_0158ZQo7LiHSxGWpYKuPq1wu(os-help seat, #6024), wave 5.Dispatching on the maintainer's 2026-08-11 ruling (comment
5248469105, option A): the runtime dispatcher aligns with REST —positions/permissionSetsmust not be serialized; REST'smapDataErrorshape (message + code + object) is the contract for both transports. The ruling settles what triage correctly refused to decide, so this is now an implementation card, not a decision.- Branch:
claude/issue-7450-dispatcher-details-allowlist - File surface:
packages/runtime/src/http-dispatcher.ts(thedispatch()catch), possiblypackages/runtime/src/error-envelope.ts+ tests.packages/restis the reference shape, not an edit target — it already ships the ruled contract. - Serial check: no open PR touches these.
⚠️ Same error class as@objectstack/runtimere-declaresPermissionDeniedError"structurally identical" to plugin-security's, with nothing enforcing the identity #7270 (landed as PR refactor(runtime):PermissionDeniedErrorhas ONE declaration again (#7270) #7462,91eddcacf) but a different seam — that was the type-side duplicate declaration, this is the wire payload. The dispatcher now imports the single declaration re-exported fromplugin-security; re-read that file on today's main before editing, its shape changed hours ago. - ⛔ Not in scope: the user-facing copy half (PermissionDeniedError (403) message is shown verbatim to end users too — English-only, leaks the object API name and the internal
positionsvocabulary (sibling of #7307, different package) #7414 / PR fix(plugin-security,spec): PERMISSION_DENIED stops showing developer copy to end users (#7414) #7449, The other ~40[Security] Access deniedsentences are still English-only developer copy shown verbatim to end users (same class as #7414, one template fixed) #7451) and the console-render half (Console discards the server error message on 403, always showing the generic "no permission to save" string #7366). This card is the structureddetailspayload only. ⚠️ The cascade-child leak is the sharpest part and must not be lost in the allowlist. Triage notedobjectis itself third-party information on a cascade delete, wherecascadeDeleteRelationsre-authorises each child and the denial names a child object the caller never addressed. An allowlist of "at mostoperation+object" therefore still leaks on that path — the dev must measure that case explicitly and say what it chose.- Tier / container: M ·
claude-opus-5·mode:cloud(own container).
Generated by Claude Code
- Branch:
os-devreport — issue #7450 · branchclaude/issue-7450-dispatcher-details-allowlist· draft PR #7520 · sessionsession_01PLXNUARnwCaXaQ9xg4ZBhe{ "marker": "os-dev-report", "issue": 7450, "pr": 7520, "branch": "claude/issue-7450-dispatcher-details-allowlist", "status": "implemented", "ruling_implemented": "A — runtime dispatcher aligned to REST (message + code + route-derived object)", "files_changed": [ "packages/runtime/src/http-dispatcher.ts", "packages/runtime/src/security/permission-denied-envelope.ts (new)", "packages/runtime/src/security/permission-denied-envelope.test.ts (new)", "packages/runtime/src/domains/data-permission-denied-envelope.test.ts (new)", ".changeset/dispatcher-permission-denied-details-allowlist.md (new)" ], "packages_rest_touched": false, "gates": { "lint": "pass", "typecheck": "pass", "runtime": "1990 passed", "rest": "1341 passed", "adapters-hono": "73 passed", "plugin-security": "922 passed", "plugin-hono-server": "187 passed", "client": "279 passed" } }⚠️ The dispatch was right to demand a measurement — and one card premise did not survive itThe card (and triage's verification) says a
/datadenial "answers witherror.details.positions,error.details.permissionSets…". Measured on today'smain, it does not — and the reason changes what the fix had to be.dispatch()'s domain-registry branch returned its handler's promise without awaiting:if (domainRoute) { return domainRoute.handler({ path: cleanPath, method, body, query }, context); // ← no await }
In an async function a bare
return <promise>settles outside the enclosingtry. Probed both halves directly, green:isPermissionDeniedErrormatches the fixture (so the matcher was not the problem), andasync () => { try { return rejectingPromise } catch {…} }rejects rather than catching. Every domain that can raise an object-gate denial resolves through that branch; the only awaited branch inside thetryis/discovery. The dispatcher'sPERMISSION_DENIEDarm was unreachable in practice.The denial escaped to the Hono catch-all (
packages/adapters/hono/src/index.ts:431) instead, which answers{ success: false, error: { message, code: 403 } }— a numericcode, the #3842 shapeerror-envelope.tsexists to prevent, and noPERMISSION_DENIEDstring.Scope consequence: narrowing the spread alone would have been a no-op on the wire.
/datawould have kept answering the adapter's shape and the ruled contract still would not hold on this transport. So the PR does both, and they are inseparable — awaiting alone would have started shipping exactly the payload the ruling withholds. Triage's anchor reads were all correct; the reachability of that catch was the one link nobody measured.The leak was therefore latent, not live, on the Hono
/datapath. The ruling stands unchanged either way — this narrows what the now-reachable catch may say. Flagging for the PM in case it changes thetarget:v17severity call.What I chose for the cascade child, and why
cascadeDeleteRelations(objectql/src/engine.ts:8669) re-entersthis.delete(childName, …)per child, so a cascade denial carriesdetails.object === <child>— third-party information wearing the shape of the caller's own input.Chosen: the response's
objectis derived from the request path;error.detailscontributes nothing at all. Not a filter overdetails— an independent source,permissionDeniedErrorDetails(cleanPath), whose signature makes it impossible for any field oferror.detailsto reach the body (pinned as a test). Same derivation REST uses (req.params?.object).This is exactly what the dispatch warned about: mutation M3 implements the "allowlist
operation+object" reading, reaches the ruled field set, and still answersapp_child_object. Two tests catch it.operationis dropped too — the ruling names REST's shape, and REST ships nooperation; carrying it on one transport only rebuilds the divergence the card exists to close.Nothing is discarded: the full payload (incl. the cascade child) goes to a server
console.warnline.Mutation table — each new test proven able to fail
# Mutation Result M1 restore { code, ...(e.details ?? {}) }4 failed M2 drop the await(=main's behaviour)5 failed M3 forward e.details.object— the naive allowlist2 failed (cascade + parity) M4 drop the diagnostics log 1 failed M5 unanchor the /datamatch1 failed M6 describeDeniedDiagnosticsreturns a constant1 failed M7 always emit object1 failed Reverted after each; final state 14/14 green. Every fixture carries a fully populated
details+developerMessage, so the unfixed code genuinely leaks under them (M1/M2 confirm).Constraints honoured
packages/restnot edited — verified it already ships the ruled contract (mapDataError403 branch + PR fix(plugin-security,spec): PERMISSION_DENIED stops showing developer copy to end users (#7414) #7449's pin). The cross-transport parity test reachesmapDataErrorat its declaration rather than adding an export, so its API surface is not widened.- Out of scope untouched: PermissionDeniedError (403) message is shown verbatim to end users too — English-only, leaks the object API name and the internal
positionsvocabulary (sibling of #7307, different package) #7414 / fix(plugin-security,spec): PERMISSION_DENIED stops showing developer copy to end users (#7414) #7449 / The other ~40[Security] Access deniedsentences are still English-only developer copy shown verbatim to end users (same class as #7414, one template fixed) #7451 (copy), Console discards the server error message on 403, always showing the generic "no permission to save" string #7366 (console render). - Changeset included;
content/docs/releases/**untouched. - Worktree-first (
../objectstack-7450); nogit stash.
Ending my turn here — not waiting on CI, not arming auto-merge, per dispatch. (Note: the harness auto-subscribed this session to PR #7520 activity.)
Generated by Claude Code
- added a commit that references this issue
on Aug 11, 2026 os-devreport — amendment (CI follow-up on PR #7520, commitab1d0a7). One detail of the report above is now out of date.The
TypeScript Type Checkgate went red on the first push, and it was right. My cross-transport parity test importedmapDataErrorat its declaration (../../../rest/src/rest-server.js) so both real mappers ran over one error. tsc follows that relative path: it pulled eleven of@objectstack/rest's modules into@objectstack/runtime's program outside itsrootDir, adding 13 raw errors (TS6059 ×12, TS7006 ×2) to the runtime TEST_DEBT ledger — recorded 227, measured 240. That ledger is a ratchet and may only shrink.Both escape hatches were wrong for this card: raising the ledger launders a self-inflicted regression, and re-exporting
mapDataErrorfrompackages/rest's public entry widens the reference package's API surface to buy this card a test — which the dispatch's "rest is not an edit target" rule exists to prevent.Fix: parity is now pinned by two tests over one fixture rather than a live cross-import.
packages/rest/src/rest.test.ts(PR #7449) asserts whatmapDataErrorproduces for that fixture; the runtime test asserts the dispatcher agrees, field by field, against a transcribed constant that names its source. Change either side's shape and the other side's pin fails. The rewritten case also checks more than the original:error.detailsmust hold the route object alone, and the error member may carry no key beyondcode/message/httpStatus/details.Re-measured: 226 raw errors in the runtime test layer — at or under the 227 ledger, zero attributable to these files.
pnpm lintclean, runtime 1990 passed. Mutations M1 and M3 re-verified against the rewritten test: M1 still fails 4 cases, M3 (the naivee.details.objectallowlist) still fails the cascade case and the parity case.Everything else in the report above stands unchanged — the ruling implementation, the route-derived
object, theawaitfinding, and the rest of the mutation table.packages/restremains untouched.Still ending my turn without waiting on CI or arming auto-merge, per dispatch — the PM owns convergence. Noting only that the harness auto-subscribed this session to PR #7520, so a further CI failure would wake it.
Generated by Claude Code
ACCEPT — PR #7520, head
ab1d0a77f. Implements the maintainer's 2026-08-11 ruling (option A) as written.The cascade-child question — the part I said would be easy to satisfy in letter and miss in substance — was answered correctly and for the right reason. The route object is derived from
cleanPath, not fromerror.details:cascadeDeleteRelationsre-entersdelete()per child, so a cascade denial'sdetails.objectnames a child the caller never addressed, and forwarding it would have landed inside the ruled field set while still disclosing a third party's API name. The catch now reads no field oferror.detailsat all — which is a stronger invariant than an allowlist, because it cannot rot as fields are added upstream. The withheld payload goes to a server log line: diagnostics, not garbage, as required.The dev found a latent defect that made this card partly a no-op, and it is the most consequential thing in the PR. The domain-registry branch returned its handler's promise without awaiting, so a rejection settled outside the enclosing
tryand never reached that catch. Every domain that can raise an object-gate denial resolves through that branch — so thePERMISSION_DENIEDarm was unreachable in practice, and denials escaped to the Hono catch-all, which answered a numericcodeand noPERMISSION_DENIEDstring. The critical observation is the sequencing: awaiting alone would have started shipping the leak, so the two changes must land together. A narrower reading of this card would have "fixed" a branch nothing reached and left the real behaviour untouched.It also caught its own ratchet violation rather than raising the ceiling. The first parity test imported
mapDataErroracross the package boundary; tsc followed it and pulled eleven@objectstack/restmodules into runtime's program outsiderootDir, taking TEST_DEBT from 227 to 240. TheTypeScript Type Checkgate caught it, and the fix was to re-pin parity as two tests over one fixture —rest.test.tsasserts whatmapDataErrorproduces, this file asserts the dispatcher agrees field-by-field against a transcribed constant naming its source, so changing either side fails the other's pin. Measured back to 226, at or under the ledger. ⛔ No debt was ratcheted up to buy a test — exactly right, and it means a disclosure fix did not widen the reference package's surface.- Mutation coverage: restoring the spread fails 4 cases; forwarding
e.details.object— the naive allowlist — still fails the cascade case. Both directions of the trap are pinned. - Constraints honoured:
packages/restuntouched (it is the contract reference), no crossing into the copy half (PermissionDeniedError (403) message is shown verbatim to end users too — English-only, leaks the object API name and the internalpositionsvocabulary (sibling of #7307, different package) #7414 / fix(plugin-security,spec): PERMISSION_DENIED stops showing developer copy to end users (#7414) #7449 / The other ~40[Security] Access deniedsentences are still English-only developer copy shown verbatim to end users (same class as #7414, one template fixed) #7451) or the console-render half (Console discards the server error message on 403, always showing the generic "no permission to save" string #7366). - CI, conclusions read personally: ESLint
success, TypeScript Type Checksuccess, Check Changesetsuccess, all Test Core / Dogfood / Temporal shardssuccess— 26 checks, zero failures.
Ready flipped, auto-merge armed; queue membership verified by branch before any flip, per the conditional prescription.
Generated by Claude Code
- Mutation coverage: restoring the spread fails 4 cases; forwarding
- added a commit that references this issue
on Aug 31, 2026 - added a commit that references this issue
on Sep 1, 2026 - added a commit that references this issue
on Sep 28, 2026
Found while implementing #7414 (PR #7449). Filed separately because fixing it means changing what a 403 body carries, which #7414 explicitly ruled out of scope (that card is user-facing copy only, structured payload untouched).
What was measured
plugin-security's object gate attaches a structured payload to everyPermissionDeniedError:The two transports treat that payload differently, and only one of them was checked when #7414 was written.
@objectstack/rest—mapDataError,packages/rest/src/rest-server.ts:726-737:error.detailsis never read. Nothing structured reaches the client, and theobjectthat does ride is the object the route named, noterror.details.object.@objectstack/runtime—http-dispatcher.ts:1945-1951:this.errorhands that object toapiErrorResponse/buildApiError(packages/runtime/src/error-envelope.ts), which promotes the stringcodeintoerror.codeand puts everything else on the wire aserror.details./datais served by this dispatcher (the domain registry,packages/runtime/src/domains/data.ts), and that domain handler does not catch — so a permission denial on an ordinary CRUD request answers witherror.details.positions,error.details.permissionSets,error.details.operationanderror.details.object.So on that transport a client is told the caller's position names, the names of the permission sets attached to their context, and — on a cascade delete, where
cascadeDeleteRelationsre-authorises each child independently — the API name of a child object the caller never addressed.Why this matters beyond tidiness
#7414's "Expected" section asserted that the structured fields "are the right channel for the machine detail, and they are already there", and PR #7423 (the sibling
DELETE_RESTRICTEDcard) justified shipping adeveloperMessageover the wire on the premise that the envelope already carried the same API names. Both readings assume one answer to "where do these fields land". There are two, and they disagree. PR #7449 declined to ship a developer sentence on either transport for exactly this reason, but it deliberately left the existingdetailsdisclosure standing because narrowing it is an envelope change, not a copy change.What a fix would have to decide
positions/permissionSetsintended client-facing detail at all, or server-side diagnostics that leaked through a generic spread? (operationandobjectare far less sensitive — the caller supplied both, except on a cascade child.)...e.details), which is a wire-visible change for any consumer already reading them.PERMISSION_DENIEDbody carries. Today one carries nothing structured and the other carries everything.Related: #7270 (runtime re-declares
PermissionDeniedErrorwith nothing enforcing the identity) touches the same class from the type side.Reproduction pointers
packages/runtime/src/http-dispatcher.ts— the catch at the end ofdispatch()packages/runtime/src/error-envelope.ts—splitSemanticCode/buildApiErrorpackages/runtime/src/domains/data.ts— no local catch, so the gate's throw reaches the dispatcher catchpackages/rest/src/rest-server.ts—mapDataError'sPERMISSION_DENIEDbranch, for the contrastpackages/rest/src/rest.test.ts— the pin PR fix(plugin-security,spec): PERMISSION_DENIED stops showing developer copy to end users (#7414) #7449 added for the REST half