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}