Skip to main content

gwk_kernel/
submit.rs

1//! The command path: one [`CommandEnvelope`] in, one committed event plus its
2//! projection out.
3//!
4//! The order of the steps is the design, not an implementation detail:
5//!
6//! 1. **Lock the writer row** — before ANY read this decision depends on. The
7//!    lock is held to commit, so reading the current version after taking it is
8//!    what makes the decision and the write the same instant.
9//! 2. **Answer a replay from the log** — project-wide, by key. Doing this ahead
10//!    of the CAS is what makes a retry stable: a retry presents the same
11//!    `expected_version` it did the first time, which the CAS would now call a
12//!    conflict.
13//! 3. **Decide** — [`gwk_domain::transition::apply`] for the two state machines
14//!    in this phase, a plain version compare for the rest.
15//! 4. **Append and project in ONE transaction.** The plan requires events and
16//!    projections to land together; splitting them would let a crash leave a
17//!    log the projections do not reflect.
18//!
19//! Before any of it sits the **epoch** ([`crate::epoch`]): a log with only its
20//! genesis event is SEALED and admits `activate_kernel` alone, and a log
21//! without even that admits nothing. The check is read under the writer lock
22//! taken in step 1, and activation's own CAS re-proves it at append time.
23//!
24//! Between ownership and the decision sits **authority**: a gated command is
25//! evaluated against the grants on record, and a page commits its receipt and
26//! attention item before refusing. That is the one refusal here that leaves
27//! rows behind, and deliberately so — a page whose evidence rolls back cannot
28//! be told apart from a command nobody sent.
29//!
30//! Scope is every command in the contract: task, attempt, engine session,
31//! lease, worktree, dispatch node, budget, checkpoint, round, finding, message,
32//! execution command, gate, evidence, authority grant, attention item, and
33//! ingestion. [`route_of`] matches them all WITHOUT a wildcard arm, so a
34//! command added to the contract fails to compile here rather than falling into
35//! a silent no-op.
36
37use gwk_domain::command::KernelCommand;
38use gwk_domain::envelope::{
39    CommandEnvelope, ENVELOPE_SCHEMA_VERSION, EventEnvelope, accept_schema_version,
40};
41use gwk_domain::fsm::{AttemptState, CommandState, MessageState, TaskState};
42use gwk_domain::ids::{AggregateId, EventId, ReceiptId, Seq};
43use gwk_domain::port::EventStore;
44use gwk_domain::protocol::{KernelErrorCode, KernelResult};
45use gwk_domain::transition::{self, Cursor, TransitionRequest, TransitionResult};
46use serde::de::DeserializeOwned;
47use sqlx::{PgConnection, Row};
48
49use crate::authority;
50use crate::epoch::{self, Epoch};
51use crate::numeric::from_numeric_text;
52use crate::project::{
53    Refusal, apply_event, from_wire_str, page_attention, unresolved_attention, wire_str,
54    write_receipt,
55};
56use crate::store::{MAX_INFLIGHT_APPENDS, PgEventStore, current_aggregate_version, events_for_key};
57
58// One literal per read: sqlx 0.9 refuses a runtime-built query string, and a
59// table name cannot be a bind parameter — so the set of tables this path may
60// read is fixed here, in the open, rather than assembled from a caller's input.
61const TASK_CURSOR: &str = "SELECT state, version FROM gwk.task WHERE id = $1";
62const ATTEMPT_CURSOR: &str = "SELECT state, version FROM gwk.attempt WHERE id = $1";
63const ATTEMPT_VERSION: &str = "SELECT version FROM gwk.attempt WHERE id = $1";
64const LEASE_VERSION: &str = "SELECT version FROM gwk.lease WHERE id = $1";
65const DISPATCH_NODE_VERSION: &str = "SELECT version FROM gwk.dispatch_node WHERE id = $1";
66const AGGREGATE_OWNER: &str = "SELECT project_id FROM gwk.event \
67     WHERE aggregate_type = $1 AND aggregate_id = $2 \
68     ORDER BY aggregate_version LIMIT 1";
69const MESSAGE_CURSOR: &str = "SELECT state, version FROM gwk.message WHERE id = $1";
70const COMMAND_CURSOR: &str = "SELECT state, version FROM gwk.command WHERE id = $1";
71const GATE_VERSION: &str = "SELECT version FROM gwk.gate WHERE id = $1";
72const CHECKPOINT_SEQ: &str =
73    "SELECT seq::text AS seq_text FROM gwk.orchestrator_checkpoint WHERE orchestrator_id = $1";
74
75/// Where a command's event belongs and what it is called there.
76#[derive(Debug)]
77struct Route {
78    aggregate_type: &'static str,
79    aggregate_id: String,
80    event_type: &'static str,
81}
82
83/// What the log already holds under this command's idempotency key.
84enum Prior {
85    /// Nothing: this key has never been used in this project.
86    Unused,
87    /// The identical request, already applied. Its original events ARE the
88    /// answer — re-deciding would double-apply.
89    Replay(Vec<EventEnvelope>),
90    /// The key is taken by a different request. Refused rather than landed
91    /// beside it, which is the whole point of requiring a key.
92    Conflict(String),
93}
94
95impl PgEventStore {
96    /// Apply one command, or answer why not.
97    ///
98    /// A refusal is a value here, exactly as the contract says: this returns
99    /// [`KernelResult::Error`] rather than an `Err`, so the wire layer has
100    /// nothing left to translate.
101    pub async fn submit(&self, envelope: &CommandEnvelope) -> KernelResult {
102        match self.try_submit(envelope).await {
103            Ok(result) => result,
104            Err(refusal) => refusal.into_result(),
105        }
106    }
107
108    async fn try_submit(&self, envelope: &CommandEnvelope) -> Result<KernelResult, Refusal> {
109        let _permit = self.admit().map_err(|_| {
110            Refusal::new(
111                KernelErrorCode::Overloaded,
112                format!("append queue is full ({MAX_INFLIGHT_APPENDS} in flight)"),
113            )
114        })?;
115        accept_schema_version(envelope.schema_version, ENVELOPE_SCHEMA_VERSION, &[])
116            .map_err(|e| Refusal::new(KernelErrorCode::Schema, e.to_string()))?;
117        let command = KernelCommand::from_envelope(envelope)
118            .map_err(|e| Refusal::validation(e.to_string()))?;
119        let route = route_of(envelope, &command)?;
120        check_routing(envelope, &route)?;
121        check_body_project(envelope, &command)?;
122        check_activation(envelope, &command)?;
123        let payload = serde_json::to_value(&command)
124            .map_err(|e| Refusal::storage(format!("serialize command body: {e}")))?;
125
126        let mut tx = self
127            .pool()
128            .begin()
129            .await
130            .map_err(|e| Refusal::storage(format!("begin: {e}")))?;
131        // FIRST, and before every read below: everything this transaction
132        // decides is read under this lock, so no concurrent writer can move the
133        // ground between the decision and the append.
134        let writer = self.lock_writer(&mut tx).await?;
135        // Under the lock and before the key check: a sealed kernel refuses
136        // whether or not this key has been seen, and answering "replay" out of
137        // a log the caller may not read from yet would be a leak dressed as a
138        // convenience.
139        let epoch = epoch::epoch_of(&mut tx).await?;
140        if !admitted(epoch, &command) {
141            return Err(epoch::sealed_refusal(epoch, command.command_type()));
142        }
143
144        let expected_version = match prior_for_key(&mut tx, envelope, &route, &payload).await? {
145            Prior::Replay(events) => {
146                // Nothing to write; the commit only releases the lock. The
147                // projections this key produced were written by the original
148                // append and re-applying them would advance a CAS version no
149                // event accounts for.
150                tx.commit()
151                    .await
152                    .map_err(|e| Refusal::storage(format!("commit: {e}")))?;
153                return self.applied(envelope, events).await;
154            }
155            Prior::Conflict(reason) => {
156                return Err(Refusal::new(KernelErrorCode::IdempotencyConflict, reason));
157            }
158            Prior::Unused => {
159                // After the key check, not before it: a reused key is a
160                // conflict whoever sends it, and reporting the key is the
161                // answer a retrier can act on. Ownership is the question that
162                // only arises once the request is genuinely new.
163                check_aggregate_owner(&mut tx, envelope, &route).await?;
164                check_second_cutover(&mut tx, &command, epoch).await?;
165                let decision = authority::evaluate(
166                    &mut tx,
167                    envelope,
168                    &envelope.command_type,
169                    &route.aggregate_id,
170                )
171                .await?;
172                if let authority::Decision::Page { action_class } = decision {
173                    // A page does NOT mutate the target, so this returns before
174                    // `decide` — but it still commits, because the receipt and
175                    // the attention item are the whole point of paging. This is
176                    // the one refusal in the kernel that leaves rows behind.
177                    return self.paged(tx, envelope, &route, action_class).await;
178                }
179                if let Some(action_class) = decision.action_class() {
180                    write_receipt(
181                        &mut tx,
182                        &receipt_for(
183                            envelope,
184                            &route,
185                            action_class,
186                            "matching unexpired scoped grant",
187                        ),
188                    )
189                    .await?;
190                }
191                check_attention_dedup(&mut tx, &command).await?;
192                decide(&mut tx, envelope, &command, &route).await?
193            }
194        };
195        check_expected_version(envelope, expected_version)?;
196
197        let event = build_event(envelope, payload, &route, expected_version)?;
198        // The kernel's own command path is authorized by the writer LOCK plus
199        // the durable EPOCH `lock_writer` just compared — both strictly stronger
200        // than a fence token, which exists for an EXTERNAL append actor holding
201        // neither. Presenting the current token satisfies the port's check
202        // without pretending this path arrived fenced.
203        let fence = writer.current_fence.map(gwk_domain::ids::FenceToken::new);
204        let appended = self
205            .append_locked(&mut tx, &writer, expected_version, fence, &[event])
206            .await?;
207        if !appended.replayed {
208            for event in &appended.events {
209                apply_event(&mut tx, event).await?;
210            }
211        }
212        // After the projections, never before: a checkpoint names the sequence
213        // it describes, and taking it above this loop would snapshot a state
214        // that does not yet include the events it claims. A replay is the only
215        // thing that would ever notice, and by then the checkpoint is wrong in
216        // a way nothing can repair.
217        if let Some(last) = appended.events.last() {
218            self.checkpoint_if_due(&mut tx, &writer, last.global_sequence, &last.appended_at)
219                .await?;
220        }
221        tx.commit()
222            .await
223            .map_err(|e| Refusal::storage(format!("commit: {e}")))?;
224
225        self.applied(envelope, appended.events).await
226    }
227
228    /// The paged answer: a refusal that commits its own trail.
229    ///
230    /// The receipt and the attention item are written and COMMITTED even though
231    /// the command is refused, because a page whose evidence rolls back is
232    /// indistinguishable from a command that was never sent — and the operator
233    /// finding out is the entire mechanism.
234    async fn paged(
235        &self,
236        mut tx: sqlx::Transaction<'_, sqlx::Postgres>,
237        envelope: &CommandEnvelope,
238        route: &Route,
239        action_class: &'static str,
240    ) -> Result<KernelResult, Refusal> {
241        let actor = actor_json(envelope)?;
242        let at = envelope.issued_at.as_str();
243        let subject = format!("{}/{}", route.aggregate_type, route.aggregate_id);
244        write_receipt(
245            &mut tx,
246            &receipt_for(
247                envelope,
248                route,
249                action_class,
250                "no matching unexpired scoped grant",
251            ),
252        )
253        .await?;
254        page_attention(
255            &mut tx,
256            // Derived from what the dedup key already is, so a retry lands on
257            // the same id the first page created rather than minting a rival
258            // the index then refuses.
259            &format!("page:{action_class}:{subject}"),
260            &format!(
261                "{} requires an unexpired {action_class} grant",
262                envelope.command_type
263            ),
264            &subject,
265            &actor,
266            at,
267        )
268        .await?;
269        tx.commit()
270            .await
271            .map_err(|e| Refusal::storage(format!("commit: {e}")))?;
272        Err(Refusal::new(
273            KernelErrorCode::Authority,
274            format!(
275                "{} on {subject} requires an unexpired {action_class} grant; attention raised",
276                envelope.command_type
277            ),
278        )
279        .with_detail(serde_json::json!({ "action_class": action_class, "subject": subject })))
280    }
281
282    /// The success answer, with the watermark read after the commit that
283    /// produced it.
284    async fn applied(
285        &self,
286        envelope: &CommandEnvelope,
287        events: Vec<EventEnvelope>,
288    ) -> Result<KernelResult, Refusal> {
289        let watermark = self.watermark().await?.ok_or_else(|| {
290            Refusal::storage("the log is empty after a committed append".to_owned())
291        })?;
292        Ok(KernelResult::CommandApplied {
293            command_id: envelope.command_id.clone(),
294            events,
295            watermark,
296        })
297    }
298}
299
300/// Which aggregate a command addresses, and the event it produces there.
301///
302/// One match rather than two: an aggregate without an event name (or the
303/// reverse) would be a routing bug that only shows up in the log.
304fn route_of(envelope: &CommandEnvelope, command: &KernelCommand) -> Result<Route, Refusal> {
305    use KernelCommand as C;
306    let (aggregate_type, aggregate_id, event_type) = match command {
307        // The kernel's own lifecycle is an aggregate like any other, which is
308        // what lets the epoch be read off the log instead of a state table.
309        C::ActivateKernel { .. } => (
310            epoch::KERNEL_AGGREGATE,
311            epoch::KERNEL_SINGLETON.to_owned(),
312            epoch::ACTIVATION_EVENT_TYPE,
313        ),
314
315        C::CreateTask { task_id, .. } => ("task", task_id.as_str().to_owned(), "task_created"),
316        C::TransitionTask { task_id, .. } => {
317            ("task", task_id.as_str().to_owned(), "task_transitioned")
318        }
319
320        C::CreateAttempt { attempt_id, .. } => {
321            ("attempt", attempt_id.as_str().to_owned(), "attempt_created")
322        }
323        C::TransitionAttempt { attempt_id, .. } => (
324            "attempt",
325            attempt_id.as_str().to_owned(),
326            "attempt_transitioned",
327        ),
328        C::RecordAttemptOutcome { attempt_id, .. } => (
329            "attempt",
330            attempt_id.as_str().to_owned(),
331            "attempt_outcome_recorded",
332        ),
333        C::UpdateBudget { attempt_id, .. } => {
334            ("attempt", attempt_id.as_str().to_owned(), "budget_updated")
335        }
336        C::RecordRound { attempt_id, .. } => {
337            ("attempt", attempt_id.as_str().to_owned(), "round_recorded")
338        }
339        C::RecordFinding { attempt_id, .. } => (
340            "attempt",
341            attempt_id.as_str().to_owned(),
342            "finding_recorded",
343        ),
344
345        C::OpenEngineSession {
346            engine_session_id, ..
347        } => (
348            "engine_session",
349            engine_session_id.as_str().to_owned(),
350            "engine_session_opened",
351        ),
352        C::CloseEngineSession { engine_session_id } => (
353            "engine_session",
354            engine_session_id.as_str().to_owned(),
355            "engine_session_closed",
356        ),
357
358        C::AcquireLease { lease_id, .. } => {
359            ("lease", lease_id.as_str().to_owned(), "lease_acquired")
360        }
361        C::RenewLease { lease_id, .. } => ("lease", lease_id.as_str().to_owned(), "lease_renewed"),
362        C::ReleaseLease { lease_id, .. } => {
363            ("lease", lease_id.as_str().to_owned(), "lease_released")
364        }
365        C::ExpireLease { lease_id, .. } => ("lease", lease_id.as_str().to_owned(), "lease_expired"),
366
367        C::RegisterWorktree { worktree_id, .. } => (
368            "worktree",
369            worktree_id.as_str().to_owned(),
370            "worktree_registered",
371        ),
372        C::UpdateWorktree { worktree_id, .. } => (
373            "worktree",
374            worktree_id.as_str().to_owned(),
375            "worktree_updated",
376        ),
377        C::ReleaseWorktree { worktree_id, .. } => (
378            "worktree",
379            worktree_id.as_str().to_owned(),
380            "worktree_released",
381        ),
382
383        C::RegisterDispatchNode {
384            dispatch_node_id, ..
385        } => (
386            "dispatch_node",
387            dispatch_node_id.as_str().to_owned(),
388            "dispatch_node_registered",
389        ),
390        C::TransitionDispatchNode {
391            dispatch_node_id, ..
392        } => (
393            "dispatch_node",
394            dispatch_node_id.as_str().to_owned(),
395            "dispatch_node_transitioned",
396        ),
397
398        C::SendMessage { message_id, .. } => {
399            ("message", message_id.as_str().to_owned(), "message_sent")
400        }
401        C::TransitionMessage { message_id, .. } => (
402            "message",
403            message_id.as_str().to_owned(),
404            "message_transitioned",
405        ),
406
407        C::IssueCommand { command_id, .. } => {
408            ("command", command_id.as_str().to_owned(), "command_issued")
409        }
410        C::TransitionCommand { command_id, .. } => (
411            "command",
412            command_id.as_str().to_owned(),
413            "command_transitioned",
414        ),
415        C::RecordCommandOutcome { command_id, .. } => (
416            "command",
417            command_id.as_str().to_owned(),
418            "command_outcome_recorded",
419        ),
420
421        C::OpenGate { gate_id, .. } => ("gate", gate_id.as_str().to_owned(), "gate_opened"),
422        C::DecideGate { gate_id, .. } => ("gate", gate_id.as_str().to_owned(), "gate_decided"),
423
424        C::RecordEvidence { evidence_id, .. } => (
425            "evidence",
426            evidence_id.as_str().to_owned(),
427            "evidence_recorded",
428        ),
429
430        C::GrantAuthority {
431            authority_grant_id, ..
432        } => (
433            "authority_grant",
434            authority_grant_id.as_str().to_owned(),
435            "authority_granted",
436        ),
437        C::RevokeAuthority {
438            authority_grant_id, ..
439        } => (
440            "authority_grant",
441            authority_grant_id.as_str().to_owned(),
442            "authority_revoked",
443        ),
444
445        C::RaiseAttention {
446            attention_item_id, ..
447        } => (
448            "attention_item",
449            attention_item_id.as_str().to_owned(),
450            "attention_raised",
451        ),
452        C::ResolveAttention {
453            attention_item_id, ..
454        } => (
455            "attention_item",
456            attention_item_id.as_str().to_owned(),
457            "attention_resolved",
458        ),
459
460        C::WriteOrchestratorCheckpoint { checkpoint } => (
461            "orchestrator_checkpoint",
462            // The store is keyed on it, so an unnamed checkpoint has no row to
463            // be the latest of. Refused at the boundary rather than defaulted
464            // to some placeholder id every anonymous orchestrator would share.
465            checkpoint.orchestrator_id.clone().ok_or_else(|| {
466                Refusal::validation("a checkpoint without an orchestrator_id has no identity")
467            })?,
468            "orchestrator_checkpoint_written",
469        ),
470
471        // The one command whose aggregate id the caller does not supply — an
472        // ingested record has no name of its own, and `gw ingest submit --kind
473        // <kind> --file <path|->` offers nowhere to put one. Deriving it from
474        // the `(project_id, idempotency_key)` pair the envelope already had to
475        // carry is what `receipt_for` does for the same reason, and it is what
476        // makes a retried ingest resolve to the SAME record rather than a
477        // second copy of the same reading: the retry presents the same key, so
478        // it lands on the same aggregate and the log answers it as a replay.
479        C::IngestRecord { .. } => (
480            "ingested_record",
481            format!(
482                "ingest:{}:{}",
483                envelope.project_id.as_str(),
484                envelope.idempotency_key.as_str()
485            ),
486            "record_ingested",
487        ),
488    };
489    if aggregate_id.is_empty() {
490        return Err(Refusal::validation(format!(
491            "{} names an empty {aggregate_type} id",
492            command.command_type()
493        )));
494    }
495    Ok(Route {
496        aggregate_type,
497        aggregate_id,
498        event_type,
499    })
500}
501
502/// The envelope's routing metadata must name the aggregate its body does.
503///
504/// `KernelCommand::from_envelope` already refuses an envelope whose
505/// `command_type` disagrees with its body, for the reason that a handler chosen
506/// by the metadata would otherwise run a body of some other shape. These two
507/// fields are the same metadata pointing at the same body; leaving them
508/// unchecked would mean a caller can label an envelope for one aggregate and
509/// mutate another, and be told it succeeded.
510fn check_routing(envelope: &CommandEnvelope, route: &Route) -> Result<(), Refusal> {
511    let mismatch = |field: &str, declared: &str| {
512        Refusal::validation(format!(
513            "envelope {field} {declared:?} does not name the aggregate the body addresses \
514             ({}/{})",
515            route.aggregate_type, route.aggregate_id
516        ))
517    };
518    if let Some(declared) = &envelope.target_aggregate_type
519        && declared != route.aggregate_type
520    {
521        return Err(mismatch("target_aggregate_type", declared));
522    }
523    if let Some(declared) = &envelope.target_aggregate_id
524        && declared.as_str() != route.aggregate_id
525    {
526        return Err(mismatch("target_aggregate_id", declared.as_str()));
527    }
528    Ok(())
529}
530
531/// The receipt id for one command's authority result.
532///
533/// `(project_id, idempotency_key)` is already globally unique by
534/// `event_idempotency_project`, which makes this collision-free and stable
535/// across a retry — a denied or paged command writes no event, so the retry
536/// has to derive the same id or the ledger grows a duplicate per attempt.
537//
538// ponytail: `:`-joined like `event_id` elsewhere in this path. Two keys that
539// differ only in where a `:` falls would collide; the house already accepts
540// that for event ids, and the alternative is a length-prefixed encoding no
541// human can read in a psql session.
542fn receipt_for(
543    envelope: &CommandEnvelope,
544    route: &Route,
545    action_class: &str,
546    observed_basis: &str,
547) -> gwk_domain::entity::Receipt {
548    gwk_domain::entity::Receipt {
549        id: ReceiptId::new(format!(
550            "receipt:{}:{}",
551            envelope.project_id.as_str(),
552            envelope.idempotency_key.as_str()
553        )),
554        actor: envelope.actor.clone(),
555        action: action_class.to_owned(),
556        subject_type: route.aggregate_type.to_owned(),
557        subject_id: route.aggregate_id.clone(),
558        // No edge: an authority result attests a DECISION about a command, not
559        // a state flip. The flip receipt the liveness rule needs is a different
560        // row with `from`/`to` set, and conflating them would make the ledger
561        // unable to answer which kind of fact it is holding.
562        from: None,
563        to: None,
564        observed_basis: Some(observed_basis.to_owned()),
565        // The command's own time, not the clock: replay must rebuild the same
566        // ledger it built live.
567        ts: envelope.issued_at.clone(),
568    }
569}
570
571fn actor_json(envelope: &CommandEnvelope) -> Result<serde_json::Value, Refusal> {
572    serde_json::to_value(&envelope.actor)
573        .map_err(|e| Refusal::storage(format!("serialize actor: {e}")))
574}
575
576/// An explicit `RaiseAttention` may not open a second item over an open one.
577///
578/// The kernel's own page path silently joins the existing item, because it has
579/// no id of its own to honor. A caller-issued command does: it named an id and
580/// expects a row under it, so a duplicate is refused by name rather than
581/// accepted as a write that quietly did nothing.
582async fn check_attention_dedup(
583    conn: &mut PgConnection,
584    command: &KernelCommand,
585) -> Result<(), Refusal> {
586    let KernelCommand::RaiseAttention {
587        kind, subject_ref, ..
588    } = command
589    else {
590        return Ok(());
591    };
592    if let Some(open) = unresolved_attention(conn, kind, subject_ref.as_deref()).await? {
593        return Err(Refusal::validation(format!(
594            "attention item {open:?} is already open on ({kind:?}, {:?})",
595            subject_ref.as_deref().unwrap_or_default()
596        )));
597    }
598    Ok(())
599}
600
601/// A create that names its own project must name the one the envelope routes to.
602///
603/// `CreateTask` carries a `project` field and the envelope carries
604/// `project_id`; both end up describing the same task, so two different values
605/// is a caller mistake with no correct reading — the row would say one thing
606/// and its log another. Same treatment as the target fields above: refuse
607/// rather than silently pick a winner. An absent body field stays absent.
608fn check_body_project(envelope: &CommandEnvelope, command: &KernelCommand) -> Result<(), Refusal> {
609    let KernelCommand::CreateTask {
610        project: Some(project),
611        ..
612    } = command
613    else {
614        return Ok(());
615    };
616    if project != envelope.project_id.as_str() {
617        return Err(Refusal::validation(format!(
618            "body project {project:?} does not name the envelope's project {:?}",
619            envelope.project_id.as_str()
620        )));
621    }
622    Ok(())
623}
624
625/// The sealed allowlist, as a pattern rather than a name.
626///
627/// Matching the variant instead of comparing `command_type` means adding a
628/// command cannot quietly widen what a sealed kernel accepts: the allowlist is
629/// one arm here, and everything else is the fallthrough.
630fn admitted(epoch: Epoch, command: &KernelCommand) -> bool {
631    match epoch {
632        Epoch::Active => true,
633        Epoch::Sealed => matches!(command, KernelCommand::ActivateKernel { .. }),
634        Epoch::None => false,
635    }
636}
637
638/// What an activation must carry before it is allowed to touch the log.
639///
640/// The cutover boundary is the one irreversible write in this kernel, so both
641/// of its payload fields are checked here rather than left to be discovered
642/// later: `from_envelope` validates shape, not content, and a malformed digest
643/// in an immutable event is unfixable. The key is REQUIRED to equal
644/// [`epoch::activation_key`] rather than being derived from the body — see that
645/// function for why.
646fn check_activation(envelope: &CommandEnvelope, command: &KernelCommand) -> Result<(), Refusal> {
647    let KernelCommand::ActivateKernel {
648        cutover_id,
649        archive_manifest_sha256,
650    } = command
651    else {
652        return Ok(());
653    };
654    if cutover_id.is_empty() {
655        return Err(Refusal::validation(
656            "an activation with an empty cutover id names no cutover",
657        ));
658    }
659    if !gwk_domain::blob::is_sha256_hex(archive_manifest_sha256) {
660        return Err(Refusal::validation(format!(
661            "archive_manifest_sha256 must be a lowercase 64-hex digest, not \
662             {archive_manifest_sha256:?}"
663        )));
664    }
665    let required = epoch::activation_key(cutover_id);
666    if envelope.idempotency_key.as_str() != required {
667        return Err(Refusal::validation(format!(
668            "activating cutover {cutover_id:?} requires idempotency key {required:?}, not {:?}",
669            envelope.idempotency_key.as_str()
670        )));
671    }
672    Ok(())
673}
674
675/// An activated kernel has one cutover, and it is not this one.
676///
677/// Reached only with an unused key, which for an activation already means a
678/// different cutover id — the same one would have come back as a replay, and
679/// the same id with a different manifest as an idempotency conflict. So the
680/// answer names the cutover that actually took: the alternative is a bare CAS
681/// refusal ("expected 1, found 2") at the moment an operator most needs to know
682/// which epoch they are standing in.
683async fn check_second_cutover(
684    conn: &mut PgConnection,
685    command: &KernelCommand,
686    epoch: Epoch,
687) -> Result<(), Refusal> {
688    let KernelCommand::ActivateKernel { cutover_id, .. } = command else {
689        return Ok(());
690    };
691    if epoch != Epoch::Active {
692        return Ok(());
693    }
694    let committed = epoch::committed_cutover(conn).await?.ok_or_else(|| {
695        Refusal::storage("the kernel is past genesis with no activation event".to_owned())
696    })?;
697    Err(Refusal::new(
698        KernelErrorCode::AlreadyActive,
699        format!(
700            "this kernel activated at cutover {committed:?}; {cutover_id:?} is a different \
701                 cutover"
702        ),
703    )
704    .with_detail(serde_json::json!({ "activated_cutover_id": committed })))
705}
706
707/// The project that created an aggregate is the only one that may write to it.
708///
709/// `ProjectId` is documented as the project an aggregate belongs to, but the
710/// aggregate id space is global and no projection row carries a project — so
711/// without this, a second project can transition another's task simply by
712/// using a key of its own, and the aggregate ends up with a log whose events
713/// disagree about who owns it. Any per-project read of that log then returns a
714/// partial history of the aggregate and nothing says so.
715///
716/// The owner is not stored anywhere new: the log already records who created
717/// what, so it is the project on the aggregate's first event. An aggregate
718/// with no events yet is unowned, which is exactly the create case.
719async fn check_aggregate_owner(
720    conn: &mut PgConnection,
721    envelope: &CommandEnvelope,
722    route: &Route,
723) -> Result<(), Refusal> {
724    // Served by the UNIQUE (aggregate_type, aggregate_id, aggregate_version)
725    // index the CAS already needs — no new index, no new column.
726    let owner: Option<String> = sqlx::query_scalar(AGGREGATE_OWNER)
727        .bind(route.aggregate_type)
728        .bind(&route.aggregate_id)
729        .fetch_optional(conn)
730        .await
731        .map_err(|e| Refusal::storage(format!("read aggregate owner: {e}")))?;
732    match owner {
733        Some(owner) if owner != envelope.project_id.as_str() => Err(Refusal::validation(format!(
734            "{}/{} belongs to project {owner:?}, not {:?}",
735            route.aggregate_type,
736            route.aggregate_id,
737            envelope.project_id.as_str()
738        ))),
739        _ => Ok(()),
740    }
741}
742
743/// The envelope's `expected_version`, when present, must agree with the version
744/// the kernel derived for this command.
745///
746/// The field is documented as the CAS precondition, and for the five commands
747/// whose bodies carry no version of their own it is the ONLY CAS a caller can
748/// express — ignoring it turns an intended compare-and-swap into silent
749/// last-write-wins. Checking it against the derived version rather than
750/// replacing that version keeps one rule for all twenty commands: a create
751/// derives 0, a body-CAS command derives what its body already agreed to, and
752/// the rest derive what the log holds now.
753fn check_expected_version(envelope: &CommandEnvelope, derived: u32) -> Result<(), Refusal> {
754    match envelope.expected_version {
755        Some(expected) if expected != derived => Err(Refusal::new(
756            KernelErrorCode::StaleVersion,
757            format!("version conflict: actual {derived}, expected {expected}"),
758        )
759        .with_detail(serde_json::json!({ "actual": derived, "expected": expected }))),
760        _ => Ok(()),
761    }
762}
763
764/// What this command's key already means.
765async fn prior_for_key(
766    conn: &mut PgConnection,
767    envelope: &CommandEnvelope,
768    route: &Route,
769    payload: &serde_json::Value,
770) -> Result<Prior, Refusal> {
771    let stored = events_for_key(
772        conn,
773        envelope.project_id.as_str(),
774        route.aggregate_type,
775        &route.aggregate_id,
776        envelope.idempotency_key.as_str(),
777    )
778    .await?;
779    let Some(first) = stored.first() else {
780        return Ok(Prior::Unused);
781    };
782    // A replay must be the SAME request. Target and body are the obvious half;
783    // the other two are not.
784    //
785    // `project_id`, because the aggregate namespace is global while idempotency
786    // is per-project: a key can be free in this project and already taken on
787    // this aggregate by another, and answering that as a replay hands the caller
788    // another project's events for a command that never ran.
789    //
790    // `actor`, because a replay is answered BEFORE `transition::apply` runs, and
791    // `apply` is the only thing that enforces the liveness-producer flip rule.
792    // Without this the short-circuit becomes what `gwk_domain::transition`
793    // documents it must never be: a way to get a guarded edge answered without
794    // being authorized for it.
795    //
796    // Comparing the stored payload is exact because the payload IS the
797    // canonical command body — there is no second encoding of it to disagree
798    // with.
799    let same_request = stored.len() == 1
800        && first.project_id == envelope.project_id
801        && first.aggregate_type == route.aggregate_type
802        && first.aggregate_id.as_str() == route.aggregate_id
803        && first.actor == envelope.actor
804        && &first.payload == payload;
805    if same_request {
806        return Ok(Prior::Replay(stored));
807    }
808    Ok(Prior::Conflict(format!(
809        "idempotency key {:?} already names a different request on {}/{} in project {}",
810        envelope.idempotency_key.as_str(),
811        first.aggregate_type,
812        first.aggregate_id,
813        first.project_id
814    )))
815}
816
817/// The version the aggregate must currently be at for this command to apply.
818///
819/// Three shapes, and which one a command gets is decided by the contract, not
820/// by convenience: a create asserts the aggregate does not exist yet, a command
821/// carrying `expected_version` gets a CAS against it, and one that carries none
822/// takes the log as it stands.
823async fn decide(
824    conn: &mut PgConnection,
825    envelope: &CommandEnvelope,
826    command: &KernelCommand,
827    route: &Route,
828) -> Result<u32, Refusal> {
829    use KernelCommand as C;
830    Ok(match command {
831        // Genesis is version 1, so activation asserts version 1 — which makes
832        // the CAS a second, independent proof that the kernel was still sealed
833        // at the instant of the append, not merely when the epoch was read.
834        C::ActivateKernel { .. } => 1,
835
836        // Version 0 is the assertion "this aggregate has no events" — so the
837        // CAS in the append is what refuses a duplicate create, and no separate
838        // existence check can drift from it.
839        C::CreateTask { .. }
840        | C::CreateAttempt { .. }
841        | C::OpenEngineSession { .. }
842        | C::AcquireLease { .. }
843        | C::RegisterWorktree { .. }
844        | C::RegisterDispatchNode { .. }
845        | C::SendMessage { .. }
846        | C::IssueCommand { .. }
847        | C::OpenGate { .. }
848        | C::RecordEvidence { .. }
849        | C::GrantAuthority { .. }
850        | C::RaiseAttention { .. } => 0,
851
852        // Neither row carries a version column, so there is no CAS to express:
853        // a grant is live until revoked and an item is open until resolved,
854        // and both projections refuse the second write themselves — the UPDATE
855        // is predicated on the state it expects to find.
856        C::RevokeAuthority { .. } | C::ResolveAttention { .. } => {
857            current_aggregate_version(conn, route.aggregate_type, &route.aggregate_id).await?
858        }
859
860        C::TransitionTask {
861            to,
862            expected_version,
863            ..
864        } => {
865            let cursor: Cursor<TaskState> =
866                fsm_cursor(conn, TASK_CURSOR, &route.aggregate_id, "task").await?;
867            decide_transition(&cursor, *to, *expected_version, envelope, None)?
868        }
869        C::TransitionAttempt {
870            to,
871            expected_version,
872            receipt_id,
873            ..
874        } => {
875            let cursor: Cursor<AttemptState> =
876                fsm_cursor(conn, ATTEMPT_CURSOR, &route.aggregate_id, "attempt").await?;
877            decide_transition(
878                &cursor,
879                *to,
880                *expected_version,
881                envelope,
882                receipt_id.as_ref(),
883            )?
884        }
885
886        C::TransitionMessage {
887            to,
888            expected_version,
889            ..
890        } => {
891            let cursor: Cursor<MessageState> =
892                fsm_cursor(conn, MESSAGE_CURSOR, &route.aggregate_id, "message").await?;
893            decide_transition(&cursor, *to, *expected_version, envelope, None)?
894        }
895        C::TransitionCommand {
896            to,
897            expected_version,
898            ..
899        } => {
900            // `verification_complete` is reachable only through
901            // `RecordCommandOutcome`: the row's CHECK ties that state to an
902            // outcome column this command has no value for, so allowing it
903            // here would trade a typed refusal for a constraint violation
904            // raised from inside the projection.
905            if *to == CommandState::VerificationComplete {
906                return Err(Refusal::validation(
907                    "a command reaches verification_complete by recording its outcome, \
908                     which is the same write",
909                ));
910            }
911            let cursor: Cursor<CommandState> =
912                fsm_cursor(conn, COMMAND_CURSOR, &route.aggregate_id, "command").await?;
913            decide_transition(&cursor, *to, *expected_version, envelope, None)?
914        }
915        C::RecordCommandOutcome {
916            expected_version, ..
917        } => {
918            // Checked as the edge it is — signaled -> verification_complete —
919            // so a command that has not been signaled yet is refused with
920            // `illegal_edge` rather than silently completing.
921            let cursor: Cursor<CommandState> =
922                fsm_cursor(conn, COMMAND_CURSOR, &route.aggregate_id, "command").await?;
923            decide_transition(
924                &cursor,
925                CommandState::VerificationComplete,
926                *expected_version,
927                envelope,
928                None,
929            )?
930        }
931
932        C::DecideGate {
933            expected_version, ..
934        } => {
935            // A verdict is a value, not an edge: `gwk.gate` has a CHECK on the
936            // four verdicts and no transition table, so the contract admits
937            // re-deciding one. The version CAS is the whole guard.
938            decide_cas(
939                conn,
940                GATE_VERSION,
941                &route.aggregate_id,
942                "gate",
943                *expected_version,
944            )
945            .await?
946        }
947
948        C::UpdateBudget {
949            expected_version, ..
950        }
951        | C::RecordAttemptOutcome {
952            expected_version, ..
953        } => {
954            decide_cas(
955                conn,
956                ATTEMPT_VERSION,
957                &route.aggregate_id,
958                "attempt",
959                *expected_version,
960            )
961            .await?
962        }
963        C::RenewLease {
964            expected_version, ..
965        }
966        | C::ReleaseLease {
967            expected_version, ..
968        }
969        | C::ExpireLease {
970            expected_version, ..
971        } => {
972            decide_cas(
973                conn,
974                LEASE_VERSION,
975                &route.aggregate_id,
976                "lease",
977                *expected_version,
978            )
979            .await?
980        }
981        C::TransitionDispatchNode {
982            expected_version, ..
983        } => {
984            // Version CAS with no edge check: the spawn tree's lifecycle label
985            // is open by design, so there is no edge table to check it against.
986            decide_cas(
987                conn,
988                DISPATCH_NODE_VERSION,
989                &route.aggregate_id,
990                "dispatch_node",
991                *expected_version,
992            )
993            .await?
994        }
995
996        C::WriteOrchestratorCheckpoint { checkpoint } => {
997            if let Some(current) = checkpoint_seq(conn, &route.aggregate_id).await?
998                && checkpoint.seq.value() <= current
999            {
1000                // A resume cursor, not a counter: re-writing a seq that has
1001                // already been superseded is how recovery would restart from
1002                // stale state. Refused here so the caller gets the number back
1003                // instead of the trigger's exception.
1004                return Err(Refusal::new(
1005                    KernelErrorCode::StaleVersion,
1006                    format!(
1007                        "checkpoint seq must advance past {current} (got {})",
1008                        checkpoint.seq
1009                    ),
1010                )
1011                .with_detail(serde_json::json!({
1012                    "actual": current.to_string(),
1013                    "presented": checkpoint.seq.to_string(),
1014                })));
1015            }
1016            current_aggregate_version(conn, route.aggregate_type, &route.aggregate_id).await?
1017        }
1018
1019        // No CAS in the command, so none is invented: these take the aggregate
1020        // as the log has it. The single-writer append is what orders them.
1021        C::CloseEngineSession { .. }
1022        | C::UpdateWorktree { .. }
1023        | C::ReleaseWorktree { .. }
1024        | C::RecordRound { .. }
1025        | C::RecordFinding { .. } => {
1026            current_aggregate_version(conn, route.aggregate_type, &route.aggregate_id).await?
1027        }
1028
1029        // An ingested record's aggregate holds exactly one event, always: its id
1030        // is derived from the idempotency key, so a second command bearing that
1031        // key was already answered above as a replay or refused as a conflict
1032        // and never reaches here. Reading the version anyway — rather than
1033        // hard-coding the 0 that must be true — keeps the one place that decides
1034        // what a version means the same for every command.
1035        C::IngestRecord { .. } => {
1036            current_aggregate_version(conn, route.aggregate_type, &route.aggregate_id).await?
1037        }
1038    })
1039}
1040
1041/// Read one FSM row into the cursor [`transition::apply`] decides from.
1042async fn fsm_cursor<S: DeserializeOwned>(
1043    conn: &mut PgConnection,
1044    select: &'static str,
1045    id: &str,
1046    kind: &str,
1047) -> Result<Cursor<S>, Refusal> {
1048    let row = sqlx::query(select)
1049        .bind(id)
1050        .fetch_optional(conn)
1051        .await
1052        .map_err(|e| Refusal::storage(format!("read {kind} {id}: {e}")))?
1053        .ok_or_else(|| Refusal::not_found(format!("no {kind} {id}")))?;
1054    let state: String = row
1055        .try_get("state")
1056        .map_err(|e| Refusal::storage(format!("column state: {e}")))?;
1057    Ok(Cursor {
1058        state: from_wire_str(&state)?,
1059        version: row_version(&row)?,
1060        // A replay was already answered from the log above, so `apply`'s keyed
1061        // short-circuit has nothing left to decide. Leaving these absent is the
1062        // honest way to say so — a key here would only ever fail to match.
1063        applied_idempotency_key: None,
1064        applied_by: None,
1065    })
1066}
1067
1068fn row_version(row: &sqlx::postgres::PgRow) -> Result<u32, Refusal> {
1069    let version: i64 = row
1070        .try_get("version")
1071        .map_err(|e| Refusal::storage(format!("column version: {e}")))?;
1072    u32::try_from(version).map_err(|e| Refusal::storage(format!("version out of range: {e}")))
1073}
1074
1075/// Run one transition through the domain's single decision function.
1076///
1077/// Every refusal it can return is a contract value, so each gets its own wire
1078/// code rather than collapsing into one "rejected".
1079fn decide_transition<S>(
1080    cursor: &Cursor<S>,
1081    to: S,
1082    expected_version: u32,
1083    envelope: &CommandEnvelope,
1084    receipt_id: Option<&ReceiptId>,
1085) -> Result<u32, Refusal>
1086where
1087    S: transition::TransitionGuard + serde::Serialize,
1088{
1089    let request = TransitionRequest {
1090        to,
1091        expected_version,
1092        actor: &envelope.actor,
1093        idempotency_key: None,
1094        receipt_id,
1095    };
1096    match transition::apply(cursor, &request) {
1097        TransitionResult::Applied { .. } => Ok(cursor.version),
1098        TransitionResult::IllegalEdge { from, to } => {
1099            let (from, to) = (wire_str(&from)?, wire_str(&to)?);
1100            Err(
1101                Refusal::new(KernelErrorCode::IllegalEdge, format!("{from} -> {to}"))
1102                    .with_detail(serde_json::json!({ "from": from, "to": to })),
1103            )
1104        }
1105        TransitionResult::StaleVersion { actual, expected } => Err(Refusal::new(
1106            KernelErrorCode::StaleVersion,
1107            format!("version conflict: actual {actual}, expected {expected}"),
1108        )
1109        .with_detail(serde_json::json!({ "actual": actual, "expected": expected }))),
1110        // The liveness-producer flip rule lives here and only here: the DDL
1111        // guard checks edges and versions, never WHO. A refusal at this arm is
1112        // the one thing storage cannot catch.
1113        TransitionResult::UnauthorizedActor { reason } => {
1114            Err(Refusal::new(KernelErrorCode::Authority, reason))
1115        }
1116    }
1117}
1118
1119/// Compare a versioned row against the command's `expected_version`.
1120async fn decide_cas(
1121    conn: &mut PgConnection,
1122    select: &'static str,
1123    id: &str,
1124    kind: &str,
1125    expected: u32,
1126) -> Result<u32, Refusal> {
1127    let row = sqlx::query(select)
1128        .bind(id)
1129        .fetch_optional(conn)
1130        .await
1131        .map_err(|e| Refusal::storage(format!("read {kind} {id}: {e}")))?
1132        .ok_or_else(|| Refusal::not_found(format!("no {kind} {id}")))?;
1133    let actual = row_version(&row)?;
1134    if actual != expected {
1135        return Err(Refusal::new(
1136            KernelErrorCode::StaleVersion,
1137            format!("version conflict: actual {actual}, expected {expected}"),
1138        )
1139        .with_detail(serde_json::json!({ "actual": actual, "expected": expected })));
1140    }
1141    Ok(actual)
1142}
1143
1144/// The seq an orchestrator's checkpoint currently holds, if it has one.
1145async fn checkpoint_seq(
1146    conn: &mut PgConnection,
1147    orchestrator_id: &str,
1148) -> Result<Option<u64>, Refusal> {
1149    let text: Option<String> = sqlx::query_scalar(CHECKPOINT_SEQ)
1150        .bind(orchestrator_id)
1151        .fetch_optional(conn)
1152        .await
1153        .map_err(|e| Refusal::storage(format!("read checkpoint {orchestrator_id}: {e}")))?;
1154    text.map(|text| from_numeric_text(&text))
1155        .transpose()
1156        .map_err(|e| Refusal::storage(format!("column seq: {e}")))
1157}
1158
1159/// Build the event this command produces.
1160fn build_event(
1161    envelope: &CommandEnvelope,
1162    payload: serde_json::Value,
1163    route: &Route,
1164    expected_version: u32,
1165) -> Result<EventEnvelope, Refusal> {
1166    let aggregate_version = expected_version.checked_add(1).ok_or_else(|| {
1167        Refusal::new(
1168            KernelErrorCode::StaleVersion,
1169            format!(
1170                "{}/{} is at the version ceiling",
1171                route.aggregate_type, route.aggregate_id
1172            ),
1173        )
1174    })?;
1175    Ok(EventEnvelope {
1176        // Derived, not minted. `(aggregate_type, aggregate_id,
1177        // aggregate_version)` is already UNIQUE in the log and the last segment
1178        // is always digits, so this is collision-free by the constraint that
1179        // exists anyway — and it is stable under replay, which a random id
1180        // would not be, with no RNG dependency to make deterministic later.
1181        event_id: EventId::new(format!(
1182            "{}:{}:{aggregate_version}",
1183            route.aggregate_type, route.aggregate_id
1184        )),
1185        project_id: envelope.project_id.clone(),
1186        aggregate_type: route.aggregate_type.to_owned(),
1187        aggregate_id: AggregateId::new(route.aggregate_id.clone()),
1188        aggregate_version,
1189        event_type: route.event_type.to_owned(),
1190        // The KERNEL's version, not the caller's: the kernel wrote this event,
1191        // so it is the one making a promise about how to read it. The caller's
1192        // own declaration was checked on the way in.
1193        schema_version: ENVELOPE_SCHEMA_VERSION,
1194        // Both assigned by the store inside the append; the port documents that
1195        // whatever arrives here is overwritten.
1196        global_sequence: Seq::new(0),
1197        appended_at: envelope.issued_at.clone(),
1198        occurred_at: envelope.issued_at.clone(),
1199        actor: envelope.actor.clone(),
1200        origin: envelope.origin.clone(),
1201        causation_id: envelope.causation_id.clone(),
1202        correlation_id: envelope.correlation_id.clone(),
1203        idempotency_key: Some(envelope.idempotency_key.clone()),
1204        payload,
1205        payload_ref: None,
1206    })
1207}
1208
1209#[cfg(test)]
1210mod tests {
1211    use gwk_domain::ids::{AttemptId, TaskId};
1212
1213    use super::*;
1214
1215    fn envelope(command: &KernelCommand) -> CommandEnvelope {
1216        CommandEnvelope {
1217            command_id: gwk_domain::ids::CommandId::new("cmd-1"),
1218            project_id: gwk_domain::ids::ProjectId::new("p"),
1219            command_type: command.command_type().to_owned(),
1220            schema_version: ENVELOPE_SCHEMA_VERSION,
1221            issued_at: gwk_domain::ids::Timestamp::new("2026-07-28T00:00:00Z"),
1222            actor: gwk_domain::envelope::Actor {
1223                kind: "kernel".into(),
1224                id: None,
1225            },
1226            origin: gwk_domain::envelope::Origin {
1227                system: "gw".into(),
1228                r#ref: None,
1229            },
1230            target_aggregate_type: None,
1231            target_aggregate_id: None,
1232            expected_version: None,
1233            idempotency_key: gwk_domain::ids::IdempotencyKey::new("k-1"),
1234            causation_id: None,
1235            correlation_id: None,
1236            payload: serde_json::json!({}),
1237        }
1238    }
1239
1240    #[test]
1241    fn every_in_scope_command_routes_to_an_aggregate_and_an_event_name() {
1242        let create = KernelCommand::CreateTask {
1243            task_id: TaskId::new("t-1"),
1244            kind: None,
1245            title: None,
1246            spec_ref: None,
1247            project: None,
1248            priority: None,
1249            tracker_ref: None,
1250        };
1251        let route = route_of(&envelope(&create), &create).expect("routes");
1252        assert_eq!(route.aggregate_type, "task");
1253        assert_eq!(route.aggregate_id, "t-1");
1254        assert_eq!(route.event_type, "task_created");
1255
1256        // A round is a fact about the ATTEMPT — it has no aggregate of its own,
1257        // which is what keeps the attempt's row version equal to its log version.
1258        let round = KernelCommand::RecordRound {
1259            attempt_id: AttemptId::new("a-1"),
1260            round: 2,
1261            findings: gwk_domain::inherited::RoundFindingSummary {
1262                total: 0,
1263                auto_fix: 0,
1264                ask_user: 0,
1265                no_op: 0,
1266            },
1267        };
1268        let route = route_of(&envelope(&round), &round).expect("routes");
1269        assert_eq!(
1270            (route.aggregate_type, route.aggregate_id.as_str()),
1271            ("attempt", "a-1")
1272        );
1273
1274        // Evidence is its own aggregate even though its row has no version
1275        // column: what the log counts and what the projection stores are
1276        // separate questions.
1277        let evidence = KernelCommand::RecordEvidence {
1278            evidence_id: gwk_domain::ids::EvidenceId::new("ev-1"),
1279            kind: "diff".to_owned(),
1280            r#ref: "blob://d".to_owned(),
1281            digest: None,
1282            byte_size: None,
1283        };
1284        let route = route_of(&envelope(&evidence), &evidence).expect("routes");
1285        assert_eq!(
1286            (route.aggregate_type, route.event_type),
1287            ("evidence", "evidence_recorded")
1288        );
1289    }
1290
1291    #[test]
1292    fn an_ingested_record_is_named_by_the_envelope_that_carried_it() {
1293        // The only command whose aggregate id the caller does not supply. Two
1294        // submissions differing ONLY in idempotency key are two records; the
1295        // same key twice is one, which is what makes a retried ingest safe.
1296        let ingest = KernelCommand::IngestRecord {
1297            kind: gwk_domain::ingestion::IngestionKind::Memory,
1298            payload: serde_json::json!({ "text": "recalled" }),
1299            payload_ref: None,
1300        };
1301        let route = route_of(&envelope(&ingest), &ingest).expect("routes");
1302        assert_eq!(
1303            (
1304                route.aggregate_type,
1305                route.aggregate_id.as_str(),
1306                route.event_type
1307            ),
1308            ("ingested_record", "ingest:p:k-1", "record_ingested")
1309        );
1310
1311        let mut second = envelope(&ingest);
1312        second.idempotency_key = gwk_domain::ids::IdempotencyKey::new("k-2");
1313        assert_eq!(
1314            route_of(&second, &ingest).expect("routes").aggregate_id,
1315            "ingest:p:k-2"
1316        );
1317    }
1318
1319    #[test]
1320    fn an_anonymous_checkpoint_has_no_row_to_be_the_latest_of() {
1321        let anonymous = KernelCommand::WriteOrchestratorCheckpoint {
1322            checkpoint: gwk_domain::inherited::OrchestratorCheckpoint {
1323                orchestrator_id: None,
1324                seq: Seq::new(1),
1325                native_session_ref: None,
1326                active_goal: None,
1327                active_step_ref: None,
1328                latest_command_ref: None,
1329                open_attempts: None,
1330                leases: None,
1331                pending_approvals: None,
1332                budget_cursor: None,
1333            },
1334        };
1335        let refusal = route_of(&envelope(&anonymous), &anonymous).expect_err("no identity");
1336        assert_eq!(refusal.code, KernelErrorCode::Validation);
1337    }
1338
1339    #[test]
1340    fn an_empty_id_is_refused_before_it_becomes_an_aggregate() {
1341        let nameless = KernelCommand::CreateTask {
1342            task_id: TaskId::new(""),
1343            kind: None,
1344            title: None,
1345            spec_ref: None,
1346            project: None,
1347            priority: None,
1348            tracker_ref: None,
1349        };
1350        assert_eq!(
1351            route_of(&envelope(&nameless), &nameless)
1352                .expect_err("empty id")
1353                .code,
1354            KernelErrorCode::Validation
1355        );
1356    }
1357
1358    #[test]
1359    fn the_event_carries_the_kernels_schema_version_and_a_derived_id() {
1360        let command = KernelCommand::TransitionTask {
1361            task_id: TaskId::new("t-1"),
1362            to: TaskState::Working,
1363            expected_version: 1,
1364        };
1365        let route = route_of(&envelope(&command), &command).expect("routes");
1366        let payload = serde_json::to_value(&command).expect("serialize");
1367        let event = build_event(&envelope(&command), payload.clone(), &route, 1).expect("built");
1368        assert_eq!(event.aggregate_version, 2);
1369        assert_eq!(event.event_id.as_str(), "task:t-1:2");
1370        assert_eq!(event.schema_version, ENVELOPE_SCHEMA_VERSION);
1371        assert_eq!(event.event_type, "task_transitioned");
1372        // The payload IS the command body — which is what lets the projection
1373        // applier serve both the live write and a replay from the log.
1374        assert_eq!(event.payload, payload);
1375        assert_eq!(
1376            event.idempotency_key.as_ref().map(|k| k.as_str()),
1377            Some("k-1")
1378        );
1379    }
1380
1381    #[test]
1382    fn a_transition_at_the_version_ceiling_is_refused_not_wrapped() {
1383        let command = KernelCommand::TransitionTask {
1384            task_id: TaskId::new("t-1"),
1385            to: TaskState::Working,
1386            expected_version: u32::MAX,
1387        };
1388        let route = route_of(&envelope(&command), &command).expect("routes");
1389        let refusal = build_event(&envelope(&command), serde_json::json!({}), &route, u32::MAX)
1390            .expect_err("the ceiling");
1391        assert_eq!(refusal.code, KernelErrorCode::StaleVersion);
1392    }
1393
1394    #[test]
1395    fn the_liveness_flip_refusal_is_authority_not_a_version_probe() {
1396        // The DDL guard checks edges and versions and never WHO, so this arm is
1397        // the only thing standing between a wrong actor and a blocked flip.
1398        let command = KernelCommand::TransitionAttempt {
1399            attempt_id: AttemptId::new("a-1"),
1400            to: AttemptState::Blocked,
1401            expected_version: 999,
1402            receipt_id: None,
1403        };
1404        let cursor = Cursor {
1405            state: AttemptState::Running,
1406            version: 5,
1407            applied_idempotency_key: None,
1408            applied_by: None,
1409        };
1410        let refusal = decide_transition(
1411            &cursor,
1412            AttemptState::Blocked,
1413            999,
1414            &envelope(&command),
1415            None,
1416        )
1417        .expect_err("wrong actor");
1418        assert_eq!(refusal.code, KernelErrorCode::Authority);
1419        // And the real version never leaves: the actor refusal answers first.
1420        assert!(!refusal.message.contains('5'), "{refusal}");
1421    }
1422
1423    #[test]
1424    fn a_refusal_carries_the_number_a_retrier_needs() {
1425        let command = KernelCommand::TransitionTask {
1426            task_id: TaskId::new("t-1"),
1427            to: TaskState::Working,
1428            expected_version: 1,
1429        };
1430        let cursor = Cursor {
1431            state: TaskState::Submitted,
1432            version: 4,
1433            applied_idempotency_key: None,
1434            applied_by: None,
1435        };
1436        let refusal = decide_transition(&cursor, TaskState::Working, 1, &envelope(&command), None)
1437            .expect_err("stale");
1438        assert_eq!(refusal.code, KernelErrorCode::StaleVersion);
1439        assert_eq!(
1440            refusal.detail,
1441            Some(serde_json::json!({ "actual": 4, "expected": 1 }))
1442        );
1443    }
1444}