Repository navigation
The userMessage BOUND is decided per-boundary: /data truncates it, the two runtime doors emit it verbatim — no ruling covers which is right #13624
Description
Activity
- addedpriority:p2Medium: important, M3Medium: important, M3
on Aug 31, 2026 Triage:
packages/rest与packages/runtime按车道表同归domain:cli⇒ 三扇门在一个车道内,这不是跨域卡。pm:queue+ typeTask+priority:p2。⭐ 卡说「三个输入都没人测过,⛔ 测之前裁决就是猜」—— 其中两个一次 grep 就答完了
对
origin/main75b3bdc:卡列的未知 读数 bound 到底是多少 500。 packages/rest/src/error-response.ts:70const CLIENT_MESSAGE_MAX = 500;/data截断时是什么行为(静默?省略号?)不是静默 —— :108-110:message.length < CLIENT_MESSAGE_MAX ? message : \${message.slice(0, CLIENT_MESSAGE_MAX - 1)}…`⇒ 切到 **499** 字符并**追加省略号…`**有没有真实的 mark 接近它 ⛔ 仍未测 —— 这是唯一剩下的输入 ⇒ 本卡的范围因此收窄成一件事:测量第三个输入。 裁决所需的另两项已经在树上,不必再派人去问。
⚠️ 另有一条现成的相邻事实:packages/rest/src/rest-share-user-message-bypass-exits.test.ts:553已经用'x'.repeat(CLIENT_MESSAGE_MAX + 100)在 rest 边界上钉过这个 bound ⇒ 测量的脚手架已存在,不是从零开始。派发范围(⛔ 只测,不裁)
- 测量真实
userMessage语料的长度分布(仓内 + 参考 app 的所有 producer mark),回答:有没有任何一条接近或超过 500? - 把①的读数、以及上表的两条,一并写进一张新的决策卡(选项 1/2/3 照抄本卡),然后本卡关为已被那张卡取代。
⛔ dev 不得自行选 1/2/3。 理由是卡自己给出的、分诊背书的那条:本仓元判据②(一个操作两个实现且行为不一致 ⇒ 带治理的一侧胜出)在这里够不着 —— 三扇门里已经有两种行为,没有一个可继承的「一致侧」,无论怎么裁都有一扇门要改。⇒ 这是真裁决,不是默认。
⚠️ 而选项 1(把 bound 提到共享层)会让已发布的响应开始截断,选项 2(全面 verbatim)会拿掉 #5423 有意设的守卫 —— 两者都是公开行为变更 ⇒ 那张决策卡落在人工地板上。本卡只负责把它变成一个可裁的问题。为什么是 p2:用户可见但影响小(同一句作者写给终端用户的话,依哪扇门应答而完整或被裁),且 #13241 / PR #13619 已经有意选择匹配最近的兄弟、不引入第三种行为 ⇒ 没有在恶化。
Generated by Claude Code
- 测量真实
Triage addendum — a fourth input, measured while contract-reviewing PR #13811. Re-measured against
origin/main; ⛔ no scope change, ⛔ still not a ruling.The published contract prose is a party to this disagreement, and it is on the verbatim side
Neither the card body nor my dispatch comment above reads the published schema. It has an opinion, and it is not neutral:
carrier what it says packages/spec/src/api/contract.zod.ts—ApiErrorSchema.userMessagez.string().optional()— no.max();.describe(…)reads'Producer-marked user-facing refusal text, **verbatim**. …'packages/spec/src/api/errors.zod.ts—EnhancedApiErrorSchema.userMessagez.string().optional()— no.max();.describe(…)reads'… refusal text, **verbatim** — see ApiErrorSchema.userMessage. …'And the word is not incidental. The same docblock names truncation exactly once, and names it as something done to the other channel:
A field carrying the text, not a boolean beside
message. The mark and the marked text are one value, so a boundary that rewraps or substitutesmessage(sanitisation, truncation, the sandbox debug wrapper) can never accidentally promote platform prose into the marked channel.⇒ The published contract contemplates truncation of
messageand saysuserMessagetravels verbatim.What this changes for the three options:
- Option 1 (bound everywhere) is not only a published behaviour change — it also contradicts the published
describetext on two schemas, so it dragspackages/specin as a co-landing site. Its cost line should read: published responses start truncating and two contract descriptions must be rewritten. - Option 2 (verbatim everywhere) is the option the published prose already describes. That does not make it right — rest-server 的 4xx 直通把 ≥500 字符的 message 整条换成 "Request failed" —— #5368 刚写好的过滤器拒收措辞,客户端一个字也收不到(实测) #5423's guard is a real guard and the card is correct that it must be re-read before it is undone — but it means option 2 is documentation-consistent and option 1 is not, which was invisible from the body's table.
- Option 3 (per-boundary + document) now has a concrete deliverable: the two
describestrings currently promise something one of the three doors does not do.
Second reading: the two runtime doors' own comments assert a parity that does not hold
Both unbounded doors carry prose claiming agreement with the REST door on this very field:
packages/runtime/src/dispatcher-plugin.ts— "making it the only ADR-0112 boundary that dropped the field (/datacarries it viaboundedDeclaredUserMessage, the caught-path siblingerrorFromThrownvia this sameextramechanism)"packages/runtime/src/http-dispatcher.ts— "the shared resolver already answered whether the throw declared one (declaredUserMessage's non-empty-string rule), so this door and the REST door agree by construction"
They agree on presence — all three call
declaredUserMessage, which is what those sentences were written about — and diverge on bound. The sentences are not false as written, but a reader checking "do these doors agree?" will read them as covering the whole field and stop. Whichever way this is ruled, both comments need the bound axis named explicitly; if it is ruled option 3, that is the entire fix on the runtime side.Third reading (context, not defect): the sandbox side-channel is unbounded too
packages/runtime/src/sandbox/quickjs-runner.tsapplies the non-empty rule at both crossings and no bound.⚠️ Deliberately not counted as a fourth door: it is a VM boundary, not a wire boundary, and a bound applied there would clip the mark before any door could decide. Naming it so the eventual ruling does not silently acquire it.Unchanged
The one open input the dispatch scope names — does any real mark approach 500? — is still unmeasured, and it is still the only thing this card is dispatched to answer. ⛔ Dev still does not pick 1/2/3.
Generated by Claude Code
- Option 1 (bound everywhere) is not only a published behaviour change — it also contradicts the published
New evidence — a third unbounded door arrives with PR #13811, and the bound is now sourced.
Recording here rather than filing a new card: this is the same defect this card already owns, and this lane has a history of one defect being filed five separate times (#13513 · #13735 · #13750 · #13845). Adding to the card that exists.
From the at-tier contract review of PR #13811 (comment 5486377846), measured at source rather than inferred:
boundary bound anchor REST /datatruncates at 500 boundedDeclaredUserMessage,CLIENT_MESSAGE_MAX = 500,packages/rest/src/error-response.ts:652dispatcher, sibling exit verbatim, unbounded packages/runtime/src/http-dispatcher.ts:850dispatcher, PERMISSION_DENIEDdoorverbatim, unbounded added by PR #13811 Two things this changes for the card:
- The bound has a name and a number now. The card states the disagreement as "
/datatruncates it, the two runtime doors emit it verbatim"; the truncating side isCLIENT_MESSAGE_MAX = 500at a citable anchor. Whoever takes this card no longer has to rediscover what "truncates" means. - The unbounded side grew from two doors to three, and will keep growing. PR fix(runtime): carry the producer's
userMessageat the dispatcher's PERMISSION_DENIED door #13811 is the correct fix for APERMISSION_DENIEDrefusal never reaches the dispatcher's throw-transparent exit, so it still drops the author'suserMessage— a second door, behind #7898's trigger file #13623 and is not being held for this — it conforms the new door to the sibling dispatcher door, which is the right local reference. But that is precisely the mechanism by which the unbounded side accretes: each new door copies its neighbour, and the neighbour is unbounded. Every furtheruserMessagedoor added before this card is ruled inherits the wrong half of the disagreement.
⚠️ This does not re-grade the card and does not make it a blocker on #13811. It is still "no ruling covers which is right", and that ruling is still what the card needs. The note is that the cost of not having it is now measurably increasing rather than static.
Generated by Claude Code
- The bound has a name and a number now. The card states the disagreement as "
Measured while implementing #13241 (PR #13619), raised by the dev as an open question and filed by the
domain:cliPM seat. Unlabelled for triage. Pre-existing — this divergence predates #13241 and is not caused by it.The divergence
userMessageis the author-facing text channel (#9934). The boundaries that carry it do not agree on whether it is bounded:/data(packages/rest/src/error-response.ts)boundedDeclaredUserMessage, applyingtruncateClientMessage(#5423's bound)errorFromThrown(packages/runtime/src/http-dispatcher.ts)errorResponseBase(packages/runtime/src/dispatcher-plugin.ts)Why the standing meta-criterion does not settle it
The lane's default for one operation, two implementations, inconsistent behaviour is the aligned side wins.⚠️ That rule is ambiguous here, because the two existing implementations already disagree with each other — there is no single "aligned side" to inherit. Whichever way it is ruled, one of the three doors changes.
⇒ Recorded as a genuine open question rather than resolved by a default that does not reach it.
What #13241 did, and why that is not the answer
PR #13619 chose to match its closest sibling (
errorFromThrown: same package, same ADR-0112 nested envelope, same resolver input, byte-identical expression). That is the correct choice for that PR — it introduces no third behaviour — and it is explicitly not a ruling on the bound. Adopting/data's bound instead would have meant importing apackages/rest-private bound (truncateClientMessage/CLIENT_MESSAGE_MAX) intopackages/runtime, which is a cross-package contract decision and squarely out of that card's scope.The options
declaredUserMessage/resolveThrownHttpError, where the "one rule, every door inherits" property already lives), so no door decides it. Cost: a published response can start truncating where it did not; needs the current max measured against real marks./data's bound. Cost: removes a deliberate guard; rest-server 的 4xx 直通把 ≥500 字符的 message 整条换成 "Request failed" —— #5368 刚写好的过滤器拒收措辞,客户端一个字也收不到(实测) #5423 introduced that bound for a reason that should be re-read before it is undone./datadoes when it truncates (silent? ellipsis?). ⛔ Ruling before that is guessing.Why it is worth a card rather than a note
The mark is text an author wrote for an end user. A bound that applies at one door and not another means the same authored sentence reaches the user whole or clipped depending on which door refused — and the author has no way to know which. That is a small defect with a user-visible surface, which is the class this repo has repeatedly found expensive to leave implicit.
Refs
errorResponseBasenever surfacesuserMessage, so the dispatcher exit has no author-facing text channel — the one the 5xx prose withhold assumes as its compensation #13241 / PR fix(runtime): carry the producer'suserMessageto the wire at the dispatcher's throw-transparent exit #13619 — where this was measured; chose sibling-alignment deliberately and flagged this rather than deciding it./dataapplies.declaredCodechannel in scope for 5xx sanitisation at all — and the answer must be applied to all three doors at once #12509 — the "one rule, every door inherits" direction for the code channel, which is the shape option 1 would extend to this one.