Skip to content

feat(plugin-approvals): an empty admin_rescue slate opens on the organization's administrators; retire the unstaffed arm - #22850

Merged
objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-22824-admin-rescue-named-slate
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-22824-admin-rescue-named-slate

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

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. The unstaffed queue 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 as 6108375797 on objectstack-ai/hotcrm#2057); triage 6108769161; claim 6108865702.

FROM → TO: who receives an admin_rescue request

  • FROM: the request opened on a slate that named no one (the literal position:NAME). Measured on this branch's base efcbac73c, 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) answered data: [], total: 0, their GET /notifications?type=approval.requested answered notifications: [], unreadCount: 0, and no sys_inbox_message row was written. Only the Approval routed to an empty position permanently locks the record (no admin override, no recovery) #3424 override could decide it.
  • TO: the request opens on the holders of the TENANT_ADMIN rung of the request's organization: the same authority the tenant arm of isOverrideActor trusts (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 grants resolveUserAuthzGrants (@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. No plugin-security edit; no better-auth role is read as authority.
  • holdsTenantAdminRung(permissions) is now the one spelling of the grant read, shared by the tenant arm of isOverrideActor (a mechanical swap of the identical expression; its authority does not change) and by the slate.
  • Platform administrators are not added for being platform administrators. One who also holds the organization's rung (its owner, say) is on the slate for that rung.
  • At node entry, an empty slate under admin_rescue (and a fallback that found no one either, which already degraded to admin_rescue) goes through the SAME expandApprovers as approvers, as user entries in one synthetic group (#0). So OOO delegation, the approverId index, the approval.requested fan-out and decide apply unchanged, and a decision by one of them is a slot decision recorded with via_override: false.
  • The request says why. The abandoned slate and the reason are kept in __resolvedFrom as admin_rescue:abandoned / admin_rescue:reason (the fallback branch'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."
  • Break-glass stays break-glass. An organization with no administrator, a request with no organization, or an administrator read that fails opens on the slate that names no one, logged as a warning that names which of the three and the fix. A read failure answers a failure, never an invented "nobody".
  • The unstaffed arm retires: awaitingRequestIds and unstaffedOverridableRequestIds are removed, and listRequests / countRequests are 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). slateNamesAPerson stays (the open path reads it); its docblock no longer names the arm.

@objectstack/spec: IApprovalService.listRequests' unstaffed?: boolean member retires with its docblock. The limit docblock keeps #22775's correction minus the arm. The onEmptyApprovers .describe() text and TSDoc say that admin_rescue opens on the organization's administrators and that fallback names who decides instead. content/docs/references/automation/approval.mdx is regenerated (check:generated --fix, check:docs only).

@objectstack/rest: unstaffed leaves APPROVAL_REQUEST_LIST_PARAMS and the multiplicity set, and readDeclaredQueryBoolean (its only reader) goes. The file equals the exact reverse-apply of #22775's hunks onto efcbac73c (git apply -R, then cmp). ?unstaffed= is now refused as an unknown name, 400 with nested error.code: VALIDATION_ERROR, before the service runs.

@objectstack/lint: approval-approvers-may-resolve-empty no longer says "waits forever". Both arms say the request opens on the organization's administrators. The hints name onEmptyApprovers: 'fallback' as the way to name who decides, and the os explain paragraph 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, the onEmptyApprovers list, the fallback text, the notification callout and the admin-override callout now describe the administrators' slate. The unstaffed filter paragraph and curl are gone.

The census, first

No shipped consumer of the unstaffed member or ?unstaffed exists outside #22775's own files:

  • this repository at efcbac73c: every code hit of unstaffed:, ?unstaffed, .unstaffed and '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 of listRequests / countRequests are the REST handler's two calls;
  • objectstack-ai/objectui origin/main 1d1cfe0 (read only): approvalsApi.ts builds the list query from status / object / recordId / approverId / submitterId / q / limit / offset and never sends unstaffed. Every other unstaffed hit there is the console's own seat label (approverIdentity.ts's unstaffed: boolean chip state), not the filter.
  • Not published: npm pack @objectstack/{spec,rest,plugin-approvals}@17.7.0 (npm latest is 17.7.0) holds 0 files matching unstaffed?: boolean | unstaffedOverridableRequestIds | readDeclaredQueryBoolean; control: each tarball holds 3–5 files naming listRequests.

Mechanism assumptions, measured

  1. Holds. See FROM above: the real boot at efcbac73c stored literals only, listed and counted nothing for the admin, and notified no one.
  2. Partly. ORGANIZATION_ADMIN_GRANTS is declared in packages/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 in plugin-security was edited.
  3. Partly false. The fallback branch keeps the primary slate's resolution INPUTS (field:NAME, expression#N) and namespaces its own as fallback:KEY. It keeps neither the abandoned slate nor a reason, and a position-only slate writes nothing there (measured: __resolvedFrom absent on the boot above). The new keys mirror that flat, prefixed shape (admin_rescue:abandoned, admin_rescue:reason); there is no second snapshot shape.
  4. Holds. The member never shipped (the tarball reading above). 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 real ApprovalService on a real ObjectQL over SqlDriver (better-sqlite3), the real resolveUserAuthzGrants over a bound security catalog, and the real listRequests / countRequests / getRequest / decide / messaging fan-out:

  • a position nobody holds opens on the two administrators (organization_admin, organization_admin_no_bypass), each one's My Pending lists it with total: 1 and viewer.can_act: true, and each is told once;
  • the scene anchored on that slate: a member sees nothing and is told nothing, and a platform admin (unscoped admin_full_access) and another organization's administrator are not on it;
  • a tenant administrator's decision: finalized: true, approved, acted_as their id, via_override false;
  • the timeline note and __resolvedFrom (exact keys and reason);
  • a fallback that resolves to no one degrades to the administrators and says so in the reason;
  • an administrator out of office is covered by their delegate (approval.ooo_substituted), and is not told twice;
  • CONTROLS: a staffed slate is unchanged (no administrator, no note); fallback opens on its own list; auto_approve opens nothing and fail refuses (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_override true).

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 a sys_member role write, which provisions their capability. Both administrators find the request in My Pending (total: 1, can_act: true) and hold one approval.requested notification. A member sees and holds nothing. The staffed control is unchanged. An administrator's POST …/approve is an approval with via_override false and the routing note on the timeline. ?unstaffed=true answers 400 VALIDATION_ERROR.

Retired with the code they pinned, ⛔ none skipped or disabled: the 9-case ApprovalService — the unstaffed arm (#22725) block in approval-service.test.ts (the file equals the exact reverse-apply of #22775's hunk), the REST §4 unstaffed block (replaced by one refusal pin: ?unstaffed=true|false answers 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.mjs replaced if (administrators.length) { with if (false && administrators.length) { (anchor 1 → 0, blob 259e3e834b00 → 551f2027d03b), wrapped in a trap restoring git 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: blob 259e3e834b00 equals HEAD's, and git diff HEAD is 0 bytes. The integration test imports the service from src, so no dist hop applies. The dogfood pin was not ablated (it reads plugin-approvals through dist).

Suites (final runs at 06f9948c8 unless noted)

  • @objectstack/plugin-approvals: test 73 files / 1035 passed; typecheck exit 0 (check:test-typecheck OK, the test layer included).
  • dogfood, 12 approval-reading files (the new pin, override-composite, my-pending, position-address-readers, flow-node-config, notification-body, activity-text-predicate, both snapshot pins, lookup-title-exposure, seed-ownership-claim-dispatch, audit-log-admin-search): 12 files / 43 passed. dogfood typecheck exit 0, and its tsc --listFiles lists the new pin and fixtures.
  • @objectstack/rest (at 23a63a18a; rest is unchanged since): test 272 files / 5239 passed, 326 skipped; typecheck exit 0.
  • @objectstack/lint (at 23a63a18a; unchanged since): test 137 files / 6416 passed; typecheck exit 0.
  • @objectstack/spec (at f3ff683a1; unchanged since): typecheck exit 0; test 648 files / 19342 passed; check:generated all artifacts up to date after the one gen:docs regeneration.
  • The Dogfood Regression Gate's full population is left to CI.

Gates (at 06f9948c8, the final commit)

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 116 families (32 paths against merge base efcbac73c). Each was run with its exit code recorded to disk before any pipe: 115 exit 0, 1 exit 1. The one red is check-empty-changeset.mjs --base origin/main, the deliberate correction below. --ran reconciliation: "116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN".
  • Two families first answered PREREQUISITE NOT MET (exit 3) on unbuilt packages: check:skill-examples (client-react) and check:dual-build-cjs-loads. After those packages were built, both exit 0 in the final run. In an earlier run, check:query-options-erasure caught two as any option erasures this branch had added, and check-tenant-audit-census caught the new write call site. Both were fixed (commits 06f9948c8, 8af84ef80) and both exit 0 in the final run.
  • Lint, narrowed and proven: eslint's population is **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} (eslint.config.mjs). eslint --no-inline-config --format json over the 24 changed TypeScript files reports 24 files, 0 errors, 0 warnings. The config sets no parserOptions.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.
  • The 49 artifact-roster families, the CI-only jobs and the type-check lanes are left to CI.

Changesets, and one deliberately red check

  • .changeset/22824-admin-rescue-named-slate.md: @objectstack/plugin-approvals minor, @objectstack/spec patch (description text), @objectstack/lint patch (verdict text). Clause-②: yes, stating FROM → TO. @objectstack/rest is not listed: against the published 17.7.0, its shipped content does not move.
  • .changeset/22725-approvals-unstaffed-arm.md is withdrawn (deleted). It announces an unstaffed arm that this PR retires before it ever ships, so it would publish a false release note. This is the DELIBERATE CORRECTION class check-empty-changeset.mjs names. check:empty-changeset --base origin/main stays red by design: Check Changeset is not a required context, and per ruling D the skip-changeset label 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's os explain paragraph) and the rule's hints in validate-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".
  • Dogfood pins whose scene was a slate the administrators' slate now replaces:
    • approval-override-composite-pin + fixture route to a named approver who never acts (the override's remaining break-glass case);
    • my-pending-position-address and position-address-readers + fixtures add a named co-approver, so the position:NAME literal stays on the slate for the late holder;
    • flow-node-config-values-at-registration asserts the administrators' slate;
    • activity-text-predicate and both approval-snapshot pins stamp the holder's organization on the sys_user_position rows 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.mdx and docs/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 with node scripts/tenant-audit-census.mjs --write, and the census-held prose figures were updated by hand.

Acceptance notes

  • The abandoned slate is replaced, not kept beside the administrators, as the ruling's "abandoned primary slate" and the fallback branch do. A holder staffed into a position after an all-literal request opened no longer receives it (an administrator can reassign it). A position:NAME literal beside a named approver is untouched, and so is the late holder's path to it.
  • Unstamped position assignments. expandPositionUsers matches sys_user_position.organization_id by strict equality (the platform's stated fail-closed reading of unknown tenancy, shared with plugin-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 the position:NAME address. 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.
  • Cost. One grant resolution per member of the request's organization, eight at a time, on the empty-slate path only.
  • The submitter is not excluded from the administrators' slate (a one-admin organization decides its own request), the same as any slate that names its submitter.
  • docs/qa/platform-checklist/areas/approvals.json (the empty-slate fork item) still says admin_rescue is "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.
  • The ApproverType describe 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:services seat 1; the dev never edits this body)

  • origin/main d48931871 was merged in by merge commit 9152dfdf5, made by scripts/pm/os-regen-merge.sh (no rebase, no amend, no force-push).
  • The census conflict took main's bytes in the merge. 9e24d7812 then regenerates both census files with scripts/tenant-audit-census.mjs --write and re-holds the page's prose figures (233 → 234: this branch's one new write call site).
  • Gates at 9e24d7812: 116 derived, 116 run. The one expected red is check-empty-changeset, the deletion of the 22725 note, which the maintainer confirmed in 6109850105.
  • Tests: plugin-approvals 1035/1035, lint 6455/6455, approval dogfood 43/43.

Seat append: patch round 2 (the dev's note, appended by the domain:services seat 1)


Generated by Claude Code

…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>
…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>
…in's reads without erasing their options

Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Maintainer confirmation: the DELIBERATE CORRECTION of .changeset/22725-approvals-unstaffed-arm.md · domain:services seat 1 (#6021) · session_01CBAfsWMSfM3EToQGVStEcp · 2026-10-11T14:05Z

Check Changeset on this PR is red by design: check-empty-changeset.mjs refuses the deletion of a changeset this PR did not add, and names the DELIBERATE CORRECTION class. The gate asks for the correction to be confirmed on the PR in writing, and for the check to be left red. This comment is that record.


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>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 11, 2026
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/lint, @objectstack/plugin-approvals, @objectstack/rest, @objectstack/spec, touching 28 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-approvals/src/approver-address.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

39 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 6364375b51f6a301681185a78839115a9ccbd19f.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-approvals/src/approver-address.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 33 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 141 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6364375b51f6a301681185a78839115a9ccbd19f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4111d43be96081bc6547fae65bedf262186afbfe — the merge of head e25e6c27fedc577bd05c834423e76c545e5f4f77 into base 6364375b51f6a301681185a78839115a9ccbd19f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6364375b51f6a301681185a78839115a9ccbd19f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9e24d7812eba7557a791a97c17b53d847098312d
Local-runs: none

Inputs: card #22824 body and comments (triage 6108769161, claim 6108865702, dev reports 6109812487 and 6110138030; the dispatching seat's own conclusion 6110166167 was not read), PR #22850 body, file list, comments (6109850105, 6109875269), the net diff against main at d48931871, the 46 check-runs on the head, origin/main eed637c09 for the contract files, packages/spec/package.json (17.7.0), the pending .changeset/22725-approvals-unstaffed-arm.md, ADR-0095 D3, scripts/check-empty-changeset.mjs and the pr-automation.yml DISCRIMINATOR step, and the Check Changeset job's steps and annotations. The head was fetched into a ref of this clone's own for git grep and git diff reads only; nothing was checked out, built, run or re-run.

① Derived judgments

Every accept-set and public-surface change the diff implies, each judged:

  1. @objectstack/spec contract — IApprovalService.listRequests loses the unstaffed?: boolean filter member and its docblock; countRequests follows through Parameters; the limit docblock keeps 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 correction minus the arm. RIGHT, and not a release-visible removal: @objectstack/spec on origin/main is 17.7.0 and .changeset/22725-approvals-unstaffed-arm.md was still pending there (a changeset is consumed at release), so the member's whole life is between c1b7f2b168 and this head. git diff c1b7f2b168~1 9e24d7812 -- packages/spec/src/contracts/approval-service.ts holds zero unstaffed lines. No ADR-0087 disposition is owed (Check Changeset step 13, the disposition step, is green). The interface export itself persists, so check:api-surface is unmoved (TypeScript Type Check green).
  2. onEmptyApprovers describe text and TSDoc (approval.zod.ts): admin_rescue (default) opens on the organization's administrators, fallback names who decides; enum values and default unchanged; content/docs/references/automation/approval.mdx regenerated for check:docs. RIGHT — card done-when item 5, and the authorable surface does not move.
  3. @objectstack/rest: unstaffed leaves APPROVAL_REQUEST_LIST_PARAMS and the multiplicity list, readDeclaredQueryBoolean (its only reader) is deleted, and ?unstaffed= is now refused by refuseUnknownQueryParams as 400 with nested error.code: VALIDATION_ERROR before either service method runs. RIGHT, and byte-equal to the 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 route: git diff c1b7f2b168~1 9e24d7812 -- packages/rest/src/rest-server.ts holds zero unstaffed lines, and git grep over the head's packages/ finds no unstaffed identifier in spec, rest or plugin-approvals source; the only hits are the two refusal pins (REST unit, dogfood). The closed set shrinks by the one name that was never published.
  4. holdsTenantAdminRung(permissions) in approval-service.ts: the expression (ORGANIZATION_ADMIN_GRANTS.some(...) over perms.includes, unchanged) lifted out of isOverrideActor's tenant arm, which now reads posture === 'TENANT_ADMIN' || holdsTenantAdminRung(perms). RIGHT — a mechanical swap, the override's authority unchanged, one spelling of the rung. This is ADR-0095 D3 as written: TENANT_ADMIN derives from the org-admin capability grants, never from the better-auth role.
  5. organizationAdministrators(org): candidates are sys_member rows with organization_id = org (only user_id read, never role), each asked holdsTenantAdminRung over resolveUserAuthzGrants(engine, userId, { tenantId: org }).permissions, eight at a time, capped at APPROVER_INDEX_CAP with a warn; no organization answers { ids: [] }; a failed read answers { failure } and never an invented empty. RIGHT — the card's "same authority as isOverrideActor's tenant arm, resolved to users, no second notion of admin" holds, and the "platform admins are not put on the slate" rule holds: an unscoped admin_full_access member (u_root) and another organization's owner are pinned absent. No plugin-security file is edited (file list).
  6. Node entry under admin_rescue (and a fallback that found no one): after the existing policy branches, !slateNamesAPerson(approvers) builds the reason (emptySlateReason), resolves the administrators through the SAME expandApprovers as approvers (as user entries in one synthetic group #0, per-group tags of the abandoned slate cleared first), writes __resolvedFrom['admin_rescue:abandoned'] and ['admin_rescue:reason'], warns, and replaces the slate; when no administrator resolves it warns naming which of the three cases (no org, no admin, read failed) and keeps the nameless slate for the Approval routed to an empty position permanently locks the record (no admin override, no recovery) #3424 override. RIGHT — card items 1 and 3. OOO delegation rides the shared expansion (pinned: u_admin out of office, u_cover on the slate, told once by approval.ooo_substituted). fail, auto_approve and the fallback landing people are untouched in logic — fallbackDegraded is a marker read only by the reason string — and all three are pinned as controls.
  7. One system timeline note: a sys_approval_action row, action: 'comment', actor_id: null, organization_id: requestOrg, written after the submit row only when routedToAdministrators is set. RIGHT — card item 1's "the request's display says it was routed to the administrators"; the OOO hop row already uses that shape. It is one new tenant-stamped write call site, and the census moved by exactly that one (233 to 234, 9152dfdf5 stamp; check-tenant-audit-census inside Lint & Repo Gates green).
  8. listRequests / countRequests: awaitingRequestIds and unstaffedOverridableRequestIds removed; both read approverRequestIds(approverTargets, tenantOrg) again. RIGHT — the 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 shape; slateNamesAPerson is kept for the open path and its docblock no longer names the arm.
  9. Fan-out: the administrators are told once on approval.requested through the unchanged fan-out, and a decision by one of them is a slot decision with via_override: false. RIGHT — card item 2, pinned at the service layer (10-case integration file on a real ObjectQL + SqlDriver with the real resolveUserAuthzGrants) and on a real boot through the REST door (both administrators listed with total: 1, can_act: true, one notification each; a member sees and holds nothing; the co-admin's POST .../approve recorded via_override: false).
  10. @objectstack/lint: two verdict messages, two hints and the one os explain paragraph of approval-approvers-may-resolve-empty now say the request opens on the organization's administrators and name onEmptyApprovers: 'fallback' as the way to name who decides; rule ids, severity and firing conditions unchanged; the facts pin updated. RIGHT — card item 6 asks for the fallback suggestion, which lives in the hint; see ③ on the explain paragraph.
  11. Docs content/docs/automation/approvals.mdx: new "When a slate names no one" section; the manager callout, the policy list, the fallback text, the notification callout and the admin-override callout re-framed; the unstaffed filter paragraph and curl removed. RIGHT — says what ships.
  12. Pins retired with the code they pinned, none skipped or disabled: the 9-case unstaffed arm (#22725) block, the REST §4 block (replaced by the refusal pin), the dogfood arm pin and fixture (rewritten under the new names, R055). Fixtures the behaviour change turned red, each keeping its own subject: override composite routes to a departed named approver (break-glass case 1 of card item 3); my-pending and position-address-readers add a named co-approver so the position: literal stays on the slate; flow-node-config asserts the administrator's id; activity-text-predicate and both snapshot pins stamp organization_id on the sys_user_position rows they seed. RIGHT — read line by line; none weakens what it pinned.
  13. Scope and landing shape: 31 files, +1051 / -801 (1852 changed lines, under the 3,000 queue threshold); no governed-surface path (docs/audits/ and content/docs/ are not register rows); head repo equals base repo; draft; no review submitted; the claim's prohibitions hold (no isOverrideActor authority change, no plugin-security edit, fallback / auto_approve / fail unchanged, no objectui change).
  14. Gate verdicts on the head (46 check-runs, all completed): the seven required contexts — Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard — are success; the only failure is Check Changeset (job 114495918539), and it is ONLY the declared class: step 11 (a changeset was added) success, step 12 (check-empty-changeset.mjs) failure with one annotation naming only .changeset/22725-approvals-unstaffed-arm.md under the finding: random changeset filenames collide silently across parallel agents — a round overwrote a sibling PR's minor changeset and every gate stayed green #17712 foreign-changeset rule and its two-class remedy, step 13 (ADR-0087 disposition) success, step 15 (major guard, launch window) success. That is the DELIBERATE CORRECTION class the script header and the workflow's DISCRIMINATOR text describe, confirmed on the PR in writing by the maintainer (6109850105, "Confirm deletion"), with skip-changeset not applied (ruling D). Not a required context; it stays red by design.

② Semver level

  • .changeset/22824-admin-rescue-named-slate.md declares @objectstack/plugin-approvals: minor, @objectstack/spec: patch, @objectstack/lint: patch. Each matches what the diff publishes against the released 17.7.0: plugin-approvals gains a routing behaviour (who receives an admin_rescue request), stated FROM → TO in the body — minor; spec moves only describe/TSDoc text (the retired member never shipped, item ①.1) — patch; lint moves only verdict and hint text, no rule added or removed — patch. @objectstack/rest is correctly absent: against 17.7.0 its shipped content does not move (item ①.3). The deleted 22725 note announced an arm this PR retires before it ever ships; keeping it would publish a false release note, so the deletion is the correction, confirmed per ①.14.
  • Clause-②: yes — present once in the PR body and in the changeset, no arm. Consistent with the triage and claim lines and with the diff: the served __resolvedFrom snapshot gains two keys and the slate gains people (additive), nothing released is removed, so (narrowing) is not owed; yes takes at least one minor, which plugin-approvals carries (Check Changeset step 15 green); no breaking arm, so no ADR-0087 marker is owed (step 13 green). The body carries no model identifier and no skip-changeset label.

③ Boundary flags

Dev report round 0 (6109812487) deviations, each answered:

  1. Lint hints and the os explain paragraph edited beyond "the message text only". Answered: right. The hints and the explain paragraph said "waits forever" / "recoverable only by a platform/tenant admin override", text this diff falsifies; leaving it would advertise behaviour the runtime no longer delivers (Prime Directive chore: version packages #10). Logic, severity and ids untouched; the conflict was named, not silently resolved. Card item 6 itself asks the rule to suggest fallback, which is hint text.
  2. File surface beyond the claim (notification test header, five dogfood pins and three fixtures, the two census files). Answered: right — each was turned red by the behaviour change and each keeps its own subject (①.12, ①.7).
  3. Dogfood arm pin rewritten under new names rather than kept under the retired arm's name. Answered: right — the retired arm's pin is retired with the arm; the new pin carries card item 7 on a real boot.
  4. Check Changeset red by design, confirmation asked on the PR, not routed around. Answered: right — verified at the job-step and annotation level (①.14); the maintainer's written confirmation is 6109850105.
  5. Mechanism assumption 2 partly false (no reader resolves "users holding the rung in org X"); assumption 3 partly false (the fallback branch keeps resolution inputs, not the abandoned slate). Answered: right resolutions — composing the per-user resolver over the organization's members keeps one notion of admin without a plugin-security edit; the new __resolvedFrom keys mirror the fallback branch's flat, policy-prefixed shape, so there is no second snapshot shape, and the card's intent (abandoned slate and reason kept) is met.
  6. Commit trailers are the model-free pair AGENTS.md requires, not the model-named one the harness offered. Answered: compliant — the pre-push hook refuses a model identifier in that pair.
  7. Zero label writes. Answered: right — no label was dispatched and skip-changeset is refused for this class.

Patch round 1 (6110138030) deviations: the census page's prose figures edited by hand as check-tenant-audit-census prescribes, after the merge took main's bytes and 9e24d7812 regenerated both files — answered: right, the gate is green and the delta against main is exactly the one new write site; the worktree recreation is mechanics only.

open_questions: none in either round.

out_of_scope_findings (round 0; none in round 1):

  • Organization-less sys_user_position holders: expandPositionUsers matches organization_id by strict equality while the acting path admits a global assignment, so before this change such a holder could still act through the position:NAME literal, and after it an all-literal slate opens on the administrators instead. Answered: not introduced by this diff and not a defect of it — the expansion read is pre-existing and documented as the fail-closed reading of unknown tenancy shared with plugin-sharing; rows written through a door and the showcase seed are stamped, and the measured unstamped population was three hand-seeded fixtures, now stamped. Escalated to the seat: the asymmetry between the two readers has a repro and is a tenancy-contract inconsistency, which is Prime Directive chore: version packages #10 card material rather than an acceptance note; the seat files it unassigned with the dev's dedupe words (position expansion organization-less assignment), or records why not. Not a verdict carrier.
  • docs/qa/platform-checklist/areas/approvals.json (the empty-slate fork item) still says admin_rescue is decidable only via the override. Answered: internal QA text, not a published surface, so the falsified-text clause does not reach it in this PR, but the item is now false and will mislead the next checklist run. Escalated to the seat as a one-line follow-up for the checklist-author lane. Not a verdict carrier.

Implemented-by: claude/issue-22824-admin-rescue-named-slate
Reviewed-by: session_01CBAfsWMSfM3EToQGVStEcp

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 14:52
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 14:52
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 11, 2026
…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e25e6c27fedc577bd05c834423e76c545e5f4f77
Local-runs: none

A fresh review of this head on its own: the hop from 9e24d7812e (record 6110255223) passed through two merges of main (9152dfdf5, 5d0a4a152) and two census regenerations, and the PR body itself names rest-server.ts and rule-explanations.ts as paths main's side moved, so that record does not carry.

Inputs: card #22824 body and comments (triage 6108769161, claim 6108865702, the dev's round 0 6109812487, patch round 1 6110138030, patch round 2 6111036900; the dispatching seat's own conclusions 6110166167 and 6111059961 were not read), PR #22850 body, its 31-file list, its comments (6109850105 the maintainer's written confirmation, 6109875269 docs drift, 6110255223 the earlier record), the net diff against main (merge base 6364375b5), the 46 check-runs on this head read twice (16:4x and again before this verdict, every one status: completed, the latest run per name), the Check Changeset job 114515873908's steps and the check-run's three annotations, the main ruleset's required contexts, pr-automation.yml's triggers, scripts/check-empty-changeset.mjs's header, scripts/pm/clause2-line.mjs's key-line pattern, and on origin/main (55382dc02a) packages/spec/src/contracts/approval-service.ts, packages/spec/src/automation/approval.zod.ts, .changeset/22725-approvals-unstaffed-arm.md (still pending there), packages/spec/package.json (17.7.0), ADR-0095 D3, ORGANIZATION_ADMIN_GRANTS and resolve-authz-context.ts's isTenantAdmin line. The head was fetched into this clone's FETCH_HEAD for git diff and git grep reads only; nothing was checked out, built, run or re-run.

① Derived judgments

Every accept-set and public-surface change the diff implies, each judged:

  1. @objectstack/spec contract: IApprovalService.listRequests loses the unstaffed?: boolean member and its docblock, countRequests follows through the shared filter type, and the limit docblock keeps 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 correction minus the arm. RIGHT, and not a release-visible removal. @objectstack/spec on origin/main is 17.7.0 and the 22725 changeset is still pending there, so the member's whole life is between c1b7f2b168 and this head. Measured: git diff c1b7f2b168~1 FETCH_HEAD -- packages/spec/src/contracts/approval-service.ts is the limit docblock hunk alone, zero unstaffed lines; the PR's contract hunk is the line-set reverse of 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 hunk except for that one docblock. No ADR-0087 disposition is owed (Check Changeset step 13, the disposition step, success). The interface export persists; TypeScript Type Check and Type Check · source gates are success.
  2. onEmptyApprovers TSDoc and .describe() text (approval.zod.ts): admin_rescue (default) opens on the organization's administrators, fallback names who decides; the enum and the default are unchanged; content/docs/references/automation/approval.mdx regenerated to the same text. RIGHT: card done-when item 5, and no authorable surface moves (Spec property liveness success).
  3. @objectstack/rest: unstaffed leaves APPROVAL_REQUEST_LIST_PARAMS and the repeated-parameter list, readDeclaredQueryBoolean (its only reader) is deleted, the handler no longer forwards it, and ?unstaffed= is refused by the closed-set gate as 400 with nested error.code: VALIDATION_ERROR before either service method runs. RIGHT, and byte-exact against 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. Measured: the sorted added/removed line set of the PR's rest-server.ts hunk equals the sorted line set of 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 hunk on that file (59 lines each, diff empty), and git diff c1b7f2b168~1 FETCH_HEAD -- packages/rest/src/rest-server.ts holds zero unstaffed lines. The REST §4 block is not reverse-applied but replaced by one refusal pin (?unstaffed=true|false answers the unknown-name refusal and neither service method runs), as the body declares. Against the published 17.7.0 the route's accepted set is what it was.
  4. holdsTenantAdminRung(permissions) in approval-service.ts: the expression ORGANIZATION_ADMIN_GRANTS.some(...) over permissions.includes(n), lifted out of isOverrideActor's tenant arm, which now reads posture === 'TENANT_ADMIN' || holdsTenantAdminRung(perms). RIGHT: a mechanical swap, the override's authority unchanged. It is also the posture ladder's own evidence: resolve-authz-context.ts:1307 derives isTenantAdmin from exactly that ORGANIZATION_ADMIN_GRANTS.some(...) test, and ORGANIZATION_ADMIN_GRANTS is [organization_admin, organization_admin_no_bypass] (eval-user.zod.ts:186). ADR-0095 D3 as written: the rung derives from the capability grants, never from the better-auth role.
  5. organizationAdministrators(org): candidates are sys_member rows with organization_id = org (user_id read only, never role), each asked holdsTenantAdminRung over resolveUserAuthzGrants(engine, userId, { tenantId: org }).permissions, eight at a time, capped at APPROVER_INDEX_CAP with a warn; no organization answers { ids: [] }; a failed read answers { failure } and never an invented empty. RIGHT: one notion of "admin" (item 4), platform administrators are not added for being one (an unscoped admin_full_access member u_root and another organization's owner are pinned absent), and no plugin-security file is in the diff. Bounded reading, declared in the docblock: a person holding the rung with no sys_member row of that organization is not a candidate; the grant is provisioned from that membership, so the bound is the organization's own roster.
  6. Node entry under admin_rescue (and a fallback that landed nobody, which already degraded there): after the existing policy branches, when !slateNamesAPerson(approvers), build the reason (emptySlateReason), resolve the administrators through the SAME expandApprovers as approvers (as user entries in one synthetic group #0, the abandoned slate's per-group tags cleared first), write __resolvedFrom['admin_rescue:abandoned'] and ['admin_rescue:reason'], warn, replace the slate; when no administrator resolves, warn naming which of the three cases (no organization, no administrator, read failed) and keep the nameless slate for the Approval routed to an empty position permanently locks the record (no admin override, no recovery) #3424 override. RIGHT: card items 1 and 3. fail, auto_approve and a fallback that lands people are untouched in logic (fallbackDegraded is a marker read only by the reason string) and all three are pinned as controls; OOO delegation rides the shared expansion (pinned: u_admin out of office, u_cover on the slate, told once by approval.ooo_substituted).
  7. One system timeline note: a sys_approval_action row with action: 'comment', actor_id: null, organization_id: requestOrg, written after the submit row only when routedToAdministrators is set. RIGHT: card item 1's "the request's display says it was routed"; comment is a kind of APPROVAL_ACTION_KINDS the thread route already writes. It is one new tenant-stamped write call site, and the census on this head moves against main by exactly that one (approval-service.ts insert sys_approval_action 14 to 15; totals 231 to 232, decidable 156 to 157, elevated 126 to 127, measured-at 5d0a4a152); check-tenant-audit-census is inside Lint & Repo Gates, success.
  8. listRequests / countRequests: awaitingRequestIds and unstaffedOverridableRequestIds removed; both read approverRequestIds(approverTargets, tenantOrg) again, the 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 shape (compared against c1b7f2b168~1). RIGHT. slateNamesAPerson stays for the open path; its docblock no longer names the arm. git grep on the head finds no identifier-shaped unstaffed (unstaffed?:, ?unstaffed, .unstaffed, unstaffed:) outside tests, changesets and changelogs; the prose word survives only in docblocks about unstaffed positions.
  9. Fan-out and decision: the administrators are told once on approval.requested through the unchanged fan-out, and a decision by one of them is a slot decision recorded with via_override: false. RIGHT: card item 2, pinned at the service layer (the 10-case integration file on a real ObjectQL over SqlDriver with the real resolveUserAuthzGrants) and on a real boot through the REST door (both administrators listed with total: 1 and can_act: true, one notification each; a member sees and holds nothing; the co-admin's approve records via_override: false and the routing note is on the timeline).
  10. @objectstack/lint: the two verdict messages, two hints and the one os explain paragraph of approval-approvers-may-resolve-empty now say the request opens on the organization's administrators and name onEmptyApprovers: 'fallback' as the way to name who decides; rule id, severity and firing conditions unchanged; the one-line-verdict pins and the facts pin updated. RIGHT: card item 6 asks for the fallback suggestion, which is hint text. Zero waits forever left in either file (diff read).
  11. Docs content/docs/automation/approvals.mdx: the new "When a slate names no one" section; the manager callout, the policy list, the fallback text, the notification callout and the admin-override callout re-framed; the unstaffed filter paragraph and its curl removed. RIGHT: says what ships. Check Documentation Links and Build Docs success.
  12. Pins retired with the code they pinned, none skipped or disabled: the 9-case unstaffed arm (#22725) block (approval-service.test.ts is byte-identical to its 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 self: git diff --stat c1b7f2b168~1 FETCH_HEAD on it is empty), the REST §4 block (item 3), the dogfood arm pin and fixture (rewritten under the new names, R055). Fixtures the behaviour change turned red, each keeping its own subject: override-composite routes to a departed named approver (break-glass case 1 of card item 3); my-pending and position-address-readers add a named co-approver so the position: literal stays on the slate for the late holder; flow-node-config asserts the administrator's id; activity-text-predicate and both snapshot pins stamp organization_id on the sys_user_position rows they seed. RIGHT: read line by line; none weakens what it pinned. Both census files move by the one write site only.
  13. Scope and landing shape: 31 files, +1051 / -801 (under the 3,000 queue threshold); no governed-surface path (docs/audits/ and content/docs/ are not register rows in check-governed-merges.mjs); head repo equals base repo; no review submitted (/reviews count 0); the claim's prohibitions hold (no isOverrideActor authority change, no plugin-security edit, fallback / auto_approve / fail unchanged, no objectui change).
  14. Gate verdicts on this head: the seven required contexts of the main ruleset (TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Lint & Repo Gates, Governed Surface Queue Guard) are success; every other non-skipped check is success; the four skipped ones are Auto Label, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in). The only failure is Check Changeset (job 114515873908), and it is ONLY the declared class: step 11 (require a changeset) success, step 12 (check-empty-changeset.mjs) failure with exactly one file annotation, naming .changeset/22725-approvals-unstaffed-arm.md under the finding: random changeset filenames collide silently across parallel agents — a round overwrote a sibling PR's minor changeset and every gate stayed green #17712 foreign-changeset rule and its two-class remedy, step 13 (ADR-0087 disposition) success, step 15 (major guard) success; the other two annotations are the step's exit code and a runner-image notice. That is the DELIBERATE CORRECTION class the script header and the workflow's DISCRIMINATOR text describe, confirmed on the PR in writing (6109850105, "Confirm deletion"), skip-changeset not applied (ruling D; the label is absent). It is not a required context, and pr-automation.yml declares no merge_group trigger, so it stays red by design and does not reach the queue.

② Semver level

  • .changeset/22824-admin-rescue-named-slate.md declares @objectstack/plugin-approvals: minor, @objectstack/spec: patch, @objectstack/lint: patch. Each matches what the diff publishes against the released 17.7.0: plugin-approvals gains a routing behaviour (who receives an admin_rescue request), stated FROM to TO in the body, with the one released behaviour it changes also written down (an all-literal slate no longer keeps the position: literal for a later holder; an administrator reassigns), so minor is the floor and the right level, as the card ruled the direction and nothing an author can write is removed or renamed; spec moves only TSDoc and .describe() text (the retired member never shipped, item ①.1), patch; lint moves only verdict, hint and explain text, no rule added or removed, patch. @objectstack/rest is correctly absent: against 17.7.0 the route's shipped content does not move (item ①.3). The deleted 22725 note announced an arm this PR retires before it ever ships; keeping it would publish a false release note, so the deletion is the correction (①.14). The frontmatter is non-empty, so the check-empty-changeset rule 1 is not touched.
  • Clause-②: yes. The PR body carries exactly one line scripts/pm/clause2-line.mjs's CLAUSE2_KEY_LINE reads as a declaration (Clause-②: yes, key at line start, no arm); the second spelling in the body is mid-prose inside a bullet that starts with a file name, which that pattern does not read. The changeset body carries the same line. Consistent with triage, the claim and the diff: nothing released is removed, so no (narrowing) arm is owed; yes takes at least one minor, which plugin-approvals carries (Check Changeset step 15 success); no breaking arm, so no ADR-0087 marker is owed (step 13 success). No model identifier in the body, no skip-changeset label.

③ Boundary flags

Round 0 (6109812487) deviations, each answered:

  1. Lint hints and the os explain paragraph edited beyond "the message text only". Right. Those texts said "waits forever" / "recoverable only by a platform/tenant admin override", which this diff falsifies; leaving them would advertise behaviour the runtime no longer delivers. Logic, severity and ids untouched; the conflict was named, not silently resolved; card item 6 itself asks the rule to suggest fallback, which is hint text.
  2. File surface beyond the claim (the notification test header, five dogfood pins and three fixtures, the two census files). Right: each was turned red by the behaviour change and each keeps its own subject (①.12, ①.7).
  3. The dogfood arm pin rewritten under new names rather than kept under the retired arm's name. Right: the retired arm's pin retires with the arm; the new pin carries card item 7 on a real boot.
  4. Check Changeset red by design, confirmation asked on the PR, not routed around. Right, verified at the job-step and annotation level on this head (①.14); the written confirmation is 6109850105.
  5. Mechanism assumption 2 partly false (no reader resolves "the users holding the rung in organization X"); assumption 3 partly false (the fallback branch keeps resolution inputs, not the abandoned slate). Right resolutions: composing the per-user resolver over the organization's members keeps one notion of administrator with no plugin-security edit (①.4, ①.5); the new __resolvedFrom keys mirror the fallback branch's flat, policy-prefixed shape, so there is no second snapshot shape and the card's intent (abandoned slate and reason kept) is met.
  6. Commit trailers are the model-free pair AGENTS.md requires, not the model-named one the harness offered. Compliant.
  7. Zero label writes. Right: no label was dispatched and skip-changeset is refused for this class.
  8. Line budget: 31 files, +1053 / -803 in round 0, +1051 / -801 on this head; the excess over the card's 400 to 700 estimate is pins. Right: under the queue threshold, and the pins are the card's item 7.

Patch round 1 (6110138030) deviations: the census page's prose figures edited by hand as check-tenant-audit-census prescribes after the merge took main's bytes; the worktree recreated. Right, and superseded on this head by round 2's regeneration; mechanics only.

Patch round 2 (6111036900) deviations, each answered:

  1. The push came before the re-runs the order listed first. Right: the merge-tree check and a fresh fetch preceded the push, every reading in that report is on the pushed head e25e6c27f, and this review reads the head's own check-runs, not the report.
  2. The census page's prose figures edited by hand. Right: the gate prescribes it for the hand-written region, the generated regions and the counts file are generator output, and the delta against main is exactly the one new write site (①.7); Lint & Repo Gates success.
  3. The worktree recreated for the round and removed after it. Mechanics only.

open_questions: none in any of the three rounds.

out_of_scope_findings (round 0; none in rounds 1 and 2), each unchanged on this head:

  • Organization-less sys_user_position holders: expandPositionUsers matches organization_id by strict equality while the acting path admits a global assignment, so before this change such a holder could still act through the position:NAME literal, and after it an all-literal slate opens on the administrators instead. Not introduced by this diff and not a defect of it: the expansion read is pre-existing and documented as the fail-closed reading of unknown tenancy shared with plugin-sharing; rows written through a door and the showcase seed are stamped; the measured unstamped population was three hand-seeded fixtures, now stamped. Escalated to the seat: a reader asymmetry with a repro is a tenancy-contract inconsistency, which is card material rather than an acceptance note; file it unassigned with the dev's dedupe words (position expansion organization-less assignment) or record why not. Not a verdict carrier.
  • docs/qa/platform-checklist/areas/approvals.json (the empty-slate fork item, :1237 / :1266 / :1268 / :1272 on this head) still says admin_rescue is decidable only via the privileged override and that its pending_approvers holds only the literal slot. Internal QA text, not a published surface, so the falsified-text clause does not reach it in this PR, but the item is now false and will mislead the next checklist run. Escalated to the seat as a one-line follow-up for the checklist-author lane. Not a verdict carrier.

Implemented-by: claude/issue-22824-admin-rescue-named-slate
Reviewed-by: session_01CBAfsWMSfM3EToQGVStEcp

VERDICT: PASS


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants