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}