Skip to content

Commit 4bcc4df

Browse files
feat(spec)!: refuse excludeSubmitter: true beside onEmptyApprovers: 'auto_approve' on an approval node (#25700)
Fixes #25635 Clause-②: no (narrowing) Metadata-protocol: narrowed Ruling-ref: 6386627950 ## What changes An approval node's config now refuses `excludeSubmitter: true` beside `onEmptyApprovers: 'auto_approve'` at the parse. This is letter A of ruling `6386627950` (batch #12 item 1, maintainer 「25635 25647 25675 同意」): > **A** = 发布时拒绝 `excludeSubmitter: true` 与 `onEmptyApprovers: 'auto_approve'` 的组合:审批节点多一条配对校验(形如 `checkFallbackApproversPairing`),提示改用 `admin_rescue` / `fail` / `fallback`。让裁决 6377842908 的「绝不静默放行」在契约里结构性成立。 - `packages/spec/src/automation/approval.zod.ts`: a new refinement, `checkExcludeSubmitterPolicyPairing`, follows the shape of `checkFallbackApproversPairing` and is wired the same way, as a second `.superRefine` on `ApprovalNodeConfigSchema`. It raises one `custom` issue when `excludeSubmitter === true` and `onEmptyApprovers === 'auto_approve'`, and nothing otherwise. - **The issue sits on `onEmptyApprovers`, not on `excludeSubmitter`.** The ruled fix is to choose a different policy, so `onEmptyApprovers` is the key the author has to edit. The message names both keys and offers the three ruled policies: `'admin_rescue'` (the default), `'fail'`, or `'fallback'` with `fallbackApprovers`. Unlike the fallback pairing, it does not offer "remove the other key" as an equal fix. Removing `excludeSubmitter` drops the control the author asked for, and an author or an AI told that either key will do usually makes the cheaper edit. - **Only `true` triggers it.** With `excludeSubmitter` set to `false` or left out, `auto_approve` keeps its meaning. Every other policy still parses beside `true`. - **Describe and TSDoc.** The `excludeSubmitter` describe said an emptied slate "never passes silently", but did not mention that `auto_approve` is refused beside it. That said less than the code now enforces. It gains one sentence naming the refused value and the three policies to choose instead. The `[PLANNED — not yet enforced]` marker stays, because the runtime exclusion (#25573) is still to come. The `auto_approve` bullet in the `onEmptyApprovers` TSDoc now points at the new rule. `content/docs/references/automation/approval.mdx` is regenerated (`gen:docs`, one row). - No runtime change. #25573 keeps its scope, and no other `onEmptyApprovers` value is touched. ## Release-window reading (no published surface narrows) The ruling requires this to merge before the next objectstack release. Reading taken 2026-10-24: - `npm view @objectstack/spec`: `latest` 18.1.0, `next` 18.0.0-next.5, `rc` 17.0.0-rc.6. In the 18.1.0 tarball (`npm pack`), `excludeSubmitter` appears in **0** files. Controls in the same tarball: `onEmptyApprovers` in 28 files, `fallbackApprovers` in 30. - `packages/spec/CHANGELOG.md` at `3cee74ecf0`: 0 hits for `excludeSubmitter`. The key's own changeset, `.changeset/25609-approval-exclude-submitter.md`, is still pending, so it will ship in the same release as this one. - Producers: `git grep excludeSubmitter` at `3cee74ecf0` hits only the spec schema, its test, the generated reference, the `authorable-surface` shard, the `liveness/flow.json` note and the two changesets. No example, platform object, plugin or service writes it, so there is nothing to migrate. Control: `onEmptyApprovers` is written by `examples/app-showcase` flows. Absent from every release, the refusal narrows nothing a published version accepted. The changeset is `'@objectstack/spec': minor`, because a `narrowed` kind takes at least `minor`. It carries the three declaration lines above, and its ADR-0087 marker reads `not-required (no-migration-prescription)` on those facts (`check-adr-0087-registration` exit 0, classifying it `[clause-②-narrowing+metadata-protocol-narrowed]`). ## Consumers - `plugin-approvals` parses `ApprovalNodeConfigSchema` when it executes. A stored node with the pair would now fail at execute, but no producer writes the key, so no such row exists. - `packages/lint` and `metadata-protocol`: no code change. Their full suites are green at `3cee74ecf0` (below). - The flow doors pass the refusal through in the contract's own words. `FlowSchema` reports it at `nodes.N.config.onEmptyApprovers`, and the build judge `flowNodeConfigRefusals` reports it as `node-config-refused-by-contract` on `onEmptyApprovers`. Both are pinned in §6. - JSON Schema: a refinement has no projection, so `automation/ApprovalNodeConfig` keeps its existing `dropped-refinements` site `""`. That ledger is unchanged and `check:generated` is green. ## Tests and gates (all readings at `3cee74ecf0` unless stated) - **Test first.** `approval-exclude-submitter-25609.test.ts` gains §6 and one §5 assertion. Run before the implementation: `Tests 6 failed | 22 passed (28)`. The six were exactly the new refusal pins and the describe pin; the controls were already green. - **Ablation, committed first.** `node scripts/ablation-replace.mjs --delete` removed the wiring `.superRefine(checkExcludeSubmitterPolicyPairing)` (anchor 1 → 0, blob `8c310f2bea7b` → `a85bce9b6733`). Predicted direction: the five §6 refusal pins go red and the controls stay green. Observed: `Tests 5 failed | 23 passed (28)`. Restored: blob equals HEAD `8c310f2bea7b`, `git diff HEAD` empty. - `pnpm --filter @objectstack/spec test`: `Test Files 719 passed (719)`, `Tests 21800 passed | 1 todo (21801)`. - `pnpm --filter @objectstack/spec test:repo` (at `4c615790bf`, before the merge of `origin/main`): `Test Files 79 passed (79)`, `Tests 1269 passed (1269)`. Not re-run after the merge. CI's `Test Core` runs it on the merged head. - `pnpm --filter @objectstack/spec typecheck`: exit 0 (`check:test-typecheck` OK, ledger held). - Consumers, each a full suite: `plugin-approvals` `96 passed (96)` / `1259 passed`. `lint` `161 passed (161)` / `7269 passed`. `metadata-protocol` `263 passed | 3 skipped (266)` / `21475 passed | 19 skipped`. These packages have no `test:repo` script. - Full dogfood (owed by the `(narrowing)` arm and the `!` title): `pnpm --filter @objectstack/dogfood test` under the lock, after building `@objectstack/dogfood^...`. Result: `Test Files 295 passed | 3 skipped (298)`, `Tests 2520 passed | 18 skipped (2538)`, `VERDICT command-exit 0`. - `pnpm --filter @objectstack/spec check:generated` after the merge: all 14 artifacts up to date. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 113 commands. All 113 exit 0. Four needed a re-run: three ran while the dogfood closure build was rewriting `dist/` (`check:exported-any` 3, `check:generated` 1, `check:dual-build-cjs-loads` 1), and `check:type-check-debt` hit a lock queue-timeout (3). Each re-run after the build exited 0. `--ran` reconciliation: `113 derived famil(ies) accounted for — 113 run, 0 NOT-MEASURED (a DERIVED zero — all 113 recorded an exit code and none of them is 3)`. - `node scripts/docs-audit/affected-docs.mjs --json origin/main` named 7 docs. Four are hand-written (`automation/approvals.mdx`, `automation/flows.mdx`, `capabilities/approvals.mdx`, `getting-started/common-patterns.mdx`) and three are release-owned (not touched). None of the hand-written pages mentions `excludeSubmitter`, and their `auto_approve` sentences stay true, so no hand-written sentence is made false. ## Acceptance notes - **Lint hint, noted rather than filed.** The `approval-expression-no-empty-policy` hint lists `'auto_approve'` among the policies to declare, whatever `excludeSubmitter` says. An author with `excludeSubmitter: true` and an expression approver who follows it verbatim is refused at once by this rule, with the prescription. That is loud, and nobody writes the key today. carrier: none. - **Boundary, noted rather than filed.** SLA `escalation.action: 'auto_approve'` is a different key, a timed policy that notifies the submitter (`approval.sla_breached`). The ruling covers `onEmptyApprovers` only, so it is untouched. carrier: none. - **Declaration lines.** The card's own "After the ruling" text anticipated `Clause-②: no` for a pre-release landing. The PM's claim carries `Clause-②: no (narrowing)`, and those lines are copied verbatim as the claim prescribes. `Metadata-protocol: narrowed` alone already classifies the changeset as breaking for the ADR-0087 gate, so the arm changes no gate verdict here. The release-window reading above is the evidence that no published surface narrows. - The window: this must merge before the next objectstack release, or it becomes a narrowing of a published key that needs a fresh ADR-0087 disposition. --- _Generated by [Claude Code](https://claude.ai/code/session_01PC4NPXPgPmpDRG9DdDnyXT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b179084 commit 4bcc4df

4 files changed

Lines changed: 172 additions & 7 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): an approval node refuses `excludeSubmitter: true` beside `onEmptyApprovers: 'auto_approve'`
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: narrowed
9+
Ruling-ref: 6386627950
10+
11+
An approval node's `excludeSubmitter: true` removes the request's submitter from the slate its `approvers` resolve to. When that leaves nobody, the request takes the node's `onEmptyApprovers` route, and under `'auto_approve'` that route approves the request with no one deciding: a submitter who is the only approver would get their own request approved, the one outcome the option exists to prevent. The approval node config now refuses the pair at the parse, with one issue at `onEmptyApprovers` naming both keys, so `os validate`, `compile`, `defineStack`, the flow build judge and the metadata save door all refuse it before it is deployed. Choose a policy that never passes the request instead: `'admin_rescue'` (the default, which opens the request on the organization's administrators), `'fail'`, or `'fallback'` with a `fallbackApprovers` list naming who decides.
12+
13+
- **Narrows nothing a released version accepted.** `excludeSubmitter` is new in this same release (its own changeset declares it), and no published `@objectstack/spec` carries it, so no package source or stored approval node written against a released version can hold the pair.
14+
- **Only `true` arms it.** `onEmptyApprovers: 'auto_approve'` beside `excludeSubmitter: false`, or with the key omitted, parses as before and keeps its meaning. Every other policy parses beside `excludeSubmitter: true`.
15+
- **The approval engine is unchanged.** Removing the submitter at run time is still to come; this change makes the contract refuse the one combination that would let an emptied slate pass.
16+
17+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves for an upgrading consumer: the refused pair needs `excludeSubmitter`, a key added in this same release and absent from every published `@objectstack/spec` (the latest on npm, 18.1.0, carries no `excludeSubmitter` anywhere in its tarball), and no producer in this repository writes it, so no stored row or package source written against a released version holds the pair and there is nothing for `objectstack migrate meta` to rewrite. No spec key, authorable spelling or stored shape is removed, renamed or re-shaped, and the remedy for an author who writes the pair is a choice among three policies, not a mechanical rewrite. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

