Pre-1.0 Roadmap
Pre-1.0 Roadmap
Section titled “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
North Star: v1.0 Definition
Section titled “North Star: v1.0 Definition”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
What’s Already Solid
Section titled “What’s Already Solid”| Area | Status |
|---|---|
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 |
Identified Gaps
Section titled “Identified Gaps”| Gap | Phase | Status |
|---|---|---|
| CI/CD workflow fully commented out | Phase 0 | ✅ Done |
SetScope silently passes nil org — no redirect | Phase 1 | ✅ Done |
| Invitations accepted into cancelled/suspended orgs | Phase 1 | ✅ Done |
allow_org_creation blocks system’s own bootstrap path | Phase 1 | ✅ Done |
| Single-tenant deployment mode (no auto-provisioning) | Phase 2 | ✅ Done |
| Onboarding is frontend-only, no backend enforcement | Phase 3 | |
| Feature flags not distributed to frontend root layout | Phase 4 | |
| Rate limiting wired but applied to nothing | Phase 5 | |
| Billing: plan fields exist, zero payment processing | Phase 6 | |
| OAuth strategies not implemented | Phase 7 | |
| Zero outbound webhook code | Phase 8 | |
| Analytics framed as “Plausible replacement” — wrong positioning | Phase 9 | |
SMS is Logger.info placeholder | Phase 10 | |
| OpenTelemetry deps present but commented out | Phase 11 | |
dynamic_modules/0 permission registry hardcoded | Phase 12 |
Security & Access Guardrails
Section titled “Security & Access Guardrails”- LiveView Access: LiveView interaction is strictly limited to users with the following roles:
superuser,admin, and theagentrole (specifically for Helpdesk interactions). Standarduseractors interact via the SvelteKit frontend.
Phase Dependency Graph
Section titled “Phase Dependency Graph”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 4Phase 10 (SMS) ← independent, any timePhase 11 (OTEL) ← independent, after Phase 0Phase 12 (docs) ← after Phase 6 and Phase 2Phase 0: CI/CD + Test Foundation
Section titled “Phase 0: CI/CD + Test Foundation”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.
- Uncomment and fix
.github/workflows/ci.yml— verifymix workspace.run -t testtraverses all packages - Add migration drift check:
mix ash_postgres.generate_migrations --checkas a CI step - Add Biome format check:
bun biome check --reporter=githubas a parallel step - Write integration test: sign-up → create org → verify owner role seeded via
SeedOrganizationRoles - 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
Phase 1: Close Silent Failures
Section titled “Phase 1: Close Silent Failures”Goal: Make existing code honest. Three specific silent failures must be fixed before layering new features.
1.1 — Nil org scope silently passes
Section titled “1.1 — Nil org scope silently passes”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: ifmultitenancy_enabled == false, callSystem.Setup.provision_default_org/0- Creates org with
slug: "default"idempotently - Stores
default_org_idinPlatform.System.Preferences(new UUID field on the JSONB struct — no table migration needed) - Files:
apps/api/lib/platform/api/release.ex, newapps/api/lib/platform/api/system/setup.ex,packages/core/lib/platform/system/preferences.ex
2.2 — Auto-join new users to default org
- On
:registerafter_action: enqueueAutoJoinDefaultOrgOban worker - Worker reads
default_org_idfrom settings cache, createsMemberwithauthorize?: 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: ifmultitenancy_enabled == false, returndefault_org_idfrom 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::registeraction for agents requiresinvitation_tokenargument. - 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-leveltruewins; system-levelfalseis a global block - Expose in
Platform.Scopestruct so all policy checks and frontend queries use one source - Files:
packages/core/lib/platform/scope.ex, newpackages/core/lib/platform/system/effective_flags.ex
4.3 — Per-org flag admin UI
apps/web/src/routes/(app)/settings/general/+page.svelte: org-levelenabled_featurestoggles, gated bysettings.writepermission
4.4 — Document the two-step pattern for adding new flags
Adding a flag = (1) add field to
Platform.System.FeatureFlagsstruct + (2) add tosettingsFeaturesFieldsRPC list. Zero migrations.
Phase 5: Rate Limiting
Section titled “Phase 5: Rate Limiting”Goal: Apply the already-wired Platform.Hammer + ash_rate_limiter to actual endpoints.
Architecture
Section titled “Architecture”- 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.
- New
Platform.API.Plugs.RateLimitplug with configurablebucket_fn,limit,period - Apply to
:apipipeline auth subrouter; stricter limits on sign-in/sign-up/magic-link POST routes - Apply
ash_rate_limiterDSL to User auth actions (runmix usage_rules.search_docs "ash_rate_limiter"first) - Return
429withRetry-Afterheader
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
Phase 6: Billing Adapter Pattern ⭐
Section titled “Phase 6: Billing Adapter Pattern ⭐”Blog angle: “The billing adapter pattern: swappable payment processors in Elixir”
Model after: Platform.Storage exactly — same structure, same adapter selection via application config.
Behaviour Callbacks
Section titled “Behaviour Callbacks”@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()}Adapters to Build
Section titled “Adapters to Build”| Adapter | Status |
|---|---|
Platform.Billing.Local | Full implementation — returns fake IDs, :active status, no-ops webhooks. Default. Works out of the box with no config. |
Platform.Billing.Stripe | Correct @behaviour, each callback raises "not implemented". Stub for contributors. |
Platform.Billing.Polar | Same stub pattern. |
Platform.Billing.Creemio | Same stub pattern. |
packages/core/lib/platform/billing.ex— behaviour + delegation functionspackages/core/lib/platform/billing/local.ex— full local adapter- Stripe, Polar, Creemio stubs
- Wire into org lifecycle:
create_customeron:create,create_subscriptionon:activate(after_actionhooks inOrganization.Actions) - New
Platform.Auth.Policy.HasPlancheck — mirrorsHasPermission, takesminimum_plan: :professional - Wire billing settings UI (
settings/billing/+page.svelte— replace<Unavailable />) - Migration: add
billing_customer_id :stringandbilling_subscription_id :stringtoOrganization
Phase 7: OAuth Strategies
Section titled “Phase 7: OAuth Strategies”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.
- Add to
FeatureFlags:oauth_google_enabled: :boolean, default: false,oauth_github_enabled: :boolean, default: false - Add
AshAuthentication.Strategy.OAuth2for Google — credentials viasecretscallback (runtime, not compile-time) - Verify
/auth/google/callbackdoesn’t conflict inrouter.ex - Frontend: conditionally render “Continue with Google” based on
features.oauthGoogleEnabled - Single-tenant compatibility: OAuth-created users auto-joined by
AutoJoinDefaultOrgworker (no special case — it’s generic)
Phase 8: Outbound Webhooks
Section titled “Phase 8: Outbound Webhooks”Blog angle: “Outbound webhooks with Oban: reliable delivery with HMAC signing”
Architecture
Section titled “Architecture”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.
Initial events
Section titled “Initial events”member.invited— inEnqueueInvitationEmailchangemember.joined— inAcceptInvitationchangeorganization.activated— in:activateaction
Phase 9: Analytics Refocus
Section titled “Phase 9: Analytics Refocus”Decision: Pivot from “Plausible replacement” → internal business telemetry per tenant.
PageView: opt-in, disabled by default viaanalytics_enabledflagBusinessEvent: primary surface — the per-tenant activity log
- New
Platform.Analytics.track/3public API — no-op whenanalytics_enabled: false, zero overhead BusinessEventread action:for_tenantwith time range filtering; expose via RPC- Move GeoIP enrichment behind a separate flag (expensive, only needed for page view mode)
- Wire
analytics/+page.sveltetotenantEventsQuery— bar chart of events by type over time
Phase 10: SMS Real Implementation
Section titled “Phase 10: SMS Real Implementation”Follow Platform.Storage / Platform.Billing adapter pattern exactly:
Platform.SMSbehaviour withsend/3callbackPlatform.SMS.Local— promotes currentLogger.infoplaceholder to an official adapterPlatform.SMS.Twilio— correct@behaviour, raises"not implemented"Platform.SMS.Vonage— same stub pattern
Wire DeliverSMS Oban worker to delegate to Platform.SMS.send/3.
Phase 11: OpenTelemetry
Section titled “Phase 11: OpenTelemetry”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:
BootstrapSessionOrganization.Cache.get_snapshot/1Platform.Analytics.IngestReactorDeliverWebhookworker
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.
12.2 — ADDING_A_MODULE.md checklist
Section titled “12.2 — ADDING_A_MODULE.md checklist”Complete numbered checklist for new optional modules:
- Create
packages/<name>with OTP app:platform_<name> - Implement
Platform.Auth.Permissionsbehaviour - Permission registry auto-discovers (from 12.1 fix)
- Gate with
Code.ensure_loaded?/1at callsites - Add feature flag to
FeatureFlagsstruct - Register Oban queues if needed
- Add to
ash_domainsinapps/api/config/config.exs
12.3 — CRM / Marketing wiring guides
Section titled “12.3 — CRM / Marketing wiring guides”Prose docs: how to enable each module, what DB tables are created, what permissions are registered, what the frontend feature-flag guard looks like.
Critical Files Reference
Section titled “Critical Files Reference”| File | Why It Matters |
|---|---|
packages/core/lib/platform/storage.ex | Template for ALL new adapter-pattern modules (Billing, SMS) |
apps/api/lib/platform/api/plugs/set_scope.ex | Central integration point for single-tenant mode and scope-dependent features |
packages/core/lib/platform/system/feature_flags.ex | Zero-migration extension point for every new flag |
packages/auth/lib/platform/auth/organization/actions.ex | Org lifecycle hub — Phases 1, 2, 3, 6 all touch this |
apps/api/lib/platform/api/release.ex | Production bootstrap — single-tenant auto-provisioning lives here |
packages/auth/lib/platform/auth/changes/accept_invitation.ex | Phase 1 silent failure fix |
packages/auth/lib/platform/auth/organization/permission.ex | Phase 12 registry fix target |
Blog Series Outline
Section titled “Blog Series Outline”| Post | Phase | Topic |
|---|---|---|
| 1 | — | Project overview: Ash + SvelteKit + monorepo structure |
| 2 | — | Auth deep dive: AshAuthentication strategies, JWT extra_data, session management |
| 3 | — | RBAC: two-tier roles, permission codes, wildcard matching, org cache |
| 4 | 0 | CI for Ash monorepos: mix workspace, migration drift checks, Playwright |
| 5 | 2 | One codebase, two deployment modes: multi-tenant and single-tenant with Ash |
| 6 | 3 | Invitation-first user registration in AshAuthentication |
| 7 | 6 | The billing adapter pattern: swappable payment providers in Elixir |
| 8 | 7 | OAuth behind feature flags: opt-in social login with AshAuthentication |
| 9 | 8 | Outbound webhooks with Oban: reliable delivery with HMAC signing |