Skip to content

Pre-1.0 Roadmap

This document captures the strategic roadmap for bringing the Platform starter kit to a v1.0 release state. It is derived from a full codebase audit conducted in March 2026 and shaped by the following goals:

  • Serve as a personal project template (clone and go)
  • Eventual open-source release of a minimal version
  • Blog series documenting each phase

A v1.0 release is a kit a developer can git clone, set six env vars, docker compose up, and have:

  • A working multi-tenant or single-tenant SaaS shell with no manual database surgery
  • Auth (password + magic link + optional OAuth) with email confirmation gated by a feature flag
  • An org lifecycle that enforces its own invariants on the backend, not just the frontend
  • A billing adapter that trivially swaps between Stripe, Polar, or a local dev no-op
  • Rate limiting on every public-facing action
  • A CI pipeline that runs on every push
  • One worked example of a domain module (Helpdesk) properly wired to plan gating

AreaStatus
Multi-tenancy (attribute-based, tenant_id, org state machine)✅ Done
Auth: password + magic link + API keys, JWT mutable extra_data✅ Done
RBAC: two-tier roles, permission codes, wildcard matching, Nebulex org cache✅ Done
Organizations: invite flow, teams, state machine, org switching✅ Done
Oban background jobs with named queues✅ Done
Multi-channel notifications: in-app (PubSub) + email (Swoosh)✅ Done
Analytics: Broadway pipeline, PageView + BusinessEvent✅ Done
File storage: pluggable adapter (Local + S3)✅ Done
Optional module system (modules/crm, modules/marketing)✅ Done
Feature flags: system-level + per-org JSONB struct✅ Done
SvelteKit frontend: remote functions, Phoenix WebSockets, shared UI✅ Done
CI/CD workflow fully enabled✅ Done
Phase 1 silent failures closed✅ Done

GapPhaseStatus
CI/CD workflow fully commented outPhase 0✅ Done
SetScope silently passes nil org — no redirectPhase 1✅ Done
Invitations accepted into cancelled/suspended orgsPhase 1✅ Done
allow_org_creation blocks system’s own bootstrap pathPhase 1✅ Done
Single-tenant deployment mode (no auto-provisioning)Phase 2✅ Done
Onboarding is frontend-only, no backend enforcementPhase 3
Feature flags not distributed to frontend root layoutPhase 4
Rate limiting wired but applied to nothingPhase 5
Billing: plan fields exist, zero payment processingPhase 6
OAuth strategies not implementedPhase 7
Zero outbound webhook codePhase 8
Analytics framed as “Plausible replacement” — wrong positioningPhase 9
SMS is Logger.info placeholderPhase 10
OpenTelemetry deps present but commented outPhase 11
dynamic_modules/0 permission registry hardcodedPhase 12

  • LiveView Access: LiveView interaction is strictly limited to users with the following roles: superuser, admin, and the agent role (specifically for Helpdesk interactions). Standard user actors interact via the SvelteKit frontend.

Phase 0 (CI)
└─ Phase 1 (close silent failures)
├─ Phase 2 (single-tenant) ──────────────────────────┐
│ └─ Phase 3 (onboarding enforcement) │
│ │
└─ Phase 4 (feature flags pipeline) │
├─ Phase 5 (rate limiting) │
└─ Phase 7 (OAuth) ← depends on Phase 4 │
Phase 6 (billing) ← depends on Phase 1, benefits from 2 ───┘
└─ Phase 8 (webhooks)
Phase 9 (analytics) ← independent, after Phase 4
Phase 10 (SMS) ← independent, any time
Phase 11 (OTEL) ← independent, after Phase 0
Phase 12 (docs) ← after Phase 6 and Phase 2

Blog angle: “Testing an Ash Framework monorepo with mix workspaces”

Goal: Every push is automatically verified. The CI file is 100% commented out — nothing else can ship safely without this.

  1. Uncomment and fix .github/workflows/ci.yml — verify mix workspace.run -t test traverses all packages
  2. Add migration drift check: mix ash_postgres.generate_migrations --check as a CI step
  3. Add Biome format check: bun biome check --reporter=github as a parallel step
  4. Write integration test: sign-up → create org → verify owner role seeded via SeedOrganizationRoles
  5. Write Playwright e2e test: sign-in → org landing page (canary for the plug chain)

