Skip to content

feat(auth): tenancy service + membership reconciler + degraded-tenancy fail-fast (ADR-0093 Phases 1–3)#2884

Merged
os-zhuang merged 4 commits into
mainfrom
claude/tenancy-lifecycle-membership
Jul 13, 2026
Merged

feat(auth): tenancy service + membership reconciler + degraded-tenancy fail-fast (ADR-0093 Phases 1–3)#2884
os-zhuang merged 4 commits into
mainfrom
claude/tenancy-lifecycle-membership

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Implements ADR-0093 Phases 1–3. Follows #2882 (Phase 0 — tactical create-user bind, merged), which surfaced the two structural gaps this PR closes.

Why

The bug in #2882 (an admin-created user wasn't a member of the org) was one instance of two systemic gaps:

  1. No one owns the "every new user gets a membership" invariant — it was re-implemented or forgotten per creation path.
  2. "What tenancy mode is this?" had four disagreeing answers — the worst being silent degradation: OS_MULTI_ORG_ENABLED=true without the enterprise package strips tenant RLS with only a console warning (an ADR-0049-class unenforced security property).

What

Phase 1 — tenancy as a first-class capability

  • tenancy kernel service (plugin-auth/src/tenancy-service.ts) — single source of truth: mode / isolationActive / requested / degraded / defaultOrgId(). Derives isolationActive from the org-scoping probe, so @objectstack/organizations (separate repo) lights it up with no change.
  • Fail-fast boot guard (serve.ts, D5) — multi-org requested but unavailable ⇒ refuse to boot (process.exit(1) with an actionable message; a plain throw would be swallowed by the surrounding AuthPlugin try). Escape hatch OS_ALLOW_DEGRADED_TENANCY=1 boots in an explicitly branded degraded state. New resolver resolveAllowDegradedTenancy in @objectstack/types.

Phase 2 — single-owner membership + consumer migration

  • Membership reconciler (plugin-auth/src/reconcile-membership.ts) composed into better-auth's user.create.after — the one seam every creation path flows through (signup, admin create-user, import, SSO JIT). Yields to any existing membership (host hooks win — no double bind), honors membershipPolicy: 'auto' | 'invite-only' (default auto), binds only to an unambiguous target org (single → default; multi → nothing).
  • Consumers migrated to the tenancy service: SecurityPlugin's RLS-strip gate and getPublicConfig (features.multiOrgEnabled now reflects actual capability; new features.degradedTenancy). Both keep a direct-probe fallback so lean embeddings are unchanged.
  • fix(auth): bind admin-created users to the sole organization #2882 endpoint bind delegates to the shared reconciler — belt-and-suspenders until the hook's coverage of admin.createUser is integration-verified; both idempotent and yield-to-existing, so double-coverage is harmless (ADR-0093 D2).

Phase 3 — backfill + docs

  • kernel:ready backfill for pre-existing member-less users (single-org / auto only; self-guards in multi-org; OS_SKIP_MEMBERSHIP_BACKFILL=1 opt-out).
  • Docs: Deployment → Tenancy Modes & Membership; ADR-0093 implementation notes.

Testing

