Skip to main content

skilj_core/plugin/
mod.rs

1//! The public plugin API - the one module every consuming application's
2//! own code touches directly. See [docs/architecture.md §1](../../../docs/architecture.md#plugin-api-decide-project) for the full
3//! reasoning behind this shape.
4
5use crate::event_store::{Event, MissedOccurrencePolicy};
6use crate::shared::{
7    CommandDecision, PrivateField, QueryItemMapping, SensitiveField, Tag, TagMapping,
8};
9use schemars::JsonSchema;
10use serde::{de::DeserializeOwned, Serialize};
11
12/// See `CommandType::required_role`'s own doc comment and docs/
13/// architecture.md §1.3.1 - the one deliberate proc-macro exception to
14/// this module's otherwise macro-free plugin API. Re-exported here so a
15/// consumer only ever needs `skilj_core::plugin::{CommandType,
16/// requires_role}`, not a direct dependency on `skilj-macros` itself.
17pub use skilj_macros::requires_role;
18
19/// The fallback every plugin trait's `BOUNDED_CONTEXT` associated const
20/// carries - see that const's own doc comment on `EventType`/
21/// `CommandType`/`Projection`. Also what `skilj::SkiljBuilder::builder()`
22/// seeds `current_bounded_context` with, so a single-bounded-context app
23/// never has to call `.bounded_context(...)` at all: every `.event_type::<T>()`/
24/// `.command_type::<T>()`/`.projection::<T>()` call registers under this
25/// name until something changes it.
26pub const DEFAULT_BOUNDED_CONTEXT: &str = "default";
27
28/// Bridges a raw, type-erased `event_store::Event` into the
29/// strongly-typed, hand-written per-bounded-context event enum
30/// `decide()`/`project()` pattern-match over (§1.4's `BankingEvent`, say),
31/// implemented once per bounded context, on that enum. See docs/
32/// architecture.md §1.6 for the full reasoning; §1.7's decider bridge is
33/// the one caller of `try_from_event` in this crate.
34///
35/// ```ignore
36/// impl BoundedContextEvent for BankingEvent {
37///     fn try_from_event(event: &Event) -> Option<Result<Self, serde_json::Error>> {
38///         match event.event_type.name.as_str() {
39///             "MoneyDeposited" => Some(serde_json::from_str(&event.payload).map(Self::MoneyDeposited)),
40///             "MoneyWithdrawn" => Some(serde_json::from_str(&event.payload).map(Self::MoneyWithdrawn)),
41///             _ => None,
42///         }
43///     }
44/// }
45/// ```
46pub trait BoundedContextEvent: Sized {
47    /// `None` when `event.event_type.name` doesn't match any variant this
48    /// enum declares - never expected in practice (a caller only ever
49    /// passes this bounded context's own events), so `None` is a
50    /// defensive case, not a designed-for one. `Some(Err(..))` when the
51    /// stored payload doesn't deserialize into the matched variant's
52    /// payload type - narrower than it used to be (an externally- or
53    /// directly-created event's own payload is now schema-checked before
54    /// it's ever stored), but still reachable: a command-triggered or
55    /// system-triggered event's payload came from `decide()`/
56    /// `scheduled_payload`, which are never schema-checked at all (see
57    /// the note above `entity CommandType`'s "Payload schema shape" in
58    /// specs/skilj.allium), and even a schema-valid payload can still be
59    /// stricter than the compiled Rust type expects.
60    fn try_from_event(event: &Event) -> Option<Result<Self, serde_json::Error>>;
61}
62
63/// One step in an `upcast_payload` chain - see that function's own doc
64/// comment. `to_version` is the schema_version this step's `transform`
65/// produces: a payload whose own `Metadata.version` is strictly less
66/// than `to_version` needs this step applied before whichever step (or
67/// final deserialization) comes next.
68#[derive(Clone, Copy)]
69pub struct UpcastStep {
70    /// The schema version `transform` produces.
71    pub to_version: i64,
72    /// Rewrites a payload written before `to_version` into that version's
73    /// shape.
74    pub transform: fn(serde_json::Value) -> serde_json::Value,
75}
76
77/// Sugar around the one pattern a hand-written `BoundedContextEvent::
78/// try_from_event` already has every ingredient for: `event.metadata.version`
79/// records the schema_version a payload was actually written under
80/// (specs/skilj.allium's own note above, "that is what Metadata.version
81/// is for"), and nothing stops a `try_from_event` arm from branching on
82/// it before deserializing. This is that branch, generalised into a
83/// declared chain of pure JSON transforms instead of a repeated
84/// `match metadata.version { ... }` per event type. See
85/// [docs/architecture.md §33](../../../docs/architecture.md#payload-upcasting) for the full "why not first-class
86/// upcasting" investigation this is the one built piece of.
87///
88/// Not a replacement for `schema_is_backwards_compatible`'s additive-only
89/// contract, and doesn't need to be: a genuinely reshaping change
90/// (rename, retype, split/merge a field) still can't be registered as a
91/// revision of an existing type - `Self::Payload`'s own derived JSON
92/// Schema only ever has to describe the *current*, already-reshaped
93/// value, because this runs entirely on the raw `serde_json::Value`
94/// before `serde_json::from_value::<T>` ever sees it. The transform
95/// chain lives in application code only; nothing about the registered
96/// schema, `RegisterEventType`, or `RegisterCommandType` changes.
97///
98/// `chain` is walked once, in the order given, applying every step whose
99/// `to_version` is strictly greater than `written_at_version` - a
100/// payload written at version 1 runs every step in a `[..to 2, ..to 3]`
101/// chain, one written at version 2 runs only the second, one written at
102/// version 3 (or later) runs none. Steps are expected in ascending
103/// `to_version` order; this does not sort them - an out-of-order chain
104/// applies transforms in the wrong sequence, the same "caller's own
105/// responsibility" register `consumed_event_types()` already trusts the
106/// caller to get right.
107///
108/// ```ignore
109/// // MoneyDeposited moved from a float-dollar `amount` (schema_version 1)
110/// // to an integer-cent `amount_cents` (schema_version 2) - a genuine
111/// // reshape schema_is_backwards_compatible would reject as a revision
112/// // of the same type, handled here entirely in application code:
113/// const MONEY_DEPOSITED_UPCASTS: &[UpcastStep] = &[UpcastStep {
114///     to_version: 2,
115///     transform: |mut v| {
116///         if let Some(amount) = v.get("amount").and_then(|a| a.as_f64()) {
117///             if let Some(obj) = v.as_object_mut() {
118///                 obj.remove("amount");
119///                 obj.insert("amount_cents".into(), ((amount * 100.0).round() as i64).into());
120///             }
121///         }
122///         v
123///     },
124/// }];
125///
126/// "MoneyDeposited" => Some(
127///     skilj_core::plugin::upcast_payload(
128///         &event.payload,
129///         event.metadata.version,
130///         MONEY_DEPOSITED_UPCASTS,
131///     )
132///     .map(Self::MoneyDeposited),
133/// ),
134/// ```
135pub fn upcast_payload<T: DeserializeOwned>(
136    payload: &str,
137    written_at_version: i64,
138    chain: &[UpcastStep],
139) -> Result<T, serde_json::Error> {
140    let mut value: serde_json::Value = serde_json::from_str(payload)?;
141    for step in chain {
142        if written_at_version < step.to_version {
143            value = (step.transform)(value);
144        }
145    }
146    serde_json::from_value(value)
147}
148
149/// One command type a bounded context registers.
150///
151/// `decide()` is synchronous and pure: it receives only `payload` and
152/// `matching_events`, nothing else - no database access, no I/O. That's
153/// deliberate (§1.1): `ProcessCommand`'s optimistic-then-locked retry may
154/// call `decide()` more than once per submission.
155pub trait CommandType {
156    /// The payload shape, and the source of its JSON Schema - derived via
157    /// `schemars`, never hand-written (§1.2).
158    type Payload: Serialize + DeserializeOwned + JsonSchema;
159
160    /// The bounded context's own generated event enum, one variant per
161    /// registered `EventType`. `matching_events` is typed against this so
162    /// a missed match arm is a compile error, not a silently-ignored
163    /// event type (§1.4). `: BoundedContextEvent` is what lets §1.7's
164    /// decider bridge convert this bounded context's raw, stored `Event`s
165    /// into it before calling `decide()`.
166    type Event: BoundedContextEvent;
167
168    /// The command type's name, unique within its bounded context - what
169    /// GraphQL, REST and `EventSpec::event_type` name it by.
170    const NAME: &'static str;
171
172    /// Which bounded context `skilj::SkiljBuilder::auto_register()`
173    /// registers this command type under - see `skilj_macros::auto_register`'s
174    /// own doc comment for the `#[auto_register]` attribute that reads
175    /// this. Irrelevant to manual `.bounded_context(name).command_type::<T>()`
176    /// chaining, which ignores this const entirely and keeps working
177    /// exactly as before.
178    ///
179    /// Defaults to `DEFAULT_BOUNDED_CONTEXT` ("default"), so a
180    /// single-bounded-context app never has to override this at all. A
181    /// multi-context app points it at its own module-level
182    /// `BOUNDED_CONTEXT` const (the same one `skilj-demo`'s `banking`/
183    /// `courses` modules already declare), e.g.
184    /// `const BOUNDED_CONTEXT: &'static str = BOUNDED_CONTEXT;`.
185    const BOUNDED_CONTEXT: &'static str = DEFAULT_BOUNDED_CONTEXT;
186
187    /// The consistency tags this command's payload carries: each maps a tag
188    /// key to a payload field. `decide()` is handed every event sharing at
189    /// least one of the resulting tags. Empty by default.
190    fn tag_mappings() -> Vec<TagMapping> {
191        Vec::new()
192    }
193
194    /// See `plugin::EventType::owner_tag_key`'s own doc comment - identical
195    /// role here: which of `tag_mappings()`'s own keys names this command
196    /// type's "owner" dimension, `None` (the default) for one with no such
197    /// notion. Registered and validated exactly the same way
198    /// (`RegisterCommandType`'s own `valid_owner_tag_key`), and read by
199    /// `event_store::command_owner_scope_satisfied` - `Command.consistency_tags`'
200    /// own sibling check to `event_owner_scope_satisfied`'s `Event.tags`.
201    /// Cross-tenant read fix (docs/architecture.md's own write-up of these
202    /// passes).
203    fn owner_tag_key() -> Option<&'static str> {
204        None
205    }
206
207    /// Payload fields encrypted per subject, so `forgetSubject` makes them
208    /// unreadable. Empty by default.
209    fn sensitive_fields() -> Vec<SensitiveField> {
210        Vec::new()
211    }
212
213    /// See `EventType::private_fields()`'s own doc comment - identical
214    /// role, for `Command.payload` instead of `Event.payload`.
215    fn private_fields() -> Vec<PrivateField> {
216        Vec::new()
217    }
218
219    /// Opt-in: submitting over GraphQL with a write-level grant is always
220    /// permitted; triggering over REST with a `CommandToken` needs this.
221    fn rest_trigger_allowed() -> bool {
222        false
223    }
224
225    /// Opt-in approval flow (issue #98, docs/architecture.md
226    /// §214): when `true`, a submission - REST or GraphQL - creates a
227    /// pending `CommandProposal` instead of running `decide()`. An
228    /// approver with an active admin-level `RoleAccessMapping` on the
229    /// bounded context then approves it over GraphQL, which runs the
230    /// ordinary submission pipeline against the state at that time.
231    ///
232    /// A command may still be rejected by `decide()` at approval time;
233    /// the proposal is then recorded as rejected, with the decision's own
234    /// reason. `false` by default, so every existing command type keeps
235    /// its immediate-submission behaviour.
236    fn requires_approval() -> bool {
237        false
238    }
239
240    /// Narrows who may approve a `requires_approval()` command type, on
241    /// top of the admin grant every approver needs: `Some(name)` requires
242    /// the approver's `Role.name` to equal `name`, `None` (the default)
243    /// lets any active admin-level mapping approve. Ignored for a type
244    /// that does not require approval.
245    fn approval_role() -> Option<&'static str> {
246        None
247    }
248
249    /// An optional approval deadline, in seconds from proposal creation.
250    /// `None` (the default) means a proposal stays approvable until it is
251    /// approved or rejected. An expired proposal is recorded as expired
252    /// and cannot be approved.
253    fn approval_deadline_seconds() -> Option<i64> {
254        None
255    }
256
257    /// Whether the account that proposed a command may also approve it.
258    /// `false` (the default) requires a different approver - a different
259    /// human, not merely a different Role row; rejecting - withdrawing -
260    /// one's own proposal is always allowed. Ignored for a type that does
261    /// not require approval.
262    fn approval_allows_self_approval() -> bool {
263        false
264    }
265
266    /// An extra caller-facing gate, on top of the ordinary write-level
267    /// `RoleAccessMapping` check: `None` (the default) means no extra
268    /// restriction. Set this by writing `#[requires_role("name")]`
269    /// directly above the `impl CommandType for ...` block - see
270    /// `requires_role`'s own doc comment - rather than overriding this
271    /// method by hand; the attribute is what makes the requirement read
272    /// as part of the command's own declaration.
273    ///
274    /// Not a spec-level concept, not persisted anywhere: `Role.name` has
275    /// no uniqueness guarantee in `specs/skilj.allium` (see `entity
276    /// Role`), so this is only as safe as the deployment's own discipline
277    /// keeping role names meaningful and non-colliding - a `skilj-core`
278    /// concern, not something the engine itself enforces. Checked
279    /// exclusively by `skilj-graphql`'s own mutation resolver (docs/
280    /// architecture.md §1.3.1, [§8](../../../docs/architecture.md#open-for-a-future-pass) item 5) - REST triggering is untouched,
281    /// since `CommandToken` is already its own, separate per-token grant.
282    fn required_role() -> Option<&'static str> {
283        None
284    }
285
286    /// Decides from the events the command's query selects. Implement
287    /// this, or [`decide_with`](CommandType::decide_with) when the
288    /// decision also needs a snapshot's state; the framework only calls
289    /// `decide_with`, whose default calls this. The default here refuses
290    /// every command, for a type that implements neither.
291    fn decide(_payload: &Self::Payload, _matching_events: &[Self::Event]) -> CommandDecision {
292        CommandDecision::Rejected {
293            reason: "this CommandType implements neither decide() nor decide_with()".to_string(),
294            kind: "decide_not_implemented".to_string(),
295        }
296    }
297
298    /// What the framework calls to decide a command
299    /// (docs/architecture.md §202): `decision.events()` is what
300    /// `matching_events` is to `decide()`, and `decision.snapshot::<S>(value)`
301    /// is snapshot `S`'s state for a tag value one of the query's
302    /// [`QueryItemMapping::snapshot`] items derived, already brought up to
303    /// date - nothing to fold. The default calls `decide()` with the
304    /// events, so a type that needs no snapshot implements `decide()` alone.
305    fn decide_with(
306        payload: &Self::Payload,
307        decision: &Decision<'_, Self::Event>,
308    ) -> CommandDecision {
309        Self::decide(payload, decision.events())
310    }
311
312    /// Which events `decide()` receives, and which later events conflict
313    /// with it (docs/architecture.md §198, the DCB query model). Each item
314    /// matches events of its types (any type when it names none) that
315    /// carry all of its tags; `matching_events` is every event matching
316    /// any item, in sequence order, except that an item with `latest`
317    /// contributes only that many of its matches, the newest
318    /// (`QueryItemMapping::latest()` for one, `last(n)` for n).
319    ///
320    /// Empty (the default) means one item per `tag_mappings()` entry, any
321    /// type, every match: every event carrying any of the command's tags,
322    /// as before queries existed. A narrower query reads less, and also
323    /// conflicts less: an event carrying the command's tag but matching
324    /// no item no longer makes it decide again.
325    ///
326    /// `vec![QueryItemMapping::nothing()]` is a command whose `decide()`
327    /// needs no history (docs/architecture.md §201): it reads no events,
328    /// and nothing committed after it is read conflicts with it.
329    ///
330    /// `build()` refuses a query naming an event type its bounded context
331    /// doesn't register in the same builder, a tag mapping that isn't one
332    /// of `tag_mappings()`, an item with neither types nor tags other than
333    /// a lone `nothing()`, and a query together with `snapshot()`.
334    fn consistency_query() -> Vec<QueryItemMapping> {
335        Vec::new()
336    }
337
338    /// Superseded by snapshot items in `consistency_query()`
339    /// (`QueryItemMapping::snapshot`, docs/architecture.md §202), which
340    /// work with any number of tags and hand `decide_with()` the state
341    /// already brought up to date; kept working for existing command
342    /// types.
343    ///
344    /// Opt-in accelerator for `decide()` against a large tag-scoped
345    /// history ([docs/architecture.md §19](../../../docs/architecture.md#optional-snapshotting-matching-events)'s "Problem 2") - names the
346    /// `Snapshot::NAME` this command type's own decide() can fold
347    /// forward from instead of replaying every matching event. `None`
348    /// (the default) means no snapshot; every existing `CommandType`
349    /// impl is completely unaffected.
350    ///
351    /// Only actually used when this command type derives exactly the
352    /// one tag the named `Snapshot::TAG_KEY` is - a multi-tag command
353    /// that names a snapshot anyway is a harmless misconfiguration,
354    /// silently ignored (falls back to the ordinary `decide()` path),
355    /// not a hard error - see `decide_from_snapshot`'s own doc comment
356    /// for the *reachable* misconfiguration this trait can't catch for
357    /// you.
358    fn snapshot() -> Option<&'static str> {
359        None
360    }
361
362    /// Called instead of `decide()` when `snapshot()` names a real,
363    /// tag-matching `Snapshot` (see that method's own doc comment).
364    /// `snapshot_state_json` is `Snapshot::State::default()`,
365    /// JSON-encoded, when no snapshot has been written yet for this tag
366    /// value, or when a stored one's own `snapshot_version` no longer
367    /// matches `Snapshot::VERSION` - the model changed, so it's treated
368    /// as absent rather than trusted; `events_since_snapshot` is then
369    /// simply everything for this tag, the same set `decide()` would
370    /// have seen. Correctness obligation: this must reach the identical
371    /// `CommandDecision` `decide()` would over the *full* matching_events
372    /// for the same payload - `snapshot_state_json` plus
373    /// `events_since_snapshot` is meant to be exactly equivalent
374    /// information, not an approximation.
375    ///
376    /// `snapshot_state_json` deliberately isn't a typed `Snapshot::State`,
377    /// since linking `CommandType` to one specific `Snapshot` impl at
378    /// the type level needs either a new required associated type
379    /// (breaking every existing `CommandType` impl) or a *defaulted*
380    /// one (`associated_type_defaults`, nightly-only). Deserialise it
381    /// yourself, the same way `tag_mappings()`'s own `field` is already
382    /// just a string, unchecked against `Payload` at compile time.
383    ///
384    /// The default implementation is a safe, non-panicking rejection -
385    /// only ever reached if a `CommandType` overrides `snapshot()`
386    /// without also overriding this: a real misconfiguration (unlike
387    /// the multi-tag case above), but one this trait can still fail
388    /// safely on rather than silently producing a wrong decision.
389    ///
390    /// `context` (issue #97) is the same [`DecisionContext`]
391    /// `decide_with` reads through [`Decision::context`].
392    fn decide_from_snapshot(
393        _payload: &Self::Payload,
394        _snapshot_state_json: &str,
395        _events_since_snapshot: &[Self::Event],
396        _context: &DecisionContext,
397    ) -> CommandDecision {
398        CommandDecision::Rejected {
399            reason: "this CommandType declares snapshot() but never overrides \
400                     decide_from_snapshot()"
401                .to_string(),
402            kind: "snapshot_misconfigured".to_string(),
403        }
404    }
405}
406
407/// One event type a bounded context registers. Unlike `CommandType`/
408/// `Projection`, there's no behaviour to plug in here - an `EventType` is
409/// pure declaration (its schema, its tag/sensitivity mappings, which
410/// origins may create it).
411pub trait EventType {
412    /// The payload shape, and the source of its JSON Schema - derived via
413    /// `schemars`, never hand-written.
414    type Payload: Serialize + DeserializeOwned + JsonSchema;
415
416    /// The event type's name, unique within its bounded context - what
417    /// `EventSpec::event_type` and the API surfaces name it by.
418    const NAME: &'static str;
419
420    /// See `CommandType::BOUNDED_CONTEXT`'s own doc comment - identical
421    /// role and default here, for `skilj::SkiljBuilder::auto_register()`'s
422    /// `EventType` side.
423    const BOUNDED_CONTEXT: &'static str = DEFAULT_BOUNDED_CONTEXT;
424
425    /// The tags this event's payload carries: each maps a tag key to a payload
426    /// field. A command whose tags overlap an event's sees that event in
427    /// `decide()`. Empty by default.
428    fn tag_mappings() -> Vec<TagMapping> {
429        Vec::new()
430    }
431
432    /// Which of `tag_mappings()`'s own keys names this type's "owner"
433    /// dimension, if it has one - `Some("company")` for a type whose
434    /// events belong to different tenants sharing one bounded context,
435    /// `None` (the default) for a type with no such notion. Unlike
436    /// `plugin::Projection::OWNER_TAG_KEY` (deliberately Rust-only, no
437    /// spec field), this one is registered and validated:
438    /// `RegisterEventType`'s own `valid_owner_tag_key` requires it be
439    /// null or name a real `tag_mappings()` key, since `EventType.tag_mappings`
440    /// is already a real, spec'd, registered field this joins rather than
441    /// a fresh Rust-only concept. Cross-tenant read fix
442    /// (docs/architecture.md's own write-up of these passes).
443    fn owner_tag_key() -> Option<&'static str> {
444        None
445    }
446
447    /// Payload fields encrypted per subject, so `forgetSubject` makes them
448    /// unreadable. Empty by default.
449    fn sensitive_fields() -> Vec<SensitiveField> {
450        Vec::new()
451    }
452
453    /// See `value PrivateField`'s own doc comment - a plain read-time
454    /// redaction rule, opt-in per field, default empty like
455    /// `sensitive_fields()` above. Private-field mechanism
456    /// (docs/architecture.md's own write-up of this pass).
457    fn private_fields() -> Vec<PrivateField> {
458        Vec::new()
459    }
460
461    /// Opt-in: whether an external system holding an external-event token
462    /// (`createExternalEventToken`) may create this event over REST, as the
463    /// broker bridges do. `false` by default.
464    fn external_creation_allowed() -> bool {
465        false
466    }
467
468    /// Opt-in: whether this event may be created without a command, by a
469    /// caller holding a direct-creation token (`createDirectCreationToken`).
470    /// `false` by default.
471    fn direct_creation_allowed() -> bool {
472        false
473    }
474
475    /// Opt-in: whether event read tokens may be minted for this event type
476    /// (`createEventReadToken`), so REST can serve its events. `false` by
477    /// default.
478    fn event_read_allowed() -> bool {
479        false
480    }
481
482    /// Opt-in: `rule CreateSystemEvent`'s own gate, `EventType.
483    /// system_triggered_allowed`. `false` by default, like every other
484    /// creation-path flag on this trait - a type that never overrides
485    /// this three plus `system_triggered_schedule`/`missed_occurrence_policy`
486    /// is never scheduled at all, so `scheduled_payload` below is never
487    /// called for it.
488    fn system_triggered_allowed() -> bool {
489        false
490    }
491
492    /// The 7-field Quartz-dialect cron expression (`cron` crate) the
493    /// scheduler evaluates - required, and checked at registration time
494    /// (see `Error::MissingScheduleOrPolicy`), when
495    /// `system_triggered_allowed()` is `true`. `None` otherwise.
496    fn system_triggered_schedule() -> Option<String> {
497        None
498    }
499
500    /// See `enum MissedOccurrencePolicy`. Deliberately no default that
501    /// silently picks one policy over another - the user's own call on
502    /// this: "either way can be potentially dangerous", so a type opting
503    /// into scheduling must say which it means, explicitly, by
504    /// overriding this method; `register_event_type`'s own guard rejects
505    /// registration outright if this is still `None` while
506    /// `system_triggered_allowed()` is `true`.
507    fn missed_occurrence_policy() -> Option<MissedOccurrencePolicy> {
508        None
509    }
510
511    /// `scheduled_payload(event_type)` - the black box `rule
512    /// CreateSystemEvent` names. Called at most once per eligible
513    /// occurrence (see `event_store::create_system_event`'s own doc
514    /// comment on why it's never called for one that's about to be
515    /// rejected), never for a type that isn't `system_triggered_allowed`.
516    /// Panics if reached without being overridden - a type opting into
517    /// scheduling without ever producing a payload for it is a genuine
518    /// programming error, not a runtime condition to handle gracefully.
519    fn scheduled_payload() -> Self::Payload {
520        unimplemented!(
521            "{}::scheduled_payload() must be overridden - this event type is registered with \
522             system_triggered_allowed() = true, so the scheduler needs a payload for every \
523             occurrence it fires",
524            Self::NAME
525        )
526    }
527}
528
529/// Type-erased access to a bounded context's own `EventType::
530/// scheduled_payload()`, for the background scheduler in `db::
531/// fire_system_event` - the `EventType` equivalent of `CommandDispatcher`/
532/// `ProjectionDispatcher` above `Skilj` implements over its own compiled-in
533/// registry.
534pub trait EventDispatcher: Send + Sync {
535    /// `scheduled_payload()`'s own output, JSON-serialised - `None` when
536    /// no `(bounded_context, event_type)` pair matches anything
537    /// registered in this process, the same "pair isn't registered at
538    /// all" convention `CommandDispatcher::dispatch`/`ProjectionDispatcher::
539    /// keys` already use. A genuine misconfiguration when it happens for
540    /// a type the database itself marks `system_triggered_allowed` (see
541    /// `@guarantee ScheduleStateIsShared`'s own expectation that every
542    /// instance in a cluster registers the same scheduled types) -
543    /// `db::fire_system_event`'s caller treats it as a skip, not a panic,
544    /// the same tolerance `catch_up_bounded_context` already extends to
545    /// an unregistered projection dispatcher.
546    fn scheduled_payload(&self, bounded_context: &str, event_type: &str) -> Option<String>;
547}
548
549/// One projection a bounded context registers.
550///
551/// `project()` folds exactly one event into this projection's stored
552/// state - synchronous, like `decide()`, and for the same reason (§1.1).
553pub trait Projection {
554    /// The state `project()` folds events into, stored as JSON. `Default` is
555    /// the state before any event.
556    type State: Serialize + DeserializeOwned + JsonSchema + Default;
557
558    /// Same generated per-bounded-context event enum `CommandType::Event`
559    /// uses - a projection folds any event type it consumes, not just
560    /// one. Same `: BoundedContextEvent` bound and reasoning.
561    type Event: BoundedContextEvent;
562
563    /// The projection's name, unique within its bounded context - what
564    /// `queryProjection` names it by.
565    const NAME: &'static str;
566
567    /// See `CommandType::BOUNDED_CONTEXT`'s own doc comment - identical
568    /// role and default here, for `skilj::SkiljBuilder::auto_register()`'s
569    /// `Projection` side.
570    const BOUNDED_CONTEXT: &'static str = DEFAULT_BOUNDED_CONTEXT;
571
572    /// The `EventType::NAME`s this projection actually folds - `RegisterProjection`'s
573    /// own `consumed_event_types`. No default: unlike `tag_mappings()`/
574    /// `sensitive_fields()`, there's no reasonable "consumes nothing"
575    /// default for a projection to fall back to, and Rust has no way to
576    /// infer this from `project()`'s own body (which variants of
577    /// `Self::Event` it actually matches on isn't something the type
578    /// system exposes) - so it's declared explicitly, the same
579    /// "must specify" register `NAME` itself already uses.
580    fn consumed_event_types() -> Vec<&'static str>;
581
582    /// Whether this projection updates inline, in the same transaction as
583    /// the events it consumes (`true`), or via a background consumer
584    /// (`false`, the default) - see `Projection.sync` in the spec.
585    fn sync() -> bool {
586        false
587    }
588
589    /// Which instance(s) of this projection `event` updates - see the
590    /// instance-data note on `entity Projection` in the spec. Defaults to
591    /// one constant, unnamed key (`""`) - the "single shared value" case
592    /// every projection built before this pass already is, needing no
593    /// change at all to keep working exactly as it already does.
594    /// Returning more than one key (a transfer event naming both the
595    /// giver's and the receiver's own account id, say) folds this event
596    /// into each of those instances independently, via its own call to
597    /// `project()` - see that method's own doc comment for the `key`
598    /// parameter it's handed each time.
599    fn keys(_event: &Self::Event) -> Vec<String> {
600        vec![String::new()]
601    }
602
603    /// The tag key (one of some consumed event type's own `tag_mappings`
604    /// keys) that names this projection's "owner" dimension, if it has
605    /// one - `Some("company")` for a projection whose instances belong to
606    /// different tenants sharing one bounded context, `None` (the
607    /// default) for a projection with no such notion, e.g. `keys()`
608    /// already being a person's own id or the projection being unkeyed.
609    /// Deliberately a Rust-only implementation detail with no spec entity
610    /// field and no registration/admin-visible surface of its own - the
611    /// identical treatment `Snapshot::TAG_KEY` already gets, and for the
612    /// same reason: `RoleAccessMapping.scope` is what an admin actually
613    /// grants and sees, not this.
614    ///
615    /// When set, each instance's own "owner" value is derived
616    /// automatically at fold time from whichever event touched it: the
617    /// value of the tag on `event.tags` whose key matches this one, if
618    /// present. An event lacking that tag leaves an already-established
619    /// owner untouched rather than clearing it. `query_projection`
620    /// rejects a `scope`-restricted `RoleAccessMapping` whose `scope`
621    /// does not match an instance's own derived owner - see that
622    /// function's own doc comment and specs/skilj.allium's
623    /// `owner_scope_satisfied`.
624    const OWNER_TAG_KEY: Option<&'static str> = None;
625
626    /// The capability a grant must hold to query this projection at all
627    /// (`RoleAccessMapping::capabilities`), if any - `None` (the default,
628    /// and every projection that predates this) for one anybody with read
629    /// access may query. A superadmin grants or revokes it, or an admin
630    /// allowed to delegate it (`RoleAccessMapping::delegable`); see
631    /// `access_control::grant_capability`. A name is lowercase letters,
632    /// digits, `_` and `-`, up to 64 characters
633    /// (`access_control::valid_capability_name`).
634    ///
635    /// Unlike `OWNER_TAG_KEY`, which is derived per-instance from folded
636    /// event tags, this is a fixed, whole-projection gate: it names no
637    /// dimension to look up per instance, just one required capability, so
638    /// either every instance of this projection is reachable by a holder
639    /// or none are - never a per-instance answer. Composes with
640    /// `OWNER_TAG_KEY` rather than replacing it: a projection may declare
641    /// both (staff-only *and* company-scoped), and `query_projection`
642    /// checks both independently. Nothing implies a capability: not an
643    /// admin level, not a superadmin Role, not a Role that happens to be
644    /// named like it. To a grant without it the projection answers as if
645    /// it did not exist. See that function's own doc comment and
646    /// specs/skilj.allium's `CapabilityGatedWhenDeclared`.
647    ///
648    /// Replaces `TEAM_ONLY`, which matched `Role.name` exactly and so
649    /// needed every member of a team to share one Role name (issue #17).
650    /// The requirement is read from the compiled projection; an
651    /// admin-readable copy registered alongside it is a follow-up.
652    const REQUIRES_CAPABILITY: Option<&'static str> = None;
653
654    /// How many partitions this projection's own `keys()` values hash
655    /// into for async catch-up (issue #25,
656    /// docs/architecture.md §51) - `1` (the default, and every
657    /// projection that predates this) means every key hashes to the
658    /// same partition, so `catch_up_bounded_context` takes its original,
659    /// unpartitioned code path unchanged and order is exactly as it
660    /// already was. A value greater than `1` lets multiple skilj
661    /// instances each independently claim and fold a different slice of
662    /// this projection's own keys concurrently, instead of every
663    /// instance redundantly redoing all of it - see
664    /// `catch_up_partitioned_projection`'s own doc comment for the
665    /// mechanism. Deliberately Rust-only, no spec entity field and no
666    /// registration/admin-visible surface, the identical treatment
667    /// `OWNER_TAG_KEY`/`REQUIRES_CAPABILITY` above already get: it's a pure
668    /// work-distribution detail, invisible to what `project()` actually
669    /// folds, so changing it between deploys needs no rebuild (unlike
670    /// `schema`/`consumed_event_types`/`sync`, which change what gets
671    /// folded - see `register_projection`'s own rebuild-trigger logic).
672    /// Meaningful only for `sync() == false`; inert (never validated
673    /// against, never rejected) for a sync one, the same stance
674    /// `OWNER_TAG_KEY`/`REQUIRES_CAPABILITY` already take toward combinations that
675    /// don't make sense rather than erroring on them.
676    const PARTITION_COUNT: u32 = 1;
677
678    /// `key` is which instance is currently being folded - one of
679    /// `Self::keys(event)`'s own return values, handed back so `project()`
680    /// can tell them apart when an event touches more than one (compare
681    /// `key` against the event's own fields to decide, e.g., whether this
682    /// call is crediting or debiting). Ignored entirely by a projection
683    /// that never overrides `keys()` - it only ever sees the one constant
684    /// `""` instance, the same single fold every existing projection
685    /// already does.
686    fn project(state: &mut Self::State, event: &Self::Event, key: &str);
687}
688
689/// A folded, tag-scoped accelerator for `CommandType::decide()` against
690/// a large `matching_events` history ([docs/architecture.md §19](../../../docs/architecture.md#optional-snapshotting-matching-events)'s
691/// "Problem 2") - `CommandType::snapshot()`'s own doc comment is where a
692/// command type opts in.
693///
694/// Deliberately **not** `Projection`, even though the shape rhymes
695/// (`fold` vs. `project`, `TAG_KEY` vs. `keys()`, `VERSION` vs. a
696/// restage): `Projection`s are eventually-consistent, rebuildable/
697/// restageable, Admin-exposed read-model infrastructure with no
698/// obligation to the timing/correctness bar a DCB consistency check
699/// needs - letting `decide()`'s own inputs depend on `Projection`
700/// machinery would mean an operator rebuilding a projection for
701/// ordinary read-model reasons could silently corrupt what `decide()`
702/// sees. `Snapshot` is its own trait, own storage
703/// (`{schema}.snapshots`/`{schema}.snapshot_progress`), own background
704/// catch-up task, own inspection endpoint - no shared code path a
705/// read-model change could destabilise.
706///
707/// Scoped to one tag key, holding a state per value of it. A command
708/// type of any shape uses it through snapshot items in its consistency
709/// query (`QueryItemMapping::snapshot`, docs/architecture.md §202): any
710/// number of them, over one snapshot or several, next to ordinary items,
711/// each handing `decide_with()` the state for one value. An event
712/// carrying several values of the key - a transfer between two accounts -
713/// is folded into each value's state, through `fold_for`, which is told
714/// the value.
715///
716/// One `Snapshot` definition is shared by every `CommandType` that tags
717/// on its `TAG_KEY` and opts in - `banking.rs`'s `DepositMoney`/
718/// `WithdrawMoney` both tag on `account` and both want the same running
719/// balance, so they share one `AccountBalanceSnapshot` rather than each
720/// computing their own, free to drift apart.
721pub trait Snapshot {
722    /// The state `fold()` builds per tag value, stored as JSON. `Default` is
723    /// the state before any event.
724    type State: Serialize + DeserializeOwned + JsonSchema + Default;
725
726    /// Same generated per-bounded-context event enum `CommandType::Event`/
727    /// `Projection::Event` use - a snapshot folds any event type
728    /// carrying its `TAG_KEY`, not just one.
729    type Event: BoundedContextEvent;
730
731    /// The snapshot's name, unique within its bounded context - what
732    /// `SnapshotState::snapshot` and `Decision::snapshot` name it by.
733    const NAME: &'static str;
734
735    /// See `CommandType::BOUNDED_CONTEXT`'s own doc comment - identical
736    /// role and default here, for `skilj::SkiljBuilder::auto_register()`'s
737    /// `Snapshot` side.
738    const BOUNDED_CONTEXT: &'static str = DEFAULT_BOUNDED_CONTEXT;
739
740    /// The single tag key this snapshot is scoped to - see this trait's
741    /// own doc comment for why only one. The background catch-up task
742    /// derives which tag *value* an event belongs to generically, from
743    /// `event.tags`, so there's no `keys()`-equivalent method to
744    /// implement here the way `Projection` needs one.
745    const TAG_KEY: &'static str;
746
747    /// Which tag key names this snapshot's "owner" dimension, if it has
748    /// one and it differs from `TAG_KEY` itself - `None` (the default)
749    /// for a snapshot with no such notion. Deliberately Rust-only, no
750    /// spec entity field and no registration surface, the identical
751    /// treatment `TAG_KEY` itself already gets (`Snapshot` is compiled,
752    /// deployed configuration throughout - see this trait's own doc
753    /// comment). When set, each stored row's own owner value is derived
754    /// at fold time from whichever event touched it: the value of the
755    /// tag on `event.tags` whose key matches this one, if present - not
756    /// necessarily the same tag `TAG_KEY` itself reads (a snapshot keyed
757    /// by `"account"` might still need owner-scoping by `"company"`).
758    /// `inspectSnapshot` rejects a `scope`-restricted `RoleAccessMapping`
759    /// whose `scope` does not match a stored row's own derived owner -
760    /// see `access_control::scope_matches_owner` and
761    /// `snapshot_query::inspect_snapshot_field`'s own doc comment. Cross-
762    /// tenant read fix (docs/architecture.md's own write-up of these
763    /// passes).
764    const OWNER_TAG_KEY: Option<&'static str> = None;
765
766    /// Bumped by hand whenever `fold()`'s own logic or `State`'s shape
767    /// changes - a stored row at an older version is treated as if it
768    /// doesn't exist (see `CommandType::decide_from_snapshot`'s own doc
769    /// comment), never trusted. Not inferred: Rust has no way to detect
770    /// a fold's own semantic change, only a decider declaring one can.
771    const VERSION: u64;
772
773    /// How many partitions this snapshot's own tag values hash into for
774    /// catch-up (issue #25, docs/architecture.md §52 - the
775    /// `Snapshot` twin of `Projection::PARTITION_COUNT`, see that
776    /// const's own doc comment for the full reasoning, identical here
777    /// with the derived tag *value* playing the role `Projection`'s own
778    /// `keys()` return values play). `1` (the default) means every tag
779    /// value hashes to the same partition, so `catch_up_snapshots` takes
780    /// its original, unpartitioned code path unchanged.
781    const PARTITION_COUNT: u32 = 1;
782
783    /// Folds one event into `state`, in place - the identical shape
784    /// `Projection::project` already has, minus the `key` parameter.
785    /// Implement this, or [`fold_for`](Snapshot::fold_for) when the fold
786    /// depends on which tag value it's folding for; the framework only
787    /// calls `fold_for`, whose default calls this. The default here
788    /// changes nothing.
789    fn fold(_state: &mut Self::State, _event: &Self::Event) {}
790
791    /// Folds `event` into the state for `tag_value`, one of the values of
792    /// `TAG_KEY` the event carries (docs/architecture.md §202). An event
793    /// carrying several - a transfer tagged with both accounts - is
794    /// folded into each value's state, once per value, so a fold that
795    /// treats them differently (a debit for one, a credit for the other)
796    /// tells them apart by `tag_value`. The default calls `fold()`.
797    fn fold_for(state: &mut Self::State, tag_value: &str, event: &Self::Event) {
798        let _ = tag_value;
799        Self::fold(state, event)
800    }
801}
802
803/// One blind-index constraint (docs/architecture.md §205): the tag key
804/// whose values are stored as a keyed hash of a payload field instead of
805/// the field, so a command can check that no earlier event carries the
806/// same value - an email address registered once - without the value
807/// ever being stored in the clear. A type tags it with
808/// [`TagMapping::blind::<Self>`](crate::shared::TagMapping::blind), which
809/// may name a sensitive field; a command's consistency query reads it
810/// like any other tag.
811///
812/// ```
813/// use skilj_core::blind::normalize;
814/// use skilj_core::plugin::{BlindErasure, BlindTag};
815///
816/// struct CustomerEmail;
817/// impl BlindTag for CustomerEmail {
818///     const BOUNDED_CONTEXT: &'static str = "crm";
819///     const TAG_KEY: &'static str = "email";
820///     const ERASURE: BlindErasure = BlindErasure::with_subject("customer");
821///     fn normalize(value: &str) -> String {
822///         normalize::email(value)
823///     }
824/// }
825/// ```
826///
827/// Every instance serving the bounded context must declare it, with the
828/// same `blind_index_key`: an instance that doesn't refuses to derive the
829/// tag (`blind_tag_not_declared`) rather than store the value.
830pub trait BlindTag {
831    /// See `CommandType::BOUNDED_CONTEXT`'s own doc comment.
832    const BOUNDED_CONTEXT: &'static str = DEFAULT_BOUNDED_CONTEXT;
833
834    /// The tag key. One `BlindTag` per key per bounded context; the key's
835    /// constraint is its own HMAC key, derived from the
836    /// `blind_index_key` and `(BOUNDED_CONTEXT, TAG_KEY)`.
837    const TAG_KEY: &'static str;
838
839    /// Bumped by hand whenever [`normalize`](BlindTag::normalize)
840    /// changes what it returns for any input. Values hashed under the old
841    /// normalizer don't match new ones, so `build()` refuses a version
842    /// that differs from the stored one rather than silently letting a
843    /// taken value be taken again.
844    const NORMALIZER_VERSION: u32 = 1;
845
846    /// What `forgetSubject` does with the tag - see [`BlindErasure`].
847    const ERASURE: BlindErasure;
848
849    /// What a payload value becomes before it's hashed, so values that
850    /// mean the same thing collide: [`crate::blind::normalize::email`]
851    /// trims and lowercases. Must be deterministic. It can also add an
852    /// application secret of its own - a pepper the database never sees.
853    /// The default leaves the value as it is.
854    fn normalize(value: &str) -> String {
855        value.to_string()
856    }
857}
858
859/// What `forgetSubject` does with a [`BlindTag`]'s hashes.
860#[derive(Debug, Clone, Copy, PartialEq, Eq)]
861#[non_exhaustive]
862pub enum BlindErasure {
863    /// The hash stays: the value is still taken after the subject is
864    /// forgotten. For a rule that outlives the person - a trial used once,
865    /// an address that was banned.
866    Keep,
867    /// Forgetting `subject_key`'s subject removes the tag from every event
868    /// and command whose `subject_tag` tag carries the subject's value, so
869    /// the value can be taken again and no hash of it is left. Every type
870    /// tagging the blind key must also tag `subject_tag`; `build()`
871    /// checks.
872    WithSubject {
873        /// The `SensitiveField` subject key whose forgetting removes the tag.
874        subject_key: &'static str,
875        /// The plain tag carrying the subject's value on every event and
876        /// command that carries the blind tag.
877        subject_tag: &'static str,
878    },
879}
880
881impl BlindErasure {
882    /// [`BlindErasure::WithSubject`] where the encryption subject key and
883    /// the plain tag carrying the subject's value share one name - the
884    /// usual case, e.g. `SensitiveField::new("email", "customer",
885    /// "customer_id")` next to `TagMapping::new("customer", "customer_id")`.
886    pub const fn with_subject(subject: &'static str) -> Self {
887        BlindErasure::WithSubject {
888            subject_key: subject,
889            subject_tag: subject,
890        }
891    }
892}
893
894impl From<BlindErasure> for crate::blind::Erasure {
895    fn from(erasure: BlindErasure) -> Self {
896        match erasure {
897            BlindErasure::Keep => crate::blind::Erasure::Keep,
898            BlindErasure::WithSubject {
899                subject_key,
900                subject_tag,
901            } => crate::blind::Erasure::WithSubject {
902                subject_key: subject_key.to_string(),
903                subject_tag: subject_tag.to_string(),
904            },
905        }
906    }
907}
908
909/// One snapshot's state for one tag value, as a decision is handed it
910/// (docs/architecture.md §202): the stored row with every later event
911/// carrying the value folded in, JSON-encoded. Read typed through
912/// [`Decision::snapshot`].
913#[derive(Debug, Clone, PartialEq, Eq)]
914pub struct SnapshotState {
915    /// `Snapshot::NAME`.
916    pub snapshot: String,
917    /// The tag value this state was folded for.
918    pub tag_value: String,
919    /// The folded state, JSON-encoded.
920    pub state_json: String,
921    /// The position `state_json` holds every event carrying the value
922    /// through: the stored row's `as_of_sequence`, or the last event
923    /// folded on top of it; -1 for none.
924    pub folded_through: i64,
925}
926
927/// The time a decision is made against, and, for a command a deadline
928/// fired, that deadline's own schedule and expiry (issue #97).
929///
930/// `decide()` never reads a clock itself (`CommandType`'s own doc comment,
931/// §1.1): the framework stamps every submission with the `now` it uses for
932/// the command's own `created_at`, its idempotency row and the events it
933/// appends, and hands that same instant here. A decision therefore stays
934/// deterministic and replayable, and a test can drive it with any `now`.
935/// `decide_with()` reads it through [`Decision::context`] (`Decision::now`
936/// and `Decision::deadline` are the two shorthands); the legacy
937/// `decide_from_snapshot` is handed it directly.
938#[derive(Debug, Clone, PartialEq, Eq)]
939pub struct DecisionContext {
940    /// The instant the submission is stamped with - the same `now` the
941    /// command's own `created_at` and idempotency row carry.
942    pub now: chrono::DateTime<chrono::Utc>,
943    /// Present only when this submission is a deadline firing
944    /// (`db::fire_due_deadlines`); `None` for every ordinary submission.
945    pub deadline: Option<DeadlineContext>,
946}
947
948impl DecisionContext {
949    /// An ordinary submission's context: `now`, no deadline.
950    pub fn at(now: chrono::DateTime<chrono::Utc>) -> Self {
951        Self {
952            now,
953            deadline: None,
954        }
955    }
956
957    /// A deadline firing's context (issue #97): `scheduled_at` is
958    /// the fired row's own `fire_at`, `deadline_at` its optional
959    /// `expires_at`.
960    pub fn for_deadline(
961        now: chrono::DateTime<chrono::Utc>,
962        scheduled_at: chrono::DateTime<chrono::Utc>,
963        deadline_at: Option<chrono::DateTime<chrono::Utc>>,
964    ) -> Self {
965        Self {
966            now,
967            deadline: Some(DeadlineContext::new(scheduled_at, deadline_at, now)),
968        }
969    }
970}
971
972/// What a deadline-fired command's `decide()` may reason about (
973/// issue #97): `scheduled_at` is the deadline's own `fire_at` (when the
974/// chain intended it to fire), `deadline_at` its optional `expires_at`,
975/// and `expired` whether this firing happened after `deadline_at`. A
976/// target command can refuse an action it fired too late for, and can tell
977/// a genuine firing from a redrive or a retry by comparing
978/// `DecisionContext::now` with `scheduled_at`.
979#[derive(Debug, Clone, Copy, PartialEq, Eq)]
980pub struct DeadlineContext {
981    /// The fired row's own `fire_at` - when the deadline was scheduled to
982    /// fire.
983    pub scheduled_at: chrono::DateTime<chrono::Utc>,
984    /// The fired row's own `expires_at`, if it had one.
985    pub deadline_at: Option<chrono::DateTime<chrono::Utc>>,
986    /// `deadline_at` is `Some` and `now` is past it. Always `false` when
987    /// there is no expiry at all.
988    pub expired: bool,
989}
990
991impl DeadlineContext {
992    /// A deadline's context: `expired` is whether `now` is past `deadline_at`.
993    pub fn new(
994        scheduled_at: chrono::DateTime<chrono::Utc>,
995        deadline_at: Option<chrono::DateTime<chrono::Utc>>,
996        now: chrono::DateTime<chrono::Utc>,
997    ) -> Self {
998        Self {
999            scheduled_at,
1000            deadline_at,
1001            expired: deadline_at.is_some_and(|at| now > at),
1002        }
1003    }
1004}
1005
1006/// What `CommandType::decide_with` decides from (docs/architecture.md
1007/// §202): the events the command's query selects, the state of every
1008/// snapshot item it derives, and the [`DecisionContext`] the decision is
1009/// made against (issue #97). Built by the framework; room to grow
1010/// without changing `decide_with`'s signature.
1011pub struct Decision<'a, E> {
1012    events: &'a [E],
1013    snapshots: &'a [SnapshotState],
1014    context: &'a DecisionContext,
1015}
1016
1017impl<'a, E> Decision<'a, E> {
1018    /// Built by the framework for each decision.
1019    pub fn new(
1020        events: &'a [E],
1021        snapshots: &'a [SnapshotState],
1022        context: &'a DecisionContext,
1023    ) -> Self {
1024        Self {
1025            events,
1026            snapshots,
1027            context,
1028        }
1029    }
1030
1031    /// The events the query's items other than snapshot items select, in
1032    /// sequence order, each once - `decide()`'s `matching_events`.
1033    pub fn events(&self) -> &'a [E] {
1034        self.events
1035    }
1036
1037    /// Snapshot `S`'s state for `tag_value`, as of the command's read,
1038    /// with every event carrying the value folded in. `None` when no item
1039    /// of the query derived that value for `S`: a decision should treat
1040    /// that as a misconfiguration rather than an empty state.
1041    pub fn snapshot<S: Snapshot>(&self, tag_value: &str) -> Option<S::State> {
1042        self.snapshots
1043            .iter()
1044            .find(|s| s.snapshot == S::NAME && s.tag_value == tag_value)
1045            .and_then(|s| serde_json::from_str(&s.state_json).ok())
1046    }
1047
1048    /// The time this decision is made against, and, for a deadline firing,
1049    /// the deadline's own context (issue #97).
1050    pub fn context(&self) -> &'a DecisionContext {
1051        self.context
1052    }
1053
1054    /// Shorthand for `self.context().now` - the instant the submission is
1055    /// stamped with, so `decide()` can reason about time without reading a
1056    /// clock.
1057    pub fn now(&self) -> chrono::DateTime<chrono::Utc> {
1058        self.context.now
1059    }
1060
1061    /// Shorthand for `self.context().deadline`: the deadline that fired
1062    /// this command, when one did (issue #97).
1063    pub fn deadline(&self) -> Option<&'a DeadlineContext> {
1064        self.context.deadline.as_ref()
1065    }
1066}
1067
1068/// Type-erased dispatch to a bounded context's own typed `decide()` -
1069/// what `CommandTrigger`'s REST handler and GraphQL's `CommandSubmission`
1070/// resolver both call through to actually process a command. Lives here,
1071/// as a trait, rather than `skilj-rest`/
1072/// `skilj-graphql` reaching into whichever crate happens to build the
1073/// concrete registry (`skilj`'s `SkiljBuilder`, today): both surface
1074/// crates already depend on `skilj-core`, so this is the natural shared
1075/// boundary - the same "opaque handle both sides can reach" reasoning
1076/// `db::Pool` already uses. See docs/architecture.md §1.7/[§8](../../../docs/architecture.md#open-for-a-future-pass) item 4.
1077///
1078/// A consumer using `skilj-core` + `skilj-rest` directly, without the
1079/// `skilj` facade's builder, implements this by hand instead - nothing
1080/// about it is facade-specific.
1081pub trait CommandDispatcher: Send + Sync {
1082    /// `None` when no `(bounded_context, command_type)` pair matches
1083    /// anything registered - the caller decides what that means for its
1084    /// own wire contract (an unrecognised-command rejection, a 404,
1085    /// whatever fits), since neither existing wire contract has needed
1086    /// to yet and this trait shouldn't invent one on their behalf.
1087    fn dispatch(
1088        &self,
1089        bounded_context: &str,
1090        command_type: &str,
1091        payload: &str,
1092        matching_events: &[Event],
1093    ) -> Option<crate::error::Result<CommandDecision>>;
1094
1095    /// `dispatch`, with the states of the query's snapshot items
1096    /// (docs/architecture.md §202) and the [`DecisionContext`] the
1097    /// decision is made against (issue #97) - what the command
1098    /// path calls. The default ignores `snapshots` and `context`, calling
1099    /// `dispatch`, right for a dispatcher whose command types declare no
1100    /// snapshot items and don't read time.
1101    fn dispatch_with(
1102        &self,
1103        bounded_context: &str,
1104        command_type: &str,
1105        payload: &str,
1106        matching_events: &[Event],
1107        snapshots: &[SnapshotState],
1108        context: &DecisionContext,
1109    ) -> Option<crate::error::Result<CommandDecision>> {
1110        let _ = (snapshots, context);
1111        self.dispatch(bounded_context, command_type, payload, matching_events)
1112    }
1113
1114    /// The snapshots this dispatcher's command types name in
1115    /// [`QueryItemMapping::snapshot`] items (docs/architecture.md §202):
1116    /// what the command path reads their versions, defaults and folds
1117    /// from, before the lock and under it. `None`, the default, for a
1118    /// dispatcher whose command types declare none; a command whose query
1119    /// has one is then refused as a configuration error.
1120    fn snapshots(&self) -> Option<&dyn SnapshotDispatcher> {
1121        None
1122    }
1123
1124    /// The registered command type's own `CommandType::required_role()` -
1125    /// see that method's own doc comment. Outer `None` mirrors
1126    /// `dispatch`'s "pair isn't registered at all" convention; inner
1127    /// `None` is a registered command type declaring no extra gate (the
1128    /// default). Meant to be checked by `skilj-graphql`'s mutation
1129    /// resolver *before* calling `dispatch` at all, so an unauthorised
1130    /// caller never reaches `decide()` - not folded into `dispatch`
1131    /// itself, since REST triggering never needs this check (`CommandToken`
1132    /// is its own, separate per-token grant) and shouldn't pay for it.
1133    fn required_role(
1134        &self,
1135        bounded_context: &str,
1136        command_type: &str,
1137    ) -> Option<Option<&'static str>>;
1138
1139    /// The registered command type's own `CommandType::consistency_query()`
1140    /// (docs/architecture.md §198) - `None` when the pair isn't
1141    /// registered, or for a dispatcher that predates queries; either reads
1142    /// as the default query.
1143    fn consistency_query(
1144        &self,
1145        _bounded_context: &str,
1146        _command_type: &str,
1147    ) -> Option<Vec<QueryItemMapping>> {
1148        None
1149    }
1150
1151    /// The registered command type's own `CommandType::snapshot()` -
1152    /// same outer/inner `Option` convention as `required_role`. Meant to
1153    /// be checked before `dispatch`, the same way `required_role` is:
1154    /// if this is `Some(Some(name))` and the command's own derived tags
1155    /// match `SnapshotDispatcher::tag_key(bc, name)` exactly, the caller
1156    /// should read that snapshot and call `dispatch_from_snapshot`
1157    /// instead of `dispatch` - see [docs/architecture.md §19](../../../docs/architecture.md#optional-snapshotting-matching-events).
1158    fn snapshot_name(
1159        &self,
1160        bounded_context: &str,
1161        command_type: &str,
1162    ) -> Option<Option<&'static str>>;
1163
1164    /// `CommandType::decide_from_snapshot`'s own type-erased entry
1165    /// point - `dispatch`'s counterpart for the snapshot-accelerated
1166    /// path. `None` for the identical "pair isn't registered at all"
1167    /// case `dispatch` already has.
1168    fn dispatch_from_snapshot(
1169        &self,
1170        bounded_context: &str,
1171        command_type: &str,
1172        payload: &str,
1173        snapshot_state_json: &str,
1174        events_since_snapshot: &[Event],
1175        context: &DecisionContext,
1176    ) -> Option<crate::error::Result<CommandDecision>>;
1177
1178    /// The blind tags this process declares, keyed for hashing
1179    /// (docs/architecture.md §205) - what every tag derivation on the
1180    /// command path, and event creation through the same surfaces, hashes
1181    /// a blind mapping with. Empty, the default, for a dispatcher whose
1182    /// bounded contexts declare none: a stored blind mapping is then
1183    /// refused with `blind_tag_not_declared`, never derived in the clear.
1184    fn blind_index(&self) -> crate::blind::BlindIndex {
1185        crate::blind::BlindIndex::default()
1186    }
1187}
1188
1189/// Type-erased dispatch to a bounded context's own typed `project()` -
1190/// `CommandDispatcher`'s own counterpart for the other black box §1.1
1191/// draws the pure-function line around. What the new transactional
1192/// `db::insert_event_and_update_sync_projections` calls through to
1193/// actually fold an event into a `sync` projection's stored state - see
1194/// docs/architecture.md's own write-up of this pass ([§8](../../../docs/architecture.md#open-for-a-future-pass) item 6).
1195///
1196/// Async (`sync: false`, the default) projections are folded by the
1197/// background consumer `db::catch_up_bounded_context` - see its own doc
1198/// comment and docs/architecture.md's write-up of that pass. Same
1199/// `project()` call either way; the consumer doesn't know or care whether
1200/// its caller is a request (the sync path) or a poll tick (this one).
1201pub trait ProjectionDispatcher: Send + Sync {
1202    /// `None` when no `(bounded_context, projection_name)` pair matches
1203    /// anything registered - the same "pair isn't registered at all"
1204    /// convention `dispatch`/`project` below already use. `Some(vec![])`
1205    /// when the pair *is* registered but `event`'s own type isn't one
1206    /// this projection actually consumes (`Projection::keys` is never
1207    /// called for it) - `caught_up_to` still advances, no instance is
1208    /// touched, the identical "position always advances, state only
1209    /// changes when consumed" treatment this trait's own `project`
1210    /// already documents. Otherwise, `Projection::keys(event)` verbatim -
1211    /// every instance this one event updates, one call to `project`
1212    /// below per key.
1213    fn keys(
1214        &self,
1215        bounded_context: &str,
1216        projection_name: &str,
1217        event: &Event,
1218    ) -> Option<Vec<String>>;
1219
1220    /// `None` when no `(bounded_context, projection_name)` pair matches
1221    /// anything registered - the same "pair isn't registered at all"
1222    /// convention `CommandDispatcher::dispatch` uses. `Some(Ok(new_state))`
1223    /// is the projection's `state_json` after folding `event` into it,
1224    /// for the one instance named by `key` (one of `keys`'s own return
1225    /// values) - `BoundedContextEvent::try_from_event` succeeding is not
1226    /// by itself permission to call `project()`; the caller only reaches
1227    /// this once per key `keys` returned.
1228    fn project(
1229        &self,
1230        bounded_context: &str,
1231        projection_name: &str,
1232        state_json: &str,
1233        event: &Event,
1234        key: &str,
1235    ) -> Option<crate::error::Result<String>>;
1236
1237    /// `T::State::default()`, JSON-serialised - `None` for the identical
1238    /// "pair isn't registered at all" case `project`/`dispatch` already
1239    /// have. The only source `db::catch_up_bounded_context` has for a
1240    /// `ProjectionRebuild`'s own starting state when it begins (or
1241    /// resumes after a restage) folding one: a rebuild's `caught_up_to`
1242    /// resets to `None` on every `db::upsert_projection_rebuild` call,
1243    /// including a restage of an already-`building` row (see that
1244    /// function's own doc comment), so whatever state a previous attempt
1245    /// had accumulated is stale the moment that happens - only the
1246    /// dispatcher, not whichever caller triggered the reset, knows the
1247    /// *current* correct starting point.
1248    fn default_state(&self, bounded_context: &str, projection_name: &str) -> Option<String>;
1249
1250    /// The registered projection's own `Projection::OWNER_TAG_KEY` -
1251    /// `CommandDispatcher::snapshot_name`'s identical `Option<Option<_>>`
1252    /// shape: outer `None` for the same "pair isn't registered at all"
1253    /// case every method here already has, inner `None` when the
1254    /// projection is registered but declares no owner dimension (the
1255    /// default, and every projection that predates this).
1256    fn owner_tag_key(
1257        &self,
1258        bounded_context: &str,
1259        projection_name: &str,
1260    ) -> Option<Option<&'static str>>;
1261
1262    /// The registered projection's own `Projection::REQUIRES_CAPABILITY` - the
1263    /// identical `Option<Option<_>>` shape `owner_tag_key` just above
1264    /// already has, for the same reason. Defaulted to `None` (unlike
1265    /// `owner_tag_key`, which has no default) - every call site here
1266    /// only ever `.flatten()`s the result, for which the outer "pair
1267    /// isn't registered" `None` and the inner "registered, no capability
1268    /// required" `Some(None)` already read identically, so a dispatcher
1269    /// with nothing capability-gated needs no explicit override at all.
1270    fn requires_capability(
1271        &self,
1272        _bounded_context: &str,
1273        _projection_name: &str,
1274    ) -> Option<Option<&'static str>> {
1275        None
1276    }
1277
1278    /// The registered projection's own `Projection::PARTITION_COUNT`
1279    /// (issue #25, docs/architecture.md §51) - `None` for the
1280    /// same "pair isn't registered at all" case every method here
1281    /// already has, treated by every real caller (`db::
1282    /// catch_up_bounded_context`) identically to `Some(1)`: unpartitioned.
1283    /// Defaulted (unlike `owner_tag_key`, which has no default) for the
1284    /// same reason `requires_capability` is - a dispatcher with nothing partitioned
1285    /// needs no explicit override at all.
1286    fn partition_count(&self, _bounded_context: &str, _projection_name: &str) -> Option<u32> {
1287        None
1288    }
1289}
1290
1291/// Type-erased dispatch to a bounded context's own typed `Snapshot::fold` -
1292/// `CommandDispatcher`'s `dispatch_from_snapshot`/`snapshot_name`'s own
1293/// counterpart for the other half of the type-erasure boundary, and what
1294/// `db::catch_up_snapshots` (the background catch-up task -
1295/// [docs/architecture.md §19](../../../docs/architecture.md#optional-snapshotting-matching-events)) calls through to actually fold an event
1296/// into a stored snapshot row. Same "outer `None` = not registered"
1297/// convention every other dispatcher trait in this module already uses.
1298pub trait SnapshotDispatcher: Send + Sync {
1299    /// Every `Snapshot::NAME` registered for `bounded_context` in this
1300    /// process - `db::catch_up_snapshots`' own discovery mechanism,
1301    /// since (deliberately - see `Snapshot`'s own doc comment) there is
1302    /// no `snapshots` metadata table to enumerate the way
1303    /// `list_projections_for_bounded_context` does for `Projection`.
1304    fn snapshot_names(&self, bounded_context: &str) -> Vec<&'static str>;
1305
1306    /// The registered snapshot's own `Snapshot::TAG_KEY`.
1307    fn tag_key(&self, bounded_context: &str, snapshot_name: &str) -> Option<&'static str>;
1308
1309    /// The registered snapshot's own `Snapshot::OWNER_TAG_KEY` -
1310    /// `ProjectionDispatcher::owner_tag_key`'s identical `Option<Option<_>>`
1311    /// shape: outer `None` for the same "pair isn't registered at all"
1312    /// case every method here already has, inner `None` when the
1313    /// snapshot is registered but declares no owner dimension (the
1314    /// default, and every snapshot that predates this).
1315    fn owner_tag_key(
1316        &self,
1317        bounded_context: &str,
1318        snapshot_name: &str,
1319    ) -> Option<Option<&'static str>>;
1320
1321    /// The registered snapshot's own `Snapshot::VERSION` - compared
1322    /// against a stored row's `snapshot_version` to decide whether it's
1323    /// still trustworthy (see `CommandType::decide_from_snapshot`'s own
1324    /// doc comment).
1325    fn version(&self, bounded_context: &str, snapshot_name: &str) -> Option<u64>;
1326
1327    /// `Some(Ok(new_state))` is `state_json` with `event` folded into it
1328    /// via `Snapshot::fold` - `None` for the identical "pair isn't
1329    /// registered at all" case every dispatcher here already has.
1330    /// `event`'s own `BoundedContextEvent::try_from_event` failing is a
1331    /// real `Err`, not a silent skip - a stored event this snapshot's
1332    /// own `TAG_KEY` names should always convert cleanly.
1333    fn fold(
1334        &self,
1335        bounded_context: &str,
1336        snapshot_name: &str,
1337        state_json: &str,
1338        event: &Event,
1339    ) -> Option<crate::error::Result<String>>;
1340
1341    /// `fold`, for one of the values of the snapshot's tag key `event`
1342    /// carries: `Snapshot::fold_for` (docs/architecture.md §202). What
1343    /// catch-up and the command path call. The default ignores
1344    /// `tag_value` and calls `fold`.
1345    fn fold_value(
1346        &self,
1347        bounded_context: &str,
1348        snapshot_name: &str,
1349        tag_value: &str,
1350        state_json: &str,
1351        event: &Event,
1352    ) -> Option<crate::error::Result<String>> {
1353        let _ = tag_value;
1354        self.fold(bounded_context, snapshot_name, state_json, event)
1355    }
1356
1357    /// `T::State::default()`, JSON-serialised - the starting point for a
1358    /// tag value with no stored snapshot yet, or one whose stored
1359    /// `snapshot_version` no longer matches. `None` for the identical
1360    /// "pair isn't registered at all" case.
1361    fn default_state(&self, bounded_context: &str, snapshot_name: &str) -> Option<String>;
1362
1363    /// The registered snapshot's own `Snapshot::PARTITION_COUNT`
1364    /// (issue #25, docs/architecture.md §52) -
1365    /// `ProjectionDispatcher::partition_count`'s identical shape and
1366    /// defaulting: `None` for the same "pair isn't registered at all"
1367    /// case every method here already has, treated by every real caller
1368    /// (`db::catch_up_snapshots`) identically to `Some(1)`: unpartitioned.
1369    fn partition_count(&self, _bounded_context: &str, _snapshot_name: &str) -> Option<u32> {
1370        None
1371    }
1372}
1373
1374/// A single-hop, stateless reaction: when `Source` commits in its own
1375/// bounded context, submit `Target`'s own command into *its* bounded
1376/// context - the answer to "make messages cross bounded contexts
1377/// without needing an external system like Temporal" (docs/architecture.md's
1378/// own write-up of this pass) for the case that never needed Temporal's
1379/// own durable-timer/retry/compensation machinery in the first place:
1380/// both bounded contexts already live in the same Postgres database,
1381/// often the same process, so relaying one committed fact into another
1382/// bounded context's own command needs nothing more than a durable
1383/// cursor and the same `submit_command` path every other caller already
1384/// goes through.
1385///
1386/// Deliberately **not** a Saga/process manager - see this trait's own
1387/// design note in docs/architecture.md: no multi-step state is tracked
1388/// across calls, no compensation, no retry beyond `submit_command`'s own
1389/// idempotency-key mechanism. One commit causes at most one more commit
1390/// elsewhere, the same bounded, single-hop shape `submit_command`'s own
1391/// DCB-conflict retry already has - not an open-ended state machine.
1392///
1393/// A `Target` command that gets rejected by its own `decide()` is a
1394/// legitimate business outcome here too (§5.4's own "a business
1395/// rejection is not an error" register), not a failure to retry -
1396/// `route()` translating `Source`'s payload into a `Target` payload
1397/// `Target::decide()` goes on to reject is exactly as valid an outcome
1398/// as one it accepts.
1399pub trait CrossContextRoute {
1400    /// The `EventType` this route reacts to, in its own bounded context.
1401    type Source: EventType;
1402    /// The `CommandType` this route submits, in *its* bounded context -
1403    /// deliberately a command, not a raw event: `Target`'s own
1404    /// `decide()` still gets to accept, reject, or reshape the outcome,
1405    /// the same as every other command submission. A route that skipped
1406    /// straight to creating an event would bypass whatever domain rules
1407    /// the target bounded context wants to enforce on its own history.
1408    type Target: CommandType;
1409
1410    /// This route's own stable identity - names its durable cursor row
1411    /// (`{Source::BOUNDED_CONTEXT}.cross_context_route_cursors`) and the
1412    /// idempotency key derived for every command it submits
1413    /// (`"{NAME}:{source_event_sequence}"`), so a redelivered/retried
1414    /// catch-up tick is exactly as safe as any other idempotency-keyed
1415    /// submission already is.
1416    const NAME: &'static str;
1417
1418    /// Where this route's own durable cursor starts the first time it is
1419    /// ever registered - `Beginning` (the default) replays every
1420    /// `Source` occurrence ever committed, exactly what every route
1421    /// registered before this const existed already does. A route whose
1422    /// job is a side effect that must never re-fire for old history (the
1423    /// canonical case: `UserRegistered -> SendWelcomeEmail`) overrides
1424    /// this to `Latest`, the `CrossContextRoute`-side counterpart to
1425    /// `EventReadStartPosition::Latest` on the spec-backed `EventReadToken`
1426    /// track (docs/architecture.md's own write-up of this pass) - a
1427    /// separate Rust-only type rather than reusing that one, since a
1428    /// route has no spec entity of its own to hang a shared type off of
1429    /// (§36's own "no spec entity" note). See
1430    /// `db::catch_up_cross_context_route`'s own doc comment for exactly
1431    /// when this is read - only on this route's first-ever catch-up
1432    /// tick, never again once a cursor row exists.
1433    const START_FROM: CrossContextRouteStartFrom = CrossContextRouteStartFrom::Beginning;
1434
1435    /// `None` skips this occurrence of `Source` entirely - no command
1436    /// submitted, cursor still advances (retrying can never produce a
1437    /// target payload for an occurrence this route itself decided
1438    /// doesn't apply). `Some(payload)` submits `Target` with that
1439    /// payload, going through the identical `decide()`/`submit_command`
1440    /// path every other caller of `Target` already does.
1441    fn route(
1442        source_payload: &<Self::Source as EventType>::Payload,
1443    ) -> Option<<Self::Target as CommandType>::Payload>;
1444}
1445
1446/// See `CrossContextRoute::START_FROM`. Deliberately not
1447/// `#[derive(Default)]`: `Beginning` is the trait const's own default,
1448/// spelled out explicitly at every use site (`CrossContextRouteInfo::
1449/// start_from`) rather than leaned on implicitly, so a reader never has
1450/// to check whether "the default" here means this type's `Default` impl
1451/// or the trait const's - there is only ever one to check.
1452///
1453/// `AtSequence`/`AtTime` are this trait's own counterpart to
1454/// `EventReadStartPosition`'s identically-named extension
1455/// (docs/architecture.md's own write-up of this pass) - a route's first
1456/// tick seeds its cursor at a caller-chosen sequence, or at the highest
1457/// sequence already committed at or before a given point in time,
1458/// exactly like an `EventReadToken` minted the same way would. `AtTime`
1459/// carries a plain Unix timestamp (seconds, UTC) rather than
1460/// `chrono::DateTime<Utc>` for one structural reason: `START_FROM` is a
1461/// trait *associated const*, so every value it can ever hold has to be
1462/// const-evaluable at the implementing type's own definition site, and
1463/// `DateTime<Utc>` has no `const fn` constructor to build one from - an
1464/// `i64` literal is. Converted to a real `DateTime<Utc>` only where it's
1465/// actually compared against event timestamps
1466/// (`db::catch_up_cross_context_route`).
1467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1468#[non_exhaustive]
1469pub enum CrossContextRouteStartFrom {
1470    /// From the first event in the source bounded context.
1471    Beginning,
1472    /// From the events committed after the route first starts; earlier ones
1473    /// are skipped.
1474    Latest,
1475    /// From the event after this sequence number.
1476    AtSequence(i64),
1477    /// Unix timestamp, seconds, UTC - see this enum's own doc comment
1478    /// for why not `DateTime<Utc>`.
1479    AtTime(i64),
1480}
1481
1482/// One registered route's own static identity - `CrossContextRouteDispatcher::routes()`'s
1483/// own element type, enumerated once at startup by the background
1484/// catch-up loop (`db::catch_up_cross_context_route`).
1485#[derive(Debug, Clone, Copy)]
1486pub struct CrossContextRouteInfo {
1487    /// `CrossContextRoute::NAME`: the route's identity and the name of its
1488    /// cursor.
1489    pub name: &'static str,
1490    /// The bounded context the source event type lives in.
1491    pub source_bounded_context: &'static str,
1492    /// The event type whose events the route reacts to.
1493    pub source_event_type: &'static str,
1494    /// The bounded context the target command type lives in.
1495    pub target_bounded_context: &'static str,
1496    /// The command type the route submits.
1497    pub target_command_type: &'static str,
1498    /// `CrossContextRoute::START_FROM`, carried through so
1499    /// `db::catch_up_cross_context_route` can act on it without needing
1500    /// the route's own concrete type.
1501    pub start_from: CrossContextRouteStartFrom,
1502}
1503
1504/// Type-erased dispatch to a bounded context's own typed
1505/// `CrossContextRoute::route` - the same "outer `None` = not registered
1506/// at all" convention every other dispatcher trait in this module
1507/// already uses, one level deeper here since `route()` itself can also
1508/// legitimately produce nothing for a given occurrence.
1509pub trait CrossContextRouteDispatcher: Send + Sync {
1510    /// Every registered route, across every bounded context - routes
1511    /// aren't scoped to one bounded context the way `EventType`/
1512    /// `CommandType`/`Projection` are (a route by definition spans two),
1513    /// so unlike `ProjectionDispatcher::keys` and friends there is no
1514    /// `bounded_context` parameter to filter by here; the background
1515    /// catch-up loop calls this once and iterates the full list itself.
1516    fn routes(&self) -> Vec<CrossContextRouteInfo>;
1517
1518    /// `route_name` names one of `routes()`'s own entries;
1519    /// `source_payload_json` is the triggering `Source` event's own
1520    /// stored payload. Outer `None` - `route_name` isn't registered at
1521    /// all (defensive; the caller only ever names one of its own
1522    /// `routes()`). `Some(Err(e))` - the stored payload didn't
1523    /// deserialize into `Source::Payload` (see `BoundedContextEvent::
1524    /// try_from_event`'s own doc comment on why this is still reachable
1525    /// even though payloads are schema-checked at write time). `Some(Ok(None))` -
1526    /// `route()` itself decided this occurrence doesn't apply. `Some(Ok(Some(payload)))` -
1527    /// the `Target` command's own JSON payload, ready to submit.
1528    fn route(
1529        &self,
1530        route_name: &str,
1531        source_payload_json: &str,
1532    ) -> Option<Result<Option<String>, serde_json::Error>>;
1533}
1534
1535/// issue #20's own "where does this cursor start" const, one per
1536/// `ScheduleDeadline`/`CancelDeadline` implementor - identical in shape to
1537/// `CrossContextRouteStartFrom` but its own type rather than a reuse of
1538/// it: a deadline reactor isn't a route, and giving it a name that says
1539/// what it's actually for beats leaning on a type whose own doc comment
1540/// is written entirely in terms of routes. See `CrossContextRouteStartFrom`'s
1541/// own doc comment for why `AtTime` carries a plain Unix timestamp rather
1542/// than `DateTime<Utc>` - the identical reasoning (a trait associated
1543/// const has to be const-evaluable) applies here.
1544#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1545#[non_exhaustive]
1546pub enum DeadlinePollStartFrom {
1547    /// From the first event in the source bounded context.
1548    Beginning,
1549    /// From the events committed after the reactor first starts; earlier ones
1550    /// are skipped.
1551    Latest,
1552    /// From the event after this sequence number.
1553    AtSequence(i64),
1554    /// Unix timestamp, seconds, UTC.
1555    AtTime(i64),
1556}
1557/// Missed-run policy for recurring deadlines (issue #95).
1558///
1559/// Mirrors the global scheduler's own `MissedOccurrencePolicy`
1560/// (`ReplayBacklog`/`FireOnce`/`Skip`) but is deliberately its own type:
1561/// a global schedule and a per-entity deadline are different reactor
1562/// families (`SkipMissedOccurrences` skips on the shared `event_types`
1563/// position; this one advances a private `deadlines` chain), and a name
1564/// saying what it's for beats leaning on a type whose own doc comment is
1565/// written entirely in terms of `event_types`. Same ordering the global
1566/// one uses: `Skip` never replays, `FireOnce` collapses a backlog to one
1567/// firing, `FireAll` steps through every missed occurrence.
1568#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1569#[non_exhaustive]
1570pub enum MissedRunPolicy {
1571    /// Missed occurrences are not fired; the chain resumes at the next
1572    /// occurrence still ahead.
1573    Skip,
1574    /// A backlog of missed occurrences fires once, then the chain resumes
1575    /// ahead.
1576    FireOnce,
1577    /// Every missed occurrence fires, in order.
1578    FireAll,
1579}
1580
1581/// Recurrence specification for per-entity deadlines (issue
1582/// #95). `schedule` is the same 7-field Quartz-dialect cron expression
1583/// (`cron` crate) the global scheduler evaluates, so one mental model
1584/// covers both surfaces. `start_at`/`end_at` bound the occurrences
1585/// themselves; `valid_until` is the kill-switch - a pending row past it
1586/// never fires and begets nothing, even if an occurrence would still be
1587/// due. All three are optional: absent means unbounded that way.
1588#[derive(Debug, Clone)]
1589pub struct RecurrenceSpec {
1590    /// A 7-field Quartz-dialect cron expression (`cron` crate) the occurrences
1591    /// follow.
1592    pub schedule: String,
1593    /// What happens to occurrences missed while nothing could fire them.
1594    pub policy: MissedRunPolicy,
1595    /// No occurrence before this instant; `None` for no lower bound.
1596    pub start_at: Option<chrono::DateTime<chrono::Utc>>,
1597    /// No occurrence after this instant; `None` for no upper bound.
1598    pub end_at: Option<chrono::DateTime<chrono::Utc>>,
1599    /// A pending firing past this instant never fires and schedules nothing
1600    /// further; `None` for no limit.
1601    pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
1602}
1603
1604/// The next firing of a recurring deadline chain, given only the
1605/// previous firing - pure, no I/O, so both `db::fire_due_deadlines` and
1606/// the unit tests below evaluate the identical math. `prev_fire_at` is
1607/// the occurrence the chain just fired (or, at schedule time, the initial
1608/// `fire_at`); `now` bounds the catch-up, the same register
1609/// `event_store::create_system_event`'s own `nothing_later_is_due`
1610/// already is. Returns `None` when the chain ends: the schedule doesn't
1611/// parse, no occurrence is left, or the candidate fails the validity
1612/// window (`start_at`/`end_at`/`valid_until`, or `now` past
1613/// `valid_until`).
1614pub fn next_recurrence_fire_at(
1615    recurrence: &RecurrenceSpec,
1616    prev_fire_at: chrono::DateTime<chrono::Utc>,
1617    now: chrono::DateTime<chrono::Utc>,
1618) -> Option<chrono::DateTime<chrono::Utc>> {
1619    use crate::event_store::{latest_occurrence_at_or_before, next_occurrence_after};
1620    if now
1621        > recurrence
1622            .valid_until
1623            .unwrap_or(chrono::DateTime::<chrono::Utc>::MAX_UTC)
1624    {
1625        return None;
1626    }
1627    let candidate = match recurrence.policy {
1628        MissedRunPolicy::Skip => {
1629            // Missed occurrences are skipped outright: the first
1630            // occurrence strictly after whichever is later, the previous
1631            // firing or now.
1632            next_occurrence_after(&recurrence.schedule, prev_fire_at.max(now))?
1633        }
1634        MissedRunPolicy::FireOnce => {
1635            // A backlog collapses to a single firing of its latest
1636            // occurrence - the `fire_once` register §150 already is for
1637            // the global scheduler - and with no backlog this is just the
1638            // next occurrence after the previous firing.
1639            match latest_occurrence_at_or_before(&recurrence.schedule, now) {
1640                Some(latest) if latest > prev_fire_at => latest,
1641                _ => next_occurrence_after(&recurrence.schedule, prev_fire_at)?,
1642            }
1643        }
1644        MissedRunPolicy::FireAll => {
1645            // Step through every occurrence, missed or not.
1646            next_occurrence_after(&recurrence.schedule, prev_fire_at)?
1647        }
1648    };
1649    if candidate
1650        < recurrence
1651            .start_at
1652            .unwrap_or(chrono::DateTime::<chrono::Utc>::MIN_UTC)
1653    {
1654        return None;
1655    }
1656    if candidate
1657        > recurrence
1658            .end_at
1659            .unwrap_or(chrono::DateTime::<chrono::Utc>::MAX_UTC)
1660    {
1661        return None;
1662    }
1663    if candidate
1664        > recurrence
1665            .valid_until
1666            .unwrap_or(chrono::DateTime::<chrono::Utc>::MAX_UTC)
1667    {
1668        return None;
1669    }
1670    Some(candidate)
1671}
1672
1673/// `ScheduleDeadline::schedule`'s own `Some` output - everything a
1674/// one-shot, per-entity timer needs: when it fires (`fire_at`), what it's
1675/// scoped to for a later `CancelDeadline` lookup (`tags`), and what to
1676/// submit when it does (`payload`, `Self::Target`'s own payload type).
1677/// `recurrence` (issue #95) turns the one-shot into the first
1678/// firing of a chain: after it fires, `db::fire_due_deadlines` derives
1679/// the next `fire_at` from the cron schedule and the missed-run policy,
1680/// until the validity window ends or a `CancelDeadline` cancels the
1681/// pending row. `None` - the default every existing schedule passes - is
1682/// the unchanged one-shot.
1683///
1684/// `expires_at` (issue #97) is an optional latest instant this
1685/// firing is still meaningful. It is carried to the fired command's
1686/// `decide_with` through [`DecisionContext::deadline`] as
1687/// `deadline_at`/`expired`, so a target that only makes sense soon after
1688/// its `fire_at` - an auto-reply, a reminder - can refuse one the
1689/// scheduler or a retry ran late. `None` (the default every existing
1690/// schedule passes) is unchanged: a firing is never `expired`. A window
1691/// probe: it does not stop the row firing, only tells the command it was
1692/// late - a chain that wants to stop outright uses
1693/// `RecurrenceSpec::valid_until`.
1694#[derive(Debug, Clone)]
1695pub struct DeadlineSpec<P> {
1696    /// When the deadline fires.
1697    pub fire_at: chrono::DateTime<chrono::Utc>,
1698    /// See this struct's own doc comment (issue #97).
1699    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
1700    /// The tags a `CancelDeadline` matches to cancel this deadline before it
1701    /// fires.
1702    pub tags: Vec<Tag>,
1703    /// The target command's payload, submitted when the deadline fires.
1704    pub payload: P,
1705    /// Makes this the first firing of a recurring chain; `None` for a one-shot
1706    /// deadline.
1707    pub recurrence: Option<RecurrenceSpec>,
1708}
1709
1710/// issue #20: a native one-shot, per-entity deadline/timer -
1711/// "cancel this order if not paid within 30 minutes," without reaching
1712/// for `skilj-temporal` (docs/architecture.md §34) or hand-building it on
1713/// top of the recurring, `EventType`-wide scheduler (`rule
1714/// CreateSystemEvent`). Deliberately the same shape as `CrossContextRoute`
1715/// just above, for the same reason: `decide()`'s only output is
1716/// `CommandDecision` (`Accepted { events }` / `Rejected`) - it has no
1717/// side-effect channel to schedule a timer through, and none should be
1718/// added there. So scheduling one is itself modelled as a pure reaction
1719/// to a committed event, with a background poller doing the actual
1720/// durable write, exactly like a route reacting to one.
1721///
1722/// Fires `Target` as a real command, not a raw event - the same reasoning
1723/// `CrossContextRoute::Target` already gives: whether a deadline is still
1724/// relevant is a decision `Target::decide()` gets to make against
1725/// *current* state at fire time, not one baked in back when the timer was
1726/// scheduled.
1727pub trait ScheduleDeadline {
1728    /// The event that, on commit, may schedule a new deadline.
1729    type Source: EventType;
1730    /// The command submitted when the deadline fires.
1731    type Target: CommandType;
1732
1733    /// This schedule's own stable identity - names its durable cursor row
1734    /// and, combined with the triggering `Source` event's own sequence,
1735    /// the deterministic id of every row it inserts
1736    /// (`"{NAME}:{source_event_sequence}"`) - so a redelivered/retried
1737    /// catch-up tick re-inserts the identical row rather than a duplicate
1738    /// (`ON CONFLICT (id) DO NOTHING`), the same property a route's own
1739    /// idempotency key gives its command submissions, applied here to the
1740    /// row insert itself.
1741    const NAME: &'static str;
1742
1743    /// See `DeadlinePollStartFrom`. `Beginning` (the default) replays
1744    /// every `Source` occurrence ever committed - the same default
1745    /// `CrossContextRoute::START_FROM` has, and for the same reason: it's
1746    /// what every schedule registered before this const existed already
1747    /// does.
1748    const START_FROM: DeadlinePollStartFrom = DeadlinePollStartFrom::Beginning;
1749
1750    /// `None` skips this occurrence of `Source` entirely - no deadline
1751    /// scheduled, cursor still advances. `Some(spec)` inserts a `pending`
1752    /// row, fired the next time `spec.fire_at` comes due (or immediately,
1753    /// if it's already in the past by the time this runs - a deadline is
1754    /// a one-shot, so there's no missed-occurrence policy to choose
1755    /// between the way the recurring scheduler needs one).
1756    fn schedule(
1757        source_payload: &<Self::Source as EventType>::Payload,
1758    ) -> Option<DeadlineSpec<<Self::Target as CommandType>::Payload>>;
1759}
1760
1761/// issue #20's own cancellation half - "cancellable if the
1762/// awaited thing happens first." Bound to a specific `ScheduleDeadline`
1763/// via `Deadline` (not just any deadline sharing a tag) so two unrelated
1764/// schedules that happen to reuse a tag key never cross-cancel each
1765/// other's rows.
1766///
1767/// Cancels **by tag**, not by id: the cancelling event (say, `OrderPaid`)
1768/// only ever carries its own payload/tags, never the opaque id a
1769/// `ScheduleDeadline` implementor generated deep inside its own row
1770/// insert - tag-based lookup is what actually lets "whichever happens
1771/// first" work without threading an id back out through some side
1772/// channel. Reuses the same tag-containment query
1773/// `list_events_for_bounded_context_matching_tags` already uses for
1774/// events (docs/architecture.md §19 Problem 1), applied to the
1775/// `deadlines` table's own `tags` column.
1776///
1777/// **Adding one to an existing schedule in a rolling deploy.** A due
1778/// deadline waits until every cancel targeting its schedule has processed
1779/// its source events up to the deadline's `fire_at` - but an instance only
1780/// knows the cancels declared in its own code. While instances without
1781/// the new cancel are still running, one of them can fire a deadline whose
1782/// cancelling event had already committed but not yet been processed.
1783/// Where that matters, deploy the new cancel while no such deadline can
1784/// come due, or roll out completely before relying on it
1785/// (docs/architecture.md §164).
1786pub trait CancelDeadline {
1787    /// The event that, on commit, may cancel one or more pending
1788    /// deadlines.
1789    type Source: EventType;
1790    /// Which `ScheduleDeadline`'s own pending rows this targets.
1791    type Deadline: ScheduleDeadline;
1792
1793    /// This cancel reactor's own stable identity - names its durable
1794    /// cursor row, independent of `Self::Deadline::NAME`'s own cursor (a
1795    /// schedule and its cancel counterpart advance independently, each
1796    /// walking its own `Source` event stream).
1797    const NAME: &'static str;
1798
1799    /// See `DeadlinePollStartFrom`; same default and reasoning as
1800    /// `ScheduleDeadline::START_FROM`.
1801    const START_FROM: DeadlinePollStartFrom = DeadlinePollStartFrom::Beginning;
1802
1803    /// `None` skips this occurrence entirely - nothing cancelled, cursor
1804    /// still advances. `Some(tags)` cancels every still-`pending` row
1805    /// `Self::Deadline::NAME` owns whose own `tags` contain these -
1806    /// cancelling zero, one, or several rows is all a legitimate outcome
1807    /// here (a deadline that already fired, was already cancelled, or was
1808    /// never scheduled in the first place is not an error), the same
1809    /// register `CrossContextRoute::route` returning `None` already is.
1810    fn cancel_tags(source_payload: &<Self::Source as EventType>::Payload) -> Option<Vec<Tag>>;
1811}
1812
1813/// One registered `ScheduleDeadline`'s own static identity -
1814/// `ScheduleDeadlineDispatcher::schedules()`'s own element type, the
1815/// `ScheduleDeadline`/`CancelDeadline` pair's counterpart to
1816/// `CrossContextRouteInfo`.
1817#[derive(Debug, Clone, Copy)]
1818pub struct ScheduleDeadlineInfo {
1819    /// `ScheduleDeadline::NAME`: the schedule's identity and the name of its
1820    /// cursor.
1821    pub name: &'static str,
1822    /// The bounded context the source event type lives in.
1823    pub source_bounded_context: &'static str,
1824    /// The event type whose events schedule deadlines.
1825    pub source_event_type: &'static str,
1826    /// The bounded context the target command type lives in.
1827    pub target_bounded_context: &'static str,
1828    /// The command type a firing submits.
1829    pub target_command_type: &'static str,
1830    /// `ScheduleDeadline::START_FROM`.
1831    pub start_from: DeadlinePollStartFrom,
1832}
1833
1834/// One registered `CancelDeadline`'s own static identity.
1835#[derive(Debug, Clone, Copy)]
1836pub struct CancelDeadlineInfo {
1837    /// `CancelDeadline::NAME`: the cancel reactor's identity and the name of
1838    /// its cursor.
1839    pub name: &'static str,
1840    /// The bounded context the cancelling event type lives in.
1841    pub source_bounded_context: &'static str,
1842    /// The event type whose events cancel deadlines.
1843    pub source_event_type: &'static str,
1844    /// The paired `ScheduleDeadline::NAME` whose pending deadlines this
1845    /// cancels.
1846    pub deadline_schedule_name: &'static str,
1847    /// `Self::Deadline::Source::BOUNDED_CONTEXT` - where the paired
1848    /// `ScheduleDeadline`'s own `deadlines`/`deadline_cursors` rows
1849    /// physically live (mirroring `CrossContextRoute`'s own
1850    /// `source_bounded_context` placement choice for its cursor table),
1851    /// which can differ from this cancel reactor's own
1852    /// `source_bounded_context` above - the cancelling event and the
1853    /// deadline it cancels aren't required to live in the same bounded
1854    /// context, the same generality `CrossContextRoute::Source`/`Target`
1855    /// already has.
1856    pub deadline_schedule_bounded_context: &'static str,
1857    /// `Self::Deadline::Source::NAME` - the event type the paired
1858    /// `ScheduleDeadline` schedules from. A cancel waits until that
1859    /// schedule has processed every such event that precedes the
1860    /// cancelling one (docs/architecture.md §130).
1861    pub deadline_schedule_source_event_type: &'static str,
1862    /// `CancelDeadline::START_FROM`.
1863    pub start_from: DeadlinePollStartFrom,
1864}
1865
1866/// Type-erased dispatch to a bounded context's own typed
1867/// `ScheduleDeadline::schedule` - `CrossContextRouteDispatcher`'s own
1868/// counterpart for this trait.
1869pub trait ScheduleDeadlineDispatcher: Send + Sync {
1870    /// Every registered schedule, across every bounded context - not
1871    /// scoped to one bounded context, the same reasoning
1872    /// `CrossContextRouteDispatcher::routes` already gives.
1873    fn schedules(&self) -> Vec<ScheduleDeadlineInfo>;
1874
1875    /// `schedule_name` names one of `schedules()`'s own entries;
1876    /// `source_payload_json` is the triggering `Source` event's own
1877    /// stored payload. Outer `None` - not registered at all. `Some(Err(e))` -
1878    /// the stored payload didn't deserialize into `Source::Payload`.
1879    /// `Some(Ok(None))` - `schedule()` itself decided this occurrence
1880    /// doesn't apply. `Some(Ok(Some(spec)))` - ready to insert as a
1881    /// `pending` row, `Target`'s own JSON payload already resolved.
1882    fn schedule(
1883        &self,
1884        schedule_name: &str,
1885        source_payload_json: &str,
1886    ) -> Option<Result<Option<ErasedDeadlineSpec>, serde_json::Error>>;
1887}
1888
1889/// `ScheduleDeadlineDispatcher::schedule`'s own type-erased output -
1890/// `DeadlineSpec<P>` with `P` resolved to its already-serialised JSON,
1891/// the same "erase the payload's own concrete type at the dispatcher
1892/// boundary" treatment every other dispatcher trait in this module
1893/// already gives its own typed payloads. `recurrence` is carried through
1894/// verbatim (issue #95) - the dispatcher boundary erases the
1895/// payload type only, never the schedule's own recurrence decision.
1896#[derive(Debug, Clone)]
1897pub struct ErasedDeadlineSpec {
1898    /// `DeadlineSpec::fire_at`.
1899    pub fire_at: chrono::DateTime<chrono::Utc>,
1900    /// `DeadlineSpec::expires_at`, carried through verbatim (
1901    /// issue #97).
1902    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
1903    /// `DeadlineSpec::tags`.
1904    pub tags: Vec<Tag>,
1905    /// `DeadlineSpec::payload`, serialized to JSON.
1906    pub payload_json: String,
1907    /// `DeadlineSpec::recurrence`, carried through as is.
1908    pub recurrence: Option<RecurrenceSpec>,
1909}
1910
1911/// Type-erased dispatch to a bounded context's own typed
1912/// `CancelDeadline::cancel_tags`.
1913pub trait CancelDeadlineDispatcher: Send + Sync {
1914    /// Every registered cancel reactor, across every bounded context.
1915    fn cancels(&self) -> Vec<CancelDeadlineInfo>;
1916
1917    /// `cancel_name` names one of `cancels()`'s own entries;
1918    /// `source_payload_json` is the triggering `Source` event's own
1919    /// stored payload. Outer `None` - not registered at all. `Some(Err(e))` -
1920    /// the stored payload didn't deserialize into `Source::Payload`.
1921    /// `Some(Ok(None))` - `cancel_tags()` itself decided this occurrence
1922    /// doesn't apply. `Some(Ok(Some(tags)))` - cancel every still-`pending`
1923    /// row the paired `ScheduleDeadline` owns whose own tags contain
1924    /// these.
1925    fn cancel_tags(
1926        &self,
1927        cancel_name: &str,
1928        source_payload_json: &str,
1929    ) -> Option<Result<Option<Vec<Tag>>, serde_json::Error>>;
1930}