Skip to main content

cratefield_core/
tenant.rs

1//! The tenant a request belongs to, and the statuses the control
2//! database's registry carries (RECONCILIATION.md §6, ADR 0008,
3//! TENANT-ROUTING.md).
4//!
5//! [`TenantId`] and [`Tenant`] are nameable everywhere and constructible
6//! only here. That is the whole isolation argument: a module cannot ask
7//! the registry for a neighbour's pool by writing a different string,
8//! because it cannot write a [`TenantId`] at all. Same shape as
9//! `HarnessOnly` in the secrets crate (#39), and for the same reason —
10//! the type is the boundary, not a convention about how to call a
11//! function.
12
13/// A tenant database's reconciliation status.
14///
15/// Serializes as the registry's own text (`"active"`), not the variant
16/// name, so a structured log field and a registry row cannot drift apart.
17/// `round_trips_through_the_registry_text` and
18/// `serializes_as_the_registry_text` together pin that.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
20#[serde(rename_all = "lowercase")]
21// The lifecycle will grow again — #154 has a promotion path in it — and
22// adding a variant to an exhaustive public enum is a breaking change for
23// every downstream `match`. This release breaks anyway, adding
24// `Offboarding` and `Archived`; spending that break once buys every later
25// status for free.
26#[non_exhaustive]
27pub enum TenantStatus {
28    /// Reconciled and serving.
29    Active,
30    /// Registered, reconciliation not yet succeeded.
31    Provisioning,
32    /// The last reconciliation failed: requests answer
33    /// `503 tenant-degraded` until a boot succeeds.
34    Degraded,
35    /// Being retired: the export and key shred of
36    /// `docs/TENANT-ONBOARDING.md` §2 are under way. It has stopped
37    /// serving and will not start again.
38    Offboarding,
39    /// Retired. The database is dropped and the keys are destroyed;
40    /// nothing about this tenant can be served or recovered.
41    Archived,
42}
43
44impl TenantStatus {
45    /// The registry's text form, lowercase and stable.
46    #[must_use]
47    pub fn as_str(self) -> &'static str {
48        match self {
49            Self::Active => "active",
50            Self::Provisioning => "provisioning",
51            Self::Degraded => "degraded",
52            Self::Offboarding => "offboarding",
53            Self::Archived => "archived",
54        }
55    }
56
57    /// Whether boot-time reconciliation should fly this tenant.
58    ///
59    /// A `match` and not a negation, which is the whole point. The
60    /// reconciler asked `status != Degraded` while there were three
61    /// statuses, so it was accidentally right; adding `Offboarding` and
62    /// `Archived` silently enrolled both in the fleet, which would have
63    /// reconnected to a shredded tenant's database and flipped it back to
64    /// `active`. Written this way, the next variant added to this
65    /// `#[non_exhaustive]` enum cannot compile until someone says which
66    /// side of the line it is on.
67    #[must_use]
68    pub fn is_reconciled(self) -> bool {
69        match self {
70            Self::Active | Self::Provisioning => true,
71            Self::Degraded | Self::Offboarding | Self::Archived => false,
72        }
73    }
74
75    /// Whether the lifecycle ends here. Only [`TenantStatus::Archived`]
76    /// does: the database is dropped and the data keys are destroyed
77    /// (`docs/TENANT-ONBOARDING.md` §2), so there is nothing left to
78    /// serve and no honest way back. The registry enforces it — see
79    /// `Postgres::register_tenant`.
80    #[must_use]
81    pub fn is_terminal(self) -> bool {
82        match self {
83            Self::Archived => true,
84            Self::Active | Self::Provisioning | Self::Degraded | Self::Offboarding => false,
85        }
86    }
87
88    /// The statuses a tenant may be in and still become this one.
89    ///
90    /// The lifecycle rule, in one place, because more than one writer
91    /// moves a tenant through it: the reconciler, the offboarding
92    /// runbook's tooling and the lifecycle API of #154. A rule that lived
93    /// in one adapter's `WHERE` clause would be a rule the next writer
94    /// did not have.
95    ///
96    /// Written as "who may become me" rather than "who may I become" so
97    /// the match is on the target, which is what a caller setting a
98    /// status has in its hand — and so the list drops straight into the
99    /// `UPDATE`'s `WHERE` as the set of previous values that may be
100    /// overwritten, making the check part of the same statement rather
101    /// than a read the write races.
102    ///
103    /// Each status admits itself, because writing the status a tenant is
104    /// already in changes nothing and a reconciler that runs twice must
105    /// not fail the second time. [`TenantStatus::Archived`] is the
106    /// exception: it admits nothing, itself included. The row is the
107    /// record that the database was dropped and the keys destroyed, and a
108    /// record of an irreversible act is not rewritten.
109    #[must_use]
110    pub const fn admits(self) -> &'static [Self] {
111        match self {
112            // A tenant is registered as provisioning and nothing returns
113            // to it. Re-provisioning an existing tenant is a new tenant,
114            // because the old one's keys are either live or destroyed.
115            Self::Provisioning => &[Self::Provisioning],
116            // Reconciliation succeeded — from a first boot, from a repeat
117            // boot, or from a failure that has cleared. Not from
118            // `Offboarding`: that tenant's export and key shred are under
119            // way (`docs/TENANT-ONBOARDING.md` §2), and a reconciler
120            // reviving it would serve a database whose keys are being
121            // destroyed underneath it.
122            Self::Active => &[Self::Provisioning, Self::Degraded, Self::Active],
123            // Reconciliation failed. Reachable from anything still
124            // serving or trying to.
125            Self::Degraded => &[Self::Provisioning, Self::Active, Self::Degraded],
126            // Retirement starts from anywhere that is not already
127            // retiring or retired, including `Provisioning`: a tenant
128            // abandoned before it ever served still has keys, and they
129            // still have to be shredded.
130            Self::Offboarding => &[
131                Self::Provisioning,
132                Self::Active,
133                Self::Degraded,
134                Self::Offboarding,
135            ],
136            // Only the offboarding that performed the shred ends here.
137            // Archiving straight from `Active` would record a shred that
138            // never happened.
139            Self::Archived => &[Self::Offboarding],
140        }
141    }
142
143    /// Whether `self` may become `next`. The same rule as
144    /// [`TenantStatus::admits`], asked from the other end.
145    #[must_use]
146    pub fn can_become(self, next: Self) -> bool {
147        next.admits().contains(&self)
148    }
149
150    /// Parses the registry's text form; anything else (a future version
151    /// wrote a status this binary does not know) reads as
152    /// [`TenantStatus::Degraded`] — refuse, do not guess.
153    #[must_use]
154    pub fn parse(status: &str) -> Self {
155        match status {
156            "active" => Self::Active,
157            "provisioning" => Self::Provisioning,
158            "offboarding" => Self::Offboarding,
159            "archived" => Self::Archived,
160            _ => Self::Degraded,
161        }
162    }
163}
164
165impl std::fmt::Display for TenantStatus {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        f.write_str(self.as_str())
168    }
169}
170
171/// A tenant's key in the control database's registry.
172///
173/// Not `String`, and not constructible outside core:
174///
175/// ```compile_fail
176/// use cratefield_core::TenantId;
177/// // The field is private: module code cannot name a tenant.
178/// let _id = TenantId("someone-elses-tenant".to_owned());
179/// ```
180///
181/// Naming the type is fine, which keeps signatures writable:
182///
183/// ```
184/// use cratefield_core::TenantId;
185/// fn takes_id(_id: &TenantId) {}
186/// ```
187#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
188pub struct TenantId(String);
189
190impl TenantId {
191    /// Minted from a registry row. `pub(crate)` on purpose — see the
192    /// module docs.
193    pub(crate) fn new(id: impl Into<String>) -> Self {
194        Self(id.into())
195    }
196
197    #[must_use]
198    pub fn as_str(&self) -> &str {
199        &self.0
200    }
201}
202
203impl std::fmt::Display for TenantId {
204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        f.write_str(&self.0)
206    }
207}
208
209/// The tenant a request belongs to. Immutable, and built only by core's
210/// resolution layer from a registry row — never from a header, a path
211/// segment, or module code.
212///
213/// `Clone` because a request carries it into deferred work. Deliberately
214/// **not** `Deserialize`: the threat is an identity read back in from an
215/// attacker-supplied body, not one written out, so `Serialize` is fine
216/// and wanted — a tenant id is a structured log field.
217///
218/// ```compile_fail
219/// use cratefield_core::{Tenant, TenantStatus};
220/// // No public constructor, so a handler cannot conjure a neighbour.
221/// let _t = Tenant::new("other", TenantStatus::Active);
222/// ```
223#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
224pub struct Tenant {
225    id: TenantId,
226    status: TenantStatus,
227}
228
229impl Tenant {
230    pub(crate) fn new(id: impl Into<String>, status: TenantStatus) -> Self {
231        Self {
232            id: TenantId::new(id),
233            status,
234        }
235    }
236
237    #[must_use]
238    pub fn id(&self) -> &TenantId {
239        &self.id
240    }
241
242    #[must_use]
243    pub fn status(&self) -> TenantStatus {
244        self.status
245    }
246}
247
248/// What a deployment's registry says about a host.
249///
250/// The design sketch had three arms — `Found(Tenant)`, `Unknown`,
251/// `Degraded(TenantId)` — but a `Tenant` already carries its status, so
252/// the third was the same fact twice and two places to keep in step. It
253/// also could not work: a `ResolveTenant` impl lives in a runtime crate,
254/// and if the arm carried a `Tenant` the constructor would have to be
255/// `pub`, which is exactly what §3 wants to avoid. So the trait reports
256/// what the registry said and **core mints the [`Tenant`]**.
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub enum Resolution {
259    /// A registry row for this host.
260    Found {
261        /// The row's primary key.
262        id: String,
263        /// The row's status, parsed fail-closed by
264        /// [`TenantStatus::parse`].
265        status: TenantStatus,
266    },
267    /// No row for this host: `404 unknown-tenant`.
268    Unknown,
269}
270
271impl Resolution {
272    /// The tenant a request may proceed with, or the problem that refuses
273    /// it.
274    ///
275    /// Only [`TenantStatus::Active`] serves. `Provisioning` is refused
276    /// with the same `503 tenant-degraded` as `Degraded`: it means
277    /// registered-but-not-yet-reconciled, so its schema is not known to
278    /// match the code, which is the condition the refusal exists for. One
279    /// slug rather than two because the caller's situation is identical —
280    /// come back later — and the operator learns which from the registry,
281    /// not from the response.
282    pub(crate) fn admit(self) -> Result<Tenant, &'static crate::problems::ProblemDef> {
283        match self {
284            Self::Found { id, status } if status == TenantStatus::Active => {
285                Ok(Tenant::new(id, status))
286            }
287            // Retired, in either sense: the caller is not waiting for a
288            // boot, and there is nothing to come back to. Answered as an
289            // unknown tenant so a retired tenant and a host that never
290            // existed are indistinguishable from outside — "offboarding"
291            // is a fact about a customer, not something to publish to
292            // whoever guesses the host.
293            Self::Found { status, .. }
294                if status == TenantStatus::Offboarding || status == TenantStatus::Archived =>
295            {
296                Err(&crate::problems::SLUGS.unknown_tenant)
297            }
298            Self::Found { .. } => Err(&crate::problems::SLUGS.tenant_degraded),
299            Self::Unknown => Err(&crate::problems::SLUGS.unknown_tenant),
300        }
301    }
302}
303
304/// How a deployment turns a request's host into a tenant.
305///
306/// A trait so a deployment that must resolve by something else can, and
307/// so tests resolve without DNS. Host is the harness's answer — it is
308/// established before any tenant state is consulted, is already validated
309/// (#129), and is what the registry is keyed on anyway. A caller-supplied
310/// header is refused as policy: a deployment that got
311/// `TRUSTED_PROXY_HEADERS` wrong would let any caller name any tenant,
312/// and the blast radius of that mistake is every tenant's data
313/// (TENANT-ROUTING.md §3).
314pub trait ResolveTenant: Send + Sync {
315    /// The registry's answer for `host`. Implementations must not block
316    /// on a per-request round trip to the control database; see §13's
317    /// open question on cache lifetime.
318    fn resolve(&self, host: &str) -> Resolution;
319}
320
321/// How a deployment turns a resolved tenant into a database handle.
322///
323/// Separate from [`ResolveTenant`] because the two answers come from
324/// different places and fail differently: resolution reads a registry row
325/// (cheap, cacheable, and a miss means `404`), while this opens or reuses
326/// a connection pool (lazy, evictable, and a failure means the tenant is
327/// unreachable rather than unknown).
328///
329/// Implemented by the runtime, never by a module. The registry that backs
330/// it is the only thing in the process that knows a DSN
331/// (TENANT-ROUTING.md §4): it maps a connect failure to
332/// [`TenantDbError::Unreachable`] **before** the URL can reach a log line
333/// or a response body. `Display for DbError` already scrubs, so that is
334/// not the only defence — but `Debug` derives raw, and a DSN formatted
335/// before it ever becomes a `DbError` is scrubbed by nothing. Not
336/// producing the string is the first line; the sink is the second.
337#[async_trait::async_trait]
338pub trait TenantDatabases: Send + Sync {
339    /// The handle for `tenant`, opening its pool on first use.
340    ///
341    /// # Errors
342    ///
343    /// [`TenantDbError::Unreachable`] when the tenant's database cannot be
344    /// reached. The DSN is never part of the error.
345    async fn database(
346        &self,
347        tenant: &Tenant,
348    ) -> Result<std::sync::Arc<dyn crate::ports::Database>, TenantDbError>;
349}
350
351/// Why a tenant's database could not be handed over. Deliberately carries
352/// the tenant id and nothing else — no DSN, no driver message.
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub enum TenantDbError {
355    /// The pool could not be opened or the server did not answer.
356    Unreachable {
357        /// Which tenant. Safe to log.
358        tenant: String,
359    },
360}
361
362impl std::fmt::Display for TenantDbError {
363    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364        match self {
365            Self::Unreachable { tenant } => {
366                write!(f, "tenant `{tenant}`'s database is unreachable")
367            }
368        }
369    }
370}
371
372impl std::error::Error for TenantDbError {}
373
374/// A deployment's tenant plane: a host becomes a tenant, and a tenant
375/// becomes a database handle.
376///
377/// One slot on [`Ports`](crate::Ports) rather than two, because the
378/// resolution layer needs both halves of the same request and a
379/// deployment that supplied only one would be misconfigured in a way no
380/// type could catch. The blanket impl means a runtime writes the two
381/// traits and gets this for free.
382pub trait TenantRouting: ResolveTenant + TenantDatabases {}
383
384impl<T: ResolveTenant + TenantDatabases> TenantRouting for T {}
385
386/// The registry id a deployment without a registry resolves to.
387///
388/// Not a magic string scattered across three runtimes: one constant, so
389/// a log line from a Cloudflare Worker and one from a `cargo test` run
390/// say the same word.
391pub const IMPLICIT_TENANT: &str = "default";
392
393/// The resolver a deployment with **no control database** uses: every
394/// host is the one venture, always `active`.
395///
396/// This is not the Cloudflare path, it is the *no registry* path — which
397/// is also the browser runtime, and native in every development run and
398/// every test (TENANT-ROUTING.md §6). Scoping it to Cloudflare would mean
399/// `cargo test` cannot resolve a tenant and every module suite 500s.
400///
401/// A module is therefore written once, against the stricter shape, and
402/// the path most ventures actually run in production is not the one
403/// without the isolation.
404#[derive(Debug, Clone, Copy, Default)]
405pub struct ImplicitTenant;
406
407impl ResolveTenant for ImplicitTenant {
408    fn resolve(&self, _host: &str) -> Resolution {
409        Resolution::Found {
410            id: IMPLICIT_TENANT.to_owned(),
411            status: TenantStatus::Active,
412        }
413    }
414}
415
416/// Which of the two tenancy shapes served a request.
417///
418/// A deployment either has a tenant plane — a registry that turns each
419/// request's `Host` into a tenant — or it has none, and every host is
420/// the one implicit tenant ([`ImplicitTenant`], TENANT-ROUTING.md §6).
421/// The two shapes differ in more than wiring: they differ in what "a
422/// verified caller" can mean (issue #385). With a registry,
423/// `a.example` and `b.example` are different tenants behind one binary,
424/// and a subject who signed in as a user of tenant A can present the
425/// same bearer at tenant B's host — there, "any verified caller" and
426/// "a member of this tenant" are different sets. Without one, the
427/// request could only ever have reached the single database there is,
428/// and the two sets are the same set.
429///
430/// The bit records which shape a request arrived under, so a rule that
431/// needs membership can fail closed where the sets differ and serve
432/// where they coincide. It is a routing fact and deliberately nothing
433/// more: [`Tenancy::FromRegistry`] says a registry *named* this tenant,
434/// not that the caller belongs to it. Membership is a registry fact
435/// this bit does not carry.
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437pub enum Tenancy {
438    /// No tenant plane: every host is the one implicit tenant
439    /// ([`IMPLICIT_TENANT`]), and there is no second tenant to leak
440    /// into.
441    Sole,
442    /// A registry resolved this request's tenant from its `Host`
443    /// header.
444    FromRegistry,
445}
446
447#[cfg(test)]
448mod tests {
449    use super::{Resolution, TenantStatus};
450
451    fn found(status: TenantStatus) -> Resolution {
452        Resolution::Found {
453            id: "acme".to_owned(),
454            status,
455        }
456    }
457
458    /// Declares [`EVERY`] and, from the same list, a match that has to
459    /// be exhaustive.
460    ///
461    /// Every "for each status" test reads `EVERY` rather than writing its
462    /// own list, because a hand-written list quietly covers one fewer
463    /// case than its name claims the day a variant is added — which had
464    /// already happened here: `Offboarding` and `Archived` arrived in
465    /// #336 and the serialization test kept checking three of five.
466    ///
467    /// The macro is what makes that impossible rather than merely
468    /// discouraged. A first attempt at this used a separate
469    /// `position(status) -> usize` with an exhaustive match, which only
470    /// forced the *function* to be updated: nothing ever called it for a
471    /// variant the array did not already hold, so the array could stay
472    /// short and every test still passed. Adding a variant to the enum
473    /// now fails to compile in `exhaustive` below, and the only way to
474    /// satisfy that is to add it to this one invocation — which is also
475    /// the array.
476    macro_rules! statuses {
477        ($($variant:ident),+ $(,)?) => {
478            /// Every status. Not public: a caller enumerating an
479            /// `#[non_exhaustive]` enum is the thing that attribute
480            /// exists to prevent, and inside the crate it is exhaustive
481            /// anyway.
482            const EVERY: &[TenantStatus] = &[$(TenantStatus::$variant),+];
483
484            /// Never called. It exists so that a variant absent from the
485            /// list above is a compile error here.
486            #[expect(dead_code, reason = "its only job is to be exhaustive")]
487            fn exhaustive(status: TenantStatus) {
488                match status {
489                    $(TenantStatus::$variant => {}),+
490                }
491            }
492        };
493    }
494
495    statuses!(Provisioning, Active, Degraded, Offboarding, Archived);
496
497    #[test]
498    fn the_implicit_tenant_answers_for_every_host() {
499        use super::{IMPLICIT_TENANT, ImplicitTenant, ResolveTenant};
500        // A deployment with no registry has one tenant, and which host
501        // asked is not a question it can answer differently - including
502        // for a host it has never seen, which is what a test client and a
503        // loopback probe both look like.
504        for host in ["acme.example", "localhost:8787", ""] {
505            let tenant = ImplicitTenant
506                .resolve(host)
507                .admit()
508                .expect("the implicit tenant always serves");
509            assert_eq!(tenant.id().as_str(), IMPLICIT_TENANT);
510            assert_eq!(tenant.status(), TenantStatus::Active);
511        }
512    }
513
514    #[test]
515    fn only_an_active_tenant_is_admitted() {
516        let tenant = found(TenantStatus::Active)
517            .admit()
518            .expect("an active tenant serves");
519        assert_eq!(tenant.id().as_str(), "acme");
520        assert_eq!(tenant.status(), TenantStatus::Active);
521    }
522
523    #[test]
524    fn provisioning_is_refused_like_degraded_and_not_like_unknown() {
525        // Registered but not yet reconciled: the schema is not known to
526        // match the code, which is the condition the refusal exists for.
527        // The caller's situation is identical to degraded - come back
528        // later - so it is one slug, and the operator learns which from
529        // the registry rather than from the response.
530        for status in [TenantStatus::Provisioning, TenantStatus::Degraded] {
531            let problem = found(status).admit().expect_err("must not serve");
532            assert_eq!(problem.slug, "tenant-degraded", "for {status}");
533            assert_eq!(problem.status.as_u16(), 503);
534        }
535    }
536
537    #[test]
538    fn a_retired_tenant_is_indistinguishable_from_one_that_never_existed() {
539        // Not `tenant-degraded`: that says "come back later", and there is
540        // nothing to come back to. Answering as unknown also keeps
541        // "this customer left" from being readable by anyone who guesses
542        // the host.
543        for status in [TenantStatus::Offboarding, TenantStatus::Archived] {
544            let retired = found(status).admit().expect_err("must not serve");
545            let never_existed = Resolution::Unknown.admit().expect_err("must not serve");
546            assert_eq!(
547                retired.slug, never_existed.slug,
548                "{status} must answer exactly as an unknown host does"
549            );
550        }
551    }
552
553    #[test]
554    fn every_status_round_trips_through_the_registry_text() {
555        for &status in EVERY {
556            assert_eq!(
557                TenantStatus::parse(status.as_str()),
558                status,
559                "{status} does not survive a trip through the registry"
560            );
561        }
562    }
563
564    #[test]
565    fn only_active_serves() {
566        // The whole lifecycle, stated once: exactly one status admits.
567        for &status in EVERY
568            .iter()
569            .filter(|status| **status != TenantStatus::Active)
570        {
571            assert!(
572                found(status).admit().is_err(),
573                "{status} must not serve requests"
574            );
575        }
576        assert!(found(TenantStatus::Active).admit().is_ok());
577    }
578
579    #[test]
580    fn a_retired_tenant_is_never_flown_by_the_fleet() {
581        // The bug this pins: the reconciler filtered `status != Degraded`,
582        // which was right for three statuses and silently enrolled both
583        // new ones. Flying an offboarding tenant reconnects to a database
584        // mid-shred and flips it back to `active`; flying an archived one
585        // reconnects to a database that no longer exists.
586        assert!(TenantStatus::Active.is_reconciled());
587        assert!(TenantStatus::Provisioning.is_reconciled());
588        for retired in [
589            TenantStatus::Degraded,
590            TenantStatus::Offboarding,
591            TenantStatus::Archived,
592        ] {
593            assert!(
594                !retired.is_reconciled(),
595                "{retired} must not be flown by boot reconciliation"
596            );
597        }
598    }
599
600    #[test]
601    fn archived_is_the_only_terminal_status() {
602        assert!(TenantStatus::Archived.is_terminal());
603        for live in [
604            TenantStatus::Active,
605            TenantStatus::Provisioning,
606            TenantStatus::Degraded,
607            TenantStatus::Offboarding,
608        ] {
609            assert!(!live.is_terminal(), "{live} is a state, not an ending");
610        }
611    }
612
613    #[test]
614    fn a_terminal_status_never_serves_and_is_never_reconciled() {
615        // The two properties together are what "retired" means: it cannot
616        // answer a request and no boot brings it back. Asserting each
617        // separately would let a future variant satisfy one and not the
618        // other.
619        for &status in EVERY {
620            if status.is_terminal() {
621                assert!(found(status).admit().is_err(), "{status} served");
622                assert!(!status.is_reconciled(), "{status} was flown");
623            }
624        }
625    }
626
627    #[test]
628    fn an_unknown_host_is_a_404_not_a_503() {
629        // Distinguishable on purpose: 503 says "this tenant exists and is
630        // unwell", 404 says "no such tenant". Collapsing them would tell
631        // an operator the wrong thing during an incident.
632        let problem = Resolution::Unknown.admit().expect_err("must not serve");
633        assert_eq!(problem.slug, "unknown-tenant");
634        assert_eq!(problem.status.as_u16(), 404);
635    }
636
637    #[test]
638    fn a_status_this_binary_does_not_know_is_refused_not_guessed() {
639        // `parse` is fail-closed, so a future version writing a status
640        // this binary has never heard of reads as degraded and is
641        // refused. The alternative - defaulting to active - would serve a
642        // tenant on the strength of not understanding it.
643        let unknown = TenantStatus::parse("quiesced");
644        assert_eq!(unknown, TenantStatus::Degraded);
645        assert!(found(unknown).admit().is_err());
646    }
647
648    #[test]
649    fn serializes_as_the_registry_text_not_the_variant_name() {
650        for &status in EVERY {
651            let json = serde_json::to_string(&status).expect("a unit variant serializes");
652            assert_eq!(
653                json,
654                format!("\"{}\"", status.as_str()),
655                "the wire form is the registry's word, so a log field and a row agree"
656            );
657        }
658    }
659
660    #[test]
661    fn an_unknown_status_reads_as_degraded() {
662        assert_eq!(TenantStatus::parse("busy"), TenantStatus::Degraded);
663        assert_eq!(TenantStatus::parse(""), TenantStatus::Degraded);
664    }
665
666    #[test]
667    fn a_tenant_being_shredded_is_not_revived_by_a_reconciliation() {
668        // The live one. `offboarding` means the export and key shred of
669        // `docs/TENANT-ONBOARDING.md` §2 are under way; a reconciler
670        // writing `active` would serve a database whose keys are being
671        // destroyed underneath it. The fleet already skips it
672        // (`is_reconciled`), and this is the second line, because the
673        // reconciler is not the only writer.
674        assert!(!TenantStatus::Offboarding.can_become(TenantStatus::Active));
675        assert!(!TenantStatus::Offboarding.can_become(TenantStatus::Degraded));
676        assert!(!TenantStatus::Offboarding.can_become(TenantStatus::Provisioning));
677        // Forward is the only way out.
678        assert!(TenantStatus::Offboarding.can_become(TenantStatus::Archived));
679    }
680
681    #[test]
682    fn archiving_records_a_shred_that_happened() {
683        // Only the offboarding that performed it ends there. Archiving
684        // straight from `active` would record a destruction of keys that
685        // nothing has destroyed.
686        assert_eq!(
687            TenantStatus::Archived.admits(),
688            &[TenantStatus::Offboarding]
689        );
690        for &from in EVERY {
691            assert_eq!(
692                from.can_become(TenantStatus::Archived),
693                from == TenantStatus::Offboarding,
694                "{from} -> archived"
695            );
696        }
697    }
698
699    #[test]
700    fn nothing_leaves_archived_including_a_write_of_archived() {
701        // The row is the record that the database was dropped and the
702        // keys destroyed. A record of an irreversible act is not
703        // rewritten — not even with the same word, which would move its
704        // timestamp and lose when the shred actually happened.
705        for &to in EVERY {
706            assert!(
707                !TenantStatus::Archived.can_become(to),
708                "archived -> {to} is not a move that exists"
709            );
710        }
711    }
712
713    #[test]
714    fn a_status_written_twice_is_not_a_failure() {
715        // A reconciler that runs twice writes `active` twice, and the
716        // second must not be refused. Archived is the exception, and has
717        // its own test saying why.
718        for &status in EVERY {
719            assert_eq!(
720                status.can_become(status),
721                status != TenantStatus::Archived,
722                "{status} -> {status}"
723            );
724        }
725    }
726
727    #[test]
728    fn a_tenant_abandoned_before_it_served_still_has_keys_to_shred() {
729        // So retirement starts from `provisioning` too. Skipping straight
730        // to `archived` would leave the keys alive and claim they were
731        // destroyed.
732        assert!(TenantStatus::Provisioning.can_become(TenantStatus::Offboarding));
733        assert!(!TenantStatus::Provisioning.can_become(TenantStatus::Archived));
734    }
735
736    #[test]
737    fn a_tenant_never_goes_back_to_provisioning() {
738        // Re-provisioning an existing tenant is a new tenant: the old
739        // one's keys are either live or destroyed, and neither is a
740        // starting point.
741        assert_eq!(
742            TenantStatus::Provisioning.admits(),
743            &[TenantStatus::Provisioning]
744        );
745    }
746
747    #[test]
748    fn the_rule_reads_the_same_from_both_ends() {
749        // `admits` is the match and `can_become` asks it backwards. One
750        // rule, so a change to either cannot disagree with the other.
751        for &to in EVERY {
752            for &from in EVERY {
753                assert_eq!(
754                    from.can_become(to),
755                    to.admits().contains(&from),
756                    "{from} -> {to}"
757                );
758            }
759        }
760    }
761
762    #[test]
763    fn a_status_the_fleet_flies_is_one_a_reconciliation_can_write() {
764        // The two rules have to agree or a reconciled tenant is one whose
765        // outcome the registry will not take: `is_reconciled` picks the
766        // tenants the fleet boots, and reconciliation ends by writing
767        // `active` or `degraded`.
768        for &status in EVERY.iter().filter(|status| status.is_reconciled()) {
769            assert!(
770                status.can_become(TenantStatus::Active)
771                    && status.can_become(TenantStatus::Degraded),
772                "{status} is flown, and its own outcome would be refused"
773            );
774        }
775    }
776}