← all docs · second-client3 September 2026

Second client instance — design spec

Status: Planned — reviewed; awaiting kwiss's decisions in §7
Author: Fable (PLAN second client instance)
Date: 2026-09-03 (revised 2026-09-04 and 2026-09-07 after two rounds of dual adversarial review)
Repo: north-os — packages/db, apps/web, deploy/preprod, apps/connectors-api, packages/agent-runtime
Related: plans/2026-09-03-second-client-instance · specs/2026-07-29-zero-downtime-deploy-design · specs/2026-08-27-netdocs-firmwide-connection-design · specs/2026-08-26-onedrive-absent-first-class · packages/db/MIGRATIONS.md

The ask. North signed its second client. The firm arrives in about six weeks and uses OneDrive; their instance must be usable around 2026-09-20 so an admin can be invited, onboard, connect the firm's account and start ingesting their own corpus, with the weeks to 2026-10-15 as deliberate bug-finding slack. Two questions have to be answered before anything is built: is a second client a second organization or a second deployment, and is self-service account creation a configured posture rather than an accident of the current code.

Provenance. Code references are the worktree at 1e0e5002 (origin/main has since moved on). Live figures were read on 2026-09-03 and 2026-09-04 from preprod's database through OWNER_DATABASE_URL, read-only, SELECT only, no writes and no restarts. Host figures come from systemctl, docker stats, free, df and du on this box. Anything not measured is labelled unverified.

Revision note. This document went through two rounds of independent adversarial review with no context on it: 2026-09-04 (fresh Opus + omp gpt-5.6-sol) and 2026-09-07 on the rewritten version (fresh Fable 5.1 + omp GPT-6-Astra). Round one overturned three claims and found six missed failure modes, including one that blocks the client's onboarding outright. Round two found that the round-one corrections themselves were incomplete in one place that matters (the runtime login's forma membership) and overstated in three others. Every finding was re-verified against the code and the live database before it was written in. §8 records exactly what changed and what was wrong, so neither version's errors are quietly absorbed.

1. Recommendation

One organization per deployment, and that deployment on its own host with its own Postgres cluster, its own Redis service, its own R2 bucket, its own KMS key and its own sending domain. Not a second organization inside heynorth.dev, and — this is the part the first version got wrong — not merely a second database inside the existing Postgres cluster either.

The reason is not that the code cannot do many organizations. It demonstrably can, and that fact is what makes the recommendation safe to hold rather than a panic. The reason is that four content stores in this product are not covered by the organization RLS chain at all (§1.3), and a separate cluster and a separate Redis are the only things that close them by construction rather than by a promise about caller discipline.

1.1 The case for many organizations per deployment

Multi-tenancy is modelled end to end, not bolted on:

1.2 Why those 51 rows are not the reassurance they look like

Exactly one of the 51 carries a corpus. Measured per organization on 2026-09-03:

So the multi-organization identity plane (signup, invitation, membership, auto-join ambiguity) is genuinely exercised; the multi-organization corpus plane — mail, documents, knowledge, connectors, ingestion, retrieval, Metabase — has never had two real occupants at once. That is a materially different claim from "multi-tenancy works in production", and it is the honest one.

One deployment is also one operational blast radius: one migration, one Redis, one worker fleet, one deploy, one release record. HSE's next migration would be the new client's outage.

1.3 The four content stores the organization RLS chain does not cover

This is the argument, and the first version of this spec did not make it. Each of these is verified.

(a) The LangGraph checkpointer — no organization id, no RLS

packages/agent-runtime/src/checkpointer.ts:12-19 says so itself, in a warning comment: the PostgresSaver tables are keyed by thread_id alone, with no organization_id column and no independent org isolation. Live: checkpoints, checkpoint_writes and checkpoint_blobs all have relrowsecurity = false, no organization column, and app_role holds SELECT on them. They contain full agent execution state — message history, tool results, retrieved document text (preprod runs CHECKPOINT_RAW_CHAR_CEILING=2000000).

Firm-scoping here depends entirely on every caller resolving the thread through an org-scoped helper first. The comment names that helper as apps/web/lib/chat/threads.ts, a file that no longer exists (it is now packages/chat-runtime/src/threads.ts). And apps/worker-core/src/clicky-run-loop.ts:153-191,243-272 loads graph.getState({ thread_id: job.threadId }) from an organization-scoped job without independently proving the thread belongs to that organization. No exploitable ordinary-user path was found by either reviewer or by me — the point is that one upstream integrity mistake becomes cross-firm content access with nothing beneath it to catch the fall.

(b) The single login can assume a BYPASSRLS role

One Postgres login, north_app, serves every service; the role is selected per-DSN by the options=-c role=… directive (DATABASE_URL, APP_DATABASE_URL, WORKER_SYNC_DATABASE_URL, CONNECTORS_API_DATABASE_URL, WORKER_SCHEDULER_DATABASE_URL all use it). Provisioning grants that one login membership in app_role, sync_role, scheduler_role and forma (packages/db/scripts/provision-roles.ts:1258-1282), and scheduler_role is BYPASSRLS by design (:462).

Verified live, read-only: from an ordinary session, SET ROLE scheduler_role then SELECT count(*) FROM chat_thread returns 263 rows across 2 organizations — on a table that is RLS-enabled and FORCEd. FORCE is irrelevant to this path.

And scheduler_role is not the only one. ASSUMABLE_ROLES in provision-roles.ts:1250-1255 is app_role, sync_role, scheduler_role, forma. forma is the table-owning role: on preprod it owns working_profile_answer (verified: tableowner = forma, not FORCEd by design, SET ROLE forma from the runtime login reads it cross-org — 0 rows today, the path exists). On preprod the other 110 tables happen to be owned by the superuser app because migrations run on OWNER_DATABASE_URL (deploy/preprod/lib/migrate-lock-bounded.sh:13-31) — an accident of the pg_dump seed. A clean bootstrap that runs migrations as forma, which is what CLAUDE.md calls the owner role, makes forma own every table, and north_app ∈ forma then gives owner bypass on all 29 non-FORCEd tables. That is exactly the first version's C-2 argument, and it comes back in full on the new instance unless the bootstrap pins which login migrates and which roles the runtime login may assume. Splitting out the scheduler alone (the 09-07 decision) is necessary and not sufficient.