Files: .github/workflows/ci.yml, each packages/*/mix.exs

Acceptance: Green CI on a clean branch; migration drift fails loudly


Goal: Make existing code honest. Three specific silent failures must be fixed before layering new features.

Problem: SetScope sets current_organization: nil when no org is resolved. The app renders with a broken scope and no error.

Fix: Add a RequireOrganization LiveView on_mount guard and/or a halt-and-redirect in SetScope for authenticated routes. In single-tenant mode (Phase 2), redirect to auto-provisioning instead.

Files:

  • apps/api/lib/platform/api/plugs/set_scope.ex
  • New: apps/api/lib/platform/api/hooks/require_organization.ex

1.2 — Invitations accepted into dead orgs

Section titled “1.2 — Invitations accepted into dead orgs”

Problem: AcceptInvitation does not check whether the org is :cancelled or :suspended before creating a member.

Fix: Validate org status is in [:trial, :active] before proceeding.

Files: packages/auth/lib/platform/auth/changes/accept_invitation.ex

1.3 — allow_org_creation blocks system bootstrap

Section titled “1.3 — allow_org_creation blocks system bootstrap”

Problem: The allow_org_creation validation runs even when actor is nil (the system bootstrap path uses authorize?: false, setting actor to nil). This will block Phase 2’s single-tenant auto-provisioning.

Fix: Skip the validation when context.actor == nil.

Files: packages/auth/lib/platform/auth/organization/actions.ex


Phase 2: Single-Tenant Deployment Mode ⭐

Section titled “Phase 2: Single-Tenant Deployment Mode ⭐”

Blog angle: “One codebase, two deployment modes: multi-tenant and single-tenant with Ash”

Goal: When multitenancy_enabled: false, auto-provision one default org on first boot, auto-join every new user to it, and skip the org-creation wizard entirely.

Key decision: Branch once in bootstrap + SetScope. Everything downstream stays transparent — in single-tenant mode, SetScope always resolves an org, so the rest of the application sees no difference.

2.1 — Auto-provision default org on boot

  • Release.bootstrap/0: if multitenancy_enabled == false, call System.Setup.provision_default_org/0
  • Creates org with slug: "default" idempotently
  • Stores default_org_id in Platform.System.Preferences (new UUID field on the JSONB struct — no table migration needed)
  • Files: apps/api/lib/platform/api/release.ex, new apps/api/lib/platform/api/system/setup.ex, packages/core/lib/platform/system/preferences.ex

2.2 — Auto-join new users to default org

  • On :register after_action: enqueue AutoJoinDefaultOrg Oban worker
  • Worker reads default_org_id from settings cache, creates Member with authorize?: false
  • Async + retryable via Oban — does not slow sign-up
  • Files: new packages/auth/lib/platform/auth/workers/auto_join_default_org.ex, packages/auth/lib/platform/auth/user/actions.ex

2.3 — SetScope fallback for single-tenant

  • Final fallback in get_org_id/2: if multitenancy_enabled == false, return default_org_id from settings cache
  • If cache miss during first boot, return 503 rather than silently proceeding with nil
  • Files: apps/api/lib/platform/api/plugs/set_scope.ex

2.4 — Skip onboarding wizard in single-tenant mode

  • In onboarding $effect: if !allowOrgCreation && user exists, skip wizard → goto('/')
  • Files: apps/web/src/routes/(app)/onboarding/+page@.svelte

2.5 — Add Platform.System.is_single_tenant?/0 helper

  • Convenience wrapper: SettingsCache.get!().features.multitenancy_enabled == false
  • Keeps flag checks at a single callsite
  • Files: packages/core/lib/platform/system/system.ex

Phase 3: Admin & Agent Onboarding Enforcement ⭐

Section titled “Phase 3: Admin & Agent Onboarding Enforcement ⭐”

Blog angle: “Role-specific onboarding flows in Ash”

Dependencies: Phases 1 + 2 complete.

3.1 — Track onboarding state for Admins and Agents

  • Track individual onboarding progress for users with privileged roles (admin, agent).
  • Ensure they complete security training and profile setup before accessing sensitive dashboards.

3.2 — Invitation-first enforcement for Agents

  • When allow_registration: false: :register action for agents requires invitation_token argument.
  • Backend enforces it — not just frontend.
  • Files: packages/auth/lib/platform/auth/user/actions.ex, apps/web/src/routes/auth/invitations/+page.svelte

3.3 — Post-onboarding email sequence

  • Enqueue Oban sequence for newly onboarded admins/agents.
    • Immediately: “Getting Started” guide.
    • After 24h: “Security best practices” (conditional).
  • Files: new packages/auth/lib/platform/auth/workers/agent_onboarding_sequence.ex, new email templates

Phase 4: Feature Flags Frontend Distribution

Section titled “Phase 4: Feature Flags Frontend Distribution”

Goal: getFeatureFlags() must never return null for an authenticated user.

4.1 — Verify systemSettingsFeaturesQuery is called in apps/web/src/routes/+layout.svelte and its result passed to setFeatureFlags. If missing, add it.

4.2 — Effective flags: system-level + org-level merge

  • New Platform.System.effective_flags/2: org-level true wins; system-level false is a global block
  • Expose in Platform.Scope struct so all policy checks and frontend queries use one source
  • Files: packages/core/lib/platform/scope.ex, new packages/core/lib/platform/system/effective_flags.ex

4.3 — Per-org flag admin UI

  • apps/web/src/routes/(app)/settings/general/+page.svelte: org-level enabled_features toggles, gated by settings.write permission

4.4 — Document the two-step pattern for adding new flags

Adding a flag = (1) add field to Platform.System.FeatureFlags struct + (2) add to settingsFeaturesFields RPC list. Zero migrations.


Goal: Apply the already-wired Platform.Hammer + ash_rate_limiter to actual endpoints.

  • Plug-level (IP-based): all /api/auth/* routes and the RPC endpoint
  • Ash action-level (identity-based): :register, :sign_in_with_password, :request_magic_link, :create_api_key

Action-level limiting by email survives load balancer IP rotation.

  1. New Platform.API.Plugs.RateLimit plug with configurable bucket_fn, limit, period
  2. Apply to :api pipeline auth subrouter; stricter limits on sign-in/sign-up/magic-link POST routes
  3. Apply ash_rate_limiter DSL to User auth actions (run mix usage_rules.search_docs "ash_rate_limiter" first)
  4. Return 429 with Retry-After header

Files: new apps/api/lib/platform/api/plugs/rate_limit.ex, apps/api/lib/platform/api/router.ex, packages/auth/lib/platform/auth/user/actions.ex


Blog angle: “The billing adapter pattern: swappable payment processors in Elixir”

Model after: Platform.Storage exactly — same structure, same adapter selection via application config.

@callback create_customer(org, opts) ::
{:ok, %{customer_id: String.t()}} | {:error, term()}
@callback create_subscription(customer_id, plan, opts) ::
{:ok, %{subscription_id: String.t(), status: atom()}} | {:error, term()}
@callback cancel_subscription(subscription_id, opts) ::
{:ok, term()} | {:error, term()}
@callback get_subscription_status(subscription_id, opts) ::
{:ok, atom()} | {:error, term()}
@callback handle_webhook(payload, signature, opts) ::
{:ok, %{event: atom(), payload: map()}} | {:error, term()}
AdapterStatus
Platform.Billing.LocalFull implementation — returns fake IDs, :active status, no-ops webhooks. Default. Works out of the box with no config.
Platform.Billing.StripeCorrect @behaviour, each callback raises "not implemented". Stub for contributors.
Platform.Billing.PolarSame stub pattern.
Platform.Billing.CreemioSame stub pattern.
  1. packages/core/lib/platform/billing.ex — behaviour + delegation functions
  2. packages/core/lib/platform/billing/local.ex — full local adapter
  3. Stripe, Polar, Creemio stubs
  4. Wire into org lifecycle: create_customer on :create, create_subscription on :activate (after_action hooks in Organization.Actions)
  5. New Platform.Auth.Policy.HasPlan check — mirrors HasPermission, takes minimum_plan: :professional
  6. Wire billing settings UI (settings/billing/+page.svelte — replace <Unavailable />)
  7. Migration: add billing_customer_id :string and billing_subscription_id :string to Organization

Blog angle: “OAuth behind feature flags: opt-in Google login with AshAuthentication”

Activation rule: GOOGLE_CLIENT_ID env var present AND features.oauth_google_enabled == true. Default is false — requires explicit admin opt-in.

  1. Add to FeatureFlags: oauth_google_enabled: :boolean, default: false, oauth_github_enabled: :boolean, default: false
  2. Add AshAuthentication.Strategy.OAuth2 for Google — credentials via secrets callback (runtime, not compile-time)
  3. Verify /auth/google/callback doesn’t conflict in router.ex
  4. Frontend: conditionally render “Continue with Google” based on features.oauthGoogleEnabled
  5. Single-tenant compatibility: OAuth-created users auto-joined by AutoJoinDefaultOrg worker (no special case — it’s generic)

Blog angle: “Outbound webhooks with Oban: reliable delivery with HMAC signing”

Platform.Auth.Organization.Webhook resource:

  • url: :string, events: {:array, :string}, secret: :string (write-once, never returned in reads)
  • status: :atom (:active | :disabled | :failed), failure_count: :integer
  • Auto-disabled after 10 consecutive failures

Platform.Webhooks.Dispatcher.dispatch/2: queries active webhooks filtered by event, enqueues delivery jobs

Platform.Webhooks.Workers.DeliverWebhook: HMAC-SHA256 signs payload, POSTs to URL, updates failure_count on non-2xx

Domain packages opt-in by calling Dispatcher.dispatch/2 in after_action hooks.

  • member.invited — in EnqueueInvitationEmail change
  • member.joined — in AcceptInvitation change
  • organization.activated — in :activate action

Decision: Pivot from “Plausible replacement” → internal business telemetry per tenant.

  • PageView: opt-in, disabled by default via analytics_enabled flag
  • BusinessEvent: primary surface — the per-tenant activity log
  1. New Platform.Analytics.track/3 public API — no-op when analytics_enabled: false, zero overhead
  2. BusinessEvent read action :for_tenant with time range filtering; expose via RPC
  3. Move GeoIP enrichment behind a separate flag (expensive, only needed for page view mode)
  4. Wire analytics/+page.svelte to tenantEventsQuery — bar chart of events by type over time

Follow Platform.Storage / Platform.Billing adapter pattern exactly:

  • Platform.SMS behaviour with send/3 callback
  • Platform.SMS.Local — promotes current Logger.info placeholder to an official adapter
  • Platform.SMS.Twilio — correct @behaviour, raises "not implemented"
  • Platform.SMS.Vonage — same stub pattern

Wire DeliverSMS Oban worker to delegate to Platform.SMS.send/3.


Uncomment the existing OTEL configuration (deps and runtime.exs setup are already present). Wire the Uptrace endpoint via OTEL_EXPORTER_OTLP_ENDPOINT.

Add custom spans to critical paths:

  • BootstrapSession
  • Organization.Cache.get_snapshot/1
  • Platform.Analytics.IngestReactor
  • DeliverWebhook worker

Phase 12: Module Integration + Registry Fix

Section titled “Phase 12: Module Integration + Registry Fix”

12.1 — Fix dynamic_modules/0 hardcoded list

Section titled “12.1 — Fix dynamic_modules/0 hardcoded list”

Replace with compile-time discovery: enumerate all loaded modules implementing the Platform.Auth.Permissions behaviour via Application.spec(:platform_api, :modules). Adding a new domain no longer requires editing the registry.

Complete numbered checklist for new optional modules:

  1. Create packages/<name> with OTP app :platform_<name>
  2. Implement Platform.Auth.Permissions behaviour
  3. Permission registry auto-discovers (from 12.1 fix)
  4. Gate with Code.ensure_loaded?/1 at callsites
  5. Add feature flag to FeatureFlags struct
  6. Register Oban queues if needed
  7. Add to ash_domains in apps/api/config/config.exs

Prose docs: how to enable each module, what DB tables are created, what permissions are registered, what the frontend feature-flag guard looks like.


FileWhy It Matters
packages/core/lib/platform/storage.exTemplate for ALL new adapter-pattern modules (Billing, SMS)
apps/api/lib/platform/api/plugs/set_scope.exCentral integration point for single-tenant mode and scope-dependent features
packages/core/lib/platform/system/feature_flags.exZero-migration extension point for every new flag
packages/auth/lib/platform/auth/organization/actions.exOrg lifecycle hub — Phases 1, 2, 3, 6 all touch this
apps/api/lib/platform/api/release.exProduction bootstrap — single-tenant auto-provisioning lives here
packages/auth/lib/platform/auth/changes/accept_invitation.exPhase 1 silent failure fix
packages/auth/lib/platform/auth/organization/permission.exPhase 12 registry fix target

PostPhaseTopic
1Project overview: Ash + SvelteKit + monorepo structure
2Auth deep dive: AshAuthentication strategies, JWT extra_data, session management
3RBAC: two-tier roles, permission codes, wildcard matching, org cache
40CI for Ash monorepos: mix workspace, migration drift checks, Playwright
52One codebase, two deployment modes: multi-tenant and single-tenant with Ash
63Invitation-first user registration in AshAuthentication
76The billing adapter pattern: swappable payment providers in Elixir
87OAuth behind feature flags: opt-in social login with AshAuthentication
98Outbound webhooks with Oban: reliable delivery with HMAC signing