You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
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-110 — not silent: it slices to 499 and appends an ellipsis.
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
z.string().optional() — no .max(); describe reads '… refusal text, verbatim — see ApiErrorSchema.userMessage. …'
The word is not incidental. The same docblock names truncation exactly once, and names it as something done to the other channel:
A field carrying the text, not a boolean beside message. The mark and the marked text are one value, so a boundary that rewraps or substitutes message (sanitisation, truncation, the sandbox debug wrapper) can never accidentally promote platform prose into the marked channel.
Input 3 — the two runtime doors' own comments assert a parity that holds on presence, not on bound
packages/runtime/src/dispatcher-plugin.ts — "making it the only ADR-0112 boundary that dropped the field (/data carries it via boundedDeclaredUserMessage, the caught-path sibling errorFromThrown via this same extra mechanism)"
packages/runtime/src/http-dispatcher.ts — "the shared resolver already answered whether the throw declared one (declaredUserMessage's non-empty-string rule), so this door and the REST door agree by construction"
All three call declaredUserMessage, so they do agree on presence — which is what those sentences were written about. They diverge on bound. The sentences are not false as written, but a reader checking "do these doors agree?" reads them as covering the whole field and stops. Whichever way this is ruled, both comments need the bound axis named; if it is ruled option 3, that is the entire fix on the runtime side.
Input 4 — the door census: eight exits, two behaviours, and the split is NOT by package
Derived by taking every tracked file matching git grep -l userMessage (71 files), then every assignment-shaped site inside them (195), then classifying each by what it does with the value.
Bounded — all three reach the bound through the one helper boundedDeclaredUserMessage:
exit
anchor
/data, via mapDataError → withDeclaredUserMessage (also serves both resolveErrorResponse passthrough arms, 4xx and 5xx)
packages/rest/src/error-response.ts:617
record-share family, classified-refusal exit
packages/rest/src/rest-server.ts:9880
record-share family, ADR-0111 message-prefix exit
packages/rest/src/rest-server.ts:10467
(packages/rest/src/rest-server.ts:10633 re-emits an already-classified body into the nested envelope, so it inherits the bound rather than deciding one.)
Verbatim, unbounded — each reads the raw field:
exit
anchor
named before?
/api/v1/packages registrar, sendThrownError
packages/rest/src/package-routes.ts:266 (function at :206)
⭐ NO — this card is the first to name it
errorFromThrown
packages/runtime/src/http-dispatcher.ts:1018
yes
HttpDispatcher.dispatch foot catch, the PERMISSION_DENIED door (#13623 / PR #13811)
packages/runtime/src/http-dispatcher.ts:2377 emitted at :2382
yes
errorResponseBase
packages/runtime/src/dispatcher-plugin.ts:682
yes
⭐ Why the new one matters more than "one more door"
packages/rest/src/package-routes.ts:266 is a production-mounted wire door — its own docblock records "Two doors serve /api/v1/packages and this one mounts FIRST in the production stack" — and it emits the mark verbatim. It sits in packages/rest, the same package as the bounded /data door.
So the two functions named sendThrownError in one package apply opposite bounds to the same field:
packages/rest/src/error-response.ts:1575 → resolveErrorResponse → withDeclaredUserMessage → truncates at 500
⚠️ 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.
packages/metadata-protocol re-wrap carriers — markedApplicationRefusalError (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 body — failed[].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:
Start from every tracked file matching git grep -l userMessage — 71 files (a first grep for userMessage: finds 28; that is a starting point, not the population).
Inside them, every assignment-shaped site (userMessage: / .userMessage =) — 195 sites.
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.
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).
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
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.
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.
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.
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/restversuspackages/runtime.packages/restcontains 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)
declaredUserMessage/resolveThrownHttpError, where the "one rule, every door inherits" property already lives), so no door decides it. Cost: a published response can start truncating where it did not; needs the current max measured against real marks./data's bound. Cost: removes a deliberate guard; rest-server 的 4xx 直通把 ≥500 字符的 message 整条换成 "Request failed" —— #5368 刚写好的过滤器拒收措辞,客户端一个字也收不到(实测) #5423 introduced that bound for a reason that should be re-read before it is undone.Input 1 — the bound is 500, and it is a truncation, not a replacement
packages/rest/src/error-response.ts:70:108-110— not silent: it slices to 499 and appends an ellipsis.Applied to the mark by
boundedDeclaredUserMessage(:652), which isdeclaredUserMessagecomposed withtruncateClientMessage.<, 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
packages/spec/src/api/contract.zod.ts:87—ApiErrorSchema.userMessagez.string().optional()— no.max();describereads'Producer-marked user-facing refusal text, verbatim. …'packages/spec/src/api/errors.zod.ts:378—EnhancedApiErrorSchema.userMessagez.string().optional()— no.max();describereads'… 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:
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 (/datacarries it viaboundedDeclaredUserMessage, the caught-path siblingerrorFromThrownvia this sameextramechanism)"packages/runtime/src/http-dispatcher.ts— "the shared resolver already answered whether the throw declared one (declaredUserMessage's non-empty-string rule), so this door and the REST door agree by construction"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:/data, viamapDataError→withDeclaredUserMessage(also serves bothresolveErrorResponsepassthrough arms, 4xx and 5xx)packages/rest/src/error-response.ts:617packages/rest/src/rest-server.ts:9880packages/rest/src/rest-server.ts:10467(
packages/rest/src/rest-server.ts:10633re-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:
/api/v1/packagesregistrar,sendThrownErrorpackages/rest/src/package-routes.ts:266(function at:206)errorFromThrownpackages/runtime/src/http-dispatcher.ts:1018HttpDispatcher.dispatchfoot catch, thePERMISSION_DENIEDdoor (#13623 / PR #13811)packages/runtime/src/http-dispatcher.ts:2377emitted at:2382errorResponseBasepackages/runtime/src/dispatcher-plugin.ts:682⭐ Why the new one matters more than "one more door"
packages/rest/src/package-routes.ts:266is a production-mounted wire door — its own docblock records "Two doors serve/api/v1/packagesand this one mounts FIRST in the production stack" — and it emits the mark verbatim. It sits inpackages/rest, the same package as the bounded/datadoor.So the two functions named
sendThrownErrorin one package apply opposite bounds to the same field:packages/rest/src/error-response.ts:1575→resolveErrorResponse→withDeclaredUserMessage→ truncates at 500packages/rest/src/package-routes.ts:206→:266→ verbatimpackages/restanswered, and the two share a name. Documenting that is the deliverable option 3 actually buys.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
packages/runtime/src/sandbox/quickjs-runner.ts:288,:1421,:1474) — unbounded, applies the non-empty rule at both crossings. TheuserMessageBOUND is decided per-boundary:/datatruncates 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-protocolre-wrap carriers —markedApplicationRefusalError(protocol.ts:2646) andcarryDeclaredUserMessage(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.failed[].userMessage(protocol.ts:17996) andcleanups[].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
resolveThrownHttpErroralready answers presence for every door atpackages/types/src/thrown-http-error.ts:242(declaredUserMessageis:270, same file).packages/typessits below bothpackages/restandpackages/runtime, so option 1 would move the bound down rather than importing apackages/rest-private constant sideways intopackages/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:
git grep -l userMessage— 71 files (a first grep foruserMessage:finds 28; that is a starting point, not the population).userMessage:/.userMessage =) — 195 sites.MARK,REMEDY,RECOVERING,AUTHOR_TEXT, …) and table columns (mark:in the two table-driven suites) followed to their definitions; template literals resolved from source.'x'.repeat(N)— measures the bound, not a mark), or a non-declaration probe ('', whitespace,42,null, an object — the shapesdeclaredUserMessageanswersundefinedfor).String.length(UTF-16 code units) — the same unittruncateClientMessageslices 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/andapps/contain zero occurrences of the stringuserMessage, case-insensitive. The four reference apps (app-crm,app-showcase,app-todo,app-multi-package) andembed-objectqldo not use the channel. Neither doesskills/.⇒ 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.
The longest three:
Your trial plan does not include custom objects — ask your admin to upgrade. [Qqauthormark7]protocol.marked-refusal-classification.test.ts:103Your plan does not include publishing packages. Ask an admin to upgrade the workspace.package-door-user-message.test.ts:109You do not have access to this report. Ask an admin for the Reporting role.http-dispatcher.permission-denied-user-message.test.ts:67The 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
messagechannel: 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):The longest is
packages/spec/src/data/object.zod.ts:1029— alifecycle.ttl/retention.maxAgerefinement message, 330 characters of fixed text plus 4 interpolation holes.What the four inputs jointly do and do not settle
userMessageBOUND is decided per-boundary:/datatruncates 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.四维分析(中文,按仓库约定)
⛔ 本卡不选 1/2/3。#13624 的分诊裁决保留了这个选择,理由照抄不译:
本次普查把这条理由加强了,没有削弱它:门从三扇变成八扇(四扇有界、四扇 verbatim),而且新发现的那扇 verbatim 门就在有界的那个包里,与有界门同名。可继承的「一致侧」比裁决当时更不存在。以下按四维给出读数,每一维的结论都是读数,不是推荐。
一、实际业务需求(Real business need)
实测拉动为零,两个方向都是零。
examples/、apps/、skills/对userMessage命中数为 0。⇒ 这一维对三个选项都不提供拉动。选项 1 声称要防的「截断已发布响应」在今天没有一个实例;选项 2 声称要拿掉的守卫在这条通道上从未触发过;选项 3 要文档化的差异今天对任何真实作者都不可观测。
二、项目长远合理性(Long-term soundness,权重 ≥50%)
这一维是本卡唯一有实质读数的一维,也是唯一在恶化的一维。
userMessageBOUND is decided per-boundary:/datatruncates it, the two runtime doors emit it verbatim — no ruling covers which is right #13624 记录三扇门,本次普查得到八扇。verbatim 一侧从两扇长到四扇。package-routes.ts自己写着「Byte for byte the dispatcher twin's expression」,dispatcher-plugin.ts也按兄弟对齐(runtime:errorResponseBasenever surfacesuserMessage, so the dispatcher exit has no author-facing text channel — the one the 5xx prose withhold assumes as its compensation #13241 / PR fix(runtime): carry the producer'suserMessageto the wire at the dispatcher's throw-transparent exit #13619),PR fix(runtime): carry the producer'suserMessageat the dispatcher's PERMISSION_DENIED door #13811 同样按兄弟对齐。每扇新门抄邻居,而邻居是 verbatim。packages/rest内部两个同名函数行为相反。按包、按层、按 ADR 都无法陈述这条规则。describe承诺 verbatim,八扇门里四扇不是。这不是文档滞后,是已发布契约与实现不符。⇒ 读数:「一条规则,每扇门继承」的方向(#12509 对 code 通道已确立的形状)是这一维唯一自洽的形状;per-boundary 每多存活一天,可陈述性就更差一分。⛔ 这条读数不选选项 1 —— 「一条规则」既可以是「统一有界」也可以是「统一 verbatim」,这一维不分辨这两者,只排除「继续按门决定」。
三、防 AI 写错代码(Making AI-written code hard to get wrong)
当前形态在这一维上是最坏的:静默的、依门而异的、作者不可见的。
content/docs里userMessage的全部出现要么是生成的 schema 表格(复述describe,其中写着 verbatim),要么是 release notes。没有一处示例、没有一处写「上限」。⇒ 一个照文档写作的作者(人或 AI)会合理地相信没有上限,因为契约就是这么写的。⇒ 读数:任何统一的答案都严格优于现状,因为现状要求作者知道一件他无法知道的事。统一有界 + 在 schema 上写出
.max()(declared = enforced)会在编写时点结构性防错;统一 verbatim 会让契约散文变成真话。⛔ 两者在这一维都是改善,本维同样不分辨。failed[].userMessage/cleanups[].userMessage两个成功响应里的成员够不着(protocol.ts:17996、:18069)。那会造出一个新的「已声明统一、实际不统一」的形态 —— 正是本卡在修的那类缺陷的下一代。选项 1 若被采纳,落地面必须包含这两处,否则它没有兑现自己的卖点。四、创业阶段不扩散需求(Startup scope discipline)
describe散文,把 bound 轴写明);选项 2 次之(删一处truncateClientMessage调用,契约散文已经与之一致);选项 1 最大(bound 下沉到packages/types、重写两个 schema 的describe、并处理上面那两个成功响应成员)。结论:本卡交付的是「可裁」,不是「已裁」
四维合起来的读数:实际业务需求为零拉动(两侧皆零)· 长远合理性(权重 ≥50%)明确排除「继续按门决定」但不分辨有界/verbatim · 防错维同样只排除现状、不分辨两个统一解 · 创业纪律提示选项 3 是唯一成本随时间增长的那个。
⇒ 四维能收敛到的最强结论是:现状(per-boundary)在权重最高的两维上都被排除,而剩下的两个统一解,四维分析不分辨。 分辨它们需要的是一个偏好裁决 —— 「作者写的那句话是不是有长度上限」—— 而那正是 #13624 说的、分诊裁决保留给维护者的那件事。
Refs
userMessageBOUND is decided per-boundary:/datatruncates it, the two runtime doors emit it verbatim — no ruling covers which is right #13624 — the card this supersedes; carries the original framing and its three comments (the triage grading, the contract-prose addendum, os-steve's third-door evidence)./dataapplies, and why it truncates rather than replaces.errorResponseBasenever surfacesuserMessage, so the dispatcher exit has no author-facing text channel — the one the 5xx prose withhold assumes as its compensation #13241 / PR fix(runtime): carry the producer'suserMessageto the wire at the dispatcher's throw-transparent exit #13619 — where the divergence was measured; chose sibling-alignment deliberately.PERMISSION_DENIEDrefusal never reaches the dispatcher's throw-transparent exit, so it still drops the author'suserMessage— a second door, behind #7898's trigger file #13623 / PR fix(runtime): carry the producer'suserMessageat the dispatcher's PERMISSION_DENIED door #13811 — thePERMISSION_DENIEDdoor, the third verbatim exit.declaredCodechannel in scope for 5xx sanitisation at all — and the answer must be applied to all three doors at once #12509 — the "one rule, every door inherits" direction for the code channel.package-routes.ts'ssendThrownError, the fourth verbatim door this census names.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