Repository navigation
feat(plugin-approvals): excludeSubmitter keeps the submitter from approving their own request - #25722
Conversation
…y approval slate (WIP) Claude-Session: https://claude.ai/code/session_01LXX9EBHog6ZMo6apV2jvXX Co-authored-by: Claude <noreply@anthropic.com>
…n, the reassign door and the SLA escalation Claude-Session: https://claude.ai/code/session_01LXX9EBHog6ZMo6apV2jvXX Co-authored-by: Claude <noreply@anthropic.com>
…e planned marker Claude-Session: https://claude.ai/code/session_01LXX9EBHog6ZMo6apV2jvXX Co-authored-by: Claude <noreply@anthropic.com>
…nding Claude-Session: https://claude.ai/code/session_01LXX9EBHog6ZMo6apV2jvXX Co-authored-by: Claude <noreply@anthropic.com>
…l_action insert Claude-Session: https://claude.ai/code/session_01LXX9EBHog6ZMo6apV2jvXX Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 144 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 c41f1586399f8bd61c39c0eb5c8a4cdb444e5db4 && git checkout c41f1586399f8bd61c39c0eb5c8a4cdb444e5db4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f6ba1343ba41cb173baadfc36e6069123d3f2cfd 0aed0b90bb7cc68dac571fc160d5331aa6351c40 && git checkout -B drift-repro f6ba1343ba41cb173baadfc36e6069123d3f2cfd && git merge --no-ff 0aed0b90bb7cc68dac571fc160d5331aa6351c40
node scripts/docs-audit/affected-docs.mjs --json f6ba1343ba41cb173baadfc36e6069123d3f2cfd
|
席位核对 · ACCEPT (lane part,
|
| item | reading |
|---|---|
| Shape | draft, base main, Fixes #25573 (only #25573 next to a closing keyword), Clause-②: no and Metadata-protocol: none line-initial in the body and the changeset. Not governed (check-governed-merges --pr 25722), 916 changed lines. |
Runtime, seat's read of approval-service.ts |
every new branch is gated on excludeSubmitter === true through excludedSubmitterSlots (returns [] when the option is off or there is no submitter), so off-path behaviour is untouched. The exclusion runs on the resolved slate (one seam for every approver type), screens the fallback and admin_rescue slates, routes an emptied slate through onEmptyApprovers, and records the route as a timeline note. After the open: decideNode refuses the submitter's approve (FORBIDDEN, override included; reject stays), reassign refuses the submitter or a position only they hold (VALIDATION_FAILED), and the SLA reassign leaves them off. These are the ruling's "never approved by the submitter" and "never a silent pass" held at every slate writer; the dev's choice on the fallback/admin slates is stated in the PR body as the order asked. |
| Surface outside the claim | (1) the fenced spec hand-off (describe, TSDoc, liveness/flow.json _note, the §5 pin, regenerated approval.mdx), as the seat allowed on the thread and noted on #6017 (6390733466); (2) the tenant-audit census regen (238 → 239) and its prose counts in content/docs/permissions/tenant-audit-census.mdx and docs/audits/…counts.md, forced by one new engine insert call site. Both declared. |
| Changeset sentence check | Before / at the open / on the record / after the open / spec / unchanged: each sentence matches a hunk read above; @objectstack/plugin-approvals and @objectstack/spec both patch. |
| Tests | new exclude-submitter.integration.test.ts and submitter-exclusion-note.test.ts; five ablations reported red and restored; package 1276/1276, integration 14/14. |
| Gates | dev union 119/119 exit 0 at this head. CI at this act: 18 success, 6 opt-in skipped, 15 in progress, 0 failed. |
| Contract face | the diff touches packages/spec/src/automation/approval.zod.ts (a published schema file): a CONTRACT_REVIEW_TIER record is owed on this head before ready; an isolated reviewer is on it. needs:contract-review is set on this PR until it lands. |
| Landing | ready + auto-merge through the relay once the contract review is PASS on this head and every check is green or an expected skip. |
Generated by Claude Code
Contract reviewServed-tier: Inputs: card #25573 (body, all six comments, the dev report 6391228711); the rulings it cites, read where it points — 6377731715 and 6377842908 on #25496, 6377859800 and 6377888641 on #25446, 6386627950 on #25635, and #25609's report 6383750916 with its landing 6384560185; PR #25722's body, its 14-file list and the net diff ① Derived judgments
② Semver level
③ Boundary flagsDev report 6391228711 on #25573 (head Deviations declared (3), each answered:
Flag raised by this review, answered, not a FAIL: the describe is a strict subset of the TSDoc — it does not say that the Check-runs on Implemented-by: VERDICT: PASS Generated by Claude Code |
Requested by Leehom · project thread
Fixes #25573
Clause-②: no
Metadata-protocol: none
What this does
An approval node's
excludeSubmitter: truenow keeps the request's submitter from approving their own request. The key was declared by #25609 (PR #25632), and its pairing withonEmptyApprovers: 'auto_approve'is refused at the parse (#25635 ruled A, PR #25700). Until this PR the approval engine parsed the key and read nothing.Rulings carried, not re-decided:
excludeSubmitter, default off; empty set escalates per ADR-0042 or reroutes to the fallback".onEmptyApproversfallback, else ADR-0042 escalation, never a silent pass. The approval record notes when the exclusion changed the route." No runtime override ofauto_approve(ruling A on [Decision] An approval step with excludeSubmitter and onEmptyApprovers auto_approve would auto-approve a self-submitted request: refuse the pair at publish (A), override at runtime (B), or allow it (C)? #25635).Where the exclusion lives (measured)
user,field,manager,position,team,department,org_membership_levelandexpressionall resolve throughApprovalService.expandApprovers, called fromopenNodeRequest. The exclusion runs on its RESOLVED output, so it needs no per-type code and also catches an out-of-office hop that lands on the submitter. The removed slots are the addresses the submitter acts under without naming one (actingAddressesfromapprover-address.ts: the user id and the account's email). Aposition:Pliteral is never removed, because other holders take it.openNodeRequest), the reassign door (reassign), the SLA sweep (escalateRequest, actionreassign), and the decision (decideNode), which is the one check every approval passes through. The mid-tally slate update indecideNodeonly shrinks the open-time slate, so it needs nothing.Choices made inside the ruling's words
The order left two questions to this card. Both are answered by "never approved by the submitter" and "never a silent pass":
fallbackApproversslate and theadmin_rescueadministrators are screened too. Measured before this change: both slates go through the sameexpandApproverswith no submitter screen, so a submitter on the fallback list, or a submitter who is an organization administrator, would get their own request back one route later. Screening them is the only reading under which "never approved by the submitter" holds. A fallback slate that names only the submitter degrades toadmin_rescue, exactly as a fallback that resolves to no one already does. An organization whose only administrator is the submitter opens on the slate that names no one (the existing break-glass outcome), never on the submitter.expandApprovers, so the open-time screen covers it. After the open:reassignrefuses a target that is the submitter (id or email) or a position only the submitter holds, withVALIDATION_FAILED, asked after the authorization branches like the plugin-approvals: reassign accepts atothat resolves to no principal and strands the request on an unresolvable slate #25485 target check. An SLAreassignescalation leaves the submitter off the new slate; when the submitter is its only target, it is not applied and the pending approvers are told the SLA lapsed, the shape plugin-approvals: an SLA escalation withaction: reassignwrites a literalescalateTothat resolves to no one onto the slate, stranding the request the way #25485's door did #25598 gives an unresolvable target.decideNoderefuses an approval decided by the submitter withFORBIDDEN, the privileged override included. A rejection by the submitter stands, because it approves nothing.The route note
When the exclusion took the submitter off a slate, the request writes one system note on its timeline (
action: 'comment', no actor), stored as data in the node-config snapshot under__submitterExclusionNotewith the route the request took:remaining_approvers,fallback,administratorsorno_one. It reuses the #25576 shape: the row'scommentis the English, and the approvals door renders the reader's language from newapprovalSubmitterExcluded*catalog sentences in en, zh-CN, ja-JP and es-ES. When the exclusion emptied the slate and the request went to the administrators, the administrators' routing note now says "the only approver it resolved to was the request's submitter, whom excludeSubmitter removes" instead of "no approver on the node resolved to a person", which would have been false. A node with the option on whose slate never named the submitter writes no note.Declared surface widening:
packages/spec(seat-approved)The claim's file surface was
packages/plugins/plugin-approvals/src/**. #25609 handed this card an edit inpackages/spec: theexcludeSubmitterdescribe opened with[PLANNED — not yet enforced]and ended "The approval engine does not read this key yet, so today the submitter is NOT removed", and both the TSDoc andliveness/flow.json's_notesaid "When #25573 lands, that marker and that last sentence leave and this sentence is rewritten." Leaving them would make shipped text false. The server seat approved the edit within this fence: no schema shape, default or refusal change. Spec files touched:packages/spec/src/automation/approval.zod.ts: marker and last sentence dropped from the describe; the TSDoc's PLANNED paragraph rewritten to name the consumer.packages/spec/liveness/flow.json: the_notePLANNED RECORD sentence rewritten as a LIVE RECORD naming the consumer seams.packages/spec/src/automation/approval-exclude-submitter-25609.test.ts: the §5 status pin flipped from "opens with the planned marker" to "carries no planned marker".content/docs/references/automation/approval.mdx: regenerated bycheck:generated --fix(the one artifact it proved stale).Tests
packages/plugins/plugin-approvals/src/exclude-submitter.integration.test.ts: 14 cases on a realObjectQLoverSqlDriver(better-sqlite3), with real administrator resolution. Covers afieldstep and apositionstep resolving to the submitter (administrators, fallback,fail), a shared slate minus the submitter (per_group tags), the email slot and an out-of-office hop onto the submitter, a fallback naming only the submitter, a submitter who is the organization's only administrator (and whose override is refused), the zh-CN render, the decision guard, the reassign door, the SLA escalation, and three controls with the option off or never triggered.src/submitter-exclusion-note.test.ts: 3 cases (every route in every shipped locale, the strict shape read, the administrators' note clause).src/approver-address-readers.test.ts: thePERSON_SITESledger key follows the renamelocalizeAdminRescueNote→localizeRoutingNotes(same twoa.actor_idpresence tests).Ablations (WRAP mode of
scripts/ablation-replace.mjs, each restored and provenblob == HEAD,git diff HEADempty; tests importsrc/, so no dist rebuild is involved):Gates (local, on head
0aed0b90)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 119 commands derived; all 119 run, each exit code captured before any pipe, all 0 on the final head.--ranreconciliation: "119 derived, 119 run, 0 NOT-MEASURED, 0 UNRUN".check-tenant-audit-census(and its--self-test, which reads the tree) counted the route note's newsys_approval_actioninsert, 238 to 239 write call sites. Regenerated withnode scripts/tenant-audit-census.mjs --writeand the hand-written prose counts oncontent/docs/permissions/tenant-audit-census.mdxupdated to match; both exit 0.check-plugin-teardown-shape --self-test(exit 3 on this shallow clone until its pinned fixture commit was fetched),check:skill-examples(needed@objectstack/client-reactbuilt),check:i18n(needed its extract closure built),check:dual-build-cjs-loads(needed built output). Each re-run green.@objectstack/plugin-approvals:pnpm test98 files / 1276 tests passed;pnpm typecheckexit 0 (includescheck:test-typecheck).@objectstack/spec:approval-exclude-submitter-25609.test.ts28/28;check:generatedgreen after--fixregenerated the one stale artifact.eslint --no-inline-configoverpackages/plugins/plugin-approvals/srcand the two touched spec files, exit 0.pnpm lint,Test Core,Build Core, the type-check lanes).Acceptance notes
onEmptyApprovers: 'auto_approve'besideexcludeSubmitter: trueis not overridden at runtime (ruling A on [Decision] An approval step with excludeSubmitter and onEmptyApprovers auto_approve would auto-approve a self-submitted request: refuse the pair at publish (A), override at runtime (B), or allow it (C)? #25635). The approval node'sApprovalNodeConfigSchema.safeParserefuses the pair beforeopenNodeRequest, so only a caller that bypasses the parse could reach it.action: 'auto_approve'still approves an overdue request under the system context. That is the author's ADR-0042 timeout policy, not the submitter deciding, so it is left as it is.position:Pliteral slot (a position nobody held at open time) is not removed at open. If the submitter is later staffed into it, the decision guard refuses their approval.Generated by Claude Code