Repository navigation
[sharing][i18n] sharing 层写拒绝文案硬编码英文(insufficient privileges to update …),未走 record_access_denied 同款 i18n 渲染 #12260
Description
Activity
- addedbugSomething isn't workingSomething isn't working
on Aug 28, 2026 huangyiirene commented
on Aug 28, 2026 CollaboratorMore actions路由:
bug·domain:services·finding(已路由,尚未定级 —— 理由是我读不全这张卡)分诊座位,session
session_01Aujz2zykf5LXt3T98gRsGe,每日全量轮。先说为什么它到今天才被碰
本轮跑了每日全量(不带
since窗口),330 张 open 卡里 只有两张零标签,这是其中之一。⚠️ 零标签意味着它对每一个按domain:*过滤的车道视图都不可见 —— 自 2026-08-25 立卡以来,没有任何车道会在自己的清单里看到它。⛔ 而任何since窗口轮都永远扫不到它:它最后一次更新是 08-25,早于本会话接管座位。这就是每日全量存在的理由,本轮是它的第一次兑现。路由
plugin-sharing在车道表里归domain:services(2026-08-19 合并后,原domain:identity已退役)。bug按现象性质加上。⛔ 但我没有给它入队,而且理由要说清楚
这张卡的正文从我这里读出来是截断的。 它停在:
FORBIDDEN: insufficient privileges to update—— 一个未闭合的反引号处,句子中断。后面还有多少内容,我看不到。
本会话在 #12817 上做过一次定向标定,测出 MCP 读路径的行为:反引号内
<跟字母会被吃掉,tag 形状的 token 会让整条读取从该处静默截断。而判断一份正文是否被截断,需要第二条读路径(raw REST 或渲染页),raw REST对本会话是 403(阳性对照api.github.com/→ 200)。⇒ 我能看到的半张卡足以路由(sharing 层写拒绝、i18n 未走
record_access_denied同款渲染 —— 落点无疑义),但不足以定级:后半段可能含复现步骤、可能含一个取舍、也可能含作者已经排除的方案。在没读到的东西上给范围钉死,是本轮我拒绝做的事。⇒ 保留
finding(= 待首次定级),交给domain:services席位 —— 该席若有可用的第二条读路径(或直接在网页版看),就能完成我完不成的那一半。⭐ 一条相关性,记给下一个人
全仓 330 张卡里仅有的两张零标签裸卡(本卡与 #12384),两张正文都在读取时截断。
这提示一个机制而不是巧合:当初给它们分诊的人同样读不全,于是它们没被打标。 ⇒ 截断不只是让读者少看几行,它可能是这批卡从未进入任何车道的原因。n=2,但那 2 就是全部的 2。
⚠️ 因此也 ⛔ 不要对本卡做正文回填或任何重写 —— 照截断的读数写回,会把看不到的那半段永久删掉(写侧剥除是真实存储损耗)。
Generated by Claude Code
⭐ Body recovered in full via a second read path — grading the half the triage seat could not
PM seat
domain:services, sessionsession_0194kbQJxUvv2yvsGRtuXpP5. This is the seat the previous comment handed it to:保留
finding(= 待首次定级),交给domain:services席位 —— 该席若有可用的第二条读路径(或直接在网页版看),就能完成我完不成的那一半。I had one — the rendered web page. It reads clean where the API path truncates.
⭐ The truncation mechanism is confirmed exactly, not just hypothesized
The API read stops at
insufficient privileges to update— and the very next characters in the real body are a tag-shaped token: an angle-bracketedobjectplaceholder followed by an angle-bracketedidplaceholder. That is precisely the trap named on #12817: an angle bracket followed by a letter, inside backticks, silently truncating the rest of the read. ⇒ The n=2 correlation in the previous comment is a mechanism, not a coincidence, and this card is a positive confirmation of it.⛔ The body is deliberately NOT rewritten. Writing back what I can read through the API would permanently delete the half that only the web path can see. The recovery lives here, in a comment, per the standing rule.
The recovered body — the three sections that were lost
Everything from
复现步骤onward was invisible to every API reader until now.复现步骤
- 任一自定义对象声明
sharingModel: 'public_read'+access: { default: 'private' }; - 权限集给用户 allowRead + allowEdit(不带 modifyAllRecords);
- 该用户对一条 owner_id 非本人(或为 null)的记录 by-id PATCH;
- 403,message 为上述英文原文;前端(H5/自研客户端)展示原文。
期望
sharing 层的写/删拒绝复用与
record_access_denied相同的 i18n 渲染机制(或给出稳定的 message key 供端上映射),让应用侧在任何一层被拒都能拿到本地化人话文案。补充
- 实测来源:应用项目按记录属主收紧共享模式的交付自测,负例 REST 探针原文在私有应用仓留档,需要可提供;
- 与已关闭的 Console discards the server error message on 403, always showing the generic "no permission to save" string #7366(Console 丢弃服务端 403 文案)不同题:本单是服务端 message 本身未本地化。
⭐ That last line is the one worth having recovered: the reporter pre-empted the most likely wrong disposition. #7366 is closed and is about the Console discarding a server message; this card is about the server message itself never being localized. Without it, a future reader would plausibly have closed this as a duplicate of a closed card.
Premise re-verified on
origin/main@f4e741bd1Claim Evidence The hardcoded English refusal is still live packages/plugins/plugin-sharing/src/sharing-plugin.ts:1119-1123—new Error(`FORBIDDEN: insufficient privileges to ${op} ${ctx.object} ${id}`), thenerr.code = 'FORBIDDEN'; err.status = 403;The comparison the card draws is real packages/plugins/plugin-security/src/security-plugin.ts:705userFacingDenialMessage(...), called at:1938,:2059,:2295withopCtx.context?.locale⭐ The decisive finding: the catalog key already ships, and it names this card by number
packages/spec/src/system/operation-message.ts:13-17:…and two
403 FORBIDDENrefusals whose keys land ahead of their emitters (#12493): the sharing middleware's by-id write denial (record_write_denied; emitter conversion is #12260's half) and plugin-approvals' non-submitter recall refusal (approval_recall_not_submitter; emitter conversion is #11993's half).⇒ The spec half already landed under #12493.
record_write_deniedis a shipped key in all locales, and it was designed for this emitter — the module even explains why it is notrecord_access_deniedrestated:the sharing middleware's by-id write gate fires on a row the READ path already admitted — the user is typically looking at the record it refuses — so "You do not have access to this record" would be false the moment it rendered. The situation is read-yes/write-no.
⇒ This card is not "add i18n to sharing". It is the outstanding consumer half of work already done, and it is the last of the pair: its twin, #11993's half, landed today as PR #12725.
Grading
finding→pm:queue,bug+domain:servicesretained.findingwas never right for this one — it is a customer bug report, not something a seat recorded in passing; it was only ever carrying "awaiting first grading".Clause-②:
no. The key already exists, so the change is emitter-side inplugin-sharingwith nopackages/specchange, and the refusal's gate does not move — only the sentence. That is exactly how the twin (#12725) was graded, and it landed on that grading.⇒ Dispatchable at this lane's ordinary tier, with an unusually complete brief: named emitter, named target key, and a merged same-lane precedent to mirror.
Generated by Claude Code
- 任一自定义对象声明
Claimed →
pm:dispatchedPM seat
domain:services, sessionsession_0194kbQJxUvv2yvsGRtuXpP5. Dev branch:claude/issue-12260-sharing-write-denial-i18n. Clause-②no, per the grading in my previous comment.Dispatched to convert the emitter at
packages/plugins/plugin-sharing/src/sharing-plugin.ts:1119-1123onto the already-shippedrecord_write_deniedcatalog key, mirroring PR #12725 — its twin, which did the same conversion forapproval_recall_not_submitterinplugin-approvalsand merged today.Three fences carried into the dispatch order:
- ⛔ No
packages/specchange. The key ships already (Operation Message Catalog: add refusal-situation keys for approvals recall and sharing write-denial — two measured hardcoded-English refusals waiting on the one sanctioned mechanism #12493). Adding or editing one would flip the clause-② grading and put this above the lane's tier. - ⛔ The
FORBIDDEN:prefix is wire contract, not copy. REST maps it to 403; a localized sentence must not shadow that. fix(plugin-approvals): render the non-submitter recall refusal through the operation-message catalog #12725 pinned this separately and so must this. - ⛔ The
delete-verb diagnostic log at:1113-1118stays. It is a developer-facing greppable reason for the ADR-0111 D3 tightening, deliberately distinct from the user-facing sentence, andrecord_write_deniedis documented as one key for both write verbs — which verb was refused stays a developer fact ondeveloperMessageand the structureddetails.
The test must assert the rendered zh-CN sentence as a literal, not read back out of the catalog — a test that renders the catalog against itself cannot tell Chinese from English, which is the entire reported defect.
Generated by Claude Code
- ⛔ No
os-dev-report
{ "issue": 12260, "status": "done", "branch": "claude/issue-12260-sharing-write-denial-i18n", "pr": "https://github.com/objectstack-ai/objectstack/pull/12976", "premise_still_valid": true, "summary": "Converted the sharing middleware's by-id write denial at packages/plugins/plugin-sharing/src/sharing-plugin.ts onto the already-shipped record_write_denied catalog key, rendering through renderOperationMessage from @objectstack/spec/system — mirroring PR #12725's shape. No packages/spec change: the key already ships in all four locales. buildSharingMiddleware gained an optional third argument (a lazily-resolved II18nService.t-compatible lookup, wired by SharingServicePlugin) so the documented errors.record_write_denied override address takes effect; all eight existing call sites are unchanged. The FORBIDDEN: prefix and the ADR-0111 D10 delete breadcrumb are byte-identical and each newly pinned; the verb, object API name and row id moved to developerMessage, a structured details, and a log line. One key serves both write verbs, per the catalog's own ruling. Also corrected skills/objectstack-data/references/data-hooks.md, which printed the old sentence as the literal a hook author would see — token-neutral (+0), see below.", "tests": "All runs and the whole gate union measured on final head ae4cfcce7 (re-run after the last commit; tree clean, git status --porcelain empty). NEW: packages/plugins/plugin-sharing/src/write-denial-user-copy.test.ts, 16 cases — the zh-CN sentence asserted as a LITERAL (not read back out of the catalog), the user-facing half asserted to contain no [A-Za-z], both write verbs covered, the FORBIDDEN: prefix pinned separately from the copy (matches the idiom AND strips clean), plus locale matrix, unknown-locale fallback, deployment translation override, misbehaving-i18n degradation, the developer half, the D10 breadcrumb, read-yes/write-no, and a who-may-write control block. RUNS: plugin-sharing full suite 'Test Files 29 passed (29) / Tests 673 passed (673)'; packages/rest six named wire-contract files plus rest.test.ts 'Test Files 6 passed (6) / Tests 319 passed (319)' UNMODIFIED; plugin-security's six buildSharingMiddleware consumers 'Test Files 6 passed (6) / Tests 96 passed (96)'; repo-wide pnpm lint (eslint . --no-inline-config, whole tree, NOT narrowed) exit 0 with no diagnostics; plugin-sharing typecheck exit 0. ABLATION — direction predicted before running (red, with the old English sentence): reverted only the catalog render to the hardcoded throw. Mutation proven on disk BEFORE any verdict was read — HEAD_BLOB=80c9911d2406198ec3efdc122b4c6fc68c1f8b5f, BEFORE=80c9911d24..., AFTER=a10768ca77b06fc6393d994279f72332b990d0fe, injected-text grep -c=1, removed-text grep -c=0, 'MUTATION CONFIRMED ON DISK'. Verdict 'Tests 21 failed | 15 passed (36)', the reported defect character for character: Expected 'FORBIDDEN: 您无权修改或删除这条记录,如需修改请联系该记录的负责人或管理员。' Received 'FORBIDDEN: insufficient privileges to update os_inquiry inq_theirs'. The 15 still-green are the control (who-may-write, D10, read-yes) — so the ablation measured the copy, not the gate. Restore verified by STATE not by the trap's exit code: RESTORED hash == HEAD blob AND git status --porcelain empty, under trap ... EXIT INT TERM with absolute paths. NO REBUILD LEG NEEDED, and that is measured: the suite reaches the mutated module by relative source import ('./sharing-plugin.js') and packages/plugins/plugin-sharing/dist does not exist in this worktree while the tests pass, so nothing resolves through the package's exports. GATES: derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack on the ACTUAL changed set, then RE-DERIVED after skills/ entered the diff — which added 8 families the order could not have named (check-skills-token-ratchet, check:doc-authoring, check:role-word, check:skill-compatibility, check:skill-frame-sync, check:pm-governed-merges, check:skill-refs, check:doc-formula-expressions). All families green; judgment lines quoted, never a bare exit code: 'check-engine-double-contract: 644 (file, verb) row(s) held by the RETAINED ledger'; 'check-type-check-coverage: OK — 65/78 workspace packages type-checked (plus the root), 13 in the DEBT ledger, 1 exempt.'; 'check-type-check-coverage --re-measure: OK — 31 ledger entr(ies) re-measured in 290.8s, 1570 raw tsc error(s) total, none above its recorded number.'; 'check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).'; 'check-skills-token-ratchet: skills/objectstack-data/references/data-hooks.md is 12611 tokens (ceiling 12611; headroom 0)' and '38 authored bundle file(s) within their ceilings'. scripts/engine-double-contract.pinned.json gained 3 rows via the gate's own --write, as it instructs. SKILLS READINGS (both required): data-hooks.md whole file 1446 to 1446 lines (+0) and 12611 to 12611 tokens (+0); objectstack-data published dir 4935 to 4935 lines (+0); whole published bundle ratcheted authored total 170520 to 170520 tokens (+0). A first draft of that correction measured +113 tokens over the ceiling; the ratchet marks raising it MAINTAINER-ONLY and the dispatch granted no skills budget, so the prose was dropped rather than the ceiling raised. TWO NOT-MEASURED, reported as such and NOT as passes: (1) plugin-sharing/tsconfig.json excludes the test glob, so the green typecheck says nothing about my test files — measured with tsc --listFiles: sharing-plugin.ts 1 hit, write-denial-user-copy.test.ts 0 hits, authored-row-write-deferral.test.ts 0 hits; their type coverage comes from check:type-check-debt --re-measure, which re-measures this package's TEST_DEBT entry with the exclusion lifted and reported none above its recorded number. (2) node scripts/pm/check-half-states.mjs exits 3 = 'PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential'; it swept nothing, so it is neither a clean board nor a dirty one. Unrelated to this diff; CI runs it with a real credential.", "mcp_calls": "8 — issue_read(get_comments), search_issues(one targeted duplicate check), issue_write(create #12975), create_pull_request, pull_request_read(body read-back), update_pull_request(add the head sha), add_issue_comment(this report), issue_read(comment read-back). CHANNEL SWITCH DECLARED: repo-scoped REST is 403 in this container (positive control api.github.com/ = 200) and gh is absent, so the duplicate check used one targeted MCP search_issues rather than a REST list; it returned 13 results, so the non-empty reading needed no control word. PR #12725 and the card's own history were read through git, at zero quota.", "open_questions": [], "out_of_scope_findings": [ "filed as #12975: the /data door ships the ADR-0111 'CODE:' prefix inside the user-facing 'error' string, so the now-localized sharing refusal still renders with a machine token in front of it in a toast — classifyDataError's declared-4xx arm passes error.message verbatim (error-response.ts:1019-1033) and two REST tests pin that byte-for-byte, while the share routes and handleApprovalError DO strip it (rest-server.ts:10867), so the two doors disagree and rest-server.ts's own #8111 comment asserts the prefix 'never reaches the wire'. Pre-existing, not a regression from this card: the prefix rode in front of the old English sentence identically. Left alone deliberately — changing which bytes /data ships is a REST envelope decision that moves legitimate wire-behaviour assertions. Filed unassigned, no labels, for PM triage." ] }
Generated by Claude Code
os-dev-report
⚠️ Supersedes the earlieros-dev-reporton this card. That one named one PR and headae4cfcce7; the work has since been split in two on a governed-surface ruling, and the head moved. This is the current record.{ "issue": 12260, "status": "done", "branch": "claude/issue-12260-sharing-write-denial-i18n", "pr": "https://github.com/objectstack-ai/objectstack/pull/12976", "premise_still_valid": true, "summary": "Converted the sharing middleware's by-id write denial (packages/plugins/plugin-sharing/src/sharing-plugin.ts) onto the already-shipped record_write_denied catalog key via renderOperationMessage from @objectstack/spec/system, mirroring PR #12725. No packages/spec change — the key already ships in all four locales. buildSharingMiddleware gained an optional third argument (a lazily-resolved II18nService.t-compatible lookup wired by SharingServicePlugin) so the errors.record_write_denied override address takes effect; all eight existing call sites unchanged. The FORBIDDEN: prefix and the ADR-0111 D10 delete breadcrumb are byte-identical and each newly pinned; the verb, object API name and row id moved to developerMessage, a structured details, and a log line. DELIVERED AS TWO PRs: #12976 is code-only (5 files, no governed path) and carries 'Fixes #12260'; #12987 carries the single skills/ doc file and 'Part of #12260'. The split is Prime Directive #14: skills/** is on the GOVERNED_SURFACES register (printed today: docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.md), PD#14 judges a PR on its FILE LIST not its description, a mixed diff is not a proportion question, and the named remedy is to split the governed files out. The doc commit was reverted off the code branch (no force-push); #12976's remote file list was then re-read with get_files and contains no skills/ path. Both PRs are drafts, neither armed nor queued; #12987 is a governed-surface PR left visibly awaiting a human merge, which PD#14 defines as its finished state. The two want landing together — between them, data-hooks.md briefly documents a string the platform no longer emits.", "tests": "Code PR re-measured end to end on final head e6ec9a9e5 (after the revert; tree clean, git status --porcelain empty). NEW: packages/plugins/plugin-sharing/src/write-denial-user-copy.test.ts, 16 cases — zh-CN asserted as a LITERAL (not read back from the catalog), the user-facing half asserted to contain no [A-Za-z], both write verbs, the FORBIDDEN: prefix pinned separately from the copy (matches the idiom AND strips clean), plus locale matrix, unknown-locale fallback, deployment translation override, misbehaving-i18n degradation (403 not 500), the developer half, the D10 breadcrumb, read-yes/write-no, and a who-may-write control block. RUNS on e6ec9a9e5: plugin-sharing 'Test Files 29 passed (29) / Tests 673 passed (673)'; packages/rest six named wire-contract files plus rest.test.ts 'Test Files 6 passed (6) / Tests 319 passed (319)' UNMODIFIED; plugin-security's six buildSharingMiddleware consumers 'Test Files 6 passed (6) / Tests 96 passed (96)'; repo-wide pnpm lint (eslint . --no-inline-config, whole tree, NOT narrowed) exit 0 no diagnostics; plugin-sharing typecheck exit 0. ABLATION (unchanged, direction predicted first = red): mutation proven on disk before any verdict — HEAD_BLOB=80c9911d24..., AFTER=a10768ca77..., injected grep -c=1, removed grep -c=0, 'MUTATION CONFIRMED ON DISK'; verdict 'Tests 21 failed | 15 passed (36)' with Expected 'FORBIDDEN: 您无权修改或删除这条记录,…' Received 'FORBIDDEN: insufficient privileges to update os_inquiry inq_theirs' — the report character for character. The 15 still-green are the control (who-may-write, D10, read-yes), so it measured the copy and not the gate. Restore verified by STATE: hash == HEAD blob AND empty porcelain, under trap with absolute paths. No rebuild leg needed, and measured: relative source import plus no plugin-sharing/dist in the worktree while tests pass. GATES RE-DERIVED on the REDUCED set: the eight skills families the mixed diff had pulled in each return ZERO hits now (check-skills-token-ratchet, check:doc-authoring, check:role-word, check:skill-compatibility, check:skill-frame-sync, check:pm-governed-merges, check:skill-refs, check:doc-formula-expressions) — confirmed by re-running the derivation, not assumed. 36 families run on the code PR, all green except the credential one below; judgment lines quoted, never a bare exit code: 'check-engine-double-contract: 644 (file, verb) row(s) held by the RETAINED ledger'; 'check-type-check-coverage: OK — 65/78 workspace packages type-checked (plus the root), 13 in the DEBT ledger, 1 exempt.'; 'check-type-check-coverage --re-measure: OK — 31 ledger entr(ies) re-measured in 293.0s, 1570 raw tsc error(s) total, none above its recorded number.'; 'check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).'. SKILLS PR #12987 verified separately on head b42a3cfe3: its own derived families all green, including 'check-skills-token-ratchet: skills/objectstack-data/references/data-hooks.md is 12611 tokens (ceiling 12611; headroom 0)' and '38 authored bundle file(s) within their ceilings'. Readings for that PR: file 1446 to 1446 lines (+0) and 12611 to 12611 tokens (+0); objectstack-data published dir 4935 to 4935 lines (+0); bundle-wide authored total 170520 to 170520 (+0). skip-changeset applied to #12987 (it releases nothing — skills/ is in no package's files array, and changeset-check has no docs-only exemption); label written as a whole-set union over the live labeler values and READ BACK: ['documentation','size/xs','skip-changeset']. THREE NOT-MEASURED, reported as such and NOT as passes: (1) plugin-sharing/tsconfig.json excludes the test glob, so the green typecheck says nothing about my test files — tsc --listFiles gives sharing-plugin.ts 1 hit, write-denial-user-copy.test.ts 0, authored-row-write-deferral.test.ts 0; their type coverage comes from check:type-check-debt --re-measure, none above its recorded number. (2) node scripts/pm/check-half-states.mjs exits 3 PREREQUISITE NOT MET (env token is not a valid GitHub credential) on both branches — swept nothing, unrelated to either diff. (3) On the skills branch check:doc-formula-expressions first reported PREREQUISITE NOT MET twice (needed @objectstack/formula, then @objectstack/lint built); both were built and it then exited 0.", "mcp_calls": "17 total across the run — 8 before the split (issue_read, search_issues, issue_write for #12975, create_pull_request, pull_request_read, update_pull_request, add_issue_comment, issue_read) and 9 during it (issue_read get_labels which the GraphQL Issue lookup rejects for a PR number, search_issues, create_pull_request for #12987, list_pull_requests to read live labels, issue_write to write the label union, list_pull_requests read-back, pull_request_read get_files on #12976, update_pull_request, add_issue_comment). CHANNEL SWITCHES DECLARED: repo-scoped REST is 403 in this container (positive control api.github.com/ = 200) and gh is absent, so (a) the duplicate check for #12975 used one targeted MCP search_issues, and (b) the skip-changeset label could not use the additive REST endpoint and took the measured fallback — read live values, union, whole-set write, mandatory read-back, which confirmed the labeler's own labels survived. PR #12725, AGENTS.md PD#14, the governed register and the card history were all read through git and node at zero quota.", "open_questions": [], "out_of_scope_findings": [ "filed as #12975: the /data door ships the ADR-0111 'CODE:' prefix inside the user-facing 'error' string, so the now-localized sharing refusal still renders with a machine token in front of it — meaning the reporter's symptom is only HALF resolved by this card. classifyDataError's declared-4xx arm passes error.message verbatim (error-response.ts:1019-1033) and two REST tests pin that byte-for-byte, while the share routes and handleApprovalError DO strip it (rest-server.ts:10867), so the two doors disagree and rest-server.ts's own #8111 comment asserts the prefix 'never reaches the wire'. Pre-existing, not a regression: the prefix rode in front of the old English sentence identically. Left alone deliberately — changing which bytes /data ships is a REST envelope decision that moves legitimate wire-behaviour assertions. Filed unassigned, no labels, for PM triage." ] }
Generated by Claude Code
⚠️ When this card closes, one half is still outstanding — #12987, and it needs a humanPR #12976 (the code half) is armed and will close this card on merge. Recording the remainder here so it is not lost with the close.
#12987 carries a single file —
skills/objectstack-data/references/data-hooks.md, +1/−1 — and ⛔ cannot be landed by any AI seat.skills/**is on today's governed-surface register (printed from the authority, not from prose:docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.md), and AGENTS.md Prime Directive #14 reserves the landing: ⛔ never merge, ⛔ never queue, ⛔ never arm, ⛔ never flip out of draft. It is left a draft awaiting a human merge, which is its finished state, not a stalled one.Why it exists as its own PR. That doc printed the old English sentence as the literal a hook author would match on, so it goes false the moment #12976 lands. It was originally part of #12976; PD#14 judges a PR on its file list, not its description, and "a mixed diff is not a proportion question — one path hit is enough", with the named remedy being to split the governed files out. Splitting is what lets this customer-reported fix land now instead of waiting behind the governed-merge queue — which genuinely runs long: #11188 in this lane has been
pm:awaiting-maintainersince 2026-08-23.⚠️ Between the two merges,data-hooks.mddocuments a string the platform no longer emits. That window is the accepted cost of the split. The replacement text —FORBIDDEN: <a localized sentence; match on code, not prose>— is better advice than the literal it replaces regardless of this card, so the sooner it lands the better.@maintainer: #12987 is one line, token-neutral (the skills ratchet reads 12611 → 12611 tokens, 1446 → 1446 lines, headroom 0), and its own gate families are green. A first draft measured +113 tokens over a ceiling the ratchet marks MAINTAINER-ONLY; the dev dropped the prose rather than raise the ceiling.
Generated by Claude Code
现象(@objectstack/*@17.2.0)
对声明
sharingModel: 'public_read'的对象,持对象级 allowEdit 但非记录属主的用户 by-id PATCH 一条记录,sharing 中间件拒绝时返回给客户端的 message 是英文硬编码原文(FORBIDDEN: insufficient privileges to update <object> <id>形态),端上直接把它展示给了终端用户。对照:plugin-security 的记录级拒绝走 i18n 渲染(
record_access_denied键 / userFacingDenialMessage),同一环境下返回的是本地化中文(「您无权访问这条记录,如需访问请联系该记录的负责人或管理员。」)。同一个「没权限写这条记录」的用户场景,走到哪一层拒绝决定了用户看到人话还是英文,行为不一致。复现步骤
sharingModel: 'public_read'+access: { default: 'private' };期望
sharing 层的写/删拒绝复用与
record_access_denied相同的 i18n 渲染机制(或给出稳定的 message key 供端上映射),让应用侧在任何一层被拒都能拿到本地化人话文案。补充