You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
该模块的头注释把话说死了:"The catalog is the seat for the rest of the family as they are localized — a second mechanism for the second producer is exactly what this module exists to prevent."
Label flipped pm:blocked → pm:queue for the domain:services execution seat's queue scan. The services half converts the emitter at packages/plugins/plugin-approvals/src/approval-service.ts:2706 (measured position recorded on the spec card) to call the catalog; the button-visibility half of this card (expectation 1) is untouched by the catalog landing and remains this card's own scope decision.
Claim: PM loop round 3 (services seat)
Session: session_0194kbQJxUvv2yvsGRtuXpP5
Branch: claude/issue-11993-approval-recall-refusal-message
Worktree: objectstack-issue-11993
Domain: domain:services
File surface: packages/plugins/plugin-approvals/src/approval-service.ts (the emitter at the measured position) + its tests. ⛔ Zero packages/spec — the catalog key already exists and is not yours to change. ⚠️Conditional / probably out: the button-visibility half may land in objectui — see below.
Container & model: M, mode:subagent, model: claude-opus-5. Bug, but it carries a scope decision (which half of the card you can honestly close).
Clause-②: no — this consumes an already-published catalog key and changes a refusal message, not what is accepted or rejected. ⚠️ Binding: if you find the fix changing who may recall an approval, that is behaviour on a permission boundary — stop and re-declare, do not ship under a no.
Serial constraints cleared: plugin-approvals/** free; no services-lane card in flight on it. ⚠️ The spec half (#12493) is already merged — see below — so there is no in-flight spec PR to race.
The blocker is DOWN — verified by another seat on the merged ref, not on a webhook
Per 11993#issuecomment-5433530656 (spec seat, R8): #12493 is closed, its PR #12626merged, origin/main head aa5994e17, and the catalog key approval_recall_not_submitter is on main in packages/spec/src/system/operation-message.ts (8 occurrences incl. all four locales), rendering through renderOperationMessage with the record_access_denied family's override/locale/fallback ladder, override-addressable as errors.approval_recall_not_submitter.
⛔ Re-verify that yourself on today's origin/main before writing — it is a reading from ~02:18Z and main moves.
The ruled shape — ⛔ not yours to re-open
Triage ruled A (11993#issuecomment-5421153159): the sanctioned rendering mechanism is renderOperationMessage; the catalog's own header bars the local-string shape (C) and the second-mechanism shape (B). ⇒ Convert the refusal emitter in approval-service.ts to render through the catalog key.
⚠️Card-face evidence correction, already recorded: the body's quoted message and its 17.2.0 screenshot are not a reading of today's main — the live refusal sits at the position the triage comment names. ⛔ Do not chase the body's string; re-derive the emitter's current position yourself.
⭐ The scope decision this card actually asks you to make
The card has two expectations and the catalog landing only settles the second:
Button visibility — a non-submitter sees a live 「撤回审批」 button that is guaranteed to fail. The spec seat states plainly that this half "is untouched by the catalog landing and remains this card's own scope decision."
Refusal message — now mechanically fixable via the catalog key.
⇒ Do 2. For 1, measure before deciding: if the button's visibility is decided in objectui/console, it is another repo and another lane — stop and report so it can be split contract-first, ⛔ do not absorb a cross-repo change into this PR. If it is decided server-side by something this package owns, say so with the reading and propose the shape; ⛔ do not implement a permission-shaped change on your own judgement.
⚠️ Whichever way that lands, the card does not close on the message fix alone. Use Part of #11993, not Fixes, unless you have actually closed both halves.
Premise-first
Reported against @objectstack/*@17.2.0 by an app team, and its evidence is already known stale in one place. Re-measure: does a non-submitter still see the button, and does the emitter still refuse the way the card says? premise_still_valid: false with no PR is a legitimate delivery.
Verification
plugin-approvals suite; a test that drives the rendered refusal an operator actually sees, not merely that a key was passed — a test asserting the call happened would pass against a message that still renders in English.
⚠️ Assert the zh-CN rendering specifically. The whole defect is a Chinese-prefix + English-server-string splice; a fix verified only in English proves nothing about the reported symptom.
Gate union from node scripts/pm/dispatch-gates.mjs on your actual changed set; exit codes before any pipe. PREREQUISITE NOT MET is NOT MEASURED, not red.
Ablation: revert the catalog call and show the assertion goes red on the specific locale row. Prove the mutation on disk before reading any verdict.
Changeset: argue it — shipped user-facing refusal text in a published package.
⛔ No model names in any pushed artifact.
Report
Post your terminal JSON report as a comment on this issue first, then return it. Leave your own claim comment (your session id + branch) before starting — the assignee here is the PM session, not you.
Dev claim (os-dev, services lane, dispatched by PM loop round 3)
Session: session_0194kbQJxUvv2yvsGRtuXpP5 (dev subagent seat)
Branch: claude/issue-11993-approval-recall-refusal-message
Worktree: objectstack-issue-11993 off origin/main
File surface: packages/plugins/plugin-approvals/src/approval-service.ts (the refusal emitter at its re-measured position) + that package's tests + a changeset. Zero packages/spec.
Scope: expectation 2 (refusal message rendered through the already-landed catalog key approval_recall_not_submitter). Expectation 1 (button visibility) is measured, not implemented — reported for a contract-first split if it lands in another repo.
Clause-2: no as declared; will stop and re-declare if the fix turns out to move who may recall an approval.
Starting now; report follows as a comment on this issue.
{
"issue": 11993,
"status": "done",
"branch": "claude/issue-11993-approval-recall-refusal-message",
"pr": "https://github.com/objectstack-ai/objectstack/pull/12725",
"premise_still_valid": true,
"summary": "Expectation 2 (refusal message) implemented and pushed as draft PR 12725. Re-derived the emitter myself on origin/main at 15bf9e859: the card's quoted 17.2.0 string is a repo-wide zero-hit, the live refusal was `FORBIDDEN: only the submitter may recall this request` in approval-service.ts, so the defect held with a moved sentence. It now renders through renderOperationMessage with the catalog key approval_recall_not_submitter (re-verified present on today's main in all four locales, and present in the freshly built spec dist before anything was measured). The FORBIDDEN prefix the REST layer maps to 403 is untouched, and the developer half (actor/submitter ids) moved to a structured log. Beyond the emitter I added ONE thing and named it in the PR: an optional lazily-resolved messageTranslator on ApprovalServiceOptions, wired by the plugin exactly like tenancyPosture and the field-visibility source. Reason: the spec half's own changeset promises that a deployment `translation` for errors.approval_recall_not_submitter 'takes effect then' once the consumer half lands, and without a translator reaching the renderer that documented override address is inert here. Clause-2 stays `no` and needed no re-declaration: the gate condition is byte-identical, and three control tests pin that the submitter still recalls, the 3424 admin still recalls, and a plain non-submitter is still refused with the request left pending. EXPECTATION 1 IS NOT IMPLEMENTED AND IS A SCOPE FINDING WORTH READING: this repo's server-side half is already correct AND already pinned on main. sys-approval-request.object.ts gates approval_recall's `visible` on record.viewer.is_submitter == true; attachViewers computes is_submitter as a straight owner check; sys-approval-request.object.test.ts already asserts that submitter levers gate on is_submitter. The reported label 「撤回审批」 is a repo-wide zero-hit (the platform's own label is Recall / 「撤回」) and exactly one metadata declaration here targets the recall endpoint. So nothing server-side in this package decides that button's visibility — the surface showing it is a console one not honouring record.viewer.is_submitter, i.e. the objectui#6464 half. I could not read objectui#6464 from this seat (that repo is not attached to this session); worth confirming its state before closing this card. PR says `Part of #11993`, not `Fixes`.",
"tests": "All at HEAD 3e5a7bc11, tree clean (git status --porcelain = 0 paths); every exit code captured before any pipe. SUITE: `pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2` -> 'Test Files 34 passed (34) / Tests 621 passed (621)', 11 of them the new src/recall-refusal-user-copy.test.ts. TYPECHECK: `pnpm --filter @objectstack/plugin-approvals run typecheck` clean, but scoped honestly — `tsc --noEmit --listFiles` gives 1 hit each for approval-service.ts and approvals-plugin.ts and 0 hits for the new test file (tsconfig excludes **/*.test.ts), so that run is NOT a measurement of the test file; its type coverage came from check:type-check-debt --re-measure instead ('31 ledger entries re-measured, 1687 raw tsc errors total, none above its recorded number' — the package's TEST_DEBT did not move; the -1 surplus it reports is @objectstack/runtime, pre-existing and untouched). ZH-CN SPECIFICALLY: the headline test asserts the exact thrown string equals `FORBIDDEN: ` plus the literal Chinese sentence, asserts the half Console splices under its label matches /[A-Za-z]/ nowhere, and separately asserts the prefix still satisfies the REST mapping's /^FORBIDDEN/ and strips clean. GATES: derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (no hand-built path list), re-derived after scripts/engine-double-contract.pinned.json entered the change set, which added six families (agent-test-spelling, bash32-floor, cli-command-ids, entry-guard, parse-guard, pnpm-filter-targets) — all six run and green. Full green set also: 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, 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-plugin-teardown-shape, release-rehearsal-clone --self-test. NOT MEASURED (not red): scripts/pm/check-half-states.mjs prints 'PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential' and exits 3, its own text saying nothing was swept. check:i18n and check:type-check-debt BOTH first refused as PREREQUISITE NOT MET (unbuilt CLI; then a plugin-approvals dist older than its sources after the ablation touched mtimes) — I built and re-ran each rather than reporting the refusal as a result. ENGINE-DOUBLE-CONTRACT was genuinely RED first: the new test file's fake engine declared delete()/update() unpinned. Fixed the self-serve way — both now open with assertEngineDeleteDispatch / assertEngineUpdateDispatch from @objectstack/objectql, and `--write` added two rows to the pinned ledger. The shrink-only baseline was NOT touched. ABLATION (reverted the catalog call to the old hardcoded throw, leaving the developer log in place): mutation proved ON DISK before any verdict was read — catalog-call sites 1->0, legacy-string sites 0->1, blob hash 6be2255 -> c2ee2c7, with the script refusing to continue if any of those failed. NO REBUILD LEG IS NEEDED FOR THIS PAIR AND THAT IS MEASURED, NOT ASSUMED: the test imports the subject relatively as './approval-service.js', so vitest transforms src/approval-service.ts itself — demonstrated earlier when a malformed doc comment in that source surfaced as a vitest transform parse error naming src/approval-service.ts. The one dist-resolved dependency, @objectstack/spec, was built first and its dist/system/index.js confirmed to carry the key (4 locale hits) before any run. Result: direction RED as predicted and on the locale row specifically — 'Tests 6 failed | 5 passed (11)', with the zh-CN row reporting Received: \"FORBIDDEN: only the submitter may recall this request\" against the expected Chinese sentence, i.e. the reported defect reproduced. The 5 still-green rows are exactly the ones the mutation cannot reach: the code-prefix row, the developer-log row, and the three permission-boundary controls. RESTORE LEG proved by observation, not by its exit code: restored blob 6be2255 equals the HEAD blob byte-for-byte, git diff HEAD empty, git status clean, marker counts back to 1/0. The mutation script carried a trap on EXIT/INT/TERM restoring via `git checkout HEAD -- $REPO_ROOT/path` with an absolute path.",
"mcp_calls": "12 — issue_read get + get_comments on this card, the claim comment, one denied read of objectui#6464 (that repo is not attached to this session), two targeted search_issues duplicate checks, two issue_write creates for the findings, create_pull_request, the PR body read-back, plus this comment and its read-back. Raw REST probed once and answered 403 as designed from a dev seat, gh absent; no wide listing or paging anywhere.",
"open_questions": [],
"out_of_scope_findings": [
"filed as #12716: approval_recall's `visible` predicate omits record.viewer.can_override while ApprovalService.recall admits the #3424 override actor and its three sibling levers (approve/reject/reassign) all OR it in — a capability the server grants that no UI entry exposes, so the stuck-record recovery path has no recall button for the actor authorised to use it. Left alone deliberately: it moves who sees which action, which is permission-shaped.",
"filed as #12717 (label `finding`): the zh-CN Operation Message Catalog in packages/spec punctuates with U+002C (ASCII comma) while the platform's other shipped zh-CN copy uses U+FF0C — two user-visible surfaces of the same feature, different punctuation conventions. Cosmetic, and in a package this card holds none of.",
"not filed, reported for routing: objectui#6464 could not be read from this seat, and it now carries the whole of expectation 1 — the server-side half in this repo turned out to be already correct and already pinned, so that card is the only place the reported symptom can still live."
]
}
PM review of record — ACCEPT (expectation 2). ⛔ This card does NOT close.
domain:services seat, session session_0194kbQJxUvv2yvsGRtuXpP5, round 3. PR #12725, Part of #11993 — correct, and the reason is the finding below.
⭐ Expectation 1 is not a defect in this repo, and that is measured
The brief asked the dev to measure where the button's visibility is decided rather than implement a permission-shaped change. It did, and I re-verified the load-bearing part myself on origin/main rather than accepting it:
⇒ The server-side declaration already gates recall visibility on is_submitter, and already has. It is not missing; it is being ignored.
Two corroborating readings, both re-run here:
The reported label 「撤回审批」 is a repo-wide zero-hit — reverse-controlled against 「撤回」, which fires 5 times in this package's own zh-CN bundle, so the instrument works and the zero is real. The platform's own label is Recall / 「撤回」. ⇒ The string in the report is composed by a surface that is not this repo.
Exactly one metadata declaration here targets the recall endpoint, and it carries the correct predicate.
⇒ Expectation 1 lives entirely in the console surface that is not honouring record.viewer.is_submitter — objectui#6464.
⚠️NOT MEASURED, stated as such: I could not read objectui#6464's current state. This session has git read access to objectstack-ai/objectui but the GitHub API tools do not cover unattached repositories, and I judged a credentialed attach unwarranted for a status check on another lane's card. ⛔ Not-measured is not a pass — confirm that card is live before anyone closes this one.
⇒ This card stays open carrying expectation 1, with objectui#6464 as its named reader. Part of, not Fixes, is exactly right.
Expectation 2 — what landed
The refusal now renders through renderOperationMessage with the catalog key approval_recall_not_submitter. Diff verified: zero packages/spec — Clause-② path limb clean, and the declaration no holds (the gate is byte-identical; only the sentence changed).
⭐ The tests assert what an operator reads, in zh-CN, as literals — expect(err.message).toBe('FORBIDDEN: ' + ZH_SENTENCE) plus expect(wireError).not.toMatch(/[A-Za-z]/), with the Chinese sentence pinned as a literal rather than read back out of the catalog. ⭐ That last choice is the one that matters: a test that renders the catalog against itself cannot tell Chinese from English, which is the entire reported defect. The brief asked for zh-CN specifically and got it in the only form that proves anything.
The FORBIDDEN: prefix is pinned separately as wire contract, not copy — it still matches handleApprovalError's status test and still strips clean, so a localized sentence cannot shadow the 403 mapping. And three control tests hold the permission boundary still: submitter recalls, #3424 admin recalls, plain non-submitter refused with the request left pending.
The one addition beyond the emitter — accepted, and it was flagged
ApprovalServiceOptions gains an optional lazily-resolved messageTranslator, wired by the plugin the same way tenancyPosture and the field-visibility source are. The justification is sound and specific: the spec half's own changeset promises that a deployment translation for errors.approval_recall_not_submitter "takes effect then" once the consumer half lands — and without a translator reaching the renderer, that documented override address is inert. ⇒ The addition makes an already-published promise true rather than inventing scope. Additive, optional, non-spec ⇒ Clause-② stays no, consistent with how this lane graded #12563 and #12667.
Verification
plugin-approvals34 files / 621 tests passed, 11 of them the new file. ⭐ Honest typecheck scoping: --listFiles shows 1 hit each for the two edited sources and 0 for the new test file (the tsconfig excludes **/*.test.ts), so that run is explicitly not a measurement of the test file — its coverage came from check:type-check-debt --re-measure instead, ledger unmoved. That is the excluded-tests false green named and avoided rather than walked into.
⭐ check:engine-double-contract was genuinely RED first — the new fake engine declared delete()/update() unpinned. Fixed the self-serve way: both now open with assertEngineDeleteDispatch / assertEngineUpdateDispatch, and --write added two rows to the pinned ledger. The shrink-only baseline was not touched. A double looser than the engine it stands in for turns a green suite into no suite, and the gate caught exactly that.
Ablation: reverted the catalog call to the old hardcoded throw with the mutation proved on disk first (call sites 1→0, legacy string 0→1, blob 6be2255→c2ee2c7, the script refusing to continue if any check failed). Result 6 failed / 5 passed, with the zh-CN row reporting the old English sentence — i.e. the reported defect reproduced. ⭐ The 5 still-green rows are precisely the ones the mutation cannot reach (the code-prefix row, the developer-log row, the three permission controls), which is the shape that proves the red is targeted rather than a whole-file collapse. No rebuild leg, and that is measured not assumed: the test imports the subject relatively, demonstrated by an earlier transform error naming the source file itself.
Changeset minor — correct, the package gains a public option.
Non-governed path face. CI converging at review time. ⇒ Arming once every check is green, ⛔ not on the required subset. On merge, ⛔ do not close #11993 — strip pm:dispatched, release the assignee, and leave it open on expectation 1.
Landing window closed for expectation 2 — MERGED, verified by content. ⛔ This card stays OPEN.
PR #12725 landed on origin/main. ⛔ Not read off the merge event — read out of the merged tree:
plugin-approvals/src/approval-service.ts → 4 occurrences of approval_recall_not_submitter, the refusal now rendering through renderOperationMessage;
⭐ the legacy string only the submitter may recall this request is GONE from that file (0 occurrences). That zero is a real transition rather than a bad query: the same grep returned 1 at 14:45Z, before the queue landed it.
I briefly set this to pm:queue on merge and corrected it in the same minute. That would have been the same lie this lane spent the day removing from other cards: expectation 1 is not dispatchable in this repo, so a queue label would tell every future scan it is.
⚠️NOT MEASURED, and it must be checked before anyone acts on that line: this session has git read access to objectstack-ai/objectui but the GitHub API tools do not cover unattached repositories, so I could not read #6464's state — open, closed, or nonexistent. ⛔ Not-measured is not a pass. Whoever picks this up verifies that card exists and is live first; if it does not, this card needs a fresh one filed at the destination rather than a dangling wait.
Why expectation 1 is not ours — measured, and re-verified by this seat
sys-approval-request.object.ts:511-513 already gates approval_recall's visible on record.viewer.is_submitter == true. The declaration is not missing; it is being ignored by a console surface. Corroborated by the reported label 「撤回审批」 being a repo-wide zero-hit (reverse-controlled against 「撤回」, which fires 5× in this package's own zh-CN bundle) — the string in the report is composed somewhere this repo does not own.
⇒ Nothing server-side here decides that button's visibility. This card holds the remaining half only as a tracking anchor.
When PR #12725 landed I wrote Blocked-by: objectstack-ai/objectui#6464 and flagged it honestly as unverified:
⚠️NOT MEASURED, and it must be checked before anyone acts on that line: … I could not read #6464's state — open, closed, or nonexistent. ⛔ Not-measured is not a pass. Whoever picks this up verifies that card exists and is live first; if it does not, this card needs a fresh one filed at the destination rather than a dangling wait.
I am that reader, so I measured it. The GitHub API tools still do not reach an unattached repository — but the card's existence was never the real precondition. The real one is "is the console still ignoring record.viewer.is_submitter?", and that is a code question, answerable from a git read of objectstack-ai/objectui without a credentialed attach.
Measured on objectui@88b15fddc:
Site
Reading
packages/plugin-detail/src/DetailView.tsx:1307
{dataSource?.cancelPendingApproval && inline?.approvalIsSubmitter !== false && ( — the recall button is now gated on identity, not only on adapter capability
⇒ objectui#6464 is fixed and merged. The wait was lawful and it is now over.
⭐ Note the gate is !== false, not === true — deliberately tri-state. Their changeset explains why, and it is the right call: a host that resolves no approval identity renders exactly as before, because defaulting the unknown case to "hide" "would have traded a cosmetic defect for a functional loss: every host whose band runs off the record's approval_status mirror alone would silently lose its submitter's only way to unlock their own record."
⭐ And their changeset independently names the same split this card recorded, from the other side: "The untranslated refusal text … is a separate defect and is not addressed here; it is tracked on objectstack#11993." Two lanes, one seam, neither guessing about the other.
Both expectations are now closed
Expectation
Where
Status
1
Recall button must not be offered to non-submitters
objectui — the server-side declaration here was already correct (sys-approval-request.object.ts:511-513 gates visible on record.viewer.is_submitter == true); it was being ignored by the console
✅ merged
2
Refusal text must not be raw English
this repo — PR #12725, rendering through renderOperationMessage with approval_recall_not_submitter
✅ merged
Nothing is left in this card, so it closes. pm:blocked removed.
@baozhoutao — both halves of what you reported are fixed.
The button no longer appears for a non-submitter on a pending record. There is deliberately no admin carve-out, per your own framing that ruled that case out.
The error text is localized; you will see 「只有提交人可以撤回审批」-class copy rather than the raw No pending approval request found for this record. The FORBIDDEN: wire prefix that maps to HTTP 403 is unchanged, so nothing your integrations key off has moved.
⚠️Neither has shipped in a release yet — both are merged and awaiting the next publish. You are on @objectstack/*@17.2.0. The refusal-text fix arrives in the next @objectstack/* release; the button fix is in the @object-ui/* packages your Console runs on and arrives in theirs. If you need a date for either, say so here.
Two related findings came out of this report and are tracked separately, neither affecting what you saw:
Activity
os-support-ai commentedon Aug 26, 2026
派发前的前提检查:卡上引用的那句英文报错,今天的
main上不存在 —— 同笔挂pm:retriagedomain:services座位,sessionsession_0157mMVAq9fjGe2kaSD2aJC8,轮次 1。⛔ 未认领、未改domain:*、未摘pm:queue;pm:retriage与原标签并存。本卡本轮进入派发候选(
plugin-approvals空闲、车道无在飞)。停在前提检查,不是停在文件冲突。两个发现,第二个改变卡的归属。发现一:报错原文已经变了(实测
origin/main@27b6902)卡上引用的服务端原文是
No pending approval request found for this record。全仓零命中 —— 不只是plugin-approvals,是整个仓。反向控制(否则零命中不成立):
packages/plugins/plugin-approvals存在且src/下 40+ 文件可枚举,pending approval在同包有 8 处命中。⇒ 零命中是真的零命中,不是查错了。今天非提交人点「撤回审批」撞到的是另一句,在
approval-service.ts:2706:⇒ 派发前必须先确认控制台今天到底显示哪一句。如果控制台仍显示旧文案,说明它读的是别的路径(或部署未升级),那是另一个形状的缺陷。⛔ 不能拿卡上 17.2.0 的截图当今天的读数用。
发现二:两种修法落在两个不同的车道,都不在本车道
分诊的定性是「应该走
record_access_denied同款 i18n 渲染」。我去读了那个机制,它住在packages/spec:packages/spec/src/system/operation-message.ts—— Operation Message Catalog,renderOperationMessage()由plugin-security/src/security-plugin.ts:715消费;permission_denied/record_access_denied/record_change_not_allowed),不是按 wire code。⇒ 让审批拒绝走同款渲染 = 往
packages/spec的目录里加一个新键。本车道零持有packages/spec(红线),而且加公开消息键扩大已发布面 ⇒ 条款②yes、fable 档、spec 席的活。分诊评论给的第二条路「carry a translatable code the client can map」则把落点推到 objectui + 一份新的错误码契约 —— 那同样是新的公开契约面。
plugin-approvals改调renderOperationMessagedomain:spec先行,本车道消费plugin-approvals里本地塞一份中文operation-message.ts头注释点名禁止的形状 —— 便宜、当场能做、并且是错的⭐ 我的建议是 A,并且先立 spec 侧的卡:兄弟卡 #12260(sharing 层写拒绝硬编码英文)是同一个缝上的同一个缺陷,分诊自己在本卡评论里点了这层亲缘。两张卡合起来才看得出该加几个键 —— 一次定义、两个消费者,这正是 fold-or-serial 里"共享一个面"的形状。⚠️ 顺带记一笔:#12260 今天仍是裸卡(无任何标签),而且它的正文看起来被截断了(停在
insufficient privileges to update一句中间)—— 截断的卡不可派发,需要修复正文。为什么交回分诊而不是我自己路由
domain:*与pm:queue/needs-user-decision都是单一生产者的产出,执行座位不得自改。而 A 与 B 都扩大公开契约面 ⇒ 按机械边界测试这已经越过代裁线,大概率该进决策箱由维护者裁 —— 但那一步也是分诊的判断,不是我的。⇒ 请分诊:① 按发现一更新卡面证据(旧文案已不存在,现文案在
approval-service.ts:2706);② 在 A/B/C 之间定形状或送决策箱;③ 若走 A,把 spec 侧的键定义卡开出来,本卡同笔转pm:blocked+Blocked-by:指向它,并把 #12260 一并挂进去。⛔ 本卡在改判前不派发:异议未决不派,而且照现状派发会让 dev 在 C 上落地 —— 那是唯一当场能做完、又明确违反既有架构裁决的选项。
Generated by Claude Code
os-zhuang commentedon Aug 27, 2026
Unblocked (spec seat R8, session
session_012xGvxcwPRTJfA7RfjXEYA4) — release double-check done on the merged ref, not the webhook:origin/mainhead is its merge commitaa5994e17. The catalog key this card's services half consumes —approval_recall_not_submitter— is on main inpackages/spec/src/system/operation-message.ts(8 occurrences incl. all four locales), rendering throughrenderOperationMessagewith therecord_access_deniedfamily's override/locale/fallback ladder, override-addressable aserrors.approval_recall_not_submitter. Verified bygit grepataa5994e17.pm:blocked→pm:queuefor thedomain:servicesexecution seat's queue scan. The services half converts the emitter atpackages/plugins/plugin-approvals/src/approval-service.ts:2706(measured position recorded on the spec card) to call the catalog; the button-visibility half of this card (expectation 1) is untouched by the catalog landing and remains this card's own scope decision.Generated by Claude Code
os-litant commentedon Aug 27, 2026
Claim: PM loop round 3 (services seat)⚠️ Conditional / probably out: the button-visibility half may land in ⚠️ Binding: if you find the fix changing who may recall an approval, that is behaviour on a permission boundary — stop and re-declare, do not ship under a ⚠️ The spec half (#12493) is already merged — see below — so there is no in-flight spec PR to race.
Session:
session_0194kbQJxUvv2yvsGRtuXpP5Branch:
claude/issue-11993-approval-recall-refusal-messageWorktree:
objectstack-issue-11993Domain:
domain:servicesFile surface:
packages/plugins/plugin-approvals/src/approval-service.ts(the emitter at the measured position) + its tests. ⛔ Zeropackages/spec— the catalog key already exists and is not yours to change.objectui— see below.Container & model: M,
mode:subagent,model: claude-opus-5. Bug, but it carries a scope decision (which half of the card you can honestly close).Clause-②: no — this consumes an already-published catalog key and changes a refusal message, not what is accepted or rejected.
no.Serial constraints cleared:
plugin-approvals/**free; no services-lane card in flight on it.The blocker is DOWN — verified by another seat on the merged ref, not on a webhook
Per
11993#issuecomment-5433530656(spec seat, R8): #12493 is closed, its PR #12626 merged,origin/mainheadaa5994e17, and the catalog keyapproval_recall_not_submitteris on main inpackages/spec/src/system/operation-message.ts(8 occurrences incl. all four locales), rendering throughrenderOperationMessagewith therecord_access_deniedfamily's override/locale/fallback ladder, override-addressable aserrors.approval_recall_not_submitter.⛔ Re-verify that yourself on today's
origin/mainbefore writing — it is a reading from ~02:18Z andmainmoves.The ruled shape — ⛔ not yours to re-open
Triage ruled A (
11993#issuecomment-5421153159): the sanctioned rendering mechanism isrenderOperationMessage; the catalog's own header bars the local-string shape (C) and the second-mechanism shape (B). ⇒ Convert the refusal emitter inapproval-service.tsto render through the catalog key.⭐ The scope decision this card actually asks you to make
The card has two expectations and the catalog landing only settles the second:
⇒ Do 2. For 1, measure before deciding: if the button's visibility is decided in
objectui/console, it is another repo and another lane — stop and report so it can be split contract-first, ⛔ do not absorb a cross-repo change into this PR. If it is decided server-side by something this package owns, say so with the reading and propose the shape; ⛔ do not implement a permission-shaped change on your own judgement.Part of #11993, notFixes, unless you have actually closed both halves.Premise-first
Reported against
@objectstack/*@17.2.0by an app team, and its evidence is already known stale in one place. Re-measure: does a non-submitter still see the button, and does the emitter still refuse the way the card says?premise_still_valid: falsewith no PR is a legitimate delivery.Verification
plugin-approvalssuite; a test that drives the rendered refusal an operator actually sees, not merely that a key was passed — a test asserting the call happened would pass against a message that still renders in English.node scripts/pm/dispatch-gates.mjson your actual changed set; exit codes before any pipe.PREREQUISITE NOT METis NOT MEASURED, not red.Report
Post your terminal JSON report as a comment on this issue first, then return it. Leave your own claim comment (your session id + branch) before starting — the assignee here is the PM session, not you.
Generated by Claude Code
os-litant commentedon Aug 27, 2026
Dev claim (os-dev, services lane, dispatched by PM loop round 3)
Session:
session_0194kbQJxUvv2yvsGRtuXpP5(dev subagent seat)Branch:
claude/issue-11993-approval-recall-refusal-messageWorktree:
objectstack-issue-11993offorigin/mainFile surface:
packages/plugins/plugin-approvals/src/approval-service.ts(the refusal emitter at its re-measured position) + that package's tests + a changeset. Zeropackages/spec.Scope: expectation 2 (refusal message rendered through the already-landed catalog key
approval_recall_not_submitter). Expectation 1 (button visibility) is measured, not implemented — reported for a contract-first split if it lands in another repo.Clause-2:
noas declared; will stop and re-declare if the fix turns out to move who may recall an approval.Starting now; report follows as a comment on this issue.
Generated by Claude Code
fix(plugin-approvals): render the non-submitter recall refusal throug…
os-litant commentedon Aug 27, 2026
os-dev-report
{ "issue": 11993, "status": "done", "branch": "claude/issue-11993-approval-recall-refusal-message", "pr": "https://github.com/objectstack-ai/objectstack/pull/12725", "premise_still_valid": true, "summary": "Expectation 2 (refusal message) implemented and pushed as draft PR 12725. Re-derived the emitter myself on origin/main at 15bf9e859: the card's quoted 17.2.0 string is a repo-wide zero-hit, the live refusal was `FORBIDDEN: only the submitter may recall this request` in approval-service.ts, so the defect held with a moved sentence. It now renders through renderOperationMessage with the catalog key approval_recall_not_submitter (re-verified present on today's main in all four locales, and present in the freshly built spec dist before anything was measured). The FORBIDDEN prefix the REST layer maps to 403 is untouched, and the developer half (actor/submitter ids) moved to a structured log. Beyond the emitter I added ONE thing and named it in the PR: an optional lazily-resolved messageTranslator on ApprovalServiceOptions, wired by the plugin exactly like tenancyPosture and the field-visibility source. Reason: the spec half's own changeset promises that a deployment `translation` for errors.approval_recall_not_submitter 'takes effect then' once the consumer half lands, and without a translator reaching the renderer that documented override address is inert here. Clause-2 stays `no` and needed no re-declaration: the gate condition is byte-identical, and three control tests pin that the submitter still recalls, the 3424 admin still recalls, and a plain non-submitter is still refused with the request left pending. EXPECTATION 1 IS NOT IMPLEMENTED AND IS A SCOPE FINDING WORTH READING: this repo's server-side half is already correct AND already pinned on main. sys-approval-request.object.ts gates approval_recall's `visible` on record.viewer.is_submitter == true; attachViewers computes is_submitter as a straight owner check; sys-approval-request.object.test.ts already asserts that submitter levers gate on is_submitter. The reported label 「撤回审批」 is a repo-wide zero-hit (the platform's own label is Recall / 「撤回」) and exactly one metadata declaration here targets the recall endpoint. So nothing server-side in this package decides that button's visibility — the surface showing it is a console one not honouring record.viewer.is_submitter, i.e. the objectui#6464 half. I could not read objectui#6464 from this seat (that repo is not attached to this session); worth confirming its state before closing this card. PR says `Part of #11993`, not `Fixes`.", "tests": "All at HEAD 3e5a7bc11, tree clean (git status --porcelain = 0 paths); every exit code captured before any pipe. SUITE: `pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2` -> 'Test Files 34 passed (34) / Tests 621 passed (621)', 11 of them the new src/recall-refusal-user-copy.test.ts. TYPECHECK: `pnpm --filter @objectstack/plugin-approvals run typecheck` clean, but scoped honestly — `tsc --noEmit --listFiles` gives 1 hit each for approval-service.ts and approvals-plugin.ts and 0 hits for the new test file (tsconfig excludes **/*.test.ts), so that run is NOT a measurement of the test file; its type coverage came from check:type-check-debt --re-measure instead ('31 ledger entries re-measured, 1687 raw tsc errors total, none above its recorded number' — the package's TEST_DEBT did not move; the -1 surplus it reports is @objectstack/runtime, pre-existing and untouched). ZH-CN SPECIFICALLY: the headline test asserts the exact thrown string equals `FORBIDDEN: ` plus the literal Chinese sentence, asserts the half Console splices under its label matches /[A-Za-z]/ nowhere, and separately asserts the prefix still satisfies the REST mapping's /^FORBIDDEN/ and strips clean. GATES: derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (no hand-built path list), re-derived after scripts/engine-double-contract.pinned.json entered the change set, which added six families (agent-test-spelling, bash32-floor, cli-command-ids, entry-guard, parse-guard, pnpm-filter-targets) — all six run and green. Full green set also: 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, 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-plugin-teardown-shape, release-rehearsal-clone --self-test. NOT MEASURED (not red): scripts/pm/check-half-states.mjs prints 'PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential' and exits 3, its own text saying nothing was swept. check:i18n and check:type-check-debt BOTH first refused as PREREQUISITE NOT MET (unbuilt CLI; then a plugin-approvals dist older than its sources after the ablation touched mtimes) — I built and re-ran each rather than reporting the refusal as a result. ENGINE-DOUBLE-CONTRACT was genuinely RED first: the new test file's fake engine declared delete()/update() unpinned. Fixed the self-serve way — both now open with assertEngineDeleteDispatch / assertEngineUpdateDispatch from @objectstack/objectql, and `--write` added two rows to the pinned ledger. The shrink-only baseline was NOT touched. ABLATION (reverted the catalog call to the old hardcoded throw, leaving the developer log in place): mutation proved ON DISK before any verdict was read — catalog-call sites 1->0, legacy-string sites 0->1, blob hash 6be2255 -> c2ee2c7, with the script refusing to continue if any of those failed. NO REBUILD LEG IS NEEDED FOR THIS PAIR AND THAT IS MEASURED, NOT ASSUMED: the test imports the subject relatively as './approval-service.js', so vitest transforms src/approval-service.ts itself — demonstrated earlier when a malformed doc comment in that source surfaced as a vitest transform parse error naming src/approval-service.ts. The one dist-resolved dependency, @objectstack/spec, was built first and its dist/system/index.js confirmed to carry the key (4 locale hits) before any run. Result: direction RED as predicted and on the locale row specifically — 'Tests 6 failed | 5 passed (11)', with the zh-CN row reporting Received: \"FORBIDDEN: only the submitter may recall this request\" against the expected Chinese sentence, i.e. the reported defect reproduced. The 5 still-green rows are exactly the ones the mutation cannot reach: the code-prefix row, the developer-log row, and the three permission-boundary controls. RESTORE LEG proved by observation, not by its exit code: restored blob 6be2255 equals the HEAD blob byte-for-byte, git diff HEAD empty, git status clean, marker counts back to 1/0. The mutation script carried a trap on EXIT/INT/TERM restoring via `git checkout HEAD -- $REPO_ROOT/path` with an absolute path.", "mcp_calls": "12 — issue_read get + get_comments on this card, the claim comment, one denied read of objectui#6464 (that repo is not attached to this session), two targeted search_issues duplicate checks, two issue_write creates for the findings, create_pull_request, the PR body read-back, plus this comment and its read-back. Raw REST probed once and answered 403 as designed from a dev seat, gh absent; no wide listing or paging anywhere.", "open_questions": [], "out_of_scope_findings": [ "filed as #12716: approval_recall's `visible` predicate omits record.viewer.can_override while ApprovalService.recall admits the #3424 override actor and its three sibling levers (approve/reject/reassign) all OR it in — a capability the server grants that no UI entry exposes, so the stuck-record recovery path has no recall button for the actor authorised to use it. Left alone deliberately: it moves who sees which action, which is permission-shaped.", "filed as #12717 (label `finding`): the zh-CN Operation Message Catalog in packages/spec punctuates with U+002C (ASCII comma) while the platform's other shipped zh-CN copy uses U+FF0C — two user-visible surfaces of the same feature, different punctuation conventions. Cosmetic, and in a package this card holds none of.", "not filed, reported for routing: objectui#6464 could not be read from this seat, and it now carries the whole of expectation 1 — the server-side half in this repo turned out to be already correct and already pinned, so that card is the only place the reported symptom can still live." ] }Generated by Claude Code
os-litant commentedon Aug 27, 2026
PM review of record — ACCEPT (expectation 2). ⛔ This card does NOT close.
domain:servicesseat, sessionsession_0194kbQJxUvv2yvsGRtuXpP5, round 3. PR #12725,Part of #11993— correct, and the reason is the finding below.⭐ Expectation 1 is not a defect in this repo, and that is measured
The brief asked the dev to measure where the button's visibility is decided rather than implement a permission-shaped change. It did, and I re-verified the load-bearing part myself on
origin/mainrather than accepting it:packages/plugins/plugin-approvals/src/sys-approval-request.object.ts:511-513,approval_recall'svisible:⇒ The server-side declaration already gates recall visibility on
is_submitter, and already has. It is not missing; it is being ignored.Two corroborating readings, both re-run here:
Recall/ 「撤回」. ⇒ The string in the report is composed by a surface that is not this repo.⇒ Expectation 1 lives entirely in the console surface that is not honouring
record.viewer.is_submitter— objectui#6464.objectstack-ai/objectuibut the GitHub API tools do not cover unattached repositories, and I judged a credentialed attach unwarranted for a status check on another lane's card. ⛔ Not-measured is not a pass — confirm that card is live before anyone closes this one.⇒ This card stays open carrying expectation 1, with objectui#6464 as its named reader.
Part of, notFixes, is exactly right.Expectation 2 — what landed
The refusal now renders through
renderOperationMessagewith the catalog keyapproval_recall_not_submitter. Diff verified: zeropackages/spec— Clause-② path limb clean, and the declarationnoholds (the gate is byte-identical; only the sentence changed).⭐ The tests assert what an operator reads, in zh-CN, as literals —
expect(err.message).toBe('FORBIDDEN: ' + ZH_SENTENCE)plusexpect(wireError).not.toMatch(/[A-Za-z]/), with the Chinese sentence pinned as a literal rather than read back out of the catalog. ⭐ That last choice is the one that matters: a test that renders the catalog against itself cannot tell Chinese from English, which is the entire reported defect. The brief asked for zh-CN specifically and got it in the only form that proves anything.The
FORBIDDEN:prefix is pinned separately as wire contract, not copy — it still matcheshandleApprovalError's status test and still strips clean, so a localized sentence cannot shadow the 403 mapping. And three control tests hold the permission boundary still: submitter recalls, #3424 admin recalls, plain non-submitter refused with the request leftpending.The one addition beyond the emitter — accepted, and it was flagged
ApprovalServiceOptionsgains an optional lazily-resolvedmessageTranslator, wired by the plugin the same waytenancyPostureand the field-visibility source are. The justification is sound and specific: the spec half's own changeset promises that a deploymenttranslationforerrors.approval_recall_not_submitter"takes effect then" once the consumer half lands — and without a translator reaching the renderer, that documented override address is inert. ⇒ The addition makes an already-published promise true rather than inventing scope. Additive, optional, non-spec ⇒ Clause-② staysno, consistent with how this lane graded #12563 and #12667.Verification
plugin-approvals34 files / 621 tests passed, 11 of them the new file. ⭐ Honest typecheck scoping:--listFilesshows 1 hit each for the two edited sources and 0 for the new test file (the tsconfig excludes**/*.test.ts), so that run is explicitly not a measurement of the test file — its coverage came fromcheck:type-check-debt --re-measureinstead, ledger unmoved. That is the excluded-tests false green named and avoided rather than walked into.⭐
check:engine-double-contractwas genuinely RED first — the new fake engine declareddelete()/update()unpinned. Fixed the self-serve way: both now open withassertEngineDeleteDispatch/assertEngineUpdateDispatch, and--writeadded two rows to the pinned ledger. The shrink-only baseline was not touched. A double looser than the engine it stands in for turns a green suite into no suite, and the gate caught exactly that.Ablation: reverted the catalog call to the old hardcoded throw with the mutation proved on disk first (call sites 1→0, legacy string 0→1, blob
6be2255→c2ee2c7, the script refusing to continue if any check failed). Result 6 failed / 5 passed, with the zh-CN row reporting the old English sentence — i.e. the reported defect reproduced. ⭐ The 5 still-green rows are precisely the ones the mutation cannot reach (the code-prefix row, the developer-log row, the three permission controls), which is the shape that proves the red is targeted rather than a whole-file collapse. No rebuild leg, and that is measured not assumed: the test imports the subject relatively, demonstrated by an earlier transform error naming the source file itself.Changeset
minor— correct, the package gains a public option.Two more findings, both correctly not absorbed
approval_recallaction hides the #3424 admin-override recall — the service admits it, the visible predicate never shows it #12716 —approval_recall'svisibleomitsrecord.viewer.can_overridewhileApprovalService.recalladmits 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. ⭐ Independently confirmed here: lines 381 / 411 / 434 carrycan_override; line 513 does not. ⇒ A capability the server grants that no UI entry exposes — the stuck-record recovery path has no recall button for the actor authorised to use it. Correctly left alone: it moves who sees which action, which is permission-shaped.,where the platform's other shipped zh-CN copy uses,. Cosmetic, in a package this card holds none of.Next step
Non-governed path face. CI converging at review time. ⇒ Arming once every check is green, ⛔ not on the required subset. On merge, ⛔ do not close #11993 — strip
pm:dispatched, release the assignee, and leave it open on expectation 1.Generated by Claude Code
os-litant commentedon Aug 27, 2026
Landing window closed for expectation 2 — MERGED, verified by content. ⛔ This card stays OPEN.
PR #12725 landed on
origin/main. ⛔ Not read off the merge event — read out of the merged tree:plugin-approvals/src/approval-service.ts→ 4 occurrences ofapproval_recall_not_submitter, the refusal now rendering throughrenderOperationMessage;only the submitter may recall this requestis GONE from that file (0 occurrences). That zero is a real transition rather than a bad query: the same grep returned 1 at 14:45Z, before the queue landed it.pm:dispatchedstripped, assignee released, read back.State:
pm:blocked, notpm:queueI briefly set this to
pm:queueon merge and corrected it in the same minute. That would have been the same lie this lane spent the day removing from other cards: expectation 1 is not dispatchable in this repo, so a queue label would tell every future scan it is.Blocked-by: objectstack-ai/objectui#6464
objectstack-ai/objectuibut the GitHub API tools do not cover unattached repositories, so I could not read #6464's state — open, closed, or nonexistent. ⛔ Not-measured is not a pass. Whoever picks this up verifies that card exists and is live first; if it does not, this card needs a fresh one filed at the destination rather than a dangling wait.Why expectation 1 is not ours — measured, and re-verified by this seat
sys-approval-request.object.ts:511-513already gatesapproval_recall'svisibleonrecord.viewer.is_submitter == true. The declaration is not missing; it is being ignored by a console surface. Corroborated by the reported label 「撤回审批」 being a repo-wide zero-hit (reverse-controlled against 「撤回」, which fires 5× in this package's own zh-CN bundle) — the string in the report is composed somewhere this repo does not own.⇒ Nothing server-side here decides that button's visibility. This card holds the remaining half only as a tracking anchor.
Generated by Claude Code
os-litant commentedon Aug 28, 2026
⭐ Blocker discharged — and it discharges the whole card. Both halves are now fixed.
PM seat
domain:services, sessionsession_0194kbQJxUvv2yvsGRtuXpP5.Closing out my own NOT MEASURED
When PR #12725 landed I wrote
Blocked-by: objectstack-ai/objectui#6464and flagged it honestly as unverified:I am that reader, so I measured it. The GitHub API tools still do not reach an unattached repository — but the card's existence was never the real precondition. The real one is "is the console still ignoring
record.viewer.is_submitter?", and that is a code question, answerable from a git read ofobjectstack-ai/objectuiwithout a credentialed attach.Measured on
objectui@88b15fddc:packages/plugin-detail/src/DetailView.tsx:1307{dataSource?.cancelPendingApproval && inline?.approvalIsSubmitter !== false && (— the recall button is now gated on identity, not only on adapter capabilitypackages/react/src/context/InlineEditContext.tsx:126,:194,:205,:254approvalIsSubmitterthreaded throughInlineEditProvider, additive and optionalpackages/app-shell/src/views/RecordDetailView.tsx:1040const approvalIsSubmitter = isSubmitterOf(approvals.pendingRequest, user?.id);, passed at:2340packages/app-shell/src/hooks/useRecordApprovals.ts:228export function isSubmitterOf— one derivation, not twopackages/app-shell/src/views/RecordDetailView.approvalRecallGate.test.tsx.changeset/6464-recall-submitter-gate.mdmain, pending release⇒ objectui#6464 is fixed and merged. The wait was lawful and it is now over.
⭐ Note the gate is
!== false, not=== true— deliberately tri-state. Their changeset explains why, and it is the right call: a host that resolves no approval identity renders exactly as before, because defaulting the unknown case to "hide" "would have traded a cosmetic defect for a functional loss: every host whose band runs off the record'sapproval_statusmirror alone would silently lose its submitter's only way to unlock their own record."⭐ And their changeset independently names the same split this card recorded, from the other side: "The untranslated refusal text … is a separate defect and is not addressed here; it is tracked on objectstack#11993." Two lanes, one seam, neither guessing about the other.
Both expectations are now closed
sys-approval-request.object.ts:511-513gatesvisibleonrecord.viewer.is_submitter == true); it was being ignored by the consolerenderOperationMessagewithapproval_recall_not_submitterNothing is left in this card, so it closes.
pm:blockedremoved.@baozhoutao — both halves of what you reported are fixed.
No pending approval request found for this record. TheFORBIDDEN:wire prefix that maps to HTTP 403 is unchanged, so nothing your integrations key off has moved.@objectstack/*@17.2.0. The refusal-text fix arrives in the next@objectstack/*release; the button fix is in the@object-ui/*packages your Console runs on and arrives in theirs. If you need a date for either, say so here.Two related findings came out of this report and are tracked separately, neither affecting what you saw:
approval_recallaction hides the #3424 admin-override recall — the service admits it, the visible predicate never shows it #12716 — the recall action'svisiblepredicate omitsrecord.viewer.can_override, while the server does admit the override actor. So the stuck-record recovery path currently has no recall button for the one actor authorised to use it. That is the opposite defect to yours and is a real gap.,. Cosmetic.Thank you for the report — the reproduction was precise enough that the two halves could be separated cleanly and sent to the right repos.
Generated by Claude Code
fix(plugin-approvals): render the non-submitter recall refusal throug…