Obscura SaaS Feasibility & Tiering Design
Status: tenancy core BUILT on branch
feat/saas-tenancy(unmerged) · design doc otherwise
as written · Date: 2026-07-10, updated 2026-07-29 · Owner: productReframes frozen decision ARCHITECTURE §0 (single-tenant, no
tenant_id) — see §3.
This is a planned pivot, made deliberately, not a retrofit of the shipping product.What is actually built vs. still planned: see
CLOUD_STATUS.md.
Everything about billing, KYB and self-serve signup in this document remains DESIGN ONLY.
0. Re-measurement — 2026-07-29 (supersedes the sizing below)
The codebase grew substantially since the first review. Re-measured at 7f11b27:
| Metric | First review | 2026-07-29 | Δ |
|---|---|---|---|
| Migrations | 92 | 149 | +62% |
| Tables | 99 | 133 | +34 |
UNIQUE constraints |
44 | 64 (+17 unique indexes) | +45% |
| Bounded contexts | 31 | 34 (+groups, mcp, previewcache, shortlink, variables) |
|
| Non-test Go | — | 90,314 LOC / 353 files | |
| SQL-bearing repo files | — | 64 files, ~795 queries | = the pooled-scoping surface |
What got EASIER — module entitlements are ~80% built
LICENSING.md + internal/httpapi/licensing.go already implement per-module gating:
requireModule(name) wraps 131 route groups across 7 modules (ai, correspondence,
esign, mcp, office, semantic, watermarking); entitlements are a hot-swappable atomic
snapshot (applyLicense), fail closed, and are exposed via /me.enabled_modules + a
ModuleChecker port the workflow context consumes.
Verified: requireModule(module string) evaluates s.moduleEnabled(module) inside the
request handler, where the request (hence tenant) is in scope. Making entitlements per-tenant
therefore changes one function body — moduleEnabled(module) → moduleEnabled(ctx, module)
backed by a per-tenant entitlement lookup — and all 131 call sites stay byte-identical.
D6 (paid modules) is largely done; what's missing is the per-tenant resolver + billing.
What got HARDER — new pooled-tenancy landmines (all verified)
- Fixed-UUID seed rows — e.g.
00000000-0000-7000-8000-00000000c0f1/c0f2(seeded org
workflow template),d0c1d000-…-0001; 14 migrations containINSERTs. In a pooled DB
these collide on the primary key for tenant #2. Every seed must become per-tenant. - 10 singleton settings tables —
ai_provider_settings,ai_settings,auth_settings,
backup_settings,esign_wa_settings,mcp_settings,protection_settings,
rate_limit_settings,workflow_settings,protection_policy— "one row per install" must
become one row per tenant. Several hold admin-configured secrets (age-encrypted AI key)
→ per-tenant secret storage. - Per-install crypto identities with legal/forensic meaning:
- Internal CA (esign/adapters/x509ca.go) — issued signer certs takeOrganizationfrom
the CA's own Subject, so in SaaS every tenant's signatures chain to YOUR CA and carry YOUR
org name unless you issue per-tenant CAs/intermediates. Trust + legal issue.
-STEGO_MASTER_KEY— per-install stego master secret; forensic watermark attribution
needs per-tenant derivation (also the commitment-channel KEY+ROSTER).
-AUDIT_CHAIN_KEY— one HMAC key across all tenants' chains; tenants can't independently
verify, and integrity is coupled.
- Blob DEK — single envelope key → per-tenant DEK wanted. - Shared sidecars = shared compute / noisy neighbour — gotenberg, embed (3g), extract (4g),
stego (4g — already OOMs in prod → egress403 protection.failed), OnlyOffice. Per-install
today; multi-tenant needs pooling, queueing and per-tenant quotas. - OnlyOffice (
officemodule) — AGPLv3. As the network operator you take on AGPL §13
source-offer duties (satisfiable — unmodified upstream), but Community Edition connection
limits mean multi-tenant editing likely needs a commercial licence → real COGS line. - Global namespaces — shortlink 8-char slugs, share tokens, public
/verify: must resolve
per tenant without leaking cross-tenant existence; collision space shrinks as tenants grow. - Per-install filesystem state —
BACKUP_DIR,OFFICE_FONTS_DIR, and the PERURI on-prem
Sharefolder(a mounted folder the Peruri appliance reads) — a cross-tenant document
exposure surface in a shared install.
The strategic consequence — pick SCHEMA/DB-per-tenant, not row-level tenant_id
Every landmine above is a cost of pooled row-level tenancy, and all of them cost ~zero
under schema- or database-per-tenant: each tenant gets its own copy of every table, so fixed-UUID
seeds, singleton settings rows, and per-tenant keys all work unchanged, and none of the ~795
queries or 64 unique constraints need touching. The codebase has grown in exactly the direction
that penalises pooled tenancy and is neutral for silo.
Revised recommendation: use schema-per-tenant (or DB-per-tenant) for all SaaS tiers,
including freemium. Reserve the row-leveltenant_idsweep for a future scale problem
(thousands of tenants) — it is no longer the default path.
The one real code change it needs: db.Exec(ctx) returns the raw pool when no transaction
is active (platform/db/db.go), so a per-connection search_path is not safe for non-transactional
reads. Options: (a) a per-tenant pgxpool with an LRU cache (simplest, memory-bounded by tenant
count — fine for hundreds), (b) route every tenant-scoped query through the UnitOfWork so
SET LOCAL search_path at Do() is the single chokepoint, or (c) DB-per-tenant with pooled
connections. This is the single highest-leverage design decision in the fork.
Difficulty (rough, one engineer)
| Track | Effort | Risk |
|---|---|---|
| Silo/DB-per-tenant + on-prem on Supabase | 2–4 weeks | low |
| Schema-per-tenant + pool routing + migration fan-out | 3–6 weeks | low-medium |
~~Pooled row-level tenant_id sweep~~ (not recommended) |
2–4 months | high — a single missed WHERE = cross-tenant leak |
Per-tenant entitlement resolver (reuses requireModule) |
~1 week | low |
| Billing/metering/wallet + gateway (net-new) | 1–2 months | medium |
| Control plane + KYB + onboarding | 1–2 months | medium |
1. Summary verdict
A three-tier SaaS (freemium → silo → on-prem) is feasible on one codebase, and this
backend is unusually well-shaped for it:
- Datastore on Supabase = mostly config. The frozen "vanilla Postgres + S3 + SMTP, no
Redis/Kafka" choice makes the store portable. Workers poll (noLISTEN/NOTIFY); only
vector(pgvector) is required; blob is endpoint-agnosticminio-go. Gotchas are bounded
(pooler mode, IPv6, storage S3 endpoint). - The hard part is not the datastore — it's tenancy + metered billing, both net-new.
- Do not maintain two codebases. Make the schema multi-tenant-capable and treat on-prem
single-tenant as the degenerate N=1 case (fixed sentineltenant_id, registration off).
All three tiers = the same binary, different config. No permanent fork. - Keep the custom auth context. Adopt Supabase Auth only as one more OIDC IdP behind the
existing multi-IdPauthcontext — not as a replacement (positions/RBAC depend ondirectory,
which GoTrue does not model). - Sequence cheap→expensive: ship silo + on-prem first (near-zero code, monetizable now),
build freemium last (it carries thetenant_idsweep + billing + abuse controls).
2. Decisions locked in this review
| # | Decision |
|---|---|
| D1 | SaaS targets smaller clients that do not need air-gap/on-prem; residency is moot for that segment. |
| D2 | Build a fork/clone made SaaS-ready — but as a superset (see D3), not a divergent codebase. |
| D3 | Three tiers, one binary: freemium (pooled, shared DB) · silo (dedicated DB per customer) · on-prem (single-tenant, air-gapped). On-prem = N=1 of the pooled model. |
| D4 | Keep the custom auth context; add Supabase Auth as an OIDC provider only. |
| D5 | Freemium exposes the money features (AI, e-sign, PERURI e-Meterai) but prepaid/metered → billing + usage-metering infra required. |
| D6 | All modules are paid — no free modules. Registration is open, but everything is behind a paywall. Entitlements = per-tenant module licensing. |
| D7 | The regulated products (e-Meterai, certified e-Sign, e-Stamp/Segel) are a special module gated behind KYB (business verification) before purchase; certified e-Sign additionally needs per-signer KYC. Native PAdES stays non-gated. KYB makes pooled-resell the default; BYO optional for enterprise. |
| D8 | Billing = recurring subscription per module + metered consumables on top, both auto-debited from one unified prepaid wallet. |
3. One codebase, three tiers (the superset model)
Retire "no tenant_id anywhere." Make the pooled multi-tenant schema the superset;
single-tenant becomes tenancy-mode = single (a fixed sentinel tenant, registration disabled).
| Tier | DB | Storage | tenant_id |
Registration | Auth | Metering |
|---|---|---|---|---|---|---|
| Freemium | shared Supabase PG | shared bucket, tenant-prefixed keys | one per account | open (registration-v2) | Supabase as OIDC IdP | wallet (metered) |
| Silo (paid) | dedicated Supabase project/DB | own bucket | N=1 (or few) | domain-scoped | own IdP / OIDC | unlimited or metered |
| On-prem (enterprise) | self-hosted PG | MinIO | N=1, sentinel | off | existing OIDC/LDAP/SCIM | no-op (unmetered) |
On-prem's isolation story is unharmed: still a physically separate install with exactly one
tenant; the extra column is inert. The lint rules (depguard/go-arch-lint) keep layering
honest through the change.
4. Datastore on Supabase — compatibility
| Component | Today | Supabase | Verdict / gotcha |
|---|---|---|---|
| Relational + vectors | DATABASE_URL → pgx; only ext = vector |
pgvector first-class | ✅ |
| FTS | tsvector/GIN |
built-in | ✅ |
| Queue/outbox | polling ticker, no LISTEN/NOTIFY |
pooler-safe | ✅ (the big one) |
| Advisory locks | pg_advisory_**xact**_lock only |
survives txn-mode pooling | ✅ |
| Transactions | one pgx.Tx across contexts |
full txns in session/direct mode | ✅ |
| Blob | minio-go, configurable endpoint + own envelope crypto |
S3-compatible Storage | ✅ path-style + region |
| Migrations | goose embed.FS on boot | runs as-is | ✅ |
Gotchas: (1) pgx defaults to extended protocol + prepared-statement cache → use Supavisor
session mode (:5432) or direct, not transaction mode (:6543); or set simple query mode.
(2) Direct connections are IPv6-only (IPv4 is paid); session-mode pooler gives IPv4 — mind the
connection ceiling vs. per-replica pool size. (3) Storage needs region label + path-style.
5. Tenancy: the tenant_id superset (freemium's shared DB)
Only the pooled freemium DB needs this; silo/on-prem run N=1. What it touches:
- 99 tables gain
tenant_id; 44 unique constraints/indexes become composite
(tenant_id, …)— the highest-risk item; a miss = cross-tenant collision or leak. kernel.Principalgains a tenant; threaded into every use-case.rbac.ScopeSQL— the app already injects per-request SQL scoping fragments; add
AND tenant_id = $tthere. Natural seam, but per-context.- Landmines (regulatory-grade, must be exact):
- Gapless correspondence numbering —
MAX(seq)+1under advisory lock
"correspondence:scheme:"+code; scheme codes and lock key must be tenant-scoped. - Hash-chained audit — already partitioned; set
partition = "tenant:<id>"and each
tenant gets its own tamper-evident chain nearly for free. - pgvector / FTS / blob keys gain tenant predicates / prefixes.
- Defense-in-depth (recommended for freemium only): Postgres RLS keyed on
SET LOCAL app.tenant_id— cheap insurance against a missedWHERE. Works in session mode.
6. Identity & auth
Keep the auth context. Authorization is position-based (Principal.Positions →
rbac.Can/ScopeSQL), and positions/org-units live in directory — which Supabase Auth does
not model. Ripping auth out would also discard TOTP, sessions, scoped API keys, per-account
lockout, registration-v2, SCIM, LDAP.
Cheap win: the auth context already does OIDC multi-IdP. Register Supabase as an OIDC
provider → freemium users get Supabase-hosted login while positions/RBAC stay intact.
Freemium tenant bootstrap must auto-seed a default org unit + "Admin" position so a solo
signup isn't forced to model an org chart.
7. Metered billing & prepaid wallet (the new subsystem)
7.1 Why the existing design fits
- Atomic metering: everything already runs in one
pgx.Tx(workflow→rbac→audit→outbox).
A wallet debit + usage-event insert joins that same transaction → no double-spend, no lost
debit. - Reserve→execute→reconcile/refund is already proven here: the esign/PERURI path does exactly
this for e-Meterai saldo (timeout-reconcile, refund-tracking, failed-serial ledger). The
metering wallet generalizes that pattern. - Inbound webhooks + rate-limit infra exists in
publicapi/ratelimit→ payment-gateway
callbacks fit. - Hash-chained audit exists → reuse the same integrity model for the financial ledger.
7.2 The port seam (keeps one binary)
Add BillingProvider as a platform port, exactly like BlobStore/ProtectionEngine:
- On-prem / silo-unlimited →
BILLING_PROVIDER=noop(authorize always succeeds, no debit). - Freemium →
BILLING_PROVIDER=wallet(real prepaid ledger).
Money-costing app services (ai, esign, protection/meterai) get a pre-flight
billing.Authorize(ctx, tenant, meter, estimate) and a post-commit Commit(actual).
On-prem behavior is unchanged; zero divergence.
7.3 Flow (prepaid, variable cost)
Authorize(tenant, meter, estimateMax) // check entitlement + balance ≥ estimate; reserve
→ execute action (LLM call / e-sign / e-Meterai stamp)
→ Commit(actual) // debit actual, release reservation; reconcile/refund on failure
- Meters: AI (tokens/calls — external LLM
claude-haiku+ embeddings), e-sign (Mekari
per-signature; native PAdES ~free), e-Meterai (real PERURI saldo — money), storage (per-GB),
optionally seats/doc-count. - Idempotency: usage events carry an idempotency key so worker/outbox retries never
double-debit. - Fail-closed: zero balance mid-flow → graceful denial; global per-tenant money-feature
kill-switch.
7.4 Billing (top-up)
- Prepaid wallet top-up via a local payment gateway — Indonesia: Xendit / Midtrans / Doku
(QRIS, VA, e-wallet, cards); Stripe only covers cards. Gateway webhook → credit wallet
(verify signature; reuse inbound-webhook infra). - Entitlements are net-new (
subscriptioncontext is notification-follows, not billing;
ratelimitis per-user req/min, not per-tenant quota). Addplans+tenant_entitlements:
features on/off, included free allotments, hard caps, overage → wallet. - Financial ledger: append-only, double-entry, reconcilable against PERURI saldo statements +
gateway settlements. Model it like the hash-chained audit for integrity.
7.5 Business/compliance flags (not code)
- Reselling PERURI e-Meterai to freemium tenants makes you a merchant-of-record / reseller
of a government fiscal product → check PERURI reseller terms + PPN (11% VAT) on top-ups and
on meterai resale. Legal/tax review required before exposing e-Meterai to self-serve accounts. - Residency is moot for the SaaS segment (D1); Supabase region = Singapore
ap-southeast-1.
8. Freemium abuse & cost control
Open registration + real-money features is an abuse magnet. Controls:
- Hard per-tenant balance gate (not just
METERAI_QUOTA_WARN) before any paid action. - Per-tenant rate limits (extend
ratelimit) + storage/doc/user caps from entitlements. - Registration-v2 email verification + domain allowlist already blunt disposable-email abuse.
- Top-up fraud controls (card-testing, chargebacks) at the gateway.
- Free tier gets zero paid-feature access until a top-up clears (no free saldo/LLM burn).
9. Phased roadmap
| Phase | Scope | Notes |
|---|---|---|
| P1 — Silo + on-prem | control plane (provision DB, route DSN, run migrations, seed admin); Supabase datastore plumbing (session-mode DSN, storage endpoint) | ~zero schema change; monetizable immediately |
| P2 — Tenancy superset | tenant_id sweep (99 tables / 44 constraints), ScopeSQL scoping, tenant-scoped numbering + audit partitions, optional RLS; on-prem/silo set mode=single |
prerequisite for freemium |
| P3 — Billing & metering | billing context + BillingProvider port, wallet ledger, meters on ai/esign/meterai, entitlements/plans, payment gateway + webhooks |
reuses reserve/reconcile pattern |
| P4 — Freemium GA | open registration → tenant bootstrap + default org seed; Supabase-as-OIDC; abuse controls; legal/tax sign-off on e-Meterai resale |
10. Effort & risk summary
- Low / fast: Supabase datastore plumbing; silo control plane; Supabase-as-OIDC.
- Large / invariant-sensitive:
tenant_idsuperset (unique constraints, gapless numbering). - Net-new subsystem: billing/metering/wallet + payment gateway + entitlements.
- Non-engineering gate: PERURI resale licensing + PPN tax treatment for self-serve e-Meterai.
11. Open questions
- Expected freemium tenant volume (drives shared-DB sizing + when to shard)?
- Payment gateway of record (Xendit vs Midtrans vs Doku)?
- Does silo want its own metering, or unlimited flat-rate?
- Wallet currency + PPN handling — gross-up at top-up or at consumption?
- Who is merchant-of-record for e-Meterai resale (you vs. the tenant)?