# Enterprise registries (`Registry.dedicated`)

The standard path only ever talks to the one canonical, shared
`GrantorRegistry` — every SDK surface, both sides (RP verifier and
holder/agent), resolves it automatically from a compiled-in `chainId →
address` map. There is no address to paste anywhere in the standard SDK.

An **enterprise dedicated registry** is a *separate deployment* of
`GrantorRegistry` — your own contract instance, your own tenants, your own
billing — pointed at from your own SDK configuration instead of the canonical
one. Naming a custom registry address anywhere in the SDK requires an
**operator-signed license grant**: a credential Grantor issues, verified
offline against operator public keys compiled into the SDK. The right to run
your own registry is itself a Grantor grant, checked by the same credential
machinery the product ships to everyone else.

If you have not read [Getting started](getting-started.md) yet, start there —
this page assumes you already know what a deed is and how `DeedVerifier`
verifies one against *a* registry; this page is only about *which* registry.

## The registry seam: one type, three variants

Every surface that takes a registry — `DeedVerifier`'s constructor,
`ZkAgent.mintDeed`, `ZkUser.mintUserDeed`, `delegateZk`, `authenticate()` /
`signInWithDeed()`'s `registry` option — takes a `RegistryRef`, built with one
of three static constructors (`Registry` in TS/Go, module-level functions in
Python):

| | TypeScript | Python | Go | Rust |
|---|---|---|---|---|
| Standard | `Registry.canonical()` | `registry.canonical()` | `guard.RegistryCanonical()` | `RegistryRef::Canonical` |
| Local devnet | `Registry.devnet(address)` | `registry.devnet(address)` | `guard.RegistryDevnet(address)` | `RegistryRef::Devnet { address }` |
| Enterprise | `Registry.dedicated(address, license)` | `registry.dedicated(address, license)` | `guard.RegistryDedicated(address, licenseJSON)` | `RegistryRef::Dedicated { address, license }` |

A `RegistryRef` is a JSON string on every FFI boundary (wasm and uniffi
alike): `{"kind":"canonical"}` / `{"kind":"devnet","address":"0x…"}` /
`{"kind":"dedicated","address":"0x…","license":{…}}`. The three helper
functions above just produce that string — they are the ONLY supported way to
build one; hand-writing the JSON works too but gains you nothing.

- **`Registry.canonical()`** — the standard path. Resolves from a compiled-in
  map, keyed by chain id. **Until mainnet launch the map is empty**, so a
  standard-path construction against any real chain errs, naming the chain —
  this is expected and documented, not a bug; see [Develop
  locally](local-devnet.md). Devnet chain ids are not map entries either —
  `Registry.canonical()` on a devnet chain still errors; use `.devnet()`
  there.
- **`Registry.devnet(address)`** — free-form address, hard-gated to chain ids
  `31337`/`1337`. The first chain interaction self-checks `eth_chainId` and
  refuses closed on any other chain — structurally unusable in production.
  This is what every example, the forge suites, and `just devnet` use.
- **`Registry.dedicated(address, license)`** — this page.

`license` is a `RegistryLicense` — either the parsed object `grantor-license
issue` prints (after `JSON.parse`/`json.loads`), or its raw JSON string; both
forms work in every language's `dedicated()` helper.

## The license artifact

A long-lived grant in the same capability grammar the delegation/capabilities
vertical uses — no second credential format:

- **verb:** `dedicated`
- **resource:** `registry:{chainId}:{address}` — the exact `(chain, address)`
  pair the grant authorizes. A multi-chain enterprise gets one grant per
  chain.
- **claims:** `licensee` (an opaque display label chosen at issuance), `iat`,
  `exp`, `grace_secs`
- **signature:** the operator's ed25519 license key

The grant is a **bearer attestation with no secret inside** — distribute the
one JSON file (env var or config path) to every RP and every agent that needs
it. There is nothing in it that leaks by being copied around.

### Ceilings

Enforced at *verification* (an over-limit grant refuses outright, whatever
its signature says), mirroring how the origin-vouch TTL ceiling is checked
where the vouch is consumed:

