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/main2aa8456cf2d6. ⛔ 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

constCLIENT_MESSAGE_MAX=500;

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

returnmessage.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

carrierwhat it says
packages/spec/src/api/contract.zod.ts:87ApiErrorSchema.userMessagez.string().optional()no .max(); describe reads 'Producer-marked user-facing refusal text, verbatim. …'
packages/spec/src/api/errors.zod.ts:378EnhancedApiErrorSchema.userMessagez.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:

exitanchor
/data, via mapDataErrorwithDeclaredUserMessage (also serves both resolveErrorResponse passthrough arms, 4xx and 5xx)packages/rest/src/error-response.ts:617
record-share family, classified-refusal exitpackages/rest/src/rest-server.ts:9880
record-share family, ADR-0111 message-prefix exitpackages/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:

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

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

charstextsite
92Your trial plan does not include custom objects — ask your admin to upgrade. [Qqauthormark7]protocol.marked-refusal-classification.test.ts:103
86Your plan does not include publishing packages. Ask an admin to upgrade the workspace.package-door-user-message.test.ts:109
75You 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

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n 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;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
      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/main2aa8456cf2d6. ⛔ 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

      constCLIENT_MESSAGE_MAX=500;

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

      returnmessage.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

      carrierwhat it says
      packages/spec/src/api/contract.zod.ts:87ApiErrorSchema.userMessagez.string().optional()no .max(); describe reads 'Producer-marked user-facing refusal text, verbatim. …'
      packages/spec/src/api/errors.zod.ts:378EnhancedApiErrorSchema.userMessagez.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:

      exitanchor
      /data, via mapDataErrorwithDeclaredUserMessage (also serves both resolveErrorResponse passthrough arms, 4xx and 5xx)packages/rest/src/error-response.ts:617
      record-share family, classified-refusal exitpackages/rest/src/rest-server.ts:9880
      record-share family, ADR-0111 message-prefix exitpackages/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:

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

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

      charstextsite
      92Your trial plan does not include custom objects — ask your admin to upgrade. [Qqauthormark7]protocol.marked-refusal-classification.test.ts:103
      86Your plan does not include publishing packages. Ask an admin to upgrade the workspace.package-door-user-message.test.ts:109
      75You 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

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
          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/main2aa8456cf2d6. ⛔ 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

          constCLIENT_MESSAGE_MAX=500;

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

          returnmessage.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

          carrierwhat it says
          packages/spec/src/api/contract.zod.ts:87ApiErrorSchema.userMessagez.string().optional()no .max(); describe reads 'Producer-marked user-facing refusal text, verbatim. …'
          packages/spec/src/api/errors.zod.ts:378EnhancedApiErrorSchema.userMessagez.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:

          exitanchor
          /data, via mapDataErrorwithDeclaredUserMessage (also serves both resolveErrorResponse passthrough arms, 4xx and 5xx)packages/rest/src/error-response.ts:617
          record-share family, classified-refusal exitpackages/rest/src/rest-server.ts:9880
          record-share family, ADR-0111 message-prefix exitpackages/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:

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

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

          charstextsite
          92Your trial plan does not include custom objects — ask your admin to upgrade. [Qqauthormark7]protocol.marked-refusal-classification.test.ts:103
          86Your plan does not include publishing packages. Ask an admin to upgrade the workspace.package-door-user-message.test.ts:109
          75You 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

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              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/main2aa8456cf2d6. ⛔ 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

              constCLIENT_MESSAGE_MAX=500;

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

              returnmessage.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

              carrierwhat it says
              packages/spec/src/api/contract.zod.ts:87ApiErrorSchema.userMessagez.string().optional()no .max(); describe reads 'Producer-marked user-facing refusal text, verbatim. …'
              packages/spec/src/api/errors.zod.ts:378EnhancedApiErrorSchema.userMessagez.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:

              exitanchor
              /data, via mapDataErrorwithDeclaredUserMessage (also serves both resolveErrorResponse passthrough arms, 4xx and 5xx)packages/rest/src/error-response.ts:617
              record-share family, classified-refusal exitpackages/rest/src/rest-server.ts:9880
              record-share family, ADR-0111 message-prefix exitpackages/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:

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

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

              charstextsite
              92Your trial plan does not include custom objects — ask your admin to upgrade. [Qqauthormark7]protocol.marked-refusal-classification.test.ts:103
              86Your plan does not include publishing packages. Ask an admin to upgrade the workspace.package-door-user-message.test.ts:109
              75You 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

                  , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
                  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/main2aa8456cf2d6. ⛔ 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

                  constCLIENT_MESSAGE_MAX=500;

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

                  returnmessage.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

                  carrierwhat it says
                  packages/spec/src/api/contract.zod.ts:87ApiErrorSchema.userMessagez.string().optional()no .max(); describe reads 'Producer-marked user-facing refusal text, verbatim. …'
                  packages/spec/src/api/errors.zod.ts:378EnhancedApiErrorSchema.userMessagez.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:

                  exitanchor
                  /data, via mapDataErrorwithDeclaredUserMessage (also serves both resolveErrorResponse passthrough arms, 4xx and 5xx)packages/rest/src/error-response.ts:617
                  record-share family, classified-refusal exitpackages/rest/src/rest-server.ts:9880
                  record-share family, ADR-0111 message-prefix exitpackages/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:

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

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

                  charstextsite
                  92Your trial plan does not include custom objects — ask your admin to upgrade. [Qqauthormark7]protocol.marked-refusal-classification.test.ts:103
                  86Your plan does not include publishing packages. Ask an admin to upgrade the workspace.package-door-user-message.test.ts:109
                  75You 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

                      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                      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/main2aa8456cf2d6. ⛔ 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

                      constCLIENT_MESSAGE_MAX=500;

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

                      returnmessage.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

                      carrierwhat it says
                      packages/spec/src/api/contract.zod.ts:87ApiErrorSchema.userMessagez.string().optional()no .max(); describe reads 'Producer-marked user-facing refusal text, verbatim. …'
                      packages/spec/src/api/errors.zod.ts:378EnhancedApiErrorSchema.userMessagez.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:

                      exitanchor
                      /data, via mapDataErrorwithDeclaredUserMessage (also serves both resolveErrorResponse passthrough arms, 4xx and 5xx)packages/rest/src/error-response.ts:617
                      record-share family, classified-refusal exitpackages/rest/src/rest-server.ts:9880
                      record-share family, ADR-0111 message-prefix exitpackages/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:

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

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

                      charstextsite
                      92Your trial plan does not include custom objects — ask your admin to upgrade. [Qqauthormark7]protocol.marked-refusal-classification.test.ts:103
                      86Your plan does not include publishing packages. Ask an admin to upgrade the workspace.package-door-user-message.test.ts:109
                      75You 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

                          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                          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/main2aa8456cf2d6. ⛔ 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

                          constCLIENT_MESSAGE_MAX=500;

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

                          returnmessage.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

                          carrierwhat it says
                          packages/spec/src/api/contract.zod.ts:87ApiErrorSchema.userMessagez.string().optional()no .max(); describe reads 'Producer-marked user-facing refusal text, verbatim. …'
                          packages/spec/src/api/errors.zod.ts:378EnhancedApiErrorSchema.userMessagez.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:

                          exitanchor
                          /data, via mapDataErrorwithDeclaredUserMessage (also serves both resolveErrorResponse passthrough arms, 4xx and 5xx)packages/rest/src/error-response.ts:617
                          record-share family, classified-refusal exitpackages/rest/src/rest-server.ts:9880
                          record-share family, ADR-0111 message-prefix exitpackages/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:

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

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

                          charstextsite
                          92Your trial plan does not include custom objects — ask your admin to upgrade. [Qqauthormark7]protocol.marked-refusal-classification.test.ts:103
                          86Your plan does not include publishing packages. Ask an admin to upgrade the workspace.package-door-user-message.test.ts:109
                          75You 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

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

                              constCLIENT_MESSAGE_MAX=500;

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

                              returnmessage.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

                              carrierwhat it says
                              packages/spec/src/api/contract.zod.ts:87ApiErrorSchema.userMessagez.string().optional()no .max(); describe reads 'Producer-marked user-facing refusal text, verbatim. …'
                              packages/spec/src/api/errors.zod.ts:378EnhancedApiErrorSchema.userMessagez.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:

                              exitanchor
                              /data, via mapDataErrorwithDeclaredUserMessage (also serves both resolveErrorResponse passthrough arms, 4xx and 5xx)packages/rest/src/error-response.ts:617
                              record-share family, classified-refusal exitpackages/rest/src/rest-server.ts:9880
                              record-share family, ADR-0111 message-prefix exitpackages/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:

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

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

                              charstextsite
                              92Your trial plan does not include custom objects — ask your admin to upgrade. [Qqauthormark7]protocol.marked-refusal-classification.test.ts:103
                              86Your plan does not include publishing packages. Ask an admin to upgrade the workspace.package-door-user-message.test.ts:109
                              75You 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