Postgres roles and memberships are cluster-global, so a second database in the same cluster inherits exactly the same capability. Concretely: the preprod cluster holds 45 databasespreprod beside app_loop_meetings_phase1_*, tips_loop_test_*, app_restore_20260624_*, north_billing_test, meeting_leak_test, ci_netdocs_mcp_fix_* and the rest — all with default PUBLIC CONNECT, all owned by the same superuser, with ci_app and nos200_ci_app logins alongside north_app. That is the shared dev sandbox a paying client's database would sit in.

(c) The owner DSN is a superuser

OWNER_DATABASE_URL logs in as app, which the compose file creates as the Postgres superuser (docker-compose.yml:12-15, deploy/preprod/install.sh:73). Verified live: rolsuper = true, rolbypassrls = true. It reads every firm's content on every table, forced or not. This is the DSN operators, backfills and seeding scripts reach for casually — including, unavoidably, the read-only queries behind this document. It is a break-glass credential and nothing in the repo says so.

(d) Redis Pub/Sub ignores the logical database

Two literal, global channel names carry organization, user, thread and job identifiers: clicky.events (apps/clicky-gateway/src/events.ts:20-32) and northos:thread-activity (packages/chat-runtime/src/thread-activity.ts:28). Redis Pub/Sub is not scoped by the logical database number, so redis://…/1 versus /2 isolates BullMQ keys and nothing else. Current listeners apply downstream audience filters, so this is verified cross-instance delivery and metadata exposure, not verified content disclosure — but APP_BULLMQ_PREFIX plus a logical db is not an isolation boundary, and identifier collisions after any clone or restore would make the filtering assumption unsafe.

Together: (a) and (c) are unaffected by any amount of RLS work; (b) is unaffected by FORCE and unaffected by a second database in the same cluster; (d) is unaffected by a second logical Redis db. A separate host with its own cluster and its own Redis closes all four by construction. (Round two is right that "only" is too strong — separate Postgres and Redis services co-resident on this box would also close (b) and (d); a separate host additionally removes host-compromise and resource-contention exposure, and it is the cheaper build. The recommendation stands; the exclusivity claim is withdrawn.)

Round two also made the plainer argument this spec should have led with: heynorth.dev is a preprod-grade instance — dev cipher (ALLOW_INSECURE_DEV_CIPHER=true), R2_BUCKET=north-os-dev, a dev-seeded database, the development env copied wholesale. Putting a paying firm's OAuth tokens and documents inside that is the argument; the RLS theory is secondary, and on inspection is mostly an operator-path story (§3).

1.4 What the recommendation costs

The deploy machinery is single-instance by construction, and the first version of this spec priced that as "lift the literals into a config file". That is wrong, and the reason is worth stating precisely:

The cheap path is therefore not generalization. On a dedicated host, instance 2 keeps the existing internal names, paths and ports — there is no collision to resolve — and what gets written is a small production bootstrap (empty database, migrations run by a named owner login, runtime login limited to app_role + sync_role, dedicated scheduler and owner-sweep logins, an explicit allowlist of secrets, real KMS, own bucket with a bucket-scoped token, own Redis, own public URLs) plus the bounded deploy-path decision above. Note this is the first NODE_ENV=production + Scaleway KMS boot of this stack anywhere: packages/secrets/src/runtime-cipher.ts:111-114 throws in production without a KMS config, the Scaleway cipher has unit tests only, and no deploy script or env on this box references SCW_KMS_* (the paused AWS production used AWS KMS). Generalizing co-resident deployments is a refactor for after 10-15.

What it forecloses: nothing about SaaS. Self-service posture is orthogonal to instance topology (§4).

1.5 The fallback, priced honestly

If the work slips, a second organization inside the existing heynorth.dev deployment still works at the application layer once C-17 is fixed. The first version claimed "the ingestion date is therefore never at risk; only the isolation posture is". That claim was wrong and is withdrawn: C-17 blocks a OneDrive-first firm on both topologies, and the Microsoft consent (C-20) binds on both too. The fallback also requires C-1 (resolver unification), C-6 (Metabase scoping) and C-25 (worker privilege narrowing) before any North account exists in both firms, and it accepts every one of §1.3's four uncovered stores as a two-firm risk. It is not a one-day escape hatch; it is a different, larger piece of work that recovers no time.

2. Conflicts inventory

Severity is "what breaks if a second firm arrives without this being fixed", not general code quality. Model says which topology the conflict applies to: both, shared (only if two firms share one deployment), separate (only when standing up a second deployment).

2.1 On the critical path

