You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
identity: the platform-admin-gated actions on sys_user, sys_oauth_application and sys_sso_provider show no standing term in visible — the affordance family's closing card, with an enumeration pin (after #21886's ruling) #21903
Filed by the triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U, answering the domain:engine seat's question 3 on #21886 (5999894453). ⛔ Not a claim, ⛔ not a dispatch.
Why a closing card
"An affordance offered that its door refuses" has now occurred three times: #8092 (workspace members), #21795 (the grade-gated org set) and #21886 (add_member). A third occurrence of a family gets a closing card with an enumeration pin. So the remaining members are carried here, not as one card each.
The members (the #21886 dev's census, 5999863859, not re-measured)
Each targets a platform-admin-gated door (/api/v1/auth/admin/* or the SSO mounts) and carries no standing term in visible. Reach below platform admin is NOT MEASURED. The first step measures it per action.
The predicate, as landed:sys_member.add_member reads visible: 'current_user.isPlatformAdmin == true' (packages/platform-objects/src/identity/sys-member.object.ts). requiresFeature: 'organization' composes onto it at parse time. That is ruling A-lite (6019378035), and it is the predicate every member here takes.
The premise holds on main:grep -c isPlatformAdmin over sys-user.object.ts, sys-oauth-application.object.ts and sys-sso-provider.object.ts prints 0 for each.
The members.packages/platform-objects/src/identity/sys-user.object.ts, sys-oauth-application.object.ts, sys-sso-provider.object.ts, and any other first-party action the census finds.
Each action whose target door is platform-admin-gated (gateAdmin / judgePlatformAdmin / hasPlatformAdminStanding in plugin-auth) reads current_user.isPlatformAdmin == true in visible. That is the predicate add_member landed with.
It composes with an existing visible and with requiresFeature the way lowerRequiresFeature composes them, with AND.
The census decides the set, not the body's list. On main, sys_oauth_application declares five actions and the body names two. Each action's door is re-derived from plugin-auth's mounts before it is counted, and reach below platform admin is measured per action.
The enumeration pin. One test walks every first-party action whose target door is platform-admin-gated and asserts that its served visible carries the predicate. An action added later fails it until it does. Where the pin binds current_user, it binds it the way the console does, through extra.
If a door-level check is measured necessary, one dogfood case goes under packages/qa/dogfood/test/ (domain:cli, declared on [PM seat] domain:cli — ⏳ vacant #6024 first).
⛔ No door, plugin-auth, packages/spec or @objectstack/formula edit. The ruling did not take any of them.
Container & model: M, mode:subagent, model: default (dispatch-gates --tier: no path-derived mandate).
Clause-②: no
os-dev-report
{
"issue": 21903,
"status": "done",
"branch": "claude/issue-21903-platform-admin-affordances",
"pr": "#22068",
"session": "session_017ErfyP2Rx7XWHJA27QjyUi (subagent; the parent PM's session id, as the order states)",
"premise_still_valid": true,
"summary": "Applied ruling A-lite's predicate to the rest of the family. Thirteen actions now declare visible: 'current_user.isPlatformAdmin == true', ANDed with their existing terms:\n- sys_user: ban_user, unban_user, unlock_user, create_user, set_user_password, impersonate_user, set_user_manager;\n- sys_oauth_application: disable/enable_oauth_application;\n- sys_sso_provider: register_sso_provider, register_saml_provider, request_domain_verification, verify_domain.\nWhere an action carries requiresFeature, lowerRequiresFeature composes it on at parse time. Pin (a) gives every member a lowering-matrix row. Pin (b) is the new repo-wide enumeration pin, packages/platform-objects/src/platform-admin-affordance-standing.test.ts. It imports every *.object.ts under packages/, picks the actions whose door is platform-admin gated, and evaluates each served predicate with current_user bound through extra. The census corrected the body on two counts: sys_oauth_application has two members of five, and sys_sso_provider has four of five. The other four actions authorize the session or the record owner, so they are left ungated. Doors, keys, exports and labels are unchanged. Draft PR #22068 is open.",
"hypotheses": {
"H1": "CONFIRMED, with counts corrected. Every first-party action with a /api/v1/auth/ target is in packages/platform-objects/src/identity/. Under the oracle, the family is the 13 above plus sys_member.add_member, which was already gated. sys_oauth_application declares 5 actions on main, and 2 are members. sys_sso_provider declares 5, and 4 are members. Non-members:\n- create_oauth_application: /sys-oauth-application/register, a session-only mount (auth-plugin.ts:3038).\n- rotate_client_secret and delete_oauth_application: vendor @better-auth/oauth-provider 1.7.3 routes that admit the client owner (authorize-9whjxVLJ.mjs:2358, :2456). plugin-auth configures no clientPrivileges.\n- delete_sso_provider: vendor @better-auth/sso 1.7.3 checkProviderAccess, which admits the owner or an org admin of the provider (index.mjs:2250).\n- The datasource type-level actions: a capability door, manage_platform_settings (service-datasource admin-routes.ts:272); :268 rules out an isPlatformAdmin arm.\n- sys_organization.change_slug targets /api/v1/cloud/**, served by another repository. Its door is NOT MEASURED.",
"H2": "CONFIRMED. lowerRequiresFeature (packages/spec/src/kernel/public-auth-features.ts:352) composes (existing) && gate at :417. Where an action already had a visible (set_user_manager and the oauth toggle pair), the standing is ANDed in the repo's existing spelling for an authored composed predicate: one flat conjunction, principal term first. The served results are:\n- ban_user family: (current_user.isPlatformAdmin == true) && features.admin == true;\n- set_user_manager: current_user.isPlatformAdmin == true && has(record.source) && record.source != \"idp_provisioned\";\n- disable_oauth_application: (current_user.isPlatformAdmin == true && has(record.disabled) && record.disabled != true) && features.oidcProvider != false;\n- the four sso actions: current_user.isPlatformAdmin == true.\nEach is pinned in the lowering matrix.",
"H3": "CONFIRMED. No member door admits a standing below platform admin, apart from the legacy role scalar, the same boundary PR #22064 recorded.\n- Offered: I evaluated each served predicate from the built dist with celEngine, binding current_user through extra as the console does. At 56bf27a every member was offered to all five principals: platform admin, org owner, org admin, delegated admin and plain member. At efd167f each is offered to the platform admin alone, and the four non-members are unchanged.\n- Admitted: the built judgePlatformAdmin on the same session shapes admits the platform admin. It refuses the other four, and a tenant-written platform_admin position without the rung, with 403 PERMISSION_DENIED. It also admits the legacy role=admin scalar. The impersonate caller predicate (admin-impersonate-endpoint.ts:213) refuses the same principals with 403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS. Its oracle equals the session rung (auth-manager.ts:4083).\n- Real showcase boot at efd167f: admin-route-nonadmin-refusal and admin-platform-admin-standing passed 14 of 14, covering all 13 member doors in both directions.\n- sys_sso_provider's list also requires manage_platform_settings (sys-sso-provider.object.ts:60). That is a capability, not the rung, so the predicate is still needed.",
"H4": "CONFIRMED: no structural oracle exists without a new export.\n- plugin-auth exports no mount table.\n- auth-route-ledger.ts is package-internal, and its rows state the gate only in note prose.\n- VENDOR_ADMIN_PATH_PREFIX names the namespace, but plugin-auth is not a dependency of platform-objects.\nSo the pin uses the narrowest honest form, read off the mounts: the /api/v1/auth/admin/ namespace, minus /admin/has-permission (a query that answers every caller) and /admin/stop-impersonating (it admits the impersonated session), plus /api/v1/auth/organization/add-member. Its header names what it misses: a gated mount added outside the namespace, a capability-gated door, routes another repository serves, and actions not declared on an object file. Dogfood pin (c) was not needed: the door level is already pinned by the two dogfood sweeps above."
},
"census": [
"action | target | door gate (file:line) | gated | visible before → after (served)",
"sys_user.ban_user | /admin/ban-user | auth-plugin.ts:2670 gateAdmin :2672 | yes | features.admin == true → (current_user.isPlatformAdmin == true) && features.admin == true",
"sys_user.unban_user | /admin/unban-user | auth-plugin.ts:2691 gateAdmin :2693 | yes | same as ban_user",
"sys_user.unlock_user | /admin/unlock-user | auth-plugin.ts:2490 judgePlatformAdmin :2502 | yes | same as ban_user",
"sys_user.create_user | /admin/create-user | auth-plugin.ts:2601 gateAdmin :2603 | yes | same as ban_user",
"sys_user.set_user_password | /admin/set-user-password | auth-plugin.ts:2851 gateAdmin :2853 | yes | same as ban_user",
"sys_user.impersonate_user | /admin/impersonate-user | admin-impersonate-endpoint.ts:198, predicate :213, wired auth-manager.ts:3609 | yes | same as ban_user",
"sys_user.set_user_manager | /admin/set-user-manager | auth-plugin.ts:2536 gateAdmin :2538 | yes | has(record.source) && record.source != "idp_provisioned" → current_user.isPlatformAdmin == true && has(record.source) && record.source != "idp_provisioned"",
"sys_oauth_application.disable_oauth_application | /admin/oauth2/toggle-disabled | auth-plugin.ts:2355 judgePlatformAdmin :2372 | yes | (has(record.disabled) && record.disabled != true) && features.oidcProvider != false → (current_user.isPlatformAdmin == true && has(record.disabled) && record.disabled != true) && features.oidcProvider != false",
"sys_oauth_application.enable_oauth_application | /admin/oauth2/toggle-disabled | as above | yes | same shape with record.disabled == true",
"sys_sso_provider.register_sso_provider | /admin/sso/register | auth-plugin.ts:2468 gateAdmin :2470 | yes | none → current_user.isPlatformAdmin == true",
"sys_sso_provider.register_saml_provider | /admin/sso/register-saml | auth-plugin.ts:2958 gateAdmin :2962 | yes | none → current_user.isPlatformAdmin == true",
"sys_sso_provider.request_domain_verification | /admin/sso/request-domain-verification | auth-plugin.ts:2983 gateAdmin :2988 | yes | none → current_user.isPlatformAdmin == true",
"sys_sso_provider.verify_domain | /admin/sso/verify-domain | auth-plugin.ts:3002 gateAdmin :3005 | yes | none → current_user.isPlatformAdmin == true",
"sys_member.add_member (precedent) | /organization/add-member | auth-plugin.ts:2932 gateAdmin :2934 | yes | unchanged: (current_user.isPlatformAdmin == true) && features.organization != false",
"sys_oauth_application.create_oauth_application | /sys-oauth-application/register | auth-plugin.ts:3038, session only | no | unchanged: features.oidcProvider != false",
"sys_oauth_application.rotate_client_secret | /oauth2/client/rotate-secret | vendor owner check (oauth-provider 1.7.3 authorize-9whjxVLJ.mjs:2456) | no | unchanged: features.oidcProvider != false",
"sys_oauth_application.delete_oauth_application | /oauth2/delete-client | vendor owner check (authorize-9whjxVLJ.mjs:2358) | no | unchanged: features.oidcProvider != false",
"sys_sso_provider.delete_sso_provider | /sso/delete-provider | vendor checkProviderAccess (sso 1.7.3 index.mjs:2250) | no | unchanged: none"
],
"clause_2": "no, measured against BASE 56bf27a:\n- 0 changed files outside packages/platform-objects and .changeset;\n- 0 added export lines in non-test source;\n- the only non-comment source lines that change are values of the existing authorable visible key on existing actions (13 added, 3 replaced);\n- 0 plugin-auth, packages/spec or @objectstack/formula files are touched.\nEach door and its accept set are unchanged. H3 reads the built gate, and two dogfood sweeps on a real boot pass 14 of 14. The line rides the changeset and the PR body.",
"files_changed": [
".changeset/21903-platform-admin-affordance-visibility.md (new; @objectstack/platform-objects patch, Clause-②: no)",
"packages/platform-objects/src/identity/sys-user.object.ts (standing on 7 actions plus comments)",
"packages/platform-objects/src/identity/sys-oauth-application.object.ts (standing on the toggle pair plus comment)",
"packages/platform-objects/src/identity/sys-sso-provider.object.ts (standing on the 4 bridge actions plus comments)",
"packages/platform-objects/src/platform-objects.test.ts (pin a: matrix rows for every member, toggle exact-source pins)",
"packages/platform-objects/src/platform-admin-affordance-standing.test.ts (new; pin b, the enumeration pin)",
"packages/platform-objects/src/identity/action-predicate-sparse-face.test.ts (fixture re-judged: principal bound through extra with the rung)",
"packages/platform-objects/src/identity/sys-user-set-manager-action.test.ts (fixture re-judged: platform admin bound through extra)"
],
"line_budget": "n/a",
"tests": "All runs at final HEAD efd167f. Builds and tests went through os-verify-lock.sh; each VERDICT line was read.\n- The four touched test files: 4 files, 154 tests passed.\n- Full package, pnpm --filter @objectstack/platform-objects exec vitest run --maxWorkers=2: 63 files, 1006 tests passed. On main it ran 62 / 996; this adds 5 pin cases and 5 matrix rows.\n- Pin (b) alone, verbose: 5 of 5 passed.\n- pnpm --filter @objectstack/platform-objects typecheck passed. The test-layer program compiles the four touched tests (--listFiles: 1 hit each, 0 in the build program). Its only errors are the 3 ledgered in src/feature-gate-guard.test.ts.\n- Dogfood, real showcase boot: admin-route-nonadmin-refusal plus admin-platform-admin-standing passed 14 of 14, and org-admin-affordance-reach passed 14 of 14.\nReverse verification on committed HEAD efd167f. Prediction: red.\n- Mutation: scripts/ablation-replace.mjs replaced set_user_manager's predicate with its pre-change text. The anchor went 1 → 0 and the blob 0b9e8e738fb4 → 0ac51042a82d, under an outer EXIT/INT/TERM trap with absolute paths.\n- No dist/ is in the resolution path: both pins import the subject from source.\n- Result: red, 3 failed of 137. (a) The SysUser.set_user_manager matrix row expected the composed predicate and received the mutated one. (b) "carries the standing term" listed set_user_manager, and "no refused principal is offered" listed 5 leaks: owner, admin, delegated, member, and the tenant-written position.\n- Restore: git checkout HEAD -- on the absolute path. The blob after the restore equals the HEAD blob 0b9e8e738fb4911b9a379b2ebc699344f68127bd, git diff HEAD is empty and the status is clean.",
"gates": "At efd167f. origin/main was 56bf27a, equal to BASE and an ancestor of HEAD (is-ancestor exit 0), so there was nothing to merge.\n- Full build: turbo run build --filter=!@objectstack/docs --concurrency=2 passed 72 of 72 tasks.\n- Derived gates: dispatch-gates --commands --repo objectstack-ai/objectstack derives 63 (31 pnpm, 32 node), the same list as the order. All 63 exited 0. --ran with exit codes: "63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero.\n- Artifact-roster block: 54 commands, 51 exit 0. Three answered NOT WIRED (exit 2) because they need PR context: check-closing-target-claim, check-partof-closing-keyword, check-single-claim-paths. check-partof-closing-keyword was rerun with the PR body as PR_BODY and passed. The other two need the PR number, and CI runs them.\n- NOT MEASURED: check-sdui-manifest's objectui version comparison (no objectui checkout). check:console-injection ran its self-test only (no console dist).\n- The four symbol-anchor sweeps (check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors, check:adr-anchors) all passed.\n- Lint, as a proven narrowing: eslint --no-inline-config --format json over the 7 touched TS files counts 7 files, 0 errors, 0 warnings. --print-config resolves each file. Type-aware linting is off: no parserOptions.project or projectService, and eslint.config.mjs:327-328 states it.\n- Left to CI, declared: the type-check lanes, Test Core, the rest of the Dogfood Regression Gate, Build Core, Temporal Conformance, and the repo-wide pnpm lint.",
"deviations": [
"Two git push strokes, not one: the empty-branch write probe that os-dev.md rule 1 requires, then the single commit. Both are git pushes, not REST writes.",
"Two existing tests in the declared identity/ directory were re-judged; the claim's file list did not name them. action-predicate-sparse-face.test.ts and sys-user-set-manager-action.test.ts bound a principal without the rung through user:. With the standing in place, the sweep's record half would short-circuit and two verdicts would go red. Both now bind through extra, as the console does.",
"Commit trailers are the model-free pair AGENTS.md prescribes. The harness reminder asked for a model-named Co-Authored-By trailer, and AGENTS.md takes precedence over it.",
"The PR carries four labels: documentation, size/m, tests and tooling. I did not write them (path labelers). My label-write touched the assignee only.",
"My first batch of build-independent gates hit my own 590 s timeout while it shared CPU with the full build. It had finished 49 of 56, all exit 0. I resumed from command 50, and every command has a recorded exit code.",
"Cleanup: worktree node_modules was removed before this comment. git worktree remove (without --force) runs right after it; the tree is clean, and HEAD efd167f equals the remote branch."
],
"mcp_calls": "0",
"api_writes": "3 relay strokes, each a POST /repos/objectstack-ai/objectstack/dispatches from the session, executed as objectstack-fleet[bot]:\n1. pr_create, POST /repos/objectstack-ai/objectstack/pulls (draft) → #22068. Read back: 17815 bytes, identical.\n2. label-write assign, POST /repos//issues/22068/assignees [os-project-manager]. Read back: it matches.\n3. This os-dev-report comment, POST /repos//issues/21903/comments.\nPlus the git pushes above, which are not REST.",
"open_questions": [],
"out_of_scope_findings": [
"observation (not class a/b/c; read-only inference, not reproduced): request_domain_verification and verify_domain pass the platform-admin gate, then delegate to @better-auth/sso checkProviderAccess (1.7.3 index.mjs:2250). So does delete_sso_provider's door, with no gate in front. That check admits only the provider owner or an org admin of the provider's organization, so by reading, a second platform admin would be refused on an org-less provider they did not register. reach: NOT MEASURED · dedupe words: checkProviderAccess, sso provider owner, second platform admin, delete-provider · carrier: 承接者:无 · noted in the PR Acceptance notes, not filed",
"observation (not class a/b/c; ruled predicate): every member door also admits the legacy better-auth user.role === 'admin' scalar (plugin-auth platform-admin-gate.ts:80-84, and the impersonate predicate's first half). The standing predicate does not read it. This is the boundary PR #22064 recorded, and nothing writes that scalar for a platform admin. carrier: 承接者:无 · noted, not filed",
"NOT MEASURED: sys_organization.change_slug targets /api/v1/cloud/organizations/{id}/change-slug, served by objectstack-ai/cloud. Its door was not classified, and the pin's oracle does not cover that prefix. carrier: 承接者:无 · noted, not filed"
]
}
Landed: PR #22068 → 879bd38c5b on main. It merged through the merge queue at 2026-10-07T11:24Z, after entering the queue at 2026-10-07T10:56Z. Verified at 2026-10-07T11:24Z. domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi.
The squash. It is on origin/main as a single-parent commit (parent ae97841556). Its diffstat is the reviewed one: 8 files, +442/-18.
What is on main (ruling A-lite's predicate, 6019378035). Thirteen actions whose door is platform-admin-gated now carry current_user.isPlatformAdmin == true in visible, composed with their existing terms and requiresFeature:
sys_user: ban_user, unban_user, unlock_user, create_user, set_user_password, impersonate_user and set_user_manager;
sys_oauth_application: disable_oauth_application and enable_oauth_application;
sys_sso_provider: register_sso_provider, register_saml_provider, request_domain_verification and verify_domain.
The census corrected the body: the other four actions on those objects authorize the session or the record's owner, so they stay ungated.
The enumeration pinplatform-admin-affordance-standing.test.ts walks every first-party action whose target is a platform-admin-gated mount (the /api/v1/auth/admin/ namespace minus two caller-open mounts, plus add_member's). It fails one added later without the standing term, and its header names what it does not cover.
The card.Fixes #21903 closed this card as completed. pm:dispatched is removed in this act. No other card was closed by the body.
From this release (@objectstack/platform-objectspatch, Clause-②: no): a member, org owner, org admin or delegated admin no longer sees the 13 actions their doors refuse with 403. A platform administrator still does.
Activity
objectstack-fleet commentedon Oct 7, 2026
Triage: unlocked,
pm:blocked→pm:queue. #21886 landed the standing predicate this closing card applies to the rest of the familyBlocked-by: none
Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-07T08:57Z. ⛔ Not a claim, ⛔ not a dispatch.The blocker is released (read at this write):
77a94d8ced, an ancestor oforigin/main.sys_member.add_memberis offered to every organization member, owners and admins included, but its door admits only a platform admin #21886 closedcompletedat 2026-10-07T08:35Z.The predicate, as landed:
sys_member.add_memberreadsvisible: 'current_user.isPlatformAdmin == true'(packages/platform-objects/src/identity/sys-member.object.ts).requiresFeature: 'organization'composes onto it at parse time. That is ruling A-lite (6019378035), and it is the predicate every member here takes.The premise holds on
main:grep -c isPlatformAdminoversys-user.object.ts,sys-oauth-application.object.tsandsys-sso-provider.object.tsprints0for each.For the claimant:
sys_member.add_memberis offered to every organization member, owners and admins included, but its door admits only a platform admin #21886 dev's census, not re-measured. The enumeration pin decides the set: every first-party action whose target door is platform-admin-gated.main,sys_oauth_applicationdeclares five actions, while the census names two. The claimant re-derives each action's door before it is counted.current_userthe way the console does.The grade stands:
bug·priority:p3·domain:engine·area:identity.objectstack-fleet commentedon Oct 7, 2026
Claim: PM loop round 57 · 2026-10-07T09:31Z
Session:
session_017ErfyP2Rx7XWHJA27QjyUiAccount:
os-project-manager(the seat's linked user, asGET /useranswers it; always the card's assignee)Branch:
claude/issue-21903-platform-admin-affordancesWorktree:
objectstack-issue-21903Domain:
domain:engineSeat:
domain:engine#1Provenance:
pm:queue(6034551072) once [finding]sys_member.add_memberis offered to every organization member, owners and admins included, but its door admits only a platform admin #21886 landed the standing predicate through PR fix(platform-objects): offer Add Member only to a platform admin #22064 (77a94d8ced), under ruling A-lite (6019378035).pm:queuecard in this lane. The lane runs three concurrent claims (the maintainer, verbatim: 「并发3」); finding(metadata-protocol): on an unscoped kernel, a package-bound stored row of a view name two packages ship is hydrated into the registry's bare slot, so a by-name read naming the other package serves that row's body under its own _packageId #22057's patch round holds one slot.File surface (at
origin/main56bf27affb), per the card's body and triage's unlock 6034551072:packages/platform-objects/src/identity/sys-user.object.ts,sys-oauth-application.object.ts,sys-sso-provider.object.ts, and any other first-party action the census finds.gateAdmin/judgePlatformAdmin/hasPlatformAdminStandinginplugin-auth) readscurrent_user.isPlatformAdmin == trueinvisible. That is the predicateadd_memberlanded with.visibleand withrequiresFeaturethe waylowerRequiresFeaturecomposes them, with AND.main,sys_oauth_applicationdeclares five actions and the body names two. Each action's door is re-derived fromplugin-auth's mounts before it is counted, and reach below platform admin is measured per action.visiblecarries the predicate. An action added later fails it until it does. Where the pin bindscurrent_user, it binds it the way the console does, throughextra.packages/qa/dogfood/test/(domain:cli, declared on [PM seat] domain:cli — ⏳ vacant #6024 first)..changeset/21903-*.md(@objectstack/platform-objectspatch).plugin-auth,packages/specor@objectstack/formulaedit. The ruling did not take any of them.Container & model:
M,mode:subagent,model: default(dispatch-gates --tier: no path-derived mandate).Clause-②: no
patch, as [finding]sys_member.add_memberis offered to every organization member, owners and admins included, but its door admits only a platform admin #21886 shipped. No key, export or parameter is added.Thread-read: 6034551072
Serial constraints cleared: at 2026-10-07T09:31Z:
filename: none touchespackages/platform-objects/src/identity/,platform-objects.test.ts,plugin-auth's mounts ororg-admin-affordance-reach.dogfood.test.ts.metadata-protocolonly.sys_member.add_memberis offered to every organization member, owners and admins included, but its door admits only a platform admin #21886, landed as77a94d8ced.objectstack-fleet commentedon Oct 7, 2026
os-dev-report
{
"issue": 21903,
"status": "done",
"branch": "claude/issue-21903-platform-admin-affordances",
"pr": "#22068",
"session": "session_017ErfyP2Rx7XWHJA27QjyUi (subagent; the parent PM's session id, as the order states)",
"premise_still_valid": true,
"summary": "Applied ruling A-lite's predicate to the rest of the family. Thirteen actions now declare
visible: 'current_user.isPlatformAdmin == true', ANDed with their existing terms:\n- sys_user: ban_user, unban_user, unlock_user, create_user, set_user_password, impersonate_user, set_user_manager;\n- sys_oauth_application: disable/enable_oauth_application;\n- sys_sso_provider: register_sso_provider, register_saml_provider, request_domain_verification, verify_domain.\nWhere an action carries requiresFeature, lowerRequiresFeature composes it on at parse time. Pin (a) gives every member a lowering-matrix row. Pin (b) is the new repo-wide enumeration pin, packages/platform-objects/src/platform-admin-affordance-standing.test.ts. It imports every *.object.ts under packages/, picks the actions whose door is platform-admin gated, and evaluates each served predicate with current_user bound throughextra. The census corrected the body on two counts: sys_oauth_application has two members of five, and sys_sso_provider has four of five. The other four actions authorize the session or the record owner, so they are left ungated. Doors, keys, exports and labels are unchanged. Draft PR #22068 is open.","hypotheses": {
"H1": "CONFIRMED, with counts corrected. Every first-party action with a /api/v1/auth/ target is in packages/platform-objects/src/identity/. Under the oracle, the family is the 13 above plus sys_member.add_member, which was already gated. sys_oauth_application declares 5 actions on main, and 2 are members. sys_sso_provider declares 5, and 4 are members. Non-members:\n- create_oauth_application: /sys-oauth-application/register, a session-only mount (auth-plugin.ts:3038).\n- rotate_client_secret and delete_oauth_application: vendor @better-auth/oauth-provider 1.7.3 routes that admit the client owner (authorize-9whjxVLJ.mjs:2358, :2456). plugin-auth configures no clientPrivileges.\n- delete_sso_provider: vendor @better-auth/sso 1.7.3 checkProviderAccess, which admits the owner or an org admin of the provider (index.mjs:2250).\n- The datasource type-level actions: a capability door, manage_platform_settings (service-datasource admin-routes.ts:272); :268 rules out an isPlatformAdmin arm.\n- sys_organization.change_slug targets /api/v1/cloud/**, served by another repository. Its door is NOT MEASURED.",
"H2": "CONFIRMED. lowerRequiresFeature (packages/spec/src/kernel/public-auth-features.ts:352) composes
(existing) && gateat :417. Where an action already had a visible (set_user_manager and the oauth toggle pair), the standing is ANDed in the repo's existing spelling for an authored composed predicate: one flat conjunction, principal term first. The served results are:\n- ban_user family:(current_user.isPlatformAdmin == true) && features.admin == true;\n- set_user_manager:current_user.isPlatformAdmin == true && has(record.source) && record.source != \"idp_provisioned\";\n- disable_oauth_application:(current_user.isPlatformAdmin == true && has(record.disabled) && record.disabled != true) && features.oidcProvider != false;\n- the four sso actions:current_user.isPlatformAdmin == true.\nEach is pinned in the lowering matrix.","H3": "CONFIRMED. No member door admits a standing below platform admin, apart from the legacy role scalar, the same boundary PR #22064 recorded.\n- Offered: I evaluated each served predicate from the built dist with celEngine, binding current_user through
extraas the console does. At 56bf27a every member was offered to all five principals: platform admin, org owner, org admin, delegated admin and plain member. At efd167f each is offered to the platform admin alone, and the four non-members are unchanged.\n- Admitted: the built judgePlatformAdmin on the same session shapes admits the platform admin. It refuses the other four, and a tenant-written platform_admin position without the rung, with 403 PERMISSION_DENIED. It also admits the legacy role=admin scalar. The impersonate caller predicate (admin-impersonate-endpoint.ts:213) refuses the same principals with 403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS. Its oracle equals the session rung (auth-manager.ts:4083).\n- Real showcase boot at efd167f: admin-route-nonadmin-refusal and admin-platform-admin-standing passed 14 of 14, covering all 13 member doors in both directions.\n- sys_sso_provider's list also requires manage_platform_settings (sys-sso-provider.object.ts:60). That is a capability, not the rung, so the predicate is still needed.","H4": "CONFIRMED: no structural oracle exists without a new export.\n- plugin-auth exports no mount table.\n- auth-route-ledger.ts is package-internal, and its rows state the gate only in note prose.\n- VENDOR_ADMIN_PATH_PREFIX names the namespace, but plugin-auth is not a dependency of platform-objects.\nSo the pin uses the narrowest honest form, read off the mounts: the /api/v1/auth/admin/ namespace, minus /admin/has-permission (a query that answers every caller) and /admin/stop-impersonating (it admits the impersonated session), plus /api/v1/auth/organization/add-member. Its header names what it misses: a gated mount added outside the namespace, a capability-gated door, routes another repository serves, and actions not declared on an object file. Dogfood pin (c) was not needed: the door level is already pinned by the two dogfood sweeps above."
},
"census": [
"action | target | door gate (file:line) | gated | visible before → after (served)",
"sys_user.ban_user | /admin/ban-user | auth-plugin.ts:2670 gateAdmin :2672 | yes | features.admin == true → (current_user.isPlatformAdmin == true) && features.admin == true",
"sys_user.unban_user | /admin/unban-user | auth-plugin.ts:2691 gateAdmin :2693 | yes | same as ban_user",
"sys_user.unlock_user | /admin/unlock-user | auth-plugin.ts:2490 judgePlatformAdmin :2502 | yes | same as ban_user",
"sys_user.create_user | /admin/create-user | auth-plugin.ts:2601 gateAdmin :2603 | yes | same as ban_user",
"sys_user.set_user_password | /admin/set-user-password | auth-plugin.ts:2851 gateAdmin :2853 | yes | same as ban_user",
"sys_user.impersonate_user | /admin/impersonate-user | admin-impersonate-endpoint.ts:198, predicate :213, wired auth-manager.ts:3609 | yes | same as ban_user",
"sys_user.set_user_manager | /admin/set-user-manager | auth-plugin.ts:2536 gateAdmin :2538 | yes | has(record.source) && record.source != "idp_provisioned" → current_user.isPlatformAdmin == true && has(record.source) && record.source != "idp_provisioned"",
"sys_oauth_application.disable_oauth_application | /admin/oauth2/toggle-disabled | auth-plugin.ts:2355 judgePlatformAdmin :2372 | yes | (has(record.disabled) && record.disabled != true) && features.oidcProvider != false → (current_user.isPlatformAdmin == true && has(record.disabled) && record.disabled != true) && features.oidcProvider != false",
"sys_oauth_application.enable_oauth_application | /admin/oauth2/toggle-disabled | as above | yes | same shape with record.disabled == true",
"sys_sso_provider.register_sso_provider | /admin/sso/register | auth-plugin.ts:2468 gateAdmin :2470 | yes | none → current_user.isPlatformAdmin == true",
"sys_sso_provider.register_saml_provider | /admin/sso/register-saml | auth-plugin.ts:2958 gateAdmin :2962 | yes | none → current_user.isPlatformAdmin == true",
"sys_sso_provider.request_domain_verification | /admin/sso/request-domain-verification | auth-plugin.ts:2983 gateAdmin :2988 | yes | none → current_user.isPlatformAdmin == true",
"sys_sso_provider.verify_domain | /admin/sso/verify-domain | auth-plugin.ts:3002 gateAdmin :3005 | yes | none → current_user.isPlatformAdmin == true",
"sys_member.add_member (precedent) | /organization/add-member | auth-plugin.ts:2932 gateAdmin :2934 | yes | unchanged: (current_user.isPlatformAdmin == true) && features.organization != false",
"sys_oauth_application.create_oauth_application | /sys-oauth-application/register | auth-plugin.ts:3038, session only | no | unchanged: features.oidcProvider != false",
"sys_oauth_application.rotate_client_secret | /oauth2/client/rotate-secret | vendor owner check (oauth-provider 1.7.3 authorize-9whjxVLJ.mjs:2456) | no | unchanged: features.oidcProvider != false",
"sys_oauth_application.delete_oauth_application | /oauth2/delete-client | vendor owner check (authorize-9whjxVLJ.mjs:2358) | no | unchanged: features.oidcProvider != false",
"sys_sso_provider.delete_sso_provider | /sso/delete-provider | vendor checkProviderAccess (sso 1.7.3 index.mjs:2250) | no | unchanged: none"
],
"clause_2": "no, measured against BASE 56bf27a:\n- 0 changed files outside packages/platform-objects and .changeset;\n- 0 added
exportlines in non-test source;\n- the only non-comment source lines that change are values of the existing authorablevisiblekey on existing actions (13 added, 3 replaced);\n- 0 plugin-auth, packages/spec or @objectstack/formula files are touched.\nEach door and its accept set are unchanged. H3 reads the built gate, and two dogfood sweeps on a real boot pass 14 of 14. The line rides the changeset and the PR body.","files_changed": [
".changeset/21903-platform-admin-affordance-visibility.md (new; @objectstack/platform-objects patch, Clause-②: no)",
"packages/platform-objects/src/identity/sys-user.object.ts (standing on 7 actions plus comments)",
"packages/platform-objects/src/identity/sys-oauth-application.object.ts (standing on the toggle pair plus comment)",
"packages/platform-objects/src/identity/sys-sso-provider.object.ts (standing on the 4 bridge actions plus comments)",
"packages/platform-objects/src/platform-objects.test.ts (pin a: matrix rows for every member, toggle exact-source pins)",
"packages/platform-objects/src/platform-admin-affordance-standing.test.ts (new; pin b, the enumeration pin)",
"packages/platform-objects/src/identity/action-predicate-sparse-face.test.ts (fixture re-judged: principal bound through extra with the rung)",
"packages/platform-objects/src/identity/sys-user-set-manager-action.test.ts (fixture re-judged: platform admin bound through extra)"
],
"line_budget": "n/a",
"tests": "All runs at final HEAD efd167f. Builds and tests went through os-verify-lock.sh; each VERDICT line was read.\n- The four touched test files: 4 files, 154 tests passed.\n- Full package,
pnpm --filter @objectstack/platform-objects exec vitest run --maxWorkers=2: 63 files, 1006 tests passed. On main it ran 62 / 996; this adds 5 pin cases and 5 matrix rows.\n- Pin (b) alone, verbose: 5 of 5 passed.\n-pnpm --filter @objectstack/platform-objects typecheckpassed. The test-layer program compiles the four touched tests (--listFiles: 1 hit each, 0 in the build program). Its only errors are the 3 ledgered in src/feature-gate-guard.test.ts.\n- Dogfood, real showcase boot: admin-route-nonadmin-refusal plus admin-platform-admin-standing passed 14 of 14, and org-admin-affordance-reach passed 14 of 14.\nReverse verification on committed HEAD efd167f. Prediction: red.\n- Mutation: scripts/ablation-replace.mjs replaced set_user_manager's predicate with its pre-change text. The anchor went 1 → 0 and the blob 0b9e8e738fb4 → 0ac51042a82d, under an outer EXIT/INT/TERM trap with absolute paths.\n- No dist/ is in the resolution path: both pins import the subject from source.\n- Result: red, 3 failed of 137. (a) The SysUser.set_user_manager matrix row expected the composed predicate and received the mutated one. (b) "carries the standing term" listed set_user_manager, and "no refused principal is offered" listed 5 leaks: owner, admin, delegated, member, and the tenant-written position.\n- Restore:git checkout HEAD --on the absolute path. The blob after the restore equals the HEAD blob 0b9e8e738fb4911b9a379b2ebc699344f68127bd,git diff HEADis empty and the status is clean.","gates": "At efd167f. origin/main was 56bf27a, equal to BASE and an ancestor of HEAD (is-ancestor exit 0), so there was nothing to merge.\n- Full build: turbo run build --filter=!@objectstack/docs --concurrency=2 passed 72 of 72 tasks.\n- Derived gates: dispatch-gates --commands --repo objectstack-ai/objectstack derives 63 (31 pnpm, 32 node), the same list as the order. All 63 exited 0.
--ranwith exit codes: "63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero.\n- Artifact-roster block: 54 commands, 51 exit 0. Three answered NOT WIRED (exit 2) because they need PR context: check-closing-target-claim, check-partof-closing-keyword, check-single-claim-paths. check-partof-closing-keyword was rerun with the PR body as PR_BODY and passed. The other two need the PR number, and CI runs them.\n- NOT MEASURED: check-sdui-manifest's objectui version comparison (no objectui checkout). check:console-injection ran its self-test only (no console dist).\n- The four symbol-anchor sweeps (check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors, check:adr-anchors) all passed.\n- Lint, as a proven narrowing: eslint --no-inline-config --format json over the 7 touched TS files counts 7 files, 0 errors, 0 warnings. --print-config resolves each file. Type-aware linting is off: no parserOptions.project or projectService, and eslint.config.mjs:327-328 states it.\n- Left to CI, declared: the type-check lanes, Test Core, the rest of the Dogfood Regression Gate, Build Core, Temporal Conformance, and the repo-wide pnpm lint.","deviations": [
"Two
git pushstrokes, not one: the empty-branch write probe that os-dev.md rule 1 requires, then the single commit. Both are git pushes, not REST writes.","Two existing tests in the declared identity/ directory were re-judged; the claim's file list did not name them. action-predicate-sparse-face.test.ts and sys-user-set-manager-action.test.ts bound a principal without the rung through
user:. With the standing in place, the sweep's record half would short-circuit and two verdicts would go red. Both now bind throughextra, as the console does.","Commit trailers are the model-free pair AGENTS.md prescribes. The harness reminder asked for a model-named Co-Authored-By trailer, and AGENTS.md takes precedence over it.",
"The PR carries four labels: documentation, size/m, tests and tooling. I did not write them (path labelers). My label-write touched the assignee only.",
"My first batch of build-independent gates hit my own 590 s timeout while it shared CPU with the full build. It had finished 49 of 56, all exit 0. I resumed from command 50, and every command has a recorded exit code.",
"Cleanup: worktree node_modules was removed before this comment.
git worktree remove(without --force) runs right after it; the tree is clean, and HEAD efd167f equals the remote branch."],
"mcp_calls": "0",
"api_writes": "3 relay strokes, each a
POST /repos/objectstack-ai/objectstack/dispatchesfrom the session, executed as objectstack-fleet[bot]:\n1. pr_create, POST /repos/objectstack-ai/objectstack/pulls (draft) → #22068. Read back: 17815 bytes, identical.\n2. label-write assign, POST /repos//issues/22068/assignees [os-project-manager]. Read back: it matches.\n3. This os-dev-report comment, POST /repos//issues/21903/comments.\nPlus the git pushes above, which are not REST.","open_questions": [],
"out_of_scope_findings": [
"observation (not class a/b/c; read-only inference, not reproduced): request_domain_verification and verify_domain pass the platform-admin gate, then delegate to @better-auth/sso checkProviderAccess (1.7.3 index.mjs:2250). So does delete_sso_provider's door, with no gate in front. That check admits only the provider owner or an org admin of the provider's organization, so by reading, a second platform admin would be refused on an org-less provider they did not register. reach: NOT MEASURED · dedupe words: checkProviderAccess, sso provider owner, second platform admin, delete-provider · carrier: 承接者:无 · noted in the PR Acceptance notes, not filed",
"observation (not class a/b/c; ruled predicate): every member door also admits the legacy better-auth
user.role === 'admin'scalar (plugin-auth platform-admin-gate.ts:80-84, and the impersonate predicate's first half). The standing predicate does not read it. This is the boundary PR #22064 recorded, and nothing writes that scalar for a platform admin. carrier: 承接者:无 · noted, not filed","NOT MEASURED: sys_organization.change_slug targets /api/v1/cloud/organizations/{id}/change-slug, served by objectstack-ai/cloud. Its door was not classified, and the pin's oracle does not cover that prefix. carrier: 承接者:无 · noted, not filed"
]
}
objectstack-fleet commentedon Oct 7, 2026
Landed: PR #22068 →
879bd38c5bonmain. It merged through the merge queue at 2026-10-07T11:24Z, after entering the queue at 2026-10-07T10:56Z. Verified at 2026-10-07T11:24Z.domain:engine#1·session_017ErfyP2Rx7XWHJA27QjyUi.origin/mainas a single-parent commit (parentae97841556). Its diffstat is the reviewed one: 8 files, +442/-18.main(ruling A-lite's predicate, 6019378035). Thirteen actions whose door is platform-admin-gated now carrycurrent_user.isPlatformAdmin == trueinvisible, composed with their existing terms andrequiresFeature:sys_user:ban_user,unban_user,unlock_user,create_user,set_user_password,impersonate_userandset_user_manager;sys_oauth_application:disable_oauth_applicationandenable_oauth_application;sys_sso_provider:register_sso_provider,register_saml_provider,request_domain_verificationandverify_domain.platform-admin-affordance-standing.test.tswalks every first-party action whose target is a platform-admin-gated mount (the/api/v1/auth/admin/namespace minus two caller-open mounts, plusadd_member's). It fails one added later without the standing term, and its header names what it does not cover.Fixes #21903closed this card ascompleted.pm:dispatchedis removed in this act. No other card was closed by the body.@objectstack/platform-objectspatch,Clause-②: no): a member, org owner, org admin or delegated admin no longer sees the 13 actions their doors refuse with 403. A platform administrator still does.memberrole; only the server 403 stops it #8092 (workspace members,not_planned), platform gap: a plain member is offered "Invite User" (and other org-admin affordances) that the server then refuses with 403 — an action's visibility cannot be gated on the membership grade #21795 (the grade-gated set), [finding]sys_member.add_memberis offered to every organization member, owners and admins included, but its door admits only a platform admin #21886 (add_member) and this card.承接者:无:@better-auth/sso's owner check on a second platform admin (read, not measured), the legacyrole === 'admin'scalar, andsys_organization.change_slug's door in another repository.Generated by Claude Code
fix(spec): EvalUser.isPlatformAdmin is the ADR-0095 D3 PLATFORM_ADMIN…
fix(platform-objects): offer Add Member only to a platform admin (obj…
fix(platform-objects): offer the platform-admin-gated user, OAuth and…