1use std::fmt;
23use std::fmt::Write as _;
24
25use serde::de::Deserializer;
26use serde::{Deserialize, Serialize};
27use sha2::{Digest as _, Sha256};
28
29use super::config::{ConfigDefaults, ResolvedOperationConfig};
30use super::effect::Digest;
31use super::envelope::{
32 DeliverExternalEvent, HostControl, KernelInput, OperationLifecycle, ResolveEffect,
33 StartOperation, WireEnvelope, WireRejection,
34};
35use super::fault::KernelFaultCode;
36use super::scalar::{CanonicalBytes, InputId, JS_SAFE_INTEGER_MAX, OperationId, WireU64};
37
38pub const RECORD_ERROR_MARKER: &str = "kernel record rejected";
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum RecordError {
48 NotCanonical(String),
51 ChainBroken(String),
54 DigestMismatch(String),
57}
58
59impl RecordError {
60 pub fn message(&self) -> &str {
61 match self {
62 Self::NotCanonical(message)
63 | Self::ChainBroken(message)
64 | Self::DigestMismatch(message) => message,
65 }
66 }
67
68 pub fn code(&self) -> KernelFaultCode {
80 match self {
81 Self::NotCanonical(_) => KernelFaultCode::MalformedEnvelope,
82 Self::ChainBroken(_) => KernelFaultCode::TransactionConflict,
83 Self::DigestMismatch(_) => KernelFaultCode::RecordCorrupted,
84 }
85 }
86}
87
88impl fmt::Display for RecordError {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 write!(f, "{RECORD_ERROR_MARKER}: {}", self.message())
91 }
92}
93
94impl std::error::Error for RecordError {}
95
96pub const CANONICAL_MAX_DEPTH: usize = 128;
103
104pub const DIGEST_ALGORITHM: &str = "sha256";
107
108pub fn canonical_bytes<T: Serialize + ?Sized>(value: &T) -> Result<CanonicalBytes, RecordError> {
129 let value = serde_json::to_value(value).map_err(|error| {
130 RecordError::NotCanonical(format!("value is not serialisable: {error}"))
131 })?;
132 let mut out = String::new();
133 write_canonical(&value, 1, &mut out)?;
134 Ok(CanonicalBytes::new(out.into_bytes()))
135}
136
137pub fn canonical_digest(bytes: &[u8]) -> Digest {
139 let hash = Sha256::digest(bytes);
140 let mut text = String::with_capacity(DIGEST_ALGORITHM.len() + 1 + hash.len() * 2);
141 text.push_str(DIGEST_ALGORITHM);
142 text.push(':');
143 for byte in hash {
144 write!(text, "{byte:02x}").expect("writing to a String cannot fail");
145 }
146 Digest::new(text).expect("an algorithm-prefixed hex digest is always a legal branded ref")
147}
148
149fn write_canonical(
150 value: &serde_json::Value,
151 depth: usize,
152 out: &mut String,
153) -> Result<(), RecordError> {
154 if depth > CANONICAL_MAX_DEPTH {
155 return Err(RecordError::NotCanonical(format!(
156 "value nests deeper than {CANONICAL_MAX_DEPTH}"
157 )));
158 }
159 match value {
160 serde_json::Value::Null => out.push_str("null"),
161 serde_json::Value::Bool(true) => out.push_str("true"),
162 serde_json::Value::Bool(false) => out.push_str("false"),
163 serde_json::Value::Number(number) => write_canonical_number(number, out)?,
164 serde_json::Value::String(text) => write_canonical_string(text, out),
165 serde_json::Value::Array(items) => {
166 out.push('[');
167 for (index, item) in items.iter().enumerate() {
168 if index > 0 {
169 out.push(',');
170 }
171 write_canonical(item, depth + 1, out)?;
172 }
173 out.push(']');
174 }
175 serde_json::Value::Object(map) => {
176 let mut keys: Vec<&String> = map.keys().collect();
177 keys.sort_unstable();
178 out.push('{');
179 for (index, key) in keys.into_iter().enumerate() {
180 if index > 0 {
181 out.push(',');
182 }
183 write_canonical_string(key, out);
184 out.push(':');
185 write_canonical(&map[key], depth + 1, out)?;
186 }
187 out.push('}');
188 }
189 }
190 Ok(())
191}
192
193fn write_canonical_string(text: &str, out: &mut String) {
194 let encoded = serde_json::to_string(text).expect("a Rust string is always JSON-encodable");
195 out.push_str(&encoded);
196}
197
198fn write_canonical_number(
199 number: &serde_json::Number,
200 out: &mut String,
201) -> Result<(), RecordError> {
202 const SAFE: i128 = JS_SAFE_INTEGER_MAX as i128;
203
204 let unsafe_integer = |value: i128| {
205 RecordError::NotCanonical(format!(
206 "integer {value} exceeds the cross-language exact-integer range \
207 (±{JS_SAFE_INTEGER_MAX}); logical u64 travels as a decimal string"
208 ))
209 };
210
211 if let Some(value) = number.as_u64() {
212 if i128::from(value) > SAFE {
213 return Err(unsafe_integer(i128::from(value)));
214 }
215 write!(out, "{value}").expect("writing to a String cannot fail");
216 return Ok(());
217 }
218 if let Some(value) = number.as_i64() {
219 if i128::from(value) < -SAFE {
220 return Err(unsafe_integer(i128::from(value)));
221 }
222 write!(out, "{value}").expect("writing to a String cannot fail");
223 return Ok(());
224 }
225
226 let float = number
227 .as_f64()
228 .filter(|value| value.is_finite())
229 .ok_or_else(|| RecordError::NotCanonical(format!("number {number} is not finite")))?;
230 if float.fract() == 0.0 && float.abs() <= JS_SAFE_INTEGER_MAX as f64 {
233 write!(out, "{}", float as i64).expect("writing to a String cannot fail");
234 } else {
235 let encoded = serde_json::to_string(&float).expect("a finite f64 is always JSON-encodable");
236 out.push_str(&encoded);
237 }
238 Ok(())
239}
240
241#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
258#[serde(deny_unknown_fields)]
259pub struct NormalizedInput {
260 pub operation_id: OperationId,
261 pub input_id: InputId,
262 pub observed_at_ms: WireU64,
263 pub input: NormalizedPayload,
264}
265
266#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
269#[serde(tag = "kind", rename_all = "snake_case")]
270pub enum NormalizedPayload {
271 ConfigureOperation(ResolvedConfiguration),
273 StartOperation(StartOperation),
274 ResolveEffect(ResolveEffect),
275 DeliverExternalEvent(DeliverExternalEvent),
276 HostControl(HostControl),
277}
278
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
280#[serde(deny_unknown_fields)]
281pub struct ResolvedConfiguration {
282 pub config: ResolvedOperationConfig,
283}
284
285impl NormalizedPayload {
286 pub fn is_genesis(&self) -> bool {
288 matches!(self, Self::ConfigureOperation(_))
289 }
290
291 pub fn kind(&self) -> &'static str {
292 match self {
293 Self::ConfigureOperation(_) => "configure_operation",
294 Self::StartOperation(_) => "start_operation",
295 Self::ResolveEffect(_) => "resolve_effect",
296 Self::DeliverExternalEvent(_) => "deliver_external_event",
297 Self::HostControl(_) => "host_control",
298 }
299 }
300
301 pub fn admissible_lifecycles(&self) -> &'static [OperationLifecycle] {
308 match self {
309 Self::ConfigureOperation(_) => &[OperationLifecycle::Created],
310 Self::StartOperation(_) => &[OperationLifecycle::Configured],
311 Self::ResolveEffect(_) | Self::DeliverExternalEvent(_) => {
312 &[OperationLifecycle::Running, OperationLifecycle::Suspended]
313 }
314 Self::HostControl(_) => &[
315 OperationLifecycle::Configured,
316 OperationLifecycle::Running,
317 OperationLifecycle::Suspended,
318 ],
319 }
320 }
321}
322
323impl NormalizedInput {
324 pub fn normalize(
328 envelope: &WireEnvelope,
329 defaults: &ConfigDefaults,
330 ) -> Result<Self, WireRejection> {
331 let input = match &envelope.input {
332 KernelInput::ConfigureOperation(configure) => {
333 NormalizedPayload::ConfigureOperation(ResolvedConfiguration {
334 config: configure.config.resolve(defaults)?,
335 })
336 }
337 KernelInput::StartOperation(start) => NormalizedPayload::StartOperation(start.clone()),
338 KernelInput::ResolveEffect(resolve) => {
339 NormalizedPayload::ResolveEffect(resolve.clone())
340 }
341 KernelInput::DeliverExternalEvent(event) => {
342 NormalizedPayload::DeliverExternalEvent(event.clone())
343 }
344 KernelInput::HostControl(control) => NormalizedPayload::HostControl(control.clone()),
345 };
346 Ok(Self {
347 operation_id: envelope.operation_id.clone(),
348 input_id: envelope.input_id.clone(),
349 observed_at_ms: envelope.observed_at_ms,
350 input,
351 })
352 }
353
354 pub fn is_genesis(&self) -> bool {
355 self.input.is_genesis()
356 }
357
358 pub fn resolved_config(&self) -> Option<&ResolvedOperationConfig> {
360 match &self.input {
361 NormalizedPayload::ConfigureOperation(configure) => Some(&configure.config),
362 _ => None,
363 }
364 }
365}
366
367#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
379pub struct KernelRecord {
380 operation_id: OperationId,
381 input_id: InputId,
382 step_seq: WireU64,
383 previous_record_digest: Option<Digest>,
386 canonical_input: CanonicalBytes,
389 input_digest: Digest,
390 step_digest: Digest,
392 record_digest: Digest,
393}
394
395#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct ChainAnchor {
402 pub operation_id: OperationId,
403 pub step_seq: WireU64,
404 pub record_digest: Digest,
405}
406
407#[derive(Serialize)]
409struct RecordBody<'a> {
410 operation_id: &'a OperationId,
411 input_id: &'a InputId,
412 step_seq: WireU64,
413 previous_record_digest: Option<&'a Digest>,
414 canonical_input: &'a CanonicalBytes,
415 input_digest: &'a Digest,
416 step_digest: &'a Digest,
417}
418
419impl KernelRecord {
420 pub fn chain<S: Serialize + ?Sized>(
428 previous: Option<&Self>,
429 input: &NormalizedInput,
430 planned_step: &S,
431 ) -> Result<Self, RecordError> {
432 Self::chain_after(previous.map(Self::anchor).as_ref(), input, planned_step)
433 }
434
435 pub fn anchor(&self) -> ChainAnchor {
437 ChainAnchor {
438 operation_id: self.operation_id.clone(),
439 step_seq: self.step_seq,
440 record_digest: self.record_digest.clone(),
441 }
442 }
443
444 pub fn chain_after<S: Serialize + ?Sized>(
451 previous: Option<&ChainAnchor>,
452 input: &NormalizedInput,
453 planned_step: &S,
454 ) -> Result<Self, RecordError> {
455 let (step_seq, previous_record_digest) = match previous {
456 None => {
457 if !input.is_genesis() {
458 return Err(RecordError::ChainBroken(format!(
459 "an operation's first record must be its resolved configuration, got {}",
460 input.input.kind()
461 )));
462 }
463 (WireU64::ZERO, None)
464 }
465 Some(previous) => {
466 if input.is_genesis() {
467 return Err(RecordError::ChainBroken(
468 "an operation is configured exactly once; a second configure_operation \
469 has no position in the chain"
470 .to_string(),
471 ));
472 }
473 if previous.operation_id != input.operation_id {
474 return Err(RecordError::ChainBroken(format!(
475 "input belongs to operation {}, but the chain head belongs to {}",
476 input.operation_id, previous.operation_id
477 )));
478 }
479 let next = previous.step_seq.get().checked_add(1).ok_or_else(|| {
480 RecordError::ChainBroken("step sequence overflowed u64".to_string())
481 })?;
482 (WireU64::new(next), Some(previous.record_digest.clone()))
483 }
484 };
485
486 let canonical_input = canonical_bytes(input)?;
487 let input_digest = canonical_digest(canonical_input.as_slice());
488 let step_digest = canonical_digest(canonical_bytes(planned_step)?.as_slice());
489 let record_digest = Self::body_digest(
490 &input.operation_id,
491 &input.input_id,
492 step_seq,
493 previous_record_digest.as_ref(),
494 &canonical_input,
495 &input_digest,
496 &step_digest,
497 )?;
498
499 Ok(Self {
500 operation_id: input.operation_id.clone(),
501 input_id: input.input_id.clone(),
502 step_seq,
503 previous_record_digest,
504 canonical_input,
505 input_digest,
506 step_digest,
507 record_digest,
508 })
509 }
510
511 #[allow(clippy::too_many_arguments)]
512 fn body_digest(
513 operation_id: &OperationId,
514 input_id: &InputId,
515 step_seq: WireU64,
516 previous_record_digest: Option<&Digest>,
517 canonical_input: &CanonicalBytes,
518 input_digest: &Digest,
519 step_digest: &Digest,
520 ) -> Result<Digest, RecordError> {
521 let body = RecordBody {
522 operation_id,
523 input_id,
524 step_seq,
525 previous_record_digest,
526 canonical_input,
527 input_digest,
528 step_digest,
529 };
530 Ok(canonical_digest(canonical_bytes(&body)?.as_slice()))
531 }
532
533 pub fn operation_id(&self) -> &OperationId {
536 &self.operation_id
537 }
538
539 pub fn input_id(&self) -> &InputId {
540 &self.input_id
541 }
542
543 pub fn step_seq(&self) -> WireU64 {
544 self.step_seq
545 }
546
547 pub fn previous_record_digest(&self) -> Option<&Digest> {
548 self.previous_record_digest.as_ref()
549 }
550
551 pub fn expected_head(&self) -> Option<&Digest> {
554 self.previous_record_digest.as_ref()
555 }
556
557 pub fn canonical_input(&self) -> &CanonicalBytes {
558 &self.canonical_input
559 }
560
561 pub fn input_digest(&self) -> &Digest {
562 &self.input_digest
563 }
564
565 pub fn step_digest(&self) -> &Digest {
566 &self.step_digest
567 }
568
569 pub fn record_digest(&self) -> &Digest {
570 &self.record_digest
571 }
572
573 pub fn is_genesis(&self) -> bool {
574 self.previous_record_digest.is_none()
575 }
576
577 pub fn record_bytes(&self) -> CanonicalBytes {
582 canonical_bytes(self).expect("a record contains only canonical scalars")
583 }
584
585 pub fn from_record_bytes(bytes: &[u8]) -> Result<Self, RecordError> {
587 let text = std::str::from_utf8(bytes).map_err(|error| {
588 RecordError::NotCanonical(format!("record bytes are not UTF-8: {error}"))
589 })?;
590 serde_json::from_str(text).map_err(|error| decode_error(&error.to_string()))
591 }
592
593 pub fn normalized_input(&self) -> Result<NormalizedInput, RecordError> {
595 let text = std::str::from_utf8(self.canonical_input.as_slice()).map_err(|error| {
596 RecordError::NotCanonical(format!("canonical input is not UTF-8: {error}"))
597 })?;
598 serde_json::from_str(text).map_err(|error| {
599 RecordError::NotCanonical(format!("canonical input does not decode: {error}"))
600 })
601 }
602
603 pub fn verify(&self) -> Result<(), RecordError> {
607 let input_digest = canonical_digest(self.canonical_input.as_slice());
608 if input_digest != self.input_digest {
609 return Err(RecordError::DigestMismatch(format!(
610 "record {} step {}: canonical input hashes to {input_digest}, \
611 but the record claims {}",
612 self.operation_id, self.step_seq, self.input_digest
613 )));
614 }
615 let record_digest = Self::body_digest(
616 &self.operation_id,
617 &self.input_id,
618 self.step_seq,
619 self.previous_record_digest.as_ref(),
620 &self.canonical_input,
621 &self.input_digest,
622 &self.step_digest,
623 )?;
624 if record_digest != self.record_digest {
625 return Err(RecordError::DigestMismatch(format!(
626 "record {} step {}: body hashes to {record_digest}, but the record claims {}",
627 self.operation_id, self.step_seq, self.record_digest
628 )));
629 }
630 Ok(())
631 }
632
633 pub fn verify_step<S: Serialize + ?Sized>(&self, planned_step: &S) -> Result<(), RecordError> {
635 let digest = canonical_digest(canonical_bytes(planned_step)?.as_slice());
636 if digest != self.step_digest {
637 return Err(RecordError::DigestMismatch(format!(
638 "record {} step {}: the rebuilt step hashes to {digest}, \
639 but the record froze {}",
640 self.operation_id, self.step_seq, self.step_digest
641 )));
642 }
643 Ok(())
644 }
645
646 pub fn verify_follows(&self, previous: Option<&Self>) -> Result<(), RecordError> {
648 self.verify()?;
649 match (previous, self.previous_record_digest.as_ref()) {
650 (None, None) => {
651 if self.step_seq != WireU64::ZERO {
652 return Err(RecordError::ChainBroken(format!(
653 "a genesis record is step 0, got step {}",
654 self.step_seq
655 )));
656 }
657 if self.normalized_input()?.is_genesis() {
658 Ok(())
659 } else {
660 Err(RecordError::ChainBroken(
661 "a genesis record must carry the resolved configuration".to_string(),
662 ))
663 }
664 }
665 (None, Some(digest)) => Err(RecordError::ChainBroken(format!(
666 "record {} step {} expects head {digest}, but the operation has no head",
667 self.operation_id, self.step_seq
668 ))),
669 (Some(previous), None) => Err(RecordError::ChainBroken(format!(
670 "record {} step {} claims to be a genesis, but the operation head is {}",
671 self.operation_id, self.step_seq, previous.record_digest
672 ))),
673 (Some(previous), Some(digest)) => {
674 if previous.operation_id != self.operation_id {
675 return Err(RecordError::ChainBroken(format!(
676 "record belongs to operation {}, its predecessor to {}",
677 self.operation_id, previous.operation_id
678 )));
679 }
680 if digest != &previous.record_digest {
681 return Err(RecordError::ChainBroken(format!(
682 "record {} step {} expects head {digest}, but the head is {}",
683 self.operation_id, self.step_seq, previous.record_digest
684 )));
685 }
686 if previous.step_seq.get().checked_add(1) != Some(self.step_seq.get()) {
687 return Err(RecordError::ChainBroken(format!(
688 "record {} is step {}, but its predecessor is step {}",
689 self.operation_id, self.step_seq, previous.step_seq
690 )));
691 }
692 Ok(())
693 }
694 }
695 }
696}
697
698pub type RecordPreparation<Step> = super::fault::KernelPreparation<KernelRecord, Step>;
701
702pub fn verify_record_chain(records: &[KernelRecord]) -> Result<&Digest, RecordError> {
708 let Some(genesis) = records.first() else {
709 return Err(RecordError::ChainBroken(
710 "an operation chain starts at its genesis record; this one is empty".to_string(),
711 ));
712 };
713 genesis.verify_follows(None)?;
714 for pair in records.windows(2) {
715 pair[1].verify_follows(Some(&pair[0]))?;
716 }
717 Ok(&genesis.record_digest)
718}
719
720#[derive(Deserialize)]
727#[serde(deny_unknown_fields)]
728struct RecordProjection {
729 operation_id: OperationId,
730 input_id: InputId,
731 step_seq: WireU64,
732 previous_record_digest: Option<Digest>,
733 canonical_input: CanonicalBytes,
734 input_digest: Digest,
735 step_digest: Digest,
736 record_digest: Digest,
737}
738
739fn decode_error(message: &str) -> RecordError {
740 if message.contains(RECORD_ERROR_MARKER) {
741 RecordError::DigestMismatch(message.to_string())
742 } else {
743 RecordError::NotCanonical(format!("record does not decode: {message}"))
744 }
745}
746
747impl<'de> Deserialize<'de> for KernelRecord {
748 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
749 let projection = RecordProjection::deserialize(deserializer)?;
750 let record = Self {
751 operation_id: projection.operation_id,
752 input_id: projection.input_id,
753 step_seq: projection.step_seq,
754 previous_record_digest: projection.previous_record_digest,
755 canonical_input: projection.canonical_input,
756 input_digest: projection.input_digest,
757 step_digest: projection.step_digest,
758 record_digest: projection.record_digest,
759 };
760 record
761 .verify()
762 .map_err(|error| serde::de::Error::custom(error.to_string()))?;
763 Ok(record)
764 }
765}
766
767#[cfg(test)]
768mod tests {
769 use std::collections::BTreeSet;
770 use std::fs;
771 use std::path::PathBuf;
772
773 use serde_json::{Value, json};
774
775 use super::super::*;
776
777 fn operation() -> OperationId {
782 OperationId::new("op-record-1").unwrap()
783 }
784
785 fn input_id(seq: u32) -> InputId {
786 InputId::new(format!("in-{seq}")).unwrap()
787 }
788
789 fn boot_config() -> OperationConfig {
790 OperationConfig {
791 execution_policy: Some(ExecutionPolicy {
792 max_turns: Some(12),
793 ..ExecutionPolicy::default()
794 }),
795 host_effect_support: HostEffectSupport::new([
796 EffectKindTag::CallProvider,
797 EffectKindTag::ExecuteTools,
798 ]),
799 ..OperationConfig::default()
800 }
801 }
802
803 fn envelope(seq: u32, observed_at_ms: u64, input: KernelInput) -> WireEnvelope {
804 WireEnvelope::new(
805 operation(),
806 input_id(seq),
807 WireU64::new(observed_at_ms),
808 input,
809 )
810 }
811
812 fn configure_envelope() -> WireEnvelope {
813 envelope(
814 0,
815 1_700_000_000_000,
816 KernelInput::ConfigureOperation(ConfigureOperation {
817 config: boot_config(),
818 }),
819 )
820 }
821
822 fn start_envelope() -> WireEnvelope {
823 envelope(
824 1,
825 1_700_000_000_500,
826 KernelInput::StartOperation(StartOperation {
827 entry: RootEntry::Agent(RootAgentEntry {
828 task: LogicalTask::new("write the brief"),
829 run_spec: None,
830 }),
831 initial_context: InitialContext::default(),
832 }),
833 )
834 }
835
836 fn resolve_envelope() -> WireEnvelope {
837 envelope(
838 2,
839 1_700_000_001_000,
840 KernelInput::ResolveEffect(ResolveEffect {
841 effect_id: EffectId::new("op-record-1:step:1:effect:0").unwrap(),
842 outcome: EffectOutcome::Failed(EffectFailed {
843 failure: HostEffectFailure {
844 kind: HostEffectFailureKind::ProtocolError,
845 message: "provider refused the request".to_string(),
846 retryable: Some(false),
847 },
848 }),
849 }),
850 )
851 }
852
853 fn cancel_envelope() -> WireEnvelope {
854 envelope(
855 3,
856 1_700_000_002_000,
857 KernelInput::HostControl(HostControl {
858 command: HostCommand::Cancel(CancelCommand {
859 reason: CancellationReason::User,
860 pending_call_ids: vec![],
861 }),
862 }),
863 )
864 }
865
866 fn normalize(envelope: &WireEnvelope) -> NormalizedInput {
867 NormalizedInput::normalize(envelope, &ConfigDefaults::default())
868 .expect("the sample envelope normalises")
869 }
870
871 fn step(name: &str) -> Value {
872 json!({ "planned": name, "effects": [{ "kind": "call_provider" }] })
873 }
874
875 fn genesis_record() -> KernelRecord {
876 KernelRecord::chain(None, &normalize(&configure_envelope()), &step("configure")).unwrap()
877 }
878
879 fn sample_chain() -> Vec<KernelRecord> {
881 let genesis = genesis_record();
882 let started = KernelRecord::chain(
883 Some(&genesis),
884 &normalize(&start_envelope()),
885 &step("start"),
886 )
887 .unwrap();
888 let resolved = KernelRecord::chain(
889 Some(&started),
890 &normalize(&resolve_envelope()),
891 &step("resolve"),
892 )
893 .unwrap();
894 vec![genesis, started, resolved]
895 }
896
897 fn canonical_text<T: Serialize + ?Sized>(value: &T) -> String {
898 String::from_utf8(canonical_bytes(value).unwrap().into_vec()).unwrap()
899 }
900
901 fn fixture_dir() -> PathBuf {
902 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/kernel-wire")
903 }
904
905 fn golden(name: &str, produced: &Value) -> Value {
910 let path = fixture_dir().join(name);
911 if std::env::var("BLESS_KERNEL_RECORD_FIXTURES").as_deref() == Ok("1") {
912 let mut text = serde_json::to_string_pretty(produced).unwrap();
913 text.push('\n');
914 fs::write(&path, text).unwrap_or_else(|e| panic!("cannot bless {name}: {e}"));
915 return produced.clone();
916 }
917 let raw = fs::read_to_string(&path).unwrap_or_else(|e| {
918 panic!("missing golden {name} ({e}); re-bless with BLESS_KERNEL_RECORD_FIXTURES=1")
919 });
920 serde_json::from_str(&raw).unwrap_or_else(|e| panic!("{name} is not JSON: {e}"))
921 }
922
923 #[test]
928 fn canonical_bytes_sort_keys_and_carry_no_whitespace() {
929 assert_eq!(
930 canonical_text(&json!({ "b": 1, "a": { "d": [1, 2], "c": true }, "": null })),
931 r#"{"":null,"a":{"c":true,"d":[1,2]},"b":1}"#
932 );
933 assert_eq!(canonical_text(&json!([])), "[]");
934 assert_eq!(canonical_text(&json!({})), "{}");
935 assert_eq!(
936 canonical_text(&json!("quote \" backslash \\ newline \n tab \t")),
937 r#""quote \" backslash \\ newline \n tab \t""#
938 );
939 assert_eq!(canonical_text(&json!("\u{1}")), "\"\\u0001\"");
940 }
941
942 #[test]
943 fn canonical_object_keys_sort_by_code_point_not_utf16() {
944 assert_eq!(
948 canonical_text(&json!({ "\u{10000}": 1, "\u{fffd}": 2, "z": 3 })),
949 "{\"z\":3,\"\u{fffd}\":2,\"\u{10000}\":1}"
950 );
951 }
952
953 #[test]
954 fn canonical_numbers_are_language_neutral() {
955 assert_eq!(canonical_text(&json!(0)), "0");
956 assert_eq!(canonical_text(&json!(-0.0)), "0", "-0 and 0 are one value");
957 assert_eq!(canonical_text(&json!(2.0)), "2", "2 and 2.0 are one value");
958 assert_eq!(canonical_text(&json!(-17)), "-17");
959 assert_eq!(canonical_text(&json!(0.5)), "0.5");
960 assert_eq!(
961 canonical_text(&json!(JS_SAFE_INTEGER_MAX)),
962 "9007199254740991"
963 );
964 }
965
966 #[test]
967 fn canonical_bytes_reject_what_no_host_can_read_back() {
968 let too_large = serde_json::from_str::<Value>("9007199254740992").unwrap();
969 let error = canonical_bytes(&too_large).expect_err("beyond the exact-integer range");
970 assert!(matches!(error, RecordError::NotCanonical(_)), "{error}");
971
972 let too_negative = serde_json::from_str::<Value>("-9007199254740992").unwrap();
973 assert!(canonical_bytes(&too_negative).is_err());
974
975 let mut deep = json!(0);
976 for _ in 0..(CANONICAL_MAX_DEPTH + 2) {
977 deep = Value::Array(vec![deep]);
978 }
979 assert!(canonical_bytes(&deep).is_err(), "recursion must be bounded");
980 }
981
982 #[test]
983 fn canonical_bytes_are_byte_identical_across_repeated_runs() {
984 let input = normalize(&configure_envelope());
985 let first = canonical_bytes(&input).unwrap();
986 for _ in 0..8 {
987 assert_eq!(canonical_bytes(&input).unwrap(), first);
988 }
989
990 let one = canonical_bytes(&json!({ "a": 1, "b": 2 })).unwrap();
992 let other = canonical_bytes(&json!({ "b": 2, "a": 1 })).unwrap();
993 assert_eq!(one, other);
994 }
995
996 #[test]
997 fn the_digest_is_sha256_over_exactly_the_canonical_bytes() {
998 assert_eq!(
1000 canonical_digest(b"").as_str(),
1001 "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1002 );
1003 assert_eq!(
1004 canonical_digest(b"abc").as_str(),
1005 "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
1006 );
1007 assert_eq!(
1008 canonical_digest(canonical_text(&json!({ "a": 1 })).as_bytes()).as_str(),
1009 canonical_digest(br#"{"a":1}"#).as_str()
1010 );
1011 }
1012
1013 #[test]
1018 fn genesis_binds_the_resolved_config_and_an_empty_previous_head() {
1019 let record = genesis_record();
1020
1021 assert!(record.is_genesis());
1022 assert_eq!(record.step_seq(), WireU64::ZERO);
1023 assert_eq!(record.previous_record_digest(), None);
1024 assert_eq!(
1025 record.expected_head(),
1026 None,
1027 "genesis appends against an empty head"
1028 );
1029
1030 let stored = record.normalized_input().unwrap();
1031 let resolved = stored
1032 .resolved_config()
1033 .expect("genesis stores a resolved config");
1034 assert_eq!(
1035 resolved,
1036 &boot_config().resolve(&ConfigDefaults::default()).unwrap(),
1037 "the genesis record freezes the resolved configuration, not the sparse one"
1038 );
1039 assert_eq!(resolved.execution_policy.max_turns, 12);
1040
1041 let value = serde_json::to_value(resolved).unwrap();
1044 for required in [
1045 "execution_policy",
1046 "governance_policy",
1047 "scheduler_policy",
1048 "resource_quota",
1049 "signal_policy",
1050 "context_policy",
1051 "recovery_policy",
1052 "payload_policy",
1053 "kernel_limits",
1054 "memory_policy",
1055 "feature_policy",
1056 "host_effect_support",
1057 ] {
1058 assert!(
1059 value.get(required).is_some(),
1060 "resolved config lacks {required}"
1061 );
1062 }
1063 }
1064
1065 #[test]
1066 fn the_genesis_digest_is_the_operations_identity() {
1067 let chain = sample_chain();
1068 let genesis_digest = verify_record_chain(&chain).unwrap();
1069 assert_eq!(genesis_digest, chain[0].record_digest());
1070 assert_eq!(
1071 chain[1].previous_record_digest(),
1072 Some(genesis_digest),
1073 "the first transition links straight to the genesis digest"
1074 );
1075 }
1076
1077 #[test]
1078 fn a_genesis_replay_survives_kernel_default_drift() {
1079 let mut drifted = ConfigDefaults::default();
1081 drifted.baseline.execution_policy.max_context_tokens = 999;
1082 drifted.baseline.recovery_policy.provider_recovery_attempts = 9;
1083
1084 let record = genesis_record();
1085 let replayed = record.normalized_input().unwrap();
1088 assert_eq!(
1089 replayed
1090 .resolved_config()
1091 .unwrap()
1092 .execution_policy
1093 .max_context_tokens,
1094 ConfigDefaults::default()
1095 .baseline
1096 .execution_policy
1097 .max_context_tokens
1098 );
1099 assert_ne!(
1100 drifted.baseline.execution_policy.max_context_tokens,
1101 replayed
1102 .resolved_config()
1103 .unwrap()
1104 .execution_policy
1105 .max_context_tokens,
1106 "the fixture must actually drift, or this test proves nothing"
1107 );
1108 assert_eq!(
1109 canonical_bytes(&replayed).unwrap(),
1110 *record.canonical_input()
1111 );
1112 record
1113 .verify_step(&step("configure"))
1114 .expect("the frozen step still verifies");
1115 }
1116
1117 #[test]
1122 fn each_record_links_to_its_predecessor() {
1123 let chain = sample_chain();
1124 for (index, record) in chain.iter().enumerate() {
1125 assert_eq!(record.step_seq(), WireU64::new(index as u64));
1126 assert_eq!(record.operation_id(), &operation());
1127 if index == 0 {
1128 assert!(record.previous_record_digest().is_none());
1129 } else {
1130 assert_eq!(
1131 record.previous_record_digest(),
1132 Some(chain[index - 1].record_digest())
1133 );
1134 }
1135 }
1136 verify_record_chain(&chain).unwrap();
1137 }
1138
1139 #[test]
1140 fn the_chain_refuses_a_genesis_in_the_wrong_position() {
1141 let error = KernelRecord::chain(None, &normalize(&start_envelope()), &step("start"))
1143 .expect_err("only a resolved configuration opens a chain");
1144 assert!(matches!(error, RecordError::ChainBroken(_)), "{error}");
1145
1146 let genesis = genesis_record();
1148 let error = KernelRecord::chain(
1149 Some(&genesis),
1150 &normalize(&configure_envelope()),
1151 &step("configure again"),
1152 )
1153 .expect_err("an operation is configured exactly once");
1154 assert!(matches!(error, RecordError::ChainBroken(_)), "{error}");
1155 }
1156
1157 #[test]
1158 fn a_record_cannot_chain_onto_another_operation() {
1159 let genesis = genesis_record();
1160 let mut foreign = normalize(&start_envelope());
1161 foreign.operation_id = OperationId::new("op-other").unwrap();
1162 let error = KernelRecord::chain(Some(&genesis), &foreign, &step("start"))
1163 .expect_err("operations do not share a chain");
1164 assert!(matches!(error, RecordError::ChainBroken(_)), "{error}");
1165 }
1166
1167 #[test]
1168 fn tampering_with_any_link_is_detected() {
1169 let chain = sample_chain();
1170
1171 for field in [
1173 "operation_id",
1174 "input_id",
1175 "step_seq",
1176 "input_digest",
1177 "step_digest",
1178 "previous_record_digest",
1179 ] {
1180 let mut value = serde_json::to_value(&chain[2]).unwrap();
1181 value[field] = match field {
1182 "step_seq" => json!("99"),
1183 "previous_record_digest" | "input_digest" | "step_digest" => {
1184 json!(canonical_digest(b"forged").as_str())
1185 }
1186 _ => json!("forged"),
1187 };
1188 let error = serde_json::from_value::<KernelRecord>(value)
1189 .expect_err(&format!("editing {field} must be detected"));
1190 assert!(
1191 error.to_string().contains(RECORD_ERROR_MARKER),
1192 "{field}: {error}"
1193 );
1194 }
1195
1196 let mut value = serde_json::to_value(&chain[1]).unwrap();
1198 value["canonical_input"]["data"] =
1199 serde_json::to_value(CanonicalBytes::new(b"{}".to_vec())).unwrap()["data"].clone();
1200 assert!(serde_json::from_value::<KernelRecord>(value).is_err());
1201
1202 let forged = KernelRecord::chain(
1204 Some(&chain[0]),
1205 &normalize(&cancel_envelope()),
1206 &step("forged"),
1207 )
1208 .unwrap();
1209 let mut broken = chain.clone();
1210 broken[1] = forged;
1211 let error = verify_record_chain(&broken).expect_err("link 2 no longer follows link 1");
1212 assert!(matches!(error, RecordError::ChainBroken(_)), "{error}");
1213
1214 let gapped = vec![chain[0].clone(), chain[2].clone()];
1216 assert!(verify_record_chain(&gapped).is_err());
1217
1218 assert!(verify_record_chain(&[]).is_err());
1220 }
1221
1222 #[test]
1223 fn a_record_verifies_the_step_a_rebuild_recomputes() {
1224 let chain = sample_chain();
1225 chain[1]
1226 .verify_step(&step("start"))
1227 .expect("the same step verifies");
1228 let error = chain[1]
1229 .verify_step(&step("something else"))
1230 .expect_err("a different step must not verify");
1231 assert!(matches!(error, RecordError::DigestMismatch(_)), "{error}");
1232 }
1233
1234 #[test]
1239 fn the_record_is_exactly_the_eight_declared_fields() {
1240 let value = serde_json::to_value(genesis_record()).unwrap();
1241 let keys: BTreeSet<String> = value.as_object().unwrap().keys().cloned().collect();
1242 assert_eq!(
1243 keys,
1244 BTreeSet::from([
1245 "operation_id".to_string(),
1246 "input_id".to_string(),
1247 "step_seq".to_string(),
1248 "previous_record_digest".to_string(),
1249 "canonical_input".to_string(),
1250 "input_digest".to_string(),
1251 "step_digest".to_string(),
1252 "record_digest".to_string(),
1253 ])
1254 );
1255 }
1256
1257 #[test]
1258 fn no_planned_step_survives_into_the_durable_record() {
1259 const BANNED: [&str; 8] = [
1260 "step",
1261 "planned_step",
1262 "committed_step",
1263 "actions",
1264 "effects",
1265 "rendered_context",
1266 "messages",
1267 "faults",
1268 ];
1269
1270 let record = KernelRecord::chain(
1271 None,
1272 &normalize(&configure_envelope()),
1273 &json!({
1274 "actions": [{ "kind": "call_provider" }],
1275 "rendered_context": { "messages": [{ "role": "user", "content": "x" }] },
1276 "effects": [{ "effect_id": "op-record-1:step:0:effect:0" }],
1277 }),
1278 )
1279 .unwrap();
1280
1281 let text = String::from_utf8(record.record_bytes().into_vec()).unwrap();
1282 let value: Value = serde_json::from_str(&text).unwrap();
1283 let mut keys = BTreeSet::new();
1284 collect_keys(&value, &mut keys);
1285 for banned in BANNED {
1286 assert!(
1287 !keys.contains(banned),
1288 "the record leaked the step key {banned:?}"
1289 );
1290 }
1291 assert!(!text.contains("rendered_context"));
1292 assert!(!text.contains("call_provider"));
1293 }
1294
1295 #[test]
1296 fn record_size_is_a_function_of_the_input_not_of_the_step() {
1297 let input = normalize(&resolve_envelope());
1298 let genesis = genesis_record();
1299
1300 let tiny = KernelRecord::chain(Some(&genesis), &input, &json!({})).unwrap();
1301 let huge = KernelRecord::chain(
1302 Some(&genesis),
1303 &input,
1304 &json!({ "rendered_context": "x".repeat(200_000) }),
1305 )
1306 .unwrap();
1307
1308 assert_eq!(
1309 tiny.record_bytes().len(),
1310 huge.record_bytes().len(),
1311 "a 200 KiB step must not grow the journal record by one byte"
1312 );
1313 assert_ne!(tiny.step_digest(), huge.step_digest());
1314 assert_eq!(tiny.input_digest(), huge.input_digest());
1315 }
1316
1317 fn collect_keys(value: &Value, out: &mut BTreeSet<String>) {
1318 match value {
1319 Value::Object(map) => {
1320 for (key, item) in map {
1321 out.insert(key.clone());
1322 collect_keys(item, out);
1323 }
1324 }
1325 Value::Array(items) => items.iter().for_each(|item| collect_keys(item, out)),
1326 _ => {}
1327 }
1328 }
1329
1330 #[test]
1335 fn records_round_trip_through_their_journal_bytes() {
1336 for record in sample_chain() {
1337 let bytes = record.record_bytes();
1338 let back = KernelRecord::from_record_bytes(bytes.as_slice()).unwrap();
1339 assert_eq!(back, record);
1340 assert_eq!(back.record_bytes(), bytes, "journal bytes are stable");
1341
1342 let input = record.normalized_input().unwrap();
1344 assert_eq!(canonical_bytes(&input).unwrap(), *record.canonical_input());
1345 }
1346 }
1347
1348 #[test]
1349 fn a_record_with_removed_or_unknown_fields_fails_closed() {
1350 let mut value = serde_json::to_value(genesis_record()).unwrap();
1351 value["abi_version"] = json!(1);
1352 assert!(serde_json::from_value::<KernelRecord>(value).is_err());
1353
1354 let mut value = serde_json::to_value(genesis_record()).unwrap();
1355 value["surprise"] = json!(true);
1356 assert!(
1357 serde_json::from_value::<KernelRecord>(value).is_err(),
1358 "a record with an unknown field is not a record"
1359 );
1360 }
1361
1362 #[test]
1363 fn the_record_is_the_durable_half_of_a_preparation() {
1364 let record = genesis_record();
1365 let preparation: RecordPreparation<Value> =
1366 KernelPreparation::Prepared(PreparedTransition {
1367 token: PrepareToken::new("prepare-1").unwrap(),
1368 record: record.clone(),
1369 planned_step: step("configure"),
1370 });
1371
1372 assert_eq!(preparation.record(), Some(&record));
1373 assert!(
1374 preparation.token().is_some(),
1375 "only a prepared transition commits"
1376 );
1377
1378 let text = serde_json::to_string(&preparation).unwrap();
1379 let back: RecordPreparation<Value> = serde_json::from_str(&text).unwrap();
1380 assert_eq!(back, preparation);
1381
1382 record
1384 .verify_step(preparation.step().unwrap())
1385 .expect("the preparation's step is the one the record froze");
1386 }
1387
1388 #[test]
1393 fn golden_canonical_bytes_vectors() {
1394 let vectors = vec![
1395 ("empty_object", json!({})),
1396 ("empty_array", json!([])),
1397 ("key_order", json!({ "b": 1, "A": 2, "a": 3, "": 4 })),
1398 (
1399 "code_point_key_order",
1400 json!({ "\u{10000}": 1, "\u{fffd}": 2, "z": 3 }),
1401 ),
1402 ("escapes", json!("\" \\ \n \t \u{1} \u{e9} \u{1f600}")),
1403 (
1404 "numbers",
1405 json!([0, -0.0, 2.0, -17, 0.5, 9007199254740991u64]),
1406 ),
1407 (
1408 "null_and_bools",
1409 json!({ "a": null, "b": true, "c": false }),
1410 ),
1411 (
1412 "nested",
1413 json!({ "outer": { "inner": [1, { "deep": "value" }] } }),
1414 ),
1415 (
1416 "wire_u64_is_a_string",
1417 json!({ "step_seq": "18446744073709551615" }),
1418 ),
1419 ];
1420
1421 let produced = json!({
1422 "description":
1423 "Canonical byte vectors (spec 7.1.1). `canonical` is the exact UTF-8 byte string \
1424 core produces; `digest` is SHA-256 over those bytes, hex, sha256-prefixed. \
1425 Object keys sort by Unicode code point.",
1426 "vectors": vectors
1427 .iter()
1428 .map(|(name, value)| {
1429 let canonical = canonical_text(value);
1430 json!({
1431 "name": name,
1432 "value": value,
1433 "canonical": canonical,
1434 "digest": canonical_digest(canonical.as_bytes()).as_str(),
1435 })
1436 })
1437 .collect::<Vec<_>>(),
1438 "rejected": [
1439 { "name": "integer_beyond_exact_range", "value": 9007199254740992u64 },
1440 { "name": "negative_integer_beyond_exact_range", "value": -9007199254740992i64 },
1441 ],
1442 });
1443
1444 let expected = golden("golden_record_canonical_bytes.json", &produced);
1445 assert_eq!(produced, expected, "canonical byte vectors drifted");
1446
1447 for rejected in expected["rejected"].as_array().unwrap() {
1448 assert!(
1449 canonical_bytes(&rejected["value"]).is_err(),
1450 "{} must have no canonical form",
1451 rejected["name"]
1452 );
1453 }
1454 }
1455
1456 #[test]
1457 fn golden_genesis_record() {
1458 let envelope = configure_envelope();
1459 let input = normalize(&envelope);
1460 let planned = step("configure");
1461 let record = KernelRecord::chain(None, &input, &planned).unwrap();
1462
1463 let produced = json!({
1464 "description":
1465 "Genesis record (spec 8.1, 12.1). `canonical_input` holds the resolved \
1466 configuration, `previous_record_digest` is null, and `record_digest` is the \
1467 operation's genesis_digest.",
1468 "envelope": serde_json::to_value(&envelope).unwrap(),
1469 "step": planned,
1470 "normalized_input": serde_json::to_value(&input).unwrap(),
1471 "canonical_input": canonical_text(&input),
1472 "record": serde_json::to_value(&record).unwrap(),
1473 "record_bytes": String::from_utf8(record.record_bytes().into_vec()).unwrap(),
1474 "genesis_digest": record.record_digest().as_str(),
1475 });
1476
1477 let expected = golden("golden_record_genesis.json", &produced);
1478 assert_eq!(produced, expected, "the genesis record drifted");
1479 assert_eq!(expected["record"]["previous_record_digest"], Value::Null);
1480 }
1481
1482 #[test]
1483 fn golden_transition_record() {
1484 let genesis = genesis_record();
1485 let envelope = resolve_envelope();
1486 let input = normalize(&envelope);
1487 let planned = step("resolve");
1488 let record = KernelRecord::chain(Some(&genesis), &input, &planned).unwrap();
1489
1490 let produced = json!({
1491 "description":
1492 "A non-genesis record (spec 8.1). It stores the normalised envelope plus a step \
1493 digest, never the planned step, and links to `previous_record`'s digest.",
1494 "previous_record": serde_json::to_value(&genesis).unwrap(),
1495 "envelope": serde_json::to_value(&envelope).unwrap(),
1496 "step": planned,
1497 "normalized_input": serde_json::to_value(&input).unwrap(),
1498 "canonical_input": canonical_text(&input),
1499 "record": serde_json::to_value(&record).unwrap(),
1500 "record_bytes": String::from_utf8(record.record_bytes().into_vec()).unwrap(),
1501 });
1502
1503 let expected = golden("golden_record_transition.json", &produced);
1504 assert_eq!(produced, expected, "the transition record drifted");
1505
1506 let previous: KernelRecord =
1508 serde_json::from_value(expected["previous_record"].clone()).unwrap();
1509 let decoded: KernelRecord = serde_json::from_value(expected["record"].clone()).unwrap();
1510 decoded.verify_follows(Some(&previous)).unwrap();
1511 }
1512
1513 #[test]
1514 fn golden_record_chain_of_three() {
1515 let envelopes = [configure_envelope(), start_envelope(), resolve_envelope()];
1516 let steps = [step("configure"), step("start"), step("resolve")];
1517
1518 let mut records: Vec<KernelRecord> = Vec::new();
1519 let mut links = Vec::new();
1520 for (envelope, planned) in envelopes.iter().zip(steps.iter()) {
1521 let input = normalize(envelope);
1522 let record = KernelRecord::chain(records.last(), &input, planned).unwrap();
1523 links.push(json!({
1524 "envelope": serde_json::to_value(envelope).unwrap(),
1525 "step": planned,
1526 "record": serde_json::to_value(&record).unwrap(),
1527 }));
1528 records.push(record);
1529 }
1530
1531 let produced = json!({
1532 "description":
1533 "Three-link record chain (spec 8.1, 12.1). Replaying the envelopes through \
1534 normalisation and KernelRecord::chain must reproduce every record byte for \
1535 byte; `genesis_digest` is the operation's identity and `head_digest` its CAS head.",
1536 "genesis_digest": records[0].record_digest().as_str(),
1537 "head_digest": records[2].record_digest().as_str(),
1538 "links": links,
1539 });
1540
1541 let expected = golden("golden_record_chain.json", &produced);
1542 assert_eq!(produced, expected, "the record chain drifted");
1543
1544 let decoded: Vec<KernelRecord> = expected["links"]
1545 .as_array()
1546 .unwrap()
1547 .iter()
1548 .map(|link| serde_json::from_value(link["record"].clone()).unwrap())
1549 .collect();
1550 assert_eq!(
1551 verify_record_chain(&decoded).unwrap().as_str(),
1552 expected["genesis_digest"].as_str().unwrap()
1553 );
1554 }
1555}