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
plugin-auth, setup users: a managed (SSO-only) hosted environment keeps only authorization + view in Setup → Users, turns its local sign-up off, and gives a JIT user the declared default permission set (cloud#3132 ruling A) #25083
Ruled: 6341690661 · letter Q1 B / Q2 D / Q3 A / Q4 A · 2026-10-22T13:16Z
Ruling supersedes two sentences below: the JIT 「declared default permission set」 and its config key are retired (Q1 B — default access is the everyone anchor, ADR-0090 D5; HotCRM instance objectstack-ai/hotcrm#2231), and the managed-mode switch is OS_CLOUD_MANAGED=1 read by isCloudManaged() with the control-plane binding as the fuse (Q2 D). Read comment 6341690661 before the body.
Filed by the director seat (summon #43 continuation 5, session_01QJC7i981TSs9L1CGJK59Ax) as the objectstack half of the maintainer's ruling on objectstack-ai/cloud#3132 (2026-10-21, 「云端环境就是受管模式,自托管模式不影响」): hosted environments are managed — every account the organisation invites lives in the cloud, authorization stays in the environment; self-hosted deployments are unchanged. ⛔ Not a claim.
Why
ADR-0024 D5.2 (cloud): under SSO the environment's local user management for the managed population 「narrows to authorization + view」; invite and password management belong to the organisation / IdP. The runtime already has ssoOnlyMode (packages/plugins/plugin-auth/src/auth-manager.ts:7499–:7508, :7617–:7631: the password form and self-registration hidden), but Setup → Users still offers the full local surface (sys_invitation, local password management), and a just-in-time user created through the cloud IdP arrives with no declared default access.
What changes
Under ssoOnlyMode (or OS_AUTH_SSO_ONLY): Setup → Users lists users read-only (identity fields come from the IdP) and keeps only authorization: positions, permission sets, sharing-relevant fields; the local 「invite」 and password actions are not offered; one line says where to invite (the hosting organisation). The break-glass local admin stays editable (ADR-0024's invariant).
Default access for a JIT user: an environment declares the permission set a just-in-time user receives on first entry (one config key, read by the IdP JIT path); absent, the smallest read-only platform set. The seat proposes the exact set and its spelling on this card before dispatch; the maintainer may veto.
No local sign-up on a managed environment: under the managed mode the audience posture is invite_only and not configurable (an application cannot open self-registration in the environment; a future self-registration door, if ever wanted, is the cloud's, scoped to an environment, as its own card — the maintainer's word: 「在应用里自己注册的人…我的建议是这个也不要」).
Self-hosted deployments without an IdP are unaffected (the mode is off).
Acceptance
With ssoOnlyMode on: Setup → Users shows no invite or password action; the audience posture reads invite_only and the configuration door refuses open; positions and permission sets remain assignable; the break-glass admin remains editable; a JIT user holds the declared default set after first entry (pinned); with the mode off, nothing changes (pinned).
Docs: data-hooks.md-style context table not touched; the identity docs name the managed mode in one paragraph.
UI copy: the objectui half, if any, is filed by the claiming seat as a cross-repo card.
Ruling-pointer · the managed-mode switch · objectstack-ai/cloud#3132 ruling A (6311479286) and its 口径 (the maintainer's 「OS_CLOUD_MANAGED」 · 「features.cloud_managed」) · director seat (summon #43 continuation 5, session_01QJC7i981TSs9L1CGJK59Ax) · 2026-10-21T08:39Z
For the claiming seat: managed mode is OS_CLOUD_MANAGED=1 (set on the cloud's objectos containers; never on self-hosted images), read by one server accessor isManaged() that every judgement site in this card calls, and served to the console as features.cloud_managed beside features.sso. A production boot with the variable set but without the hosted composition (ObjectOSEnvironmentPlugin, core/src/stack-auth.tsisHostKernelComposition) and a reachable control plane refuses to start loudly; a --dev boot honours the variable alone. The per-environment row facts and OS_AUTH_SSO_ONLY are unchanged. This card's acceptance reads the mode through that accessor and that field, not through ssoOnlyMode alone.
Ruling-pointer · correction to 6311773273 (the maintainer: 「这种完整的名字比较精确,具体开发还是按照你的建议」) · director seat (summon #43 continuation 5, session_01QJC7i981TSs9L1CGJK59Ax) · 2026-10-21T08:40Z
For the claiming seat: in production isManaged() is derived from the hosted composition (ObjectOSEnvironmentPlugin, isHostKernelComposition) and the bound control plane, never from an environment variable; OS_CLOUD_MANAGED=1 is the development / test override only (--dev boots and test rigs), and a production boot refuses to start loudly if it is set; the console reads features.cloud_managed. The acceptance pins both: a --dev boot with the variable runs managed; a production composition without the plugin stays unmanaged whatever the variable says.
Claim: PM loop round 1 · 2026-10-22T07:38Z · p1, domain:services, area:access · a read-only measuring pass for the seat's proposal this card asks for before dispatch
Session: session_01UnHcTADjeg3VwxF21QHj7W (the dispatching seat; the dev is a mode:subagent run of this session, told apart by its branch)
Account: os-bill (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-25083-managed-environment-users
Worktree: objectstack-issue-25083
Domain: domain:services
Seat: domain:services#2 (seat post #21118)
File surface: measure first, as a read-only pass: no closure, no tests, no PR, no branch commit.
What the readings feed: the proposal the card's body asks of this seat before dispatch: the permission set a just-in-time user receives and its config key. The maintainer may veto it.
The build takes an amended claim naming its files. The expected area is plugin-auth (the ssoOnlyMode reads, the audience posture, the admin user doors, the IdP just-in-time path) and the managed-mode accessor the director's pointers name.
Cross-lane: if the accessor's home is packages/core (domain:engine), that half is declared there. The Setup → Users UI half is an objectui card, filed by this seat.
Container & model: M, mode:subagent, model: opus (TIER_DEFAULT; a readings pass)
Clause-②: no (narrowing)
Ruling-ref: 6311801640
Responsibility: Setup → Users still offers the full local surface (invite, password management) under ssoOnlyMode, and a just-in-time user created through the IdP arrives with no declared default access | ADR-0024 D5.2 (under SSO, local user management narrows to authorization and view) and the maintainer's ruling on objectstack-ai/cloud#3132 ("hosted environments are managed; self-hosted is unaffected"), with the director's managed-mode pointers 6311773273 and 6311801640 | administrators and SSO users of hosted (managed) environments
Thread-read: 6311801640
Serial constraints cleared:
No open PR touches the audience posture, the admin user doors, membership reconciliation, the stack-auth composition check or the default permission sets. The open PRs' file lists were read this round.
The declaration reads the card: under the managed mode the configuration door refuses an open audience posture, and Setup → Users stops offering invite and password actions. Both narrow what an administrator can do on a managed environment. The readings measure the exact accept-set change. A build owes a review at CONTRACT_REVIEW_TIER before the queue.
Readings for the seat's proposal — managed environment users and the just-in-time default set
Readings-only pass (no closure, no tests, no branch commit). Every reading is taken against objectstack 6251f5905 (a detached read worktree) unless it names objectui 7e9708e or the vendor dist @better-auth/sso 1.7.12 (the version plugin-auth/package.json pins). The cloud repository is not readable from this account: everything attributed to cloud#3132 or to cloud code comes from the card's quotes or from comments in this tree, and is marked where it matters. The card's line anchors for ssoOnlyMode are stale: the code now sits at auth-manager.ts:7777 (resolveSsoOnly), :1996 and :7901 (the forced disableSignUp) and :8002 (features.ssoEnforced). In this tree a bare ADR-0024 resolves to the MCP-connectors record; the open mirror of cloud ADR-0024 is ADR-0135, so the build cites cloud ADR-0024 or ADR-0135.
R1 — What a just-in-time user holds today
Membership. better-auth's user.create.after runs membershipReconciler (auth-manager.ts:8920). That calls settleMembership (:5060), which calls reconcileMembership (reconcile-membership.ts:213). The reconciler yields to any existing sys_member row. A host hook, such as the cloud's personal-organization provisioning, chains first (:8919). Under policy auto and posture single it binds the Default Organization with role member: resolveTargetOrg calls tenancy.defaultOrgId(), the "Single-org → default org; multi-org → none (invite/JIT own it)" line at :5071. Under invite-only or a walled posture it binds nothing. On the @better-auth/sso path the vendor also binds the provider's own organization with organizationProvisioning.defaultRole: 'member' (auth-manager.ts:4529; vendor assignOrganization, dist index.mjs:184–278). It binds by verified domain only when domain verification is on.
Explicit grants written: none. The admission gate classifies a creation as class provider (audience-posture.ts:402–420) when it arrives through sso-oidc, sso-saml, or oauth on a configured oidcProviders id. Per the module doc that includes the cloud platform IdP. decideAudienceAdmission answers { admit: true, grantPermissionSet: false } for that class (:446–450). No sys_user_permission_set row is written for a JIT user. One edge case: under single, a JIT user who is the environment's first human able to authenticate is promoted to admin_full_access by bootstrapPlatformAdmin (header of plugin-security/src/bootstrap-platform-admin.ts).
Implicit baseline: authentication alone, not membership. It arrives through two channels, and neither reads sys_member:
(1) SecurityPlugin.resolvePermissionSetsForContextUnmemoized adds baselinePermissionSets whenever !isAgent && context.userId (security-plugin.ts:7112–7196). The list is composeHumanBaselinePermissionSets(fallbackPermissionSet) (app-default-permission-set.ts:52): the app's isDefault set, which os serve passes through appSecurityPluginOptions(config) (serve.ts:4483), plus member_default.
(2) resolveUserAuthzGrants pushes the everyone position for every user it resolves (core/src/security/resolve-authz-context.ts:1631). That position's definition names the same baseline plus the package defaults the deployment accepted. Under single they are accepted through the sys_audience_binding_suggestion door (suggested-audience-bindings.ts). Under a wall they come from the host option acceptedDefaultPermissionSets (security-plugin.ts:979–1005).
The SSO wiring's own comment says the same: member_default "already grants baseline access to any authenticated user" (auth-manager.ts:4519–4523).
Verdict. "Arrives with no declared default access" is literally false of access. From its first request a JIT user holds member_default. It also holds the app's isDefault set when the app declares one (on os serve), and any everyone-anchor defaults the deployment accepted. The sentence is true only of declared, JIT-specific, environment-chosen access: no config key exists for the provider class, and nothing is granted per user. A user with no membership holds the sets but sees little: the _self carve-outs are keyed on the user, and sys_organization_self needs an active organization.
R2 — The candidate sets (plugin-security/src/objects/default-permission-sets.ts)
member_default — "Member — Standard Access" (:582–1120).
Read-only on the 28 better-auth-managed tables (BETTER_AUTH_MANAGED_OBJECTS, :61–89). Several of them still deny through their own declared _none default row scope.
Five overrides: sys_user read and edit on the caller's own row (ADR-0092 whitelist: name, image, locale); sys_api_key read and edit on the caller's own keys (revoked only); sys_user_preference read, create and edit on the caller's own rows; sys_inbox_message and sys_notification_receipt read.
No allowDelete, no allowExport, no systemPermissions, no View/Modify All. It withholds the Admin field group of sys_user.
Row-level security: owner_only_writes and owner_only_deletes (keyed on created_by, org_member only); the _self carve-outs (organization, own user, org peers select-only, session, two-factor, preference, API key, device code, OAuth consent and application, inbox, notification receipt, invitation self and issuer); sys_comment_moderation; the audit-ledger organization scope.
It is anchor-safe, and it is the baseline (R1). It is the smallest shipped human set, but it is not read-only: it carries self-service writes.
'*': allowRead, plus the managed tables read-only.
No write bit anywhere, no systemPermissions, no export. Select-only _self carve-outs and the audit-ledger scope; it withholds the Admin group.
It is read-only, but it is the widest read of any non-admin shipped set. A wildcard read reaches every non-private object, and under single (where Layer 0 is inert) every row in the environment. skills/objectstack-data/rules/security.md:205 warns of exactly this.
Anchor-safe under ADR-0090 D5 (spec/src/security/high-privilege.ts:96–100).
No code path grants it. Only the docs mention it: permission-sets.mdx:173, "Auditors / read-only stakeholders".
The others.
admin_full_access is the platform administrator: View/Modify All plus the platform systemPermissions. The selfRegistrationPermissionSet precedent refuses it.
organization_admin and organization_admin_no_bypass carry manage_org_users, setup.access, setup.write and manage_mailboxes, with a wildcard with or without View/Modify All. Both are high-privilege.
mcp_agent_data_read, mcp_agent_data_write and mcp_agent_restricted are agent ceilings. The ADR-0090 note puts them on an explicit denylist for persons: "an agent ceiling is not a person's set".
Reading. No shipped set is both the smallest and read-only. The smallest human set is member_default, which every JIT user already holds. The only read-only human set is viewer_readonly, which is the widest read. "The smallest read-only platform set" therefore names nothing that exists as written.
R3 — The just-in-time path
Paths that create an IdP-provisioned user:
(a) genericOAuth on an oidcProviders entry (auth-manager.ts:4296–4316). This covers the cloud platform IdP (objectstack-cloud) and a customer's OIDC configured statically. better-auth's handleOAuthUserInfo calls internalAdapter.createUser. That runs user.validateUserInfo (source.method: 'oauth' plus the providerId), then user.create.before and user.create.after, then account.create.after. The last one runs stampIdentitySource, which sets sys_user.source = idp_provisioned (:9089).
(b) @better-auth/sso OIDC and SAML (sys_sso_provider, OS_SSO_ENABLED; :4497–4533). The same creation chain runs, with sso-oidc or sso-saml. Then the vendor runs provisionUser, which is not configured here. The vendor calls it on registration, or on every login with provisionUserOnEveryLogin (dist index.mjs:3104, :4053). After that comes assignOrganizationFromProvider, and a /callback/ after-hook runs assignOrganizationByDomain (domain verification only).
(c) SCIM POST /Users is class operator. It is not a first entry, and no session is created.
(d) Social providers (Google and similar) are class self-serve, gated by the posture. They are not IdP-provisioned in the operator sense.
First entry of an existing user.session.create.before settles membership only for a user created in the same request (ADR-0093 D7; usersCreatedInRequest, auth-manager.ts:5106). Nothing is re-decided at sign-in. Linking an account to an existing user is not a creation either.
The single place a default would be granted.
The admission gate validateAudienceAdmission (auth-manager.ts:5300) is the one seam that holds source.method. It judges only source.action === 'create-user'. It stages the grant (stageSelfRegistrationGrant, :5952, keyed by lowercased email).
user.create.after lands it (settleSelfRegistrationGrant, :5990). The grant is owned by the first membership's organization, or persisted as a claim that settles on the first sys_member row (ADR-0131 D3).
Today only self-serve reaches this path (grantPermissionSet: true under email_domain or open). If the provider arm answers grantPermissionSet: true when a JIT set is declared, it reuses all of this machinery. That includes selfRegistrationSetResolvable (:5775; fails closed with AUTH_CONFIG_ERROR) and the idempotent landSelfRegistrationGrant (:6036).
Once, at creation — not on every entry. The vendor's provisionUserOnEveryLogin is unused and would cover path (b) only.
R4 — The config key's spelling
Conventions. The audience declaration has three layers that mirror each other:
Runtime twin:resolveAudience and assertAudienceConfig (plugin-auth/src/audience-posture.ts:141, :232). They run at construction (auth-manager.ts:1720) and on every merged applyConfigPatch (:4963).
Settings namespace auth: keys audience_posture, audience_allowed_email_domains and audience_self_registration_permission_set (service-settings/src/manifests/auth.manifest.ts:104–136, plus four translation files). bindAuthSettings binds them (auth-plugin.ts:2157–2280). The settings service derives an OS_AUTH_* override for every manifest key (OS_AUTH_AUDIENCE_POSTURE and so on).
Two keys differ. ssoOnlyMode lives in the spec (:614), in plugin config, and in a hand-read env var OS_AUTH_SSO_ONLY (auth-manager.ts:368), with no settings key. membership_policy is a settings key plus OS_AUTH_MEMBERSHIP_POLICY. On os serve the stack's own auth.audience is not passed to the auto-composed AuthPlugin (serve.ts:4248–4300). There it is reached through the settings/env door, and a host that constructs AuthPlugin itself (the cloud factory) passes options.
Where the key belongs. It belongs in the audience declaration, "the ONE declaration answering who may become a user". Its sibling already names the set a self-registrant receives, and the JIT set is the provider-class twin of that sibling. Each candidate below is declared in the same homes: the spec's AudienceConfigSchema; plugin-auth's audience-posture.ts (ResolvedAudience, resolveAudience, assertAudienceConfig, decideAudienceAdmission); audienceKeys in auth-plugin.ts; and auth.manifest.ts with its translations.
audience.enterpriseSsoPermissionSet / auth.audience_enterprise_sso_permission_set (derived env OS_AUTH_AUDIENCE_ENTERPRISE_SSO_PERMISSION_SET). It matches the console copy ("enterprise SSO … admitted under every posture") and the module doc, and it excludes social providers by name.
audience.providerPermissionSet / auth.audience_provider_permission_set. It matches the code's class name. The trap: to an author, a Google sign-up reads as "provider", but that sign-up is self-serve.
audience.jitPermissionSet / auth.audience_jit_permission_set. It is the card's word. The trap: social-provider OAuth is also just-in-time, and this key does not cover it.
Unlike its sibling, the key is legal under every posture, because provider JIT is admitted under invite_only. So the sibling's "refused for invite_only" rule does not carry over. The admin_full_access refusal does.
R5 — isManaged()
What exists today.
isHostKernelComposition(plugins) (core/src/stack-auth.ts:112–118) asks for the name com.objectstack.runtime.objectos-environment or the class ObjectOSEnvironmentPlugin.
Two places read it: rule 3 of resolvePlatformAuthComposition (host-kernel), and serve's own inline copy isRuntimeHostKernel (serve.ts:3925–3928), which is a second spelling of the same predicate.
The plugin itself is not in this repository. It belongs to the cloud's createObjectOSStack, multi-tenant branch only.
No managed-mode spelling exists: git grep for OS_CLOUD_MANAGED|cloud_managed|cloudManaged|isManaged( hits only the local isManaged(object) in identity-write-guard.ts.
The structural catch. On the hosted shape, ObjectOSEnvironmentPlugin sits on the host kernel, which is a routing shell with no AuthPlugin. Every judgement site this card names runs on the per-environment kernel that the cloud's ArtifactKernelFactory builds (serve.ts:4146–4163; plugin-hono-server/src/current-user-endpoints.ts:146). That kernel's plugin list carries no ObjectOSEnvironmentPlugin, so isHostKernelComposition cannot be asked where the answer is needed. Separately, the single-environment objectos-ee shape wires no ObjectOSEnvironmentPlugin (serve.ts:1990–1997).
Narrowest home. The narrowest home is @objectstack/core, beside isHostKernelComposition (lane domain:engine). plugin-auth, cli and cloud-connection all depend on core. The accessor would hold a process-level fact that the boot establishes once (host composition and bound control plane), and every kernel in the process reads it. It honours the dev/test override only on a development boot or a test rig. Note that isDevelopmentBoot alone excludes vitest, which sets NODE_ENV=test. The name should be isCloudManaged() rather than isManaged(): "managed" already names idp_provisioned identities (ADR-0135 D2/D4), managedBy tables and managed_by provenance. The alternatives are in open question 2.
features.sso today.AuthManager.getPublicConfig() builds features (auth-manager.ts:7968–8019: sso at :7998, ssoEnforced at :8002, audiencePosture at :7984), and the /auth/config route refines it (auth-plugin.ts:2798–2812). A new flag must be classified in PUBLIC_AUTH_FEATURES (spec/src/kernel/public-auth-features.ts), because the drift guard plugin-auth/src/public-feature-registry.test.ts asserts that served booleans equal the registry. It may also be declared on AuthFeaturesConfigSchema (spec/src/api/auth-endpoints.zod.ts:227, which declares ssoEnforced but not sso). Every key in that object is camelCase.
Production refusal. It goes in os serve, beside the ADR-0131 boot refusals (serve.ts:4667–4685: const productionBoot = !isDev; … throw new Error(…), before any plugin starts). The accessor must also ignore the variable outside dev/test, because embedded kernels (createStandaloneStack, verify's bootStack) and the cloud's per-environment kernels never pass through serve.
R6 — The audience posture
Values.AUDIENCE_POSTURES = ['invite_only','email_domain','open'] (spec auth-config.zod.ts:362), default invite_only. audiencePermitsSelfRegistration is true for both email_domain and open.
How it is read. Everything reads it through AuthManager.getAudience(), which calls resolveAudience(this.config.audience) (:4998):
The admission gate. Under invite_only it refuses a self-serve creation with 403 SELF_REGISTRATION_CLOSED, unless a pending, unexpired sys_invitation or the bootstrap window admits it. The operator and provider classes are never gated.
Forced email verification.
features.audiencePosture.
Card nuance. "Refuses open" has to mean "refuses every posture but invite_only". email_domain also opens self-registration, and the maintainer's word covers self-registration in the app in general.
The door.assertAudienceConfig(raw, emailAndPassword, options) (audience-posture.ts:232) runs in two places. At construction (auth-manager.ts:1720) a refusal stops the boot. On every merged applyConfigPatch (:4963–4967) the standing config keeps ruling. On the settings path, bindAuthSettings (auth-plugin.ts:2228–2276) logs "[auth] audience settings REFUSED — the standing posture … keeps ruling" at error.
Existing error vocabulary. Entry refusals throw a plain Error, "[audience] invalid audience configuration: …", with no wire code. The admission codes are SELF_REGISTRATION_CLOSED, EMAIL_DOMAIN_NOT_ALLOWED and AUTH_CONFIG_ERROR (constant AUDIENCE_CONFIG_ERROR; audience-posture.ts:103–107). A managed arm is one more fail(…) branch keyed on the accessor, with no new code. The settings select (auth.manifest.ts:104–117) cannot read the mode, so the refusal is the enforcement.
Sign-up is a second door.invite_only still admits a self-serve sign-up that holds a pending local sys_invitation. Under ssoOnlyMode, disableSignUp is forced true (auth-manager.ts:1996, :7901). "No local sign-up" under the managed mode therefore means forcing the same two arms on the accessor and refusing local invitation creation (R7).
R7 — Setup → Users, server side
Server doors. plugin-auth mounts all of them, and none consults ssoOnlyMode today. git grep finds only the disableSignUp arms and the public config.
Invitation creation:organizationHooks.beforeCreateInvitation (auth-manager.ts:3750). It is the one seam behind POST /organization/invite-member, which the four invite actions call: sys_userinvite_user (sys-user.object.ts:83–118), sys_member (:104), sys_invitation resend (:65, :112), and the sys_organization page. sys_invitation is better-auth-managed, so the generic data path already fails closed (ADR-0092). git grep finds no other insert into sys_invitation in plugin-auth.
POST /admin/create-user (auth-plugin.ts:3172, runAdminCreateUser) mints a local env_native account.
POST /admin/import-users (auth-plugin.ts:3442, runAdminImportUsers) creates local identities. Its invite policy sends an account-invitation password-reset email.
POST /admin/set-user-password (auth-plugin.ts:3423) is offered today to platform admins on every row (sys-user.object.ts:301–315, no source predicate).
POST /set-initial-password (auth-plugin.ts:2860) lets any session holder without a credential mint a local password. This is by design: it "lets a user that the platform onboarded via the objectstack-cloud OAuth provider sign in with email/password". Its header names a twin in the cloud's AuthProxyPlugin, which this repository cannot see.
The better-auth native /request-password-reset, /reset-password and /change-password are not refusal candidates: break-glass needs them.
Refusal precedent. cloud-connection's platform-managed doors answer 409 RESOURCE_CONFLICT with a message naming the platform (cloud-connection-plugin.ts:199–205). No new error code is needed.
UI-only. Display only; no door reads these. They are the visible predicates on the platform-objects actions (invite_user ×4, create_user, set_user_password; lane domain:engine) and the console. ADR-0135 D5.2 already splits change_my_password, change_my_email, delete_my_account and set_user_manager by record.source != "idp_provisioned".
The objectui half (objectui 7e9708e):
packages/auth/src/types.ts: the AuthFeatures type needs the new flag.
packages/app-shell/src/views/ObjectView.tsx: identityImportEnabled = isIdentityImport && features?.admin === true && isAdmin (:1936) gates the import wizard that posts /admin/import-users. This is also where a "where to invite" line on the Users list would render.
packages/app-shell/src/views/identityImport.ts and IdentityImportPanels.tsx.
packages/app-shell/src/console/organizations/manage/MembersPage.tsx (Invite member button :159–167, dialog :312), InviteMemberDialog.tsx, orgCapabilities.ts.
packages/app-shell/src/console/home/RecoveryPasswordReminder.tsx (with recoveryReminderGate.ts) nudges every SSO user to set a local recovery password.
No break-glass flag exists. Break-glass is a population: users who hold a local credential account (sys_account.provider_id = 'credential'). stampIdentitySource keeps those users at sys_user.source = env_native (auth-manager.ts:9089–9133). Gaining a credential flips idp_provisioned back to env_native, "so the break-glass admin never loses self-service password management".
Guards.
isLastLocalCredentialHolder (last-local-credential.ts:53) on /delete-user, /admin/remove-user (auth-manager.ts:2646–2735) and /admin/ban-user (admin-ban-endpoints.ts:166), answering 409 LAST_LOCAL_CREDENTIAL.
registerLastAdminGuard (last-admin-guard.ts:908, wired at auth-plugin.ts:1695): no write may leave zero administrators able to sign in, in any context.
What "stays editable" means. ADR-0135 D5.2, mirroring cloud ADR-0024 D5.2, splits the surface by the D2/D4 population, "not switched off wholesale". It names set-initial-password and the admin set_user_password as the break-glass routes. So every narrowing must key on source or the credential, never switch the surface off wholesale.
Hazard. The remedy in LAST_LOCAL_CREDENTIAL_MESSAGE ("Add another local password first") must stay reachable once create-user and import are refused. Today only set-initial-password and set_user_password can add a second local credential.
R9 — Proposal inputs (the seat decides; the maintainer may veto)
Declared: every provider-class creation receives it once, at creation. The grant is owned by the first membership's organization, held as a claim until there is one.
Unresolvable: admission is refused with AUTH_CONFIG_ERROR. admin_full_access is refused at entry.
Independent of the mode: a self-hosted SSO deployment has the same need (ADR-0135 D7).
Absent: nothing is written. The JIT user holds the baseline every signed-in human holds: member_default, plus the app's isDefault set where the app declares one. That is the honest reading of "the smallest platform set" (R1, R2). Writing member_default again would be a row that changes nothing; writing viewer_readonly would be the widest read.
Accessor:isCloudManaged() in core. Console flag: features.cloudManaged (open question 3).
Build shape, by lane.
spec (domain:spec): the AudienceConfigSchema key; a PUBLIC_AUTH_FEATURES entry, with a hide-when-on gate semantics (open question 3); AuthFeaturesConfigSchema; gen:schema for the authorable surface.
core (domain:engine): the accessor beside isHostKernelComposition, with serve's duplicate predicate folded onto it.
cli (domain:cli): establish the fact and add the production refusal in serve.ts. Extract cloud-connection's platformManaged if it is the control-plane leg.
plugin-auth (domain:services):
audience-posture.ts: the key, the provider arm, and a managed arm in assertAudienceConfig.
auth-manager.ts: provider staging, the disableSignUp arms, the beforeCreateInvitation refusal, and the features flag.
auth-plugin.ts: the create-user, import-users, set-user-password and set-initial-password doors, and audienceKeys.
service-settings (domain:services):auth.manifest.ts and its four translations.
platform-objects (domain:engine): the action visible predicates.
objectui: a cross-repo card naming the paths in R7.
cloud: confirm the ArtifactKernelFactory and AuthProxyPlugin twins.
The narrowing's exact accept-set change. This applies to managed environments only; unmanaged environments stay byte-identical.
Door
Today
Managed
Audience posture — stack config
invite_only, email_domain, open
invite_only only; the boot is refused otherwise
Audience posture — settings / env
invite_only, email_domain, open
invite_only only; the patch is refused and the standing value rules
Invitation creation
open
∅ (409)
admin/create-user, admin/import-users
open
∅ (409)
admin/set-user-password
every row
targets with source != idp_provisioned (or per open question 4)
set-initial-password
any user without a credential
per open question 4
Email sign-up form
follows the posture
closed (disableSignUp forced)
The one widening is a single optional audience key (Metadata-protocol added). The spec's posture vocabulary does not change: the refusal is conditional at runtime.
Pins.
A provider creation with the key writes one sys_user_permission_set row naming the set, owned by the first membership, or a claim when there is none.
With the key absent, no row is written and member_default resolves.
An unresolvable set answers AUTH_CONFIG_ERROR and creates no user.
admin_full_access is refused at entry.
Managed: email_domain and open are refused at construction and through applyConfigPatch. Unmanaged: unchanged.
Managed: invite-member answers 409 RESOURCE_CONFLICT (code and status asserted) and no sys_invitation row is written.
Managed: create-user and import-users answer 409. set-user-password is refused on an idp_provisioned row and answers 200 on an env_native row (break-glass stays editable).
features.cloudManaged is served true and false; the registry drift guard holds.
Accessor: --dev with the override runs managed; production with the override refuses the boot; a production host composition with a bound control plane runs managed; production without the plugin stays unmanaged.
An unmanaged deployment with ssoOnlyMode on keeps every door unchanged (self-hosted is unaffected).
The report (machine-readable)
{
"issue": 25083,
"status": "reading",
"branch": "claude/issue-25083-managed-environment-users",
"pr": null,
"session": "session_01UnHcTADjeg3VwxF21QHj7W — the dispatching seat's session; this is a mode:subagent run of it",
"premise_still_valid": true,
"summary": "Read-only pass against objectstack 6251f5905 and objectui 7e9708e. The readings are in this report's `readings` field and in the card comment that carries it. The card's work holds: no admin door consults ssoOnlyMode today, so Setup → Users still offers invite, create, import and password actions. One premise sentence is literally false. A just-in-time (JIT) user already holds member_default on authentication alone, plus the app's isDefault set on os serve and any accepted everyone-anchor defaults. What does not exist is a declared, JIT-specific, environment-chosen grant. 'Absent, the smallest read-only platform set' names nothing that exists: the smallest human set (member_default) is not read-only and is already in force, and the only read-only one (viewer_readonly) is the widest read. The managed-mode accessor cannot be derived where it is needed. ObjectOSEnvironmentPlugin sits on the host kernel, while every judgement site runs on the per-environment kernel that cloud's ArtifactKernelFactory builds. The cloud repo is not readable from this account, so the cloud-side facts come from the card's quotes or from comments in this tree. The reserved branch was not pushed (readings-only).",
"tests": "none — readings-only dispatch (no dependency closure, no tests, no PR). Readings taken from a detached read worktree at objectstack 6251f5905, the shared objectui checkout at 7e9708e (read only), and the @better-auth/sso 1.7.12 dist in a sibling worktree's node_modules (the version plugin-auth/package.json pins).",
"gates": "none run — no diff exists; no local gate and no CI applies to a readings pass",
"dogfood_battery": "n/a — readings-only, no PR",
"mcp_calls": "0",
"api_writes": "1 — POST /repos/objectstack-ai/objectstack/issues/25083/comments (this report, through scripts/pm/post-stamped.mjs and the fleet-write relay)",
"readings": "Rendered in full above in this comment (preamble and R1–R9); the hand-back carries the same text in this field.",
"open_questions": [
{
"question": "Q1 — What does the declared JIT key add, and what does 'absent' do, given that a JIT user already holds the baseline (member_default, plus the app's isDefault set on os serve) on authentication alone?",
"options": [
"A — The key is an explicit per-user grant, written once at creation for provider-class users and owned by the first membership (the ADR-0131 D3 claim machinery the self-registration grant already uses). Absent ⇒ nothing is written and the user holds the baseline. Cost: one optional key in four homes (spec, audience-posture.ts, auth-plugin.ts, settings manifest), reusing validateAudienceAdmission → settleSelfRegistrationGrant. About a day, plus pins 1–4.",
"B — No new key. Environment-wide default access goes through the existing everyone-anchor acceptance (isDefault, anchor-safe sets only). Cost: zero code. It does not meet the card's 'one config key', it grants every member including local users, and it is limited to isDefault sets.",
"C — The key, and absent ⇒ write an explicit grant of a fallback set (viewer_readonly or member_default). Cost: A plus a fallback grant. viewer_readonly reads every non-private row in the environment under single; member_default is a row that changes nothing, because the baseline already resolves it."
],
"recommendation": "A. The card's 'absent, the smallest read-only platform set' becomes 'absent, the baseline every signed-in human already holds', and the maintainer should see that rewording.",
"axes": {
"business_need": "The only demand is the cloud#3132 ruling as the card quotes it. git grep of examples/ and apps/ shows zero producers of selfRegistrationPermissionSet, oidcProviders or ssoOnlyMode; the producer is the cloud's per-environment factory, which this account cannot read. The baseline already covers the platform floor and the app's declared default, so the key's real job is an environment choice beyond them.",
"long_term": "A reuses one grant mechanism. C re-creates the implicit fallback grant that #11739 retired for self-registrants ('the implicit member_default fallback is exactly the undeclared grant this card retires', AudienceConfigSchema). B leaves the ruling's key unmet.",
"ai_proofing": "A is a strict, entry-validated, fail-closed key. C hands out a grant no author declared, which is the masking that this axis warns about. B gives an author nothing to get wrong, but also nothing to declare.",
"startup_scope": "A adds one optional key and no new mechanism. B adds nothing. C adds a hidden grant. No new gate is proposed."
}
},
{
"question": "Q2 — Where does isCloudManaged() live, and how do per-environment kernels learn it? ObjectOSEnvironmentPlugin is on the host kernel; the judgement sites run on cloud-built per-environment kernels (serve.ts:4146–4163). The accessor's sound home is core (domain:engine), which is cross-lane from this seat.",
"options": [
"A — A process-level fact in @objectstack/core beside isHostKernelComposition. os serve establishes it once, from the host plugin list plus the control-plane binding (the CloudConnectionPlugin platformManaged input, OS_CLOUD_API_KEY or controlPlaneApiKey). Every kernel in the process reads it. OS_CLOUD_MANAGED is honoured on dev and test boots only, and a production serve refuses it. Cost: core + cli + plugin-auth, plus module state. It works only if the cloud's host process boots through os serve; serve's host-kernel guards suggest so, but that is unverified.",
"B — A per-kernel service that the cloud factory registers on each per-environment kernel, read by plugin-auth. Cost: a cloud-side change (cross-repo). Sound per kernel, but 'the composition' becomes the factory's assertion.",
"C — An AuthPlugin option passed by the cloud factory, the way ssoOnlyMode arrives today. Cost: the smallest. But it is configuration, not derivation, which is the shape pointer 6311801640 corrected away from; and the serve refusal and the console flag still need their own reading."
],
"recommendation": "A, with the cross-lane (core: domain:engine; serve: domain:cli) and cross-repo dependencies escalated. Cloud must confirm three facts this account cannot read: whether the hosted host process boots through os serve; whether single-environment (objectos-ee) containers are ever hosted, since that shape carries no ObjectOSEnvironmentPlugin (serve.ts:1990–1997); and whether the cloud's AuthProxyPlugin still mounts per-environment twins of plugin-auth doors (named in the set-initial-password.ts header).",
"axes": {
"business_need": "Hosted environments are the only consumer, and their real composition is in the cloud repo, unread.",
"long_term": "A puts one predicate beside isHostKernelComposition and retires serve's inline duplicate (serve.ts:3925–3928). B spreads the truth into the cloud. C is per-environment configuration, the shape the director's correction rejected.",
"ai_proofing": "A gives one accessor and no re-derivation at each site; the name isCloudManaged avoids the existing 'managed' meanings (idp_provisioned identities, managedBy tables, managed_by provenance, identity-write-guard's isManaged). C lets a flag disagree with the composition.",
"startup_scope": "A adds no public surface. B adds a cross-repo contract. C is cheapest but leaves two readings."
}
},
{
"question": "Q3 — The wire spelling and the gate shape. Pointer 6311801640 says features.cloud_managed, but every features key is camelCase (twoFactor, multiOrgEnabled, ssoEnforced, audiencePosture; Prime Directive 3). The registry's gate predicate supports only show-when-on (== true) and default-on (!= false) (featureGatePredicate, public-auth-features.ts:308–311), and platform-objects' feature-gate-guard.test.ts requires a booked gate to end with that predicate. A hide-when-managed visible predicate is neither.",
"options": [
"A — features.cloudManaged. The registry gains a hide-when-on semantics (features.X != true) and the forward guard learns it (spec change, domain:spec).",
"B — features.cloud_managed verbatim, the only snake_case key in the object, with the same registry change.",
"C — cloudManaged as an exempt status flag. Metadata hides nothing by the flag; server refusals and per-row source predicates carry the narrowing, and objectui code hides the toolbar invite."
],
"recommendation": "A, flagged for the maintainer because the binding text spells cloud_managed. The dev override OS_CLOUD_MANAGED raises the same kind of question: Prime Directive 9 asks a new boolean variable for an _ENABLED suffix, or OS_TEST_* if it is test-only. The maintainer named the variable, so this pass flags it and picks nothing.",
"axes": {
"business_need": "The console needs the flag for the 'where to invite' line and the toolbar invite.",
"long_term": "With A, one flag carries one fact. C leaves list-toolbar actions to objectui code, so two places decide.",
"ai_proofing": "A keeps camelCase consistent, and a booked gate stays guarded in both directions. B adds a naming dialect that authors will copy.",
"startup_scope": "A and B each add one registry semantics; C adds none but splits the decision."
}
},
{
"question": "Q4 — The password doors under the managed mode. set-initial-password lets every idp_provisioned user mint a local password (by ADR-0135 D5.2's design, and objectui's RecoveryPasswordReminder nudges them to), and set_user_password is offered on every row. 'Password management belongs to the IdP' collides with D5.2's break-glass routes.",
"options": [
"A — Managed: set-initial-password admits only a caller with platform-admin standing; set_user_password refuses idp_provisioned targets; objectui hides RecoveryPasswordReminder. Cost: two door arms plus objectui. The LAST_LOCAL_CREDENTIAL remedy ('Add another local password first') stays reachable through an admin's own set-initial-password.",
"B — Managed: refuse both doors wholesale, leaving only existing env_native rows. Cost: the simplest. A second break-glass account becomes impossible, so the remedy text is unreachable.",
"C — Leave both doors as they are (D5.2 unchanged) and hide only invite, create and import. Cost: zero. Managed users keep a route around the IdP."
],
"recommendation": "A.",
"axes": {
"business_need": "Break-glass needs at least one way to add a second administrator credential when the IdP is down.",
"long_term": "A keeps D5.2's population split ('not switched off wholesale'); B is the wholesale switch-off; C leaves the managed promise unenforced.",
"ai_proofing": "With A, the door refuses and the UI mirrors it. With C, the UI can hide what the server still allows.",
"startup_scope": "A adds two arms and no new surface."
}
}
],
"out_of_scope_findings": [
"carrier: #25083's build (R9 folds it into the core accessor) · serve.ts:3925–3928 spells isHostKernelComposition a second time inline (isRuntimeHostKernel) · noted, not filed"
]
}
Release: session_01UnHcTADjeg3VwxF21QHj7W · Cause: the card asks the seat to propose the JIT default set and its spelling before dispatch, and the read-only pass (6335833179) found that three more of the card's binding sentences do not hold as written · Goes to: needs-user-decision. This write's label stroke clears the assignee. The reserved branch claude/issue-25083-managed-environment-users has no commits. This seat is winding down by the maintainer's word, so the build is dispatched by whichever seat holds the lane after the ruling.
The maintainer's ruling on objectstack-ai/cloud#3132 as the card quotes it (「云端环境就是受管模式,自托管模式不影响」, 「在应用里自己注册的人…我的建议是这个也不要」). The director's pointers on this card: 6311773273, and its binding correction 6311801640 (「这种完整的名字比较精确,具体开发还是按照你的建议」). Cloud ADR-0024 D5.2, mirrored in this tree as ADR-0135 D5.2 (narrow by the D2/D4 population, "not switched off wholesale"). ADR-0131 D3 (grant ownership by first membership). Prime Directive 3 (camelCase keys).
Answer to hand-off 6349666746 (on seat post #18549) · domain:spec seat 2 · os-bill · session session_01B2EeooonSXrANpkpgUSfFZ · 2026-10-22T20:48Z
The spec half of ruling 6341690661 Q3 A is filed as #25292 (p1, domain:spec, pm:queue): the cloudManaged entry in PUBLIC_AUTH_FEATURES with the hide-when-on semantics, and AuthFeaturesConfigSchema declaring it. #25083 can carry Blocked-by: #25292.
Cross-lane declaration · domain:spec seat 2 (seat post #18549) · os-bill · session session_01B2EeooonSXrANpkpgUSfFZ · 2026-10-22T22:18Z · ⛔ Not a claim.
Under ruling 6352436010 on #25292 (letter A, the 跨域例外路径), the one served line cloudManaged: isCloudManaged() in getPublicConfig()'s features literal (packages/plugins/plugin-auth/src/auth-manager.ts), with its import from @objectstack/core, lands in #25292's PR, together with the spec entry and #25295's six predicates. Those three halves are pinned against each other and can only be green in one commit. That PR carries Part of #25083, never Fixes #25083.
For domain:services seat 1's claim 6349623693: when the services branch merges main after #25292's PR lands, drop your own copy of that line. Everything else in 6349623693 stands, including the "true when managed" pins (with #25294's resetCloudManagedForTesting()). An objection goes on #25292.
Cloud card for the hand-off 6349688637 (ruling 6341690661, Q4 → A and Q3 → A): objectstack-ai/cloud#3169, filed by the cloud ops seat on the maintainer's direct dispatch (2026-10-23).
It covers the AuthProxyPlugin twin of set-initial-password: under isCloudManaged() it admits only callers with platform-admin standing, using the same standing check and the same refusal shape as plugin-auth's door. It also fixes the two worker.ts comment spellings (isCloudManaged(), features.cloudManaged).
It carries Blocked-by: objectstack-ai/objectstack#25083 and is labelled pm:blocked. The twin lands in the cloud pin move that first carries this card's services half, so both doors switch together.
The cloud rows of this card's list can now point at objectstack-ai/cloud#3169.
Claim: takeover · cloud ops seat (the maintainer's local session, account hotlong) · maintainer direct dispatch · 2026-10-23
Maintainer, in the cloud ops seat's chat on 2026-10-23: 「相关任务你都负责帮我跟进和派发」「包括25083」
Supersedes: claim 6349623693 (domain:services seat 1, os-sales). That seat's card thread should not resume this card. Its work-in-progress is carried forward, not redone.
Executor: a Claude Code cloud session dispatched from the cloud ops seat. The PR names its branch.
File surface: the same as claim 6349623693 (plugin-auth only; not core, spec, platform-objects or service-settings), plus one export. The platform-admin standing check that the set-initial-password door uses is exported, so that objectstack-ai/cloud#3169's twin reuses it rather than deriving its own.
Clause-②: no (narrowing), as 6349623693 measured it; the executor re-measures from the diff.
Metadata-protocol: none
Ruling-ref: 6341690661
PR: #25399, draft until CI is green. Branch claude/issue-25083-cloud-managed-doors, head f37350ae. It carries the services seat's WIP (a6598847, 7b0b758c, 0366f01d) merged with main.
For objectstack-ai/cloud#3169 (the cloud's per-environment set-initial-password twin). Import both of these from @objectstack/plugin-auth:
the standing check: isPlatformAdminUser(session.user), which was already exported (platform-admin-gate.ts);
the refusal: cloudManagedRefusal('setInitialPassword'), which is new in this PR. It returns { status: 409, body: { success: false, error: { code: 'RESOURCE_CONFLICT', message } } }. Its constants are CLOUD_MANAGED_REFUSAL_CODE, CLOUD_MANAGED_REFUSAL_STATUS and CLOUD_MANAGED_REFUSAL_MESSAGES.
The door's arm: when isCloudManaged() is true and the session has a user id, a user without platform-admin standing gets the refusal; an anonymous caller is answered as before (401).
What each refusing door answers under OS_CLOUD_MANAGED=1:
Door
Status / code
Body
organization/invite-member, new invitation and resend: true alike
409RESOURCE_CONFLICT
better-auth's { code, message }; refused in the before hook
admin/create-user, admin/import-users
409RESOURCE_CONFLICT
ADR-0112 envelope; a non-admin still gets 403 first
admin/set-user-password on an idp_provisioned target
409RESOURCE_CONFLICT
ADR-0112 envelope; an env_native target is answered
set-initial-password, signed-in caller without platform-admin standing
409RESOURCE_CONFLICT
ADR-0112 envelope
Audience posture other than invite_only
refused at construction and on patch
—
Email sign-up is forced off.
Readings:
plugin-auth: 3560 tests pass; typecheck and build pass.
Full dogfood: 2491 passed.
Contract review: PASS. Its one should-fix, the resend bypass, is fixed in the PR.
The skills child card ruling 6341690661 (Q1 B, consequence ii) asked for — the marketplace rule 「every published package declares one anchor-safe isDefault: true set」 as governed text in skills/objectstack-data/rules/security.md and the marketplace docs — is #25773, carried here from hand-off 6349675664 on seat post #19287. It is bare for triage's first touch. Nothing on this closed card changes.
Activity
objectstack-fleet commentedon Oct 21, 2026
Ruling-pointer · the managed-mode switch · objectstack-ai/cloud#3132 ruling A (6311479286) and its 口径 (the maintainer's 「OS_CLOUD_MANAGED」 · 「features.cloud_managed」) · director seat (summon #43 continuation 5,
session_01QJC7i981TSs9L1CGJK59Ax) · 2026-10-21T08:39ZFor the claiming seat: managed mode is
OS_CLOUD_MANAGED=1(set on the cloud's objectos containers; never on self-hosted images), read by one server accessorisManaged()that every judgement site in this card calls, and served to the console asfeatures.cloud_managedbesidefeatures.sso. A production boot with the variable set but without the hosted composition (ObjectOSEnvironmentPlugin,core/src/stack-auth.tsisHostKernelComposition) and a reachable control plane refuses to start loudly; a--devboot honours the variable alone. The per-environment row facts andOS_AUTH_SSO_ONLYare unchanged. This card's acceptance reads the mode through that accessor and that field, not throughssoOnlyModealone.objectstack-fleet commentedon Oct 21, 2026
Ruling-pointer · correction to 6311773273 (the maintainer: 「这种完整的名字比较精确,具体开发还是按照你的建议」) · director seat (summon #43 continuation 5,
session_01QJC7i981TSs9L1CGJK59Ax) · 2026-10-21T08:40ZFor the claiming seat: in production
isManaged()is derived from the hosted composition (ObjectOSEnvironmentPlugin,isHostKernelComposition) and the bound control plane, never from an environment variable;OS_CLOUD_MANAGED=1is the development / test override only (--devboots and test rigs), and a production boot refuses to start loudly if it is set; the console readsfeatures.cloud_managed. The acceptance pins both: a--devboot with the variable runs managed; a production composition without the plugin stays unmanaged whatever the variable says.objectstack-fleet commentedon Oct 22, 2026
Claim: PM loop round 1 · 2026-10-22T07:38Z · p1,
domain:services,area:access· a read-only measuring pass for the seat's proposal this card asks for before dispatchSession:
session_01UnHcTADjeg3VwxF21QHj7W(the dispatching seat; the dev is amode:subagentrun of this session, told apart by its branch)Account:
os-bill(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-25083-managed-environment-usersWorktree:
objectstack-issue-25083Domain:
domain:servicesSeat:
domain:services#2(seat post #21118)File surface: measure first, as a read-only pass: no closure, no tests, no PR, no branch commit.
plugin-auth(thessoOnlyModereads, the audience posture, the admin user doors, the IdP just-in-time path) and the managed-mode accessor the director's pointers name.packages/core(domain:engine), that half is declared there. The Setup → Users UI half is an objectui card, filed by this seat.Container & model:
M,mode:subagent,model: opus(TIER_DEFAULT; a readings pass)Clause-②: no (narrowing)
Ruling-ref: 6311801640
Responsibility:
Setup → Users still offers the full local surface (invite, password management) under ssoOnlyMode, and a just-in-time user created through the IdP arrives with no declared default access | ADR-0024 D5.2 (under SSO, local user management narrows to authorization and view) and the maintainer's ruling on objectstack-ai/cloud#3132 ("hosted environments are managed; self-hosted is unaffected"), with the director's managed-mode pointers 6311773273 and 6311801640 | administrators and SSO users of hosted (managed) environmentsThread-read: 6311801640
Serial constraints cleared:
auth-manager.ts, in a different region (assertOrganizationDeletableandresolveActor's opt-in mode). The readings touch nothing.The declaration reads the card: under the managed mode the configuration door refuses an
openaudience posture, and Setup → Users stops offering invite and password actions. Both narrow what an administrator can do on a managed environment. The readings measure the exact accept-set change. A build owes a review atCONTRACT_REVIEW_TIERbefore the queue.Generated by Claude Code
objectstack-fleet commentedon Oct 22, 2026
os-dev-report
Readings for the seat's proposal — managed environment users and the just-in-time default set
Readings-only pass (no closure, no tests, no branch commit). Every reading is taken against objectstack
6251f5905(a detached read worktree) unless it names objectui7e9708eor the vendor dist@better-auth/sso1.7.12 (the versionplugin-auth/package.jsonpins). The cloud repository is not readable from this account: everything attributed to cloud#3132 or to cloud code comes from the card's quotes or from comments in this tree, and is marked where it matters. The card's line anchors forssoOnlyModeare stale: the code now sits atauth-manager.ts:7777(resolveSsoOnly),:1996and:7901(the forceddisableSignUp) and:8002(features.ssoEnforced). In this tree a bareADR-0024resolves to the MCP-connectors record; the open mirror of cloud ADR-0024 is ADR-0135, so the build citescloud ADR-0024orADR-0135.R1 — What a just-in-time user holds today
Membership. better-auth's
user.create.afterrunsmembershipReconciler(auth-manager.ts:8920). That callssettleMembership(:5060), which callsreconcileMembership(reconcile-membership.ts:213). The reconciler yields to any existingsys_memberrow. A host hook, such as the cloud's personal-organization provisioning, chains first (:8919). Under policyautoand posturesingleit binds the Default Organization with rolemember:resolveTargetOrgcallstenancy.defaultOrgId(), the "Single-org → default org; multi-org → none (invite/JIT own it)" line at:5071. Underinvite-onlyor a walled posture it binds nothing. On the@better-auth/ssopath the vendor also binds the provider's own organization withorganizationProvisioning.defaultRole: 'member'(auth-manager.ts:4529; vendorassignOrganization, distindex.mjs:184–278). It binds by verified domain only when domain verification is on.Explicit grants written: none. The admission gate classifies a creation as class
provider(audience-posture.ts:402–420) when it arrives throughsso-oidc,sso-saml, oroauthon a configuredoidcProvidersid. Per the module doc that includes the cloud platform IdP.decideAudienceAdmissionanswers{ admit: true, grantPermissionSet: false }for that class (:446–450). Nosys_user_permission_setrow is written for a JIT user. One edge case: undersingle, a JIT user who is the environment's first human able to authenticate is promoted toadmin_full_accessbybootstrapPlatformAdmin(header ofplugin-security/src/bootstrap-platform-admin.ts).Implicit baseline: authentication alone, not membership. It arrives through two channels, and neither reads
sys_member:SecurityPlugin.resolvePermissionSetsForContextUnmemoizedaddsbaselinePermissionSetswhenever!isAgent && context.userId(security-plugin.ts:7112–7196). The list iscomposeHumanBaselinePermissionSets(fallbackPermissionSet)(app-default-permission-set.ts:52): the app'sisDefaultset, whichos servepasses throughappSecurityPluginOptions(config)(serve.ts:4483), plusmember_default.resolveUserAuthzGrantspushes theeveryoneposition for every user it resolves (core/src/security/resolve-authz-context.ts:1631). That position's definition names the same baseline plus the package defaults the deployment accepted. Undersinglethey are accepted through thesys_audience_binding_suggestiondoor (suggested-audience-bindings.ts). Under a wall they come from the host optionacceptedDefaultPermissionSets(security-plugin.ts:979–1005).The SSO wiring's own comment says the same:
member_default"already grants baseline access to any authenticated user" (auth-manager.ts:4519–4523).Verdict. "Arrives with no declared default access" is literally false of access. From its first request a JIT user holds
member_default. It also holds the app'sisDefaultset when the app declares one (onos serve), and any everyone-anchor defaults the deployment accepted. The sentence is true only of declared, JIT-specific, environment-chosen access: no config key exists for theproviderclass, and nothing is granted per user. A user with no membership holds the sets but sees little: the_selfcarve-outs are keyed on the user, andsys_organization_selfneeds an active organization.R2 — The candidate sets (
plugin-security/src/objects/default-permission-sets.ts)member_default— "Member — Standard Access" (:582–1120).member_default's*wildcard object grant (C/R/E) union-merges into every org member — app-side explicit-allow object gates are erased on three axes #5491 ruling made it explicit-allow.BETTER_AUTH_MANAGED_OBJECTS,:61–89). Several of them still deny through their own declared_nonedefault row scope.sys_userread and edit on the caller's own row (ADR-0092 whitelist:name,image,locale);sys_api_keyread and edit on the caller's own keys (revokedonly);sys_user_preferenceread, create and edit on the caller's own rows;sys_inbox_messageandsys_notification_receiptread.allowDelete, noallowExport, nosystemPermissions, no View/Modify All. It withholds theAdminfield group ofsys_user.owner_only_writesandowner_only_deletes(keyed oncreated_by,org_memberonly); the_selfcarve-outs (organization, own user, org peers select-only, session, two-factor, preference, API key, device code, OAuth consent and application, inbox, notification receipt, invitation self and issuer);sys_comment_moderation; the audit-ledger organization scope.viewer_readonly— "Viewer — Read-Only" (:1122–1230).'*': allowRead, plus the managed tables read-only.systemPermissions, no export. Select-only_selfcarve-outs and the audit-ledger scope; it withholds theAdmingroup.single(where Layer 0 is inert) every row in the environment.skills/objectstack-data/rules/security.md:205warns of exactly this.spec/src/security/high-privilege.ts:96–100).permission-sets.mdx:173, "Auditors / read-only stakeholders".admin_full_accessis the platform administrator: View/Modify All plus the platformsystemPermissions. TheselfRegistrationPermissionSetprecedent refuses it.organization_adminandorganization_admin_no_bypasscarrymanage_org_users,setup.access,setup.writeandmanage_mailboxes, with a wildcard with or without View/Modify All. Both are high-privilege.mcp_agent_data_read,mcp_agent_data_writeandmcp_agent_restrictedare agent ceilings. The ADR-0090 note puts them on an explicit denylist for persons: "an agent ceiling is not a person's set".Reading. No shipped set is both the smallest and read-only. The smallest human set is
member_default, which every JIT user already holds. The only read-only human set isviewer_readonly, which is the widest read. "The smallest read-only platform set" therefore names nothing that exists as written.R3 — The just-in-time path
Paths that create an IdP-provisioned user:
oidcProvidersentry (auth-manager.ts:4296–4316). This covers the cloud platform IdP (objectstack-cloud) and a customer's OIDC configured statically. better-auth'shandleOAuthUserInfocallsinternalAdapter.createUser. That runsuser.validateUserInfo(source.method: 'oauth'plus the providerId), thenuser.create.beforeanduser.create.after, thenaccount.create.after. The last one runsstampIdentitySource, which setssys_user.source = idp_provisioned(:9089).@better-auth/ssoOIDC and SAML (sys_sso_provider,OS_SSO_ENABLED;:4497–4533). The same creation chain runs, withsso-oidcorsso-saml. Then the vendor runsprovisionUser, which is not configured here. The vendor calls it on registration, or on every login withprovisionUserOnEveryLogin(distindex.mjs:3104,:4053). After that comesassignOrganizationFromProvider, and a/callback/after-hook runsassignOrganizationByDomain(domain verification only).POST /Usersis classoperator. It is not a first entry, and no session is created.self-serve, gated by the posture. They are not IdP-provisioned in the operator sense.First entry of an existing user.
session.create.beforesettles membership only for a user created in the same request (ADR-0093 D7;usersCreatedInRequest,auth-manager.ts:5106). Nothing is re-decided at sign-in. Linking an account to an existing user is not a creation either.The single place a default would be granted.
validateAudienceAdmission(auth-manager.ts:5300) is the one seam that holdssource.method. It judges onlysource.action === 'create-user'. It stages the grant (stageSelfRegistrationGrant,:5952, keyed by lowercased email).user.create.afterlands it (settleSelfRegistrationGrant,:5990). The grant is owned by the first membership's organization, or persisted as a claim that settles on the firstsys_memberrow (ADR-0131 D3).self-servereaches this path (grantPermissionSet: trueunderemail_domainoropen). If theproviderarm answersgrantPermissionSet: truewhen a JIT set is declared, it reuses all of this machinery. That includesselfRegistrationSetResolvable(:5775; fails closed withAUTH_CONFIG_ERROR) and the idempotentlandSelfRegistrationGrant(:6036).provisionUserOnEveryLoginis unused and would cover path (b) only.R4 — The config key's spelling
Conventions. The audience declaration has three layers that mirror each other:
AuthConfigSchema.audience=AudienceConfigSchema{posture,allowedEmailDomains,selfRegistrationPermissionSet} (spec/src/system/auth-config.zod.ts:399–520). Its superRefine refuses inert keys (ADR-0078).resolveAudienceandassertAudienceConfig(plugin-auth/src/audience-posture.ts:141,:232). They run at construction (auth-manager.ts:1720) and on every mergedapplyConfigPatch(:4963).auth: keysaudience_posture,audience_allowed_email_domainsandaudience_self_registration_permission_set(service-settings/src/manifests/auth.manifest.ts:104–136, plus four translation files).bindAuthSettingsbinds them (auth-plugin.ts:2157–2280). The settings service derives anOS_AUTH_*override for every manifest key (OS_AUTH_AUDIENCE_POSTUREand so on).Two keys differ.
ssoOnlyModelives in the spec (:614), in plugin config, and in a hand-read env varOS_AUTH_SSO_ONLY(auth-manager.ts:368), with no settings key.membership_policyis a settings key plusOS_AUTH_MEMBERSHIP_POLICY. Onos servethe stack's ownauth.audienceis not passed to the auto-composed AuthPlugin (serve.ts:4248–4300). There it is reached through the settings/env door, and a host that constructs AuthPlugin itself (the cloud factory) passes options.Where the key belongs. It belongs in the audience declaration, "the ONE declaration answering who may become a user". Its sibling already names the set a self-registrant receives, and the JIT set is the
provider-class twin of that sibling. Each candidate below is declared in the same homes: the spec'sAudienceConfigSchema; plugin-auth'saudience-posture.ts(ResolvedAudience,resolveAudience,assertAudienceConfig,decideAudienceAdmission);audienceKeysinauth-plugin.ts; andauth.manifest.tswith its translations.audience.enterpriseSsoPermissionSet/auth.audience_enterprise_sso_permission_set(derived envOS_AUTH_AUDIENCE_ENTERPRISE_SSO_PERMISSION_SET). It matches the console copy ("enterprise SSO … admitted under every posture") and the module doc, and it excludes social providers by name.audience.providerPermissionSet/auth.audience_provider_permission_set. It matches the code's class name. The trap: to an author, a Google sign-up reads as "provider", but that sign-up isself-serve.audience.jitPermissionSet/auth.audience_jit_permission_set. It is the card's word. The trap: social-provider OAuth is also just-in-time, and this key does not cover it.Unlike its sibling, the key is legal under every posture, because provider JIT is admitted under
invite_only. So the sibling's "refused for invite_only" rule does not carry over. Theadmin_full_accessrefusal does.R5 —
isManaged()What exists today.
isHostKernelComposition(plugins)(core/src/stack-auth.ts:112–118) asks for the namecom.objectstack.runtime.objectos-environmentor the classObjectOSEnvironmentPlugin.resolvePlatformAuthComposition(host-kernel), and serve's own inline copyisRuntimeHostKernel(serve.ts:3925–3928), which is a second spelling of the same predicate.createObjectOSStack, multi-tenant branch only.CloudConnectionPlugin.start()computes a localplatformManaged = (controlPlaneApiKey ?? OS_CLOUD_API_KEY) !== ''(cloud-connection-plugin.ts:316–335; ruling 甲 on cloud-connection: a runtime holding the env service key is platform-managed: register no Setup page, refuse unbind; move the entry to Integrations (ruling 甲) #25097, which produces 409RESOURCE_CONFLICTrefusals). It is not exported.git grepforOS_CLOUD_MANAGED|cloud_managed|cloudManaged|isManaged(hits only the localisManaged(object)inidentity-write-guard.ts.The structural catch. On the hosted shape,
ObjectOSEnvironmentPluginsits on the host kernel, which is a routing shell with no AuthPlugin. Every judgement site this card names runs on the per-environment kernel that the cloud'sArtifactKernelFactorybuilds (serve.ts:4146–4163;plugin-hono-server/src/current-user-endpoints.ts:146). That kernel's plugin list carries noObjectOSEnvironmentPlugin, soisHostKernelCompositioncannot be asked where the answer is needed. Separately, the single-environmentobjectos-eeshape wires noObjectOSEnvironmentPlugin(serve.ts:1990–1997).Narrowest home. The narrowest home is
@objectstack/core, besideisHostKernelComposition(lanedomain:engine). plugin-auth, cli and cloud-connection all depend on core. The accessor would hold a process-level fact that the boot establishes once (host composition and bound control plane), and every kernel in the process reads it. It honours the dev/test override only on a development boot or a test rig. Note thatisDevelopmentBootalone excludes vitest, which setsNODE_ENV=test. The name should beisCloudManaged()rather thanisManaged(): "managed" already namesidp_provisionedidentities (ADR-0135 D2/D4),managedBytables andmanaged_byprovenance. The alternatives are in open question 2.features.ssotoday.AuthManager.getPublicConfig()buildsfeatures(auth-manager.ts:7968–8019:ssoat:7998,ssoEnforcedat:8002,audiencePostureat:7984), and the/auth/configroute refines it (auth-plugin.ts:2798–2812). A new flag must be classified inPUBLIC_AUTH_FEATURES(spec/src/kernel/public-auth-features.ts), because the drift guardplugin-auth/src/public-feature-registry.test.tsasserts that served booleans equal the registry. It may also be declared onAuthFeaturesConfigSchema(spec/src/api/auth-endpoints.zod.ts:227, which declaresssoEnforcedbut notsso). Every key in that object is camelCase.Production refusal. It goes in
os serve, beside the ADR-0131 boot refusals (serve.ts:4667–4685:const productionBoot = !isDev; … throw new Error(…), before any plugin starts). The accessor must also ignore the variable outside dev/test, because embedded kernels (createStandaloneStack, verify'sbootStack) and the cloud's per-environment kernels never pass through serve.R6 — The audience posture
Values.
AUDIENCE_POSTURES = ['invite_only','email_domain','open'](spec auth-config.zod.ts:362), defaultinvite_only.audiencePermitsSelfRegistrationis true for bothemail_domainandopen.How it is read. Everything reads it through
AuthManager.getAudience(), which callsresolveAudience(this.config.audience)(:4998):invite_onlyit refuses a self-serve creation with 403SELF_REGISTRATION_CLOSED, unless a pending, unexpiredsys_invitationor the bootstrap window admits it. Theoperatorandproviderclasses are never gated.features.audiencePosture.Card nuance. "Refuses
open" has to mean "refuses every posture butinvite_only".email_domainalso opens self-registration, and the maintainer's word covers self-registration in the app in general.The door.
assertAudienceConfig(raw, emailAndPassword, options)(audience-posture.ts:232) runs in two places. At construction (auth-manager.ts:1720) a refusal stops the boot. On every mergedapplyConfigPatch(:4963–4967) the standing config keeps ruling. On the settings path,bindAuthSettings(auth-plugin.ts:2228–2276) logs "[auth] audience settings REFUSED — the standing posture … keeps ruling" aterror.Existing error vocabulary. Entry refusals throw a plain
Error, "[audience] invalid audience configuration: …", with no wire code. The admission codes areSELF_REGISTRATION_CLOSED,EMAIL_DOMAIN_NOT_ALLOWEDandAUTH_CONFIG_ERROR(constantAUDIENCE_CONFIG_ERROR;audience-posture.ts:103–107). A managed arm is one morefail(…)branch keyed on the accessor, with no new code. The settings select (auth.manifest.ts:104–117) cannot read the mode, so the refusal is the enforcement.Sign-up is a second door.
invite_onlystill admits a self-serve sign-up that holds a pending localsys_invitation. UnderssoOnlyMode,disableSignUpis forced true (auth-manager.ts:1996,:7901). "No local sign-up" under the managed mode therefore means forcing the same two arms on the accessor and refusing local invitation creation (R7).R7 — Setup → Users, server side
Server doors. plugin-auth mounts all of them, and none consults
ssoOnlyModetoday.git grepfinds only thedisableSignUparms and the public config.organizationHooks.beforeCreateInvitation(auth-manager.ts:3750). It is the one seam behindPOST /organization/invite-member, which the four invite actions call:sys_userinvite_user(sys-user.object.ts:83–118),sys_member(:104),sys_invitationresend (:65,:112), and thesys_organizationpage.sys_invitationis better-auth-managed, so the generic data path already fails closed (ADR-0092).git grepfinds no other insert intosys_invitationin plugin-auth.POST /admin/create-user(auth-plugin.ts:3172,runAdminCreateUser) mints a localenv_nativeaccount.POST /admin/import-users(auth-plugin.ts:3442,runAdminImportUsers) creates local identities. Itsinvitepolicy sends an account-invitation password-reset email.POST /admin/set-user-password(auth-plugin.ts:3423) is offered today to platform admins on every row (sys-user.object.ts:301–315, nosourcepredicate).POST /set-initial-password(auth-plugin.ts:2860) lets any session holder without a credential mint a local password. This is by design: it "lets a user that the platform onboarded via the objectstack-cloud OAuth provider sign in with email/password". Its header names a twin in the cloud'sAuthProxyPlugin, which this repository cannot see./request-password-reset,/reset-passwordand/change-passwordare not refusal candidates: break-glass needs them.Refusal precedent. cloud-connection's platform-managed doors answer 409
RESOURCE_CONFLICTwith a message naming the platform (cloud-connection-plugin.ts:199–205). No new error code is needed.UI-only. Display only; no door reads these. They are the
visiblepredicates on the platform-objects actions (invite_user×4,create_user,set_user_password; lanedomain:engine) and the console. ADR-0135 D5.2 already splitschange_my_password,change_my_email,delete_my_accountandset_user_managerbyrecord.source != "idp_provisioned".The objectui half (objectui
7e9708e):packages/auth/src/types.ts: theAuthFeaturestype needs the new flag.packages/app-shell/src/views/ObjectView.tsx:identityImportEnabled = isIdentityImport && features?.admin === true && isAdmin(:1936) gates the import wizard that posts/admin/import-users. This is also where a "where to invite" line on the Users list would render.packages/app-shell/src/views/identityImport.tsandIdentityImportPanels.tsx.packages/app-shell/src/console/organizations/manage/MembersPage.tsx(Invite member button:159–167, dialog:312),InviteMemberDialog.tsx,orgCapabilities.ts.packages/app-shell/src/console/home/RecoveryPasswordReminder.tsx(withrecoveryReminderGate.ts) nudges every SSO user to set a local recovery password.packages/auth/src/LoginForm.tsxalready consumesssoEnforced.packages/i18n/src/locales/*.tsfor the new line.R8 — Break-glass
No break-glass flag exists. Break-glass is a population: users who hold a local
credentialaccount (sys_account.provider_id = 'credential').stampIdentitySourcekeeps those users atsys_user.source = env_native(auth-manager.ts:9089–9133). Gaining a credential flipsidp_provisionedback toenv_native, "so the break-glass admin never loses self-service password management".Guards.
isLastLocalCredentialHolder(last-local-credential.ts:53) on/delete-user,/admin/remove-user(auth-manager.ts:2646–2735) and/admin/ban-user(admin-ban-endpoints.ts:166), answering 409LAST_LOCAL_CREDENTIAL.registerLastAdminGuard(last-admin-guard.ts:908, wired atauth-plugin.ts:1695): no write may leave zero administrators able to sign in, in any context.What "stays editable" means. ADR-0135 D5.2, mirroring cloud ADR-0024 D5.2, splits the surface by the D2/D4 population, "not switched off wholesale". It names
set-initial-passwordand the adminset_user_passwordas the break-glass routes. So every narrowing must key onsourceor the credential, never switch the surface off wholesale.Hazard. The remedy in
LAST_LOCAL_CREDENTIAL_MESSAGE("Add another local password first") must stay reachable once create-user and import are refused. Today onlyset-initial-passwordandset_user_passwordcan add a second local credential.R9 — Proposal inputs (the seat decides; the maintainer may veto)
Recommended.
audience.enterpriseSsoPermissionSet/auth.audience_enterprise_sso_permission_set.provider-class creation receives it once, at creation. The grant is owned by the first membership's organization, held as a claim until there is one.AUTH_CONFIG_ERROR.admin_full_accessis refused at entry.member_default, plus the app'sisDefaultset where the app declares one. That is the honest reading of "the smallest platform set" (R1, R2). Writingmember_defaultagain would be a row that changes nothing; writingviewer_readonlywould be the widest read.isCloudManaged()in core. Console flag:features.cloudManaged(open question 3).Build shape, by lane.
domain:spec): theAudienceConfigSchemakey; aPUBLIC_AUTH_FEATURESentry, with a hide-when-on gate semantics (open question 3);AuthFeaturesConfigSchema;gen:schemafor the authorable surface.domain:engine): the accessor besideisHostKernelComposition, with serve's duplicate predicate folded onto it.domain:cli): establish the fact and add the production refusal inserve.ts. Extract cloud-connection'splatformManagedif it is the control-plane leg.domain:services):audience-posture.ts: the key, the provider arm, and a managed arm inassertAudienceConfig.auth-manager.ts: provider staging, thedisableSignUparms, thebeforeCreateInvitationrefusal, and the features flag.auth-plugin.ts: the create-user, import-users, set-user-password and set-initial-password doors, andaudienceKeys.domain:services):auth.manifest.tsand its four translations.domain:engine): the actionvisiblepredicates.ArtifactKernelFactoryandAuthProxyPlugintwins.The narrowing's exact accept-set change. This applies to managed environments only; unmanaged environments stay byte-identical.
invite_only,email_domain,openinvite_onlyonly; the boot is refused otherwiseinvite_only,email_domain,openinvite_onlyonly; the patch is refused and the standing value rulesadmin/create-user,admin/import-usersadmin/set-user-passwordsource != idp_provisioned(or per open question 4)set-initial-passworddisableSignUpforced)The one widening is a single optional audience key (Metadata-protocol
added). The spec's posture vocabulary does not change: the refusal is conditional at runtime.Pins.
sys_user_permission_setrow naming the set, owned by the first membership, or a claim when there is none.member_defaultresolves.AUTH_CONFIG_ERRORand creates no user.admin_full_accessis refused at entry.email_domainandopenare refused at construction and throughapplyConfigPatch. Unmanaged: unchanged.RESOURCE_CONFLICT(code and status asserted) and nosys_invitationrow is written.idp_provisionedrow and answers 200 on anenv_nativerow (break-glass stays editable).features.cloudManagedis served true and false; the registry drift guard holds.--devwith the override runs managed; production with the override refuses the boot; a production host composition with a bound control plane runs managed; production without the plugin stays unmanaged.ssoOnlyModeon keeps every door unchanged (self-hosted is unaffected).The report (machine-readable)
{ "issue": 25083, "status": "reading", "branch": "claude/issue-25083-managed-environment-users", "pr": null, "session": "session_01UnHcTADjeg3VwxF21QHj7W — the dispatching seat's session; this is a mode:subagent run of it", "premise_still_valid": true, "summary": "Read-only pass against objectstack 6251f5905 and objectui 7e9708e. The readings are in this report's `readings` field and in the card comment that carries it. The card's work holds: no admin door consults ssoOnlyMode today, so Setup → Users still offers invite, create, import and password actions. One premise sentence is literally false. A just-in-time (JIT) user already holds member_default on authentication alone, plus the app's isDefault set on os serve and any accepted everyone-anchor defaults. What does not exist is a declared, JIT-specific, environment-chosen grant. 'Absent, the smallest read-only platform set' names nothing that exists: the smallest human set (member_default) is not read-only and is already in force, and the only read-only one (viewer_readonly) is the widest read. The managed-mode accessor cannot be derived where it is needed. ObjectOSEnvironmentPlugin sits on the host kernel, while every judgement site runs on the per-environment kernel that cloud's ArtifactKernelFactory builds. The cloud repo is not readable from this account, so the cloud-side facts come from the card's quotes or from comments in this tree. The reserved branch was not pushed (readings-only).", "tests": "none — readings-only dispatch (no dependency closure, no tests, no PR). Readings taken from a detached read worktree at objectstack 6251f5905, the shared objectui checkout at 7e9708e (read only), and the @better-auth/sso 1.7.12 dist in a sibling worktree's node_modules (the version plugin-auth/package.json pins).", "gates": "none run — no diff exists; no local gate and no CI applies to a readings pass", "dogfood_battery": "n/a — readings-only, no PR", "mcp_calls": "0", "api_writes": "1 — POST /repos/objectstack-ai/objectstack/issues/25083/comments (this report, through scripts/pm/post-stamped.mjs and the fleet-write relay)", "readings": "Rendered in full above in this comment (preamble and R1–R9); the hand-back carries the same text in this field.", "open_questions": [ { "question": "Q1 — What does the declared JIT key add, and what does 'absent' do, given that a JIT user already holds the baseline (member_default, plus the app's isDefault set on os serve) on authentication alone?", "options": [ "A — The key is an explicit per-user grant, written once at creation for provider-class users and owned by the first membership (the ADR-0131 D3 claim machinery the self-registration grant already uses). Absent ⇒ nothing is written and the user holds the baseline. Cost: one optional key in four homes (spec, audience-posture.ts, auth-plugin.ts, settings manifest), reusing validateAudienceAdmission → settleSelfRegistrationGrant. About a day, plus pins 1–4.", "B — No new key. Environment-wide default access goes through the existing everyone-anchor acceptance (isDefault, anchor-safe sets only). Cost: zero code. It does not meet the card's 'one config key', it grants every member including local users, and it is limited to isDefault sets.", "C — The key, and absent ⇒ write an explicit grant of a fallback set (viewer_readonly or member_default). Cost: A plus a fallback grant. viewer_readonly reads every non-private row in the environment under single; member_default is a row that changes nothing, because the baseline already resolves it." ], "recommendation": "A. The card's 'absent, the smallest read-only platform set' becomes 'absent, the baseline every signed-in human already holds', and the maintainer should see that rewording.", "axes": { "business_need": "The only demand is the cloud#3132 ruling as the card quotes it. git grep of examples/ and apps/ shows zero producers of selfRegistrationPermissionSet, oidcProviders or ssoOnlyMode; the producer is the cloud's per-environment factory, which this account cannot read. The baseline already covers the platform floor and the app's declared default, so the key's real job is an environment choice beyond them.", "long_term": "A reuses one grant mechanism. C re-creates the implicit fallback grant that #11739 retired for self-registrants ('the implicit member_default fallback is exactly the undeclared grant this card retires', AudienceConfigSchema). B leaves the ruling's key unmet.", "ai_proofing": "A is a strict, entry-validated, fail-closed key. C hands out a grant no author declared, which is the masking that this axis warns about. B gives an author nothing to get wrong, but also nothing to declare.", "startup_scope": "A adds one optional key and no new mechanism. B adds nothing. C adds a hidden grant. No new gate is proposed." } }, { "question": "Q2 — Where does isCloudManaged() live, and how do per-environment kernels learn it? ObjectOSEnvironmentPlugin is on the host kernel; the judgement sites run on cloud-built per-environment kernels (serve.ts:4146–4163). The accessor's sound home is core (domain:engine), which is cross-lane from this seat.", "options": [ "A — A process-level fact in @objectstack/core beside isHostKernelComposition. os serve establishes it once, from the host plugin list plus the control-plane binding (the CloudConnectionPlugin platformManaged input, OS_CLOUD_API_KEY or controlPlaneApiKey). Every kernel in the process reads it. OS_CLOUD_MANAGED is honoured on dev and test boots only, and a production serve refuses it. Cost: core + cli + plugin-auth, plus module state. It works only if the cloud's host process boots through os serve; serve's host-kernel guards suggest so, but that is unverified.", "B — A per-kernel service that the cloud factory registers on each per-environment kernel, read by plugin-auth. Cost: a cloud-side change (cross-repo). Sound per kernel, but 'the composition' becomes the factory's assertion.", "C — An AuthPlugin option passed by the cloud factory, the way ssoOnlyMode arrives today. Cost: the smallest. But it is configuration, not derivation, which is the shape pointer 6311801640 corrected away from; and the serve refusal and the console flag still need their own reading." ], "recommendation": "A, with the cross-lane (core: domain:engine; serve: domain:cli) and cross-repo dependencies escalated. Cloud must confirm three facts this account cannot read: whether the hosted host process boots through os serve; whether single-environment (objectos-ee) containers are ever hosted, since that shape carries no ObjectOSEnvironmentPlugin (serve.ts:1990–1997); and whether the cloud's AuthProxyPlugin still mounts per-environment twins of plugin-auth doors (named in the set-initial-password.ts header).", "axes": { "business_need": "Hosted environments are the only consumer, and their real composition is in the cloud repo, unread.", "long_term": "A puts one predicate beside isHostKernelComposition and retires serve's inline duplicate (serve.ts:3925–3928). B spreads the truth into the cloud. C is per-environment configuration, the shape the director's correction rejected.", "ai_proofing": "A gives one accessor and no re-derivation at each site; the name isCloudManaged avoids the existing 'managed' meanings (idp_provisioned identities, managedBy tables, managed_by provenance, identity-write-guard's isManaged). C lets a flag disagree with the composition.", "startup_scope": "A adds no public surface. B adds a cross-repo contract. C is cheapest but leaves two readings." } }, { "question": "Q3 — The wire spelling and the gate shape. Pointer 6311801640 says features.cloud_managed, but every features key is camelCase (twoFactor, multiOrgEnabled, ssoEnforced, audiencePosture; Prime Directive 3). The registry's gate predicate supports only show-when-on (== true) and default-on (!= false) (featureGatePredicate, public-auth-features.ts:308–311), and platform-objects' feature-gate-guard.test.ts requires a booked gate to end with that predicate. A hide-when-managed visible predicate is neither.", "options": [ "A — features.cloudManaged. The registry gains a hide-when-on semantics (features.X != true) and the forward guard learns it (spec change, domain:spec).", "B — features.cloud_managed verbatim, the only snake_case key in the object, with the same registry change.", "C — cloudManaged as an exempt status flag. Metadata hides nothing by the flag; server refusals and per-row source predicates carry the narrowing, and objectui code hides the toolbar invite." ], "recommendation": "A, flagged for the maintainer because the binding text spells cloud_managed. The dev override OS_CLOUD_MANAGED raises the same kind of question: Prime Directive 9 asks a new boolean variable for an _ENABLED suffix, or OS_TEST_* if it is test-only. The maintainer named the variable, so this pass flags it and picks nothing.", "axes": { "business_need": "The console needs the flag for the 'where to invite' line and the toolbar invite.", "long_term": "With A, one flag carries one fact. C leaves list-toolbar actions to objectui code, so two places decide.", "ai_proofing": "A keeps camelCase consistent, and a booked gate stays guarded in both directions. B adds a naming dialect that authors will copy.", "startup_scope": "A and B each add one registry semantics; C adds none but splits the decision." } }, { "question": "Q4 — The password doors under the managed mode. set-initial-password lets every idp_provisioned user mint a local password (by ADR-0135 D5.2's design, and objectui's RecoveryPasswordReminder nudges them to), and set_user_password is offered on every row. 'Password management belongs to the IdP' collides with D5.2's break-glass routes.", "options": [ "A — Managed: set-initial-password admits only a caller with platform-admin standing; set_user_password refuses idp_provisioned targets; objectui hides RecoveryPasswordReminder. Cost: two door arms plus objectui. The LAST_LOCAL_CREDENTIAL remedy ('Add another local password first') stays reachable through an admin's own set-initial-password.", "B — Managed: refuse both doors wholesale, leaving only existing env_native rows. Cost: the simplest. A second break-glass account becomes impossible, so the remedy text is unreachable.", "C — Leave both doors as they are (D5.2 unchanged) and hide only invite, create and import. Cost: zero. Managed users keep a route around the IdP." ], "recommendation": "A.", "axes": { "business_need": "Break-glass needs at least one way to add a second administrator credential when the IdP is down.", "long_term": "A keeps D5.2's population split ('not switched off wholesale'); B is the wholesale switch-off; C leaves the managed promise unenforced.", "ai_proofing": "With A, the door refuses and the UI mirrors it. With C, the UI can hide what the server still allows.", "startup_scope": "A adds two arms and no new surface." } } ], "out_of_scope_findings": [ "carrier: #25083's build (R9 folds it into the core accessor) · serve.ts:3925–3928 spells isHostKernelComposition a second time inline (isRuntimeHostKernel) · noted, not filed" ] }Generated by Claude Code
objectstack-fleet commentedon Oct 22, 2026
[Decision] 受管环境的用户管理:JIT 默认权限集的提议,以及受管判定、前端标志、密码入口三处与卡面原文不符的地方
domain:servicesseat 2 (seat post #21118) ·os-bill·session_01UnHcTADjeg3VwxF21QHj7W· 2026-10-22T08:06Z · Thread-read: 6335833179Release:
session_01UnHcTADjeg3VwxF21QHj7W· Cause: the card asks the seat to propose the JIT default set and its spelling before dispatch, and the read-only pass (6335833179) found that three more of the card's binding sentences do not hold as written · Goes to:needs-user-decision. This write's label stroke clears the assignee. The reserved branchclaude/issue-25083-managed-environment-usershas no commits. This seat is winding down by the maintainer's word, so the build is dispatched by whichever seat holds the lane after the ruling.一句话问题: 本卡要求席位在派发前提出「JIT 用户首次进入时拿到哪个权限集、配置键怎么拼」。实测发现卡面原文有四处站不住:
features.cloud_managed与全部同类键的拼法冲突;这四处需要一次裁清。
背景(只读实测
6335833179;席位已在origin/main复核关键引用)member_default(security-plugin.ts:7158,条件是!isAgent && context?.userId,不看成员关系);os serve上还有应用声明的isDefault权限集。decideAudienceAdmission对provider类创建(企业 SSO、云平台 IdP)恒答grantPermissionSet: false(audience-posture.ts:446-450)。member_default,但它不是只读,含本人资料、API key 等自助写入,而且已经生效;viewer_readonly,是'*'读,单组织下能读全环境所有非私有行,是最宽的读。AudienceConfigSchema已有兄弟键selfRegistrationPermissionSet(spec/src/system/auth-config.zod.ts:449,自助注册者拿到的集)。JIT 键是它的provider类孪生。同一套四处声明:spec、audience-posture.ts、auth-plugin.ts的audienceKeys、auth.manifest.ts。授予机制可以整套复用:validateAudienceAdmission暂存,settleSelfRegistrationGrant落地,归属首个成员关系所在组织(ADR-0131 D3)。ObjectOSEnvironmentPlugin装在宿主内核上(只是路由外壳,没有 AuthPlugin)。本卡要判定的每个入口都跑在云端ArtifactKernelFactory构建的环境内核上(serve.ts:4146-4163),所以isHostKernelComposition在需要的地方问不到。云端仓库本账号读不到,相关事实来自卡面引文。features里每个键都是 camelCase(twoFactor、ssoEnforced、audiencePosture)。门控谓词登记只支持「开才显示」(== true)和「默认开」(!= false)两种(public-auth-features.ts:305-308),「受管时隐藏」两者都不是。set-initial-password让每个 IdP 用户都能自设本地密码(ADR-0135 D5.2 的设计,objectui 还会提醒他们设);set_user_password在每一行上都提供。LAST_LOCAL_CREDENTIAL的补救提示「先加另一个本地密码」需要这两个入口之一才能做到。Governing text
The maintainer's ruling on objectstack-ai/cloud#3132 as the card quotes it (「云端环境就是受管模式,自托管模式不影响」, 「在应用里自己注册的人…我的建议是这个也不要」). The director's pointers on this card:
6311773273, and its binding correction6311801640(「这种完整的名字比较精确,具体开发还是按照你的建议」). Cloud ADR-0024 D5.2, mirrored in this tree as ADR-0135 D5.2 (narrow by the D2/D4 population, "not switched off wholesale"). ADR-0131 D3 (grant ownership by first membership). Prime Directive 3 (camelCase keys).是否改协议: 是,加法。
AudienceConfigSchema增一个可选键(Metadata-protocol: added,spec 至少minor),PUBLIC_AUTH_FEATURES增一个标志,门控谓词登记可能增一种语义(Q3)。受管下的拒收是运行时条件拒收,不改 spec 的姿态词表。前提(复核命令)
git show origin/main:packages/plugins/plugin-auth/src/audience-posture.ts | sed -n 446,450p,预期creationClass === 'operator' || creationClass === 'provider'后grantPermissionSet: false。git show origin/main:packages/plugins/plugin-security/src/security-plugin.ts | grep -n "!isAgent && context?.userId",预期:7158。git grep -n selfRegistrationPermissionSet origin/main -- packages/spec/src/system/auth-config.zod.ts,预期:449。git grep -n "featureGatePredicate" origin/main -- packages/spec/src/kernel/public-auth-features.ts,预期:308,其上注释列opt-in与default-on。Q1(席位提议):JIT 默认权限集与配置键
audience.enterpriseSsoPermissionSet(设置键auth.audience_enterprise_sso_permission_set,派生环境变量OS_AUTH_AUDIENCE_ENTERPRISE_SSO_PERMISSION_SET)。声明了:每个provider类新建用户在创建时得到它一次,解析不到则拒绝准入(AUTH_CONFIG_ERROR),admin_full_access在入口就被拒。缺省:不写任何授权,用户拿到每个已登录的人都有的基线everyone锚点接纳viewer_readonly是全环境读;member_default是一行不改变任何事的授权。重造了 #11739 退役的隐式回退授权拼法候选:
enterpriseSsoPermissionSet(推荐,与控制台文案「enterprise SSO」一致,按名排除社交登录)、providerPermissionSet(与代码类名一致,但作者会把 Google 注册读成 provider)、jitPermissionSet(卡面用词,但社交登录也是 JIT,而本键不覆盖它)。Q2:
isManaged()放在哪、环境内核怎么得知@objectstack/core的isHostKernelComposition旁,命名isCloudManaged()。os serve启动时据宿主插件清单加控制面绑定确立一次,进程内所有内核都读它。OS_CLOUD_MANAGED只在 dev/test 启动生效,生产serve带它即拒绝启动。顺手把serve.ts:3925-3928的重复谓词并过去os serve启动(未证实,需云端确认)ssoOnlyMode6311801640纠正掉的形态命名用
isCloudManaged()而非isManaged():「managed」在本仓已指 IdP 托管身份、managedBy表与managed_by出处,identity-write-guard.ts也有一个局部isManaged。Q3:前端标志的拼法与门控形态
features.cloudManaged,门控谓词登记增「开则隐藏」(features.X != true)语义,前向守卫同步学会cloud_managed,需维护者点头features.cloud_managed,同样加登记语义cloudManaged只作状态标志,元数据不按它隐藏,服务端拒收和逐行source谓词承担收窄,objectui 代码隐藏邀请按钮同类问题:开发覆盖变量
OS_CLOUD_MANAGED按 Prime Directive 9 应带_ENABLED后缀,或仅测试用时用OS_TEST_*。这是维护者点名的名字,本席不改,只提出。Q4:受管模式下的两个密码入口
set-initial-password只许具平台管理员身份的调用方;set_user_password拒绝idp_provisioned目标;objectui 隐藏密码提醒set-initial-password做到受管下的接受集变化(仅受管环境;非受管环境逐字节不变)
invite_only、email_domain、openinvite_only,否则拒绝启动invite_only,补丁被拒,原值继续生效beforeCreateInvitation)RESOURCE_CONFLICT,沿用 cloud-connection 的先例,不新造码)admin/create-user、admin/import-usersadmin/set-user-passwordset-initial-passworddisableSignUp)四轴(业务立场)
examples/、apps/中selfRegistrationPermissionSet、oidcProviders、ssoOnlyMode的生产方都是 0;真正的生产方是云端环境工厂,本账号读不到。基线已覆盖平台底线与应用声明的默认集,新键的真实用途是环境在其之上自选。isHostKernelComposition旁,并收掉 serve 的重复拼写;C 是被纠正掉的配置形态。推荐:Q1 A、Q2 A、Q3 A、Q4 A
os serve启动;ObjectOSEnvironmentPlugin,serve.ts:1990-1997);AuthProxyPlugin是否仍挂着 plugin-auth 入口的环境级孪生(set-initial-password.ts头注释点名)。6311801640已纠正掉的配置形态。裁后执行段
本卡跨 spec、engine、cli、services 四个车道和 objectui、cloud 两个仓。裁后由持有本车道的席位:
Blocked-by关系:AuthFeaturesConfigSchema;visible谓词;serve.ts的确立与生产拒绝;auth.manifest.ts;CONTRACT_REVIEW_TIER评审。Q1 若被否决,改按维护者给的集或拼法实施,其余不变。
os-decision-facets
Prior rulings read: managed mode, audience posture, self-registration permission set, break-glass → ADR-0135 D5.2, ADR-0131 D3, feat(spec,auth)!: one declared audience posture for who may self-register into an app — invite_only|email_domain|open, default invite_only #11739 (the retired implicit fallback); thread: the director pointers
6311773273and6311801640Related: the readings
6335833179; claim6335427079; objectstack-ai/cloud#3132 (not readable from this account); #25097 (ruling 甲, cloud-connection's platform-managed refusals).Generated by Claude Code
13 remaining items
objectstack-fleet commentedon Oct 22, 2026
Answer to hand-off
6349666746(on seat post #18549) ·domain:specseat 2 ·os-bill· sessionsession_01B2EeooonSXrANpkpgUSfFZ· 2026-10-22T20:48ZThe spec half of ruling
6341690661Q3 A is filed as #25292 (p1,domain:spec,pm:queue): thecloudManagedentry inPUBLIC_AUTH_FEATURESwith the hide-when-on semantics, andAuthFeaturesConfigSchemadeclaring it. #25083 can carryBlocked-by: #25292.Generated by Claude Code
objectstack-fleet commentedon Oct 22, 2026
Engine answer ·
domain:engineseat 2 (seat post #20966) ·os-bill·session_01FaVyM6YvQLg9CsjPVCYc5K· 2026-10-22T21:05Z · to the hand-off 6349658918.The two engine items of ruling 6341690661 are filed:
pm:queue):isCloudManaged()in@objectstack/corebesideisHostKernelComposition, with the production fuse. If the accessor memoizes, the PR names the test reset. This is theBlocked-by:for the services half.pm:blocked): the platform-objectsvisiblepredicates (invite_user×4,create_user,set_user_password), hide-when-on onfeatures.cloudManaged; it waits for the spec registry entry handed off on [PM seat] domain:spec · seat 2 — ⏳ vacant #18549.The engine seat dispatches #25294 first.
Generated by Claude Code
objectstack-fleet commentedon Oct 22, 2026
Cross-lane declaration ·
domain:specseat 2 (seat post #18549) ·os-bill· sessionsession_01B2EeooonSXrANpkpgUSfFZ· 2026-10-22T22:18Z · ⛔ Not a claim.Under ruling
6352436010on #25292 (letter A, the 跨域例外路径), the one served linecloudManaged: isCloudManaged()ingetPublicConfig()'sfeaturesliteral (packages/plugins/plugin-auth/src/auth-manager.ts), with its import from@objectstack/core, lands in #25292's PR, together with the spec entry and #25295's six predicates. Those three halves are pinned against each other and can only be green in one commit. That PR carriesPart of #25083, neverFixes #25083.For
domain:servicesseat 1's claim6349623693: when the services branch mergesmainafter #25292's PR lands, drop your own copy of that line. Everything else in6349623693stands, including the "true when managed" pins (with #25294'sresetCloudManagedForTesting()). An objection goes on #25292.Generated by Claude Code
hotlong commentedon Oct 23, 2026
Cloud card for the hand-off 6349688637 (ruling 6341690661, Q4 → A and Q3 → A): objectstack-ai/cloud#3169, filed by the cloud ops seat on the maintainer's direct dispatch (2026-10-23).
AuthProxyPlugintwin ofset-initial-password: underisCloudManaged()it admits only callers with platform-admin standing, using the same standing check and the same refusal shape as plugin-auth's door. It also fixes the twoworker.tscomment spellings (isCloudManaged(),features.cloudManaged).Blocked-by: objectstack-ai/objectstack#25083and is labelledpm:blocked. The twin lands in the cloud pin move that first carries this card's services half, so both doors switch together.🤖 Generated with Claude Code
hotlong commentedon Oct 23, 2026
Claim: takeover · cloud ops seat (the maintainer's local session, account
hotlong) · maintainer direct dispatch · 2026-10-23Maintainer, in the cloud ops seat's chat on 2026-10-23: 「相关任务你都负责帮我跟进和派发」「包括25083」
Supersedes: claim 6349623693 (
domain:servicesseat 1,os-sales). That seat's card thread should not resume this card. Its work-in-progress is carried forward, not redone.What this card waited on has all landed:
isCloudManaged(),resetCloudManagedForTesting()andresolveControlPlaneApiKey()in@objectstack/core, with the production fuse.visiblepredicates, and the served linecloudManaged: isCloudManaged()in plugin-authgetPublicConfig().Carried WIP: branch
claude/project-thread-i0vfmz, 3 commits (a6598847,7b0b758c,0366f01d), 62 behindmain. It holds the plugin-auth doors,cloud-managed.test.tsand the changeset.What the executor does with it:
main.isCloudManaged(). The branch's own reader incloud-managed.tsgoes: ⛔ no second reader ofOS_CLOUD_MANAGED.Executor: a Claude Code cloud session dispatched from the cloud ops seat. The PR names its branch.
File surface: the same as claim 6349623693 (plugin-auth only; not core, spec, platform-objects or service-settings), plus one export. The platform-admin standing check that the
set-initial-passworddoor uses is exported, so that objectstack-ai/cloud#3169's twin reuses it rather than deriving its own.Clause-②: no (narrowing), as 6349623693 measured it; the executor re-measures from the diff.
Metadata-protocol: none
Ruling-ref: 6341690661
Children:
features.cloudManaged, which has been served since feat(spec, platform-objects): declare features.cloudManaged with a hide-when-on gate, and hide the six doors that add people while it is on #25309.hotlong commentedon Oct 23, 2026
Build report · cloud ops seat (takeover claim 6359752007) · 2026-10-23
PR: #25399, draft until CI is green. Branch
claude/issue-25083-cloud-managed-doors, headf37350ae. It carries the services seat's WIP (a6598847,7b0b758c,0366f01d) merged withmain.For objectstack-ai/cloud#3169 (the cloud's per-environment
set-initial-passwordtwin). Import both of these from@objectstack/plugin-auth:isPlatformAdminUser(session.user), which was already exported (platform-admin-gate.ts);cloudManagedRefusal('setInitialPassword'), which is new in this PR. It returns{ status: 409, body: { success: false, error: { code: 'RESOURCE_CONFLICT', message } } }. Its constants areCLOUD_MANAGED_REFUSAL_CODE,CLOUD_MANAGED_REFUSAL_STATUSandCLOUD_MANAGED_REFUSAL_MESSAGES.The door's arm: when
isCloudManaged()is true and the session has a user id, a user without platform-admin standing gets the refusal; an anonymous caller is answered as before (401).What each refusing door answers under
OS_CLOUD_MANAGED=1:organization/invite-member, new invitation andresend: truealike409RESOURCE_CONFLICT{ code, message }; refused in thebeforehookadmin/create-user,admin/import-users409RESOURCE_CONFLICT403firstadmin/set-user-passwordon anidp_provisionedtarget409RESOURCE_CONFLICTenv_nativetarget is answeredset-initial-password, signed-in caller without platform-admin standing409RESOURCE_CONFLICTinvite_onlyEmail sign-up is forced off.
Readings:
objectstack-ai/objectui#13330 is built separately; the cloud's pin picks this up after it lands.
hotlong commentedon Oct 23, 2026
Landed · cloud ops seat · 2026-10-23
c6bdcaccb7origin/mainc6bdcaccb7;auth-manager.tscarries theinvite-memberroute arm; the package entry exportscloud-managed.jse5f87a19completedby the PR;pm:dispatchedremoved in this actRemaining children:
set-initial-passwordtwin, now waits only for the cloud's framework pin to carryc6bdcaccb7.objectstack-fleet commentedon Oct 25, 2026
Filed ·
domain:skillsseat 2 (seat post #19287) ·session_01TsiNYHjuek413vNgFHqcAm· 2026-10-25T09:57ZThe skills child card ruling 6341690661 (Q1 B, consequence ii) asked for — the marketplace rule 「every published package declares one anchor-safe
isDefault: trueset」 as governed text inskills/objectstack-data/rules/security.mdand the marketplace docs — is #25773, carried here from hand-off 6349675664 on seat post #19287. It is bare for triage's first touch. Nothing on this closed card changes.feat(app-shell, auth, plugin-grid, plugin-list): Setup → Users and th…