ADR 32: Account Entitlements and Hosting Policy
Status: Accepted
Date: 2026-06-02
Last Updated: 2026-06-09 (ADR 38 operator UI in v1 scope)
Terms (this ADR)
| ID | Term | Meaning |
|---|---|---|
| Rn | Functional requirement | Numbered product/engineering obligation (R1–R17 in this ADR). |
| NRn | Non-functional requirement | Quality attribute: security, performance, i18n, testability (NR1–NR9). |
| Cn | Constraint | Non-negotiable boundary (C1–C9). |
| CCn | Cross-cutting challenge | Risk spanning components, with mitigation (CC1–CC10). |
| CTA | Call to action | Button or link for the next user step (upgrade, download installer, self-host docs). Built from /me/limits URL fields, not hardcoded in the UI. See Glossary. |
| deployment_mode | Gateway config | saas or self_hosted — whether account SaaS entitlements apply. Server-configured only. |
| hosting_topology | Installer profile | Who runs the API/catalog (self_hosted, cloud_connected). See ADR 24. |
| Account layer | SaaS entitlements | Per-DID upload caps and quotas enforced in ingress. |
| Node disk layer | Operator policy | Per-device max_bytes / warn_bytes in the triangle manifest (installer Step 3). |
| UploadPolicy | Ingress port | Resolves effective_limits(did) per request from Postgres + deployment_mode. |
| Quota reserve / commit | Storage accounting | Reserve bytes in Postgres before upload proceeds; commit on success or release on failure/cancel/TTL. Prevents concurrent uploads or gateway replicas from exceeding quota_bytes. |
| Swarm block cap | SWARM_MAX_BLOCK_BYTES | Maximum bytes per PutBlock on the private mesh (today 25 MiB). Independent of ingress upload ceiling; keeps streaming and memory bounded. |
| Upload ingest chunking | Ingress → swarm | Splitting a stored file into many blocks ≤ swarm cap (and optional DAG root CID)—required for large files without loading 1 TiB into one buffer. |
Canonical product vocabulary: Glossary.
Context
Substratum serves SaaS (Substratum Cloud), self-hosted (family home server), and cloud-connected (user nodes, Substratum API) deployments from one gateway and one file-explorer build (ADR 33). Operators and users need a coherent story for who may upload how much, where limits are enforced, and what the UI shows—without conflating Substratum billing with per-node disk choices on personal hardware.
Today the gateway applies global upload ceilings only (UPLOAD_MAX_*, currently 25 MiB per file / 250 MiB per request in code). There is no per-DID account plan, no **deployment_mode** exposed to clients, and no **GET /api/v1/me/limits**. The installer already records **hosting_topology** (ADR 24) and node storage policy (ADR 23), but ingress does not branch policy on hosting.
This ADR defines three independent policy layers, numbered requirements, constraints, and cross-cutting challenges. Delivery is tracked in the per-account upload pricing plan and begins only after acceptance.
Policy model (three layers)
Do not conflate these axes (Glossary):
| Layer | Who decides | What it limits | Enforced in (v1) |
|---|---|---|---|
| Hosting topology | Operator at install (Step 0) or Substratum ops for cloud | Whether account SaaS entitlements apply | Gateway deployment_mode → UploadPolicy resolver |
| Account plan (SaaS) | Substratum (plans in Postgres; billing provider later) | Per-DID max file size, per-request aggregate, rolling quota_bytes | Ingress + quota reserve/commit in Postgres (ADR 09) |
| Node disk (personal mesh) | Family operator per device (installer Step 3) | Local blockstore path, optional max_bytes / warn_bytes on that node | Triangle manifest device.storage (runtime enforcement follow-up per ADR 23) |
Rules:
- On
**deployment_mode: self_hosted**, account upload caps are unlimited at the account layer (nulllimits in API). Home storage is capped only by what the operator configured on each node—not by Substratum account quotas. - On
**deployment_mode: saas** (and cloud-connected API paths that adopt SaaS entitlements), account plan caps are authoritative in ingress, including strictquota_bytesvia reserve/commit. Edge/nginx remains a global hard ceiling only, not per-DID policy.
Requirements
Functional requirements
| ID | Requirement |
|---|---|
| R1 | The gateway SHALL expose **deployment_mode** (saas |
| R2 | Self-hosted installs SHALL set deployment_mode: self_hosted when hosting_topology: self_hosted is written from the installer profile. |
| R3 | Hosted Substratum Cloud gateways SHALL set deployment_mode: saas. |
| R4 | Ingress SHALL provide **GET /api/v1/me/limits** for authenticated users, returning effective byte limits, plan_code, deployment_mode, used_bytes, reserved_bytes (or equivalent), and optional outbound URL fields (upgrade_url, installer_download_url, docs_self_host_url) so the Account page and upload errors do not hardcode destinations. |
| R5 | Ingress SHALL expose an **UploadPolicy** port wired at startup (e.g. Arc<dyn UploadPolicyPort> on AppState). Handlers SHALL call **effective_limits(did) per request** from AuthenticatedDid—no per-user policy on AppState. The resolver reads deployment_mode and entitlements from Postgres; effective caps are min(system_caps, account_caps) on SaaS and unlimited account caps on self-hosted. |
| R6 | **post_drive_upload** and TUS finalize (ADR 26) SHALL enforce per-file max and per-request aggregate limits on SaaS only; self-hosted SHALL skip account-byte rejection while still respecting system/nginx ceilings. |
| R7 | SaaS rejections SHALL return HTTP 413 Payload Too Large with a structured, plan-aware error body suitable for UI and api-client. |
| R8 | Account plans and per-DID entitlements SHALL be persisted in PostgreSQL from v1 (ADR 09): plan definitions (caps, plan_code), DID-to-plan assignment, and optional quota fields. Default plans MAY be seeded via migrations; runtime reads use SeaORM through ingress with RLS for tenant-scoped rows. Rejected for v1: in-memory HashMaps, env-only per-DID overrides, or gateway-local config as the source of truth for entitlements. |
| R9 | File-explorer SHALL add an authenticated **/account** route showing identity, hosting copy, plan/usage (SaaS), and call-to-action controls from the limits response URLs (upgrade, installer download, self-host docs). |
| R10 | Explorer upload flow SHALL pre-validate selected files when max_file_bytes / max_request_bytes are non-null, with errors linking to /account for upgrade on SaaS. |
| R11 | When limits indicate unlimited account caps (self-hosted), the explorer SHALL skip account-layer pre-checks (global constants may still apply). |
| R12 | Installer Step 3 SHALL let the operator choose either a capped node budget (max_bytes and optional warn_bytes not above max) or unlimited node storage (max_bytes: null, warn_bytes: null). Both paths are valid; default MAY be capped (e.g. 32 GiB). Copy MUST clarify that unlimited is operator/home-disk only—not Substratum account quotas. |
| R13 | OpenAPI models for limits/account DTOs SHALL live under **crates/ingress/src/models**; api-client SHALL be regenerated after schema change. |
| R14 | Optional v1 follow-up: **GET /api/v1/me/account** for display profile fields; **/me/limits** remains the cacheable limits contract. |
| R15 | When quota_bytes is set on SaaS, ingress SHALL enforce rolling storage quota with reserve → commit / release in PostgreSQL so concurrent uploads and multiple gateway replicas cannot overshoot: reserve atomically before accepting upload work (multipart request start; TUS POST using declared Upload-Length); commit on successful finalize (move reserved into used_bytes); release on failure, client abort, or reservation TTL. At reserve time, used_bytes + reserved_bytes + declared_size MUST NOT exceed quota_bytes; failure returns HTTP 413. |
| R16 | Quota reservations SHALL be keyed by upload session (multipart idempotency key or TUS upload id) so retries do not double-reserve; stale reservations SHALL be releasable by TTL job or equivalent. |
| R17 | Before a file larger than **SWARM_MAX_BLOCK_BYTES** can be stored end-to-end, ingress finalize (multipart and TUS) SHALL chunk content into blocks ≤ SWARM_MAX_BLOCK_BYTES, issue multiple PutBlock calls, and persist a root reference (single CID today or IPLD manifest later) on the drive entry. Chunking SHOULD stream from disk/spool without holding the full file in gateway RAM. |
Non-functional requirements
| ID | Requirement |
|---|---|
| NR1 | Security: deployment_mode and plan resolution MUST be server-configured; clients MUST NOT override entitlements via headers or query params. |
| NR2 | Authoritativeness: On SaaS, server enforcement (per-file/request limits and quota reserve) MUST remain correct even if the UI pre-check is bypassed or stale. |
| NR3 | Cohesion: Limits DTOs and upload error shapes MUST stay in crates/ingress/src/models per repo root AGENTS.md; domain crates stay HTTP-free. |
| NR4 | Observability: Rejected uploads SHOULD log DID, plan_code, deployment_mode, and limit kind without logging file content. |
| NR5 | Performance: GET /me/limits SHOULD be cheap enough to call once per session or route transition (cacheable, no upload hot path). |
| NR6 | i18n: Account and limit messaging SHALL use Lingui catalogs in file-explorer (ADR 14). |
| NR7 | Compatibility: One file-explorer bundle MUST work for SaaS and self-hosted; differentiation via server limits response only (ADR 33). |
| NR8 | Evolution: Field names in the /me/limits JSON contract SHOULD remain stable as plan rows and external billing integration evolve. |
| NR9 | Testing: Policy resolver and handler matrices (SaaS free/pro, self-hosted unlimited account layer) SHALL have automated tests per repository testing pyramid. |
| NR10 | Quota correctness: Reserve/commit MUST use a single transactional update per DID (e.g. SELECT … FOR UPDATE on entitlement row) so two gateway pods or two parallel uploads cannot pass with stale reads. |
Constraints
| ID | Constraint |
|---|---|
| C1 | Per-DID limits MUST NOT be implemented in nginx or the edge proxy; ingress owns account policy (ADR 02). |
| C2 | SaaS account quotas MUST NOT be enforced on gateways with deployment_mode: self_hosted. |
| C3 | Node max_bytes from the installer MUST NOT be interpreted as Substratum account plan billing; it is operator storage policy only (ADR 23). |
| C4 | hosting_topology and mesh_mode remain in **substratum-installer-profile.json**; gateway **deployment_mode** is the runtime merge point for upload entitlements (ADR 24). |
| C5 | Identity remains AT Protocol DIDs only; no proprietary user table for AuthN (repo root AGENTS.md). |
| C6 | Global **UPLOAD_MAX_*** ceiling is 1 TiB (1024^4 bytes) per file and per request body in ingress; TUS Upload-Length and nginx client_max_body_size must align (ADR 26). SaaS plans MAY set lower caps via min(system_caps, account_caps). |
| C11 | **SWARM_MAX_BLOCK_BYTES** MUST stay much smaller than the ingress upload ceiling (keep ~25 MiB unless a separate ADR revisits mesh streaming). Do not raise swarm block size to 1 TiB—that would force whole-file buffering and break streaming/retrieval. Large files MUST use upload ingest chunking (R17). |
| C7 | Idempotency keys for uploads remain DID-scoped; entitlement checks use the authenticated DID from the session. |
| C8 | v1 does not require an external billing provider (Stripe, etc.); plan and entitlement rows still live in Postgres and MAY be seeded or updated operationally until billing webhooks ship. |
| C9 | Sidecar/node runtime enforcement of triangle device.storage caps is out of scope for this ADR’s v1 ingress work (tracked under ADR 23/04 follow-up). |
| C10 | Rolling quota_bytes on SaaS MUST be enforced via Postgres reserve/commit (R15)—not read-check-write at finalize only, and not per-process memory counters. |
Cross-cutting challenges
| ID | Challenge | Mitigation |
|---|---|---|
| CC1 | Terminology drift between hosting_topology, deployment_mode, and glossary “deployment context.” | Glossary and limits API echo both where useful; map self_hosted → deployment_mode: self_hosted at install; document in [substratum-installer-profile-v1](../reference/substratum-installer-profile-v1.md). |
| CC2 | **cloud_connected** users may expect SaaS limits while nodes are local. | v1: entitlements follow where the API runs (deployment_mode on that gateway). Cloud-connected account-linking UI remains ADR 24 out-of-scope until specified. |
| CC3 | UI stale limits after plan change mid-session. | Short TTL or refetch on /account and before upload batch; server always wins. |
| CC4 | Multipart vs TUS duplicate enforcement paths. | Single UploadPolicy used by post_drive_upload and TUS finalize (ADR 26); avoid divergent cap logic. |
| CC5 | Stale quota reservations (abandoned TUS or crashed clients). | Reservation TTL + sweeper; release on finalize error; idempotent reserve by upload session (R16). |
| CC6 | Operator confusion: “unlimited plan” on SaaS marketing vs unlimited account layer on self-hosted. | Copy on /account and installer Step 3; SaaS shows quota bars, self-hosted shows home-server messaging only. |
| CC7 | Compose/dev defaults vs production SaaS/self-hosted matrix. | Document two gateway profiles in ops/dev docs; E2E uses explicit deployment_mode env. |
| CC8 | Usage metering vs external billing provider. | used_bytes / reserved_bytes from Postgres (R15); stable /me/limits fields (NR8); billing webhooks adjust plan rows later without bypassing reserve/commit. |
| CC9 | 1 TiB ingress vs 25 MiB swarm blocks — today multipart/TUS finalize call store_block_in_swarm with the whole file; uploads exceeding SWARM_MAX_BLOCK_BYTES fail at PutBlock even after ingress adopts 1 TiB. | Implement R17 (streaming chunk ingest); until then document effective mesh limit in ops/dev; do not increase SWARM_MAX_BLOCK_BYTES as a shortcut (C11). |
| CC10 | Plan changes without Stripe — entitlements live in Postgres (R8); quota enforcement does not wait on webhooks (Neutral below). | v1 operator admin tooling (ADR 38): API, manual billing adapter, audit, Discourse hooks, and localized operator UI for grant/lapse/support; Stripe webhooks update the same tables when integrated. |
Decision
- Adopt the three-layer policy model and rules in the table above as the canonical product/engineering split.
- Implement
**UploadPolicy** (per-requesteffective_limits(did)from Postgres) +**deployment_mode**on the gateway; enforce account caps in ingress only for SaaS. - Add account plan / entitlement tables and migrations in
crates/migration, including**used_bytes**,**reserved_bytes**, and reservation rows or columns for reserve/commit (R15–R16, NR10, C10). - Publish
**GET /api/v1/me/limits** as the v1 contract (shape below). - Build file-explorer
/accountand explorer pre-upload validation against that contract (ADR 33). - Installer Step 3: optional capped vs unlimited node storage per device; on
install_self_hosted, writedeployment_mode: self_hostedinsubstratum-gateway.json(from Step 0hosting_topology). - Implement upload ingest chunking (R17): keep
SWARM_MAX_BLOCK_BYTESat mesh-friendly size; do not conflate withUPLOAD_MAX_*.
GET /api/v1/me/limits (v1 contract)
| Field | SaaS | Self-hosted |
|---|---|---|
deployment_mode | saas | self_hosted |
plan_code | e.g. free, pro | self_hosted or omitted |
max_file_bytes | plan value | null |
max_request_bytes | plan value | null |
quota_bytes | plan value | null |
used_bytes | committed usage | null |
reserved_bytes | in-flight reservations | null |
upgrade_url | billing/marketing | null |
installer_download_url | link to releases/installer | same (other devices) |
docs_self_host_url | Self-hosted troubleshooting | same |
Effective limit computation: **min(system_caps, account_caps)** on SaaS; rolling quota uses **used_bytes + reserved_bytes ≤ quota_bytes** at reserve time. On self-hosted, account caps are unlimited at the account layer.
Rejected alternatives
| Alternative | Why rejected |
|---|---|
| Per-DID upload limits in nginx | Cannot access session/DID; violates C1 and hexagonal boundaries. |
| Enforcing SaaS quotas on self-hosted gateways | Violates product promise and C2; families own their disk. |
Client-supplied deployment_mode | Violates NR1; enables trivial bypass. |
Using installer max_bytes as global account quota | Conflates layers; violates C3. |
| Separate account micro-frontend | Violates ADR 33; account is a feature area in file-explorer. |
| In-memory or env-only per-DID plan maps | Not durable across restarts or replicas; no audit trail; violates R8. |
Check used_bytes only at upload finalize (no reserve) | Concurrent uploads or multiple gateway replicas can overshoot quota_bytes; violates R15 and C10. |
Raising SWARM_MAX_BLOCK_BYTES to match 1 TiB upload cap | Breaks streaming/retrieval memory model; violates C11; use R17 chunking instead. |
Consequences
Positive
- Clear enforcement boundary for billing vs home-server operations.
- One API contract drives explorer pre-checks and account UI.
- Aligns installer profile, gateway config, and glossary language.
- Strict rolling quota safe under concurrency and horizontal gateway scale (R15, NR10).
Negative
- Two dimensions (
hosting_topologyvsdeployment_mode) must stay documented until converged naming. - Cloud-connected entitlement rules need a follow-up ADR amendment when account linking ships.
- Reserve/commit adds DB transactions on upload start and finalize; reservation TTL/sweeper operations required (CC5).
- 1 TiB ingress limit is not end-to-end until upload chunking (R17) replaces single-block
PutBlock(CC9).
Neutral
- External billing provider (Stripe, etc.) remains optional for v1; quota enforcement uses Postgres reserve/commit (R15) and does not depend on payment webhooks.
- SaaS admin tooling is v1 scope per ADR 38: operator API, manual billing adapter, audit log, Discourse hooks, and a fully localized operator UI — not deferred past Garage launch.
- Node-level
max_bytesenforcement in sidecar/retrieval is unchanged in scope for this ADR’s v1.
Deferred (not in first implementation slice)
| Item | Notes |
|---|---|
| Stripe / payment webhooks | Sync subscription state into entitlement rows when product chooses a provider. |
GET /api/v1/me/account | Optional profile fields (R14). |
Node runtime enforcement of installer max_bytes | ADR 23 / sidecar follow-up. |
cloud_connected account-linking UI | ADR 24. |
Verification (acceptance scenarios)
| Scenario | Expected |
|---|---|
SaaS free DID | Upload over per-file max → HTTP 413; UI blocks before upload |
SaaS pro DID | Higher caps; upgrade call to action differs or hidden |
SaaS near quota_bytes, two parallel uploads | Second upload gets 413 at reserve; used_bytes + reserved_bytes never exceeds quota |
| Two gateway replicas, same DID | Concurrent reserves serialize in Postgres; no overshoot (NR10) |
| Self-hosted gateway | Same DID would be “free” on SaaS but account-layer uploads allowed to system max |
| Account page (SaaS) | Plan, usage, installer download link |
| Account page (self-hosted) | Home hosting copy; no quota bar |
| Installer Step 3 capped | max_bytes / warn_bytes in triangle device.storage |
| Installer Step 3 unlimited | Operator selects unlimited; max_bytes: null in exported triangle JSON |
| Self-hosted install | substratum-gateway.json includes deployment_mode: self_hosted |
Related
- ADR 09: Database and ORM — plan and entitlement persistence
- ADR 23: Personal Unified Installer — node storage policy
- ADR 24: Installer post-MVP mesh modes —
hosting_topology - ADR 26: TUS Resumable Drive Uploads — shared upload ceilings
- ADR 33: Frontend Modular Monolith —
/accountfeature area - Glossary — account plan, deployment context, node storage policy
- Business model — public pricing philosophy, PDS hosting vs BYO identity, safety-net metering
- ADR 37: PDS Entitlement Proxy and Payment-Provider-Agnostic Billing — PDS repo authz proxy, capability flags, billing adapters decoupled from PSP
[substratum-installer-profile-v1](../reference/substratum-installer-profile-v1.md)