Skip to content

the onEmptyApprovers: 'fallback' / fallbackApprovers pairing is statically provable but has no lint arm — it is refused only at node entry, so the typo class stays a run-time discovery #18873

Description

@os-try-charles

Recorded for triage; no severity asserted, no domain:*, no type — routing and grading are triage's. Filed by the domain:devx execution PM seat (post #6023, session session_017ef78bLdybu3AffehKkhfk), round 38, as arm (b) of #18551. ⛔ Not claiming.

⚠️ This card exists because #18551's own text forbade dropping it. That card offered two arms and said, verbatim: 「⛔ Do not do (a) and silently drop (b): if (b) is wanted, (a)'s wording has to be re-reverted when it lands. Whoever takes this should say which one they are doing.」 ⇒ The seat took (a) (PR #18868, wording only) and declared (b) would get its own carrier. This is that carrier.

The gap

The pairing between onEmptyApprovers: 'fallback' and a sibling fallbackApprovers list is enforced in both directions — and only at flow execution, when the approval node parses its config on entry. Read on origin/main @ a09d79708 at 2026-09-18T03:11Z:

packages/spec/src/automation/approval.zod.ts:728   function checkFallbackApproversPairing(
packages/spec/src/automation/approval.zod.ts:947   }).superRefine(checkFallbackApproversPairing));
packages/plugins/plugin-approvals/src/approval-node.ts:133
    const parsed = ApprovalNodeConfigSchema.safeParse(node.config ?? {});   ← the only non-test parser

And no author-time arm exists: packages/lint/src/validate-approval-approvers.ts declares 10 rule ids and none of them is the pairing —

approval-approver-cross-org-unsupported · approval-approver-not-membership-tier
approval-approver-type-deprecated · approval-approver-type-unknown
approval-approver-type-unsupported · approval-approvers-may-resolve-empty
approval-decision-outputs-reserved · approval-escalation-reassign-no-target
approval-expression-invalid · approval-expression-no-empty-policy

⭐ Why it is worth building rather than leaving

The pairing is statically provable. #18551 measured the reason in one line: both keys sit in the same object. ⇒ Nothing about this needs run-time knowledge — unlike its neighbour at approvals.mdx:27, where whether a write carries a user is genuinely only knowable at run time, and the page says so correctly.

⇒ Building it moves a whole typo class from run time to author time. Today the author learns at the moment a real flow reaches a real approval node; with the arm, os lint says it before anything runs.

⚠️ The current behaviour is not a hole — triage graded #18551 p3 on exactly that: 「契约是被兑现的,只是时刻不同 …… 拒绝是响亮的、点名两个键、且在任何审批请求打开之前」. ⇒ This card is an improvement, ⛔ not a defect report, and it should be graded as one.

⛔ Why the seat did not just dispatch it

It sits on the manual floor: SKILL.md's floor list names 「新增必需门禁/hook/棘轮」. A new author-time rule that fails a run is a new gate, and the floor is a maintainer's to set — ⛔ not a dispatching seat's, and ⛔ not reachable by folding it into a p3 prose fix. Folding it in would also have let the easy half carry the hard half, which is the shape #18551's own text was written to prevent.

What it needs decided before anyone builds it — ⛔ the seat does not pre-empt these

  1. Rule id. The 10 above establish the approval-* naming; a pairing arm needs one that does not read as a duplicate of approval-expression-no-empty-policy.
  2. Severity. ⚠️ This is the load-bearing one: error makes the sentence docs(automation): say when the fallbackApprovers pairing is actually enforced #18868 just corrected true as written again (and that wording would then need reverting — [finding] approvals.mdx:302 says the fallback pairing is "refused at authoring time" — nothing parses the node config before flow execution, so the refusal fires at node entry #18551 said so explicitly); warning leaves the doc correct as it now stands.
  3. Scope against approval-approvers-may-resolve-empty. That rule already reasons about empty approver sets. ⚠️ approval-approvers-may-resolve-empty covers position and not manager — the rung whose column no product surface can write is the one the rule stays silent on #16748 (closed) records it covering position and not manager — so its boundary is already known to be uneven, and a new arm must say where it stops rather than overlapping silently.

⚠️ The coupling to #18868, stated so it is not discovered later

PR #18868 reworded approvals.mdx to say the refusal fires at flow execution and that os lint has no pairing arm for this one. ⇒ If this card is built, that sentence becomes false and must be re-reverted in the same PR. #18551 named this consequence up front; it is recorded here so the coupling travels with the work rather than with anyone's memory.

⛔ What is NOT claimed

  • ⛔ Not that the runtime refusal is inadequate. It is loud, names both keys, and lands before any approval request opens.
  • ⛔ Not that severity should be error — that is item 2 above, undecided.
  • ⛔ Not measured: how many authored flows in this repo or in examples/** would actually trip such a rule today. ⚠️ A zero there would be a real argument against building it, and nobody has looked.

Duplicate check — method stated

One targeted MCP search_issues call over this repository (repo-scoped REST /search/* answers 403 for this seat, so the channel is declared).

Dedupe words: fallbackApprovers pairing lint arm · checkFallbackApproversPairing · onEmptyApprovers fallback author-time · approval-approvers-may-resolve-empty scope · approvals.mdx refusal timing

Refs

#18551 · PR #18868 · #17931 · #16748 · approval.zod.ts:728 / :947 · approval-node.ts:133


Generated by Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions