Skip to content

fix(runtime): carry the producer's userMessage at the dispatcher's PERMISSION_DENIED door - #13811

Draft
os-steve wants to merge 1 commit into
mainfrom
claude/issue-13623-permission-denied-user-message
Draft

fix(runtime): carry the producer's userMessage at the dispatcher's PERMISSION_DENIED door#13811
os-steve wants to merge 1 commit into
mainfrom
claude/issue-13623-permission-denied-user-message

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes #13623

HttpDispatcher.dispatch's foot catch is not a pure rethrow: it recognises
isPermissionDeniedError and answers the refusal itself from
packages/runtime/src/http-dispatcher.ts, which did not read userMessage. A marked
authorization 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/main at eb717a12a8, which already contains PR #13763
(878aa2ed31, verified as an ancestor). Nothing was cited from the dispatch; every
number 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.

⚠️ First, a correction that matters for the next firing. The card's own re-check command,
run verbatim through a shell, returns zero hitsgit grep reads the pattern as a regex
and \/$/ does not match the source line. The zero is an artefact of the spelling, not a
finding. 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:

git grep -nF 'replace(/\/$/' -- packages/runtime/src/http-dispatcher.ts
packages/runtime/src/http-dispatcher.ts:2162:        let cleanPath = path.replace(/\/$/, '');
# reading merged main eb717a12a8 branch head 4ecc0d47f7
1 the premise sentence (trimmed line, sha256) 933a841baa6ffde7… at :2162 933a841baa6ffde7… at :2162 — byte-identical, line unmoved
2 dispatch() order cleanPath 2162 ⇒ resolveRequestScope 2174 ⇒ enforceAuthGate 2181 ⇒ enforceProjectMembership 2190 ⇒ domainRegistry.resolve 2210 identical; this diff lands at ~2324, strictly after the gate
3 /ready gate-exempt by construction ALLOW_SUFFIXES at auth-gate.ts:63 contains /ready unchanged (file untouched)
4 auth-gate call-site set 4 production sites diff of the two greps is empty

