Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-sharing): render the by-id write denial through the operation-message catalog - #12976
Conversation
…ion-message catalog The sharing middleware's by-id write gate refused with one hardcoded English sentence naming the object's API name and the row's opaque id. `@objectstack/rest` ships it as the 403 body's human-readable `error` and clients show it verbatim, so a user in a fully Chinese deployment read English prose they could not act on. The refusal now renders through the shared Operation Message Catalog in `@objectstack/spec/system` under the `record_write_denied` key that landed for it — the same mechanism plugin-security's record-level denial uses, which is the comparison the report drew. One key serves both write verbs; the verb, object and row id move to `developerMessage`, `details` and the log. `buildSharingMiddleware` gains an optional third argument, a lazily resolved `II18nService.t`-compatible lookup wired by `SharingServicePlugin`, because the i18n service is contributed by another plugin and may start later. Not changed: who may write, the `FORBIDDEN:` prefix the REST layer classifies 403 on, and the ADR-0111 D10 delete-verb diagnostic breadcrumb. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5
…er emits `data-hooks.md` quoted the by-id write gate's message verbatim. That sentence is now end-user copy rendered in the caller's locale, so a hook author reading the doc would string-match prose that varies by locale. The fence names the shape and points at the stable channel (the error's code) instead. Token-neutral by construction: the published-bundle ratchet reads 12611 against a ceiling of 12611 (+0), so this correction spends no context budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5
📓 Docs Drift Check4 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 — 8 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 200740369901972aab3c3fe8ba83aed62d840666 && git checkout 200740369901972aab3c3fe8ba83aed62d840666
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 196a6c73e1284498fee046431d46f084c369a262 e6ec9a9e5cc1c5673f9bd0aa4e5c30c8fbdc6bf2 && git checkout -B drift-repro 196a6c73e1284498fee046431d46f084c369a262 && git merge --no-ff e6ec9a9e5cc1c5673f9bd0aa4e5c30c8fbdc6bf2
node scripts/docs-audit/affected-docs.mjs --json 196a6c73e1284498fee046431d46f084c369a262 |
… no longer emits" This reverts commit ae4cfcc. Not a change of mind about the edit — it is correct and necessary, and it lands unchanged in its own PR. `skills/**` is a governed surface on the `GOVERNED_SURFACES` register in scripts/pm/check-governed-merges.mjs, and Prime Directive #14 judges a PR on its FILE LIST, not its description: a mixed diff is not a proportion question, one path hit forks the whole PR and reserves the landing for a human. Keeping the doc here would have made this branch unmergeable by anything but a hand merge. The doc half now rides claude/issue-12260-skills-data-hooks-prose, with the replacement text byte-identical. The two want landing together; until the doc one lands, data-hooks.md briefly prints a string the gate no longer emits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5
os-litant
commented
Aug 28, 2026
Review — ACCEPT, armed. The governed split is verified independently.PM seat The split — re-measured myself, because PD#14 judges on the file listI told the dev I would re-read this rather than take the report's word, and did. On head Five files, and matched against today's printed register ( ⇒ This PR is landable by this seat. ⛔ #12987 is not, and is left a draft awaiting a human merge, which PD#14 defines as its finished state. What I verified in the code half
That last pair is the distinction the order existed to force, and it was made correctly in both directions rather than by making things green. ⭐ Two things beyond what was askedThe zh-CN sentence is a literal, not read back out of the catalog, with §4 measures read-yes/write-no on the fixture itself. The catalog's justification for Also pinned: a deployment Ablation reproduced the report character for character —
|
Fixes#12260
A user holding object-level allowRead + allowEdit — and no
modifyAllRecords— PATCHed a record they do not own on an object declaringsharingModel: 'public_read'withaccess: { default: 'private' }. The sharing middleware refused, correctly, and the deployment's client showed the server's reason verbatim: one hardcoded English sentence naming the object's API name and the row's opaque id. In a fully Chinese deployment that was the only thing the user was told about why their save failed.The comparison the reporter drew is exact:
plugin-security's record-level denial already renders localized copy through the catalog (userFacingDenialMessage), so the same "I can see this record but cannot change it" situation showed human language or raw English depending on which layer refused.This is the outstanding consumer half of work already done.
record_write_deniedshipped in all four platform locales under #12493, which named this card as its emitter half. Nopackages/specchange here — the key already exists. The twin, PR #12725, did the same conversion forapproval_recall_not_submitterinplugin-approvals; this mirrors its shape deliberately.What changed
packages/plugins/plugin-sharing/src/sharing-plugin.tsuserFacingWriteDenial(locale, messageTranslator)rendersrecord_write_deniedthroughrenderOperationMessagefrom@objectstack/spec/system— sameerrors.KEYoverride address, same resolution ladder (deployment override, then the caller's locale, thenen, then the key), same guarantee that a misbehaving i18n service cannot turn a 403 into a 500.buildSharingMiddlewaregains an optional third argument, a lazily-resolvedII18nService.t-compatible lookup wired bySharingServicePlugin. Lazy for the reason ADR-0029 D8 makes structural: the i18n service is contributed by a different plugin that may start after this one, so a lookup captured at build time would pinundefinedfor the process lifetime. Additive — all eight existing call sites pass two arguments and are unchanged, and a stack with no i18n service still renders the caller's locale from the built-in catalog.developerMessage, to a structureddetails, and to a log line. REST forwards neither field on aFORBIDDENbody (onlyDELETE_RESTRICTEDforwards adeveloperMessage), so nothing was added to the wire.Why this key and not
record_access_deniedFrom the catalog's own header, quoted because it is the reason the key exists:
The new suite measures the read-yes half rather than quoting it:
read-yes/write-no: the same user reads the very row the write refusesdrives afindand the refusedupdatethrough the same middleware on the same fixture.It is also one key for both write verbs, which is the catalog's ruling and not a shortcut: the user's situation and remedy are identical for
updateanddelete. Which verb was refused stays a developer fact.The three fences, and how each is pinned
1. No
packages/specchange. None was made; the diff touches no file underpackages/spec.2. The
FORBIDDEN:prefix is wire contract, not copy. It is applied around the rendered sentence, never by it. Pinned separately from the copy, exactly as #12725 did:and the ADR-0112 envelope (
code: 'FORBIDDEN',status: 403) is asserted on both verbs.3. The
delete-verb ADR-0111 D10 breadcrumb stays. Byte-identical, and now pinned by a test of its own (the ADR-0111 D10 delete breadcrumb still fires, in its own words). The new developer-fact log is a second, differently-worded line: D10 says why the D3 tightening refuses, the new one says who/what/which verb.Triage of the six tests that read the old string — wire behaviour vs copy
Asked per test, not blanket-updated.
packages/rest/src/rest-4xx-message-truncation.test.ts:123packages/rest/src/rest-5xx-status-passthrough.test.ts:301packages/rest/src/rest-share-declared-code.test.ts:289codeis not repeated indeclaredCode; the sentence is incidentalpackages/rest/src/rest-share-refusal-classification.test.ts:226FORBIDDEN+ 403 resolves to 403 on all three share routespackages/rest/src/rest-share-user-message.test.ts:318userMessagerides the nested envelope; the technical message is the carrier, not the subjectpackages/plugins/plugin-sharing/src/authored-row-write-deferral.test.ts:408expectSharingRefusalasserted the sentence itself, as the discriminator between the two refusing authoritiesenrender plusdeveloperMessage, so the discriminator survives where it actually livesThe five REST files were left byte-identical and re-run rather than assumed: all six REST files (319 tests) pass unmodified, which is itself the evidence that their literals are independent of the emitter. A seventh site the order did not name,
packages/rest/src/rest.test.ts:2440, is the same wire shape and also green.Two further sites were examined and deliberately left alone:
packages/runtime/src/action-body-identity.test.ts:43fabricates a refusal in its own engine stub to exercise identity threading (it asserts nothing about the sentence), and theCHANGELOG.mdentries are history.The doc half, and why it is not in this PR
skills/objectstack-data/references/data-hooks.mdprinted the old sentence as the literal a hook author would match on. That correction is necessary — the string is never emitted once this lands — but it is not in this PR, and its absence is deliberate.skills/**is on theGOVERNED_SURFACESregister inscripts/pm/check-governed-merges.mjs, printed today asdocs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.md. Prime Directive #14 judges a PR on its file list, not its description, and states that a mixed diff is not a proportion question — one path hit is enough, and the named remedy is to split the governed files into their own PR. Carrying the doc here would have reserved this PR's landing for a hand merge and kept it out of the queue entirely.⇒ #12987 carries that one file and nothing else, as a draft awaiting a human merge. It was on this branch for two commits and has been reverted out (
Revert "docs(skills): …"), so the net diff below contains no governed path. The two want landing together: between them,data-hooks.mdbriefly documents a string the platform no longer emits.Ablation — the reported defect, reproduced
Direction predicted before running: red, with the old English sentence as the actual value.
Reverting only the catalog render to the pre-change hardcoded throw, with the mutation proven on disk before any verdict was read:
Result —
Tests 21 failed | 15 passed (36):That is the customer report, character for character.
The 15 that stayed green under the mutation are the control: the whole §5 "who may write" block, the D10 breadcrumb and the read-yes case are untouched by the ablation, which is what makes it a measurement of the copy rather than of the gate.
Restore was verified by state, not by the trap's exit code:
No rebuild leg was needed and that is a measured fact, not an assumption: the suite reaches the mutated module through a relative source import (
./sharing-plugin.js), andpackages/plugins/plugin-sharing/distdoes not exist in this worktree while the tests pass — so nothing here resolves through the package'sexports.Verification
Every run below, and the whole gate union, was re-measured on
e6ec9a9e5— the final head after the governed-surface revert, so the green union describes the tree that ships rather than an earlier one.plugin-sharingfull suiteTest Files 29 passed (29)·Tests 673 passed (673)rest— all six named wire-contract files plusrest.test.tsTest Files 6 passed (6)·Tests 319 passed (319)plugin-security— the six suites that callbuildSharingMiddlewareTest Files 6 passed (6)·Tests 96 passed (96)pnpm lintrepo-wide (eslint . --no-inline-config, whole tree, not narrowed)pnpm --filter @objectstack/plugin-sharing typecheckGate families re-derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackon the reduced changed set. Removing the governed path drops the eight skills families the earlier mixed diff pulled in — confirmed by re-running the derivation, not assumed:check-skills-token-ratchet,check:doc-authoring,check:role-word,check:skill-compatibility,check:skill-frame-sync,check:pm-governed-merges,check:skill-refsandcheck:doc-formula-expressionseach return zero hits in the new list. They now belong to #12987, where they were run and are green.36 families were run here. All green except the one noted below. Selected judgment lines:
scripts/engine-double-contract.pinned.jsongains the three rows for the new fake engine, written by the gate's own--writeas it instructs.Two honest gaps, neither a pass:
packages/plugins/plugin-sharing/tsconfig.jsonexcludes**/*.test.ts, so the greentypechecksays nothing about the two test files. Measured rather than assumed —tsc --listFilescontainssharing-plugin.ts(1 hit) and neither test file (0 hits each). Their type coverage comes fromcheck:type-check-debt --re-measure, which re-measures this package's TEST_DEBT entry with the exclusion lifted and reported no count above its recorded number.node scripts/pm/check-half-states.mjsexits 3 = PREREQUISITE NOT MET in this container ("the token in the environment is not a valid GitHub credential"). It swept nothing, so it is NOT MEASURED — neither a clean board nor a dirty one. It is a backlog sweep unrelated to this diff and CI runs it with a real credential.Filed out of scope
#12975 — the
/datadoor ships the ADR-0111CODE:prefix inside the user-facingerrorstring, so this now-localized refusal still renders with a machine token in front of it in a toast. That means the reporter's symptom is only half resolved by this card. Pre-existing and untouched here: the prefix rode in front of the old English sentence identically, and changing which bytes/dataships is a REST envelope decision that moves two legitimate wire-behaviour assertions. The share routes andhandleApprovalErrordo strip it, so the two doors disagree — andrest-server.ts's own comment asserts the prefix "never reaches the wire", which holds for that door and not for this one.Generated by Claude Code