| Parameter | Value |
|---|---|
| max TTL (`exp - iat`) | 400 days |
| `grace_secs` default | 30 days |
| `grace_secs` ceiling | 90 days |
| pre-expiry warning window | 30 days |

⚠️ **A license with a total TTL under 30 days is born `expiring_soon` and
never reports `ok`.** The warning threshold is `exp − 30 days`; if the whole
license is shorter than that, every valid `now` for its entire life is past
the threshold. This is not a bug — a 10-day-TTL license genuinely *is* always
within 30 days of expiring — but it surprises anyone issuing a short test
grant expecting to see `"ok"`.

### Runtime semantics — grace, then hard refusal

Checked at **construction** and on **every** verify/mint call — a pure
offline clock comparison, zero added I/O:

| Time | Behavior |
|---|---|
| `now < exp − 30d` | normal (`"ok"`) |
| `exp − 30d ≤ now < exp` | works; `"expiring_soon"` |
| `exp ≤ now < exp + grace_secs` | works; `"grace"` (surfaced loudly) |
| `now ≥ exp + grace_secs` | **hard refusal** — `LicenseExpired` |

There is no boot-time-only check (a license that lapses mid-deployment is
re-checked on the very next call, not just at startup) and no zero-grace hard
cutoff (grace is the negotiated notice-period term, carried *in the grant*
itself, not a global policy). There is also no revocation-before-expiry: the
lever is expiry plus renewal, exactly like an origin vouch. An enterprise
contract that needs instant kill-switch revocation is a different,
un-shipped feature — not something you can get out of the license mechanism
today.

`LicenseExpired` is a distinct, structured error — never confused with
`BadProof` (an attack) or `Chain` (an RPC problem worth retrying). See
[Errors](errors.md) for its exact ordering relative to every other check
(it fires FIRST, before the presented deed is even inspected — a lapsed
license means this deployment cannot authenticate *anyone*, independent of
what the caller presents).

## Using it

### The verifier (RP side)

```ts
// Imports are by path — @grantor/verify is not yet on npm, matching every
// other snippet on this site (see Getting started).
import { DeedVerifier } from "../../sdk/verify/ts/pkg/grantor_verify_wasm.js";
import { Registry } from "../../sdk/verify/ts/src/registry.js";

const license = JSON.parse(fs.readFileSync("license.json", "utf8"));

const verifier = new DeedVerifier(
  process.env.RPC_URL,
  Registry.dedicated(process.env.REGISTRY, license),
  Number(process.env.CHAIN_ID),
  Number(process.env.TENANT_ID),
  "https://api.example.com",  // audience
  "https://api.example.com",  // origin
  300,                        // max deed TTL, seconds
  30,                         // chain-read cache TTL, seconds
  false,                      // allowInsecureOrigin
  Math.floor(Date.now() / 1000),
);
```

```python
from grantor_verify import DeedVerifier
from grantor_verify import registry

license = json.load(open("license.json"))

verifier = DeedVerifier(
    rpc_url=os.environ["RPC_URL"],
    registry_ref=registry.dedicated(os.environ["REGISTRY"], license),
    chain_id=int(os.environ["CHAIN_ID"]),
    tenant=int(os.environ["TENANT_ID"]),
    audience="https://api.example.com",
    origin="https://api.example.com",
    max_ttl_secs=300,
    cache_ttl_secs=30,
    allow_insecure_origin=False,
    now_unix=int(time.time()),
)
```

```go
licenseJSON, _ := os.ReadFile("license.json")
ref, err := guard.RegistryDedicated(os.Getenv("REGISTRY"), string(licenseJSON))
if err != nil {
    log.Fatal(err)
}
verifier, err := grantor_verify_uniffi.NewDeedVerifier(
    os.Getenv("RPC_URL"), ref, chainID, tenantID,
    "https://api.example.com", "https://api.example.com",
    300, 30, false, uint64(time.Now().Unix()),
)
```

```rust
let license: grantor_sdk_core::RegistryLicense = serde_json::from_str(&license_json)?;
let registry = grantor_sdk_core::RegistryRef::Dedicated { address: registry_address, license };
let verifier = grantor_verify::verifier::DeedVerifier::new(
    &rpc_url, &registry, chain_id, tenant, audience, origin,
    max_ttl_secs, cache_ttl_secs, allow_insecure_origin, now_unix,
)?;
```

