1use 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
58const 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#[derive(Debug)]
77struct Route {
78 aggregate_type: &'static str,
79 aggregate_id: String,
80 event_type: &'static str,
81}
82
83enum Prior {
85 Unused,
87 Replay(Vec<EventEnvelope>),
90 Conflict(String),
93}
94
95impl PgEventStore {
96 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 let writer = self.lock_writer(&mut tx).await?;
135 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 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 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 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 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 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 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 &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 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
300fn 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 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 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 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
502fn 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
531fn 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 from: None,
563 to: None,
564 observed_basis: Some(observed_basis.to_owned()),
565 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
576async 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
601fn 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
625fn 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
638fn 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
675async 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
707async fn check_aggregate_owner(
720 conn: &mut PgConnection,
721 envelope: &CommandEnvelope,
722 route: &Route,
723) -> Result<(), Refusal> {
724 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
743fn 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
764async 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 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
817async 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 C::ActivateKernel { .. } => 1,
835
836 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 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 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 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 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 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 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 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 C::IngestRecord { .. } => {
1036 current_aggregate_version(conn, route.aggregate_type, &route.aggregate_id).await?
1037 }
1038 })
1039}
1040
1041async 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 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
1075fn 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 TransitionResult::UnauthorizedActor { reason } => {
1114 Err(Refusal::new(KernelErrorCode::Authority, reason))
1115 }
1116 }
1117}
1118
1119async 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
1144async 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
1159fn 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 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 schema_version: ENVELOPE_SCHEMA_VERSION,
1194 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 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 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 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 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 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 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}