C-17 · A OneDrive-first firm cannot finish onboarding — Clio gates the step OneDrive lives on · BLOCKER · both
Signup creates the organization at onboarding_step="invitations" (apps/web/lib/signup-route.ts:230-247). The order is fixed: invitations → matters → vault → apps (apps/web/app/onboarding/matters/page.tsx:40-51). On the matters step, Continue is enabled only when Clio is authorized and a matter is tracked or sync_mode="everything", and Skip is hidden — const inPhaseB = clio !== null && clio.status === "authorized", canContinue = inPhaseB && (trackedCount > 0 || clio.syncMode === "everything"), <StepActions … hideSkip /> (:94-103,171). The vault step, where OneDrive is connected, redirects away unless the organization has already reached onboarding_step="vault" (apps/web/app/onboarding/vault/page.tsx:55-63). If the second firm does not use Clio, their admin never reaches OneDrive, and invitations to their colleagues are deferred behind the same wall. Neither the first version of this spec nor its plan mentioned this.
Round two, sized: the wall is client-side only. POST /api/onboarding/advance accepts any step with no Clio check (signup-route.ts:122-127apps/web/lib/onboarding.ts:16-47), Skip is already implemented in components/onboarding/step-actions.tsx:107-115 and only suppressed by the hideSkip prop at matters/page.tsx:171, and the settings half (ManageTrackedMattersSheet, SyncModeControls) already exists under (settings)/connections/clio. So the fix is small — but the condition is not "no Clio connector": a brand-new Clio firm also has no connector at that moment, and skipping on that would change HSE-shaped onboarding. The skip must be an explicit, persisted choice ("we don't use a practice-management system"), with Back/forward navigation (onboarding.ts:6-12,25-35,64-74 are fixed-sequence) and the later settings return path specified. Decision §7.4 is amended accordingly.
C-20 · Microsoft tenant consent, not just a redirect URI · BLOCKER · separate
OneDrive requests Files.ReadWrite.All and Sites.ReadWrite.All and the code states the flow relies on those scopes already being administrator-consented (packages/connector-onedrive/src/auth.ts:27-58); the authorize endpoint is the multi-tenant /organizations one (:101-124). HSE's existing grant says nothing about the second firm's tenant. The first version treated this as "add redirect URIs to the app registration", which also conflated two different mechanisms: OAuth callbacks are Entra redirect URIs derived from BETTER_AUTH_URL (apps/web/lib/connections/oauth.ts:72-75), whereas Graph webhook URLs are supplied per POST /subscriptions request (packages/connector-onedrive/src/subscriptions.ts:169-190) and are never registered in Entra at all. Client-tenant readiness — named Entra administrator, exact delegated scopes, shared-versus-dedicated app registration, consent granted, one authorization actually completed — is an external dependency with an unbounded lead time controlled by the client's IT, and it belongs at the very front of the plan. Round two: the consent request must carry all the scopes in one round trip — Outlook's Mail.ReadWrite, Mail.Send, Files.ReadWrite.All, User.Read (packages/connector-outlook/src/auth.ts:99-105) as well as OneDrive's — because mail is the product's core intake and a second trip through a law firm's IT is the slowest thing in the plan.
C-29 · The connector never checks which Microsoft tenant authorized it · HIGH · both
OneDrive authorizes against the multi-tenant /organizations endpoint (connector-onedrive/src/auth.ts:109-115). Completion verifies the signed North organization in the state, then accepts Microsoft's verified claims and stores tenant_id_m365 without comparing it to any expected tenant for that organization (:142-185; the web callback persists under the organization from state, oauth.ts:268-304). So someone who can administer firm B's North organization and holds a firm-A Microsoft account could connect A's OneDrive as B's feeder, and A's documents would be ingested as perfectly valid B rows — inside B's database, B's bucket, B's KMS. Every isolation test in §3 would pass. Not an unaffiliated-user exploit; a support-operator or wrong-account path. The fix is small: P0 records the client's expected M365 tenant id, authorize and reconnect validate against it, and P4 includes a wrong-tenant rejection. "North accounts inside the client's organization are harmless on a separate deployment" (§7.9) is too broad for exactly this reason.
C-30 · "Firm-wide" describes the connector's audience, not what it can read · HIGH · both
The token is delegated (oauth.ts:283); the connector enumerates the connecting account's /me/drive and the SharePoint libraries that account can reach, skipping 403s silently (packages/connector-onedrive/src/drives.ts:26-100). Admin consent proves nothing about whether the account that clicks Connect can see the firm's actual corpus. A synthetic folder ingesting proves neither corpus completeness nor account permissions. P0 therefore needs from the client a corpus-location inventory and a durable connecting account with access to those locations (and its offboarding story); P4 compares discovered sources against that inventory, including one deliberately inaccessible library. The client's storage layout is unverified and can dominate the date.
C-19 · (corrected) Transactional mail can silently not be sent; the sending domain is not a dependency · MEDIUM · both
The round-one version called heynorth.dev "the first client's domain" and derived a second-sending-domain dependency from it. That was wrong: heynorth.dev is North's product domain (docs/WORKFLOW.md § environments), RESEND_FROM is a deployment-wide setting, and a North-branded sender shared across firms discloses nothing. Instance 2 reuses the established domain with its own credentials; the external dependency is withdrawn. What stands: RESEND_API_KEY is optional (apps/web/lib/env.ts:83) and without it magic-link and invitation delivery silently write a development outbox and return normally (auth.ts:491-524,702-716); APP_DEV_MAIL_OUTBOX_DIR overrides Resend even when a key is set (env.ts:85-90). Both must be refused in production, and the go-live gate is a received email in an Outlook inbox whose link points at instance 2 — not a 200. Also: the newsletter sender is a second baked identity, DEFAULT_NEWSLETTER_FROM = "North <news@heynorth.dev>" (apps/worker-notifications/src/newsletter/mail.ts:18, overridable via RESEND_NEWSLETTER_FROM).
C-7 · The deploy pipeline serves exactly one instance, and its installer is a dev-seeded one · HIGH · separate
See §1.4. The literals are the small part; install.sh's dev-workspace dependency, its pg_dump seed from the development database and its deliberate NODE_ENV=development + insecure cipher are the part that makes "parameterize it" the wrong instruction.
C-24 · (narrowed) OneDrive lifecycle handling is missing, expiry is not observed, and renewal cannot be seen in the planned window · HIGH · both
A OneDrive subscription is created for ~30 days; the renewal loop only selects inside a 7-day horizon (apps/worker-connectors/src/loops/subscription-renewal.ts:53-71), so natural renewal cannot occur during a short verification window. The OneDrive webhook route has no lifecycleEvent/reauthorizationRequired branch (zero hits in onedrive/webhook/route.ts and lib/connections/webhook.ts) — but Outlook's does: apps/web/lib/connections/outlook-lifecycle.ts:33-80 is a full dispatcher with the reauthorizationRequired branch at :62, so the OneDrive fix is wiring, not invention. Live evidence that expiry is not observed: the only OneDrive connector is revoked, and its three connector_subscriptions rows are still active with expires_at = 2026-09-04 23:45, already past. Round two's correction: the first version equated a lapsed subscription with a corpus that silently stops. That overstated it — startScheduledRewalk (apps/worker-connectors/src/index.ts:138-143, loops/scheduled-rewalk.ts:60-70,166-224) re-walks stale active sources hourly against a 6-hour staleness horizon, so a dropped webhook degrades freshness to ~6 h rather than stopping ingestion. It cannot recover revoked provider access, which is what reauthorizationRequired signals. So: wire OneDrive to the Outlook dispatcher, make an expired row leave active, and state the freshness bound (≤ 6 h without webhooks) as the contract P4 asserts.

2.2 Isolation

