Skip to content

fix(runtime): carry the producer's userMessage to the wire at the dispatcher's throw-transparent exit - #13619

Merged
huangyiirene merged 4 commits into
mainfrom
claude/issue-13241-dispatcher-usermessage-channel
Aug 31, 2026
Merged

fix(runtime): carry the producer's userMessage to the wire at the dispatcher's throw-transparent exit#13619
huangyiirene merged 4 commits into
mainfrom
claude/issue-13241-dispatcher-usermessage-channel

Conversation

@os-steve

@os-steveos-steve commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13241

errorResponseBase (packages/runtime/src/dispatcher-plugin.ts) resolved every throw through resolveThrownHttpError — whose result already carries userMessage — and then did not read the field. It was the only ADR-0112 error boundary that dropped the author-facing text channel.

Verified on this branch's merge base, all four of triage's readings re-measured rather than taken on trust, with one correction noted below.

⚠️ Parked by design — Clause-②: yes

The PM claim declares clause ② on this card, deliberately and conservatively: two careful seats disagree on whether populating an already-declared field constitutes widening, and a below-tier seat picking the reading that lets it proceed is an escape, not a judgement. This PR is complete-but-parked and an at-tier reviewer clears it.

⛔ This seat has not self-cleared any contract-review gate, has not armed auto-merge, and has not flipped the PR ready. ⛔ It also did not pick the narrower of the two implementation options to look safer under that gate — see the census below, which chose the route on architectural grounds before the gate was ever considered.

⛔ No inference is drawn here from PR #13240 having merged. A merge says that PR cleared; it says nothing about this door's clause-② question, or how it would be answered.

The binding measurement — the buildApiError caller census

Triage's instruction was to census the caller set first, then choose between (1) plumbing at this exit only and (2) making the shared buildApiError carry it so no door can forget again. Six call sites, all in packages/runtime:

#sitehas a thrown error in hand?producer userMessage available?
1error-envelope.ts:132apiErrorResponseno — pass-through wrappern/a, forwards its caller's input
2dispatcher-plugin.ts:468 — inline ROUTE_NOT_FOUND 404no — constructednone
3dispatcher-plugin.ts:662errorResponseBaseyesyes, and it was dropped
4domains/mcp.ts:176 — 405 with Allowno — constructednone
5domains/meta.ts:666 — 405 with Allowno — constructednone
6security/inbound-rate-limit.ts:356 — 429no — constructednone

Exactly one of six has a throw. The other five author their own refusal text; there is no producer mark for them to carry.

Route (2), as stated, is not implementable at buildApiError. That function takes an already-sanitised ApiErrorInput and never sees the throw. Adding userMessage?: string to ApiErrorInput would create a typed slot every caller must still populate explicitly — the "cannot forget" property is not obtained, the forgetting merely moves from the body of errorResponseBase to its argument list — while widening a shared input type consumed by endpoint-executor.ts, api-mapping.ts, endpoint-policy.ts, api-endpoint-step.ts and http-dispatcher.ts for zero behavioural gain at any of them.

The "one rule, every door inherits" property route (2) was reaching for already exists — one layer up.declaredUserMessage (@objectstack/types) is the single read every boundary applies; its own docblock says so, naming the REST door, the dispatcher door and the sandbox side-channel. resolveThrownHttpError already resolves the field onto ThrownHttpError.userMessage. This exit already called that resolver and simply did not read the answer. So the defect is one door not reading a shared rule, not an absent shared rule — and route (1) is the repair that matches the diagnosis.

The pre-registered conditional escalation does NOT fire. Route (2) could not make a door that today deliberately withholds userMessage start emitting it, because none of the other five call sites has a userMessage in scope to withhold. There is no such door. Measured, not assumed.

What the change is

Both declared siblings now share oneextra object, mirroring errorFromThrown's expression:

constextra={
...(declaredCode!==undefined ? { declaredCode } : {}),
...(thrown.userMessage!==undefined ? {userMessage: thrown.userMessage} : {}),};

⚠️ Two conditional { extra: … } spreads on one object literal do not merge — the later replaces the earlier — so a throw carrying both a demoted declaredCode and a mark would have silently shipped only one. That is pinned (§4) and ablated below.

Wider than the card's framing:userMessage is status-agnostic by ruling (#9934), so the gap was never confined to the declared-5xx band that motivated it. A marked 4xx refusal reaching this exit lost the field too, with no withhold anywhere in the picture. The plumbing is deliberately not gated on the withhold limb, and §2 drives 400/403/409/503 so a later edit cannot quietly re-gate it.

The open question triage asked to be answered on the way past

Does any producer on the throw-transparent routes set author-facing text today? Measured answer: ZERO. The only two in-repo userMessage producers are:

  • the sandbox (packages/runtime/src/sandbox/quickjs-runner.ts), reached via /actions — but domains/actions.tscatches and answers through errorFromThrown, which already carries the field;
  • metadata-protocol's markedApplicationRefusalError, reached via the REST /meta and /data doors.

Neither reaches this exit. A package-level sweep finds userMessage in only six packages (runtime, rest, metadata-protocol, spec, types, client); no plugin-* or service-* package sets it. ⇒ This confirms the p2 and confirms the change is a no-op on today's tree. It does not close the card's underlying concern, because the contract stays unenforced at one door either way — which is exactly why every test drives the real mounted route rather than asserting a predicate.

⚠️ Two findings this work turned up

1. A measured carve-out, recorded rather than closed.HttpDispatcher.dispatch's foot catch is not a pure rethrow: it intercepts isPermissionDeniedError (name === 'PermissionDeniedError', orcode === 'PERMISSION_DENIED', or a message starting [Security] Access denied) and answers it itself from packages/runtime/src/http-dispatcher.ts. Such a throw never reaches errorResponseBase, so its mark is still dropped, by a different door. That file is a trigger file of on-hold decision #7898 and is out of this change's file surface, so this is pinned as an observation, not repaired here. My own test initially assumed a PERMISSION_DENIED 403 was throw-transparent; the instrument caught it, and the corrected row now pins the carve-out so a future reader learns if it moves.

2. A build-graph mechanism, contributed to the existing card #13513 rather than filed anew.pnpm --filter '@objectstack/runtime^...' build — the closure-build recipe AGENTS.md prescribes — cannot be used for runtime: a devDependency cycle (runtimedriver-turso →(devDep) verifyruntime) pulls verifyand runtime itself into runtime's own dependency closure, so there is no topological order and verify's DTS build races runtime's, dying on Cannot find module '@objectstack/runtime'. Pre-existing and unrelated to this diff; worked around here with --filter '!@objectstack/verify', which is a local unblock and ⛔ not a proposed repair. #13513 already tracks the symptom across two earlier observations and states that what it needs is one confirming build owning the cause; that measurement is now a comment there. ⛔ A duplicate was deliberately not opened.

Patch round — check:error-code-casing was RED on the first push, now green

The gate flagged the two code: 'acme_quota_exceeded' literals in §4 as lowercase error codes in a code position (ADR-0112 D1). They are a deliberately unregistered producer spelling, and they have to be: demotedDeclaredCode yields a declaredCode only when the throw spelled something the closed vocabulary rejected, so a registered SCREAMING code there would produce no declaredCode at all and §4 would stop being the both-fields case and assert nothing.

Declared with adr0112-ok: plus a reason, on the line directly above each hit — the only two placements the gate accepts (it matches the marker on the hit line or the one above, and requires a non-empty reason; both pinned in its --self-test).

  • ⛔ Not KNOWN_LOWERCASE_CODES: the gate refuses new lines there in terms, and the list is shrink-only and currently empty by design.
  • ⛔ Not EXEMPT_FILES: that is for literals which are not error codes (D6/D6b/D6c). These genuinely are in a code position, so exempting the file would be a false declaration and would blind the gate to every future literal in it.
  • ⛔ Not the available restructure either — hoisting the spelling into a constant would satisfy the gate, and the gate's own note says so ("a vendor code passing through is a RUNTIME value … it has no literal for any pattern here to capture"). I did not take it: that makes the deliberate non-conformance invisible by construction, whereas the gate deliberately counts an applied suppression into its verdict line. Visibility is the point, so the declared form is the right instrument and the assertion is unchanged — ⛔ nothing was softened to get green.

The gate's own verdict line, after the fix:

✓ check-error-code-casing self-test: 46 recognizer case(s) + 5 registry case(s) pass.
✓ no unlisted lowercase error codes in 5083 scanned file(s) (ADR-0112).
· 15 literal(s) suppressed by an adr0112-ok: reason

Suppression count moved 13 → 15, which is the positive control that my two were applied rather than silently ignored.

⚠️Why the local union missed it.check:error-code-casing is not in the path-derived union — dispatch-gates.mjs classifies it as content-judged and says so itself: "adds or edits a file carrying an ADR-0112 error or notice CODE (judged from CONTENT — no path derivation can name this gate)". So this was not a skipped gate; it was one no path derivation can name, which is exactly the residue CI exists to cover.

Verification

All readings below were re-taken at 5d93e2427 after the patch round described just above — that is this branch's head, so the gate union, the lint run and the test runs are all on the same tree as the final commit. (Authored in session https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk, recorded here in prose because a body edit rewrites the footer below to its bare form.)

Tests. 21 new cases in packages/runtime/src/dispatcher-plugin.declared-user-message.test.ts, driving the real mounted POST /api/v1/analytics/query route.

Positive controls, not merely green. Absence is asserted twice everywhere it is asserted at all — on Object.keys(error) / hasOwnPropertyand on the serialized bytes — because JSON.stringify silently drops a key whose value is undefined, so a byte assertion alone cannot distinguish "omitted" from "emitted as undefined". §3 additionally drives the guard itself against a present-but-undefined envelope, demonstrating the byte limb blind on that shape while the key limb still catches it.

Ablations — direction predicted before running, both matched exactly.

