Skip to main content

gwk_domain/
protocol.rs

1//! The client↔kernel wire protocol, as types.
2//!
3//! Frames are `[u32 big-endian body_length][u8 frame_kind][body]` (ADR 0001).
4//! `body_length` INCLUDES the kind byte and EXCLUDES the four-byte prefix, so a
5//! reader bounds its allocation before it parses anything. Kind `0x01` carries
6//! exactly one UTF-8 JSON control value: a [`ClientControl`] or a
7//! [`ServerControl`].
8//!
9//! **Scope of this module.** It defines the frame bounds and the control
10//! grammar. It does NOT decode frames: ADR 0001 requires a *validating*
11//! decoder for recursive duplicate keys, unknown fields, invalid UTF-8, and
12//! trailing bytes, because serde alone cannot refuse a duplicate key nested
13//! three levels down. The strict decoder that enforces those refusals lives in
14//! the kernel's wire layer and answers with the [`KernelErrorCode`] values
15//! declared here. Consequently a `serde` round trip of these types is
16//! necessary but not sufficient for wire acceptance.
17//!
18//! The same split applies to the COUNT bounds: [`MAX_CAPABILITIES`] and
19//! [`FRAME_BODY_MAX_BYTES`] are declared here and enforced by that decoder.
20//! Types whose value set is closed ([`CapabilityName`],
21//! [`ProtocolVersion`], [`BlobAddress`], [`KernelErrorCode`]) DO validate
22//! themselves at `Deserialize` time, so a value of those types is legal
23//! wherever it came from.
24
25use crate::blob::{BlobAddress, BlobDescriptor};
26use crate::entity::{
27    Attempt, AttentionItem, AuthorityGrant, Command, DispatchNode, EngineSession, Evidence, Gate,
28    IngestedRecord, Lease, Message, Receipt, Task, Worktree,
29};
30use crate::envelope::{CommandEnvelope, EventEnvelope, JsonValue};
31use crate::ids::{
32    BlobUploadId, ByteCount, CommandId, EventCount, EventId, RequestId, Seq, WriterEpoch,
33};
34use crate::inherited::OrchestratorCheckpoint;
35
36// ============================================================
37// Versions and bounds
38// ============================================================
39
40/// The protocol MAJOR version. It must match EXACTLY — an unknown major is a
41/// typed refusal, never a best-effort session (ADR 0001).
42///
43/// On the wire it is a plain JSON number: the value is small and bounded, so
44/// the decimal-string rule for 64-bit counters does not apply.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
46pub enum ProtocolVersion {
47    V1,
48}
49
50impl ProtocolVersion {
51    pub const fn as_u32(self) -> u32 {
52        match self {
53            Self::V1 => 1,
54        }
55    }
56
57    pub const fn from_u32(value: u32) -> Option<Self> {
58        match value {
59            1 => Some(Self::V1),
60            _ => None,
61        }
62    }
63}
64
65impl std::fmt::Display for ProtocolVersion {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        write!(f, "{}", self.as_u32())
68    }
69}
70
71impl serde::Serialize for ProtocolVersion {
72    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
73        s.serialize_u32(self.as_u32())
74    }
75}
76
77impl<'de> serde::Deserialize<'de> for ProtocolVersion {
78    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
79        let raw = u32::deserialize(d)?;
80        Self::from_u32(raw).ok_or_else(|| {
81            serde::de::Error::custom(format!("unsupported protocol major version {raw}"))
82        })
83    }
84}
85
86impl specta::Type for ProtocolVersion {
87    fn definition(types: &mut specta::Types) -> specta::datatype::DataType {
88        <u32 as specta::Type>::definition(types)
89    }
90}
91
92/// The protocol MINOR version this crate speaks. Minor negotiates DOWNWARD:
93/// both sides use `min(client, server)`, so a newer peer degrades instead of
94/// refusing.
95pub const PROTOCOL_MINOR: u32 = 0;
96
97/// The version of the DOMAIN CONTRACT this crate defines — the event, command,
98/// and projection shapes — which is a different axis from the wire protocol
99/// above. The genesis payload and every status response report this same
100/// number, so both read it here rather than each inventing one.
101pub const CONTRACT_VERSION: u32 = 1;
102
103/// Bytes of the big-endian length prefix, excluded from `body_length`.
104pub const FRAME_LENGTH_PREFIX_BYTES: usize = 4;
105
106/// Smallest legal `body_length` — the kind byte alone. Zero is a refusal, so a
107/// peer can never announce an empty frame.
108pub const FRAME_BODY_MIN_BYTES: u32 = 1;
109
110/// Largest legal `body_length`, kind byte included. Checked BEFORE allocation.
111pub const FRAME_BODY_MAX_BYTES: u32 = 4 * 1024 * 1024;
112
113/// The first frame must be a hello no larger than this.
114pub const HELLO_MAX_BYTES: u32 = 64 * 1024;
115
116/// A connection that has not sent its hello within this many seconds is closed.
117pub const HELLO_DEADLINE_SECS: u64 = 5;
118
119/// Bytes a connection may RECEIVE per [`CONNECTION_BUDGET_WINDOW_SECS`].
120///
121/// A RATE, not a lifetime total. What this has to bound is a peer flooding the
122/// kernel — how fast bytes arrive and how fast the kernel is asked to produce
123/// them. A lifetime cap bounds that too, but it bounds every legitimate use
124/// along with it: a subscription is meant to run for days and a blob read moves
125/// as many bytes as the blob has, so under a total both die at a threshold that
126/// says nothing about whether they were behaving.
127pub const CONNECTION_INGRESS_BYTES_PER_WINDOW: usize = 8 * 1024 * 1024;
128
129/// Bytes a connection may be SENT per [`CONNECTION_BUDGET_WINDOW_SECS`].
130pub const CONNECTION_EGRESS_BYTES_PER_WINDOW: usize = 8 * 1024 * 1024;
131
132/// The window the two allowances refill over.
133///
134/// Comfortably above every bound the contract sets elsewhere — a single frame
135/// caps at [`FRAME_BODY_MAX_BYTES`], so an allowance can never be too small for
136/// one frame to fit — and comfortably above the sustained event rate the phase
137/// targets. A peer that exceeds it is going too FAST, which is answered by
138/// making it wait, not by closing a connection that has done nothing wrong.
139pub const CONNECTION_BUDGET_WINDOW_SECS: u64 = 1;
140
141/// A subscriber blocked this long is disconnected with its last delivered
142/// cursor, so it can resume rather than restart.
143pub const SLOW_CONSUMER_TIMEOUT_SECS: u64 = 30;
144
145/// Longest a subscriber can wait for an event that is already committed.
146///
147/// Delivery is driven by a database notification, which is fast and is NOT
148/// guaranteed: a notification can be lost, and one that is lost while the log
149/// then goes quiet would leave a subscriber waiting on an append that already
150/// happened. So every subscription also re-reads from its own cursor on this
151/// interval, which turns an unbounded stall into a bounded one. The notified
152/// path still delivers in milliseconds; this is the ceiling, not the norm.
153pub const SUBSCRIPTION_POLL_SECS: u64 = 5;
154
155/// Most live subscriptions one connection may hold.
156///
157/// Each carries a cursor and a queue, so without a bound a client's memory
158/// footprint on the kernel is a function of its own behaviour. Exceeding it
159/// refuses that request with [`KernelErrorCode::Overloaded`] and leaves the
160/// connection — and every subscription already on it — alone.
161pub const MAX_SUBSCRIPTIONS_PER_CONNECTION: usize = 8;
162
163/// Most capability names either side may offer or grant.
164pub const MAX_CAPABILITIES: usize = 64;
165
166/// Longest capability name, in bytes.
167pub const CAPABILITY_NAME_MAX_BYTES: usize = 64;
168
169// Relations between the bounds, checked at COMPILE time in every build — a
170// later edit that makes the hello cap exceed the frame cap, or the blob chunk
171// size unshippable once base64 expands it (4 bytes out per 3 in), fails to
172// build rather than failing in production.
173const _: () = {
174    assert!(HELLO_MAX_BYTES < FRAME_BODY_MAX_BYTES);
175    assert!(FRAME_BODY_MIN_BYTES >= 1);
176    // A poll slower than the slow-consumer timeout would let a subscriber be
177    // disconnected for not keeping up before it had been offered a second thing
178    // to read.
179    assert!(SUBSCRIPTION_POLL_SECS < SLOW_CONSUMER_TIMEOUT_SECS);
180    assert!(crate::blob::BLOB_CHUNK_BYTES.div_ceil(3) * 4 < FRAME_BODY_MAX_BYTES as usize);
181};
182
183/// The kind byte of a frame. Not a JSON type — it never appears inside a body.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
185#[repr(u8)]
186pub enum FrameKind {
187    /// Exactly one UTF-8 JSON control value.
188    Json = 0x01,
189}
190
191/// Reserved for the terminal engine's raw bytes. It is neither advertised
192/// nor accepted in v1 — reserving the number here is what stops v1 from
193/// spending it on something else.
194pub const FRAME_KIND_RESERVED_STREAM: u8 = 0x02;
195
196impl FrameKind {
197    pub const fn as_u8(self) -> u8 {
198        self as u8
199    }
200
201    /// Decode a kind byte. The reserved engine kind and every unknown byte are the
202    /// same answer: `None`, refused before any body is parsed.
203    pub const fn from_u8(value: u8) -> Option<Self> {
204        match value {
205            0x01 => Some(Self::Json),
206            _ => None,
207        }
208    }
209}
210
211// ============================================================
212// Capability names
213// ============================================================
214
215/// Why a capability name was rejected.
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum CapabilityNameError {
218    Empty,
219    TooLong,
220    /// Not `[a-z][a-z0-9_]*` without a trailing underscore.
221    NotSnakeCase,
222}
223
224impl std::fmt::Display for CapabilityNameError {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        match self {
227            Self::Empty => f.write_str("capability name is empty"),
228            Self::TooLong => write!(
229                f,
230                "capability name exceeds {CAPABILITY_NAME_MAX_BYTES} bytes"
231            ),
232            Self::NotSnakeCase => {
233                f.write_str("capability name must be snake_case: [a-z][a-z0-9_]*, no trailing _")
234            }
235        }
236    }
237}
238
239impl std::error::Error for CapabilityNameError {}
240
241/// A negotiated capability name: an OPEN set (new capabilities are additive),
242/// but each name is strictly shaped so two spellings can never mean one thing.
243///
244/// Capability negotiation is NOT authorization — being granted a name says the
245/// peer will accept those requests, not that any particular one is permitted.
246#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
247#[serde(transparent)]
248pub struct CapabilityName(String);
249
250impl CapabilityName {
251    pub fn new(value: impl AsRef<str>) -> Result<Self, CapabilityNameError> {
252        let value = value.as_ref();
253        if value.is_empty() {
254            return Err(CapabilityNameError::Empty);
255        }
256        if value.len() > CAPABILITY_NAME_MAX_BYTES {
257            return Err(CapabilityNameError::TooLong);
258        }
259        let bytes = value.as_bytes();
260        let head_ok = bytes[0].is_ascii_lowercase();
261        let body_ok = bytes
262            .iter()
263            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'_');
264        let tail_ok = bytes[bytes.len() - 1] != b'_';
265        if !(head_ok && body_ok && tail_ok) {
266            return Err(CapabilityNameError::NotSnakeCase);
267        }
268        Ok(Self(value.to_owned()))
269    }
270
271    pub fn as_str(&self) -> &str {
272        &self.0
273    }
274}
275
276impl std::fmt::Display for CapabilityName {
277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        f.write_str(&self.0)
279    }
280}
281
282impl<'de> serde::Deserialize<'de> for CapabilityName {
283    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
284        let raw = <std::borrow::Cow<'de, str>>::deserialize(d)?;
285        Self::new(raw.as_ref()).map_err(serde::de::Error::custom)
286    }
287}
288
289// The WIRE form is a string, so the exported TS type is `string`.
290impl specta::Type for CapabilityName {
291    fn definition(types: &mut specta::Types) -> specta::datatype::DataType {
292        <String as specta::Type>::definition(types)
293    }
294}
295
296// ============================================================
297// Errors
298// ============================================================
299
300/// The stable refusal set. Errors are VALUES in this contract: a client
301/// branches on the code and never parses a message.
302#[derive(
303    Debug,
304    Clone,
305    Copy,
306    PartialEq,
307    Eq,
308    PartialOrd,
309    Ord,
310    Hash,
311    serde::Serialize,
312    serde::Deserialize,
313    specta::Type,
314)]
315#[serde(rename_all = "snake_case")]
316pub enum KernelErrorCode {
317    /// The handshake did not complete: no hello, a late hello, or a
318    /// non-hello first frame.
319    Handshake,
320    /// Major version mismatch.
321    UnsupportedVersion,
322    /// A capability was malformed, over the count/length bound, or required
323    /// and not granted.
324    Capability,
325    /// The request did not satisfy the contract's own shape rules.
326    Validation,
327    /// A duplicate JSON key at any nesting depth.
328    DuplicateKey,
329    /// `body_length` was zero, over the bound, or the kind byte unknown.
330    FrameSize,
331    /// The kernel is sealed and this request is not on the sealed allowlist.
332    Sealed,
333    /// The kernel is already active and this request is activation-only.
334    AlreadyActive,
335    NotFound,
336    /// CAS refusal; the actual version travels in the error detail.
337    StaleVersion,
338    /// The requested edge is not in the state machine's table.
339    IllegalEdge,
340    /// The actor is not permitted; the kernel raised an attention item or
341    /// denied outright.
342    Authority,
343    /// The idempotency key was reused with different canonical request content.
344    IdempotencyConflict,
345    /// A stale writer epoch or fence token was presented.
346    Fenced,
347    /// Bounded queues are full.
348    Overloaded,
349    /// A subscriber fell too far behind and was disconnected.
350    SlowConsumer,
351    /// Backend failure, opaque to the contract.
352    Storage,
353    /// An unreadable `schema_version` with no upcaster.
354    Schema,
355    /// The runtime database role holds privileges the kernel refuses to run
356    /// with, or lacks ones it needs.
357    Privilege,
358    /// A blob failed authentication, digest, or length verification.
359    BlobIntegrity,
360    /// The blob was crypto-shredded; its reads fail permanently.
361    BlobTombstoned,
362}
363
364impl KernelErrorCode {
365    /// Every code, in declaration order.
366    pub const ALL: &'static [Self] = &[
367        Self::Handshake,
368        Self::UnsupportedVersion,
369        Self::Capability,
370        Self::Validation,
371        Self::DuplicateKey,
372        Self::FrameSize,
373        Self::Sealed,
374        Self::AlreadyActive,
375        Self::NotFound,
376        Self::StaleVersion,
377        Self::IllegalEdge,
378        Self::Authority,
379        Self::IdempotencyConflict,
380        Self::Fenced,
381        Self::Overloaded,
382        Self::SlowConsumer,
383        Self::Storage,
384        Self::Schema,
385        Self::Privilege,
386        Self::BlobIntegrity,
387        Self::BlobTombstoned,
388    ];
389
390    /// The wire value — identical to what serde emits.
391    pub const fn as_str(self) -> &'static str {
392        match self {
393            Self::Handshake => "handshake",
394            Self::UnsupportedVersion => "unsupported_version",
395            Self::Capability => "capability",
396            Self::Validation => "validation",
397            Self::DuplicateKey => "duplicate_key",
398            Self::FrameSize => "frame_size",
399            Self::Sealed => "sealed",
400            Self::AlreadyActive => "already_active",
401            Self::NotFound => "not_found",
402            Self::StaleVersion => "stale_version",
403            Self::IllegalEdge => "illegal_edge",
404            Self::Authority => "authority",
405            Self::IdempotencyConflict => "idempotency_conflict",
406            Self::Fenced => "fenced",
407            Self::Overloaded => "overloaded",
408            Self::SlowConsumer => "slow_consumer",
409            Self::Storage => "storage",
410            Self::Schema => "schema",
411            Self::Privilege => "privilege",
412            Self::BlobIntegrity => "blob_integrity",
413            Self::BlobTombstoned => "blob_tombstoned",
414        }
415    }
416}
417
418impl std::fmt::Display for KernelErrorCode {
419    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420        f.write_str(self.as_str())
421    }
422}
423
424// ============================================================
425// Projections
426// ============================================================
427
428/// Which projection a get/list request names. CLOSED: the projection set is
429/// the contract's own, so an unknown name is a refusal, not an empty page.
430#[derive(
431    Debug,
432    Clone,
433    Copy,
434    PartialEq,
435    Eq,
436    PartialOrd,
437    Ord,
438    Hash,
439    serde::Serialize,
440    serde::Deserialize,
441    specta::Type,
442)]
443#[serde(rename_all = "snake_case")]
444pub enum ProjectionKind {
445    Task,
446    Attempt,
447    EngineSession,
448    Message,
449    Command,
450    Gate,
451    AuthorityGrant,
452    Receipt,
453    Evidence,
454    AttentionItem,
455    Worktree,
456    Lease,
457    DispatchNode,
458    OrchestratorCheckpoint,
459    IngestedRecord,
460}
461
462impl ProjectionKind {
463    /// Every projection, in declaration order.
464    pub const ALL: &'static [Self] = &[
465        Self::Task,
466        Self::Attempt,
467        Self::EngineSession,
468        Self::Message,
469        Self::Command,
470        Self::Gate,
471        Self::AuthorityGrant,
472        Self::Receipt,
473        Self::Evidence,
474        Self::AttentionItem,
475        Self::Worktree,
476        Self::Lease,
477        Self::DispatchNode,
478        Self::OrchestratorCheckpoint,
479        Self::IngestedRecord,
480    ];
481
482    /// The wire name — the same string `serde` writes, and the same one that
483    /// tags the matching [`ProjectionRecord`]. Held as one method so a server
484    /// looking a projection up by name and a client reading the tag off a
485    /// record can never be working from two different spellings; the test
486    /// below is what keeps that true.
487    pub const fn as_str(self) -> &'static str {
488        match self {
489            Self::Task => "task",
490            Self::Attempt => "attempt",
491            Self::EngineSession => "engine_session",
492            Self::Message => "message",
493            Self::Command => "command",
494            Self::Gate => "gate",
495            Self::AuthorityGrant => "authority_grant",
496            Self::Receipt => "receipt",
497            Self::Evidence => "evidence",
498            Self::AttentionItem => "attention_item",
499            Self::Worktree => "worktree",
500            Self::Lease => "lease",
501            Self::DispatchNode => "dispatch_node",
502            Self::OrchestratorCheckpoint => "orchestrator_checkpoint",
503            Self::IngestedRecord => "ingested_record",
504        }
505    }
506}
507
508/// One returned projection row, tagged by `projection_type`.
509///
510/// A tagged union, not a bare JSON object: ADR 0001 forbids a client inferring
511/// which record it got from which fields happen to be present.
512///
513/// Every variant holds exactly ONE field, named for its tag — so a reader
514/// never has to remember a per-variant key. `record_field_name_equals_the_tag`
515/// enforces it.
516#[allow(clippy::large_enum_variant)]
517#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
518#[serde(
519    tag = "projection_type",
520    rename_all = "snake_case",
521    deny_unknown_fields
522)]
523pub enum ProjectionRecord {
524    Task {
525        task: Task,
526    },
527    Attempt {
528        attempt: Attempt,
529    },
530    EngineSession {
531        engine_session: EngineSession,
532    },
533    Message {
534        message: Message,
535    },
536    Command {
537        command: Command,
538    },
539    Gate {
540        gate: Gate,
541    },
542    AuthorityGrant {
543        authority_grant: AuthorityGrant,
544    },
545    Receipt {
546        receipt: Receipt,
547    },
548    Evidence {
549        evidence: Evidence,
550    },
551    AttentionItem {
552        attention_item: AttentionItem,
553    },
554    Worktree {
555        worktree: Worktree,
556    },
557    Lease {
558        lease: Lease,
559    },
560    DispatchNode {
561        dispatch_node: DispatchNode,
562    },
563    OrchestratorCheckpoint {
564        orchestrator_checkpoint: OrchestratorCheckpoint,
565    },
566    IngestedRecord {
567        ingested_record: IngestedRecord,
568    },
569}
570
571impl ProjectionRecord {
572    /// Which projection this row belongs to — the value a `get`/`list` request
573    /// must have named.
574    pub const fn kind(&self) -> ProjectionKind {
575        match self {
576            Self::Task { .. } => ProjectionKind::Task,
577            Self::Attempt { .. } => ProjectionKind::Attempt,
578            Self::EngineSession { .. } => ProjectionKind::EngineSession,
579            Self::Message { .. } => ProjectionKind::Message,
580            Self::Command { .. } => ProjectionKind::Command,
581            Self::Gate { .. } => ProjectionKind::Gate,
582            Self::AuthorityGrant { .. } => ProjectionKind::AuthorityGrant,
583            Self::Receipt { .. } => ProjectionKind::Receipt,
584            Self::Evidence { .. } => ProjectionKind::Evidence,
585            Self::AttentionItem { .. } => ProjectionKind::AttentionItem,
586            Self::Worktree { .. } => ProjectionKind::Worktree,
587            Self::Lease { .. } => ProjectionKind::Lease,
588            Self::DispatchNode { .. } => ProjectionKind::DispatchNode,
589            Self::OrchestratorCheckpoint { .. } => ProjectionKind::OrchestratorCheckpoint,
590            Self::IngestedRecord { .. } => ProjectionKind::IngestedRecord,
591        }
592    }
593}
594
595// ============================================================
596// Requests
597// ============================================================
598
599/// What a client asks for. Tagged by `type`.
600///
601/// The four field-less requests are written `{}` rather than as unit variants,
602/// and the difference is not cosmetic: serde applies `deny_unknown_fields` to
603/// an internally tagged enum's STRUCT variants only, so as unit variants they
604/// would accept `{"type": "health", "limit": 500}` and answer as though the
605/// caller had said nothing extra. An empty struct variant serializes to the
606/// identical `{"type": "health"}` — the wire does not change, the strictness
607/// does.
608#[allow(clippy::large_enum_variant)]
609#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
610#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
611pub enum KernelRequest {
612    /// Liveness only. Served sealed and active.
613    Health {},
614    /// Full readiness detail. Served sealed and active.
615    Status {},
616    /// How far the log goes, without a subscription. Served sealed and active.
617    Watermark {},
618    /// Prove the fresh epoch: exactly one genesis event, zero business rows.
619    /// Served sealed and active.
620    VerifySealed {},
621    /// The only mutation path. Sealed kernels admit `activate_kernel` alone.
622    SubmitCommand {
623        envelope: CommandEnvelope,
624    },
625    GetProjection {
626        projection: ProjectionKind,
627        id: String,
628    },
629    ListProjection {
630        projection: ProjectionKind,
631        #[serde(default, skip_serializing_if = "Option::is_none")]
632        #[specta(optional)]
633        cursor: Option<String>,
634        #[serde(default, skip_serializing_if = "Option::is_none")]
635        #[specta(optional)]
636        limit: Option<u32>,
637    },
638    /// One page of events strictly after `cursor` (absent = from the
639    /// beginning). `limit` is CLAMPED, not refused
640    /// ([`MAX_READ_LIMIT`](crate::port::MAX_READ_LIMIT)).
641    ReadEvents {
642        #[serde(default, skip_serializing_if = "Option::is_none")]
643        #[specta(optional)]
644        cursor: Option<Seq>,
645        limit: u32,
646    },
647    /// Durable-cursor subscription. Delivery is at-least-once; a reconnect
648    /// from the last cursor recovers everything, in order.
649    SubscribeEvents {
650        #[serde(default, skip_serializing_if = "Option::is_none")]
651        #[specta(optional)]
652        cursor: Option<Seq>,
653    },
654    BlobBegin {
655        media_type: String,
656        byte_size: ByteCount,
657    },
658    /// One plaintext chunk, base64 in a bounded control frame — blob bytes
659    /// never ride an ordinary payload (ADR 0001). Chunks are
660    /// [`BLOB_CHUNK_BYTES`](crate::blob::BLOB_CHUNK_BYTES) of plaintext, whose
661    /// base64 expansion stays inside [`FRAME_BODY_MAX_BYTES`].
662    BlobChunk {
663        upload_id: BlobUploadId,
664        /// Contiguous from 0; a gap or repeat is a refusal.
665        sequence: u32,
666        data_base64: String,
667    },
668    /// Finish an upload. The kernel recomputes the plaintext digest and
669    /// refuses if it is not `address`.
670    BlobCommit {
671        upload_id: BlobUploadId,
672        address: BlobAddress,
673    },
674    BlobAbort {
675        upload_id: BlobUploadId,
676    },
677    BlobRead {
678        address: BlobAddress,
679        offset: ByteCount,
680        length: ByteCount,
681    },
682    BlobStat {
683        address: BlobAddress,
684    },
685}
686
687// ============================================================
688// Results
689// ============================================================
690
691/// What one request answered with, tagged by `type`. [`KernelResult::Error`]
692/// is an ordinary variant: a refusal is a value, not an out-of-band condition.
693#[allow(clippy::large_enum_variant)]
694#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
695#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
696pub enum KernelResult {
697    Health {
698        ready: bool,
699        sealed: bool,
700    },
701    Status {
702        sealed: bool,
703        #[serde(default, skip_serializing_if = "Option::is_none")]
704        #[specta(optional)]
705        watermark: Option<Seq>,
706        /// The durable writer epoch this process booted into.
707        writer_epoch: WriterEpoch,
708        /// Always [`CONTRACT_VERSION`] — the same number the genesis payload
709        /// carries, so the two can be compared without a translation table.
710        contract_version: u32,
711        /// The full 40-character public revision this binary was built from.
712        public_revision: String,
713    },
714    Watermark {
715        /// Absent means an empty log — never `0`, which is a real sequence.
716        #[serde(default, skip_serializing_if = "Option::is_none")]
717        #[specta(optional)]
718        watermark: Option<Seq>,
719    },
720    /// The fresh-epoch proof. `genesis_watermark` is DATABASE-ASSIGNED and is
721    /// never assumed to be `1`.
722    SealedVerification {
723        sealed: bool,
724        genesis_event_id: EventId,
725        genesis_watermark: Seq,
726        /// Exactly `1` in a fresh sealed epoch — a COUNT, unrelated to the
727        /// sequence above.
728        event_count: EventCount,
729    },
730    /// The command was applied. `events` are the appended envelopes with their
731    /// assigned sequences; an idempotent replay answers with the original ones.
732    CommandApplied {
733        command_id: CommandId,
734        events: Vec<EventEnvelope>,
735        watermark: Seq,
736    },
737    Projection {
738        record: ProjectionRecord,
739    },
740    ProjectionPage {
741        records: Vec<ProjectionRecord>,
742        #[serde(default, skip_serializing_if = "Option::is_none")]
743        #[specta(optional)]
744        next_cursor: Option<String>,
745    },
746    Events {
747        events: Vec<EventEnvelope>,
748        /// The last delivered sequence; absent when the page was empty.
749        #[serde(default, skip_serializing_if = "Option::is_none")]
750        #[specta(optional)]
751        cursor: Option<Seq>,
752        #[serde(default, skip_serializing_if = "Option::is_none")]
753        #[specta(optional)]
754        watermark: Option<Seq>,
755    },
756    /// The subscription is live; batches follow as [`ServerControl::EventBatch`].
757    Subscribed {
758        #[serde(default, skip_serializing_if = "Option::is_none")]
759        #[specta(optional)]
760        cursor: Option<Seq>,
761    },
762    BlobBegun {
763        upload_id: BlobUploadId,
764    },
765    BlobChunkAccepted {
766        upload_id: BlobUploadId,
767        sequence: u32,
768    },
769    BlobCommitted {
770        descriptor: BlobDescriptor,
771        /// True when an identical digest, size, AND media type already
772        /// existed, so nothing new was written (ADR 0003).
773        deduplicated: bool,
774    },
775    BlobAborted {
776        upload_id: BlobUploadId,
777    },
778    BlobBytes {
779        address: BlobAddress,
780        offset: ByteCount,
781        data_base64: String,
782    },
783    BlobStat {
784        descriptor: BlobDescriptor,
785    },
786    Error {
787        code: KernelErrorCode,
788        message: String,
789        /// Machine-readable specifics the code alone cannot carry — the actual
790        /// version behind a `stale_version`, the offending field behind a
791        /// `validation`. Absent when the code says everything.
792        #[serde(default, skip_serializing_if = "Option::is_none")]
793        #[specta(optional, type = Option<JsonValue>)]
794        detail: Option<serde_json::Value>,
795    },
796}
797
798// ============================================================
799// Control frames
800// ============================================================
801
802/// Everything a client may send on kind `0x01`.
803#[allow(clippy::large_enum_variant)]
804#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
805#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
806pub enum ClientControl {
807    /// The mandatory first frame. Nothing else is decoded before it succeeds.
808    Hello {
809        protocol_major: ProtocolVersion,
810        protocol_minor: u32,
811        /// At most [`MAX_CAPABILITIES`] names the client would like.
812        capabilities: Vec<CapabilityName>,
813        /// Free-form client label, for logs only. Never an identity: the UDS
814        /// peer credential is the only authentication (ADR 0002).
815        #[serde(default, skip_serializing_if = "Option::is_none")]
816        #[specta(optional)]
817        client: Option<String>,
818    },
819    Request {
820        request_id: RequestId,
821        request: KernelRequest,
822    },
823}
824
825/// Everything the kernel may send on kind `0x01`.
826#[allow(clippy::large_enum_variant)]
827#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
828#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
829pub enum ServerControl {
830    HelloAck {
831        protocol_major: ProtocolVersion,
832        /// `min(client_minor, server_minor)`.
833        protocol_minor: u32,
834        /// The INTERSECTION of what the client asked for and what this kernel
835        /// offers. A client must not assume anything absent here.
836        capabilities: Vec<CapabilityName>,
837        sealed: bool,
838        #[serde(default, skip_serializing_if = "Option::is_none")]
839        #[specta(optional)]
840        watermark: Option<Seq>,
841    },
842    HelloRefusal {
843        code: KernelErrorCode,
844        message: String,
845    },
846    Response {
847        request_id: RequestId,
848        result: KernelResult,
849    },
850    /// One batch on a live subscription. `cursor` is the last sequence in the
851    /// batch — what a reconnect resumes from.
852    EventBatch {
853        request_id: RequestId,
854        events: Vec<EventEnvelope>,
855        cursor: Seq,
856    },
857    /// A subscription ended. `last_cursor` is what the consumer actually
858    /// received, so a slow-consumer disconnect resumes without a gap.
859    StreamClosed {
860        request_id: RequestId,
861        code: KernelErrorCode,
862        #[serde(default, skip_serializing_if = "Option::is_none")]
863        #[specta(optional)]
864        last_cursor: Option<Seq>,
865    },
866}
867
868#[cfg(test)]
869mod tests {
870    use super::*;
871
872    #[test]
873    fn protocol_major_is_exact_and_unknown_majors_are_refused() {
874        assert_eq!(ProtocolVersion::V1.as_u32(), 1);
875        assert_eq!(ProtocolVersion::from_u32(1), Some(ProtocolVersion::V1));
876        assert_eq!(ProtocolVersion::from_u32(0), None);
877        assert_eq!(ProtocolVersion::from_u32(2), None);
878
879        // A plain JSON number on the wire, and an unknown major is a typed
880        // refusal at DECODE time — not a best-effort session.
881        assert_eq!(
882            serde_json::to_value(ProtocolVersion::V1).expect("serialize"),
883            serde_json::json!(1)
884        );
885        assert!(serde_json::from_value::<ProtocolVersion>(serde_json::json!(2)).is_err());
886    }
887
888    #[test]
889    fn frame_bounds_match_the_accepted_codec() {
890        assert_eq!(FRAME_LENGTH_PREFIX_BYTES, 4);
891        assert_eq!(FRAME_BODY_MIN_BYTES, 1);
892        assert_eq!(FRAME_BODY_MAX_BYTES, 4_194_304);
893        assert_eq!(HELLO_MAX_BYTES, 65_536);
894        assert_eq!(CONNECTION_INGRESS_BYTES_PER_WINDOW, 8 * 1024 * 1024);
895        assert_eq!(CONNECTION_EGRESS_BYTES_PER_WINDOW, 8 * 1024 * 1024);
896        assert_eq!(CONNECTION_BUDGET_WINDOW_SECS, 1);
897        // A window allowance smaller than one frame could never pass a frame no
898        // matter how long a peer waited, which would be a deadlock rather than
899        // a limit.
900        assert!(CONNECTION_INGRESS_BYTES_PER_WINDOW > FRAME_BODY_MAX_BYTES as usize);
901        assert!(CONNECTION_EGRESS_BYTES_PER_WINDOW > FRAME_BODY_MAX_BYTES as usize);
902        assert_eq!(SLOW_CONSUMER_TIMEOUT_SECS, 30);
903        assert_eq!(SUBSCRIPTION_POLL_SECS, 5);
904        assert_eq!(MAX_SUBSCRIPTIONS_PER_CONNECTION, 8);
905        assert_eq!(HELLO_DEADLINE_SECS, 5);
906        assert_eq!(MAX_CAPABILITIES, 64);
907        // The relations between these bounds are pinned at compile time by the
908        // `const _` block beside their declarations.
909    }
910
911    #[test]
912    fn frame_kind_admits_json_only_and_reserves_the_engine_byte() {
913        assert_eq!(FrameKind::Json.as_u8(), 0x01);
914        assert_eq!(FrameKind::from_u8(0x01), Some(FrameKind::Json));
915        // Reserved for the engine stage: refused in v1 like an unknown byte.
916        assert_eq!(FrameKind::from_u8(FRAME_KIND_RESERVED_STREAM), None);
917        assert_eq!(FrameKind::from_u8(0x00), None);
918        assert_eq!(FrameKind::from_u8(0xFF), None);
919    }
920
921    #[test]
922    fn capability_names_are_strict_snake_case() {
923        assert!(CapabilityName::new("event_subscribe").is_ok());
924        assert!(CapabilityName::new("blob").is_ok());
925        assert!(CapabilityName::new("v2_read").is_ok());
926        for bad in [
927            "",
928            "EventSubscribe",
929            "event-subscribe",
930            "event subscribe",
931            "_leading",
932            "trailing_",
933            "1numeric",
934            "event.subscribe",
935            "événement",
936        ] {
937            assert!(CapabilityName::new(bad).is_err(), "accepted {bad}");
938        }
939        assert!(CapabilityName::new("a".repeat(CAPABILITY_NAME_MAX_BYTES)).is_ok());
940        assert!(CapabilityName::new("a".repeat(CAPABILITY_NAME_MAX_BYTES + 1)).is_err());
941        // Validation binds the wire path too, not just construction.
942        assert!(serde_json::from_str::<CapabilityName>("\"EventSubscribe\"").is_err());
943        let ok: CapabilityName = serde_json::from_str("\"event_subscribe\"").expect("decode");
944        assert_eq!(ok.as_str(), "event_subscribe");
945    }
946
947    #[test]
948    fn error_codes_are_unique_snake_case_and_agree_with_serde() {
949        for code in KernelErrorCode::ALL {
950            assert_eq!(
951                serde_json::to_value(code).expect("serialize"),
952                serde_json::json!(code.as_str()),
953                "as_str() disagrees with serde for {code:?}"
954            );
955            assert!(
956                code.as_str()
957                    .bytes()
958                    .all(|b| b.is_ascii_lowercase() || b == b'_'),
959                "{code:?} is not snake_case"
960            );
961        }
962        let mut wire: Vec<&str> = KernelErrorCode::ALL.iter().map(|c| c.as_str()).collect();
963        let total = wire.len();
964        wire.sort_unstable();
965        wire.dedup();
966        assert_eq!(wire.len(), total, "two error codes share a wire value");
967        assert_eq!(total, 21);
968        assert_eq!(
969            KernelErrorCode::IdempotencyConflict.as_str(),
970            "idempotency_conflict"
971        );
972    }
973
974    #[test]
975    fn record_field_name_equals_the_tag() {
976        // One field per variant, named for the tag: a client that read the tag
977        // already knows the key, and no variant can quietly grow a second one.
978        for record in sample_records() {
979            let json = serde_json::to_value(&record).expect("serialize");
980            let object = json.as_object().expect("record is an object");
981            let tag = object["projection_type"].as_str().expect("tagged");
982            let fields: Vec<&str> = object
983                .keys()
984                .map(String::as_str)
985                .filter(|k| *k != "projection_type")
986                .collect();
987            assert_eq!(fields, [tag], "projection {tag} has fields {fields:?}");
988        }
989    }
990
991    #[test]
992    fn every_projection_kind_has_exactly_one_record_variant() {
993        // The get/list request names a ProjectionKind and the answer carries a
994        // ProjectionRecord; a kind with no record is a request that can never
995        // be answered.
996        let records = sample_records();
997        assert_eq!(records.len(), ProjectionKind::ALL.len());
998        let kinds: Vec<ProjectionKind> = records.iter().map(ProjectionRecord::kind).collect();
999        assert_eq!(kinds, ProjectionKind::ALL.to_vec());
1000        for record in &records {
1001            let json = serde_json::to_value(record).expect("serialize");
1002            let tag = json["projection_type"].as_str().expect("tagged");
1003            let kind_wire = serde_json::to_value(record.kind()).expect("serialize kind");
1004            assert_eq!(serde_json::json!(tag), kind_wire);
1005            // And the hand-written name matches both. A server looks a
1006            // projection up by `as_str` while a client reads the tag; one
1007            // spelling drifting from the other would serve the wrong table.
1008            assert_eq!(record.kind().as_str(), tag);
1009            let back: ProjectionRecord = serde_json::from_value(json).expect("deserialize");
1010            assert_eq!(&back, record);
1011        }
1012    }
1013
1014    #[test]
1015    fn control_frames_round_trip_with_their_type_tags() {
1016        let hello = ClientControl::Hello {
1017            protocol_major: ProtocolVersion::V1,
1018            protocol_minor: PROTOCOL_MINOR,
1019            capabilities: vec![
1020                CapabilityName::new("event_subscribe").expect("valid"),
1021                CapabilityName::new("blob").expect("valid"),
1022            ],
1023            client: None,
1024        };
1025        let json = serde_json::to_value(&hello).expect("serialize");
1026        assert_eq!(json["type"], "hello");
1027        assert_eq!(json["protocol_major"], 1);
1028        assert!(json.get("client").is_none(), "absent optional was emitted");
1029        assert_eq!(
1030            serde_json::from_value::<ClientControl>(json).expect("deserialize"),
1031            hello
1032        );
1033
1034        let watermark = ServerControl::Response {
1035            request_id: RequestId::new("req-1"),
1036            result: KernelResult::Watermark {
1037                watermark: Some(Seq::new(9_007_199_254_740_993)),
1038            },
1039        };
1040        let json = serde_json::to_value(&watermark).expect("serialize");
1041        assert_eq!(json["type"], "response");
1042        assert_eq!(json["result"]["type"], "watermark");
1043        // > 2^53 survives because the counter is a decimal STRING.
1044        assert_eq!(json["result"]["watermark"], "9007199254740993");
1045        assert_eq!(
1046            serde_json::from_value::<ServerControl>(json).expect("deserialize"),
1047            watermark
1048        );
1049
1050        // An empty log answers with an ABSENT watermark, never 0 — which is a
1051        // real sequence a store could have assigned.
1052        let empty =
1053            serde_json::to_value(KernelResult::Watermark { watermark: None }).expect("serialize");
1054        assert_eq!(empty, serde_json::json!({ "type": "watermark" }));
1055    }
1056
1057    #[test]
1058    fn a_refusal_is_a_value_on_the_ordinary_response_path() {
1059        let refused = ServerControl::Response {
1060            request_id: RequestId::new("req-2"),
1061            result: KernelResult::Error {
1062                code: KernelErrorCode::StaleVersion,
1063                message: "version conflict".into(),
1064                detail: Some(serde_json::json!({ "actual": 7, "expected": 6 })),
1065            },
1066        };
1067        let json = serde_json::to_value(&refused).expect("serialize");
1068        assert_eq!(json["result"]["code"], "stale_version");
1069        assert_eq!(json["result"]["detail"]["actual"], 7);
1070        assert_eq!(
1071            serde_json::from_value::<ServerControl>(json).expect("deserialize"),
1072            refused
1073        );
1074    }
1075
1076    #[test]
1077    fn unit_requests_carry_only_their_tag() {
1078        for (request, tag) in [
1079            (KernelRequest::Health {}, "health"),
1080            (KernelRequest::Status {}, "status"),
1081            (KernelRequest::Watermark {}, "watermark"),
1082            (KernelRequest::VerifySealed {}, "verify_sealed"),
1083        ] {
1084            let json = serde_json::to_value(&request).expect("serialize");
1085            assert_eq!(json, serde_json::json!({ "type": tag }));
1086            assert_eq!(
1087                serde_json::from_value::<KernelRequest>(json).expect("deserialize"),
1088                request
1089            );
1090        }
1091    }
1092
1093    /// One record per [`ProjectionKind`], in the same order.
1094    fn sample_records() -> Vec<ProjectionRecord> {
1095        use crate::envelope::Actor;
1096        use crate::fsm::{
1097            AttemptState, CommandState, GateVerdict, LeaseMode, LeaseState, MessageState, TaskState,
1098        };
1099        use crate::ids::{
1100            AttemptId, AttentionItemId, AuthorityGrantId, DispatchNodeId, EngineId,
1101            EngineSessionId, EvidenceId, GateId, IdempotencyKey, IngestedRecordId, LeaseId,
1102            MessageId, ReceiptId, TaskId, Timestamp, WorktreeId,
1103        };
1104
1105        let ts = || Timestamp::new("2026-07-28T00:00:00Z");
1106        let actor = || Actor {
1107            kind: "kernel".into(),
1108            id: None,
1109        };
1110        vec![
1111            ProjectionRecord::Task {
1112                task: Task {
1113                    id: TaskId::new("task-1"),
1114                    version: 1,
1115                    state: TaskState::Submitted,
1116                    kind: None,
1117                    title: None,
1118                    spec_ref: None,
1119                    project: None,
1120                    priority: None,
1121                    tracker_ref: None,
1122                    created_at: ts(),
1123                    updated_at: ts(),
1124                },
1125            },
1126            ProjectionRecord::Attempt {
1127                attempt: Attempt {
1128                    id: AttemptId::new("att-1"),
1129                    version: 1,
1130                    state: AttemptState::Queued,
1131                    task_id: TaskId::new("task-1"),
1132                    engine: EngineId::new("engine-a"),
1133                    capability: None,
1134                    role: None,
1135                    model_lane: None,
1136                    permission_profile: None,
1137                    worktree_lease_id: None,
1138                    base_sha: None,
1139                    budget: None,
1140                    result_schema_ref: None,
1141                    provider_session_ref: None,
1142                    runtime_ref: None,
1143                    runtime_started_at: None,
1144                    exit_code: None,
1145                    provider_terminal_event: None,
1146                    result_valid: None,
1147                    evidence_manifest_ref: None,
1148                    gate_result: None,
1149                    created_at: ts(),
1150                    updated_at: ts(),
1151                },
1152            },
1153            ProjectionRecord::EngineSession {
1154                engine_session: EngineSession {
1155                    id: EngineSessionId::new("sess-1"),
1156                    attempt_id: AttemptId::new("att-1"),
1157                    engine: EngineId::new("engine-a"),
1158                    provider_session_ref: None,
1159                    started_at: ts(),
1160                    ended_at: None,
1161                },
1162            },
1163            ProjectionRecord::Message {
1164                message: Message {
1165                    id: MessageId::new("msg-1"),
1166                    version: 1,
1167                    state: MessageState::Accepted,
1168                    idempotency_key: IdempotencyKey::new("send-once"),
1169                    correlation_id: None,
1170                    reply_to: None,
1171                    sender: None,
1172                    recipient: None,
1173                    channel: None,
1174                    kind: None,
1175                    payload: None,
1176                    deadline: None,
1177                    delivery_attempts: 0,
1178                    dead_letter_reason: None,
1179                    delivery_refs: None,
1180                    created_at: ts(),
1181                    updated_at: ts(),
1182                },
1183            },
1184            ProjectionRecord::Command {
1185                command: Command {
1186                    id: CommandId::new("cmd-1"),
1187                    version: 1,
1188                    state: CommandState::Issued,
1189                    kind: "stop_attempt".into(),
1190                    target: None,
1191                    actor: None,
1192                    idempotency_key: None,
1193                    outcome: None,
1194                    created_at: ts(),
1195                    updated_at: ts(),
1196                },
1197            },
1198            ProjectionRecord::Gate {
1199                gate: Gate {
1200                    id: GateId::new("gate-1"),
1201                    version: 1,
1202                    attempt_id: None,
1203                    phase_ref: None,
1204                    kind: None,
1205                    verdict: GateVerdict::Pending,
1206                    evidence_ref: None,
1207                    created_at: ts(),
1208                    updated_at: ts(),
1209                },
1210            },
1211            ProjectionRecord::AuthorityGrant {
1212                authority_grant: AuthorityGrant {
1213                    id: AuthorityGrantId::new("grant-1"),
1214                    grantee: actor(),
1215                    action_class: "deploy".into(),
1216                    scope: None,
1217                    granted_at: ts(),
1218                    expires_at: None,
1219                    revoked_at: None,
1220                    revoke_reason: None,
1221                    receipt_id: None,
1222                },
1223            },
1224            ProjectionRecord::Receipt {
1225                receipt: Receipt {
1226                    id: ReceiptId::new("r-1"),
1227                    actor: actor(),
1228                    action: "state_flip".into(),
1229                    subject_type: "attempt".into(),
1230                    subject_id: "att-1".into(),
1231                    from: None,
1232                    to: None,
1233                    observed_basis: None,
1234                    ts: ts(),
1235                },
1236            },
1237            ProjectionRecord::Evidence {
1238                evidence: Evidence {
1239                    id: EvidenceId::new("ev-1"),
1240                    kind: "transcript".into(),
1241                    r#ref: "blob://transcript".into(),
1242                    digest: None,
1243                    byte_size: None,
1244                    created_at: ts(),
1245                },
1246            },
1247            ProjectionRecord::AttentionItem {
1248                attention_item: AttentionItem {
1249                    id: AttentionItemId::new("att-item-1"),
1250                    kind: "risk_tag".into(),
1251                    summary: "data-migration pages".into(),
1252                    subject_ref: None,
1253                    raised_by: None,
1254                    raised_at: ts(),
1255                    resolved_at: None,
1256                    resolution: None,
1257                },
1258            },
1259            ProjectionRecord::Worktree {
1260                worktree: Worktree {
1261                    id: WorktreeId::new("wt-1"),
1262                    repo: "gridwork".into(),
1263                    path: "/w/kernel".into(),
1264                    branch: "feature/kernel".into(),
1265                    base_sha: None,
1266                    lease_id: None,
1267                    dirty: false,
1268                    unpushed: false,
1269                    released_at: None,
1270                    disposition: None,
1271                    created_at: ts(),
1272                },
1273            },
1274            ProjectionRecord::Lease {
1275                lease: Lease {
1276                    id: LeaseId::new("lease-1"),
1277                    version: 1,
1278                    state: LeaseState::Held,
1279                    mode: LeaseMode::Exclusive,
1280                    holder: None,
1281                    scope: None,
1282                    repo: None,
1283                    path: None,
1284                    branch: None,
1285                    base_sha: None,
1286                    fence_token: None,
1287                    heartbeat_at: None,
1288                    expires_at: None,
1289                    dirty: false,
1290                    unpushed: false,
1291                    disposition: None,
1292                    created_at: ts(),
1293                    updated_at: ts(),
1294                },
1295            },
1296            ProjectionRecord::DispatchNode {
1297                dispatch_node: DispatchNode {
1298                    id: DispatchNodeId::new("node-1"),
1299                    version: 1,
1300                    parent_id: None,
1301                    attempt_id: None,
1302                    kind: "orchestrator".into(),
1303                    state: crate::entity::DISPATCH_NODE_INITIAL_STATE.into(),
1304                    label: None,
1305                    created_at: ts(),
1306                    updated_at: ts(),
1307                },
1308            },
1309            ProjectionRecord::OrchestratorCheckpoint {
1310                orchestrator_checkpoint: OrchestratorCheckpoint {
1311                    orchestrator_id: None,
1312                    seq: Seq::new(1),
1313                    native_session_ref: None,
1314                    active_goal: None,
1315                    active_step_ref: None,
1316                    latest_command_ref: None,
1317                    open_attempts: None,
1318                    leases: None,
1319                    pending_approvals: None,
1320                    budget_cursor: None,
1321                },
1322            },
1323            ProjectionRecord::IngestedRecord {
1324                ingested_record: IngestedRecord {
1325                    id: IngestedRecordId::new("ingest:proj-1:key-1"),
1326                    kind: crate::ingestion::IngestionKind::Memory,
1327                    payload: serde_json::json!({ "text": "recalled" }),
1328                    payload_ref: None,
1329                    ingested_by: None,
1330                    event_seq: Seq::new(7),
1331                    ingested_at: ts(),
1332                },
1333            },
1334        ]
1335    }
1336}