Files
cameleer-saas/docs/superpowers/specs/2026-04-07-architecture-review.md
hsiegeln 63c194dab7
Some checks failed
CI / build (push) Failing after 18s
CI / docker (push) Has been skipped
chore: rename cameleer3 to cameleer
Rename Java packages from net.siegeln.cameleer3 to net.siegeln.cameleer,
update all references in workflows, Docker configs, docs, and bootstrap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:28:44 +02:00

28 KiB

Cameleer Ecosystem Architecture Review

Date: 2026-04-07 Status: Ready for review Scope: cameleer (agent), cameleer-server, cameleer-saas Focus: Responsibility boundaries, architectural fitness, simplification opportunities Not in scope: Security hardening, code quality, performance


Executive Summary

The cameleer ecosystem has a clear vision: a standalone observability and runtime platform for Apache Camel, optionally managed by a thin SaaS vendor layer. Both deployment modes must be first-class.

The agent (cameleer) is architecturally clean. Single job, well-defined protocol.

The server (cameleer-server) is solid for observability but currently lacks runtime management capabilities (deploying and managing Camel application containers). These capabilities exist in the SaaS layer today but belong in the server, since standalone customers also need them.

The SaaS layer (cameleer-saas) has taken on too many responsibilities: environment management, app lifecycle, container orchestration, direct ClickHouse access, and partial auth duplication. It should be a thin vendor management plane: onboard tenants, provision server instances, manage billing. Nothing more.

The revised direction:

  • Server layer = the product. Observability + runtime management + auth/RBAC. Self-sufficient standalone, or managed by SaaS.
  • SaaS layer = vendor management plane. Owns tenant lifecycle (onboard, offboard, bill), provisions server instances, communicates exclusively via server REST APIs.
  • Strong data separation. Each layer has its own dedicated PostgreSQL and ClickHouse. No cross-layer database access.
  • Logto as federation hub. In SaaS mode, Logto handles all user authentication. Customers bring their own OIDC providers via Logto Enterprise SSO connectors.

What's Working Well

Agent (cameleer)

  • Clean separation: core logic in cameleer-core, protocol models in cameleer-common, delivery mechanisms (agent/extension) as thin wrappers
  • Well-defined agent-server protocol (PROTOCOL.md) with versioning
  • Dual-mode design (Java agent + Quarkus extension) is elegant
  • Compatibility matrix across 40 Camel versions demonstrates maturity
  • No changes needed

