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 planPurpose: 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 isCLOUD_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
- Keep one repo. Editions are packaging, selected by config — matching the existing idiom
(wire.gois already "the single place every adapter is selected from ENV").
TENANCY_MODE=single|schema,BILLING_PROVIDER=noop|wallet,REGISTRATION=off|open.
- Obscura Enterprise →single/noop/off(behaviour byte-identical to today).
- Obscura Cloud →schema/wallet/open. - Additive only: new contexts
tenancy,billing,controlplane; reuse existing seams
(requireModulefor entitlements, a newBillingProviderplatform port for cost gating, the
existing OIDC multi-IdP for Supabase login). Minimal edits to existing files. - Separate release trains, not separate repos. Cloud ships from
main; Enterprise pins
certified release tags. This gives enterprise its slow, auditable cadence without divergence. - 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 cloudtag for those packages — a later, cheap refinement. - 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:
- 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 aGate(tenant, module)check; module boundaries map ~1:1 onto the existing
bounded contexts. - 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"
- Per-tenant wallet + entitlements — plan allotments, hard caps, prepaid balance; each
metered action doesAuthorize(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). - 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 (
backupctx
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
- Fork repo +
upstreamremote + tier config seams (TENANCY_MODE,BILLING_PROVIDER,
REGISTRATION); ship silo + on-prem on Supabase (near-zero code). - Tenancy superset (
tenant_idsweep,ScopeSQLscoping, tenant-scoped numbering + audit
partitions, optional RLS). - Billing/metering subsystem —
billingcontext,BillingProviderport, wallet ledger,
the unified meters, payment gateway + webhooks; wirenoopfor on-prem/silo. - 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.8→v1.8.0), certified, then deployed on-prem / to prod. - Hotfix: a fix lands on
main→ cherry-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
0git tags. Prod/on-prem currently tracks a movingmain. 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
- 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). - Migrations are shared and edition-neutral — a Cloud-only table is simply empty on
Enterprise. Never branch schema by edition. - 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.