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}