Skip to content

Commit 49653f9

Browse files
feat(spec): an approval step's excludeSubmitter option, default off (protocol half of #25573) (#25632)
Fixes #25609 Clause-②: yes (widening) Metadata-protocol: added Ruling-ref: 6377731715 The protocol half of #25573: an approval step's `excludeSubmitter` option, default off. #25573 (the runtime half in `plugin-approvals`) remains open and is `Blocked-by:` this card. ## The ruling this implements Ruling 6377731715 on #25496 (batch #388 item 4, the maintainer's 「同意」), item 5, verbatim: > **#25446 → B, default off.** A step-level option `excludeSubmitter: true` removes the submitter from the resolved approver set; an emptied set takes the node's existing `onEmptyApprovers` fallback (else ADR-0042 escalation), never a silent pass; the approval record shows when the exclusion changed the route. Off, behaviour is unchanged. Priority p1 (protocol addition). Workload S–M. The later ruling 6377842908 agrees on this row: 「B 步骤选项 `excludeSubmitter`,默认关;排除后无人可批按 ADR-0042 升级 … 默认关,不改现有审批行为;审批记录显示排除改了路由」. ## What changed - **`packages/spec/src/automation/approval.zod.ts`**: `ApprovalNodeConfigSchema` gains `excludeSubmitter: z.boolean().optional()`, placed directly after `approvers`. - **Where it lives.** ADR-0019 made each approval STEP an approval NODE, so the step-level option is a node-config key. It applies to the whole slate `approvers` resolves to, whatever approver type produced each person. It is one switch per node: the approver row stays closed and refuses it. - **Who the submitter is.** The user whose action started the flow run. `plugin-approvals` hands the run's trigger user to `openNodeRequest` (`approval-node.ts`, `runTriggerUser`) and records it as `sys_approval_request.submitter_id`. The describe names `submitter_id`. - **Off by default, and absent when off.** I used `.optional()`, not `.default(false)`. A node that does not author the key parses to exactly the output it parsed to before. So stored approval nodes, and the `node_config_json` snapshot each request row keeps, are unchanged. - **The describe tells the truth today.** It opens with `[PLANNED — not yet enforced]` and ends by telling the author that the approval engine does not read the key yet, so the submitter is NOT removed. It says to keep any existing guard (an `expression` approver or a validation rule) until the engine honours the key. Per `check:doc-authoring` Rule 3 it carries no tracker id. The carrier, #25573, is named in the TSDoc and in the ledger note. - **`packages/spec/liveness/flow.json`**: a `PLANNED RECORD` sentence in `_note`. Why it is a note and not a row is under Measured below. - **Generated**: the `authorable-surface/automation.json` row `automation/ApprovalNodeConfig:excludeSubmitter`, and `content/docs/references/automation/approval.mdx`. `authorable-defaults/` is unchanged, because the key has no default. - **Test**: `packages/spec/src/automation/approval-exclude-submitter-25609.test.ts` (16 tests): - §1: the key parses as `true` and as `false`, stays absent when omitted, and a non-boolean is refused at the key's own path. - §2: `FlowSchema` accepts the key inside an approval node's `config`. This is the parse that `os validate`, compile and the metadata save door run. - §3: the same key on an approver row, on the flow node beside `config`, or on the flow is still refused, each at its own path. - §4: the published JSON schema has it as an optional boolean with no `default`. - §5: the describe opens with the `[PLANNED` marker and names `submitter_id` and `onEmptyApprovers`. - **Changeset** `.changeset/25609-approval-exclude-submitter.md`: `@objectstack/spec` minor, with both declaration lines. ## Measured (premise and the PM's mechanism assumptions) - **Premise holds** on `origin/main` `f7b0298968`. `git grep excludeSubmitter` gives 0 hits. Today the key is refused at every placement: inside `config` (`custom` at `nodes.1.config.excludeSubmitter`), on the node (`unrecognized_keys` at `nodes.1`), on the flow (`unrecognized_keys` at the root) and on an approver row (`custom` at `nodes.1.config.approvers.0.excludeSubmitter`). - **The liveness-ledger-row assumption is disproved.** No row can carry a node-config key. The `flow` walk ends at `nodes.config`, which is `z.record(z.string(), z.unknown())`, and `childShape()` does not descend into it. I planted `children: { excludeSubmitter: { status: 'planned', authorWarn: true, … } }` under `flow.json` `nodes.config` and ran `check:liveness`. It exited 1 with `✗ 1 UNCLASSIFIED … flow/nodes.config (declared children but property is not a container)`. I restored the file and confirmed the blob hash matched HEAD. - The liveness lint reads no deeper than a container's direct children, so no `authorWarn` row could reach this key's authors either. - None of the existing approval-config keys (`onEmptyApprovers`, `fallbackApprovers`, …) has a row for the same reason. - So I followed the spec lane's own precedent for a key the walk cannot reach (`liveness/page.json`'s `PLANNED RECORD` for a component-level key): a `_note` sentence naming the carrier, plus the `[PLANNED — …]` describe marker. The checkable record is the authorable-surface row. - With the key added, `check:liveness` is green. ## Tests and proofs Head: `4823c57936` (the spec suite and typecheck were read there; the ablations ran on the pre-merge commits named below and touch no merged file). - `pnpm --filter @objectstack/spec test`: `Test Files 715 passed (715)`, `Tests 21703 passed | 1 todo (21704)`. - `pnpm --filter @objectstack/spec typecheck`: exit 0, including `check:test-typecheck`, which compiles the new test file. - **Ablation 1** (proves the tests catch the key going missing). Through `scripts/ablation-replace.mjs`, I renamed the declared key to `excludeSubmitterAblated`: anchor count 1 → 0, blob `27c795830a90` → `50ecb7c5d102`. Result: `Tests 10 failed | 6 passed (16)`. Every test that needs the key went red (§1 ×6, §2, §4, §5 ×2). The §2 control, the four §3 refusals and §1 "omitted stays absent" stayed green. The tool restored the file (`blob == HEAD`, `git diff HEAD` empty). - The first run of this ablation errored the whole file at collection, because §5 read the description at describe time. I moved that read inside each `it` (commit `c6f43c7960`) and re-ran the ablation for the per-test result above. - **Ablation 2** (proves "absent when off"). I changed `.optional()` to `.default(false)`. Result: `Tests 2 failed | 14 passed`: §1 "omitted stays absent" and §4 "no default" went red. Restored the same way. - No `dist` leg: the test imports `./approval.zod` from `src`. ## Gates All of these were read at head `4823c57936`, which carries one merge of `origin/main` (`481ac62116`) through `scripts/pm/os-regen-merge.sh`, with the spec rebuilt afterwards. - **Derived families:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 114 commands from this diff. I ran every one and captured each exit code before any pipe: - 108 exited 0 on the first pass. - 6 first exited 3, PREREQUISITE NOT MET (no `dist` for lint, formula, client-react or objectql in a fresh worktree): `check:doc-formula-expressions`, `check:doc-security-posture`, `check:skill-examples`, `check:docs-transcript-drift`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`. I built `--filter=@objectstack/lint... --filter=@objectstack/client-react... --filter=@objectstack/objectql...` (turbo, 37 tasks) and re-ran them; all 6 then exited 0 with measured verdicts. For example, `✓ check:dual-build-cjs-loads — 112 published require entry point(s) across 69 package(s) load` and `✅ 262 prose examples type-check across 3 surface(s)`. - **Reconciliation:** `dispatch-gates --ran` answers `✓ dispatch-gates --ran: 114 derived famil(ies) accounted for — 114 run, 0 NOT-MEASURED (a DERIVED zero — all 114 recorded an exit code and none of them is 3)`. - `pnpm --filter @objectstack/spec check:generated`: `✓ All 14 generated artifacts are up to date`. This needed `gen:docs` once, for `references/automation/approval.mdx`. - `check:liveness`: exit 0 with the key in place. With the planted row it exited 1 (see Measured). - Local-only family: `pnpm check:dev-prereqs` exit 0, `@objectstack/spec … built from the sources on disk`. - **Not run locally, CI's:** `test:repo` (the diff touches no `repo`-project test), and the repo-wide scans led by `pnpm lint`. CI check-runs on this PR are the record for every family CI runs. - **Dogfood:** n/a. The title carries no `!` and `Clause-②` is `(widening)`. ## Acceptance notes - **Not in this PR (#25573):** the runtime. That means removing the submitter, the empty-slate route or escalation, and the route note on the approval record. When it lands, it removes the describe's `[PLANNED — not yet enforced]` marker and last sentence, and rewrites the `flow.json` `_note` sentence. - **The hand-written Approvals guide is not updated.** Documenting a key the engine does not read yet would advertise a capability it does not deliver. The guide follows the runtime. - **Window between the two merges.** Before this PR, `excludeSubmitter: true` is refused loudly at every door. After it, and until #25573 lands, the key parses and is carried unread, and the describe is the only thing that tells the author. A release cut in that window ships a segregation-of-duties key that does nothing. That is the order the ruling chose ("spec first, then `plugin-approvals`"); I record it here so release timing can take it into account. - **Open question for #25573 (no spec change made here): `onEmptyApprovers: 'auto_approve'`.** The ruling says an emptied set takes the node's `onEmptyApprovers` route and is "never a silent pass". But under `auto_approve` that route is exactly a silent pass. The existing describe says it "silently waves the record through". If the submitter is the only resolved approver, the exclusion empties the slate and `auto_approve` would approve the submitter's own request. Two shapes resolve it: - (A) the spec refuses `excludeSubmitter: true` beside `onEmptyApprovers: 'auto_approve'`, in the style of `checkFallbackApproversPairing`; - (B) the runtime routes an exclusion-emptied slate to escalation instead of auto-approving. - (A) is a narrowing once a release carries the key, so it costs least before then. The describe states the ruled invariant ("never passes silently") and commits to neither. - **Also for #25573:** does the exclusion also screen the `fallbackApprovers` slate and the `admin_rescue` administrators when the submitter is among them? The ruling names only "the resolved approver set". The describe says the slate `approvers` resolves to. - `os lint`'s `approval-approvers-may-resolve-empty` may want to count `excludeSubmitter: true` on a single-person slate (for example, one `field` approver) as able to resolve empty. That is #25573's or the lint lane's call. --- _Generated by [Claude Code](https://claude.ai/code/session_01PC4NPXPgPmpDRG9DdDnyXT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 451bf61 commit 49653f9

6 files changed

Lines changed: 259 additions & 1 deletion

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): an approval step's `excludeSubmitter` option, default off — declared ahead of the approval engine that honours it
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
10+
An approval node's `config` accepts `excludeSubmitter: boolean`. Each approval step is an approval node, so this is the step-level option: when `true`, the request's submitter (the user whose action started the flow run, recorded on the approval request as `submitter_id`) is to be removed from the slate the node's `approvers` resolve to, whatever approver type produced them, so a manager who submits their own request cannot approve it. If that leaves nobody, the request takes the node's `onEmptyApprovers` route and never passes silently, and the approval record shows that the exclusion changed the route.
11+
12+
- **Declared, not yet honoured.** This release carries the protocol half only. The approval engine parses the key with the rest of the node config and does not read it yet, so the submitter is NOT removed today. The key's describe opens with `[PLANNED — not yet enforced]` and says so. Keep any guard you rely on (an `expression` approver or a validation rule) until the approval engine honours the key.
13+
- **Off by default, and absent when off.** The key is optional with no default: a node that does not author it parses to exactly the output it did before, so stored approval nodes and the config snapshot each request keeps are unchanged.
14+
- **Step-level only.** It is one switch per node. Written on an approver row, on the flow node beside `config`, or on the flow, it is still refused as an unknown key.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +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. |
7273
| **behavior** | `Enum<'first_response' \| 'unanimous' \| 'quorum' \| 'per_group'>` | optional (default: `"first_response"`) | How to combine multiple approvers |
7374
| **minApprovals** | `integer` | optional | Approvals required — total (quorum) or per group (per_group). Omitted ⇒ all resolvable approvers for quorum, 1 per group for per_group |
7475
| **lockRecord** | `boolean` | optional (default: `true`) | Lock the record from editing while pending |

