You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 4bcc4df
Browse filesBrowse the repository at this point in the historyBrowse 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>
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). -->
|**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. |
73
73
|**behavior**|`Enum<'first_response' \| 'unanimous' \| 'quorum' \| 'per_group'>`| optional (default: `"first_response"`) | How to combine multiple approvers |
74
74
|**minApprovals**|`integer`| optional | Approvals required — total (quorum) or per group (per_group). Omitted ⇒ all resolvable approvers for quorum, 1 per group for per_group |
75
75
|**lockRecord**|`boolean`| optional (default: `true`) | Lock the record from editing while pending |
0 commit comments