C-2 · (rewritten — the first version was wrong) FORCE does not close the privileged-connection path · HIGH · both
The first version said: 29 of 87 RLS tables are not FORCEd, the login owns the tables, a table owner bypasses non-forced RLS, so FORCE is the fix. Two of those steps are false. The runtime login north_app owns no tables (all 110 are owned by app) and is NOBYPASSRLS — verified live: 0 of 4,714 clio_matter_index rows visible with no GUC on a non-FORCEd table. What is actually open is §1.3(b) and §1.3(c): SET ROLE scheduler_role from the ordinary runtime login reads every firm on every table including FORCEd ones, and OWNER_DATABASE_URL is a superuser. FORCE fixes neither. Additionally, blanket FORCE would break a deliberate design: working_profile_answer is intentionally not FORCEd because a narrowly granted SECURITY DEFINER reader owned by forma relies on owner bypass (packages/db/src/schema/working_profile.ts:92-101). Round two: the 09-07 decision to split out a scheduler login is necessary, not sufficient — north_app ∈ forma gives the same owner-bypass reach on working_profile_answer today and on every non-FORCEd table on a cleanly bootstrapped instance (§1.3(b)). And provision-roles.ts only ever GRANTs memberships (:1275-1280; the REVOKEs at :1033,1750-1763,1987 are privileges and the monitoring role) — dropping a role from ASSUMABLE_ROLES leaves instance 1's existing membership in place, so instance 1 needs an explicit sequence: create the new logins → rewrite the env → deploy → then REVOKE. Decision §7.6 is amended to cover both roles and that sequencing.
C-18 · The checkpointer is outside the RLS chain and its stated guard points at a deleted file · HIGH · both
§1.3(a). Three tables, no organization column, no RLS, holding agent execution state including retrieved document text. The compensating control is caller discipline, and the file it names does not exist any more. Round two corrected the example: clicky-run-loop.ts:169-172 loads the job via deps.getJob(organizationId, jobId) under the payload organization's GUC, so job.threadId comes from an RLS-scoped row and is transitively org-scoped; the reader is fine. The place T7 must guard is the writers of agent_job.thread_id — the gateway dispatch route (apps/clicky-gateway/src/dispatch.ts) — and every other graph entry point.
C-23 · Postgres roles are cluster-global, so a second database is not a boundary · HIGH · separate
install.sh:427-459 creates one cluster-level login north_app; provision-roles.ts:1258-1282 grants it membership in app_role, sync_role, scheduler_role and forma. Those roles are cluster identities. Nothing in the current provisioning does REVOKE CONNECT … FROM PUBLIC or gives per-database role names. A second database on the same cluster shares the whole privilege model with the first. Either take a separate cluster (the simple answer, and it comes free with a separate host), or the plan owes per-instance login and role names, explicit database CONNECT ACLs, and a negative test proving instance B's credentials cannot connect to instance A's database.
C-22 · A second logical Redis db does not isolate Pub/Sub · HIGH · separate
§1.3(d). Give instance 2 its own Redis service; it costs one container and removes an entire class of cross-firm incident. The alternative — namespacing every channel and payload by a boot-validated instance id — is strictly more work and more fragile.
C-3 · Per-table RLS tests are enough; no invariant asserts coverage · HIGH · both
packages/db/test/rls.test.ts tests the helper (GUC set, empty org throws, tx-scoped release) — not coverage. Per-table tests exist (context-digest-rls, matter-ingestion-job-rls, provenance-rls, packages/knowledge/test/retrieval/vector-rls-decoys) but nothing enumerates the schema. A new organization-bearing table ships with no policy and nothing fails. This is how C-4 happened.
C-4 · matter_hidden is filtered in application code only · MEDIUM · both
packages/db/src/schema/matter.ts:320-346 says its predicates live in lib/matters/hidden.ts, "mirroring matter_pin"; live check confirms no RLS. Reads are correctly filtered today (apps/web/lib/matters/hidden.ts:56-58,80-81, apps/web/lib/chat/sidebar.ts:167-168), so this is a missing backstop rather than a live leak. Correction to the first version: it cited matter.ts:274-280 as matter_pin's policy — those lines are matter_access's. matterPin is declared at :282 with no pgPolicy() and no .enableRLS() in the TS schema at all; its RLS is live only because of hand-written SQL (migrations/0019_matter_subsystem.sql:196-200, 0020_matter_pin_user_scope.sql:9). Repo-wide, 25 migration files carry 64 hand-written FORCE ROW LEVEL SECURITY statements. So the TS schema is not a complete source of truth for RLS state — which is a live hazard for any db:generate run, and the reason the C-4 fix needs its generated migration read line by line.
C-11 · Object storage is one bucket, and preprod uses the dev bucket · MEDIUM shared / HIGH as a client-facing posture · both
Preprod runs R2_BUCKET=north-os-dev; packages/storage/src/r2.ts:73-74 reads one bucket from env and exposes getObject(key) / listObjects with no organization scoping, and callers authorize a database row then fetch row.r2Key raw (apps/web/lib/documents/store.ts:145-204) — so a miswritten row pointing at another firm's key defeats row-level authorization. There are three different key namespaces (§1.1), not one org_<id>/ prefix. A paying client's privileged documents must not sit in a bucket named north-os-dev; instance 2 needs its own bucket and its own credentials.
C-1 · Organization resolution is duplicated and half the copies ignore the active organization · HIGH · shared
apps/web/lib/session-resolver.ts:243-260 enumerates the unconverted surfaces itself: root page, sidebar, deals, matters, meetings, settings/members, the onboarding pages and their actions, several API routes. For a user holding two memberships that picks an arbitrary firm, and the comment names the consequence: a per-user Microsoft refresh token, and every mail row ingested from it, filed under the wrong firm. Zero users hold two memberships today (measured) — and putting a North team member into both firms for support is exactly what arms it.
C-25 · Privileged handles are handed to every worker · MEDIUM · both
packages/worker-chassis/src/context.ts:62-115,137-204 gives every worker context the owner, app, sync and scheduler (BYPASSRLS) pools plus the credential cipher, including workers that process user-originated jobs. OWNER_DATABASE_URL is required by the chassis (config.ts:41) and falls back to DATABASE_URL when unset (:99-106); the owner pool is the retention-sweep DELETE handle (context.ts:83-86,149). So "no service unit references the owner DSN" cannot be implemented by deleting a variable — it needs a non-superuser owner-role login for the sweeps, distinct from north_app, and the production fallback refused. Also connector_subscription_by_provider_id is a SECURITY DEFINER function that explicitly disables row security and returns organization, connector and encrypted client-state coordinates from a provider id (packages/db/src/migrations/0013_unify_subscriptions.sql:193-245). Neither is a live leak; both are amplifiers that turn one mistake into cross-firm access.

2.3 Configuration, operations and analytics

