Repository navigation
Commit a09d797
docs(automation): add the fourth onEmptyApprovers member (fallback) to the skill config table and the approvals checklist (#18795)
Fixes #18527
Clause-②: no
## What
The approval node's empty-slate policy has four members since PR #18525
(card #17931) landed on `main`, and two carriers still enumerated three.
This PR adds the fourth member — `onEmptyApprovers: 'fallback'` with its
sibling `fallbackApprovers` — to each carrier in that carrier's own
register. Two files, nothing else: `git diff --stat` at `2b1d5784e8` =
`docs/qa/platform-checklist/areas/approvals.json | 15 ++++++++-------`
and `skills/objectstack-automation/SKILL.md | 2 +-` (9 insertions, 8
deletions; the SKILL.md change is one table row edited in place).
## The vocabulary, and where the fourth member is read from
Read on `origin/main` `e5705a8ea` (the merge base of this branch), from
the schema and its tests — not from the card's prose:
- `packages/spec/src/automation/approval.zod.ts:884` —
`onEmptyApprovers: z.enum(['admin_rescue', 'fail', 'auto_approve',
'fallback']).default('admin_rescue')`
- `:886-887` `.describe(` … `"'fallback' opens the request on
fallbackApprovers instead"`
- `:868-869` docblock: `fallback` — open the request on {@link
ApprovalNodeConfig.fallbackApprovers} instead. The only policy that
NAMES people
- `:878-882` docblock: The fallback slate is resolved by the SAME
resolver as `approvers` … A fallback that itself resolves to nobody
degrades to `admin_rescue`
- `:906-907` — `fallbackApprovers:
z.array(ApprovalNodeApproverSchema).min(1).optional().describe("Approvers
the request opens on when onEmptyApprovers is 'fallback'")`
- `:891-892` docblock: Same shape as `approvers`; `:897-898`: Required
iff the policy is `'fallback'`, and refused otherwise — both arms are
enforced by {@link checkFallbackApproversPairing} (the refinement at
`:728-767`)
- `packages/spec/src/automation/approval.test.ts:352`
`describe("ApprovalNodeConfigSchema — onEmptyApprovers: 'fallback' +
fallbackApprovers"` — accepts the pair, refuses `'fallback'` without a
list, refuses an empty list, refuses a list under the default policy.
- Runtime, because the checklist carrier asserts behaviour:
`packages/plugins/plugin-approvals/src/approval-service.ts:2782-2836`
(`openNodeRequest`) — under `'fallback'` the declared list goes through
the same `expandApprovers` as the primary slate; if it lands a concrete
person the request opens on it (`approvers = fallbackApprovers`,
`:2817`) with the warn `resolved to no concrete approver — opening on
the declared fallbackApprovers per onEmptyApprovers: 'fallback'.`
(`:2805-2807`); if it too resolves to nobody the warn is `… AND its
declared fallbackApprovers resolved to nobody either — falling back to
admin_rescue.` (`:2820-2822`) and the `admin_rescue` path follows
(`:2846-2851`).
## Carrier 1 — `skills/objectstack-automation/SKILL.md` :728 (the only
enumerating line)
:635 names the key without enumerating, :653 and :693 are examples that
pick `'fail'`; only the Node Config table row enumerates. Before:
```
| `onEmptyApprovers` | What an EMPTY resolved slate does: `admin_rescue` (default — request opens, only a privileged admin can act via Reassign; never waves through, never kills the run), `fail` (node fails — treat an empty slate as a config bug), `auto_approve` (skip the request, continue down `approve` with `output.autoApproved = true` — opt-in because it silently waves the record through). Declare it explicitly on any node with an `expression` approver (linted) |
```
After (the fourth parenthetical, same register; the tail sentence
unchanged):
```
| `onEmptyApprovers` | What an EMPTY resolved slate does: `admin_rescue` (default — request opens, only a privileged admin can act via Reassign; never waves through, never kills the run), `fail` (node fails — treat an empty slate as a config bug), `auto_approve` (skip the request, continue down `approve` with `output.autoApproved = true` — opt-in because it silently waves the record through), `fallback` (request opens on the sibling `fallbackApprovers` instead — same shape as `approvers`, required by this policy and refused under any other; a fallback that itself resolves to nobody degrades to `admin_rescue`). Declare it explicitly on any node with an `expression` approver (linted) |
```
Every clause of the gloss maps to a schema line above (opens on
`fallbackApprovers` :868; same shape :892; required iff / refused
otherwise :898; degrades to `admin_rescue` :880-881). No new row for
`fallbackApprovers`, no example, no other line.
**Token ratchet** (`node scripts/check-skills-token-ratchet.mjs`,
convention `ceil(utf8 bytes / 4)`): before, on `e5705a8ea`: 「✓
check-skills-token-ratchet: skills/objectstack-automation/SKILL.md is
12511 tokens (ceiling 12768; headroom 257).」 — after, on `2b1d5784e8`:
「✓ check-skills-token-ratchet: skills/objectstack-automation/SKILL.md is
12567 tokens (ceiling 12768; headroom 201).」 (+56 tokens = 224 bytes;
ceiling untouched, no ruling needed). Line readings (the `skills/**`
rule): file 961 → 961 lines; package — all ten `SKILL.md` under
`skills/` — 6145 → 6145 lines. The edited row was already the file's
widest line (512 chars); it is now 700 chars. `check:doc-authoring`,
`check:skill-frame-sync` and `check:skill-compatibility` are green on
it.
## Carrier 2 — `docs/qa/platform-checklist/areas/approvals.json`: the
enumeration exists, and it was edited
Premise correction to the claim (5721118247), not to the card: the
seat's grep found `onEmptyApprovers` only at :580 / :631 (prose,
`'fail'` on a showcase stage) and the `variants` list at :825 (the
escalation action vocabulary). On `e5705a8ea` the file has 15 hits, and
the item `approvals.approver-resolution-matrix` (:1176-:1265, revision 1
dated 2026-08-30, i.e. older than the card) enumerates the members three
times, plus the runtime fork's outcomes once:
- :1207 `steps` — 「empty-slate forks, one scratch flow per policy over
an UNSTAFFED position: onEmptyApprovers absent/admin_rescue → …; 'fail'
→ …; 'auto_approve' → …」
- :1236 acceptance `clause` — 「the empty-slate fork honors the declared
policy: admin_rescue (the default) …; 'fail' …; 'auto_approve' …」 with
its `verify` at :1238
- :1256 `source` — 「(onEmptyApprovers admin_rescue|fail|auto_approve,
default admin_rescue)」
- :1257 `source` — 「(the empty-slate fork: 'empty' = no concrete person,
NO_APPROVERS / autoApproved / admin_rescue warn)」
The escalation `variants` at :825 are untouched (a different
vocabulary). Edits, each in the line's own register:
- :1207 steps — appended 「; 'fallback' (fallbackApprovers naming a
STAFFED target, e.g. org_membership_level 'owner') → the request opens
on the fallback slate — pending_approvers holds the fallback's resolved
ids, no literal slot — and the 'opening on the declared
fallbackApprovers' warn lands; a fallback that itself resolves to nobody
degrades to admin_rescue with its own 'resolved to nobody either' warn」
(both warn fragments are verbatim substrings of
`approval-service.ts:2807` and `:2821`).
- :1236 clause — inserted 「; 'fallback' opens the request on the
declared fallbackApprovers (resolved by the same resolver as approvers)
and warns that the fallback fired — a fallback that resolves to nobody
too degrades to admin_rescue」 before the trailing 「— 'empty' meaning no
CONCRETE person …」.
- :1238 verify — appended 「; the fallback request's pending_approvers
holds the fallback slate, never the abandoned literal slot」 (the
clause's verification half; the other three policies' verify already
names the `admin_rescue` assertion).
- :1256 source — 「admin_rescue|fail|auto_approve|fallback, default
admin_rescue), (fallbackApprovers — same shape as approvers, required
iff 'fallback' and refused under any other policy:
checkFallbackApproversPairing)」.
- :1257 source — 「NO_APPROVERS / autoApproved / fallbackApprovers
re-expansion / admin_rescue warn」.
- The checklist's own change register
(`docs/qa/platform-checklist/README.md:287` 「Change — edit the fields,
bump `revision`, append a `history` entry」; enforced by
`scripts/check-platform-checklist.mjs:2170`): `revision` 1 → 2 and one
`history` row `{ "revision": 2, "date": "2026-09-17", … "ref": "#18527"
}`. The revision-1 history text (「all three onEmptyApprovers forks were
untested」) is a dated record and stays as written.
Not touched: the item's `title`, `knownGaps`, `negative` and the
escalation item. `pnpm check:platform-checklist` → exit 0
(「check-platform-checklist: OK — 15 areas, 264 items (264 active) …
symbol anchors: 577/633 resolved」, same counts as before the edit).
**Reverse check of the register, from the committed head**
(trap-restored, absolute paths): with only the `revision` reverted to 1
on disk (node exact-once replacement; `git diff --numstat` = `1 1`),
`node scripts/check-platform-checklist.mjs` → exit 1: 「✗ approvals.json
· approvals.approver-resolution-matrix: "revision" (1) must equal the
last history entry's revision (2) — a semantic edit bumps both」.
Restored with `git checkout HEAD --
docs/qa/platform-checklist/areas/approvals.json`: `git hash-object` =
HEAD blob `4dd89a76661948c3b1108e35a4a5df92d7a6283a`, `git diff HEAD`
empty, `git status --porcelain` empty, validator exit 0 again.
## Changeset
`skip-changeset`, decided by the repo's own gate — the 「Require a
changeset (or the skip-changeset label)」 step of
`.github/workflows/pr-automation.yml` (:757-:780): route 2 「It releases
nothing (.github/, .claude/, skills/, docs/, content/, …) → apply the
'skip-changeset' label. PREFERRED」, with `skills/**` spelled out at
:771. Measured on this tree: a walk over the 83 tracked `package.json`
manifests finds 0 whose `files[]` names a `skills` path and 0 naming
`docs/qa`/`platform-checklist` (positive control: 70 name `dist`); no
`skills/*/package.json`, no `docs/qa/package.json`. The 14
changeset-triggered families the derivation lists under 「Once a
changeset exists」 do not apply (no changeset written). `node
scripts/check-changeset-fixed.mjs` (roster family) → exit 0.
## Gates (worktree at `2b1d5784e8`)
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` — change set derived from git, 2 paths vs
merge base `e5705a8ea`; 24 commands derived, all 24 run with
redirect-then-`$?` capture; `--ran` reconcile: 「Run reconciliation — 24
derived, 24 run, 0 NOT-MEASURED, 0 UNRUN.」, exit 0. `pnpm --filter
@objectstack/lint run check:doc-formula-expressions` was run after
building `@objectstack/lint...` under `scripts/pm/os-verify-lock.sh`
(「VERDICT command-exit 0 · held the lock 137s · waited 0s」).
```text
node scripts/check-ci-filter-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-doc-route-spelling.mjs --advisory :: exit 0
node scripts/check-doc-route-spelling.mjs --self-test :: exit 0
node scripts/check-skills-token-ratchet.mjs :: exit 0
node scripts/check-skills-token-ratchet.mjs --self-test :: exit 0
pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0
pnpm --filter @objectstack/spec run check:skill-docs :: exit 0
pnpm check:agent-test-spelling :: exit 0
pnpm check:corpus-claim-drift :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:doc-authoring :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:platform-checklist :: exit 0
pnpm check:pm-governed-merges :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:role-word :: exit 0
pnpm check:skill-compatibility :: exit 0
pnpm check:skill-frame-sync :: exit 0
pnpm check:skill-identifier-liveness :: exit 0
pnpm check:watch-hint-literal :: exit 0
```
Beyond the derived set: `pnpm lint` (repo-wide, `eslint .
--no-inline-config`) → exit 0 at `2b1d5784e8`; roster families `node
scripts/check-published-list-mirrors.mjs` → exit 0 and `node
scripts/check-platform-checklist-watchdog.mjs` → exit 0;
control-character self-scan (`grep -naP` over both files) → no match. No
① dependency-closure build and no ② package test are owed — the diff
touches no package.
**Governed**: `node scripts/pm/check-governed-merges.mjs --test
skills/objectstack-automation/SKILL.md
docs/qa/platform-checklist/areas/approvals.json` → exit 3, `skills/**`
×1 hit (the published skills catalog), 「One hit governs the whole PR」.
Served tier: MANDATORY, derived from the `skills/**` surface by
`dispatch-gates.mjs --tier`. Draft at the governed terminal; the seat's
four-piece, the maintainer lands.
## Acceptance notes
- noted, not filed — 承接者: the skills seat: the claim's carrier-2 reading
(three `onEmptyApprovers` hits) undercounted a 15-hit file; the
enumerating item predates the card. Corrected above; nothing to file.
- noted, not filed — 承接者: 无: the `onEmptyApprovers` table row is now 700
characters on one line; the file's table rows are single lines by
construction and no gate reads line width here. Not a defect.
## 维护者速读(草稿)
**改了什么**:审批节点空名单策略 `onEmptyApprovers` 自 PR #18525 起有四个成员,两处仍只列三个。①
`skills/objectstack-automation/SKILL.md` 第 728 行 Node Config
表的该行,按本行既有的括号注释格式补上第四个成员 `fallback`(请求改在兄弟键 `fallbackApprovers` 上开出;与
`approvers` 同形;此策略下必填、其它策略下拒收;备选名单也解析为空时退化为 `admin_rescue`)。②
`docs/qa/platform-checklist/areas/approvals.json` 里
`approvals.approver-resolution-matrix` 这一条的空名单分叉:steps、验收子句及其 verify、两条
source 引用各补上 `fallback` 一腿,并按清单自身规则 revision 1→2、加一条 history。两文件之外一字未动。
**为什么改**:词表是 spec 声明的(`approval.zod.ts:884`),已发布 skill
与测试清单各自枚举它;少列一个成员,AI 作者按 skill 写元数据就不会知道有 `fallback`,测试清单也测不到这条分叉。语义全部取自
schema docblock 与运行时分叉,不取卡面。
**风险与代价(含回滚)**:纯文档/清单改动,不发布任何 npm 内容(`files[]` 实测 0 命中);skills token 棘轮
12511→12567(上限 12768,余量 201),不动上限。回滚 = revert 这一个提交。
**席位意见**:
**你要做的**:确认第 728 行新增括号注释与 schema 语义一致(见上文逐句对照),点合并;怎么验:`node
scripts/check-skills-token-ratchet.mjs` 与 `pnpm
check:platform-checklist` 两条门禁的判定行已在正文引用。
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_
Co-authored-by: Claude <noreply@anthropic.com>1 parent 6427ee2 commit a09d797
2 files changed
Lines changed: 9 additions & 8 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1177 | 1177 | | |
1178 | 1178 | | |
1179 | 1179 | | |
1180 | | - | |
| 1180 | + | |
1181 | 1181 | | |
1182 | 1182 | | |
1183 | 1183 | | |
| |||
1204 | 1204 | | |
1205 | 1205 | | |
1206 | 1206 | | |
1207 | | - | |
| 1207 | + | |
1208 | 1208 | | |
1209 | 1209 | | |
1210 | 1210 | | |
| |||
1233 | 1233 | | |
1234 | 1234 | | |
1235 | 1235 | | |
1236 | | - | |
| 1236 | + | |
1237 | 1237 | | |
1238 | | - | |
| 1238 | + | |
1239 | 1239 | | |
1240 | 1240 | | |
1241 | 1241 | | |
| |||
1253 | 1253 | | |
1254 | 1254 | | |
1255 | 1255 | | |
1256 | | - | |
1257 | | - | |
| 1256 | + | |
| 1257 | + | |
1258 | 1258 | | |
1259 | 1259 | | |
1260 | 1260 | | |
1261 | 1261 | | |
1262 | 1262 | | |
1263 | | - | |
| 1263 | + | |
| 1264 | + | |
1264 | 1265 | | |
1265 | 1266 | | |
1266 | 1267 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
725 | 725 | | |
726 | 726 | | |
727 | 727 | | |
728 | | - | |
| 728 | + | |
729 | 729 | | |
730 | 730 | | |
731 | 731 | | |
| |||
0 commit comments