Date: 2026-05-10
Prepared for: Jack + Mark
Lane: FORQA CTO / implementation planning
Scope: Planning/docs only. No code, repo, database, Supabase config, deploy, credential, or production data changes were made.
Primary input: Jack_OS/RESEARCH/FORQA_Multi_Tenant_SaaS_CTO_Blocker_Analysis_2026-05-10.md
0. CTO directive
FORQA should not onboard more than one external customer company into the same Supabase database until tenant isolation is implemented, tested, and owned.
The implementation path should be:
- Protect beta learning now: keep existing beta/design-partner usage single-tenant or isolated per customer environment.
- Build tenancy foundation deliberately: tenant tables, memberships, tenant-scoped schema, tenant-aware APIs, RLS, integrations, tests, and release gates.
- Only then move to shared multi-tenant SaaS: after cross-tenant negative tests pass in CI/staging and production smoke tests prove auth/tenant boundaries.
Recommended architecture: hybrid bridge.
- Short term: tenant-per-project/environment for any external customer that cannot share data.
- Medium/long term: one shared SaaS database with hard
tenant_idisolation, tenant-aware RLS, tenant-scoped Edge Functions, and per-tenant integration config.
1. Evidence paths inspected
Jack OS / lane docs
Jack_OS/PROJECTS/FORQA.mdJack_OS/AGENT_LANES/FORQA_CTO_Lane.mdJack_OS/RESEARCH/FORQA_Multi_Tenant_SaaS_CTO_Blocker_Analysis_2026-05-10.mdJack_OS/RESEARCH/FORQA_CTO_Deep_Work_Review_Pack_2026-05-10.mdJack_OS/RESEARCH/FORQA_CTO_Review_Pack_2026-05-10.md
Local read-only repo cache
Jack_OS/review_cache/FORQA/docs/adr/001-supabase-auth-strategy.mdJack_OS/review_cache/FORQA/docs/ARCHITECTURE.mdJack_OS/review_cache/FORQA/docs/DEPLOYMENT.mdJack_OS/review_cache/FORQA/scripts/deploy-production.shJack_OS/review_cache/FORQA/supabase/config.tomlJack_OS/review_cache/FORQA/supabase/config.production.tomlJack_OS/review_cache/FORQA/supabase/functions/_shared/auth.tsJack_OS/review_cache/FORQA/supabase/functions/_shared/supabase-client.tsJack_OS/review_cache/FORQA/supabase/functions/api-forecast/index.tsJack_OS/review_cache/FORQA/supabase/functions/api-filters/index.tsJack_OS/review_cache/FORQA/supabase/functions/api-budget/index.tsJack_OS/review_cache/FORQA/supabase/functions/api-budget-performance/index.tsJack_OS/review_cache/FORQA/supabase/functions/api-filter-presets/index.tsJack_OS/review_cache/FORQA/supabase/functions/api-settings/index.tsJack_OS/review_cache/FORQA/supabase/functions/api-sync-health/index.tsJack_OS/review_cache/FORQA/supabase/functions/api-deal-insights/index.tsJack_OS/review_cache/FORQA/supabase/functions/sync-hubspot/index.tsJack_OS/review_cache/FORQA/supabase/migrations/001_initial_schema.sqlJack_OS/review_cache/FORQA/supabase/migrations/004_fix_rls_policies.sqlJack_OS/review_cache/FORQA/supabase/migrations/008_add_budget_targets.sqlJack_OS/review_cache/FORQA/supabase/migrations/010_enhanced_rls_policies.sqlJack_OS/review_cache/FORQA/supabase/migrations/015_robust_locks.sqlJack_OS/review_cache/FORQA/supabase/migrations/017_add_filter_presets.sqlJack_OS/review_cache/FORQA/supabase/migrations/018_deal_insights_tables.sqlJack_OS/review_cache/FORQA/e2e/deal-insights.spec.tsJack_OS/review_cache/FORQA/frontend/e2e/dashboard.spec.tsJack_OS/review_cache/FORQA/frontend/e2e/filters.spec.tsJack_OS/review_cache/FORQA/frontend/e2e/navigation.spec.tsJack_OS/review_cache/FORQA/frontend/e2e/settings.spec.ts
Key evidence summary:
- ADR explicitly says current app is an internal single-tenant tool for
obi.io, with all authenticated users seeing the same forecast data. app_usersexists, but notenantsortenant_membershipstable was found.- Core business tables are global, not consistently tenant-scoped.
- RLS is enabled, but many policies allow authenticated reads with
USING (true)and service-role full access. - Edge Functions validate Microsoft Entra tokens and then use
getServiceRoleClient(), which bypasses RLS. sync-hubspothas global hard-codedTARGET_PIPELINES = ['1871763', '102280527'], not per-tenant integration configuration.
2. Non-negotiable principles
- Fail closed without tenant context. Any endpoint that returns or mutates tenant-owned data must fail if no tenant can be resolved and authorized.
- Tenant boundary exists in the database, not just the frontend. UI filters are convenience only, not security.
- Service role must not be a casual read path. If service-role clients remain, they must sit behind mandatory tenant-scoped helper functions and tests.
- External IDs are tenant-local. HubSpot/Sage IDs, names, slugs, pipeline IDs, stage IDs, preset names, and lock names can collide across tenants.
- Every sync is tenant-scoped. Source credentials, target pipelines, locks, logs, truncation/replacement, and forecast snapshot creation must all include tenant identity.
- Cross-tenant tests are release gates. Multi-tenant readiness is not a design doc; it is a passing test suite.
- Sage remains a roadmap/design input until implemented. The current inspected repo appears HubSpot-line-item-led. Future Sage actuals must enter the tenant model from day one.
3. Target tenant model
3.1 Core objects
create table tenants (
id uuid primary key default gen_random_uuid(),
name text not null,
slug text not null unique,
status text not null default 'active'
check (status in ('trial', 'active', 'suspended', 'churned', 'deleted')),
plan text,
primary_contact_email text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table tenant_memberships (
tenant_id uuid not null references tenants(id) on delete cascade,
user_id uuid not null references app_users(id) on delete cascade,
role text not null
check (role in ('owner', 'admin', 'finance', 'sales_manager', 'rep', 'viewer')),
status text not null default 'active'
check (status in ('invited', 'active', 'disabled')),
invited_by uuid references app_users(id),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (tenant_id, user_id)
);
3.2 User model
Keep app_users as the identity profile table, but make authorization tenant-specific.
Current app_users.role should be treated as legacy/global only. It can remain temporarily for migration compatibility, but authorization should move to tenant_memberships.role.
Recommended future fields:
alter table app_users
add column if not exists global_role text default 'user'
check (global_role in ('user', 'support', 'platform_admin')),
add column if not exists disabled_at timestamptz;
Only platform_admin should access cross-tenant admin tooling, and those actions must be explicitly audited.
3.3 Tenant-owned tables
Add tenant_id to all business/customer-owned tables:
pipelinesdeal_stagesownersservice_typesservice_types_secondaryregionsentitiessectorsresourcesdealsline_itemsforecast_phased_revenueforecast_snapshotsforecast_snapshot_detailssync_logsbudget_targetsfilter_presetsdeal_stage_changesstage_stagnation_thresholds- future
sage_*actuals/invoices/customer mapping tables - future tenant settings/integration tables
3.4 External ID uniqueness
Replace global unique constraints with tenant-scoped constraints. Examples:
-- unsafe in shared SaaS
unique (hubspot_id)
unique (hubspot_deal_id)
unique (hubspot_line_item_id)
unique (name)
-- safe pattern
unique (tenant_id, hubspot_id)
unique (tenant_id, hubspot_deal_id)
unique (tenant_id, hubspot_line_item_id)
unique (tenant_id, name)
Where uniqueness should be optional because external IDs can be null:
create unique index deals_tenant_hubspot_deal_id_uq
on deals (tenant_id, hubspot_deal_id)
where hubspot_deal_id is not null;
3.5 Tenant-aware foreign keys
Use simple tenant_id columns plus normal FKs first. Where corruption risk is high, add composite safeguards.
Example for deal_stages belonging to a pipeline within the same tenant:
alter table pipelines add constraint pipelines_tenant_id_id_uq unique (tenant_id, id);
alter table deal_stages
add constraint deal_stages_pipeline_same_tenant_fk
foreign key (tenant_id, pipeline_id)
references pipelines (tenant_id, id);
This prevents accidentally linking Tenant A stage to Tenant B pipeline.
4. Target auth and authorization design
4.1 Request flow
Every tenant-owned Edge Function should follow this sequence:
- Validate bearer token with current Entra/Supabase auth mechanism.
- Ensure/update
app_usersrecord. - Resolve requested tenant from one of:
- X-FORQA-Tenant-ID header,
- route parameter /tenants/:tenantId/...,
- subdomain mapping,
- user default tenant when and only when user has exactly one active membership.
- Verify active membership in
tenant_memberships. - Check capability for action.
- Execute query through tenant-scoped wrapper.
- Write audit event for writes/admin/sync/integration actions.
Suggested helper signature:
type Capability =
| 'forecast:read'
| 'budget:read'
| 'budget:write'
| 'filters:read'
| 'presets:write'
| 'settings:write'
| 'sync:run'
| 'sync:read'
| 'integrations:manage'
| 'users:manage'
| 'tenant:admin';
async function requireTenantContext(req, supabase, capability): Promise<{
userId: string;
tenantId: string;
role: TenantRole;
}>;
4.2 Role/capability matrix
| Capability | Owner | Admin | Finance | Sales manager | Rep | Viewer |
|---|---|---|---|---|---|---|
| Forecast dashboard read | yes | yes | yes | yes | limited/optional | yes |
| Filter options read | yes | yes | yes | yes | limited/optional | yes |
| Deal insights read | yes | yes | yes | yes | own/team optional | yes |
| Budget read | yes | yes | yes | yes | no/optional | yes/optional |
| Budget write | yes | yes | yes | no | no | no |
| Stage probability/settings write | yes | yes | no/optional | no/optional | no | no |
| Filter preset write | yes | yes | yes | yes | yes | no/optional |
| Manual sync run | yes | yes | no/optional | no | no | no |
| Integration credential manage | yes | yes | no | no | no | no |
| Users/invites manage | yes | yes | no | no | no | no |
| Tenant billing/plan | yes | no/optional | no | no | no | no |
Mark decision required: decide whether reps should see all tenant commercial data or only own/team data. If own/team data is required, the schema must support ownership/manager hierarchy and all forecast aggregate queries become more complex.
4.3 Tenant selection UX
Recommended initial UX:
- If user has one active tenant, auto-select it.
- If user has multiple active tenants, show org switcher.
- Persist selected tenant in frontend state/local storage, but never trust it without backend membership verification.
- Send tenant ID on every API request.
Avoid relying only on email domain for authorization. Domains are useful for invite/onboarding defaults but not for secure tenant resolution.
5. RLS and database enforcement
5.1 Recommended RLS policy pattern
Use RLS for tenant-owned tables where possible, especially read paths. Example:
create or replace function current_user_tenant_ids()
returns setof uuid
language sql
stable
security definer
as $$
select tm.tenant_id
from tenant_memberships tm
join app_users au on au.id = tm.user_id
where tm.status = 'active'
and au.auth_uid = auth.uid();
$$;
create policy "tenant members can read forecast"
on forecast_phased_revenue
for select
to authenticated
using (tenant_id in (select current_user_tenant_ids()));
Important: the exact identity mapping depends on whether FORQA keeps Entra-token-only auth or moves to Supabase Auth JWTs. If auth.uid() is not available for Entra identities, use an explicit mapping claim or keep RLS for direct Supabase clients and enforce tenant membership in Edge Functions until auth is unified.
5.2 Service role rules
Because getServiceRoleClient() bypasses RLS, service-role operations must be constrained by code structure:
- Disallow raw
.from('tenant_owned_table')in endpoint handlers except inside tenant repository helpers. - Add a
tenantScoped(supabase, tenantId)wrapper/module. - Add lint/test grep to flag calls to tenant-owned tables without
.eq('tenant_id', tenantId)or approved helper usage. - Keep service role for sync/admin jobs, not casual dashboard reads if authenticated client + RLS is feasible.
Suggested wrapper:
function forTenant(supabase, tenantId: string) {
return {
forecastRows: () => supabase.from('forecast_phased_revenue').select('*').eq('tenant_id', tenantId),
deals: () => supabase.from('deals').select('*').eq('tenant_id', tenantId),
budgets: () => supabase.from('budget_targets').select('*').eq('tenant_id', tenantId),
};
}
5.3 Tables that may remain global
Very few tables should be global:
app_usersas identity profile.tenantsas platform-level tenant records.tenant_membershipsas access control.- Optional global product configuration/feature flags.
- Audit log can be global physically, but every row should include
tenant_idwhen tenant-related.
Lookup tables like pipelines, owners, service_types, regions, and entities should be tenant-owned because their values are sourced from customer CRM/accounting systems and can collide or differ by customer.
6. Supabase environment strategy
6.1 Environments
| Environment | Purpose | Data | Auth | Tenancy expectation |
|---|---|---|---|---|
| Local | developer work | seeded synthetic | relaxed only where necessary | disposable two-tenant fixtures |
| Dev | integration branch testing | synthetic | auth enabled if practical | migrations/tests validate tenant model |
| Staging | release candidate | seeded/anonymised realistic | production-like | mandatory two+ tenant isolation tests |
| Production | live customers | real data | strict | no shared external data without gates |
6.2 Project strategy
Recommended bridge:
- Existing/current beta: keep as one legacy tenant if all users intentionally share data.
- New pilots before tenancy complete: use isolated Supabase project/environment per customer.
- Shared SaaS: create/upgrade production only after tenant foundation and tests pass.
6.3 Deployment requirements
Before multi-tenant production claims:
config.production.tomlauth settings must be authoritative and applied.- All deployed functions must have expected JWT enforcement.
scripts/deploy-production.shor replacement pipeline must deploy all required functions consistently.- Scheduled sync must use a secure server-side auth pattern, not unauthenticated public invocation.
- Every function must have no-token/wrong-token smoke tests.
7. Per-tenant integrations
7.1 Integration tables
create table tenant_integrations (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references tenants(id) on delete cascade,
provider text not null check (provider in ('hubspot', 'sage')),
status text not null default 'not_connected'
check (status in ('not_connected', 'connected', 'error', 'paused', 'revoked')),
display_name text,
config jsonb not null default '{}'::jsonb,
last_successful_sync_at timestamptz,
last_failed_sync_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, provider, display_name)
);
create table integration_credentials (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references tenants(id) on delete cascade,
integration_id uuid not null references tenant_integrations(id) on delete cascade,
provider text not null,
secret_ref text not null,
status text not null default 'active'
check (status in ('active', 'rotating', 'revoked')),
rotated_at timestamptz,
created_at timestamptz not null default now()
);
Store secret references, not raw credentials, in Postgres unless Mark explicitly chooses a vetted encrypted storage pattern.
7.2 HubSpot sync changes
Current risk: global TARGET_PIPELINES and global sync writes.
Required changes:
sync-hubspotrequirestenantIdand verifies caller hassync:runor job is an authorized scheduled job for that tenant.- Load HubSpot credential via tenant integration record.
- Move target pipelines into
tenant_integrations.config, e.g.{ "targetPipelineIds": ["1871763"] }. - All upserts include
tenant_id. - All selects include
tenant_id. - All deletes/truncations/replacements are scoped by
tenant_id. - Advisory/application locks include tenant and provider:
sync:hubspot:{tenantId}. sync_logsincludetenant_id,integration_id,triggered_by_user_id, and source metadata.- Forecast snapshot creation includes
tenant_id.
7.3 Sage future-proofing
Even before Sage implementation, reserve tenant-aware patterns:
sage_customers(tenant_id, sage_customer_id, ...)sage_invoices(tenant_id, sage_invoice_id, customer_id, invoice_date, due_date, net_amount, tax_amount, status, ...)sage_actuals_monthly(tenant_id, period_month, customer_id, invoice_total, paid_total, ...)crm_accounting_mappings(tenant_id, hubspot_company_id, sage_customer_id, confidence, created_by, reviewed_at, ...)
Mark must decide whether Sage is actuals-only read, reconciliation support, or write-back. Write-back materially increases permissions, audit, and rollback complexity.
8. Migration path from current beta
Phase 0 — Decision/freeze (1-2 days)
Goal: prevent unsafe data co-mingling while implementation starts.
Tasks:
- Document current beta mode as
single-tenant/design-partner onlyunless Mark says otherwise. - Confirm whether multiple external companies currently share the same Supabase project/database.
- If yes, stop and create a data-separation incident review before engineering work proceeds.
- Pick bridge strategy: project-per-customer pilots vs waiting for shared tenancy.
- Create Tenancy ADR and have Mark sign it.
Acceptance criteria:
- Written Mark/Jack decision exists.
- No new customer data is added to shared database until gates are met.
- Named owner exists for credential incident response and production auth smoke tests.
Phase 1 — Tenancy foundation (sprint 1)
Goal: introduce tenants/memberships without breaking existing app.
Tasks:
- Add migration for
tenantsandtenant_memberships. - Add one
legacy-defaulttenant. - Backfill all existing
app_usersintolegacy-defaultwith appropriate role. - Add backend helper:
requireTenantContext(req, capability). - Add frontend tenant selection/provider and tenant header on requests.
- Add platform/admin seed script for creating tenants and memberships in non-production.
Acceptance criteria:
- Existing single-tenant app still works via default tenant.
- User with no active membership receives 403.
- User with multiple memberships can switch tenants.
- No tenant-owned endpoint succeeds without tenant context unless explicitly marked platform-admin-only.
Phase 2 — Add nullable tenant_id and API scoping (sprint 1-2)
Goal: tenant scope every read/write while preserving migration safety.
Tasks:
- Add nullable
tenant_idto tenant-owned tables. - Backfill existing rows to
legacy-default. - Add indexes on
tenant_idand high-traffic compound filters. - Update every API query to include tenant filter.
- Update filter option endpoint so clients/owners/pipelines/stages are tenant-scoped.
- Update budget/preset/settings writes to require capability and tenant filter.
- Add two-tenant synthetic fixtures with overlapping external IDs/names.
Acceptance criteria:
- Grep/static check shows all tenant-owned
.from()reads/writes pass through tenant helper or include tenant filter. - Endpoint tests prove Tenant A cannot see Tenant B forecast/filter/budget/preset/sync/insight data.
- Existing production data maps to legacy tenant in staging migration rehearsal.
Phase 3 — Constraints, RLS, and fail-closed enforcement (sprint 2-3)
Goal: convert soft tenant scoping into hard guarantees.
Tasks:
- Make
tenant_idNOT NULLfor tenant-owned tables after backfill validation. - Replace global unique constraints/indexes with tenant-scoped equivalents.
- Add composite tenant-aware FKs where useful.
- Replace permissive authenticated RLS policies with tenant-aware policies.
- Add RLS verification tests in local/staging DB.
- Add deny-by-default tests for no tenant, wrong tenant, disabled membership, and disabled tenant.
Acceptance criteria:
- Migration succeeds from clean DB and from staging clone/anonymised export.
- No table has broad authenticated
USING (true)policy for tenant-owned data. - Tenant A authenticated client cannot read Tenant B rows under RLS.
- Service-role endpoint tests still fail closed on missing/wrong tenant.
Phase 4 — Tenant-scoped integrations and sync (sprint 3-4)
Goal: make HubSpot/Sage integration architecture SaaS-safe.
Tasks:
- Add
tenant_integrationsandintegration_credentials/secret-ref model. - Move hard-coded HubSpot target pipelines into tenant config.
- Update
sync-hubspotto require tenant context or authorized scheduled tenant job. - Scope all sync selects/upserts/deletes/replacements/snapshots/logs by tenant.
- Tenant-scope locks:
sync:hubspot:{tenantId}. - Add sync-health endpoint scoping.
- Add tests where Tenant A sync cannot overwrite Tenant B data, even with same HubSpot IDs.
Acceptance criteria:
- Two tenants can sync in test fixture with overlapping source IDs without collision.
- Tenant A sync logs never appear to Tenant B.
- Sync failure for Tenant A does not block Tenant B except for explicit provider/global rate limit handling.
- Credential lookup uses tenant integration secret reference only.
Phase 5 — CI, staging, release gates (sprint 4)
Goal: make tenancy regression-proof.
Tasks:
- Add CI jobs for lint/typecheck/unit tests.
- Add migration tests from clean DB.
- Add RLS policy tests.
- Add Edge Function integration tests with two-tenant fixtures.
- Add Playwright/E2E tenant switch/no-leak tests.
- Add production smoke-test script for no-token, invalid-token, wrong-tenant, and correct-tenant cases.
- Add security checklist to release template.
Acceptance criteria:
- CI fails on cross-tenant leaks.
- Staging release cannot be promoted without passing tenant isolation suite.
- Manual production smoke test runbook exists and has named owner.
Phase 6 — Controlled production rollout (after gates)
Goal: safely introduce true shared-tenancy.
Tasks:
- Back up production.
- Run migration rehearsal on staging clone/anonymised data.
- Deploy backend helpers and tenant-scoped code behind feature flag if practical.
- Run production migration during agreed window.
- Verify legacy tenant access.
- Add second tenant only after all smoke tests pass.
- Monitor auth failures, Edge Function errors, sync health, and per-tenant row counts.
Acceptance criteria:
- Legacy beta remains operational.
- No unauthenticated endpoint returns business data.
- Wrong-tenant requests return 403.
- First second-tenant onboarding passes isolated sync/read/write tests.
9. Developer ticket backlog
Epic A — Tenancy ADR and beta freeze
A1. Write Tenancy ADR
Acceptance: ADR records chosen bridge strategy, final shared-DB goal, auth provider assumptions, tenant selection method, Mark sign-off, and no-co-mingling rule.
A2. Confirm current customer data residency
Acceptance: clear statement whether current beta data contains one company or multiple companies in one DB; if multiple, incident/risk plan exists.
Epic B — Schema foundation
B1. Add tenants and memberships migration
Acceptance: migration creates tables, constraints, indexes, seed default tenant path.
B2. Backfill app users to default tenant
Acceptance: all existing users have active membership in staging rehearsal; disabled/unknown users handled.
B3. Add tenant_id nullable to business tables
Acceptance: all identified tenant-owned tables have nullable tenant_id; indexes created.
B4. Backfill tenant_id
Acceptance: zero null tenant IDs after backfill in staging; row counts match before/after.
B5. Convert unique constraints
Acceptance: external ID/name constraints are tenant-scoped; duplicate IDs across tenants allowed in tests.
B6. Enforce NOT NULL + tenant-aware FK safeguards
Acceptance: tenant-owned rows cannot be inserted without tenant; cross-tenant FK links rejected.
Epic C — Auth and API authorization
C1. Implement requireTenantContext
Acceptance: validates token, ensures user, resolves tenant, checks membership/status, checks capability, returns typed context.
C2. Add tenant header/request plumbing
Acceptance: frontend sends tenant ID; multi-tenant users can switch; backend does not trust frontend without membership check.
C3. Scope forecast API
Acceptance: api-forecast returns only tenant rows; wrong tenant returns 403.
C4. Scope filters API
Acceptance: api-filters returns only tenant options, clients, last sync, and forecast dimensions.
C5. Scope budget APIs
Acceptance: budget read/write scoped by tenant; write requires budget capability.
C6. Scope filter presets
Acceptance: presets are tenant + user scoped; no default leakage.
C7. Scope settings/probability writes
Acceptance: settings writes require admin capability; only affect current tenant.
C8. Scope sync health
Acceptance: tenant sees only own sync logs/status.
C9. Scope deal insights
Acceptance: all deal/stage/stagnation/owner queries are tenant-scoped.
Epic D — RLS and service-role reduction
D1. Replace permissive RLS policies
Acceptance: no tenant-owned table has authenticated USING (true) read policy in final migrations.
D2. Add RLS tests
Acceptance: authenticated Tenant A context cannot select Tenant B rows.
D3. Create tenant repository/helper layer
Acceptance: endpoint code uses approved helper for tenant-owned tables.
D4. Add static guard for raw tenant table access
Acceptance: CI flags new raw service-role table access without tenant helper/explicit waiver.
Epic E — Per-tenant integrations/sync
E1. Add tenant integration tables
Acceptance: HubSpot integration config can be stored per tenant; secret refs are not raw tokens.
E2. Move target pipelines to tenant config
Acceptance: no global hard-coded target pipeline array controls all tenants.
E3. Tenant-scope HubSpot sync
Acceptance: every sync query/upsert/delete/log/snapshot includes tenant ID.
E4. Tenant-scope locks
Acceptance: Tenant A sync lock does not block Tenant B unless global provider throttle intentionally applies.
E5. Add sync isolation tests
Acceptance: same HubSpot deal IDs in two tenants do not collide or overwrite.
Epic F — CI, QA, release and rollback
F1. Two-tenant fixture generator
Acceptance: creates Tenant A/B with overlapping IDs/names, user in one tenant, user in both tenants, disabled membership.
F2. Edge Function integration tests
Acceptance: no-token, invalid-token, wrong-tenant, disabled-membership, correct-tenant cases covered per endpoint.
F3. E2E tenant switching tests
Acceptance: UI displays only selected tenant data and does not retain stale data after switch.
F4. Migration rehearsal script
Acceptance: clean DB and staging-like DB migration both pass with row-count checks.
F5. Production smoke test script/runbook
Acceptance: release owner can verify auth and tenancy after deploy without touching customer data unnecessarily.
F6. Rollback plan
Acceptance: each migration has rollback or restore strategy; backup and owner documented.
10. Test and QA matrix
10.1 Auth/tenant tests
- No token -> 401/403.
- Invalid token -> 401/403.
- Valid token with no
app_usersrecord -> created or denied according to ADR, then membership checked. - Valid user with no membership -> 403.
- Valid user with disabled membership -> 403.
- Valid user requesting suspended tenant -> 403.
- User in Tenant A requesting Tenant B -> 403.
- User in both Tenant A and Tenant B can switch and sees separate data.
- Missing tenant context -> 400/403 fail closed.
10.2 Forecast and filters
- Tenant A sees only Tenant A forecast rows.
- Tenant A filter options exclude Tenant B clients, owners, pipelines, stages, service types, regions, entities, sectors, and resources.
- Same HubSpot deal ID in A/B does not collide.
- Same client name in A/B remains isolated.
- Aggregates are computed only over selected tenant.
- Cached frontend state is cleared on tenant switch.
10.3 Budgets/settings/presets
- Tenant A budget write cannot affect Tenant B.
- Viewer cannot write budget.
- Finance can write budget if Mark approves that capability.
- Stage probability/settings write requires admin/owner.
- Presets are tenant + user scoped.
- Deleting a preset in Tenant A does not remove Tenant B preset with same name.
10.4 Sync
- Tenant A sync uses Tenant A credential and target pipelines.
- Tenant A sync cannot truncate or replace Tenant B forecast rows.
- Tenant A sync logs are invisible to Tenant B.
- Tenant A sync lock is independent from Tenant B lock.
- Failed Tenant A sync does not change Tenant B sync-health status.
- Scheduled sync job can run per tenant without exposing public unauthenticated endpoint.
10.5 RLS/direct DB
- Authenticated Tenant A client cannot select Tenant B rows from each tenant-owned table.
- Insert without tenant ID fails.
- Cross-tenant FK assignment fails.
- Global authenticated read policies are absent from tenant-owned tables.
10.6 Security/operational
- All Edge Functions return 401/403 on no token in production.
- Logs do not print credentials/tokens.
- Audit events exist for writes, syncs, integration changes, membership changes, and settings changes.
- Tenant suspension prevents access and scheduled sync.
- Restore procedure is known for failed migration.
11. Release gates
Gate 0 — before further external co-residency
- Mark signs tenancy ADR.
- Current customer co-residency status is known.
- Existing credential/deployment P0s are owned.
- No new external company added to shared DB without explicit approval.
Gate 1 — staging tenant foundation
- Tenants/memberships exist.
- Default tenant migration works.
- APIs fail closed without tenant context.
- Two-tenant fixtures exist.
Gate 2 — staging isolation
- Tenant IDs present and non-null across tenant-owned tables.
- Tenant-scoped unique constraints active.
- Cross-tenant tests pass for forecast, filters, budgets, presets, settings, insights, sync health.
- RLS tests pass or service-role-only endpoints have explicit tested tenant wrappers.
Gate 3 — integration isolation
- HubSpot config/credentials are per tenant.
- Sync is tenant-scoped end to end.
- Same external IDs across tenants do not collide.
- Sync locks/logs/snapshots are tenant-scoped.
Gate 4 — production readiness
- CI green.
- Migration rehearsal green.
- Production smoke-test runbook executed.
- Backup/rollback ready.
- Monitoring/alerts active.
- Named owner signs go/no-go.
12. Rollback and incident plan
12.1 Rollback philosophy
Schema changes should be staged to avoid irreversible cutovers:
- Add new tables/nullable columns.
- Backfill.
- Deploy dual-read/dual-write or tenant-scoped reads.
- Validate.
- Enforce constraints.
- Remove legacy assumptions later.
12.2 Per-phase rollback
- Phase 1: if tenant selection breaks, fallback to default tenant only while keeping memberships intact.
- Phase 2: nullable tenant columns allow rollback to previous code if no destructive constraint changes have landed.
- Phase 3: before NOT NULL/constraint/RLS enforcement, take backup and run staging rehearsal. Rollback may require restoring prior schema or disabling new policies temporarily under controlled maintenance.
- Phase 4: sync changes should be feature-flagged per tenant. Disable tenant sync if credential/config issue occurs.
- Phase 6: production migration requires backup/restore owner and communication plan.
12.3 Cross-tenant exposure incident runbook
If a cross-tenant leak is suspected:
- Disable affected endpoints or tenant access.
- Preserve logs and audit events.
- Identify tenants/users/records affected.
- Rotate impacted integration credentials if exposure includes source data/secrets.
- Notify Mark/Jack and legal/commercial owner.
- Patch and add regression test reproducing the leak.
- Review release gate failure.
13. Security checklist
Before shared multi-tenant go-live:
- ☐ No broad authenticated
USING (true)RLS policies on tenant-owned data. - ☐ No endpoint returns tenant-owned data without tenant context.
- ☐ No service-role query accesses tenant-owned table outside approved wrapper/waiver.
- ☐ No raw integration credentials stored in normal tables.
- ☐ Secrets rotated after any exposed credential incident.
- ☐ Production JWT verification proven on every function.
- ☐ CORS production origins restricted.
- ☐ Tenant membership changes audited.
- ☐ Integration changes audited.
- ☐ Budget/settings/admin writes audited.
- ☐ Logs redact tokens, credentials, and sensitive customer data.
- ☐ Tenant suspension blocks login/API/sync.
- ☐ Data export/delete process is documented per tenant.
14. Suggested agent/subagent team structure
Coordinator: Fred / Jack OS
- Owns orchestration, evidence tracking, decision capture, Mark questions, and final synthesis.
- Model: strong reasoning/coding model for planning and review.
- Permissions: read-only unless Jack approves specific repo/production actions.
Lane 1: FORQA CTO architect
- Owns tenancy ADR, schema strategy, auth/RLS design, migration/release gates.
- Skill: FORQA CTO lane docs / CTO review mode.
- Model: strongest coding/reasoning model.
- Output: ADR + implementation sequencing + risk sign-offs.
Lane 2: Database/RLS implementer
- Owns migrations, tenant IDs, constraints, indexes, RLS tests.
- Model: code-focused model with SQL/Postgres experience.
- Approval gate before repo writes/migrations.
Lane 3: Edge Function/API implementer
- Owns
requireTenantContext, endpoint scoping, capability checks, service-role wrappers. - Model: code-focused TypeScript/Deno model.
- Approval gate before code changes.
Lane 4: Integration/sync implementer
- Owns HubSpot per-tenant config, sync scoping, locks/logs, Sage future schema.
- Model: code-focused model with API/integration experience.
- Approval gate before touching credentials or source integrations.
Lane 5: QA/security reviewer
- Owns two-tenant fixtures, negative tests, smoke tests, release checklist, regression review.
- Model: testing/security-oriented model.
- Must be independent from implementers for final review.
Lane 6: Release manager
- Owns staging rehearsal, backup/rollback plan, production smoke tests, go/no-go record.
- Human owner required: Mark/Jack or named developer.
- AI can prepare checklist but must not deploy without explicit approval.
15. Approval gates for Jack/Mark
Ask before any of the following:
- Code changes, repo writes, commits, PRs.
- Database migrations against any non-local database.
- Supabase config changes.
- Deployments.
- Credential rotation or secret access.
- Production data access.
- External customer communication.
- Changing live auth or tenant access.
Required human approvals:
- Tenancy strategy approval: shared DB, project-per-tenant bridge, or managed isolated environments.
- Auth provider approval: Entra for all tenants vs Supabase Auth/magic link/OAuth strategy.
- Role visibility approval: all tenant members see all data vs rep/manager/finance segmentation.
- Pilot safety approval: whether to onboard additional customers before shared tenancy is complete.
- Production migration approval: backup window, owner, rollback plan, smoke tests.
16. Mark decision questions
- Is the immediate next beta single-company/design-partner only, or true multi-company SaaS?
- Are any existing beta companies currently co-resident in one Supabase project/database?
- Is Mark comfortable using isolated Supabase projects per pilot as a short-term bridge?
- Should the final architecture be shared database with
tenant_id, project-per-tenant managed service, or hybrid? - Should users be able to belong to multiple tenants?
- How should tenant be selected: subdomain, URL path, org switcher, email/domain, or one tenant per login?
- Is Microsoft Entra the long-term auth provider for all SME customers, or only current/internal usage?
- Should all users inside a customer see all forecast/commercial data?
- If not, what should reps, managers, finance, admins, and viewers each see?
- Who may edit budgets?
- Who may edit stage probabilities/settings?
- Who may run manual syncs?
- Who owns production deployment verification?
- Who owns credential incident response already flagged in previous CTO review?
- Which Sage product/version is in scope: Sage Accounting, Sage 50, Sage 200, or other?
- Is Sage actuals-only, reconciliation support, or write-back?
- What matching key links HubSpot companies/deals to Sage customers/invoices?
- What is the go/no-go date for shared multi-tenant readiness?
- What risk level is acceptable for the next two weeks: safe pilot learning or long-term SaaS foundation work?
- Who is the human release owner for production migration and rollback?
17. Recommended immediate next actions
- Hold a 45-minute Mark tenancy decision meeting. Use section 16 as agenda.
- Write and sign the Tenancy ADR. Do this before code changes.
- Freeze shared co-residency. Do not add another external company to the current shared DB unless isolated or approved as same-dataset design partner.
- Assign owners for prior P0s. Credential incident response, production function deployment proof, JWT smoke tests.
- Create first implementation PR only after approval. Start with tenants/memberships/default tenant and non-breaking helper scaffolding.
Bottom line: FORQA can keep learning with carefully isolated pilots, but shared multi-company SaaS requires tenant isolation as a core engineering project, not a frontend filter or small patch.