Uh oh!
There was an error while loading. Please reload this page.
fix(rest): the /data declared-4xx body carries human language in error, not the ADR-0111 CODE: prefix - #13124
fix(rest): the /data declared-4xx body carries human language in error, not the ADR-0111 CODE: prefix#13124os-litant wants to merge 4 commits into
/data declared-4xx body carries human language in error, not the ADR-0111 CODE: prefix#13124Conversation
…x error string The `/data` door's declared-4xx arm shipped the producer's message verbatim, so a refusal using the ADR-0111 `CODE: message` idiom put the machine token in front of the human sentence a client renders. Maintainer ruling 2026-08-29: one envelope semantics — `error` is human language, `code` is the machine token already carried by `thrownCodeFields`. The strip is anchored to the producer's own declared `code`, so it can only ever remove a duplicate of something already on the wire: a 4xx with no `code` keeps its prefix, and a prefix naming some other word is left alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
The 404 arm above the declared-status passthrough builds its sentence from the route's own object name rather than echoing the producer's message, so the expectation names the route's object. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
The header now carries the predictions against what was measured, including the one prediction that was wrong (the CONVERGENCE case asserts both doors, so it reds under the revert), and the MEASURED-NOT-REPAIRED case names #13095. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
…ta-door-strips-code-prefix
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a59bfb697dd7de417ff9a2ca0cda3369f274dc49 && git checkout a59bfb697dd7de417ff9a2ca0cda3369f274dc49
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin adf70f763bce48915cf4eb90b66de5b0dc5249f8 714d2e741f54b20dbf9c1c2fe803dc7db9b4b7ab && git checkout -B drift-repro adf70f763bce48915cf4eb90b66de5b0dc5249f8 && git merge --no-ff 714d2e741f54b20dbf9c1c2fe803dc7db9b4b7ab
node scripts/docs-audit/affected-docs.mjs --json adf70f763bce48915cf4eb90b66de5b0dc5249f8 |
os-litant
commented
Aug 29, 2026
PM 复核 — ACCEPT。⛔ 不扩范围的决定,我维持
⭐ 锚定选择是本卡的关键,而它不是裁定给的裁定只说"剥掉 constdeclared=typeoferror?.code==='string'&&error.code.length>0 ? error.code : undefined;if(declared===undefined||!message.startsWith(declared))returnmessage;⇒ 剥离只能删掉一个已经在 wire 上的令牌的重复。三个后果都被你的 byte-identical 清单钉住了:
模式匹配版本在这三条上都会错,而且错得静默。 ⭐ 你发现裁定的范围达不到裁定自己的目标,并且报而不做裁定的措辞是"one envelope semantics"。你测出 处置正确,我维持:收敛它会移动超出裁定授权的两处之外的 pin,而"移动被授权的 pin"与"为了腾地方放宽别的"之间的界线,正是裁定用 legitimate pin maintenance, not silent alteration 划的。⇒ 另立 #13095 交 maintainer 是对的。 ⭐ 并且你在树里钉了一个标题为 #8111 那句话,你去驱动门验证了而不是假定结论分三段:对它当初描述的生产者为真;对本卡的 ⇒ 这正是该做的:裁定说"那句话会变成真的并原样保留",你验证了它在哪些限内为真,而不是让一句半真的话继续挂着。 两处 pin 的移动,理由都写在字段级
两处自己的预测错了,记录而非回填(a) 预测 §5 全绿,实际 CONVERGENCE 用例在回退下红 —— 因为它同时断言两个门,一半读的正是回退移除的 ⇒ 一次错误预测直接变成了一张卡。 普查三处 objectui 的显示侧剥离将变成 no-op;进程内读者不受影响(它们读的是抛出的 message,不是 wire)。⭐ 每一个零都有同形阳性对照且不互为子串(
声明的未测量
CI 全绿后我撤草稿并 arm,并在队列里看到它才算数。 Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 33240054144 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes#12975
Executes the maintainer ruling of 2026-08-29 (verbatim: 「同意」, adopting option 1): the
/datadoor's declared-4xx arm strips the ADR-0111CODE:prefix from the human-readableerrorstring, so the envelope carries one semantics —erroris human language,codeis the machine token, whichthrownCodeFieldshas always carried separately.Verification union run at
714d2e7(git rev-parse --short HEADfrom that run), working tree clean.The change
packages/rest/src/error-response.ts—classifyDataError's declared-status 4xx arm, plus one new helper,withoutDeclaredCodePrefix.The strip is anchored to the producer's own declared
code, not to a SCREAMING_SNAKE-then-colon pattern. That is deliberate and is the whole safety argument: anchored this way the strip can only ever remove a duplicate of something already on the wire. Three consequences, each pinned in the new test file:codekeeps its prefix —thrownCodeFieldsanswers with an empty object there (ADR-0112: nothing is invented for the half the producer did not name), so a blanket strip would delete the token rather than move it;Request failedthrough the same limb an absent or empty message already took.The strip runs before the #5423 bound, not after: that budget belongs to the text addressed to the caller, and the prefix is not that text.
Measured before and after, driving the real registered route handlers
Not mock envelope assertions — every row boots a real
RestServer, registers the real routes and calls the registered handler. The producer is the shapepackages/plugins/plugin-sharing/src/sharing-plugin.ts's by-id write gate throws: a thrownErrorcarryingcode: 'FORBIDDEN',status: 403, messageFORBIDDEN:followed by the localized sentence.PATCH /data/:object/:id, declared 4xx + code + prefix{"error":"FORBIDDEN: 您无权修改…","code":"FORBIDDEN","object":"showcase_inquiry"}{"error":"您无权修改…","code":"FORBIDDEN","object":"showcase_inquiry"}DELETE /data/:object/:id, sameRECORD_LOCKED_BY_APP{"error":"RECORD_LOCKED_BY_APP: the row is checked out","code":"VALIDATION_ERROR","declaredCode":"RECORD_LOCKED_BY_APP",…}{"error":"the row is checked out","code":"VALIDATION_ERROR","declaredCode":"RECORD_LOCKED_BY_APP",…}FORBIDDEN:{"error":"FORBIDDEN:","code":"FORBIDDEN",…}{"error":"Request failed","code":"FORBIDDEN",…}userMessagemarkByte-identical before and after — measured, not assumed:
{"error":"insufficient privileges","code":"FORBIDDEN","object":"showcase_inquiry"}code, prefixed message{"error":"FORBIDDEN: 您无权修改…","object":"showcase_inquiry"}{"error":"SQLITE_ERROR: no such table: x","code":"FORBIDDEN",…}{"error":"Internal server error","code":"SERVICE_UNAVAILABLE"}{"error":"Internal server error","code":"INTERNAL_ERROR"}{"error":"Opportunity is closed.","object":"showcase_inquiry"}DELETE_RESTRICTED(arm above the passthrough){"error":"DELETE_RESTRICTED: dependents exist","code":"DELETE_RESTRICTED","dependentCount":3,…}OBJECT_NOT_FOUND(arm above the passthrough){"error":"Object 'zz' is not registered","code":"OBJECT_NOT_FOUND","object":"zz"}POST /data/:object/batch(the other door — see the finding below){"error":"FORBIDDEN: 您无权修改…","code":"FORBIDDEN"}GET/DELETEshares, classified arm (same){"success":false,"error":{"code":"FORBIDDEN","message":"FORBIDDEN: 您无权修改…"}}GETshares, ADR-0111 prefix-idiom arm{"success":false,"error":{"code":"NOT_FOUND","message":"record showcase_inquiry/rec1 does not exist"}}The two pin moves the ruling authorised — and only those two
The ruling permits exactly these to move, "legitimate pin maintenance, not silent alteration". Each is annotated in place with the ruling's date.
1.
packages/rest/src/rest-4xx-message-truncation.test.ts— case "a normal-length message passes through with no ellipsis and no slicing".expect(r.body.error).toBe(msg)wheremsgis the whole thrown stringFORBIDDEN: insufficient privileges to update showcase_inquiry rec1.expect(r.body.error).toBe(human)wherehumanisinsufficient privileges to update showcase_inquiry rec1, plusexpect(r.body.code).toBe('FORBIDDEN')and an explicit no-ellipsis assertion.codeassertion exists because readingerroralone would also pass for the other way of getting this wrong, dropping the token along with the prefix.2.
packages/rest/src/rest-5xx-status-passthrough.test.ts— §4 case, retitled from "a short 4xx is still byte-for-byte verbatim, with its object" to "a short 4xx keeps its authored sentence — nothing withheld — with its object".expect(r.body).toEqual({ error: msg, code: 'FORBIDDEN', object: 'showcase_inquiry' })with the prefixedmsg.error: human, plusexpect(r.body.error).not.toBe(INTERNAL_ERROR_MESSAGE).codein front of it was never part of what was authored for the caller. Whole-body equality is kept so losing the token would red.No third test was relaxed. Under the ablation revert, every other case in both files stayed green — see below.
New pins
packages/rest/src/rest-data-door-code-prefix.test.ts— 17 cases across five sections, all driven through real routes: the ruled behaviour on both write verbs, the eight must-not-move branches (two of which are the controls that rule out a blanket pattern strip), the demoted-spelling case, the degradation, and the share family.Ablation
Fix committed first; then
error-response.tsalone reverted to the branch point's bytes withgit checkout db39dfc1c -- "$REPO_ROOT/packages/rest/src/error-response.ts", undertrap … EXIT INT TERMwith an absolute path.git hash-objectof the file equalled the branch point's blobbb8bde347f83f7522a88ef72d30c087ece43164aexactly;grep -c withoutDeclaredCodePrefixon the mutated file returned 0. (A prediction of mine was wrong here and is recorded rather than re-fitted: I predicted the pre-fix arm text would appear once and it appears twice — the same expression also lives inresolveErrorResponse's 4xx arm, which is the finding below.)CONVERGENCEcase is RED under the revert. It asserts both doors, so half of it reads the/dataside the revert removes. Red is the correct answer for it.25ed097978f89e76dac64c1564e2aa2bd565f111(HEAD's) andgit diff HEADempty for that path; the restored tree re-ran green.No rebuild between legs, deliberately: every symbol under test is reached by a relative import inside this package, which vitest transforms from source, so no
dist/sits between the mutation and the assertion.Consumer census — the ruling's precondition
Scanned with
grep -r(nevergit grep, which reads tracked files only) across two corpora: this repo at the branch point, and theobjectuicheckout in this container at190fbd01d.Result: nothing parses meaning out of the prefix. The only readers that touch it on the wire are three display-side strippers in
objectui, which delete it for rendering and become no-ops:packages/react/src/utils/error-message.tspackages/plugin-detail/src/InlineEditSaveBar.tsx(its own comment: "Strip noisy backend prefixes so the inline error reads cleanly")packages/plugin-detail/src/DetailView.tsxEvery prefix reader inside this repo runs in-process, upstream of the wire:
rest-server.ts's own route mappings, and oneplugin-emailcheck on an error it threw itself. Two plugin tests model the REST layer's behaviour as a strip and are unaffected because they operate on the thrown message. One published-skill passage naming the prefix (skills/objectstack-data/references/data-hooks.md) documents what a hook body sees in-process, not the wire body, so it stays accurate and this PR touches no skill.Axes scanned, with counts, and a positive control for each zero reading (control terms chosen so they are not substrings of the tested terms):
startsWithon a SCREAMING_SNAKE literalplugin-emailself-checkspliton a colon over an error/messagemapDataError= 574 hits, same globs and filterscleanError= 4 hits, same globs and filtersindexOfon a colon over an error/messagethrownCodeFields= 30 hits, same corpus and filterserrorfield whose value opens with a prefixBoundary, stated because the scan cannot cross it: this covers only the two repositories checked out in this container. It cannot see published consumers — third-party apps, the reporting deployment's own client, or the
cloudrepo, which is not present here. The ruling assessed that risk as low on the ground that the siblingcodefield has long been on the wire, and this PR does not movecode.Verified rather than assumed: the
#8111commentThe ruling says that comment's "never reaches the wire" claim becomes true and stays as written, and it is unchanged here. Driven rather than assumed:
Errorprefix throws insharing-service.ts— it is true: the record-share prefix-idiom arm strips, measured above./data, it is now true: the prefix is not in the body./data's bulk exits. Both are measured above and filed rather than fixed.Out of scope, measured and filed
packages/rest/src/error-response.tshas two declared-4xx arms and the ruling named one.resolveErrorResponse's own passthrough — reached by/data's bulk exits and, throughclassifiedRefusalAnswer, by the record-share family's classified arm — still ships the prefix. Both rows are in the measurement tables above.Converging that second arm would move pins beyond the two the ruling authorised, so it was not taken here. Filed unassigned as #13095 with the measurements and three options. The new test file carries a case titled "MEASURED, NOT REPAIRED HERE" pinning both rows, so whichever option is adopted moves it deliberately.
Gates
Derived from the merged head with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no paths passed; the script reads its own change set), which reported no stale tree and exactly the five changed paths. All 28 derived families were run, pluscheck:nul-bytes.check:route-envelope,check:dispatcher-error-vocabulary,check:nul-bytes; pluscheck:cross-package-test-inputs,check:test-source-alias,check:type-source-resolution,check:published-files,check:changeset-gate-self-tests,check:objectui-changeset,check:pm-half-states,check:objectql-double-limit,check:page-declaration-shape,check:slot-lookup,check-adr-0087-registration,check-changeset-no-major,check-ci-filter-parity,check-comment-mask-adoption,check-cross-package-test-inputs,check-empty-changeset,check-keyed-text-bounds,check-plugin-teardown-shape,check-shard-attestation,check-undeclared-dep-imports,docs-audit/check-affected-docs,docs-audit/check-drift-comment,pm/release-rehearsal-clone.check:dual-build-cjs-loadsexit 3, "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/". Its own self-test passed (53 cases). It needs a whole-repopnpm build, which is CI's run; this diff changes no package's build shape.scripts/pm/check-half-states.mjsexit 3, "PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential". Independently corroborated: repo-scoped REST from this seat answers 403 "GitHub access is not enabled for this session", whileGET /rate_limitreports a live 15000/hour core quota — the documented session-gate shape for an os-dev seat, not a repo problem.scripts/check-test-completeness.mjsexits 1 on a usage error — it requires a turbo test log argument — so it is a CI-shaped invocation, not a measurement either way.)pnpm --filter @objectstack/rest test— 155 files / 2560 tests, all passing.pnpm --filter @objectstack/rest typecheck— exit 0, and itscheck:test-typecheckhalf reports "@objectstack/rest's test layer compiles under packages/rest/tsconfig.test.json", so the new and edited test files are genuinely covered rather than excluded.A changeset is included (
patchon@objectstack/rest) stating the wire FROM and TO, because the bytes oferrorchange for producers using the idiom.Generated by Claude Code