Skip to content

Commit 888d686

Browse files
feat(plugin-approvals): excludeSubmitter keeps the submitter from approving their own request (#25722)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X4Svj29jUyus5ER1MGdNio8)_ Fixes #25573 Clause-②: no Metadata-protocol: none ## What this does An approval node's `excludeSubmitter: true` now keeps the request's submitter from approving their own request. The key was declared by #25609 (PR #25632), and its pairing with `onEmptyApprovers: '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: - 6377888641 (batch #9 item 1, letter B): "step option `excludeSubmitter`, default off; empty set escalates per ADR-0042 or reroutes to the fallback". - Triage 6390007010: "remove the submitter from the resolved slate. An emptied slate takes the node's `onEmptyApprovers` fallback, else ADR-0042 escalation, never a silent pass. The approval record notes when the exclusion changed the route." No runtime override of `auto_approve` (ruling A on #25635). ## Where the exclusion lives (measured) - **One seam covers every approver type.** `user`, `field`, `manager`, `position`, `team`, `department`, `org_membership_level` and `expression` all resolve through `ApprovalService.expandApprovers`, called from `openNodeRequest`. 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 (`actingAddresses` from `approver-address.ts`: the user id and the account's email). A `position:P` literal is never removed, because other holders take it. - **Four writers put people on a slate, so four places read the option.** The open (`openNodeRequest`), the reassign door (`reassign`), the SLA sweep (`escalateRequest`, action `reassign`), and the decision (`decideNode`), which is the one check every approval passes through. The mid-tally slate update in `decideNode` only 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": 1. **The `fallbackApprovers` slate and the `admin_rescue` administrators are screened too.** Measured before this change: both slates go through the same `expandApprovers` with 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 to `admin_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. 2. **Delegation, reassign and escalation hold the exclusion too.** Out-of-office delegation is applied inside `expandApprovers`, so the open-time screen covers it. After the open: `reassign` refuses a target that is the submitter (id or email) or a position only the submitter holds, with `VALIDATION_FAILED`, asked after the authorization branches like the #25485 target check. An SLA `reassign` escalation 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 #25598 gives an unresolvable target. `decideNode` refuses an approval decided by the submitter with `FORBIDDEN`, 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 `__submitterExclusionNote` with the route the request took: `remaining_approvers`, `fallback`, `administrators` or `no_one`. It reuses the #25576 shape: the row's `comment` is the English, and the approvals door renders the reader's language from new `approvalSubmitterExcluded*` 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 in `packages/spec`: the `excludeSubmitter` describe 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 and `liveness/flow.json`'s `_note` said "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 `_note` PLANNED 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 by `check:generated --fix` (the one artifact it proved stale). ## Tests - New `packages/plugins/plugin-approvals/src/exclude-submitter.integration.test.ts`: 14 cases on a real `ObjectQL` over `SqlDriver` (better-sqlite3), with real administrator resolution. Covers a `field` step and a `position` step 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. - New `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`: the `PERSON_SITES` ledger key follows the rename `localizeAdminRescueNote` → `localizeRoutingNotes` (same two `a.actor_id` presence tests). Ablations (WRAP mode of `scripts/ablation-replace.mjs`, each restored and proven `blob == HEAD`, `git diff HEAD` empty; tests import `src/`, so no dist rebuild is involved): - A1, open-time filter removed: 8 failed / 6 passed. - A2, decision guard disabled: 2 failed / 12 passed. The first attempt was a no-op (the replacement contained the anchor, the tool refused it) and was re-run with a non-overlapping replacement. - A3, reassign refusal deleted: 1 failed / 13 passed. - A4, escalation filter removed: 1 failed / 13 passed. - A5, route note disabled: 6 failed / 8 passed. ## 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. `--ran` reconciliation: "119 derived, 119 run, 0 NOT-MEASURED, 0 UNRUN". - Reds found and fixed on the way: `check-tenant-audit-census` (and its `--self-test`, which reads the tree) counted the route note's new `sys_approval_action` insert, 238 to 239 write call sites. Regenerated with `node scripts/tenant-audit-census.mjs --write` and the hand-written prose counts on `content/docs/permissions/tenant-audit-census.mdx` updated to match; both exit 0. - Prerequisite refusals cleared, not skipped: `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-react` built), `check:i18n` (needed its extract closure built), `check:dual-build-cjs-loads` (needed built output). Each re-run green. - `@objectstack/plugin-approvals`: `pnpm test` 98 files / 1276 tests passed; `pnpm typecheck` exit 0 (includes `check:test-typecheck`). `@objectstack/spec`: `approval-exclude-submitter-25609.test.ts` 28/28; `check:generated` green after `--fix` regenerated the one stale artifact. - Scoped lint: `eslint --no-inline-config` over `packages/plugins/plugin-approvals/src` and the two touched spec files, exit 0. - CI owns the full farm (`pnpm lint`, `Test Core`, `Build Core`, the type-check lanes). ## Acceptance notes - `onEmptyApprovers: 'auto_approve'` beside `excludeSubmitter: true` is not overridden at runtime (ruling A on #25635). The approval node's `ApprovalNodeConfigSchema.safeParse` refuses the pair before `openNodeRequest`, so only a caller that bypasses the parse could reach it. - An SLA escalation with `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. - A `position:P` literal 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](https://claude.ai/code/session_01LXX9EBHog6ZMo6apV2jvXX)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e5cf9f0 commit 888d686

14 files changed

Lines changed: 836 additions & 80 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
feat(plugin-approvals): an approval step's `excludeSubmitter: true` keeps the submitter from approving their own request
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Before.** `excludeSubmitter` was declared on the approval node config and its describe opened with `[PLANNED — not yet enforced]`: the approval engine parsed it and read nothing, so a category manager who submitted their own admission was step 1's `field` approver and approved it.
12+
- **At the open.** The submitter (the run's trigger user, `sys_approval_request.submitter_id`) is taken off the resolved slate, whatever approver type produced them, by their user id or their account's email, an out-of-office hop that lands on them included. Everyone else stays on it, per_group tags included. A slate this empties takes the node's `onEmptyApprovers` route: the organization's administrators by default, the declared `fallbackApprovers` under `'fallback'`, or the `NO_APPROVERS` refusal under `'fail'`, which now names the exclusion. The fallback slate and the administrators' slate are screened the same way, so an organization whose only administrator is the submitter opens the request on the slate that names no one.
13+
- **On the record.** A request whose slate lost its submitter carries a system note on its timeline naming the route it took (the remaining approvers, the fallback approvers, the administrators, or no one), rendered in the reader's language by the approvals door. The administrators' routing note says the only approver was the submitter instead of "no approver resolved to a person".
14+
- **After the open.** An approval decided by the submitter is refused with `FORBIDDEN`, the privileged override included; their rejection stands. A reassign to the submitter, or to a position only they hold, is refused with `VALIDATION_FAILED`. An SLA escalation that reassigns leaves them off the new slate, and one whose only target is the submitter is not applied: the pending approvers keep the request and are told the SLA lapsed.
15+
- **Spec.** The describe drops the `[PLANNED — not yet enforced]` marker and its last sentence; nothing else in the schema changes.
16+
- **Unchanged.** With the option off or omitted (the default), every slate, timeline, decision, reassign and escalation behaves as before.
17+
18+
What to do: nothing to migrate. A step that set `excludeSubmitter: true` now enforces it; an `expression` approver or validation rule added by hand to keep the submitter off can be removed.

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.
132132

133133
The same holds twice over for the context. An options argument spelled as a
134134
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
135-
forwarding shim cannot, and **51 of the 238 sites are spelled that way**. A
135+
forwarding shim cannot, and **51 of the 239 sites are spelled that way**. A
136136
context resolved from an inline literal or a local `const` can be tested for
137137
`isSystem`; one arriving from a helper call cannot.
138138

@@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
197197

198198
| carried figure | where it survives | this census |
199199
| :--- | :--- | ---: |
200-
| 175 write call sites | quoted in the merged changeset | **238** |
200+
| 175 write call sites | quoted in the merged changeset | **239** |
201201
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
202-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **162 of 238** decidable, **76** undecidable |
203-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 135 decidably elevated, 0 decidably not, 95 undecidable |
202+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **163 of 239** decidable, **76** undecidable |
203+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 136 decidably elevated, 0 decidably not, 95 undecidable |
204204
| 141 and 132, two independent re-derivations | the card that filed this work | — |
205205

206206
**The differences are not reconciled, and deliberately so.** The old census's
@@ -217,11 +217,11 @@ would report a smaller number and would not say so.
217217

218218
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
219219
figure has no surviving corroboration anywhere in the tree.** This census reads
220-
135 of 238 (57%) as decidably elevated, with 95 more whose elevation is a
220+
136 of 239 (57%) as decidably elevated, with 95 more whose elevation is a
221221
run-time fact — so the claim is neither confirmed nor refuted, and the honest
222222
answer is that a static reading cannot settle it.
223223

224-
⇒ **Cite `2 / 238`, and say what it is**: the sites whose options argument was
224+
⇒ **Cite `2 / 239`, and say what it is**: the sites whose options argument was
225225
READ and holds no tenant context, against a decidably tenancy-enabled object.
226226
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
227227
without tenant context" — **29 further sites** have an options argument this
@@ -233,28 +233,28 @@ cannot read, and they are neither in nor out.
233233

234234
| what | count |
235235
| :--- | ---: |
236-
| write call sites on the application surface | **238** |
237-
| …whose object name is statically decidable | 162 |
236+
| write call sites on the application surface | **239** |
237+
| …whose object name is statically decidable | 163 |
238238
| …whose object name is chosen at run time | 76 |
239-
| …against an object with tenancy ENABLED | 161 |
239+
| …against an object with tenancy ENABLED | 162 |
240240
| …against an object that declares tenancy off | 1 |
241-
| threading a tenant context | 179 |
241+
| threading a tenant context | 180 |
242242
| PROVABLY carrying none (options read, no context key) | **8** |
243243
| …of those, against a decidably tenancy-enabled object | **2** |
244244
| options argument UNREADABLE — may or may not carry one | 51 |
245245
| …of those, against a decidably tenancy-enabled object | 29 |
246-
| threading a decidably ELEVATED (`isSystem`) context | 135 |
246+
| threading a decidably ELEVATED (`isSystem`) context | 136 |
247247
| threading a context that is decidably NOT elevated | 0 |
248248
| threading a context whose elevation is a run-time fact | 95 |
249249

250250
| how the instrument reached the site | count |
251251
| :--- | ---: |
252-
| receiver carried a readable engine type | 183 |
252+
| receiver carried a readable engine type | 184 |
253253
| receiver erased, placed by the object NAME | 45 |
254254
| receiver erased, placed by an `object: string` PARAMETER | 5 |
255255
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
256256

257-
| object name spelled inline | 111 |
257+
| object name spelled inline | 112 |
258258
| object name spelled through a `const` | 51 |
259259
| object name is an `object: string` parameter | 7 |
260260
| object name is some other run-time expression | 69 |
@@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
307307
their values are not compared. The reasoning, and the measurement behind it,
308308
are in `scripts/check-tenant-audit-census.mjs`.
309309

310-
Measured on 2026-10-23 at `487d57fed`.
310+
Measured on 2026-10-24 at `28b6d27e7`.
311311

312312
| corpus scale (not enforced) | count |
313313
| :--- | ---: |
314-
| tracked non-test sources scanned | 723 |
314+
| tracked non-test sources scanned | 767 |
315315
| engine-shaped types recognised | 87 |
316316
| declared objects in the registry | 117 |
317-
| same-named calls subtracted as non-engine | 174 |
317+
| same-named calls subtracted as non-engine | 176 |
318318

319319
{/* END GENERATED: tenant-audit-census */}

‎content/docs/references/automation/approval.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ const result = ApprovalDecision.parse(data);
6969
| Property | Type | Required | Description |
7070
| :--- | :--- | :--- | :--- |
7171
| **approvers** | `{ type: Enum<'manager' \| 'position' \| 'department' \| 'team' \| 'field' \| 'expression' \| …>; value?: string; resolveAs?: Enum<'user' \| 'department' \| 'position' \| 'team'>; group?: string; … }[]` | ✅ | Allowed approvers for this node |
72-
| **excludeSubmitter** | `boolean` | optional | [PLANNED — not yet enforced] Segregation of duties for this step: when true, the request's submitter (the user whose action started the flow run, recorded on the approval request as `submitter_id`) is removed from the slate `approvers` resolves to, whatever approver type produced them. If that leaves nobody, the request takes the node's `onEmptyApprovers` route (`admin_rescue` by default, or `fallbackApprovers` under `'fallback'`) and never passes silently, and the approval record shows that the exclusion changed the route. `onEmptyApprovers: 'auto_approve'` is refused beside `excludeSubmitter: true`, because it would approve exactly the request this option exists to stop; choose `'admin_rescue'`, `'fail'` or `'fallback'`. Omitted or false (the default), the slate stays as resolved. The approval engine does not read this key yet, so today the submitter is NOT removed: keep any guard you rely on (an `expression` approver or a validation rule) until it does. |
72+
| **excludeSubmitter** | `boolean` | optional | Segregation of duties for this step: when true, the request's submitter (the user whose action started the flow run, recorded on the approval request as `submitter_id`) is removed from the slate `approvers` resolves to, whatever approver type produced them. If that leaves nobody, the request takes the node's `onEmptyApprovers` route (`admin_rescue` by default, or `fallbackApprovers` under `'fallback'`) and never passes silently, and the approval record shows that the exclusion changed the route. `onEmptyApprovers: 'auto_approve'` is refused beside `excludeSubmitter: true`, because it would approve exactly the request this option exists to stop; choose `'admin_rescue'`, `'fail'` or `'fallback'`. Omitted or false (the default), the slate stays as resolved. |
7373
| **behavior** | `Enum<'first_response' \| 'unanimous' \| 'quorum' \| 'per_group'>` | optional (default: `"first_response"`) | How to combine multiple approvers |
7474
| **minApprovals** | `integer` | optional | Approvals required — total (quorum) or per group (per_group). Omitted ⇒ all resolvable approvers for quorum, 1 per group for per_group |
7575
| **lockRecord** | `boolean` | optional (default: `true`) | Lock the record from editing while pending |

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3333

3434
| Measure | Value |
3535
|---|---:|
36-
| Write call sites | 238 |
37-
| Object name statically decidable | 162 |
36+
| Write call sites | 239 |
37+
| Object name statically decidable | 163 |
3838
| Object name chosen at run time | 76 |
39-
| Against a tenancy-enabled object | 161 |
39+
| Against a tenancy-enabled object | 162 |
4040
| Against an object declaring tenancy off | 1 |
41-
| Threading a tenant context | 179 |
41+
| Threading a tenant context | 180 |
4242
| Provably carrying none | 8 |
4343
| …and decidably tenancy-enabled | 2 |
4444
| Options argument unreadable | 51 |
4545
| …and decidably tenancy-enabled | 29 |
46-
| Threading a decidably elevated context | 135 |
46+
| Threading a decidably elevated context | 136 |
4747
| Threading a decidably non-elevated context | 0 |
4848
| Threading a context of undecidable elevation | 95 |
4949

@@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-10-23 at `487d57fed`.
93+
Measured on 2026-10-24 at `28b6d27e7`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 723 |
97+
| tracked non-test sources scanned | 767 |
9898
| engine-shaped types recognised | 87 |
9999
| declared objects in the registry | 117 |
100-
| same-named calls subtracted as non-engine | 174 |
100+
| same-named calls subtracted as non-engine | 176 |
101101

102102
## Every site
103103

@@ -106,7 +106,7 @@ Measured on 2026-10-23 at `487d57fed`.
106106
| `packages/plugins/organizations/src/claim-org-seed-ownership.ts` | `update` | `schema.name` | undecidable | elevated | 1 |
107107
| `packages/plugins/plugin-approvals/src/action-slot-backfill.ts` | `update` | `sys_approval_action` | enabled | elevated | 2 |
108108
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 |
109-
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 15 |
109+
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 16 |
110110
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `delete` | `sys_approval_approver` | enabled | elevated | 2 |
111111
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_approver` | enabled | elevated | 2 |
112112
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_request` | enabled | elevated | 1 |

‎packages/plugins/plugin-approvals/src/admin-rescue-note.ts‎

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ export interface AdminRescueNote {
5959
readonly abandoned: readonly string[];
6060
/** Set when the node declared `onEmptyApprovers: 'fallback'` and it did not help. */
6161
readonly fallback?: AdminRescueFallback;
62+
/**
63+
* [#25573] Set when the slate named a person, the request's submitter, and
64+
* the node's `excludeSubmitter: true` removed them: the slate named no one
65+
* else, which "no approver resolved to a person" would misstate.
66+
*/
67+
readonly submitter_excluded?: true;
6268
}
6369

6470
/** One catalog sentence and its params. */
@@ -73,8 +79,15 @@ export type ApprovalSentenceRender = (
7379
params: Readonly<Record<string, string>>,
7480
) => string | undefined;
7581

76-
/** The reason's clauses: one per abandoned `type:value` slot, then the fallback's. */
77-
function reasonClauses(abandoned: readonly string[], fallback: AdminRescueFallback | undefined): Clause[] {
82+
/**
83+
* The reason's clauses: one per abandoned `type:value` slot, then the
84+
* submitter's exclusion, then the fallback's.
85+
*/
86+
function reasonClauses(
87+
abandoned: readonly string[],
88+
fallback: AdminRescueFallback | undefined,
89+
submitterExcluded?: boolean,
90+
): Clause[] {
7891
const clauses: Clause[] = abandoned
7992
.filter((slot) => !!slot && slot.includes(':'))
8093
.map((slot): Clause => {
@@ -85,6 +98,7 @@ function reasonClauses(abandoned: readonly string[], fallback: AdminRescueFallba
8598
? { id: 'approvalAdminRescuePositionUnheld', params: { value } }
8699
: { id: 'approvalAdminRescueSlotUnresolved', params: { type, value } };
87100
});
101+
if (submitterExcluded) clauses.push({ id: 'approvalAdminRescueSubmitterExcluded', params: {} });
88102
if (!clauses.length) clauses.push({ id: 'approvalAdminRescueNoApprover', params: {} });
89103
if (fallback === 'resolved_to_no_one') clauses.push({ id: 'approvalAdminRescueFallbackUnresolved', params: {} });
90104
if (fallback === 'not_declared') clauses.push({ id: 'approvalAdminRescueFallbackUndeclared', params: {} });
@@ -97,8 +111,13 @@ const english = (clause: Clause): string => interpolate(enMessages[clause.id], c
97111
* Why the slate named no one, in English: the audit snapshot's
98112
* `admin_rescue:reason` and the log lines.
99113
*/
100-
export function adminRescueReason(abandoned: readonly string[], fallback: AdminRescueFallback | undefined): string {
101-
return reasonClauses(abandoned, fallback).map(english).join(enMessages.approvalAdminRescueReasonSeparator);
114+
export function adminRescueReason(
115+
abandoned: readonly string[],
116+
fallback: AdminRescueFallback | undefined,
117+
submitterExcluded?: boolean,
118+
): string {
119+
return reasonClauses(abandoned, fallback, submitterExcluded)
120+
.map(english).join(enMessages.approvalAdminRescueReasonSeparator);
102121
}
103122

104123
/**
@@ -115,7 +134,7 @@ export function adminRescueNoteText(
115134
note: Omit<AdminRescueNote, 'action_id'>,
116135
render?: ApprovalSentenceRender,
117136
): string | undefined {
118-
const clauses = reasonClauses(note.abandoned, note.fallback);
137+
const clauses = reasonClauses(note.abandoned, note.fallback, note.submitter_excluded);
119138
if (!render) {
120139
const reason = clauses.map(english).join(enMessages.approvalAdminRescueReasonSeparator);
121140
return interpolate(enMessages.approvalAdminRescueNote, { reason });
@@ -136,9 +155,12 @@ export function adminRescueNoteOf(snapshot: unknown): AdminRescueNote | undefine
136155
if (!snapshot || typeof snapshot !== 'object') return undefined;
137156
const note = (snapshot as Record<string, unknown>)[ADMIN_RESCUE_NOTE_SNAPSHOT_KEY];
138157
if (!note || typeof note !== 'object') return undefined;
139-
const { action_id, abandoned, fallback } = note as Record<string, unknown>;
158+
const { action_id, abandoned, fallback, submitter_excluded } = note as Record<string, unknown>;
140159
if (typeof action_id !== 'string' || !action_id) return undefined;
141160
if (!Array.isArray(abandoned) || !abandoned.every((slot) => typeof slot === 'string')) return undefined;
142161
if (fallback !== undefined && fallback !== 'not_declared' && fallback !== 'resolved_to_no_one') return undefined;
143-
return { action_id, abandoned, ...(fallback ? { fallback } : {}) };
162+
if (submitter_excluded !== undefined && submitter_excluded !== true) return undefined;
163+
return {
164+
action_id, abandoned, ...(fallback ? { fallback } : {}), ...(submitter_excluded ? { submitter_excluded } : {}),
165+
};
144166
}

0 commit comments

Comments
 (0)