Skip to main content

cratefield_core/
personal_data.rs

1//! What a module holds about a person, declared next to the table that holds it.
2//!
3//! Export and erasure are cross-cutting: the rows belong to whichever modules a
4//! venture composed, and a privacy module that named them would be a copy of
5//! somebody else's schema that a migration can leave behind. So the module that
6//! owns a table declares what is personal about it, and
7//! `cratefield-module-privacy` reads those declarations rather than knowing any
8//! venture's shape.
9//!
10//! One declaration serves three readers, which is the point of putting it here
11//! rather than in three places:
12//!
13//! 1. **Export** — every row for a subject, per [`PersonalDataSet::subject`].
14//! 2. **Erasure** — what happens to each row, per [`Disposition`].
15//! 3. **The published table** — the sentence a privacy page shows, per
16//!    [`PersonalDataSet::description`]. A page generated from the schema cannot
17//!    drift from the schema; a page written beside it always eventually does.
18//!
19//! ```
20//! use cratefield_core::{DataKind, Disposition, PersonalDataSet};
21//!
22//! const SETS: &[PersonalDataSet] = &[PersonalDataSet {
23//!     table: "practice_sessions",
24//!     subject: "account_id",
25//!     kind: DataKind::Fitness,
26//!     disposition: Disposition::Erase,
27//!     description: "Joint angles and scores for one practice, with its date.",
28//!     redacted: &[],
29//!     subject_via: None,
30//! }];
31//! ```
32
33use std::fmt;
34
35/// The category a row falls into, in the vocabulary a privacy page and an App
36/// Store questionnaire both use.
37///
38/// Deliberately coarse. A finer taxonomy invites a judgement call per column,
39/// and the reader this exists for — someone deciding whether to trust the
40/// product — is not served by precision nobody can check.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42#[non_exhaustive]
43pub enum DataKind {
44    /// Name, email, phone: how to reach the person.
45    Contact,
46    /// Account ids, device ids, tokens: how the system recognises them.
47    Identifier,
48    /// Health and fitness measurements.
49    Fitness,
50    /// What they did and when: sessions, streaks, usage.
51    Usage,
52    /// Things they wrote or uploaded.
53    Content,
54    /// Money: invoices, payouts, ledger entries.
55    Financial,
56}
57
58/// Declares [`DataKind::ALL`] and, from the same list, a match that has to
59/// be exhaustive.
60///
61/// The manifest carries its own copy of this vocabulary
62/// (`cratefield_manifest::KINDS`) because that crate does not depend on
63/// the harness, and `fz build` turns an author's `kind` straight into
64/// `DataKind::<Kind>` in generated source. A variant here that the
65/// manifest does not know is a kind no author can declare; a string
66/// there that is not a variant here is a generated venture that does not
67/// compile. `the_manifest_knows_every_data_kind` compares them, and this
68/// is what gives it something to compare against.
69macro_rules! kinds {
70    ($($variant:ident),+ $(,)?) => {
71        impl DataKind {
72            /// Every kind.
73            pub const ALL: &'static [DataKind] = &[$(DataKind::$variant),+];
74        }
75
76        /// Never called. It exists so that a variant absent from the list
77        /// above is a compile error here.
78        #[expect(dead_code, reason = "its only job is to be exhaustive")]
79        fn every_kind_is_in_all(kind: DataKind) {
80            match kind {
81                $(DataKind::$variant => {}),+
82            }
83        }
84    };
85}
86
87kinds!(Contact, Identifier, Fitness, Usage, Content, Financial);
88
89impl DataKind {
90    /// The stable wire name, used by the manifest endpoint and by anything that
91    /// renders the table. Kebab-case, matching the harness's route convention.
92    #[must_use]
93    pub const fn as_str(self) -> &'static str {
94        match self {
95            Self::Contact => "contact",
96            Self::Identifier => "identifier",
97            Self::Fitness => "fitness",
98            Self::Usage => "usage",
99            Self::Content => "content",
100            Self::Financial => "financial",
101        }
102    }
103}
104
105impl fmt::Display for DataKind {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        f.write_str(self.as_str())
108    }
109}
110
111/// What erasure does to these rows.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113#[non_exhaustive]
114pub enum Disposition {
115    /// The rows go. The ordinary case.
116    Erase,
117    /// The rows stay and the person leaves them: the named columns are
118    /// overwritten, everything else is untouched.
119    ///
120    /// This exists because [`Erase`] breaks aggregates. A commission ledger
121    /// cannot lose rows without the totals becoming wrong, and "your deletion
122    /// made the accounts disagree" is not a story anybody wants. The columns
123    /// named here must be nullable or have a default, which
124    /// `cratefield-module-privacy` checks against the applied schema rather
125    /// than trusting.
126    ///
127    /// [`Erase`]: Disposition::Erase
128    Anonymise(&'static [&'static str]),
129    /// The rows stay, whole, and this says why in a sentence a regulator could
130    /// read.
131    ///
132    /// Retention is sometimes required — tax law outlives an account. Making it
133    /// a variant that carries a reason means the argument is written next to
134    /// the table it applies to, in a reviewed diff, rather than being the
135    /// silence left by a table nobody declared.
136    Retain(&'static str),
137    /// The rows hold personal data and erasure **cannot reach them**, and this
138    /// says why in a sentence a regulator could read.
139    ///
140    /// This is a third thing, and the reason it exists is that the other two
141    /// are both wrong for it: [`Erase`] and [`Anonymise`] name a subject column
142    /// the query builder binds a request value to, and [`Retain`] says "the
143    /// rows stay on purpose", which reads as *your data, kept deliberately* —
144    /// not as *your data, kept because we have no way to find it*. The case
145    /// that demanded the variant is a table whose personal data is inside a
146    /// value no equality predicate can match — a rendered message filed under
147    /// the send, a composite `<email>:<product>` key — where declaring
148    /// `PersonalDataSet::none` would make a subject-facing manifest report a
149    /// table holding the person's message as **not personal data** (issue
150    /// #274).
151    ///
152    /// [`Erase`]: Disposition::Erase
153    /// [`Anonymise`]: Disposition::Anonymise
154    /// [`Retain`]: Disposition::Retain
155    Unreachable(&'static str),
156}
157
158impl Disposition {
159    /// Whether erasure leaves the row in place.
160    ///
161    /// `Unreachable` is included: the row is not deleted, and anything reading
162    /// this answer as "the data is gone" is being lied to exactly as hard as
163    /// a `Retain` would lie. The difference is that a `Retain` row is counted,
164    /// exported and described, while an `Unreachable` row can be none of
165    /// those — which is the whole problem.
166    #[must_use]
167    pub const fn keeps_row(self) -> bool {
168        matches!(
169            self,
170            Self::Anonymise(_) | Self::Retain(_) | Self::Unreachable(_)
171        )
172    }
173}
174
175/// One table's worth of personal data, declared by the module that owns it.
176///
177/// Deliberately **not** `#[non_exhaustive]`, unlike the enums above: every
178/// declaration is a `const` literal written in a module's own crate, and
179/// `#[non_exhaustive]` forbids exactly that from outside this one. The type
180/// would be undeclarable by the only people who declare it. Adding a field is
181/// therefore a breaking change here, which is the honest trade — a new required
182/// field ought to make every module state its answer rather than inherit a
183/// default nobody chose.
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub struct PersonalDataSet {
186    /// The table. Must be one this module claims in
187    /// [`Module::tables`](crate::Module::tables); a name that is not is a build
188    /// error, so a rename that misses this declaration cannot ship.
189    pub table: &'static str,
190    /// The column holding the subject's id — the value export and erasure match
191    /// on.
192    pub subject: &'static str,
193    /// What kind of data this is.
194    pub kind: DataKind,
195    /// What erasure does to it.
196    pub disposition: Disposition,
197    /// One sentence, for a person deciding whether to trust this. It is
198    /// published verbatim, so write it for them rather than for a colleague.
199    pub description: &'static str,
200    /// Columns an export names but never copies: the row is the subject's,
201    /// and one of its columns is credential material.
202    ///
203    /// A push token and a Web Push endpoint are the case this exists for. They
204    /// are the subject's — the table has to be declared, or erasure never
205    /// reaches them — but an endpoint is a **bearer capability**: whoever holds
206    /// it can notify that device, so a copy in an export file is a copy of the
207    /// credential, and ADR 0015 keeps that material out of a response body.
208    /// Dropping the column silently would be worse than either: an export is
209    /// read by somebody checking whether anything is missing, so the column is
210    /// listed with its value replaced by `[redacted]`, which says "held, not
211    /// handed over" rather than "not held".
212    ///
213    /// It changes nothing about erasure. A row that is deleted takes its
214    /// columns with it, and a column named here is still counted, still
215    /// erased, and still described.
216    pub redacted: &'static [&'static str],
217    /// How this table reaches its subject through another table, when the
218    /// column that identifies a person here is not the column the requests
219    /// are made with.
220    ///
221    /// The privacy query builders run **one** subject value across every
222    /// declaration, and by default match it against [`subject`](Self::subject)
223    /// directly. A table whose rows are written before an account is known —
224    /// a provider callback naming a person by their app-scoped id — has an
225    /// honest `subject` that no request value will ever match. Naming a route
226    /// through the table that does hold the account id makes the export find
227    /// the row and the erasure preview count it:
228    ///
229    /// ```text
230    /// WHERE <subject> IN (SELECT <via.key> FROM <via.table> WHERE <via.subject> = ?)
231    /// ```
232    ///
233    /// `Some` **replaces** direct matching rather than adding to it: the row
234    /// is found only through the join, so there is exactly one answer to
235    /// "which rows are this person's", the same one for export, preview,
236    /// delete and verify. The subject value is still bound, never
237    /// interpolated, and the three names are checked to be plain identifiers
238    /// exactly like the rest.
239    ///
240    /// The case that demanded this is `deletion_jobs` (issue #281): keyed on
241    /// `provider_subject`, reached through `identities(user_id →
242    /// provider_subject)`.
243    pub subject_via: Option<SubjectVia>,
244}
245
246/// One hop to the subject, through a table that holds the id requests are
247/// made with.
248///
249/// Read as a sentence: *rows of `table` belong to the person whose
250/// `subject` value the caller supplies, when that value appears in the join
251/// table's `subject` column; the join table's `key` column is the one the
252/// declaring table's own subject column matches against.* `None` means the
253/// declaring table is matched directly, which is the ordinary case.
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub struct SubjectVia {
256    /// The table one hop away that holds the account id.
257    pub table: &'static str,
258    /// The column on that table the request's subject value is matched
259    /// against — usually the account id.
260    pub subject: &'static str,
261    /// The column on that table whose values the declaring table's subject
262    /// column matches — for `deletion_jobs`, `identities.provider_subject`.
263    pub key: &'static str,
264}
265
266impl PersonalDataSet {
267    /// A table this module owns that holds nothing personal, and the reason.
268    ///
269    /// The reason is the point. A module may legitimately own reference data
270    /// keyed to nobody — pose definitions, plan tiers, a cue library — and
271    /// those tables should not appear in an export. But "no declaration" and
272    /// "nothing personal here" look identical in source, and only one of them
273    /// is a decision. This makes the second one say so.
274    #[must_use]
275    pub const fn none(table: &'static str, reason: &'static str) -> Self {
276        Self {
277            table,
278            subject: "",
279            kind: DataKind::Identifier,
280            disposition: Disposition::Retain(reason),
281            description: "",
282            redacted: &[],
283            subject_via: None,
284        }
285    }
286
287    /// A table this module owns that holds personal data erasure cannot reach,
288    /// and the reason.
289    ///
290    /// The table *does* hold data about somebody — the sentence says what —
291    /// but no declaration can make it queryable, because the identifying
292    /// value is inside a rendered message or a composite key rather than in a
293    /// column an `… = ?` predicate can match. Declaring it
294    /// [`none`](Self::none) would be a lie in the other direction: a
295    /// subject-facing manifest would report a table holding the person's
296    /// message as **not personal data** (issue #274).
297    ///
298    /// Like [`none`](Self::none), this sets a blank subject, so export,
299    /// erasure and [`PersonalDataCatalog::subject_sets`] skip the table —
300    /// they cannot reach it, and pretending otherwise would mean building a
301    /// predicate that matches nothing. Unlike `none`, the declaration counts
302    /// towards [`PersonalDataCatalog::is_empty`]: a deployment holding data
303    /// it cannot erase does hold data.
304    #[must_use]
305    pub const fn unreachable(
306        table: &'static str,
307        kind: DataKind,
308        description: &'static str,
309        reason: &'static str,
310    ) -> Self {
311        Self {
312            table,
313            subject: "",
314            kind,
315            disposition: Disposition::Unreachable(reason),
316            description,
317            redacted: &[],
318            subject_via: None,
319        }
320    }
321
322    /// Whether this declaration says the table holds no personal data.
323    ///
324    /// Read as "no subject column a request value can be bound to", not
325    /// literally "nothing personal": [`unreachable`](Self::unreachable)
326    /// declarations also have a blank subject, and a manifest must bucket
327    /// those with [`is_unreachable`](Self::is_unreachable) before treating
328    /// a blank subject as an answer about the table's contents.
329    #[must_use]
330    pub const fn is_none(&self) -> bool {
331        self.subject.is_empty()
332    }
333
334    /// Whether this declaration describes personal data erasure cannot reach.
335    ///
336    /// A table answering `true` holds somebody's data and says so; the reason
337    /// is the [`Unreachable`] payload, published verbatim. It is not
338    /// queryable — there is no subject column a request value can be bound
339    /// to — so it never appears in an export, an erasure plan or
340    /// [`PersonalDataCatalog::subject_sets`], but it *is* counted by
341    /// [`PersonalDataCatalog::is_empty`] and bucketed on its own by a
342    /// subject-facing manifest.
343    ///
344    /// [`Unreachable`]: Disposition::Unreachable
345    /// [`PersonalDataCatalog::subject_sets`]: PersonalDataCatalog::subject_sets
346    /// [`PersonalDataCatalog::is_empty`]: PersonalDataCatalog::is_empty
347    #[must_use]
348    pub const fn is_unreachable(&self) -> bool {
349        matches!(self.disposition, Disposition::Unreachable(_))
350    }
351
352    /// Rejects a declaration that cannot mean anything, reporting against the
353    /// module that made it. Called by `HarnessBuilder::build`.
354    ///
355    /// The rules are the ones a typo breaks: a table the module does not own, a
356    /// subject column that is blank on a set that claims to hold data, an
357    /// `Anonymise` naming no columns (which would erase nothing and report
358    /// success), and a description missing from something that gets published.
359    #[must_use]
360    pub fn validate(&self, module: &str, owns: &[&'static str]) -> Vec<String> {
361        let mut errors: Vec<String> = Vec::new();
362
363        // The privacy module builds SQL from these names. They are `&'static
364        // str` written by a module author, not user input, so this is not an
365        // injection defence so much as a refusal to make one necessary: a name
366        // that is not a plain identifier never reaches a query builder,
367        // because it never gets past `build`.
368        for (label, name) in [("table", self.table), ("subject column", self.subject)] {
369            if name.is_empty() {
370                continue;
371            }
372            if !is_plain_identifier(name) {
373                errors.push(format!(
374                    "module `{module}` declares {label} `{name}`, which is not a plain identifier"
375                ));
376            }
377        }
378        if let Disposition::Anonymise(columns) = self.disposition {
379            for column in columns {
380                if !is_plain_identifier(column) {
381                    errors.push(format!(
382                        "module `{module}` anonymises column `{column}` of `{}`, which is not a plain identifier",
383                        self.table
384                    ));
385                }
386            }
387        }
388        // A join names three more things the privacy module interpolates into
389        // SQL, so it gets the same refusal a bad table or column name gets.
390        if let Some(via) = self.subject_via {
391            for (label, name) in [
392                ("via table", via.table),
393                ("via subject column", via.subject),
394                ("via key column", via.key),
395            ] {
396                check_identifier(
397                    &mut errors,
398                    module,
399                    label,
400                    name,
401                    &format!("for `{}`", self.table),
402                );
403            }
404        }
405        for column in self.redacted {
406            if !is_plain_identifier(column) {
407                errors.push(format!(
408                    "module `{module}` redacts column `{column}` of `{}`, which is not a plain identifier",
409                    self.table
410                ));
411            }
412            // Redacting the key the export matched on protects nothing: the
413            // caller supplied that value. A list that reads as a protection
414            // and is not one is worse than no list.
415            if *column == self.subject && !self.subject.is_empty() {
416                errors.push(format!(
417                    "module `{module}` redacts the subject column `{}` of `{}`, which the caller already has",
418                    self.subject, self.table
419                ));
420            }
421        }
422        if !owns.contains(&self.table) {
423            errors.push(format!(
424                "module `{module}` declares personal data in table `{}`, which it does not own",
425                self.table
426            ));
427        }
428        if self.is_none() {
429            if self.is_unreachable() {
430                errors.extend(self.unreachable_errors(module));
431            } else {
432                errors.extend(self.none_errors(module));
433            }
434            return errors;
435        }
436        if self.description.trim().is_empty() {
437            errors.push(format!(
438                "module `{module}` declares personal data in `{}` with no description; it is published verbatim",
439                self.table
440            ));
441        }
442        match self.disposition {
443            Disposition::Anonymise([]) => errors.push(format!(
444                "module `{module}` anonymises `{}` without naming a column, which would erase nothing",
445                self.table
446            )),
447            Disposition::Anonymise(columns) if columns.contains(&self.subject) => {
448                errors.push(format!(
449                    "module `{module}` anonymises the subject column `{}` of `{}`; erasure could then never find the row again",
450                    self.subject, self.table
451                ));
452            }
453            Disposition::Retain(reason) if reason.trim().is_empty() => errors.push(format!(
454                "module `{module}` retains personal data in `{}` without saying why",
455                self.table
456            )),
457            _ => {}
458        }
459        errors
460    }
461
462    /// The contradictions only an `Unreachable` declaration can contain.
463    ///
464    /// Mirror of [`none_errors`](Self::none_errors): the blank subject is not
465    /// carelessness here but the point, so the checks are about refusing the
466    /// escape hatches that would make the blank subject lie.
467    fn unreachable_errors(&self, module: &str) -> Vec<String> {
468        let mut errors: Vec<String> = Vec::new();
469        if let Disposition::Unreachable(reason) = self.disposition
470            && reason.trim().is_empty()
471        {
472            errors.push(format!(
473                "module `{module}` declares personal data in `{}` as unreachable without saying why",
474                self.table
475            ));
476        }
477        // The description is published beside the reason in the manifest's
478        // unreachable bucket, so an empty one publishes a bucket entry that
479        // names a table and says nothing about it.
480        if self.description.trim().is_empty() {
481            errors.push(format!(
482                "module `{module}` declares personal data in `{}` as unreachable with no description; it is published verbatim",
483                self.table
484            ));
485        }
486        if self.subject_via.is_some() {
487            errors.push(format!(
488                "module `{module}` declares table `{}` as unreachable and names a way to reach a subject; one or the other",
489                self.table
490            ));
491        }
492        if !self.redacted.is_empty() {
493            errors.push(format!(
494                "module `{module}` redacts a column of `{}`, which is declared unreachable; nothing is exported from it to redact",
495                self.table
496            ));
497        }
498        errors
499    }
500
501    /// The contradictions only a `none` declaration can contain.
502    fn none_errors(&self, module: &str) -> Vec<String> {
503        let mut errors: Vec<String> = Vec::new();
504        if self.subject_via.is_some() {
505            errors.push(format!(
506        "module `{module}` declares table `{}` as holding no personal data and names a way to reach a subject; one or the other",
507        self.table
508    ));
509        }
510        if let Disposition::Retain(reason) = self.disposition
511            && reason.trim().is_empty()
512        {
513            errors.push(format!(
514        "module `{module}` declares table `{}` as holding no personal data without saying why",
515        self.table
516    ));
517        }
518        if !self.redacted.is_empty() {
519            errors.push(format!(
520        "module `{module}` redacts a column of `{}`, which it declares holds no personal data; nothing is exported from it to redact",
521        self.table
522    ));
523        }
524        errors
525    }
526}
527
528/// One refusal for a name the privacy module would interpolate into SQL.
529///
530/// The same rule as [`is_plain_identifier`], said once because `validate`
531/// applies it to five kinds of name and counting them in one function made
532/// that function outgrow any reader's patience.
533fn check_identifier(errors: &mut Vec<String>, module: &str, label: &str, name: &str, where_: &str) {
534    if !is_plain_identifier(name) {
535        errors.push(format!(
536            "module `{module}` declares {label} `{name}` {where_}, which is not a plain identifier"
537        ));
538    }
539}
540
541/// Every personal-data declaration in a composition, with the module that made
542/// it.
543///
544/// Composed once at `HarnessBuilder::build` and handed to every module through
545/// [`ModuleContext`](crate::ModuleContext), the same way the UI surface is.
546/// `cratefield-module-privacy` reads it to export, to erase, and to publish the
547/// table; nothing else needs it, but nothing else is harmed by having it, and a
548/// port just for this would be a capability the runtime has to provide for a
549/// list the harness already holds.
550#[derive(Debug, Clone, Default)]
551pub struct PersonalDataCatalog {
552    entries: Vec<CatalogEntry>,
553}
554
555/// One declaration, and the module that owns the table.
556#[derive(Debug, Clone, Copy, PartialEq, Eq)]
557pub struct CatalogEntry {
558    /// The declaring module's [`Module::name`](crate::Module::name).
559    pub module: &'static str,
560    /// What it declared.
561    pub set: PersonalDataSet,
562}
563
564impl PersonalDataCatalog {
565    /// Collects declarations in composition order.
566    ///
567    /// Tables declared as holding nothing personal are kept rather than
568    /// filtered: the published table is more honest for saying `pose_library`:
569    /// reference poses, identical for every member than for omitting it, and a
570    /// reader cannot tell an omission from an oversight.
571    #[must_use]
572    pub fn compose<'a>(
573        modules: impl IntoIterator<Item = (&'static str, &'a [PersonalDataSet])>,
574    ) -> Self {
575        let mut entries = Vec::new();
576        for (module, sets) in modules {
577            for set in sets {
578                entries.push(CatalogEntry { module, set: *set });
579            }
580        }
581        Self { entries }
582    }
583
584    /// Every declaration, composition order.
585    #[must_use]
586    pub fn entries(&self) -> &[CatalogEntry] {
587        &self.entries
588    }
589
590    /// The declarations that actually hold data about somebody: what export
591    /// reads and erasure acts on.
592    ///
593    /// Unreachable declarations are skipped too. They hold personal data, but
594    /// there is no subject column to bind a request value to, so there is no
595    /// predicate to build — which is the point of the variant. They are not
596    /// forgotten: [`is_empty`](Self::is_empty) counts them, and the manifest
597    /// publishes them in their own bucket, because "cannot reach" and "holds
598    /// nobody" must never look the same to a subject.
599    pub fn subject_sets(&self) -> impl Iterator<Item = &CatalogEntry> {
600        self.entries.iter().filter(|e| !e.set.is_none())
601    }
602
603    /// Whether anything in this composition holds personal data at all.
604    ///
605    /// A venture where this is false has an honest empty export, and its
606    /// privacy page can say so. It is also the signal that a venture composed
607    /// the privacy module and forgot that its own modules declare nothing —
608    /// an export that succeeds and returns nothing is the failure this
609    /// distinguishes.
610    ///
611    /// Unreachable declarations count as holding. An export over a table
612    /// erasure cannot reach is empty, yes — but "we hold nothing about you"
613    /// over a table that holds the person's message is precisely the
614    /// mis-answer issue #274 is about, and `subject_sets` alone would
615    /// produce it.
616    #[must_use]
617    pub fn is_empty(&self) -> bool {
618        !self
619            .entries
620            .iter()
621            .any(|e| !e.set.is_none() || e.set.is_unreachable())
622    }
623}
624
625/// The tables a module owns and has said nothing about: the gap this type
626/// exists to close, named so a test can fail on it.
627///
628/// `HarnessBuilder::build` already rejects a declaration for a table the
629/// module does **not** own, so a rename that misses a declaration cannot ship.
630/// This is the converse — a table with no declaration at all — and it is the
631/// one that actually happens: a module grows a migration, the new table joins
632/// [`Module::tables`](crate::Module::tables) because `fz data export` reads
633/// that list, and nothing anywhere notices that erasure plans from a different
634/// list. `cratefield-module-notifications` gained six migrations in two days
635/// and declared none of them (issue #244).
636///
637/// It is a **kit** check rather than a build error on purpose. Silence is
638/// legitimate for a module with no tables, and a venture composing a module
639/// whose author has not got to this yet should not be unable to boot; the
640/// place a module is *supposed* to be complete is its own test suite, so
641/// `cratefield_testing::conformance` fails on a non-empty result here and a
642/// deployment keeps running.
643///
644/// ```
645/// # use cratefield_core::{
646/// #     Config, ConfigError, Migrations, Module, ModuleContext, PersonalDataSet, Port,
647/// #     undeclared_tables,
648/// # };
649/// struct Notes;
650/// impl Module for Notes {
651///     fn name(&self) -> &'static str { "notes" }
652///     fn version(&self) -> &'static str { "0.1.0" }
653///     fn requires(&self) -> &'static [Port] { &[] }
654///     fn tables(&self) -> &'static [&'static str] { &["notes", "note_tags"] }
655///     fn personal_data(&self) -> &'static [PersonalDataSet] {
656///         const SETS: &[PersonalDataSet] = &[PersonalDataSet::none(
657///             "notes",
658///             "Notes belong to a board, not to a person.",
659///         )];
660///         SETS
661///     }
662/// #   fn migrations(&self) -> Migrations { Migrations::EMPTY }
663/// #   fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> { Ok(()) }
664/// #   fn router(&self, _ctx: ModuleContext) -> axum::Router { axum::Router::new() }
665/// }
666/// // `notes` is declared — as holding nobody, which is a declaration. The
667/// // table nobody wrote a line for is the one that comes back.
668/// assert_eq!(undeclared_tables(&Notes), vec!["note_tags"]);
669/// ```
670#[must_use]
671pub fn undeclared_tables(module: &dyn crate::Module) -> Vec<&'static str> {
672    let declared: Vec<&'static str> = module.personal_data().iter().map(|set| set.table).collect();
673    module
674        .tables()
675        .iter()
676        .copied()
677        .filter(|table| !declared.contains(table))
678        .collect()
679}
680
681/// Every table a module's migrations leave behind, on either dialect.
682///
683/// The union of the two sets rather than one of them: the `postgres` set is an
684/// override list (ADR 0004) that reuses most of the sqlite files unchanged, so
685/// a table is the module's if either dialect creates it. Each set is read in
686/// its own apply order, because that is what makes a drop or a rename mean
687/// anything — see [`created_tables`](crate::created_tables) for how a
688/// statement is read and which shapes are deliberately not counted.
689///
690/// The list is the input to [`unlisted_tables`], and it is public because the
691/// check that consumes it has to be able to say *what it found* and not only
692/// what was missing: an absence assertion over a scan that has silently
693/// stopped matching passes for the wrong reason, forever.
694#[must_use]
695pub fn migration_tables(module: &dyn crate::Module) -> Vec<String> {
696    let migrations = module.migrations();
697    let mut found: Vec<String> = Vec::new();
698    for set in [migrations.sqlite, migrations.postgres] {
699        // Joined with a newline, never bare: a migration whose last line is a
700        // `--` comment would otherwise swallow the first line of the next.
701        let sql = set
702            .iter()
703            .map(|migration| migration.sql)
704            .collect::<Vec<_>>()
705            .join("\n");
706        for table in crate::lint::created_tables(&sql) {
707            if !found.iter().any(|seen| seen.eq_ignore_ascii_case(&table)) {
708                found.push(table);
709            }
710        }
711    }
712    found
713}
714
715/// The tables a module's own migrations create and its
716/// [`Module::tables`](crate::Module::tables) never mentions: the hole the
717/// declaration rule cannot see (issue #272).
718///
719/// [`undeclared_tables`] asks whether every table in `tables()` has a
720/// declaration, which is the right question about a table that is in the
721/// list. It has nothing to say about a table that is in neither list, and
722/// that table is outside **three** things at once rather than one: `fz data
723/// export` walks `tables()`, subject access and erasure walk
724/// `personal_data()`, and the rule compares the two. `auth-core` grew
725/// `deletion_jobs` — a row holding `provider_subject`, a person's identifier
726/// at their identity provider — and the table was in none of them.
727///
728/// So the comparison is against the migrations, because a `CREATE TABLE` is
729/// where a table actually comes into being and is the one statement an author
730/// cannot forget to write. A non-empty result means the module owns a table
731/// nothing in the harness knows it owns.
732///
733/// Like [`undeclared_tables`] this is a **kit** check rather than a build
734/// error, for the same reason: a venture should not fail to boot because some
735/// other module's author has not got to this yet, and the place a module is
736/// supposed to be complete is its own test suite. `cratefield_testing::conformance`
737/// fails on a non-empty result.
738///
739/// ```
740/// # use cratefield_core::{
741/// #     Config, ConfigError, Migrations, Module, ModuleContext, PersonalDataSet, Port,
742/// #     SqlMigration, unlisted_tables,
743/// # };
744/// struct Notes;
745/// impl Module for Notes {
746///     fn name(&self) -> &'static str { "notes" }
747///     fn version(&self) -> &'static str { "0.1.0" }
748///     fn requires(&self) -> &'static [Port] { &[] }
749///     fn tables(&self) -> &'static [&'static str] { &["notes"] }
750///     fn migrations(&self) -> Migrations {
751///         const MIGRATIONS: [SqlMigration; 1] = [SqlMigration::new(
752///             "0001",
753///             "init",
754///             "CREATE TABLE notes (id TEXT PRIMARY KEY);
755///                   CREATE TABLE note_tags (note_id TEXT NOT NULL);",
756///         )];
757///         Migrations::sqlite(&MIGRATIONS)
758///     }
759/// #   fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> { Ok(()) }
760/// #   fn router(&self, _ctx: ModuleContext) -> axum::Router { axum::Router::new() }
761/// }
762/// // The migration creates two tables; `tables()` names one. The other is
763/// // outside export, outside erasure, and outside the rule that checks them.
764/// assert_eq!(unlisted_tables(&Notes), ["note_tags"]);
765/// ```
766#[must_use]
767pub fn unlisted_tables(module: &dyn crate::Module) -> Vec<String> {
768    let listed = module.tables();
769    let mut found = migration_tables(module);
770    found.retain(|table| !listed.iter().any(|name| name.eq_ignore_ascii_case(table)));
771    found
772}
773
774/// An unquoted SQL identifier: ASCII letters, digits and underscore, starting
775/// with a letter or underscore, at most 63 bytes — the shortest limit among the
776/// dialects the harness targets (Postgres truncates there; SQLite does not
777/// care).
778///
779/// Deliberately stricter than any dialect allows. A quoted identifier can
780/// contain a space, a dot, or a closing quote, and every one of those is a way
781/// to make a generated statement mean something else. Nothing in a schema
782/// anybody would want needs them.
783#[must_use]
784pub fn is_plain_identifier(name: &str) -> bool {
785    if name.is_empty() || name.len() > 63 {
786        return false;
787    }
788    let mut chars = name.chars();
789    let first = chars.next().unwrap_or('\0');
790    if !(first.is_ascii_alphabetic() || first == '_') {
791        return false;
792    }
793    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
794}