Admin GuidesPlatform Admin

Platform Admin Layer

Who this guide is for: Embay Consulting internal staff with a platform user account. The platform is not accessible to tenant admins or learners.

Overview

app.embaylms.com is the Embay staff platform administration domain. It provides cross-tenant operations — tenant provisioning, status management, migration monitoring, billing oversight, and support tooling — against the global schema. Platform users are a separate identity from tenant users; they are stored in embaylms_global.platform_users.

The platform does not surface tenant-scoped features (courses, enrollments, catalog, SCORM, reports, learning paths, etc.). Those are always accessed at the tenant’s own subdomain (e.g. acme.embaylms.com).


Platform User Roles

RoleWhoWhat they can do
super_adminEngineering leads, opsEverything: create/delete/suspend tenants, manage platform users and roles, override tenant config, export audit logs, impersonate any tenant user
support_agentCustomer support staffRead all tenant records (status, config, migration state). Impersonate a tenant user for debugging (1-hour session, audit-logged). Cannot create/delete tenants or manage platform users.
billing_adminFinance / accountsRead billing and subscription data across all tenants. Export invoices. No access to tenant user data or migration tools.

Getting Access

Platform accounts are created by a super_admin via /platform/users. There is no self-signup.

  1. Ask a super_admin to invite your Embay email address
  2. You will receive an email with a one-time setup link
  3. Set your password and enable TOTP MFA (required for all platform users)
  4. Navigate to https://app.embaylms.com and sign in

Bootstrap note: The very first super_admin must be created via the seed script:

pnpm tsx infra/scripts/seed-platform-admin.ts

This is a one-time operation. See the runbook in docs/enablement/runbooks/ for production bootstrap steps.

Forgot Your Password? (LMS-366)

