think
16px
820px

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: product

Reframes 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 bodymoduleEnabled(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)

  1. Fixed-UUID seed rows — e.g. 00000000-0000-7000-8000-00000000c0f1/c0f2 (seeded org
    workflow template), d0c1d000-…-0001; 14 migrations contain INSERTs. In a pooled DB
    these collide on the primary key for tenant #2. Every seed must become per-tenant.
  2. 10 singleton settings tablesai_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.
  3. Per-install crypto identities with legal/forensic meaning:
    - Internal CA (esign/adapters/x509ca.go) — issued signer certs take Organization from
    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.
  4. Shared sidecars = shared compute / noisy neighbour — gotenberg, embed (3g), extract (4g),
    stego (4g — already OOMs in prod → egress 403 protection.failed), OnlyOffice. Per-install
    today; multi-tenant needs pooling, queueing and per-tenant quotas.
  5. OnlyOffice (office module) — 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.
  6. 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.
  7. Per-install filesystem stateBACKUP_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-level tenant_id sweep 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 (no LISTEN/NOTIFY); only
    vector (pgvector) is required; blob is endpoint-agnostic minio-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 sentinel tenant_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-IdP auth context — not as a replacement (positions/RBAC depend on directory,
    which GoTrue does not model).
  • Sequence cheap→expensive: ship silo + on-prem first (near-zero code, monetizable now),
    build freemium last (it carries the tenant_id sweep + 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.Principal gains a tenant; threaded into every use-case.
  • rbac.ScopeSQL — the app already injects per-request SQL scoping fragments; add
    AND tenant_id = $t there. Natural seam, but per-context.
  • Landmines (regulatory-grade, must be exact):
  • Gapless correspondence numberingMAX(seq)+1 under 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 missed WHERE. 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 (subscription context is notification-follows, not billing;
    ratelimit is per-user req/min, not per-tenant quota). Add plans + 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_id superset (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

  1. Expected freemium tenant volume (drives shared-DB sizing + when to shard)?
  2. Payment gateway of record (Xendit vs Midtrans vs Doku)?
  3. Does silo want its own metering, or unlimited flat-rate?
  4. Wallet currency + PPN handling — gross-up at top-up or at consumption?
  5. Who is merchant-of-record for e-Meterai resale (you vs. the tenant)?