Server (cameleer-server)

  • Two-database pattern (PostgreSQL control plane, ClickHouse observability data) is correct
  • In-memory agent registry with heartbeat-based auto-recovery is operationally sound
  • cameleer-server-core / cameleer-server-app split keeps domain logic framework-free
  • SSE command push with Ed25519 signing is well-designed
  • The UI is competitive-grade (per UX audit #100)
  • Independent user/group/role management works for standalone deployments

SaaS (cameleer-saas)

  • Logto as identity provider was a good buy-vs-build decision
  • The async deployment pipeline (DeploymentExecutor) is well-implemented (will migrate to server)
  • Tenant isolation interceptor is a solid pattern
  • M2M token infrastructure (ServerApiClient) is the right integration pattern
  • The dual-deployment architecture document shows strong strategic thinking

Cross-cutting

  • MOAT features (debugger, lineage, correlation) correctly planned as agent+server features
  • Design doc discipline provides good decision traceability
  • Gitea issues show clear product thinking and prioritization

Current Architectural Problems

Problem 1: Environment and app management lives in the wrong layer

What happens today:

  • The SaaS has EnvironmentEntity, AppEntity, DeploymentEntity — full environment/app lifecycle management
  • The server treats applicationId and environmentId as opaque strings from agent heartbeats
  • The server has no concept of "deploy an app" or "create an environment"

Why this is wrong:

  • Standalone customers need environment and app management too. Without the SaaS, they have no way to deploy Camel JARs through a UI.
  • The server is the product. Runtime management is a core product capability, not a SaaS add-on.
  • The SaaS should provision a server instance for a tenant and then get out of the way. The tenant interacts with their server instance directly.

What should happen:

  • Environment CRUD, app CRUD, JAR upload, deployment lifecycle — all move to the server.
  • The server gains a RuntimeOrchestrator interface with auto-detected implementations:
    • Docker socket available → Docker mode (image build, container lifecycle)
    • K8s service account available → K8s mode (Deployments, Kaniko builds)
    • Neither → observability-only mode (agents connect externally, no managed runtime)
  • One deployable. Adapts to its environment. Standalone customer mounts Docker socket and gets full runtime management.

Problem 2: SaaS bypasses the server to access its databases

What happens today:

  • AgentStatusService queries the server's ClickHouse directly (SELECT count(), max(start_time) FROM executions)
  • ContainerLogService creates and manages its own container_logs table in the server's ClickHouse
  • The SaaS has its own ClickHouse connection pool (HikariCP, 10 connections)

Why this is wrong:

  • Violates the exclusive data ownership principle. The server owns its ClickHouse schema.
  • Schema changes in the server silently break the SaaS.
  • Creates tight coupling where there should be a clean API boundary.
  • Two connection pools to ClickHouse from different services adds unnecessary operational complexity.

What should happen:

  • The SaaS has zero access to the server's databases. All data flows through the server's REST API.
  • Container logs (a runtime concern) move to the server along with runtime management.
  • The SaaS has its own PostgreSQL for vendor concerns (tenants, billing, provisioning records). No ClickHouse needed.

Problem 3: Auth architecture doesn't support per-tenant OIDC

What happens today:

  • The server has one global OIDC configuration
  • In SaaS mode, it validates Logto tokens. All tenants use the same Logto instance.
  • Customers cannot bring their own OIDC providers (Okta, Azure AD, etc.)
  • The server generates its own JWTs after OIDC callback, creating dual-issuer problems (#38)
  • syncOidcRoles writes shadow copies of roles to PostgreSQL on every login

Why this matters:

  • Enterprise customers require SSO with their own identity provider. This is table-stakes for B2B SaaS.
  • The dual-issuer pattern (server JWTs + Logto JWTs) causes the session synchronization problem (#38).
  • Per-tenant OIDC needs a federation hub, not per-server OIDC config changes.

What should happen:

  • Standalone mode: Server manages users/groups/roles independently. Optional OIDC integration pointing to customer's IdP directly. Works exactly as today.
  • SaaS mode: Logto acts as federation hub via Enterprise SSO connectors. Each tenant/organization configures their own SSO connector (SAML or OIDC). Logto handles federation and issues a single token type. The server validates Logto tokens (one OIDC config). Single token issuer eliminates #38.
  • Server auth behavior (inferred from config, no explicit mode flag):
    • No OIDC configured: Full local auth. Server generates JWTs, manages users/groups/roles.
    • OIDC configured: Local + OIDC coexist. Claim mapping available.
    • OIDC configured + cameleer.auth.local.enabled=false: Pure resource server. No local login, no JWT generation, no shadow role sync. SaaS provisioner sets this.

Problem 4: License validation has no server-side implementation

What exists today:

  • The SaaS generates Ed25519-signed license JWTs with tier/features/limits
  • The server has zero license awareness — no validation, no feature gating, no tier concept
  • MOAT features cannot be gated at the server level

What should happen:

  • Server validates Ed25519-signed license JWTs
  • License loaded from: env var, file path, or API endpoint
  • MOAT feature endpoints check license before serving data
  • In standalone: license file at /etc/cameleer/license.jwt
  • In SaaS: license injected during tenant provisioning

Revised Architecture

Layer Separation

SAAS LAYER (vendor management plane)
  Owns: tenants, billing, provisioning, onboarding/offboarding
  Storage: own PostgreSQL (vendor data only)
  Auth: Logto (SaaS vendor UI, tenant SSO federation hub)
  Communicates with server layer: exclusively via REST API

SERVER LAYER (the product)
  Owns: observability, runtime management, environments, apps,
        deployments, users, groups, roles, agent protocol, licenses
  Storage: own PostgreSQL (RBAC, config, app metadata) + own ClickHouse (traces, metrics, logs)
  Auth: standalone (local + optional OIDC) or oidc-only (validates external tokens)
  Multi-tenancy: tenant_id-scoped data access in shared PG + CH

AGENT LAYER
  Owns: instrumentation, data collection, command execution
  Communicates with server: via PROTOCOL.md (HTTP + SSE)

Deployment Models

Standalone (single tenant):

Customer runs one server instance.
Server manages its own users, apps, environments, deployments.
Server connects to its own PG + CH.
No SaaS involvement. No Logto.
Optional: customer configures OIDC to their corporate IdP.
License: file-based or env var.

SaaS (multi-tenant):

Vendor runs the SaaS layer + Logto + shared PG (vendor) + shared PG (server) + shared CH.
SaaS provisions one server instance per tenant.
Each server instance is scoped to one tenant_id.
All server instances share PG + CH (tenant_id partitioning).
Auth: Logto federation hub. Per-tenant Enterprise SSO connectors.
Server runs in oidc-only mode, validates Logto tokens.
SaaS communicates with each server instance via REST API (M2M token).
License: injected by SaaS during provisioning, pushed via server API.

SaaS Tenant Onboarding Flow

1. Vendor creates tenant in SaaS layer (name, slug, tier, billing)
2. SaaS creates Logto organization (maps 1:1 to tenant)
3. SaaS generates Ed25519-signed license JWT (tier, features, limits, expiry)
4. SaaS provisions server instance:
   - Docker mode: start container with tenant_id + license + OIDC config
   - K8s mode: create Deployment in tenant namespace
5. SaaS calls server API to verify health
6. Tenant admin logs in via Logto (federated to their SSO if configured)
7. Tenant admin uploads Camel JARs, manages environments, deploys apps — all via server UI
8. SaaS only re-engages for: billing, license renewal, tier changes, offboarding

Runtime Orchestration in the Server

The server gains runtime management, auto-detected by environment:

RuntimeOrchestrator (interface)
  + createEnvironment(name, config) -> Environment
  + deployApp(envId, jar, config) -> Deployment
  + stopApp(appId) -> void
  + restartApp(appId) -> void
  + getAppLogs(appId, since) -> Stream<LogLine>
  + getAppStatus(appId) -> AppStatus

DockerRuntimeOrchestrator
  - Activated when /var/run/docker.sock is accessible
  - docker-java for image build + container lifecycle
  - Traefik labels for HTTP routing

KubernetesRuntimeOrchestrator
  - Activated when K8s service account is available
  - fabric8 for Deployments, Services, ConfigMaps
  - Kaniko for image builds

DisabledRuntimeOrchestrator
  - Activated when neither Docker nor K8s is available
  - Observability-only mode: agents connect externally
  - Runtime management endpoints return 404

Auth Architecture

Auth mode is inferred from configuration — no explicit mode flag.

No OIDC configured → standalone:

  • Server manages users, groups, roles in its own PostgreSQL
  • Local login via username/password
  • Server generates JWTs (HMAC-SHA256)
  • Agent auth: bootstrap token → JWT exchange

OIDC configured → standalone + OIDC:

  • Local auth still available alongside OIDC
  • OIDC users auto-signup on first login
  • Claim mapping available for automated role/group assignment
  • Server generates JWTs for both local and OIDC users
  • Agent auth: bootstrap token → JWT exchange

OIDC configured + local auth disabled (cameleer.auth.local.enabled=false) → OIDC-only:

  • Server is a pure OAuth2 resource server
  • Validates external JWTs (Logto or any OIDC provider)
  • Reads roles from configurable JWT claim (roles, scope, etc.)
  • No local login, no JWT generation, no user table writes on login
  • Agent auth: bootstrap token → JWT exchange (agents always use server-issued tokens)
  • In SaaS mode, the SaaS provisioner sets cameleer.auth.local.enabled=false

Per-tenant OIDC in SaaS (via Logto Enterprise SSO):

Tenant A (uses Okta):
  User → Logto → detects email domain → redirects to Okta → authenticates
  → Okta returns assertion → Logto issues JWT with org context
  → Server validates Logto JWT (one OIDC config for all tenants)

Tenant B (uses Azure AD):
  User → Logto → detects email domain → redirects to Azure AD → authenticates
  → Azure AD returns assertion → Logto issues JWT with org context
  → Server validates same Logto JWT

Tenant C (no enterprise SSO):
  User → Logto → authenticates with Logto credentials directly
  → Logto issues JWT with org context
  → Server validates same Logto JWT

Single token issuer (Logto). Single server OIDC config. Per-tenant SSO handled entirely in Logto. Eliminates dual-issuer problem (#38).


What Moves Where

From SaaS to Server

Component Current Location New Home Notes
EnvironmentEntity + CRUD SaaS Server First-class server concept
AppEntity + CRUD SaaS Server First-class server concept
DeploymentEntity + lifecycle SaaS Server First-class server concept
DeploymentExecutor SaaS Server Async deployment pipeline
DockerRuntimeOrchestrator SaaS Server Docker mode runtime
JAR upload + image build SaaS Server Runtime management
Container log collection SaaS Server Part of runtime management
AgentStatusService SaaS Removed Server already has this natively
ContainerLogService SaaS Server Logs stored in server's ClickHouse

Stays in SaaS

Component Why
TenantEntity + lifecycle Vendor concern: onboarding, offboarding
LicenseService (generation) Vendor signs licenses
Billing integration (Stripe) Vendor concern
Logto bootstrap + org management Vendor concern
ServerApiClient SaaS → server communication (grows in importance)
Audit logging (vendor actions) Vendor concern

Stays in Server

Component Why
Agent protocol (registration, heartbeat, SSE) Core product
Observability pipeline (ingestion, storage, querying) Core product
User/group/role management Must work standalone
OIDC integration Must work standalone
Dashboard, route diagrams, execution detail Core product
Ed25519 config signing Agent security
License validation (new) Feature gating

Removed Entirely from SaaS

Component Why
ClickHouseConfig + connection pool SaaS must not access server's CH
ClickHouseProperties No ClickHouse in SaaS
AgentStatusService.getObservabilityStatus() Server API replaces this
container_logs table in CH Moves to server with runtime management
Environment/App/Deployment entities Move to server

What the SaaS Becomes

After migration, the SaaS layer is small and focused:

cameleer-saas/
├── tenant/          Tenant CRUD, lifecycle (PROVISIONING → ACTIVE → SUSPENDED → DELETED)
├── license/         License generation (Ed25519-signed JWTs)
├── billing/         Stripe integration (subscriptions, webhooks, tier changes)
├── identity/        Logto org management, Enterprise SSO configuration
├── provisioning/    Server instance provisioning (Docker / K8s)
├── config/          Security, SPA routing
├── audit/           Vendor action audit log
└── ui/              Vendor management dashboard (tenant list, billing, provisioning status)

SaaS API surface shrinks to:

  • POST/GET /api/tenants — tenant CRUD (vendor admin)
  • POST/GET /api/tenants/{id}/license — license management
  • POST /api/tenants/{id}/provision — provision server instance
  • POST /api/tenants/{id}/suspend — suspend tenant
  • DELETE /api/tenants/{id} — offboard tenant
  • GET /api/tenants/{id}/status — server instance health (via server API)
  • GET /api/config — public config (Logto endpoint, scopes)
  • Billing webhooks (Stripe)

Everything else (environments, apps, deployments, observability, user management) is the server's UI and API, accessed directly by the tenant.


Migration Path

Order Action Effort Notes
1 Add Environment/App/Deployment entities + CRUD to server Medium Port from SaaS, adapt to server's patterns
2 Add RuntimeOrchestrator interface + DockerRuntimeOrchestrator to server Medium Port from SaaS, add auto-detection
3 Add JAR upload + image build pipeline to server Medium Port DeploymentExecutor
4 Add container log collection to server Small Part of runtime management
5 Add server API endpoints for app/env management Medium REST controllers + UI pages
6 Add oidc-only auth mode to server Medium Resource server mode
7 Implement server-side license validation Medium Ed25519 JWT validation + feature gating
8 Strip SaaS down to vendor management plane Medium Remove migrated code, simplify
9 Remove ClickHouse dependency from SaaS entirely Small Delete config, connection pool, queries
10 Write SAAS-INTEGRATION.md Small Document server API contract for SaaS

Steps 1-5 can be developed as a "runtime management" feature in the server. Steps 6-7 are independent server features. Step 8 is the SaaS cleanup after server capabilities are in place.


Issue Triage Notes

Issues resolved by this architecture:

  • saas#38 (session management): Eliminated — single token issuer in SaaS mode
  • server#100 (UX audit): Server UI gains full runtime management, richer experience
  • server#122 (ClickHouse scaling): SaaS no longer a ClickHouse client
  • saas#7 (license & feature gating): Server-side license validation
  • saas#37 (admin tenant creation UI): SaaS UI becomes vendor-focused, simpler

Issues that become more important:

  • agent#33 (version cameleer-common independently): Critical before server API contract stabilizes
  • server#46 (OIDC PKCE for SPA): Required for server-ui in oidc-only mode
  • server#101 (onboarding experience): Server UI needs guided setup for standalone users

Issues unaffected:

  • MOAT epics (#57-#72): Correctly scoped as agent+server. License gating is the prerequisite.
  • UX audit P0s (#101-#103): PMF-critical. Independent of architectural changes.
  • Agent transport/security (#13-#15, #52-#54): Agent concerns, unrelated.

User Management Model

RBAC Structure

The server has a classical RBAC model with users, groups, and roles. All stored in the server's PostgreSQL.

Entities:

  • Users — identity records (local or OIDC-sourced)
  • Groups — organizational units; can nest (parent_group_id). Users belong to groups.
  • Roles — permission sets. Attached to users (directly) or groups (inherited by members).
  • Permissions — what a role allows (view executions, send commands, manage config, admin, deploy apps, etc.)

Built-in roles (system roles, cannot be deleted):

  • VIEWER — read-only access to observability data and runtime status
  • OPERATOR — VIEWER + send commands, edit config, deploy/manage apps
  • ADMIN — full access including user/group/role management, server settings, license

Custom roles may be defined by the server admin for finer-grained control.

Assignment Types

Every user-role and user-group assignment has an origin:

Origin Set by Lifecycle On OIDC login
direct Admin manually assigns via UI/API Persisted until admin removes it Untouched
managed Claim mapping rules evaluate JWT Recalculated on every OIDC login Cleared and re-evaluated

Effective permissions = union of direct roles + managed roles + roles inherited from groups (both direct and managed group memberships).

Schema:

-- user_roles
user_id     UUID NOT NULL
role_id     UUID NOT NULL
origin      VARCHAR NOT NULL  -- 'direct' or 'managed'
mapping_id  UUID              -- NULL for direct; FK to claim_mapping_rules for managed

-- user_groups (same pattern)
user_id     UUID NOT NULL
group_id    UUID NOT NULL
origin      VARCHAR NOT NULL  -- 'direct' or 'managed'
mapping_id  UUID              -- NULL for direct; FK to claim_mapping_rules for managed

Claim Mapping

When OIDC is configured, the server admin can define claim mapping rules that automatically assign roles or group memberships based on JWT claims. Rules are server-level config (one set per server instance = effectively per-tenant in SaaS mode).

Rule structure:

-- claim_mapping_rules
id          UUID PRIMARY KEY
claim       VARCHAR NOT NULL  -- JWT claim to read (e.g., 'groups', 'roles', 'department')
match_type  VARCHAR NOT NULL  -- 'equals', 'contains', 'regex'
match_value VARCHAR NOT NULL  -- value to match against
action      VARCHAR NOT NULL  -- 'assignRole' or 'addToGroup'
target      VARCHAR NOT NULL  -- role name or group name
priority    INT DEFAULT 0     -- evaluation order (higher = later, for conflict resolution)

Examples:

[
  { "claim": "groups", "match": "contains", "value": "cameleer-admins", "action": "assignRole", "target": "ADMIN" },
  { "claim": "groups", "match": "contains", "value": "integration-team", "action": "addToGroup", "target": "Integration Developers" },
  { "claim": "department", "match": "equals", "value": "ops", "action": "assignRole", "target": "OPERATOR" }
]

Login flow with claim mapping:

1. User authenticates via OIDC
2. Server receives JWT with claims
3. Auto-signup: create user record if not exists (with configurable default role)
4. Clear all MANAGED assignments for this user:
     DELETE FROM user_roles WHERE user_id = ? AND origin = 'managed'
     DELETE FROM user_groups WHERE user_id = ? AND origin = 'managed'
5. Evaluate claim mapping rules against JWT claims:
     For each rule (ordered by priority):
       Read claim value from JWT
       If match_type matches match_value:
         Insert MANAGED assignment (role or group)
6. User's effective permissions are now:
     direct roles + managed roles + group-inherited roles

How It Scales

Size OIDC Claim mapping Admin work
Solo / small (1-10) No, local auth N/A Create users, assign roles manually
Small + OIDC (5-20) Yes Not needed Auto-signup with default VIEWER. Admin promotes key people (direct assignments)
Medium org (20-100) Yes 3-5 claim-to-role rules One-time setup. Most users get roles automatically. Manual overrides for exceptions
Large org (100+) Yes Claim-to-group mappings One-time setup. IdP groups → server groups → roles. Fully automated, self-maintaining
SaaS (Logto) Enforced Default mapping: roles claim → server roles Vendor sets defaults. Customer configures SSO claim structure, then adds mapping rules

Standalone vs SaaS Behavior

Standalone (no OIDC configured):

  • Full local user management: create users, set passwords, assign roles/groups
  • Admin manages everything via server UI

Standalone + OIDC (OIDC configured and enabled):

  • Local user management still available
  • OIDC users auto-signup on first login (configurable: on/off, default role)
  • Claim mapping available for automated role/group assignment
  • Both local and OIDC users coexist

SaaS / OIDC-only (OIDC configured and enabled, local auth disabled):

  • Inferred: when OIDC is configured and enabled, the server operates as a pure resource server
  • No local user creation or password management
  • Users exist only after first OIDC login (auto-signup always on)
  • Claim mapping is the primary role assignment mechanism
  • Admin can still make direct assignments via UI (for overrides)
  • User/password management UI sections hidden
  • Logto org roles → JWT roles claim → server claim mapping → server roles

Note: There is no explicit cameleer.auth.mode flag. The server infers its auth behavior from whether OIDC is configured and enabled. If OIDC is present, the server acts as a resource server for user-facing auth (agents always use server-issued tokens regardless).

SaaS with Enterprise SSO (per-tenant customer IdPs)

Customer uses Okta:
  Okta JWT contains: { "groups": ["eng", "cameleer-admins"], "department": "platform" }
    → Logto Enterprise SSO federates, forwards claims into Logto JWT
    → Server evaluates claim mapping rules:
         "groups contains cameleer-admins" → ADMIN role (managed)
         "department equals platform" → add to "Platform Team" group (managed)
    → User gets: ADMIN + Platform Team's inherited roles

Customer uses Azure AD:
  Azure AD JWT contains: { "roles": ["CameleerOperator"], "jobTitle": "Integration Developer" }
    → Same flow, different mapping rules configured by that tenant's admin:
         "roles contains CameleerOperator" → OPERATOR role (managed)

Each tenant configures their own mapping rules in their server instance. The server doesn't care which IdP issued the claims — it just evaluates rules against whatever JWT it receives.


Resolved Design Questions

Server Instance Topology

  • CAMELEER_TENANT_ID env var (already exists) scopes all data access in PG and CH
  • Standalone: defaults to "default", customer never thinks about it
  • SaaS: the SaaS provisioner sets it when starting the server container
  • Auth behavior is inferred from OIDC configuration (no explicit mode flag)
  • The server doesn't need to "know" it's in SaaS mode — tenant_id + OIDC config is sufficient

Runtime Orchestrator Scope (Routing)

The server owns routing as part of the RuntimeOrchestrator abstraction. Two routing strategies, configured at the server level:

cameleer.routing.mode=path        → api.example.com/apps/{env}/{app}  (default, works everywhere)
cameleer.routing.mode=subdomain   → {app}.{env}.apps.example.com      (requires wildcard DNS + TLS)
  • Path-based is the default — no wildcard DNS or TLS required, works in every environment
  • Subdomain-based is opt-in for customers who prefer it and can provide wildcard infrastructure
  • Docker mode: Traefik labels on deployed containers
  • K8s mode: Service + Ingress resources
  • The routing mechanism is an implementation detail of each RuntimeOrchestrator, not a separate concern

UI Navigation

The current server navigation structure is preserved. Runtime management integrates as follows:

  • Environment management → Admin section (high-privilege task, not daily workflow)
  • Applications → New top-level nav item (app list, deploy, JAR upload, container status, deployment history)
  • Observability pages (Exchanges, Dashboard, Routes, Logs) → unchanged

Applications page design requires a UI mock before finalizing — to be explored in the frontend design phase.

Data Migration

Not applicable — greenfield. No existing installations to migrate.


Summary

The cameleer ecosystem is well-conceived but the current SaaS-server boundary is in the wrong place. The SaaS has grown into a second product rather than a thin vendor layer.

The fix is architectural: move runtime management (environments, apps, deployments) into the server, make the SaaS a pure vendor management plane, enforce strict data separation, and use Logto Enterprise SSO as the federation hub for per-tenant OIDC.

The result: the server is the complete product (observability + runtime + auth). The SaaS is how the vendor manages tenants of that product. Both standalone and SaaS are first-class because the server doesn't depend on the SaaS for any of its capabilities.