The three restart conditions, checked live — none fires.

  1. A second transport adapter. packages/adapters/ contains exactly one entry, hono.
    This PR touches neither packages/adapters/** nor packages/core (measured: 0 files).
  2. A synthetic-request caller reaching the gate without a populated path — the condition
    triage 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 — aiSubPath floors at the literal '/ai' (idx >= 0 ? … : '/ai'), and
    mountPackagesRoute's '' sub-path is concatenated onto /packages. The transport's own
    subPath is 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.
  3. An empty-path request reaching a non-discovery route. DomainHandlerRegistry.matches
    is path === prefix (exact), path === prefix || path.startsWith(prefix + '/') (segment)
    or path.startsWith(prefix) (prefix); '' satisfies none of the registered prefixes, so an
    empty cleanPath still reaches only the discovery payload (GET) or routeNotFound. This
    PR 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_DENIED path
to withhold userMessage. Measured three ways, with controls on the zeroes:

⇒ 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:

const userMessage = declaredUserMessage(e);
return {
    handled: true,
    response: this.error(
        e.message, 403, permissionDeniedErrorDetails(cleanPath), undefined,
        userMessage !== undefined ? { userMessage } : undefined,
    ),
};
  • declaredUserMessage (@objectstack/types), not an inline probe. It is the one read
    every boundary applies, so this door cannot fork its own answer to "what counts as marked".
    Ablation B below is that choice under test.
  • The extra bag, so the mark lands as a declared top-level sibling of code/message
    exactly as the sibling errorFromThrown places it — never inside details.
  • The runtime dispatcher serialises a PermissionDeniedError's details to the client, so positions / permissionSets reach the browser on the /data transport #7450 is untouched. details still carries only the ROUTE-derived object, the gate's
    structured 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.ts is 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 did
not: ⛔ the exit did not become throw-transparent — a PERMISSION_DENIED throw still never
reaches 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
FORBIDDEN row'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:

  • Does the field reach a built .d.ts? The published type surface is unchanged.
    Measured by building @objectstack/runtime twice at this head — once with this branch's
    source, once with origin/main's http-dispatcher.ts swapped in — and hashing the artefacts:
    dist/index.d.ts is identical (c8525a7e0579942a… both ends), while dist/index.js
    differs (75d93f43… vs def4918b…). The .js inequality is the positive control that
    the rebuild really picked up the source change, so the .d.ts equality is a reading rather
    than a stale artefact. The mutation and the restore were both proven by blob hash, with an
    empty git diff HEAD and a clean git status --porcelain after.
    Zero files under packages/spec/** are touched: ApiErrorSchema.userMessage is declared
    already and this PR does not edit its declaration.
  • Does any accept/reject behaviour change? No parse behaviour changes — the schema is
    untouched, error-envelope.conformance.test.ts (which parses every body this door emits
    against ApiErrorSchema) is green in the full package run, and an unmarked denial's key set
    is 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 on
whether 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/:id with real
PermissionDeniedError fixtures, plus the moved carve-out row and its new control in the
sibling file.

Ablations — direction predicted in writing BEFORE running; both matched row-for-row.

leg mutation prediction observed
A drop the mark from the door's extra (the pre-fix behaviour) partial red, presence rows only: 9 named rows, and specifically ⛔ NOT the unmarked-envelope row, ⛔ NOT any §4 omission row, ⛔ NOT the log-line row 9 failed / 28 passed (37) — the failing set is exactly the 9 predicted rows, no others
B replace declaredUserMessage(e) with a naive (e as any).userMessage exactly the 5 §4 non-declaration rows ('', whitespace, 42, null, an object), nothing else 5 failed / 32 passed (37) — exactly those 5

Both legs: anchored replacement asserting exactly one hit before writing; mutation proven on
disk by removed-text count 1 ⇒ 0 and blob-hash inequality against the HEAD blob (for leg A
the injected token undefined, occurs 23 times and is not a discriminating count — the removal
count 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 HEAD plus a clean git status --porcelain;
absolute paths throughout and trap … EXIT INT TERM on both legs.

No rebuild was needed or claimed, and the runs prove it rather than assert it. Both suites
reach http-dispatcher.ts through same-package relative specifiers, which vitest resolves to
src/. A suite reading dist/ would have stayed green under both mutations; every red above
is positive evidence the mutation reached the code under test.

The hidden test layer — measured, because @objectstack/runtime sits at surplus: none.
That package's typecheck excludes **/*.test.ts, so its green says nothing about a new test
file. 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; and
of 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/objectstack
at 4ecc0d47f7 (no paths passed; stderr read — it confirms the repo assertion holds against this
checkout's origin, and the 4-path change set from the merge base eb717a12a), harvested with
--commands so neither invocation spelling can be dropped. Every exit code captured before
any pipe.

Reconciliation, by comm -23 over 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:

union named 34, ran 34, unreconciled 0

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-adoption and 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 direct
    grep -naP control-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's userMessage to the wire at the dispatcher's throw-transparent exit #13619. This diff's
    only code literals are PERMISSION_DENIED, already registered, so no adr0112-ok: declaration
    was 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 is
    NOT MEASURED. ⛔ It is not a red, and there is nothing here to fix."
    It needs a saved
    turbo run test log that only CI produces.
  • pnpm check:dual-build-cjs-loads — exit 3: "PREREQUISITE NOT MET — this gate reads built
    output, 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-measure ratchet
    refused: "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"
    . ⚠️ Declared narrowing: the whole-workspace closure build this gate wants was
    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 the
sibling door, also patch. The reasoning, not just the precedent: no package's public API or
type surface changes (measured above — identical .d.ts), no field is declared (the slot has
existed 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, which
is patch. ⛔ Not minor: no new capability arrives; three of the four boundaries already
carried 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 grep reads it as a regex.
Any future discharge of that hold should run it with -F, and should keep a positive control
beside 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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6b285eca42a780636ea416a916e0de228f797fc1packageMentionDocs.

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants