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

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

This branch was successfully deployed

1 active deployment
Preview — 5ba1080c Deployed Jul 13, 2026 by vercel[bot]
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