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.
The canonical database architecture (hanzoai/.github → profile/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:
validateSessionToken reads the global Sessiontable 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.
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.
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.
resolver, so there is no request-time hook at which a file could be chosen the way a multi-tenant edge router would.
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.
tenant-isolation.test.ts (6/6 pass) — stands up the real 0_initmigration, 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.
json-array.ts) throws — fails closed — on any non-array / corrupt roles rather than silently degrading.
BEFORE INSERT/UPDATE enum/domain triggers reconstruct the SQL enum 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.
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.
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.