← Multi-tenant roadmap

Phase 2 beta verification — 2026-08-23

Verified against merge commit 83eecba1 (PRs #288–#294, SP-609 / SP-557 / SP-610 / SP-611 / SP-476 / SP-612 / SP-613), beta environment

All checks executed 2026-08-23 ICT (2026-08-22 approximately 17:40–18:10 UTC)

Verdict

All Phase 2 tickets verified on beta

Every ticket in the Phase 2 stack behaves as specified on the beta environment. Twenty-four automated checks passed. One minor pre-existing bug and one infrastructure finding were recorded, together with three observations. Neither finding blocks the phase.

24Checks passed
1Minor bug
1Infra finding
3Observations

What was verified

One row per ticket in the Phase 2 stacked chain. Each result links to the evidence section below.

TicketScopeResultEvidence
SP-609 De-branded tenant shell — one static bundle serves every tenant (PR #288) PASS Section 4.1
SP-557 Banner management API and admin surface (PR #289) PASS Section 4.2
SP-610 Landing strategy resolved from tenant configuration (PR #290) PASS 1 OBSERVATION Section 4.3
SP-611 Shared landing template for non-default tenants (PR #291) PASS Section 4.4
SP-476 Managed subdomain activation gate (PR #292) PASS Section 4.5
SP-612 Staff invitations backend — issue, revoke, accept (PR #293) PASS Section 4.6
SP-613 Staff invitations user interface — tenant admin, platform admin, accept page (PR #294) PASS Section 4.7

Check breakdown

The figure of 24 is the count of discrete automated checks. It is not inflated: each row below is a distinct assertion against a live beta endpoint or a distinct verified browser state.

AreaChecks
Deployment currency (backend run, frontend deployment)2
Tenant shell and branding2
Banners (public endpoint, admin endpoint)2
Bootstrap and landing strategy4
Activation gate3
Invitations API7
Invitations user interface and browser states (one per screenshot-verified state)4
Total24

The database and log audits described in section 5 are reported separately and are not counted in the 24.

Deployment currency

Before any behavioural check, the beta environment was confirmed to be running the head of main.

Environment state

Beta runs merge commit 83eecba1

main HEAD83eecba1 — “feat(tenant): staff invitation flow, UI (SP-613) (#294)”
BackendGitHub Actions “Deploy Beta” run 32588143988 completed with status success for that commit at 2026-08-22T17:34Z; Cloud Run service lms-backend-beta in asia-southeast1.
FrontendDigitalOcean app lms-beta-frontend, deployment c3c58534-d52a-4af3-a607-fc5e748846d9, state ACTIVE 5/5, cause “commit 83eecba pushed”.
DatabaseBeta Alembic head 689e5242694b = add_tenant_invitations, which is the newest migration in the repository.
  • Check 1. Backend deployment for 83eecba1 succeeded and is the active Cloud Run revision.
  • Check 2. Frontend deployment for the same commit is ACTIVE with all five components healthy.

Evidence by ticket

Each card records the checks executed, the observed responses, and the supporting screenshots.

4.1 — PR #288

SP-609 — De-branded tenant shell

SP-609 PASS

A single static bundle must serve every tenant, with no SkillPixel branding baked into the served HTML.

  • Check 3. curl https://historyclass.beta.skillpixel.vn/ returns the tenant-neutral static shell. The served document carries the HTML comment “Tenant-neutral: one bundle serves every tenant…” and contains zero case-insensitive matches for “SkillPixel”.
  • Check 4. The runbook browser console check on the rendered page returns tenant-specific metadata with no SkillPixel body text:
    {
      "title": "HistoryClass - Nền tảng học tập",
      "description": "Khám phá các khoá học được xuất bản bởi HistoryClass.",
      "ogSiteName": "HistoryClass",
      "hasSkillPixelBodyText": false
    }
HistoryClass landing page rendered on a desktop viewport, showing the tenant hero, featured courses and brown branding.
01 — HistoryClass landing, desktop. Tenant-branded header, hero and footer served from the neutral shell. The footer copyright line is the subject of BUG-1.
HistoryClass landing page rendered on a mobile viewport.
02 — HistoryClass landing, mobile. The same shell and template at a mobile viewport.
4.2 — PR #289

SP-557 — Banner management

SP-557 PASS
  • Check 5. GET /api/v1/banners with header X-Tenant-Host: beta.skillpixel.vn returns 200.
  • Check 6. GET /api/v1/banners/admin with a platform-admin token returns 200.
  • The admin surface at /admin/marketing renders the banner list in a clean browser: three banners with active and default badges, together with the add and reorder controls. The screen is also reachable on the HistoryClass host.
Admin marketing screen listing three banners with active and default badges and reorder controls.
05b — /admin/marketing in a clean browser. Banner list with three banners, active and default badges, and the add and reorder controls. The stale-cache variant of this route is documented in INFRA-1.
4.3 — PR #290

SP-610 — Landing strategy from tenant configuration

SP-610 PASS OBSERVATION O1
  • Check 7. GET /api/v1/tenant/bootstrap with X-Tenant-Host: historyclass.beta.skillpixel.vn returns 200:
    {
      "slug": "historyclass",
      "is_default": false,
      "landing_strategy": null,
      "seo.site_name": "HistoryClass"
    }
  • Check 8. Default-tenant bootstrap with X-Tenant-Host: beta.skillpixel.vn returns 200 with is_default: true and site name SkillPixel.
  • Check 9. An unknown host returns 400.
  • Check 10. The default landing page is unchanged.

Observation O1. No landing.strategy platform-config entry exists on beta for HistoryClass: GET /platform/config/effective returns null. The frontend therefore uses its deterministic fallback, under which non-default tenants render shared-template. This behaviour is correct by design. The product decision — a bespoke historyclass page versus the shared template — remains open, and either outcome requires an explicit configuration row.

Default SkillPixel beta landing page captured full length.
03 — Default tenant landing on beta.skillpixel.vn, unchanged. The middle sections appear blank only because lazy-loaded sections do not render in a full-page capture without scrolling. The footer copyright line shows BUG-1.
4.4 — PR #291

SP-611 — Shared landing template

SP-611 PASS
  • The HistoryClass landing page renders the hero with the title “HistoryClass” and the call to action “Khám phá khoá học”, a featured-courses section containing three courses, a tenant-branded header and footer, and the brown theme colours drawn from the tenant branding record. Desktop and mobile evidence appear in figures 01 and 02.
  • The announcement block is not configured on beta — the tenants.landing JSON contains no announcement — so no announcement is shown. This is the expected result.
4.5 — PR #292

SP-476 — Managed subdomain activation gate

SP-476 PASS
  • Check 11. A throwaway tenant gate-check-beta-verify was created via POST /platform/tenants/ → 201, status PROVISIONED.
  • Check 12. PATCH of the status to ACTIVE returns 409 and enumerates every missing prerequisite rather than only the first:
    Tenant cannot be activated: an active or verified managed
    subdomain is required; a tenant admin membership is required
  • Check 13. The throwaway tenant was deleted afterwards via DELETE with the confirmation slug → 204.
  • HistoryClass itself has been ACTIVE since 2026-07-16, from the earlier rehearsal, with an ACTIVE and verified primary managed subdomain historyclass.beta.skillpixel.vn. The domain serves correctly over HTTPS through Cloudflare.
4.6 — PR #293

SP-612 — Staff invitations backend

SP-612 PASS

All checks were executed against tenant HistoryClass, 7c1e9f42-3b6a-4d58-9e10-2f4c8a7b5d31.

  • Check 14. POST invitation for npkhang99@gmail.com with role INSTRUCTOR returns 201, status PENDING, expiry 2026-08-29 (seven days). The response carries the one-time token.
  • Check 15. A duplicate invitation for the same address returns 409, “A pending invitation already exists for this email”.
  • Check 16. POST invitation for curoa99@gmail.com with role STUDENT returns 201.
  • Check 17. Revoking that invitation returns 200 with status REVOKED.
  • Check 18. Accepting with a bogus token returns 404, “Invitation not found”.
  • Check 19. Accepting the revoked token returns 409, “Invitation has been revoked”.
  • Check 20. Accepting the valid token while signed in as the invitee npkhang99@gmail.com returns 200 with status ACCEPTED. The platform membership list then shows npkhang99@gmail.com with exactly ["INSTRUCTOR"], and /users/me on the HistoryClass host returns tenant_roles: ["INSTRUCTOR"].
  • Invitation e-mail delivery is fire-and-forget after commit, with failures logged. Missing e-mail infrastructure — Phase 3, SP-614 — therefore cannot fail the API. This matches the design.
4.7 — PR #294

SP-613 — Staff invitations user interface

SP-613 PASS
  • Check 21. The tenant-admin screen /admin/invitations on HistoryClass shows the sidebar entry “Lời mời nhân sự”, the invite form with e-mail, role and send, and the table listing curoa99 as REVOKED and npkhang99 as PENDING with resend and revoke actions.
  • Check 22. The platform-admin surface — lms-admin staging on Vercel — shows an Invitations tab for tenant HistoryClass listing both invitations with role, status, expiry and inviter, plus a send-invite form.
  • Check 23. The accept page in the signed-out state presents a card asking the visitor to sign in, “Đăng nhập để nhận lời mời”.
  • Check 24. The accept page, opened while signed in as a different account, shows “Sai tài khoản”, names the signed-in e-mail address, and offers a sign-out-and-switch action.
  • Both accept-page states are fully tenant-branded.
Tenant admin invitations screen with invite form and a table of two invitations.
06 — Tenant admin, /admin/invitations on HistoryClass. Sidebar entry “Lời mời nhân sự”, invite form, and the invitation table showing one REVOKED and one PENDING row.
Platform admin invitations tab for tenant HistoryClass listing two invitations.
07 — Platform admin, Invitations tab for HistoryClass. Both invitations listed with role, status, expiry and inviter, alongside the send-invite form.
Invitation accept page in the signed-out state asking the visitor to sign in.
04 — Accept page, signed out. Tenant-branded card prompting “Đăng nhập để nhận lời mời”.
Invitation accept page showing a wrong-account warning naming the signed-in e-mail address.
09 — Accept page, wrong account. “Sai tài khoản” names the signed-in address and offers a sign-out-and-switch action.

Database and log audit

Reported separately from the 24 checks. The purpose is to confirm that tenant isolation held during and after the deployment.

Isolation and health

No cross-tenant leakage, no errors since the deployment

  • Hot tables — courses, chapters, lessons, questions — partition cleanly across the default tenant and HistoryClass. Zero default-tenant rows were created after the deployment cutoff of 2026-08-22T17:30Z.
  • tenant_default_fallback_write has zero occurrences in Cloud Run logs over the last seven days. This is the SP-478 Gate C metric. The gate's own seven-day window started 2026-08-20, so the gate completes on 2026-08-27.
  • tenant_non_default_request markers are present with the correct tenant identifier and hostname for HistoryClass traffic, on both bootstrap and courses requests.
  • Zero Cloud Run log entries at severity ERROR or above since the deployment.

Tenant and domain table state

TenantTenant statusPrimary domainDomain state
skillpixelACTIVEbeta.skillpixel.vnACTIVE, verified
historyclassACTIVEhistoryclass.beta.skillpixel.vnACTIVE, verified
tenant-a-rehearsalACTIVEtenanta.beta.skillpixel.vnACTIVE, never verified — see O2

Findings

One bug, one infrastructure finding and three observations. Jira keys for the two items requiring tickets are placeholders until the tickets are filed.

BUG-1 — Footer copyright year is hardcoded

MINOR PRE-EXISTING SP-623

The footer copyright year is hardcoded as “© 2025” in apps/frontend/src/components/layout/Footer.tsx at line 126. Every tenant therefore displays “© 2025 <site name>” during 2026. The defect predates Phase 2 and affects the default tenant as well.

Visible in figures 01 and 03.

Jira: SP-623

INFRA-1 — CDN may serve a pre-deployment index.html for up to 24 hours

INFRASTRUCTURE SP-624

The beta frontend serves index.html with cache-control: public, max-age=10, s-maxage=86400. The DigitalOcean CDN may therefore serve a pre-deployment index.html, and consequently an old bundle, for up to 24 hours after a release.

This was observed in practice. Approximately 25 minutes after the deployment, a warm real-Chrome profile was redirected to /not-found on /admin/marketing twice, while a clean headless browser rendered the same route correctly.

Recommendation: purge the CDN cache as part of the deployment pipeline, or drop s-maxage for index.html.  Jira: SP-624
Not-found page shown in a warm Chrome profile when opening the admin marketing route.
05 — Stale bundle. Warm real-Chrome profile redirected to /not-found on /admin/marketing.
Admin marketing banner list rendering correctly in a clean headless browser.
05b — Clean browser, same route. The banner list renders correctly, confirming the cause is a cached document rather than a code defect.

O1 — landing.strategy configuration entry absent on beta

OBSERVATIONDECISION OPEN

No landing.strategy entry exists for HistoryClass on beta, so the frontend falls back to shared-template for non-default tenants. The behaviour is correct. The product decision between a bespoke historyclass page and the shared template is still open, and either outcome requires an explicit configuration row. See section 4.3.

O2 — Leftover rehearsal tenant tenant-a-rehearsal

OBSERVATIONCLEANUP

tenant-a-rehearsal is a leftover Phase 1 rehearsal tenant. It is ACTIVE with a domain that was activated but never verified, and it has no DNS. The state is harmless on beta. It should be cleaned up before any production rehearsal parity check.

O3 — Non-default tenants inherit the generic platform tagline

OBSERVATIONKNOWN FOLLOW-UP

Confirmed during this verification: non-default tenants still inherit the generic platform tagline copy in the footer — “Nền tảng giáo dục AI kết nối tri thức Việt” appears beneath the HistoryClass wordmark.

Test residue and cleanup

All residue on beta is intentional and recorded here so that a later reader does not mistake it for real data.

State left on beta

Two invitation history rows; everything else restored

  • HistoryClass invitations. npkhang99@gmail.com remains as an ACCEPTED history row and curoa99@gmail.com as a REVOKED history row. The membership created by the accepted invitation was deleted afterwards, and memberships were restored to the original two administrators.
  • Throwaway tenant. gate-check-beta-verify was created for the activation-gate check and deleted afterwards.
  • Untouched. No configuration, branding, banner or domain rows were changed.

Method

Checks were API-driven: curl against the beta Cloud Run API using a platform-admin Firebase token. The database audit was performed with psql against the beta Supabase database. The log audit was performed with gcloud. Screenshots come from a real Chrome walkthrough together with a clean headless Chromium session, the latter used to separate cached-bundle behaviour from application behaviour.

Verified by Claude (Fable 5) on 2026-08-23, ICT.