Skip to content

Multi-tenant operations

A tenant is a directory: one operator manifest plus its journals. Nothing else. The runner (unidpp-stack tenant) reads the manifest, renders each declared service’s environment through unidpp-config render-env, and starts the same binaries the reference deployment runs. Zero per-tenant code.

tenants/
├── acme/
│ ├── unidpp-operator.yaml # whitelabel, EU
│ ├── registry-journal.jsonl
│ └── issuer-journal.jsonl
└── acme-cn/
├── unidpp-operator.yaml # sovereign, CN, sm2 packs
├── registry-journal.jsonl
└── issuer-journal.jsonl

Tenant runtime state (pids, logs) lands under run/tenants/<name>/, one directory per tenant.

Terminal window
$ ./ops/target/release/unidpp-stack tenant acme up
tenant acme (up):
registry: started (pid 35999)
issuer: started (pid 36003)
console: started (pid 21035)
$ ./ops/target/release/unidpp-stack tenant acme status
tenant acme (status):
registry: running (pid 35999)
issuer: running (pid 36003)
console: running (pid 21035)
$ ./ops/target/release/unidpp-stack tenant acme down
tenant acme (stop):
stopped acme/registry
stopped acme/issuer
stopped acme/console

up is idempotent: a service with a live pid file is reported already running and left alone. down kills by pid file; journals are never touched, so a stopped tenant restarts with its state replayed.

An upgrade is new binaries over unchanged state. The compatibility contract is the journal: every service replays its append-only JSONL on start, and the manifest’s API version (unidpp.org/v1) is pinned, and a manifest the running schema does not accept is a loud error, not a silent-misconfiguration risk.

The procedure, per the runner’s own behavior:

  1. Stop: unidpp-stack tenant <name> down (or unidpp-stack down for the reference deployment). Journals are preserved by both; nothing is wiped.
  2. Build the new binaries: cargo build --release per repository. unidpp-stack rebuilds only when a binary is missing; force a rebuild of a present-but-stale tree with UNIDPP_FORCE_BUILD=1 unidpp-stack up.
  3. Start: the same start command; every service replays its journal (unidpp-stack status shows the journal record and item counts).
  4. Verify: same acceptance as a restore: the registry serves the same item count as before the upgrade, and the log verifies its head (a tree size that moved backwards is a hard fault).

Between stop and start there is no migration step to forget: if the new binary can read the journal, the deployment is up; if it cannot, it says so at replay, before serving. Take a backup first when the jump is large, because the restore procedure is the rollback.

The console needs one variable the manifest cannot express (its own manifest path), which the runner supplies: UNIDPP_CONSOLE_MANIFEST=tenants/<name>/unidpp-operator.yaml.

A minimal tenant manifest declares registry + issuer + console, which is enough to issue passports, mint packs, and manage itself:

Terminal window
$ unidpp-config validate tenants/acme/unidpp-operator.yaml
valid: acme-eu (profile whitelabel, 3 service(s): ["registry", "issuer", "console"])
$ unidpp-config render-env issuer tenants/acme-cn/unidpp-operator.yaml
UNIDPP_ISSUER_BIND=127.0.0.1:9593
UNIDPP_ISSUER_PACK_SUITE=sm2
UNIDPP_ISSUER_STATE_FILE=tenants/acme-cn/issuer-journal.jsonl

A tenant can declare any subset of the family’s services; trust, log, projector, gateway, and archive blocks are optional. What a tenant does not declare, it does not run.

What separates tenants from each other and from the reference deployment:

  • Journals. Each service’s state_file points inside the tenant directory. State never crosses tenants.
  • Ports. Each tenant’s binds are its own (acme on 93xx, acme-cn on 95xx). The runner does not invent ports; the manifest declares them and a collision is a manifest fix.
  • Keys. Each issuer derives its keyring from its seed environment. Two tenants’ packs verify against two different published anchors. (Dev seeds are shared by default; see security posture; production tenants set per-tenant seeds.)
  • Branding and policy. Per-tenant manifest: names, theme, footer, pack suites, residency, egress.

What is not isolated: the binaries (shared, by design), the host, and the process table — every tenant is a process group on your box; treat host access accordingly.

The family’s working convention:

Range User
8389-8397 reference deployment (console, the seven core services, and the hub on 8397)
8399 JP national peer registry
93xx whitelabel tenants (acme: 9389 console, 9390 registry, 9393 issuer)
95xx sovereign tenants (acme-cn: 9589 console, 9590 registry, 9593 issuer)

There is no registry of ports beyond the manifests themselves; when adding a tenant, pick a decade and stay in it.

Everything in the service references applies to a tenant’s services, with the same endpoints and shapes on the tenant bind. Example: the whitelabel tenant’s issuer answers on 9393 exactly as the reference issuer answers on 8393:

Terminal window
$ curl -s http://127.0.0.1:9393/keyring | jq '{mode, roles: (.roles | keys)}'
{
"mode": "seeded-dev",
"roles": [
"event",
"pack"
]
}

And the CN tenant’s issuer would answer the same query on 9593 with an SM2 pack role. Issuing a passport on a tenant is the first-passport walkthrough with the bind changed.

  1. mkdir tenants/<name> and write unidpp-operator.yaml (start from tenants/acme/, the closest profile).
  2. unidpp-config validate tenants/<name>/unidpp-operator.yaml until it passes.
  3. ./ops/target/release/unidpp-stack tenant <name> up.
  4. Probe: curl -s http://127.0.0.1:<console-port>/.well-known/unidpp-service.

The full procedure with branding, tokens, and tunnel is the 15-minute whitelabel deployment.

Retiring a tenant is the doctrine-shaped act: prove, then move aside, never delete:

Terminal window
./unidpp-ops decommission northwind
# stopped (if running) -> a final VERIFIED data-only bundle
# -> tenants/northwind -> tenants/northwind.retired.<stamp>
# reversal: mv back && ./ops/target/release/unidpp-stack tenant northwind up

The bundle is the audit artifact; the rename-aside is the reversal path; nothing is ever destroyed.

Whitelabel as a separate deployment ships as one verified artifact:

Terminal window
./unidpp-ops bundle acme-cn

The bundle carries the tenant’s manifest (secrets stay ${VAR} references; render-env substitutes them at the target host), the release binaries for exactly the services the manifest declares, its own runner (unidpp-run, with binaries from ./bin and manifest at the bundle root), an .env template, and a runbook. Its sidecar lists every member’s SHA-256, and a bundle verifies exactly like a backup (./unidpp-ops verify <bundle>.tar.gz), so the receiving host proves integrity independently of its producer. First boot on the target: ./bin/unidpp-config validate, fill .env, ./unidpp-run start; the bundle starts from its own contents (CI smokes exactly this).