C-21 · There are no database backups — for either client · HIGH · both
systemctl list-timers on this box shows only north-os-preprod-webprobe.timer and web-drain@green.timer; no backup unit, no cron entry, and the only pg_dump uses in the repo are install.sh's dev seed and refresh-db.sh. The first version budgeted this as one clause inside a three-day phase ("backups configured with one tested restore"). It is a subsystem that does not exist, with no destination, no encryption or key handling, no RPO/RTO, no database/R2 consistency rule and no restore procedure — and it is the most alarming thing found about instance 1.
C-28 · The feeder gate admits knowledge tools on OAuth, not on a corpus · MEDIUM · both
Non-negotiable #6 says firm knowledge is a continuously ingested corpus or it is nothing. The implemented gate checks only for an org-scoped OneDrive connector at status="authorized" (packages/agent-runtime/src/tools/onedrive-capability.ts:53-74) and then admits the whole knowledge family (:117-190). Source selection creates status="pending" rows (apps/web/lib/connections/ingestions.ts:71-123); starting ingestion is a separate operation (:153-175); the onboarding vault can continue without it (apps/web/app/onboarding/vault/page.tsx:167-195). So the tools can be advertised over an empty corpus. This is not a regression the second client introduces — it is a contract the second client will be the first to actually exercise from zero, and the acceptance gate must be corpus health, not OAuth status.
C-26 · The merge gate and the eval corpus are single-firm · MEDIUM · both
package.json:31 makes eval:gate:cheap a merge gate; packages/knowledge/scripts/eval-retrieval.ts:69 has const DEFAULT_ORG = "3faddc1b-…". Retrieval and routing changes are validated only against HSE's corpus shape (NetDocuments, NY real estate) while firm B is OneDrive with a different shape, and no gate can see a regression that affects only them. The first version dismissed the hardcoded ids as "dev tooling, worth a follow-up but not a phase" — fair for scripts, not for the gate named in CLAUDE.md. (Count corrected: 66 occurrences of the HSE org id, not ~50.)
C-27 · Shared provider accounts, no per-organization quota · MEDIUM · both
ANTHROPIC_API_KEY and VOYAGE_API_KEY are per-deployment variables but will almost certainly be the same upstream account. Firm B's first full ingestion — HSE's comparable figure is 22,275 sources / 218,465 chunks — competes for one account-level rate limit with firm A's live agent, and lands on one bill with no per-firm attribution. Round two narrowed the claim: per-organization accounting does exist for context digests — an organization/day spend reservation that refuses when exhausted, with cost settlement (packages/agent-runtime/src/context/digest.ts:1352-1397,1110-1114). What is missing is ingestion-wide admission control and any allocation of shared provider capacity to firm B's first crawl (apps/clicky-proxy/src/main.ts:51-70 is a proxy-level bucket, not that). §6 measures RAM and disk; the binding constraint on ingestion day is upstream of the box, and it has to be confirmed before ingestion, not in a phase that runs until October.
C-5 · Three deployment-wide variables are pinned to HSE's organization id · MEDIUM · shared
Preprod's env sets SIMULATOR_ORGANIZATION_ID, APP_MCP_OAUTH_SYNTHETIC_ORG_ID and NETDOCS_MCP_PILOT_ORG_IDS to 3faddc1b-…. A second firm would be silently outside the NetDocs pilot (correct today — they are OneDrive — but silent), and if APP_MCP_OAUTH_ALLOW_REAL_ORGS ever returns to 0 only HSE could complete MCP OAuth (apps/web/lib/oauth-org-gate.ts:19, apps/mcp-server/src/oauth-auth.ts:171-185). Per-deployment values, so a separate deployment fixes this by construction.
C-6 · Analytics is a single cross-organization view · MEDIUM · shared
packages/db/scripts/provision-roles.ts:1094-1110; reporting_role is a BYPASSRLS login with an explicit column allowlist on metadata tables. Content tables are not granted, so this is metadata and counts — but it is still two clients in one dashboard.
C-8 · Ports are literal, one block · MEDIUM · separate
install.sh:90-122 (5160–5176); dev owns 5140–5149. Moot on a dedicated host, which is one more reason to take one.
C-9 · Desktop clients bake the first instance's hostnames · HIGH for Polaris on instance 2 · separate
apps/polaris/leanring-buddy/AppBundleConfiguration.swift:77,108,128 compiles in heynorth.dev, live.heynorth.dev, live-proxy.heynorth.dev; apps/polaris-win/README.md:98-99 the same in NorthConfig.cs. Overridable only per-machine, and the Windows updater feed is a single path. Polaris is out of scope for the client's first weeks unless the host becomes first-class configuration or a per-instance build — and that has to be said to the client rather than discovered.
C-10 · Connector redirect and webhook URLs derive from the instance base URL · MEDIUM · separate
apps/web/lib/connections/oauth.ts:74 builds ${BETTER_AUTH_URL}/api/connectors/onedrive/callback; install.sh:46 already treats the Clio and Graph notification/lifecycle URLs as per-instance env. The app side is parameterized; the external registrations are not. Superseded on the critical path by C-20, which is the larger half of the same problem.
C-15 · One release record is one changelog baseline · MEDIUM · separate
deploy/preprod/deploy.sh:30-54 feeding scripts/changelog-gate.sh. Two instances deploying the same main at different shas need two baselines, or the gate computes its range against the wrong last success.
C-16 · One bare repo and one deploy remote · MEDIUM · separate
deploy.sh:31, post-receive. git push deploy main has one meaning; a deploy that does not name its instance must refuse rather than default, and the deploy-preprod skill and the deploy-bot flow both have to learn which instance they address.
C-14 · Monitoring defaults name the first instance · LOW · separate
deploy/monitoring/render-config.sh:63,86-94,147. All : "${VAR:=default}"-style, so configuration work — but instance 2 needs its own targets, dashboards and alert routes, on the single Better Stack path.
C-31 · Third-party SaaS accounts are cross-firm by construction · MEDIUM · both
Sentry: one SENTRY_DSN, initSentry for the whole fleet (worker-chassis/src/context.ts:144) — one project receives both firms' error payloads. Resend: one account stores every sent body for both firms. R2: "own bucket" isolates nothing unless the API token is bucket-scoped. Each is one line in P0's "own secrets" list, and none was there.
C-32 · newsletter_* tables are deployment-global · LOW · shared
packages/db/src/schema/newsletter.ts has no organization reference; live newsletter_issue, newsletter_send, newsletter_opt_out carry no org column and no RLS. Harmless on a separate deployment.
C-12 · The public signup routes have no rate limiting · LOW / MEDIUM · both
apps/web/lib/signup-route.ts:31,47,58 — three unauthenticated POSTs; no rateLimit anywhere in apps/web. Codes carry ~59 bits (scripts/gen-signup-code.ts), so guessing is not the threat; unbounded DB round-trips on a scanner-probed public subdomain are. A hard prerequisite before open is ever switched on.
C-13 · A claimed email domain is not globally unique · LOW · shared
organization.domain carries a plain index; organization_domain is unique only on (organization_id, domain). Round two narrowed this: merely claiming A's domain is not enough — a candidate organization only counts if it has an owner/admin with a verified email in that domain, is active, onboarding-complete and auto-join-enabled (domain-autojoin.ts:50-74). The exactly-one check at :76-78 is correctly fail-closed. Uniqueness remains a design question for open; it is not a demonstrated denial path today.

2.4 Checked and not conflicts

3. The isolation argument, stated so it can be tested

Claim. A member of firm B cannot read a row or an object belonging to firm A. Not "should not" — cannot, by a chain where every link is independently observable. The first version stated six links; three of them were incomplete and one whole store was missing. The corrected chain:

  1. Every request resolves exactly one organization id, or refuses. Ambiguity is never resolved by picking.
  2. Every organization-scoped read runs inside withRlsTransaction with that id, never a bare BEGIN (packages/db/src/rls.ts:26-48).
  3. Every table reachable from an organization — by column or by foreign-key path — has RLS enabled and at least one policy, and every permissive policy on it is organization-canonical. Exceptions are a named, reasoned allowlist, not silence.
  4. The connecting role is NOBYPASSRLS, owns no tables, is pinned at session start, and is boot-probed.
  5. No role reachable from a runtime credential can bypass RLS or own a table. (New. Today this link is false twice: the single login can SET ROLE scheduler_role, and SET ROLE forma.) And, stated so nobody over-reads it: RLS protects against an omitted predicate, not against arbitrary SQL run with the application credential — withRlsTransaction sets whatever organization string it is handed (rls.ts:25-47). Request and job authorization are a separate link, not this one.
  6. Every store outside the RLS chain has a written, tested compensating control. (New: the checkpointer, and R2.)
  7. Every object-storage key is built from the resolved organization id, parsed back on read, and the embedded id checked.

Links 2 and 4 hold today. Link 1 holds in the resolver and fails in its unconverted callers (C-1). Links 3, 5, 6 and 7 are the gaps.

Guard tests

T1 — schema invariant. Does not exist.
Enumerate every table reachable from an organization: an organization_id column or a foreign-key path to one. The column-only key the first version specified structurally excludes chat_message, whose policy is a subquery through chat_thread (chat_message_org_isolation, verified live), and also organization itself, the three checkpoint tables, the OAuth tables, signup_code and account. Assert RLS enabled and that every permissive policy is organization-canonical — not "≥1 policy is", because PostgreSQL ORs permissive policies, so a canonical SELECT policy beside a USING (true) INSERT policy would pass and leak. Accept a small set of normalized canonical forms, written down: 7 permissive policies on org tables today are legitimately not the exact string (matter, contact, matter_pin, matter_access, matter_party, matter_conflict, attachment_tagging_candidates — five omit the missing-ok flag, two add user isolation), and a literal match would fail on all seven and invite the author to loosen the matcher until it stops biting. Round two tightened this further: the FK-traversal key still cannot discover the checkpoint tables (their DDL has neither an organization column nor a foreign key, provision-roles.ts:1123-1165), so non-FK stores must be registered, not discovered; the assertion is per command — USING for SELECT/UPDATE/DELETE, WITH CHECK for INSERT/UPDATE — because an INSERT policy does not OR into SELECT visibility; and working_profile_answer is not an RLS exception (it has the canonical org policy plus a restrictive owner policy, working_profile.ts:147-159) — its exception is FORCE only. So the deliverable is three lists, not one: the complete store inventory, the RLS-covered subset with its per-command assertions, and the compensating-control subset (member, invitation, organization_domain, matter_hidden until C-4, the three checkpoint tables, the identity/OAuth tables, newsletter_*) each with its written control.
T3 — no-GUC fail-closed. Does not exist (asserted for wiki_page only, in prose, in MIGRATIONS.md:14).
Scoped to the RLS-covered subset only — the compensating-control stores (checkpoints are granted to app_role by design, provision-roles.ts:983-986; identity tables have no RLS by design) would fail it and are tested against their own contract instead. With no app.organization_id set, every covered table returns zero rows as the runtime role.
T4 — privileged reachability. (redefined; as first specified it could not fail)
The first version defined T4 as "a connection as the table owner without the role directive cannot read firm A", and said FORCE would turn it green. Verified: the runtime login owns nothing and already reads nothing, so that test passes today and proves nothing; and the actual owner DSN is a superuser, which no FORCE constrains. T4 must instead assert: a connection made with a runtime credential cannot read firm A's rows by any role it can assume — enumerating pg_auth_members, not a hardcoded list. It fails today twice: SET ROLE scheduler_role (263 chat_thread rows across 2 orgs) and SET ROLE forma (owner bypass on working_profile_answer, and on every non-FORCEd table on a cleanly bootstrapped instance). It goes green only when the runtime login is a member of app_role and sync_role alone, with the scheduler sweeps and the owner-role retention sweeps on their own logins. T4b, reworded: the superuser DSN is break-glass and no service unit references it; service units that need owner-role DELETEs receive a non-superuser login that can assume the owner role, and the chassis's DATABASE_URL fallback for the owner pool is refused in production.
T7 — checkpointer thread authorization. Does not exist. New.
Every entry point that compiles a graph with the checkpointer resolves its thread_id through the org-scoped thread helper before invocation, and every writer of agent_job.thread_id (the gateway dispatch route, apps/clicky-gateway/src/dispatch.ts) proves the thread belongs to the job's organization. Fix the stale file reference in checkpointer.ts:19 in the same change.
T2 — two-firm read. Partly exists, one file per table.
Seed firms A and B; for every enumerated table carrying data, a read under B's GUC returns zero of A's rows. Fold context-digest-rls, matter-ingestion-job-rls, provenance-rls and vector-rls-decoys behind T1's enumeration rather than adding a fifth copy. Lower priority than T1/T3/T4: T1 proves the structural fact, T2 re-derives it at fixture cost.
T6 — storage namespace. (redefined)
The first version required every key to sit under org_<resolvedId>/. There is no such universal prefix — documents, meetings, wiki and avatars use three different shapes (§1.1). T6 is instead a per-namespace key parser: every read, write, list, delete and cleanup parses the organization out of the key and checks it against the resolved id, and a boot-time bucket marker asserts the instance is talking to its own bucket.
T8 — cross-instance negative test. New, and only meaningful on the separate topology.
Instance B's credentials cannot connect to instance A's database, Redis, bucket, KMS key or internal APIs — and, per C-29, a firm-A Microsoft tenant cannot authorize as B's feeder. On a host whose Postgres and Redis bind localhost the first half is a two-line check, not a suite; the tenant half is a real test. This is what makes "separate deployment" a claim rather than a hope; on a shared Postgres cluster it fails by construction (C-23).
T5 — one resolver. Prerequisite for the shared topology only.
One organization-resolution function; a user with two memberships and no active organization is refused on every surface, never given an arbitrary firm.

Every one of these must be shown to fail when the invariant it guards is broken — break it deliberately, watch the test go red, restore, and record it in the PR body. A test that cannot fail is a defect; an isolation test that cannot fail converts an unknown into a false certainty, which is exactly what the first version's T4 would have done.

4. Self-service as a configured posture

4.1 What is true today

Account self-creation is already closed — but closed by three independent implementation details rather than by one named posture:

So the posture is right and the landing is thin: a stranger's experience is a sign-in box, and nothing in the codebase names the posture, which means it can drift by accident — exactly what kwiss asked to remove.

4.2 The design: one setting, three values, default = today

APP_SELF_SERVICE = off | code | open, default code, read server-side only and never NEXT_PUBLIC_.

4.3 Where it is enforced, and why the API cannot be walked around

4.4 Why it is per-deployment and not per-firm

At the moment a stranger creates an account there is no organization yet, so there is no organization row to hold the policy. Self-service posture therefore cannot be an organization column, unlike domain_autojoin_enabled and its siblings. "Possibly named" resolves to: the instance carries its own display name and landing copy (an APP_INSTANCE_LABEL-shaped value used by the off landing page and transactional email), not per-firm self-service policy. If per-firm ever becomes real it must key off the requested email domain, which is the only firm-shaped thing present at that moment — a different, larger design.

4.5 How the SaaS door stays open without being built