A lapsed license (past `exp + grace_secs`) refuses **construction outright** —
"restart after lapse fails." Every subsequent `verifyAt` call re-checks the
license at *its own* `now`, independent of what the license looked like at
construction time — a license that was fine at boot can still lapse mid
deployment, and the very next request after that sees `LicenseExpired`.

⚠️ **These two lapses surface as DIFFERENT codes, and an operator health
check must watch both.** A license already lapsed *at construction* throws
`"BadConfig"` (the constructor maps every `VerifierConfigError`, including a
lapsed license, to `"BadConfig"`; the message starts `"license/registry: "`)
— it is NOT `LicenseExpired`, because there is no verifier instance yet to
carry that runtime code. A license that lapses *while the verifier keeps
running* throws the caller-facing `LicenseExpired`/`503` on the next
`verifyAt` (see [Errors](errors.md)). A restart-time health check that only
alerts on `LicenseExpired` will silently miss the boot-time case.

`newQuorum`/`new_quorum`/`NewQuorum` (the multi-RPC quorum constructor) takes
the identical `registryRef`/license shape, with `rpcUrl: string` replaced by
`rpcUrls: string[]`.

### The holder (agent/user side)

`ZkAgent.mintDeed` / `ZkUser.mintUserDeed` / `delegateZk` all take the same
`registryRef` (as their second positional argument, after `rpcUrl`) and a
trailing `nowUnix` — the clock used to resolve the license, exactly like the
verifier's constructor:

```ts
const deedJson = await agent.mintDeed(
  rpcUrl, Registry.dedicated(registryAddress, license), tenant, audience,
  origin, challenge, exp, vouchSignature, vouchEpoch, vouchExp,
  allowInsecureOrigin, nowUnix,
);
```

A malformed `registryRef` (bad JSON, unknown `kind`) is refused offline,
before any chain call, as `"BadConfig"`. A resolution failure — an
unreachable RPC's `eth_chainId` read, or a lapsed/invalid enterprise license
— throws `"License"` (agent shims) or `LicenseExpired`/`BadConfig` (verify
shims — see [Errors](errors.md)).

### `resolveRegistry` — same name, two different shapes

⚠️ Two packages export a function with this exact name and **they are not
interchangeable**:

- `grantor-agent-wasm`/`grantor-agent-uniffi` (the **holder** SDK) export an
  **async, RPC-based** `resolveRegistry(registryRef, rpcUrl, nowUnix)` — it
  reads `eth_chainId` over the network to discover the effective chain, then
  resolves. This is what `authenticate()`/holder code use, because a holder
  generally does not already know which chain it is on.
- `grantor-sdk-wasm` (the **standard/verify-adjacent** SDK) exports a
  **synchronous, `chainId`-based** `resolveRegistry(registryRef, chainId,
  nowUnix)` — no network call, because the verifier side already has an
  explicit `chainId` (it needs one for the discovery document regardless).

Importing the wrong one for your use case either gets you a `Promise` where
you expected a string, or an unexpected trailing network round-trip. If your
code already holds a `chainId`, use the sync one; if it only holds an RPC URL,
use the async one.

## `licenseStatus` — introspection, not enforcement

`DeedVerifier` exposes `licenseStatus(nowUnix)`, re-evaluated at the `nowUnix`
you pass:

- **`undefined`/`None`/`nil`** — this verifier resolved `Canonical` or
  `Devnet`, which carry no license to lapse.
- **`"ok" | "expiring_soon" | "grace" | "lapsed"`** — this verifier resolved
  `Dedicated`. Poll this for your own monitoring/alerting; `DeedGuard` also
  logs a rate-limited warning (at most once per interval, never per request)
  once a resolved license enters `"grace"` or `"expiring_soon"`.

`licenseStatus` is pure clock arithmetic over the license the verifier
already resolved at construction — it never makes a chain call and can report
`"lapsed"` without throwing (unlike `verifyAt`, which refuses outright once
the license has lapsed). Use it to know *before* your next caller hits
`LicenseExpired`, not to gate anything yourself.