ablationpredictionobserved
A — delete the userMessage limb from the merged extrapartial red with a specific shape: the presence-asserting rows go red, the absence-asserting rows (§3 omissions, §1's unmarked row, the §2 carve-out) stay green, siblings untouched9 failed / 67 passed; the 9 are exactly the predicted presence rows; both sibling suites green
B — restore the two-spread extra (the pre-merge bug shape)exactly one red — §4's both-fields row — everything else green1 failed / 75 passed; the failure is §4's both-fields row

Both legs were mutated with an anchored replacement asserting a hit count of exactly 1 (a zero-hit edit exits 0 and would have read as a successful ablation), confirmed on disk by blob-hash inequality against the HEAD blob, and restored via git checkout HEAD -- ABSOLUTE_PATH with the restore proven by hash equality plus an empty git diff HEAD — not by an exit code. No rebuild was needed or claimed: the suite imports ./dispatcher-plugin.js, which vitest resolves to same-package source, and every @objectstack/* it touches is source-aliased in packages/runtime/vitest.config.ts.

Gates. Union derived at 5d93e2427 with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed; stderr read and confirms the change set and the repo assertion). Harvested with --commands to avoid the 15-of-30 single-spelling trap. Exit codes captured before any pipe.

  • 31 of 34 pass. Including the most targeted: check:route-envelope, check:test-source-alias, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:type-check-coverage, check:comment-mask-adoption, check:undeclared-dep-imports, and the changeset family.
  • pnpm lint (whole repo, eslint . --no-inline-config): exit 0, run in full — no narrowing claimed and none needed.
  • node scripts/check-nul-bytes.mjs: OK, 7541 files, no raw control bytes.

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 printed OK — 66/78 workspace packages type-checked, but 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 measured without the closure "would silently measure a DIFFERENT WORLD".

One NOT MEASURED I am flagging on my own diff, not on a gate.@objectstack/runtime's tsconfig excludes **/*.test.ts, so its green typecheck says nothing about the new test file. Measured rather than assumed with tsc --noEmit --listFiles: src/dispatcher-plugin.ts → 1 hit (the implementation is covered), the new test file → 0 hits. check:type-check-coverage passes, so the existing TEST_DEBT entry already accounts for that layer.

Triage's four readings, re-measured

Three hold exactly. One has drifted and is corrected here:

claimreading at 5d93e2427
userMessage absent from dispatcher-plugin.ts⚠️1 hit, not 0 — but it is a comment line added by #13240 ("The author-facing text channel is userMessage"), not code. The substantive claim holds: zero code references. Triage measured 0 at a81aa9dd, before that comment landed.
positive control — buildApiError in the same file5 hits ⇒ the absence was real, not a broken read
the caught-path sibling carries ithttp-dispatcher.ts:850 — confirmed verbatim
the envelope slot is declaredcontract.zod.ts:87 — confirmed

Card body sanitizer self-check

Clean. The body is structurally complete — ## The gap## Why it matters more after #12281## Not established here## Region## Refs, closing on its three-ref list, with no tag-shaped fragment anywhere in it and nothing cut mid-sentence.

os-steveand others added 2 commits August 31, 2026 02:56
…throw-transparent exit
`errorResponseBase` resolved every throw through `resolveThrownHttpError` —
whose result already carries `userMessage` — and never read the field, making
it the only ADR-0112 boundary that dropped the author-facing text channel.
Both declared siblings now share ONE `extra` object: two conditional
`{ extra: … }` spreads do not merge, so a throw carrying both a demoted
`declaredCode` and a mark would have shipped only one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 eaba72e48cc294038e74ece5bf7677568d55b038packageMentionDocs.

Which tree this was computed on

This run read content/docs from 707d35adf48162df29799f09f0c6c6fa4d0044ca — the merge of head 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4 into base eaba72e48cc294038e74ece5bf7677568d55b038, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 707d35adf48162df29799f09f0c6c6fa4d0044ca && git checkout 707d35adf48162df29799f09f0c6c6fa4d0044ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eaba72e48cc294038e74ece5bf7677568d55b038 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4 && git checkout -B drift-repro eaba72e48cc294038e74ece5bf7677568d55b038 && git merge --no-ff 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4
node scripts/docs-audit/affected-docs.mjs --json eaba72e48cc294038e74ece5bf7677568d55b038

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

claudeand others added 2 commits August 31, 2026 04:19
…:error-code-casing
The two `code: 'acme_quota_exceeded'` literals are the fixture for the #9106
demotion path: `demotedDeclaredCode` yields a `declaredCode` only for a spelling
the closed vocabulary rejected, so a registered SCREAMING code would make §4
stop being the both-fields case and assert nothing.
Declared with `adr0112-ok:` and a reason rather than hidden behind a constant —
the gate counts an applied suppression into its verdict, so the deliberate
non-conformance stays visible instead of becoming invisible by construction.
⛔ Not KNOWN_LOWERCASE_CODES (closed to new lines) and ⛔ not EXEMPT_FILES
(these ARE in a code position, so exempting the file would be a false
declaration and would blind the gate to every future literal in it).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@huangyiirene
huangyiirene marked this pull request as ready for review August 31, 2026 07:30
@huangyiirene
huangyiirene added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit a21d2a9Aug 31, 2026
40 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-13241-dispatcher-usermessage-channel branch August 31, 2026 07:55
os-steve added a commit that referenced this pull request Aug 31, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

3 participants

@os-steve@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(runtime): carry the producer's `userMessage` to the wire at the dispatcher's throw-transparent exit by os-steve · Pull Request #13619 · objectstack-ai/objectstack · GitHub
Skip to content

fix(runtime): carry the producer's userMessage to the wire at the dispatcher's throw-transparent exit - #13619

Merged
huangyiirene merged 4 commits into
mainfrom
claude/issue-13241-dispatcher-usermessage-channel
Aug 31, 2026
Merged

fix(runtime): carry the producer's userMessage to the wire at the dispatcher's throw-transparent exit#13619
huangyiirene merged 4 commits into
mainfrom
claude/issue-13241-dispatcher-usermessage-channel

Conversation

@os-steve

@os-steveos-steve commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13241

errorResponseBase (packages/runtime/src/dispatcher-plugin.ts) resolved every throw through resolveThrownHttpError — whose result already carries userMessage — and then did not read the field. It was the only ADR-0112 error boundary that dropped the author-facing text channel.

Verified on this branch's merge base, all four of triage's readings re-measured rather than taken on trust, with one correction noted below.

⚠️ Parked by design — Clause-②: yes

The PM claim declares clause ② on this card, deliberately and conservatively: two careful seats disagree on whether populating an already-declared field constitutes widening, and a below-tier seat picking the reading that lets it proceed is an escape, not a judgement. This PR is complete-but-parked and an at-tier reviewer clears it.

⛔ This seat has not self-cleared any contract-review gate, has not armed auto-merge, and has not flipped the PR ready. ⛔ It also did not pick the narrower of the two implementation options to look safer under that gate — see the census below, which chose the route on architectural grounds before the gate was ever considered.

⛔ No inference is drawn here from PR #13240 having merged. A merge says that PR cleared; it says nothing about this door's clause-② question, or how it would be answered.

The binding measurement — the buildApiError caller census

Triage's instruction was to census the caller set first, then choose between (1) plumbing at this exit only and (2) making the shared buildApiError carry it so no door can forget again. Six call sites, all in packages/runtime:

#sitehas a thrown error in hand?producer userMessage available?
1error-envelope.ts:132apiErrorResponseno — pass-through wrappern/a, forwards its caller's input
2dispatcher-plugin.ts:468 — inline ROUTE_NOT_FOUND 404no — constructednone
3dispatcher-plugin.ts:662errorResponseBaseyesyes, and it was dropped
4domains/mcp.ts:176 — 405 with Allowno — constructednone
5domains/meta.ts:666 — 405 with Allowno — constructednone
6security/inbound-rate-limit.ts:356 — 429no — constructednone

Exactly one of six has a throw. The other five author their own refusal text; there is no producer mark for them to carry.

Route (2), as stated, is not implementable at buildApiError. That function takes an already-sanitised ApiErrorInput and never sees the throw. Adding userMessage?: string to ApiErrorInput would create a typed slot every caller must still populate explicitly — the "cannot forget" property is not obtained, the forgetting merely moves from the body of errorResponseBase to its argument list — while widening a shared input type consumed by endpoint-executor.ts, api-mapping.ts, endpoint-policy.ts, api-endpoint-step.ts and http-dispatcher.ts for zero behavioural gain at any of them.

The "one rule, every door inherits" property route (2) was reaching for already exists — one layer up.declaredUserMessage (@objectstack/types) is the single read every boundary applies; its own docblock says so, naming the REST door, the dispatcher door and the sandbox side-channel. resolveThrownHttpError already resolves the field onto ThrownHttpError.userMessage. This exit already called that resolver and simply did not read the answer. So the defect is one door not reading a shared rule, not an absent shared rule — and route (1) is the repair that matches the diagnosis.

The pre-registered conditional escalation does NOT fire. Route (2) could not make a door that today deliberately withholds userMessage start emitting it, because none of the other five call sites has a userMessage in scope to withhold. There is no such door. Measured, not assumed.

What the change is

Both declared siblings now share oneextra object, mirroring errorFromThrown's expression:

constextra={
...(declaredCode!==undefined ? { declaredCode } : {}),
...(thrown.userMessage!==undefined ? {userMessage: thrown.userMessage} : {}),};

⚠️ Two conditional { extra: … } spreads on one object literal do not merge — the later replaces the earlier — so a throw carrying both a demoted declaredCode and a mark would have silently shipped only one. That is pinned (§4) and ablated below.

Wider than the card's framing:userMessage is status-agnostic by ruling (#9934), so the gap was never confined to the declared-5xx band that motivated it. A marked 4xx refusal reaching this exit lost the field too, with no withhold anywhere in the picture. The plumbing is deliberately not gated on the withhold limb, and §2 drives 400/403/409/503 so a later edit cannot quietly re-gate it.

The open question triage asked to be answered on the way past

Does any producer on the throw-transparent routes set author-facing text today? Measured answer: ZERO. The only two in-repo userMessage producers are:

  • the sandbox (packages/runtime/src/sandbox/quickjs-runner.ts), reached via /actions — but domains/actions.tscatches and answers through errorFromThrown, which already carries the field;
  • metadata-protocol's markedApplicationRefusalError, reached via the REST /meta and /data doors.

Neither reaches this exit. A package-level sweep finds userMessage in only six packages (runtime, rest, metadata-protocol, spec, types, client); no plugin-* or service-* package sets it. ⇒ This confirms the p2 and confirms the change is a no-op on today's tree. It does not close the card's underlying concern, because the contract stays unenforced at one door either way — which is exactly why every test drives the real mounted route rather than asserting a predicate.

⚠️ Two findings this work turned up

1. A measured carve-out, recorded rather than closed.HttpDispatcher.dispatch's foot catch is not a pure rethrow: it intercepts isPermissionDeniedError (name === 'PermissionDeniedError', orcode === 'PERMISSION_DENIED', or a message starting [Security] Access denied) and answers it itself from packages/runtime/src/http-dispatcher.ts. Such a throw never reaches errorResponseBase, so its mark is still dropped, by a different door. That file is a trigger file of on-hold decision #7898 and is out of this change's file surface, so this is pinned as an observation, not repaired here. My own test initially assumed a PERMISSION_DENIED 403 was throw-transparent; the instrument caught it, and the corrected row now pins the carve-out so a future reader learns if it moves.

2. A build-graph mechanism, contributed to the existing card #13513 rather than filed anew.pnpm --filter '@objectstack/runtime^...' build — the closure-build recipe AGENTS.md prescribes — cannot be used for runtime: a devDependency cycle (runtimedriver-turso →(devDep) verifyruntime) pulls verifyand runtime itself into runtime's own dependency closure, so there is no topological order and verify's DTS build races runtime's, dying on Cannot find module '@objectstack/runtime'. Pre-existing and unrelated to this diff; worked around here with --filter '!@objectstack/verify', which is a local unblock and ⛔ not a proposed repair. #13513 already tracks the symptom across two earlier observations and states that what it needs is one confirming build owning the cause; that measurement is now a comment there. ⛔ A duplicate was deliberately not opened.

Patch round — check:error-code-casing was RED on the first push, now green

The gate flagged the two code: 'acme_quota_exceeded' literals in §4 as lowercase error codes in a code position (ADR-0112 D1). They are a deliberately unregistered producer spelling, and they have to be: demotedDeclaredCode yields a declaredCode only when the throw spelled something the closed vocabulary rejected, so a registered SCREAMING code there would produce no declaredCode at all and §4 would stop being the both-fields case and assert nothing.

Declared with adr0112-ok: plus a reason, on the line directly above each hit — the only two placements the gate accepts (it matches the marker on the hit line or the one above, and requires a non-empty reason; both pinned in its --self-test).

  • ⛔ Not KNOWN_LOWERCASE_CODES: the gate refuses new lines there in terms, and the list is shrink-only and currently empty by design.
  • ⛔ Not EXEMPT_FILES: that is for literals which are not error codes (D6/D6b/D6c). These genuinely are in a code position, so exempting the file would be a false declaration and would blind the gate to every future literal in it.
  • ⛔ Not the available restructure either — hoisting the spelling into a constant would satisfy the gate, and the gate's own note says so ("a vendor code passing through is a RUNTIME value … it has no literal for any pattern here to capture"). I did not take it: that makes the deliberate non-conformance invisible by construction, whereas the gate deliberately counts an applied suppression into its verdict line. Visibility is the point, so the declared form is the right instrument and the assertion is unchanged — ⛔ nothing was softened to get green.

The gate's own verdict line, after the fix:

✓ check-error-code-casing self-test: 46 recognizer case(s) + 5 registry case(s) pass.
✓ no unlisted lowercase error codes in 5083 scanned file(s) (ADR-0112).
· 15 literal(s) suppressed by an adr0112-ok: reason

Suppression count moved 13 → 15, which is the positive control that my two were applied rather than silently ignored.

⚠️Why the local union missed it.check:error-code-casing is not in the path-derived union — dispatch-gates.mjs classifies it as content-judged and says so itself: "adds or edits a file carrying an ADR-0112 error or notice CODE (judged from CONTENT — no path derivation can name this gate)". So this was not a skipped gate; it was one no path derivation can name, which is exactly the residue CI exists to cover.

Verification

All readings below were re-taken at 5d93e2427 after the patch round described just above — that is this branch's head, so the gate union, the lint run and the test runs are all on the same tree as the final commit. (Authored in session https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk, recorded here in prose because a body edit rewrites the footer below to its bare form.)

Tests. 21 new cases in packages/runtime/src/dispatcher-plugin.declared-user-message.test.ts, driving the real mounted POST /api/v1/analytics/query route.

Positive controls, not merely green. Absence is asserted twice everywhere it is asserted at all — on Object.keys(error) / hasOwnPropertyand on the serialized bytes — because JSON.stringify silently drops a key whose value is undefined, so a byte assertion alone cannot distinguish "omitted" from "emitted as undefined". §3 additionally drives the guard itself against a present-but-undefined envelope, demonstrating the byte limb blind on that shape while the key limb still catches it.

Ablations — direction predicted before running, both matched exactly.

ablationpredictionobserved
A — delete the userMessage limb from the merged extrapartial red with a specific shape: the presence-asserting rows go red, the absence-asserting rows (§3 omissions, §1's unmarked row, the §2 carve-out) stay green, siblings untouched9 failed / 67 passed; the 9 are exactly the predicted presence rows; both sibling suites green
B — restore the two-spread extra (the pre-merge bug shape)exactly one red — §4's both-fields row — everything else green1 failed / 75 passed; the failure is §4's both-fields row

Both legs were mutated with an anchored replacement asserting a hit count of exactly 1 (a zero-hit edit exits 0 and would have read as a successful ablation), confirmed on disk by blob-hash inequality against the HEAD blob, and restored via git checkout HEAD -- ABSOLUTE_PATH with the restore proven by hash equality plus an empty git diff HEAD — not by an exit code. No rebuild was needed or claimed: the suite imports ./dispatcher-plugin.js, which vitest resolves to same-package source, and every @objectstack/* it touches is source-aliased in packages/runtime/vitest.config.ts.

Gates. Union derived at 5d93e2427 with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed; stderr read and confirms the change set and the repo assertion). Harvested with --commands to avoid the 15-of-30 single-spelling trap. Exit codes captured before any pipe.

  • 31 of 34 pass. Including the most targeted: check:route-envelope, check:test-source-alias, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:type-check-coverage, check:comment-mask-adoption, check:undeclared-dep-imports, and the changeset family.
  • pnpm lint (whole repo, eslint . --no-inline-config): exit 0, run in full — no narrowing claimed and none needed.
  • node scripts/check-nul-bytes.mjs: OK, 7541 files, no raw control bytes.

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 printed OK — 66/78 workspace packages type-checked, but 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 measured without the closure "would silently measure a DIFFERENT WORLD".

One NOT MEASURED I am flagging on my own diff, not on a gate.@objectstack/runtime's tsconfig excludes **/*.test.ts, so its green typecheck says nothing about the new test file. Measured rather than assumed with tsc --noEmit --listFiles: src/dispatcher-plugin.ts → 1 hit (the implementation is covered), the new test file → 0 hits. check:type-check-coverage passes, so the existing TEST_DEBT entry already accounts for that layer.

Triage's four readings, re-measured

Three hold exactly. One has drifted and is corrected here:

claimreading at 5d93e2427
userMessage absent from dispatcher-plugin.ts⚠️1 hit, not 0 — but it is a comment line added by #13240 ("The author-facing text channel is userMessage"), not code. The substantive claim holds: zero code references. Triage measured 0 at a81aa9dd, before that comment landed.
positive control — buildApiError in the same file5 hits ⇒ the absence was real, not a broken read
the caught-path sibling carries ithttp-dispatcher.ts:850 — confirmed verbatim
the envelope slot is declaredcontract.zod.ts:87 — confirmed

Card body sanitizer self-check

Clean. The body is structurally complete — ## The gap## Why it matters more after #12281## Not established here## Region## Refs, closing on its three-ref list, with no tag-shaped fragment anywhere in it and nothing cut mid-sentence.

os-steveand others added 2 commits August 31, 2026 02:56
…throw-transparent exit
`errorResponseBase` resolved every throw through `resolveThrownHttpError` —
whose result already carries `userMessage` — and never read the field, making
it the only ADR-0112 boundary that dropped the author-facing text channel.
Both declared siblings now share ONE `extra` object: two conditional
`{ extra: … }` spreads do not merge, so a throw carrying both a demoted
`declaredCode` and a mark would have shipped only one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 eaba72e48cc294038e74ece5bf7677568d55b038packageMentionDocs.

Which tree this was computed on

This run read content/docs from 707d35adf48162df29799f09f0c6c6fa4d0044ca — the merge of head 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4 into base eaba72e48cc294038e74ece5bf7677568d55b038, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 707d35adf48162df29799f09f0c6c6fa4d0044ca && git checkout 707d35adf48162df29799f09f0c6c6fa4d0044ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eaba72e48cc294038e74ece5bf7677568d55b038 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4 && git checkout -B drift-repro eaba72e48cc294038e74ece5bf7677568d55b038 && git merge --no-ff 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4
node scripts/docs-audit/affected-docs.mjs --json eaba72e48cc294038e74ece5bf7677568d55b038

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

claudeand others added 2 commits August 31, 2026 04:19
…:error-code-casing
The two `code: 'acme_quota_exceeded'` literals are the fixture for the #9106
demotion path: `demotedDeclaredCode` yields a `declaredCode` only for a spelling
the closed vocabulary rejected, so a registered SCREAMING code would make §4
stop being the both-fields case and assert nothing.
Declared with `adr0112-ok:` and a reason rather than hidden behind a constant —
the gate counts an applied suppression into its verdict, so the deliberate
non-conformance stays visible instead of becoming invisible by construction.
⛔ Not KNOWN_LOWERCASE_CODES (closed to new lines) and ⛔ not EXEMPT_FILES
(these ARE in a code position, so exempting the file would be a false
declaration and would blind the gate to every future literal in it).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@huangyiirene
huangyiirene marked this pull request as ready for review August 31, 2026 07:30
@huangyiirene
huangyiirene added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit a21d2a9Aug 31, 2026
40 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-13241-dispatcher-usermessage-channel branch August 31, 2026 07:55
os-steve added a commit that referenced this pull request Aug 31, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

3 participants

@os-steve@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(runtime): carry the producer's `userMessage` to the wire at the dispatcher's throw-transparent exit by os-steve · Pull Request #13619 · objectstack-ai/objectstack · GitHub
Skip to content

fix(runtime): carry the producer's userMessage to the wire at the dispatcher's throw-transparent exit - #13619

Merged
huangyiirene merged 4 commits into
mainfrom
claude/issue-13241-dispatcher-usermessage-channel
Aug 31, 2026
Merged

fix(runtime): carry the producer's userMessage to the wire at the dispatcher's throw-transparent exit#13619
huangyiirene merged 4 commits into
mainfrom
claude/issue-13241-dispatcher-usermessage-channel

Conversation

@os-steve

@os-steveos-steve commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13241

errorResponseBase (packages/runtime/src/dispatcher-plugin.ts) resolved every throw through resolveThrownHttpError — whose result already carries userMessage — and then did not read the field. It was the only ADR-0112 error boundary that dropped the author-facing text channel.

Verified on this branch's merge base, all four of triage's readings re-measured rather than taken on trust, with one correction noted below.

⚠️ Parked by design — Clause-②: yes

The PM claim declares clause ② on this card, deliberately and conservatively: two careful seats disagree on whether populating an already-declared field constitutes widening, and a below-tier seat picking the reading that lets it proceed is an escape, not a judgement. This PR is complete-but-parked and an at-tier reviewer clears it.

⛔ This seat has not self-cleared any contract-review gate, has not armed auto-merge, and has not flipped the PR ready. ⛔ It also did not pick the narrower of the two implementation options to look safer under that gate — see the census below, which chose the route on architectural grounds before the gate was ever considered.

⛔ No inference is drawn here from PR #13240 having merged. A merge says that PR cleared; it says nothing about this door's clause-② question, or how it would be answered.

The binding measurement — the buildApiError caller census

Triage's instruction was to census the caller set first, then choose between (1) plumbing at this exit only and (2) making the shared buildApiError carry it so no door can forget again. Six call sites, all in packages/runtime:

#sitehas a thrown error in hand?producer userMessage available?
1error-envelope.ts:132apiErrorResponseno — pass-through wrappern/a, forwards its caller's input
2dispatcher-plugin.ts:468 — inline ROUTE_NOT_FOUND 404no — constructednone
3dispatcher-plugin.ts:662errorResponseBaseyesyes, and it was dropped
4domains/mcp.ts:176 — 405 with Allowno — constructednone
5domains/meta.ts:666 — 405 with Allowno — constructednone
6security/inbound-rate-limit.ts:356 — 429no — constructednone

Exactly one of six has a throw. The other five author their own refusal text; there is no producer mark for them to carry.

Route (2), as stated, is not implementable at buildApiError. That function takes an already-sanitised ApiErrorInput and never sees the throw. Adding userMessage?: string to ApiErrorInput would create a typed slot every caller must still populate explicitly — the "cannot forget" property is not obtained, the forgetting merely moves from the body of errorResponseBase to its argument list — while widening a shared input type consumed by endpoint-executor.ts, api-mapping.ts, endpoint-policy.ts, api-endpoint-step.ts and http-dispatcher.ts for zero behavioural gain at any of them.

The "one rule, every door inherits" property route (2) was reaching for already exists — one layer up.declaredUserMessage (@objectstack/types) is the single read every boundary applies; its own docblock says so, naming the REST door, the dispatcher door and the sandbox side-channel. resolveThrownHttpError already resolves the field onto ThrownHttpError.userMessage. This exit already called that resolver and simply did not read the answer. So the defect is one door not reading a shared rule, not an absent shared rule — and route (1) is the repair that matches the diagnosis.

The pre-registered conditional escalation does NOT fire. Route (2) could not make a door that today deliberately withholds userMessage start emitting it, because none of the other five call sites has a userMessage in scope to withhold. There is no such door. Measured, not assumed.

What the change is

Both declared siblings now share oneextra object, mirroring errorFromThrown's expression:

constextra={
...(declaredCode!==undefined ? { declaredCode } : {}),
...(thrown.userMessage!==undefined ? {userMessage: thrown.userMessage} : {}),};

⚠️ Two conditional { extra: … } spreads on one object literal do not merge — the later replaces the earlier — so a throw carrying both a demoted declaredCode and a mark would have silently shipped only one. That is pinned (§4) and ablated below.

Wider than the card's framing:userMessage is status-agnostic by ruling (#9934), so the gap was never confined to the declared-5xx band that motivated it. A marked 4xx refusal reaching this exit lost the field too, with no withhold anywhere in the picture. The plumbing is deliberately not gated on the withhold limb, and §2 drives 400/403/409/503 so a later edit cannot quietly re-gate it.

The open question triage asked to be answered on the way past

Does any producer on the throw-transparent routes set author-facing text today? Measured answer: ZERO. The only two in-repo userMessage producers are:

  • the sandbox (packages/runtime/src/sandbox/quickjs-runner.ts), reached via /actions — but domains/actions.tscatches and answers through errorFromThrown, which already carries the field;
  • metadata-protocol's markedApplicationRefusalError, reached via the REST /meta and /data doors.

Neither reaches this exit. A package-level sweep finds userMessage in only six packages (runtime, rest, metadata-protocol, spec, types, client); no plugin-* or service-* package sets it. ⇒ This confirms the p2 and confirms the change is a no-op on today's tree. It does not close the card's underlying concern, because the contract stays unenforced at one door either way — which is exactly why every test drives the real mounted route rather than asserting a predicate.

⚠️ Two findings this work turned up

1. A measured carve-out, recorded rather than closed.HttpDispatcher.dispatch's foot catch is not a pure rethrow: it intercepts isPermissionDeniedError (name === 'PermissionDeniedError', orcode === 'PERMISSION_DENIED', or a message starting [Security] Access denied) and answers it itself from packages/runtime/src/http-dispatcher.ts. Such a throw never reaches errorResponseBase, so its mark is still dropped, by a different door. That file is a trigger file of on-hold decision #7898 and is out of this change's file surface, so this is pinned as an observation, not repaired here. My own test initially assumed a PERMISSION_DENIED 403 was throw-transparent; the instrument caught it, and the corrected row now pins the carve-out so a future reader learns if it moves.

2. A build-graph mechanism, contributed to the existing card #13513 rather than filed anew.pnpm --filter '@objectstack/runtime^...' build — the closure-build recipe AGENTS.md prescribes — cannot be used for runtime: a devDependency cycle (runtimedriver-turso →(devDep) verifyruntime) pulls verifyand runtime itself into runtime's own dependency closure, so there is no topological order and verify's DTS build races runtime's, dying on Cannot find module '@objectstack/runtime'. Pre-existing and unrelated to this diff; worked around here with --filter '!@objectstack/verify', which is a local unblock and ⛔ not a proposed repair. #13513 already tracks the symptom across two earlier observations and states that what it needs is one confirming build owning the cause; that measurement is now a comment there. ⛔ A duplicate was deliberately not opened.

Patch round — check:error-code-casing was RED on the first push, now green

The gate flagged the two code: 'acme_quota_exceeded' literals in §4 as lowercase error codes in a code position (ADR-0112 D1). They are a deliberately unregistered producer spelling, and they have to be: demotedDeclaredCode yields a declaredCode only when the throw spelled something the closed vocabulary rejected, so a registered SCREAMING code there would produce no declaredCode at all and §4 would stop being the both-fields case and assert nothing.

Declared with adr0112-ok: plus a reason, on the line directly above each hit — the only two placements the gate accepts (it matches the marker on the hit line or the one above, and requires a non-empty reason; both pinned in its --self-test).

  • ⛔ Not KNOWN_LOWERCASE_CODES: the gate refuses new lines there in terms, and the list is shrink-only and currently empty by design.
  • ⛔ Not EXEMPT_FILES: that is for literals which are not error codes (D6/D6b/D6c). These genuinely are in a code position, so exempting the file would be a false declaration and would blind the gate to every future literal in it.
  • ⛔ Not the available restructure either — hoisting the spelling into a constant would satisfy the gate, and the gate's own note says so ("a vendor code passing through is a RUNTIME value … it has no literal for any pattern here to capture"). I did not take it: that makes the deliberate non-conformance invisible by construction, whereas the gate deliberately counts an applied suppression into its verdict line. Visibility is the point, so the declared form is the right instrument and the assertion is unchanged — ⛔ nothing was softened to get green.

The gate's own verdict line, after the fix:

✓ check-error-code-casing self-test: 46 recognizer case(s) + 5 registry case(s) pass.
✓ no unlisted lowercase error codes in 5083 scanned file(s) (ADR-0112).
· 15 literal(s) suppressed by an adr0112-ok: reason

Suppression count moved 13 → 15, which is the positive control that my two were applied rather than silently ignored.

⚠️Why the local union missed it.check:error-code-casing is not in the path-derived union — dispatch-gates.mjs classifies it as content-judged and says so itself: "adds or edits a file carrying an ADR-0112 error or notice CODE (judged from CONTENT — no path derivation can name this gate)". So this was not a skipped gate; it was one no path derivation can name, which is exactly the residue CI exists to cover.

Verification

All readings below were re-taken at 5d93e2427 after the patch round described just above — that is this branch's head, so the gate union, the lint run and the test runs are all on the same tree as the final commit. (Authored in session https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk, recorded here in prose because a body edit rewrites the footer below to its bare form.)

Tests. 21 new cases in packages/runtime/src/dispatcher-plugin.declared-user-message.test.ts, driving the real mounted POST /api/v1/analytics/query route.

Positive controls, not merely green. Absence is asserted twice everywhere it is asserted at all — on Object.keys(error) / hasOwnPropertyand on the serialized bytes — because JSON.stringify silently drops a key whose value is undefined, so a byte assertion alone cannot distinguish "omitted" from "emitted as undefined". §3 additionally drives the guard itself against a present-but-undefined envelope, demonstrating the byte limb blind on that shape while the key limb still catches it.

Ablations — direction predicted before running, both matched exactly.

ablationpredictionobserved
A — delete the userMessage limb from the merged extrapartial red with a specific shape: the presence-asserting rows go red, the absence-asserting rows (§3 omissions, §1's unmarked row, the §2 carve-out) stay green, siblings untouched9 failed / 67 passed; the 9 are exactly the predicted presence rows; both sibling suites green
B — restore the two-spread extra (the pre-merge bug shape)exactly one red — §4's both-fields row — everything else green1 failed / 75 passed; the failure is §4's both-fields row

Both legs were mutated with an anchored replacement asserting a hit count of exactly 1 (a zero-hit edit exits 0 and would have read as a successful ablation), confirmed on disk by blob-hash inequality against the HEAD blob, and restored via git checkout HEAD -- ABSOLUTE_PATH with the restore proven by hash equality plus an empty git diff HEAD — not by an exit code. No rebuild was needed or claimed: the suite imports ./dispatcher-plugin.js, which vitest resolves to same-package source, and every @objectstack/* it touches is source-aliased in packages/runtime/vitest.config.ts.

Gates. Union derived at 5d93e2427 with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed; stderr read and confirms the change set and the repo assertion). Harvested with --commands to avoid the 15-of-30 single-spelling trap. Exit codes captured before any pipe.

  • 31 of 34 pass. Including the most targeted: check:route-envelope, check:test-source-alias, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:type-check-coverage, check:comment-mask-adoption, check:undeclared-dep-imports, and the changeset family.
  • pnpm lint (whole repo, eslint . --no-inline-config): exit 0, run in full — no narrowing claimed and none needed.
  • node scripts/check-nul-bytes.mjs: OK, 7541 files, no raw control bytes.

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 printed OK — 66/78 workspace packages type-checked, but 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 measured without the closure "would silently measure a DIFFERENT WORLD".

One NOT MEASURED I am flagging on my own diff, not on a gate.@objectstack/runtime's tsconfig excludes **/*.test.ts, so its green typecheck says nothing about the new test file. Measured rather than assumed with tsc --noEmit --listFiles: src/dispatcher-plugin.ts → 1 hit (the implementation is covered), the new test file → 0 hits. check:type-check-coverage passes, so the existing TEST_DEBT entry already accounts for that layer.

Triage's four readings, re-measured

Three hold exactly. One has drifted and is corrected here:

claimreading at 5d93e2427
userMessage absent from dispatcher-plugin.ts⚠️1 hit, not 0 — but it is a comment line added by #13240 ("The author-facing text channel is userMessage"), not code. The substantive claim holds: zero code references. Triage measured 0 at a81aa9dd, before that comment landed.
positive control — buildApiError in the same file5 hits ⇒ the absence was real, not a broken read
the caught-path sibling carries ithttp-dispatcher.ts:850 — confirmed verbatim
the envelope slot is declaredcontract.zod.ts:87 — confirmed

Card body sanitizer self-check

Clean. The body is structurally complete — ## The gap## Why it matters more after #12281## Not established here## Region## Refs, closing on its three-ref list, with no tag-shaped fragment anywhere in it and nothing cut mid-sentence.

os-steveand others added 2 commits August 31, 2026 02:56
…throw-transparent exit
`errorResponseBase` resolved every throw through `resolveThrownHttpError` —
whose result already carries `userMessage` — and never read the field, making
it the only ADR-0112 boundary that dropped the author-facing text channel.
Both declared siblings now share ONE `extra` object: two conditional
`{ extra: … }` spreads do not merge, so a throw carrying both a demoted
`declaredCode` and a mark would have shipped only one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 eaba72e48cc294038e74ece5bf7677568d55b038packageMentionDocs.

Which tree this was computed on

This run read content/docs from 707d35adf48162df29799f09f0c6c6fa4d0044ca — the merge of head 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4 into base eaba72e48cc294038e74ece5bf7677568d55b038, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 707d35adf48162df29799f09f0c6c6fa4d0044ca && git checkout 707d35adf48162df29799f09f0c6c6fa4d0044ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eaba72e48cc294038e74ece5bf7677568d55b038 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4 && git checkout -B drift-repro eaba72e48cc294038e74ece5bf7677568d55b038 && git merge --no-ff 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4
node scripts/docs-audit/affected-docs.mjs --json eaba72e48cc294038e74ece5bf7677568d55b038

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

claudeand others added 2 commits August 31, 2026 04:19
…:error-code-casing
The two `code: 'acme_quota_exceeded'` literals are the fixture for the #9106
demotion path: `demotedDeclaredCode` yields a `declaredCode` only for a spelling
the closed vocabulary rejected, so a registered SCREAMING code would make §4
stop being the both-fields case and assert nothing.
Declared with `adr0112-ok:` and a reason rather than hidden behind a constant —
the gate counts an applied suppression into its verdict, so the deliberate
non-conformance stays visible instead of becoming invisible by construction.
⛔ Not KNOWN_LOWERCASE_CODES (closed to new lines) and ⛔ not EXEMPT_FILES
(these ARE in a code position, so exempting the file would be a false
declaration and would blind the gate to every future literal in it).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@huangyiirene
huangyiirene marked this pull request as ready for review August 31, 2026 07:30
@huangyiirene
huangyiirene added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit a21d2a9Aug 31, 2026
40 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-13241-dispatcher-usermessage-channel branch August 31, 2026 07:55
os-steve added a commit that referenced this pull request Aug 31, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

3 participants

@os-steve@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(runtime): carry the producer's `userMessage` to the wire at the dispatcher's throw-transparent exit by os-steve · Pull Request #13619 · objectstack-ai/objectstack · GitHub
Skip to content

fix(runtime): carry the producer's userMessage to the wire at the dispatcher's throw-transparent exit - #13619

Merged
huangyiirene merged 4 commits into
mainfrom
claude/issue-13241-dispatcher-usermessage-channel
Aug 31, 2026
Merged

fix(runtime): carry the producer's userMessage to the wire at the dispatcher's throw-transparent exit#13619
huangyiirene merged 4 commits into
mainfrom
claude/issue-13241-dispatcher-usermessage-channel

Conversation

@os-steve

@os-steveos-steve commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13241

errorResponseBase (packages/runtime/src/dispatcher-plugin.ts) resolved every throw through resolveThrownHttpError — whose result already carries userMessage — and then did not read the field. It was the only ADR-0112 error boundary that dropped the author-facing text channel.

Verified on this branch's merge base, all four of triage's readings re-measured rather than taken on trust, with one correction noted below.

⚠️ Parked by design — Clause-②: yes

The PM claim declares clause ② on this card, deliberately and conservatively: two careful seats disagree on whether populating an already-declared field constitutes widening, and a below-tier seat picking the reading that lets it proceed is an escape, not a judgement. This PR is complete-but-parked and an at-tier reviewer clears it.

⛔ This seat has not self-cleared any contract-review gate, has not armed auto-merge, and has not flipped the PR ready. ⛔ It also did not pick the narrower of the two implementation options to look safer under that gate — see the census below, which chose the route on architectural grounds before the gate was ever considered.

⛔ No inference is drawn here from PR #13240 having merged. A merge says that PR cleared; it says nothing about this door's clause-② question, or how it would be answered.

The binding measurement — the buildApiError caller census

Triage's instruction was to census the caller set first, then choose between (1) plumbing at this exit only and (2) making the shared buildApiError carry it so no door can forget again. Six call sites, all in packages/runtime:

#sitehas a thrown error in hand?producer userMessage available?
1error-envelope.ts:132apiErrorResponseno — pass-through wrappern/a, forwards its caller's input
2dispatcher-plugin.ts:468 — inline ROUTE_NOT_FOUND 404no — constructednone
3dispatcher-plugin.ts:662errorResponseBaseyesyes, and it was dropped
4domains/mcp.ts:176 — 405 with Allowno — constructednone
5domains/meta.ts:666 — 405 with Allowno — constructednone
6security/inbound-rate-limit.ts:356 — 429no — constructednone

Exactly one of six has a throw. The other five author their own refusal text; there is no producer mark for them to carry.

Route (2), as stated, is not implementable at buildApiError. That function takes an already-sanitised ApiErrorInput and never sees the throw. Adding userMessage?: string to ApiErrorInput would create a typed slot every caller must still populate explicitly — the "cannot forget" property is not obtained, the forgetting merely moves from the body of errorResponseBase to its argument list — while widening a shared input type consumed by endpoint-executor.ts, api-mapping.ts, endpoint-policy.ts, api-endpoint-step.ts and http-dispatcher.ts for zero behavioural gain at any of them.

The "one rule, every door inherits" property route (2) was reaching for already exists — one layer up.declaredUserMessage (@objectstack/types) is the single read every boundary applies; its own docblock says so, naming the REST door, the dispatcher door and the sandbox side-channel. resolveThrownHttpError already resolves the field onto ThrownHttpError.userMessage. This exit already called that resolver and simply did not read the answer. So the defect is one door not reading a shared rule, not an absent shared rule — and route (1) is the repair that matches the diagnosis.

The pre-registered conditional escalation does NOT fire. Route (2) could not make a door that today deliberately withholds userMessage start emitting it, because none of the other five call sites has a userMessage in scope to withhold. There is no such door. Measured, not assumed.

What the change is

Both declared siblings now share oneextra object, mirroring errorFromThrown's expression:

constextra={
...(declaredCode!==undefined ? { declaredCode } : {}),
...(thrown.userMessage!==undefined ? {userMessage: thrown.userMessage} : {}),};

⚠️ Two conditional { extra: … } spreads on one object literal do not merge — the later replaces the earlier — so a throw carrying both a demoted declaredCode and a mark would have silently shipped only one. That is pinned (§4) and ablated below.

Wider than the card's framing:userMessage is status-agnostic by ruling (#9934), so the gap was never confined to the declared-5xx band that motivated it. A marked 4xx refusal reaching this exit lost the field too, with no withhold anywhere in the picture. The plumbing is deliberately not gated on the withhold limb, and §2 drives 400/403/409/503 so a later edit cannot quietly re-gate it.

The open question triage asked to be answered on the way past

Does any producer on the throw-transparent routes set author-facing text today? Measured answer: ZERO. The only two in-repo userMessage producers are:

  • the sandbox (packages/runtime/src/sandbox/quickjs-runner.ts), reached via /actions — but domains/actions.tscatches and answers through errorFromThrown, which already carries the field;
  • metadata-protocol's markedApplicationRefusalError, reached via the REST /meta and /data doors.

Neither reaches this exit. A package-level sweep finds userMessage in only six packages (runtime, rest, metadata-protocol, spec, types, client); no plugin-* or service-* package sets it. ⇒ This confirms the p2 and confirms the change is a no-op on today's tree. It does not close the card's underlying concern, because the contract stays unenforced at one door either way — which is exactly why every test drives the real mounted route rather than asserting a predicate.

⚠️ Two findings this work turned up

1. A measured carve-out, recorded rather than closed.HttpDispatcher.dispatch's foot catch is not a pure rethrow: it intercepts isPermissionDeniedError (name === 'PermissionDeniedError', orcode === 'PERMISSION_DENIED', or a message starting [Security] Access denied) and answers it itself from packages/runtime/src/http-dispatcher.ts. Such a throw never reaches errorResponseBase, so its mark is still dropped, by a different door. That file is a trigger file of on-hold decision #7898 and is out of this change's file surface, so this is pinned as an observation, not repaired here. My own test initially assumed a PERMISSION_DENIED 403 was throw-transparent; the instrument caught it, and the corrected row now pins the carve-out so a future reader learns if it moves.

2. A build-graph mechanism, contributed to the existing card #13513 rather than filed anew.pnpm --filter '@objectstack/runtime^...' build — the closure-build recipe AGENTS.md prescribes — cannot be used for runtime: a devDependency cycle (runtimedriver-turso →(devDep) verifyruntime) pulls verifyand runtime itself into runtime's own dependency closure, so there is no topological order and verify's DTS build races runtime's, dying on Cannot find module '@objectstack/runtime'. Pre-existing and unrelated to this diff; worked around here with --filter '!@objectstack/verify', which is a local unblock and ⛔ not a proposed repair. #13513 already tracks the symptom across two earlier observations and states that what it needs is one confirming build owning the cause; that measurement is now a comment there. ⛔ A duplicate was deliberately not opened.

Patch round — check:error-code-casing was RED on the first push, now green

The gate flagged the two code: 'acme_quota_exceeded' literals in §4 as lowercase error codes in a code position (ADR-0112 D1). They are a deliberately unregistered producer spelling, and they have to be: demotedDeclaredCode yields a declaredCode only when the throw spelled something the closed vocabulary rejected, so a registered SCREAMING code there would produce no declaredCode at all and §4 would stop being the both-fields case and assert nothing.

Declared with adr0112-ok: plus a reason, on the line directly above each hit — the only two placements the gate accepts (it matches the marker on the hit line or the one above, and requires a non-empty reason; both pinned in its --self-test).

  • ⛔ Not KNOWN_LOWERCASE_CODES: the gate refuses new lines there in terms, and the list is shrink-only and currently empty by design.
  • ⛔ Not EXEMPT_FILES: that is for literals which are not error codes (D6/D6b/D6c). These genuinely are in a code position, so exempting the file would be a false declaration and would blind the gate to every future literal in it.
  • ⛔ Not the available restructure either — hoisting the spelling into a constant would satisfy the gate, and the gate's own note says so ("a vendor code passing through is a RUNTIME value … it has no literal for any pattern here to capture"). I did not take it: that makes the deliberate non-conformance invisible by construction, whereas the gate deliberately counts an applied suppression into its verdict line. Visibility is the point, so the declared form is the right instrument and the assertion is unchanged — ⛔ nothing was softened to get green.

The gate's own verdict line, after the fix:

✓ check-error-code-casing self-test: 46 recognizer case(s) + 5 registry case(s) pass.
✓ no unlisted lowercase error codes in 5083 scanned file(s) (ADR-0112).
· 15 literal(s) suppressed by an adr0112-ok: reason

Suppression count moved 13 → 15, which is the positive control that my two were applied rather than silently ignored.

⚠️Why the local union missed it.check:error-code-casing is not in the path-derived union — dispatch-gates.mjs classifies it as content-judged and says so itself: "adds or edits a file carrying an ADR-0112 error or notice CODE (judged from CONTENT — no path derivation can name this gate)". So this was not a skipped gate; it was one no path derivation can name, which is exactly the residue CI exists to cover.

Verification

All readings below were re-taken at 5d93e2427 after the patch round described just above — that is this branch's head, so the gate union, the lint run and the test runs are all on the same tree as the final commit. (Authored in session https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk, recorded here in prose because a body edit rewrites the footer below to its bare form.)

Tests. 21 new cases in packages/runtime/src/dispatcher-plugin.declared-user-message.test.ts, driving the real mounted POST /api/v1/analytics/query route.

Positive controls, not merely green. Absence is asserted twice everywhere it is asserted at all — on Object.keys(error) / hasOwnPropertyand on the serialized bytes — because JSON.stringify silently drops a key whose value is undefined, so a byte assertion alone cannot distinguish "omitted" from "emitted as undefined". §3 additionally drives the guard itself against a present-but-undefined envelope, demonstrating the byte limb blind on that shape while the key limb still catches it.

Ablations — direction predicted before running, both matched exactly.

ablationpredictionobserved
A — delete the userMessage limb from the merged extrapartial red with a specific shape: the presence-asserting rows go red, the absence-asserting rows (§3 omissions, §1's unmarked row, the §2 carve-out) stay green, siblings untouched9 failed / 67 passed; the 9 are exactly the predicted presence rows; both sibling suites green
B — restore the two-spread extra (the pre-merge bug shape)exactly one red — §4's both-fields row — everything else green1 failed / 75 passed; the failure is §4's both-fields row

Both legs were mutated with an anchored replacement asserting a hit count of exactly 1 (a zero-hit edit exits 0 and would have read as a successful ablation), confirmed on disk by blob-hash inequality against the HEAD blob, and restored via git checkout HEAD -- ABSOLUTE_PATH with the restore proven by hash equality plus an empty git diff HEAD — not by an exit code. No rebuild was needed or claimed: the suite imports ./dispatcher-plugin.js, which vitest resolves to same-package source, and every @objectstack/* it touches is source-aliased in packages/runtime/vitest.config.ts.

Gates. Union derived at 5d93e2427 with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed; stderr read and confirms the change set and the repo assertion). Harvested with --commands to avoid the 15-of-30 single-spelling trap. Exit codes captured before any pipe.

  • 31 of 34 pass. Including the most targeted: check:route-envelope, check:test-source-alias, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:type-check-coverage, check:comment-mask-adoption, check:undeclared-dep-imports, and the changeset family.
  • pnpm lint (whole repo, eslint . --no-inline-config): exit 0, run in full — no narrowing claimed and none needed.
  • node scripts/check-nul-bytes.mjs: OK, 7541 files, no raw control bytes.

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 printed OK — 66/78 workspace packages type-checked, but 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 measured without the closure "would silently measure a DIFFERENT WORLD".

One NOT MEASURED I am flagging on my own diff, not on a gate.@objectstack/runtime's tsconfig excludes **/*.test.ts, so its green typecheck says nothing about the new test file. Measured rather than assumed with tsc --noEmit --listFiles: src/dispatcher-plugin.ts → 1 hit (the implementation is covered), the new test file → 0 hits. check:type-check-coverage passes, so the existing TEST_DEBT entry already accounts for that layer.

Triage's four readings, re-measured

Three hold exactly. One has drifted and is corrected here:

claimreading at 5d93e2427
userMessage absent from dispatcher-plugin.ts⚠️1 hit, not 0 — but it is a comment line added by #13240 ("The author-facing text channel is userMessage"), not code. The substantive claim holds: zero code references. Triage measured 0 at a81aa9dd, before that comment landed.
positive control — buildApiError in the same file5 hits ⇒ the absence was real, not a broken read
the caught-path sibling carries ithttp-dispatcher.ts:850 — confirmed verbatim
the envelope slot is declaredcontract.zod.ts:87 — confirmed

Card body sanitizer self-check

Clean. The body is structurally complete — ## The gap## Why it matters more after #12281## Not established here## Region## Refs, closing on its three-ref list, with no tag-shaped fragment anywhere in it and nothing cut mid-sentence.

os-steveand others added 2 commits August 31, 2026 02:56
…throw-transparent exit
`errorResponseBase` resolved every throw through `resolveThrownHttpError` —
whose result already carries `userMessage` — and never read the field, making
it the only ADR-0112 boundary that dropped the author-facing text channel.
Both declared siblings now share ONE `extra` object: two conditional
`{ extra: … }` spreads do not merge, so a throw carrying both a demoted
`declaredCode` and a mark would have shipped only one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 eaba72e48cc294038e74ece5bf7677568d55b038packageMentionDocs.

Which tree this was computed on

This run read content/docs from 707d35adf48162df29799f09f0c6c6fa4d0044ca — the merge of head 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4 into base eaba72e48cc294038e74ece5bf7677568d55b038, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 707d35adf48162df29799f09f0c6c6fa4d0044ca && git checkout 707d35adf48162df29799f09f0c6c6fa4d0044ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eaba72e48cc294038e74ece5bf7677568d55b038 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4 && git checkout -B drift-repro eaba72e48cc294038e74ece5bf7677568d55b038 && git merge --no-ff 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4
node scripts/docs-audit/affected-docs.mjs --json eaba72e48cc294038e74ece5bf7677568d55b038

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

claudeand others added 2 commits August 31, 2026 04:19
…:error-code-casing
The two `code: 'acme_quota_exceeded'` literals are the fixture for the #9106
demotion path: `demotedDeclaredCode` yields a `declaredCode` only for a spelling
the closed vocabulary rejected, so a registered SCREAMING code would make §4
stop being the both-fields case and assert nothing.
Declared with `adr0112-ok:` and a reason rather than hidden behind a constant —
the gate counts an applied suppression into its verdict, so the deliberate
non-conformance stays visible instead of becoming invisible by construction.
⛔ Not KNOWN_LOWERCASE_CODES (closed to new lines) and ⛔ not EXEMPT_FILES
(these ARE in a code position, so exempting the file would be a false
declaration and would blind the gate to every future literal in it).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@huangyiirene
huangyiirene marked this pull request as ready for review August 31, 2026 07:30
@huangyiirene
huangyiirene added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit a21d2a9Aug 31, 2026
40 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-13241-dispatcher-usermessage-channel branch August 31, 2026 07:55
os-steve added a commit that referenced this pull request Aug 31, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

3 participants

@os-steve@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(runtime): carry the producer's `userMessage` to the wire at the dispatcher's throw-transparent exit by os-steve · Pull Request #13619 · objectstack-ai/objectstack · GitHub
Skip to content

fix(runtime): carry the producer's userMessage to the wire at the dispatcher's throw-transparent exit - #13619

Merged
huangyiirene merged 4 commits into
mainfrom
claude/issue-13241-dispatcher-usermessage-channel
Aug 31, 2026
Merged

fix(runtime): carry the producer's userMessage to the wire at the dispatcher's throw-transparent exit#13619
huangyiirene merged 4 commits into
mainfrom
claude/issue-13241-dispatcher-usermessage-channel

Conversation

@os-steve

@os-steveos-steve commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13241

errorResponseBase (packages/runtime/src/dispatcher-plugin.ts) resolved every throw through resolveThrownHttpError — whose result already carries userMessage — and then did not read the field. It was the only ADR-0112 error boundary that dropped the author-facing text channel.

Verified on this branch's merge base, all four of triage's readings re-measured rather than taken on trust, with one correction noted below.

⚠️ Parked by design — Clause-②: yes

The PM claim declares clause ② on this card, deliberately and conservatively: two careful seats disagree on whether populating an already-declared field constitutes widening, and a below-tier seat picking the reading that lets it proceed is an escape, not a judgement. This PR is complete-but-parked and an at-tier reviewer clears it.

⛔ This seat has not self-cleared any contract-review gate, has not armed auto-merge, and has not flipped the PR ready. ⛔ It also did not pick the narrower of the two implementation options to look safer under that gate — see the census below, which chose the route on architectural grounds before the gate was ever considered.

⛔ No inference is drawn here from PR #13240 having merged. A merge says that PR cleared; it says nothing about this door's clause-② question, or how it would be answered.

The binding measurement — the buildApiError caller census

Triage's instruction was to census the caller set first, then choose between (1) plumbing at this exit only and (2) making the shared buildApiError carry it so no door can forget again. Six call sites, all in packages/runtime:

#sitehas a thrown error in hand?producer userMessage available?
1error-envelope.ts:132apiErrorResponseno — pass-through wrappern/a, forwards its caller's input
2dispatcher-plugin.ts:468 — inline ROUTE_NOT_FOUND 404no — constructednone
3dispatcher-plugin.ts:662errorResponseBaseyesyes, and it was dropped
4domains/mcp.ts:176 — 405 with Allowno — constructednone
5domains/meta.ts:666 — 405 with Allowno — constructednone
6security/inbound-rate-limit.ts:356 — 429no — constructednone

Exactly one of six has a throw. The other five author their own refusal text; there is no producer mark for them to carry.

Route (2), as stated, is not implementable at buildApiError. That function takes an already-sanitised ApiErrorInput and never sees the throw. Adding userMessage?: string to ApiErrorInput would create a typed slot every caller must still populate explicitly — the "cannot forget" property is not obtained, the forgetting merely moves from the body of errorResponseBase to its argument list — while widening a shared input type consumed by endpoint-executor.ts, api-mapping.ts, endpoint-policy.ts, api-endpoint-step.ts and http-dispatcher.ts for zero behavioural gain at any of them.

The "one rule, every door inherits" property route (2) was reaching for already exists — one layer up.declaredUserMessage (@objectstack/types) is the single read every boundary applies; its own docblock says so, naming the REST door, the dispatcher door and the sandbox side-channel. resolveThrownHttpError already resolves the field onto ThrownHttpError.userMessage. This exit already called that resolver and simply did not read the answer. So the defect is one door not reading a shared rule, not an absent shared rule — and route (1) is the repair that matches the diagnosis.

The pre-registered conditional escalation does NOT fire. Route (2) could not make a door that today deliberately withholds userMessage start emitting it, because none of the other five call sites has a userMessage in scope to withhold. There is no such door. Measured, not assumed.

What the change is

Both declared siblings now share oneextra object, mirroring errorFromThrown's expression:

constextra={
...(declaredCode!==undefined ? { declaredCode } : {}),
...(thrown.userMessage!==undefined ? {userMessage: thrown.userMessage} : {}),};

⚠️ Two conditional { extra: … } spreads on one object literal do not merge — the later replaces the earlier — so a throw carrying both a demoted declaredCode and a mark would have silently shipped only one. That is pinned (§4) and ablated below.

Wider than the card's framing:userMessage is status-agnostic by ruling (#9934), so the gap was never confined to the declared-5xx band that motivated it. A marked 4xx refusal reaching this exit lost the field too, with no withhold anywhere in the picture. The plumbing is deliberately not gated on the withhold limb, and §2 drives 400/403/409/503 so a later edit cannot quietly re-gate it.

The open question triage asked to be answered on the way past

Does any producer on the throw-transparent routes set author-facing text today? Measured answer: ZERO. The only two in-repo userMessage producers are:

  • the sandbox (packages/runtime/src/sandbox/quickjs-runner.ts), reached via /actions — but domains/actions.tscatches and answers through errorFromThrown, which already carries the field;
  • metadata-protocol's markedApplicationRefusalError, reached via the REST /meta and /data doors.

Neither reaches this exit. A package-level sweep finds userMessage in only six packages (runtime, rest, metadata-protocol, spec, types, client); no plugin-* or service-* package sets it. ⇒ This confirms the p2 and confirms the change is a no-op on today's tree. It does not close the card's underlying concern, because the contract stays unenforced at one door either way — which is exactly why every test drives the real mounted route rather than asserting a predicate.

⚠️ Two findings this work turned up

1. A measured carve-out, recorded rather than closed.HttpDispatcher.dispatch's foot catch is not a pure rethrow: it intercepts isPermissionDeniedError (name === 'PermissionDeniedError', orcode === 'PERMISSION_DENIED', or a message starting [Security] Access denied) and answers it itself from packages/runtime/src/http-dispatcher.ts. Such a throw never reaches errorResponseBase, so its mark is still dropped, by a different door. That file is a trigger file of on-hold decision #7898 and is out of this change's file surface, so this is pinned as an observation, not repaired here. My own test initially assumed a PERMISSION_DENIED 403 was throw-transparent; the instrument caught it, and the corrected row now pins the carve-out so a future reader learns if it moves.

2. A build-graph mechanism, contributed to the existing card #13513 rather than filed anew.pnpm --filter '@objectstack/runtime^...' build — the closure-build recipe AGENTS.md prescribes — cannot be used for runtime: a devDependency cycle (runtimedriver-turso →(devDep) verifyruntime) pulls verifyand runtime itself into runtime's own dependency closure, so there is no topological order and verify's DTS build races runtime's, dying on Cannot find module '@objectstack/runtime'. Pre-existing and unrelated to this diff; worked around here with --filter '!@objectstack/verify', which is a local unblock and ⛔ not a proposed repair. #13513 already tracks the symptom across two earlier observations and states that what it needs is one confirming build owning the cause; that measurement is now a comment there. ⛔ A duplicate was deliberately not opened.

Patch round — check:error-code-casing was RED on the first push, now green

The gate flagged the two code: 'acme_quota_exceeded' literals in §4 as lowercase error codes in a code position (ADR-0112 D1). They are a deliberately unregistered producer spelling, and they have to be: demotedDeclaredCode yields a declaredCode only when the throw spelled something the closed vocabulary rejected, so a registered SCREAMING code there would produce no declaredCode at all and §4 would stop being the both-fields case and assert nothing.

Declared with adr0112-ok: plus a reason, on the line directly above each hit — the only two placements the gate accepts (it matches the marker on the hit line or the one above, and requires a non-empty reason; both pinned in its --self-test).

  • ⛔ Not KNOWN_LOWERCASE_CODES: the gate refuses new lines there in terms, and the list is shrink-only and currently empty by design.
  • ⛔ Not EXEMPT_FILES: that is for literals which are not error codes (D6/D6b/D6c). These genuinely are in a code position, so exempting the file would be a false declaration and would blind the gate to every future literal in it.
  • ⛔ Not the available restructure either — hoisting the spelling into a constant would satisfy the gate, and the gate's own note says so ("a vendor code passing through is a RUNTIME value … it has no literal for any pattern here to capture"). I did not take it: that makes the deliberate non-conformance invisible by construction, whereas the gate deliberately counts an applied suppression into its verdict line. Visibility is the point, so the declared form is the right instrument and the assertion is unchanged — ⛔ nothing was softened to get green.

The gate's own verdict line, after the fix:

✓ check-error-code-casing self-test: 46 recognizer case(s) + 5 registry case(s) pass.
✓ no unlisted lowercase error codes in 5083 scanned file(s) (ADR-0112).
· 15 literal(s) suppressed by an adr0112-ok: reason

Suppression count moved 13 → 15, which is the positive control that my two were applied rather than silently ignored.

⚠️Why the local union missed it.check:error-code-casing is not in the path-derived union — dispatch-gates.mjs classifies it as content-judged and says so itself: "adds or edits a file carrying an ADR-0112 error or notice CODE (judged from CONTENT — no path derivation can name this gate)". So this was not a skipped gate; it was one no path derivation can name, which is exactly the residue CI exists to cover.

Verification

All readings below were re-taken at 5d93e2427 after the patch round described just above — that is this branch's head, so the gate union, the lint run and the test runs are all on the same tree as the final commit. (Authored in session https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk, recorded here in prose because a body edit rewrites the footer below to its bare form.)

Tests. 21 new cases in packages/runtime/src/dispatcher-plugin.declared-user-message.test.ts, driving the real mounted POST /api/v1/analytics/query route.

Positive controls, not merely green. Absence is asserted twice everywhere it is asserted at all — on Object.keys(error) / hasOwnPropertyand on the serialized bytes — because JSON.stringify silently drops a key whose value is undefined, so a byte assertion alone cannot distinguish "omitted" from "emitted as undefined". §3 additionally drives the guard itself against a present-but-undefined envelope, demonstrating the byte limb blind on that shape while the key limb still catches it.

Ablations — direction predicted before running, both matched exactly.

ablationpredictionobserved
A — delete the userMessage limb from the merged extrapartial red with a specific shape: the presence-asserting rows go red, the absence-asserting rows (§3 omissions, §1's unmarked row, the §2 carve-out) stay green, siblings untouched9 failed / 67 passed; the 9 are exactly the predicted presence rows; both sibling suites green
B — restore the two-spread extra (the pre-merge bug shape)exactly one red — §4's both-fields row — everything else green1 failed / 75 passed; the failure is §4's both-fields row

Both legs were mutated with an anchored replacement asserting a hit count of exactly 1 (a zero-hit edit exits 0 and would have read as a successful ablation), confirmed on disk by blob-hash inequality against the HEAD blob, and restored via git checkout HEAD -- ABSOLUTE_PATH with the restore proven by hash equality plus an empty git diff HEAD — not by an exit code. No rebuild was needed or claimed: the suite imports ./dispatcher-plugin.js, which vitest resolves to same-package source, and every @objectstack/* it touches is source-aliased in packages/runtime/vitest.config.ts.

Gates. Union derived at 5d93e2427 with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed; stderr read and confirms the change set and the repo assertion). Harvested with --commands to avoid the 15-of-30 single-spelling trap. Exit codes captured before any pipe.

  • 31 of 34 pass. Including the most targeted: check:route-envelope, check:test-source-alias, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:type-check-coverage, check:comment-mask-adoption, check:undeclared-dep-imports, and the changeset family.
  • pnpm lint (whole repo, eslint . --no-inline-config): exit 0, run in full — no narrowing claimed and none needed.
  • node scripts/check-nul-bytes.mjs: OK, 7541 files, no raw control bytes.

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 printed OK — 66/78 workspace packages type-checked, but 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 measured without the closure "would silently measure a DIFFERENT WORLD".

One NOT MEASURED I am flagging on my own diff, not on a gate.@objectstack/runtime's tsconfig excludes **/*.test.ts, so its green typecheck says nothing about the new test file. Measured rather than assumed with tsc --noEmit --listFiles: src/dispatcher-plugin.ts → 1 hit (the implementation is covered), the new test file → 0 hits. check:type-check-coverage passes, so the existing TEST_DEBT entry already accounts for that layer.

Triage's four readings, re-measured

Three hold exactly. One has drifted and is corrected here:

claimreading at 5d93e2427
userMessage absent from dispatcher-plugin.ts⚠️1 hit, not 0 — but it is a comment line added by #13240 ("The author-facing text channel is userMessage"), not code. The substantive claim holds: zero code references. Triage measured 0 at a81aa9dd, before that comment landed.
positive control — buildApiError in the same file5 hits ⇒ the absence was real, not a broken read
the caught-path sibling carries ithttp-dispatcher.ts:850 — confirmed verbatim
the envelope slot is declaredcontract.zod.ts:87 — confirmed

Card body sanitizer self-check

Clean. The body is structurally complete — ## The gap## Why it matters more after #12281## Not established here## Region## Refs, closing on its three-ref list, with no tag-shaped fragment anywhere in it and nothing cut mid-sentence.

os-steveand others added 2 commits August 31, 2026 02:56
…throw-transparent exit
`errorResponseBase` resolved every throw through `resolveThrownHttpError` —
whose result already carries `userMessage` — and never read the field, making
it the only ADR-0112 boundary that dropped the author-facing text channel.
Both declared siblings now share ONE `extra` object: two conditional
`{ extra: … }` spreads do not merge, so a throw carrying both a demoted
`declaredCode` and a mark would have shipped only one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 eaba72e48cc294038e74ece5bf7677568d55b038packageMentionDocs.

Which tree this was computed on

This run read content/docs from 707d35adf48162df29799f09f0c6c6fa4d0044ca — the merge of head 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4 into base eaba72e48cc294038e74ece5bf7677568d55b038, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 707d35adf48162df29799f09f0c6c6fa4d0044ca && git checkout 707d35adf48162df29799f09f0c6c6fa4d0044ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eaba72e48cc294038e74ece5bf7677568d55b038 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4 && git checkout -B drift-repro eaba72e48cc294038e74ece5bf7677568d55b038 && git merge --no-ff 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4
node scripts/docs-audit/affected-docs.mjs --json eaba72e48cc294038e74ece5bf7677568d55b038

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

claudeand others added 2 commits August 31, 2026 04:19
…:error-code-casing
The two `code: 'acme_quota_exceeded'` literals are the fixture for the #9106
demotion path: `demotedDeclaredCode` yields a `declaredCode` only for a spelling
the closed vocabulary rejected, so a registered SCREAMING code would make §4
stop being the both-fields case and assert nothing.
Declared with `adr0112-ok:` and a reason rather than hidden behind a constant —
the gate counts an applied suppression into its verdict, so the deliberate
non-conformance stays visible instead of becoming invisible by construction.
⛔ Not KNOWN_LOWERCASE_CODES (closed to new lines) and ⛔ not EXEMPT_FILES
(these ARE in a code position, so exempting the file would be a false
declaration and would blind the gate to every future literal in it).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@huangyiirene
huangyiirene marked this pull request as ready for review August 31, 2026 07:30
@huangyiirene
huangyiirene added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit a21d2a9Aug 31, 2026
40 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-13241-dispatcher-usermessage-channel branch August 31, 2026 07:55
os-steve added a commit that referenced this pull request Aug 31, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

3 participants

@os-steve@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(runtime): carry the producer's `userMessage` to the wire at the dispatcher's throw-transparent exit by os-steve · Pull Request #13619 · objectstack-ai/objectstack · GitHub
Skip to content

fix(runtime): carry the producer's userMessage to the wire at the dispatcher's throw-transparent exit - #13619

Merged
huangyiirene merged 4 commits into
mainfrom
claude/issue-13241-dispatcher-usermessage-channel
Aug 31, 2026
Merged

fix(runtime): carry the producer's userMessage to the wire at the dispatcher's throw-transparent exit#13619
huangyiirene merged 4 commits into
mainfrom
claude/issue-13241-dispatcher-usermessage-channel

Conversation

@os-steve

@os-steveos-steve commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13241

errorResponseBase (packages/runtime/src/dispatcher-plugin.ts) resolved every throw through resolveThrownHttpError — whose result already carries userMessage — and then did not read the field. It was the only ADR-0112 error boundary that dropped the author-facing text channel.

Verified on this branch's merge base, all four of triage's readings re-measured rather than taken on trust, with one correction noted below.

⚠️ Parked by design — Clause-②: yes

The PM claim declares clause ② on this card, deliberately and conservatively: two careful seats disagree on whether populating an already-declared field constitutes widening, and a below-tier seat picking the reading that lets it proceed is an escape, not a judgement. This PR is complete-but-parked and an at-tier reviewer clears it.

⛔ This seat has not self-cleared any contract-review gate, has not armed auto-merge, and has not flipped the PR ready. ⛔ It also did not pick the narrower of the two implementation options to look safer under that gate — see the census below, which chose the route on architectural grounds before the gate was ever considered.

⛔ No inference is drawn here from PR #13240 having merged. A merge says that PR cleared; it says nothing about this door's clause-② question, or how it would be answered.

The binding measurement — the buildApiError caller census

Triage's instruction was to census the caller set first, then choose between (1) plumbing at this exit only and (2) making the shared buildApiError carry it so no door can forget again. Six call sites, all in packages/runtime:

#sitehas a thrown error in hand?producer userMessage available?
1error-envelope.ts:132apiErrorResponseno — pass-through wrappern/a, forwards its caller's input
2dispatcher-plugin.ts:468 — inline ROUTE_NOT_FOUND 404no — constructednone
3dispatcher-plugin.ts:662errorResponseBaseyesyes, and it was dropped
4domains/mcp.ts:176 — 405 with Allowno — constructednone
5domains/meta.ts:666 — 405 with Allowno — constructednone
6security/inbound-rate-limit.ts:356 — 429no — constructednone

Exactly one of six has a throw. The other five author their own refusal text; there is no producer mark for them to carry.

Route (2), as stated, is not implementable at buildApiError. That function takes an already-sanitised ApiErrorInput and never sees the throw. Adding userMessage?: string to ApiErrorInput would create a typed slot every caller must still populate explicitly — the "cannot forget" property is not obtained, the forgetting merely moves from the body of errorResponseBase to its argument list — while widening a shared input type consumed by endpoint-executor.ts, api-mapping.ts, endpoint-policy.ts, api-endpoint-step.ts and http-dispatcher.ts for zero behavioural gain at any of them.

The "one rule, every door inherits" property route (2) was reaching for already exists — one layer up.declaredUserMessage (@objectstack/types) is the single read every boundary applies; its own docblock says so, naming the REST door, the dispatcher door and the sandbox side-channel. resolveThrownHttpError already resolves the field onto ThrownHttpError.userMessage. This exit already called that resolver and simply did not read the answer. So the defect is one door not reading a shared rule, not an absent shared rule — and route (1) is the repair that matches the diagnosis.

The pre-registered conditional escalation does NOT fire. Route (2) could not make a door that today deliberately withholds userMessage start emitting it, because none of the other five call sites has a userMessage in scope to withhold. There is no such door. Measured, not assumed.

What the change is

Both declared siblings now share oneextra object, mirroring errorFromThrown's expression:

constextra={
...(declaredCode!==undefined ? { declaredCode } : {}),
...(thrown.userMessage!==undefined ? {userMessage: thrown.userMessage} : {}),};

⚠️ Two conditional { extra: … } spreads on one object literal do not merge — the later replaces the earlier — so a throw carrying both a demoted declaredCode and a mark would have silently shipped only one. That is pinned (§4) and ablated below.

Wider than the card's framing:userMessage is status-agnostic by ruling (#9934), so the gap was never confined to the declared-5xx band that motivated it. A marked 4xx refusal reaching this exit lost the field too, with no withhold anywhere in the picture. The plumbing is deliberately not gated on the withhold limb, and §2 drives 400/403/409/503 so a later edit cannot quietly re-gate it.

The open question triage asked to be answered on the way past

Does any producer on the throw-transparent routes set author-facing text today? Measured answer: ZERO. The only two in-repo userMessage producers are:

  • the sandbox (packages/runtime/src/sandbox/quickjs-runner.ts), reached via /actions — but domains/actions.tscatches and answers through errorFromThrown, which already carries the field;
  • metadata-protocol's markedApplicationRefusalError, reached via the REST /meta and /data doors.

Neither reaches this exit. A package-level sweep finds userMessage in only six packages (runtime, rest, metadata-protocol, spec, types, client); no plugin-* or service-* package sets it. ⇒ This confirms the p2 and confirms the change is a no-op on today's tree. It does not close the card's underlying concern, because the contract stays unenforced at one door either way — which is exactly why every test drives the real mounted route rather than asserting a predicate.

⚠️ Two findings this work turned up

1. A measured carve-out, recorded rather than closed.HttpDispatcher.dispatch's foot catch is not a pure rethrow: it intercepts isPermissionDeniedError (name === 'PermissionDeniedError', orcode === 'PERMISSION_DENIED', or a message starting [Security] Access denied) and answers it itself from packages/runtime/src/http-dispatcher.ts. Such a throw never reaches errorResponseBase, so its mark is still dropped, by a different door. That file is a trigger file of on-hold decision #7898 and is out of this change's file surface, so this is pinned as an observation, not repaired here. My own test initially assumed a PERMISSION_DENIED 403 was throw-transparent; the instrument caught it, and the corrected row now pins the carve-out so a future reader learns if it moves.

2. A build-graph mechanism, contributed to the existing card #13513 rather than filed anew.pnpm --filter '@objectstack/runtime^...' build — the closure-build recipe AGENTS.md prescribes — cannot be used for runtime: a devDependency cycle (runtimedriver-turso →(devDep) verifyruntime) pulls verifyand runtime itself into runtime's own dependency closure, so there is no topological order and verify's DTS build races runtime's, dying on Cannot find module '@objectstack/runtime'. Pre-existing and unrelated to this diff; worked around here with --filter '!@objectstack/verify', which is a local unblock and ⛔ not a proposed repair. #13513 already tracks the symptom across two earlier observations and states that what it needs is one confirming build owning the cause; that measurement is now a comment there. ⛔ A duplicate was deliberately not opened.

Patch round — check:error-code-casing was RED on the first push, now green

The gate flagged the two code: 'acme_quota_exceeded' literals in §4 as lowercase error codes in a code position (ADR-0112 D1). They are a deliberately unregistered producer spelling, and they have to be: demotedDeclaredCode yields a declaredCode only when the throw spelled something the closed vocabulary rejected, so a registered SCREAMING code there would produce no declaredCode at all and §4 would stop being the both-fields case and assert nothing.

Declared with adr0112-ok: plus a reason, on the line directly above each hit — the only two placements the gate accepts (it matches the marker on the hit line or the one above, and requires a non-empty reason; both pinned in its --self-test).

  • ⛔ Not KNOWN_LOWERCASE_CODES: the gate refuses new lines there in terms, and the list is shrink-only and currently empty by design.
  • ⛔ Not EXEMPT_FILES: that is for literals which are not error codes (D6/D6b/D6c). These genuinely are in a code position, so exempting the file would be a false declaration and would blind the gate to every future literal in it.
  • ⛔ Not the available restructure either — hoisting the spelling into a constant would satisfy the gate, and the gate's own note says so ("a vendor code passing through is a RUNTIME value … it has no literal for any pattern here to capture"). I did not take it: that makes the deliberate non-conformance invisible by construction, whereas the gate deliberately counts an applied suppression into its verdict line. Visibility is the point, so the declared form is the right instrument and the assertion is unchanged — ⛔ nothing was softened to get green.

The gate's own verdict line, after the fix:

✓ check-error-code-casing self-test: 46 recognizer case(s) + 5 registry case(s) pass.
✓ no unlisted lowercase error codes in 5083 scanned file(s) (ADR-0112).
· 15 literal(s) suppressed by an adr0112-ok: reason

Suppression count moved 13 → 15, which is the positive control that my two were applied rather than silently ignored.

⚠️Why the local union missed it.check:error-code-casing is not in the path-derived union — dispatch-gates.mjs classifies it as content-judged and says so itself: "adds or edits a file carrying an ADR-0112 error or notice CODE (judged from CONTENT — no path derivation can name this gate)". So this was not a skipped gate; it was one no path derivation can name, which is exactly the residue CI exists to cover.

Verification

All readings below were re-taken at 5d93e2427 after the patch round described just above — that is this branch's head, so the gate union, the lint run and the test runs are all on the same tree as the final commit. (Authored in session https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk, recorded here in prose because a body edit rewrites the footer below to its bare form.)

Tests. 21 new cases in packages/runtime/src/dispatcher-plugin.declared-user-message.test.ts, driving the real mounted POST /api/v1/analytics/query route.

Positive controls, not merely green. Absence is asserted twice everywhere it is asserted at all — on Object.keys(error) / hasOwnPropertyand on the serialized bytes — because JSON.stringify silently drops a key whose value is undefined, so a byte assertion alone cannot distinguish "omitted" from "emitted as undefined". §3 additionally drives the guard itself against a present-but-undefined envelope, demonstrating the byte limb blind on that shape while the key limb still catches it.

Ablations — direction predicted before running, both matched exactly.

ablationpredictionobserved
A — delete the userMessage limb from the merged extrapartial red with a specific shape: the presence-asserting rows go red, the absence-asserting rows (§3 omissions, §1's unmarked row, the §2 carve-out) stay green, siblings untouched9 failed / 67 passed; the 9 are exactly the predicted presence rows; both sibling suites green
B — restore the two-spread extra (the pre-merge bug shape)exactly one red — §4's both-fields row — everything else green1 failed / 75 passed; the failure is §4's both-fields row

Both legs were mutated with an anchored replacement asserting a hit count of exactly 1 (a zero-hit edit exits 0 and would have read as a successful ablation), confirmed on disk by blob-hash inequality against the HEAD blob, and restored via git checkout HEAD -- ABSOLUTE_PATH with the restore proven by hash equality plus an empty git diff HEAD — not by an exit code. No rebuild was needed or claimed: the suite imports ./dispatcher-plugin.js, which vitest resolves to same-package source, and every @objectstack/* it touches is source-aliased in packages/runtime/vitest.config.ts.

Gates. Union derived at 5d93e2427 with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed; stderr read and confirms the change set and the repo assertion). Harvested with --commands to avoid the 15-of-30 single-spelling trap. Exit codes captured before any pipe.

  • 31 of 34 pass. Including the most targeted: check:route-envelope, check:test-source-alias, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:type-check-coverage, check:comment-mask-adoption, check:undeclared-dep-imports, and the changeset family.
  • pnpm lint (whole repo, eslint . --no-inline-config): exit 0, run in full — no narrowing claimed and none needed.
  • node scripts/check-nul-bytes.mjs: OK, 7541 files, no raw control bytes.

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 printed OK — 66/78 workspace packages type-checked, but 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 measured without the closure "would silently measure a DIFFERENT WORLD".

One NOT MEASURED I am flagging on my own diff, not on a gate.@objectstack/runtime's tsconfig excludes **/*.test.ts, so its green typecheck says nothing about the new test file. Measured rather than assumed with tsc --noEmit --listFiles: src/dispatcher-plugin.ts → 1 hit (the implementation is covered), the new test file → 0 hits. check:type-check-coverage passes, so the existing TEST_DEBT entry already accounts for that layer.

Triage's four readings, re-measured

Three hold exactly. One has drifted and is corrected here:

claimreading at 5d93e2427
userMessage absent from dispatcher-plugin.ts⚠️1 hit, not 0 — but it is a comment line added by #13240 ("The author-facing text channel is userMessage"), not code. The substantive claim holds: zero code references. Triage measured 0 at a81aa9dd, before that comment landed.
positive control — buildApiError in the same file5 hits ⇒ the absence was real, not a broken read
the caught-path sibling carries ithttp-dispatcher.ts:850 — confirmed verbatim
the envelope slot is declaredcontract.zod.ts:87 — confirmed

Card body sanitizer self-check

Clean. The body is structurally complete — ## The gap## Why it matters more after #12281## Not established here## Region## Refs, closing on its three-ref list, with no tag-shaped fragment anywhere in it and nothing cut mid-sentence.

os-steveand others added 2 commits August 31, 2026 02:56
…throw-transparent exit
`errorResponseBase` resolved every throw through `resolveThrownHttpError` —
whose result already carries `userMessage` — and never read the field, making
it the only ADR-0112 boundary that dropped the author-facing text channel.
Both declared siblings now share ONE `extra` object: two conditional
`{ extra: … }` spreads do not merge, so a throw carrying both a demoted
`declaredCode` and a mark would have shipped only one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 eaba72e48cc294038e74ece5bf7677568d55b038packageMentionDocs.

Which tree this was computed on

This run read content/docs from 707d35adf48162df29799f09f0c6c6fa4d0044ca — the merge of head 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4 into base eaba72e48cc294038e74ece5bf7677568d55b038, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 707d35adf48162df29799f09f0c6c6fa4d0044ca && git checkout 707d35adf48162df29799f09f0c6c6fa4d0044ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eaba72e48cc294038e74ece5bf7677568d55b038 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4 && git checkout -B drift-repro eaba72e48cc294038e74ece5bf7677568d55b038 && git merge --no-ff 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4
node scripts/docs-audit/affected-docs.mjs --json eaba72e48cc294038e74ece5bf7677568d55b038

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

claudeand others added 2 commits August 31, 2026 04:19
…:error-code-casing
The two `code: 'acme_quota_exceeded'` literals are the fixture for the #9106
demotion path: `demotedDeclaredCode` yields a `declaredCode` only for a spelling
the closed vocabulary rejected, so a registered SCREAMING code would make §4
stop being the both-fields case and assert nothing.
Declared with `adr0112-ok:` and a reason rather than hidden behind a constant —
the gate counts an applied suppression into its verdict, so the deliberate
non-conformance stays visible instead of becoming invisible by construction.
⛔ Not KNOWN_LOWERCASE_CODES (closed to new lines) and ⛔ not EXEMPT_FILES
(these ARE in a code position, so exempting the file would be a false
declaration and would blind the gate to every future literal in it).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@huangyiirene
huangyiirene marked this pull request as ready for review August 31, 2026 07:30
@huangyiirene
huangyiirene added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit a21d2a9Aug 31, 2026
40 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-13241-dispatcher-usermessage-channel branch August 31, 2026 07:55
os-steve added a commit that referenced this pull request Aug 31, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

3 participants

@os-steve@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(runtime): carry the producer's `userMessage` to the wire at the dispatcher's throw-transparent exit by os-steve · Pull Request #13619 · objectstack-ai/objectstack · GitHub
Skip to content

fix(runtime): carry the producer's userMessage to the wire at the dispatcher's throw-transparent exit - #13619

Merged
huangyiirene merged 4 commits into
mainfrom
claude/issue-13241-dispatcher-usermessage-channel
Aug 31, 2026
Merged

fix(runtime): carry the producer's userMessage to the wire at the dispatcher's throw-transparent exit#13619
huangyiirene merged 4 commits into
mainfrom
claude/issue-13241-dispatcher-usermessage-channel

Conversation

@os-steve

@os-steveos-steve commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13241

errorResponseBase (packages/runtime/src/dispatcher-plugin.ts) resolved every throw through resolveThrownHttpError — whose result already carries userMessage — and then did not read the field. It was the only ADR-0112 error boundary that dropped the author-facing text channel.

Verified on this branch's merge base, all four of triage's readings re-measured rather than taken on trust, with one correction noted below.

⚠️ Parked by design — Clause-②: yes

The PM claim declares clause ② on this card, deliberately and conservatively: two careful seats disagree on whether populating an already-declared field constitutes widening, and a below-tier seat picking the reading that lets it proceed is an escape, not a judgement. This PR is complete-but-parked and an at-tier reviewer clears it.

⛔ This seat has not self-cleared any contract-review gate, has not armed auto-merge, and has not flipped the PR ready. ⛔ It also did not pick the narrower of the two implementation options to look safer under that gate — see the census below, which chose the route on architectural grounds before the gate was ever considered.

⛔ No inference is drawn here from PR #13240 having merged. A merge says that PR cleared; it says nothing about this door's clause-② question, or how it would be answered.

The binding measurement — the buildApiError caller census

Triage's instruction was to census the caller set first, then choose between (1) plumbing at this exit only and (2) making the shared buildApiError carry it so no door can forget again. Six call sites, all in packages/runtime:

#sitehas a thrown error in hand?producer userMessage available?
1error-envelope.ts:132apiErrorResponseno — pass-through wrappern/a, forwards its caller's input
2dispatcher-plugin.ts:468 — inline ROUTE_NOT_FOUND 404no — constructednone
3dispatcher-plugin.ts:662errorResponseBaseyesyes, and it was dropped
4domains/mcp.ts:176 — 405 with Allowno — constructednone
5domains/meta.ts:666 — 405 with Allowno — constructednone
6security/inbound-rate-limit.ts:356 — 429no — constructednone

Exactly one of six has a throw. The other five author their own refusal text; there is no producer mark for them to carry.

Route (2), as stated, is not implementable at buildApiError. That function takes an already-sanitised ApiErrorInput and never sees the throw. Adding userMessage?: string to ApiErrorInput would create a typed slot every caller must still populate explicitly — the "cannot forget" property is not obtained, the forgetting merely moves from the body of errorResponseBase to its argument list — while widening a shared input type consumed by endpoint-executor.ts, api-mapping.ts, endpoint-policy.ts, api-endpoint-step.ts and http-dispatcher.ts for zero behavioural gain at any of them.

The "one rule, every door inherits" property route (2) was reaching for already exists — one layer up.declaredUserMessage (@objectstack/types) is the single read every boundary applies; its own docblock says so, naming the REST door, the dispatcher door and the sandbox side-channel. resolveThrownHttpError already resolves the field onto ThrownHttpError.userMessage. This exit already called that resolver and simply did not read the answer. So the defect is one door not reading a shared rule, not an absent shared rule — and route (1) is the repair that matches the diagnosis.

The pre-registered conditional escalation does NOT fire. Route (2) could not make a door that today deliberately withholds userMessage start emitting it, because none of the other five call sites has a userMessage in scope to withhold. There is no such door. Measured, not assumed.

What the change is

Both declared siblings now share oneextra object, mirroring errorFromThrown's expression:

constextra={
...(declaredCode!==undefined ? { declaredCode } : {}),
...(thrown.userMessage!==undefined ? {userMessage: thrown.userMessage} : {}),};

⚠️ Two conditional { extra: … } spreads on one object literal do not merge — the later replaces the earlier — so a throw carrying both a demoted declaredCode and a mark would have silently shipped only one. That is pinned (§4) and ablated below.

Wider than the card's framing:userMessage is status-agnostic by ruling (#9934), so the gap was never confined to the declared-5xx band that motivated it. A marked 4xx refusal reaching this exit lost the field too, with no withhold anywhere in the picture. The plumbing is deliberately not gated on the withhold limb, and §2 drives 400/403/409/503 so a later edit cannot quietly re-gate it.

The open question triage asked to be answered on the way past

Does any producer on the throw-transparent routes set author-facing text today? Measured answer: ZERO. The only two in-repo userMessage producers are:

  • the sandbox (packages/runtime/src/sandbox/quickjs-runner.ts), reached via /actions — but domains/actions.tscatches and answers through errorFromThrown, which already carries the field;
  • metadata-protocol's markedApplicationRefusalError, reached via the REST /meta and /data doors.

Neither reaches this exit. A package-level sweep finds userMessage in only six packages (runtime, rest, metadata-protocol, spec, types, client); no plugin-* or service-* package sets it. ⇒ This confirms the p2 and confirms the change is a no-op on today's tree. It does not close the card's underlying concern, because the contract stays unenforced at one door either way — which is exactly why every test drives the real mounted route rather than asserting a predicate.

⚠️ Two findings this work turned up

1. A measured carve-out, recorded rather than closed.HttpDispatcher.dispatch's foot catch is not a pure rethrow: it intercepts isPermissionDeniedError (name === 'PermissionDeniedError', orcode === 'PERMISSION_DENIED', or a message starting [Security] Access denied) and answers it itself from packages/runtime/src/http-dispatcher.ts. Such a throw never reaches errorResponseBase, so its mark is still dropped, by a different door. That file is a trigger file of on-hold decision #7898 and is out of this change's file surface, so this is pinned as an observation, not repaired here. My own test initially assumed a PERMISSION_DENIED 403 was throw-transparent; the instrument caught it, and the corrected row now pins the carve-out so a future reader learns if it moves.

2. A build-graph mechanism, contributed to the existing card #13513 rather than filed anew.pnpm --filter '@objectstack/runtime^...' build — the closure-build recipe AGENTS.md prescribes — cannot be used for runtime: a devDependency cycle (runtimedriver-turso →(devDep) verifyruntime) pulls verifyand runtime itself into runtime's own dependency closure, so there is no topological order and verify's DTS build races runtime's, dying on Cannot find module '@objectstack/runtime'. Pre-existing and unrelated to this diff; worked around here with --filter '!@objectstack/verify', which is a local unblock and ⛔ not a proposed repair. #13513 already tracks the symptom across two earlier observations and states that what it needs is one confirming build owning the cause; that measurement is now a comment there. ⛔ A duplicate was deliberately not opened.

Patch round — check:error-code-casing was RED on the first push, now green

The gate flagged the two code: 'acme_quota_exceeded' literals in §4 as lowercase error codes in a code position (ADR-0112 D1). They are a deliberately unregistered producer spelling, and they have to be: demotedDeclaredCode yields a declaredCode only when the throw spelled something the closed vocabulary rejected, so a registered SCREAMING code there would produce no declaredCode at all and §4 would stop being the both-fields case and assert nothing.

Declared with adr0112-ok: plus a reason, on the line directly above each hit — the only two placements the gate accepts (it matches the marker on the hit line or the one above, and requires a non-empty reason; both pinned in its --self-test).

  • ⛔ Not KNOWN_LOWERCASE_CODES: the gate refuses new lines there in terms, and the list is shrink-only and currently empty by design.
  • ⛔ Not EXEMPT_FILES: that is for literals which are not error codes (D6/D6b/D6c). These genuinely are in a code position, so exempting the file would be a false declaration and would blind the gate to every future literal in it.
  • ⛔ Not the available restructure either — hoisting the spelling into a constant would satisfy the gate, and the gate's own note says so ("a vendor code passing through is a RUNTIME value … it has no literal for any pattern here to capture"). I did not take it: that makes the deliberate non-conformance invisible by construction, whereas the gate deliberately counts an applied suppression into its verdict line. Visibility is the point, so the declared form is the right instrument and the assertion is unchanged — ⛔ nothing was softened to get green.

The gate's own verdict line, after the fix:

✓ check-error-code-casing self-test: 46 recognizer case(s) + 5 registry case(s) pass.
✓ no unlisted lowercase error codes in 5083 scanned file(s) (ADR-0112).
· 15 literal(s) suppressed by an adr0112-ok: reason

Suppression count moved 13 → 15, which is the positive control that my two were applied rather than silently ignored.

⚠️Why the local union missed it.check:error-code-casing is not in the path-derived union — dispatch-gates.mjs classifies it as content-judged and says so itself: "adds or edits a file carrying an ADR-0112 error or notice CODE (judged from CONTENT — no path derivation can name this gate)". So this was not a skipped gate; it was one no path derivation can name, which is exactly the residue CI exists to cover.

Verification

All readings below were re-taken at 5d93e2427 after the patch round described just above — that is this branch's head, so the gate union, the lint run and the test runs are all on the same tree as the final commit. (Authored in session https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk, recorded here in prose because a body edit rewrites the footer below to its bare form.)

Tests. 21 new cases in packages/runtime/src/dispatcher-plugin.declared-user-message.test.ts, driving the real mounted POST /api/v1/analytics/query route.

Positive controls, not merely green. Absence is asserted twice everywhere it is asserted at all — on Object.keys(error) / hasOwnPropertyand on the serialized bytes — because JSON.stringify silently drops a key whose value is undefined, so a byte assertion alone cannot distinguish "omitted" from "emitted as undefined". §3 additionally drives the guard itself against a present-but-undefined envelope, demonstrating the byte limb blind on that shape while the key limb still catches it.

Ablations — direction predicted before running, both matched exactly.

ablationpredictionobserved
A — delete the userMessage limb from the merged extrapartial red with a specific shape: the presence-asserting rows go red, the absence-asserting rows (§3 omissions, §1's unmarked row, the §2 carve-out) stay green, siblings untouched9 failed / 67 passed; the 9 are exactly the predicted presence rows; both sibling suites green
B — restore the two-spread extra (the pre-merge bug shape)exactly one red — §4's both-fields row — everything else green1 failed / 75 passed; the failure is §4's both-fields row

Both legs were mutated with an anchored replacement asserting a hit count of exactly 1 (a zero-hit edit exits 0 and would have read as a successful ablation), confirmed on disk by blob-hash inequality against the HEAD blob, and restored via git checkout HEAD -- ABSOLUTE_PATH with the restore proven by hash equality plus an empty git diff HEAD — not by an exit code. No rebuild was needed or claimed: the suite imports ./dispatcher-plugin.js, which vitest resolves to same-package source, and every @objectstack/* it touches is source-aliased in packages/runtime/vitest.config.ts.

Gates. Union derived at 5d93e2427 with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed; stderr read and confirms the change set and the repo assertion). Harvested with --commands to avoid the 15-of-30 single-spelling trap. Exit codes captured before any pipe.

  • 31 of 34 pass. Including the most targeted: check:route-envelope, check:test-source-alias, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:type-check-coverage, check:comment-mask-adoption, check:undeclared-dep-imports, and the changeset family.
  • pnpm lint (whole repo, eslint . --no-inline-config): exit 0, run in full — no narrowing claimed and none needed.
  • node scripts/check-nul-bytes.mjs: OK, 7541 files, no raw control bytes.

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 printed OK — 66/78 workspace packages type-checked, but 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 measured without the closure "would silently measure a DIFFERENT WORLD".

One NOT MEASURED I am flagging on my own diff, not on a gate.@objectstack/runtime's tsconfig excludes **/*.test.ts, so its green typecheck says nothing about the new test file. Measured rather than assumed with tsc --noEmit --listFiles: src/dispatcher-plugin.ts → 1 hit (the implementation is covered), the new test file → 0 hits. check:type-check-coverage passes, so the existing TEST_DEBT entry already accounts for that layer.

Triage's four readings, re-measured

Three hold exactly. One has drifted and is corrected here:

claimreading at 5d93e2427
userMessage absent from dispatcher-plugin.ts⚠️1 hit, not 0 — but it is a comment line added by #13240 ("The author-facing text channel is userMessage"), not code. The substantive claim holds: zero code references. Triage measured 0 at a81aa9dd, before that comment landed.
positive control — buildApiError in the same file5 hits ⇒ the absence was real, not a broken read
the caught-path sibling carries ithttp-dispatcher.ts:850 — confirmed verbatim
the envelope slot is declaredcontract.zod.ts:87 — confirmed

Card body sanitizer self-check

Clean. The body is structurally complete — ## The gap## Why it matters more after #12281## Not established here## Region## Refs, closing on its three-ref list, with no tag-shaped fragment anywhere in it and nothing cut mid-sentence.

os-steveand others added 2 commits August 31, 2026 02:56
…throw-transparent exit
`errorResponseBase` resolved every throw through `resolveThrownHttpError` —
whose result already carries `userMessage` — and never read the field, making
it the only ADR-0112 boundary that dropped the author-facing text channel.
Both declared siblings now share ONE `extra` object: two conditional
`{ extra: … }` spreads do not merge, so a throw carrying both a demoted
`declaredCode` and a mark would have shipped only one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 eaba72e48cc294038e74ece5bf7677568d55b038packageMentionDocs.

Which tree this was computed on

This run read content/docs from 707d35adf48162df29799f09f0c6c6fa4d0044ca — the merge of head 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4 into base eaba72e48cc294038e74ece5bf7677568d55b038, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 707d35adf48162df29799f09f0c6c6fa4d0044ca && git checkout 707d35adf48162df29799f09f0c6c6fa4d0044ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eaba72e48cc294038e74ece5bf7677568d55b038 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4 && git checkout -B drift-repro eaba72e48cc294038e74ece5bf7677568d55b038 && git merge --no-ff 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4
node scripts/docs-audit/affected-docs.mjs --json eaba72e48cc294038e74ece5bf7677568d55b038

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

claudeand others added 2 commits August 31, 2026 04:19
…:error-code-casing
The two `code: 'acme_quota_exceeded'` literals are the fixture for the #9106
demotion path: `demotedDeclaredCode` yields a `declaredCode` only for a spelling
the closed vocabulary rejected, so a registered SCREAMING code would make §4
stop being the both-fields case and assert nothing.
Declared with `adr0112-ok:` and a reason rather than hidden behind a constant —
the gate counts an applied suppression into its verdict, so the deliberate
non-conformance stays visible instead of becoming invisible by construction.
⛔ Not KNOWN_LOWERCASE_CODES (closed to new lines) and ⛔ not EXEMPT_FILES
(these ARE in a code position, so exempting the file would be a false
declaration and would blind the gate to every future literal in it).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@huangyiirene
huangyiirene marked this pull request as ready for review August 31, 2026 07:30
@huangyiirene
huangyiirene added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit a21d2a9Aug 31, 2026
40 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-13241-dispatcher-usermessage-channel branch August 31, 2026 07:55
os-steve added a commit that referenced this pull request Aug 31, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

3 participants

@os-steve@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(runtime): carry the producer's `userMessage` to the wire at the dispatcher's throw-transparent exit by os-steve · Pull Request #13619 · objectstack-ai/objectstack · GitHub
Skip to content

fix(runtime): carry the producer's userMessage to the wire at the dispatcher's throw-transparent exit - #13619

Merged
huangyiirene merged 4 commits into
mainfrom
claude/issue-13241-dispatcher-usermessage-channel
Aug 31, 2026
Merged

fix(runtime): carry the producer's userMessage to the wire at the dispatcher's throw-transparent exit#13619
huangyiirene merged 4 commits into
mainfrom
claude/issue-13241-dispatcher-usermessage-channel

Conversation

@os-steve

@os-steveos-steve commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13241

errorResponseBase (packages/runtime/src/dispatcher-plugin.ts) resolved every throw through resolveThrownHttpError — whose result already carries userMessage — and then did not read the field. It was the only ADR-0112 error boundary that dropped the author-facing text channel.

Verified on this branch's merge base, all four of triage's readings re-measured rather than taken on trust, with one correction noted below.

⚠️ Parked by design — Clause-②: yes

The PM claim declares clause ② on this card, deliberately and conservatively: two careful seats disagree on whether populating an already-declared field constitutes widening, and a below-tier seat picking the reading that lets it proceed is an escape, not a judgement. This PR is complete-but-parked and an at-tier reviewer clears it.

⛔ This seat has not self-cleared any contract-review gate, has not armed auto-merge, and has not flipped the PR ready. ⛔ It also did not pick the narrower of the two implementation options to look safer under that gate — see the census below, which chose the route on architectural grounds before the gate was ever considered.

⛔ No inference is drawn here from PR #13240 having merged. A merge says that PR cleared; it says nothing about this door's clause-② question, or how it would be answered.

The binding measurement — the buildApiError caller census

Triage's instruction was to census the caller set first, then choose between (1) plumbing at this exit only and (2) making the shared buildApiError carry it so no door can forget again. Six call sites, all in packages/runtime:

#sitehas a thrown error in hand?producer userMessage available?
1error-envelope.ts:132apiErrorResponseno — pass-through wrappern/a, forwards its caller's input
2dispatcher-plugin.ts:468 — inline ROUTE_NOT_FOUND 404no — constructednone
3dispatcher-plugin.ts:662errorResponseBaseyesyes, and it was dropped
4domains/mcp.ts:176 — 405 with Allowno — constructednone
5domains/meta.ts:666 — 405 with Allowno — constructednone
6security/inbound-rate-limit.ts:356 — 429no — constructednone

Exactly one of six has a throw. The other five author their own refusal text; there is no producer mark for them to carry.

Route (2), as stated, is not implementable at buildApiError. That function takes an already-sanitised ApiErrorInput and never sees the throw. Adding userMessage?: string to ApiErrorInput would create a typed slot every caller must still populate explicitly — the "cannot forget" property is not obtained, the forgetting merely moves from the body of errorResponseBase to its argument list — while widening a shared input type consumed by endpoint-executor.ts, api-mapping.ts, endpoint-policy.ts, api-endpoint-step.ts and http-dispatcher.ts for zero behavioural gain at any of them.

The "one rule, every door inherits" property route (2) was reaching for already exists — one layer up.declaredUserMessage (@objectstack/types) is the single read every boundary applies; its own docblock says so, naming the REST door, the dispatcher door and the sandbox side-channel. resolveThrownHttpError already resolves the field onto ThrownHttpError.userMessage. This exit already called that resolver and simply did not read the answer. So the defect is one door not reading a shared rule, not an absent shared rule — and route (1) is the repair that matches the diagnosis.

The pre-registered conditional escalation does NOT fire. Route (2) could not make a door that today deliberately withholds userMessage start emitting it, because none of the other five call sites has a userMessage in scope to withhold. There is no such door. Measured, not assumed.

What the change is

Both declared siblings now share oneextra object, mirroring errorFromThrown's expression:

constextra={
...(declaredCode!==undefined ? { declaredCode } : {}),
...(thrown.userMessage!==undefined ? {userMessage: thrown.userMessage} : {}),};

⚠️ Two conditional { extra: … } spreads on one object literal do not merge — the later replaces the earlier — so a throw carrying both a demoted declaredCode and a mark would have silently shipped only one. That is pinned (§4) and ablated below.

Wider than the card's framing:userMessage is status-agnostic by ruling (#9934), so the gap was never confined to the declared-5xx band that motivated it. A marked 4xx refusal reaching this exit lost the field too, with no withhold anywhere in the picture. The plumbing is deliberately not gated on the withhold limb, and §2 drives 400/403/409/503 so a later edit cannot quietly re-gate it.

The open question triage asked to be answered on the way past

Does any producer on the throw-transparent routes set author-facing text today? Measured answer: ZERO. The only two in-repo userMessage producers are:

  • the sandbox (packages/runtime/src/sandbox/quickjs-runner.ts), reached via /actions — but domains/actions.tscatches and answers through errorFromThrown, which already carries the field;
  • metadata-protocol's markedApplicationRefusalError, reached via the REST /meta and /data doors.

Neither reaches this exit. A package-level sweep finds userMessage in only six packages (runtime, rest, metadata-protocol, spec, types, client); no plugin-* or service-* package sets it. ⇒ This confirms the p2 and confirms the change is a no-op on today's tree. It does not close the card's underlying concern, because the contract stays unenforced at one door either way — which is exactly why every test drives the real mounted route rather than asserting a predicate.

⚠️ Two findings this work turned up

1. A measured carve-out, recorded rather than closed.HttpDispatcher.dispatch's foot catch is not a pure rethrow: it intercepts isPermissionDeniedError (name === 'PermissionDeniedError', orcode === 'PERMISSION_DENIED', or a message starting [Security] Access denied) and answers it itself from packages/runtime/src/http-dispatcher.ts. Such a throw never reaches errorResponseBase, so its mark is still dropped, by a different door. That file is a trigger file of on-hold decision #7898 and is out of this change's file surface, so this is pinned as an observation, not repaired here. My own test initially assumed a PERMISSION_DENIED 403 was throw-transparent; the instrument caught it, and the corrected row now pins the carve-out so a future reader learns if it moves.

2. A build-graph mechanism, contributed to the existing card #13513 rather than filed anew.pnpm --filter '@objectstack/runtime^...' build — the closure-build recipe AGENTS.md prescribes — cannot be used for runtime: a devDependency cycle (runtimedriver-turso →(devDep) verifyruntime) pulls verifyand runtime itself into runtime's own dependency closure, so there is no topological order and verify's DTS build races runtime's, dying on Cannot find module '@objectstack/runtime'. Pre-existing and unrelated to this diff; worked around here with --filter '!@objectstack/verify', which is a local unblock and ⛔ not a proposed repair. #13513 already tracks the symptom across two earlier observations and states that what it needs is one confirming build owning the cause; that measurement is now a comment there. ⛔ A duplicate was deliberately not opened.

Patch round — check:error-code-casing was RED on the first push, now green

The gate flagged the two code: 'acme_quota_exceeded' literals in §4 as lowercase error codes in a code position (ADR-0112 D1). They are a deliberately unregistered producer spelling, and they have to be: demotedDeclaredCode yields a declaredCode only when the throw spelled something the closed vocabulary rejected, so a registered SCREAMING code there would produce no declaredCode at all and §4 would stop being the both-fields case and assert nothing.

Declared with adr0112-ok: plus a reason, on the line directly above each hit — the only two placements the gate accepts (it matches the marker on the hit line or the one above, and requires a non-empty reason; both pinned in its --self-test).

  • ⛔ Not KNOWN_LOWERCASE_CODES: the gate refuses new lines there in terms, and the list is shrink-only and currently empty by design.
  • ⛔ Not EXEMPT_FILES: that is for literals which are not error codes (D6/D6b/D6c). These genuinely are in a code position, so exempting the file would be a false declaration and would blind the gate to every future literal in it.
  • ⛔ Not the available restructure either — hoisting the spelling into a constant would satisfy the gate, and the gate's own note says so ("a vendor code passing through is a RUNTIME value … it has no literal for any pattern here to capture"). I did not take it: that makes the deliberate non-conformance invisible by construction, whereas the gate deliberately counts an applied suppression into its verdict line. Visibility is the point, so the declared form is the right instrument and the assertion is unchanged — ⛔ nothing was softened to get green.

The gate's own verdict line, after the fix:

✓ check-error-code-casing self-test: 46 recognizer case(s) + 5 registry case(s) pass.
✓ no unlisted lowercase error codes in 5083 scanned file(s) (ADR-0112).
· 15 literal(s) suppressed by an adr0112-ok: reason

Suppression count moved 13 → 15, which is the positive control that my two were applied rather than silently ignored.

⚠️Why the local union missed it.check:error-code-casing is not in the path-derived union — dispatch-gates.mjs classifies it as content-judged and says so itself: "adds or edits a file carrying an ADR-0112 error or notice CODE (judged from CONTENT — no path derivation can name this gate)". So this was not a skipped gate; it was one no path derivation can name, which is exactly the residue CI exists to cover.

Verification

All readings below were re-taken at 5d93e2427 after the patch round described just above — that is this branch's head, so the gate union, the lint run and the test runs are all on the same tree as the final commit. (Authored in session https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk, recorded here in prose because a body edit rewrites the footer below to its bare form.)

Tests. 21 new cases in packages/runtime/src/dispatcher-plugin.declared-user-message.test.ts, driving the real mounted POST /api/v1/analytics/query route.

Positive controls, not merely green. Absence is asserted twice everywhere it is asserted at all — on Object.keys(error) / hasOwnPropertyand on the serialized bytes — because JSON.stringify silently drops a key whose value is undefined, so a byte assertion alone cannot distinguish "omitted" from "emitted as undefined". §3 additionally drives the guard itself against a present-but-undefined envelope, demonstrating the byte limb blind on that shape while the key limb still catches it.

Ablations — direction predicted before running, both matched exactly.

ablationpredictionobserved
A — delete the userMessage limb from the merged extrapartial red with a specific shape: the presence-asserting rows go red, the absence-asserting rows (§3 omissions, §1's unmarked row, the §2 carve-out) stay green, siblings untouched9 failed / 67 passed; the 9 are exactly the predicted presence rows; both sibling suites green
B — restore the two-spread extra (the pre-merge bug shape)exactly one red — §4's both-fields row — everything else green1 failed / 75 passed; the failure is §4's both-fields row

Both legs were mutated with an anchored replacement asserting a hit count of exactly 1 (a zero-hit edit exits 0 and would have read as a successful ablation), confirmed on disk by blob-hash inequality against the HEAD blob, and restored via git checkout HEAD -- ABSOLUTE_PATH with the restore proven by hash equality plus an empty git diff HEAD — not by an exit code. No rebuild was needed or claimed: the suite imports ./dispatcher-plugin.js, which vitest resolves to same-package source, and every @objectstack/* it touches is source-aliased in packages/runtime/vitest.config.ts.

Gates. Union derived at 5d93e2427 with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed; stderr read and confirms the change set and the repo assertion). Harvested with --commands to avoid the 15-of-30 single-spelling trap. Exit codes captured before any pipe.

  • 31 of 34 pass. Including the most targeted: check:route-envelope, check:test-source-alias, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:type-check-coverage, check:comment-mask-adoption, check:undeclared-dep-imports, and the changeset family.
  • pnpm lint (whole repo, eslint . --no-inline-config): exit 0, run in full — no narrowing claimed and none needed.
  • node scripts/check-nul-bytes.mjs: OK, 7541 files, no raw control bytes.

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 printed OK — 66/78 workspace packages type-checked, but 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 measured without the closure "would silently measure a DIFFERENT WORLD".

One NOT MEASURED I am flagging on my own diff, not on a gate.@objectstack/runtime's tsconfig excludes **/*.test.ts, so its green typecheck says nothing about the new test file. Measured rather than assumed with tsc --noEmit --listFiles: src/dispatcher-plugin.ts → 1 hit (the implementation is covered), the new test file → 0 hits. check:type-check-coverage passes, so the existing TEST_DEBT entry already accounts for that layer.

Triage's four readings, re-measured

Three hold exactly. One has drifted and is corrected here:

claimreading at 5d93e2427
userMessage absent from dispatcher-plugin.ts⚠️1 hit, not 0 — but it is a comment line added by #13240 ("The author-facing text channel is userMessage"), not code. The substantive claim holds: zero code references. Triage measured 0 at a81aa9dd, before that comment landed.
positive control — buildApiError in the same file5 hits ⇒ the absence was real, not a broken read
the caught-path sibling carries ithttp-dispatcher.ts:850 — confirmed verbatim
the envelope slot is declaredcontract.zod.ts:87 — confirmed

Card body sanitizer self-check

Clean. The body is structurally complete — ## The gap## Why it matters more after #12281## Not established here## Region## Refs, closing on its three-ref list, with no tag-shaped fragment anywhere in it and nothing cut mid-sentence.

os-steveand others added 2 commits August 31, 2026 02:56
…throw-transparent exit
`errorResponseBase` resolved every throw through `resolveThrownHttpError` —
whose result already carries `userMessage` — and never read the field, making
it the only ADR-0112 boundary that dropped the author-facing text channel.
Both declared siblings now share ONE `extra` object: two conditional
`{ extra: … }` spreads do not merge, so a throw carrying both a demoted
`declaredCode` and a mark would have shipped only one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 eaba72e48cc294038e74ece5bf7677568d55b038packageMentionDocs.

Which tree this was computed on

This run read content/docs from 707d35adf48162df29799f09f0c6c6fa4d0044ca — the merge of head 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4 into base eaba72e48cc294038e74ece5bf7677568d55b038, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 707d35adf48162df29799f09f0c6c6fa4d0044ca && git checkout 707d35adf48162df29799f09f0c6c6fa4d0044ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eaba72e48cc294038e74ece5bf7677568d55b038 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4 && git checkout -B drift-repro eaba72e48cc294038e74ece5bf7677568d55b038 && git merge --no-ff 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4
node scripts/docs-audit/affected-docs.mjs --json eaba72e48cc294038e74ece5bf7677568d55b038

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

claudeand others added 2 commits August 31, 2026 04:19
…:error-code-casing
The two `code: 'acme_quota_exceeded'` literals are the fixture for the #9106
demotion path: `demotedDeclaredCode` yields a `declaredCode` only for a spelling
the closed vocabulary rejected, so a registered SCREAMING code would make §4
stop being the both-fields case and assert nothing.
Declared with `adr0112-ok:` and a reason rather than hidden behind a constant —
the gate counts an applied suppression into its verdict, so the deliberate
non-conformance stays visible instead of becoming invisible by construction.
⛔ Not KNOWN_LOWERCASE_CODES (closed to new lines) and ⛔ not EXEMPT_FILES
(these ARE in a code position, so exempting the file would be a false
declaration and would blind the gate to every future literal in it).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@huangyiirene
huangyiirene marked this pull request as ready for review August 31, 2026 07:30
@huangyiirene
huangyiirene added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit a21d2a9Aug 31, 2026
40 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-13241-dispatcher-usermessage-channel branch August 31, 2026 07:55
os-steve added a commit that referenced this pull request Aug 31, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

3 participants

@os-steve@huangyiirene@claude