--- id: capacity title: A deployment stops minting before somebody else's quota does status: open crates: [didbot-pds, didbot-identity, didbot-serve] dependsOn: [zone-scale, account-types] exitCriterion: > A deployment configured with a maximum account count refuses provisioning once that many accounts hold records, names which zone is full, and an operator watching the dashboard saw the number climbing before it refused anything. --- # capacity Agent hostnames resolve through one wildcard record, so the zone holds a handful of records whatever the account count and Route53's record quota is not this deployment's ceiling. What is finite is this server's own: the account store and the write-ahead log, the name pool [zone-scale](zone-scale.md) tracks, and the operator's budget for what an account costs to serve. This epic is the cap on that population, and the number an operator watches before it refuses anything. ## What an account costs, verified [`docs/deployment.md`](../docs/deployment.md) states it plainly: two hostnames per account, one for the DID and one for the handle, and both resolve the moment they are chosen through `*.`. An account costs the zone nothing. The certificate does not add to that per account either: [agent-accounts](agent-accounts.md) commits to one wildcard certificate per zone over ACME DNS-01, so the `_acme-challenge` TXT records it needs and the apex `CAA` are fixed overhead on the zone. What an account does cost is one row in the account store, one repository and its blobs in the write-ahead log, and one ledger entry that outlives it ([name-pools](name-pools.md) is why the ledger only grows). Route53's own quotas — 10,000 records per hosted zone and 500 hosted zones per account by default, both soft (see [Amazon Route 53 quotas](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html)) — bound the number of zones a deployment can hold, not the number of accounts. ## Historical accounts count, not just live ones Every account stays until the operator deletes it. The document a DID serves has to keep answering for as long as a reader might hold a reference to it — an `at://` URI copied out of a log, a citation, a mention — which is longer than the session that minted the account. An account whose DID document must still answer still holds its row, whether or not anything is presently running as that account. So the population this cap is checked against is not "live sessions right now." It is every account whose document still answers: - **Live** (provisioned, in use or idle): a row and a repository held. - **Erased** (its data gone, its document still answering because something might still reference it): a row held. This is why "historical" is the right word for the population that matters here, not "active." - **Removed** (the operator's hard delete, or the sweep taking a row a killed create left): nothing held. The name itself moves to `NameRegistry`'s `Released` hold, which is a name-pool cost tracked in [zone-scale](zone-scale.md), not a cost tracked here. - **Reserved** (a zone apex, or an operational block in `NameRegistry`): nothing held. A reservation is a name held out of the naming pool; it never had a row to begin with. A cap that only ever counted the first bucket would let a deployment mint past what it can serve, because the erased bucket only shrinks when the operator deletes something. ## The tasks - [ ] **The cap counts the historical population, not the live one.** Whatever counts against `max_accounts` has to include every account whose document still answers — live and erased — and exclude only the ones actually removed. Getting this wrong in either direction is a real failure: undercounting lets provisioning walk past the real ceiling, and overcounting (counting a `Released` name hold, which has no row) refuses provisioning that would have succeeded. - [ ] **Publish occupancy so it is watched, not discovered.** [zone-scale](zone-scale.md) already asks for `bot.did.stats` to carry how much of a zone's name pool is spoken for; this epic adds the same for the cap, making sure the number it publishes is the historical count above, not a live-session count. [ops-dashboard](ops-dashboard.md) is where an operator watches it — a panel showing accounts held against the configured cap. [alerts](alerts.md) is the channel for a warning ahead of the wall, on the same terms as every other quietly-failing condition it already carries: a threshold crossing, not a full-zone event, because thirty seconds' notice at 99.9% full is not what an operator needs. No fourth mechanism belongs here; all three already exist for exactly this purpose. - [ ] **Subzones are the real answer, and they only work if chosen early.** Each zone in `didbot_identity::ZoneRegistry` carries its own name pool and its own cap, so a deployment routing agents across several zones multiplies what it can hold by the number of zones it configures. That only helps a deployment that adopts a naming scheme with subzones in it before it needs the capacity: a DID is permanent, so an agent already minted at `claudes.pds.did.bot` cannot be moved under a new subzone later without breaking every reference to it. A deployment expecting to grow past a few thousand accounts should choose its subzone layout — by team, by agent type, by cohort, whatever the deployment's own axis is — at first configuration, not when the occupancy panel turns amber. This is the single most useful thing an operator sizing a deployment can be told, and it belongs in the operator-facing documentation this epic's config work produces, not only in this file. - [ ] **Decide, and implement, what happens at the wall.** Three answers, not mutually exclusive with each other in principle but a deployment has to pick one to start: refuse provisioning outright; refuse and name which zone is full, so an operator knows which subzone to add capacity to or which to route around; or place the account into another configured zone automatically. The third interacts with the rule [zone-scale](zone-scale.md) already established for `ZoneRouter`: nothing a request asserts may choose its own zone, so automatic placement at the wall has to be the router's decision from the same admission facts it already uses (harness, agent type), not a fallback a caller can trigger by exhausting the zone it wanted. Refusing and naming the full zone is the smallest correct answer and the one to build first; automatic placement is worth having once there is more than one non-default zone actually configured to place into. - [ ] **Hosted zones per account is a second, higher ceiling worth stating once, not tracking continuously.** Five hundred zones (the default, also raisable) times a zone's configured cap is a population no deployment this project is aware of is near. It is not worth a config setting or a dashboard panel; it is worth one sentence in the operator documentation this epic's config section produces, so a deployment that is somehow near it does not have to rediscover the number. - [ ] **Bluesky's relay admits less from a new host than this cap allows.** Its limits for a new host are 100 accounts, and 50 events a second, 2,600 an hour and 21,000 a day ([rate limits](https://bsky.network/docs/rate-limits/)), against a `DEFAULT_MAX_ACCOUNTS` of 1000. Each boot also re-announces every account, one `#account` frame apiece (`Provisioner::announce_accounts` in `crates/didbot-pds/src/provision/events.rs`), and the hourly budget covers those frames like any other event: at the cap one boot spends 1,000 of the hour's 2,600, so a third boot inside the hour passes it ([#154](https://tangled.org/did:plc:swttlkbjvcoln67iievud7b3/issues/154)). [relay-sync](relay-sync.md) proposes what a boot sends instead. ## Done - [x] **A configured cap on accounts, with a sane default.** `--max-accounts` or `[capacity] max_accounts` sets it, and `DEFAULT_MAX_ACCOUNTS` is 1000, a cap an operator raises deliberately. `create_account` refuses with `503 AccountCapReached`, naming the cap, before it parses the request or writes anything, so a caller never sees a `ChangeResourceRecordSets` failure after the keys and the first hostname exist. The cap is one number for the whole deployment. It is checked against `Registry::account_count`, the account store's size, which is cheap enough to read on every request.