docs / deploy

view as .md

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#

VarMeaning
PRIVATE_KEYDeployer key. Also the owner unless OWNER is set, so tier config runs in the same broadcast.
KILL_NOTICE_SECSThe 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_FEEFee in USDC (6 decimals) per billing period.
TIER_MAX_APPSMax apps per tenant.
TIER_MAX_ISSUER_KEYSMax 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_AGENTSMax agent-zk registrations.
TIER_MAX_ADMINSMax tenant admins.
TIER_MAX_USERSMax 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#

VarDefaultNotes
TREASURYnoneRequired 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.
OWNERthe deployerShould be the operator multisig for anything real; the deployer key is fine only for the local proof run.
GRACE_WINDOW3 days (259200)Grace after a billing period lapses, before status() reports the tenant Inactive for billing reasons — unrelated to the kill-switch.
USDCdeploys a fresh MockUSDC on a devnet chain id onlyOn any other chain id, an unset USDC is refused — point it at the chain's real USDC.
TIER_PERIOD_SECS30 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. drawPeriod is external with 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 prints state per issued license (ok / expiring_soon / grace / lapsed), so you know who to contact before a license lapses into a LicenseExpired refusal on their side (see Enterprise registries § Runtime semantics).
  • Point grantor-watch at 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, because GrantorRegistryDedicated inherits 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).

StepCallEffect
AnnounceannounceKill() (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.
CurecancelKill() (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. killNotice is 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. withdrawBalance checks only tenant-admin authorization, never status() — 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 KillAnnounced gap noted in §4.
  • Testnet deployment runbook — the shared-registry equivalent of this page.
  • ErrorsTenantInactive, LicenseExpired, and every other code a verifier can return.

This page is also served as Markdown — agents should read that. The whole tree is indexed for machines in llms.txt.