‎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. 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 | [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. |
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 |

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

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,19 @@
2626
* `additionalProperties: false`.
2727
* - §5 THE STATUS: the describe opens with the `[PLANNED` marker (the runtime
2828
* half has not landed, so the key is declared and not yet read) and names
29-
* the two subjects an author needs — `submitter_id` (who is removed) and
30-
* `onEmptyApprovers` (where an emptied slate goes). The prose itself is not
31-
* pinned.
29+
* the subjects an author needs — `submitter_id` (who is removed),
30+
* `onEmptyApprovers` (where an emptied slate goes) and `'auto_approve'` (the
31+
* one policy refused beside it, §6). The prose itself is not pinned.
32+
* - §6 THE REFUSED PAIR (#25635, ruling 6386627950, letter A):
33+
* `excludeSubmitter: true` beside `onEmptyApprovers: 'auto_approve'` is
34+
* refused at the parse, ONE issue at `onEmptyApprovers` — the key the ruled
35+
* prescription edits — naming both keys and the three policies it offers.
36+
* Controls: every other policy beside `true`, and `auto_approve` beside a
37+
* `false` or omitted key, parse green. The refusal reaches the flow door
38+
* (the issue at the node config's own key, and the build judge's
39+
* `node-config-refused-by-contract`), and it is independent of the
40+
* `fallbackApprovers` pairing: a config that trips both carries both, and the
41+
* one edit to `'fallback'` clears both.
3242
*/
3343

3444
import { describe, expect, it } from 'vitest';
@@ -39,6 +49,7 @@ import {
3949
ApprovalNodeConfigSchema,
4050
getApprovalNodeConfigJsonSchema,
4151
} from './approval.zod';
52+
import { flowNodeConfigRefusals } from './flow-node-config-refusals';
4253
import { FlowSchema } from './flow.zod';
4354

4455
type Door = { safeParse: (v: unknown) => z.ZodSafeParseResult<unknown> };
@@ -195,4 +206,80 @@ describe('§5 the describe declares the key planned and names its two subjects',
195206
expect(doc()).toContain('`submitter_id`');
196207
expect(doc()).toContain('`onEmptyApprovers`');
197208
});
209+
210+
it("names the one policy refused beside it ('auto_approve'), so an author reading it is not surprised by §6", () => {
211+
expect(doc()).toContain("'auto_approve'");
212+
});
213+
});
214+
215+
// ───────────────────────────────────────────────────────────────────────────
216+
// §6 the refused pair: excludeSubmitter: true + onEmptyApprovers: 'auto_approve'
217+
// ───────────────────────────────────────────────────────────────────────────
218+
219+
describe("§6 excludeSubmitter: true beside onEmptyApprovers: 'auto_approve' is refused at the parse", () => {
220+
const PAIR = { ...BASE, excludeSubmitter: true, onEmptyApprovers: 'auto_approve' };
221+
const FALLBACK = [{ type: 'org_membership_level', value: 'owner' }];
222+
223+
/** The refusal's message, read off the contract itself — never re-spelled here. */
224+
const refusalMessage = (value: unknown): string => {
225+
const result = ApprovalNodeConfigSchema.safeParse(value);
226+
if (result.success) return '';
227+
return result.error.issues.find((i) => i.path.join('.') === 'onEmptyApprovers')?.message ?? '';
228+
};
229+
230+
it('refuses the pair with ONE issue, at onEmptyApprovers (the key the prescribed edit changes)', () => {
231+
expect(issues(ApprovalNodeConfigSchema, PAIR)).toEqual([{ code: 'custom', path: 'onEmptyApprovers' }]);
232+
});
233+
234+
it('names both keys and the three policies it offers instead', () => {
235+
const message = refusalMessage(PAIR);
236+
for (const subject of ['excludeSubmitter', "'auto_approve'", "'admin_rescue'", "'fail'", "'fallback'", 'fallbackApprovers']) {
237+
expect(message, subject).toContain(subject);
238+
}
239+
});
240+
241+
it.each([
242+
['admin_rescue, declared', { onEmptyApprovers: 'admin_rescue' }],
243+
['admin_rescue, the default (key omitted)', {}],
244+
['fail', { onEmptyApprovers: 'fail' }],
245+
['fallback with fallbackApprovers', { onEmptyApprovers: 'fallback', fallbackApprovers: FALLBACK }],
246+
])('control: excludeSubmitter: true beside %s parses green', (_label, policy) => {
247+
expect(issues(ApprovalNodeConfigSchema, { ...BASE, excludeSubmitter: true, ...policy })).toEqual([]);
248+
});
249+
250+
it.each([
251+
['excludeSubmitter: false', { excludeSubmitter: false }],
252+
['excludeSubmitter omitted', {}],
253+
])("control: onEmptyApprovers: 'auto_approve' beside %s parses green and keeps its meaning", (_label, exclusion) => {
254+
const config = { ...BASE, ...exclusion, onEmptyApprovers: 'auto_approve' };
255+
expect(issues(ApprovalNodeConfigSchema, config)).toEqual([]);
256+
expect(ApprovalNodeConfigSchema.parse(config).onEmptyApprovers).toBe('auto_approve');
257+
});
258+
259+
it('reaches the flow door: FlowSchema refuses it at the node config key, in the contract\'s own words', () => {
260+
const result = FlowSchema.safeParse(flowWith({ config: PAIR }));
261+
expect(result.success).toBe(false);
262+
const found = result.success ? [] : result.error.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message }));
263+
expect(found.map(({ code, path }) => ({ code, path }))).toEqual([
264+
{ code: 'custom', path: 'nodes.1.config.onEmptyApprovers' },
265+
]);
266+
expect(found[0]!.message).toContain(refusalMessage(PAIR));
267+
});
268+
269+
it("reaches the build judge as node-config-refused-by-contract on onEmptyApprovers", () => {
270+
const refusals = flowNodeConfigRefusals('approval', PAIR);
271+
expect(refusals.map(({ code, path }) => ({ code, path }))).toEqual([
272+
{ code: 'node-config-refused-by-contract', path: 'onEmptyApprovers' },
273+
]);
274+
expect(refusals[0]!.message).toContain(refusalMessage(PAIR));
275+
});
276+
277+
it('is independent of the fallbackApprovers pairing: both arms report, and one edit to fallback clears both', () => {
278+
const both = { ...PAIR, fallbackApprovers: FALLBACK };
279+
expect(issues(ApprovalNodeConfigSchema, both)).toEqual([
280+
{ code: 'custom', path: 'onEmptyApprovers' },
281+
{ code: 'custom', path: 'onEmptyApprovers' },
282+
]);
283+
expect(issues(ApprovalNodeConfigSchema, { ...both, onEmptyApprovers: 'fallback' })).toEqual([]);
284+
});
198285
});

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

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,59 @@ function checkFallbackApproversPairing(
829829
});
830830
}
831831

