Repository navigation
spec: approval onEmptyApprovers gains 'fallback' with a sibling fallbackApprovers at the node level — the empty { type: 'manager' } rung becomes survivable (from #16678 Phase 2 §8.2, ruled) #17931
Description
Activity
- addedenhancementNew feature or requestNew feature or requestpriority:p2Medium: important, M3Medium: important, M3
on Sep 13, 2026 Claim: PM loop,
domain:specexecution seat
Session:session_01KB5PFtxuy1x3dcR5gxudx6
Branch:claude/issue-17931-approval-onemptyapprovers-fallback
Worktree:objectstack-issue-17931
Domain:domain:spec
File surface:packages/spec/src/automation/approval.zod.ts(+ its pins),packages/plugins/plugin-approvals/**(theopenNodeRequestempty-slate block and its tests),packages/lint/src/validate-approval-approvers.ts,content/docs/automation/approvals.mdx, the generated reference/authorable-surface artifacts that follow, and.changeset/. Stop on breach; explain in the report.
Container & model:M,mode:subagent,model: claude-opus-5— the dispatch tier, measured this shift by transcript census on this seat'sos-devruns (138 of 138"model":"claude-opus-5"on the most recent), ⛔ not from a self-report.
Clause-②: yes
Thread-read: none
Serial constraints cleared: none, and this is the cross-domain exception path — the card's landing point ispackages/spec(this lane) but the change reachesplugin-approvals(services) andpackages/lint(devx). Targeted in-flight check run against all 17 open PRs'/files, enumerated 2026-09-16T18:48:40Z:packages/spec/src/automation/approval.zod.ts— 0 PRs;packages/plugins/plugin-approvals— 0 PRs;packages/lint/src/validate-approval-approvers.ts— 0 PRs (the lint files in flight aredata-model-rules.ts,object-graph.ts,validate-security-posture.ts,validate-flow-trigger-readiness.tsand six*.test.ts, none of them this one);content/docs/automation/— onlyflows.mdx(PR #18420), notapprovals.mdx. No intersection.Premises measured on
origin/mainfb6b2c369e, 2026-09-16T18:51:25Z — ⛔ not restated from the cardreading value today's enum approval.zod.ts:794—onEmptyApprovers: z.enum(['admin_rescue', 'fail', 'auto_approve']).default('admin_rescue'), exactly the three the card namesthe parent ruling #16678 closed / completed; #3807 closed / completed; PR #17575 closed action surface git log -6 origin/main -- packages/spec/src/automation/approval.zod.ts— last touch2eb4724182(#17640), nothing pending on this file⭐ One card premise is FALSE as spelled, and the dev inherits the correction, not the card's wording. The card says the sibling key reuses "
ApproverEntrySchema, ⛔ not a new shape". There is noApproverEntrySchemainpackages/spec/src—git grep -n "export const [A-Za-z]*Approver[A-Za-z]*Schema"overpackages/spec/srcreturns exactly one line,packages/spec/src/automation/approval.zod.ts:416: export const ApprovalNodeApproverSchema, and the dark controlexport const ZzzApproverNoSuchSchemaexits 1, so the grep discriminates. The intent stands and is binding — reuse the existing approver-entry shape, ⛔ do not invent a second one — but the spelling to reuse isApprovalNodeApproverSchema. Verify that yourself before you write the key, and say in the report which shape you bound to and why.Everything else in the card's §"Measured facts the implementer inherits" was measured by the services seat, ⛔ not by this seat — treat each line as a premise to falsify on the tree, starting with the
manager:undefinedliteral and the #3807 pin table row.
Generated by Claude Code
os-dev-report
{ "issue": 17931, "status": "done", "branch": "claude/issue-17931-approval-onemptyapprovers-fallback", "pr": "https://github.com/objectstack-ai/objectstack/pull/18525", "session": "session_01KB5PFtxuy1x3dcR5gxudx6 — this run's harness-stamped id", "premise_still_valid": true, "summary": "onEmptyApprovers gains a fourth member 'fallback' with a sibling fallbackApprovers, at the node. I bound the sibling key to ApprovalNodeApproverSchema, re-measured: 'git grep \"export const [A-Za-z]*Approver[A-Za-z]*Schema\" origin/main -- packages/spec/src' returns exactly one line (approval.zod.ts:416), the dark control 'export const ZzzApproverNoSuchSchema' exits 1, and a whole-repo grep for ApproverEntrySchema exits 1 — the card's spelling does not exist, the intent does, so the existing approver entry shape is reused and a pin proves it is not a second dialect. Pairing is refined in BOTH directions (required iff 'fallback'; refused under any other policy, which is the arm that stops a declared-but-never-read list shipping), each message naming both keys; a superRefine rather than a union so the published JSON-Schema def stays one def and .shape / additionalProperties:false survive. openNodeRequest resolves fallbackApprovers through the SAME expandApprovers call as the primary slate and opens on those ids, never on the manager:undefined literal; a fallback that itself resolves to nobody, or the policy declared with no list, degrades to admin_rescue. The lint rule gained the escape in MANAGER_ONLY_ROUTES and keeps firing on shape with a fallback declared (stackWiresManagerChain stays the one silencer); the approvals guide carries the same prose. Inherited premises re-measured: manager IS in GRAPH_APPROVER_TYPES but at approval-service.ts:860-862 (card said 831-833) and the 'expanded to nobody' warning is at :1779 (card said 1730) — facts hold, line numbers drifted; the #3807 pin table did omit manager and now has the row, pinning the manager:undefined literal itself. One repair this change forced: ApproverType's .describe() still said manager_id 'has no product write surface', and the generated reference renders that string once per shape reusing the approver entry, so fallbackApprovers would have shipped a third copy — corrected (4 lines out, 0 in) and pinned. assignee was already set to os-warren by the seat; I wrote none. needs:contract-review is the seat's on both carriers, so I attached nothing — reported below as a reading.", "tests": "ALL FOREGROUND. Heavy runs through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=issue-17931; verdicts read from the printed 'VERDICT command-exit' line, never a bare $?. Exit codes captured by redirect-then-$?, never through a pipe. Head measured on: 6d3f5a7946. PACKAGE RUNS: spec test 483 files / 13791 passed exit 0; spec typecheck exit 0; lint test 103 files / 3848 passed exit 0; lint typecheck exit 0; plugin-approvals test 46 files / 764 passed exit 0; plugin-approvals typecheck exit 0. DEPENDENCY CLOSURE: pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-approvals^...' build — VERDICT command-exit 0 (needed: the first plugin run failed on 'Failed to resolve entry for package @objectstack/formula', a PREREQUISITE NOT MET, not a red test). GENERATED ARTIFACTS: pnpm --filter @objectstack/spec check:generated exit 0, 15 of 15 current (gen:docs was the one it proved stale, twice; authorable-surface/automation.json gained exactly one line, authorable-surface.base.json untouched). ESLINT: pnpm lint (eslint . --no-inline-config, whole repo) exit 0 in under 540s — the repo-level scan, so no narrowing argument is needed; targeted eslint --format json over the 6 changed TS files also reports 6 files, 0 errors, 0 warnings. DERIVED GATES: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 112 commands; ALL 112 RUN; 107 exit 0. The 5 non-zero, each a reading and none a verdict on this diff: check:dual-build-cjs-loads exit 3, check:i18n exit 3, check:type-check-debt exit 3 — all three print PREREQUISITE NOT MET and name the full build they need = NOT MEASURED; check:skill-examples exit 1 — same class in substance, it refuses because packages/client-react/dist holds no declarations = NOT MEASURED; check:cross-package-test-inputs exit 1 — REPRODUCED ON A PRISTINE MERGE-BASE TREE with zero of this branch's changes (detached worktree at fb6b2c369e: exit 0 with no packages/spec/dist on disk, exit 1 with it copied in, identical finding), so it is pre-existing and filed below. The tool flagged its own derivation tree as 4-5 commits behind origin/main over exactly one file, scripts/pm/check-half-states.mjs; I fetched origin/main and re-derived — the 112-command set is byte-identical (diff of the two sorted command lists is empty) and check-half-states appears 0 times in the runnable list, so the staleness does not move this answer. ABLATION — 3 legs, each: mutate the COMMITTED implementation, prove it reached disk (removed marker grep -c to 0, injected marker grep -c to 1, plus git diff --numstat), run, restore with 'git checkout HEAD -- path', prove the restore by git hash-object vs the HEAD blob (empty hash treated as FAILURE), re-run. a bash trap on EXIT INT TERM calling the restore function with absolute paths guarded the whole sequence; working tree clean afterwards. (1) spec, drop .superRefine(checkFallbackApproversPairing): 5 failed / 58 passed exit 1 then restored 63 passed exit 0. (2) plugin, 'approvers = fallbackApprovers' made a no-op: 2 failed / 306 passed exit 1 then restored 308 passed exit 0. (3) lint, drop the new escape from MANAGER_ONLY_ROUTES: 1 failed / 46 passed exit 1 then restored 47 passed exit 0. No dist preflight was owed on any leg: all three test files import their subject by an in-package relative specifier, so vitest transforms source and dist is not on the resolution path. A REVERSE-VERIFICATION FINDING ABOUT MY OWN CODE, not a template outcome: my first refinement tried to say 'the key is omitted' by testing onEmptyApprovers === undefined. The pin went red, correctly — Zod applies .default() BEFORE a refinement runs, so an omitted key and an explicit 'admin_rescue' are indistinguishable there and that branch was dead code. The message now names both readings on the default value instead of guessing. CI on 6d3f5a7946, latest run per check NAME, read AFTER the last push: 10 in_progress, 1 skipped (Packed-tarball smoke, opt-in), 0 concluded — the run had just started. IN_PROGRESS, not green. I did not sleep or poll for it. (An earlier reading on fa9ba24043, before the final commit was pushed, had 7 success / 22 in_progress / 2 skipped / 1 queued and is superseded.)", "mcp_calls": "0 — no MCP GitHub tool was called (no read tool, no write tool); every GitHub read and write went through the REST proxy with curl", "api_writes": "2 REST proxy writes: POST /repos/objectstack-ai/objectstack/pulls (draft PR 18525), POST /repos/objectstack-ai/objectstack/issues/17931/comments (this report). 0 label writes — needs:contract-review is the seat's on both carriers (.claude/agents/os-dev.md on origin/main, L287: '归席位,不挂不摘不等'), verified by reading that blob, not the stale in-container copy. READINGS instead: PR 18525 labels = [] (bare), card 17931 labels = [enhancement, priority:p2, pm:dispatched, domain:spec, needs:contract-review]; node scripts/pm/check-clause2-carriers.mjs --pair 18525 exit 4 (C1: card carries the gate, PR does not — the pair is split on the PR side, which is the half the seat closes). Plus 5 git pushes on claude/issue-17931-..., which are not REST writes: the first was the empty-branch routing probe and it returned no 403.", "open_questions": [], "out_of_scope_findings": [ "to file (reproducible defect, dedupe words: cross-package-test-inputs / init-created-files-summary / packages/spec/dist / declared glob / 7802): check:cross-package-test-inputs fails on main as soon as packages/spec/dist/ exists on disk. Control on a detached worktree at the merge base fb6b2c369e, zero source changes: no spec dist then exit 0; spec dist copied in then exit 1 with 'packages/cli descends a directory tree from packages/spec/dist/ ... rooted in packages/cli/test/init-created-files-summary.e2e.test.ts'. The walk root is a gitignored build directory, so the gate is silent on an unbuilt tree and reports a real undeclared input on a built one — it is quiet exactly where the #7802 blind spot lives.", "to file (declared vocabulary now incomplete, dedupe words: objectstack-automation SKILL onEmptyApprovers / platform-checklist approvals.json / fallbackApprovers / empty-slate policy table): skills/objectstack-automation/SKILL.md line 728 and docs/qa/platform-checklist/areas/approvals.json both enumerate the onEmptyApprovers vocabulary and now list 3 of 4 members. BOTH ARE OUTSIDE THIS CARD'S DECLARED FILE SURFACE and skills/** is a governed surface whose inclusion would change how this PR lands, so I stopped rather than widened. Successor: the skills seat. This one genuinely needs a card — the line-count budget discipline for skills/** is the PM's to set.", "noted, not filed: the lint rule's group-routed arm (position/team/department) now names the node-level policy too, and its 'add a guaranteed-staffed fallback approver' wording was disambiguated to 'approver entry' — 'a fallback approver' had become two things once fallbackApprovers was a real key. Successor: the next editor of validate-approval-approvers.ts. Same rule id, same remedy vocabulary, so it is inside this diff rather than a card.", "noted, not filed: the card's inherited line numbers for approval-service.ts drifted (GRAPH_APPROVER_TYPES 831-833 then 860-862; the expanded-to-nobody warning 1730 then 1779). The facts hold; only the anchors moved. Successor: none — a line number in a card body is not a carrier anyone maintains." ] }
Generated by Claude Code
Round returned
done— collected, and the two things it stopped on are fileddomain:specexecution seat, 2026-09-16T20:1xZ. PR #18525, head the round finished on:6d3f5a7946ed0159944a6ed72a68367326f5d6c6.The card premise this seat corrected, and what the round did with it
The dispatch carried a correction: the card names
ApproverEntrySchema, which does not exist. The round re-measured rather than took it on trust —git grep "export const [A-Za-z]*Approver[A-Za-z]*Schema" origin/main -- packages/spec/srcreturns exactly one line (approval.zod.ts:416), the dark controlexport const ZzzApproverNoSuchSchemaexits 1, and a whole-repo grep forApproverEntrySchemaexits 1. It bound the sibling key toApprovalNodeApproverSchemaand added a pin proving the sibling is not a second dialect. That is the right handling of a corrected premise: verify it, then act.Two more inherited premises drifted and the round said so:
GRAPH_APPROVER_TYPESis atapproval-service.ts:860-862(card said 831-833) and the "expanded to nobody" warning at:1779(card said 1730). The facts hold; only the anchors moved. ⛔ Nothing was changed on the strength of a line number.What landed in the diff
- Pairing refined in BOTH directions —
fallbackApproversrequired iff'fallback', and refused under any other policy. The second arm is the one that stops a declared-but-never-read list from shipping; a one-directional refinement would have let it. - A
superRefinerather than a union, so the published JSON-Schema def stays one def and.shape/additionalProperties: falsesurvive. openNodeRequestresolvesfallbackApproversthrough the sameexpandApproverscall as the primary slate and opens on those ids — ⛔ never themanager:undefinedliteral. A fallback that itself resolves to nobody, or the policy declared with no list, degrades toadmin_rescue.- The lint rule gained the escape in
MANAGER_ONLY_ROUTESand keeps firing on shape;stackWiresManagerChainstays the one silencer. ⛔ The rule was not deleted. - The approvals: a
departmentapprover never resolves when the business unit hasorganization_id = null(every seeded BU) #3807 pin table gained themanagerrow, pinning themanager:undefinedliteral itself. - One repair the change forced:
ApproverType's.describe()still saidmanager_id"has no product write surface", and the generated reference renders that string once per shape reusing the approver entry — sofallbackApproverswould have shipped a third copy of a claim this lane already corrected. Removed (4 lines out, 0 in) and pinned.
⭐ A reverse-verification finding about the round's own code
Its first refinement tried to express "the key is omitted" by testing
onEmptyApprovers === undefined. The pin went red, correctly: Zod applies.default()before a refinement runs, so an omitted key and an explicit'admin_rescue'are indistinguishable there and that branch was dead code. The message now names both readings on the default value instead of guessing. A pin that catches the round's own mistake is the outcome an ablation discipline exists for.Evidence
Three ablations, each mutating the committed implementation, proving the mutation reached disk, running, restoring via
git checkout HEAD --, and proving the restore bygit hash-objectagainst the HEAD blob (an empty hash treated as failure), all under atrap … EXIT INT TERM: (1) drop the pairing refinement → 5 failed, restored 63 passed; (2) make the plugin's fallback assignment a no-op → 2 failed, restored 308 passed; (3) drop the lint escape → 1 failed, restored 47 passed.Package runs all exit 0: spec 483 files / 13791 tests, lint 103 / 3848, plugin-approvals 46 / 764, plus all three typechecks.
check:generated15 of 15 current. Repo-widepnpm lintexit 0 — the whole-repo scan, so no narrowing argument is owed. All 112 derived gate commands run: 107 exit 0; the 5 non-zero are readings, not verdicts — four are PREREQUISITE NOT MET / refuse-to-judge (NOT MEASURED, never green and never red), and the fifth is filed below.Filed from this round
- check:cross-package-test-inputs answers differently on the same commit depending on whether packages/spec was built — silent on an unbuilt tree, exit 1 on a built one #18526 —
check:cross-package-test-inputsanswers differently on the same commit depending on whetherpackages/specwas built: silent on an unbuilt tree, exit 1 on a built one. Reproduced on a pristine merge-base worktree carrying none of this branch's changes, which is what makes it this repo's defect rather than this PR's. - two declared enumerations of the approval
onEmptyApproversvocabulary go to 3 of 4 members when #17931 lands #18527 —skills/objectstack-automation/SKILL.md:728anddocs/qa/platform-checklist/areas/approvals.jsonenumerate theonEmptyApproversvocabulary and go to 3 of 4 members when this lands. Filed withBlocked-by: #17931because fixing it before the member exists would be the wrong enumeration. ⭐ The round stopped rather than widening into a governed surface — the right call, and the line-budget question there is the PM's, not a dev's.
State
needs:contract-reviewis now on both carriers: the card had it from claim time, and the seat hung the PR side at 2026-09-16T20:07:10Z, once the PR existed. The round's own reading of--pair 18525→ exit 4 (C1, split) was taken before that; re-run at 20:10Z it reads exit 0, "both carriers agree".- CI on
6d3f5a7946was in progress when the round reported, and the round said so rather than calling it green — correct. The seat is watching it; an earlierTypeScript Type Checkfailure belongs to the superseded headfa9ba24043. ⚠️ The at-tier review cannot start yet. The contract-review tier is refusing at the account level (HTTP 429) andreferences/contract-review.mdL60 forbids a quota-exhaustion downgrade for review — 「豁免对象是派发,复核 ⛔ 不随派发档位免除」. Labels stay hung, the PR stays draft and out of the queue. Details: Retire check-reference-carrier-shape; refuse an unreadablereferencecarrier at the reader #18503 (comment)
Generated by Claude Code
- Pairing refined in BOTH directions —
CI is now complete on the reviewed head — recorded so the reading is on GitHub, not in a session
Follow-up to the round-close note above, which said CI was in progress and being watched. It has finished.
Head
6d3f5a7946ed0159944a6ed72a68367326f5d6c6, latest run per check NAME, read 2026-09-16T20:35:18Z:- 35 names — 33 success, 2 skipped, 0 failure, 0 cancelled, 0 in progress.
- The 2 skipped are NOT MEASURED, not verdicts.
- The
TypeScript Type Checkfailure that fired earlier belongs to the superseded headfa9ba24043, not to this one.
That settles landing pre-check ③ for this PR as of this reading;
⚠️ it is re-read after the last state write before any enqueue, because a carrier write or a ready flip starts new runs.Landing still waits on pre-check ① — an at-tier PASS record naming this head — and the contract-review tier is refusing at the account level (HTTP 429).
references/contract-review.mdL60 forbids a quota-exhaustion downgrade for review, so the carriers stay hung on both sides and the PR stays draft and out of the queue. ⛔ Nothing about a green CI shortens that: CI is one of three conditions, not a substitute for the other two.
Generated by Claude Code
- added a commit that references this issue
on Sep 17, 2026 github-actions commented
on Sep 17, 2026 on Sep 17, 2026 – with GitHub ActionsContributorMore actionsos-closed-card-sweep — machine-findable marker for this generated comment.
Removed the pm-loop state label(s) this closed card no longer claims:
pm:dispatched.- Closing pull request: feat(spec): approval onEmptyApprovers gains 'fallback' with a sibling fallbackApprovers #18525, merged.
- Closing commit
b0eb9a59c8, merged intomain. - Left untouched:
enhancement,priority:p2,domain:spec— ownership, priority and outcome are not state claims. - The label set was read back after the write and matched.
A state label claims work is in flight. This card is closed on a merged delivery, so the claim
is stale; every other label is left exactly as it was found. Nothing here is a judgement about
the card, and no verdict-bearing label is ever touched by this sweep.posted by half-state-patrol run 35228917608 · trigger
scheduleGenerated by Claude Code
- added a commit that references this issue
on Sep 28, 2026
Filed by the director seat out of #16678's Phase 2 ruling (decision batch #127 item 1, maintainer ruling 2026-09-13; recorded on #16678). ⛔ Not claimed, ⛔ not dispatched.
domain:specbecause the landing point ispackages/spec/src/automation/approval.zod.ts;Clause-②: yesis expected — this widens an authorable enum and adds a sibling key — and thedomain:specseat declares it at claim time.What is ruled (verbatim scope from the #16678 Phase 2 design §8.2, adopted)
Extend the approval node's empty-slate policy so it can name people:
onEmptyApproversgains a'fallback'member, with a siblingfallbackApproversreusing the existing approver-entry shape (ApproverEntrySchema, ⛔ not a new shape). This belongs on the node, ⛔ not on themanagerrung: the #3807 warning already shows all five graph approver types (manager/team/department/position/org_membership_level) share the dead end, and the node is where the platform already decided emptiness is handled (admin_rescue|fail|auto_approve). Take the capability the measured platforms bind to the manager rung (Entra "Add fallback"; Odoo "If empty, the approval is done by an Administrator or Approver") at the node level.Measured facts the implementer inherits (#16678 design §8.1, verified by the services seat on
origin/main)managerIS inGRAPH_APPROVER_TYPES(approval-service.ts:831-833); an empty manager fires the approvals: adepartmentapprover never resolves when the business unit hasorganization_id = null(every seeded BU) #3807 "expanded to nobody" warning (:1730) — ⛔ the card body's and PR docs(approvals): qualify themanagerapprover as a directory-sync dependency #17575's "silently" is stale.`${a.type}:${a.value}`andvalueis omitted formanager⇒pending_approverscarries the literalmanager:undefined. That literal is in this card's blast radius: with a fallback declared, the slate holds the fallback approvers instead.departmentapprover never resolves when the business unit hasorganization_id = null(every seeded BU) #3807 pin table (approval-service.test.ts:3152-3157) omitsmanager; add the row.Scope
packages/spec:onEmptyApproversenum +fallbackApprovers(required iff'fallback', refined with a remedy naming both keys);.describe()text and the generated reference page;authorable-surfacegains the entries;minorchangeset.packages/plugins/plugin-approvals(openNodeRequestempty-slate block): the'fallback'branch resolvesfallbackApproversthrough the same resolver as ordinary approver entries and opens the request on them; ⛔ never themanager:undefinedliteral.packages/lintvalidate-approval-approvers.ts: theMANAGER_ONLY_REMEDY/MANAGER_ONLY_ROUTEStext gains "or declareonEmptyApprovers: 'fallback'"; the finding keeps firing on shape (a static check cannot read the column);stackWiresManagerChainstays the silencer. ⛔ Do not delete the rule. The docs calloutcontent/docs/automation/approvals.mdx:66-87follows.fail/auto_approve/admin_rescueunchanged (negative controls); the newmanagerrow in the approvals: adepartmentapprover never resolves when the business unit hasorganization_id = null(every seeded BU) #3807 table.Cross-domain declaration:
packages/spec(designated lane) +plugin-approvals+packages/lintfaces declared in the claim comment, per the cross-domain exception path.Relations
Parent ruling: #16678 (Phase 2 design adopted as the Phase 3 baseline, minus §4). This card is independent of the manager write surface (design §8.2: "a fallback does not make
managerresolve; it makes the failure survivable. The two are independent and both are wanted") ⇒ ⛔ noBlocked-by:. #17579 (the generated reference page'smanagerline) is closed in substance by #17640 and is re-verified here.Generated by Claude Code