feat(spec): approval onEmptyApprovers gains 'fallback' with a sibling fallbackApprovers - #18525
Conversation
… fallbackApprovers The node-level empty-slate policy can now NAME people. `fallbackApprovers` reuses `ApprovalNodeApproverSchema` (not a second approver shape) and is paired with the policy in BOTH directions by a `superRefine`: required when the policy is 'fallback', refused under any other policy so a list nothing reads can never ship. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
…slate is empty The `fallback` empty-slate policy resolves the node's declared `fallbackApprovers` through the SAME expansion as `approvers` and opens the request on those ids — never on the `manager:undefined` literal an empty manager rung leaves behind. A fallback that itself resolves to nobody, or a stored node declaring the policy with no list, degrades to admin_rescue. Adds the missing `manager` row to the graph-approver warning table. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
…e-only verdict `approval-approvers-may-resolve-empty` now offers `onEmptyApprovers: 'fallback'` as a route that needs no write to `sys_user.manager_id`, and the empty-policy nudge enumerates all four members. The finding still fires when a fallback is declared — the rule reads shape, and a fallback list can resolve empty too. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
…face that exists `sys_user.manager_id` gained a dedicated admin operation, and this string still said the column "has no product write surface" — a sentence the generated reference renders once per shape that reuses the approver entry, so this change's new `fallbackApprovers` shape would have shipped a third copy of it. The sentence now describes the column and keeps pointing at the one authoritative remedy instead of asserting a fact of its own. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
…prover-type text Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b50d67578ca99b08898e2e3f8232ad0b95a997d9 && git checkout b50d67578ca99b08898e2e3f8232ad0b95a997d9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2882528786ee33c870a9c14ee0c4d81030250c76 6d3f5a7946ed0159944a6ed72a68367326f5d6c6 && git checkout -B drift-repro 2882528786ee33c870a9c14ee0c4d81030250c76 && git merge --no-ff 6d3f5a7946ed0159944a6ed72a68367326f5d6c6
node scripts/docs-audit/affected-docs.mjs --json 2882528786ee33c870a9c14ee0c4d81030250c76
|
Contract reviewServed-tier: ① Derived judgments
Summary for ①: the card's ② Semver levelChangeset ③ Boundary flags
Implemented-by: VERDICT: PASS Basis: ① the card's Generated by Claude Code |
Clause-② carriers cleared — provenanceBoth carriers stripped in one stroke by the dispatching seat,
How this verdict was produced. This seat's measured served model is Independence pair: a Boundary item carried to #18551, filed before this PR lands so a closing keyword cannot lose it. The review named it as a required correction to shipped prose; ⛔ the seat did not let it ride unnamed, and ⛔ did not fix it in-branch, because that would move the head and void an otherwise complete review record. Pre-landing checks: ① review PASS on record ✅ · ② Generated by Claude Code |
Fixes #17931
Clause-②: yes (widening) —
minor. This adds an enum member and an authorable key; nothing an author can write is removed or renamed, so it is not breaking.What this does
An approval node whose approvers resolve to nobody had three endings, and none of them named anyone:
admin_rescue(the default — the request opens on a deadtype:valueslot and waits for a privileged admin),fail(the run dies),auto_approve(the record is waved through). This adds the fourth, ruled in the #16678 Phase 2 design §8.2:onEmptyApprovers: 'fallback'with a siblingfallbackApproverslist, at the node, not on themanagerrung.packages/spec— the fourth enum member, plusfallbackApprovers: z.array(ApprovalNodeApproverSchema).min(1).optional(). Pairing is enforced in BOTH directions by asuperRefine, each message naming both keys:'fallback'with no list is refused, and a list under any other policy is refused too, because nothing would ever read it. A refinement rather than a discriminated union, so the published JSON-Schema def stays one def (the Studio property form andregisterFlow()both read it) and theZodObjectclass,.shapeandadditionalProperties: falseall survive — pinned.packages/plugins/plugin-approvals—openNodeRequest's empty-slate block resolvesfallbackApproversthrough the SAMEexpandApproverscall the primary slate uses (so approver types, OOO delegation andper_grouptagging behave identically) and opens the request on those ids, never on themanager:undefinedliteral. The abandoned slate'sper_grouptags are dropped from the snapshot; the fallback's resolution inputs land under afallback:prefix in__resolvedFrom, so the audit trail answers both "why did the fallback fire" and "who did it pick". A fallback that itself resolves to nobody, and a stored node declaring the policy with no list, both degrade toadmin_rescue: a rescue path must not kill the run.packages/lint—MANAGER_ONLY_ROUTESgains the node-level escape as a route needing no write tosys_user.manager_id, and the empty-policy nudge enumerates all four members. The rule is NOT deleted and NOT silenced by a declared fallback: it reads shape, and afallbackApproverslist can itself resolve empty.stackWiresManagerChainstays the one silencer. Theapprovals.mdxcallout carries the same prose.managerrow the approvals: adepartmentapprover never resolves when the business unit hasorganization_id = null(every seeded BU) #3807 warning table was missing, which is the row that pins themanager:undefinedliteral itself.One card premise was false as spelled
The card says the sibling key reuses
ApproverEntrySchema. There is noApproverEntrySchemaanywhere in this repo. Measured onorigin/mainfb6b2c369e:git grep -n "export const [A-Za-z]*Approver[A-Za-z]*Schema" -- packages/spec/srcreturns exactly one line —approval.zod.ts:416: export const ApprovalNodeApproverSchema— the dark controlexport const ZzzApproverNoSuchSchemaexits 1, and a whole-repogit grep ApproverEntrySchemaexits 1. The intent binds and was followed: the key reuses the existing approver entry shape,ApprovalNodeApproverSchema, and a pin proves it is not a second dialect (an entry the primary list would refuse is refused here too, by the same surface error).Two other inherited premises drifted only in line numbers and are re-measured here:
manageris inGRAPH_APPROVER_TYPESatapproval-service.ts:860-862(the card said 831-833), and theexpanded to nobodywarning is at:1779(the card said 1730). Both facts hold. Themanager:undefinedliteral holds and is now pinned.One repair this change forced
ApproverType's.describe()still saidsys_user.manager_id"has no product write surface" — an assertion #16678 retired when it landed the admin operation, and one the lint rule's own docblock named as the stale carrier. The generated reference renders that string once per shape that reuses the approver entry, sofallbackApproverswould have shipped a third copy of it. The sentence now describes the column and keeps pointing at the single authoritative remedy instead of asserting a fact of its own; a spec-side pin holds it. 4 lines carrying the old sentence leavecontent/docs/references/automation/approval.mdx, 0 arrive.Verification
Measured on head
6d3f5a7946, all foreground, heavy runs throughscripts/pm/os-verify-lock.sh(VERDICT command-exitread, never a bare$?).pnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec typecheckpnpm --filter @objectstack/lint testpnpm --filter @objectstack/lint typecheckpnpm --filter @objectstack/plugin-approvals testpnpm --filter @objectstack/plugin-approvals typecheckpnpm --filter '@objectstack/plugin-approvals^...' buildpnpm --filter @objectstack/spec check:generatedpnpm lint(whole repo,eslint . --no-inline-config)Derived gates.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 112 commands for this file surface; all 112 were run. 107 exit 0. The five that did not are eachPREREQUISITE NOT METor a pre-existing condition, none of them a verdict on this diff:check:dual-build-cjs-loadsexit 3,check:i18nexit 3,check:type-check-debtexit 3 — each printsPREREQUISITE NOT METand names the full build it needs. NOT MEASURED, not green.check:skill-examplesexit 1 — same class in substance: it refuses becausepackages/client-react/distholds no declarations ("a verdict now would be computed against a build that no longer matches src"). NOT MEASURED.check:cross-package-test-inputsexit 1 — reproduced on a pristine merge-base tree with zero of this branch's changes, and it is not about any file here. Control, same commitfb6b2c369e, detached worktree: with nopackages/spec/dist/on disk it exits 0; after copyingpackages/spec/dist/in, it exits 1 with the identical finding (@objectstack/cli descends a directory tree from packages/spec/dist/ … rooted in packages/cli/test/init-created-files-summary.e2e.test.ts). Filed as a finding below.Ablation — every new pin proved non-vacuous. Each leg mutated the committed implementation, proved the mutation reached disk by grepping the removed marker to 0 and the injected marker to 1 (plus
git diff --numstat), ran, restored withgit checkout HEAD -- path, proved the restore by comparinggit hash-objectagainst the HEAD blob, and re-ran. Atrap … EXIT INT TERMwith absolute paths guarded the whole sequence; the working tree is clean..superRefine(checkFallbackApproversPairing)approvers = fallbackApproversbecomes a no-opMANAGER_ONLY_ROUTESThe five spec failures are exactly the pairing pins (both arms, and each of the three negative-control policies); the two plugin failures are "opens on the declared fallback, never on the
manager:undefinedliteral" and "resolves the fallback through the same expansion approvers use"; the lint failure is "names the node-level escape".Acceptance notes
Noted, not filed — out of scope, listed for whoever next touches these files:
skills/objectstack-automation/SKILL.mdline 728 anddocs/qa/platform-checklist/areas/approvals.jsonboth enumerate theonEmptyApproversvocabulary and now list three of four members. Both are outside this PR's declared file surface, andskills/**is a governed surface whose inclusion would change how this PR lands, so neither was touched. Successor: theskillsseat (that file's next editor) — needs its own card.approval-approvers-may-resolve-empty's group-routed arm (position/team/department) now names the node-level policy too, because the same escape applies; its "add a guaranteed-staffed fallback approver" wording was also disambiguated, sincefallbackApproversis now a real key and "a fallback approver" had become two things.To file (with dedupe words):
check:cross-package-test-inputsfails onmainas soon aspackages/spec/dist/exists on disk — 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. Reproduced above with a discriminating control. Dedupe words:cross-package-test-inputs,init-created-files-summary,packages/spec/dist,declared glob,#7802.Generated by Claude Code