fix(runtime): carry the producer's userMessage at the dispatcher's PERMISSION_DENIED door - #13811
Draft
os-steve wants to merge 1 commit into
Draft
fix(runtime): carry the producer's userMessage at the dispatcher's PERMISSION_DENIED door#13811os-steve wants to merge 1 commit into
userMessage at the dispatcher's PERMISSION_DENIED door#13811os-steve wants to merge 1 commit into
Conversation
…RMISSION_DENIED door `HttpDispatcher.dispatch`'s foot catch recognises `isPermissionDeniedError` and answers the refusal itself, so a marked denial never reaches the throw-transparent exit #13241 repaired and lost its `userMessage` at this second door instead. The read is `declaredUserMessage` (`@objectstack/types`) — the one rule every boundary applies — and the mark rides as a declared top-level sibling via the `extra` bag, exactly as the sibling `errorFromThrown` carries it. #7450's withhold is untouched: `details` still carries only the ROUTE-derived object, and the gate's positions/permissionSets/cascade child stay server-side. The PR #13619 §2 carve-out pin is MOVED, not fixed green — its docblock asked for exactly that when the denial path stopped dropping the mark. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
Contributor
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
This was referenced Aug 31, 2026
Open
This was referenced Aug 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #13623
HttpDispatcher.dispatch's foot catch is not a pure rethrow: it recognisesisPermissionDeniedErrorand answers the refusal itself frompackages/runtime/src/http-dispatcher.ts, which did not readuserMessage. A markedauthorization refusal therefore never reached the throw-transparent exit #13241 repaired
(PR #13619) and lost the author's text at a second door. It now carries it.
All readings below were taken at
4ecc0d47f7— this branch's head and its only commit —on a worktree cut from
origin/mainateb717a12a8, which already contains PR #13763(
878aa2ed31, verified as an ancestor). Nothing was cited from the dispatch; everynumber here was re-measured.
⛔ MANDATORY: the #7898 hold check, re-taken on the merged tree and on this branch
That card's file-touch clause fires — this PR edits
packages/runtime/src/http-dispatcher.ts.It is a premise-staleness trigger, so it is discharged by re-measuring the premise it
protects, not by comparing against the promotion triggers.
run verbatim through a shell, returns zero hits —
git grepreads the pattern as a regexand
\/$/does not match the source line. The zero is an artefact of the spelling, not afinding. Measured with a positive control (the same command shape with a term known
present, which also returned nothing) and then re-run as a fixed string:
maineb717a12a84ecc0d47f7933a841baa6ffde7…at :2162933a841baa6ffde7…at :2162 — byte-identical, line unmoveddispatch()order/readygate-exempt by constructionALLOW_SUFFIXESatauth-gate.ts:63contains/readydiffof the two greps is emptyThe three restart conditions, checked live — none fires.
packages/adapters/contains exactly one entry,hono.This PR touches neither
packages/adapters/**norpackages/core(measured: 0 files).path— the conditiontriage singled out. Census of every production
dispatcher.dispatch(call site (40 lines,38 real calls): every one passes a non-empty path. The two computed ones were checked
individually —
aiSubPathfloors at the literal'/ai'(idx >= 0 ? … : '/ai'), andmountPackagesRoute's''sub-path is concatenated onto/packages. The transport's ownsubPathis the pre-existing${prefix}/case the card already prices in.This PR adds no production caller; the new test file is a caller and passes
/data/app_parent_object/1.DomainHandlerRegistry.matchesis
path === prefix(exact),path === prefix || path.startsWith(prefix + '/')(segment)or
path.startsWith(prefix)(prefix);''satisfies none of the registered prefixes, so anempty
cleanPathstill reaches only the discovery payload (GET) orrouteNotFound. ThisPR registers no route and changes no path handling.
⇒ A2's premise survives, and the hold is not pushed through. Nothing here decides,
re-grades or promotes #7898; the A-versus-B appetite call is untouched.
The stop condition was checked and did NOT fire — the withhold is not deliberate
The card's premise is that no recorded reason requires the central
PERMISSION_DENIEDpathto withhold
userMessage. Measured three ways, with controls on the zeroes:userMessageanywhere indocs/(includingdocs/adr/**).Positive controls on the same search:
ADR-0112anddeclaredCodeboth hit. So no ADR orruling names this field at all, let alone requires withholding it at 403.
detailsto the client, sopositions/permissionSetsreach the browser on the/datatransport #7450(2026-08-11) rules on
error.details— the gate'spositions/permissionSetsand thecascade-child
object. It says nothing about author-marked text, which did not exist then.contract for both transports, and REST has carried the mark on this identical denial since
contract: a hook refusal has no way to mark its message user-facing — the console's 403 substitution (ruled in #3821) needs a producer-side opt-in channel #9934:
mapDataErroriswithDeclaredUserMessageoverclassifyDataError, whosePERMISSION_DENIEDbranch is pinned inpackages/rest/src/rest-user-facing-refusal-marking.test.ts("rides the structured-code branches too — a hook throwing the catalog PERMISSION_DENIED",
asserting
r.body.userMessage). The dispatcher's 403 was the transport that differed.⇒ Carrying the mark here moves toward the recorded ruling, not against one. No fork to escalate.
What changed
One expression at the denial door, reading the same rule every other boundary reads:
declaredUserMessage(@objectstack/types), not an inline probe. It is the one readevery boundary applies, so this door cannot fork its own answer to "what counts as marked".
Ablation B below is that choice under test.
extrabag, so the mark lands as a declared top-level sibling ofcode/messageexactly as the sibling
errorFromThrownplaces it — never insidedetails.detailsto the client, sopositions/permissionSetsreach the browser on the/datatransport #7450 is untouched.detailsstill carries only the ROUTE-derived object, the gate'sstructured payload is still dropped from the wire and still logged server-side, and an
unmarked denial's envelope is byte-identical to before (key set pinned).
packages/rest/src/rest-server.tsis not edited — the fix needed nothing there.⭐ The §2 carve-out pin went red, and it was MOVED, not fixed green
PR #13619's carve-out row asserted the mark was absent, and its docblock said what to do:
"if a later change makes the denial path throw-transparent, this test fails and tells the
author that the carve-out has moved". This is that change. The row is rewritten to assert
presence, retitled
MOVED CARVE-OUT, and its docblock now records what moved and what didnot: ⛔ the exit did not become throw-transparent — a
PERMISSION_DENIEDthrow still neverreaches
errorResponseBase; what moved is that the other door now reads the same rule.A new positive control sits beside it (unmarked denial, same route ⇒ still no key), and the
FORBIDDENrow's comment above the table was updated so it no longer reads as stale.⛔ Nothing was weakened, skipped or deleted.
Clause ② — declared yes, with the measurement
The measurement, in both halves the dispatch asked for:
.d.ts? The published type surface is unchanged.Measured by building
@objectstack/runtimetwice at this head — once with this branch'ssource, once with
origin/main'shttp-dispatcher.tsswapped in — and hashing the artefacts:dist/index.d.tsis identical (c8525a7e0579942a…both ends), whiledist/index.jsdiffers (
75d93f43…vsdef4918b…). The.jsinequality is the positive control thatthe rebuild really picked up the source change, so the
.d.tsequality is a reading ratherthan a stale artefact. The mutation and the restore were both proven by blob hash, with an
empty
git diff HEADand a cleangit status --porcelainafter.Zero files under
packages/spec/**are touched:ApiErrorSchema.userMessageis declaredalready and this PR does not edit its declaration.
untouched,
error-envelope.conformance.test.ts(which parses every body this door emitsagainst
ApiErrorSchema) is green in the full package run, and an unmarked denial's key setis pinned unchanged. What does change is wire behaviour at one door: a marked throw's
403 body now carries a declared optional field it previously dropped.
⇒
Clause-②: yes, declared conservatively and deliberately. Two careful seats disagree onwhether populating an already-declared field counts as widening; the maintainer question is
still open on #12297, and a below-tier seat picking the reading that lets it proceed is an
escape rather than a judgement. ⛔ No inference is drawn from PR #13619 having merged — that
PR's own body forbids exactly that inference about its predecessor.
⛔ This PR is left draft. Auto-merge is not armed, ready is not flipped, no contract-review
gate is self-cleared, and the scope was not narrowed to duck the tier — the fix is the
whole door, all three recognition limbs.
Verification
Tests. 17 new cases in
packages/runtime/src/http-dispatcher.permission-denied-user-message.test.ts,driving the real
HttpDispatcher.dispatch()over/data/:object/:idwith realPermissionDeniedErrorfixtures, plus the moved carve-out row and its new control in thesibling file.
domains/data-permission-denied-envelope,security/permission-denied-envelope,dispatcher-plugin.declared-5xx-prose-withhold):62 passed / 62, so neither The runtime dispatcher serialises a PermissionDeniedError's
detailsto the client, sopositions/permissionSetsreach the browser on the/datatransport #7450's disclosure pins nor runtime: a declared 5xx carrying NOcodekeeps its prose on/analytics/querywhere/datawithholds it unconditionally #12281's withhold moved.@objectstack/runtime: 203 files, 3029 tests, all passing(
TEST_EXIT=0, captured before any pipe).pnpm --filter @objectstack/runtime run typecheck:TYPECHECK_EXIT=0.;inside one lock hold, and the lock's own verdict line warns that it then reports only the
last part's status.
Ablations — direction predicted in writing BEFORE running; both matched row-for-row.
extra(the pre-fix behaviour)declaredUserMessage(e)with a naive(e as any).userMessage'', whitespace,42,null, an object), nothing elseBoth legs: anchored replacement asserting exactly one hit before writing; mutation proven on
disk by removed-text count
1 ⇒ 0and blob-hash inequality against theHEADblob (for leg Athe injected token
undefined,occurs 23 times and is not a discriminating count — the removalcount and the hash are what prove it); restore by
git checkout HEAD -- ABSOLUTE_PATH, never bare,proven by blob-hash equality plus an empty
git diff HEADplus a cleangit status --porcelain;absolute paths throughout and
trap … EXIT INT TERMon both legs.No rebuild was needed or claimed, and the runs prove it rather than assert it. Both suites
reach
http-dispatcher.tsthrough same-package relative specifiers, which vitest resolves tosrc/. A suite readingdist/would have stayed green under both mutations; every red aboveis positive evidence the mutation reached the code under test.
The hidden test layer — measured, because
@objectstack/runtimesits atsurplus: none.That package's
typecheckexcludes**/*.test.ts, so its green says nothing about a new testfile. Replicating the gate's own re-measure project (the package tsconfig with the test globs
removed from
exclude) reports 206 errors — exactly the ledger's recorded number for@objectstack/runtime, which is the positive control that this is the same measurement; andof those 206, 0 are in either test file this PR touches and 0 in
http-dispatcher.ts.⛔ No TEST_DEBT entry is raised, lowered or edited.
Gates. Union derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackat
4ecc0d47f7(no paths passed; stderr read — it confirms the repo assertion holds against thischeckout's
origin, and the 4-path change set from the merge baseeb717a12a), harvested with--commandsso neither invocation spelling can be dropped. Every exit code captured beforeany pipe.
⭐ Reconciliation, by
comm -23over the two sorted lists (the harvested union and the commands actually run), exact string comparison, no prefix or fuzzy matching and no self-maintained counter:31 of 34 pass, including the ones nearest this diff:
check:cross-package-test-inputs,check:test-source-alias,check:engine-double-contract,check:where-matcher,check:query-options-erasure,check:type-check-coverage,check:dispatcher-error-vocabulary,check:undeclared-dep-imports,check:comment-mask-adoptionand the whole changeset family.Run beyond the union (unconditional CI steps and one content-judged gate no path derivation
can name):
pnpm lint— whole repo,eslint . --no-inline-config: exit 0 in 76s, run in full.⛔ No narrowing claimed and none needed.
node scripts/check-nul-bytes.mjs: OK, 7588 text files, no raw control bytes. Plus a directgrep -naPcontrol-byte self-scan over all four changed files: no hits.pnpm check:error-code-casing: exit 0 — the gate that went red on PR fix(runtime): carry the producer'suserMessageto the wire at the dispatcher's throw-transparent exit #13619. This diff'sonly code literals are
PERMISSION_DENIED, already registered, so noadr0112-ok:declarationwas needed or added.
3 refusals, reported separately and ⛔ NOT folded into the passes — each is NOT MEASURED, in the
gate's own words:
node scripts/check-test-completeness.mjs— exit 3: "the local reading for this gate isNOT MEASURED. ⛔ It is not a red, and there is nothing here to fix." It needs a saved
turbo run testlog that only CI produces.pnpm check:dual-build-cjs-loads— exit 3: "PREREQUISITE NOT MET — this gate reads builtoutput, and some package has no dist/. … ⛔ This is NOT a pass: nothing was measured."
pnpm check:type-check-debt— exit 1. Its structural half ran; the--re-measureratchetrefused: "cannot run: 23 workspace dependenc(ies) of the ledgered packages have no built type
entry point on disk", because a number taken without the closure "would silently measure a
DIFFERENT WORLD".
in progress in a sibling agent's hold on the shared verify lock, and queueing behind it plus the
build itself does not fit this container's foreground ceiling. The substance is measured
locally instead — see the hidden-test-layer paragraph above, whose total reproduces the ledger's
206 exactly — and CI runs the gate with the closure built either way.
The changeset grade, with reasoning rather than a silent pick
@objectstack/runtime: patch. Nearest precedent: #13241 / PR #13619, the same field at thesibling door, also
patch. The reasoning, not just the precedent: no package's public API ortype surface changes (measured above — identical
.d.ts), no field is declared (the slot hasexisted since #9934), and nothing that parsed before stops parsing. What changes is that one
door stops dropping a declared optional field — a bug fix closing
declared ≠ enforced, whichis
patch. ⛔ Notminor: no new capability arrives; three of the four boundaries alreadycarried the field.
Findings recorded on the way past
⛔ None filed. The one thing worth recording is a documentation-grade correction rather than a
defect, and it is stated in full above so the next author does not pay for it: #7898's own
re-check command returns a false zero through a shell, because
git grepreads it as a regex.Any future discharge of that hold should run it with
-F, and should keep a positive controlbeside it — without one, that zero reads as "the premise sentence is gone", which is the exact
opposite of what the tree says.
Generated by Claude Code