Skip to main content

cratefield_core/
module.rs

1//! The module contract (architecture section 4). A module is a crate that
2//! contributes one router under `/v1/<name>`, its migrations, its events and
3//! its scheduled work — and sees nothing but ports.
4
5use crate::config::{Config, ConfigError};
6use crate::events::{AnyError, EventBus, EventHandler, EventName};
7use crate::ports::{Port, Ports};
8use crate::surface::Surface;
9use crate::template::TemplateRegistry;
10use crate::venture::Venture;
11use std::sync::Arc;
12
13pub use futures_core::future::BoxFuture;
14
15/// Contract version shared by core and every module. `Harness::build`
16/// rejects modules whose `harness_api` differs. Bumped only on breaking
17/// contract changes; `cratefield-core`'s major follows it.
18pub const HARNESS_API: u32 = 1;
19
20/// The message `Harness::build` and `fz doctor` report for a module whose
21/// [`Module::harness_api`] differs from core's: it names the module, the
22/// module crate's version, the API it targets, and the `cratefield-core`
23/// crate with its version and API (issue #17).
24#[must_use]
25pub fn harness_api_mismatch(module: &dyn Module) -> String {
26    format!(
27        "module `{name}` v{version} targets harness API {api}, but {core} v{core_version} \
28         provides harness API {harness_api}: rebuild `{name}` against this core — the supported \
29         ranges are in docs/COMPATIBILITY.md",
30        name = module.name(),
31        version = module.version(),
32        api = module.harness_api(),
33        core = env!("CARGO_PKG_NAME"),
34        core_version = env!("CARGO_PKG_VERSION"),
35        harness_api = HARNESS_API,
36    )
37}
38
39/// One migration step, embedded with `include_str!` from
40/// `crates/<module>/migrations/<dialect>/NNNN_name.sql` (issue #8).
41///
42/// `#[non_exhaustive]` plus [`SqlMigration::new`] is the same fix `Message`
43/// got (issue #189): a module crate cannot build this by literal, so adding
44/// a field here stops being a breaking change across the 70+ migration sets
45/// in the workspace (issue #255).
46#[non_exhaustive]
47#[derive(Debug, Clone)]
48pub struct SqlMigration {
49    /// Sortable id: `0001`, `0002`, ... — zero-padded so lexical order is
50    /// apply order.
51    pub id: &'static str,
52    /// Short slug from the file name (`0001_init.sql` -> `init`), used in
53    /// the wrangler-facing collected file names.
54    pub name: &'static str,
55    pub sql: &'static str,
56    /// Whether the migration and its tracking row commit atomically
57    /// (the default). `false` marks the statements Postgres refuses inside
58    /// a transaction block (`CREATE INDEX CONCURRENTLY`): they run alone,
59    /// the tracking row follows, and the SQL must be idempotent —
60    /// RECONCILIATION.md §4.
61    pub transactional: bool,
62}
63
64impl SqlMigration {
65    /// The ordinary migration: the four-digit id, the slug from the file
66    /// name, the embedded SQL, transactional. `const` because every
67    /// migration set is a `const` array checked by
68    /// [`assert_migration_set`], itself a `const fn`.
69    #[must_use]
70    pub const fn new(id: &'static str, name: &'static str, sql: &'static str) -> Self {
71        Self {
72            id,
73            name,
74            sql,
75            transactional: true,
76        }
77    }
78
79    /// Marks the migration as running outside a transaction: for statements
80    /// Postgres refuses inside one (`CREATE INDEX CONCURRENTLY`).
81    #[must_use]
82    pub const fn non_transactional(mut self) -> Self {
83        self.transactional = false;
84        self
85    }
86}
87
88/// Rejects a malformed migration set **at compile time**.
89///
90/// The array a module hands to [`Migrations`] is the apply order, and
91/// nothing checked that it agreed with the ids in it. `auth-core` defines
92/// its six migration constants in the file in the order 1, 2, 5, 6, 4, 3
93/// and lists them in the arrays correctly; that is one careless edit away
94/// from applying `0005` before `0003`, against a database that would
95/// record both as applied and never notice (issue #27).
96///
97/// Call it in a `const` item beside the set, where a failure is a build
98/// error rather than something a boot discovers:
99///
100/// ```
101/// # use cratefield_core::{SqlMigration, assert_migration_set};
102/// const MIGRATIONS: [SqlMigration; 2] = [
103///     SqlMigration::new("0001", "init", ""),
104///     SqlMigration::new("0002", "next", ""),
105/// ];
106/// const _: () = assert_migration_set(&MIGRATIONS);
107/// ```
108///
109/// A gap does not compile:
110///
111/// ```compile_fail
112/// # use cratefield_core::{SqlMigration, assert_migration_set};
113/// const MIGRATIONS: [SqlMigration; 2] = [
114///     SqlMigration::new("0001", "init", ""),
115///     SqlMigration::new("0003", "skipped", ""),
116/// ];
117/// const _: () = assert_migration_set(&MIGRATIONS);
118/// ```
119///
120/// Nor a duplicate, nor an id out of order, nor one that is not four
121/// digits:
122///
123/// ```compile_fail
124/// # use cratefield_core::{SqlMigration, assert_migration_set};
125/// const MIGRATIONS: [SqlMigration; 2] = [
126///     SqlMigration::new("0002", "second", ""),
127///     SqlMigration::new("0001", "first", ""),
128/// ];
129/// const _: () = assert_migration_set(&MIGRATIONS);
130/// ```
131///
132/// ```compile_fail
133/// # use cratefield_core::{SqlMigration, assert_migration_set};
134/// const MIGRATIONS: [SqlMigration; 1] =
135///     [SqlMigration::new("1", "unpadded", "")];
136/// const _: () = assert_migration_set(&MIGRATIONS);
137/// ```
138///
139/// # Panics
140///
141/// At compile time when called in a `const` item, at run time otherwise.
142/// The message is a fixed string: `panic!` in a `const fn` cannot format,
143/// so the offending id is not in it. The compile error points at the
144/// `const` item, which names the crate and the line — enough to find the
145/// set, which is the whole array anyway.
146pub const fn assert_migration_set(set: &[SqlMigration]) {
147    let mut index = 0;
148    while index < set.len() {
149        let id = set[index].id.as_bytes();
150        assert!(
151            id.len() == 4,
152            "a migration id is four digits, zero-padded: `0001`, not `1`"
153        );
154        let mut digit = 0;
155        while digit < 4 {
156            assert!(
157                id[digit] >= b'0' && id[digit] <= b'9',
158                "a migration id is four digits, zero-padded: `0001`, not `init`"
159            );
160            digit += 1;
161        }
162        let value = (id[0] - b'0') as usize * 1000
163            + (id[1] - b'0') as usize * 100
164            + (id[2] - b'0') as usize * 10
165            + (id[3] - b'0') as usize;
166        assert!(
167            value == index + 1,
168            "migration ids run contiguously from 0001 in apply order: a gap, a duplicate, or an entry listed out of order"
169        );
170        index += 1;
171    }
172}
173
174/// The sha256 of a migration's SQL, lowercase hex. Recorded in
175/// `harness_migrations` when the migration is applied, so a later run
176/// can tell "already applied" from "applied, then edited" — the rule
177/// forward-only migrations rest on, enforced by the database rather
178/// than by a lockfile in one repository (issues #28, #34).
179///
180/// The lockfile's hash covers the collected *file*; this covers the
181/// embedded SQL. They answer different questions and are not compared.
182#[must_use]
183pub fn migration_checksum(sql: &str) -> String {
184    use sha2::{Digest, Sha256};
185    let digest = Sha256::digest(sql.as_bytes());
186    let mut hex = String::with_capacity(64);
187    for byte in digest {
188        use std::fmt::Write as _;
189        let _ = write!(hex, "{byte:02x}");
190    }
191    hex
192}
193
194/// True when the SQL carries an idempotence guard, so re-running it
195/// after a crash is safe. Required of every migration marked
196/// `transactional: false` (RECONCILIATION.md §4).
197#[must_use]
198pub fn is_idempotent_sql(sql: &str) -> bool {
199    // Comments are stripped first. A guard that reads the whole file
200    // accepts `-- we could use IF NOT EXISTS here but did not` above SQL
201    // that has no guard at all — which is precisely the migration this
202    // check exists to refuse, waved through by a sentence about it.
203    let sql = strip_sql_comments(sql).to_ascii_lowercase();
204    sql.contains("if not exists") || sql.contains("if exists") || sql.contains("or replace")
205}
206
207/// SQL with `--` line comments and `/* */` blocks removed.
208///
209/// Not a parser: it does not know that a `--` inside a string literal is
210/// not a comment. That direction is safe here — it can only remove text
211/// the guard would have read, so the guard refuses rather than accepts.
212fn strip_sql_comments(sql: &str) -> String {
213    let mut out = String::with_capacity(sql.len());
214    let mut rest = sql;
215    while let Some(at) = rest.find("--").into_iter().chain(rest.find("/*")).min() {
216        out.push_str(&rest[..at]);
217        let tail = &rest[at..];
218        if tail.starts_with("--") {
219            rest = tail.find('\n').map_or("", |end| &tail[end..]);
220        } else {
221            rest = tail.find("*/").map_or("", |end| &tail[end + 2..]);
222        }
223    }
224    out.push_str(rest);
225    out
226}
227
228/// The message a non-transactional migration without an idempotence
229/// guard gets. Shared so both engines say the same thing.
230#[must_use]
231pub fn migration_missing_guard(key: &str) -> String {
232    format!(
233        "migration `{key}` is marked transactional: false, so it runs outside a transaction and \
234         must be idempotent: give the SQL an `IF NOT EXISTS` or `OR REPLACE` guard \
235         (RECONCILIATION.md, section 4)"
236    )
237}
238
239/// The message a migration whose recorded checksum no longer matches
240/// gets. Shared so both engines say the same thing.
241#[must_use]
242pub fn migration_edited(key: &str, recorded: &str, found: &str) -> String {
243    format!(
244        "migration `{key}` was already applied to this database with different SQL \
245         (recorded sha256 {}…, embedded {}…). Applied migrations are never edited: \
246         revert the change and write a new migration instead \
247         (docs/MODULE-AUTHORING.md, step 3)",
248        &recorded[..recorded.len().min(12)],
249        &found[..found.len().min(12)]
250    )
251}
252
253/// The module's migrations, per dialect. `postgres` differs from `sqlite`
254/// only where the SQL truly differs (ADR 0004).
255#[derive(Debug, Clone)]
256pub struct Migrations {
257    pub sqlite: &'static [SqlMigration],
258    pub postgres: &'static [SqlMigration],
259}
260
261impl Migrations {
262    pub const EMPTY: Migrations = Migrations {
263        sqlite: &[],
264        postgres: &[],
265    };
266
267    pub const fn sqlite(migrations: &'static [SqlMigration]) -> Self {
268        Migrations {
269            sqlite: migrations,
270            postgres: &[],
271        }
272    }
273}
274
275impl Default for Migrations {
276    fn default() -> Self {
277        Self::EMPTY
278    }
279}
280
281/// Everything a module's router needs: its declared ports, the typed
282/// config, the bus, the templates and the venture identity.
283pub struct ModuleContext {
284    /// Only the ports the module declared in `requires()`/`optional()`.
285    pub ports: Ports,
286    /// Full config; module keys are prefixed (`EMAIL_SIGNUP_CONFIRM_TTL_DAYS`).
287    pub config: Arc<dyn Config>,
288    pub events: EventBus,
289    pub templates: Arc<TemplateRegistry>,
290    pub venture: Arc<Venture>,
291    /// Whether an operator has recorded an explicit acceptance that this
292    /// deployment serves guarded routes it cannot fully protect
293    /// (`HARNESS_ALLOW_UNPROTECTED_WRITES`, issue #143).
294    ///
295    /// It exists because the acceptance has to mean the same thing at
296    /// both layers. The boot gate honours it and serves; if the
297    /// per-request gate did not, the deployment would refuse every write
298    /// anyway — the same outage, with a different status code. A module
299    /// enforcing an abuse control passes this to
300    /// [`verify_human_form`](crate::route_policy::verify_human_form)
301    /// rather than deciding from the environment alone.
302    pub unprotected_writes_accepted: bool,
303    /// Every personal-data declaration in this composition, composed once at
304    /// build (issue: privacy module). A module reads it to export or erase a
305    /// subject's data without knowing which other modules the venture chose;
306    /// most modules ignore it, which costs them an `Arc` clone per request.
307    pub personal_data: std::sync::Arc<crate::PersonalDataCatalog>,
308    /// `true` when the venture mounted a UI renderer (ADR 0010). A module
309    /// then defaults its landing redirects (confirmed, expired,
310    /// unsubscribed, status) to `<api base>/ui/<module>/<action>/<page>`
311    /// instead of pages the venture site has to provide.
312    pub ui_mounted: bool,
313}
314
315/// A Factory Zero module. Object-safe; composed as `Arc<dyn Module>`.
316///
317/// Handlers get the request scope as an axum extractor:
318/// `async fn join(scope: Scope, State(ctx): State<Arc<ModuleContext>>, ...)`.
319/// There is no ambient "current request" (ADR 0007).
320pub trait Module: Send + Sync + 'static {
321    /// Kebab-case name; mounted at `/v1/<name>`.
322    fn name(&self) -> &'static str;
323    /// `env!("CARGO_PKG_VERSION")`, surfaced by `/__health`.
324    fn version(&self) -> &'static str;
325    /// Contract version, checked by `Harness::build`.
326    fn harness_api(&self) -> u32 {
327        HARNESS_API
328    }
329    /// Ports the module cannot run without; missing = build error.
330    fn requires(&self) -> &'static [Port];
331    /// Ports the module uses when present.
332    fn optional(&self) -> &'static [Port] {
333        &[]
334    }
335    /// Modules this one's schema sits on top of, by name.
336    ///
337    /// A partial order, not a load order for routes: it exists so
338    /// migrations apply in an order where a foreign key can point at a
339    /// table another module owns (RECONCILIATION.md §2). Naming a module
340    /// the venture did not compose is a build error, and so is a cycle —
341    /// both at `HarnessBuilder::build`, never at boot, because a boot
342    /// error is one a deployment discovers in production.
343    ///
344    /// Declaring nothing is the common case and costs nothing: with no
345    /// dependencies anywhere the resolved order is composition order,
346    /// unchanged.
347    fn depends_on(&self) -> &'static [&'static str] {
348        &[]
349    }
350    /// Table names this module owns; duplicates across modules are a build
351    /// error.
352    fn tables(&self) -> &'static [&'static str] {
353        &[]
354    }
355    /// What this module holds about a person, per table.
356    ///
357    /// Read by `cratefield-module-privacy` to export a subject's data, to
358    /// erase it, and to publish the "what is stored" table. Declaring it here
359    /// rather than in the privacy module is what makes that module reusable:
360    /// it never names a venture's tables, so a schema change cannot leave it
361    /// describing something that no longer exists.
362    ///
363    /// Validated at [`HarnessBuilder::build`]: a table this module does not
364    /// own is an error, and so is a declaration that cannot mean anything —
365    /// an `Anonymise` naming no columns, a `Retain` with no reason, a
366    /// published description left blank. See [`PersonalDataSet::none`] for a
367    /// table that legitimately holds nothing personal, which says so rather
368    /// than staying silent.
369    ///
370    /// Defaulting to empty keeps every existing module compiling unchanged,
371    /// and a module with no tables has nothing to say. A module that **owns**
372    /// tables and declares nothing is a different thing, and since issue #244
373    /// it fails [`cratefield_testing::conformance`] rather than the build:
374    /// see [`undeclared_tables`](crate::undeclared_tables) for why the kit is
375    /// the place that refuses it and a running deployment is not.
376    ///
377    /// [`cratefield_testing::conformance`]: https://docs.rs/cratefield-testing
378    ///
379    /// [`HarnessBuilder::build`]: crate::HarnessBuilder::build
380    /// [`PersonalDataSet::none`]: crate::PersonalDataSet::none
381    fn personal_data(&self) -> &'static [crate::PersonalDataSet] {
382        &[]
383    }
384    /// Event names this module emits (`"<module>.<event>"`), listed by
385    /// `/__health`.
386    fn emits(&self) -> &'static [&'static str] {
387        &[]
388    }
389    /// Whether the module has public write endpoints.
390    ///
391    /// **Advisory since issue #133.** The enforcement point is each
392    /// route's [`RoutePolicy`] on the declared surface, collected by
393    /// [`WriteGuards::collect`] and gated at `HarnessBuilder::build`.
394    /// A module that writes publicly but declares **no** policy on any
395    /// action counts as a [`HumanForm`] writer (the conservative
396    /// fallback for pre-surface modules); declaring an explicit
397    /// `Open`/`Signature` policy on every action opts out of the
398    /// fallback.
399    ///
400    /// [`RoutePolicy`]: crate::route_policy::RoutePolicy
401    /// [`WriteGuards::collect`]: crate::route_policy::WriteGuards::collect
402    /// [`HumanForm`]: crate::route_policy::RoutePolicy::HumanForm
403    fn public_writes(&self) -> bool {
404        false
405    }
406    /// What actually protects those public writes, for a module with no
407    /// ADR 0010 surface to hang a per-action [`RoutePolicy`] on
408    /// (issue #143).
409    ///
410    /// Consulted **only** when [`public_writes`] is true and the surface
411    /// declares no guarded action, so a module with a surface is
412    /// unaffected and the conservative default is unchanged: say nothing
413    /// and you are still a [`HumanForm`] writer. The auth login methods
414    /// are the reason it exists — they are public writers whose proof is
415    /// a single-use challenge or a signed link, never a CAPTCHA widget,
416    /// and before this they could only be filed under a gate they can
417    /// never satisfy.
418    ///
419    /// [`public_writes`]: Module::public_writes
420    /// [`RoutePolicy`]: crate::route_policy::RoutePolicy
421    /// [`HumanForm`]: crate::route_policy::RoutePolicy::HumanForm
422    fn public_write_policy(&self) -> crate::route_policy::RoutePolicy {
423        crate::route_policy::RoutePolicy::HumanForm
424    }
425    /// The module's migrations, embedded per dialect.
426    fn migrations(&self) -> Migrations;
427    /// Rejects invalid configuration: missing required keys or malformed
428    /// values, reported together with the module name.
429    ///
430    /// **When it runs.** The conformance kit calls it, and a module's own
431    /// tests should. It cannot run at `Harness::build` or in `fz doctor`,
432    /// because neither has the deploy config — the values live on the runtime
433    /// `Env` and only exist per request. The Cloudflare runtime therefore runs
434    /// it **once at cold start** and logs any failure loudly (`console_error!`,
435    /// so it reaches Workers Logs); it does not fail the boot, so a
436    /// misconfigured module still degrades per request (issue #101) rather
437    /// than taking the whole Worker down. Turning that into a hard boot
438    /// failure is a deployment decision for an ADR.
439    ///
440    /// # Errors
441    ///
442    /// `Err` listing every invalid or missing key for this module.
443    fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError>;
444    /// Problems the module can find in **itself**, with no configuration
445    /// and no environment: its own embedded data, checked against its own
446    /// declarations.
447    ///
448    /// This is the half of [`Module::validate_config`] that `fz doctor`
449    /// can actually run. The doctor has no deploy config — those values
450    /// live on the runtime `Env` and exist only per request — so it cannot
451    /// ask a module whether its *deployment* is valid. It can ask whether
452    /// what the module compiled in is coherent, and that answer is the
453    /// same on a laptop, in CI and in production.
454    ///
455    /// The notifications module answers with every translation its catalog
456    /// is missing (issue #190), so a translation gap is a pull request
457    /// rather than a person reading a message id on a lock screen.
458    ///
459    /// Each string is one problem, already naming the module. Return
460    /// **identifiers**, never values: this is printed by a CLI and copied
461    /// into issues, so nothing read from the environment, a recipient or a
462    /// URL may appear in one. Default: nothing, so no existing module
463    /// changes.
464    fn self_check(&self) -> Vec<String> {
465        Vec::new()
466    }
467    /// The module's router, nested under `/v1/<name>`.
468    fn router(&self, ctx: ModuleContext) -> axum::Router;
469    /// Routes this module serves at the root under `/.well-known`, for
470    /// spec-mandated discovery documents (OIDC `openid-configuration`,
471    /// `jwks.json`) that must live outside `/v1` (issue #46). Paths are
472    /// relative to the prefix: register `/jwks.json`, not
473    /// `/.well-known/jwks.json`.
474    ///
475    /// At most one module may provide one: discovery URLs are a singleton
476    /// namespace, so `Harness::build` fails (naming every provider) when
477    /// two modules return a router here. `None` by default.
478    fn well_known(&self) -> Option<axum::Router> {
479        None
480    }
481    /// The module's UI surface (ADR 0010): the actions a renderer may
482    /// offer and the views that compose them. Input schemas come from
483    /// the handler's own body types (`Action::input::<Body>()`), so the
484    /// declaration cannot drift from the route. `Harness::build`
485    /// validates it; `GET /__surface` serves the composition. Default:
486    /// nothing, and a module that declares nothing renders nothing.
487    fn surface(&self) -> Surface {
488        Surface::none()
489    }
490    /// Handlers for events other modules emit; registered at
491    /// `Harness::build`.
492    fn events(&self) -> Vec<(EventName, EventHandler)> {
493        Vec::new()
494    }
495    /// Scheduled work (`cron` is the trigger expression). Default: none.
496    fn scheduled<'a>(
497        &'a self,
498        ctx: &'a ModuleContext,
499        cron: &'a str,
500    ) -> BoxFuture<'a, Result<(), AnyError>> {
501        let _ = (ctx, cron);
502        Box::pin(async { Ok(()) })
503    }
504}
505
506#[cfg(test)]
507mod reconciliation_guard_tests {
508    use super::{SqlMigration, is_idempotent_sql, migration_missing_guard};
509
510    #[test]
511    fn a_migration_is_transactional_unless_marked_otherwise() {
512        // The flag exists so RECONCILIATION.md §4 has something to read;
513        // every module that does not say a word keeps the atomic
514        // behaviour both runners have always had.
515        let migration = SqlMigration::new("0001", "init", "CREATE TABLE t (id TEXT PRIMARY KEY);");
516        assert!(migration.transactional);
517        assert!(!migration.clone().non_transactional().transactional);
518    }
519
520    #[test]
521    fn guarded_ddl_is_idempotent_plain_ddl_is_not() {
522        assert!(is_idempotent_sql(
523            "CREATE INDEX CONCURRENTLY IF NOT EXISTS i ON t (c)"
524        ));
525        assert!(is_idempotent_sql(
526            "CREATE OR REPLACE FUNCTION f() RETURNS void AS $$ BEGIN END $$;"
527        ));
528        assert!(is_idempotent_sql("DROP INDEX CONCURRENTLY IF EXISTS i"));
529        assert!(!is_idempotent_sql("CREATE INDEX CONCURRENTLY i ON t (c)"));
530        // Case-insensitive: SQL is written in any case.
531        assert!(is_idempotent_sql("create index if not exists i on t (c)"));
532
533        // A sentence about the guard is not the guard. Reading the whole
534        // file waves through exactly the migration this refuses: one that
535        // runs outside a transaction, is re-run after a crash, and fails.
536        assert!(!is_idempotent_sql(
537            "-- we could use IF NOT EXISTS here but did not\nCREATE INDEX CONCURRENTLY i ON t (c)"
538        ));
539        assert!(!is_idempotent_sql(
540            "/* IF NOT EXISTS is unavailable on this engine */ CREATE INDEX i ON t (c)"
541        ));
542        // And a real guard after a comment is still found.
543        assert!(is_idempotent_sql(
544            "-- concurrently, so it must be idempotent\nCREATE INDEX CONCURRENTLY IF NOT EXISTS i ON t (c)"
545        ));
546    }
547
548    #[test]
549    fn the_missing_guard_message_names_the_migration_and_the_rule() {
550        let message = migration_missing_guard("waitlist/0005");
551        assert!(message.contains("waitlist/0005"), "{message}");
552        assert!(message.contains("IF NOT EXISTS"), "{message}");
553    }
554}