‎packages/spec/authorable-surface/automation.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"automation/ApprovalNodeConfig:behavior",
3737
"automation/ApprovalNodeConfig:decisionOutputs",
3838
"automation/ApprovalNodeConfig:escalation",
39+
"automation/ApprovalNodeConfig:excludeSubmitter",
3940
"automation/ApprovalNodeConfig:fallbackApprovers",
4041
"automation/ApprovalNodeConfig:lockRecord",
4142
"automation/ApprovalNodeConfig:maxRevisions",

‎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.",
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.",
44
"props": {
55
"name": {
66
"status": "live",
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25609] An approval step's `excludeSubmitter` option, default off — the
5+
* protocol half of #25573 (ruling 6377731715 on #25496, letter B for #25446).
6+
* ADR-0019 made each approval STEP an approval NODE, so the step-level option
7+
* is a key on `ApprovalNodeConfigSchema`.
8+
*
9+
* ## What is pinned, and why each half
10+
*
11+
* - §1 THE KEY PARSES on the node config, `true` and `false`, the whole config
12+
* green beside a `field` and a `position` approver (the two types the ruling
13+
* names; the key is one switch for the whole slate, never per type). Omitted,
14+
* it stays ABSENT after the parse — no `false` materializes — so a node that
15+
* does not author it parses to the output it parsed to before the key
16+
* existed. A non-boolean is refused at the key's own path.
17+
* - §2 THE DOOR AUTHORS MEET: `FlowSchema` judges an approval node's `config`
18+
* against this contract whole, so the key is accepted inside a flow too —
19+
* the parse `os validate`, `compile` and the metadata save door run.
20+
* - §3 STEP-LEVEL ONLY: the same key written anywhere but the node config — on
21+
* an approver row, on the flow node beside `config`, on the flow — is still
22+
* refused, each at its own path. These are the controls that prove the
23+
* widening opened one coordinate and nothing else.
24+
* - §4 THE DESIGNER'S SCHEMA: the published JSON schema carries the key as a
25+
* plain optional boolean with no `default`, under the same
26+
* `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
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.
32+
*/
33+
34+
import { describe, expect, it } from 'vitest';
35+
import type { z } from 'zod';
36+
37+
import {
38+
ApprovalNodeApproverSchema,
39+
ApprovalNodeConfigSchema,
40+
getApprovalNodeConfigJsonSchema,
41+
} from './approval.zod';
42+
import { FlowSchema } from './flow.zod';
43+
44+
type Door = { safeParse: (v: unknown) => z.ZodSafeParseResult<unknown> };
45+
46+
/** The issue codes and paths a refusal carries, so a refusal for the WRONG reason reds. */
47+
function issues(door: Door, value: unknown): { code: string; path: string }[] {
48+
const result = door.safeParse(value);
49+
if (result.success) return [];
50+
return result.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') }));
51+
}
52+
53+
/** A category manager on the record, then a procurement director — the card's two-level shape. */
54+
const SLATE = [
55+
{ type: 'field', value: 'category_manager_id' },
56+
{ type: 'position', value: 'procurement_director' },
57+
];
58+
const BASE = { approvers: SLATE };
59+
60+
/** start → the approval node → approve / reject ends; `node` and `top` spread where a key is planted. */
61+
function flowWith(node: Record<string, unknown>, top: Record<string, unknown> = {}) {
62+
return {
63+
name: 'supplier_admission',
64+
label: 'Supplier admission',
65+
type: 'autolaunched',
66+
...top,
67+
nodes: [
68+
{ id: 'start', type: 'start', label: 'Start' },
69+
{ id: 'gate', type: 'approval', label: 'Category manager', ...node },
70+
{ id: 'approved', type: 'end', label: 'Approved' },
71+
{ id: 'rejected', type: 'end', label: 'Rejected' },
72+
],
73+
edges: [
74+
{ id: 'e1', source: 'start', target: 'gate' },
75+
{ id: 'e2', source: 'gate', target: 'approved', label: 'approve' },
76+
{ id: 'e3', source: 'gate', target: 'rejected', label: 'reject' },
77+
],
78+
};
79+
}
80+
81+
// ───────────────────────────────────────────────────────────────────────────
82+
// §1 the key parses on the node config
83+
// ───────────────────────────────────────────────────────────────────────────
84+
85+
describe('§1 excludeSubmitter parses on the approval node config, absent when off', () => {
86+
it.each([true, false])('excludeSubmitter: %s parses, whole config green, and is carried through', (value) => {
87+
const result = ApprovalNodeConfigSchema.safeParse({ ...BASE, excludeSubmitter: value });
88+
expect(issues(ApprovalNodeConfigSchema, { ...BASE, excludeSubmitter: value })).toEqual([]);
89+
expect(result.success && result.data.excludeSubmitter).toBe(value);
90+
});
91+
92+
it('parses beside the empty-slate fallback it hands an emptied slate to', () => {
93+
const config = {
94+
...BASE,
95+
excludeSubmitter: true,
96+
onEmptyApprovers: 'fallback',
97+
fallbackApprovers: [{ type: 'org_membership_level', value: 'owner' }],
98+
};
99+
expect(issues(ApprovalNodeConfigSchema, config)).toEqual([]);
100+
});
101+
102+
it('omitted, stays absent after the parse: no default materializes', () => {
103+
const parsed = ApprovalNodeConfigSchema.parse(BASE);
104+
expect(Object.prototype.hasOwnProperty.call(parsed, 'excludeSubmitter')).toBe(false);
105+
});
106+
107+
it.each(['yes', 1, null])('refuses the non-boolean %j at the key\'s own path', (value) => {
108+
expect(issues(ApprovalNodeConfigSchema, { ...BASE, excludeSubmitter: value })).toEqual([
109+
{ code: 'invalid_type', path: 'excludeSubmitter' },
110+
]);
111+
});
112+
});
113+
114+
// ───────────────────────────────────────────────────────────────────────────
115+
// §2 the flow door
116+
// ───────────────────────────────────────────────────────────────────────────
117+
118+
describe('§2 FlowSchema accepts it inside an approval node config', () => {
119+
it('a flow whose approval node sets excludeSubmitter: true parses whole', () => {
120+
expect(issues(FlowSchema, flowWith({ config: { ...BASE, excludeSubmitter: true } }))).toEqual([]);
121+
});
122+
123+
it('control: the same flow without the key parses too', () => {
124+
expect(issues(FlowSchema, flowWith({ config: BASE }))).toEqual([]);
125+
});
126+
});
127+
128+
// ───────────────────────────────────────────────────────────────────────────
129+
// §3 step-level only — every other coordinate still refuses it
130+
// ───────────────────────────────────────────────────────────────────────────
131+
132+
describe('§3 the key is step-level only: written anywhere else it is refused', () => {
133+
it('on an approver row: the closed approver shape refuses it', () => {
134+
expect(issues(ApprovalNodeApproverSchema, { ...SLATE[0], excludeSubmitter: true })).toEqual([
135+
{ code: 'unrecognized_keys', path: '' },
136+
]);
137+
});
138+
139+
it('on an approver row inside a flow: refused at that row\'s path', () => {
140+
const config = { approvers: [{ ...SLATE[0], excludeSubmitter: true }, SLATE[1]] };
141+
expect(issues(FlowSchema, flowWith({ config }))).toEqual([
142+
{ code: 'custom', path: 'nodes.1.config.approvers.0.excludeSubmitter' },
143+
]);
144+
});
145+
146+
it('on the flow node beside config: refused at the node', () => {
147+
expect(issues(FlowSchema, flowWith({ config: BASE, excludeSubmitter: true }))).toEqual([
148+
{ code: 'unrecognized_keys', path: 'nodes.1' },
149+
]);
150+
});
151+
152+
it('on the flow itself (no flow-level default was ruled): refused at the root', () => {
153+
expect(issues(FlowSchema, flowWith({ config: BASE }, { excludeSubmitter: true }))).toEqual([
154+
{ code: 'unrecognized_keys', path: '' },
155+
]);
156+
});
157+
});
158+
159+
// ───────────────────────────────────────────────────────────────────────────
160+
// §4 the JSON schema the designer and registerFlow() read
161+
// ───────────────────────────────────────────────────────────────────────────
162+
163+
describe('§4 the published JSON schema carries it as an optional boolean with no default', () => {
164+
it('is a plain boolean property, not required, with no default, and the shape stays closed', () => {
165+
const js = getApprovalNodeConfigJsonSchema() as {
166+
properties: Record<string, Record<string, unknown>>;
167+
required?: string[];
168+
additionalProperties?: unknown;
169+
};
170+
expect(js.properties.excludeSubmitter?.type).toBe('boolean');
171+
expect(js.properties.excludeSubmitter).not.toHaveProperty('default');
172+
expect(js.required ?? []).not.toContain('excludeSubmitter');
173+
expect(js.additionalProperties).toBe(false);
174+
});
175+
});
176+
177+
// ───────────────────────────────────────────────────────────────────────────
178+
// §5 the status the describe declares
179+
// ───────────────────────────────────────────────────────────────────────────
180+
181+
describe('§5 the describe declares the key planned and names its two subjects', () => {
182+
// Read inside each test, never at collection: a key that went missing then
183+
// reds these two tests instead of erroring the file and hiding §1–§4.
184+
const doc = (): string => {
185+
const member = (ApprovalNodeConfigSchema.shape as Record<string, { description?: string } | undefined>)
186+
.excludeSubmitter;
187+
return member?.description ?? '';
188+
};
189+
190+
it('opens with the planned marker: declared, not yet read by the approval engine', () => {
191+
expect(doc().startsWith('[PLANNED')).toBe(true);
192+
});
193+
194+
it('names who is removed (submitter_id) and where an emptied slate goes (onEmptyApprovers)', () => {
195+
expect(doc()).toContain('`submitter_id`');
196+
expect(doc()).toContain('`onEmptyApprovers`');
197+
});
198+
});

0 commit comments

Comments
 (0)