Repository navigation
feat(plugin-approvals): an empty admin_rescue slate opens on the organization's administrators; retire the unstaffed arm - #22850
Conversation
…nization's administrators; retire the unstaffed arm An approval node whose approvers resolve to no concrete person opens, under the default onEmptyApprovers 'admin_rescue', on the request organization's administrators: the holders of the TENANT_ADMIN rung (ADR-0095 D3), resolved per member through resolveUserAuthzGrants and the same grant read isOverrideActor's tenant arm uses. They go through the ordinary approver expansion, so My Pending, the bell, the approval.requested fan-out, OOO delegation and decide apply unchanged. The unstaffed filter member, its REST query parameter and their pins retire with it; the member never shipped, so the pending changeset announcing it is withdrawn. Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
…s description Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
…l service and resolver Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
…late, through the REST door Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
…the administrators' slate does not replace Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
…s the slate must resolve Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
…s' slate Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
…te's insert Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
…in's reads without erasing their options Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
Maintainer confirmation: the DELIBERATE CORRECTION of
|
…d-slate The two tenant-audit census files conflicted; both take origin/main's bytes here and are regenerated by scripts/tenant-audit-census.mjs in the next commit, on the merged tree. Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
…cripts/tenant-audit-census.mjs --write) and re-hold its prose figures Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 39 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 141 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 4111d43be96081bc6547fae65bedf262186afbfe && git checkout 4111d43be96081bc6547fae65bedf262186afbfe
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6364375b51f6a301681185a78839115a9ccbd19f e25e6c27fedc577bd05c834423e76c545e5f4f77 && git checkout -B drift-repro 6364375b51f6a301681185a78839115a9ccbd19f && git merge --no-ff e25e6c27fedc577bd05c834423e76c545e5f4f77
node scripts/docs-audit/affected-docs.mjs --json 6364375b51f6a301681185a78839115a9ccbd19f
|
Contract reviewServed-tier: Inputs: card #22824 body and comments (triage ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each judged:
② Semver level
③ Boundary flagsDev report round 0 (
Patch round 1 (
Implemented-by: VERDICT: PASS Generated by Claude Code |
…d-slate The two tenant-audit census files conflicted; both take origin/main's bytes here and are regenerated by scripts/tenant-audit-census.mjs in the next commit, on the merged tree. Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
…cripts/tenant-audit-census.mjs --write) and re-hold its prose figures Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: A fresh review of this head on its own: the hop from Inputs: card #22824 body and comments (triage ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each judged:
② Semver level
③ Boundary flagsRound 0 (
Patch round 1 ( Patch round 2 (
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #22824
Clause-②: yes
An approval node whose approvers resolve to no concrete person now opens, under the default
onEmptyApprovers: 'admin_rescue', on the request organization's administrators, as ordinary approvers in the ordinary inbox. Theunstaffedqueue arm that #22775 added (never published) retires in the same stroke, since the named slate makes it obsolete. Route P1, as ruled (maintainer 「同意 P1」, recorded as6108375797on objectstack-ai/hotcrm#2057); triage6108769161; claim6108865702.FROM → TO: who receives an
admin_rescuerequestposition:NAME). Measured on this branch's baseefcbac73c, on a real boot (bootStack, the seeded admin owning the default organization): the stored slate was["position:unstaffed_arm_nobody"], the admin's My Pending (status=pending&approverId=ADMIN_ID,EMAIL) answereddata: [], total: 0, theirGET /notifications?type=approval.requestedanswerednotifications: [], unreadCount: 0, and nosys_inbox_messagerow was written. Only the Approval routed to an empty position permanently locks the record (no admin override, no recovery) #3424 override could decide it.TENANT_ADMINrung of the request's organization: the same authority the tenant arm ofisOverrideActortrusts (ADR-0095 D3,ORGANIZATION_ADMIN_GRANTS), resolved to people. There is no second notion of "admin".What changed
@objectstack/plugin-approvals(approval-service.ts)organizationAdministrators(org): the candidates are the organization's members (sys_member.organization_id, this file's existing answer to "who is in this organization"). Each is asked the rung question over the grantsresolveUserAuthzGrants(@objectstack/core) resolves for them with that organization as the active tenant: the resolver every authorization envelope comes from, so grant windows, deactivated sets, other organizations' scoped grants and position-bound sets answer exactly as when the person acts. Noplugin-securityedit; no better-auth role is read as authority.holdsTenantAdminRung(permissions)is now the one spelling of the grant read, shared by the tenant arm ofisOverrideActor(a mechanical swap of the identical expression; its authority does not change) and by the slate.admin_rescue(and afallbackthat found no one either, which already degraded toadmin_rescue) goes through the SAMEexpandApproversasapprovers, asuserentries in one synthetic group (#0). So OOO delegation, theapproverIdindex, theapproval.requestedfan-out anddecideapply unchanged, and a decision by one of them is a slot decision recorded withvia_override: false.__resolvedFromasadmin_rescue:abandoned/admin_rescue:reason(thefallbackbranch's flat, policy-prefixed shape), and the timeline carries one system note (action: 'comment',actor_id: null, the shape the OOO hop row already uses), for example "Routed to this organization's administrators because the approver slate named no one: position 'sales_manager' has no holder."unstaffedarm retires:awaitingRequestIdsandunstaffedOverridableRequestIdsare removed, andlistRequests/countRequestsare byte-identical to their pre-feat(spec,plugin-approvals,rest): an unstaffed arm on the approvals inbox read returns an admin_rescue request to a caller who may override it #22775 bodies (compared function by function).slateNamesAPersonstays (the open path reads it); its docblock no longer names the arm.@objectstack/spec:IApprovalService.listRequests'unstaffed?: booleanmember retires with its docblock. Thelimitdocblock keeps #22775's correction minus the arm. TheonEmptyApprovers.describe()text and TSDoc say thatadmin_rescueopens on the organization's administrators and thatfallbacknames who decides instead.content/docs/references/automation/approval.mdxis regenerated (check:generated --fix,check:docsonly).@objectstack/rest:unstaffedleavesAPPROVAL_REQUEST_LIST_PARAMSand the multiplicity set, andreadDeclaredQueryBoolean(its only reader) goes. The file equals the exact reverse-apply of #22775's hunks ontoefcbac73c(git apply -R, thencmp).?unstaffed=is now refused as an unknown name,400with nestederror.code: VALIDATION_ERROR, before the service runs.@objectstack/lint:approval-approvers-may-resolve-emptyno longer says "waits forever". Both arms say the request opens on the organization's administrators. The hints nameonEmptyApprovers: 'fallback'as the way to name who decides, and theos explainparagraph says the same. Severity, firing conditions and rule ids are unchanged.Docs (
approvals.mdx): a new "When a slate names no one" section. The manager callout, theonEmptyApproverslist, the fallback text, the notification callout and the admin-override callout now describe the administrators' slate. Theunstaffedfilter paragraph and curl are gone.The census, first
No shipped consumer of the
unstaffedmember or?unstaffedexists outside #22775's own files:efcbac73c: every code hit ofunstaffed:,?unstaffed,.unstaffedand'unstaffed'is in feat(spec,plugin-approvals,rest): an unstaffed arm on the approvals inbox read returns an admin_rescue request to a caller who may override it #22775's file list. The only non-test callers oflistRequests/countRequestsare the REST handler's two calls;origin/main1d1cfe0(read only):approvalsApi.tsbuilds the list query fromstatus / object / recordId / approverId / submitterId / q / limit / offsetand never sendsunstaffed. Every otherunstaffedhit there is the console's own seat label (approverIdentity.ts'sunstaffed: booleanchip state), not the filter.npm pack @objectstack/{spec,rest,plugin-approvals}@17.7.0(npmlatestis17.7.0) holds 0 files matchingunstaffed?: boolean | unstaffedOverridableRequestIds | readDeclaredQueryBoolean; control: each tarball holds 3–5 files naminglistRequests.Mechanism assumptions, measured
efcbac73cstored literals only, listed and counted nothing for the admin, and notified no one.ORGANIZATION_ADMIN_GRANTSis declared inpackages/spec/src/identity/eval-user.zod.ts. NO reader resolves "the users holding that rung in organization X": every reader is per user (resolveUserAuthzGrants,derivePosture, the explain engine). So the slate composes the per-user resolver over the organization's members. Nothing inplugin-securitywas edited.fallbackbranch keeps the primary slate's resolution INPUTS (field:NAME,expression#N) and namespaces its own asfallback:KEY. It keeps neither the abandoned slate nor a reason, and a position-only slate writes nothing there (measured:__resolvedFromabsent on the boot above). The new keys mirror that flat, prefixed shape (admin_rescue:abandoned,admin_rescue:reason); there is no second snapshot shape.check:adr-0087-registration --base origin/main: exit 0. The new changeset declares no breaking arm, so no disposition marker is owed, and the deleted 22725 row is not a judged status. See Changesets for the one gate that stays red.Pins
src/admin-rescue-named-slate.integration.test.ts: the realApprovalServiceon a realObjectQLoverSqlDriver(better-sqlite3), the realresolveUserAuthzGrantsover a bound security catalog, and the reallistRequests/countRequests/getRequest/decide/ messaging fan-out:organization_admin,organization_admin_no_bypass), each one's My Pending lists it withtotal: 1andviewer.can_act: true, and each is told once;admin_full_access) and another organization's administrator are not on it;finalized: true,approved,acted_astheir id,via_overridefalse;__resolvedFrom(exact keys and reason);fallbackthat resolves to no one degrades to the administrators and says so in the reason;approval.ooo_substituted), and is not told twice;fallbackopens on its own list;auto_approveopens nothing andfailrefuses (NO_APPROVERS); an organization with no administrator gets the slate that names no one, logs it, tells no one, and the platform admin's decision there is an override (via_overridetrue).packages/qa/dogfood/test/approval-admin-rescue-slate.dogfood.test.ts(the #22775 dogfood pin, rewritten to the named slate) on a real boot, through the REST door: the seeded admin owns the default organization, and a second member is promoted by asys_memberrole write, which provisions their capability. Both administrators find the request in My Pending (total: 1,can_act: true) and hold oneapproval.requestednotification. A member sees and holds nothing. The staffed control is unchanged. An administrator'sPOST …/approveis an approval withvia_overridefalse and the routing note on the timeline.?unstaffed=trueanswers400 VALIDATION_ERROR.Retired with the code they pinned, ⛔ none skipped or disabled: the 9-case
ApprovalService — the unstaffed arm (#22725)block inapproval-service.test.ts(the file equals the exact reverse-apply of #22775's hunk), the REST §4unstaffedblock (replaced by one refusal pin:?unstaffed=true|falseanswers the full ADR-0112 unknown-name refusal and neither service method runs), and the dogfood arm pin (rewritten above).Ablation (one-time, from committed
f3ff683a1)node scripts/ablation-replace.mjsreplacedif (administrators.length) {withif (false && administrators.length) {(anchor 1 → 0, blob259e3e834b00→551f2027d03b), wrapped in atraprestoringgit checkout HEAD --on an absolute path. The integration file then had 6 failed / 4 passed: every named-slate case red, and the four controls (staffed, fallback, auto_approve / fail, and the leaderless organization with its override) green. Restore proven: blob259e3e834b00equals HEAD's, andgit diff HEADis 0 bytes. The integration test imports the service fromsrc, so nodisthop applies. The dogfood pin was not ablated (it readsplugin-approvalsthroughdist).Suites (final runs at
06f9948c8unless noted)@objectstack/plugin-approvals:test73 files / 1035 passed;typecheckexit 0 (check:test-typecheckOK, the test layer included).dogfoodtypecheckexit 0, and itstsc --listFileslists the new pin and fixtures.@objectstack/rest(at23a63a18a;restis unchanged since):test272 files / 5239 passed, 326 skipped;typecheckexit 0.@objectstack/lint(at23a63a18a; unchanged since):test137 files / 6416 passed;typecheckexit 0.@objectstack/spec(atf3ff683a1; unchanged since):typecheckexit 0;test648 files / 19342 passed;check:generatedall artifacts up to date after the onegen:docsregeneration.Gates (at
06f9948c8, the final commit)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 116 families (32 paths against merge baseefcbac73c). Each was run with its exit code recorded to disk before any pipe: 115 exit 0, 1 exit 1. The one red ischeck-empty-changeset.mjs --base origin/main, the deliberate correction below.--ranreconciliation: "116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN".PREREQUISITE NOT MET(exit 3) on unbuilt packages:check:skill-examples(client-react) andcheck:dual-build-cjs-loads. After those packages were built, both exit 0 in the final run. In an earlier run,check:query-options-erasurecaught twoas anyoption erasures this branch had added, andcheck-tenant-audit-censuscaught the new write call site. Both were fixed (commits06f9948c8,8af84ef80) and both exit 0 in the final run.**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}(eslint.config.mjs).eslint --no-inline-config --format jsonover the 24 changed TypeScript files reports 24 files, 0 errors, 0 warnings. The config sets noparserOptions.project(no type-aware linting), so this diff cannot move any untouched file's verdict. The Markdown and MDX files are outside eslint's population.Changesets, and one deliberately red check
.changeset/22824-admin-rescue-named-slate.md:@objectstack/plugin-approvalsminor,@objectstack/specpatch (description text),@objectstack/lintpatch (verdict text).Clause-②: yes, stating FROM → TO.@objectstack/restis not listed: against the published 17.7.0, its shipped content does not move..changeset/22725-approvals-unstaffed-arm.mdis withdrawn (deleted). It announces anunstaffedarm that this PR retires before it ever ships, so it would publish a false release note. This is the DELIBERATE CORRECTION classcheck-empty-changeset.mjsnames.check:empty-changeset --base origin/mainstays red by design: Check Changeset is not a required context, and per ruling D theskip-changesetlabel is not applied. Confirmation requested here: the 22725 note is withdrawn because the behaviour it describes does not ship.File surface beyond the claim's list (declared)
My change turned these red, so each is updated to keep what it pins:
packages/lint/src/rule-explanations.ts(the rule'sos explainparagraph) and the rule's hints invalidate-approval-approvers.ts. The dispatch ruled "the message text only"; the explanation and hints said "waits forever" / "recoverable only by override", which this change makes false, and the os-dev standard clause makes a text this change falsifies a must-fix. The rule's logic is untouched. The conflict is named in the report, not silently resolved.approval-requested-notification.integration.test.ts: one test name and the header now say "with no organization administrator".approval-override-composite-pin+ fixture route to a named approver who never acts (the override's remaining break-glass case);my-pending-position-addressandposition-address-readers+ fixtures add a named co-approver, so theposition:NAMEliteral stays on the slate for the late holder;flow-node-config-values-at-registrationasserts the administrators' slate;activity-text-predicateand both approval-snapshot pins stamp the holder's organization on thesys_user_positionrows they seed. The routing read never resolved an unstamped (unknown-tenancy) row, and the literal fallback had masked that.content/docs/permissions/tenant-audit-census.mdxanddocs/audits/2026-08-tenant-audit-write-call-sites.counts.md: the routing note's insert is one more write call site (238 → 239, decidable 159 → 160, elevated 126 → 127). Regenerated withnode scripts/tenant-audit-census.mjs --write, and the census-held prose figures were updated by hand.Acceptance notes
fallbackbranch do. A holder staffed into a position after an all-literal request opened no longer receives it (an administrator can reassign it). Aposition:NAMEliteral beside a named approver is untouched, and so is the late holder's path to it.expandPositionUsersmatchessys_user_position.organization_idby strict equality (the platform's stated fail-closed reading of unknown tenancy, shared withplugin-sharing), so an organization-less assignment row never put its holder on a slate. Before, the literal stayed and that holder could still act through theposition:NAMEaddress. Now an all-literal slate opens on the administrators. Rows written through a door are stamped, and the showcase seed stamps them. Noted, not filed; carrier: none.docs/qa/platform-checklist/areas/approvals.json(the empty-slate fork item) still saysadmin_rescueis "decidable only via the Approval routed to an empty position permanently locks the record (no admin override, no recovery) #3424 privileged override". It is internal QA text, not a published surface; carrier: none.ApproverTypedescribe text ("a manager step resolves to nobody and the request waits") is still literally true (it waits on the administrators). It was left alone under the describe-only scope.Seat append: patch round 1 (the dev's note, appended by the
domain:servicesseat 1; the dev never edits this body)origin/maind48931871was merged in by merge commit9152dfdf5, made byscripts/pm/os-regen-merge.sh(no rebase, no amend, no force-push).main's bytes in the merge.9e24d7812then regenerates both census files withscripts/tenant-audit-census.mjs --writeand re-holds the page's prose figures (233 → 234: this branch's one new write call site).9e24d7812: 116 derived, 116 run. The one expected red ischeck-empty-changeset, the deletion of the22725note, which the maintainer confirmed in6109850105.plugin-approvals1035/1035, lint 6455/6455, approval dogfood 43/43.Seat append: patch round 2 (the dev's note, appended by the
domain:servicesseat 1)MERGE_CONFLICT, the same two census files).origin/main6364375b5was merged in by merge commit5d0a4a152(os-regen-merge.sh), ande25e6c27fregenerates both census files withscripts/tenant-audit-census.mjs --write(231 → 232: this branch's one write call site).e25e6c27f: 116 derived, 116 run. The one expected red ischeck-empty-changeset, the22725deletion confirmed in6109850105.plugin-approvals1035/1035, lint 6461/6461, approval dogfood 43/43, on dists rebuilt from the merged tree (which carries feat(plugin-security)!: a package's isDefault suggestion takes effect once a manage_metadata holder accepts it for the deployment (#15204 stage U2) #22854 and fix(objectql): an aborting after* hook rolls its write back on the default write door #22819).6110255223, on9e24d7812e) does NOT carry to this head. The hop moved paths this PR owns that are notmerge=os-regenregenerations: the two census files, plusrest-server.tsandrule-explanations.tsfrommain's side (unexplainedPathsBetween, machine-read on the committed trees). So a fresh at-tier review runs one25e6c27f.Generated by Claude Code