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