SkillPixel LMS — Multi-Tenant Roadmap

From single-tenant SkillPixel to safely serving multiple tenants — audit date 2026-08-17, epic SP-371

Last updated 2026-08-25 · v0.0.37 in production · Phase 1 merged and beta-verified 2026-08-20 · Phase 2 merged 2026-08-23 and beta-verified the same day (report) · Phase 3 merged 2026-08-24 as PRs #300–#302 and beta-verified 2026-08-25 (report)

Core multi-tenant plumbing is done. What is left is ordered into six phases.

The tenant model, host routing, query scoping, bootstrap API, platform-admin CRUD, feature gating, theming and storage namespacing are all in place.

The remaining work: release what is merged, close activation blockers, activate managed subdomains, make product surfaces tenant-grade, add custom domains, then harden and clear debt.

Ordering principle: security and correctness first, then first-tenant launch, then polish and debt.
Legend SP-xxxexisting Jira ticket DECISION NEEDED SECURITY BETA-VERIFY SHIPPED v0.0.37 MERGED · UNRELEASED MERGED · BETA-VERIFIED
Phase 0 — now

Beta-verify what is already merged

GoalBeta-verify the merged code — much of it is already live after v0.0.37 — and clear the Testing column; everything later builds on it.
Why firstVerifying now avoids stacking new work on unverified code.
1 of 4 items shipped
  • Beta-verify tenantization phases 05–12

    Storage and media, enrollments and certificates, submissions and grading, commerce, platform-admin APIs and UI, ops surfaces, frontend bootstrap.

  • Beta-verify routing, authz and branding hardening

    Domain-aware URLs, tenant-scoped roles, branding and SEO in bootstrap, slug at provisioning, self-serve settings.

  • Beta-verify feature gating

    Config-backed entitlements plus the feature matrix UI. The backend and frontend gating code (SP-586, SP-587) is live in production as of v0.0.37 — what remains is beta verification of the gating behaviour itself, plus SP-585's rollout decisions. PR #276 (2026-08-19) centralized frontend gating into a TenantFeature enum with enabled_features sanitized at a single choke point — including the sessionStorage cache — hardening the surface before beta verification. MERGED · UNRELEASED

    SP-585 SP-586SHIPPED v0.0.37 SP-587SHIPPED v0.0.37 BETA-VERIFY
  • Platform-admin overhaul released

    Shipped to production in v0.0.37 on 2026-08-18, together with SP-595 and SP-596 moving platform-admin fully onto the /platform API behind a centralized guard, off direct DB access. Jira status still needs moving.

    SP-590SP-595SP-596 SHIPPED v0.0.37
Phase 1 — security + correctness

Close activation blockers

GoalMake it safe to turn on a second tenant at all. The six tickets are merged and beta-verified (2026-08-20); some items fold into SP-476 scope.
StackThe six tickets were merged to main as a stacked-PR chain (SP-602 → SP-607) and beta-verified 2026-08-20; SP-476 is now unblocked; SP-478 Gate C's 7-day zero-fallback-write window started 2026-08-20.
Why nowThese are silent failure modes that get worse the moment real tenant traffic exists.
StatusMerged to main 2026-08-20 as PRs #277–#282; live on beta (revision 00247) and verified the same day. SP-476 is now unblocked; SP-478 Gate C's 7-day zero-fallback-write window started 2026-08-20.
6 of 6 merged · beta-verified 2026-08-20 · awaiting production release
  • Stop signup trusting X-Tenant-Host (stack 1/6)

    Profile sync auto-creates a STUDENT membership in whatever tenant the client-sent header names (apps/backend/app/services/user.py), so any logged-in user can self-join any active tenant. The single biggest blocker. Beta-verified 2026-08-20: a closed tenant no longer gains a membership from the header; an open-enrollment tenant still does.

    SP-602SECURITYMERGED · BETA-VERIFIED
  • Fallback-write metric for hot-table defaults (stack 2/6)

    Courses, chapters, lessons and questions still carry a default-tenant server default. The metric that gates its later removal — zero fallback writes for 7 days — is merged and beta-verified (2026-08-20) and feeds the SP-478 Gate C criterion, whose window is now running. Beta-verified 2026-08-20: tenant_default_fallback_write is wired and has logged zero events since deploy.

    SP-603MERGED · BETA-VERIFIED
  • Default entitlements at tenant creation (stack 3/6)

    create_tenant writes only the tenant row, so all 10 gated features 404 until an operator flips them by hand — this already bit HistoryClass. Decided default (2026-08-20): new tenants start with certificates and DOCX import enabled, all other gated features disabled. Beta-verified 2026-08-20: a new tenant is seeded with all 10 feature rows (certificates and DOCX import on, the other eight off); deleting a tenant also removes its config rows. Tenants created via the platform API start PROVISIONED, so their domains only route once the tenant is set ACTIVE.

    SP-604MERGED · BETA-VERIFIED
  • Reminder crons fan out over all active tenants (stack 4/6)

    Notification crons bind only the default tenant, so other tenants get no deadline or live-class reminders (apps/backend/app/routers/v1/notification.py). Beta-verified 2026-08-20: a manual cron run completed with cron_fanout_completed tenant_count=3 and failed_count=0.

    SP-605MERGED · BETA-VERIFIED
  • Harden the domain write path (stack 5/6)

    Create and update only lowercase the value. Add IDNA/punycode canonicalization to match the read path, plus a reserved and platform-label blocklist (apps/backend/app/schemas/platform.py). Beta-verified 2026-08-20: uppercase and whitespace are canonicalized, Unicode is stored as punycode, reserved labels (www/api/beta/apex/localhost) return 422, PLATFORM type returns 422, and duplicates return 409.

    SP-606MERGED · BETA-VERIFIED
  • Cross-instance domain cache invalidation (stack 6/6)

    Domain activate and deactivate clear only the local process cache; other instances serve stale routing for up to 5 minutes. Beta-verified 2026-08-20: domain activate and disable flip routing instantly, and the Redis generation key bumps on every domain write.

    SP-607MERGED · BETA-VERIFIED
  • Decide and fix the RSA environment

    Dropped 2026-08-20 — RSA is a separate deployment with a long-diverged codebase; not part of this rollout.

    DROPPED
Phase 2 — first tenant live

Activate managed subdomains and minimum launch polish

GoalFirst non-default tenant (HistoryClass, SP-542) live on a managed subdomain.
StackThe seven tickets were merged to main as a stacked-PR chain (SP-609 → SP-557 → SP-610 → SP-611 → SP-476 → SP-612 → SP-613) on 2026-08-23, with the pre-existing SP-557 and SP-476 folded in; SP-476 unblocks SP-477 (Phase 4) once this phase is beta-verified.
Why hereActivation is only safe after Phase 1; the polish items are what its users see on day one.
StatusMerged to main 2026-08-23 as PRs #288–#294 and beta-verified the same day — 24 automated checks passed across all seven tickets; full evidence with screenshots in the beta verification report. Jira moved to Unrelease. Findings: SP-623 (hardcoded footer year, minor) and SP-624 (CDN can serve a stale index.html for up to 24 h after a deploy). Not yet in a production release.
NoteHistoryClass content work (SP-549) already shipped in v0.0.37 — this phase is what stands between it and a live tenant site.
7 of 7 merged · beta-verified 2026-08-23 · awaiting production release
  • Activate managed subdomain routing (stack 5/7)

    Browser hostname pass-through, tenant activation gates, audit queries, monitoring. Default-tenant traffic must stay stable. SP-607 is merged and beta-verified (2026-08-20), so this work is unblocked. Merged 2026-08-23 together with the activation runbook (docs/tenant-activation-runbook.md). Beta-verified 2026-08-23: activating a tenant with no domain and no tenant admin returns 409 listing both missing prerequisites; HistoryClass stays healthy (tenant and primary managed subdomain ACTIVE and verified, served over HTTPS); zero default-tenant fallback writes and zero backend errors since the deploy. SP-477 (Phase 4) is now unblocked.

    SP-476PR #292MERGED · BETA-VERIFIED
  • Fix default banner branding (stack 2/7)

    Default banner components hardcode SkillPixel imagery and copy on shared surfaces. Also add a uniqueness guard against duplicate seeded default banners. Beta-verified 2026-08-23: public and admin banner APIs return 200 and the /admin/marketing screen renders the banner list on the default host; the Banner menu entry also appears for HistoryClass tenant admins.

    SP-557PR #289MERGED · BETA-VERIFIED
  • De-brand the frontend shell (stack 1/7)

    A static index.html title and meta is served to every tenant; the hardcoded default-tenant snapshot and fallback hosts in resolution.ts must derive from config. Beta-verified 2026-08-23: the served shell contains zero SkillPixel references; after bootstrap the HistoryClass page reports its own title, description and og:site_name with no SkillPixel body text.

    SP-609PR #288MERGED · BETA-VERIFIED
  • Tenant landing pages to launch quality (stack 3/7 + 4/7)

    Split in two. SP-610 (stack 3/7) — drive the landing strategy from tenant config instead of the hardcoded build-time UUID map, which holds only the local seed UUID. SP-611 (stack 4/7) — bring the shared template to launch quality: hero copy, announcement and featured courses (frontend phase-3 WP3–WP6). Beta-verified 2026-08-23: bootstrap exposes landing_strategy per tenant, and HistoryClass renders the shared template with its own hero, featured courses and branding on desktop and mobile. Open: beta has no landing.strategy config row for HistoryClass, so it renders the shared template via the deterministic non-default fallback — the bespoke-page-versus-template decision still needs an explicit config row either way.

    SP-610PR #290SP-611PR #291MERGED · BETA-VERIFIEDDECISION NEEDED
  • Staff invitation flow (stack 6/7 + 7/7)

    Memberships 404 unless the user already self-registered. SP-612 (stack 6/7) — backend: invite tokens, a pending membership state, an accept endpoint and the invite email. SP-613 (stack 7/7) — UI: platform-admin invite screen, the tenant-admin menu entry and the accept page. Both merged 2026-08-23, so no manual membership workaround is needed at launch. Beta-verified 2026-08-23 end to end on HistoryClass: invite → pending → accept as the invitee → membership with the invited role; duplicate, revoked and bogus tokens are rejected with the right statuses; the tenant-admin screen, platform-admin tab and both accept-page states render correctly. Real email delivery stays blocked on SP-614.

    SP-612PR #293SP-613PR #294MERGED · BETA-VERIFIED
