hip-0305

HIP-305: esign: shared-DB tenancy via team-where, not file-per-tenant. Status Active. Hanzo's own standard — read this before implementing against it.

HIP-0305: esign: shared-DB tenancy via team-where, not file-per-tenant

Abstract

The canonical database architecture (hanzoai/.githubprofile/ARCHITECTURE-DATABASES.md) mandates one Base SQLite file per (org, user, project) for every internal app — "the file is the tenant boundary. Do not add a tenant column." Decision #7 of that document is explicit that "Deviation needs a HIP, not a Slack thread." This is that HIP.

esign (a Documenso fork) is being migrated SQL → Base SQLite (PR hanzoai/esign#7). Its data shape does not fit file-per-tenant:

  1. Bootstrap paradox. validateSessionToken reads the global Session

table to discover which user is calling — this happens before any org is known. A per-org file cannot be selected at request entry because the org is only learned by first reading a global table.

  1. Global identity. A User owns and belongs to many organisations

(ownedOrganisations[], organisationMember[]). Session, Account, Passkey, ApiToken carry no organisationId. Splitting identity by org would shard a single user across files.

  1. Relation-scoped tables. Only 8 of 47 tables carry an organisationId

column (EmailDomain, OrganisationEmail, OrganisationGroup, OrganisationMember, OrganisationMemberInvite, RateLimit, Subscription, Team). The other 39 scope through relations (Envelope → Team → Organisation) that a per-file split would make un-joinable.

  1. No routing-layer escape hatch. esign has no hostname/subdomain tenant

resolver, so there is no request-time hook at which a file could be chosen the way a multi-tenant edge router would.

Specification

esign uses ONE Base SQLite file shared across orgs. Tenant isolation is enforced in-query at the buildTeamWhereQuery({ teamId, userId }) layer — the single predicate every cross-org read in packages/lib/server-only/ funnels through. A team is reachable only when the authenticated user is a member via teamGroups → organisationGroup → organisationGroupMembers → organisationMember.userId. The predicate is keyed on the server-trusted user id, never on a client-supplied value. The auth boundary is unchanged: tenancy is still the IAM OIDC owner claim; this HIP changes the file count*, not the scope. Encryption-at-rest and S3 replication (HIP-0302) apply to the single file exactly as to a per-tenant one.

Proof

migration, seeds two independent org graphs through the real Prisma client, and proves: owner reaches own team (1 row); org B owner reaches org A's team (0 rows); a forged/non-member userId reaches it (0 rows); the same predicate filters Envelopes so B sees 0 of A's documents.

non-array / corrupt roles rather than silently degrading.

domains SQLite drops, including a User.roles shape guard (json_type != 'array') + a domain guard (every element ∈ Role). A fabricated privilege string, or a non-array masquerading as roles, can never persist. (enum-constraints.test.ts, 14/14 pass.)

(backfill-resume.itest.ts): kill mid-copy, resume, end with every row exactly once, no gaps — for both the integer-PK and composite-PK keyset cases.

Tradeoffs

File-per-tenant gives stronger isolation: an in-process bug (a handler that forgets buildTeamWhereQuery) can leak across orgs in a shared file, whereas a per-tenant file makes cross-org reads physically impossible. We accept this weaker boundary because per-tenant files would have required re-architecting global identity (sessions, accounts, passkeys, the many-orgs-per-user model) — out of scope for, and orthogonal to, a SQL→SQLite storage migration. The codec-throw + trigger layers are the compensating controls that keep a forgotten predicate from silently corrupting or leaking auth-relevant state.

Reversibility

File-per-tenant remains future work iff global identity is also refactored to be org-addressable at request entry (a tenant resolver before validateSessionToken, and a sharding story for User/Session/Account). Until that work is scoped, the shared file + team-where boundary is the settled position for esign. A follow-up HIP supersedes this one if/when global identity is reworked; this HIP is the authority in the interim.