832+
/**
833+
* `excludeSubmitter: true` beside `onEmptyApprovers: 'auto_approve'` is
834+
* refused as a pair (ruling 6386627950 on #25635, letter A).
835+
*
836+
* ## Why the pair contradicts itself
837+
*
838+
* `excludeSubmitter` exists so that a submitter cannot approve their own
839+
* request. The slate it empties is the slate of a submitter who was its only
840+
* approver, and an emptied slate takes the node's `onEmptyApprovers` route.
841+
* Under `auto_approve` that route approves the request with nobody deciding:
842+
* the submitter's own request passes, the one outcome the option was written
843+
* to prevent. The ruling that governs the option (6377842908: an emptied slate
844+
* "never passes silently") is made true here, in the contract both ends read,
845+
* rather than by a runtime override of an authored policy (AGENTS.md Prime
846+
* Directive 12: reject at authoring, never reinterpret in a consumer).
847+
*
848+
* ## Where the issue lands
849+
*
850+
* On `onEmptyApprovers`, because the ruled prescription is a different policy
851+
* (`admin_rescue`, `fail` or `fallback`), so that is the key the author edits.
852+
* The message names both keys, but unlike {@link checkFallbackApproversPairing}
853+
* it does not offer removing the other key as an equal fix: removing
854+
* `excludeSubmitter` drops the control the author asked for, and an author or
855+
* an AI told that either key will do takes the cheaper edit.
856+
*
857+
* ## What it does NOT judge
858+
*
859+
* Only `true` arms it. `false` and an omitted key are the "off" the option
860+
* defaults to, under which `auto_approve` keeps its own meaning. No other
861+
* policy is refused: `admin_rescue` and `fallback` open the request on other
862+
* people, and `fail` stops the run; none of them passes the request. Who the
863+
* runtime removes, and what the approval record says, is the approval
864+
* engine's (`plugin-approvals`), not this rule's.
865+
*/
866+
function checkExcludeSubmitterPolicyPairing(
867+
cfg: { excludeSubmitter?: unknown; onEmptyApprovers?: unknown },
868+
ctx: z.RefinementCtx,
869+
): void {
870+
if (cfg.excludeSubmitter !== true || cfg.onEmptyApprovers !== 'auto_approve') return;
871+
ctx.addIssue({
872+
code: 'custom',
873+
path: ['onEmptyApprovers'],
874+
message:
875+
"onEmptyApprovers: 'auto_approve' cannot be paired with excludeSubmitter: true. "
876+
+ 'excludeSubmitter removes the submitter from the slate, and when that leaves nobody '
877+
+ "'auto_approve' approves the request with no one deciding, so a submitter who is the "
878+
+ 'only approver gets their own request approved: the outcome excludeSubmitter exists to '
879+
+ "prevent. Choose a policy that never passes the request: 'admin_rescue' (the default, "
880+
+ "which opens it on the organization's administrators), 'fail', or 'fallback' with a "
881+
+ 'fallbackApprovers list naming who decides.',
882+
});
883+
}
884+
832885
export const ApprovalNodeConfigSchema = lazySchema(() => strictObject(
833886
{
834887
surface: "this approval node's config",
@@ -886,6 +939,11 @@ export const ApprovalNodeConfigSchema = lazySchema(() => strictObject(
886939
* - **An emptied slate.** Takes the node's `onEmptyApprovers` route, never a
887940
* silent pass, and the approval record shows that the exclusion changed
888941
* the route (the ruled behaviour, built in `plugin-approvals` by #25573).
942+
* The one policy that would pass it, `'auto_approve'`, is refused beside
943+
* `excludeSubmitter: true` at the parse by
944+
* {@link checkExcludeSubmitterPolicyPairing} (ruling 6386627950 on
945+
* #25635), so "never a silent pass" holds in the contract, not only at
946+
* the run.
889947
* - **Off by default, and ABSENT when off.** `.optional()`, not
890948
* `.default(false)`: a node that does not author the key parses to
891949
* exactly the output it parsed to before the key existed, so every stored
@@ -908,7 +966,9 @@ export const ApprovalNodeConfigSchema = lazySchema(() => strictObject(
908966
+ 'removed from the slate `approvers` resolves to, whatever approver type produced them. If that leaves '
909967
+ 'nobody, the request takes the node\'s `onEmptyApprovers` route (`admin_rescue` by default, or '
910968
+ '`fallbackApprovers` under `\'fallback\'`) and never passes silently, and the approval record shows that '
911-
+ 'the exclusion changed the route. Omitted or false (the default), the slate stays as resolved. '
969+
+ 'the exclusion changed the route. `onEmptyApprovers: \'auto_approve\'` is refused beside '
970+
+ '`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. '
912972
+ 'The approval engine does not read this key yet, so today the submitter is NOT removed: keep any guard '
913973
+ 'you rely on (an `expression` approver or a validation rule) until it does.',
914974
),
@@ -976,7 +1036,8 @@ export const ApprovalNodeConfigSchema = lazySchema(() => strictObject(
9761036
* slate can only mean a configuration bug.
9771037
* - `auto_approve` — skip the request and continue down the `approve` edge
9781038
* with `output.autoApproved = true`. The DingTalk/Feishu default; opt-in
979-
* here because it silently waves the record through.
1039+
* here because it silently waves the record through. Refused beside
1040+
* `excludeSubmitter: true` ({@link checkExcludeSubmitterPolicyPairing}).
9801041
* - `fallback` — open the request on {@link ApprovalNodeConfig.fallbackApprovers}
9811042
* instead: the way to NAME who decides, rather than leave it to the
9821043
* organization's administrators. Measured elsewhere as
@@ -1058,7 +1119,7 @@ export const ApprovalNodeConfigSchema = lazySchema(() => strictObject(
10581119
*/
10591120
maxRevisions: z.number().int().min(0).default(3)
10601121
.describe('Max send-backs for revision before auto-reject (0 = send-back disabled)'),
1061-
}).superRefine(checkFallbackApproversPairing));
1122+
}).superRefine(checkFallbackApproversPairing).superRefine(checkExcludeSubmitterPolicyPairing));
10621123
export type ApprovalNodeConfig = z.input<typeof ApprovalNodeConfigSchema>;
10631124
/** Post-parse shape of {@link ApprovalNodeConfig} — defaults applied, transforms run (ADR-0122). */
10641125
export type ApprovalNodeConfigParsed = z.infer<typeof ApprovalNodeConfigSchema>;

0 commit comments

Comments
 (0)