Skip to main content

gwk_kernel/
project.rs

1//! Event → projection rows.
2//!
3//! [`apply_event`] derives every column from the event alone: the payload is
4//! the command body that caused it, the row's `version` is the event's
5//! `aggregate_version`, and the timestamps are the event's `appended_at`.
6//! Nothing reads the clock, nothing reads ambient state, so replaying a log
7//! reproduces those rows BYTE for byte — which is what the checkpoint hash and
8//! the scratch rebuild rest on. That is also why the live command path routes
9//! through here rather than writing its own SQL: one applier means replay
10//! cannot disagree with the write that it is replaying.
11//!
12//! It is the only projection writer for thirteen of the fifteen tables, and the
13//! exception is worth stating plainly rather than leaving to be discovered by a
14//! rebuild that will not agree. [`write_receipt`] and [`page_attention`] are
15//! called by [`crate::submit`] directly, and on the PAGED path they run for a
16//! command that is REFUSED — so those rows exist with no event behind them and
17//! no replay can produce them. `gwk.receipt` and `gwk.attention_item` are
18//! therefore excluded from the checkpoint digest; see
19//! [`crate::checkpoint::derived_records`].
20
21use 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/// A refusal, already shaped as the answer the wire will send.
31///
32/// The contract makes a refusal a VALUE ([`KernelResult::Error`]), so this
33/// carries the wire code from the start instead of being translated at the
34/// boundary and losing the reason on the way.
35#[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    /// The store's refusals, given their wire codes. `VersionConflict` and
85    /// `Fenced` carry their numbers into `detail` because a caller that means
86    /// to retry needs the actual value, not just the name of the failure.
87    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    // A foreign key that does not resolve is the caller naming something that
114    // is not there — an attempt under an uncreated task, a worktree under an
115    // uncreated lease. `storage` would read as "the kernel broke" for what is
116    // ordinary bad input, so it gets the code that says which.
117    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
125/// The wire string of a contract enum, taken from the one serializer the
126/// contract already defines — a stored state cannot drift from the JSON one.
127pub(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
136/// The inverse: a stored state column back into its contract enum.
137pub(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
145/// SQL NULL for an absent optional, never a JSON `null` literal — the wire's
146/// tri-state discipline reaches the columns too.
147fn 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
154/// An update that named a row must have found exactly it.
155///
156/// Zero rows is `not_found`, not a silent no-op: on the live path it means the
157/// caller addressed something that does not exist, and on the replay path it
158/// means the log and the projection disagree — both are refusals, never a
159/// quietly skipped write.
160fn 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
170/// Write one event's projection rows into the transaction that appended it.
171pub(crate) async fn apply_event(
172    conn: &mut PgConnection,
173    event: &EventEnvelope,
174) -> Result<(), Refusal> {
175    // Borrowed, not cloned: an inline payload runs to 64 KiB and this is the
176    // hot path.
177    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    // The row's version IS the event's — that equality is what lets a client's
184    // `expected_version` mean the same thing to the CAS on the log and to the
185    // CAS on the projection row.
186    let version = i64::from(event.aggregate_version);
187    let at = event.appended_at.as_str();
188
189    match &command {
190        // ---- task ----
191        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        // ---- attempt ----
236        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            // `coalesce` because absent means "not specified" (the tri-state
311            // rule), so a later record that names fewer fields must not erase
312            // what an earlier one established.
313            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        // A round and a finding are facts about the attempt that live in the
335        // log; the row carries no ledger for them. It still advances, because
336        // its version tracks the aggregate's — letting it lag would put the
337        // projection CAS and the log CAS on different numbers.
338        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        // ---- engine session ----
353        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        // ---- lease ----
386        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        // ---- worktree ----
475        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        // ---- dispatch tree ----
537        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        // ---- orchestrator ----
583        KernelCommand::WriteOrchestratorCheckpoint { checkpoint } => {
584            // Latest-per-orchestrator, so this is an upsert rather than a
585            // ledger. The seq guard rides the UPDATE branch: a rewind — the one
586            // way recovery could resume from a superseded snapshot — is refused
587            // by the trigger even if this path ever stopped checking first.
588            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        // ---- message ----
626        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            // `gwk.message.idempotency_key` is NOT NULL, and the message body
638            // carries none — the key that names this send is the ENVELOPE's,
639            // which the event preserves. Taking it from there rather than
640            // minting one keeps the row's uniqueness the same uniqueness the
641            // log already enforced, instead of a second, weaker one.
642            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            // COALESCE, not a plain assignment: a reason is written once, by
675            // the transition that carries it, and a later edge with no reason
676            // of its own must not erase why the message died.
677            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        // ---- execution command ----
695        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            // The contract says the projection keeps the FIRST target as its
711            // primary; the full list stays in the event, which is where a
712            // multi-target stop is reconstructed from.
713            .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            // ONE statement writing both, because `outcome_iff_verification_complete`
741            // makes any other order unrepresentable: the outcome column may
742            // hold a value only while the state is `verification_complete`, and
743            // that state may exist only with one. So this command IS the
744            // terminal transition, not a follow-up to it — which is also why
745            // `TransitionCommand` refuses to name that state itself.
746            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        // ---- gate ----
763        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        // ---- evidence ----
808        KernelCommand::RecordEvidence {
809            evidence_id,
810            kind,
811            r#ref,
812            digest,
813            byte_size,
814        } => {
815            // No version column and no updated_at: a piece of evidence is a
816            // fact about something that already happened, so the row is
817            // written once and never moves. Its event still advances the
818            // aggregate version — the log's count is not the row's business.
819            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        // ---- authority ----
835        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            // `revoked_at` is set from the EVENT, not `now()`: replay must
862            // reach the same grant table it did live, and a grant's liveness is
863            // read against a command's `issued_at`.
864            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        // ---- attention ----
879        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            // Only an UNRESOLVED item resolves. Resolving twice would move the
906            // stamp and, worse, silently re-open the (kind, subject_ref) dedup
907            // slot's history — `require_one` turns that into a not-found.
908            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        // The epoch boundary is the log itself — there is no row behind it.
927        KernelCommand::ActivateKernel { .. } => {}
928
929        // ---- ingestion ----
930        KernelCommand::IngestRecord {
931            kind,
932            payload,
933            payload_ref,
934        } => {
935            // The id is the event's aggregate id, which `route_of` derived from
936            // the envelope's project and idempotency key. Re-deriving it here
937            // would need the envelope, which a replay does not have — and the
938            // event already carries the answer, which is the rule this whole
939            // module follows: every column comes off the EVENT.
940            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
960/// Write the immutable attestation every authority result owes.
961///
962/// `ON CONFLICT DO NOTHING` because a receipt is a fact, not a counter: a
963/// denied or paged command writes no event, so the idempotency pre-check —
964/// which reads the log — cannot see the retry coming, and re-attesting the
965/// identical fact must be a no-op rather than a primary-key exception.
966pub(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
992/// Raise the deduplicated attention item a page owes.
993///
994/// `ON CONFLICT DO NOTHING` against the unresolved-dedup index: a page fires
995/// once per real problem, so the second command to hit the same
996/// `(kind, subject_ref)` joins the open item instead of stacking a new one.
997/// Deliberately different from the explicit `RaiseAttention` command, which
998/// refuses a duplicate rather than silently doing nothing — there the caller
999/// named an id and expects a row under it.
1000pub(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    // One kind, fixed here rather than passed: every item this path raises is
1009    // the same thing — the kernel refused a gated command. The caller-facing
1010    // `RaiseAttention` is where an open kind belongs.
1011    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
1030/// Is an unresolved item already covering this `(kind, subject_ref)`?
1031///
1032/// The pre-check in front of the explicit `RaiseAttention` command, so a
1033/// duplicate comes back as a typed refusal naming the open item rather than as
1034/// a unique-violation raised from inside the projection.
1035pub(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        // NULL subject_ref is distinct in the dedup index, so there is nothing
1042        // to collide with and nothing to pre-check.
1043        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        // The stored `state` column and the JSON state must be the same string;
1065        // deriving both from serde is what makes that true by construction
1066        // rather than by a hand-kept table.
1067        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        // A state this contract does not know is a storage refusal, never a
1078        // best-effort guess at the nearest one.
1079        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        // `gwk.ingested_record.kind` is the one CLOSED classification column in
1101        // the schema, and the reason is that the absence of an import path
1102        // has to be a property of the DATABASE, not a convention the writing
1103        // process happens to honor. That only holds while the CHECK and
1104        // `IngestionKind` name the same twelve values, and nothing else here
1105        // compares them.
1106        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        // The door that closes, checked against the DDL rather than only
1124        // against the enum: a CHECK that admits it would let the row in even
1125        // though the contract type cannot name it.
1126        for forbidden in ["import", "migrate", "backfill", "legacy"] {
1127            assert!(!listed.contains(&forbidden), "the DDL admits {forbidden}");
1128        }
1129    }
1130}