Pure modules are fully unit-tested; wiring verified by typecheck (no new-symbol errors — remaining tsc output is the repo's pre-existing unbuilt-workspace cascade).

Suite Result
tenancy-service.test.ts 11 passed
reconcile-membership.test.ts 11 passed
admin-user-endpoints.test.ts 29 passed (refactor preserved the #2882 cases)
env.test.ts (+ resolveAllowDegradedTenancy) 10 passed

New env vars

Variable Default Effect
OS_ALLOW_DEGRADED_TENANCY off Boot despite multi-org requested but isolation unavailable (degraded, branded).
OS_SKIP_MEMBERSHIP_BACKFILL off Skip the boot-time membership backfill.

Rollout note (breaking for silently-degraded deployments)

A deployment running OS_MULTI_ORG_ENABLED=true without @objectstack/organizations was silently unisolated; it will now fail to boot after upgrade. That is intentional (ADR-0093 D5) — install the enterprise package or set OS_ALLOW_DEGRADED_TENANCY=1 to acknowledge the state.

Deferred (per ADR non-goals)

Single-org membership still does not gate data access; the dual frontend feature flags keep their meaning; better-auth keeps sys_member CRUD; multi-org target-org policy stays "don't guess". Fully deleting the endpoint bind waits on integration confirmation that the hook fires for admin.createUser.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XbLd2KKeVs39EztDYMQ1YU


Generated by Claude Code

…wner membership reconciler (ADR-0093)

Implements ADR-0093 Phases 1-3.

Phase 1 — tenancy as a first-class capability:
- New `tenancy` kernel service (plugin-auth) — single source of truth for
  mode / isolationActive / requested / degraded / defaultOrgId(). Derives
  isolationActive from the org-scoping probe, so @objectstack/organizations
  lights it up with no change.
- serve.ts fail-fast (D5): OS_MULTI_ORG_ENABLED=true without a working
  @objectstack/organizations refuses to boot (tenant RLS would be silently
  stripped). Escape hatch OS_ALLOW_DEGRADED_TENANCY=1 boots in an explicitly
  branded degraded state. New env resolver resolveAllowDegradedTenancy.

Phase 2 — single-owner membership + consumer migration:
- reconcile-membership.ts: the membership invariant's one owner, composed into
  better-auth user.create.after (covers signup / admin create / import / SSO
  JIT). Yields to existing membership, honors membershipPolicy (auto |
  invite-only), binds only to an unambiguous target org.
- SecurityPlugin RLS-strip gate and getPublicConfig (features.multiOrgEnabled
  now reflects actual capability; new features.degradedTenancy) consume the
  tenancy service instead of re-deriving.
- The #2882 endpoint create-user bind delegates to the shared reconciler
  (belt-and-suspenders; both idempotent and yield-to-existing).

Phase 3 — backfill + docs:
- kernel:ready backfill for pre-existing member-less users (single/auto only;
  OS_SKIP_MEMBERSHIP_BACKFILL to opt out).
- Deployment → Tenancy Modes & Membership docs; ADR-0093 implementation notes.

Tests: tenancy-service (11), reconcile-membership (11), admin-user-endpoints
(29, refactor preserved), env resolveAllowDegradedTenancy (added). Pure modules
fully unit-tested; wiring verified by typecheck (no new-symbol errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbLd2KKeVs39EztDYMQ1YU
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/xl labels Jul 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/cli, @objectstack/plugin-auth, @objectstack/plugin-security, @objectstack/types.

26 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx (via packages/cli)
  • content/docs/api/client-sdk.mdx (via @objectstack/cli)
  • content/docs/api/data-flow.mdx (via @objectstack/cli)
  • content/docs/api/environment-routing.mdx (via @objectstack/cli)
  • content/docs/api/error-catalog.mdx (via @objectstack/cli)
  • content/docs/automation/hook-bodies.mdx (via packages/cli)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/getting-started/cli.mdx (via @objectstack/cli, @objectstack/plugin-auth, @objectstack/plugin-security)
  • content/docs/kernel/runtime-services/data-service.mdx (via packages/cli)
  • content/docs/kernel/runtime-services/index.mdx (via packages/cli)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/access-recipes.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/authentication.mdx (via @objectstack/cli, @objectstack/plugin-auth)
  • content/docs/permissions/authorization.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/explain.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/permissions-matrix.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth, @objectstack/plugin-security)
  • content/docs/plugins/packages.mdx (via @objectstack/cli, @objectstack/plugin-auth, @objectstack/plugin-security, @objectstack/types)
  • content/docs/protocol/objectos/plugin-spec.mdx (via @objectstack/cli)
  • content/docs/protocol/objectos/realtime-protocol.mdx (via @objectstack/cli)
  • content/docs/releases/implementation-status.mdx (via @objectstack/cli, @objectstack/plugin-auth, @objectstack/plugin-security)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth)
  • content/docs/ui/audience-based-interfaces.mdx (via packages/plugins/plugin-security)
  • content/docs/ui/dashboards.mdx (via @objectstack/plugin-security)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…nancy guard

Cross-links the new fail-fast guard (ADR-0093 D5) from the production-readiness
checklist — operators reviewing a multi-org deployment should confirm
features.degradedTenancy is false and never set OS_ALLOW_DEGRADED_TENANCY in
production.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbLd2KKeVs39EztDYMQ1YU
@vercel

vercel Bot commented Jul 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jul 13, 2026 2:34pm

Request Review

…rst registration

Test Core failed: auth-plugin.test.ts captures the registered AuthManager via
registerService.mock.calls[0][1] (first registration). The tenancy service was
registered before `auth`, so calls[0] became tenancy (no setRuntimeBaseUrl) and
the spies threw. Move the tenancy registration to after the `auth` service
registration — the getTenancy closure in authConfig is lazy (read at hook-fire
time), so nothing needs it earlier.

Also update getPublicConfig's exact-match features assertion to include the new
`degradedTenancy` field.

Verified locally with built workspace deps: plugin-auth 433, plugin-security 308,
types 10 — all green; plugin-auth + plugin-security typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbLd2KKeVs39EztDYMQ1YU
Verified against objectstack-ai/cloud and runtime dogfooding:

- D1: clarify that `auto` JOINS the sole existing org and never CREATES one —
  workspace-per-user stays a host/product decision (cloud personal-org hook),
  so the default does not conflict with the framework's B2B posture.
- D2: ratify endpoint delegation as the FINAL state (not interim). The hook
  fires for admin.createUser (runtime-verified), but better-auth defers
  user.create.after post-commit (#1881), so the endpoint keeps its delegated
  call to report organizationId/membershipCreated deterministically.
- D3: add the cloud reality-check — personal-org hook chains first, reconciler
  yields/no-ops; the division of labor is what the host already assumes.
- D5: blast-radius note — cloud SaaS/EE boot outside serve.ts with the plugin
  bundled (immune); EE license-gates the flag off; only misconfigured CE
  self-hosts are stopped, which is the guard's exact purpose. Supports
  shipping in the next minor. Cloud-side defense-in-depth (factory throw, not
  process.exit) tracked as a cloud PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbLd2KKeVs39EztDYMQ1YU
@os-zhuang os-zhuang marked this pull request as ready for review July 13, 2026 14:42
@os-zhuang os-zhuang merged commit 824a395 into main Jul 13, 2026
16 checks passed
@os-zhuang os-zhuang deleted the claude/tenancy-lifecycle-membership branch July 13, 2026 14:42
os-zhuang added a commit that referenced this pull request Jul 13, 2026
… tests (#2887)

Follow-up to #2884. Multi-org runtime verification caught a real bug: the endpoint create-user bind resolved its target org via resolveDefaultOrgId, which prefers slug='default' regardless of org count — so in multi-org it would have mis-bound every new user into the bootstrap org (violating ADR-0093 D3). The bind now consults the tenancy service (single → default org; multi → no bind), falling back to the old resolution only when tenancy isn't wired. Adds dogfood integration tests driving the real better-auth pipeline: sign-up reconciler membership, D6 backfill, invite-only policy, and the D2 yield rule. Exports reconcile-membership + tenancy-service as the ADR-0093 host API.
This was referenced Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants