Repository navigation
fix(plugin-approvals): every slot reader takes the caller's acting addresses — can_act, the decision slot test, the already-acted probe and the email-keyed slot - #21410
Conversation
…dresses from approver-address The default actor, can_act, the decision methods' slot test and the already-acted probe now read the one equivalence in approver-address.ts (actingAddresses / actorAddresses / heldSlot), so a holder of a position whose slot reads position:P (or a user approver authored as an email) decides it with the default actor or the console's role:P spelling, sees can_act true, and keeps sight of it after deciding. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
… pin approval-service.test.ts pins can_act as a table (holder, bystander, submitter, admin), the decision slot test under the default actor and both spellings (and the recorded slot spelling), the sibling decision methods, the unanimous tally, the already-acted probe and the email-keyed slot. approver-address-readers.test.ts lists every reader and fails on a slot-against-caller comparison written outside approver-address.ts. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…ed app A position-literal slot staffed after open: can_act matches the default actor's decision for holder, bystander, submitter and admin; the holder decides with no actorId and with the console's role: spelling, the decision records the slot's stored spelling, and the holder keeps sight of it afterwards. An email-keyed slot is listed, flagged, decided and kept in sight for its non-submitter reviewer. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…he caller's acting addresses Correct the approvals page sentences this change makes false (the decision's slot test, can_act, the participant and already-acted counts, the admin-override callout's unstaffed-position line), and add the plugin-approvals patch changeset. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…elevation read actingCaller no longer branches on context.isSystem to skip the email read: the only system context naming a user is the action link's, whose token approver's own account is the identity the token proves. This keeps the isSystem census at its declared 114 sites. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 499a4954d868e47399dfdd460bbd5a2786b93e5e && git checkout 499a4954d868e47399dfdd460bbd5a2786b93e5e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6d67ad5eca0f1694b5e74effbc91e0f5e1634581 dabba36f33c09e747bea78f4d32dc96c61fd5c56 && git checkout -B drift-repro 6d67ad5eca0f1694b5e74effbc91e0f5e1634581 && git merge --no-ff dabba36f33c09e747bea78f4d32dc96c61fd5c56
node scripts/docs-audit/affected-docs.mjs --json 6d67ad5eca0f1694b5e74effbc91e0f5e1634581
|
Fixes #21379
Clause-②: no
What changes
PR #21378 moved the slot-address equivalence into
packages/plugins/plugin-approvals/src/approver-address.tsand pointed the "My Pending" filter and the participant gate at it. Four readers still compared a slot with the bare user id. Each now takes the caller's acting addresses from that module. That set is the user id, the email the caller's ownsys_userrow carries, and both spellings of each position in the server-resolvedcontext.positions.approver-address.tsgains three functions, and there is still one fold:actingAddresses(caller)returns the default actor's addresses, in the order a slot is taken: user id, account email, thenposition:Pand the deprecated prefix for each held position.actorAddresses(actorId, caller)covers one resolved actor. The caller's own user id expands toactingAddresses. A named position address expands to both spellings of that position. Anything else, such as a named email or a machine actor, matches only itself.heldSlot(pending, actorId, caller)is the slot test. It returns the first of those addresses that the slate holds.The readers:
attachViewers'viewer.can_actpending.includes(uid)heldSlot(pending, uid, caller), the same call the decision methods make with noactorIddecideNode,sendBack,reassign,requestInfo,commentpending.includes(actorId)takenSlot(), which wrapsheldSlotvisibleRequestIds, already-acted probeactor_id: uidactor_idinactingAddresses(caller)visibleRequestIds, current approveractingAddresses(caller), which adds the email half (item 4)resolveActoris unchanged. It still admits exactly the identities it admitted before, and the #21350 oracle pin still holds. Nobody new may decide. The holder could always decide by namingposition:P. Now the default actor and the console's spelling reach the same slot. A user who holds a different position is still refused: 403 at the route, pinned in the unit and dogfood suites.What
sys_approval_action.actor_idrecords. A slot-gated action records the slot it took, in that slot's stored spelling:position:Pon aposition:Pslot (whichever spelling the caller used), the deprecated-prefix literal on a 15.x slot, and the email on an email slot. Naming that slot has always recorded exactly this; the measured row onmainisapprove:position:m21379_reviewer. It must stay this way because the multi-approver tally indecideNodeand thedecision_progressenrichment count approvals by matchingactor_idagainst the slate. A decision recorded under any other spelling would leave its slot pending after its holder approved, so a unanimous request would never finalize. The tally pin goes red if this changes. The cost is noted under Acceptance notes.#21387(retiring the deprecated prefix) is not addressed here.Measured on a booted app (
bootStack, real/api/v1/approvalsroutes)The fixture opens a request on a position nobody holds, then staffs the holder. Readings were taken with a scratch measurement test, which was not committed. The committed dogfood pin below replaces it.
mainatecb6ca025viewer.can_actfalsetrueactorIdFORBIDDENapprovedactorIdwith the deprecated prefixFORBIDDENactorId: position:PGET /requests/:idafter decidingactorIdItem 4, the email-keyed slot: it reproduced and is fixed here. A
userapprover authored as an email stores the email as its slot. The table below is for the reviewer who owns that email and is not the submitter.mainatecb6ca025approverId= id, email)[]can_act: trueGET /requests/:idactorIdactorId= the emailGET /requests/:idafter decidingPins
approval-service.test.ts, describeevery slot reader takes the acting addresses (#21379), has one pin per reader:can_actas a table over holder, bystander, submitter and admin. It asserts thatcan_actequals "admitted as a slot holder" with noactorId, and thatcan_overridecovers the admin.position:P, each on its own request, with the recorded spelling. A 15.x slot keeps its own spelling. The bystander is refused under the default actor and under a named position.can_act, decided by the default actor, and still in sight afterwards. Another account's email is a negative control.approver-address.test.tspins the three new pure functions, with negative controls.approver-address-readers.test.tsis the enumeration pin:READERSlists every reader of the equivalence: acting path, list filter, participant gate,can_act,takenSlot, and the five decision methods. Each listed method must read itsapprover-address.tsexport, which the pin checks through the TypeScript AST. It also checks that both participant-gate probes read the oneactingset.The pin parses every non-test
.tsfile undersrc/exceptapprover-address.ts. It collects every site shaped like a slot comparison:includes,has,some, and so on) on a receiver whose text names a pending slate;approver,actor_id,pending_approvers) inside awhereorfilterpredicate, or assigned towhere.COLUMN;field: 'pending_approvers'row under afilter.Every site must be classified in
SLOT_SITESby file, enclosing function and exact text. Today there are 3readersites, 6slot-vs-slotsites, such as a hand-off target or a token's bound slot, and 1declarativesite (see Acceptance notes). A new site fails with its location. So does a changed or vanished site. A planted-source case shows that each of the four shapes is detected.What the pin does not see: a comparison written in none of those shapes, such as a hand-written loop using
===.Ablations
Each ablation is committed first and runs through
node scripts/ablation-replace.mjsin WRAP mode. A belt trap restores fromgit checkout HEADon an absolute path. Each mutation was checked on disk: anchor count went from 1 to 0, replacement count from 0 to 1, and the blob changed. Each restore was proven by blob == HEAD and an emptygit diff HEAD.The unit pins import
srcdirectly, and dogfood'sisolatedproject aliases@objectstack/plugin-approvalstosrc/index.ts, so nodistleg applies.Final run at
dabba36f3:can_actback topending.includes(caller.userId)can_actexpected truetakenSlotback to the literal actoractor_id: uidheldSlottakes anyposition:slot)can_actbecame trueThe first A5 attempt was refused by the tool: the replacement contained the anchor text, so the anchor count did not drop. No test ran on that attempt. It was redone with a replacement that does not contain the anchor. Every direction was red, as predicted.
Gates and tests, at
dabba36f3pnpm --filter @objectstack/plugin-approvals test: 56 files, 855 tests, all passed.typecheckfor plugin-approvals and dogfood: exit 0.check:test-typecheckholds 8 files and 324 errors in the ledger, with no new debt. All three new or edited test files are intsconfig.test.json's program (checked with--listFilesOnly).--project isolated): the newposition-address-readerspin, the 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 pin,approval-override-composite-pinandapproval-snapshot-masked-fieldran: 4 files, all passed.dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 95 commands. All 95 exited 0 atdabba36f3, and--ranreconciled them as95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN. Three earlier readings were superseded:check-system-context-censuswas red on the first pass, because a newcontext.isSystemread inactingCallerhad no row. Commitdabba36f3removes that read, and the census holds at 114 sites.check:skill-examplesandcheck:dual-build-cjs-loadsanswered PREREQUISITE NOT MET until the eight packages withoutdist/were built.check:dual-build-cjs-loadsthen went red once on@objectstack/mcp'sindex.d.cts. That file is outside this diff, and its timestamp shows it was being written during the run. The rerun was green.eslint --no-inline-config --format jsonon the 7 touched.tsfiles: 7 files, 0 errors, 0 warnings.eslint.config.mjsenables no type-aware linting (--print-configshows noparserOptions.projectorprojectService), so no untouched file's result can move. The repo-widepnpm lintis left to CI.origin/mainwas merged in as8312775bd. The two incoming commits do not touch these files.Docs
content/docs/automation/approvals.mdx: corrected the sentences this change made false.can_actbullet;position:Pslot."A position-addressed approver is never wrongly hidden" is now true and stays. The deprecated prefix is described in words, and
check:role-wordholds.Acceptance notes
via_override: false, one vote in a multi-approver tally), exactly as when they named the slot. An admin who holds no slot is unchanged. The changeset says this.position:Pstays visible to every current holder of P. A former holder who decided it stops seeing it once they leave P. Pinned (successor) and documented.sys_approval_action.actor_idis asys_userlookup, but for a position or email slot it holds the slot literal. The person who decided is not on the row. This was already true when the slot was named; the default actor now reaches it too. Reported to the seat.resolveActoradmits a named email case-insensitively. So a slot authored in a casing that differs from the account's stored email can be decided only by naming that exact spelling, as before, and the participant gate does not count it. NOT MEASURED through a door.my_pendinglist view insys-approval-request.object.tsfilterspending_approvers contains {current_user_id}. It is the one declarative slot-against-caller comparison, and metadata cannot readapprover-address.ts. It is served only by the generic data door. On this fixture that door answers 403PERMISSION_DENIEDto a member without read onsys_approval_request. The admin read returned the email-slot row. It is classifieddeclarativein the enumeration pin, so a second such filter goes red.can_actTSDoc (packages/spec/src/contracts/approval-service.ts) still says "their user id is in the request's resolvedpending_approvers". Its main sentence, "mirrors the exact check the service uses to authorize a decision", is now true. The parenthetical is narrower than the behavior.packages/specwas not edited under this order.Generated by Claude Code