Skip to content

Commit 3e5ad08

Browse files
claude[bot]claude
andauthored
docs(spec): state minApprovals' real per-behavior default in the schema prose (#14543)
* docs(spec): state minApprovals' per-behavior default in the schema prose `ApprovalNodeConfigSchema.minApprovals` described itself as "Default 1", but an omitted threshold has never meant 1 under `quorum`: the runtime falls back to the resolvable approver count, so a quorum node authored without the key requires EVERY approver, not one. Under `per_group` the fallback really is 1 per group. Converge the declared text onto the enforced behaviour — the prose is the half that drifted, so only the prose moves; no schema default is added and no runtime threshold changes. A pin test asserts the description names both behaviours' defaults and that the schema still injects no default, so the two readings cannot drift apart silently again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 * docs(spec): regenerate the approval reference table and add the changeset Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 * chore(spec): classify minApprovals' omitted threshold in the empty-state registry Making the omitted-threshold contract explicit put a permissive-shaped sentence in front of the empty-state scanner ("Omitted ⇒ all resolvable approvers …"). The gate is right to demand a decision, and the decision is `closed`: an omitted threshold lands on the STRICTEST reading — every resolvable approver under `quorum` — so careless authoring lands on least privilege, not on the widest grant. Registered with the runtime enforcement site as evidence rather than reworded around the scanner. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1f45690 commit 3e5ad08

5 files changed

Lines changed: 54 additions & 4 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
Approval nodes: `minApprovals` now documents its real per-behavior default.
6+
7+
The property described itself as "Default 1", but an omitted threshold has never
8+
meant 1 under `behavior: 'quorum'` — the approval runtime falls back to the
9+
resolvable approver count, so a quorum node authored without `minApprovals`
10+
requires **every** approver rather than one. Under `behavior: 'per_group'` the
11+
fallback really is one approval per group.
12+
13+
The schema text and the generated reference table now state both defaults. This
14+
is a documentation correction only: no schema default was added, the accepted
15+
value set is unchanged, and no stored approval flow changes behavior.

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
| :--- | :--- | :--- | :--- |
7070
| **approvers** | `{ type: Enum<'manager' \| 'position' \| 'department' \| 'team' \| 'field' \| 'expression' \| …>; value?: string; resolveAs?: Enum<'user' \| 'department' \| 'position' \| 'team'>; group?: string; … }[]` || Allowed approvers for this node |
7171
| **behavior** | `Enum<'first_response' \| 'unanimous' \| 'quorum' \| 'per_group'>` | optional (default: `"first_response"`) | How to combine multiple approvers |
72-
| **minApprovals** | `integer` | optional | Approvals required — total (quorum) or per group (per_group). Default 1 |
72+
| **minApprovals** | `integer` | optional | Approvals required — total (quorum) or per group (per_group). Omitted ⇒ all resolvable approvers for quorum, 1 per group for per_group |
7373
| **lockRecord** | `boolean` | optional (default: `true`) | Lock the record from editing while pending |
7474
| **approvalStatusField** | `string` | optional | Business-object field to mirror request status onto |
7575
| **onEmptyApprovers** | `Enum<'admin_rescue' \| 'fail' \| 'auto_approve'>` | optional (default: `"admin_rescue"`) | Behavior when no concrete approver resolves at node entry |

packages/spec/scripts/liveness/empty-state-registry.mts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,16 @@ export const EMPTY_STATE_REGISTRY: EmptyStateEntry[] = [
117117
'packages/core/src/security/resolve-authz-context.ts (resolveAuthzContext.hasPlatformAdminGrant — the single source of truth) and packages/plugins/plugin-security/src/explain-engine.ts (the identical predicate, replicated so the panel cannot overstate)',
118118
},
119119

120+
{
121+
file: 'packages/spec/src/automation/approval.zod.ts',
122+
property: 'minApprovals',
123+
semantics: 'closed',
124+
rationale:
125+
"The approval threshold of a `quorum` / `per_group` node, and a case where the permissive-looking word is the restrictive one. Omitted under `quorum` the runtime requires EVERY resolvable approver — the threshold falls back to the resolved approver count, so a node whose author never wrote the key blocks until all of them sign, the least-privilege direction rather than the widest. Under `per_group` omission means one approval per group, and every group must still reach it. The `all` in the declared text therefore names APPROVERS REQUIRED, never access granted. Registered rather than reworded around the scanner because that sentence is the only place an author learns the omitted threshold is not 1 — which is precisely the drift this classification exists to keep visible.",
126+
evidence:
127+
'packages/plugins/plugin-approvals/src/approval-service.ts (ApprovalService.isApprovalSatisfied — the omitted-threshold fallbacks and their clamp to the resolvable count)',
128+
},
129+
120130
// ---- Scope selectors ---------------------------------------------------
121131

122132
{

packages/spec/src/automation/approval.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,26 @@ describe('ApprovalNodeConfigSchema', () => {
296296
expect(perGroup.behavior).toBe('per_group');
297297
expect(perGroup.approvers[0].group).toBe('legal');
298298
});
299+
300+
// The schema injects NO default for `minApprovals`: omitting it leaves the
301+
// key undefined and the enforced threshold is then chosen by `behavior` —
302+
// every resolvable approver under `quorum`, one per group under `per_group`.
303+
// The `.describe()` prose is the only place an author can read that, so pin
304+
// both halves together: a threshold silently declared as 1 while the runtime
305+
// demands unanimity is the drift this test exists to catch.
306+
it('declares the per-behavior default of an omitted minApprovals', () => {
307+
const quorum = ApprovalNodeConfigSchema.parse({ ...minimal, behavior: 'quorum' });
308+
expect(quorum.minApprovals).toBeUndefined();
309+
const perGroup = ApprovalNodeConfigSchema.parse({ ...minimal, behavior: 'per_group' });
310+
expect(perGroup.minApprovals).toBeUndefined();
311+
312+
const doc = ApprovalNodeConfigSchema.shape.minApprovals.description ?? '';
313+
expect(doc).toContain('quorum');
314+
expect(doc).toContain('per_group');
315+
expect(doc).toContain('all resolvable approvers for quorum');
316+
expect(doc).toContain('1 per group for per_group');
317+
expect(doc).not.toContain('Default 1');
318+
});
299319
});
300320

301321
describe('ApprovalEscalationSchema', () => {

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -721,11 +721,16 @@ export const ApprovalNodeConfigSchema = lazySchema(() => strictObject(
721721

722722
/**
723723
* Threshold for `quorum` (total approvals required, M of N) and `per_group`
724-
* (approvals required from EACH group). Defaults to 1. Clamped at runtime so
725-
* it can never exceed the resolvable approver count (no deadlock).
724+
* (approvals required from EACH group). Omitted, the threshold follows the
725+
* behaviour rather than a fixed 1: `quorum` requires every resolvable
726+
* approver, `per_group` requires one approval per group. Clamped at runtime
727+
* so it can never exceed the resolvable approver count (no deadlock).
726728
*/
727729
minApprovals: z.number().int().min(1).optional()
728-
.describe('Approvals required — total (quorum) or per group (per_group). Default 1'),
730+
.describe(
731+
'Approvals required — total (quorum) or per group (per_group). '
732+
+ 'Omitted ⇒ all resolvable approvers for quorum, 1 per group for per_group',
733+
),
729734

730735
/** Lock the triggering record from edits while this node is pending. */
731736
lockRecord: z.boolean().default(true).describe('Lock the record from editing while pending'),

0 commit comments

Comments
 (0)