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 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. 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 ids31337/1337. The first chain interaction self-checkseth_chainIdand refuses closed on any other chain — structurally unusable in production. This is what every example, the forge suites, andjust devnetuse.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 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)#
// 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),
);
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()),
)
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()),
)
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, ®istry, 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). 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:
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).
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-basedresolveRegistry(registryRef, rpcUrl, nowUnix)— it readseth_chainIdover the network to discover the effective chain, then resolves. This is whatauthenticate()/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-basedresolveRegistry(registryRef, chainId, nowUnix)— no network call, because the verifier side already has an explicitchainId(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 resolvedCanonicalorDevnet, which carry no license to lapse."ok" | "expiring_soon" | "grace" | "lapsed"— this verifier resolvedDedicated. Poll this for your own monitoring/alerting;DeedGuardalso 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:
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; 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 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 bareDeedGuard,verify_deed_claims, a hand-rolledAlloyGate— bypasses the license check entirely, because those functions take a resolved address, not aRegistryRef. 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, deedexp). 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.
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 — the standard path (
Registry.canonical()). - Develop locally —
Registry.devnet(...), and why the canonical map is empty until mainnet launch. - Errors —
LicenseExpired/WrongChainand their exact HTTP status and ordering. - Sovereign tier — the full verification reference.
- Enterprise dedicated deployment — 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.