From 3701f04f59aa3d941fa5bb642efa6fcfce379b18 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Mon, 31 Aug 2026 09:14:42 -0400 Subject: [PATCH] feat(didbot-name): add generated namers for bounded-pool zones Adds Counter, Timestamp, Uuid (canonical and compact), and Random (base32/base36) namers, each documenting its pool size and what it leaks (an ordinal, a mint time, nothing). check() now delegates to didbot_identity::validate_label instead of a hand-rolled copy. Namer gains max_useful_attempts so a single-shot generator like Counter is asked once instead of retried through the 16-attempt loop it can never satisfy differently. plan/zone-scale.md's pool-size table and checklist item are updated to match. Change-Id: I91c126eb8e8a973f3ecdfd2a2e2cb6903cfe0ac4 --- crates/didbot-name/src/generated.rs | 528 ++++++++++++++++++++++++++++ crates/didbot-name/src/lib.rs | 53 ++- plan/zone-scale.md | 47 ++- 3 files changed, 613 insertions(+), 15 deletions(-) create mode 100644 crates/didbot-name/src/generated.rs diff --git a/crates/didbot-name/src/generated.rs b/crates/didbot-name/src/generated.rs new file mode 100644 index 00000000..5b17e878 --- /dev/null +++ b/crates/didbot-name/src/generated.rs @@ -0,0 +1,528 @@ +//! Names that are computed rather than drawn from a list. +//! +//! [`fragments::Fragments`](crate::fragments::Fragments) is finite in a way +//! that matters at scale: `mineral+creature` is 128 words against 112, about +//! fourteen thousand names, and [`Naming::issue`](../../didbot_pds/struct.Naming.html)'s +//! retry loop makes the usable ceiling nearer eleven thousand. See +//! `plan/zone-scale.md`. The namers in this module do not draw from a fixed +//! set at all, so "how many names are left" is either "effectively never +//! runs out" or "as many as the deployment configured", and each one says +//! which in its own docs. +//! +//! Every namer here still goes through [`check`], the same DNS-label +//! validator [`fragments::Fragments`](crate::fragments::Fragments) and +//! [`command::CommandNamer`](crate::command::CommandNamer) use, and that +//! validator is [`didbot_identity::validate_label`] — the same function that +//! decides whether a label can become part of a `did:web` identifier. There +//! is one legality rule, not one for DNS and a second one somebody hoped +//! agreed with it. +//! +//! # What a generated name gives up +//! +//! A word pair leaks nothing about the deployment: `basalt-otter` says +//! nothing about when it was minted or how many agents came before it. Every +//! namer below trades that away for something else, and what it trades away +//! is documented on the type rather than left to be discovered from traffic: +//! +//! * [`Counter`] tells any observer exactly how many agents this deployment +//! has ever minted — the counter's value *is* that count. +//! * [`Timestamp`] tells an observer when an agent was minted, to the +//! resolution the label carries. +//! * [`Uuid`] and [`Random`] leak nothing beyond their own existence: a +//! uniformly random identifier carries no information about the population +//! it was drawn from. These are the private-by-default choice among the +//! generated forms, the same role the word lists play among the fixed ones. + +use std::fmt; +use std::sync::Arc; + +use crate::{check, tidy, NameError, Namer, Seed}; + +/// Where a [`Counter`] gets its next value, and remembers it past a restart. +/// +/// This crate has no notion of disk or of a write-ahead log — see the crate +/// docs — so it does not implement this itself. `didbot-pds` does, backed by +/// the same log [`crate::fragments`]'s deployment already writes claims and +/// releases to: a value is durable before [`CounterSource::next`] returns it, +/// the same check-before-apply order the rest of that log keeps. A counter +/// that only lived in memory would hand out `0` again after every restart, +/// which is not monotonic, it only looks monotonic between restarts. +pub trait CounterSource: Send + Sync + fmt::Debug { + /// The next value, guaranteed not equal to any value this has returned + /// before — including before a restart, if the implementation is durable. + /// + /// Fallible, unlike everything else a namer touches here, because a + /// durable implementation's whole reason to exist is the write reaching + /// disk before the value is handed out: an implementation that could not + /// make that promise this call must say so rather than hand out a value + /// it cannot back. The error is a message rather than a type, so this + /// crate is not made to know the shape of whatever failed underneath it. + fn next(&self) -> Result; +} + +/// A namer backed by a counter that only ever goes up. +/// +/// # Pool size +/// +/// `u64::MAX`, which is not a number a deployment will reach: at one +/// provisioning a second, sustained, it is filled in a little under six +/// hundred thousand centuries. Call it unbounded. It is also the only namer +/// in this crate that cannot collide with itself — every value +/// [`CounterSource::next`] returns is one no earlier call returned — so it +/// asks the caller for at most one attempt; see +/// [`Namer::max_useful_attempts`]. +/// +/// # What it leaks +/// +/// The count itself. `agent-4813` says this deployment has minted at least +/// four thousand eight hundred and thirteen agents, to anyone who can see one +/// label. A deployment that would rather not publish its population uses a +/// word list, [`Uuid`], or [`Random`] instead. +#[derive(Debug, Clone)] +pub struct Counter { + source: Arc, + prefix: String, +} + +impl Counter { + /// A counter namer drawing from `source`, labelled `agent-`. + pub fn new(source: Arc) -> Self { + Self { + source, + prefix: "agent-".to_owned(), + } + } + + /// Uses `prefix` instead of `agent-`. + /// + /// Not validated until the first name is asked for — [`check`] runs on + /// the assembled label either way, so a prefix that would make every name + /// illegal is caught there rather than twice. + #[must_use] + pub fn with_prefix(mut self, prefix: impl Into) -> Self { + self.prefix = prefix.into(); + self + } +} + +impl Namer for Counter { + fn suggest(&self, _seed: &Seed) -> Result { + let value = self + .source + .next() + .map_err(|error| NameError::Failed(format!("counter: {error}")))?; + let name = tidy(&format!("{}{value}", self.prefix)); + check(&name)?; + Ok(name) + } + + fn describe(&self) -> String { + "counter (unbounded; never collides with itself; the label discloses how many agents this deployment has ever minted)".to_owned() + } + + fn max_useful_attempts(&self) -> Option { + // A collision here is not the counter's bad luck to try again for — + // it means something else holds a name the counter never handed out, + // and asking the same source for the same-shaped next value again + // does not change that. One attempt is the whole budget it can use. + Some(1) + } +} + +/// A namer that stamps the current time. +/// +/// # Pool size +/// +/// One name per nanosecond of wall clock, which on most platforms is coarser +/// than it sounds — clock resolution, not `u128` range, is the real bound. +/// Two agents minted in the same tick collide, which is why this namer does +/// *not* claim [`Namer::max_useful_attempts`]: it uses the ordinary retry +/// budget, and folds the attempt number into the label on any attempt past +/// the first so a retry is not the same collision twice. +/// +/// # What it leaks +/// +/// When each agent was minted, to the resolution the label carries. A +/// deployment that publishes hostnames publicly is publishing a rough +/// provisioning timeline alongside them. +#[derive(Debug, Clone, Copy, Default)] +pub struct Timestamp; + +impl Timestamp { + /// A namer that stamps `SystemTime::now()`. + pub fn new() -> Self { + Self + } +} + +impl Namer for Timestamp { + fn suggest(&self, seed: &Seed) -> Result { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_nanos()) + .unwrap_or(0); + let mut name = format!("t{}", to_base36(nanos)); + if seed.attempt > 0 { + name.push('-'); + name.push_str(&seed.attempt.to_string()); + } + let name = tidy(&name); + check(&name)?; + Ok(name) + } + + fn describe(&self) -> String { + "timestamp (one name per clock tick; the label discloses roughly when each agent was minted)".to_owned() + } +} + +/// A namer that draws a fresh UUID (version 4, i.e. all-random) per name. +/// +/// # Pool size +/// +/// 2^122 — 122 bits of randomness after the version and variant bits are +/// fixed, per RFC 9562. The birthday bound on that is far past anything a +/// deployment will ever mint; a collision is not a case this namer plans for +/// any harder than [`Random`] does. +/// +/// # Canonical form only +/// +/// [`Uuid::new`] emits the canonical `8-4-4-4-12` hyphenated lowercase hex +/// form. Its hyphens land in fixed interior positions — never first, never +/// last — which is exactly what [`check`] requires, and that is not a +/// coincidence worth assuming holds for other encodings: the RFC 4648 +/// base64 alphabet includes `+` and `/`, and this crate's own [`Random`] +/// alphabets exist because an *uppercase* rendering would fail the same +/// check that lowercase hex passes. [`Uuid::compact`] drops the hyphens +/// entirely — 32 lowercase hex characters, still legal — for a shorter label; +/// nothing here offers any encoding beyond those two, on purpose. +/// +/// # What it leaks +/// +/// Nothing beyond its own existence. A uniformly random 122-bit value carries +/// no information about how many agents came before it or when it was +/// minted — the private-by-default generated form, alongside [`Random`]. +#[derive(Debug, Clone, Copy, Default)] +pub struct Uuid { + compact: bool, +} + +impl Uuid { + /// Canonical `8-4-4-4-12` hyphenated form, 36 characters. + pub fn new() -> Self { + Self { compact: false } + } + + /// 32 lowercase hex characters, no hyphens. + pub fn compact() -> Self { + Self { compact: true } + } +} + +impl Namer for Uuid { + fn suggest(&self, _seed: &Seed) -> Result { + let mut bytes = rand::random::<[u8; 16]>(); + // Version 4 and the RFC 9562 variant bits. Cosmetic here — nothing + // reads a version out of a hostname label — but a "UUID" that skips + // them is not one, and setting them costs two lines. + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + let hex = to_hex(&bytes); + let name = if self.compact { + hex + } else { + format!( + "{}-{}-{}-{}-{}", + &hex[0..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..32] + ) + }; + check(&name)?; + Ok(name) + } + + fn describe(&self) -> String { + if self.compact { + "uuid v4, compact (2^122 possible names; discloses nothing about the population)" + .to_owned() + } else { + "uuid v4, canonical (2^122 possible names; discloses nothing about the population)" + .to_owned() + } + } +} + +/// Which characters a [`Random`] namer draws from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Alphabet { + /// `234567abcdefghijklmnopqrstuvwxyz` — base32, missing the digits and + /// letters most often confused for each other or for punctuation + /// (`0`/`o`, `1`/`l`/`i`). What an operator reads off a screen and types + /// back is what this is for. + Base32, + /// `0123456789abcdefghijklmnopqrstuvwxyz` — base36, every lowercase + /// letter and digit. Denser than base32 at the same length, at the cost + /// of the ambiguous characters base32 leaves out. + Base36, +} + +impl Alphabet { + fn chars(self) -> &'static [u8] { + match self { + Self::Base32 => b"234567abcdefghijklmnopqrstuvwxyz", + Self::Base36 => b"0123456789abcdefghijklmnopqrstuvwxyz", + } + } +} + +/// A namer that draws a fixed-length random string per name. +/// +/// # Pool size +/// +/// `alphabet_size ^ length`, reported by [`Random::pool_size`] and folded +/// into [`Random::describe`] so a deployment configuring one can see the +/// number rather than compute it: base36 at 10 characters is a little over +/// 3.6 × 10^15, base32 at the same length a little under 1.1 × 10^15. Like +/// any drawn-not-derived namer it can collide, so it takes the ordinary +/// sixteen-attempt retry budget rather than claiming +/// [`Namer::max_useful_attempts`]. +/// +/// # What it leaks +/// +/// Nothing beyond its own existence — see [`Uuid`]'s docs, which apply here +/// unchanged. +#[derive(Debug, Clone)] +pub struct Random { + alphabet: Alphabet, + length: usize, +} + +impl Random { + /// A namer drawing `length` characters from `alphabet`. + /// + /// Refused at construction, not at the first name, for the same reason + /// [`Fragments::new`](crate::fragments::Fragments::new) checks its slots + /// up front: a namer that starts and then cannot produce a single legal + /// name is worse than one that refuses to start. + pub fn new(alphabet: Alphabet, length: usize) -> Result { + if length == 0 { + return Err(NameError::Misconfigured( + "a random name needs at least one character".to_owned(), + )); + } + if length > crate::MAX_NAME { + return Err(NameError::Misconfigured(format!( + "a random name of {length} characters is longer than a DNS label may be ({})", + crate::MAX_NAME + ))); + } + Ok(Self { alphabet, length }) + } + + /// How many distinct names this can produce, before a collision is even + /// possible: `alphabet_size ^ length`. + pub fn pool_size(&self) -> u128 { + (self.alphabet.chars().len() as u128).pow(self.length as u32) + } +} + +impl Namer for Random { + fn suggest(&self, _seed: &Seed) -> Result { + let alphabet = self.alphabet.chars(); + let mut name = String::with_capacity(self.length); + for _ in 0..self.length { + // Not cryptographic and does not need to be: see the "no rolled + // crypto" discussion in `didbot-hookd`'s `agent_id` module, which + // makes the same call for the same reason — a collision here is + // an availability problem, not a security one, since nothing is + // authorized by the label. + let index = (rand::random::() as usize) % alphabet.len(); + name.push(char::from(alphabet[index])); + } + let name = tidy(&name); + check(&name)?; + Ok(name) + } + + fn describe(&self) -> String { + format!( + "random {:?}, {} characters ({} possible names)", + self.alphabet, + self.length, + self.pool_size() + ) + } +} + +/// Renders `value` as lowercase base36, with no leading zero padding. +fn to_base36(mut value: u128) -> String { + const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + if value == 0 { + return "0".to_owned(); + } + let mut out = Vec::new(); + while value > 0 { + out.push(DIGITS[(value % 36) as usize]); + value /= 36; + } + out.reverse(); + String::from_utf8(out).expect("base36 digits are ascii") +} + +/// Lowercase hex, dependency-free — the same call `didbot-hookd`'s +/// `agent_id` module makes for its own encoding, and for the same reason: +/// this is a handful of lines, not a reason to add a crate. +fn to_hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(char::from(DIGITS[(byte >> 4) as usize])); + out.push(char::from(DIGITS[(byte & 0x0f) as usize])); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + /// The zone this crate's tests mint labels under, matching the pattern + /// `didbot-hookd`'s `agent_id` tests use: assert against + /// `didbot_identity::AgentDid::mint` rather than a local copy of the + /// rules, so a test can only pass by agreeing with the actual authority. + fn mints(label: &str) -> bool { + let zone = didbot_identity::Zone::new("agents.example") + .expect("agents.example is a valid zone host"); + didbot_identity::AgentDid::mint(&zone, label).is_ok() + } + + #[derive(Debug)] + struct TestSource(AtomicU64); + + impl CounterSource for TestSource { + fn next(&self) -> Result { + Ok(self.0.fetch_add(1, Ordering::SeqCst)) + } + } + + #[test] + fn a_counter_never_repeats_and_always_mints() { + let counter = Counter::new(Arc::new(TestSource(AtomicU64::new(0)))); + let mut seen = std::collections::HashSet::new(); + for attempt in 0..500 { + let seed = Seed::new("tok", "agents.example").with_parent(None); + let seed = Seed { attempt, ..seed }; + let name = counter.suggest(&seed).expect("counter always succeeds"); + assert!(check(&name).is_ok(), "{name:?} failed check()"); + assert!(mints(&name), "{name:?} did not mint"); + assert!(seen.insert(name.clone()), "{name:?} repeated"); + } + assert_eq!(counter.max_useful_attempts(), Some(1)); + } + + #[test] + fn a_counter_with_a_custom_prefix_still_mints() { + let counter = Counter::new(Arc::new(TestSource(AtomicU64::new(0)))).with_prefix("worker-"); + let seed = Seed::new("tok", "agents.example"); + let name = counter.suggest(&seed).expect("a legal prefix mints"); + assert!(name.starts_with("worker-")); + assert!(mints(&name), "{name:?}"); + } + + #[test] + fn timestamps_mint_and_vary_with_the_attempt() { + for attempt in [0u32, 1, 2, 15] { + let seed = Seed { + attempt, + ..Seed::new("tok", "agents.example") + }; + let name = Timestamp::new().suggest(&seed).expect("clock is readable"); + assert!(check(&name).is_ok(), "{name:?}"); + assert!(mints(&name), "{name:?}"); + if attempt > 0 { + assert!(name.ends_with(&format!("-{attempt}")), "{name:?}"); + } + } + } + + #[test] + fn many_uuids_are_legal_labels_in_both_forms() { + for uuid in [Uuid::new(), Uuid::compact()] { + for _ in 0..500 { + let name = uuid + .suggest(&Seed::new("tok", "agents.example")) + .expect("uuid generation cannot fail"); + assert!(check(&name).is_ok(), "{name:?} failed check()"); + assert!(mints(&name), "{name:?} did not mint"); + } + } + } + + #[test] + fn a_canonical_uuid_has_hyphens_only_in_the_legal_positions() { + let name = Uuid::new() + .suggest(&Seed::new("tok", "agents.example")) + .expect("uuid generation cannot fail"); + assert_eq!(name.len(), 36); + for (index, ch) in name.char_indices() { + if [8, 13, 18, 23].contains(&index) { + assert_eq!(ch, '-', "{name:?} at {index}"); + } else { + assert!( + ch.is_ascii_hexdigit() && !ch.is_ascii_uppercase(), + "{name:?} at {index}" + ); + } + } + } + + #[test] + fn a_compact_uuid_has_no_hyphens() { + let name = Uuid::compact() + .suggest(&Seed::new("tok", "agents.example")) + .expect("uuid generation cannot fail"); + assert_eq!(name.len(), 32); + assert!(!name.contains('-')); + } + + #[test] + fn random_names_of_every_configured_length_mint() { + for alphabet in [Alphabet::Base32, Alphabet::Base36] { + for length in [1usize, 4, 16, 63] { + let namer = Random::new(alphabet, length).expect("a legal length constructs"); + for _ in 0..200 { + let name = namer + .suggest(&Seed::new("tok", "agents.example")) + .expect("random generation cannot fail"); + assert_eq!(name.len(), length, "{name:?}"); + assert!(check(&name).is_ok(), "{name:?} failed check()"); + assert!(mints(&name), "{name:?} did not mint"); + } + } + } + } + + #[test] + fn random_pool_size_is_alphabet_to_the_length() { + let namer = Random::new(Alphabet::Base36, 10).expect("legal"); + assert_eq!(namer.pool_size(), 36u128.pow(10)); + } + + #[test] + fn random_refuses_zero_length_and_over_length_labels() { + assert!(Random::new(Alphabet::Base36, 0).is_err()); + assert!(Random::new(Alphabet::Base36, crate::MAX_NAME + 1).is_err()); + assert!(Random::new(Alphabet::Base36, crate::MAX_NAME).is_ok()); + } + + #[test] + fn base36_matches_known_values() { + assert_eq!(to_base36(0), "0"); + assert_eq!(to_base36(35), "z"); + assert_eq!(to_base36(36), "10"); + } +} diff --git a/crates/didbot-name/src/lib.rs b/crates/didbot-name/src/lib.rs index f71a4d79..7ddb5b41 100644 --- a/crates/didbot-name/src/lib.rs +++ b/crates/didbot-name/src/lib.rs @@ -27,12 +27,14 @@ pub mod command; pub mod fragments; +pub mod generated; pub mod lists; use std::fmt; pub use command::CommandNamer; pub use fragments::{Fragments, Template}; +pub use generated::{Alphabet, Counter, CounterSource, Random, Timestamp, Uuid}; pub use lists::{bundled, bundled_names, from_spec, BUNDLED, DEFAULT_SPEC}; /// The longest a name can be, because a DNS label is 63 octets. @@ -115,6 +117,22 @@ pub trait Namer: Send + Sync + fmt::Debug { /// What to call this namer in a log line. fn describe(&self) -> String; + + /// The most attempts it is ever worth asking this namer for a name, when + /// the first suggestion turns out to be taken. + /// + /// `None`, the default, defers to the caller's own bound — sixteen + /// attempts against a shared or randomized pool is what resolves the + /// ordinary kind of bad luck, for [`fragments::Fragments`] and + /// [`command::CommandNamer`] alike. A namer whose own state already + /// guarantees a name nothing has held before — [`generated::Counter`] — + /// overrides this to `Some(1)`: retrying buys it nothing, because a + /// collision there is not bad luck, it is something else claiming a name + /// the counter never offered, and asking the counter again sixteen times + /// would not change that answer. + fn max_useful_attempts(&self) -> Option { + None + } } /// Uses one namer, and another when the first will not answer. @@ -168,7 +186,19 @@ impl Namer for Fallback { /// Every name becomes a DNS label under the zone, so this is not a house style /// rule: a name that fails here cannot be published at all. Applied to what a /// namer returns rather than trusted from it, because one of the namers is an -/// arbitrary program and another may be a language model. +/// arbitrary program, another may be a language model, and — since +/// [`generated`] — another is this crate's own arithmetic, which is no more +/// trustworthy than the rest for having been written here. +/// +/// The character rules are not re-derived: they are +/// [`didbot_identity::validate_label`], the same function `AgentDid::mint` +/// checks an agent id against before it becomes part of a DID. A second, +/// hand-maintained copy of "lowercase ascii, digits, hyphens, no hyphen at +/// either end" is exactly how a name legal here and illegal there gets +/// minted — the two would have to be kept in agreement by a person remembering +/// to, rather than by there being only one. The length bound is added on top +/// because it is a DNS-label rule with no `did:web` meaning of its own; see +/// that function's docs. pub fn check(name: &str) -> Result<(), NameError> { let unusable = |why| NameError::Unusable(name.to_owned(), why); if name.is_empty() { @@ -177,17 +207,16 @@ pub fn check(name: &str) -> Result<(), NameError> { if name.len() > MAX_NAME { return Err(unusable("longer than a DNS label may be")); } - if name.starts_with('-') || name.ends_with('-') { - return Err(unusable("a label may not start or end with a hyphen")); - } - if !name - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') - { - return Err(unusable( - "a label is lowercase ascii letters, digits and hyphens", - )); - } + didbot_identity::validate_label(name).map_err(|error| { + use didbot_identity::DidError; + unusable(match error { + DidError::HyphenEdge(_) => "a label may not start or end with a hyphen", + DidError::InvalidCharacter(_) => { + "a label is lowercase ascii letters, digits and hyphens" + } + _ => "not a usable did:web label", + }) + })?; Ok(()) } diff --git a/plan/zone-scale.md b/plan/zone-scale.md index d737f0f6..30a968bc 100644 --- a/plan/zone-scale.md +++ b/plan/zone-scale.md @@ -30,9 +30,6 @@ 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. -- [ ] **Bigger pools, and say what each spec is worth.** A third fragment - multiplies rather than adds. The choice is a deployment's and the - arithmetic should not be. - [ ] **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. @@ -67,6 +64,50 @@ the fix is cheap and knowing when to apply it is not free. ## 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 `layout::LAYOUT` moves to 6 (see that + constant's doc comment for why not 5: two other in-flight branches had + already claimed it independently). `didbot-dev`'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 -- 2.51.2