Phase 3 — product surfaces

Tenant-grade product surfaces

GoalA tenant's users never see SkillPixel branding, legal identity, or SkillPixel-only behavior.
StackImplemented and merged 2026-08-24 as stacked PRs #300 → #301 → #302 (SP-614 → SP-615 → SP-616), squash-merged atomically; beta-verified 2026-08-25 — 41 automated checks passed across the three tickets; full evidence with screenshots in the beta verification report. Jira moved to Unrelease. Findings: SP-626 (/about and /faq reachable by URL on non-default tenants), SP-627 (NganLuong webhook 500 on malformed payload, pre-existing) and SP-628 (maintenance notice names SkillPixel on tenant carts). Both open decisions were resolved the same day and recorded on the tickets.
Why hereNot needed to flip the switch, but needed before a tenant runs a real cohort.
  • Per-tenant email (stack 1/3)

    One global SMTP identity (info@skillpixel.vn) and SkillPixel-branded templates and subjects everywhere. PR #300 ships two layers. Layer 1, always on: per-tenant from-name, reply-to and branded templates (name, logo, address, contact) on the platform SMTP; reminder subjects use the tenant name. Layer 2, tenant self-service: the tenant's own SMTP server from the new /admin/email page, password Fernet-encrypted with a new APP_ENCRYPTION_KEY secret, with a send-test-email button (needs EMAIL_DELIVERY_ENABLED on beta). Known limit: on the shared platform SMTP the From address stays info@skillpixel.vn — relays like Gmail rewrite it — so only the name and reply-to are per-tenant; a tenant that needs mail from its own domain turns on Layer 2. Tenant-editable templates were deliberately skipped (no template system exists). Beta-verified 2026-08-25: tenant from-name and Reply-To apply, the SMTP password is stored Fernet-encrypted and never returned, partial saves keep it, validation and membership checks hold, and the default tenant is unchanged; real delivery stays a no-op until EMAIL_DELIVERY_ENABLED is set on beta.

    SP-614PR #300MERGED · BETA-VERIFIED
  • Per-tenant legal pages (stack 2/3)

    Privacy, terms, refund and company address were shared i18n copy naming SkillPixel's legal entity. Decided 2026-08-24: tenant data + generic copy. PR #301 adds tenants.legal (entity name, tax code, optional external privacy/terms URLs), rewrites the legal copy to interpolate the tenant's identity (zero SkillPixel literals left, grep-gated), points footer links at the external URLs when set, hides /about and /faq for non-default tenants, and adds a Legal card to the tenant-admin branding page. No rich-text editor. Default-tenant wording changes slightly (full registered entity name; hotline now from the tenant phone on file). Beta-verified 2026-08-25: HistoryClass legal pages, footer and signup consent carry its own entity, hotline and external privacy URL with zero SkillPixel copy; the default tenant shows its full registered name and new hotline. Follow-up SP-626: /about and /faq are still reachable by URL on non-default tenants.

    SP-615PR #301MERGED · BETA-VERIFIED
  • Tenant-aware payments (stack 3/3)

    Payment and cart routes cannot be feature-gated naively — webhooks resolve the tenant from the transaction after the gateway already charged. Decided 2026-08-24: provider list entitlement, merchant credentials stay platform-global. PR #302 adds payments.providers (platform-admin, empty = no new paid checkouts; new tenants start empty, default tenant backfilled with AlePay + SePay), gates only POST /payments/initiate and the paid path of POST /cart/checkout — free carts and every provider callback stay ungated — filters the providers endpoint and bootstrap by the list, and keys the provider factory by tenant so per-tenant credentials can follow later. Beta-verified 2026-08-25: an empty list returns 403 on initiate and paid checkout while free carts and provider callbacks pass, a tenant list filters bootstrap, the providers endpoint and the cart, GLOBAL writes and unknown providers are rejected, and the default tenant keeps AlePay + SePay. Follow-ups SP-627 and SP-628.

    SP-616PR #302MERGED · BETA-VERIFIED
  • Backend-backed localisations

    In progress. Per-tenant copy overrides fit naturally alongside the email and legal work.

Phase 4 — custom domains

Verified custom domains

GoalCustomer-owned domains served safely (SP-477).
StackTicketed 2026-08-21 as stacked PRs SP-476 → SP-617 → SP-618 → SP-619 → SP-477, with the Phase-2 SP-476 at the head and SP-477 as the final activation step. SP-476 merged 2026-08-23 (PR #292), so the chain starts at SP-617 once Phase 2 is beta-verified.
Why hereThe spec sequences this strictly after managed subdomains are stable.
  • Domain verification flow (stack 1/4)

    TXT/CNAME challenge, with activation requiring a verified domain. Today ACTIVE can be set with no verification and no external side effects.

  • Firebase authorized-domain automation and guardrails (stack 2/4)

    The spec assumes under 50 active custom domains, warns at 75 and blocks at 90. No counter or guardrail exists in code.

  • Cloudflare custom hostnames and canonical-host rules (stack 3/4)

    Registration on activate and deactivate, predictable canonical redirects, managed subdomain as the fallback.

  • Custom-domain activation (stack 4/4)

    SP-477 is the final activation step, landing once SP-617, SP-618 and SP-619 are merged.

Phase 5 — hardening

Hardening and debt cleanup

GoalRemove compatibility layers and add defense in depth (SP-478, SP-573 and small items).
StackTicketed 2026-08-21 as stacked PRs SP-573 → SP-620 → SP-621 → SP-478; SP-478 closes the phase and stays gated on the SP-603 fallback-write metric reading zero for 7 days.
Why lastNeeds runtime evidence from real multi-tenant traffic to do safely.
  • Finish tenant-scoped authorization (stack 1/4)

    The contest service and the admin chat WebSocket still authorize on global Firebase claims. Add wrong-tenant 403 tests per router family.

  • Audit trail and scoped platform-admin (stack 2/4)

    One is_platform_admin boolean controls every tenant's entitlements, with no append-only log of config writes.

  • Small items sweep (stack 3/4)

    Banner uploads missing a tenant prefix, the last direct-Postgres read in the platform-admin activity route, a CORS wildcard revisit, and cron tenant-binding as a dependency instead of a convention.

  • Remove hot-table defaults and phased RLS (stack 4/4)

    Drop default-tenant stamping once the Phase-1 metric shows 7 clean days (SP-603 is merged and beta-verified 2026-08-20; Gate C's 7-day window is now running), add PostgreSQL RLS for prioritized tenant tables, and reduce global roles to platform-wide semantics.

Open decisions

Each one blocks or shapes a phase below.

  1. Default entitlement set for a new tenant. Resolved 2026-08-20 — new tenants start with certificates and DOCX import enabled, all other gated features disabled. Implemented by SP-604. No longer blocks Phase 1
  2. RSA tenancy model — tenant, separate deployment, or new environment. Resolved 2026-08-20 — RSA stays a separate deployment with a long-diverged codebase; dropped from this rollout. No longer blocks Phase 1
  3. Tenant legal identity ownership. Resolved 2026-08-24 — tenant data (tenants.legal + contact links) interpolated into generic legal copy, with optional external privacy/terms URLs; no per-tenant i18n namespaces and no editor. Implemented by SP-615. No longer blocks Phase 3
  4. Payment provider model per tenant. Resolved 2026-08-24 — providers are a per-tenant entitlement list (payments.providers); all tenants settle through SkillPixel's merchant accounts; per-tenant credentials deferred. Implemented by SP-616. No longer blocks Phase 3
  5. Cross-tenant identity policy — users are global, and membership-add errors leak email existence. Informs the invitation flow

Changelog