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