The posture is an enum with a third value, the enforcement is one middleware, and no other part of the product needs to know. What open would still require before it could honestly be switched on — deliberately out of scope, listed so the door stays honest rather than merely unlocked: rate limiting on the three public routes (C-12); email ownership proven before an organization is created, not after; a plan/billing gate (org billing credits, PR #26); global uniqueness for a claimed domain (C-13).

4.6 Guard tests

With off: each of the three routes returns 404 and creates no row; GET /signup does not render; an invitation link still works end to end; a magic-link request for an unknown address still creates nothing. With code: an invalid code creates nothing. Note the response shape is { valid: true, email } for an email-bound code (signup-route.ts:40-44), not a bare { valid } as the first version said — a valid code discloses the address it is bound to, which is acceptable (you need the code) but is what the test must assert. Each asserted by mutation.

5. Connectors for a OneDrive-first firm

The re-verification, explicitly: a no-Clio admin completes admission and reaches the vault step; firm-wide OneDrive connect end to end on a fresh organization in the client's own tenant; drive and folder discovery plus the ingest-source picker, including the overlap guard against a lawyer's personal drafts location; sources moved from pending to active and ingestion actually started; first ingestion reaching knowledge_sources/knowledge_chunks with a retrieval that returns the material; knowledge tools visible only once the corpus is real and gone on revoke, on both surfaces; Graph subscription creation, the reauthorizationRequired lifecycle path, and a forced renewal (move the synthetic subscription's expiry inside the 7-day horizon and run one worker tick — you cannot observe a 30-day renewal in a two-day window); and that the NetDocuments firm/personal exclusivity rules do not refuse a legitimate firm-wide OneDrive connection.

6. Cost and capacity — is this box enough?

Measured on this box, 2026-09-03:

A second instance costs roughly +4–5 GB RAM, +15–20 GB of release disk and +6 GB of database for a comparable corpus. On those numbers the box is enough, with room to spare — so capacity is not the argument. The argument for a separate host is §1.3: cluster-global roles, a superuser owner DSN, global Redis Pub/Sub channels and an installer that seeds from the dev database. Those are what a separate host removes for free, and what a second database on this box does not.

Unverified, and not to be guessed at: two concurrent Next.js production builds on this box (never run — do not run them concurrently until measured); CPU and disk pressure during the client's first full ingestion while HSE is live; whether the shared app-postgres container's settings hold with two occupants; and upstream Anthropic/Voyage account rate limits during that ingestion (C-27), which is the constraint most likely to bind and the one this box's numbers say nothing about.

7. Decisions that are kwiss's alone

  1. Second deployment or second organization. Recommendation: second deployment, on its own host and cluster. Note the correction: the fallback no longer protects the ingestion date, because C-17 blocks a OneDrive-first firm on either topology.
  2. This box or a separate server. Decided 2026-09-07: a second dedicated Scaleway Elastic Metal for the client — same image as this box, its own Postgres cluster, Redis, bucket, KMS key. AWS was weighed and set aside: ~600 $/month minimum against ~100–150 €, the AWS production has been paused since 2026-07-21 so it would be a first boot there too, and at two clients its automation buys nothing the P2 bootstrap script does not. Its one real advantage — AWS KMS was exercised in production, the Scaleway cipher has unit tests only — is why P2's KMS rehearsal is mandatory. Revisit cloud when a fourth or fifth firm makes create-instance <firm> worth a command. kwiss's intent, recorded: HSE moves to its own dedicated bare metal later on the same pattern, which retires heynorth.dev's dev-sandbox co-residency (§1.3) for the first client too.
  3. Environment grade. Production-grade (own bucket, real KMS, NODE_ENV=production, backups with a tested restore, an alert path someone answers) or preprod-grade like heynorth.dev? This spec assumes production-grade because the firm is in real use from 10-15. If preprod-grade is acceptable for the first weeks, say so explicitly — it changes the bootstrap and it changes what "usable on 09-20" means.
  4. The onboarding fix's shape (C-17). Decided 2026-09-07, amended after round two: the matters step becomes skippable — but on an explicit, persisted choice ("we don't use a practice-management system"), not on the absence of a Clio connector, which a new Clio firm also has at that moment. Skip already exists behind the hideSkip prop; matters stays reachable later from the existing Clio settings page. A fresh Clio firm still takes today's path, pinned by the e2e test alongside the no-system path.
  5. One Entra app registration or two (C-20). Shared means both firms' OneDrive access is mediated by one application identity and one secret, and a rotation or compromise takes down both — which contradicts "its own secrets". Dedicated means the client's Entra administrator reviews and consents to a new application, with a lead time North does not control. Either is defensible; leaving it undecided is not. Whichever: one consent request carrying the Outlook scopes too, and the client's expected M365 tenant id recorded and enforced (C-29).
  6. C-2, restated. The first version asked "FORCE, or forbid owner-DSN content reads". FORCE is now known to close neither hole and to break working_profile_answer's deliberate design. Decided 2026-09-07, extended the same day after round two ("it must be simple"): instance 2 only, at bootstrap. (i) north_app keeps app_role + sync_role only; (ii) north_scheduler holds scheduler_role for the BYPASSRLS sweeps; (iii) north_owner — a non-superuser login able to assume forma — runs migrations and the retention DELETE sweeps, replacing the superuser in OWNER_DATABASE_URL for services; (iv) the superuser DSN is break-glass and referenced by no unit, and the chassis's DATABASE_URL fallback for the owner pool is refused in production; (v) instance 1 is not touched before 10-15 — its sequenced change (create logins → rewrite env → deploy → REVOKE, because provision-roles.ts has no membership REVOKE) is HSE hygiene and goes to P8. On instance 2 all of this is ~15 lines in a bootstrap script that does not exist yet, and T4 checks pg_auth_members there. FORCE is still not touched. A note on the name: forma is simply this repo's Postgres table-owner role (CLAUDE.md § Database — "forma (owner, migrations only)"), a legacy identifier like FORMA_*, not the old prototype.
  7. Backups (C-21) — and when. Decided 2026-09-07: before admission. They do not exist for HSE either. "Preprod-grade until 10-15" would defer protection until after irreplaceable state exists — checkpoints, identity, memberships, connector grants — none of which re-ingesting OneDrive rebuilds. So: a minimum encrypted pg_dump with one isolated restore proven, plus a KMS encrypt → restart → decrypt proof, in P2 before the client's admin is admitted; the richer automation in P5.
  8. Polaris on instance 2 (C-9). Out of scope for the client's first weeks, per-machine override, or the host becomes first-class configuration?
  9. Do North accounts exist inside the client's organization? On a shared deployment, C-1 must be fixed first — a North account in both firms is precisely what arms the arbitrary-firm resolver. On a separate deployment it is mostly harmless, with one exception now known: a North operator who is an admin of the client's organization and holds an HSE Microsoft account is the C-29 path. Tenant pinning closes it.
  10. The date. All four reviewers across two rounds judged 2026-09-20 unreachable for "admin invited, onboarded, OneDrive connected, ingesting". Round two added that on 09-07 no host exists, no P1/P3 branch exists, §7.1–7.3 are still "recommendation", and the consent request has not gone out. The plan now carries a conjunctive readiness gate on 09-12 (any prerequisite missing moves the date) and makes 09-27 conditional rather than automatic. See the plan's §0.
  11. Deploy path for instance 2. Decided 2026-09-07: a bounded instance-2 script, no blue/green. A firm not yet in real use does not need zero-downtime; a ~50-line deploy.sh (fetch, build, migrate, restart, health) carries instance 2 to 10-15. It touches nothing HSE runs on and does not depend on the #354 cutover. It is still run by the deploy bot, on kwiss's word, through the deploy-preprod skill — never by hand (kwiss, 09-07: deployment is rustic today and goes through his deploy bot; that stays the one path). So the skill takes an instance argument from day one and a deploy that does not name its instance refuses; that is the only part of C-16 that lands now. First install, a second release and one rollback verified before the admin exists. Blue/green for instance 2 is an October topic, with two live instances to test the generalization against.
  12. Naming. Decided 2026-09-07: one subdomain per firm under the product domain — <firm>.northos.com. Consequence for later, not now: transactional mail would leave from @heynorth.dev for an app on northos.com; the sender should eventually move to northos.com for both instances (P8).
  13. Sending domain (C-19, withdrawn). No decision needed — reuse heynorth.dev. Recorded so it is not re-asked.

8. Review record

Two independent adversarial reviews ran on 2026-09-04 with no context on this document: a fresh Opus session and an omp gpt-5.6-sol session at high effort. Every finding below was re-verified by the author against the code or the live database before being written in; nothing was taken on the reviewers' word.

Claims of the first version that were wrong and are now corrected:

Failure modes the first version missed entirely, all verified: C-17 (Clio-gated onboarding — found by the omp reviewer, and the most valuable single finding in either review), C-18 (checkpointer, found by both), C-19 (sending identity, found by both), C-20 (tenant consent, found by both), C-21 (no backups, found by both), C-22 (Redis Pub/Sub), C-23 (cluster-global roles), C-24 (subscription lifecycle and renewal horizon), C-25 (worker privilege breadth and the SECURITY DEFINER lookup), C-26 (single-firm merge gate), C-27 (shared provider accounts), C-28 (feeder gate admits on OAuth, not corpus).

Where the round-one reviewers disagreed with each other: the Opus reviewer proposed forking deploy/preprod/ to deploy/client2/; the omp reviewer showed why copying that installer at all is unsafe (dev-database seed, dev cipher, dev env copy). §1.4 takes the omp position: a small production bootstrap on a dedicated host, reusing the existing internal names because nothing collides there.

8.1 Round two — 2026-09-07, on the rewritten version (Fable 5.1 medium + GPT-6-Astra medium)

Round-one corrections that were themselves wrong or incomplete, now fixed:

Failure modes round one also missed, all verified: C-29 (no M365 tenant binding on authorize — Astra), C-30 (delegated token sees the connecting account's corpus, not the firm's — Astra), C-31 (Sentry, Resend account, non-bucket-scoped R2 token — Fable), C-32 (newsletter_* global — Fable), the instance-2 deploy loop pinning heynorth.dev in production mode (Astra), the first-ever NODE_ENV=production + Scaleway KMS boot (Fable), the Outlook scopes belonging in the same consent request (Fable), the 45-database shared cluster as the concrete co-residency argument (Fable), and the three OneDrive subscriptions sitting active past expiry on a revoked connector (Fable).

Sizing corrections accepted: C-17 is one prop plus a persisted choice and an e2e pin, not a five-day phase (Fable); P3's enumeration tests are HSE hygiene and shared-topology insurance and move off the client's critical path (both); T8 on a localhost-bound host is a two-line check (Fable).

Where round two disagreed with itself: Fable would keep instance 2 preprod-grade and defer backups to October; Astra would require a minimum backup + restore proof and a KMS restart proof before admission because checkpoints and identity state are not rebuildable from OneDrive. §7.7 takes Astra's position as the recommendation and leaves the call to kwiss.