### `verifyRegistryLicense` — the standalone check

The same license check `Registry.dedicated(...)` resolution runs internally,
exposed as a free function so you can check a license file without wiring up
a chain endpoint at all — useful for a CI job, a support script, or the
`grantor-license verify` subcommand below:

```ts
import { verifyRegistryLicense } from "../../sdk/verify/ts/pkg/grantor_verify_wasm.js";

const state = verifyRegistryLicense(licenseJson, chainId, registryAddress, nowUnix);
// -> "ok" | "expiring_soon" | "grace", or throws "LicenseExpired" / "BadConfig"
```

Python: `verify_registry_license`. Go: `VerifyRegistryLicense`. Rust:
`grantor_sdk_core::verify_registry_license`.

## The `grantor-license` operator tool

An operator-only binary (`publish = false`, not published anywhere) that
issues, tracks, and checks license grants. It never talks to a chain and
never phones home — the operator's whole visibility into outstanding
licenses comes from a **local, git-ignored JSONL ledger** it writes to,
honoring the product's no-SDK-telemetry rule.

```
grantor-license keygen --out <path>
```
Generates a fresh ed25519 operator license key (see the key-ceremony note
below — this is NOT the deploy/treasury ceremony). Writes a `0600` hex seed
to `<path>` and prints `pubkey: <hex>`.

```
grantor-license issue --key <path> --licensee <label> --chain <id> \
  --registry <address> --expires-days <n> [--grace-days <n>] \
  [--iat <unix>] [--ledger <path>]
```
Signs a grant with the operator key, prints the pretty-JSON `RegistryLicense`
to stdout (this is the one file you hand to the customer), and appends a
record to the ledger (default `license-ledger.jsonl` in the current
directory). `--grace-days` defaults to 30; `--iat` defaults to now.

```
grantor-license status [--ledger <path>] [--now <unix>]
```
Reads the ledger and prints one line per issued license — `<licensee>
chain=<id> registry=<addr> exp=<unix> state=<state>` — so the operator knows
who to contact before a turnkey engages. A missing ledger is treated as
empty; a malformed line is skipped with a stderr warning, not a hard failure.

```
grantor-license verify --file <path> --chain <id> --registry <address> [--now <unix>]
```
Full verification (signature, ceilings, time state) of any grant file —
`state: <state>` on success, `error: <message>` on failure.

**Exit codes:** `status` exits `0` for every license state, including
`lapsed` — it is a *report*, and a report is not a failure. `keygen`,
`issue`, and `verify` exit `1` on a genuine problem (malformed/missing
required flags, a signature that does not verify, a scope mismatch); `verify`
in particular exits `1` for `LicenseExpired` the same as any other
verification failure — a lapsed license IS the failure `verify` exists to
report, unlike `status`'s survey of everything issued so far.

## The dedicated deployment

Everything above this section is the SDK-side half of the story — the
license artifact, its ceilings, and how a verifier or holder resolves it.
The other half lives on-chain: a `Registry.dedicated(address, license)`
address is not just any address you're licensed to name — for the
productized offering (sub-model A) it is a **contract deployed specifically
for that one enterprise**, `GrantorRegistryDedicated`, not a second tenant
squeezed onto the shared `GrantorRegistry`. The full operator-side runbook
— deploy, onboarding, steady-state operation, the kill procedure — is
[Enterprise dedicated deployment](../deploy/dedicated.md); this section is
the guide-side summary of what that separate contract buys you.

**The operator owns the venue; the enterprise owns its tenancy.** The
operator retains `owner` (an operator multisig) and the immutable
`treasury` (an operator Safe) on the dedicated instance — exactly the two
powers it holds on the shared registry. Everything tenant-shaped (tenant
creation, funding, agent/user registration, origin vouches, issuer keys) is
the enterprise's own, administered on their contract precisely as it would
be on the shared one. The only thing a dedicated deployment adds beyond
"your own address" is one extra owner power the shared registry
deliberately does not have — the kill-switch below.

### Two levers, two postures

| | License (this page, above) | Timelocked on-chain kill (dedicated only) |
|---|---|---|
| Nature | Routine — a renewal ladder: `ok` → `expiring_soon` → `grace` → hard `LicenseExpired` refusal | Nuclear — a deliberate owner action, not a clock lapsing on its own |
| Where it's checked | Offline, inside the SDK, on the caller's own clock | On-chain, inside `status()` itself |
| Survives an SDK fork? | **No** — a determined fork can patch the license check out (see "Enforcement posture" below) | **For routine forks, yes** — a fork that merely skips the offline license check still dies on the chain read. A fork that also strips the `status()` billing gate is the same licensee-only seam as patching the license check (see "Enforcement posture" below), covered by the same legal backstop. The kill raises the bar for a fork to succeed, but enforcement ultimately depends on the deployment's own integrity, not on code structure. |
| Reversible? | Renew before lapse, or don't | Cancelable up to the moment it matures (`cancelKill()`); re-announcing after a cancel restarts the **full** notice window, never a shortened one |

The honest framing: **a fork that keeps fail-closed verification survives the
license but not the chain read; a fork that also strips the `status()` billing
gate altogether is the same licensee-only seam as patching the license check
itself.**
The license lever is enforcement-as-legal-backstop, exactly as described
below — bypassable by anyone willing to patch their own copy of the SDK.
The kill-switch is not a check the SDK performs at all; it's a fact about
what the contract returns, and every verifier's fail-closed behavior on an
unreachable or adverse chain read was already load-bearing before this
feature existed. What the kill adds is that a routine fork (one that merely
skips the offline license check) still dies on the chain read; a fork that
also strips the `status()` billing gate altogether is possible, but is no
different structurally from patching the license check (the same licensee-only
seam, covered by the same legal backstop).

### Notice-as-code

`GrantorRegistryDedicated.killNotice` is set once, in the constructor, and
is **immutable** for the life of the contract — floor 7 days, typically
negotiated at 30. `announceKill()` starts the window
(`killEffectiveAt = block.timestamp + killNotice`, emitting
`KillAnnounced(effectiveAt)`); `status()` is unchanged for every tenant
until that timestamp is reached, at which point it reports `Inactive` for
**all** of them, overriding whatever their individual billing state was.
`cancelKill()` cures it. The contractual notice clause an enterprise
negotiates becomes a value their own monitoring can read back from the
chain the moment an announcement lands — not a promise dependent on the
operator's word after the fact. See
[Enterprise dedicated deployment § The kill procedure](../deploy/dedicated.md#5-the-kill-procedure)
for the exact calls and the billing/non-custody guarantees that survive a
completed kill (undrawn balance stays withdrawable regardless).

## Enforcement posture — read this before relying on it commercially

**Structural API + license-as-legal-backstop, not cryptographic prevention.**
The SDK runs on the customer's own machine; a determined fork can patch the
check out. This is accepted, deliberately, for the same reason the SDK's
license is proprietary rather than open source: **the commercial deal rests
on the license, not on secrecy.** Rejected alternatives (an on-chain
endorsement registry, a per-boot ZK membership deed) all cost real
complexity for enforcement that stays exactly as bypassable — see the design
doc's "enforcement posture" section for the full reasoning.

Two consequences worth being explicit about:

- **The gate lives in `DeedVerifier` (and the mint-side equivalents), not
  underneath them.** A Rust integrator who composes the lower-level pieces
  directly — a bare `DeedGuard`, `verify_deed_claims`, a hand-rolled
  `AlloyGate` — bypasses the license check entirely, because those functions
  take a resolved address, not a `RegistryRef`. This seam is *permitted*, not
  a hole to close: it exists for the licensee's own use (composing your own
  verification pipeline against a registry you're licensed for), not as an
  invitation to route around the license you didn't buy.
- **The license clock is enforcement against lapse, never a security
  boundary.** Every timestamp check (`now`) is caller-supplied, exactly like
  every other clock in this SDK (origin vouches, deed `exp`). It stops an
  unlicensed or lapsed deployment from continuing to authenticate; it proves
  nothing about the deployment's honesty otherwise, and should never be
  described as one.

**This is why the dedicated deployment (above) pairs the license with a
second, structurally different lever instead of trying to make the license
itself unforkable.** A bypassable, offline, SDK-side check and a chain read
every fail-closed verifier already depends on are not the same kind of
control, and stacking them buys something neither buys alone: routine
lifecycle management (renewal, warning, grace) stays cheap and
transaction-free, while the one power that must survive a hostile fork —
cutting off a specific dedicated instance for cause — lives somewhere no
SDK patch can reach. Sub-model B (an enterprise's own or a permissioned
chain) does not get this pairing "for free" the way sub-model A does: if
the enterprise controls consensus on that chain, the on-chain kill is
illusory (they can simply not include the transaction that would enforce
it), so its real levers are the license grant and the signed agreement
only — see [Enterprise dedicated deployment § Sub-model
B](../deploy/dedicated.md#6-sub-model-b-bespoke-only).

## admin-sig / smart-wallet verification through a dedicated registry

`verify_admin_deed`/`verify_admin_deed_smartwallet` verify against whatever
`RegistryRef` the `DeedVerifier` instance was constructed with — including
`Dedicated`. If your enterprise deployment runs its own admin-sig-style
dashboard login against your dedicated registry, that verification is
license-gated exactly like every other mode.

This does **not** recreate the pay-page lockout `admin-sig` was designed to
avoid (see `docs/superpowers/specs/2026-07-26-grantor-runs-on-grantor-design.md`):
license renewal is a commercial process between you and Grantor, handled
entirely out-of-band of your own dashboard — it is not gated behind anything
your dashboard's admin needs to be logged in to reach. A lapsed *tenant
billing* status on your own registry and a lapsed *SDK license* to point at
that registry at all are two independent things; only the latter is what
this page is about.

## A DX gotcha: a typo'd address looks like a license error

If you pass an address to `Registry.dedicated(address, license)` that does
not *exactly* match what the license's scope names — a single wrong hex
digit is enough — you will not see an "invalid address" error. Address
syntax is checked first and passes fine (it's a well-formed address, just
the wrong one); what fails is the SCOPE comparison inside license
verification, so the error you get names the license
(`"license scope \"registry:1:0x…\" does not cover \"registry:1:0x…\""`),
not the address field you actually mistyped. If a dedicated construction is
refusing and the license *looks* right, diff the address you passed against
the address the license was issued for, character for character — the
license itself is very likely fine.

## Key ceremony

The operator license key is generated in its own offline ceremony, separate
from the registry deploy/treasury ceremony — see the "License key" section
of `docs/strategy/2026-08-02-key-ceremony.md` (operator-internal; not
published as part of this site) for the exact procedure: generate with
`grantor-license keygen` on a non-networked machine, append the pubkey to
`OPERATOR_LICENSE_PUBKEYS` in `crates/grantor-sdk-core/src/license.rs`, ship
in the next SDK release. Rotation is a released-SDK event, not a chain
transaction — there is no on-chain state for this key at all.

**The devnet test key is not an operator key.** `grantor-sdk-core` ships a
well-known, committed-on-purpose test signing key (seed `[0x42; 32]`) whose
signatures are accepted **only** when the effective chain id is a devnet id
(`31337`/`1337`) — this is what lets the e2e suites and `just devnet`
exercise the entire dedicated-registry path with zero real ceremony. On any
other chain id, `verify_registry_license` refuses a test-key signature
outright, independent of everything else about the license (this is a
required negative test in the conformance vectors). Never treat the test key
as a template for anything beyond a local devnet.

## See also

- [Getting started](getting-started.md) — the standard path (`Registry.canonical()`).
- [Develop locally](local-devnet.md) — `Registry.devnet(...)`, and why the
  canonical map is empty until mainnet launch.
- [Errors](errors.md) — `LicenseExpired`/`WrongChain` and their exact HTTP
  status and ordering.
- [Sovereign tier](../sovereign-tier.md) — the full verification reference.
- [Enterprise dedicated deployment](../deploy/dedicated.md) — the
  operator-side runbook: deploy, onboarding, steady state, and the kill
  procedure.
- `docs/superpowers/specs/2026-08-09-enterprise-registry-licensing-design.md`
  — the full design record, including rejected alternatives.
- `docs/superpowers/specs/2026-08-10-dedicated-deployment-design.md` — the
  dedicated-deployment design record.
