Skip to content

Commit 357ad56

Browse files
committed
docs(spec): excludeSubmitter is read by the approval engine — drop the planned marker
Claude-Session: https://claude.ai/code/session_01LXX9EBHog6ZMo6apV2jvXX Co-authored-by: Claude <noreply@anthropic.com>
1 parent addd6e1 commit 357ad56

5 files changed

Lines changed: 74 additions & 19 deletions

File tree

‎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 |
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25573] The submitter exclusion note: one English source, every shipped
5+
* language from the same catalog, and a strict read of the stored shape. The
6+
* end-to-end half (the note an opened request writes, and the door a zh-CN
7+
* reader reads it through) is `exclude-submitter.integration.test.ts`.
8+
*/
9+
10+
import { describe, it, expect } from 'vitest';
11+
import { submitterExclusionNoteOf, submitterExclusionNoteText, type SubmitterExclusionRoute } from './submitter-exclusion-note.js';
12+
import { adminRescueNoteOf, adminRescueReason, type ApprovalSentenceRender } from './admin-rescue-note.js';
13+
import { approvalMessages } from './translations/messages.js';
14+
15+
const renderIn = (locale: keyof typeof approvalMessages): ApprovalSentenceRender =>
16+
(key) => (approvalMessages[locale] as Record<string, string>)[key.replace(/^messages\./, '')];
17+
18+
const ROUTES: SubmitterExclusionRoute[] = ['remaining_approvers', 'fallback', 'administrators', 'no_one'];
19+
20+
describe('[#25573] the submitter exclusion note', () => {
21+
it('each route has its own English sentence, and every shipped language renders each one', () => {
22+
const english = ROUTES.map((route) => submitterExclusionNoteText({ route }));
23+
expect(new Set(english).size).toBe(ROUTES.length);
24+
for (const text of english) expect(text).toMatch(/^The request's submitter was removed from its approvers/);
25+
for (const locale of Object.keys(approvalMessages) as Array<keyof typeof approvalMessages>) {
26+
for (const route of ROUTES) {
27+
expect(submitterExclusionNoteText({ route }, renderIn(locale)), `${locale} ${route}`).toBeTruthy();
28+
}
29+
}
30+
expect(submitterExclusionNoteText({ route: 'fallback' }, () => undefined)).toBeUndefined();
31+
});
32+
33+
it('reads only the shape it writes', () => {
34+
const good = { action_id: 'aact_1', route: 'administrators' };
35+
expect(submitterExclusionNoteOf({ __submitterExclusionNote: good })).toEqual(good);
36+
for (const bad of [
37+
undefined,
38+
{},
39+
{ __submitterExclusionNote: 'The request…' },
40+
{ __submitterExclusionNote: { route: 'fallback' } },
41+
{ __submitterExclusionNote: { action_id: 'aact_1', route: 'auto_approve' } },
42+
]) {
43+
expect(submitterExclusionNoteOf(bad), JSON.stringify(bad)).toBeUndefined();
44+
}
45+
});
46+
47+
it("the administrators' note names the exclusion instead of saying no approver resolved to a person", () => {
48+
expect(adminRescueReason([], undefined, true))
49+
.toBe("the only approver it resolved to was the request's submitter, whom excludeSubmitter removes");
50+
expect(adminRescueReason([], undefined)).toBe('no approver on the node resolved to a person');
51+
expect(adminRescueNoteOf({ __adminRescueNote: { action_id: 'a', abandoned: [], submitter_excluded: true } }))
52+
.toEqual({ action_id: 'a', abandoned: [], submitter_excluded: true });
53+
expect(adminRescueNoteOf({ __adminRescueNote: { action_id: 'a', abandoned: [], submitter_excluded: 'yes' } }))
54+
.toBeUndefined();
55+
});
56+
});

‎packages/spec/liveness/flow.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"type": "flow",
3-
"_note": "FlowSchema. Seeded from docs/audits/2026-06-flowschema-property-liveness.md. Consumers: service-automation engine + node executors. runAs is marker-driven experimental (spec). CORRECTED 2026-07: the previous note claimed 'status/active gate nothing' — that was true when the audit ran but became FALSE with commit 497bda853 (2026-07-05, 'honor flow status for enable/disable'). `status` now gates binding AND execution; `active` remains a deprecated no-op. 2026-07-30 (#3896 close-out sweep): the dead authoring keys were REMOVED — tombstoned at the schema with prescriptions (retiredKey) and stripped by the protocol-17 close-out conversions; entries deleted per the #3715 precedent. 2026-08-28 (commit 8f10a79f7): the four `path:NNN` citations in this file were re-anchored to their consuming symbols. All four were wrong, all four IN RANGE, and `status` also carried three bare line suffixes with no path that no check has ever resolved. PLANNED RECORD (#25609, 2026-10-24, ruling 6377731715 on #25496, letter B for #25446, \"default off\"): an approval node's `config.excludeSubmitter` (ApprovalNodeConfigSchema in packages/spec/src/automation/approval.zod.ts), optional and absent when off, removes the request's submitter (`sys_approval_request.submitter_id`, the run's trigger user) from the slate `approvers` resolves to, for every approver type; an emptied slate takes the node's `onEmptyApprovers` route, never a silent pass, and the approval record shows that the exclusion changed the route. PLANNED, not live: measured at f7b0298968, plugin-approvals parses the key with the rest of the node config (packages/plugins/plugin-approvals/src/approval-node.ts, the ApprovalNodeConfigSchema.safeParse of node.config) and nothing reads it, so the submitter is not removed. Carrier: #25573 (plugin-approvals, Blocked-by #25609). No row carries it: the walk ends at `nodes.config`, a `z.record(z.string(), z.unknown())` that childShape() does not descend — a `children` map planted there is refused, exit 1, as `flow/nodes.config (declared children but property is not a container)` (measured on #25609's branch) — and the liveness lint reads no deeper than a container's direct children, so no `authorWarn` row can reach this key's authors either. The checkable record is the authorable-surface `automation/ApprovalNodeConfig:excludeSubmitter` row and the key's own describe, which opens with `[PLANNED — not yet enforced]` and ends by telling the author the submitter is not removed yet. When #25573 lands, that marker and that last sentence leave and this sentence is rewritten.",
3+
"_note": "FlowSchema. Seeded from docs/audits/2026-06-flowschema-property-liveness.md. Consumers: service-automation engine + node executors. runAs is marker-driven experimental (spec). CORRECTED 2026-07: the previous note claimed 'status/active gate nothing' — that was true when the audit ran but became FALSE with commit 497bda853 (2026-07-05, 'honor flow status for enable/disable'). `status` now gates binding AND execution; `active` remains a deprecated no-op. 2026-07-30 (#3896 close-out sweep): the dead authoring keys were REMOVED — tombstoned at the schema with prescriptions (retiredKey) and stripped by the protocol-17 close-out conversions; entries deleted per the #3715 precedent. 2026-08-28 (commit 8f10a79f7): the four `path:NNN` citations in this file were re-anchored to their consuming symbols. All four were wrong, all four IN RANGE, and `status` also carried three bare line suffixes with no path that no check has ever resolved. LIVE RECORD (#25609 declared it, #25573 reads it; ruling 6377731715 on #25496, letter B for #25446, \"default off\"): an approval node's `config.excludeSubmitter` (ApprovalNodeConfigSchema in packages/spec/src/automation/approval.zod.ts), optional and absent when off, removes the request's submitter (`sys_approval_request.submitter_id`, the run's trigger user) from the slate `approvers` resolves to, for every approver type; an emptied slate takes the node's `onEmptyApprovers` route, never a silent pass, and the approval record shows that the exclusion changed the route. Consumer: plugin-approvals (packages/plugins/plugin-approvals/src/approval-service.ts): `openNodeRequest` screens the `approvers`, `fallback` and `admin_rescue` slates through `excludedSubmitterSlots` and writes the `__submitterExclusionNote` route note, and `decideNode`, `reassign` and the SLA `escalateRequest` keep the slot out of the submitter's hands after the open. No row carries it: the walk ends at `nodes.config`, a `z.record(z.string(), z.unknown())` that childShape() does not descend — a `children` map planted there is refused, exit 1, as `flow/nodes.config (declared children but property is not a container)` (measured on #25609's branch) — and the liveness lint reads no deeper than a container's direct children, so no `authorWarn` row can reach this key's authors either. The checkable record is the authorable-surface `automation/ApprovalNodeConfig:excludeSubmitter` row and the key's own describe.",
44
"props": {
55
"name": {
66
"status": "live",

‎packages/spec/src/automation/approval-exclude-submitter-25609.test.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424
* - §4 THE DESIGNER'S SCHEMA: the published JSON schema carries the key as a
2525
* plain optional boolean with no `default`, under the same
2626
* `additionalProperties: false`.
27-
* - §5 THE STATUS: the describe opens with the `[PLANNED` marker (the runtime
28-
* half has not landed, so the key is declared and not yet read) and names
27+
* - §5 THE STATUS: the describe carries no `[PLANNED` marker (the runtime
28+
* half, #25573, reads the key in `plugin-approvals`) and names
2929
* the subjects an author needs — `submitter_id` (who is removed),
3030
* `onEmptyApprovers` (where an emptied slate goes) and `'auto_approve'` (the
3131
* one policy refused beside it, §6). The prose itself is not pinned.
@@ -189,7 +189,7 @@ describe('§4 the published JSON schema carries it as an optional boolean with n
189189
// §5 the status the describe declares
190190
// ───────────────────────────────────────────────────────────────────────────
191191

192-
describe('§5 the describe declares the key planned and names its two subjects', () => {
192+
describe('§5 the describe declares the key live and names its two subjects', () => {
193193
// Read inside each test, never at collection: a key that went missing then
194194
// reds these two tests instead of erroring the file and hiding §1–§4.
195195
const doc = (): string => {
@@ -198,8 +198,9 @@ describe('§5 the describe declares the key planned and names its two subjects',
198198
return member?.description ?? '';
199199
};
200200

201-
it('opens with the planned marker: declared, not yet read by the approval engine', () => {
202-
expect(doc().startsWith('[PLANNED')).toBe(true);
201+
it('carries no planned marker: the approval engine reads the key (#25573)', () => {
202+
expect(doc()).toMatch(/^Segregation of duties/);
203+
expect(doc()).not.toContain('PLANNED');
203204
});
204205

205206
it('names who is removed (submitter_id) and where an emptied slate goes (onEmptyApprovers)', () => {

‎packages/spec/src/automation/approval.zod.ts‎

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -951,26 +951,24 @@ export const ApprovalNodeConfigSchema = lazySchema(() => strictObject(
951951
* unchanged — "off, behaviour is unchanged" holds at the parse as well as
952952
* at the run.
953953
*
954-
* PLANNED, not yet read: `plugin-approvals` parses the key with the rest of
955-
* the config and nothing reads it yet, so the submitter is NOT removed until
956-
* #25573 lands. The liveness record is the `liveness/flow.json` `_note`
957-
* (the ledger walk ends at `flow/nodes.config`, a record of `unknown`, so
958-
* no row can carry a node-config key) and the `[PLANNED — not yet
959-
* enforced]` marker this describe opens with; when #25573 lands, the marker
960-
* and the describe's last sentence leave and the `_note` sentence is
961-
* rewritten.
954+
* Read by `plugin-approvals` (#25573): `openNodeRequest` takes the
955+
* submitter off every slate the request can open on (`approvers`, the
956+
* `fallback` slate and the `admin_rescue` administrators) and writes the
957+
* route note, and the decision, the reassign door and the SLA escalation
958+
* keep the slot out of the submitter's hands after the open. The liveness
959+
* record is the `liveness/flow.json` `_note` (the ledger walk ends at
960+
* `flow/nodes.config`, a record of `unknown`, so no row can carry a
961+
* node-config key).
962962
*/
963963
excludeSubmitter: z.boolean().optional().describe(
964-
'[PLANNED — not yet enforced] Segregation of duties for this step: when true, the request\'s submitter '
964+
'Segregation of duties for this step: when true, the request\'s submitter '
965965
+ '(the user whose action started the flow run, recorded on the approval request as `submitter_id`) is '
966966
+ 'removed from the slate `approvers` resolves to, whatever approver type produced them. If that leaves '
967967
+ 'nobody, the request takes the node\'s `onEmptyApprovers` route (`admin_rescue` by default, or '
968968
+ '`fallbackApprovers` under `\'fallback\'`) and never passes silently, and the approval record shows that '
969969
+ 'the exclusion changed the route. `onEmptyApprovers: \'auto_approve\'` is refused beside '
970970
+ '`excludeSubmitter: true`, because it would approve exactly the request this option exists to stop; '
971-
+ 'choose `\'admin_rescue\'`, `\'fail\'` or `\'fallback\'`. Omitted or false (the default), the slate stays as resolved. '
972-
+ 'The approval engine does not read this key yet, so today the submitter is NOT removed: keep any guard '
973-
+ 'you rely on (an `expression` approver or a validation rule) until it does.',
971+
+ 'choose `\'admin_rescue\'`, `\'fail\'` or `\'fallback\'`. Omitted or false (the default), the slate stays as resolved.',
974972
),
975973

976974
/**

0 commit comments

Comments
 (0)