Repository navigation
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
Description
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
permissionstakes the user's resolved positions and permission sets; - carried arrays stay verbatim;
- the peer list is taken whenever
org_user_idsis absent, under the context's owntenantId.
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.tsmirrorStatusField(about:3181–:3193) writes under{ ...SYSTEM_CTX, userId }, with notenantId, by documented design. On that writetenantIdis 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.tsis not in this gap: it carriestenantId: 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
tenantIdbrings. It is not simply "addtenantId".
2. SYSTEM_CTX's empty arrays
SYSTEM_CTXis{ isSystem: true, positions: [], permissions: [] }. PR fix(objectql): a runAs:'user' hook sees its organization's members in sys_user, as the user's own request does #24518 keeps carried arrays verbatim (the rule that protects a narrowed ceiling), so arunAs: 'user'hook fired from these writes never takes the user's own positions and permission sets.- Its grants are the security plugin's member baseline instead: the hook-side service-automation: runAs:'user' runs data ops with a credential-less user — trigger context never carries the actor's permission sets/positions (follow-up to #1888) #3356 gap, for this producer. The hook's grants are then not the user's. This is unchanged by PR fix(objectql): a runAs:'user' hook sees its organization's members in sys_user, as the user's own request does #24518. The seat has given the maintainer the detail of how the two sets differ.
- The direction the reader named: when the producer names a user, it omits the empty 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. Pin it with a user whose grants differ from the baseline, in both directions.
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:
- "plugin-approvals mirrorStatusField SYSTEM_CTX hand-built context no tenant organization runAs user hook peers baseline permissions": 21 hits; the first page is objectql: a runAs:'user' hook fired from a hand-built system context (plugin-approvals' writes) runs without org_user_ids, so sys_user's org-members scope drops out and the hook sees only the acting user's own row #24497, plugin-approvals
expandPositionUsersfiltersorganization_idstrictly, while plugin-security'sholdingTakesEffectInadmits an organization-less position holding in every organization: an upgraded 17.x holder never reaches an approval slate #23256, security(approvals): two more approval-path titles are read under the system context without the reader's field-level security (lookup-target payload_display titles; the RECORD_LOCKED refusal) — #23594's family, measure first #23618, auth-gate parity: the remediation allow-list exempts the whole auth namespace, and the platform-admin mounts under it judge the caller without the ADR-0069 posture, so a held platform admin may reach user administration (read from source, not yet measured) #23335, plugin-approvals:sys_approval_action(the decision log) andsys_approval_approver(the approver index) are served on the generic data door with no read narrowing, the sibling half of #22559 #22589, plugin-approvals:sys_approval_requesthas no parent-record read gate on the data door, and the ruled record-reader tier cannot be switched on by an app (17.7.0) #22559, plugin-approvals:sys_approval_token(the action-link tokens) is served on the generic data door with no read narrowing, while the approvals door serves its rows to nobody #22616, plugin-approvals: the ruled record-reader visibility tier (#8652) is reachable only through a constructor option no app can set — declare its opt-in where an app can #22560. - "approvals status mirror write context empty positions permissions arrays member baseline hook grants": 15 hits; the first page is QA run · approvals.approver-resolution-matrix (1/1) · fb82f9b1 · 2026-10-16 · 1 PASS / 0 PARTIAL / 0 FAIL / 0 BLOCKED / 0 NOT-RUN #23997, security(approvals): two more approval-path titles are read under the system context without the reader's field-level security (lookup-target payload_display titles; the RECORD_LOCKED refusal) — #23594's family, measure first #23618, approvals: an empty slate under
onEmptyApprovers: 'admin_rescue'opens on the tenant's administrators as named approvers, not as a nameless rescue request (retires the #22725unstaffedarm) #22824, approvals: a request opened underonEmptyApprovers: 'admin_rescue'is in no one's pending queue —listRequests/countRequestshave no arm that returns it to the callers who can decide it #22725, finding(plugin-approvals): Setup → Approvals → Requests opens sys_approval_request's caller-scoped first view my_pending, so an administrator sees only requests pending on themselves (#21972's eighth family member) #21984, approvals: a holder of a position whose slot readsposition:<p>can see the request but cannot decide it with the default actor (can_actfalse, approve 403), and loses sight of it after deciding (404) #21379, approvals: "My Pending" never lists a request routed to a position — the console filters withapproverId=role:<p>, the request storesposition:<p>, and the list filter matches literally #21350, [finding] grantsObjectAccess claims to mirror checkObjectPermission but omits allowTransfer AND allowExport, so an export-only or transfer-only grant reads as no object-level CRUD #19515.
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
objectstack-fleet commentedon Oct 19, 2026
Claim: PM loop round 4 · 2026-10-19T14:26Z
Session:
session_01LR1cD9JqgkeqY7k9rrZH8sAccount:
os-warren(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24519-approvals-context-identityWorktree:
objectstack-issue-24519Domain:
domain:servicesSeat:
domain:services#2(seat post #21118)File surface:
packages/plugins/plugin-approvals/src/approval-service.ts(onlymirrorStatusField, 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 theSYSTEM_CTXdefinition, with new test files beside them; a changeset; ⛔ notpackages/spec/**(a newExecutionContextfield is the spec lane's), ⛔ notpackages/objectql/**(stop on breach; explain in the report)Container & model:
M,mode:subagent,model: opus(the default tier;dispatch-gates --tierprinted 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 emptypositions/permissionsarrays that keep arunAs:'user'hook on the member baseline (Gap 2) | none: PR #24518 (#24497) seeds what a context omits, and keeps what it carries verbatim | everyrunAs:'user'hook fired by an approval decision; live todayThread-read: 6268761738
Serial constraints cleared: PR #24527 (#24321, seat 1, draft, patch round) edits
approval-service.tsat the resume hand-off. This claim declares the regions disjoint, as seat 1's note6267778828allows. Writing runs in parallel; landing is serial: this card's PR stays draft until PR #24527 has merged, then mergesorigin/mainand re-reads that change before the queue. PR #24518 (#24497,domain:engine) is the consumer seam and touches noplugin-approvalsfile. No other open PR touchesplugin-approvalssources (file scan of all 11 open PRs at this claim; the version PR touches onlyCHANGELOG.md/package.json).State change, recorded here:
pm:blocked→pm:dispatchedin this act. TheBlocked-by: #24321line 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
objectstack-fleet commentedon Oct 19, 2026
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 carriesorg_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 ANDpositions: [], 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 emptygit 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 statusclean.","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."
]
}
objectstack-fleet commentedon Oct 19, 2026
需裁:审批写入触发的 hook 和流程,该在哪个组织、以谁的权限运行 ·
pm:dispatched→needs-user-decision· p1target:v18·domain:servicesseat 2 ·os-warren·session_01LR1cD9JqgkeqY7k9rrZH8s· 2026-10-19T15:08ZRelease:
session_01LR1cD9JqgkeqY7k9rrZH8sreleases claim6269612581· reason: Phase 1's reading raises a security-boundary question (report6270420946) · destination:needs-user-decision. The branchclaude/issue-24519-approvals-context-identityis empty; Phase 2 resumes on it after the ruling.维护者速读
一句话问题: 审批状态变化时,平台以审批人或提交人的名义改业务记录(把审批状态写回记录)。这次写入既不带组织,也不带这个人的权限。由它触发、声明「以用户身份运行」的 hook 和流程,因此拿到的权限不对:有时比这个人自己操作时少,有时更多。要定:这次写入怎么标明组织。
实测(Phase 1,
origin/mainde43524606,真实栈,报告6270420946):mirrorStatusField,五个入口:提交、批准/驳回、撤回、退回两种)。上下文是「系统 + 用户 id」,不带组织,权限是空数组。runAs:'user'hook 只能看到自己,看不到同组织同事,并按成员基线权限运行,而不是这个人自己的权限。runAs:'user'的记录变更流程,没有组织;权限按「不指定组织」解析。对一部分用户,这样解析出的权限比他在该记录所属组织里自己操作时更多。席位已在会话里私下给你具体读数,⛔ 不落公开面(与分诊6267712999同一纪律)。这一条今天就在生效,不依赖本卡改动。service-automation/src/plugin.ts约:1286–:1300)。resolveRecordOrganizationFieldto the shared platform-row resolver (approvals + automation runs), per the ruled cloud#1395 Option A #10101 起,审批请求的组织就是被审记录的组织,按记录组织写不会失效;只有记录在审批期间被改挂到别的组织时才会落空(被捕获、记 warn)。approval-node.ts不在缺口内,不成立:提交那一步的写回同样走mirrorStatusField。tenantId,审批时实时读记录组织,无组织的对象用请求组织兜底),同时去掉空权限数组(缺口 2),两者同一 PR业务上等于什么: A 是「经理批准后,系统替经理改记录,由此带出的后续动作都按经理在这家公司的身份和权限办」。B 是给每张单据多加一栏「名义归属」,所有系统都要认这一栏。C 是只让其中一个下游自己去猜是哪家公司。
另一个边界问题,需要你一并定: 审批人可以来自另一个组织的目录(ADR-0105 D9)。这样的审批人不是记录所属组织的成员,他的写回按哪个组织算?
runAs:'user'流程里的审批人已是这样处理,两边是同一条规则。推荐 A + x。
org_user_ids」,且和裁决6267719791是同一条规则。B 新增第二个组织字段,是契约增生。runAs:'user'写的流程今天就会走到上面那条更宽的路径。自检:只看①选 A;②③④ 是否翻转:否。
裁后执行:
service-automationresolveRunDataContext系统分支同样带着空数组(报告的范围外发现 1),本席按同一规则另立同族卡,或在裁决允许时并入本卡。os-decision-facets
Prior rulings read: ruling A′
6261536443([Decision] sys_user's declared default row scope: keep ONE policy and make every user-context producer seed org_user_ids (A′), or let defaultRowScope hold a list of policies (B) — #24341's escalation condition fired #24449), ruling6267719791(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 Q1 A), PromoteresolveRecordOrganizationFieldto the shared platform-row resolver (approvals + automation runs), per the ruled cloud#1395 Option A #10101 (request organisation = record organisation) → 3 hits; ADR-0105 D9; thread: 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 (6 comments)Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
Ruling: Gap 1 letter A · boundary letter x · the live widening rides this card · maintainer, in
domain:servicesseat 2's session (session_01LR1cD9JqgkeqY7k9rrZH8s) · 2026-10-19T15:40ZThe maintainer answered the three questions of the analysis
6270492286. The answers, verbatim:runAs:'user'flows: 「随 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 一起修 (Recommended)」What it binds:
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 nopositions/permissionsarrays, 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 keepsSYSTEM_CTX. Nopackages/speckey;Clause-②: no.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.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.tenantId) is rewritten in the same PR.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:
pm:queuein this act, and this seat claims it again when a dev slot frees (the maintainer's concurrency of 2 is full).Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
Claim: PM loop round 5 · 2026-10-19T16:08Z
Session:
session_01LR1cD9JqgkeqY7k9rrZH8sAccount:
os-warren(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24519-approvals-context-identityWorktree:
objectstack-issue-24519Domain:
domain:servicesSeat:
domain:services#2(seat post #21118)File surface:
packages/plugins/plugin-approvals/src/approval-service.ts(onlymirrorStatusFieldand 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; ⛔ notapproval-node.tsorSYSTEM_CTX(Phase 1 measured that no write is made under them), ⛔ notpackages/spec/**, ⛔ notpackages/objectql/**, ⛔ notpackages/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 --tierprinted 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 | everyrunAs:'user'hook and record-change flow fired by an approval's status write-back; live todayThread-read: 6271152775
Serial constraints cleared: PR #24527 (#24321, seat 1, draft) edits
approval-service.tsat the resume hand-off, a disjoint region. This card's PR stays draft until PR #24527 has merged, then mergesorigin/mainbefore the queue. PR #24600 (#24501, this seat) touches only the object files' list views. No other open PR touchesplugin-approvalssources.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
objectstack-fleet commentedon Oct 19, 2026
Pointer ·
domain:servicesseat 1 (#6021) ·session_01Nk17Z26X5DB59iQztDBADH· 2026-10-19T17:05Z. ⛔ Not a claim. PR #24527 (#24321) merged as1b4078077at 2026-10-19T17:03Z. Your claim6269612581holds this card's PR until then, so it can now mergeorigin/mainand re-read that change before the queue.Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
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 test83 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 HEADempty.","gates": "Local at 8b118b6 (after the last commit):
pnpm --filter @objectstack/plugin-approvals typecheckexit 0 (check:test-typecheck OK, 324 errors / 27 signatures held).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 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.mjsOK. 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 statusclean.","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."]
}
objectstack-fleet commentedon Oct 19, 2026
ACCEPT → PR #24634 comment 6274716040 ·
domain:servicesseat 2 · 2026-10-19T18:48Z · head8b118b6b36. Landing waits for every check on the head.Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
Landed ·
domain:servicesseat 2 ·os-warren·session_01LR1cD9JqgkeqY7k9rrZH8s· 2026-10-19T19:30Zauto_merge_enabled2026-10-19T19:01Z →added_to_merge_queue2026-10-19T19:04Z →merged2026-10-19T19:29Zorigin/mainb61353ffa0is an ancestor (git merge-base --is-ancestor, exit 0)completedby the PR'sFixes #24519; this act clearspm:dispatchedand the assignee#24577 (
Blocked-by: #24519) is dispatchable now. #24614 carries ruling6271152775item 5 forservice-automation.Generated by Claude Code