30 KiB
Multi-Tenant Plan — JQC
Audience: AI assistants and developers extending JQC into a multi-tenant SaaS. Companion to:
CLAUDE.md(single-tenant architecture reference). Status: MT-0 through MT-8 complete and deployed (MT-8 billing is flag-gated behindBILLING_ENABLED, default off). MT-9 (iOS multi-tenant) is fully pending — both the server-side discovery endpoints and the iOS client are unbuilt.Feature parity with the single-tenant tree (MT-10 → MT-17): complete. MT forked from ST before ST kept shipping, and that gap has now been closed phase by phase — see §12. The only deliberate divergence is ST's
phase50_default_modern, which MT does not adopt (§12.3). Tenant migration head:phase52_user_ui_theme.
1. Decisions (locked)
| Decision | Choice |
|---|---|
| Isolation model | Shared codebase + database-per-tenant (one deploy, dynamic Host routing). NOT fork-per-tenant. |
| "Managed by tenant" | Tenant-admin self-serves branding / users / settings / in-plan feature toggles. No per-tenant code edits. |
| Plan-tier axes | (1) user quota, (2) facility quota, (3) inspections/month quota, (4) issues/month quota, (5) feature gates (mobile API, scheduled reports, …), (6) branding/white-label, (7) custom-domain vs subdomain-only. |
| Existing LT deploy | Migrated in as tenant-zero — register existing live DB in place, no data move (§8). |
| Quota-exceed behavior | Soft warn — allow submit, flag for upgrade. Never reject. |
| Tenant DB credentials | Per-tenant MySQL user + password. Provisioning creates the user/grants; creds encrypted at rest in control DB. |
Terminology: control plane = manages tenants (registry, plans, provisioning). data plane = serves tenant traffic (the existing Flask app, now tenant-aware).
2. Why this model
- DB-per-tenant → true data isolation; satisfies "separate database". Each tenant = own MySQL database, e.g.
jqc_<slug>. - Shared code → one codebase to patch. As a solo maintainer, N forks would mean N bug-fix deploys. Avoided.
- App already uses the application-factory pattern (
create_app) and relative/api/v1paths — both ideal for adding Host-based tenant routing with minimal churn. - Migrations already use
INFORMATION_SCHEMAexistence checks (CLAUDE.md Rule 14) → safe to re-run across every tenant DB unchanged.
3. Control-plane database (new, separate schema)
Lives in its own MySQL database (e.g. jqc_control), its own Alembic env (chain prefix control{N}_…), never routed by tenant middleware.
plans
id, code (slug: free/starter/pro/enterprise), name, active,
max_users INT NULL, -- NULL = unlimited
max_facilities INT NULL,
max_inspections_month INT NULL,
max_issues_month INT NULL,
allow_custom_domain BOOL,
allow_mobile_api BOOL,
allow_scheduled_reports BOOL,
allow_branding BOOL, -- white-label
price_cents INT NULL, -- future billing
billing_period VARCHAR(16) NULL
plan_features -- optional EAV escape hatch for future boolean flags
id, plan_id (FK), feature_key, enabled BOOL
tenants
id, slug (subdomain label, UNIQUE), name, plan_id (FK),
status ENUM(provisioning, active, suspended, deleted),
db_host, db_port, db_name, db_user, db_password_enc, -- creds encrypted at rest
alembic_head VARCHAR(64), -- last tenant-schema rev applied
created_at, suspended_at, notes
tenant_domains
id, tenant_id (FK), domain (UNIQUE), kind ENUM(subdomain, custom),
is_primary BOOL, verified BOOL, verification_token,
tls_status ENUM(pending, active, failed), created_at
superadmins -- cross-tenant accounts, control-plane only
id, username, email, password_hash, active, created_at,
mfa_enabled, mfa_secret, mfa_recovery_codes -- control0005: opt-in TOTP 2FA
provisioning_jobs
id, tenant_id (FK), action ENUM(create_db, migrate, seed, suspend, delete),
status ENUM(queued, running, ok, failed), log TEXT, created_at, finished_at
tenant_audit -- superadmin actions (separate from per-tenant log_action)
id, superadmin_id, action, tenant_id, details, ip_address, created_at
Submission quotas (inspections/issues per month) are enforced by live-counting current-period rows in the tenant DB at submit time (cheap with an index on the date column). No separate counter table required; always accurate.
4. Tenant resolution + DB routing (the core mechanism)
Resolution (before_request): host = request.host.split(':')[0] → look up tenant_domains.domain == host where verified AND tenant.status='active' → load tenant + plan into g. Unknown/unverified/suspended host → branded error/landing page (no tenant DB touched).
Routing without rewriting every db.session call — use a routing Session so the entire existing codebase keeps using db.session unchanged:
# app/tenancy/routing.py
from sqlalchemy.orm import Session
from flask import g
class RoutingSession(Session):
def get_bind(self, mapper=None, clause=None, **kw):
return getattr(g, 'tenant_engine', None) or _default_engine
MT-0 refinement (implemented): the control plane uses its own
ControlBase+ engine + session (control/base.py), notdb.Modelwith a__control_plane__flag. TheRoutingSession(MT-1) therefore only handles tenant models.
# app/__init__.py (one-line change to the existing db init)
db = SQLAlchemy(session_options={'class_': RoutingSession})
_engine_cache:dict[tenant_id -> Engine], lazily built, small pools +pool_recycle.- Net code change to existing models/routes: zero.
MT-5 addition: Plan limits and feature flags are loaded into TenantContext by the resolver in the same control-DB session — zero extra queries per request. g.tenant.allow_mobile_api, g.tenant.max_users, etc. are available everywhere.
5. Migrations
Two independent Alembic chains:
- Tenant schema — existing chain (HEAD:
phase34_inspection_schedules). Runs per-tenant DB. New tenant features continue asphase35_…per existing naming. - Control schema — chain
control{N}_…, runs once againstjqc_control. HEAD:control0005_superadmin_mfa(control0001_init → control0002_billing → control0003_trial_reminder_sent → control0004_dunning_tracking → control0005_superadmin_mfa).
CLI (always source env first):
set -a; . /etc/jqc/control.env; set +a
python -m control.tenant_migrate upgrade --tenant all
Deploy ordering rule: run tenant migrations before shipping app code that references new columns.
6. Plan-tier matrix
| Axis | Free | Starter | Pro | Enterprise |
|---|---|---|---|---|
| Max users | 3 | 15 | 50 | unlimited |
| Max facilities | 2 | 10 | 50 | unlimited |
| Inspections / month | 50 | 500 | 5 000 | unlimited |
| Issues / month | 50 | 500 | 5 000 | unlimited |
| Mobile API (iPad app) | ✗ | ✓ | ✓ | ✓ |
| Scheduled reports | ✗ | ✗ | ✓ | ✓ |
| Branding / white-label | ✗ | ✗ | ✓ | ✓ |
| Custom domain | ✗ | ✗ | ✓ | ✓ |
Subdomain (*.jqc.app) |
✓ | ✓ | ✓ | ✓ |
Enforcement: @feature_required('mobile_api') hard-blocks (403) disabled features. @quota_soft_check('inspections') sets g.quota_warning but never rejects — over-limit submits succeed, UI shows upgrade prompt. Both decorators are inert when MULTI_TENANT_ENABLED=false.
Feature + quota checks live in both web routes and /api/v1 endpoints (iPad submits via API — web-only check is bypassable).
7. Phased roadmap
MT-0 — Control-plane scaffold. ✅ DONE.
Self-contained control/ package: own ControlBase + engine + session, own Alembic chain (control0001_init), 7 models, Fernet-encrypted tenant creds, idempotent plan seeder, operator CLI. Zero imports into app/.
MT-1 — Tenant resolution + routing. ✅ DONE.
app/tenancy/ package: RoutingSession, resolve_tenant(), engine_cache, TenantContext, init_tenancy() before_request hook + branded 404. Gated behind MULTI_TENANT_ENABLED=false — fully inert until flipped.
MT-2 — Per-tenant migration runner. ✅ DONE.
migrations_tenant/env.py + control/tenant_migrate.py (upgrade_tenant, bootstrap_tenant, chain_head, CLI). Guarded squashed baseline 0003_add_user_active restores chain root. Fresh DBs use bootstrap_tenant (baseline → stamp head, skips unguarded phase migrations). Incremental upgrades use upgrade_tenant (phase33+).
⚠ Blocker found by MT-2 — RESOLVED. Chain had no base; 14/30 phase migrations unguarded. Fixed by guarded squashed baseline. Fresh DB provisioning must use
bootstrap_tenant, never rawflask db upgrade.
MT-3 — Provisioning service. ✅ DONE.
control/provision.py: create_tenant() (DB + user + schema + admin seed + domain), register_tenant_zero() (adopt LT DB in place), delete_tenant(). Fernet-encrypted creds, provisioning_jobs logged. CLI: python -m control.provision.
MT-4 — Superadmin control panel. ✅ DONE.
Standalone Flask app at admin.jqc.app (control/panel/). WSGI entry: control/panel/wsgi_panel.py → Gunicorn on port 8001 (jqc-panel.service). Separate Nginx server block — must appear before the *.jqc.app wildcard block or Nginx routes admin.jqc.app to port 8000 (main app).
Routes: tenant list/detail, plan change, suspend/resume, domain CRUD (add/verify/delete), migration status + upgrade trigger, provision new tenant, impersonation.
Impersonation flow:
- Panel generates HMAC-SHA256 signed token (
PANEL_IMPERSONATE_KEY, TTL 60 s). - Redirects to
https://<tenant-primary-domain>/auth/impersonate?token=<t>. - Main app validates token, sets
session['impersonating_tenant_id']. - Tenancy middleware reads this key and short-circuits Host resolution.
- "End impersonation" banner clears key, redirects back to
admin.jqc.app.
Required env vars (add to /etc/jqc/control.env):
PANEL_SECRET_KEY=<hex32>
PANEL_IMPERSONATE_KEY=<hex32>
MT-5 — Plans + quota/feature gating. ✅ DONE.
app/tenancy/quota.py — live counters (inspections/issues this month, total users/facilities) against tenant DB.
app/tenancy/gates.py — @feature_required(key) (hard 403) and @quota_soft_check(axis) (sets g.quota_warning, never rejects).
app/tenancy/context.py — TenantContext extended with 9 plan fields (all default to unlimited/True → single-tenant unchanged).
app/tenancy/resolver.py — loads plan in the same control session, populates TenantContext plan fields.
app/templates/_quota_warning.html — reusable upgrade-prompt banner partial.
Gated routes:
| Route | Gate |
|---|---|
inspections.start() |
@quota_soft_check('inspections') |
issues.create() |
@quota_soft_check('issues') |
auth.create_user() |
@quota_soft_check('users') |
facilities.create_facility() |
@quota_soft_check('facilities') |
scheduled_reports.index() + create() |
@feature_required('scheduled_reports') |
api.create_inspection() |
@feature_required('mobile_api') + @quota_soft_check('inspections') |
api.create_issue() |
@feature_required('mobile_api') + @quota_soft_check('issues') |
Decorator stack order: @login_required → @role_required → @feature_required → @quota_soft_check.
MT-6 — Custom domain + TLS. ⚙ INFRASTRUCTURE ONLY — no Python deliverables.
Wildcard cert *.jqc.app via DNS-01 challenge (certbot + DNS plugin). Custom-domain TLS via Caddy on-demand TLS. Domain-verification flow (TXT/CNAME) already in MT-7 self-service UI; superadmin marks verified=True in the control panel after DNS check. When Caddy is deployed, update Nginx to pass custom domains to Caddy rather than directly to port 8000.
MT-7 — Tenant self-service. ✅ DONE.
app/models/tenant_settings.py — TenantSettings model, one row per tenant DB, get_or_default() returns transient defaults when no row exists (zero migration burden for existing tenants).
app/routes/tenant_settings.py — blueprint at /settings/, @admin_required.
app/templates/tenant_settings/ — branding.html, plan.html, domains.html.
Migration: phase33_tenant_settings (INFORMATION_SCHEMA guarded, safe to re-run).
Branding injection: inject_tenant_branding() context processor in app/__init__.py pushes tenant_branding into every template. base.html patches: navbar brand reads logo/name from tenant_branding; CSS vars --bs-primary, --jqc-accent injected via inline <style> from tenant_branding.primary_color / accent_color. Jinja2 filter hex_to_rgb registered for Bootstrap RGB var.
Self-service features:
- Branding: company name, logo upload (magic-byte validated), primary/accent colours, support email. Gated by
allow_branding— shows warning but doesn't block form (soft, consistent with quota philosophy). - Plan & Usage: read-only plan info + live quota progress bars (reads from control DB + tenant DB live counts). "Request upgrade" mailto link.
- Domains: lists all
TenantDomainrows. Admin can request a custom domain (creates unverified row, shows TXT/CNAME DNS instructions). Delete unverified custom domains. Superadmin does final verification via control panel.
MT-8 — Billing. ✅ DONE — flag-gated behind BILLING_ENABLED (default off).
Stripe per-plan subscription, fully implemented in app/billing/ (routes.py, webhooks.py, emails.py, stripe_client.py; blueprint registered + CSRF-exempted in app/__init__.py). Lifecycle: trial → active → past_due → suspended → cancelled. Billing gate (_billing_gate() in app/tenancy/middleware.py) enforces trial expiry + cancellation on every request. Dunning sequence (day 3/7/14) via cron POST /notifications/dunning-reminders, tracked by three Tenant columns (past_due_since, dunning_stage, dunning_sent_at; migration control0004_dunning_tracking). HTML + plain-text lifecycle emails, invoice history on /settings/plan, superadmin billing controls on the panel, and public self-service signup (/signup) with 14-day trial + welcome email. See CLAUDE.md §23 for the full reference.
MT-9 — iOS multi-tenant. 🔲 PENDING (server + client both unbuilt).
Planned server side: GET /api/v1/discover?subdomain=acme and GET /api/v1/tenant public endpoints — to be exempt from tenant middleware via MULTI_TENANT_EXEMPT_PATHS. Neither endpoint exists in the code yet — no api_discovery blueprint is registered. iOS side: also pending (web-first priority).
MT-20 — R2 photo storage cutover. ✅ CODE DONE / 🔲 CUTOVER PENDING.
No schema change, no migration. The data-plane code was already fully on the
storage seam (storage.save() / media_url() / materialize_to_dir() /
delete() at every inspection and issue photo site) — MT served local purely
because STORAGE_BACKEND had never been flipped, and it could not be:
boto3was missing fromrequirements.txt, soS3Backend.__init__'s lazy import would have raisedModuleNotFoundErroron the first upload after the flip — at request time, not at boot. Now pinned (boto3>=1.34, matching ST).- there was no cutover tooling, and ST's could not be reused (below).
Why ST's sync script does not port. The local backend deliberately does not
prefix keys, so one shared app/static/uploads/ holds every tenant's photos and
a file on disk carries no ownership marker. ST's script walks the disk and
uploads everything, which on MT writes objects with no tenant prefix — keys
S3Backend._object_key() will never read. Ownership must instead be derived
from each tenant DB's references, then written under t<tenant_id>/.
| Layer | Value |
|---|---|
| DB | uploads/issue_photos/abc.jpg |
| Disk (local backend) | app/static/uploads/issue_photos/abc.jpg |
| R2 object (s3 backend) | t3/uploads/issue_photos/abc.jpg |
Delivered:
scripts/audit_photos.py— read-only. Per tenant, collects every key its DB references (5 sources:issues.photo_path,.mobile_photo_paths[],.result_photos[],inspections.form_data,inspection_results.photo_path) and reconciles against disk. Emits the baseline count that must still resolve after cutover, plus orphans and any key claimed by more than one tenant.scripts/migrate_photos_to_r2.py— copy-only, idempotent, resumable, MD5+size verified, tenant-prefixed. Exit 0 only on full verification. Orphans are not uploaded: no prefix could legitimately claim them.tests/test_storage_backend.py— pins the prefix arithmetic, the bare key returned to the DB, the refusal to write unprefixed with no tenant bound, and the legacy-unprefixed read/delete fallback.
Both scripts read the control DB then each tenant DB via raw SQL (no Flask app
context — the ORM's default bind is the wrong database for every tenant), and
the sync imports collect_referenced from the audit script so the two key sets
can never diverge. Neither writes to any database.
STORAGE_BACKEND is process-wide, so cutover is all-tenants-at-once; isolation
comes from the prefix, not from separate backends. Per-tenant backend selection
would require the resolver to carry a storage selector and get_backend() to
cache per tenant rather than per app — do not half-build it.
Rollback is one env var (STORAGE_BACKEND=local + restart); the sync never
deletes local files.
Open, deliberately not done in MT-20: routes/tenant_settings.py::_save_logo
still writes tenant logos directly to static/uploads/logos/ with os.path.join,
bypassing the seam. After cutover it is the only remaining local-disk writer, so
logos would sit outside whatever backs up R2.
8. Tenant-zero (LT Services) migration
Register the existing live LT database in place — no dump/reload:
- Insert
tenantsrow (id 1),db_*pointing at the current LT DB + creds. Record its current alembic head. - Insert
tenant_domains: primary subdomainlts.jqc.app; plusjqc.ltservicesinc.com(kind=custom, verified) so existing web users keep working. - Point wildcard DNS / Nginx at the (now tenant-aware) app. Resolver returns tenant 1 for both hosts.
Result: existing users notice nothing; LT is now "tenant 1".
9. Decisions resolved
- Quota-exceed → soft warn (allow + flag upgrade). Never reject.
- Tenant DB credentials → per-tenant MySQL user + password; creds encrypted at rest.
- Tenant-zero → register existing live LT DB in place, no data move.
- Impersonation → HMAC-signed token (60 s TTL), panel→tenant redirect, session key short-circuits Host resolution.
- Branding gate → soft (shows warning, form still usable) — consistent with quota philosophy.
10. Nginx — block ordering (critical)
# /etc/nginx/sites-available/jqc
# 1. PANEL — exact match, must be FIRST
server {
listen 80;
server_name admin.jqc.app;
location / { proxy_pass http://127.0.0.1:8001; ... }
}
# 2. APEX — jqc.app serves the public marketing/landing page
# (was a 301 redirect to lts.jqc.app; now proxied to the app, which the
# tenant middleware serves the landing page for — see app/routes/landing.py)
server {
listen 80;
server_name jqc.app www.jqc.app;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host; # resolver reads this
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# 3. WILDCARD — all tenant subdomains
server {
listen 80;
server_name *.jqc.app;
location / { proxy_pass http://127.0.0.1:8000; ... }
}
The apex block must pass Host through unchanged: the app's tenant middleware
compares request.host to TENANT_BASE_DOMAIN (and its www. variant) and
serves the landing page (landing.index) for /, bouncing any other apex path
back to /. /signup, /welcome, and /static/ are tenant-exempt and served
directly. Tenant subdomains and admin.jqc.app are unaffected.
If admin.jqc.app is in the same block as *.jqc.app, Nginx routes it to port 8000 (main app), which returns "Workspace not found" because admin.jqc.app is not a registered tenant domain.
11. CLI quick-reference
# Source env first — CONTROL_DATABASE_URL not in interactive shell by default
set -a; . /etc/jqc/control.env; set +a
# Control schema + plans + first superadmin (run once)
alembic -c control/migrations/alembic.ini upgrade head
python -m control.cli seed
python -m control.cli create-superadmin --username admin --email you@example.com
# Adopt existing LT database as tenant-zero (run once, no data move)
python -m control.provision register-tenant-zero \
--slug lts --name "LT Services" --plan enterprise \
--db-host 127.0.0.1 --db-name <LT_DB> --db-user <LT_USER> --db-password '<pw>' \
--custom-domain jqc.ltservicesinc.com --base-domain jqc.app
# Provision a new tenant
python -m control.provision create-tenant \
--slug acme --name "Acme Corp" --plan pro --admin-email ops@acme.com
# Delete / deregister a tenant
python -m control.provision delete-tenant --slug ztest --drop-db --yes
python -m control.provision delete-tenant --slug lts --yes # no --drop-db for adopted DB
# Migration status + upgrades
python -m control.tenant_migrate heads
python -m control.tenant_migrate current --tenant all
python -m control.tenant_migrate upgrade --tenant all # incremental (phase33+)
python -m control.tenant_migrate bootstrap --tenant acme # fresh DB only
# Superadmin panel
sudo systemctl status jqc-panel
sudo systemctl restart jqc-panel
12. Feature parity with the single-tenant tree (MT-10 → MT-17)
MT forked from the single-tenant codebase (LT_Janitorial_Quality_Control, "ST")
before ST continued shipping features. This section records how that gap was
closed. It is complete as of MT-17.
12.1 Why the two trees look more different than they are
A file-by-file comparison of the two trees overstates the gap. Several ST files have no MT counterpart by name while the feature is fully present under MT's own naming. These are not gaps and must not be "fixed":
| ST | MT equivalent |
|---|---|
models/scheduled_inspection.py |
models/inspection_schedule.py |
routes/scheduled_inspections.py |
routes/inspection_schedules.py |
routes/public.py |
routes/facility_qr.py |
models/notification_recipient.py |
models/project_recipient.py |
ContractNotificationRecipient / get_event_types() |
ProjectNotificationRecipient / get_events() |
app/add_form_schema.py |
scripts/add_form_schema.py |
support/admin_conversation_detail.html |
support/conversation_detail.html |
There is also one place where MT is ahead of ST: utils/forms.py
strong_password() (length + complexity + common-password blocklist) versus
ST's bare Length(min=6). Porting ST's version would be a downgrade.
12.2 Phase log
Migrations phase41 → phase56 in migrations/versions/ are the parity track:
auditor role, area QR tokens, schedule plan fields, internal handler, frequency
ENUM widening, recurrence, end date, parent inspection, follow-up attribution,
schedule acknowledgement, and then:
Aug 2026 tail — ST parity, ported after MT-17. ST's chain numbers these differently (its phase51/52/53); MT's was already past those numbers, so match by NAME:
| MT | ST | What |
|---|---|---|
phase54_user_notif_matrix |
phase51 | Per-account notification overrides for the two customer-side roles. Empty table = everyone inherits the global matrix, so it shipped changing nothing. See CLAUDE.md §27b. |
phase55_template_contracts |
phase52 | Forms restricted to specific contracts. No rows = shared, which is why it needed no backfill. |
phase56_followup_assignee |
phase53 | inspections.follow_up_assigned_to — hand a re-inspection to another inspector. NULL = the inspection's own inspector, as before. See CLAUDE.md §27c. |
Shipped alongside them, without migrations: customer-role management under
/customers, bulk actions on the issues/inspections lists, list-filter
preservation, contract-scoped flag-issue assignees, Customer Directors planning
their own inspections, and the support surface serving both customer roles.
MT-15 — External Inspector role. ✅ DONE (phase51_external_inspector)
Adds external_inspector to the users.role ENUM: an inspector employed by the
customer or a third party, with identical capabilities to inspector and scoped
the same way through InspectorAssignment.
The ENUM widening and the code must ship together. MT had ~80 sites testing
role == 'inspector' with a literal comparison; widening the ENUM alone would
make every one of them evaluate False for the new role and fall through to the
unscoped branch — get_inspector_scope() returns None, downstream queries
drop their facility filter, and an inspector employed by one customer sees every
other customer's contracts. User.INSPECTOR_ROLES (exposed as the
is_inspector property) is now the single definition, and
test_external_inspector_scope_is_not_unrestricted fails loudly if anyone
reverts a membership test to a literal.
Also in this phase: external inspectors are invited, never given a password
(password_set=False + emailed 72-hour token, reusing the customer invite mail),
with a new auth.resend_invite route so a bounced invitation cannot brick an
account permanently. Creating any other role with a blank password is now
rejected — it previously stored the hash of the empty string.
MT-16 — Modern web portal design. ✅ DONE (phase52_user_ui_theme)
Ports ST's phase48. base.html became a one-line dispatcher
({% extends jqc_layout %}); the old chrome moved verbatim to
layouts/classic.html; layouts/modern.html is the sidebar shell. All existing
page templates needed zero edits — Jinja resolves {% block %} overrides
through the whole inheritance chain.
ThemedEnvironment.get_template() swaps x.html → modern/x.html for modern
users. The swap is in get_template(), not the loader, on purpose: Jinja's
template cache is keyed on the name get_template() receives, so a cached
modern template can never be served to a classic user — and in MT, where one
Gunicorn worker serves many tenants, a loader-level swap would leak across
tenants too.
MT-specific adaptations that ST's files required: tenant branding (ST hardcodes
its own company name), inspection_schedules for ST's scheduled_inspections,
facilities.qr_print_all for ST's facility_qr_print_all, the billing banner,
role_label for MT-15's new role, and a rewritten tenant-neutral About page.
_quota_warning.html is deliberately not in the modern layout — it is a
per-form include, not chrome, and would render twice on four pages.
MT-17 — Enrollment intake form. ✅ DONE (no migration)
Ports ST's app/enrollment/ — a public, login-free intake form plus an
admin-only inbox, kept deliberately outside the schema (flat JSON, no model, no
migration, deletable package).
Tenant isolation was the change ST's version required. ST keeps every
submission in one flat directory; in MT that directory is shared by every tenant
on the host, so /enrollment/admin would list other organisations' submissions.
Submissions are now filed under <ENROLLMENT_DIR>/t<tenant_id>/, mirroring
storage.tenant_key_prefix(). When multi-tenancy is on and no tenant is bound,
storage.enrollment_dir() raises TenantUnresolved rather than falling back
to the root — a fallback would be a silent cross-tenant leak; an exception is
loud and safe.
Branding was the second change: ST hardcodes its company name in four places and
a personal Gmail address as the customer-facing "corrections" contact. Both now
resolve from TenantSettings, with a test that greps the package so they cannot
silently return.
12.3 Deliberate divergence: ST phase50_default_modern is NOT ported
ST's phase50 flips the ui_theme column default to modern and runs:
UPDATE users SET ui_theme = 'modern' WHERE ui_theme = 'classic';
That overwrites every saved preference. It was defensible for a single-tenant deployment deciding for its own staff after its own A/B test.
It is not portable to MT. The same statement runs against every tenant
database, flipping the entire UI for tenants who never saw the test and never
asked. MT therefore ships the phase48 semantics only: default classic, no
backfill of any kind.
The effective default for accounts that never chose is config
DEFAULT_UI_THEME (app/__init__.py::resolve_ui_theme), which reads the
environment and itself defaults to classic. A stored users.ui_theme always
wins. To put a tenant on the modern design, set DEFAULT_UI_THEME=modern in
that tenant's process environment — a config change, reversible, with no
preferences destroyed. test_new_user_defaults_to_classic pins this so a future
port of phase50 has to be a deliberate act.
12.4 Open decisions
- Seat quota.
tenancy/quota.py::count_active_users()counts all active users regardless of role, so external inspectors consume a seat againstmax_users. Intentional (they are real accounts), but tenants near their cap will hit@quota_soft_check('users')when inviting third parties. Excluding them is a billing-policy decision, not a bug fix. enrollment/schema.py::CORRECTIONS_EMAILis now an empty last-resort default. Decide whether to drop the constant and its two config fallbacks in favour of requiringTenantSettings.support_email.
12.5 Deferred, with reasons
_handler_splitdashboard cards. ST's classic dashboard shows handler breakdowns for opened today and unassigned as well as open issues. MT supplieshandler_breakdown(open issues) and both MT dashboards render it; the other two would require converting.count()queries to.all()and fetching full rows for a cosmetic card, which regresses large tenants. If wanted, do it as a SQLGROUP BY handler_typerather than ST's Python-side count over fetched rows.- PDF / audit hardening.
- MT-9 iOS client (see §7).