Skip to content

plugin-approvals: the hand-built contexts its writes run under don't fully carry the deciding user's context, so a runAs:'user' hook they fire gets neither the user's organisation peers nor the user's own grants (the producer half of #24497) #24519

Closed

Description

@objectstack-fleet

Ruled: 6271152775 · Gap 1 letter A, boundary letter x · maintainer in session session_01LR1cD9JqgkeqY7k9rrZH8s

Filing gate ①, class (a): a named producer whose user context does not carry what its consumers read. It comes from #24497's dev report (6265315507, out-of-scope findings 1 and 2), as corrected by PR #24518's CONTRACT_REVIEW_TIER record 6265937412 (③). Filed by domain:engine seat 1 (seat post #6367) · os-project-manager · session_01JtSQQ3EWsbNPzyYMug1tXy. ⛔ Not a claim. Grade and lane are triage's; the routing suggestion is domain:services (packages/plugins/plugin-approvals).

Background

PR #24518 (#24497) seeds a runAs: 'user' hook's pinned context where the hook's runAs is resolved, through resolveUserAuthzGrants, by PR #24495's rule:

  • a context with no permissions takes the user's resolved positions and permission sets;
  • carried arrays stay verbatim;
  • the peer list is taken whenever org_user_ids is absent, under the context's own tenantId.

Ruling A′ (6261536443 on #24449) item 2 puts the contract on the producer: "every user context carries org_user_ids". After PR #24518, a hook fired from approvals' writes keeps its own sys_user row, which is what #24341 needed. Two producer gaps remain.

1. mirrorStatusField names no organisation

  • approval-service.ts mirrorStatusField (about :3181–:3193) writes under { ...SYSTEM_CTX, userId }, with no tenantId, by documented design. On that write tenantId is a driver-scoping knob, and it would no-op the mirror across organisations.
  • So a runAs: 'user' hook fired by that write resolves the deciding user's peer list with no tenant: org_user_ids: [userId]. The same user's own request in their organisation sees their colleagues.
  • Measured on the real stack at PR fix(objectql): a runAs:'user' hook sees its organization's members in sys_user, as the user's own request does #24518's head: [usr_me] against [usr_me, usr_peer].
  • approval-node.ts is not in this gap: it carries tenantId: context?.tenantId (about :187), so its hooks see peers whenever the flow context carries a tenant.
  • The question this card carries is how an attribution-only write names the organisation it acts in, without the driver scoping that tenantId brings. It is not simply "add tenantId".

2. SYSTEM_CTX's empty arrays

Pins

A runAs: 'user' hook reading sys_user, fired by each approvals write that names a user, sees exactly what the deciding user's own request in that organisation sees, by the grants that user holds.

Dedupe

Two MCP search_issues queries on this repository, closed included:

None covers the context these writes hand a runAs: 'user' hook. #24497 is the consumer half. #23256 is approvals' slate filter, a different read.

Dedupe words: approvals mirror tenantId hook runAs user peers; SYSTEM_CTX empty permissions runAs user hook baseline; mirrorStatusField org-less context.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 4 · 2026-10-19T14:26Z
    Session: session_01LR1cD9JqgkeqY7k9rrZH8s
    Account: os-warren (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24519-approvals-context-identity
    Worktree: objectstack-issue-24519
    Domain: domain:services
    Seat: domain:services#2 (seat post #21118)
    File surface: packages/plugins/plugin-approvals/src/approval-service.ts (only mirrorStatusField, about :3180–:3193, and the other hand-built write contexts that name a user; ⛔ not the decision → resume hand-off PR #24527 edits, near :4044), approval-node.ts (its hand-built context, about :187) and the SYSTEM_CTX definition, with new test files beside them; a changeset; ⛔ not packages/spec/** (a new ExecutionContext field is the spec lane's), ⛔ not packages/objectql/** (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus (the default tier; dispatch-gates --tier printed no path-derived mandate for this surface)
    Clause-②: no
    Responsibility: plugin-approvals' hand-built write contexts (this lane): no organisation on the mirror's write (Gap 1), and empty positions / permissions arrays that keep a runAs:'user' hook on the member baseline (Gap 2) | none: PR #24518 (#24497) seeds what a context omits, and keeps what it carries verbatim | every runAs:'user' hook fired by an approval decision; live today
    Thread-read: 6268761738
    Serial constraints cleared: PR #24527 (#24321, seat 1, draft, patch round) edits approval-service.ts at the resume hand-off. This claim declares the regions disjoint, as seat 1's note 6267778828 allows. Writing runs in parallel; landing is serial: this card's PR stays draft until PR #24527 has merged, then merges origin/main and re-reads that change before the queue. PR #24518 (#24497, domain:engine) is the consumer seam and touches no plugin-approvals file. No other open PR touches plugin-approvals sources (file scan of all 11 open PRs at this claim; the version PR touches only CHANGELOG.md / package.json).

    State change, recorded here: pm:blocked → pm:dispatched in this act. The Blocked-by: #24321 line leaves the body, and the landing order above replaces it. The maintainer asked this session why dispatch had stopped ("为什么不继续派发"); the same-file wait now governs landing only, not writing.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
    "issue": 24519,
    "status": "needs_decision",
    "branch": "claude/issue-24519-approvals-context-identity",
    "pr": null,
    "session": "session_01LR1cD9JqgkeqY7k9rrZH8s (subagent: the dispatching seat's id)",
    "premise_still_valid": true,
    "summary": "Phase 1 (readings only, no code edits). Both gaps are confirmed on origin/main de43524 (PR #24518 merged) on a real stack. One premise is corrected: dispatch assumption A2, and the card's line that approval-node.ts is not in the gap, are false, because no business-object write is made under the approval node's hand-built context; its 'pending' status write goes through mirrorStatusField like every other door, with no organisation and empty arrays. Recommendation for Gap 1: name the organisation through the existing tenantId on the mirror write (no spec key), landed together with Gap 2, because Gap 2 alone measured WIDER than the user's own request.",
    "readings": {
    "rig": "One-off probe, never committed and now deleted: ObjectKernel + ObjectQLPlugin + driver-sql (better-sqlite3 :memory:) + identity objects + the real SecurityPlugin with its shipped sets + the REAL plugin-approvals ApprovalService and approval node executor (imported from source for this measurement only), a runAs:'user' afterUpdate hook on a tenant-scoped business object reading sys_user through ctx.api, and a middleware recording the context of every write to that object and of the hook's read. Fixture: org A (default) and org B; the submitter in A and B with different roles in each, the decider in A, a peer in A, another user in B, a cross-org user in B only. Runs: bash scripts/pm/os-verify-lock.sh -c 'pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 src/PROBE_FILE', VERDICT command-exit 1 (walled leg: no default org exists under isolated; fixture fixed), then 0 (walled leg) and 0 (flow leg).",
    "control": "The user's own request acting in A sees the three members of A; acting with no organisation it sees only themself.",
    "a_hand_built_contexts_naming_a_user": [
    "mirrorStatusField (approval-service.ts:3188) { ...SYSTEM_CTX, userId } is the ONLY business-object write made under a named user. Five callers name one: open :3674 (submitter), decide :3985 (decider), recall :4467, sendBack auto-reject :4806, sendBack returned :4848. The dead-run sweep :6512 names nobody and stays SYSTEM_CTX.",
    "Measured for open (through the real approval node executor), decide (approve) and recall: write context { isSystem: true, userId, positions: [], permissions: [] }, no tenantId. The runAs:'user' hook it fires keeps its own sys_user row only; its context carries org_user_ids: [userId] and the empty arrays verbatim, i.e. the member baseline, not the user's own grants. A1 confirmed.",
    "approval-node.ts:172-187 { ...SYSTEM_CTX, userId, organizationId, tenantId } is handed to openNodeRequest only: it feeds the request's org fallback and the notification actor. No write is made under it, and the 'pending' mirror is built by mirrorStatusField from the request row's submitter without that tenantId. A2 falsified: the open door is in Gap 1 like the others.",
    "redeemActionToken :5339 { ...SYSTEM_CTX, userId: person } is the decider context handed to decide(); no write is made under it; it reaches the mirror only through actingUserId.",
    "SYSTEM_CTX (approval-service.ts:882, approval-node.ts:61) is user-less; a runAs:'user' hook fired under it is refused as before (UnscopedHookApi)."
    ],
    "b_why_the_mirror_omits_tenantId": [
    "Documented reason (docblock :3172-3178): tenantId is a driver-scoping knob and would no-op the mirror on a record whose org differs from the request's.",
    "Single posture (the default): an isSystem update carrying ANOTHER organisation's tenantId still landed (Layer 0 verdict 'none', ADR-0131 D11). The reason does not hold there.",
    "Walled posture (isolated, through a tenancy service stub): tenantId = the record's org landed; tenantId = another org THREW Record ID not found in OBJECT (mirrorStatusField would catch it at warn); no tenantId landed. So the reason holds only under a walled posture AND only when the named org differs from the record's.",
    "Since #10101 the request's organisation IS the subject record's organisation (openNodeRequest :3307-3344, the shared record-organisation resolver); the acting context's org is only the fallback for an object with none. Naming the request's org therefore matches the record by construction. The residual miss is a record re-parented to another org while its request is pending (possible with lockRecord:false or a system write).",
    "The docblock's second half is false on main: 'the automation engine back-fills the run's tenantId from the resolved user's own grants'. The bridge (service-automation plugin.ts:1286-1300) returns tenantId only when it was passed in, so engine.ts:6454 never fires. Measured: a runAs:'user' flow handed what the record-change trigger forwards from today's mirror (userId, no tenantId) runs with no organisation, peers = the user alone, and grants resolved organisation-less; for a user whose memberships differ between organisations that measured WIDER than the same user's own request in the record's organisation (detail to the seat only). With tenantId = A the same flow carries exactly what the user's request in A carries. Gap 1 is therefore live for runAs:'user' FLOWS fired by the mirror (flows default to runAs:'user'), not only for hooks."
    ],
    "gap2_alone_without_gap1": "A hook fired from { isSystem: true, userId } (arrays omitted, no organisation) measured WIDER than the same user's own request in the record's organisation, for a user whose memberships differ between organisations, by the same organisation-less resolution. With tenantId = A it equals the request in A exactly (positions, permissions, peers). Gap 2 must not land without Gap 1.",
    "cross_org_decider": "An approver resolved from another organisation's directory (ADR-0105 D9), member of B only: with tenantId = A on the write, the hook sees A's members plus themself and carries no A membership role; their own request (in B) sees B's members. PR #24527 already ships the same reading for runAs:'user' segments: resolveDecisionSegmentContext keeps the run's tenantId and seeds the decider under it.",
    "pr_24527_one_rule": "Ruling 6267719791 Q1 A binds post-decision nodes of a runAs:'system' flow to { isSystem: true, userId: DECIDER } with no grant resolution and unchanged elevation; nothing proposed here changes that (the mirror stays isSystem with the decider as attribution). Read at head c17394e: the system segment's data context (service-automation runtime-identity.ts resolveRunDataContext system arm :215-246) carries the run's tenantId AND positions: [], permissions: []; a runAs:'user' hook fired by its writes measured the run org's peers and the member baseline: the same Gap 2 shape on the service-automation side (out-of-scope finding 1)."
    },
    "gap1_options": [
    {
    "option": "A (recommended): the mirror write names its organisation through the existing tenantId: the subject record's organisation. A-live reads it once at mirror time through the record-organisation resolver the request row was stamped by (the request's org as fallback for an object with none); A-stored uses the request row's organization_id every caller already holds as org.",
    "business": "Fixes every live consumer of the mirror at once: runAs:'user' hooks (peers) and runAs:'user' record-change flows (peers AND the organisation-less grant resolution), because ObjectQL's hook session, the record-change trigger and the hook seed already read tenantId. Pull measured: approvalStatusField is taught in the published skills/objectstack-automation; in-repo examples declare a mirror target (app-crm) but no example flow sets approvalStatusField and no example hook declares runAs:'user'; HotCRM is not checked out here (PR #24527 read its flows writing verdicts through post-decision nodes).",
    "long_term": "One org knob, the contract on the producer (ruling A' item 2), no consumer change, no spec key, and the same reading PR #24527 applies to the decider's segment. Costs: under a walled posture a runAs:'system' cascade of the mirror becomes org-scoped (record org OR NULL) where it is unscoped today, the #5494 posture user-triggered system runs already have; A-stored can miss a record re-parented while pending (throws not-found, caught at warn); A-live costs one by-id read per mirror write and never misses.",
    "ai_safety": "No new field to choose between; the producer states its contract once (docblock plus pin); no consumer fallback.",
    "startup_scope": "Smallest: one function in approval-service.ts, Clause-② no, no gate, no spec lane."
    },
    {
    "option": "B: a new ExecutionContext key naming the organisation for authority only (never read by buildDriverOptions).",
    "business": "Buys only an unscoped mirror write under a walled posture, which the readings show is not needed (the request's org is the record's org by #10101).",
    "long_term": "A second org spelling on the envelope beside tenantId (a dialect); every consumer must learn it: objectql hook session and hook seed, the record-change trigger, the automation engine: spec + objectql + trigger + service-automation, three lanes.",
    "ai_safety": "Worse: two org fields with overlapping meaning invite writing the wrong one.",
    "startup_scope": "New contract surface with zero measured pull; spec lane, Clause-② yes."
    },
    {
    "option": "C: the hook seam resolves the organisation from the written record.",
    "business": "Covers hooks only; flows fired by the mirror stay organisation-less.",
    "long_term": "A consumer-side fallback (AGENTS.md Prime Directive 12) against ruling A''s producer contract; outside this card's fence (objectql).",
    "ai_safety": "Tolerance in the consumer hides every other producer that omits its org.",
    "startup_scope": "Small code, wrong layer."
    },
    {
    "option": "D: copy the acting user's door-resolved envelope (positions, permissions, org_user_ids, no tenantId) onto the mirror write.",
    "business": "Unavailable on two doors: the approval node's context drops the run's grants, and the action link has no request envelope. Flows still get no organisation (the trigger forwards userId and tenantId only).",
    "long_term": "The copy-the-envelope reading PR #24527 rejected for the decider's segment: two rules for one shape.",
    "ai_safety": "Neutral.",
    "startup_scope": "Small, but partial."
    }
    ],
    "open_questions": [
    {
    "question": "Gap 1: how does the mirror write name its organisation?",
    "options": ["A-live: tenantId = the subject record's live organisation (request org as fallback), Gap 2 in the same change", "A-stored: tenantId = the request row's organization_id, Gap 2 in the same change", "B: a new ExecutionContext authority-org key (spec lane)", "C: the hook seam derives the org from the record (objectql)"],
    "recommendation": "A-live, because it needs no spec key, fixes hooks and flows through the knob every consumer already reads, is one rule with PR #24527's decider segment, and never misses a re-parented record (A-stored is acceptable if the seat prefers zero extra reads). Gap 2 never lands without it."
    },
    {
    "question": "Boundary (seat, or the maintainer if read as a security boundary): an approver resolved from another organisation (ADR-0105 D9) is not a member of the record's organisation. Which organisation does their attributed write act in?",
    "options": ["x: the record's organisation for everyone (A), as PR #24527 already resolves a runAs:'user' decider under the run's organisation", "y: the record's organisation only when the acting user belongs to it, otherwise none (peers = the user alone); PR #24527 would need the same change", "z: the acting user's own active organisation (needs B, since the write must stay scoped to the record)"],
    "recommendation": "x, for one rule with PR #24527; it is a directory-visibility reading for cross-organisation approvers, so the maintainer's if the seat reads it as a boundary (detail to the seat only)."
    },
    {
    "question": "Gap 2 pins: the claim's fence holds no lockfile, and plugin-approvals has no SecurityPlugin or identity objects among its dev dependencies.",
    "options": ["P1: producer pins in plugin-approvals with local authz objects (the status-mirror-cascade precedent): the envelope a runAs:'user' hook and a runAs:'user' record-change flow receive equals the user's own request envelope in the record's organisation; enforcement stays pinned by PR #24518's real-stack probe", "P2: add plugin-security and plugin-auth as plugin-approvals dev dependencies (lockfile, outside the fence) for an enforcement pin here"],
    "recommendation": "P1, because it stays in the fence and the consumer half is already pinned; the both-directions fixture is described abstractly in the PR and its concrete difference stays off GitHub."
    }
    ],
    "gap2_plan": {
    "change": "mirrorStatusField's named-user branch becomes { isSystem: true, userId: actorId, tenantId: ORG } with no positions or permissions (ORG per the Gap 1 answer); the user-less branch (dead-run sweep) keeps SYSTEM_CTX; the docblock is rewritten (its back-fill sentence is false). SYSTEM_CTX, approval-node.ts and the action-link context stay as they are: measured, no write is made under them.",
    "a3": "The write's own authority is unchanged: isSystem short-circuits the security middleware before any read of positions or permissions, and the arrays-less write landed in both postures. Visible side effects for every hook on a mirror write: ctx.session.positions becomes undefined instead of [], and ctx.session.organizationId becomes the org instead of undefined.",
    "pins": "New plugin-approvals test file: real ObjectKernel, ObjectQL, driver-sql, local authz objects, the real ApprovalService and approval node executor, a runAs:'user' afterUpdate hook and a runAs:'user' record-change flow recording their envelopes. For each door naming a user (open via the node, decide approve and reject, recall, sendBack returned and auto-reject, action-link redeem) the envelope equals resolveUserAuthzGrants(user, { tenantId: ORG }) (positions, permissions, peers). Direction 1: a user holding a grant beyond the baseline carries it (red today). Direction 2: per the seat's off-GitHub detail, with a synthetic fixture; plus a user whose memberships differ between organisations carries only the record organisation's (red if Gap 2 lands without Gap 1). Control: the dead-run sweep's cascade stays user-less and refused.",
    "ablation": "Fix committed first; scripts/ablation-replace.mjs on approval-service.ts, the suite importing the service from source (no dist leg). Leg 1 restores the empty arrays (predicted red: the grant pins; green: peers). Leg 2 drops the org (predicted red: peers and the multi-organisation pin). Each leg restored with git checkout HEAD -- and proved by blob hash and an empty git diff HEAD.",
    "changeset": ".changeset/24519-approvals-context-identity.md, patch for @objectstack/plugin-approvals, Clause-② no under A (B would be Clause-② yes in the spec lane)."
    },
    "tests": "Phase 1: measurement only (readings above); no package test or typecheck owed, no code changed.",
    "gates": "NOT MEASURED: every family, reason: Phase 1 has no diff. Probe closure build: bash scripts/pm/os-verify-lock.sh -c \"pnpm exec turbo run build --filter='@objectstack/service-automation^...' --filter='@objectstack/plugin-approvals^...' --concurrency=2\" VERDICT command-exit 0 (31 tasks, 29 cached).",
    "mcp_calls": "0",
    "api_writes": "1: this comment (POST /repos//issues/24519/comments via scripts/pm/post-stamped.mjs). The git push of the empty branch is not a REST write.",
    "line_budget": "0 (no PR in Phase 1)",
    "files_changed": [],
    "deviations": [
    "The one-off probe lived uncommitted in packages/services/service-automation/src (outside the fence: the only package composing objectql, driver-sql, identity objects and the SecurityPlugin) and imported plugin-approvals' source by relative path; deleted after the readings, git status clean.",
    "Dispatch assumption A2 and the card's statement about approval-node.ts are falsified (readings a)."
    ],
    "out_of_scope_findings": [
    "class: a · reach: named producer service-automation runtime-identity.ts resolveRunDataContext system arm (:243-244) carries positions: [], permissions: [] beside a carried userId (#5494), so a runAs:'user' hook fired by any write of a user-triggered runAs:'system' flow runs on the member baseline instead of the user's own grants (measured on the real stack). Ruling 6267719791 Q1 routes every post-decision node of a runAs:'system' approval flow through this arm with the decider. Same family as this card's Gap 2 (the producer half of #24497): proposed for that family's service-automation lane, not as a single-point card · dedupe words: resolveRunDataContext system arm empty positions permissions runAs user hook baseline; runAs system flow runAs user hook grants",
    "carrier: this card's PR · mirrorStatusField catches a failed mirror at warn (approval-service.ts:3191); under A-stored a re-parented record would land there. Noted, not filed."
    ]
    }

  3. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    需裁:审批写入触发的 hook 和流程,该在哪个组织、以谁的权限运行 · pm:dispatched → needs-user-decision · p1 target:v18 · domain:services seat 2 · os-warren · session_01LR1cD9JqgkeqY7k9rrZH8s · 2026-10-19T15:08Z

    Release: session_01LR1cD9JqgkeqY7k9rrZH8s releases claim 6269612581 · reason: Phase 1's reading raises a security-boundary question (report 6270420946) · destination: needs-user-decision. The branch claude/issue-24519-approvals-context-identity is empty; Phase 2 resumes on it after the ruling.

    维护者速读

    一句话问题: 审批状态变化时,平台以审批人或提交人的名义改业务记录(把审批状态写回记录)。这次写入既不带组织,也不带这个人的权限。由它触发、声明「以用户身份运行」的 hook 和流程,因此拿到的权限不对:有时比这个人自己操作时少,有时更多。要定:这次写入怎么标明组织。

    实测(Phase 1,origin/main de43524606,真实栈,报告 6270420946):

    • 审批写回只有一处(mirrorStatusField,五个入口:提交、批准/驳回、撤回、退回两种)。上下文是「系统 + 用户 id」,不带组织,权限是空数组。
    • 由此触发的 runAs:'user' hook 只能看到自己,看不到同组织同事,并按成员基线权限运行,而不是这个人自己的权限。
    • 由此触发、默认 runAs:'user' 的记录变更流程,没有组织;权限按「不指定组织」解析。对一部分用户,这样解析出的权限比他在该记录所属组织里自己操作时更多。席位已在会话里私下给你具体读数,⛔ 不落公开面(与分诊 6267712999 同一纪律)。这一条今天就在生效,不依赖本卡改动。
    • 代码注释说「自动化引擎会按用户自己的授权补回组织」,实测不成立:桥接只在调用方传了组织时才回填(service-automation/src/plugin.ts 约 :1286–:1300)。
    • 注释说不带组织是为了避免驱动层按组织过滤,导致跨组织的写回静默失效。自 Promote resolveRecordOrganizationField to the shared platform-row resolver (approvals + automation runs), per the ruled cloud#1395 Option A #10101 起,审批请求的组织就是被审记录的组织,按记录组织写不会失效;只有记录在审批期间被改挂到别的组织时才会落空(被捕获、记 warn)。
    • 卡上说 approval-node.ts 不在缺口内,不成立:提交那一步的写回同样走 mirrorStatusField。
    选项 做法 客户能感知的后果 工作量
    A(推荐) 写回带上记录所属的组织(tenantId,审批时实时读记录组织,无组织的对象用请求组织兜底),同时去掉空权限数组(缺口 2),两者同一 PR 由审批触发的 hook 和流程,与这个人在该组织里自己操作时完全一致:同事看得见,权限是自己的,不会多出别的组织的权限 services · 1 PR · 先例 PR #24495(+371/−19);不碰 spec
    A′ 同 A,但组织取审批请求行上存的组织,不实时读 同 A;记录在审批期间被改挂组织时,写回落空并记 warn 同 A,每次写回少一次读
    B 在执行上下文里新增一个「只表示归属、不参与过滤」的组织字段 同 A,但平台多一个组织字段,所有消费方都要学会读它 spec + objectql + 触发器 + 自动化,三条车道,Clause-② yes,无先例
    C 由 hook 那一侧从被写的记录推出组织 只修 hook,流程照旧没有组织;是消费端兜底 objectql,本卡范围外

    业务上等于什么: A 是「经理批准后,系统替经理改记录,由此带出的后续动作都按经理在这家公司的身份和权限办」。B 是给每张单据多加一栏「名义归属」,所有系统都要认这一栏。C 是只让其中一个下游自己去猜是哪家公司。

    另一个边界问题,需要你一并定: 审批人可以来自另一个组织的目录(ADR-0105 D9)。这样的审批人不是记录所属组织的成员,他的写回按哪个组织算?

    推荐 A + x。

    • 只看①(长远合理性):两年后的平台样子是「被谁触发,就以谁在那家组织里的身份运行」。主流平台同样如此:Salesforce 的审批动作在审批人的事务里执行,Odoo 以点按钮的用户执行。A 用的是平台已有的组织字段,契约落在生产方,正合裁决 A′ 第 2 条「每个用户上下文都带 org_user_ids」,且和裁决 6267719791 是同一条规则。B 新增第二个组织字段,是契约增生。
    • ② 业务拉动:审批状态写回是已发布技能里教的用法;按默认 runAs:'user' 写的流程今天就会走到上面那条更宽的路径。
    • ③ 防 AI 犯错:A 不新增可选字段;B 有两个含义重叠的组织字段,最容易被写错;C 是消费端兜底,会掩盖其他漏传组织的生产方。
    • ④ 创业阶段不扩散:A 最小,一个函数,不碰 spec、不加门禁。
    • 回退: A′(每次写回少一次读,代价是改挂组织时写回落空)。
    • 置信缺口: 多出来的权限在真实安全插件下具体能对该组织的数据多做什么,未测。

    自检:只看①选 A;②③④ 是否翻转:否。

    裁后执行:

    os-decision-facets


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: Gap 1 letter A · boundary letter x · the live widening rides this card · maintainer, in domain:services seat 2's session (session_01LR1cD9JqgkeqY7k9rrZH8s) · 2026-10-19T15:40Z

    The maintainer answered the three questions of the analysis 6270492286. The answers, verbatim:

    What it binds:

    1. The mirror write names its organisation through the existing tenantId: the subject record's live organisation, read at write time through the record-organisation resolver the request was stamped by, with the request's organisation as the fallback for an object that has none. Gap 2 lands in the same PR: on the named-user branch the write context carries no positions / permissions arrays, so PR fix(objectql): a runAs:'user' hook sees its organization's members in sys_user, as the user's own request does #24518's seed resolves the user's own grants under that organisation. The user-less sweep keeps SYSTEM_CTX. No packages/spec key; Clause-②: no.
    2. Every attributed write acts in the record's organisation, including an approver drawn from another organisation's directory (ADR-0105 D9). That is the reading PR feat(automation)!: an approval decision resumes a flow as the person who decided #24527 already applies to a runAs:'user' decider, so A resumed flow run writes as its trigger user (the submitter), not the approver who resumed it, so post-decision writes in an approval flow name the submitter as Last Modified By and in the audit log #24321 does not change.
    3. The widening of today's mirror-fired runAs:'user' record-change flows rides this card at p1; A closes it. Phase 2 also measures, with the detail kept off GitHub, what the organisation-less envelope permits today, and names it in the seat-only report.
    4. The docblock's false sentence (that the engine back-fills the run's tenantId) is rewritten in the same PR.
    5. The same-family producer in service-automation (resolveRunDataContext's system arm carries empty arrays beside a user id; the dev's out-of-scope finding 1) is filed by this seat as its own family card under the same rule, ⛔ not widened into this PR.

    Execution:


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 5 · 2026-10-19T16:08Z
    Session: session_01LR1cD9JqgkeqY7k9rrZH8s
    Account: os-warren (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24519-approvals-context-identity
    Worktree: objectstack-issue-24519
    Domain: domain:services
    Seat: domain:services#2 (seat post #21118)
    File surface: packages/plugins/plugin-approvals/src/approval-service.ts (only mirrorStatusField and its docblock, and the record-organisation read it needs; ⛔ not the decision → resume hand-off PR #24527 edits), new test files beside it, and a changeset; ⛔ not approval-node.ts or SYSTEM_CTX (Phase 1 measured that no write is made under them), ⛔ not packages/spec/**, ⛔ not packages/objectql/**, ⛔ not packages/services/service-automation/** (the same-family system arm is its own card) (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus (the default tier; dispatch-gates --tier printed no path-derived mandate for this surface)
    Clause-②: no
    Responsibility: plugin-approvals' mirrorStatusField (this lane), which writes under { isSystem, userId } with no organisation and empty arrays | none | every runAs:'user' hook and record-change flow fired by an approval's status write-back; live today
    Thread-read: 6271152775
    Serial constraints cleared: PR #24527 (#24321, seat 1, draft) edits approval-service.ts at the resume hand-off, a disjoint region. This card's PR stays draft until PR #24527 has merged, then merges origin/main before the queue. PR #24600 (#24501, this seat) touches only the object files' list views. No other open PR touches plugin-approvals sources.

    Executes ruling 6271152775 (Gap 1 A, boundary x, the live widening rides this card). Phase 2 resumes the Phase 1 dev on the same branch.


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    Pointer · domain:services seat 1 (#6021) · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-19T17:05Z. ⛔ Not a claim. PR #24527 (#24321) merged as 1b4078077 at 2026-10-19T17:03Z. Your claim 6269612581 holds this card's PR until then, so it can now merge origin/main and re-read that change before the queue.


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
    "issue": 24519,
    "status": "done",
    "branch": "claude/issue-24519-approvals-context-identity",
    "pr": "#24634",
    "session": "session_01LR1cD9JqgkeqY7k9rrZH8s (subagent: the dispatching seat's id)",
    "premise_still_valid": true,
    "summary": "Built ruling 6271152775 (A-live + x). mirrorStatusField's named-actor branch now writes as { isSystem: true, userId, tenantId } with tenantId the subject record's live organisation (one by-id read through recordOrgResolver; the request's organisation as the fallback) and no positions / permissions, so a runAs:'user' hook or record-change flow it fires runs with the person's own request envelope in the record's organisation, a cross-organisation approver included; the user-less sweep keeps SYSTEM_CTX. Ten new producer pins cover every door that names a person in both directions plus the cross-organisation, re-parented and user-less cases, both ablation legs went red as predicted, and PR #24527 has merged into this branch (8b118b6).",
    "tests": "At head 8b118b6: pnpm --filter @objectstack/plugin-approvals test 83 files / 1132 tests passed (TEST_EXIT=0, under os-verify-lock). New file status-mirror-envelope.integration.test.ts: 10/10 green (tests 1.89 s of test time; a shared box). Moved pin, one: approval-service.test.ts › status mirror identity (#3783) › the case asserting NO tenantId now asserts the ruled context { isSystem: true, userId: 'u1', tenantId: 't1' }. Ablation (fix committed first, scripts/ablation-replace.mjs on approval-service.ts, the suite imports the service from source so no dist leg; predicted 9 red / CONTROL green for both legs): leg 1 restore the empty arrays, blob 9ceb29f2c7f9 -> d2258164b9de, observed 9 failed / 1 passed, each at the hook envelope (positions and permission sets [] where the person's own were expected); leg 2 drop the organisation, blob 9ceb29f2c7f9 -> 3522d1ac69f9, observed 9 failed / 1 passed, each at the hook envelope (tenantId undefined, peers the person alone, positions and permission sets not the record organisation's). Each leg restored: blob == HEAD 9ceb29f2c7f9, git diff HEAD empty.",
    "gates": "Local at 8b118b6 (after the last commit): pnpm --filter @objectstack/plugin-approvals typecheck exit 0 (check:test-typecheck OK, 324 errors / 27 signatures held). node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 69 commands; all 69 recorded :: exit 0; --ran: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN (exit 0). First pass at 31100d0-era: check:query-options-erasure and check:slot-lookup each flagged one new erasure in this diff, fixed by typing, not baselined; check:i18n and check:dual-build-cjs-loads exited 3 until a full turbo build, then 0. node scripts/check-nul-bytes.mjs OK. CI on 8b118b6 at report time: 23 check-runs, 21 in_progress, 1 success, 1 skipped (Governed Surface Queue Guard in_progress); not awaited, per the dispatch contract.",
    "mcp_calls": "0",
    "api_writes": "3 across both phases, all through scripts/pm: Phase 1 report comment (POST /repos//issues/24519/comments, 6270420946); pr_create via the fleet relay (POST /repos/objectstack-ai/objectstack/pulls, draft, then POST /repos//issues/24634/assignees os-warren in the same action); this comment (POST /repos//issues/24519/comments). git pushes of the branch are not REST writes.",
    "line_budget": "530 (+503 / -27; 4 files)",
    "files_changed": [
    "packages/plugins/plugin-approvals/src/approval-service.ts",
    "packages/plugins/plugin-approvals/src/status-mirror-envelope.integration.test.ts",
    "packages/plugins/plugin-approvals/src/approval-service.test.ts",
    ".changeset/24519-approvals-context-identity.md"
    ],
    "deviations": [
    "Six mirrorStatusField call sites in approval-service.ts gain one argument each (the request organisation they already hold, the fallback the ruling names); none is in PR #24527's region.",
    "One existing test file is edited (approval-service.test.ts, the moved pin above): its assertion was the contract the ruling reverses.",
    "One-off measurement probes ran uncommitted in packages/services/service-automation/src (the only package composing the real SecurityPlugin) and were deleted; git status clean.",
    "Before the first edit the empty branch was fast-forwarded to origin/main 5a871e4; origin/main was merged once more at 8b118b6 when PR #24527 landed during the round (clean merge, workspace rebuilt, full suite and gates re-run on the merged head).",
    "Commit trailers use the model-free pair AGENTS.md prescribes rather than the harness reminder's model-named form."
    ],
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: domain:services seat 2 (ruling 6271152775 item 5) · service-automation resolveRunDataContext system arm carries positions: [], permissions: [] beside a carried userId; same family, filed by the seat, not in this PR."
    ]
    }

  8. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24634 comment 6274716040 · domain:services seat 2 · 2026-10-19T18:48Z · head 8b118b6b36. Landing waits for every check on the head.


    Generated by Claude Code

  9. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 2 · os-warren · session_01LR1cD9JqgkeqY7k9rrZH8s · 2026-10-19T19:30Z

    Reading Value
    PR #24634 merged through the merge queue: auto_merge_enabled 2026-10-19T19:01Z → added_to_merge_queue 2026-10-19T19:04Z → merged 2026-10-19T19:29Z
    origin/main merge commit b61353ffa0 is an ancestor (git merge-base --is-ancestor, exit 0)
    This card closed completed by the PR's Fixes #24519; this act clears pm:dispatched and the assignee

    #24577 (Blocked-by: #24519) is dispatchable now. #24614 carries ruling 6271152775 item 5 for service-automation.


    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

    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:servicespriority:p1High: required for production / M2target:v18

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions