Audited the 21 Draft HIPs against production. Three describe what is running
and become Final:
0111 every canonical path answers on hanzo.id with the right content type,
and the eight verb aliases this HIP forbids now return 410 with a
successor pointer. access_token is 404; token is the only spelling.
The conformance section said the prohibitions were unmet — that was
measured in July, the retirement order landed, and it is now wrong in
the HIP's favour. Also: serverUrl for Hanzo is hanzo.id, not
iam.hanzo.ai, which serves the SPA catch-all on every path and would
walk a client straight into the failure this HIP exists to prevent.
0033 all three tiers answer — 275 first-party images running from GHCR
under three orgs, Docker Hub mirrors, and oci.hanzo.ai returning 401
rather than a 404 from a host with nothing behind it.
0305 the deviation argued for is the one that shipped, and schema.prisma
cites this HIP by number as the reason.
The rest stay Draft, and six of them say something untrue that had to be
written down before anyone can fix it:
0018 there is no native PSP. Every fiat ProcessorType is third-party and
pay.hanzo.ai's default is Square, so payment sovereignty is a mandate
the code does not implement. Port was 4242; it binds 8090.
0027 the ownership question this HIP flagged as owed is answered — both
files in hanzoai/kms now agree it is archived and luxfi/kms is
canonical. The /api/v1 and /api/v3 surface it documents returns 404.
0038 Console is not a Langfuse fork. The NOTICE says no Langfuse source is
used and the first commit is a clean-room build on @hanzo/gui.
0053 the name belongs to something else now. hanzoai/visor is Hanzo
Compute, a multi-cloud compute plane with zero Grafana in it.
0409 Final, and pointing image.repository at an archived repository.
0002 linking the Jin repo, archived in May, framework never built.
Co-authored-by: Hanzo Dev <[email protected]>
6.0 KiB
hip, title, author, type, category, status, created, requires
| hip | title | author | type | category | status | created | requires |
|---|---|---|---|---|---|---|---|
| 0305 | esign: shared-DB tenancy via team-where, not file-per-tenant | Zach Kelling ([email protected]) | Standards Track | Infrastructure | Final | 2026-06-21 | HIP-0302 |
HIP-0305: esign: shared-DB tenancy via team-where, not file-per-tenant
Abstract
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:
- Bootstrap paradox.
validateSessionTokenreads the globalSessiontable 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. - Global identity. A
Userowns and belongs to many organisations (ownedOrganisations[],organisationMember[]).Session,Account,Passkey,ApiTokencarry noorganisationId. Splitting identity by org would shard a single user across files. - Relation-scoped tables. Only 8 of 47 tables carry an
organisationIdcolumn (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. - 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
tenant-isolation.test.ts(6/6 pass) — stands up the real0_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.- Defense in depth (the file is not the only guard):
- The list-field codec (
json-array.ts) throws — fails closed — on any non-array / corruptrolesrather than silently degrading. - 68
BEFORE INSERT/UPDATEenum/domain triggers reconstruct the SQL enum domains SQLite drops, including aUser.rolesshape 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.)
- The list-field codec (
- Full prisma suite: 38/38 pass. Backfill crash-resume is separately proven
(
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.
Conformance status
Measured against hanzoai/esign at 643d0fd3e on 2026-09-09. The deviation this
HIP argues for is the one that shipped, and the schema says so in the datasource
block — it cites this HIP by number as the reason the estate's file-per-tenant
rule does not apply here:
datasource db {
// Single shared SQLite file via Hanzo Base. Tenancy is enforced at the
// query layer via buildTeamWhereQuery keyed on the authenticated user
// (see ./tenant.ts). DATABASE_URL points at one sign.db file shared
// across all orgs. See HIP-0305 for the architectural deviation ...
provider = "sqlite"
url = env("DATABASE_URL")
}
provider = "sqlite" closes the SQL → Base migration the abstract describes.
buildTeamWhereQuery is defined in packages/prisma/tenant.ts and reached from
54 files, including every handler under packages/trpc/zap/server/handlers/
(envelope, team, organisation, webhook, admin) — so the predicate is the
boundary in practice and not only in the proposal. esign serves at
esign.hanzo.ai, which redirects an anonymous caller to /signin.