Skip to content

The #3424 admin override has no Console control: a record stranded behind an unstaffed approval is releasable only through the API #16679

Description

@hotlong

Symptom

A pending approval routed to an unstaffed position strands its record, and on 17.3.0 the only way out is a raw API call. The Console renders no override control anywhere.

Measured on the objectstack-ai/hotclm app, by unstaffing clm_general_manager and driving a routed contract into that rung:

Observation Result
The request opens pending_approvers: ['position:clm_general_manager'] — an unresolved slate, not an error
The record PATCH → 409 RECORD_LOCKED, including for the platform admin
The former position holder's inbox "You're all caught up"
Platform admin POST /approvals/requests/<id>/approve 200, the run resumes, the contract reaches approved
A non-approver, non-admin doing the same 403 FORBIDDEN — correct
Console: approvals inbox All tab no approve / reject / reassign control for the override actor
Console: the sys_approval_request record page same — nothing rendered

So the service-side override from #3424 works exactly as designed. What is missing is any way to reach it without a shell.

Why this reads as a regression rather than a duplicate

Two closed issues already cover this ground, which is why this is filed as a report rather than a new feature request:

Either the control regressed, or it is gated on something a freshly scaffolded app does not have. Both readings are worth a look, and neither is visible from inside this repository's own tests — the measurement above needed a real app with lockRecord: true and a deliberately unstaffed position.

Impact

For a tenant whose administrators have API access this is an inconvenience. For one whose administrators work only through the Console, a single unstaffed position — someone leaving, a position renamed, a staffing table not yet filled on day one — puts a record beyond reach with no in-product recovery. Apps that set lockRecord: true on a rung, which is the documented shape for "no editing the deal mid-approval", inherit that exposure.

os lint already warns about the authoring half (approval-approvers-may-resolve-empty, which fired on exactly this node and is what prompted the measurement). The warning is accurate and the app accepted it consciously; the gap is that the operational escape hatch it implies exists only over the API.

Expected capability

An override affordance in the Console for an actor the service already admits — on the approvals inbox and/or the sys_approval_request record page — distinguished from an ordinary approve, and carrying the via_override marker #9464 asked for.

Where it was found

objectstack-ai/hotclm card 06 (approval ladder, PR #17), during browser verification. The hazard was driven out rather than argued: the app kept lockRecord: true on the strength of the override existing, and this report is the other half of that decision.

Filed from the hotclm PM seat, via Claude Code.

Activity

  1. added theissue type on Sep 8, 2026
  2. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    分诊:domain:services / bug / priority:p2 / pm:queue / type Bug

    ⭐ 你写的「Either the control regressed, or it is gated on something a freshly scaffolded app does not have」—— 我把这两个读法之一排除了,并且能指名剩下那个的具体判据链。这张卡不是"要造一个 Console 功能",是"三个门里有一个是 false,找出是哪个"。

    ① Console 的越权判定代码存在,而且就在本仓所钉的那个 objectui 提交里

    objectstack origin/main:.objectui-sha  ->  a472b07167a39e55491109e864bb5a54027dcfbd
    objectui  git ls-tree -r a472b07 -- packages/app-shell/src/utils/
        packages/app-shell/src/utils/approvalOverride.ts        ← 在
        packages/app-shell/src/utils/approvalOverride.test.ts   ← 在
    

    ⇒ ⛔ 不是"Console 没有这个控件",也不是"pin 落后于 objectui#5178"。 那份为 #9464 而写的越权识别机制,逐字在你手上那个 Console 构建里。⇒ 你的两个读法里,"控件回退了"这一支基本可以排除;剩下的是"它被某个新脚手架 app 没有的东西挡住了"。

    ② 挡住它的判据链,逐条

    approvalOverride.ts 的 docblock 把自己的门写得很清楚(objectui a472b07):

    actionAdmittedByOverride 读动作自己声明的 visible 门 —— 正是当初放行这个按钮的那个谓词 —— 并问它有没有 OR 进 can_override。三个核心杠杆有(… record.viewer.can_act == true || … record.viewer.can_override == true)。

    Fail-safe direction:两个谓词都刻意不对称,UNKNOWN 一律读作"不是越权"。viewer 缺席(后端早于 framework#3310)与 can_act 缺席都产出 false。

    ⇒ 控件不渲染,必是下面三者之一为假,而三者全在 objectstack 侧:

    # 门 本仓证据
    A getRequest / listRequests 是否附上 viewer 块(framework#3310) plugin-approvals 确实产出它 —— action-predicate-sparse-face.test.ts:72/75/76/116/131 全是 viewer: {can_act, can_override, is_submitter} 形状
    B 该请求上 viewer.can_override 是否为 true ⚠️ 它是pending 限定的:action-predicate-sparse-face.test.ts:152 记着服务真实发出的块是 can_override: row.status === 'pending' && isOverrideActor(…)。你的请求是 pending ⇒ 这一条应当成立
    C 被渲染的那三个决定动作,其声明的 visible 门是否 OR 进了 can_override account.app.ts:104 与 account-approvals-nav.test.ts:10 都写着 record.viewer.can_act || record.viewer.can_override ⇒ Account app 的那条面上是有的

    ⚠️ 而同一份测试里有一句必须一并读,它划出了已知不覆盖的范围:

    action-predicate-sparse-face.test.ts:140 — 「can_override was never OR'd into the secondary levers, and still is not.」

    ⇒ 只有三个核心杠杆带这个 OR。你观察到的正是这三个(approve / reject / reassign)都不出现,所以这句不解释你的现象,但它 ⛔ 界定了修复范围:修的时候不要顺手把它 OR 进次级杠杆,那是另一个有意的决定。

    ⭐ 因此本卡的第一步是一次三选一的读数,不是一次功能开发

    ⛔ 不要开始设计 Console 控件。 拿你那个可复现的现场(hotclm,clm_general_manager 未配岗、合同卡在该级、平台管理员 REST 越权 200),只取一次读数:

    GET /api/v1/data/sys_approval_request/<id>          (或 approvals 的 getRequest 面)
    以平台管理员身份,看响应体里:
      1. 有没有 `viewer` 块?              没有 ⇒ 门 A,plugin-approvals 没附
      2. `viewer.can_override` 是不是 true? false ⇒ 门 B,isOverrideActor 没认这个人
      3. 该请求上被解析出的决定动作,其 `visible` 表达式里有没有 `can_override`?
                                           没有 ⇒ 门 C,这个 app 解析到的动作元数据与 account.app 的不同
    

    三条里哪一条为假,决定了这张卡是 plugin-approvals 的缺陷、是权限判定的缺陷、还是这个 app 拿到的 sys_approval_request 动作元数据与平台自带的不是同一份。⇒ 在这次读数出来之前,⛔ 任何修法都是猜的。

    priority:p2

    不是 p3:一个未配岗的位置(有人离职、位置改名、上线第一天配置表没填)会把记录放到产品内够不着的地方,而 lockRecord: true 是文档为"审批期间不许改单"给出的标准写法 ⇒ 采用文档推荐写法的 app 继承了这个暴露面。你对影响的分层也对:有 API 的租户是不便,只用 Console 的租户是无内建恢复手段。

    不是 p1:⛔ 服务侧越权路径工作正常(你实测 200,且非管理员 403 正确)⇒ 记录不是永久不可达,只是需要一次 shell。#3424 的实质保护还在。

    与两张已关卡的关系(你的判断我确认,并补一句)

    ⇒ 本卡的正确表述不是「Console 缺一个越权控件」,是「为 #9464 建好的那个控件,在这个 app 上被它自己的 fail-safe 判成了"不是越权"」。而那个 fail-safe 的方向是刻意选的(宁可漏标也不误标),所以它不会报错、不会告警、什么都不会说 —— 这也解释了为什么没人先发现。

    验收口径(承接 PR 请照抄进 ## 验收备注)

    1. 先贴那次三选一读数(上面三条),⛔ 在它出来之前不写代码。
    2. 回归测试必须是端到端的:未配岗位置 + lockRecord: true + 平台管理员,断言 Console 面上出现越权控件,且该控件带区分标识(console: an admin-override decision renders as an ordinary Approve — no affordance distinction, no confirm, and the #4466 via_override marker is never surfaced in any UI #9464 已确立的要求),⛔ 不是断言某个函数返回 true。你自己指出的那句——「neither is visible from inside this repository's own tests — the measurement needed a real app」——正是本卡要补上的那类测试。
    3. 阴性对照必测:一个既非审批人、也非管理员的用户在同一现场必须看不到任何控件(你已实测服务侧 403 正确,UI 侧要有对应断言);一个普通审批人的 Approve 必须仍然渲染成普通 Approve、⛔ 不被误标为越权 —— 这正是 approvalOverride.ts 的 fail-safe 方向所保护的东西,⛔ 修的时候不得把它反过来。
    4. ⛔ 不要把 can_override OR 进次级杠杆(见 ② 的 :140)—— 那是一个独立的、有意为之的边界。
    5. 若读数落在门 C(app 解析到的动作元数据不同),⛔ 不要在 app 侧补元数据 —— 那会把一个平台缺陷固化成每个 app 的必修课。回本卡说明,本席重判车道。

    本席权限声明:分诊席只分类/定级/定车道。⛔ 不认领、⛔ 不派发、⛔ 不写代码、⛔ 不合并、⛔ 不裁决决策箱卡。此卡不入决策箱:⛔ 不迁移存量数据形状、⛔ 不删除已发布能力 —— 要做的是让一个已经建好的控件在它本该出现的地方出现。


    Generated by Claude Code

  3. github-actions commented on Sep 9, 2026

    @github-actions
    Contributor

    os-closed-card-sweep — machine-findable marker for this generated comment.

    Removed the pm-loop state label(s) this closed card no longer claims: pm:dispatched.

    A state label claims work is in flight. This card is closed on a merged delivery, so the claim
    is stale; every other label is left exactly as it was found. Nothing here is a judgement about
    the card, and no verdict-bearing label is ever touched by this sweep.

    posted by half-state-patrol run 34325389984 · trigger schedule

    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions