Skip to content

fix(platform-objects): offer Add Member only to a platform admin - #22064

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21886-add-member-visibility
Oct 7, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21886-add-member-visibility

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21886
Clause-②: no

What changes

sys_member.add_member ("Add Member" on an organization's member list) is now offered only to a platform administrator. That is the one standing its endpoint admits.

  • Before: the action was gated on the organization feature alone, so every member of the organization saw it: plain members, admins and owners. POST /api/v1/auth/organization/add-member then answered each of them 403 PERMISSION_DENIED, because the mount runs the ADR-0068 platform-admin gate before anything else.
  • After: the action declares visible: 'current_user.isPlatformAdmin == true', the predicate the maintainer's ruling A-lite fixed (record 6019378035). ADR-0068 D4 names that predicate, and since PR fix(spec): EvalUser.isPlatformAdmin is the ADR-0095 D3 PLATFORM_ADMIN standing, not a deprecated alias #22018 EvalUserSchema.isPlatformAdmin describes it as the PLATFORM_ADMIN standing of ADR-0095 D3. requiresFeature: 'organization' composes onto it at parse time, so the served predicate is (current_user.isPlatformAdmin == true) && features.organization != false.

The door and the callers it admits are unchanged. No key, export or parameter is added, and the label is unchanged. That makes Clause-②: no, and the changeset is a @objectstack/platform-objects patch.

File surface, as the claim declared it. The producer is packages/platform-objects/src/identity/sys-member.object.ts. The other files are the lowering-matrix pin in packages/platform-objects/src/platform-objects.test.ts, one dogfood case in packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts, and .changeset/21886-add-member-platform-admin-visibility.md. There is no packages/spec, plugin-auth or @objectstack/formula edit.

The dispatch's hypotheses, measured at 56c88446ea

  • H1: confirmed. The two keys compose with AND, the authored term first. lowerRequiresFeature (packages/spec/src/kernel/public-auth-features.ts:352-419) turns an existing CEL visible into (existing) && gate (:415-418). The first-party precedent that carries both keys is sys_user.enable_two_factor (sys-user.object.ts:534-535), pinned at platform-objects.test.ts as (...) && features.twoFactor == true.
  • H2: confirmed. The served visible is exactly (current_user.isPlatformAdmin == true) && features.organization != false. This was read from the parsed SysMember object in the matrix test, and from the served /meta/object/sys_member on a real boot through the dogfood case. EvalUserSchema.isPlatformAdmin (eval-user.zod.ts:225-241) now describes the ADR-0095 D3 standing.
  • H3: confirmed, with one correction.
    • The console binds current_user through extra. At the current objectui pin a58626c88d:
      • fieldRules.ts:284 passes the scope as extra;
      • ExpressionProvider.tsx:190 builds one subject under current_user, user, ctx.user and os.user, with features beside it;
      • expressionUser.ts:176 forwards isPlatformAdmin from the session.
    • The session value is grants.posture === 'PLATFORM_ADMIN' (plugin-auth auth-manager.ts:4083, emitted at :4107).
    • Under user:, @objectstack/formula re-derives it from positions (stdlib.ts:417-430 through createEvalUser). Under extra, the bag is merged verbatim (stdlib.ts:462).
    • The correction: the file's existing owner principal is the harness's seeded dev admin, and on this boot its served session carries isPlatformAdmin: true. It is the platform-admin principal, reused. Nothing on this boot declares OS_PLATFORM_OWNER_EMAIL: it is unset in the environment, absent from the dogfood package and the showcase config, and the harness sets it only under a walled posture (packages/verify/src/harness.ts:481-497). By EvalUserSchema's definition, the rung therefore comes from the single posture's unscoped admin_full_access grant. The file had no org owner who is not a platform admin, so the case signs one up and sets its membership to owner, the way the file already sets grades.
    • The existing cases still bind through user:, unchanged.
  • H4: confirmed. No server path evaluates an action visible.
    • rest, runtime, objectql, services, metadata, metadata-protocol, core and plugins have zero non-test hits for an action-visible evaluation.
    • The eight ExpressionEngine.evaluate / celEngine.evaluate call sites in package sources evaluate other things: formula fields and defaults (objectql/src/engine.ts:2263, :6190), hook conditions (hook-wrappers.ts:309), approval conditions, share-link gates and automation steps.
    • So nothing on the server binds current_user.isPlatformAdmin for this predicate, and the door stays the authority.

Tests

All runs below are at the final HEAD (bb53601015) unless marked otherwise.

  • Pin (a), platform-objects: the lowering-matrix row SysMember#add_member now expects (current_user.isPlatformAdmin == true) && features.organization != false.
    • pnpm --filter @objectstack/platform-objects exec vitest run: 62 files, 996 passed.
    • The package typecheck passed, with 0 TS errors. Its main program does not compile platform-objects.test.ts. check:test-typecheck compiles it through tsconfig.test.json: --listFiles counts 1 hit there and 0 in the main program.
  • Pin (b), one dogfood case on a real showcase boot: Add Member is offered to a platform admin alone — current_user bound as the console binds it — and the door agrees.
    • It reads each principal's served session, its own served /meta/object/sys_member and the served /auth/config flags.
    • It asserts each session's isPlatformAdmin: true for the seeded admin, false for the second owner, the admin, the delegated admin and the plain member. It also asserts that the second owner carries org_owner.
    • It evaluates the served predicate with celEngine, with current_user bound through extra the way the console binds it. Exactly the platform admin is offered the action.
    • It then probes the door on the same boot: the owner and the plain member get 403 PERMISSION_DENIED (envelope error.code) and no row is written; the platform admin gets 200 success: true and the sys_member row lands.
    • Result: test/org-admin-affordance-reach.dogfood.test.ts passed 14 of 14. The dogfood typecheck passed, and its program compiles the touched file (--listFiles: 1 hit).
  • Builds: turbo run build --filter='@objectstack/dogfood^...' passed 63 of 63 after the merge of origin/main.

Reverse verification, on committed HEAD 57b18bf94e

  • Mutation: scripts/ablation-replace.mjs deleted the line visible: 'current_user.isPlatformAdmin == true',. The anchor count went from 1 to 0 and the file's blob from 9beac68ce1c7 to b8792313cac4. An outer trap … EXIT INT TERM used absolute paths.
  • Proof the mutation reached dist/: @objectstack/platform-objects was rebuilt, then scripts/ablation-dist-preflight.mjs @objectstack/platform-objects 'visible: "current_user.isPlatformAdmin == true"' --absent --source-marker=… reported the marker absent from all 66 built files. Before the mutation, the same spelling was present in dist/index.mjs:2219.
  • (a) turned red, 1 failed of 127: expected 'features.organization != false' to be '(current_user.isPlatformAdmin == true) && features.organization != false'.
  • (b) turned red, 1 failed of 14: the set offered "Add Member" was received as platform admin, org owner, not a platform admin, org admin, delegated admin and plain member, against the expected platform admin alone. So the owner and member cells (and the admin cells) went red.
  • Direction: the run turned red, as predicted.
  • Restore: git checkout HEAD -- on the absolute path. The blob after restore and the HEAD blob are both 9beac68ce1c7ed2b7143505916818a0a2e39a456, and git diff HEAD is empty. Both ablation-replace and the outer trap proved this.
  • After the restore: the package was rebuilt, and the preflight in present mode found the marker in 6 built files on a clean tree. (a) passed 127 of 127 and (b) passed 14 of 14.

Gates

These ran locally at bb53601015, after the merge of origin/main (5cfd8661c4).

  • The derived gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 67 commands for this change set (35 pnpm, 32 node). All 67 ran and exited 0. --ran reconciles them: 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN.
    • check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3), because the build had covered only the dogfood closure. It was rerun after a full workspace build (turbo run build, 72 of 72) and loaded 106 require entry points across 66 packages.
    • check:dts-closure, check:lean-entry-closure, check:published-files and check:sourcemap-no-sources-content were rerun on that full build too, and all four passed.
  • The artifact-roster block: the derivation prints 53 more commands outside its total, and all 53 ran.
    • 50 exited 0.
    • Three answered NOT WIRED / NOT MEASURED (exit 2) because they need pull-request context: check-partof-closing-keyword, check-closing-target-claim and check-single-claim-paths. check-partof-closing-keyword was then run with this body as PR_BODY and passed. The other two need this PR's number and CI runs them on it.
    • check-sdui-manifest passed, but its objectui version comparison is NOT CHECKED, because the container has no objectui checkout.
    • check:console-injection is NOT MEASURED: there is no console dist/ here, so only its self-test ran. This diff touches neither surface.
  • The four symbol-anchor sweeps: check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors and check:adr-anchors all passed.
  • Lint, as a proven narrowing: pnpm exec eslint --no-inline-config --format json ran over the three touched TypeScript files.
    • The population is read from eslint's own config: --print-config resolves for each of the three, so none is ignored.
    • The JSON output counts 3 files, with 0 errors and 0 warnings.
    • The narrowing cannot hide a verdict elsewhere. Type-aware linting is off: the resolved configs carry no parserOptions.project or projectService, and eslint.config.mjs:327-328 states the repo never enables it. So this diff cannot move the verdict on any untouched file.
  • Left to CI, as a declared narrowing: the type-check lanes, Test Core, Dogfood Regression Gate, Build Core and the repo-wide pnpm lint.

Acceptance notes

  • The door also admits a legacy user.role === 'admin' scalar (platform-admin-gate.ts:80-84), and this predicate does not read it. A deployment that still carries that pre-ADR-0068-D2 scalar on a user who lacks the posture rung would be admitted by the door, but would not be offered the button. The gate's own header says nothing in ObjectStack writes that scalar for a platform admin, and that re-synthesizing it is vetoed. The predicate is the one the ruling fixed, so this is noted, not filed.
  • Only one platform-admin route is exercised here. The dogfood boot uses the single posture, where the standing comes from the seeded admin's unscoped grant. The OS_PLATFORM_OWNER_EMAIL route of the walled postures is not exercised by this case.
  • Only the new case binds through extra. The grade cases above it still bind through user:, as the dispatch required. Their predicates read only current_user.positions, which is identical under either binding.
  • Out of this PR: 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 (Blocked-by: #21886) remains open and carries the thirteen sibling platform-admin actions and the enumeration pin.

Generated by Claude Code

claude added 3 commits October 7, 2026 06:44
sys_member.add_member targets /auth/organization/add-member, which the
ADR-0068 platform-admin gate admits for a platform admin alone; every other
caller, org owners and admins included, gets 403 PERMISSION_DENIED. The
action was gated on the organization feature only, so the button was
offered to every member.

Its visible now reads the standing the door reads,
current_user.isPlatformAdmin == true (ADR-0068 D4, the ADR-0095 D3 rung),
and requiresFeature composes onto it at parse time.

- platform-objects: the lowering-matrix row pins the served predicate.
- dogfood: on a real showcase boot, with current_user bound the way the
  console binds it (through extra, from each served session), a platform
  admin is offered Add Member and an org owner, an admin, a delegated
  admin and a plain member are not; the door refuses the owner and the
  member and admits the platform admin.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
One equality over the principals offered the action names every cell in
its failure output, instead of stopping at the first principal that
disagrees.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 7, 2026
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/platform-objects, touching 1 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/runtime-services/sharing-service.mdx (via platform_admin (literal, a string literal in a comment in actions))
  • content/docs/permissions/authentication.mdx (via platform_admin (literal, a string literal in a comment in actions))
  • content/docs/permissions/authorization.mdx (via platform_admin (literal, a string literal in a comment in actions))
  • content/docs/permissions/permission-metadata.mdx (via platform_admin (literal, a string literal in a comment in actions))
  • content/docs/permissions/positions.mdx (via platform_admin (literal, a string literal in a comment in actions))
  • content/docs/permissions/sharing-rules.mdx (via platform_admin (literal, a string literal in a comment in actions))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx (via platform_admin (literal, a string literal in a comment in actions))
  • content/docs/releases/v17/17-4.mdx (via platform_admin (literal, a string literal in a comment in actions))
  • content/docs/releases/v17/17-5.mdx (via platform_admin (literal, a string literal in a comment in actions))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see

Coarse fallback — 3 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 5cfd8661c4deef4716d1895883633003a54a9db7 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 572b062bc3a2d7cbdbd7df88c372a489ca1324a8 — the merge of head bb5360101575090406643fbbe3e6585ac78d2f8b into base 5cfd8661c4deef4716d1895883633003a54a9db7, 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 572b062bc3a2d7cbdbd7df88c372a489ca1324a8 && git checkout 572b062bc3a2d7cbdbd7df88c372a489ca1324a8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5cfd8661c4deef4716d1895883633003a54a9db7 bb5360101575090406643fbbe3e6585ac78d2f8b && git checkout -B drift-repro 5cfd8661c4deef4716d1895883633003a54a9db7 && git merge --no-ff bb5360101575090406643fbbe3e6585ac78d2f8b

node scripts/docs-audit/affected-docs.mjs --json 5cfd8661c4deef4716d1895883633003a54a9db7

⚠️ 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 5cfd8661c4deef4716d1895883633003a54a9db7 → 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

ACCEPT (seat review) — PR #22064 at head bb53601015

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-07T08:06Z. The dev's report is os-dev-report on #21886 (6033238520). It lands under the maintainer's ruling A-lite (batch #281 item 2, 6019378035).

  • The change is the ruling's, and nothing else. The seat read the diff:
    • The predicate. sys_member.add_member declares visible: 'current_user.isPlatformAdmin == true'. requiresFeature: 'organization' composes onto it at parse time with AND, authored term first (lowerRequiresFeature, the sys_user.enable_two_factor precedent). The served predicate is (current_user.isPlatformAdmin == true) && features.organization != false.
    • The comment names the standing (ADR-0068 D4, the ADR-0095 D3 rung) and forbids the positions form.
    • Unchanged: the door (organization-add-member.ts, the mount, platform-admin-gate.ts), packages/spec, @objectstack/formula and the label.
  • Pins:
    • (a) the platform-objects lowering-matrix row now pins the composed predicate.
    • (b) one dogfood case on a real showcase boot. It binds current_user through extra from each principal's served session, the console's binding, so the platform admin alone is offered "Add Member". An org owner who is not a platform admin, an org admin, a delegated admin and a plain member are not. On the same boot, the door refuses the owner and the member (403 PERMISSION_DENIED, no row written) and admits the platform admin.
    • Reverse verification on the committed head deleted the visible line: (a) and (b) went red, with (b)'s offered set holding all five principals. It was restored by blob equality, with an empty git diff HEAD.
  • H4: no server path evaluates an action visible, re-confirmed at 56c88446ea. Nothing server-side binds this predicate.
  • Changeset, sentence by sentence against the diff:
    • "offered only to a platform administrator, the one standing its endpoint admits": matches the predicate and pin (b).
    • "the served predicate reads (…) && features.organization != false": matches pin (a).
    • "Nothing you author changes … no key, export or parameter is added": matches the file list.
  • Clause-②: no, patch. The door's accept set is unchanged, and no packages/spec file is in the diff. No contract review is owed.
  • Cross-lane: the dogfood case was declared to domain:cli on [PM seat] domain:cli — 🟢 os-warren · session_01RWZbGvPFcRKvUqASZtunCU #6024 (6032418205), and no reply came. domain:services' PR fix(plugin-email): the boot sweep reads stored templates in bulk and no longer rewrites an unchanged row #22065 adds a different dogfood file.
  • CI on bb53601015: 32 success, and the 3 skips are in the roster.
  • Readings against main at 5cfd8661c4: NOT governed (0 of 4 paths), 133 changed lines, and git merge-tree is clean.
  • Noted, not filed (PR Acceptance notes):

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 7, 2026 08:07
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 7, 2026 08:07
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit 77a94d8 Oct 7, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21886-add-member-visibility branch October 7, 2026 08:35
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… SSO actions only to a platform admin (objectstack-ai#22068)

Fixes objectstack-ai#21903
Clause-②: no

## What changes

Thirteen first-party actions are now offered only to a platform
administrator. That is the one standing their endpoints admit.

- **Before:** each action was offered to every principal who could open
the row or list. That covered org owners, org admins, delegated admins
and plain members. The endpoint then answered each of them 403, because
every one of these doors runs the ADR-0068 platform-admin gate first.
- **After:** each action declares `visible:
'current_user.isPlatformAdmin == true'`. That is ruling A-lite's
predicate (record 6019378035), the one `sys_member.add_member` landed
with in PR objectstack-ai#22064. It is ANDed with any term the action already had, and
`requiresFeature` composes onto it at parse time.
- **The members:**
- `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 enumeration pin:**
`packages/platform-objects/src/platform-admin-affordance-standing.test.ts`
imports every `*.object.ts` under `packages/` and judges every action
whose target door is platform-admin gated. An action added later fails
it until its `visible` carries the standing.

The doors and the callers they admit are unchanged. No key, export or
parameter is added, and no label changes. That makes `Clause-②: no`, and
the changeset is a `@objectstack/platform-objects` patch.

**File surface.** This is the surface the claim declared:
- the three producers in `packages/platform-objects/src/identity/`;
- the lowering-matrix rows in
`packages/platform-objects/src/platform-objects.test.ts`;
- the new enumeration pin;
- `.changeset/21903-platform-admin-affordance-visibility.md`.

Two existing tests in the same `identity/` directory evaluate these
predicates, so their fixtures were re-judged (see Acceptance notes):
`action-predicate-sparse-face.test.ts` and
`sys-user-set-manager-action.test.ts`. There is no `packages/spec`,
`plugin-auth`, `@objectstack/formula`, `docs/adr` or dogfood edit.

## The census (H1), measured at `56bf27affb`

Every first-party action with a `/api/v1/auth/` target lives in
`packages/platform-objects/src/identity/`. The gate lines are in
`packages/plugins/plugin-auth/src/auth-plugin.ts` unless named
otherwise.

| Action | Target | Door and its gate | Gated | `visible` served before
| `visible` served after |
|---|---|---|---|---|---|
| `sys_user.ban_user` | `/admin/ban-user` | mount `:2670`, `gateAdmin`
`:2672` | yes | `features.admin == true` |
`(current_user.isPlatformAdmin == true) && features.admin == true` |
| `sys_user.unban_user` | `/admin/unban-user` | mount `:2691`,
`gateAdmin` `:2693` | yes | `features.admin == true` | same as
`ban_user` |
| `sys_user.unlock_user` | `/admin/unlock-user` | mount `:2490`,
`judgePlatformAdmin` `:2502` | yes | `features.admin == true` | same as
`ban_user` |
| `sys_user.create_user` | `/admin/create-user` | mount `:2601`,
`gateAdmin` `:2603` | yes | `features.admin == true` | same as
`ban_user` |
| `sys_user.set_user_password` | `/admin/set-user-password` | mount
`:2851`, `gateAdmin` `:2853` | yes | `features.admin == true` | same as
`ban_user` |
| `sys_user.impersonate_user` | `/admin/impersonate-user` | better-auth
endpoint replaced in place (`admin-impersonate-endpoint.ts:198`, caller
predicate `:213`, wired at `auth-manager.ts:3609`) | yes |
`features.admin == true` | same as `ban_user` |
| `sys_user.set_user_manager` | `/admin/set-user-manager` | mount
`: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` | mount `: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 |
`(has(record.disabled) && record.disabled == true) &&
features.oidcProvider != false` | `(current_user.isPlatformAdmin == true
&& has(record.disabled) && record.disabled == true) &&
features.oidcProvider != false` |
| `sys_sso_provider.register_sso_provider` | `/admin/sso/register` |
mount `:2468`, `gateAdmin` `:2470` | yes | none |
`current_user.isPlatformAdmin == true` |
| `sys_sso_provider.register_saml_provider` | `/admin/sso/register-saml`
| mount `:2958`, `gateAdmin` `:2962` | yes | none |
`current_user.isPlatformAdmin == true` |
| `sys_sso_provider.request_domain_verification` |
`/admin/sso/request-domain-verification` | mount `:2983`, `gateAdmin`
`:2988` | yes | none | `current_user.isPlatformAdmin == true` |
| `sys_sso_provider.verify_domain` | `/admin/sso/verify-domain` | mount
`:3002`, `gateAdmin` `:3005` | yes | none |
`current_user.isPlatformAdmin == true` |
| `sys_member.add_member` (the precedent) | `/organization/add-member` |
mount `:2932`, `gateAdmin` `:2934` | yes | unchanged:
`(current_user.isPlatformAdmin == true) && features.organization !=
false` | unchanged |
| `sys_oauth_application.create_oauth_application` |
`/sys-oauth-application/register` | mount `:3038`, session only | no |
`features.oidcProvider != false` | unchanged |
| `sys_oauth_application.rotate_client_secret` |
`/oauth2/client/rotate-secret` | vendor route:
`@better-auth/oauth-provider` 1.7.3 `rotateClientSecretEndpoint`
authorizes the client's owner (`client.userId === session.user.id`);
plugin-auth configures no `clientPrivileges` | no |
`features.oidcProvider != false` | unchanged |
| `sys_oauth_application.delete_oauth_application` |
`/oauth2/delete-client` | vendor route: `deleteClientEndpoint`, same
owner check | no | `features.oidcProvider != false` | unchanged |
| `sys_sso_provider.delete_sso_provider` | `/sso/delete-provider` |
vendor route: `@better-auth/sso` 1.7.3 `checkProviderAccess` admits the
provider's owner or an admin of its organization | no | none | unchanged
|

Targets are spelled after `/api/v1/auth`.

## The dispatch's hypotheses, measured

- **H1: confirmed, with one count corrected.**
  - The family is the thirteen actions above plus `add_member`.
- The body's "`sys_oauth_application`: two actions" is two of the five
that object declares. The other three authorize the session or the
application's owner, as the table shows.
- The body's "`sys_sso_provider`: four actions" is four of five.
`delete_sso_provider`'s door is the vendor's provider-access check.
- Outside object files, `service-datasource` registers two type-level
actions. Their doors ask for the `manage_platform_settings` capability
(`admin-routes.ts:272`), and the file rules out an `isPlatformAdmin` arm
(`admin-routes.ts:268`). They are not members.
- `sys_organization.change_slug` targets `/api/v1/cloud/...`, which
another repository serves. Its door is **NOT MEASURED** here.
- **H2: confirmed.**
- `lowerRequiresFeature`
(`packages/spec/src/kernel/public-auth-features.ts:352`) composes
`(existing) && gate` at `:417`.
- Where an action already had a `visible`, the standing is ANDed in the
repo's existing spelling for an authored composed predicate. That is one
flat conjunction with the principal term first, as the `sys_user`
self-service predicates spell theirs.
  - Each served result is pinned in the lowering matrix.
- **H3: confirmed. No member's door admits a principal below platform
admin, apart from the legacy scalar noted below.**
- **Offered (before / after):** I evaluated the served predicate from
the built `dist` with `celEngine`, binding `current_user` the way the
console does. The whole scope goes in as `extra`, with one subject under
`current_user`, `user`, `ctx.user` and `os.user`.
- At `56bf27affb`, every member was offered to all five principals:
platform admin, org owner, org admin, delegated admin and plain member.
- At `efd167f5a3`, each member is offered to the platform admin alone.
    - The four non-members are unchanged.
- **Admitted:** I called the built `judgePlatformAdmin` on the same five
session shapes. It admits the platform admin and refuses the other four
with 403 `PERMISSION_DENIED`. It also refuses a session whose
`positions` carry a tenant-written `platform_admin` without the rung.
- The impersonate caller predicate (`admin-impersonate-endpoint.ts:213`)
refuses the same principals with 403
`YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS`. Its oracle is the posture
rung, which `auth-manager.ts:4083` also emits as the session's
`isPlatformAdmin`.
- On a real showcase boot at this head,
`admin-route-nonadmin-refusal.dogfood.test.ts` and
`admin-platform-admin-standing.dogfood.test.ts` passed 14 of 14. They
cover all thirteen member doors in both directions.
- **Page reach:** `sys_sso_provider` also requires
`manage_platform_settings` at object level
(`sys-sso-provider.object.ts:60`). That is a capability, not the rung,
so the predicate is still needed for a holder of the capability who is
not a platform admin.
- **H4: confirmed. No structural oracle exists without a new export.**
  - `plugin-auth` exports no mount table.
- `auth-route-ledger.ts` is package-internal, and its rows state the
gate only in `note` prose.
- `VENDOR_ADMIN_PATH_PREFIX` names the namespace, but `plugin-auth` is
not a dependency of `platform-objects`.
  - So 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`.
  - **What it misses** (also stated in the test's header):
    - a gated mount added outside the namespace;
    - a capability-gated door;
    - routes another repository serves;
    - actions not declared on an object file.
- **Dogfood pin (c) was not needed.** The door-level check is already
pinned by the two dogfood sweeps above, which passed 14 of 14.

## Tests

All runs are at the final head `efd167f5a3`.

- **Pin (a), the lowering matrix in `platform-objects.test.ts`:** every
member has a row with its composed served predicate, beside
`add_member`'s row.
  - `set_user_manager` and the four `sys_sso_provider` rows are new.
- The two exact-source assertions on the OAuth toggle pair were updated.
- **Pin (b), `platform-admin-affordance-standing.test.ts`:** 5 cases.
- The instrument is live: the walk loads more than 50 object files, and
the family contains all 14 known members. That is a floor, not the exact
set.
- The oracle does not include the four non-member targets or the two
excluded namespace routes.
- Every member's served `visible` carries `current_user.isPlatformAdmin
== true`.
- No refused principal is offered any member on any candidate row. The
refused principals are the four grades and a tenant-written
`platform_admin` position without the rung. `current_user` is bound
through `extra`.
  - The platform admin is still offered every member.
- **The package:** `pnpm --filter @objectstack/platform-objects exec
vitest run --maxWorkers=2` passed 63 files and 1006 tests. On `main` it
ran 62 files and 996 tests: this adds 5 pin cases and 5 matrix rows.
- **Typecheck:** `pnpm --filter @objectstack/platform-objects typecheck`
passed.
- The test-layer program (`tsconfig.test.json`) compiles all four
touched test files: `--listFiles` counts 1 hit each, and the build
program counts 0.
- Its only errors are the 3 already ledgered in
`src/feature-gate-guard.test.ts`.
- **Dogfood, real showcase boot:** these passed on a real showcase boot:
- `admin-route-nonadmin-refusal` and `admin-platform-admin-standing`: 14
of 14;
  - `org-admin-affordance-reach`: 14 of 14.

### Reverse verification, on committed HEAD `efd167f5a3`

- **Prediction:** both pins turn red.
- **Mutation:** `scripts/ablation-replace.mjs` replaced
`set_user_manager`'s predicate with its pre-change text,
`has(record.source) && record.source != "idp_provisioned"`.
- The anchor count went from 1 to 0, and the file's blob from
`0b9e8e738fb4` to `0ac51042a82d`.
  - An outer `trap … EXIT INT TERM` used absolute paths.
- Both pins import the subject from source: a relative import, and the
absolute `.object.ts` path. No `dist/` is in their resolution path, so
no rebuild was needed.
- **Result: red, as predicted, 3 failed of 137.**
- (a) The `SysUser.set_user_manager` row expected
`current_user.isPlatformAdmin == true && has(record.source) &&
record.source != "idp_provisioned"` and received the mutated text.
- (b) "each member serves a visible that carries the standing term"
listed `sys_user.set_user_manager`.
- (b) "no principal the gate refuses is offered a member" listed five
leaks. The org owner, org admin, delegated admin, plain member and the
tenant-written position were each offered `set_user_manager`.
- **Restore:** `git checkout HEAD --` on the absolute path.
- The blob after the restore and the HEAD blob are both
`0b9e8e738fb4911b9a379b2ebc699344f68127bd`.
  - `git diff HEAD` is empty, and `git status` is clean.
  - Both `ablation-replace` and the outer script proved this.

## Gates

These ran at `efd167f5a3`. `origin/main` was still `56bf27affb` at the
last fetch, so there was nothing to merge.

- **Full build:** `turbo run build --filter=!@objectstack/docs
--concurrency=2` passed 72 of 72 tasks.
- **The derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` derives 63 commands (31 pnpm, 32
node). All 63 exited 0.
- `--ran`, with each exit code recorded: `63 derived, 63 run, 0
NOT-MEASURED, 0 UNRUN` (a derived zero).
- The build-dependent ones printed real verdicts. For example,
`check:dual-build-cjs-loads` loaded 106 entries across 66 packages, and
`check:dts-closure` swept 72 packages.
- **The artifact-roster block:** 54 commands outside the total. 51
exited 0.
- Three answered NOT WIRED (exit 2) because they need pull-request
context: `check-closing-target-claim`, `check-partof-closing-keyword`
and `check-single-claim-paths`.
- `check-partof-closing-keyword` was rerun with this body as `PR_BODY`.
The other two need this PR's number, and CI runs them.
- `check-sdui-manifest` passed, but its objectui version comparison is
NOT CHECKED: there is no objectui checkout here.
- `check:console-injection` ran its self-test only, because there is no
console `dist/`.
- **The four symbol-anchor sweeps:** `check:adr-symbol-anchors`,
`check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors` and
`check:adr-anchors` all passed.
- **Lint, as a proven narrowing:** `pnpm exec eslint --no-inline-config
--format json` over the seven touched TypeScript files.
  - The JSON output counts 7 files, with 0 errors and 0 warnings.
- `--print-config` resolves a config for each file, so none is ignored.
- Type-aware linting is off. No resolved config carries
`parserOptions.project` or `projectService`, and
`eslint.config.mjs:327-328` states the repo never enables it. So this
diff cannot move the verdict on any untouched file.
- **Left to CI, as a declared narrowing:**
  - the type-check lanes;
  - Test Core;
  - the Dogfood Regression Gate beyond the three files above;
  - Build Core;
  - Temporal Conformance;
  - the repo-wide `pnpm lint`.

## Acceptance notes

- **The door also admits a legacy `user.role === 'admin'` scalar**
(`platform-admin-gate.ts:80-84`, and the impersonate predicate's first
half), and this predicate does not read it. This is the boundary PR
objectstack-ai#22064 recorded for `add_member`. Nothing in ObjectStack writes that
scalar for a platform admin, and re-synthesizing it is vetoed. Noted,
not filed.
- **Two fixtures were re-judged, because the predicates they evaluate
changed:**
- `action-predicate-sparse-face.test.ts` bound an org owner through
`user:`. Under that binding `@objectstack/formula` re-derives
`isPlatformAdmin` from `positions`.
- With the standing term in place, the owner would have short-circuited
the record half of three predicates to `false`, and the sweep exists to
test exactly that half.
    - Its OAuth per-site verdicts would also have gone red.
- It now binds the same principal through `extra`, with the rung, the
way the console binds it. The only alternative under `user:` was a
`platform_admin` position, the spelling the standing must never be read
from.
- `sys-user-set-manager-action.test.ts` evaluated its "offered to an
admin" case for a principal with no standing. It now binds a platform
admin the same way.
- **An unmeasured observation about SSO providers, not filed.**
- `request_domain_verification` and `verify_domain` pass the
platform-admin gate and then delegate to `@better-auth/sso`'s
`checkProviderAccess`. So does `delete_sso_provider`'s door, with no
gate in front.
- That check admits only the provider's owner or an admin of its
organization.
- By reading, a second platform admin who did not register an org-less
provider would be offered these actions and then refused by the vendor.
This was not reproduced on a boot.
- It is a different principal from this card's family. The standing
remains the necessary term.
- **The pin's oracle and what it misses** are written in its header.
Every exclusion is named, with the principal that the excluded door
admits.

---

_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_

Co-authored-by: Claude <noreply@anthropic.com>
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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] sys_member.add_member is offered to every organization member, owners and admins included, but its door admits only a platform admin

2 participants