Entitlement admin procedures (Garage v1)
Last Updated: 2026-06-10
Day-to-day operator procedures for manual billing on Garage (0–500 users). Operators mutate entitlements through admin.* (operator UI) and the internal admin API — not a CLI, not raw SQL (ADR 38 R6–R7, C6).
Operator surface
| Surface | Role |
|---|---|
apps/admin (operator UI) | Grant Once, grant base, lapse, extend grace — localized UI for non-technical staff |
Operator admin API (/internal/v1/ops/…) | Same mutations + lookup; served by apps/ops-api (separate deploy), called from the admin origin |
ManualBillingAdapter (crates/entitlement) | Shared library behind the admin API; PSP webhooks use the same adapter |
Origins (separate from customer app)
The operator UI runs on a different origin and port than the customer file-explorer and gateway edge — not bundled into app.*.
| Surface | Local dev | Production |
|---|---|---|
| Customer app + gateway | http://127.0.0.1:8080 (nginx edge) or Vite :14200 internal; gateway http://127.0.0.1:18080 | app.* (e.g. app.substratum.cloud) |
| Operator UI + API (via proxy) | http://127.0.0.1:14220 — UI + /internal on same origin | admin.* only |
| Ops API process (internal) | :18280 loopback — not browser-facing (not gateway :18080) | private network behind admin.* edge |
Staff authenticate on the admin origin with PDS OAuth → substratum_ops_session — not customer substratum_session. See Garage v1 rollout topology.
API routing (everything on admin.*)
Operator routes run on substratum-ops-api, but the browser only ever uses the admin.* origin. The admin dev server or production edge forwards API paths to the internal ops process:
| Environment | Wiring |
|---|---|
| Local dev | Vite on :14220 proxies /internal and /.well-known → http://127.0.0.1:18280 (apps/admin/vite.config.ts) |
| Production | Edge on admin.* serves the admin SPA and forwards the same paths to ops-api — see admin deployment and infra/admin |
Set ops-api PUBLIC_BASE_URL (or OPS_API_PUBLIC_BASE_URL) and OPS_UI_PUBLIC_BASE_URL to admin.* (:14220 local) — not gateway :18080 and not the internal ops bind :18280.
Cross-origin calls directly to :18280 are a misconfiguration; use the admin proxy so substratum_ops_session stays same-origin.
Until the admin UI is fully wired in staging, call /internal/v1/ops/… from the admin origin after ops OAuth (operator_role required) — same surface the scaffold UI will use. UI-only work without ops OAuth: VITE_ADMIN_MOCK=true.
Prerequisites (when admin UI is live)
| Requirement | Notes |
|---|---|
| Staff PDS OAuth | Sign in at admin.* → substratum_ops_session (ADR 38 R17) |
operator_role row | Staff did must have support_read, entitlement_mutator, or billing_reconciler |
| Ticket reference | Every grant/lapse requires reason + ticket_ref (ADR 38 C4) |
Production admin API should sit behind VPN or allowlist (ADR 38 C8).
Capability reference (ADR 37 CC7)
| Customer state | metadata_write_allowed | safety_net_allowed |
|---|---|---|
| Substratum Once (paid) | true | false |
| Base active | true | true |
| Base lapsed, no Once | false | false |
| Base lapsed, has Once | true | false |
Grant Substratum Once (manual payment verified)
After verifying $99 payment (bank transfer, invoice, etc.):
- Open
admin.*and sign in with staff Substratum PDS account. - Look up customer by DID, handle, or support email.
- Grant Once — enter
ticket_refandreason(required). - Confirm capabilities:
metadata_write_allowed=true,safety_net_allowed=false.
Post-grant: share support.* when Discourse is live (Phase 4); customer completes Substratum PDS signup when invite flow is enabled (Phase 3).
Grant base membership
After verifying $5/mo subscription (manual ledger until Stripe ships):
- Same lookup flow on
admin.*. - Grant base with
ticket_refandreason. - Customer receives 25 GB safety-net quota (
base_membershipplan).
Full lapse
When subscription ends or payment fails after grace:
- Lapse action on
admin.*withticket_refandreason. - Lapse semantics follow ADR 37 CC7 (Once customers keep metadata write).
- Phase 4 adds Discourse deactivate on the same adapter event.
Extend grace
Before applying full lapse (PSP invoice.failed grace window):
- Extend grace on
admin.*— set end date and documentticket_ref/reason. - Capabilities stay unchanged until lapse runs or grace automation ships.
Staff DID provisioning (operator_role)
Before staff can use admin.*:
- Create staff handle on
pds.substratum.cloud(existing PDS runbook). - Insert
operator_rolerow (engineering break-glass only until admin API can provision roles):
SET LOCAL app.entitlement_admin = 'true';
INSERT INTO operator_role (id, staff_did, role)
VALUES (
gen_random_uuid(),
'did:plc:STAFF',
'entitlement_mutator'
);| Role | Use |
|---|---|
support_read | Lookup entitlement + audit tail only |
entitlement_mutator | Grant, lapse, grace extend |
billing_reconciler | PSP reconciliation + overrides |
Revoke: UPDATE operator_role SET disabled_at = now() WHERE staff_did = '…' AND disabled_at IS NULL;
Audit verification
Every admin mutation appends entitlement_audit_log with operator_principal (staff did), reason, ticket_ref, and before/after JSON snapshots. The operator UI should expose the audit tail per customer (ADR 38 R15); engineering can query:
SET LOCAL app.entitlement_admin = 'true';
SELECT created_at, action, ticket_ref, operator_principal
FROM entitlement_audit_log
WHERE customer_did = 'did:plc:CUSTOMER'
ORDER BY created_at DESC
LIMIT 20;What not to do
| Anti-pattern | Why |
|---|---|
UPDATE entitlement SET … by hand | Bypasses audit and idempotency (C6) |
| Grant without ticket ref | Rejected by adapter (C4) |
| Operator entitlement CLI | Not shipped — use admin.* UI + API |
Customer substratum_session for ops | Use staff OAuth + operator_role |