From a386b10c2db6b3c0cdc117ce032b9f665543a616 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 08:58:46 +0000 Subject: [PATCH 1/3] docs(spec): state minApprovals' per-behavior default in the schema prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 --- packages/spec/src/automation/approval.test.ts | 20 +++++++++++++++++++ packages/spec/src/automation/approval.zod.ts | 11 +++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/automation/approval.test.ts b/packages/spec/src/automation/approval.test.ts index 8df010cca3..fe1b4375c2 100644 --- a/packages/spec/src/automation/approval.test.ts +++ b/packages/spec/src/automation/approval.test.ts @@ -296,6 +296,26 @@ describe('ApprovalNodeConfigSchema', () => { expect(perGroup.behavior).toBe('per_group'); expect(perGroup.approvers[0].group).toBe('legal'); }); + + // The schema injects NO default for `minApprovals`: omitting it leaves the + // key undefined and the enforced threshold is then chosen by `behavior` — + // every resolvable approver under `quorum`, one per group under `per_group`. + // The `.describe()` prose is the only place an author can read that, so pin + // both halves together: a threshold silently declared as 1 while the runtime + // demands unanimity is the drift this test exists to catch. + it('declares the per-behavior default of an omitted minApprovals', () => { + const quorum = ApprovalNodeConfigSchema.parse({ ...minimal, behavior: 'quorum' }); + expect(quorum.minApprovals).toBeUndefined(); + const perGroup = ApprovalNodeConfigSchema.parse({ ...minimal, behavior: 'per_group' }); + expect(perGroup.minApprovals).toBeUndefined(); + + const doc = ApprovalNodeConfigSchema.shape.minApprovals.description ?? ''; + expect(doc).toContain('quorum'); + expect(doc).toContain('per_group'); + expect(doc).toContain('all resolvable approvers for quorum'); + expect(doc).toContain('1 per group for per_group'); + expect(doc).not.toContain('Default 1'); + }); }); describe('ApprovalEscalationSchema', () => { diff --git a/packages/spec/src/automation/approval.zod.ts b/packages/spec/src/automation/approval.zod.ts index 2818df6db1..f0141ced52 100644 --- a/packages/spec/src/automation/approval.zod.ts +++ b/packages/spec/src/automation/approval.zod.ts @@ -711,11 +711,16 @@ export const ApprovalNodeConfigSchema = lazySchema(() => strictObject( /** * Threshold for `quorum` (total approvals required, M of N) and `per_group` - * (approvals required from EACH group). Defaults to 1. Clamped at runtime so - * it can never exceed the resolvable approver count (no deadlock). + * (approvals required from EACH group). Omitted, the threshold follows the + * behaviour rather than a fixed 1: `quorum` requires every resolvable + * approver, `per_group` requires one approval per group. Clamped at runtime + * so it can never exceed the resolvable approver count (no deadlock). */ minApprovals: z.number().int().min(1).optional() - .describe('Approvals required — total (quorum) or per group (per_group). Default 1'), + .describe( + 'Approvals required — total (quorum) or per group (per_group). ' + + 'Omitted ⇒ all resolvable approvers for quorum, 1 per group for per_group', + ), /** Lock the triggering record from edits while this node is pending. */ lockRecord: z.boolean().default(true).describe('Lock the record from editing while pending'), From cebff4ffe25ba1a104f5a906b93a1a229285d111 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 09:07:02 +0000 Subject: [PATCH 2/3] docs(spec): regenerate the approval reference table and add the changeset Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 --- .changeset/min-approvals-default-prose.md | 15 +++++++++++++++ content/docs/references/automation/approval.mdx | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 .changeset/min-approvals-default-prose.md diff --git a/.changeset/min-approvals-default-prose.md b/.changeset/min-approvals-default-prose.md new file mode 100644 index 0000000000..fb76a9bec8 --- /dev/null +++ b/.changeset/min-approvals-default-prose.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': patch +--- + +Approval nodes: `minApprovals` now documents its real per-behavior default. + +The property described itself as "Default 1", but an omitted threshold has never +meant 1 under `behavior: 'quorum'` — the approval runtime falls back to the +resolvable approver count, so a quorum node authored without `minApprovals` +requires **every** approver rather than one. Under `behavior: 'per_group'` the +fallback really is one approval per group. + +The schema text and the generated reference table now state both defaults. This +is a documentation correction only: no schema default was added, the accepted +value set is unchanged, and no stored approval flow changes behavior. diff --git a/content/docs/references/automation/approval.mdx b/content/docs/references/automation/approval.mdx index 7946a3989e..4cd0e62d68 100644 --- a/content/docs/references/automation/approval.mdx +++ b/content/docs/references/automation/approval.mdx @@ -69,7 +69,7 @@ const result = ApprovalDecision.parse(data); | :--- | :--- | :--- | :--- | | **approvers** | `{ type: Enum<'manager' \| 'position' \| 'department' \| 'team' \| 'field' \| 'expression' \| …>; value?: string; resolveAs?: Enum<'user' \| 'department' \| 'position' \| 'team'>; group?: string; … }[]` | ✅ | Allowed approvers for this node | | **behavior** | `Enum<'first_response' \| 'unanimous' \| 'quorum' \| 'per_group'>` | optional (default: `"first_response"`) | How to combine multiple approvers | -| **minApprovals** | `integer` | optional | Approvals required — total (quorum) or per group (per_group). Default 1 | +| **minApprovals** | `integer` | optional | Approvals required — total (quorum) or per group (per_group). Omitted ⇒ all resolvable approvers for quorum, 1 per group for per_group | | **lockRecord** | `boolean` | optional (default: `true`) | Lock the record from editing while pending | | **approvalStatusField** | `string` | optional | Business-object field to mirror request status onto | | **onEmptyApprovers** | `Enum<'admin_rescue' \| 'fail' \| 'auto_approve'>` | optional (default: `"admin_rescue"`) | Behavior when no concrete approver resolves at node entry | From f1aceed03d2fb789fcb7d753f3ce18913571cbbc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 09:54:53 +0000 Subject: [PATCH 3/3] chore(spec): classify minApprovals' omitted threshold in the empty-state registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 --- .../spec/scripts/liveness/empty-state-registry.mts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/spec/scripts/liveness/empty-state-registry.mts b/packages/spec/scripts/liveness/empty-state-registry.mts index d91a52d948..f303b5f5c5 100644 --- a/packages/spec/scripts/liveness/empty-state-registry.mts +++ b/packages/spec/scripts/liveness/empty-state-registry.mts @@ -117,6 +117,16 @@ export const EMPTY_STATE_REGISTRY: EmptyStateEntry[] = [ '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)', }, + { + file: 'packages/spec/src/automation/approval.zod.ts', + property: 'minApprovals', + semantics: 'closed', + rationale: + "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.", + evidence: + 'packages/plugins/plugin-approvals/src/approval-service.ts (ApprovalService.isApprovalSatisfied — the omitted-threshold fallbacks and their clamp to the resolvable count)', + }, + // ---- Scope selectors --------------------------------------------------- {