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:
organizationis the tenant by convention (docs/CONVENTIONS.md§ Naming policy), andorganization_idis present on 91 public tables.- 87 of those 91 have
relrowsecurity = truein the live database. 80 policies are literallyorganization_id = current_setting('app.organization_id', true); no RLS-enabled table has zero policies. - The four org-bearing tables without RLS are
member,invitation,organization_domain— the Better Auth identity plane, documented as deliberately outside the firm-scoped RLS path (packages/db/src/schema/auth.ts:135-142) — andmatter_hidden(see C-4). - The runtime login is
north_app:NOSUPERUSER,NOBYPASSRLS, and not the owner of any table (all 110 content tables are owned byapp; one byforma). The role is pinned at session start through the libpqoptions=-c role=…directive rather than a per-querySET ROLE(packages/db/src/index.ts:8-24,38), with a boot probe asserting bothcurrent_userandrolbypassrls = falsein production (apps/web/lib/db.ts:1-31). Verified live: asnorth_appwith no GUC, a non-FORCEd RLS table (clio_matter_index, 4,714 rows) returns 0 rows. - Per-firm policy already lives in real columns, not free text:
domain_autojoin_enabled,chat_surface_enabled,raw_matter_mail_access,onboarding_step,domain(packages/db/src/schema/auth.ts:99-134). - Per-organization allowlists already exist as deployment configuration:
NETDOCS_MCP_PILOT_ORG_IDSis enforced at four independent seams (apps/web/lib/chat/router.ts:69,apps/web/lib/connections/netdocs-mcp-oauth.ts:629,apps/connectors-api/src/env.ts:37,packages/agent-runtime/src/tools/netdocs.ts:839). - Feeder-gated knowledge tools resolve posture per organization and per user at runtime, with no single-firm constant anywhere in the chain (
packages/agent-runtime/src/tools/feeder-coverage.ts:20,45,onedrive-capability.ts:53-74,agents/legal-assistant/prompt.ts:777-789,cowork/briefing.ts:326). - Object-storage keys are organization-prefixed by construction — though in three different shapes, which matters for T6:
org_<id>/thread_<id>/…(packages/agent-runtime/src/tools/document-write-access.ts:441),meetings/<orgId>/…(packages/meetings/src/deps.ts:26-29),wiki/<orgId>/…(packages/wiki/src/service.ts:488-500),org_<id>/avatar|logo/…(apps/web/lib/settings/router.ts:448-449). - Application caches are keyed on the organization, checked and found clean:
packages/chat-runtime/src/skills-context.ts:39,apps/mcp-server/src/access.ts:122,packages/agent-runtime/src/tools/netdocs.ts:1132,knowledge-debug.ts:59. - Domain auto-join already refuses to guess when two firms claim one domain: it selects with
.limit(2)and returns a firm only when exactly one row came back (apps/web/lib/domain-autojoin.ts:73-78). That is code written for plurality, and it fails closed. - 51 organization rows exist in the live preprod database today.
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:
- Hirschen Singer & Epstein — 3 members, 4,582 matters, 22,275 knowledge sources, 218,465 knowledge chunks, 18,106 case documents, 41 connectors.
- Every other organization (50 of them) — 0–2 members and zero of everything else. The largest non-HSE footprint anywhere in the database is a single
chat_threadrow.
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 databases — preprod 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:
deploy/preprod/install.shis not a general installer. It requires the North development workspace and the running devapp-postgrescontainer (:16-26,:420-425); it creates the preprod database bypg_dumping the developmentappdatabase and restoring it (:437-446); it copies the whole development.env.localminus an override list (:44-56,:461-492); and it deliberately setsNODE_ENV=developmentwith the insecure in-memory cipher instead of KMS, with the reasoning written in the file (:58-67). Parameterizing it for a paying client would either import dev/HSE data and shared credentials into their instance, or require semantic surgery — which is not the "byte-for-byte equivalent" change that made the config-file approach look cheap.deploy/preprod/deploy.sh:19hardcodesAPP="north-os-preprod"; that string appears 383 times across 48 files indeploy/,scripts/and.github/(80 files repo-wide). The shell is 6,709 lines underdeploy/preprod/and 8,201 underdeploy/— not the ~3,000 the first version claimed.- A fixed port block 5160–5176 (
install.sh:90-122), one Caddy fragment path (:531,547), one bare repo (deploy.sh:31), one release record (deploy.sh:30) which is also the changelog gate's baseline. - The blue/green machinery moved under this document: #354 "swap blue/green colours without reloading Caddy" merged 2026-09-04 and #362 "post-receive sources lib/bluegreen.sh from the pushed sha" merged 2026-09-06;
origin/mainis 27 commits past this spec's baseline with +3,620/−947 lines underdeploy/,scripts/,.github/. Neither is deployed on instance 1 yet (#354 needs a one-time cutover). Instance 2 bootstrapped frommainwould run deploy machinery instance 1 has never run. - "Reuse the existing names verbatim" does not by itself give instance 2 a working deploy loop: in production mode
deploy/preprod/lib/bluegreen.sh:25-29pinsFRONT_URL=https://heynorth.devandCADDY_SITE_ADDRESS=heynorth.dev(theBLUEGREEN_*overrides at:9-13are honoured only in test mode), anddeploy.sh:173-184probeslive.heynorth.dev/live-proxy.heynorth.dev. Instance 2 needs either those overrides honoured in production or a small, reviewed instance-2 deploy/rollback path — chosen explicitly, not discovered.
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 orsync_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 reachedonboarding_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/advanceaccepts any step with no Clio check (signup-route.ts:122-127→apps/web/lib/onboarding.ts:16-47), Skip is already implemented incomponents/onboarding/step-actions.tsx:107-115and only suppressed by thehideSkipprop atmatters/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-74are 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.AllandSites.ReadWrite.Alland 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/organizationsone (: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 fromBETTER_AUTH_URL(apps/web/lib/connections/oauth.ts:72-75), whereas Graph webhook URLs are supplied perPOST /subscriptionsrequest (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'sMail.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
/organizationsendpoint (connector-onedrive/src/auth.ts:109-115). Completion verifies the signed North organization in the state, then accepts Microsoft's verified claims and storestenant_id_m365without 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/driveand 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.devis North's product domain (docs/WORKFLOW.md§ environments),RESEND_FROMis 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_KEYis 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_DIRoverrides 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 viaRESEND_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, itspg_dumpseed from the development database and its deliberateNODE_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 nolifecycleEvent/reauthorizationRequiredbranch (zero hits inonedrive/webhook/route.tsandlib/connections/webhook.ts) — but Outlook's does:apps/web/lib/connections/outlook-lifecycle.ts:33-80is a full dispatcher with thereauthorizationRequiredbranch at:62, so the OneDrive fix is wiring, not invention. Live evidence that expiry is not observed: the only OneDrive connector isrevoked, and its threeconnector_subscriptionsrows are stillactivewithexpires_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 whatreauthorizationRequiredsignals. So: wire OneDrive to the Outlook dispatcher, make an expired row leaveactive, 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_appowns no tables (all 110 are owned byapp) and isNOBYPASSRLS— verified live: 0 of 4,714clio_matter_indexrows visible with no GUC on a non-FORCEd table. What is actually open is §1.3(b) and §1.3(c):SET ROLE scheduler_rolefrom the ordinary runtime login reads every firm on every table including FORCEd ones, andOWNER_DATABASE_URLis a superuser. FORCE fixes neither. Additionally, blanket FORCE would break a deliberate design:working_profile_answeris intentionally not FORCEd because a narrowly grantedSECURITY DEFINERreader owned byformarelies 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 ∈ formagives the same owner-bypass reach onworking_profile_answertoday and on every non-FORCEd table on a cleanly bootstrapped instance (§1.3(b)). Andprovision-roles.tsonly everGRANTs memberships (:1275-1280; the REVOKEs at:1033,1750-1763,1987are privileges and the monitoring role) — dropping a role fromASSUMABLE_ROLESleaves 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-172loads the job viadeps.getJob(organizationId, jobId)under the payload organization's GUC, sojob.threadIdcomes from an RLS-scoped row and is transitively org-scoped; the reader is fine. The place T7 must guard is the writers ofagent_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-459creates one cluster-level loginnorth_app;provision-roles.ts:1258-1282grants it membership inapp_role,sync_role,scheduler_roleandforma. Those roles are cluster identities. Nothing in the current provisioning doesREVOKE CONNECT … FROM PUBLICor 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.tstests 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_hiddenis filtered in application code only · MEDIUM · both packages/db/src/schema/matter.ts:320-346says its predicates live inlib/matters/hidden.ts, "mirroringmatter_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 citedmatter.ts:274-280asmatter_pin's policy — those lines arematter_access's.matterPinis declared at:282with nopgPolicy()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-writtenFORCE ROW LEVEL SECURITYstatements. So the TS schema is not a complete source of truth for RLS state — which is a live hazard for anydb:generaterun, 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-74reads one bucket from env and exposesgetObject(key)/listObjectswith no organization scoping, and callers authorize a database row then fetchrow.r2Keyraw (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 oneorg_<id>/prefix. A paying client's privileged documents must not sit in a bucket namednorth-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-260enumerates 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-204gives every worker context the owner, app, sync and scheduler (BYPASSRLS) pools plus the credential cipher, including workers that process user-originated jobs.OWNER_DATABASE_URLis required by the chassis (config.ts:41) and falls back toDATABASE_URLwhen 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 fromnorth_app, and the production fallback refused. Alsoconnector_subscription_by_provider_idis aSECURITY DEFINERfunction 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-timerson this box shows onlynorth-os-preprod-webprobe.timerandweb-drain@green.timer; no backup unit, no cron entry, and the onlypg_dumpuses in the repo areinstall.sh's dev seed andrefresh-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 createsstatus="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:31makeseval:gate:cheapa merge gate;packages/knowledge/scripts/eval-retrieval.ts:69hasconst 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_KEYandVOYAGE_API_KEYare 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-70is 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_IDandNETDOCS_MCP_PILOT_ORG_IDSto3faddc1b-…. A second firm would be silently outside the NetDocs pilot (correct today — they are OneDrive — but silent), and ifAPP_MCP_OAUTH_ALLOW_REAL_ORGSever 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_roleis 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,128compiles inheynorth.dev,live.heynorth.dev,live-proxy.heynorth.dev;apps/polaris-win/README.md:98-99the same inNorthConfig.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:74builds${BETTER_AUTH_URL}/api/connectors/onedrive/callback;install.sh:46already 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-54feedingscripts/changelog-gate.sh. Two instances deploying the samemainat 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 mainhas one meaning; a deploy that does not name its instance must refuse rather than default, and thedeploy-preprodskill 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,initSentryfor 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.tshas no organization reference; livenewsletter_issue,newsletter_send,newsletter_opt_outcarry 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; norateLimitanywhere inapps/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 beforeopenis ever switched on.- C-13 · A claimed email domain is not globally unique · LOW · shared
organization.domaincarries a plain index;organization_domainis 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-78is correctly fail-closed. Uniqueness remains a design question foropen; it is not a demonstrated denial path today.
2.4 Checked and not conflicts
- MCP needs no new registration with Anthropic. Dynamic client registration accepts Claude's hosted callback and RFC 8252 loopback only, with no host of ours in the policy (
apps/web/lib/dcr-redirect-policy.ts:1-27), so instance 2's authorization server works the moment its subdomain resolves. - Auto-join ambiguity fails closed (
domain-autojoin.ts:43-78), and the shared session resolver correctly refuses ambiguous multi-membership (session-resolver.ts:199-269) — it is the unconverted callers that are the problem (C-1), not the resolver. - Vector retrieval applies explicit organization predicates on top of RLS; no B-to-A disclosure path was found.
- Public OneDrive/Outlook webhooks verify client state before enqueueing. C-24 is a narrower lifecycle failure, not absent webhook protection.
- Application caches are organization-keyed (§1.1) — the obvious cross-org cache bug is genuinely not there.
NEXT_PUBLIC_APP_URLpointing at the docs host in preprod's env is not a product misconfiguration; it is read only byapps/docs, which preprod does not serve.- The per-organization knowledge-store hook is dead and fails closed silently.
packages/knowledge/src/store/routing.ts:50-69would route an organization to a dedicated knowledge database, but its lookup runs oncontrolDboutsidewithRlsTransactionagainst an RLS-forced table, so with no GUC it returns zero rows and silently falls back toinstance_default;createDedicatedHandle(:32-34) builds a raw pool with no assumed role and no boot probe. Zero callers, zero rows. Recorded here so nobody reaches for it as a cheap isolation lever. - The ~66 hardcoded HSE organization ids are in
scripts/, eval harnesses, probes and tests. They cost a second client nothing at runtime — except through C-26, where one of them is wired into a merge gate.
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:
- Every request resolves exactly one organization id, or refuses. Ambiguity is never resolved by picking.
- Every organization-scoped read runs inside
withRlsTransactionwith that id, never a bareBEGIN(packages/db/src/rls.ts:26-48). - 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.
- The connecting role is NOBYPASSRLS, owns no tables, is pinned at session start, and is boot-probed.
- 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, andSET ROLE forma.) And, stated so nobody over-reads it: RLS protects against an omitted predicate, not against arbitrary SQL run with the application credential —withRlsTransactionsets whatever organization string it is handed (rls.ts:25-47). Request and job authorization are a separate link, not this one. - Every store outside the RLS chain has a written, tested compensating control. (New: the checkpointer, and R2.)
- 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_idcolumn or a foreign-key path to one. The column-only key the first version specified structurally excludeschat_message, whose policy is a subquery throughchat_thread(chat_message_org_isolation, verified live), and alsoorganizationitself, the three checkpoint tables, the OAuth tables,signup_codeandaccount. 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 aUSING (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 —USINGfor SELECT/UPDATE/DELETE,WITH CHECKfor INSERT/UPDATE — because an INSERT policy does not OR into SELECT visibility; andworking_profile_answeris 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_hiddenuntil 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_pageonly, in prose, inMIGRATIONS.md:14). - Scoped to the RLS-covered subset only — the compensating-control stores (checkpoints are granted to
app_roleby 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 noapp.organization_idset, 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(263chat_threadrows across 2 orgs) andSET ROLE forma(owner bypass onworking_profile_answer, and on every non-FORCEd table on a cleanly bootstrapped instance). It goes green only when the runtime login is a member ofapp_roleandsync_rolealone, 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'sDATABASE_URLfallback 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_idthrough the org-scoped thread helper before invocation, and every writer ofagent_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 incheckpointer.ts:19in 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-rlsandvector-rls-decoysbehind 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:
- Better Auth account creation is off on all three plugins:
emailAndPassword.disableSignUp: true(apps/web/lib/auth.ts:385), magic link (:485), email OTP (:556). The comment at:346-384records why: squatting on the unique-email row, and a live session on an address the caller never proved they own. - The only account-creation paths are (a)
POST /api/signup, gated on a single-usesignup_codeconsumed inside the signup transaction under an advisory lock (apps/web/lib/signup-route.ts:58-113,lib/signup-codes.ts:36-60); (b) the invitation flow, self-gated so an unauthenticated first-time invitee can reach it (apps/web/middleware.ts:39-43); (c) domain auto-join, an organization-level flag that fails closed on ambiguity. /signupis a public page (middleware.ts:12-24) whose wizard opens on a code step (apps/web/components/signup/step-code.tsx).- A stranger at
/is redirected to/loginwith the original path preserved (middleware.ts:82-90). An authenticated user with no membership lands on/no-organization, which distinguishes "no firm" from "several firms, none active" (apps/web/app/no-organization/page.tsx:16-31).
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_.
off— a stranger sees a configured landing page: what this is, that access is by invitation, and how to ask, with no enumeration and no account form./signupand the two public signup APIs return 404. Invitations and auto-join are unaffected.code(default, today) — the signup wizard opening on the invitation-code step. Exactly current behaviour; nothing changes for HSE or for instance 2.open— the wizard with no code step. The SaaS door. Not built now: the enum value and its single enforcement seam exist so that opening it is a configuration change plus removing one check.
4.3 Where it is enforced, and why the API cannot be walked around
- One middleware on the signup router —
signupRoute.use("*", …)inapps/web/lib/signup-route.ts— covers/signup,/signup/check-codeand/signup/check-domaintogether, evaluated before any handler runs. Hiding the page is cosmetic and must never be the only gate; a per-handler check is three chances to forget one. disableSignUp: truestays on all three Better Auth plugins in every posture,openincluded. Open self-service still goes through our own route, which pre-creates the user and the organization in one transaction and consumes admission; the squatting and unverified-session reasoning atauth.ts:346-384survives the flag unchanged.- Invitation and auto-join are never gated by this flag. They are admission by an existing firm, not self-creation.
offmust not lock out an invitee — the one regression this design could plausibly introduce, so it gets its own test.
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
- OneDrive is firm-wide by construction. One non-terminal
connectorsrow of typeonedriveper organization, read and created org-scoped (apps/web/lib/connections/oauth.ts:97-170). An admin connects it once for the firm; there is no per-lawyer OneDrive to chase. Personal OneDrive appears only as the agent's write destination (the "North Drafts" deliver route), a separate concern. - But the admin cannot get to it. C-17: the vault step where OneDrive is connected is unreachable until the Clio-gated matters step is passed. This is the first thing to fix.
- And the client's Entra tenant must consent first. C-20:
Files.ReadWrite.AllandSites.ReadWrite.All, administrator-consented, in the client's own tenant. Not a redirect-URI edit. - The redirect URI is derived, not configured —
${BETTER_AUTH_URL}/api/connectors/onedrive/callback(oauth.ts:74), with the signed-statereturnTopinned to a fixed prefix so a caller cannot inject one (oauth.ts:35-60). Graph webhook URLs are a different mechanism entirely: supplied per subscription request (packages/connector-onedrive/src/subscriptions.ts:169-190), and they must be publicly reachable before Microsoft accepts a subscription. - Knowledge tools stay hidden while the feeder is disconnected — non-negotiable #6 — resolved per organization and per user at runtime on both surfaces. Nothing to change for a second firm structurally; but see C-28: the gate's definition of "connected" is OAuth status, not corpus health, and the second client is the first firm to exercise it from zero.
- This is a first run, not a re-verification. HSE moved to NetDocuments and the connector work moved with it (the firm/personal split's exclusivity rules now sit in the shared
createConnectorpath —connectors-api-client.ts:789-879,netdocs-mcp-oauth.ts:1081-1173). Live: there is exactly one OneDrive connector in the database, HSE's,scope = organization,status = revoked. No live firm-wide OneDrive connection exists anywhere on this codebase.
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:
- Preprod fleet: 4.0 GB RSS across 16 units — web blue 581 MB, clicky-gateway 444 MB, mcp-server 356 MB, worker-mail 352 MB, worker-automations 336 MB, worker-memory 332 MB, worker-context 329 MB, worker-ingestion-knowledge 309 MB, connectors-api 146 MB, worker-notifications 145 MB, worker-meetings 138 MB, worker-core 137 MB, worker-connectors 128 MB, worker-clio 117 MB, worker-matter-info 106 MB, clicky-proxy 94 MB. Green is idle.
- Database: 5.85 GB for one firm at 22,275 knowledge sources / 218,465 chunks / 18,106 case documents / 4,582 matters.
- Release root: 15 GB (three retained releases plus a staging tree; individual trees 2.3–5.2 GB).
- Shared Redis: 128 MB used, 2,006 keys on db0 and 3,030 on db1,
maxmemory 0withnoeviction. - Monitoring stack: Metabase 1.41 GB, VictoriaMetrics 620 MB, everything else under 120 MB.
- Host: 125 GB RAM (58 used, 67 available), 24 cores at load 5.34 / 6.04 / 7.87, 635 GB free on
/.
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
- 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.
- 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. - 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. - 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
hideSkipprop; 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. - 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).
- 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_appkeepsapp_role+sync_roleonly; (ii)north_schedulerholdsscheduler_rolefor the BYPASSRLS sweeps; (iii)north_owner— a non-superuser login able to assumeforma— runs migrations and the retention DELETE sweeps, replacing the superuser inOWNER_DATABASE_URLfor services; (iv) the superuser DSN is break-glass and referenced by no unit, and the chassis'sDATABASE_URLfallback 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, becauseprovision-roles.tshas 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 checkspg_auth_membersthere. FORCE is still not touched. A note on the name:formais simply this repo's Postgres table-owner role (CLAUDE.md § Database — "forma(owner, migrations only)"), a legacy identifier likeFORMA_*, not the old prototype. - 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_dumpwith 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. - 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?
- 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.
- 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.
- 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 thedeploy-preprodskill — 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. - 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.devfor an app onnorthos.com; the sender should eventually move tonorthos.comfor both instances (P8). - 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:
- C-2's premise — "the login role owns the tables, so a table owner bypasses non-forced RLS, so FORCE is the fix". The runtime login owns nothing and is NOBYPASSRLS; the owner DSN is a superuser; the real path is
SET ROLE scheduler_role, which FORCE does not touch. Verified live. - T4 as specified would have passed on day one while the hole stayed open — the precise defect this spec's own mutation-discipline rule forbids.
- T1's enumeration key ("every table with an
organization_idcolumn") structurally excludedchat_message, the checkpoint tables and the identity/OAuth tables; and "≥1 canonical policy" is not a sound assertion because permissive policies OR. - T6's universal
org_<id>/prefix does not exist; there are three key namespaces. - C-4 cited
matter.ts:274-280formatter_pin; those lines arematter_access.matterPinhas no RLS in the TS schema at all — its policies live in hand-written SQL, and 64 such statements exist across 25 migrations. - §1.4's "~3,000 lines of deploy shell" (it is 6,709 under
deploy/preprod/), "37 files" (48), "~50 hardcoded org ids" (66). - §1.5's "the ingestion date is never at risk" — withdrawn; C-17 blocks both topologies.
- §4.6's "bare
{ valid }shape" — the route returns{ valid: true, email }for an email-bound code.
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:
- C-2 / §7.6: "split out the scheduler login and T4 goes green" — false.
north_app ∈ formagives owner bypass onworking_profile_answertoday and on every non-FORCEd table on a cleanly bootstrapped instance (both reviewers, verified live:tableowner = forma, unforced,SET ROLE formasucceeds from the runtime login). And "the runtime login owns no tables" is an accident of preprod's superuser-run migrations, not a property of the schema. - T4b "no service unit references
OWNER_DATABASE_URL" — unimplementable as written; every worker requires it and falls back toDATABASE_URL(both reviewers). - C-19:
heynorth.devis North's domain, not HSE's; the second sending domain was an invented dependency (Astra). Withdrawn. - C-24 overstated "corpus silently stops": the scheduled rewalk bounds it to ~6 h; and Outlook's lifecycle dispatcher already exists (Fable, Astra).
- C-18's example (
clicky-run-loop.ts) loads the job under RLS, so the thread is transitively scoped; T7 re-targeted at the writers (Fable). - C-13 (domain squatting) and C-27 ("no per-org quota anywhere") overstated (Astra).
- T1/T3: FK traversal cannot find the checkpoint tables; T3 as written would fail on the very exceptions T1 lists;
working_profile_answerhas org RLS — its exception is FORCE only; an INSERT policy does not OR into SELECT (Astra). - §1.3's "nothing else does" — separate services co-resident would also do; exclusivity withdrawn, recommendation kept (Astra).
- Blue/green state was stale the day it was written: #354 merged 09-04, #362 09-06 (Fable).
- Count: 65 HSE org-id occurrences outside
docs/, not 66 (Fable).
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.