Skip to main content

gwk_kernel/
epoch.rs

1//! The epoch: one genesis append, the seal it opens under, and the activation
2//! that breaks it.
3//!
4//! The kernel's own lifecycle is an aggregate like every other one —
5//! `kernel`/`singleton`, in project `system` — which is why no state table
6//! stands behind it. Version 1 is genesis and the kernel is SEALED; version 2
7//! is activation and it is ACTIVE. So `max(aggregate_version)` on that
8//! aggregate IS the epoch, read under the same writer lock the append will
9//! take. A cached flag or a projection row would be a second place the answer
10//! lives, and the log is the one that survives a restore.
11//!
12//! Genesis is appended through the fenced store, never beside it:
13//! [`crate::admin::init`] runs as the admin role and holds neither the writer
14//! lock nor the durable epoch, so a genesis written there would be the one
15//! event in the log that skipped the path every other event takes.
16
17use gwk_domain::envelope::{Actor, ENVELOPE_SCHEMA_VERSION, EventEnvelope, Origin};
18use gwk_domain::ids::{
19    AggregateId, EventId, FenceToken, IdempotencyKey, ProjectId, Seq, Timestamp,
20};
21use gwk_domain::protocol::{CONTRACT_VERSION, KernelErrorCode};
22use sqlx::PgConnection;
23
24use crate::project::Refusal;
25use crate::store::{PgEventStore, current_aggregate_version};
26
27/// The aggregate the kernel keeps its own lifecycle in.
28pub const KERNEL_AGGREGATE: &str = "kernel";
29/// Its only id: there is one kernel per database.
30pub const KERNEL_SINGLETON: &str = "singleton";
31/// The project genesis is written under, and therefore — by the ownership rule
32/// in [`crate::submit`] — the only project that may activate.
33pub const SYSTEM_PROJECT: &str = "system";
34/// Genesis carries a fixed key: there is no caller to supply one, and a fixed
35/// key makes a second genesis attempt a constraint violation rather than a
36/// second event.
37pub const GENESIS_KEY: &str = "kernel_initialized:v1";
38pub const GENESIS_EVENT_TYPE: &str = "kernel_initialized";
39pub const ACTIVATION_EVENT_TYPE: &str = "kernel_activated";
40
41/// Length of a full git revision — the form the genesis payload records.
42const REVISION_HEX_LEN: usize = 40;
43
44const COMMITTED_CUTOVER: &str = "SELECT payload ->> 'cutover_id' FROM gwk.event \
45     WHERE aggregate_type = 'kernel' AND aggregate_id = 'singleton' \
46       AND event_type = 'kernel_activated' \
47     ORDER BY aggregate_version LIMIT 1";
48// The database's clock, not the process's: `appended_at` is assigned by `now()`
49// inside the append, so taking `occurred_at` from anywhere else would put two
50// clocks in one row.
51const DB_NOW: &str = "SELECT to_json(now()) #>> '{}'";
52
53/// Where this kernel is in its own lifecycle.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub(crate) enum Epoch {
56    /// No genesis event. Nothing may be submitted — including activation,
57    /// which asserts version 1 and would find 0.
58    None,
59    /// Genesis only. The sealed allowlist is in force.
60    Sealed,
61    /// Activated. Business commands are admitted.
62    Active,
63}
64
65impl Epoch {
66    fn of_version(version: u32) -> Self {
67        match version {
68            0 => Self::None,
69            1 => Self::Sealed,
70            _ => Self::Active,
71        }
72    }
73}
74
75/// Read the epoch. Call it under the writer lock, so the answer and the append
76/// that depends on it are the same instant.
77pub(crate) async fn epoch_of(conn: &mut PgConnection) -> Result<Epoch, Refusal> {
78    let version = current_aggregate_version(conn, KERNEL_AGGREGATE, KERNEL_SINGLETON).await?;
79    Ok(Epoch::of_version(version))
80}
81
82/// The cutover this kernel actually activated at, if it has.
83pub(crate) async fn committed_cutover(conn: &mut PgConnection) -> Result<Option<String>, Refusal> {
84    sqlx::query_scalar(COMMITTED_CUTOVER)
85        .fetch_optional(conn)
86        .await
87        .map(Option::flatten)
88        .map_err(|e| Refusal::storage(format!("read the committed cutover: {e}")))
89}
90
91/// The one idempotency key an activation may carry.
92///
93/// Required of the caller rather than derived from the body, because the two
94/// disagree in a way that matters: deriving it would let two envelopes with
95/// different keys collapse onto one event key, and the second would come back
96/// as a storage-level collision instead of the typed answer the caller needs.
97pub(crate) fn activation_key(cutover_id: &str) -> String {
98    format!("{ACTIVATION_EVENT_TYPE}:{cutover_id}")
99}
100
101/// True iff `value` is a full lowercase 40-hex git revision.
102///
103/// Abbreviated revisions are refused: the genesis payload is immutable and is
104/// what a later build compares itself against, so a prefix that is unique today
105/// and ambiguous in a year would make that comparison unanswerable.
106pub fn is_public_revision(value: &str) -> bool {
107    value.len() == REVISION_HEX_LEN
108        && value
109            .bytes()
110            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
111}
112
113impl PgEventStore {
114    /// Append genesis if this log has none. Idempotent: a kernel that already
115    /// has an epoch is left exactly as it was.
116    ///
117    /// Separate from [`PgEventStore::open`] on purpose — opening a store reads,
118    /// and a constructor that writes would put an append in the path of every
119    /// replay and every read-only inspection of a log.
120    pub async fn ensure_genesis(&self, public_revision: &str) -> Result<(), Refusal> {
121        if !is_public_revision(public_revision) {
122            return Err(Refusal::validation(format!(
123                "genesis records the full 40-character public revision, not {public_revision:?}"
124            )));
125        }
126        let mut tx = self
127            .pool()
128            .begin()
129            .await
130            .map_err(|e| Refusal::storage(format!("begin: {e}")))?;
131        let writer = self.lock_writer(&mut tx).await?;
132        // Under the lock, so the check and the append cannot be separated by
133        // another writer's genesis.
134        if epoch_of(&mut tx).await? != Epoch::None {
135            return Ok(());
136        }
137        let now: String = sqlx::query_scalar(DB_NOW)
138            .fetch_one(&mut *tx)
139            .await
140            .map_err(|e| Refusal::storage(format!("read the database clock: {e}")))?;
141        let fence = writer.current_fence.map(FenceToken::new);
142        self.append_locked(
143            &mut tx,
144            &writer,
145            0,
146            fence,
147            &[genesis_event(public_revision, &now)],
148        )
149        .await?;
150        // No projection: the epoch boundary is the log itself. That is also why
151        // the genesis payload is not a `KernelCommand` — nothing issued it.
152        tx.commit()
153            .await
154            .map_err(|e| Refusal::storage(format!("commit: {e}")))?;
155        Ok(())
156    }
157}
158
159/// The genesis event, at the version and under the key the contract pins.
160///
161/// `event_id` is derived exactly as [`crate::submit`] derives every other one,
162/// so genesis is not a special case to anything that reads ids.
163fn genesis_event(public_revision: &str, at: &str) -> EventEnvelope {
164    EventEnvelope {
165        event_id: EventId::new(format!("{KERNEL_AGGREGATE}:{KERNEL_SINGLETON}:1")),
166        project_id: ProjectId::new(SYSTEM_PROJECT),
167        aggregate_type: KERNEL_AGGREGATE.to_owned(),
168        aggregate_id: AggregateId::new(KERNEL_SINGLETON),
169        aggregate_version: 1,
170        event_type: GENESIS_EVENT_TYPE.to_owned(),
171        schema_version: ENVELOPE_SCHEMA_VERSION,
172        // Assigned inside the append, like every other event's.
173        global_sequence: Seq::new(0),
174        appended_at: Timestamp::new(at),
175        occurred_at: Timestamp::new(at),
176        actor: Actor {
177            kind: "kernel".to_owned(),
178            id: None,
179        },
180        origin: Origin {
181            system: "gw".to_owned(),
182            r#ref: None,
183        },
184        causation_id: None,
185        correlation_id: None,
186        idempotency_key: Some(IdempotencyKey::new(GENESIS_KEY)),
187        // Exactly what the contract says genesis records, and nothing more: the
188        // number a client compares its own contract against, and the revision
189        // this binary was built from.
190        payload: serde_json::json!({
191            "contract_version": CONTRACT_VERSION,
192            "public_revision": public_revision,
193        }),
194        payload_ref: None,
195    }
196}
197
198/// The refusal a sealed — or epoch-less — kernel answers with.
199///
200/// [`KernelErrorCode::Sealed`] covers both: a kernel with no genesis is a
201/// kernel whose sealed allowlist is empty, and giving it a different code would
202/// mean adding one to a frozen set to describe a state the client reacts to
203/// identically — it cannot proceed, and only an operator can change that.
204pub(crate) fn sealed_refusal(epoch: Epoch, command_type: &str) -> Refusal {
205    let message = match epoch {
206        Epoch::None => {
207            "this kernel has no epoch: genesis has not been appended, so nothing may be \
208             submitted — not even activation, which asserts the sealed version"
209                .to_owned()
210        }
211        _ => format!("this kernel is sealed; {command_type} is not on the sealed allowlist"),
212    };
213    Refusal::new(KernelErrorCode::Sealed, message)
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn the_version_is_the_epoch() {
222        assert_eq!(Epoch::of_version(0), Epoch::None);
223        assert_eq!(Epoch::of_version(1), Epoch::Sealed);
224        assert_eq!(Epoch::of_version(2), Epoch::Active);
225        // There is no version above 2 today, but the log is append-only and a
226        // later kernel event must not read as "sealed again".
227        assert_eq!(Epoch::of_version(9), Epoch::Active);
228    }
229
230    #[test]
231    fn only_a_full_lowercase_revision_is_one() {
232        assert!(is_public_revision(&"a1b2c3d4e5".repeat(4)));
233        assert!(!is_public_revision(&"A1B2C3D4E5".repeat(4)));
234        assert!(!is_public_revision("a1b2c3d4"));
235        assert!(!is_public_revision(""));
236        assert!(!is_public_revision(&"z".repeat(40)));
237    }
238
239    #[test]
240    fn the_activation_key_names_its_cutover() {
241        assert_eq!(activation_key("c-1"), "kernel_activated:c-1");
242    }
243}