1use gwk_domain::command::KernelCommand;
22use gwk_domain::entity::DISPATCH_NODE_INITIAL_STATE;
23use gwk_domain::envelope::EventEnvelope;
24use gwk_domain::protocol::{KernelErrorCode, KernelResult};
25use serde::Deserialize;
26use sqlx::{PgConnection, postgres::PgQueryResult};
27
28use crate::numeric::to_numeric_text;
29
30#[derive(Debug, Clone, PartialEq)]
36pub struct Refusal {
37 pub code: KernelErrorCode,
38 pub message: String,
39 pub detail: Option<serde_json::Value>,
40}
41
42impl Refusal {
43 pub fn new(code: KernelErrorCode, message: impl Into<String>) -> Self {
44 Self {
45 code,
46 message: message.into(),
47 detail: None,
48 }
49 }
50
51 pub fn with_detail(mut self, detail: serde_json::Value) -> Self {
52 self.detail = Some(detail);
53 self
54 }
55
56 pub fn validation(message: impl Into<String>) -> Self {
57 Self::new(KernelErrorCode::Validation, message)
58 }
59
60 pub fn not_found(message: impl Into<String>) -> Self {
61 Self::new(KernelErrorCode::NotFound, message)
62 }
63
64 pub fn storage(message: impl Into<String>) -> Self {
65 Self::new(KernelErrorCode::Storage, message)
66 }
67
68 pub fn into_result(self) -> KernelResult {
69 KernelResult::Error {
70 code: self.code,
71 message: self.message,
72 detail: self.detail,
73 }
74 }
75}
76
77impl std::fmt::Display for Refusal {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 write!(f, "{}: {}", self.code, self.message)
80 }
81}
82
83impl From<gwk_domain::port::AppendError> for Refusal {
84 fn from(error: gwk_domain::port::AppendError) -> Self {
88 use gwk_domain::port::AppendError as E;
89 let message = error.to_string();
90 match error {
91 E::VersionConflict { actual, expected } => {
92 Self::new(KernelErrorCode::StaleVersion, message)
93 .with_detail(serde_json::json!({ "actual": actual, "expected": expected }))
94 }
95 E::Fenced { presented, current } => Self::new(KernelErrorCode::Fenced, message)
96 .with_detail(serde_json::json!({
97 "presented": presented.to_string(),
98 "current": current.to_string(),
99 })),
100 E::MalformedBatch(_) => Self::validation(message),
101 E::Storage(_) => Self::storage(message),
102 }
103 }
104}
105
106impl From<gwk_domain::port::StorageError> for Refusal {
107 fn from(error: gwk_domain::port::StorageError) -> Self {
108 Self::storage(error.0)
109 }
110}
111
112fn db(context: &str, error: sqlx::Error) -> Refusal {
113 if let sqlx::Error::Database(ref db) = error
118 && db.is_foreign_key_violation()
119 {
120 return Refusal::not_found(format!("{context}: {error}"));
121 }
122 Refusal::storage(format!("{context}: {error}"))
123}
124
125pub(crate) fn wire_str<T: serde::Serialize>(value: &T) -> Result<String, Refusal> {
128 match serde_json::to_value(value) {
129 Ok(serde_json::Value::String(text)) => Ok(text),
130 other => Err(Refusal::storage(format!(
131 "expected a wire string, got {other:?}"
132 ))),
133 }
134}
135
136pub(crate) fn from_wire_str<T: serde::de::DeserializeOwned>(text: &str) -> Result<T, Refusal> {
138 serde_json::from_value(serde_json::Value::String(text.to_owned())).map_err(|e| {
139 Refusal::storage(format!(
140 "stored state {text:?} is not a state this contract knows: {e}"
141 ))
142 })
143}
144
145fn json_opt<T: serde::Serialize>(value: Option<&T>) -> Result<Option<serde_json::Value>, Refusal> {
148 value
149 .map(serde_json::to_value)
150 .transpose()
151 .map_err(|e| Refusal::storage(format!("serialize projection column: {e}")))
152}
153
154fn require_one(done: PgQueryResult, kind: &str, id: &str) -> Result<(), Refusal> {
161 match done.rows_affected() {
162 1 => Ok(()),
163 0 => Err(Refusal::not_found(format!("no {kind} {id}"))),
164 n => Err(Refusal::storage(format!(
165 "{kind} {id} matched {n} rows on a primary key"
166 ))),
167 }
168}
169
170pub(crate) async fn apply_event(
172 conn: &mut PgConnection,
173 event: &EventEnvelope,
174) -> Result<(), Refusal> {
175 let command = KernelCommand::deserialize(&event.payload).map_err(|e| {
178 Refusal::storage(format!(
179 "event {} payload is not a command body: {e}",
180 event.event_id
181 ))
182 })?;
183 let version = i64::from(event.aggregate_version);
187 let at = event.appended_at.as_str();
188
189 match &command {
190 KernelCommand::CreateTask {
192 task_id,
193 kind,
194 title,
195 spec_ref,
196 project,
197 priority,
198 tracker_ref,
199 } => {
200 sqlx::query(
201 "INSERT INTO gwk.task \
202 (id, version, state, kind, title, spec_ref, project, priority, tracker_ref, \
203 created_at, updated_at) \
204 VALUES ($1, $2, 'submitted', $3, $4, $5, $6, $7, $8, \
205 $9::timestamptz, $9::timestamptz)",
206 )
207 .bind(task_id.as_str())
208 .bind(version)
209 .bind(kind.as_deref())
210 .bind(title.as_deref())
211 .bind(spec_ref.as_deref())
212 .bind(project.as_deref())
213 .bind(*priority)
214 .bind(tracker_ref.as_deref())
215 .bind(at)
216 .execute(&mut *conn)
217 .await
218 .map_err(|e| db("insert task", e))?;
219 }
220 KernelCommand::TransitionTask { task_id, to, .. } => {
221 let done = sqlx::query(
222 "UPDATE gwk.task SET state = $2, version = $3, updated_at = $4::timestamptz \
223 WHERE id = $1",
224 )
225 .bind(task_id.as_str())
226 .bind(wire_str(to)?)
227 .bind(version)
228 .bind(at)
229 .execute(&mut *conn)
230 .await
231 .map_err(|e| db("transition task", e))?;
232 require_one(done, "task", task_id.as_str())?;
233 }
234
235 KernelCommand::CreateAttempt {
237 attempt_id,
238 task_id,
239 engine,
240 capability,
241 role,
242 model_lane,
243 permission_profile,
244 worktree_lease_id,
245 base_sha,
246 budget,
247 } => {
248 sqlx::query(
249 "INSERT INTO gwk.attempt \
250 (id, version, state, task_id, engine, capability, role, model_lane, \
251 permission_profile, worktree_lease_id, base_sha, budget, \
252 created_at, updated_at) \
253 VALUES ($1, $2, 'queued', $3, $4, $5, $6, $7, $8, $9, $10, $11, \
254 $12::timestamptz, $12::timestamptz)",
255 )
256 .bind(attempt_id.as_str())
257 .bind(version)
258 .bind(task_id.as_str())
259 .bind(engine.as_str())
260 .bind(capability.as_deref())
261 .bind(role.as_deref())
262 .bind(model_lane.as_deref())
263 .bind(permission_profile.as_deref())
264 .bind(worktree_lease_id.as_ref().map(|l| l.as_str()))
265 .bind(base_sha.as_deref())
266 .bind(json_opt(budget.as_ref())?)
267 .bind(at)
268 .execute(&mut *conn)
269 .await
270 .map_err(|e| db("insert attempt", e))?;
271 }
272 KernelCommand::TransitionAttempt { attempt_id, to, .. } => {
273 let done = sqlx::query(
274 "UPDATE gwk.attempt SET state = $2, version = $3, updated_at = $4::timestamptz \
275 WHERE id = $1",
276 )
277 .bind(attempt_id.as_str())
278 .bind(wire_str(to)?)
279 .bind(version)
280 .bind(at)
281 .execute(&mut *conn)
282 .await
283 .map_err(|e| db("transition attempt", e))?;
284 require_one(done, "attempt", attempt_id.as_str())?;
285 }
286 KernelCommand::UpdateBudget {
287 attempt_id, budget, ..
288 } => {
289 let done = sqlx::query(
290 "UPDATE gwk.attempt SET budget = $2, version = $3, updated_at = $4::timestamptz \
291 WHERE id = $1",
292 )
293 .bind(attempt_id.as_str())
294 .bind(json_opt(Some(budget))?)
295 .bind(version)
296 .bind(at)
297 .execute(&mut *conn)
298 .await
299 .map_err(|e| db("update budget", e))?;
300 require_one(done, "attempt", attempt_id.as_str())?;
301 }
302 KernelCommand::RecordAttemptOutcome {
303 attempt_id,
304 exit_code,
305 provider_terminal_event,
306 result_valid,
307 evidence_manifest_ref,
308 ..
309 } => {
310 let done = sqlx::query(
314 "UPDATE gwk.attempt SET \
315 exit_code = coalesce($2, exit_code), \
316 provider_terminal_event = coalesce($3, provider_terminal_event), \
317 result_valid = coalesce($4, result_valid), \
318 evidence_manifest_ref = coalesce($5, evidence_manifest_ref), \
319 version = $6, updated_at = $7::timestamptz \
320 WHERE id = $1",
321 )
322 .bind(attempt_id.as_str())
323 .bind(*exit_code)
324 .bind(provider_terminal_event.as_deref())
325 .bind(*result_valid)
326 .bind(evidence_manifest_ref.as_deref())
327 .bind(version)
328 .bind(at)
329 .execute(&mut *conn)
330 .await
331 .map_err(|e| db("record attempt outcome", e))?;
332 require_one(done, "attempt", attempt_id.as_str())?;
333 }
334 KernelCommand::RecordRound { attempt_id, .. }
339 | KernelCommand::RecordFinding { attempt_id, .. } => {
340 let done = sqlx::query(
341 "UPDATE gwk.attempt SET version = $2, updated_at = $3::timestamptz WHERE id = $1",
342 )
343 .bind(attempt_id.as_str())
344 .bind(version)
345 .bind(at)
346 .execute(&mut *conn)
347 .await
348 .map_err(|e| db("advance attempt", e))?;
349 require_one(done, "attempt", attempt_id.as_str())?;
350 }
351
352 KernelCommand::OpenEngineSession {
354 engine_session_id,
355 attempt_id,
356 engine,
357 provider_session_ref,
358 } => {
359 sqlx::query(
360 "INSERT INTO gwk.engine_session \
361 (id, attempt_id, engine, provider_session_ref, started_at) \
362 VALUES ($1, $2, $3, $4, $5::timestamptz)",
363 )
364 .bind(engine_session_id.as_str())
365 .bind(attempt_id.as_str())
366 .bind(engine.as_str())
367 .bind(provider_session_ref.as_deref())
368 .bind(at)
369 .execute(&mut *conn)
370 .await
371 .map_err(|e| db("open engine session", e))?;
372 }
373 KernelCommand::CloseEngineSession { engine_session_id } => {
374 let done = sqlx::query(
375 "UPDATE gwk.engine_session SET ended_at = $2::timestamptz WHERE id = $1",
376 )
377 .bind(engine_session_id.as_str())
378 .bind(at)
379 .execute(&mut *conn)
380 .await
381 .map_err(|e| db("close engine session", e))?;
382 require_one(done, "engine_session", engine_session_id.as_str())?;
383 }
384
385 KernelCommand::AcquireLease {
387 lease_id,
388 mode,
389 holder,
390 scope,
391 repo,
392 path,
393 branch,
394 base_sha,
395 expires_at,
396 } => {
397 sqlx::query(
398 "INSERT INTO gwk.lease \
399 (id, version, state, mode, holder, scope, repo, path, branch, base_sha, \
400 expires_at, created_at, updated_at) \
401 VALUES ($1, $2, 'held', $3, $4, $5, $6, $7, $8, $9, $10::timestamptz, \
402 $11::timestamptz, $11::timestamptz)",
403 )
404 .bind(lease_id.as_str())
405 .bind(version)
406 .bind(wire_str(mode)?)
407 .bind(holder.as_deref())
408 .bind(scope.as_deref())
409 .bind(repo.as_deref())
410 .bind(path.as_deref())
411 .bind(branch.as_deref())
412 .bind(base_sha.as_deref())
413 .bind(expires_at.as_ref().map(|t| t.as_str()))
414 .bind(at)
415 .execute(&mut *conn)
416 .await
417 .map_err(|e| db("acquire lease", e))?;
418 }
419 KernelCommand::RenewLease {
420 lease_id,
421 expires_at,
422 ..
423 } => {
424 let done = sqlx::query(
425 "UPDATE gwk.lease SET \
426 expires_at = coalesce($2::timestamptz, expires_at), \
427 heartbeat_at = $3::timestamptz, version = $4, updated_at = $3::timestamptz \
428 WHERE id = $1",
429 )
430 .bind(lease_id.as_str())
431 .bind(expires_at.as_ref().map(|t| t.as_str()))
432 .bind(at)
433 .bind(version)
434 .execute(&mut *conn)
435 .await
436 .map_err(|e| db("renew lease", e))?;
437 require_one(done, "lease", lease_id.as_str())?;
438 }
439 KernelCommand::ReleaseLease {
440 lease_id,
441 disposition,
442 ..
443 } => {
444 let done = sqlx::query(
445 "UPDATE gwk.lease SET state = 'released', \
446 disposition = coalesce($2, disposition), \
447 version = $3, updated_at = $4::timestamptz \
448 WHERE id = $1",
449 )
450 .bind(lease_id.as_str())
451 .bind(disposition.as_deref())
452 .bind(version)
453 .bind(at)
454 .execute(&mut *conn)
455 .await
456 .map_err(|e| db("release lease", e))?;
457 require_one(done, "lease", lease_id.as_str())?;
458 }
459 KernelCommand::ExpireLease { lease_id, .. } => {
460 let done = sqlx::query(
461 "UPDATE gwk.lease SET state = 'expired', version = $2, \
462 updated_at = $3::timestamptz \
463 WHERE id = $1",
464 )
465 .bind(lease_id.as_str())
466 .bind(version)
467 .bind(at)
468 .execute(&mut *conn)
469 .await
470 .map_err(|e| db("expire lease", e))?;
471 require_one(done, "lease", lease_id.as_str())?;
472 }
473
474 KernelCommand::RegisterWorktree {
476 worktree_id,
477 repo,
478 path,
479 branch,
480 base_sha,
481 lease_id,
482 } => {
483 sqlx::query(
484 "INSERT INTO gwk.worktree (id, repo, path, branch, base_sha, lease_id, created_at) \
485 VALUES ($1, $2, $3, $4, $5, $6, $7::timestamptz)",
486 )
487 .bind(worktree_id.as_str())
488 .bind(repo)
489 .bind(path)
490 .bind(branch)
491 .bind(base_sha.as_deref())
492 .bind(lease_id.as_ref().map(|l| l.as_str()))
493 .bind(at)
494 .execute(&mut *conn)
495 .await
496 .map_err(|e| db("register worktree", e))?;
497 }
498 KernelCommand::UpdateWorktree {
499 worktree_id,
500 dirty,
501 unpushed,
502 base_sha,
503 } => {
504 let done = sqlx::query(
505 "UPDATE gwk.worktree SET dirty = $2, unpushed = $3, \
506 base_sha = coalesce($4, base_sha) \
507 WHERE id = $1",
508 )
509 .bind(worktree_id.as_str())
510 .bind(*dirty)
511 .bind(*unpushed)
512 .bind(base_sha.as_deref())
513 .execute(&mut *conn)
514 .await
515 .map_err(|e| db("update worktree", e))?;
516 require_one(done, "worktree", worktree_id.as_str())?;
517 }
518 KernelCommand::ReleaseWorktree {
519 worktree_id,
520 disposition,
521 } => {
522 let done = sqlx::query(
523 "UPDATE gwk.worktree SET released_at = $2::timestamptz, \
524 disposition = coalesce($3, disposition) \
525 WHERE id = $1",
526 )
527 .bind(worktree_id.as_str())
528 .bind(at)
529 .bind(disposition.as_deref())
530 .execute(&mut *conn)
531 .await
532 .map_err(|e| db("release worktree", e))?;
533 require_one(done, "worktree", worktree_id.as_str())?;
534 }
535
536 KernelCommand::RegisterDispatchNode {
538 dispatch_node_id,
539 parent_id,
540 attempt_id,
541 kind,
542 label,
543 } => {
544 sqlx::query(
545 "INSERT INTO gwk.dispatch_node \
546 (id, version, parent_id, attempt_id, kind, state, label, \
547 created_at, updated_at) \
548 VALUES ($1, $2, $3, $4, $5, $6, $7, $8::timestamptz, $8::timestamptz)",
549 )
550 .bind(dispatch_node_id.as_str())
551 .bind(version)
552 .bind(parent_id.as_ref().map(|p| p.as_str()))
553 .bind(attempt_id.as_ref().map(|a| a.as_str()))
554 .bind(kind)
555 .bind(DISPATCH_NODE_INITIAL_STATE)
556 .bind(label.as_deref())
557 .bind(at)
558 .execute(&mut *conn)
559 .await
560 .map_err(|e| db("register dispatch node", e))?;
561 }
562 KernelCommand::TransitionDispatchNode {
563 dispatch_node_id,
564 to,
565 ..
566 } => {
567 let done = sqlx::query(
568 "UPDATE gwk.dispatch_node SET state = $2, version = $3, \
569 updated_at = $4::timestamptz \
570 WHERE id = $1",
571 )
572 .bind(dispatch_node_id.as_str())
573 .bind(to)
574 .bind(version)
575 .bind(at)
576 .execute(&mut *conn)
577 .await
578 .map_err(|e| db("transition dispatch node", e))?;
579 require_one(done, "dispatch_node", dispatch_node_id.as_str())?;
580 }
581
582 KernelCommand::WriteOrchestratorCheckpoint { checkpoint } => {
584 let orchestrator_id = checkpoint.orchestrator_id.as_deref().ok_or_else(|| {
589 Refusal::validation("a checkpoint without an orchestrator_id has no identity")
590 })?;
591 sqlx::query(
592 "INSERT INTO gwk.orchestrator_checkpoint \
593 (orchestrator_id, seq, native_session_ref, active_goal, active_step_ref, \
594 latest_command_ref, open_attempts, leases, pending_approvals, budget_cursor, \
595 updated_at) \
596 VALUES ($1, $2::numeric, $3, $4, $5, $6, $7, $8, $9, $10, $11::timestamptz) \
597 ON CONFLICT (orchestrator_id) DO UPDATE SET \
598 seq = EXCLUDED.seq, \
599 native_session_ref = EXCLUDED.native_session_ref, \
600 active_goal = EXCLUDED.active_goal, \
601 active_step_ref = EXCLUDED.active_step_ref, \
602 latest_command_ref = EXCLUDED.latest_command_ref, \
603 open_attempts = EXCLUDED.open_attempts, \
604 leases = EXCLUDED.leases, \
605 pending_approvals = EXCLUDED.pending_approvals, \
606 budget_cursor = EXCLUDED.budget_cursor, \
607 updated_at = EXCLUDED.updated_at",
608 )
609 .bind(orchestrator_id)
610 .bind(to_numeric_text(checkpoint.seq.value()))
611 .bind(checkpoint.native_session_ref.as_deref())
612 .bind(checkpoint.active_goal.as_deref())
613 .bind(checkpoint.active_step_ref.as_deref())
614 .bind(checkpoint.latest_command_ref.as_ref().map(|c| c.as_str()))
615 .bind(json_opt(checkpoint.open_attempts.as_ref())?)
616 .bind(json_opt(checkpoint.leases.as_ref())?)
617 .bind(json_opt(checkpoint.pending_approvals.as_ref())?)
618 .bind(json_opt(checkpoint.budget_cursor.as_ref())?)
619 .bind(at)
620 .execute(&mut *conn)
621 .await
622 .map_err(|e| db("write orchestrator checkpoint", e))?;
623 }
624
625 KernelCommand::SendMessage {
627 message_id,
628 correlation_id,
629 reply_to,
630 sender,
631 recipient,
632 channel,
633 kind,
634 payload,
635 deadline,
636 } => {
637 let key = event.idempotency_key.as_ref().ok_or_else(|| {
643 Refusal::validation("a message needs the idempotency key that sent it")
644 })?;
645 sqlx::query(
646 "INSERT INTO gwk.message \
647 (id, version, state, idempotency_key, correlation_id, reply_to, sender, \
648 recipient, channel, kind, payload, deadline, created_at, updated_at) \
649 VALUES ($1, $2, 'accepted', $3, $4, $5, $6, $7, $8, $9, $10, \
650 $11::timestamptz, $12::timestamptz, $12::timestamptz)",
651 )
652 .bind(message_id.as_str())
653 .bind(version)
654 .bind(key.as_str())
655 .bind(correlation_id.as_ref().map(|c| c.as_str()))
656 .bind(reply_to.as_ref().map(|m| m.as_str()))
657 .bind(sender.as_deref())
658 .bind(recipient.as_deref())
659 .bind(channel.as_deref())
660 .bind(kind.as_deref())
661 .bind(json_opt(payload.as_ref())?)
662 .bind(deadline.as_ref().map(|d| d.as_str()))
663 .bind(at)
664 .execute(&mut *conn)
665 .await
666 .map_err(|e| db("insert message", e))?;
667 }
668 KernelCommand::TransitionMessage {
669 message_id,
670 to,
671 dead_letter_reason,
672 ..
673 } => {
674 let done = sqlx::query(
678 "UPDATE gwk.message \
679 SET state = $2, version = $3, updated_at = $4::timestamptz, \
680 dead_letter_reason = COALESCE($5, dead_letter_reason) \
681 WHERE id = $1",
682 )
683 .bind(message_id.as_str())
684 .bind(wire_str(to)?)
685 .bind(version)
686 .bind(at)
687 .bind(dead_letter_reason.as_deref())
688 .execute(&mut *conn)
689 .await
690 .map_err(|e| db("transition message", e))?;
691 require_one(done, "message", message_id.as_str())?;
692 }
693
694 KernelCommand::IssueCommand {
696 command_id,
697 kind,
698 targets,
699 actor,
700 } => {
701 sqlx::query(
702 "INSERT INTO gwk.command \
703 (id, version, state, kind, target, actor, idempotency_key, \
704 created_at, updated_at) \
705 VALUES ($1, $2, 'issued', $3, $4, $5, $6, $7::timestamptz, $7::timestamptz)",
706 )
707 .bind(command_id.as_str())
708 .bind(version)
709 .bind(kind)
710 .bind(targets.first())
714 .bind(json_opt(actor.as_ref())?)
715 .bind(event.idempotency_key.as_ref().map(|k| k.as_str()))
716 .bind(at)
717 .execute(&mut *conn)
718 .await
719 .map_err(|e| db("insert command", e))?;
720 }
721 KernelCommand::TransitionCommand { command_id, to, .. } => {
722 let done = sqlx::query(
723 "UPDATE gwk.command SET state = $2, version = $3, updated_at = $4::timestamptz \
724 WHERE id = $1",
725 )
726 .bind(command_id.as_str())
727 .bind(wire_str(to)?)
728 .bind(version)
729 .bind(at)
730 .execute(&mut *conn)
731 .await
732 .map_err(|e| db("transition command", e))?;
733 require_one(done, "command", command_id.as_str())?;
734 }
735 KernelCommand::RecordCommandOutcome {
736 command_id,
737 outcome,
738 ..
739 } => {
740 let done = sqlx::query(
747 "UPDATE gwk.command \
748 SET state = 'verification_complete', outcome = $2, version = $3, \
749 updated_at = $4::timestamptz \
750 WHERE id = $1",
751 )
752 .bind(command_id.as_str())
753 .bind(wire_str(outcome)?)
754 .bind(version)
755 .bind(at)
756 .execute(&mut *conn)
757 .await
758 .map_err(|e| db("record command outcome", e))?;
759 require_one(done, "command", command_id.as_str())?;
760 }
761
762 KernelCommand::OpenGate {
764 gate_id,
765 attempt_id,
766 phase_ref,
767 kind,
768 } => {
769 sqlx::query(
770 "INSERT INTO gwk.gate \
771 (id, version, attempt_id, phase_ref, kind, verdict, created_at, updated_at) \
772 VALUES ($1, $2, $3, $4, $5, 'pending', $6::timestamptz, $6::timestamptz)",
773 )
774 .bind(gate_id.as_str())
775 .bind(version)
776 .bind(attempt_id.as_ref().map(|a| a.as_str()))
777 .bind(phase_ref.as_deref())
778 .bind(kind.as_deref())
779 .bind(at)
780 .execute(&mut *conn)
781 .await
782 .map_err(|e| db("insert gate", e))?;
783 }
784 KernelCommand::DecideGate {
785 gate_id,
786 verdict,
787 evidence_ref,
788 ..
789 } => {
790 let done = sqlx::query(
791 "UPDATE gwk.gate \
792 SET verdict = $2, version = $3, updated_at = $4::timestamptz, \
793 evidence_ref = COALESCE($5, evidence_ref) \
794 WHERE id = $1",
795 )
796 .bind(gate_id.as_str())
797 .bind(wire_str(verdict)?)
798 .bind(version)
799 .bind(at)
800 .bind(evidence_ref.as_deref())
801 .execute(&mut *conn)
802 .await
803 .map_err(|e| db("decide gate", e))?;
804 require_one(done, "gate", gate_id.as_str())?;
805 }
806
807 KernelCommand::RecordEvidence {
809 evidence_id,
810 kind,
811 r#ref,
812 digest,
813 byte_size,
814 } => {
815 sqlx::query(
820 "INSERT INTO gwk.evidence (id, kind, ref, digest, byte_size, created_at) \
821 VALUES ($1, $2, $3, $4, $5::numeric, $6::timestamptz)",
822 )
823 .bind(evidence_id.as_str())
824 .bind(kind)
825 .bind(r#ref)
826 .bind(digest.as_deref())
827 .bind(byte_size.map(|b| to_numeric_text(b.value())))
828 .bind(at)
829 .execute(&mut *conn)
830 .await
831 .map_err(|e| db("insert evidence", e))?;
832 }
833
834 KernelCommand::GrantAuthority {
836 authority_grant_id,
837 grantee,
838 action_class,
839 scope,
840 expires_at,
841 } => {
842 sqlx::query(
843 "INSERT INTO gwk.authority_grant \
844 (id, grantee, action_class, scope, granted_at, expires_at) \
845 VALUES ($1, $2, $3, $4, $5::timestamptz, $6::timestamptz)",
846 )
847 .bind(authority_grant_id.as_str())
848 .bind(json_opt(Some(grantee))?)
849 .bind(action_class)
850 .bind(scope.as_deref())
851 .bind(at)
852 .bind(expires_at.as_ref().map(|t| t.as_str()))
853 .execute(&mut *conn)
854 .await
855 .map_err(|e| db("insert authority grant", e))?;
856 }
857 KernelCommand::RevokeAuthority {
858 authority_grant_id,
859 reason,
860 } => {
861 let done = sqlx::query(
865 "UPDATE gwk.authority_grant \
866 SET revoked_at = $2::timestamptz, revoke_reason = COALESCE($3, revoke_reason) \
867 WHERE id = $1",
868 )
869 .bind(authority_grant_id.as_str())
870 .bind(at)
871 .bind(reason.as_deref())
872 .execute(&mut *conn)
873 .await
874 .map_err(|e| db("revoke authority grant", e))?;
875 require_one(done, "authority_grant", authority_grant_id.as_str())?;
876 }
877
878 KernelCommand::RaiseAttention {
880 attention_item_id,
881 kind,
882 summary,
883 subject_ref,
884 raised_by,
885 } => {
886 sqlx::query(
887 "INSERT INTO gwk.attention_item \
888 (id, kind, summary, subject_ref, raised_by, raised_at) \
889 VALUES ($1, $2, $3, $4, $5, $6::timestamptz)",
890 )
891 .bind(attention_item_id.as_str())
892 .bind(kind)
893 .bind(summary)
894 .bind(subject_ref.as_deref())
895 .bind(json_opt(raised_by.as_ref())?)
896 .bind(at)
897 .execute(&mut *conn)
898 .await
899 .map_err(|e| db("insert attention item", e))?;
900 }
901 KernelCommand::ResolveAttention {
902 attention_item_id,
903 resolution,
904 } => {
905 let done = sqlx::query(
909 "UPDATE gwk.attention_item \
910 SET resolved_at = $2::timestamptz, resolution = COALESCE($3, resolution) \
911 WHERE id = $1 AND resolved_at IS NULL",
912 )
913 .bind(attention_item_id.as_str())
914 .bind(at)
915 .bind(resolution.as_deref())
916 .execute(&mut *conn)
917 .await
918 .map_err(|e| db("resolve attention item", e))?;
919 require_one(
920 done,
921 "unresolved attention_item",
922 attention_item_id.as_str(),
923 )?;
924 }
925
926 KernelCommand::ActivateKernel { .. } => {}
928
929 KernelCommand::IngestRecord {
931 kind,
932 payload,
933 payload_ref,
934 } => {
935 sqlx::query(
941 "INSERT INTO gwk.ingested_record \
942 (id, kind, payload, payload_ref, ingested_by, event_seq, ingested_at) \
943 VALUES ($1, $2, $3, $4, $5, $6::numeric, $7::timestamptz)",
944 )
945 .bind(event.aggregate_id.as_str())
946 .bind(kind.as_str())
947 .bind(payload.clone())
948 .bind(json_opt(payload_ref.as_ref())?)
949 .bind(json_opt(Some(&event.actor))?)
950 .bind(to_numeric_text(event.global_sequence.value()))
951 .bind(at)
952 .execute(&mut *conn)
953 .await
954 .map_err(|e| db("insert ingested record", e))?;
955 }
956 }
957 Ok(())
958}
959
960pub(crate) async fn write_receipt(
967 conn: &mut PgConnection,
968 receipt: &gwk_domain::entity::Receipt,
969) -> Result<(), Refusal> {
970 sqlx::query(
971 "INSERT INTO gwk.receipt \
972 (id, actor, action, subject_type, subject_id, from_state, to_state, \
973 observed_basis, ts) \
974 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::timestamptz) \
975 ON CONFLICT (id) DO NOTHING",
976 )
977 .bind(receipt.id.as_str())
978 .bind(json_opt(Some(&receipt.actor))?)
979 .bind(&receipt.action)
980 .bind(&receipt.subject_type)
981 .bind(&receipt.subject_id)
982 .bind(receipt.from.as_deref())
983 .bind(receipt.to.as_deref())
984 .bind(receipt.observed_basis.as_deref())
985 .bind(receipt.ts.as_str())
986 .execute(&mut *conn)
987 .await
988 .map_err(|e| db("write receipt", e))?;
989 Ok(())
990}
991
992pub(crate) async fn page_attention(
1001 conn: &mut PgConnection,
1002 attention_item_id: &str,
1003 summary: &str,
1004 subject_ref: &str,
1005 raised_by: &serde_json::Value,
1006 at: &str,
1007) -> Result<(), Refusal> {
1008 let kind = "authority";
1012 sqlx::query(
1013 "INSERT INTO gwk.attention_item \
1014 (id, kind, summary, subject_ref, raised_by, raised_at) \
1015 VALUES ($1, $2, $3, $4, $5, $6::timestamptz) \
1016 ON CONFLICT (kind, subject_ref) WHERE resolved_at IS NULL DO NOTHING",
1017 )
1018 .bind(attention_item_id)
1019 .bind(kind)
1020 .bind(summary)
1021 .bind(subject_ref)
1022 .bind(raised_by)
1023 .bind(at)
1024 .execute(&mut *conn)
1025 .await
1026 .map_err(|e| db("page attention", e))?;
1027 Ok(())
1028}
1029
1030pub(crate) async fn unresolved_attention(
1036 conn: &mut PgConnection,
1037 kind: &str,
1038 subject_ref: Option<&str>,
1039) -> Result<Option<String>, Refusal> {
1040 let Some(subject_ref) = subject_ref else {
1041 return Ok(None);
1044 };
1045 sqlx::query_scalar(
1046 "SELECT id FROM gwk.attention_item \
1047 WHERE kind = $1 AND subject_ref = $2 AND resolved_at IS NULL",
1048 )
1049 .bind(kind)
1050 .bind(subject_ref)
1051 .fetch_optional(conn)
1052 .await
1053 .map_err(|e| db("read unresolved attention", e))
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058 use gwk_domain::fsm::{AttemptState, LeaseMode, TaskState};
1059
1060 use super::*;
1061
1062 #[test]
1063 fn wire_strings_round_trip_through_the_contract_serializer() {
1064 assert_eq!(
1068 wire_str(&TaskState::InputRequired).as_deref(),
1069 Ok("input_required")
1070 );
1071 assert_eq!(wire_str(&AttemptState::Blocked).as_deref(), Ok("blocked"));
1072 assert_eq!(wire_str(&LeaseMode::Exclusive).as_deref(), Ok("exclusive"));
1073 assert_eq!(
1074 from_wire_str::<AttemptState>("blocked"),
1075 Ok(AttemptState::Blocked)
1076 );
1077 assert!(from_wire_str::<TaskState>("half_done").is_err());
1080 }
1081
1082 #[test]
1083 fn an_absent_optional_becomes_sql_null_not_a_json_null() {
1084 let absent: Option<&gwk_domain::entity::Budget> = None;
1085 assert_eq!(json_opt(absent), Ok(None));
1086 let present = gwk_domain::entity::Budget {
1087 max_tokens: Some(5),
1088 max_tool_calls: None,
1089 max_wall_ms: None,
1090 max_cost_micros: None,
1091 };
1092 assert_eq!(
1093 json_opt(Some(&present)),
1094 Ok(Some(serde_json::json!({ "max_tokens": 5 })))
1095 );
1096 }
1097
1098 #[test]
1099 fn the_closed_ingestion_set_is_the_same_one_in_the_ddl_and_the_contract() {
1100 let ddl = crate::contract_sql::CONTRACT_SQL;
1107 let check = ddl
1108 .split_once("CREATE TABLE gwk.ingested_record")
1109 .and_then(|(_, rest)| rest.split_once("kind IN ("))
1110 .and_then(|(_, rest)| rest.split_once("))"))
1111 .map(|(list, _)| list)
1112 .expect("the ingested_record kind CHECK");
1113 let listed: Vec<&str> = check
1114 .split(',')
1115 .map(|value| value.trim().trim_matches('\''))
1116 .collect();
1117 let contract: Vec<&str> = gwk_domain::ingestion::IngestionKind::ALL
1118 .iter()
1119 .map(|kind| kind.as_str())
1120 .collect();
1121 assert_eq!(listed, contract);
1122
1123 for forbidden in ["import", "migrate", "backfill", "legacy"] {
1127 assert!(!listed.contains(&forbidden), "the DDL admits {forbidden}");
1128 }
1129 }
1130}