Platform accounts have self-service password reset:

  1. On the sign-in page (https://app.embaylms.com/platform/login), click Forgot password?
  2. Enter your platform account email and click Send reset link
  3. Check your inbox for an email titled “Password reset — EmbayLMS platform” and click Reset password
  4. Choose a new password (minimum 8 characters) and confirm it
  5. Sign in with the new password — the old one stops working immediately
BehaviourDetail
Link lifetime1 hour — request a new link if it expires
Email languageMatches the locale of the page you requested from (EN / FR-CA)
Unknown emailsThe page always shows the same success message — it never reveals whether an account exists
Rate limit5 requests per minute per IP address
AuditBoth the request and the completed reset are written to the audit log
MFAResetting the password does not reset TOTP MFA. If you also lost your authenticator, a super_admin must clear your MFA enrollment

Troubleshooting

  • No email arrives: check spam, confirm you used the exact email on your platform account, and verify with a super_admin that the account is active — deactivated accounts never receive reset emails.
  • “This reset link is invalid or has expired”: the link is older than 1 hour, was already used after a newer one was issued, or was generated for a tenant account. Request a fresh link from /platform/forgot-password.

Dashboard (/platform/dashboard)

Shows aggregate platform health. Visible to all platform roles.

MetricDescription
Total TenantsAll tenants in the global registry
Active TenantsTenants with status = active
Migrations DoneCount of completed migration jobs
Migrations FailedCount of failed jobs (highlighted in red if > 0)

Tenant Management (/platform/tenants)

Access: super_admin for mutations. All roles can view.

Viewing Tenants

Lists all tenants with display name, subdomain, status, region, latest migration state, and creation date.

Tenant Detail (/platform/tenants/{slug}) — tabbed workspace (LMS-773)

Access: all platform roles can open the page; write actions are gated per tab (below). Three read surfaces are narrower than the page — the Administrators list in Key Contacts (tenant user data) plus the Recent Activity card and the whole Audit tab (audit data) are visible to super_admin and support_agent only. A billing_admin sees none of them; they are omitted rather than rendered empty, and the audit trail is withheld server-side, not merely hidden in the UI. LMS-775.

Opening a tenant shows the central per-tenant management workspace, organized into tabs. The tab is part of the URL (?tab=…), so any tab can be bookmarked or linked in a support thread. Log in to tenant and the Edit drawer (the global tenant record: display name, custom domain, status) both live in the page header on every tab, Overview included. The Overview tab’s content is read-only — anything that changes entitlements, subscription, or exports lives on its own tab.

TabWhat’s thereWho can see itWho can change things
OverviewIdentity (region, created, custom domain, billing, health badge), usage metric cards, configuration snapshot, recent activity, Key Contacts, support-access panelAll roles — but the Administrators list within Key Contacts and the Recent Activity card are super_admin/support_agent only, and the support-access panel is super_admin onlyNobody — read-only
EntitlementsLive gated-feature flags vs tier defaults + effective capsAll rolessuper_admin, billing_admin
SubscriptionCommercial summary + the four subscription actions (tier / trial / limits / billing state)All rolessuper_admin, billing_admin
HealthThe three customer-health measures behind the R/A/G signal, with thresholdsAll rolesNobody — read-only
AuditThis tenant’s audit trail, filterable, with CSV exportsuper_admin, support_agent (tab hidden otherwise)Nobody — read-only
Data ExportFull tenant data-export jobs + downloadssuper_admin (tab hidden otherwise)super_admin

Two more tabs depend on the tenant kind. Every customer tenant has an Academy tab (its staff’s Embay Academy adoption, all roles, read-only). The platform Academy tenant has a Curriculum tab instead (super_admin only). Both are described under Embay Academy below.

Usage metrics (LMS-590)

Metrics come from a scheduled hourly rollup into tenant_metrics, never a live query across tenant schemas (ADR-013; the isolation rule in CLAUDE.md §3). The freshness line under the cards shows when the tenant was last computed — a tenant created minutes ago reads “Metrics pending first sync” until the next sweep.

The Users card carries two different “active” numbers; they answer different questions and must not be substituted for each other:

FieldMeansUse it for
LicensedActive, non-deleted accounts — provisioned seatsSeat-cap headroom
Signed in (30d)Accounts that actually signed in during the windowThe billing definition (PRD §7.6) — matches the tenant billing page
Active (30d)Accounts whose record changed in the windowEngagement only. Moves when an admin edits a profile or SCIM syncs, so it can exceed “Signed in” without anyone logging in

A large gap between Signed in and Active usually means provisioning activity (a bulk import or SCIM run) rather than real usage.

Storage is the tenant’s content usage — the de-duplicated sum of module asset sizes, the same figure the plan-cap meter enforces — shown against the plan’s cap.

The rollup holds counts only, no PII.

Key Contacts (LMS-440 / LMS-773). The Overview tab’s Key Contacts section shows who to talk to about a tenant without signing in to it:

  • Administrators — every tenant user with the owner or admin role, with name, email (click to compose), last login, and active status. Soft-deleted users are excluded. The list is read from the tenant schema live; if it is briefly unavailable the section shows an empty state rather than failing the page. This is tenant user data, so it is shown to super_admin and support_agent only — billing_admin sees the billing contact but not this list (LMS-775).
  • Billing contact — the invoice recipient (billing email) and PO number from the subscription record. When no billing email is set, invoices fall back to the earliest active owner/admin — the section says so instead of showing a blank.

Customer Health tab (LMS-773)

The Health tab explains the R/A/G health dot shown on the tenant list — same inputs, same thresholds, never a second opinion. Four measures, each with its measured value and the rule it is judged against:

MeasureRule
Activity recency≤ 7 days healthy · 8–30 days needs attention · > 30 days (or never active) at risk — the only measure that can turn the signal red
Seat utilizationActive users (30d) over the seat cap → needs attention. Unlimited caps show “no impact”
Completion trendCompletions in the last 30 days more than 10% below the prior per-30d average (days 31–90) → needs attention
Academy onboarding (LMS-1070)After day 14, no Owner or Admin has completed the Administrator track in Embay Academy → needs attention. Never at risk. Not judged for tenants created before 2026-09-25, in a tenant’s first 14 days, or on the Academy tenant itself

This is deliberately a lightweight signal (PRD §6.3.1) — the weighted composite health score is future CS tooling, not the console. “No data yet” means the tenant has not had its first hourly metrics sweep.

Audit tab (LMS-773)

Access: super_admin and support_agent. The tab does not appear for billing_admin, and ?tab=audit falls back to Overview for that role (LMS-775).

The Audit tab embeds the cross-tenant audit viewer pre-filtered to this tenant: filter by action or resource type, paginate, and Export CSV for the current page (SOC 2 evidence). Use Open in cross-tenant viewer for investigations that span tenants.

Feature Entitlements tab (super_admin for changes) — LMS-373

The tenant page’s Entitlements tab shows the tenant’s plan tier, seat/course/storage limits, API monthly cap, and each gated feature flag (SSO/SAML, SCIM, API access, custom branding, certificates, advanced automations) with its current On/Off state and whether entitlements are enforced for the tenant.

  • Per-flag overrides. A super_admin can toggle an individual flag on or off outside the tier default — e.g. enable SSO temporarily for a security review. Every override requires a reason, and the change is audit-logged (actor, before/after, reason).
  • Overrides persist until the tier changes — using Change plan tier resets the flags to the new tier’s defaults.
  • Tenants with entitlements not enforced (grandfathered) show all features available regardless of flags.

Subscription tab (super_admin, billing_admin) — LMS-373 / LMS-773

The Subscription tab replaces the former “Manage subscription” side drawer. It shows a read-only Commercial Summary — plan · status, caps, trial end, billing cycle, current period start, next renewal, cancellation intent, scheduled plan/seat changes, billing email, PO number, and the Stripe customer/subscription ids (Stripe is the commercial source of truth, ADR-016) — beside the management form with the same four actions as before:

ActionEffect
Change plan tierResets caps and feature flags to the new tier’s defaults
Grant / extend trialSets the tier + trialing status for 1–90 days
Adjust limits & featuresOverride seat cap, storage cap, monthly API cap, or individual feature flags
Set billing stateactive / past_due for dunning — does not change tenant login status

Every action requires a reason and is audit-logged with before/after snapshots. Other platform roles see the commercial summary read-only.

Pay by purchase order (LMS-783)

Below the management form, the Pay by purchase order card shows the tenant’s invoice-billing request — PO number, billing contact, terms requested, note, date — and the current arrangement (charge_automatically or send_invoice · Nd). A new request also lands on the platform bell (billing.invoice_billing_requested, billing_admin broadcast).

Action (billing_admin, super_admin)Effect
Approve with net 30 / 45 / 60Switches the Stripe subscription to collection_method: send_invoice with days_until_due = the terms, mirrors both on billing_subscriptions, emails the tenant’s billing contact
Decline (pending request)Records the decline; collection stays on card; emails the contact with your note
Revoke — back to card (approved arrangement)Switches Stripe back to charge_automatically; emails the contact

Every decision needs a reason (audit) and an optional note (sent to the tenant). If Stripe refuses the collection-method change, nothing is recorded — the mirror is never allowed to disagree with Stripe. A tenant with no Stripe subscription yet (Free) is mirrored locally only; the card says so. Dunning for an invoice-billed tenant uses the net terms as the grace period instead of the 14-day card-retry window.

Negotiated e-commerce platform fee (LMS-1050)

Access: all platform roles can read the panel. Recording and ending a rate is billing_admin and super_admin only.

The platform fee is the share EmbayLMS keeps on a tenant’s own course sales through Stripe Connect. Every tenant starts on the rate of its plan tier. When a customer negotiates a different rate, record it on the tenant. The Subscription tab’s E-commerce platform fee panel shows the rate in force today, where it comes from, and the history, newest first.

The rate for a sale is resolved in this order (packages/api/src/lib/ecommerce-fee.ts):

OrderSourceShown in the panel as
1The tenant’s negotiated term, if one is active and today is inside its windownegotiated term
2The per-tier override set on the Settings pagetier override (Platform → Settings)
3The code default for the tier: Free 10%, Starter 8%, Growth 5%, Enterprise 3%tier default
48% when the tier is unknownfallback (tier unknown)

Because step 1 wins, a tier change never moves a negotiated rate, and neither does an edit to the per-tier table.

To record a negotiated rate:

  1. Open the tenant, then the Subscription tab, and find the E-commerce platform fee panel.
  2. Click Record a negotiated rate.
  3. Fill in the form:
FieldRequiredDescription
Rate (% of net sale)Yes0 to 50, up to two decimals (for example 2.5)
StartsYesFirst day the rate applies. A future date does not apply early
Ends (blank = until replaced)NoMust be after the start. The rate stops applying the moment this date passes
Authorising HubSpot dealOne of these twoThe id of the deal that authorises the rate
…or why there is no dealOne of these twoAt least 10 characters. Fill in one or the other, never both
What was agreedYesThe agreement in words. Audit-logged
  1. Click Record rate. The next sale is charged at the new rate. There is nothing to create in Stripe: the platform sets the fee on every checkout.
ActionHowEffect
RecordSteps aboveCreates an active term
SupersedeRecord a new rate while one is activeIn one write, the old term becomes superseded and the new one becomes active. The old row stays in the history
RevokeClick End early on the active term and give a reasonThe term becomes revoked. The next sale falls back to the tier rate

Provenance is mandatory: a term is refused without a reason, and without either the authorising deal or the reason there is none. A term that reaches its end date is stamped expired by the nightly sweep. A rate cannot be recorded for a demo, sandbox or Academy tenant.

Every write is audit-logged with before and after values: ecommerce_fee_term_recorded and ecommerce_fee_term_revoked on the tenant, ecommerce_fee_tier_set and ecommerce_fee_tier_reset for the per-tier table. Each order also stores the rate it was charged and its source.

The per-tier table is on the Settings page (/platform/settings), in the E-commerce take-rate card. Only a super_admin can change a tier rate (Save) or return it to the code default (Reset); other roles see it read-only. Its Negotiated column lists the tenants on a negotiated rate, so you know who a tier change will not affect.

The tenant sees its effective rate, read-only, on its own Billing page. It never sees the reason, the deal or the history. Operator detail: docs/runbooks/negotiated-terms.md §7.

Live from Stripe (LMS-774)

Below the summary, the tab reads live from Stripe (the commercial source of truth):

  • MRR — normalized monthly figure (unit amount × billed quantity, annual prices ÷ 12; same formula as the HubSpot CRM mirror), with the per-user rate shown underneath
  • Last payment — amount and date of the most recent paid invoice
  • Subscription — live status, renewal date, cycle, and a “cancelling” marker when cancel-at-period-end is set
  • Default card — brand, last four, expiry
  • Recent invoices — up to 12, with status, amount, and a link to the Stripe-hosted PDF

The Billed Usage card shows the LMS-side numbers the pushed billed quantity derives from: active users in the current billing period (the actual Stripe period, not the calendar month) and licensed users, next to the quantity currently on the Stripe subscription item. A gap between active users and the Stripe quantity is normal mid-period — it closes at the next invoice.upcoming push or daily backstop sweep.

Subscription History (below the two widgets) tables every change event on the subscription with its date: creation, quantity adjustments, plan/price changes, cancellation scheduled or removed, status transitions, and renewals — read from the Stripe customer.subscription.* events feed. Stripe’s events API retains roughly 30 days; the subscription’s original creation (and cancellation) dates are still shown as anchor rows read from the subscription record itself.

When “Live from Stripe” shows Unavailable: the tenant has no Stripe customer yet (typical for Free tenants) or Stripe could not be reached. Nothing is wrong with the page — the commercial summary above still shows the last webhook-reconciled state.

Data Export tab (super_admin only) — LMS-379

The Data Export tab produces a full export of a tenant’s data (users, courses, enrollment/completion records) — for contract-end offboarding or a support investigation (§6.3.1).

  1. Click Export tenant data. A job is queued and an audit entry (tenant_export_requested) records who requested it.
  2. The worker gathers the data, writes a JSON document to the exports bucket, and emails you when it’s ready.
  3. Click Refresh to update the list, then Download on a completed job. The link is a short-lived presigned URL (≤15 minutes, CLAUDE.md §4.4); each download is audit-logged (tenant_export_downloaded). Re-click to get a fresh link after it expires.
StateMeaning
queued / runningThe export is in progress.
completedReady — a Download button appears (shows file size).
failedSomething went wrong (reason shown), e.g. exports storage not configured.

Signing in to a Tenant (super_admin only) — LMS-437

A super_admin can sign in to any active tenant as a full administrator without waiting for a customer access request. On the tenant detail page click Log in to tenant.

  1. The action records an audit entry (platform_tenant_login) tying your platform identity to the target tenant — this is the trace of which super_admin entered which tenant and when.
  2. You are redirected to the tenant (e.g. acme.embaylms.com) and signed in as a synthetic platform-support administrator. A navy banner across the top reads “You are signed in to {tenant} as Embay platform support”.
  3. You have the full tenant-admin permission set — you can do anything a tenant admin can.
  4. Click Return to platform in the banner to sign out of the tenant and go back to the platform console (your platform session is preserved).
PropertyDetail
Whosuper_admin only. Support agents use consent-based access (future) instead.
Tenant visibilityYou do not appear in the tenant’s user table — the platform-support session has no tenant user record.
AttributionActions inside the tenant are audit-logged with your platform user id as the actor.
AvailabilityOnly active tenants — reactivate a suspended tenant first.
Exit”Return to platform”, or simply signing out of the tenant.

This is distinct from user impersonation (logging in as a specific tenant user); platform-support login acts as a generic tenant administrator.

Creating a Tenant (super_admin only)

  1. Click New Tenant
  2. Fill in the form:
FieldRequiredDescription
Display NameYesHuman-readable name (e.g. “Acme Corp”)
Subdomain SlugYesLowercase, alphanumeric + hyphens. Becomes {slug}.embaylms.com
RegionYesDefault: ca-central-1
Custom DomainNoe.g. learn.acme.com
Create as a demo tenantNoThe tenant’s kind is chosen at creation and cannot be toggled later (LMS-812). Demo tenants unlock the seeded demo content set and can never carry a paid subscription (ADR-021 billing guardrail)
  1. Click Create Tenant

The system creates the tenant record (status: provisioning) and queues a worker job to:

  • Create the embaylms_tenant_{slug} PostgreSQL schema
  • Register {slug}.embaylms.com in Vercel

If Redis is unavailable, run provisioning manually:

pnpm tsx infra/scripts/provision-tenant.ts --tenant {slug}

Suspending a Tenant (super_admin only)

Click Suspend. Sets status: suspended — all logins at {slug}.embaylms.com are blocked. No data is deleted.

Activating a Tenant (super_admin only)

Click Activate on a suspended tenant to restore access.

Deleting a Tenant (super_admin only)

Soft-deletes the tenant record (status: deleted). The PostgreSQL schema and S3 data are not immediately purged — a separate manual purge step is required. Deletion is audit-logged and irreversible via the UI.


Demo Tenants (LMS-809 / LMS-811 / LMS-812)

Access: super_admin only for all demo actions.

A demo tenant is a tenant created with the demo kind (see the Create Tenant form above — the kind is fixed at creation, not toggled later). Demo tenants exist to carry the seeded “Northpoint Manufacturing” demo content set (ADR-021) for sales demos and evaluation, and are barred from paid subscriptions by the billing guardrail.

Demo Seed panel

The tenant detail page of a demo tenant shows a Demo Seed panel:

  • Seed / Reseed queues a demo-seed job on the worker. Reseeding is destructive — it wipes the tenant’s content (the audit log is preserved) and re-applies the fixture set deterministically. The panel warns before queueing.
  • Job status — the panel polls the current job (queued → running → step-by-step progress → done/failed) and shows the last job’s outcome. Failures surface an operator-safe error message.
  • Seeded demo users share one password (DEMO_SEED_PASSWORD on the worker; fixture default when unset — see CLAUDE.md §13.2).

Operational details, prerequisites (worker S3 write access) and troubleshooting: docs/runbooks/demo-tenant-seeding.md. Coverage decisions (what the seed does and does not demonstrate) are tracked in the seed manifest (packages/api/src/lib/demo-seed/manifest.ts, ADR-021).


Embay Academy (LMS-1063 / LMS-1066 / LMS-1069)

Embay Academy is Embay’s own training portal for customers’ staff. The console covers three things: the Academy tenant itself, publishing its curriculum, and seeing how customers use it.

The platform Academy tenant (LMS-1063)

Access: created by an operator script, not from the console. All platform roles see it in the tenant list.

The Academy runs in one tenant at academy.embaylms.com. It is its own tenant kind, next to customer and demo tenants, marked by the is_platform_academy flag.

PropertyDetail
One onlyThe database allows a single tenant with the flag, and the provisioning script refuses to run a second time
How it is createdinfra/scripts/provision-platform-academy.ts, run once by an operator. It is a dry run by default; add --execute to create the tenant. The academy slug is reserved, so signup and the New Tenant form both refuse it. Runbook: docs/runbooks/platform-academy.md
Never billedIt has no subscription record. The console’s subscription actions, signup and the Stripe webhook all refuse to attach one, and a negotiated fee cannot be recorded for it
All features onIt resolves to the full entitlement without a plan
Not a customerThe lifecycle engine, the Stripe orphan sweep, signup repair and the billing metrics endpoint skip it. It cannot be flagged as a demo tenant
In the tenant listThe Status column shows an Academy badge beside the tenant’s status
TabsIt has a Curriculum tab (super_admin only) and no Academy tab
RegistrationOpen to outside people such as consultants and partners, with email verification and CAPTCHA on. Customers’ staff arrive from their own portal instead

A customer that runs its own academy is an ordinary billed tenant. The flag is for Embay’s Academy only.

Importing the Academy curriculum (LMS-1066)

Access: super_admin only. The tab exists only on the platform Academy tenant.

The curriculum is written in the repository under content/academy/: the tracks in academy.yaml, one folder per course under courses/, one per exam under exams/. The importer publishes it into the Academy tenant. Nothing is edited in the Academy’s own admin screens.

Run it once after the tenant is created, and again each time a merged change touches content/academy/:

  1. In the console sidebar, click Tenants and open the tenant with the Academy badge.
  2. Open the Curriculum tab.
  3. Click Import curriculum. The button reads Import in progress… until the run ends, and a second import cannot start while one is queued or running.
  4. The worker runs the import. The panel updates every 4 seconds while it runs; click Refresh to update it by hand.
  5. Read the result line: the status (queued, running, succeeded or failed), the request and finish times, and the created / updated / unchanged totals. Below it, each course key is listed with its version number and outcome.
OutcomeMeaning
createdThe course did not exist and was published
updatedThe course changed. A new version was published and its learners moved onto it with their progress
unchangedNothing to do

The import never deletes anything, so it is safe to run twice and has no confirmation step. The Academy tenant must be active. Each request is audit-logged (academy_import_requested).

SymptomCauseFix
”Import could not be queued. Please try again.”The worker queue (Redis) could not be reachedCheck the worker and REDIS_URL, then click Import curriculum again
Status failed with “The curriculum failed validation” and a list of errorsA curriculum file is invalidFix the file in content/academy/, merge, and import again. pnpm tsx infra/scripts/import-academy.ts --check validates the files without touching a database
Status failed with “The import failed; see the worker log”The run stopped for another reasonFind academy_import.failed in the worker log, fix the cause, and import again. A failed import is never retried automatically

The same import can be run without the console: pnpm tsx infra/scripts/import-academy.ts.

Academy adoption (LMS-1069)

Access: all platform roles. Named people are shown to super_admin and support_agent only. A billing_admin sees counts, never names.

There are two views. Both are read-only.

Across all customers: click Academy in the console sidebar (/platform/academy). The page shows tenant names and counts only, never a person.

SectionWhat it shows
Fleet funnelTotals across customers: eligible staff, entered, in progress, track completed, certified, each as a share of eligible staff
Customers by Administrator onboardingOne row per customer that has eligible staff, with a milestone badge (Admin track done, Entered, not done or Not entered), the eligible, entered and track-completed counts, the date the Administrator track was completed, and the last activity. The tenant name opens that tenant’s Academy tab
Courses — completion and drop-offPer Academy course: enrolled, completed, completion rate, and the lesson where most people stop
Certification examsPer exam: attempts, passed attempts, pass rate, people, people certified
Most-missed questionsThe exam questions most often answered wrong, with the miss rate. Use it to find unclear lessons or questions

The customer rows come from the hourly metrics rollup (the line above the table says when it last synced). The course, exam and question sections are read from the Academy tenant. If the Academy tenant does not exist yet, the page says so and shows no course data.

For one customer: open the tenant and click its Academy tab (/platform/tenants/{slug}?tab=academy).

SectionWhat it showsWho sees it
Adoption funnelEligible staff, entered, in progress, track completed, certifiedAll roles
Administrator onboardingDone, with the date and the number of days after the tenant was created, or Not yet. The milestone is an Owner or Admin completing the Administrator trackAll roles
TracksPer track: enrolled, completed, median days to complete, exam average, pass rate, active certifications, certifications expiring within 30 daysAll roles
PeopleEach staff member who entered the Academy: name, role, tracks, progress, last activity, certification, expiry, best score, attemptssuper_admin and support_agent only

For a billing_admin the People table is left out by the server, not just hidden on the page. The line at the bottom of the tab says how fresh the progress data is: it is refreshed on every lesson and hourly.

The Overview tab also has a small Academy card (entered out of eligible, the Administrator track date, active certifications), and the Health tab has the Academy onboarding measure.

“Eligible staff” means active users whose role comes with an Academy track: Owner, Admin, E-commerce Admin, Group Admin, Manager, Instructor, and any custom role that can manage users. Learners are not counted. Three of these adoption figures are also mirrored to HubSpot: see the HubSpot CRM mirror guide.


Support: Tenant Impersonation (/platform/tenants/{slug}/impersonate)

Access: super_admin and support_agent

Allows an Embay staff member to log in to a tenant’s context as a specific user, for debugging purposes.

  1. Find the tenant in the tenant list
  2. Click Impersonate
  3. Select the target tenant user (by email)
  4. Confirm — you are redirected to {slug}.embaylms.com with a 1-hour impersonation session

Audit trail: Every impersonation login is written to audit_log with:

  • actor_id: platform user ID
  • is_impersonation: true
  • impersonated_user_id: target user
  • impersonated_by_role: your platform role

Constraints:

  • Impersonation sessions expire after 1 hour and cannot be extended
  • An impersonation session cannot itself perform impersonation
  • support_agent impersonation sessions are read-only within the tenant (no mutations)

Platform User Management (/platform/users)

Access: super_admin only

  • Invite a new platform user by email — sends a one-time set-password link.
  • Add a new platform user directly (LMS-438) — generates a temporary password shown once for you to hand off securely. The new user is forced to change it on first sign-in (a full-screen prompt blocks the console until they do). Use this when email delivery is unavailable or you need to bootstrap an account immediately.
  • Assign or change roles
  • Deactivate a platform user (immediately revokes access)
  • View login history per user

Impersonating a Platform User (LMS-439)

Access: super_admin only

For debugging another Embay staff member’s experience, hover a row in /platform/users and click Impersonate. Your session is swapped to that user and an amber banner appears at the top of the console; click Exit impersonation to return to your own account. You cannot impersonate yourself or a deactivated user. Every impersonation start is written to the audit log (platform_impersonation_started) with your id as impersonatedBy.

This impersonates a platform user on app.embaylms.com — it is distinct from entering a tenant (see Tenant access above).


Errors Console & Platform Notifications (/platform/errors) — LMS-600/601

Access: super_admin and support_agent can view; status changes and identity resolution are super_admin only.

The Errors console surfaces the self-healing error pipeline (ADR-018): user-facing tenant errors are captured first-party, deduplicated into error groups, ticketed at runtime in Linear, and triaged by an automated coding agent. The console is the human window into that loop.

The Errors list

/platform/errors lists error groups (not individual occurrences), filterable by status and tenant slug. Each row shows the tenant slug, environment, occurrence and affected-user counts, first/last seen, and the linked Linear bug.

Group statuses: New, Triaging, Fix in progress, Awaiting decision (the agent escalated — a human call is needed), Resolved, Ignored, and Regressed (a resolved error came back).

Status actions (super_admin, audit-logged): Resolve an open group, Ignore noise, or Reopen a resolved/ignored group. Each action is also pushed to the linked Linear issue (Resolve → completed, Ignore → cancelled, Reopen → reopened). If the platform app has no LINEAR_AGENT_API_KEY, the status still saves locally and the console warns that the Linear issue was not updated.

Identity resolution (audit-logged)

Error rows are pseudonymous — they carry the user’s UUID, tenant slug, and browser/OS only, never email, name, or IP. On a group’s detail page a super_admin can explicitly resolve a captured UUID to a person (name, email, role) when needed for support follow-up. Every resolution is written to the audit log.

The notifications bell

The platform console header shows a notifications bell — the first in-app notification path for Embay platform users. It signals pipeline events that need attention: an error group awaiting a decision, a fix deployed, or a regression. Clicking a notification deep-links to the relevant error group.

For pipeline operations (enabling capture, agent caps, the safety envelope, callback secrets, and incident handling), see the runbook: docs/runbooks/self-healing-pipeline.md.

Email reputation alarms (LMS-705)

An hourly worker sweep computes each email provider’s bounce and complaint rates over a rolling 24-hour window (from the webhook-fed email_metrics counters) and raises a platform bell notification when a threshold is crossed. Both providers are checked — the dormant one included — and rates are only computed once a provider has sent at least 100 emails in the window. The same alert level is not re-raised within a 6-hour cooldown.

SignalWarningCriticalAWS review line (for reference)
Bounce rate2.5%4%5%
Complaint rate0.05%0.08%0.1%

Thresholds sit deliberately below the AWS review lines even though sending runs on Resend — SES is the retained return path (ADR-020), and a domain whose reputation degraded on Resend would arrive at SES already in trouble.

When an alarm fires, check:

  1. The suppression list (email_suppressions) — is it growing? A burst of hard bounces usually means a bad address batch (e.g. a CSV import with stale emails).
  2. The Resend dashboard — per-message bounce/complaint detail and any provider-side status notices.
  3. Recent bulk sends — a tenant’s large notification blast, import-triggered invites, or a scheduled report run that targeted a stale audience is the most common cause. Identify the tenant and pause the source if needed.

What the Platform Does NOT Include

The following are not available at app.embaylms.com. Always access them at the tenant’s subdomain:

  • Course catalog and course editor
  • Enrollments and completions
  • Learning paths and skills
  • SCORM content upload and launch
  • ILT sessions and quizzes
  • Tenant user management (inviting learners, setting tenant roles)
  • Reports and certificates

Troubleshooting

SymptomLikely CauseResolution
Redirected to login page on app.embaylms.comNo platform user account, or session expiredSign in, or ask a super_admin to create your account
Signed in but immediately redirected awayAccount exists in a tenant schema, not in platform_usersA super_admin must create you a platform account via /platform/users
Tenant shows provisioning indefinitelyRedis/BullMQ not runningRun pnpm tsx infra/scripts/provision-tenant.ts --tenant {slug} manually
app.embaylms.com/admin/courses/... returns redirectCorrect — tenant routes are blocked on the platform domainAccess at {tenant}.embaylms.com instead
”Platform API only accessible from platform domain” error/api/platform called from a non-platform domainSecurity guard — do not call the platform API from tenant subdomains
Impersonation session expired1-hour limit reachedStart a new impersonation session from /platform/tenants