think
16px
820px

Obscura SaaS Fork — Build Plan

Companion to SAAS_FEASIBILITY.md · Date: 2026-07-10, updated
2026-07-29 · Status: §1 superseded by what shipped; §2–§6 still plan

Purpose: the repo/edition strategy, the generalized consumable-credit pool system, and the
full checklist of what else the SaaS build needs.

Build status: the tenancy core is BUILT on branch feat/saas-tenancy — routing, host
resolution, registry, provisioning, per-tenant entitlements, the operator control API,
per-tenant bootstrap and tenant-aware workers. Billing, the wallet/meters, KYB and
self-serve signup are NOT built and remain design.
The authoritative inventory is
CLOUD_STATUS.md; the runbook is CLOUD_DEPLOYMENT.md.


1. Repo & fork strategy

REVISED 2026-07-29 — the re-measurement (see SAAS_FEASIBILITY.md §0) overturns the
original "hard fork" recommendation. Two products, ONE codebase. Rationale below.

Two products ≠ two repos

The product split (Obscura Enterprise = self-hosted/air-gapped · Obscura Cloud = SaaS) is a
go-to-market decision. The repo split is an engineering decision. They are independent, and
the industry norm is one codebase, two editions (GitLab CE/EE, Sentry, Metabase, Discourse).

Why NOT a hard fork (the calculus changed)

Under schema/DB-per-tenant the SaaS delta is almost entirely additive:

SaaS need Change to existing code
Tenant isolation none — no tenant_id, no touching ~795 queries or 64 unique constraints
Per-tenant entitlements one function body (moduleEnabled); all 131 requireModule call sites unchanged
Connection routing one isolated change in platform/db (per-tenant pool / search_path)
Billing, control plane, KYB 100% new packages

Forking 90k LOC + 149 migrations to add ~5–8k LOC of additive code is a bad ratio — and the
decisive cost is the security-patch tax: this repo has an active audit cadence (an ACL deny-leg
fix, a workflow-chain fix and an MCP-credential fix landed in the last week alone). Every such fix
would need double-application, and divergence means missed patches in a product sold to banks and
government.

Recommended: one repo, two editions, three deployment modes

  1. Keep one repo. Editions are packaging, selected by config — matching the existing idiom
    (wire.go is already "the single place every adapter is selected from ENV").
    TENANCY_MODE=single|schema, BILLING_PROVIDER=noop|wallet, REGISTRATION=off|open.
    - Obscura Enterprisesingle / noop / off (behaviour byte-identical to today).
    - Obscura Cloudschema / wallet / open.
  2. Additive only: new contexts tenancy, billing, controlplane; reuse existing seams
    (requireModule for entitlements, a new BillingProvider platform port for cost gating, the
    existing OIDC multi-IdP for Supabase login). Minimal edits to existing files.
  3. Separate release trains, not separate repos. Cloud ships from main; Enterprise pins
    certified release tags. This gives enterprise its slow, auditable cadence without divergence.
  4. Attack surface: start config-gated (simplest). Only if an enterprise security review demands
    the control-plane/billing code not ship at all in the air-gapped binary, add a //go:build cloud tag for those packages — a later, cheap refinement.
  5. Migrations: one shared set; schema-per-tenant fans out per tenant schema.

When to actually split (revisit triggers)

Split into a real fork only if one of these becomes true:
- You abandon schema-per-tenant for row-level pooled tenancy (that genuinely diverges ~795 queries).
- Separate teams with incompatible release trains make the merge tax exceed the fork tax.
- Legal/IP separation — selling or licensing the SaaS entity independently.

Reference: why a thin "library" repo is impossible either way

Go internal/ packages are module-scoped — they cannot be imported from another module, and
Obscura keeps almost everything under go/internal/…. So a separate SaaS repo could only ever be a
full copy, never a thin consumer, unless core is first promoted out of internal/ to pkg/
(a large refactor, not worth it for this delta).


2. Entitlements & the consumable-credit pool

Model two axes (they coexist, like a subscription + metered usage)

Modules are all paid — no free modules. Self-serve registration is open, but everything is
behind a paywall. Two distinct billing axes, both per tenant:

  1. Module entitlement (licensing): which modules a tenant has bought/subscribed — DMS, AI,
    Correspondence, Workflow, Regulated e-Sign/Meterai/Stamp, … Gate at the app-service
    boundary with a Gate(tenant, module) check; module boundaries map ~1:1 onto the existing
    bounded contexts.
  2. Consumable metering (wallet): per-use credits within a purchased module — the pool below.

The consumable pool — unify it

e-Meterai, e-Signature, e-Stamp/Segel, AI, and storage are all consumable external credits.
Build one metering+wallet subsystem with a meter per product, not three bespoke quotas.
(Config already treats MeteraiProvider independently of ESignProvider, so the meters are
naturally separable.)

