Skip to content

Rule the userMessage bound: 8 doors, 2 behaviours — and packages/rest holds BOTH, at two functions named sendThrownError #14674

Description

@os-trump

Supersedes #13624, which was dispatched to measure one missing input and is now answered. Every input a ruling needs is below, measured against origin/main 2aa8456cf2d6. ⛔ No option is chosen here — the triage ruling on #13624 reserved that, and the reason it gave still holds (quoted verbatim in the four-facet block).

One thing the census changed: the divergence is not packages/rest versus packages/runtime. packages/rest contains both behaviours, at two functions that share a name. That is new, and it is stated in Input 4 below.

The question

userMessage (#9934) is the author-facing text channel: a producer marks a thrown error, and the boundaries carry that text to the wire. The boundaries do not agree on whether it is bounded, and no ruling covers which is right.

The options (copied from #13624)

  1. Bound everywhere — promote the bound to the shared layer (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.
  2. Verbatim everywhere — drop /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.
  3. Leave it per-boundary and document it. Cost: makes "how long may a mark be?" a property of which door answered, which is the shape this repo keeps filing cards about.

Input 1 — the bound is 500, and it is a truncation, not a replacement

packages/rest/src/error-response.ts:70

const CLIENT_MESSAGE_MAX = 500;

:108-110not silent: it slices to 499 and appends an ellipsis.

return message.length < CLIENT_MESSAGE_MAX
    ? message
    : `${message.slice(0, CLIENT_MESSAGE_MAX - 1)}…`;

Applied to the mark by boundedDeclaredUserMessage (:652), which is declaredUserMessage composed with truncateClientMessage.

⚠️ Note the comparison is <, so a mark of exactly 500 characters is truncated to 499 + ellipsis. The bound is effectively "499 characters survive", not 500.

Input 2 — the published contract prose sits on the verbatim side

carrier what it says
packages/spec/src/api/contract.zod.ts:87ApiErrorSchema.userMessage z.string().optional()no .max(); describe reads 'Producer-marked user-facing refusal text, verbatim. …'
packages/spec/src/api/errors.zod.ts:378EnhancedApiErrorSchema.userMessage z.string().optional()no .max(); describe reads '… refusal text, verbatim — see ApiErrorSchema.userMessage. …'

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 substitutes message (sanitisation, truncation, the sandbox debug wrapper) can never accidentally promote platform prose into the marked channel.

Input 3 — the two runtime doors' own comments assert a parity that holds on presence, not on bound

  • packages/runtime/src/dispatcher-plugin.ts"making it the only ADR-0112 boundary that dropped the field (/data carries it via boundedDeclaredUserMessage, the caught-path sibling errorFromThrown via this same extra mechanism)"
  • 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"

All three call declaredUserMessage, so they do agree on presence — which is what those sentences were written about. They diverge on bound. The sentences are not false as written, but a reader checking "do these doors agree?" reads them as covering the whole field and stops. Whichever way this is ruled, both comments need the bound axis named; if it is ruled option 3, that is the entire fix on the runtime side.

Input 4 — the door census: eight exits, two behaviours, and the split is NOT by package

Derived by taking every tracked file matching git grep -l userMessage (71 files), then every assignment-shaped site inside them (195), then classifying each by what it does with the value.

Bounded — all three reach the bound through the one helper boundedDeclaredUserMessage:

exit anchor
/data, via mapDataErrorwithDeclaredUserMessage (also serves both resolveErrorResponse passthrough arms, 4xx and 5xx) packages/rest/src/error-response.ts:617
record-share family, classified-refusal exit packages/rest/src/rest-server.ts:9880
record-share family, ADR-0111 message-prefix exit packages/rest/src/rest-server.ts:10467

(packages/rest/src/rest-server.ts:10633 re-emits an already-classified body into the nested envelope, so it inherits the bound rather than deciding one.)

Verbatim, unbounded — each reads the raw field:

exit anchor named before?
/api/v1/packages registrar, sendThrownError packages/rest/src/package-routes.ts:266 (function at :206) NO — this card is the first to name it
errorFromThrown packages/runtime/src/http-dispatcher.ts:1018 yes
HttpDispatcher.dispatch foot catch, the PERMISSION_DENIED door (#13623 / PR #13811) packages/runtime/src/http-dispatcher.ts:2377 emitted at :2382 yes
errorResponseBase packages/runtime/src/dispatcher-plugin.ts:682 yes

⭐ Why the new one matters more than "one more door"

packages/rest/src/package-routes.ts:266 is a production-mounted wire door — its own docblock records "Two doors serve /api/v1/packages and this one mounts FIRST in the production stack" — and it emits the mark verbatim. It sits in packages/rest, the same package as the bounded /data door.

So the two functions named sendThrownError in one package apply opposite bounds to the same field:

  • packages/rest/src/error-response.ts:1575resolveErrorResponsewithDeclaredUserMessagetruncates at 500
  • packages/rest/src/package-routes.ts:206:266verbatim

⚠️ This removes option 3's most defensible reading. "Per-boundary" could have been documented as "the bound is a property of the package" or "REST bounds, runtime does not" — the framing #13624's own table invites. It is neither. The behaviour is a property of which function inside packages/rest answered, and the two share a name. Documenting that is the deliverable option 3 actually buys.

⚠️ Also worth reading with os-steve's note on #13624 (the unbounded side grows as each new door copies its neighbour): package-routes.ts's comment says its expression is "Byte for byte the dispatcher twin's expression (errorFromThrown)". That is the accretion mechanism, already executed once inside the bounded package.

Deliberately NOT counted as doors

  • The sandbox VM crossing (packages/runtime/src/sandbox/quickjs-runner.ts:288, :1421, :1474) — unbounded, applies the non-empty rule at both crossings. The userMessage BOUND is decided per-boundary: /data truncates it, the two runtime doors emit it verbatim — no ruling covers which is right #13624's addendum excludes it on the grounds that it is a VM boundary rather than a wire boundary, and a bound applied there would clip the mark before any door could decide. Named so the ruling does not silently acquire it.
  • packages/metadata-protocol re-wrap carriersmarkedApplicationRefusalError (protocol.ts:2646) and carryDeclaredUserMessage (protocol.ts:2671) copy the mark onto a re-wrapped error that then flows to one of the doors above. They carry, they do not emit.
  • Two payload members inside a SUCCESS bodyfailed[].userMessage (protocol.ts:17996) and cleanups[].userMessage (protocol.ts:18069) carry the mark verbatim inside a 200 response, not an error envelope. Flagged because a bound placed in the shared resolver would not reach them, so option 1 does not actually make the field uniformly bounded on the wire.

Where the shared layer is, for option 1

resolveThrownHttpError already answers presence for every door at packages/types/src/thrown-http-error.ts:242 (declaredUserMessage is :270, same file). packages/types sits below both packages/rest and packages/runtime, so option 1 would move the bound down rather than importing a packages/rest-private constant sideways into packages/runtime — which is the cross-package objection #13241 / PR #13619 correctly declined to resolve in its own scope. ⛔ Stated as a measured fact about where the code sits, not as an argument for option 1.

Input 5 (the card's missing input) — the mark census: nothing comes close to 500

Population, and how it was derived. Authored marks were separated from fixtures rather than folded together:

  1. Start from every tracked file matching git grep -l userMessage71 files (a first grep for userMessage: finds 28; that is a starting point, not the population).
  2. Inside them, every assignment-shaped site (userMessage: / .userMessage =) — 195 sites.
  3. Resolve every site's value: string literals directly; named constants (MARK, REMEDY, RECOVERING, AUTHOR_TEXT, …) and table columns (mark: in the two table-driven suites) followed to their definitions; template literals resolved from source.
  4. Classify by what the value is: a refusal sentence written for an end user (a mark), a length probe ('x'.repeat(N) — measures the bound, not a mark), or a non-declaration probe ('', whitespace, 42, null, an object — the shapes declaredUserMessage answers undefined for).
  5. Lengths are JS String.length (UTF-16 code units) — the same unit truncateClientMessage slices on.

⭐ First reading: there are ZERO real producer marks

All 35 assignment sites in non-test source author no text at all. Every one is a carrier, a read, a type, or a schema — verified mechanically, not assumed. This confirms the contract's own claim ("Platform/driver code never sets it") as a measurement.

examples/ and apps/ contain zero occurrences of the string userMessage, case-insensitive. The four reference apps (app-crm, app-showcase, app-todo, app-multi-package) and embed-objectql do not use the channel. Neither does skills/.

⇒ The entire corpus is test and dogfood fixtures. Nothing in this repo or its example apps is a real production mark.

Second reading: the fixture corpus, measured

45 distinct texts across 61 occurrence sites.

corpus size (distinct texts) : 45
max length                   : 92   (18.4% of 500)
median length                : 41
mean length                  : 42.0
>= 500 (would truncate)      : 0
>= 450 (within 10% of 500)   : 0
>= 250 (half the bound)      : 0
>= 100 (a fifth of the bound): 0
     0-19 :   7 #######
    20-39 :  15 ###############
    40-59 :  15 ###############
    60-79 :   6 ######
    80-99 :   2 ##
  100-249 :   0
  250-449 :   0
  450-499 :   0
   >= 500 :   0

The longest three:

chars text site
92 Your trial plan does not include custom objects — ask your admin to upgrade. [Qqauthormark7] protocol.marked-refusal-classification.test.ts:103
86 Your plan does not include publishing packages. Ask an admin to upgrade the workspace. package-door-user-message.test.ts:109
75 You do not have access to this report. Ask an admin for the Reporting role. http-dispatcher.permission-denied-user-message.test.ts:67

The longest mark in the repo is 5.4x under the bound. Not one is within 10% of it; not one reaches even a fifth of it.

Counted separately, as the dispatch asked: 5 synthetic length probes exist only to exercise the bound — 900, 700, 604, 600, 600 chars, all 'x'.repeat(N) — and 15 non-declaration probes ('', whitespace, 42, null, an object) whose length carries no meaning. Folding either into the corpus would have manufactured an over-500 population out of the test scaffolding that measures the bound.

Third reading: the calibration — even the adjacent channel does not reach 500 today

The mark corpus is fixtures, so on its own it cannot answer "would a real mark ever get near 500?". The nearest real evidence is the adjacent message channel: prose written by the same authors, for the same refusals, truncated by the same 500 at the same door. #5423's docblock says the two longest driver-sql filter refusals "were already over the line".

Measured over all non-test source (every string literal in a throw/Error(/message: position, template holes charged a nominal 8 characters each):

authored refusal/diagnostic literals >= 200 chars : 7
  of those, >= 500 (the bound truncates them)     : 0
  of those, >= 450                                : 0
  longest                                          : 362  (72% of 500)

The longest is packages/spec/src/data/object.zod.ts:1029 — a lifecycle.ttl / retention.maxAge refinement message, 330 characters of fixed text plus 4 interpolation holes.

⚠️ Stated as a modelling choice, not a fact: the nominal 8 characters per hole is an assumption. That literal's holes carry durations and a field name; at a realistic 10-20 characters each it renders at roughly 370-410, and it would need about 43 characters per hole to cross 500. So the honest reading is "the adjacent channel comes within roughly 30% of the bound and does not cross it today" — not "it can never cross". Whatever was over the line when #5423 was written is not over it now.

What the four inputs jointly do and do not settle

  • Settled: the bound is 500 (effectively 499 surviving characters), truncation is visible, the published contract prose is on the verbatim side, and no mark anywhere in this repo or its example apps comes near the bound — the closest is 18.4% of it.
  • Settled: option 1's cost line as written on The userMessage BOUND is decided per-boundary: /data truncates it, the two runtime doors emit it verbatim — no ruling covers which is right #13624 ("a published response can start truncating where it did not") is, on today's corpus, a cost with no measured instance. Nothing would begin truncating, because nothing is long enough.
  • NOT settled by measurement: whether that stays true once real producers arrive. The corpus is fixtures precisely because the channel is producer-side opt-in and no in-repo producer has opted in. A census of an empty production population cannot forecast one.
  • NOT settled, and this is the reason the card exists: which behaviour is correct. That is a ruling.

四维分析(中文,按仓库约定)

本卡不选 1/2/3。 #13624 的分诊裁决保留了这个选择,理由照抄不译:

dev 不得自行选 1/2/3。 理由是卡自己给出的、分诊背书的那条:本仓元判据②(一个操作两个实现且行为不一致 ⇒ 带治理的一侧胜出)在这里够不着 —— 三扇门里已经有两种行为,没有一个可继承的「一致侧」,无论怎么裁都有一扇门要改。⇒ 这是真裁决,不是默认。

本次普查把这条理由加强了,没有削弱它:门从三扇变成八扇(四扇有界、四扇 verbatim),而且新发现的那扇 verbatim 门就在有界的那个包里,与有界门同名。可继承的「一致侧」比裁决当时更不存在。以下按四维给出读数,每一维的结论都是读数,不是推荐

一、实际业务需求(Real business need)

实测拉动为零,两个方向都是零。

  • 没有任何真实 producer mark:非测试源码 35 个赋值点无一 authoring 文本;examples/apps/skills/userMessage 命中数为 0
  • 没有任何 mark 接近 500:语料最长 92(18.4%),无一达到 100。

⇒ 这一维对三个选项都不提供拉动。选项 1 声称要防的「截断已发布响应」在今天没有一个实例;选项 2 声称要拿掉的守卫在这条通道上从未触发过;选项 3 要文档化的差异今天对任何真实作者都不可观测

⚠️ 但这一维有一条反向读数,必须并列呈现:正因为拉动为零,现在是改动成本最低的时点。等真实 producer 出现之后再裁,任何一边都变成已发布行为变更。

二、项目长远合理性(Long-term soundness,权重 ≥50%)

这一维是本卡唯一有实质读数的一维,也是唯一在恶化的一维。

⇒ 读数:「一条规则,每扇门继承」的方向(#12509 对 code 通道已确立的形状)是这一维唯一自洽的形状;per-boundary 每多存活一天,可陈述性就更差一分。⛔ 这条读数不选选项 1 —— 「一条规则」既可以是「统一有界」也可以是「统一 verbatim」,这一维不分辨这两者,只排除「继续按门决定」。

三、防 AI 写错代码(Making AI-written code hard to get wrong)

当前形态在这一维上是最坏的:静默的、依门而异的、作者不可见的。

  • 作者写下一句 mark 时,无法知道它会不会被裁:取决于哪扇门拒绝了请求,而门的选择在作者的控制之外。
  • 文档没有教过这条通道的长度content/docsuserMessage 的全部出现要么是生成的 schema 表格(复述 describe,其中写着 verbatim),要么是 release notes。没有一处示例、没有一处写「上限」。⇒ 一个照文档写作的作者(人或 AI)会合理地相信没有上限,因为契约就是这么写的。
  • 截断不是静默的(有省略号),这是当前形态唯一的减分补偿:被裁的那句在客户端看得出被裁了。

⇒ 读数:任何统一的答案都严格优于现状,因为现状要求作者知道一件他无法知道的事。统一有界 + 在 schema 上写出 .max()(declared = enforced)会在编写时点结构性防错;统一 verbatim 会让契约散文变成真话。⛔ 两者在这一维是改善,本维同样不分辨。

⚠️ 一条真实的反向风险,属于这一维且只属于这一维:选项 1 若只把 bound 放进共享 resolver,failed[].userMessage / cleanups[].userMessage 两个成功响应里的成员够不着protocol.ts:17996:18069)。那会造出一个新的「已声明统一、实际不统一」的形态 —— 正是本卡在修的那类缺陷的下一代。选项 1 若被采纳,落地面必须包含这两处,否则它没有兑现自己的卖点。

四、创业阶段不扩散需求(Startup scope discipline)

  • 三个选项都不扩张能力面:没有新键、没有新契约、没有新用户可见功能。这一维对三者近似中性。
  • 成本量级:选项 3 最小(改两处运行时注释 + 两处 describe 散文,把 bound 轴写明);选项 2 次之(删一处 truncateClientMessage 调用,契约散文已经与之一致);选项 1 最大(bound 下沉到 packages/types、重写两个 schema 的 describe、并处理上面那两个成功响应成员)。
  • ⚠️ 但这一维有一条不能省的读数:选项 3 的成本不是一次性的。它把「哪扇门答的」永久变成契约的一部分,而门在增长(本次 +5)。今天最便宜的选项,是唯一成本随门数线性增长的选项。

结论:本卡交付的是「可裁」,不是「已裁」

四维合起来的读数:实际业务需求为零拉动(两侧皆零)· 长远合理性(权重 ≥50%)明确排除「继续按门决定」但不分辨有界/verbatim · 防错维同样只排除现状、不分辨两个统一解 · 创业纪律提示选项 3 是唯一成本随时间增长的那个。

⇒ 四维能收敛到的最强结论是:现状(per-boundary)在权重最高的两维上都被排除,而剩下的两个统一解,四维分析不分辨。 分辨它们需要的是一个偏好裁决 —— 「作者写的那句话是不是有长度上限」—— 而那正是 #13624 说的、分诊裁决保留给维护者的那件事。

⚠️ 两个选项都是公开行为变更(选项 1 改响应,选项 2 拿掉 #5423 的守卫,且 #5423 的理由应当在被推翻前重读),因此本卡落在人工地板上,⛔ 不由 agent 权衡定案。


Refs

Census scripts are not committed — they are one-shot measurements over fixtures, and a committed pin would have to encode a bound, which is the ruling itself. The readings above are the artefact.

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions