--- id: zone-scale title: The zone runs out of names before it runs out of accounts status: open crates: [didbot-pds, didbot-name, didbot-dns] dependsOn: [agent-accounts] exitCriterion: > A deployment holding a hundred thousand live accounts issues a name and publishes a hostname without failing, and an operator can see how close it is to the next limit before reaching it. --- # zone-scale An account costs a name and a hostname, and both are finite in ways the rest of the system is not. The default namer is `mineral+creature`, which is 128 words against 112: about fourteen thousand names. [`Naming::issue`](../crates/didbot-pds/src/names.rs) makes sixteen random attempts before giving up, so the failure rate is occupancy to the sixteenth — negligible at half full, around three per cent at eighty, around nineteen at ninety. The usable ceiling is therefore near eleven thousand simultaneously held names, not fourteen thousand, and provisioning starts failing intermittently rather than stopping. With names released after thirty days, "held" tracks churn. Keep accounts instead, as [account-types](account-types.md) proposes, and it tracks the cumulative population inside the retention window. Adding word lists moves this a long way for almost nothing, which is the point of writing the numbers down: the fix is cheap and knowing when to apply it is not free. - [ ] **Publish occupancy.** `bot.did.stats` can carry how much of the pool is spoken for, so exhaustion is something an operator watched approaching rather than something a provisioning discovered. - [ ] **Decide what happens at exhaustion.** Numbered variants, a wider spec, or falling back to the account's own hostname. Failing the provisioning is also an answer and is the current one by default rather than by choice. - [ ] **Records in the zone, against what a provider will hold.** This item is conditional on the provider: under `WildcardDns` — the shape [deployment](../docs/deployment.md) documents — the zone holds three records whatever the account count, and none of this applies. It binds only on a deployment publishing per-agent records, where every retained account is a retained record, published inline during provisioning. Hosted zones have limits, some of them raisable and some not, and this is the first place a deployment meets somebody else's quota. `Route53Dns`'s module docs now say what Route53's own default quota is and that nothing here enforces it locally — the API error surfaces as `DnsError::Backend` — but there is still no occupancy an operator can watch approaching it, which is what this item is really asking for. - [ ] **`did:web` resolution has nothing in front of it.** Every resolution by every stranger is an HTTPS request to this server for a distinct hostname. `did:plc` has a directory absorbing that load and this method has none, so read volume scales with how interesting the accounts are rather than with how many there are. - [ ] **One wildcard certificate, and what happens when it is not enough.** Per-account issuance exhausts a certificate authority, which [agent-accounts](agent-accounts.md) already says. A second subzone for accounts that are not agents is another name on the same certificate and should be planned for rather than discovered. **The zone model this needed now exists** (see Done, below): a deployment can hold several zones, and `MultiZoneDns` routes each one to its own `DnsProvider`. What is still open is the certificate side itself — actually obtaining and rotating a `` + `*.` pair per configured zone, over ACME DNS-01, which is [agent-accounts](agent-accounts.md)'s item and belongs with the deployment tooling that issues certificates today. ## Done - [x] **Bigger pools, and say what each spec is worth.** `didbot-name` now offers generated specs alongside the word lists, in a new `generated` module, and each one states its pool size in its own doc comment — the module docs list them together: | spec | pool size | leaks | | --- | --- | --- | | `mineral+creature` (word pair) | ~14,000 (usable ceiling ~11,000; see the arithmetic above) | nothing | | `Counter` (`counter`) | unbounded (`u64::MAX`) | the deployment's total mint count | | `Timestamp` (`timestamp`) | one per clock tick | roughly when each agent was minted | | `Uuid` (`uuid` / `uuid-compact`) | 2^122 | nothing | | `Random` (`random32:` / `random36:`) | `alphabet_size^n`, e.g. 36^10 ≈ 3.6 × 10^15 | nothing | `Random::pool_size` and `Fragments::capacity` both exist for the same reason: a deployment choosing a spec can read the number rather than compute it, and `describe()` folds it into the log line every namer already writes at startup. Every generated label goes through `didbot_name::check`, which no longer hand-maintains the DNS-label character rules: it calls `didbot_identity::validate_label` — now `pub`, and the same function `AgentDid::mint` checks an agent id against before it becomes part of a `did:web` identifier — so there is one legality rule for both places a label has to be legal, not two that could disagree. A UUID's canonical form is emitted with its hyphens in exactly the positions that rule allows; no other encoding (base64, uppercase) is offered, on purpose. `Counter` cannot collide with itself, so it declares `Namer::max_useful_attempts() == Some(1)` — a new default method on `Namer`, `None` for every other namer — and `Naming::issue` honours it rather than spending the usual sixteen-attempt budget on a namer that cannot need it. The counter survives a restart. `didbot-pds::names::DurableCounter` writes the value it is about to hand out into the write-ahead log *before* returning it — the same check-append-apply order `NameRegistry` already keeps for name claims — and replay resumes the counter past every value a previous run promised. That is a new `Entry` variant (`CounterAdvanced`), so it bumped `layout::LAYOUT` — which has moved several times since and reads 9 today, so do not take a number from this entry. `didbot-pds`'s `--names counter` shares the durable counter with `--names-if-down counter`, and runs in-memory, restarting at zero, without `--data` — the same split every other durable-optional store here makes. - [x] **Withdrawal is a write too.** `Route53Dns::withdraw_many` batches up to 100 `DELETE` changes into one `ChangeResourceRecordSets` call rather than one call per host, and retries a throttled batch as a whole with capped exponential backoff rather than retrying host by host. It is a Route53-specific method, not part of `DnsProvider`, because batching only makes sense for a provider that actually pays a per-call cost — the in-process test doubles do not. - [x] **A configurable tree of zones, instead of the one hardcoded field.** `didbot_identity::ZoneRegistry` holds several independently-validated `Zone`s and refuses a duplicate hostname at construction. Nesting — two zones where one's apex sits at or below the other's — is allowed: a wildcard certificate matches exactly one label, so the namespaces genuinely do not intersect except at the inner zone's own apex, and that one point of contact is guarded separately (see below), not by refusing the pair here. `didbot-pds`'s `ZoneManager` is the runtime counterpart: adding a zone checks both directions of the one invariant (an agent already at the apex refuses the zone; a zone already there is why the apex is reserved in [account-types](account-types.md)'s name registry), and removing one is gated on no live account and no unexpired name hold, not on live accounts alone. Creating a zone is also gated on an explicit `dns.may_create_zones` opt-in (default off), because creation writes `NS` records into the parent and needs write access above the zone being created — a stronger grant than serving a zone a human already delegated out of band. `ZoneRouter` is the server-side configuration that decides which zone a provisioning lands in, keyed on the same admission facts (harness, agent type) this project already treats as hook-asserted rather than caller-chosen — `ProvisionRequest` carries no zone field, on purpose; see `plan/agent-accounts.md`'s rule that nothing a hook asserts may widen anything. `MultiZoneDns` is the DNS side: one `DnsProvider` per zone, routed by hostname; it still refuses a nested pair, a stricter rule than `ZoneRegistry` applies today and one this crate has not been revisited to match. **An open question this closed the easy half of, and left the hard half for the owner to overrule.** Two things collide over one hostname, and this closed the direction where a zone already exists: its apex is reserved, so the namer can never mint an agent there. The inverse direction also needed closing — `ZoneManager::add_zone` refuses to create a zone at a hostname a live agent already answers at, naming the DID so an operator knows what to delete first. What is left is the question in between: **may a zone be created at a name still inside its release hold?** Deleting an agent does not free its name immediately — `NameRegistry` holds it `Released` for thirty days precisely so an `at://` URI copied out of a log does not later point at a different agent. The conservative answer treats a zone apex as inheriting that same hold, on the grounds that the hold's whole purpose — a stale reference not silently repointing — applies just as much to a zone suddenly answering where an agent used to. The permissive answer notes that an agent DID and a zone apex are different identifiers, so the collision is weaker than agent-vs-agent and the wait may be needless caution. `ZoneManager::add_zone` implements the **conservative** answer: it refuses while the apex name is held, and reports when the hold expires. This is a judgment call, not a settled one — a deliberate operator override (free the name explicitly, then retry) is a reasonable follow-up and is not implemented. Documented here, per the project's own posture on this kind of decision, so the owner can overrule it rather than discover it.