Meters (the priced, consumable units)

Meter Backend Cost basis Status
meterai.peruri PERURI e-Meterai per stamp (real saldo = money) wired
esign.mekari Mekari eSign per signature wired
esign.native in-house PAdES/CA ~free (CPU) wired — meter for fair-use only
estamp.peruri_segel PERURI e-Stamp/Segel per seal not wired (future contract)
ai.chat / ai.embed external LLM (claude-haiku) + embed sidecar per token/call wired
storage.gb Supabase Storage per GB-month wired (blob)
seats / docs per unit (entitlement cap) net-new

Two levels of "pool"

  1. Per-tenant wallet + entitlements — plan allotments, hard caps, prepaid balance; each
    metered action does Authorize(tenant, meter, estimate) → reserve → execute →
    Commit(actual) → reconcile/refund on failure. Reuses the PERURI saldo reserve/reconcile
    pattern you already built
    (timeout-reconcile, refund-tracking, failed-serial ledger).
  2. Your master provider pool (if reselling) — your bulk PERURI saldo / Mekari quota is
    itself a pool to monitor + auto-replenish before it runs dry, and one provider
    outage/rate-limit is an all-tenant blast radius (cf. the PERURI hang incident). Needs
    forecasting + alerting + a degrade-gracefully path.

Ledger integrity

Wallet ledger = append-only, double-entry, reconcilable against PERURI saldo statements +
payment-gateway settlements. Model it like the existing hash-chained audit context.


3. What else the SaaS build needs (checklist)

A. Tenant control plane & lifecycle (net-new)

  • Provisioning: create tenant → auto-seed default org unit + Admin position (a solo signup
    must not have to model an org chart).
  • Lifecycle states: trial → active → past_due → suspended → closed; suspension, dunning.
  • Offboarding: data export (tenant portability) + hard delete (shared-DB tenant purge).
  • Tenant-admin console: invite users, manage roles/positions, billing, usage/balance.
  • Operator/super-admin console (your side): view tenants, support impersonation (audited),
    adjust credits/refunds, per-tenant feature flags, suspend abusers.

B. Billing (net-new — subscription ctx is notifications, ratelimit is req/min, neither is this)

Model: recurring subscription per module + metered consumables, both auto-debited from one
unified prepaid wallet.

- Module catalog (SKUs, prices, billing intervals) + per-tenant subscriptions (module →
status active|past_due|canceled, cycle, next_renewal, optional included allotment).
- Unified wallet + ledger: top-ups via a local gateway (Xendit / Midtrans / Doku
QRIS/VA/e-wallet; Stripe = cards only), webhook-credited (verify signature, reuse inbound-webhook
infra). The system auto-debits the recurring module fee each cycle and per-use consumables
from the same balance → one source of truth.
- Metering pipeline: usage event → (burn included allotment, if any) → wallet debit;
idempotent across worker retries.
- Dunning/suspension: wallet can't cover the cycle fee → past_due → grace → suspend module.
- Invoices + receipts; PPN 11%; auto-recharge threshold; refunds/chargebacks; low-balance +
renewal notifications.
- Cost attribution / COGS per tenant — track LLM + saldo + storage cost vs. revenue so you
know per-tenant margin.

C. Regulated-module gating & credentials (see §4)

  • KYB gate on the e-Sign/Meterai/Stamp module before purchase (+ per-signer KYC for certified
    e-Sign). Resolves the resale/identity concern → pooled-resell is the default; BYO optional
    for enterprise.

D. Platform hardening for shared multi-tenancy (net-new)

  • Email at scale: SMTP → transactional ESP (SES/Postmark) with SPF/DKIM/DMARC, bounce/
    complaint handling, anti-abuse (don't become a spam relay). On-prem keeps SMTP via the port.
  • Storage quota + lifecycle per tenant; cleanup on downgrade/close.
  • Secrets/keys per tenant: scope the envelope-encryption DEK per tenant; decide where BYO
    provider creds are stored (KMS/secrets).
  • Isolation: composite unique constraints + ScopeSQL + optional RLS (SET LOCAL app.tenant_id) as defense-in-depth; cross-tenant isolation tests; pen-test.
  • Fair-use / noisy-neighbor: per-tenant rate limits (extend ratelimit).
  • Observability: tenant-tagged metrics/logs/traces.

E. Product & onboarding

  • Self-serve signup + email verify (registration-v2 exists) → tenant bootstrap; guided
    onboarding + sample data; plan select/upgrade/downgrade; in-app usage & balance widgets.

F. Legal / commercial (non-engineering gates)

  • ToS, Privacy, DPA, per-tier SLA, PERURI reseller agreement + KYC, PPN/tax
    registration, pricing model, refund policy.

G. Ops / release

  • CI/CD for the fork + upstream-sync process; per-tenant backup/restore/export (backup ctx
    exists but per-tenant-in-shared-DB differs); status page; support tooling.

4. Regulated module = KYB-gated (resolves the resale/identity problem)

The regulated products (PERURI e-Meterai, certified e-Signature, e-Stamp/Segel) are a special
module gated behind KYB (business verification) before purchase.
This resolves the earlier
pooled-resell/identity concern: because every purchaser is a verified business, pooled-resell
is compliant
and BYO is only an option for enterprise.

Gate structure (scope precisely)

Product Gate to purchase Gate to use Notes
e-Meterai KYB (business) org-level stamp duty; KYB account suffices
e-Stamp / Segel KYB (business) org-level seal
Certified e-Signature (PERURI/Mekari) KYB (business) per-signer KYC + certificate enrollment (PSrE) a certified signature is issued to a verified individual, not the company
Native PAdES (in-house CA) none none not a certified/regulated signature — keep as a separate, cheaper, non-gated module

KYB flow (net-new)

Collect NIB / company registration + NPWP (tax ID) + authorized-signatory identity (+ deed of
establishment where required) → verify (manual review or a KYB provider) → approve → unlock
regulated-module purchase. Needs verification states, periodic re-verification, and audit.

Credential model (now simplified by KYB)

  • Pooled-resell (your master PERURI/Mekari accounts, resold via the wallet) — compliant
    because purchasers are KYB-verified; default for self-serve + silo. You remain
    merchant-of-record (reseller terms + PPN) and run the master-saldo replenishment engine.
  • BYO (tenant connects their own provider account) — optional, mainly enterprise/on-prem;
    the pool is then their saldo, not yours.

5. Suggested build order

  1. Fork repo + upstream remote + tier config seams (TENANCY_MODE, BILLING_PROVIDER,
    REGISTRATION); ship silo + on-prem on Supabase (near-zero code).
  2. Tenancy superset (tenant_id sweep, ScopeSQL scoping, tenant-scoped numbering + audit
    partitions, optional RLS).
  3. Billing/metering subsystembilling context, BillingProvider port, wallet ledger,
    the unified meters, payment gateway + webhooks; wire noop for on-prem/silo.
  4. Control plane + module catalog + GA — provisioning, tenant-admin + operator consoles,
    onboarding, module-entitlement gating, KYB flow for the regulated module (+ per-signer
    KYC); legal/tax + PERURI reseller sign-off.

6. Release process & parallel-agent concurrency

6.1 There is no "sync" — there is a release train

With one repo/two editions, Enterprise and Cloud build from the same commit; nothing is merged
between them. What replaces fork-syncing is ordinary release engineering:

  • One main. All work lands there, both editions' features alike.
  • Obscura Cloud deploys continuously from main.
  • Obscura Enterprise ships from release branches + tags cut from main
    (release/1.8v1.8.0), certified, then deployed on-prem / to prod.
  • Hotfix: a fix lands on maincherry-pick onto the active release branch.
    This is bounded and auditable — categorically unlike reconciling a diverged fork.

Why this is mandatory, not optional: cadence is ~25 commits/day (351 in the 14 days to
2026-07-29). Regulated on-prem buyers cannot chase main. Release branches are what make a single
repo safe for them.

⚠️ Gap today: the repo has 0 git tags. Prod/on-prem currently tracks a moving main. This
is a pre-existing risk independent of Cloud, and the tagging convention fixes both at once —
worth establishing before Cloud work lands.

6.2 Rules that keep one codebase honest

  1. Edition-safety — Cloud code paths must be inert when TENANCY_MODE=single; Enterprise
    behaviour stays byte-identical. CI must run both editions. Extend the existing
    config-requirements test (which already blocks unclassified env vars).
  2. Migrations are shared and edition-neutral — a Cloud-only table is simply empty on
    Enterprise. Never branch schema by edition.
  3. Certified Enterprise train — tag + changelog + the existing deploy/obscura doctor
    preflight gate.

6.3 Parallel agents — isolation rules

Multiple agents on one checkout do clash. Evidence: at the start of the 2026-07-29 session,
go/internal/platform/config/config.go and deploy/docker-compose.yml sat modified-uncommitted in
the shared tree from another stream.

Clash vector Mitigation
Shared working tree (one Write clobbers another; git index contention) One git worktree + branch per stream — the repo already does this (.claude/worktrees/peruri-esign)
Migration number races — sequential goose (00150_…); two agents both write 00151_* Reserve a band per stream, or switch goose to timestamped versions (permanent fix). Band runway is short: ~150 migrations in ~2 months
Shared deploy targets — two streams running deploy/update.sh at one stack One stack per stream; serialize deploys
Shared test DB / dind ports Per-stream ports and databases

No pausing is required as long as each stream is worktree-isolated.