Enterprise dedicated deployment runbook#
Operator-facing. This is what you, the operator, run to stand up and administer one enterprise's dedicated registry. The enterprise's own SDK configuration is Enterprise registries — read that page for
Registry.dedicated(address, license)and everything downstream of the address + license handoff.
1. What this is#
An enterprise buys a dedicated deployment of the Grantor registry: you deploy GrantorRegistryDedicated once, keeping owner (your multisig) and treasury (your Safe), and hand the enterprise exactly two artifacts — the contract address and a signed registry license scoped to (chainId, address). The enterprise points Registry.dedicated(address, license) at that address in its own verifiers and agents, and administers its own tenancy on the contract (tenant creation, funding, agent keys, ZK trees, origin vouches) exactly as on the shared registry.
You own the venue; the enterprise owns its tenancy; the license is the ticket that lets their SDKs point at the venue at all. No account, no credentials on either side — onboarding is an address and a file.
GrantorRegistryDedicated is identical to the shared GrantorRegistry in every tenant-facing behavior, plus one additional owner power: a timelocked, cancelable kill-switch (§5). Design record: docs/superpowers/specs/2026-08-10-dedicated-deployment-design.md.
2. Deploy#
just deploy-dedicated "$RPC"
Equivalent, if you're not using just:
cd contracts && forge script script/DeployDedicated.s.sol --rpc-url "$RPC" --broadcast -vv
This deploys one GrantorRegistryDedicated and configures one enterprise tier (tier 1) with the negotiated numbers. There is no trial tier and no default tier config — a dedicated deploy happens at deal time, when the negotiated numbers already exist.
Required env — no defaults, deal-time only#
| Var | Meaning |
|---|---|
PRIVATE_KEY | Deployer key. Also the owner unless OWNER is set, so tier config runs in the same broadcast. |
KILL_NOTICE_SECS | The kill-switch notice window. Ctor reverts under 604800 (7 days) — that floor is the entire point of §5. Typical negotiated value: 30 days (2592000). |
TIER_FEE | Fee in USDC (6 decimals) per billing period. |
TIER_MAX_APPS | Max apps per tenant. |
TIER_MAX_ISSUER_KEYS | Max issuer keys per tenant (irrelevant to the sovereign path, but the same tier-config shape as the shared registry — leave it a sane number). |
TIER_MAX_AGENTS | Max agent-zk registrations. |
TIER_MAX_ADMINS | Max tenant admins. |
TIER_MAX_USERS | Max user-zk registrations. |
None of these have a fallback. If any is unset the script reverts on the missing env var — deliberately; there is no "reasonable default" for a number that was supposed to come out of a signed agreement.
Optional env#
| Var | Default | Notes |
|---|---|---|
TREASURY | none | Required off a local devnet chain id (31337/1337) — the script refuses to guess it. treasury is immutable; this must be the operator Safe, never a hot deploy key. |
OWNER | the deployer | Should be the operator multisig for anything real; the deployer key is fine only for the local proof run. |
GRACE_WINDOW | 3 days (259200) | Grace after a billing period lapses, before status() reports the tenant Inactive for billing reasons — unrelated to the kill-switch. |
USDC | deploys a fresh MockUSDC on a devnet chain id only | On any other chain id, an unset USDC is refused — point it at the chain's real USDC. |
TIER_PERIOD_SECS | 30 days (2592000) | Length of one billing period. |
The console output logs USDC, the new registry address, treasury, owner, and killNotice. Save the registry address — it's one of the two artifacts you hand off.
3. Onboard the enterprise#
The enterprise's own admin key does this, on the dedicated contract, exactly as on the shared registry (no MockUSDC minting off a devnet chain — this is their real USDC balance):
cast send $REGISTRY "createTenant(address,uint8)" $ENTERPRISE_ADMIN 1 \
--private-key $ENTERPRISE_ADMIN_KEY --rpc-url "$RPC"
# -> onchain tenant id, typically 1 (the first tenant on a fresh dedicated deploy)
cast send $USDC "approve(address,uint256)" $REGISTRY $FUND_AMOUNT \
--private-key $ENTERPRISE_ADMIN_KEY --rpc-url "$RPC"
cast send $REGISTRY "topUp(uint256,uint256)" $TENANT_ID $FUND_AMOUNT \
--private-key $ENTERPRISE_ADMIN_KEY --rpc-url "$RPC"
cast send $REGISTRY "drawPeriod(uint256)" $TENANT_ID \
--private-key $ENTERPRISE_ADMIN_KEY --rpc-url "$RPC" # -> status Active
You (the operator) issue the license, ledgered so you know what you've promised and to whom:
grantor-license issue --key operator-license.key --licensee "Acme Corp" \
--chain $CHAIN_ID --registry $REGISTRY \
--expires-days 365 --grace-days 30
Hand the printed JSON to the enterprise as license.json — that, plus the address, is the entire handoff. The enterprise configures Registry.dedicated($REGISTRY, license) in its own verifiers and agents; see Enterprise registries § Using it for the exact call in all four languages. From here their tenancy is theirs to administer — agent/user registration, origin vouches, issuer keys (if any) — identically to the shared registry, just on their own contract instance.
4. Steady state#
- Billing renews itself.
drawPeriodisexternalwith no access control — anyone (a keeper, a cron job, the enterprise itself) can call it once a period lapses, and it draws from the tenant's prepaid balance. You run nothing persistent for this. - License renewal cadence is driven off
grantor-license status --ledger <path>— it printsstateper issued license (ok/expiring_soon/grace/lapsed), so you know who to contact before a license lapses into aLicenseExpiredrefusal on their side (see Enterprise registries § Runtime semantics). - Point
grantor-watchat the address on both sides — its admin-set, billing, agent/user-registry and origin-epoch drift detection (Chain watcher) works unmodified on a dedicated deployment, becauseGrantorRegistryDedicatedinherits every one of those events verbatim.
⚠️ grantor-watch does not decode KillAnnounced/KillCanceled today — those events exist only on GrantorRegistryDedicated, not the shared GrantorRegistry interface grantor-watch was built against. Until that's added, watch the kill-switch directly:
cast logs --address $REGISTRY --rpc-url "$RPC" "KillAnnounced(uint64)"
cast logs --address $REGISTRY --rpc-url "$RPC" "KillCanceled()"
The enterprise should run this (or an equivalent poller) themselves — the whole point of §5 is that the announcement is visible on-chain the instant it lands, independent of anything the operator tells them.
5. The kill procedure#
GrantorRegistryDedicated adds one owner power the shared registry does not have: announceKill() / cancelKill(), gated by an immutable notice window (killNotice, floor 7 days).
| Step | Call | Effect |
|---|---|---|
| Announce | announceKill() (owner-only) | killEffectiveAt = block.timestamp + killNotice; emits KillAnnounced(effectiveAt). Visible on-chain instantly. status() is unchanged during the window — every tenant stays exactly as Active/Grace/Inactive as it already was. |
| Cure | cancelKill() (owner-only) | killEffectiveAt = 0; emits KillCanceled(). Fully restores — a re-announceKill() afterward starts the full notice window over again, never a shortened one. |
| Maturity | (automatic, no call) | Once block.timestamp >= killEffectiveAt, status() returns Inactive for every tenant on the instance, overriding whatever their individual billing state was. Every verifier that reads it fails closed with TenantInactive (401 — see Errors). |
cast send $REGISTRY "announceKill()" --private-key $OWNER_KEY --rpc-url "$RPC"
cast call $REGISTRY "killEffectiveAt()(uint64)" --rpc-url "$RPC"
cast send $REGISTRY "cancelKill()" --private-key $OWNER_KEY --rpc-url "$RPC" # cure
Two things worth being explicit about:
- The notice period cannot be shortened after the fact.
killNoticeis set once, in the constructor, and is immutable for the life of the contract — the contractual notice clause becomes code the buyer's own monitoring can watch, not a promise you could quietly revise. - Non-custody survives a completed kill.
withdrawBalancechecks only tenant-admin authorization, neverstatus()— a killed tenant's undrawn, unconsumed prepaid balance stays withdrawable to an address the tenant admin chooses. A kill stops future authentication; it does not lock anyone's money — undrawn balance stays withdrawable at every stage.
Enterprise action: protecting prepaid balance during a kill#
When the enterprise sees KillAnnounced on-chain, they should take two steps to protect their undrawn balance:
Step 1: Disable auto-renewal.
drawPeriod is permissionless and will keep drawing tenantFee per period to the operator's immutable treasury even after a kill matures — charging for service that will not be rendered. Call setAutoRenew(false) (tenant-admin-gated) to stop the automatic draws:
cast send $REGISTRY "setAutoRenew(uint256,bool)" $TENANT_ID false \
--private-key $ENTERPRISE_ADMIN_KEY --rpc-url "$RPC"
Step 2: Withdraw undrawn balance once you stop needing periods.
After the kill matures and authentication stops, retrieve any remaining prepaid balance. First, query the current balance:
cast call $REGISTRY "tenants(uint256)" $TENANT_ID
This returns a tuple (tier, periodEnd, balance, appCount, agentCount, userCount); the third field is balance (the withdrawable amount in USDC accounting units). Then withdraw it:
cast send $REGISTRY "withdrawBalance(uint256,uint256,address)" $TENANT_ID \
$BALANCE_AMOUNT $RECIPIENT_ADDRESS \
--private-key $ENTERPRISE_ADMIN_KEY --rpc-url "$RPC"
Operator side: once KillAnnounced lands, the operator should stop (or scale down) any keeper that calls drawPeriod on that deployment — the announced kill makes every future period worthless to the tenant, so continuing to draw is billing churn without service.
Second cure path: if the kill is announced and then matures, a fresh announceKill() call (re-announcing) will restart the notice window in full, during which status() returns to its billing-derived value and service resumes — effectively an operator-side undo that lasts until the new maturity. cancelKill() remains the true cure.
6. Sub-model B (bespoke only)#
Everything above is sub-model A: a dedicated contract on the same public L2 you already deploy on, with real USDC drawPeriod billing — both levers (license routine, on-chain kill) are real there, because the chain's consensus is nobody's to censor.
Sub-model B — the enterprise's own chain, or a permissioned chain they control — is bespoke only, and stays that way on purpose. On a chain the enterprise controls, they control consensus, so an on-chain kill is illusory: they can simply decline to include the transaction that would enforce it. status() failing closed only means something when the read itself is trustworthy. B's real levers are the license grant and the signed agreement, full stop — no on-chain enforcement is claimed for it and none should be. No B tooling exists, deliberately: building a kill-switch that doesn't actually constrain the party it's aimed at would be worse than no tooling at all — it would look like a control instead of being one.
7. No pricing numbers anywhere#
Every number in this runbook (TIER_FEE, the caps, KILL_NOTICE_SECS, license --expires-days/--grace-days) is a deal-time parameter, decided per enterprise and passed as an env var or CLI flag at deploy/issue time. Nothing here — or anywhere else in this repo — publishes what any of those numbers should be. Pricing is deferred entirely to the first deal.
See also#
- Enterprise registries — the SDK-side
RegistryRef/license mechanics this runbook hands off into. - Chain watcher — admin/billing drift detection, and the
KillAnnouncedgap noted in §4. - Testnet deployment runbook — the shared-registry equivalent of this page.
- Errors —
TenantInactive,LicenseExpired, and every other code a verifier can return.