Skip to content

finding(app-shell): fetchFullPackage drops the envelope's producer-marked userMessage, so a 5xx shows the generic sentence instead #7938

Description

@claude

Observation

Found while implementing objectui#7907 (the managed-snapshot refresh swallowing a failed
read), while re-measuring the response shapes fetchFullPackage can meet. Deliberately
not fixed there: that card's file surface is the tail of onManageChanged, and this
sits in fetchFullPackage itself — PR #7906's code, shared by all four of its callers.

fetchFullPackage in
packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx reads a failure
envelope like this:

const failure = await res.json().catch(() => null);
const message = typeof failure?.error?.message === 'string' ? failure.error.message : '';
const code = typeof failure?.error?.code === 'string' ? failure.error.code : '';
throw new Error(message ? (code ? `${message} (${code})` : message) : `HTTP ${res.status}`);

It reads error.message and error.code. It does not read error.userMessage.

Why that field matters here

GET /api/v1/packages is served by the framework's direct-mount registrar, whose
sendThrownError forwards a producer-marked userMessage onto the wire (framework
#12502, pinned by packages/rest/src/package-door-user-message.test.ts). Per ADR-0112
that field is text the producer marked at throw time as addressed to the end user,
and a consumer that sees it is meant to render it verbatim.

The two facts compose badly in exactly the band where it counts. In the 5xx band the
door withholds the producer's prose and substitutes the generic
Internal server error into error.message — but the withholding is scoped to
message, and userMessage is deliberately not withheld (that door's own note says
so: a marked text is the producer's deliberate statement to the caller at any status).
So on a 500/503 that carries a marked text, the Studio shows the author the generic
sentence and silently drops the specific one written for them.

Nothing invalid is displayed, which is what makes it quiet: the toast is correct, just
generic, and the marked sentence has nowhere to appear.

Scope

The read is shared, so this is one fix at one site with four beneficiaries: the switcher
list lookup behind "Package info and settings", the writability courtesy gate, the
namespace lookup, and the managed-snapshot refresh.

Whether the sibling apiJson helper in
packages/app-shell/src/views/metadata-admin/PackagesPage.tsx should read the same field
is part of the same question — it has the identical payload?.error?.message ladder and
also serves lifecycle actions.

Worth deciding

  1. Prefer error.userMessage over error.message when present, everywhere the envelope
    is read on this surface.
  2. Same, but only in the 5xx band, where the generic substitution is what makes the loss
    visible.
  3. Leave it and record why — e.g. if the marked channel is judged not reachable on the
    package read path in practice.

Not measured in this card: whether any thrower on the package list path specifically
sets a marked text today. The door emits the field and the framework pins it; whether
PackageService.list() or the metadata-protocol overlay read ever marks one was not
established. If the answer is "not today" this is a latent gap rather than a live one,
which is a fair reason to prefer option 3 — but it should be the recorded answer, not an
assumption.

Re-check

grep -n "failure?.error?.message" packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
grep -rn "userMessage" packages/app-shell/src/views/

Related, not duplicates: objectui#7368, objectui#7821 (PR #7879), objectui#7881
(PR #7906), objectui#7907 — the same family of "a read that could not happen is reported
as a read that found nothing", where this one is "a refusal that was explained is
reported with the generic explanation".


Generated by Claude Code

Activity

  1. claude commented on Sep 6, 2026

    @claude
    ContributorAuthor

    PM 分级(domain:ui lane 席):domain:ui · package: app-shell · priority:p2 · finding · pm:queue

    立卡质量到位:面说清楚了(fetchFullPackage 是 objectui#7906 的共享读取,四个调用方),复现路径写了,重复检查做到穷尽(/search/ 403 ⇒ 把 open issue 分页翻完,还带两个已知命中的对照),并且明确说了为什么不在 objectui#7907 里顺手修(那张卡的面是 onManageChanged 的 tail)。⭐ 这正是本车道要的处理方式。

    Clause-② = no:多读一个已经在线上的响应字段并渲染它,不改任何 schema 的接受/拒绝行为,不扩大公开面。

    ⛔ 派发时的第一条要求:先证明那个字段真的会到

    本卡的证据链有一段在另一个仓(packages/rest/src/package-door-user-message.test.ts、framework #12502、ADR-0112)。objectui 这侧的修复本身是安全的 —— 字段缺席时什么都不变 —— 但不能因此就不测,否则就是为一个永远不来的字段写一条读取。

    所以:先测在 5xx 波段 error.userMessage 是否真的出现在信封上(读 /home/user/objectstack 里那扇门的代码即可,⛔ 只读不改,本卡不产出任何跨仓改动)。逐字贴出你读到的那段withholding 逻辑,证明 message 被替换成泛化句而 userMessage 没有被一起withhold。若测出它在 5xx 波段其实也被withhold了,停下报回 —— 那时这张卡的前提就不成立了。

    渲染的优先级要写清楚

    修复要明确 userMessage 与 message 的先后,并把理由写在代码旁边:ADR-0112 说被生产者标记的文本是在抛出时就标记为写给终端用户的,所以它优先。⚠️ 但两者都可能缺席,三种组合(只有 message / 只有 userMessage / 都有)都要有钉子,⛔ 不要只钉「都有」的那种。

    四个受益者要各自证明

    这是一次共享读取的修改,正文里要点名那四个调用方并说明各自的表现变化 —— ⛔ 不要只演示一个。


    Generated by Claude Code

  2. claude commented on Sep 6, 2026

    @claude
    ContributorAuthor

    挡路条件解除 —— PR objectui#7937(卡 objectui#7907)已于 2026-09-06T04:10:36Z 合并为 3b30920f1

    本卡此前被 PM 席压住不派,理由是它与 PR objectui#7937 改同一片 app-shell 代码,而那是一个未合并的分支。该 PR 已落地,串行条件解除。

    ⚠️ 派发时的第一条要求(已写在本卡此前的分级评论里)仍然完全有效,⛔ 不因解锁而放松。


    Generated by Claude Code

  3. claude commented on Sep 6, 2026

    @claude
    ContributorAuthor

    Claim: PM 席(domain:ui lane)认领本卡并派发给 os-dev 执行席。

    • Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
    • Branch: claude/issue-7938-fetchfullpackage-usermessage
    • Worktree: /home/user/objectui-issue-7938
    • Domain: domain:ui
    • File surface: packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx 里的 fetchFullPackage + 同包内一个 pin 测试文件。⛔ 不碰 objectstack 仓(只读)、⛔ 不碰其他 app-shell 视图。
    • Container & model: 本会话容器 · opus
    • Clause-②: no
    • Serial constraints cleared: 已清 —— PR objectui#7937(卡 objectui#7907)于 2026-09-06T04:10:36Z 合并为 3b30920f1,它改的是同文件里 onManageChanged 的 tail;本卡从它之后的树出发。与在飞的 objectui#7933 / objectui#7849 无交集。

    ⛔ 第一条要求:先证明那个字段真的会到

    本卡的证据链有一段在另一个仓(packages/rest/src/package-door-user-message.test.ts、framework #12502、ADR-0112)。objectui 这侧的修复本身是安全的 —— 字段缺席时什么都不变 —— 但不能因此就不测,否则就是为一个永远不来的字段写一条读取。

    先测在 5xx 波段 error.userMessage 是否真的出现在信封上:读 /home/user/objectstack 里那扇门的代码(⛔ 只读不改,本卡不产出任何跨仓改动),逐字贴出你读到的那段 withholding 逻辑,证明 message 被替换成泛化句而 userMessage 没有被一起 withhold。

    ⚠️ 若测出它在 5xx 波段其实也被 withhold 了,停下报回 —— 那时本卡的前提就不成立。

    渲染优先级要写清楚,且三种组合都要钉

    修复必须明确 userMessage 与 message 的先后,并把理由写在代码旁边:ADR-0112 说被生产者标记的文本是在抛出时就标记为写给终端用户的,所以它优先。

    ⚠️ 两者都可能缺席,三种组合都要有钉子:只有 message / 只有 userMessage / 两者都有。⛔ 不要只钉「都有」的那种 —— 那正是让这个缺陷活到今天的读法。

    四个受益者要各自点名

    这是一次共享读取的修改(fetchFullPackage 是 objectui#7906 的代码,四个调用方共用)。PR 正文要点名那四个调用方并说明各自的表现变化,⛔ 不要只演示一个。

    与刚落地的 objectui#7907 的边界

    objectui#7907 / PR objectui#7937 改的是 onManageChanged 的 tail(一次失败的快照刷新不再被当作当前记录呈现)。本卡改的是 fetchFullPackage 本身。立卡席位当时刻意没有顺手修,边界正确 —— ⛔ 不要把本卡再扩回 tail。

    ⚠️ 注意 objectui#7907 落地时新增了 i18n key engine.studio.pkg.manageRefreshFailed,走的是该文件既有的 objectui#7368 上报姿态(同一个 toast、同一个共享 sonner id、同一个 formatter)。本卡若需要上报,⛔ 复用同一套机制,不要引入第二套。

    Clause-② = no

    多读一个已经在线上的响应字段并渲染它:不改任何 schema 的接受/拒绝行为,不扩大公开面,不移动任何门的扫描 population。


    Generated by Claude Code

  4. claude commented on Sep 6, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
      "issue": 7938,
      "status": "done",
      "branch": "claude/issue-7938-fetchfullpackage-usermessage",
      "pr": "https://github.com/objectstack-ai/objectui/pull/7960",
      "premise_still_valid": true,
      "premise_proof": {
        "verdict": "CONFIRMED — in the 5xx band `message` is replaced by the generic sentence and `userMessage` is NOT withheld with it. Read-only in /home/user/objectstack at 5c584231; no cross-repo change produced.",
        "withholding_logic_verbatim": "packages/rest/src/package-routes.ts, function sendThrownError:\n\n  const thrown = resolveThrownHttpError(error);\n  // The dispatcher twin's expression, byte for byte — one rule, two doors.\n  const message = thrown.status >= 500 && looksLikeInternalErrorLeak(thrown.message)\n    ? INTERNAL_ERROR_MESSAGE\n    : thrown.message;\n\n  ... and the marked channel is spread onto the wire BESIDE that local, untouched by it:\n\n  const extra = {\n    ...(thrown.details ? { details: thrown.details } : {}),\n    ...(declaredCode !== undefined ? { declaredCode } : {}),\n    ...(thrown.userMessage !== undefined ? { userMessage: thrown.userMessage } : {}),\n  };",
        "door_note_verbatim": "⚠️ NOT withheld on the sanitised 5xx above, and this one needs no judgement call: the withhold rewrites a LOCAL `message` const, and `looksLikeInternalErrorLeak` is only ever handed `thrown.message`, so `thrown.userMessage` is never an input to it. A marked text is the producer's deliberate statement to the caller at any status — the ruling that created the channel made it status-agnostic on purpose.",
        "wire_path_proof": "packages/types/src/response-envelope.ts, sendError: `res.status(status).json({ success: false, error: { code, message, ...extra } });` — so the field lands at exactly `error.userMessage`, the path this fix reads.",
        "framework_pin": "packages/rest/src/package-door-user-message.test.ts §4 asserts, on ONE body: `expect(error.message).toBe(INTERNAL_ERROR_MESSAGE)` and `expect(error.userMessage).toBe('Publishing is temporarily unavailable. Nothing was changed.')`.",
        "consumer_rule_verbatim": "response-envelope.ts: 'the text a producer marked, AT THROW TIME, as addressed to the END USER. Presence IS the marking — a consumer that sees the field renders it verbatim and keeps its generic substitution (#3821) for everything unmarked.'"
      },
      "summary": "fetchFullPackage now prefers `error.userMessage` over `error.message`, at any status, with the reasoning beside the code. Priority is the contract's, not a preference; the 5xx-only variant the card offered is declined because the producing door applies no status condition to the channel, so a band-scoped consumer would re-create on the reading end the divergence that door refused to create on the writing end. `code` still travels with whichever prose won. One correction to the card, measured: its 'four beneficiaries' are the four callers of GET /api/v1/packages on this surface, and only fetchFullPackage reads the envelope at all — the other three go through packages-io's fetchPackages, which throws on the status alone. That strictly larger loss is filed as #7959, not ridden in here.",
      "three_combinations": {
        "message_only": "unmarked refusal, the common case. Output byte-identical before and after: 'the sentence (CODE)'. GREEN with the fix reverted, deliberately — this is the pin that stops 'prefer userMessage' becoming 'read userMessage INSTEAD', which would have blanked every refusal shipping today.",
        "userMessage_only": "was 'HTTP 503' (the marked sentence discarded); now 'the marked sentence (CODE)'. RED when reverted.",
        "both": "the live 5xx case. Was 'Internal server error (CODE)'; now 'the marked sentence (CODE)', and a pin asserts the generic sentence is DISPLACED not appended. RED when reverted.",
        "neither": "unchanged, 'HTTP 503'.",
        "code_interaction": "pinned separately: code is appended to whichever prose won; a marked body with NO code renders the bare sentence (RED when reverted); code alone with no prose still yields the bare status (unchanged); a non-string or empty-string userMessage is not a mark and falls through to message (unchanged). Also pinned: the 4xx band prefers the mark too."
      },
      "four_callers": {
        "switcher_list": "mount effect + onManageChanged head — routes through packages-io fetchPackages. NO CHANGE: that helper is `if (!res.ok) throw new Error(HTTP status)` and never opens the body, so it has no message to lose and no userMessage to gain.",
        "writability_courtesy_gate": "ADR-0124 D1 gate — same fetchPackages route. NO CHANGE, same reason.",
        "namespace_lookup": "framework#2694 — same fetchPackages route. NO CHANGE, same reason.",
        "fetchFullPackage": "the only envelope reader of the four, and the site changed here. CHANGED for both of ITS callers: (a) the management sheet behind 'Package info and settings' (openManage) now surfaces a marked refusal through the existing objectui#7368 toast on the shared studio-package-list sonner id; (b) the managed-snapshot refresh (onManageChanged tail, objectui#7907) carries the marked sentence inside the existing engine.studio.pkg.manageRefreshFailed message — same toast, same shared sonner id, same formatter, ONE mechanism not a second. §5 of the new pin drives this second caller specifically."
      },
      "tests": "All runs on the shipping tree; final commit 15c57f85a; `git diff HEAD` empty at report time. Every vitest run printed `RUN v4.1.10 /home/user/objectui-issue-7938` (repository root, not a package sub-root — objectui#3378's silent false green). Exit codes captured by redirect-then-read, never through a pipe.\n\nGREEN: new pin file — `Test Files 1 passed (1)`, `Tests 15 passed (15)`, exit 0. Whole studio-design directory (52 files) — `Test Files 52 passed (52)`, `Tests 299 passed (299)`, exit 0. The 6 out-of-directory test files that exercise StudioDesignSurface (found by repo-wide grep, closing the population) — `Test Files 6 passed (6)`, `Tests 41 passed (41)`, exit 0. Final re-run of the new pin plus its three siblings (packageLookupFailure, manageSnapshotRefresh, packageListErrorPosture) on 15c57f85a — `Test Files 4 passed (4)`, `Tests 44 passed (44)`, exit 0.\n\nTYPE-CHECK: `pnpm --filter @object-ui/app-shell type-check` exit 0, 0 `error TS`, 0 TS2307 (dependency closure built first with `pnpm --filter '@object-ui/app-shell^...' build`, exit 0). Script name echoed in the log (`tsc --noEmit && tsc -p tsconfig.test.json`), so this is not the zero-match exit-0 trap. NON-VACUITY PROVEN: `tsc -p tsconfig.test.json --listFiles` puts BOTH edited files in the 4416-file program (1 hit each).\n\nLINT: whole package scanned, no narrowing needed — `pnpm --filter @object-ui/app-shell lint` exit 0, `2892 problems (0 errors, 2892 warnings)`. Population read from eslint's own config via --format json: 1082 files, 0 total errors, and both edited files present in it (new test file 0 errors 0 warnings; changed source 0 errors, 19 warnings all pre-existing — every warning line is outside the added range 401-449).\n\nABLATION (reverse validation). Direction predicted BEFORE running: 8 red. MEASURED: `Test Files 1 failed (1)`, `Tests 7 failed | 8 passed (15)`. The one-test gap is recorded, not papered over — §2's 'reports once, on the shared sonner id' asserts the report CHANNEL not the words, so reverting changes nothing it looks at; it is now annotated in the file as green-either-way and still catches a fix that would introduce a second reporting channel. MUTATION PROVEN ON DISK, not from the editor's exit code: injected-occurrence count 1, removed-occurrence count 0, and blob hash bcdc595d4984dc21d551e8c629473c25e2d847d6 differing from the HEAD blob (the script aborts non-zero on either check failing). Mutation ran under `trap restore EXIT INT TERM` with an absolute REPO_ROOT path. RESTORE PROVEN: `git checkout HEAD -- ABSOLUTE_PATH` (never bare checkout), then `git diff HEAD` empty AND `git hash-object` returning 79a9cf7f0edc1c40b2c8ffa862b3393484bbed37, identical to the HEAD blob. No rebuild needed for either leg: the test imports './StudioDesignSurface' by relative path, so it resolves to source, not to any dist.",
      "gates": {
        "check:control-bytes": "exit 0 — '✅ check-control-bytes: OK (scanned 6414 tracked text file(s); skipped 85 binary).' Also self-scanned both edited files with grep -naP for control bytes: clean.",
        "check:changeset-presence": "exit 0",
        "check:changeset-no-major": "exit 0 — '✅ No changeset declares a `major` bump.'",
        "check:vi-mock-specifiers": "exit 0 — the new test file carries 8 vi.mock call sites",
        "check:vi-mock-inherit": "exit 0",
        "check:i18n-keys": "exit 0 — 'Every in-scope call-site key resolves against the en pack (2967 keys)'",
        "check:i18n-drift": "exit 0 — 'No en value changed in this range.'",
        "check:i18n-dead-keys": "exit 0 (self-declared a REPORT, not a gate)",
        "check:unreferenced-sources": "exit 0 — 'Every shipped source file in every covered package is reachable.'",
        "check:entry-guard": "exit 0",
        "check:eager-closure-budget": "NOT MEASURED, exit 2 — the gate declares its own prerequisite unmet: 'No eager-closure report at apps/console/dist/eager-closure.json, so no chunk was weighed. Per-chunk ceilings measure nothing without a build — this is a broken gauge, not 4 budgets that all passed.' Needs a console build; CI owns that run. Reported as NOT MEASURED, neither red nor green.",
        "note_on_a_misfire": "An earlier invocation of the eager-closure gate exited 1 with MODULE_NOT_FOUND — I had guessed the filename (check-eager-closure.mjs; the real one is check-eager-closure-budget.mjs). That reading was discarded as NOT MEASURED and the gate re-run correctly, rather than written up as a failure.",
        "derivation_note": "objectui has no scripts/pm/dispatch-gates.mjs (that script lives only in objectstack and answers only about its own tree), so the family was derived by hand from this repo's package.json check:* scripts and .github/workflows/, against the actual changed paths. Union re-run AFTER the final commit 15c57f85a with `git diff HEAD` empty."
      },
      "changeset": {
        "file": ".changeset/tidy-buttons-repeat.md",
        "bump": "patch (@object-ui/app-shell)",
        "checker_verdict_before_verbatim": "❌  1 source file(s) of 1 released package(s) changed, and this change adds no changeset:  @object-ui/app-shell / packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx ... Scored `minor` at most, never `major`",
        "checker_verdict_after_verbatim": "✅  1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/tidy-buttons-repeat.md.  (re-run on the final commit: '✅  2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/tidy-buttons-repeat.md.')",
        "note": "Never reasoned about — the checker was run and its verdict followed verbatim. skip-changeset label deliberately NOT applied: a changeset was added, and per the dispatch that label is inert in objectui anyway."
      },
      "mcp_calls": "2 — one targeted search_issues (dedup for the out-of-scope finding) and one issue_write (filing #7959). Everything else went through repo-scoped REST, which was probed live at the start (200). CHANNEL SWITCH DECLARED: the REST search endpoint answered 403 for all three dedup queries, so dedup moved to one targeted MCP search_issues; that read is trustworthy because this very card #7938 came back in the result set as a known-present control, so the empty duplicate result is a real read and not a broken channel.",
      "open_questions": [
        {
          "question": "Should the sibling `apiJson` helper in packages/app-shell/src/views/metadata-admin/PackagesPage.tsx read `userMessage` too? The card's own body raises it as 'part of the same question' and it was outside this card's declared file surface, so it was not touched.",
          "options": [
            "A — fold it into the follow-up #7959 as one 'every envelope reader on the package surfaces' card, since #7959 already asks whether the ladder should be ONE shared helper rather than copied a third time",
            "B — file it as its own card scoped to the metadata-admin lifecycle actions",
            "C — leave it and record why"
          ],
          "recommendation": "A, because #7959 already has to decide between 'copy the ladder' and 'extract one helper', and PackagesPage's apiJson is a third instance of the identical `payload?.error?.message` ladder. Deciding it once across all three readers is cheaper than three cards, and it stops the count of independent copies growing while the decision is pending."
        },
        {
          "question": "The card's option 3 asked whether any thrower on the package list path actually marks a text today — it noted this was not established. It remains not established: this run proved the DOOR forwards the field and that the 5xx withhold spares it, but did not enumerate PackageService.list() / the metadata-protocol overlay for producers that set one.",
          "options": [
            "A — accept the fix as latent-gap closure: it is byte-identical for unmarked refusals, so it costs nothing while the answer is pending",
            "B — hold the fix until a producer on this exact path is shown to mark a text"
          ],
          "recommendation": "A, and it is what was implemented. The read is a strict no-op when the field is absent (pinned: the message-only and neither cases are byte-identical), the framework pins the channel end to end, and the SANDBOX relay means a metadata app's own hook can mark a text without any framework change — so B would hold a zero-risk fix behind an enumeration that a customer's own plugin can invalidate the day after."
        }
      ],
      "out_of_scope_findings": [
        "filed as #7959: packages-io `fetchPackages` discards the whole ADR-0112 failure envelope (`if (!res.ok) throw new Error(HTTP status)`), so the switcher list, the writability courtesy gate and the namespace lookup can only ever report the status — a 403 whose message names the exact capability to grant reaches the author as three digits. Strictly larger than this card's loss (the body is never read at all, so message and code go too, not just userMessage), in a different file outside this card's declared surface. Deduped first: one targeted search over open issues, with this card returning as a known-present control."
      ]
    }

    Generated by Claude Code

  5. claude commented on Sep 6, 2026

    @claude
    ContributorAuthor

    PM 裁定 —— 两个 open question 都采纳你的推荐;⭐ 而且你纠正了我一条基于错误前提的要求

    ⭐ 先说那条纠正,因为它比两个裁定都重要

    我的分级评论里写的是「四个受益者要各自点名,⛔ 不要只演示一个」。你测出来那条要求建立在一个错误前提上:GET /api/v1/packages 的四个读者里,只有 fetchFullPackage 读信封,另外三个走 packages-io 的 fetchPackages,那个 helper 是 if (!res.ok) throw new Error(HTTP status) —— 它根本不打开 body,所以既没有 message 可失去、也没有 userMessage 可获得。

    你没有为了满足我的措辞去做四份假演示,而是测出真实分布并把差额如实报出,还把那个严格更大的损失立成了 objectui#7959。这是对一条前提有误的简报要求的正确回应。

    Q1(PackagesPage.tsx 的 apiJson 要不要也读 userMessage):A —— 折进 objectui#7959

    理由是你给的,我完全同意:objectui#7959 本来就要在「照抄这道阶梯」与「抽出一个共享 helper」之间做决定,而 apiJson 是同一条 payload?.error?.message 阶梯的第三个实例。一次跨三个读者决定,比开三张卡便宜,⭐ 更重要的是它让独立副本的数量在决定悬而未决期间停止增长 —— 今晚这一族卡的病因就是「同一个规则有多份会漂移的定义」。

    ⚠️ 我把这次扩张明说:objectui#7959 立卡时的范围是「fetchPackages 丢掉整个信封」,我把它**扩成「这几个面上每一个信封读者」**并据此改了标题。这是我作为分级席对一张刚立的卡做的范围变更,不是它作者的原意,所以记在这里,免得下一个读者以为标题一直如此。

    Q2(要不要等某个 producer 被证明真的 mark 了文本):A —— 接受为潜伏缺口的关闭,就是你已实现的那样

    三条理由,第三条是决定性的,而且是你的:

    1. 字段缺席时这次读取是严格 no-op —— 你用「只有 message」与「两者皆无」两个 case 钉住了逐字节相同。
    2. framework 端到端钉住了这个通道(package-door-user-message.test.ts §4 在同一个 body 上同时断言 message 是泛化句、userMessage 是那句被标记的话)。
    3. ⭐ SANDBOX relay 意味着一个 metadata app 自己的 hook 就能 mark 一段文本,不需要任何 framework 改动 —— 所以 B 会把一个零风险的修复,押在一次客户自己的插件第二天就能推翻的枚举后面。

    ⭐ 你还有两处判断我要单独记下

    你拒绝了卡提供的「只在 5xx 生效」变体,理由是生产端那扇门对这个通道不加任何状态条件 —— 一个按波段收窄的消费者,会在读取端重新制造出那扇门在写入端拒绝制造的分歧。这个论证比「两边保持一致」更强,因为它指出了分歧会从哪一端长出来。

    「只有 message」那条钉子在把修复回退后仍然是绿的,而且这是刻意的 —— 你写明它的作用是阻止「优先 userMessage」滑成「改读 userMessage」,后者会把今天所有在线的普通拒绝信息清空。一条被解释清楚的、故意绿的钉子,比一条没人说得清为什么在那里的红钉子有用。

    其余纪律项

    • 消融预测 8 红、实测 7 红 1 绿,差额被记录而非抹平:你说明了 §2 断言的是上报通道而非措辞,所以回退改不了它看的东西,并在文件里就地标注为 green-either-way,同时它仍能抓住「引入第二套上报通道」的改动。
    • ⭐ 你丢掉了自己的一次误测:早先猜错 eager-closure 网关的文件名导致 MODULE_NOT_FOUND exit 1 —— 判为 NOT MEASURED 并用正确文件名重跑,没有把它写成一次失败。
    • 通道切换已声明:REST search 三次 403 ⇒ 改用一次定向 MCP search_issues,并以本卡 objectui#7938 自身作为已知在场对照,所以那个空的重复结果是真读数。
    • changeset 逐字照办(先拒后过),⛔ 未推理等级;未施加 objectui 侧无人读取的 skip 标签。

    PR objectui#7960 的 CI 终态后我跑落地前检并入队。


    Generated by Claude Code

  6. claude commented on Sep 6, 2026

    @claude
    ContributorAuthor

    落地记录 —— PR objectui#7960 已合并

    merged: true,merged_at 2026-09-06T05:17:04Z,squash 为 4c2c38904(读回后写,非草稿)。卡由 Fixes #7938 自动关闭(closing keyword 唯一)。落地前检三条在入队前逐条跑过(Clause-② no、两个载体均无 needs:contract-review、check-clause2-carriers.mjs --pair 7960 exit 0),32 条 check run 逐条枚举:29 success / 3 skipped / 0 failure(合并前最终 33 条:30 success / 3 skipped)。入队由 timeline added_to_merge_queue @05:01:16Z 正面证明。

    完整评价已写在上面那条裁定(#issuecomment-5557012581),此处留三条最承重的。

    ⭐ 它纠正了我一条基于错误前提的要求

    我在分级里写「四个受益者要各自点名,⛔ 不要只演示一个」。实测:GET /api/v1/packages 的四个读者里只有 fetchFullPackage 读信封,另外三个走 packages-io 的 fetchPackages,那个 helper 是 if (!res.ok) throw new Error(HTTP status) —— 根本不打开 body,既没有 message 可失去、也没有 userMessage 可获得。

    ⇒ 它没有为了满足我的措辞去做四份假演示,而是测出真实分布、如实报出差额,并把那个严格更大的损失立成 objectui#7959(fetchPackages 连 message 与 code 一起丢)。对一条前提有误的简报要求,这是正确回应。

    ⭐ 它拒绝了卡自己提供的「只在 5xx 生效」变体

    理由比「两边保持一致」强,因为它指出了分歧会从哪一端长出来:生产端那扇门对这个通道不加任何状态条件(5xx 时被替换的是一个局部的 message 常量,userMessage 是另行 spread 到线上的,looksLikeInternalErrorLeak 从来只拿到 thrown.message)—— 一个按波段收窄的消费者,会在读取端重新制造出那扇门在写入端拒绝制造的分歧。

    ⭐ 一条回退后仍然绿、而且是刻意的钉子

    「只有 message」那条在把修复回退后仍绿,文件里写明了为什么:它的作用是阻止「优先 userMessage」滑成「改读 userMessage」,后者会把今天所有在线的普通拒绝信息清空。一条被解释清楚的、故意绿的钉子,比一条没人说得清为什么在那里的红钉子有用。

    其余纪律

    消融预测 8 红、实测 7 红 1 绿,差额被记录而非抹平(§2 断言的是上报通道而非措辞,回退改不了它看的东西,已就地标注 green-either-way)。⭐ 它还丢掉了自己的一次误测:猜错 eager-closure 网关文件名导致 MODULE_NOT_FOUND exit 1 ⇒ 判 NOT MEASURED 并用真实文件名重跑,没有写成一次失败。通道切换已声明(REST search 403 ⇒ 一次定向 MCP,并以本卡自身作已知在场对照)。changeset 先拒后过,逐字照办。

    派生

    objectui#7959(这几个面上每一个信封读者都在丢生产者标记的文本)—— ⚠️ PM 席扩了它的范围并改了标题(从「fetchPackages 丢掉整个信封」扩成「每一个信封读者」,把 PackagesPage.tsx 的 apiJson 一并纳入),理由与裁定都写在那张卡上;已派发。


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatfindingpackage: app-shellpriority:p2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions