Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-approvals): render the non-submitter recall refusal through the operation-message catalog - #12725
Conversation
…h the operation-message catalog A non-submitter who clicked Recall on someone else's pending approval was correctly refused, but read the reason in English regardless of their own locale: the emitter threw one hardcoded sentence, `@objectstack/rest`'s `handleApprovalError` ships it as the 403 body's human-readable `error`, and Console splices that under its own localized label — so an operator in a fully Chinese deployment saw a Chinese prefix glued onto English prose (#11993). The refusal now renders through the shared Operation Message Catalog in `@objectstack/spec/system` under the `approval_recall_not_submitter` key that landed for it, the same mechanism plugin-security's denial gates use: same `errors.<key>` override address, same resolution ladder, same guarantee that a misbehaving i18n service cannot turn a 403 into a 500. The developer's half — the actor and submitter ids the catalog sentence deliberately omits — moves to the log. `ApprovalServiceOptions` gains an optional `messageTranslator`, resolved lazily and wired by `ApprovalsServicePlugin` alongside `tenancyPosture` and the field-visibility source, because the i18n service is contributed by another plugin and may start later. It is what makes the catalog's documented override address take effect for this emitter. Not changed: who may recall. The gate is byte-identical (the submitter, or a privileged admin releasing a stuck record) and the `FORBIDDEN:` prefix the REST layer maps to 403 is untouched. Both are pinned by new tests, alongside the zh-CN rendering an operator actually reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 5 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 bdb6789809028cdd4283fc0a7a17589dbfc50e60 && git checkout bdb6789809028cdd4283fc0a7a17589dbfc50e60
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d29e42f8b1b44b92ea58c1d05e619f27c027c3a9 3e5a7bc1143dd0f020a19f1f13ddb1c46a8370e0 && git checkout -B drift-repro d29e42f8b1b44b92ea58c1d05e619f27c027c3a9 && git merge --no-ff 3e5a7bc1143dd0f020a19f1f13ddb1c46a8370e0
node scripts/docs-audit/affected-docs.mjs --json d29e42f8b1b44b92ea58c1d05e619f27c027c3a9
|
Uh oh!
There was an error while loading. Please reload this page.
Part of #11993 — this addresses the card's SECOND expectation only (the refusal message). The first (button visibility) is measured below and deliberately not implemented here, so the card stays open.
The defect
A non-submitter who opened a record someone else had submitted for approval, clicked Recall and was correctly refused read the reason in English regardless of their own locale.
ApprovalService.recallthrew one hardcoded sentence.handleApprovalErrorin@objectstack/restmaps this service'sCODE: messagethrows onto the wire by testing the prefix for the status and then stripping it, shipping what remains as the 403 body's human-readableerror— and Console splices that under its own localized label. So an operator in a fully Chinese deployment read a Chinese prefix glued onto English prose they could not act on.Note the card's own quoted string is stale and was corrected on the card before dispatch: the message reported against 17.2.0 is a repo-wide zero-hit today. Re-measured on
origin/mainat15bf9e859, the live refusal wasFORBIDDEN: only the submitter may recall this requestinpackages/plugins/plugin-approvals/src/approval-service.ts. Still English, still spliced — the defect held, only the sentence had moved.The change
The refusal renders through the shared Operation Message Catalog (
@objectstack/spec/system) under theapproval_recall_not_submitterkey that #12493 landed for it — the mechanism triage ruled (shape A) and the one the catalog's own header names, barring both a package-local string table and a second mechanism for a second producer. Sameerrors.override 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.plugin-security'suserFacingDenialMessageis the sibling consumer this mirrors.The developer's half — the actor and submitter ids the catalog sentence deliberately omits, because the throw site knows the submitter only as an opaque user id — moves to the log, structured, where a developer reads it and a user never does.
The one addition beyond the emitter, named
ApprovalServiceOptionsgains an optionalmessageTranslator, andApprovalsServicePluginwires it lazily. This is not incidental scope: the spec half's own changeset states that once the consumer halves land, "any deploymenttranslationdefining the twoerrors.*keys takes effect then". Without a translator reachingrenderOperationMessage, the override address the catalog documents is inert for this emitter — a declared-but-unenforced surface. The shape is copied from the two providers already in this plugin'sstart()(tenancyPosture, the field-visibility source) and fromplugin-security's per-call i18n resolution, for the same reason all of them are lazy: the i18n service is contributed by another plugin (ADR-0029 D8) and may start later. A stack with no i18n service still renders the built-in catalog in the caller's locale.Not changed: who may recall
The gate is byte-identical — the submitter, or a privileged admin releasing a stuck record (#3424). Only the sentence changed, and the
FORBIDDEN:prefix the REST layer maps to 403 is untouched. Three control tests pin all three answers, and one pins the prefix surviving the strip.Verification
Gate union and suite run at
3e5a7bc11, the branch head.pnpm --filter @objectstack/plugin-approvals exec vitest run— 34 files / 621 tests passed (11 of them new).pnpm --filter @objectstack/plugin-approvals run typecheck— clean. Scope stated honestly:--listFilesconfirms it coversapproval-service.tsandapprovals-plugin.ts(1 hit each) and not the new test file (0 hits) — this package's tsconfig excludes**/*.test.ts. The test file's type coverage comes fromcheck:type-check-debt --re-measureinstead, which reports the package's TEST_DEBT unchanged.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackon the actual changed set (re-derived afterscripts/engine-double-contract.pinned.jsonentered it, which pulled in six further families — all run). Green:engine-double-contract,where-matcher,query-options-erasure,type-check-coverage,type-check-debt,i18n,i18n-stale-fill,nul-bytes,changeset-gate-self-tests,cross-package-test-inputs,objectql-double-limit,objectui-changeset,page-declaration-shape,pm-half-states,published-files,slot-lookup,test-source-alias,type-source-resolution,agent-test-spelling,bash32-floor,cli-command-ids,entry-guard,parse-guard,pnpm-filter-targets, pluscheck-adr-0087-registration,check-changeset-no-major,check-ci-filter-parity,check-comment-mask-adoption,check-cross-package-test-inputs,check-empty-changeset,check-plugin-teardown-shape,release-rehearsal-clone --self-test.scripts/pm/check-half-states.mjsrefuses withPREREQUISITE NOT MET(no valid GitHub credential in this container, exit 3 — its own text says nothing was swept). CI runs it.check:engine-double-contractinitially failed on the new test file's fake engine. Repaired the self-serve way —delete()andupdate()now open withassertEngineDeleteDispatch/assertEngineUpdateDispatch, and the pinned ledger learned the two new rows via--write. The shrink-only baseline was not touched.Ablation
Reverted the catalog call to the old hardcoded throw, leaving everything else (including the developer log) in place.
1 -> 0, legacy-string sites0 -> 1, blob hash6be2255...toc2ee2c7...../approval-service.js), so vitest transformssrc/approval-service.tsitself — demonstrated when a malformed doc comment in that source file surfaced as a vitest transform parse error namingsrc/approval-service.ts.@objectstack/specdoes resolve throughexportstodist/, and it was built first; the freshly builtdist/system/index.jswas confirmed to carry the key (4 locale hits) before anything was read.Received: "FORBIDDEN: only the submitter may recall this request"against the expected Chinese sentence — the reported defect, reproduced. The 5 that stayed green are exactly the ones the mutation does not reach: the code-prefix row, the developer-log row, and the three permission-boundary controls.6be2255...equals the HEAD blob byte-for-byte,git diff HEADempty,git statusclean, marker counts back to1 / 0.What this PR does NOT do — the card's first expectation
The card asks that a non-submitter not see a live Recall button at all. Measured rather than assumed, and the reading is that this repo's server-side half is already correct, and already pinned:
sys-approval-request.object.tsdeclaresapproval_recallwithvisiblegating onrecord.viewer.is_submitter == true.viewer.is_submitteris server-computed inApprovalService.attachViewersas a straight owner check against the caller'suserId.sys-approval-request.object.test.tsalready pins it: "gates on the server-computed viewer block ([P3] approvals: server-computed viewer capability on getRequest (precise approver gating for declared actions) #3310): approver actions on can_act, submitter levers on is_submitter", assertingapproval_recallcontainsrecord.viewer.is_submitter.Recall(zh-CN 「撤回」), and exactly one metadata declaration in this repo targets the recall endpoint.So the button in the report is not rendered from this package's action metadata, and nothing server-side here decides its visibility. Whatever renders it is a console surface that does not honour
record.viewer.is_submitter— the half triage already transferred to objectui#6464. Absorbing that into this PR would be a cross-repo change, so it is left where it was routed. I could not read objectui#6464 from this seat (that repo is not attached to this session), so its current state is worth confirming before the card is closed.Out-of-scope findings, filed unassigned
approval_recallaction hides the #3424 admin-override recall — the service admits it, the visible predicate never shows it #12716 —approval_recall'svisiblepredicate omitsrecord.viewer.can_overridewhile the service admits the Approval routed to an empty position permanently locks the record (no admin override, no recovery) #3424 override actor and its three sibling levers all OR it in. Declared-vs-enforced drift in the opposite direction: a capability the server grants that no UI entry exposes. Not touched here — it moves who sees which action, which is permission-shaped.finding) — thezh-CNOperation Message Catalog punctuates with the ASCII comma while the platform's other shippedzh-CNcopy uses the full-width one. Cosmetic; lives inpackages/spec, which this PR holds none of.Changeset
.changeset/approval-recall-refusal-localized.md,minor. Argued rather than assumed: this ships changed user-facing refusal text in a published package, andApprovalServiceOptions— publicly exported from the package index — gains an optional field, which is additive public API rather than a pure patch.Generated by Claude Code