1use super::heal::{heal_legacy_compaction_rewrite_semantics, heal_legacy_revision_strings};
7use super::sealed::ValidatedTranscriptHistory;
8use super::validate::{
9 validate_released_0810_transcript_rewrite_record, validate_transcript_history_state,
10 validate_transcript_rewrite_record,
11};
12use crate::session::{
13 TranscriptEditError, TranscriptRewriteReason, TranscriptRewriteSelection,
14 transcript_messages_digest,
15};
16use crate::session_store::SessionMessageRowPrefixAccumulator;
17use crate::time_compat::SystemTime;
18use crate::types::Message;
19use serde::{Deserialize, Deserializer, Serialize};
20use sha2::{Digest, Sha256};
21use std::collections::{BTreeMap, BTreeSet};
22use std::sync::Arc;
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27#[serde(rename_all = "snake_case", deny_unknown_fields)]
28pub struct TranscriptRewriteCommit {
29 #[serde(default, skip_serializing_if = "rewrite_generation_is_unknown")]
38 pub rewrite_generation: u64,
39 pub parent_revision: String,
46 pub revision: String,
49 pub selection: TranscriptRewriteSelection,
50 pub original_span_digest: String,
54 pub replacement_digest: String,
55 pub messages_before: usize,
56 pub messages_after: usize,
57 pub reason: TranscriptRewriteReason,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub actor: Option<String>,
60 #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
61 pub committed_at: SystemTime,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
74#[serde(rename_all = "snake_case")]
75pub enum TranscriptRewriteParentTransition {
76 ExactAppend,
77 ExactSplice,
78}
79
80fn rewrite_generation_is_unknown(generation: &u64) -> bool {
81 *generation == 0
82}
83
84#[cfg(test)]
95fn normalize_legacy_graph_rewrite_generations(
96 commits: &mut [TranscriptRewriteCommit],
97) -> Result<bool, TranscriptEditError> {
98 if commits.is_empty() {
99 return Ok(false);
100 }
101 let zero_count = commits
102 .iter()
103 .filter(|commit| commit.rewrite_generation == 0)
104 .count();
105 if zero_count == commits.len() {
106 for (index, commit) in commits.iter_mut().enumerate() {
107 commit.rewrite_generation = u64::try_from(index)
108 .ok()
109 .and_then(|index| index.checked_add(1))
110 .ok_or_else(|| {
111 TranscriptEditError::HistoryStateMalformed(
112 "transcript rewrite generation exceeds u64".to_string(),
113 )
114 })?;
115 }
116 return Ok(true);
117 }
118 if zero_count != 0 {
119 return Err(TranscriptEditError::HistoryStateMalformed(
120 "transcript rewrite generations mix 0.8.10 zero markers with current occurrence identities"
121 .to_string(),
122 ));
123 }
124 for (index, commit) in commits.iter().enumerate() {
125 let expected = u64::try_from(index)
126 .ok()
127 .and_then(|index| index.checked_add(1))
128 .ok_or_else(|| {
129 TranscriptEditError::HistoryStateMalformed(
130 "transcript rewrite generation exceeds u64".to_string(),
131 )
132 })?;
133 if commit.rewrite_generation != expected {
134 return Err(TranscriptEditError::HistoryStateMalformed(format!(
135 "transcript rewrite generation {} is not the expected contiguous occurrence {expected}",
136 commit.rewrite_generation
137 )));
138 }
139 }
140 Ok(false)
141}
142
143const TRANSCRIPT_REWRITE_PREFIX_CHAIN_DOMAIN: &[u8] =
144 b"meerkat.transcript-rewrite-prefix.chain.v1\0";
145const TRANSCRIPT_REWRITE_PREFIX_STEP_DOMAIN: &[u8] = b"meerkat.transcript-rewrite-prefix.step.v1\0";
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
161#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
162#[serde(rename_all = "snake_case")]
163pub struct TranscriptRewritePrefixAccumulator {
164 occurrence_count: u64,
165 digest: String,
166}
167
168impl<'de> Deserialize<'de> for TranscriptRewritePrefixAccumulator {
169 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
170 where
171 D: Deserializer<'de>,
172 {
173 #[derive(Deserialize)]
174 #[serde(rename_all = "snake_case", deny_unknown_fields)]
175 struct Wire {
176 occurrence_count: u64,
177 digest: String,
178 }
179 let wire = Wire::deserialize(deserializer)?;
180 let accumulator = Self {
181 occurrence_count: wire.occurrence_count,
182 digest: wire.digest,
183 };
184 if accumulator.raw_digest().is_none() {
185 return Err(serde::de::Error::custom(
186 "rewrite-prefix digest must be canonical sha256:<64 lowercase hex>",
187 ));
188 }
189 Ok(accumulator)
190 }
191}
192
193impl TranscriptRewritePrefixAccumulator {
194 #[must_use]
195 pub fn empty() -> Self {
196 let mut hasher = Sha256::new();
197 hasher.update(TRANSCRIPT_REWRITE_PREFIX_CHAIN_DOMAIN);
198 Self {
199 occurrence_count: 0,
200 digest: format!("sha256:{:x}", hasher.finalize()),
201 }
202 }
203
204 pub fn from_commits(commits: &[TranscriptRewriteCommit]) -> Result<Self, serde_json::Error> {
205 let mut accumulator = Self::empty();
206 for commit in commits {
207 accumulator = accumulator.extend(commit)?;
208 }
209 Ok(accumulator)
210 }
211
212 pub fn extend(&self, commit: &TranscriptRewriteCommit) -> Result<Self, serde_json::Error> {
213 let expected_generation = self.occurrence_count.checked_add(1).ok_or_else(|| {
214 serde_json::Error::io(std::io::Error::new(
215 std::io::ErrorKind::InvalidData,
216 "rewrite-prefix occurrence count exceeds u64",
217 ))
218 })?;
219 if commit.rewrite_generation != expected_generation {
220 return Err(serde_json::Error::io(std::io::Error::new(
221 std::io::ErrorKind::InvalidData,
222 format!(
223 "rewrite-prefix occurrence generation {} is not the expected {expected_generation}",
224 commit.rewrite_generation
225 ),
226 )));
227 }
228 let bytes = serde_json::to_vec(commit)?;
229 let previous = self.raw_digest().ok_or_else(|| {
230 serde_json::Error::io(std::io::Error::new(
231 std::io::ErrorKind::InvalidData,
232 "rewrite-prefix accumulator is not a canonical sha256 digest",
233 ))
234 })?;
235 let mut hasher = Sha256::new();
236 hasher.update(TRANSCRIPT_REWRITE_PREFIX_STEP_DOMAIN);
237 hasher.update(previous);
238 hasher.update((bytes.len() as u64).to_be_bytes());
239 hasher.update(bytes);
240 Ok(Self {
241 occurrence_count: expected_generation,
242 digest: format!("sha256:{:x}", hasher.finalize()),
243 })
244 }
245
246 #[must_use]
247 pub const fn occurrence_count(&self) -> u64 {
248 self.occurrence_count
249 }
250
251 #[must_use]
252 pub fn digest(&self) -> &str {
253 &self.digest
254 }
255
256 fn raw_digest(&self) -> Option<[u8; 32]> {
257 let encoded = self.digest.strip_prefix("sha256:")?;
258 if encoded.len() != 64 || !encoded.is_ascii() {
259 return None;
260 }
261 let bytes = encoded.as_bytes();
262 let mut decoded = [0u8; 32];
263 for (index, output) in decoded.iter_mut().enumerate() {
264 let high = hex_nibble(bytes[index * 2])?;
265 let low = hex_nibble(bytes[index * 2 + 1])?;
266 *output = (high << 4) | low;
267 }
268 Some(decoded)
269 }
270}
271
272impl Default for TranscriptRewritePrefixAccumulator {
273 fn default() -> Self {
274 Self::empty()
275 }
276}
277
278fn hex_nibble(value: u8) -> Option<u8> {
279 match value {
280 b'0'..=b'9' => Some(value - b'0'),
281 b'a'..=b'f' => Some(value - b'a' + 10),
282 _ => None,
283 }
284}
285
286pub fn transcript_rewrite_prefix_digest(
291 commits: &[TranscriptRewriteCommit],
292) -> Result<String, serde_json::Error> {
293 Ok(TranscriptRewritePrefixAccumulator::from_commits(commits)?
294 .digest()
295 .to_string())
296}
297
298pub fn extend_transcript_rewrite_prefix_accumulator(
300 mut accumulator: TranscriptRewritePrefixAccumulator,
301 commits: &[TranscriptRewriteCommit],
302) -> Result<TranscriptRewritePrefixAccumulator, serde_json::Error> {
303 for commit in commits {
304 accumulator = accumulator.extend(commit)?;
305 }
306 Ok(accumulator)
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize)]
311#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
312#[serde(rename_all = "snake_case")]
313pub struct TranscriptRevisionBody {
314 pub revision: String,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub parent_revision: Option<String>,
317 #[cfg_attr(feature = "schema", schemars(with = "Vec<serde_json::Value>"))]
318 pub messages: Vec<Message>,
319 #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
320 pub created_at: SystemTime,
321}
322
323#[derive(Deserialize)]
324#[serde(rename_all = "snake_case", deny_unknown_fields)]
325struct Released0810RevisionEntry {
326 revision: String,
327 #[serde(default)]
328 parent_revision: Option<String>,
329 created_at: SystemTime,
330 #[serde(default)]
331 messages: Option<Vec<Message>>,
332 #[serde(default)]
333 rebase: Option<Released0810RevisionRebase>,
334}
335
336#[derive(Deserialize)]
337#[serde(rename_all = "snake_case", deny_unknown_fields)]
338struct Released0810RevisionRebase {
339 base: String,
340 at: usize,
341 removed: usize,
342 #[serde(default)]
343 insert: Vec<Message>,
344}
345
346#[derive(Deserialize)]
353#[serde(rename_all = "snake_case", deny_unknown_fields)]
354struct Released0810Commit {
355 parent_revision: String,
356 revision: String,
357 selection: TranscriptRewriteSelection,
358 original_span_digest: String,
359 replacement_digest: String,
360 messages_before: usize,
361 messages_after: usize,
362 reason: TranscriptRewriteReason,
363 #[serde(default)]
364 actor: Option<String>,
365 committed_at: SystemTime,
366}
367
368#[derive(Deserialize)]
369#[serde(rename_all = "snake_case", deny_unknown_fields)]
370struct Released0810HistoryWire {
371 head: String,
372 #[serde(default)]
373 commits: Vec<Released0810Commit>,
374 revisions: Vec<Released0810RevisionEntry>,
375 digest_format: u32,
376 #[serde(default, rename = "replay_cursor")]
377 _replay_cursor: Option<serde::de::IgnoredAny>,
378}
379
380impl From<Released0810Commit> for TranscriptRewriteCommit {
381 fn from(released: Released0810Commit) -> Self {
382 Self {
383 rewrite_generation: 0,
384 parent_revision: released.parent_revision,
385 revision: released.revision,
386 selection: released.selection,
387 original_span_digest: released.original_span_digest,
388 replacement_digest: released.replacement_digest,
389 messages_before: released.messages_before,
390 messages_after: released.messages_after,
391 reason: released.reason,
392 actor: released.actor,
393 committed_at: released.committed_at,
394 }
395 }
396}
397
398fn decode_released_0810_revision_chain<E>(
399 entries: Vec<Released0810RevisionEntry>,
400) -> Result<Vec<TranscriptRevisionBody>, E>
401where
402 E: serde::de::Error,
403{
404 let mut materialized: std::collections::HashMap<String, usize> =
405 std::collections::HashMap::with_capacity(entries.len());
406 let mut bodies: Vec<TranscriptRevisionBody> = Vec::with_capacity(entries.len());
407 for entry in entries {
408 let messages = match (entry.messages, entry.rebase) {
409 (Some(messages), None) => messages,
410 (None, Some(rebase)) => {
411 let base = materialized
412 .get(&rebase.base)
413 .and_then(|index| bodies.get(*index))
414 .ok_or_else(|| {
415 E::custom(format!(
416 "released 0.8.10 revision {} rebases on missing {}",
417 entry.revision, rebase.base
418 ))
419 })?;
420 let end = rebase
421 .at
422 .checked_add(rebase.removed)
423 .filter(|end| *end <= base.messages.len())
424 .ok_or_else(|| {
425 E::custom(format!(
426 "released 0.8.10 revision {} splice exceeds base {}",
427 entry.revision, rebase.base
428 ))
429 })?;
430 let mut messages =
431 Vec::with_capacity(base.messages.len() - rebase.removed + rebase.insert.len());
432 messages.extend_from_slice(&base.messages[..rebase.at]);
433 messages.extend(rebase.insert);
434 messages.extend_from_slice(&base.messages[end..]);
435 messages
436 }
437 (Some(_), Some(_)) => {
438 return Err(E::custom(format!(
439 "released 0.8.10 revision {} carries messages and rebase",
440 entry.revision
441 )));
442 }
443 (None, None) => {
444 return Err(E::custom(format!(
445 "released 0.8.10 revision {} carries neither messages nor rebase",
446 entry.revision
447 )));
448 }
449 };
450 let position = bodies.len();
451 if materialized
452 .insert(entry.revision.clone(), position)
453 .is_some()
454 {
455 return Err(E::custom(format!(
456 "released 0.8.10 revision vector repeats deduplicated id {}",
457 entry.revision
458 )));
459 }
460 bodies.push(TranscriptRevisionBody {
461 revision: entry.revision,
462 parent_revision: entry.parent_revision,
463 messages,
464 created_at: entry.created_at,
465 });
466 }
467 Ok(bodies)
468}
469
470struct ValidatedReleased0810Wire {
471 head: String,
472 commits: Vec<TranscriptRewriteCommit>,
473 revisions: Vec<TranscriptRevisionBody>,
474}
475
476fn validate_released_0810_wire(
477 wire: Released0810HistoryWire,
478) -> Result<ValidatedReleased0810Wire, serde_json::Error> {
479 if wire.digest_format != TRANSCRIPT_DIGEST_FORMAT_RELEASED_0810 {
480 return Err(serde_json::Error::io(std::io::Error::new(
481 std::io::ErrorKind::InvalidData,
482 format!(
483 "released 0.8.10 transcript graph requires digest format {}, observed {}",
484 TRANSCRIPT_DIGEST_FORMAT_RELEASED_0810, wire.digest_format
485 ),
486 )));
487 }
488 if wire.commits.is_empty() || wire.revisions.is_empty() {
489 return Err(serde_json::Error::io(std::io::Error::new(
490 std::io::ErrorKind::InvalidData,
491 "released 0.8.10 transcript graph must carry commits and bodies",
492 )));
493 }
494 let commits = wire
495 .commits
496 .into_iter()
497 .map(TranscriptRewriteCommit::from)
498 .collect::<Vec<_>>();
499 let revisions = decode_released_0810_revision_chain::<serde_json::Error>(wire.revisions)?;
500 let bodies_by_revision = revisions
501 .iter()
502 .map(|body| (body.revision.as_str(), body))
503 .collect::<std::collections::HashMap<_, _>>();
504 if !bodies_by_revision.contains_key(wire.head.as_str()) {
505 return Err(serde_json::Error::io(std::io::Error::new(
506 std::io::ErrorKind::InvalidData,
507 format!(
508 "released 0.8.10 transcript graph omits advertised head {}",
509 wire.head
510 ),
511 )));
512 }
513 for commit in &commits {
514 let parent = bodies_by_revision
515 .get(commit.parent_revision.as_str())
516 .copied()
517 .ok_or_else(|| {
518 serde_json::Error::io(std::io::Error::new(
519 std::io::ErrorKind::InvalidData,
520 format!(
521 "released 0.8.10 graph omits parent body {}",
522 commit.parent_revision
523 ),
524 ))
525 })?;
526 let revision = bodies_by_revision
527 .get(commit.revision.as_str())
528 .copied()
529 .ok_or_else(|| {
530 serde_json::Error::io(std::io::Error::new(
531 std::io::ErrorKind::InvalidData,
532 format!(
533 "released 0.8.10 graph omits revision body {}",
534 commit.revision
535 ),
536 ))
537 })?;
538 validate_released_0810_transcript_rewrite_record(commit, parent, revision)
539 .map_err(|error| serde_json::Error::io(std::io::Error::other(error.to_string())))?;
540 }
541 Ok(ValidatedReleased0810Wire {
542 head: wire.head,
543 commits,
544 revisions,
545 })
546}
547
548pub(crate) fn validate_current_transcript_history_slice(
554 bytes: &[u8],
555) -> Result<usize, serde_json::Error> {
556 #[derive(Deserialize)]
557 struct FormatProbe {
558 #[serde(default)]
559 format: Option<String>,
560 }
561
562 match serde_json::from_slice::<FormatProbe>(bytes)?.format {
563 Some(format) if format == TRANSCRIPT_HISTORY_FORMAT_CURRENT => {
564 let state: TranscriptHistoryState = serde_json::from_slice(bytes)?;
565 Ok(state.commit_count())
566 }
567 Some(format) => Err(serde_json::Error::io(std::io::Error::new(
568 std::io::ErrorKind::InvalidData,
569 format!("unsupported transcript graph format {format}"),
570 ))),
571 None => Err(serde_json::Error::io(std::io::Error::new(
572 std::io::ErrorKind::InvalidData,
573 "bounded current-graph validation refuses released full-body history",
574 ))),
575 }
576}
577
578#[cfg(feature = "schema")]
579#[allow(dead_code)]
580#[derive(schemars::JsonSchema)]
581#[schemars(rename = "SystemTime")]
582struct SchemaSystemTime {
583 secs_since_epoch: u64,
584 nanos_since_epoch: u32,
585}
586
587pub const TRANSCRIPT_HISTORY_FORMAT_CURRENT: &str = "anchor_occurrence_edges_v1";
593
594const TRANSCRIPT_GRAPH_PREFIX_ANCHOR_DOMAIN: &[u8] = b"meerkat.transcript-history.anchor.v1\0";
595const TRANSCRIPT_GRAPH_PREFIX_EDGE_DOMAIN: &[u8] = b"meerkat.transcript-history.edge.v1\0";
596
597static TRANSCRIPT_HISTORY_FULL_BODY_MATERIALIZATIONS: std::sync::atomic::AtomicU64 =
598 std::sync::atomic::AtomicU64::new(0);
599
600#[must_use]
606pub fn transcript_history_full_body_materializations() -> u64 {
607 TRANSCRIPT_HISTORY_FULL_BODY_MATERIALIZATIONS.load(std::sync::atomic::Ordering::Relaxed)
608}
609
610#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
612#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
613#[serde(rename_all = "snake_case", deny_unknown_fields)]
614pub struct TranscriptRevisionAnchor {
615 revision: String,
616 #[cfg_attr(feature = "schema", schemars(with = "Vec<serde_json::Value>"))]
617 messages: Vec<Message>,
618 row_prefix: SessionMessageRowPrefixAccumulator,
619 #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
620 created_at: SystemTime,
621}
622
623impl TranscriptRevisionAnchor {
624 #[must_use]
625 pub fn revision(&self) -> &str {
626 &self.revision
627 }
628
629 #[must_use]
630 pub fn messages(&self) -> &[Message] {
631 &self.messages
632 }
633
634 #[must_use]
635 pub fn row_prefix(&self) -> &SessionMessageRowPrefixAccumulator {
636 &self.row_prefix
637 }
638
639 #[must_use]
640 pub const fn created_at(&self) -> SystemTime {
641 self.created_at
642 }
643}
644
645#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
652#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
653#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
654pub enum TranscriptParentAdvance {
655 ExactAppend {
656 #[serde(default, skip_serializing_if = "Vec::is_empty")]
657 #[cfg_attr(feature = "schema", schemars(with = "Vec<serde_json::Value>"))]
658 appended: Vec<Message>,
659 },
660 ExactSplice {
661 at: usize,
662 #[cfg_attr(feature = "schema", schemars(with = "Vec<serde_json::Value>"))]
663 replacement: Vec<Message>,
664 #[serde(default, skip_serializing_if = "Vec::is_empty")]
665 #[cfg_attr(feature = "schema", schemars(with = "Vec<serde_json::Value>"))]
666 appended: Vec<Message>,
667 },
668}
669
670impl TranscriptParentAdvance {
671 #[must_use]
672 pub fn appended(&self) -> &[Message] {
673 match self {
674 Self::ExactAppend { appended } | Self::ExactSplice { appended, .. } => appended,
675 }
676 }
677
678 #[must_use]
679 pub fn exact_splice(&self) -> Option<(usize, &[Message])> {
680 match self {
681 Self::ExactAppend { .. } => None,
682 Self::ExactSplice {
683 at, replacement, ..
684 } => Some((*at, replacement)),
685 }
686 }
687
688 #[must_use]
689 pub fn transition(&self) -> TranscriptRewriteParentTransition {
690 match self {
691 Self::ExactAppend { .. } => TranscriptRewriteParentTransition::ExactAppend,
692 Self::ExactSplice { .. } => TranscriptRewriteParentTransition::ExactSplice,
693 }
694 }
695}
696
697#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
703#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
704#[serde(rename_all = "snake_case", deny_unknown_fields)]
705pub struct TranscriptRewritePatch {
706 at: usize,
707 #[cfg_attr(feature = "schema", schemars(with = "Vec<serde_json::Value>"))]
708 replacement: Vec<Message>,
709}
710
711impl TranscriptRewritePatch {
712 #[must_use]
713 pub const fn at(&self) -> usize {
714 self.at
715 }
716
717 #[must_use]
718 pub fn replacement(&self) -> &[Message] {
719 &self.replacement
720 }
721}
722
723#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
729#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
730#[serde(rename_all = "snake_case", deny_unknown_fields)]
731pub struct TranscriptEndpointWitness {
732 message_count: usize,
733 row_prefix: SessionMessageRowPrefixAccumulator,
734}
735
736impl TranscriptEndpointWitness {
737 pub(in crate::session) fn from_messages(
738 messages: &[Message],
739 ) -> Result<Self, serde_json::Error> {
740 let row_prefix = SessionMessageRowPrefixAccumulator::from_messages(messages)
741 .map_err(session_store_error_as_json)?;
742 Self::from_messages_with_row_prefix(messages, row_prefix)
743 }
744
745 pub(in crate::session) fn from_messages_with_row_prefix(
746 messages: &[Message],
747 row_prefix: SessionMessageRowPrefixAccumulator,
748 ) -> Result<Self, serde_json::Error> {
749 if row_prefix.row_count() != messages.len() as u64 {
750 return Err(serde_json::Error::io(std::io::Error::new(
751 std::io::ErrorKind::InvalidData,
752 "endpoint row lineage count differs from its materialized messages",
753 )));
754 }
755 Ok(Self {
756 message_count: messages.len(),
757 row_prefix,
758 })
759 }
760
761 #[must_use]
762 pub const fn message_count(&self) -> usize {
763 self.message_count
764 }
765
766 #[must_use]
767 pub fn row_prefix(&self) -> &SessionMessageRowPrefixAccumulator {
768 &self.row_prefix
769 }
770}
771
772#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
781#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
782#[serde(rename_all = "snake_case", deny_unknown_fields)]
783pub struct TranscriptRevisionEdge {
784 commit: TranscriptRewriteCommit,
785 rewrite_prefix: TranscriptRewritePrefixAccumulator,
786 base_revision: String,
787 messages_before_base: usize,
788 parent_advance: TranscriptParentAdvance,
789 parent_row_prefix: SessionMessageRowPrefixAccumulator,
790 rewrite: TranscriptRewritePatch,
791 result_witness: TranscriptEndpointWitness,
792 #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
793 parent_created_at: SystemTime,
794 #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
795 revision_created_at: SystemTime,
796}
797
798impl TranscriptRevisionEdge {
799 #[doc(hidden)]
805 pub fn from_replay_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
806 serde_json::from_slice(bytes)
807 }
808
809 #[doc(hidden)]
811 pub fn to_replay_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
812 serde_json::to_vec(self)
813 }
814
815 #[must_use]
816 pub fn commit(&self) -> &TranscriptRewriteCommit {
817 &self.commit
818 }
819
820 #[must_use]
821 pub fn rewrite_prefix(&self) -> &TranscriptRewritePrefixAccumulator {
822 &self.rewrite_prefix
823 }
824
825 #[must_use]
826 pub const fn rewrite_generation(&self) -> u64 {
827 self.commit.rewrite_generation
828 }
829
830 #[must_use]
831 pub fn base_revision(&self) -> &str {
832 &self.base_revision
833 }
834
835 #[must_use]
836 pub fn parent_revision(&self) -> &str {
837 &self.commit.parent_revision
838 }
839
840 #[must_use]
841 pub fn revision(&self) -> &str {
842 &self.commit.revision
843 }
844
845 #[must_use]
846 pub const fn messages_before_base(&self) -> usize {
847 self.messages_before_base
848 }
849
850 #[must_use]
851 pub const fn messages_before(&self) -> usize {
852 self.commit.messages_before
853 }
854
855 #[must_use]
856 pub const fn messages_after(&self) -> usize {
857 self.commit.messages_after
858 }
859
860 #[must_use]
861 pub fn parent_advance(&self) -> &TranscriptParentAdvance {
862 &self.parent_advance
863 }
864
865 #[must_use]
866 pub fn parent_row_prefix(&self) -> &SessionMessageRowPrefixAccumulator {
867 &self.parent_row_prefix
868 }
869
870 #[must_use]
871 pub fn rewrite(&self) -> &TranscriptRewritePatch {
872 &self.rewrite
873 }
874
875 #[must_use]
876 pub fn result_witness(&self) -> &TranscriptEndpointWitness {
877 &self.result_witness
878 }
879
880 #[must_use]
881 pub const fn parent_created_at(&self) -> SystemTime {
882 self.parent_created_at
883 }
884
885 #[must_use]
886 pub const fn revision_created_at(&self) -> SystemTime {
887 self.revision_created_at
888 }
889}
890
891#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
893#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
894#[serde(rename_all = "snake_case", deny_unknown_fields)]
895pub struct TranscriptGraphPrefixAccumulator {
896 occurrence_count: u64,
897 digest: String,
898}
899
900impl TranscriptGraphPrefixAccumulator {
901 fn from_anchor(anchor: &TranscriptRevisionAnchor) -> Result<Self, serde_json::Error> {
902 let mut hasher = Sha256::new();
903 hasher.update(TRANSCRIPT_GRAPH_PREFIX_ANCHOR_DOMAIN);
904 update_graph_prefix_field(&mut hasher, b"revision", &anchor.revision)?;
905 update_graph_prefix_field(&mut hasher, b"messages", &anchor.messages)?;
906 update_graph_prefix_field(&mut hasher, b"row_prefix", &anchor.row_prefix)?;
907 update_graph_prefix_field(&mut hasher, b"created_at", &anchor.created_at)?;
908 Ok(Self {
909 occurrence_count: 0,
910 digest: format!("sha256:{:x}", hasher.finalize()),
911 })
912 }
913
914 fn extend(&self, edge: &TranscriptRevisionEdge) -> Result<Self, serde_json::Error> {
915 let expected = self.occurrence_count.checked_add(1).ok_or_else(|| {
916 serde_json::Error::io(std::io::Error::new(
917 std::io::ErrorKind::InvalidData,
918 "transcript graph occurrence count exceeds u64",
919 ))
920 })?;
921 if edge.commit.rewrite_generation != expected {
922 return Err(serde_json::Error::io(std::io::Error::new(
923 std::io::ErrorKind::InvalidData,
924 format!(
925 "transcript graph edge generation {} is not expected {expected}",
926 edge.commit.rewrite_generation
927 ),
928 )));
929 }
930 let previous = decode_canonical_sha256(&self.digest).ok_or_else(|| {
931 serde_json::Error::io(std::io::Error::new(
932 std::io::ErrorKind::InvalidData,
933 "transcript graph prefix is not canonical sha256",
934 ))
935 })?;
936 let mut hasher = Sha256::new();
937 hasher.update(TRANSCRIPT_GRAPH_PREFIX_EDGE_DOMAIN);
938 hasher.update(previous);
939 update_graph_prefix_field(&mut hasher, b"commit", &edge.commit)?;
940 update_graph_prefix_field(&mut hasher, b"rewrite_prefix", &edge.rewrite_prefix)?;
941 update_graph_prefix_field(&mut hasher, b"base_revision", &edge.base_revision)?;
942 update_graph_prefix_field(
943 &mut hasher,
944 b"messages_before_base",
945 &edge.messages_before_base,
946 )?;
947 update_graph_prefix_field(&mut hasher, b"parent_advance", &edge.parent_advance)?;
948 update_graph_prefix_field(&mut hasher, b"parent_row_prefix", &edge.parent_row_prefix)?;
949 update_graph_prefix_field(&mut hasher, b"rewrite", &edge.rewrite)?;
950 update_graph_prefix_field(&mut hasher, b"result_witness", &edge.result_witness)?;
951 update_graph_prefix_field(&mut hasher, b"parent_created_at", &edge.parent_created_at)?;
952 update_graph_prefix_field(
953 &mut hasher,
954 b"revision_created_at",
955 &edge.revision_created_at,
956 )?;
957 Ok(Self {
958 occurrence_count: expected,
959 digest: format!("sha256:{:x}", hasher.finalize()),
960 })
961 }
962
963 pub(super) fn from_graph<'a>(
964 anchor: &TranscriptRevisionAnchor,
965 edges: impl IntoIterator<Item = &'a TranscriptRevisionEdge>,
966 ) -> Result<Self, serde_json::Error> {
967 let mut prefix = Self::from_anchor(anchor)?;
968 for edge in edges {
969 prefix = prefix.extend(edge)?;
970 }
971 Ok(prefix)
972 }
973
974 #[must_use]
975 pub const fn occurrence_count(&self) -> u64 {
976 self.occurrence_count
977 }
978
979 #[must_use]
980 pub fn digest(&self) -> &str {
981 &self.digest
982 }
983}
984
985fn update_graph_prefix_field<T: Serialize + ?Sized>(
986 hasher: &mut Sha256,
987 label: &[u8],
988 value: &T,
989) -> Result<(), serde_json::Error> {
990 let bytes = serde_json::to_vec(value)?;
991 hasher.update((label.len() as u64).to_be_bytes());
992 hasher.update(label);
993 hasher.update((bytes.len() as u64).to_be_bytes());
994 hasher.update(bytes);
995 Ok(())
996}
997
998fn session_store_error_as_json(error: crate::SessionStoreError) -> serde_json::Error {
999 serde_json::Error::io(std::io::Error::new(
1000 std::io::ErrorKind::InvalidData,
1001 error.to_string(),
1002 ))
1003}
1004
1005fn decode_canonical_sha256(value: &str) -> Option<[u8; 32]> {
1006 let encoded = value.strip_prefix("sha256:")?;
1007 if encoded.len() != 64 || !encoded.is_ascii() {
1008 return None;
1009 }
1010 let mut decoded = [0u8; 32];
1011 for (index, output) in decoded.iter_mut().enumerate() {
1012 let bytes = encoded.as_bytes();
1013 *output = (hex_nibble(bytes[index * 2])? << 4) | hex_nibble(bytes[index * 2 + 1])?;
1014 }
1015 Some(decoded)
1016}
1017
1018#[derive(Debug, Clone, Serialize)]
1020#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1021#[serde(rename_all = "snake_case")]
1022pub struct TranscriptRewriteRecord {
1023 pub commit: TranscriptRewriteCommit,
1024 pub parent_body: TranscriptRevisionBody,
1025 pub revision_body: TranscriptRevisionBody,
1026 #[serde(default, skip_serializing_if = "digest_format_is_unknown")]
1039 pub digest_format: u32,
1040}
1041
1042impl<'de> Deserialize<'de> for TranscriptRewriteRecord {
1043 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1044 where
1045 D: Deserializer<'de>,
1046 {
1047 #[derive(Deserialize)]
1048 #[serde(rename_all = "snake_case")]
1049 struct Wire {
1050 commit: TranscriptRewriteCommit,
1051 parent_body: TranscriptRevisionBody,
1052 revision_body: TranscriptRevisionBody,
1053 #[serde(default)]
1054 digest_format: u32,
1055 }
1056 let wire = Wire::deserialize(deserializer)?;
1057 crate::digest_observability::record_rewrite_record_body_decode();
1058 let mut revisions = vec![wire.parent_body, wire.revision_body];
1059 let mut commits = vec![wire.commit];
1060 match wire.digest_format {
1065 0 => {
1066 heal_legacy_revision_strings(&mut revisions, &mut commits, None)
1067 .map_err(serde::de::Error::custom)?;
1068 heal_legacy_compaction_rewrite_semantics(&mut commits, &revisions);
1069 }
1070 TRANSCRIPT_DIGEST_FORMAT_CURRENT => {}
1071 unsupported => {
1072 return Err(serde::de::Error::custom(format!(
1073 "current transcript rewrite record digest format {unsupported} is not supported"
1074 )));
1075 }
1076 }
1077 let mut revisions = revisions.into_iter();
1078 let parent_body = revisions
1079 .next()
1080 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its parent body"))?;
1081 let revision_body = revisions
1082 .next()
1083 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its revision body"))?;
1084 let commit = commits
1085 .into_iter()
1086 .next()
1087 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its commit"))?;
1088 Ok(Self {
1089 commit,
1090 parent_body,
1091 revision_body,
1092 digest_format: TRANSCRIPT_DIGEST_FORMAT_CURRENT,
1094 })
1095 }
1096}
1097
1098impl TranscriptRewriteRecord {
1099 pub fn validate(&self) -> Result<(), TranscriptEditError> {
1101 if self.commit.rewrite_generation == 0 {
1102 return Err(TranscriptEditError::HistoryStateMalformed(
1103 "current transcript rewrite records require a non-zero occurrence generation"
1104 .to_string(),
1105 ));
1106 }
1107 validate_transcript_rewrite_record(&self.commit, &self.parent_body, &self.revision_body)
1108 }
1109
1110 pub fn new(
1111 commit: TranscriptRewriteCommit,
1112 parent_body: TranscriptRevisionBody,
1113 revision_body: TranscriptRevisionBody,
1114 ) -> Result<Self, TranscriptEditError> {
1115 let record = Self {
1116 commit,
1117 parent_body,
1118 revision_body,
1119 digest_format: TRANSCRIPT_DIGEST_FORMAT_CURRENT,
1120 };
1121 record.validate()?;
1122 Ok(record)
1123 }
1124}
1125
1126#[derive(Debug)]
1127struct TranscriptEdgeNode {
1128 previous: Option<Arc<TranscriptEdgeNode>>,
1129 edge: Arc<TranscriptRevisionEdge>,
1130 graph_prefix: TranscriptGraphPrefixAccumulator,
1131}
1132
1133#[derive(Debug)]
1134struct TranscriptEdgeChain {
1135 tail: Option<Arc<TranscriptEdgeNode>>,
1136 len: usize,
1137 ordered: std::sync::OnceLock<Vec<Arc<TranscriptRevisionEdge>>>,
1138}
1139
1140#[derive(Debug, Clone)]
1147struct PersistentTranscriptEdges {
1148 chain: Arc<TranscriptEdgeChain>,
1149}
1150
1151impl PersistentTranscriptEdges {
1152 fn empty() -> Self {
1153 Self {
1154 chain: Arc::new(TranscriptEdgeChain {
1155 tail: None,
1156 len: 0,
1157 ordered: std::sync::OnceLock::new(),
1158 }),
1159 }
1160 }
1161
1162 fn from_vec(
1163 anchor: &TranscriptRevisionAnchor,
1164 edges: Vec<TranscriptRevisionEdge>,
1165 ) -> Result<Self, serde_json::Error> {
1166 let mut persistent = Self::empty();
1167 let mut graph_prefix = TranscriptGraphPrefixAccumulator::from_anchor(anchor)?;
1168 for edge in edges {
1169 graph_prefix = graph_prefix.extend(&edge)?;
1170 persistent.push(edge, graph_prefix.clone());
1171 }
1172 Ok(persistent)
1173 }
1174
1175 fn push(
1176 &mut self,
1177 edge: TranscriptRevisionEdge,
1178 graph_prefix: TranscriptGraphPrefixAccumulator,
1179 ) {
1180 let node = Arc::new(TranscriptEdgeNode {
1181 previous: self.chain.tail.clone(),
1182 edge: Arc::new(edge),
1183 graph_prefix,
1184 });
1185 self.chain = Arc::new(TranscriptEdgeChain {
1186 tail: Some(node),
1187 len: self.chain.len + 1,
1188 ordered: std::sync::OnceLock::new(),
1189 });
1190 }
1191
1192 fn len(&self) -> usize {
1193 self.chain.len
1194 }
1195
1196 fn last(&self) -> Option<&TranscriptRevisionEdge> {
1197 self.chain.tail.as_ref().map(|node| node.edge.as_ref())
1198 }
1199
1200 fn ordered(&self) -> &[Arc<TranscriptRevisionEdge>] {
1201 self.chain.ordered.get_or_init(|| {
1202 let mut ordered = Vec::with_capacity(self.chain.len);
1203 let mut cursor = self.chain.tail.clone();
1204 while let Some(node) = cursor {
1205 ordered.push(Arc::clone(&node.edge));
1206 cursor = node.previous.clone();
1207 }
1208 ordered.reverse();
1209 ordered
1210 })
1211 }
1212
1213 fn get(&self, index: usize) -> Option<&TranscriptRevisionEdge> {
1214 if index >= self.chain.len {
1215 return None;
1216 }
1217 let mut cursor = self.chain.tail.as_deref();
1218 for _ in index + 1..self.chain.len {
1219 cursor = cursor?.previous.as_deref();
1220 }
1221 cursor.map(|node| node.edge.as_ref())
1222 }
1223
1224 fn graph_prefix_at(&self, edge_count: usize) -> Option<&TranscriptGraphPrefixAccumulator> {
1230 if edge_count == 0 || edge_count > self.chain.len {
1231 return None;
1232 }
1233 let mut cursor = self.chain.tail.as_deref();
1234 for _ in edge_count..self.chain.len {
1235 cursor = cursor?.previous.as_deref();
1236 }
1237 cursor.map(|node| &node.graph_prefix)
1238 }
1239
1240 fn suffix(&self, start: usize) -> Option<Vec<Arc<TranscriptRevisionEdge>>> {
1245 if start > self.chain.len {
1246 return None;
1247 }
1248 let suffix_len = self.chain.len - start;
1249 let mut suffix = Vec::with_capacity(suffix_len);
1250 let mut cursor = self.chain.tail.clone();
1251 for _ in 0..suffix_len {
1252 let node = cursor?;
1253 suffix.push(Arc::clone(&node.edge));
1254 cursor = node.previous.clone();
1255 }
1256 suffix.reverse();
1257 Some(suffix)
1258 }
1259
1260 fn prefix(&self, edge_count: usize) -> Option<Self> {
1261 if edge_count == 0 || edge_count > self.chain.len {
1262 return None;
1263 }
1264 let mut tail = self.chain.tail.clone();
1265 for _ in edge_count..self.chain.len {
1266 tail = tail?.previous.clone();
1267 }
1268 Some(Self {
1269 chain: Arc::new(TranscriptEdgeChain {
1270 tail,
1271 len: edge_count,
1272 ordered: std::sync::OnceLock::new(),
1273 }),
1274 })
1275 }
1276}
1277
1278#[derive(Debug, Clone)]
1285pub struct TranscriptHistoryState {
1286 format: &'static str,
1287 anchor: Arc<TranscriptRevisionAnchor>,
1288 edges: PersistentTranscriptEdges,
1289 rewrite_prefix: TranscriptRewritePrefixAccumulator,
1290 graph_prefix: TranscriptGraphPrefixAccumulator,
1291 digest_format: u32,
1300}
1301
1302#[cfg(feature = "schema")]
1312#[allow(dead_code)]
1313#[derive(schemars::JsonSchema)]
1314#[schemars(rename = "TranscriptHistoryState")]
1315struct SchemaTranscriptHistoryState {
1316 format: String,
1317 anchor: TranscriptRevisionAnchor,
1318 edges: Vec<TranscriptRevisionEdge>,
1319 rewrite_prefix: TranscriptRewritePrefixAccumulator,
1320 graph_prefix: TranscriptGraphPrefixAccumulator,
1321 digest_format: u32,
1322}
1323
1324#[cfg(feature = "schema")]
1325impl schemars::JsonSchema for TranscriptHistoryState {
1326 fn schema_name() -> std::borrow::Cow<'static, str> {
1327 "TranscriptHistoryState".into()
1328 }
1329
1330 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
1331 <SchemaTranscriptHistoryState as schemars::JsonSchema>::json_schema(generator)
1332 }
1333}
1334
1335fn digest_format_is_unknown(format: &u32) -> bool {
1336 *format == 0
1337}
1338
1339pub(crate) const TRANSCRIPT_DIGEST_FORMAT_RELEASED_0810: u32 = 2;
1341pub(crate) const TRANSCRIPT_DIGEST_FORMAT_CURRENT: u32 = 3;
1342
1343impl Serialize for TranscriptHistoryState {
1344 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1345 where
1346 S: serde::Serializer,
1347 {
1348 use serde::ser::SerializeStruct as _;
1349
1350 let mut wire = serializer.serialize_struct("TranscriptHistoryState", 6)?;
1351 wire.serialize_field("format", self.format)?;
1352 wire.serialize_field("anchor", self.anchor.as_ref())?;
1353 wire.serialize_field("edges", self.edges.ordered())?;
1354 wire.serialize_field("rewrite_prefix", &self.rewrite_prefix)?;
1355 wire.serialize_field("graph_prefix", &self.graph_prefix)?;
1356 wire.serialize_field("digest_format", &self.digest_format)?;
1357 wire.end()
1358 }
1359}
1360
1361impl<'de> Deserialize<'de> for TranscriptHistoryState {
1362 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1363 where
1364 D: Deserializer<'de>,
1365 {
1366 #[derive(Deserialize)]
1367 #[serde(rename_all = "snake_case", deny_unknown_fields)]
1368 struct CurrentWire {
1369 format: String,
1370 anchor: TranscriptRevisionAnchor,
1371 edges: Vec<TranscriptRevisionEdge>,
1372 rewrite_prefix: TranscriptRewritePrefixAccumulator,
1373 graph_prefix: TranscriptGraphPrefixAccumulator,
1374 digest_format: u32,
1375 }
1376
1377 let wire = CurrentWire::deserialize(deserializer)?;
1378 if wire.format != TRANSCRIPT_HISTORY_FORMAT_CURRENT {
1379 return Err(serde::de::Error::custom(format!(
1380 "unsupported current transcript graph format {}",
1381 wire.format
1382 )));
1383 }
1384 if wire.digest_format != TRANSCRIPT_DIGEST_FORMAT_CURRENT {
1385 return Err(serde::de::Error::custom(format!(
1386 "current transcript graph digest format {} is not supported",
1387 wire.digest_format
1388 )));
1389 }
1390 let persistent_edges = PersistentTranscriptEdges::from_vec(&wire.anchor, wire.edges)
1391 .map_err(serde::de::Error::custom)?;
1392 let state = TranscriptHistoryState {
1393 format: TRANSCRIPT_HISTORY_FORMAT_CURRENT,
1394 anchor: Arc::new(wire.anchor),
1395 edges: persistent_edges,
1396 rewrite_prefix: wire.rewrite_prefix,
1397 graph_prefix: wire.graph_prefix,
1398 digest_format: wire.digest_format,
1399 };
1400 validate_transcript_history_state(&state).map_err(serde::de::Error::custom)?;
1401 Ok(state)
1402 }
1403}
1404
1405pub(crate) fn import_released_0810_history(
1412 value: serde_json::Value,
1413) -> Result<Option<TranscriptHistoryState>, serde_json::Error> {
1414 let wire: Released0810HistoryWire = serde_json::from_value(value)?;
1415 let ValidatedReleased0810Wire {
1416 mut head,
1417 mut commits,
1418 mut revisions,
1419 } = validate_released_0810_wire(wire)?;
1420 remap_released_0810_digest_identities(&mut head, &mut commits, &mut revisions)?;
1421 if commits.is_empty() {
1422 return Ok(None);
1423 }
1424 TranscriptHistoryState::from_legacy_full_bodies(head, commits, revisions)
1425 .map(Some)
1426 .map_err(|error| serde_json::Error::io(std::io::Error::other(error.to_string())))
1427}
1428
1429#[derive(Debug)]
1431#[doc(hidden)]
1432pub enum ProvenReleased0810RewriteRemap {
1433 Retained(TranscriptRewriteRecord),
1435 Collapsed {
1438 current_revision: String,
1440 },
1441}
1442
1443#[doc(hidden)]
1454pub fn remap_proven_released_0810_rewrite_record(
1455 mut commit: TranscriptRewriteCommit,
1456 mut parent_body: TranscriptRevisionBody,
1457 mut revision_body: TranscriptRevisionBody,
1458 expected_generation: u64,
1459) -> Result<ProvenReleased0810RewriteRemap, TranscriptEditError> {
1460 let malformed = |message: &str| TranscriptEditError::HistoryStateMalformed(message.to_string());
1461 if commit.rewrite_generation != 0 || expected_generation == 0 {
1462 return Err(malformed(
1463 "released 0.8.10 rewrite remap requires generation zero and a non-zero target generation",
1464 ));
1465 }
1466 if decode_canonical_sha256(&parent_body.revision).is_none()
1467 || decode_canonical_sha256(&revision_body.revision).is_none()
1468 || decode_canonical_sha256(&commit.parent_revision).is_none()
1469 || decode_canonical_sha256(&commit.revision).is_none()
1470 || decode_canonical_sha256(&commit.original_span_digest).is_none()
1471 || decode_canonical_sha256(&commit.replacement_digest).is_none()
1472 {
1473 return Err(malformed(
1474 "released 0.8.10 rewrite remap requires canonical sha256 labels",
1475 ));
1476 }
1477 validate_released_0810_transcript_rewrite_record(&commit, &parent_body, &revision_body)?;
1478 if revision_body.parent_revision.as_deref() != Some(commit.parent_revision.as_str()) {
1479 return Err(malformed(
1480 "released 0.8.10 rewrite child body does not name its exact parent revision",
1481 ));
1482 }
1483
1484 let current_parent = transcript_messages_digest(&parent_body.messages)
1485 .map_err(|error| malformed(&error.to_string()))?;
1486 let current_revision = transcript_messages_digest(&revision_body.messages)
1487 .map_err(|error| malformed(&error.to_string()))?;
1488 let (start, end) = commit.selection.bounds();
1489 let removed = end
1490 .checked_sub(start)
1491 .ok_or_else(|| malformed("released 0.8.10 rewrite selection is inverted"))?;
1492 let retained = commit
1493 .messages_before
1494 .checked_sub(removed)
1495 .ok_or_else(|| malformed("released 0.8.10 rewrite removes more rows than its parent"))?;
1496 let replacement_len = commit
1497 .messages_after
1498 .checked_sub(retained)
1499 .ok_or_else(|| malformed("released 0.8.10 rewrite replacement length is invalid"))?;
1500 let replacement_end = start
1501 .checked_add(replacement_len)
1502 .ok_or_else(|| malformed("released 0.8.10 rewrite replacement span overflows"))?;
1503 let current_original_span = transcript_messages_digest(&parent_body.messages[start..end])
1504 .map_err(|error| malformed(&error.to_string()))?;
1505 let current_replacement_span =
1506 transcript_messages_digest(&revision_body.messages[start..replacement_end])
1507 .map_err(|error| malformed(&error.to_string()))?;
1508 if current_parent == current_revision {
1509 if current_original_span != current_replacement_span {
1510 return Err(malformed(
1511 "released 0.8.10 collapsed endpoints retain different selected-span identities",
1512 ));
1513 }
1514 return Ok(ProvenReleased0810RewriteRemap::Collapsed {
1515 current_revision: current_parent,
1516 });
1517 }
1518
1519 commit.rewrite_generation = expected_generation;
1520 commit.parent_revision = current_parent.clone();
1521 commit.revision = current_revision.clone();
1522 commit.original_span_digest = current_original_span;
1523 commit.replacement_digest = current_replacement_span;
1524 parent_body.revision = current_parent.clone();
1525 parent_body.parent_revision = None;
1528 revision_body.revision = current_revision;
1529 revision_body.parent_revision = Some(current_parent);
1530 TranscriptRewriteRecord::new(commit, parent_body, revision_body)
1531 .map(ProvenReleased0810RewriteRemap::Retained)
1532}
1533
1534fn remap_released_0810_digest_identities(
1543 head: &mut String,
1544 commits: &mut Vec<TranscriptRewriteCommit>,
1545 revisions: &mut Vec<TranscriptRevisionBody>,
1546) -> Result<(), serde_json::Error> {
1547 let mut remap = BTreeMap::new();
1548 for body in revisions.iter() {
1549 if decode_canonical_sha256(&body.revision).is_none() {
1550 return Err(serde_json::Error::io(std::io::Error::other(
1551 "released 0.8.10 transcript body carries a non-canonical revision label",
1552 )));
1553 }
1554 let current = transcript_messages_digest(&body.messages)?;
1555 remap.insert(body.revision.clone(), current);
1556 }
1557
1558 let bodies_by_released_revision = revisions
1559 .iter()
1560 .map(|body| (body.revision.as_str(), body))
1561 .collect::<std::collections::HashMap<_, _>>();
1562 let mut remapped_commits = Vec::with_capacity(commits.len());
1563 let mut collapsed_adjacency: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1564 for commit in commits.iter() {
1565 let parent = bodies_by_released_revision
1566 .get(commit.parent_revision.as_str())
1567 .copied()
1568 .ok_or_else(|| {
1569 serde_json::Error::io(std::io::Error::other(
1570 "released 0.8.10 commit parent body disappeared before remap",
1571 ))
1572 })?;
1573 let revision = bodies_by_released_revision
1574 .get(commit.revision.as_str())
1575 .copied()
1576 .ok_or_else(|| {
1577 serde_json::Error::io(std::io::Error::other(
1578 "released 0.8.10 commit revision body disappeared before remap",
1579 ))
1580 })?;
1581 let generation = u64::try_from(remapped_commits.len())
1582 .ok()
1583 .and_then(|index| index.checked_add(1))
1584 .ok_or_else(|| {
1585 serde_json::Error::io(std::io::Error::other(
1586 "released 0.8.10 rewrite generation overflowed",
1587 ))
1588 })?;
1589 let outcome = remap_proven_released_0810_rewrite_record(
1590 commit.clone(),
1591 parent.clone(),
1592 revision.clone(),
1593 generation,
1594 )
1595 .map_err(|error| serde_json::Error::io(std::io::Error::other(error.to_string())))?;
1596 match outcome {
1597 ProvenReleased0810RewriteRemap::Retained(record) => {
1598 remapped_commits.push(record.commit);
1599 }
1600 ProvenReleased0810RewriteRemap::Collapsed { current_revision } => {
1601 if remap.get(&commit.parent_revision) != Some(¤t_revision)
1602 || remap.get(&commit.revision) != Some(¤t_revision)
1603 {
1604 return Err(serde_json::Error::io(std::io::Error::other(
1605 "released 0.8.10 collapsed rewrite disagrees with graph body remap",
1606 )));
1607 }
1608 collapsed_adjacency
1609 .entry(commit.parent_revision.clone())
1610 .or_default()
1611 .insert(commit.revision.clone());
1612 collapsed_adjacency
1613 .entry(commit.revision.clone())
1614 .or_default()
1615 .insert(commit.parent_revision.clone());
1616 }
1617 }
1618 }
1619
1620 let mut current_owner: BTreeMap<String, String> = BTreeMap::new();
1621 for (released, current) in &remap {
1622 if let Some(previous) = current_owner.insert(current.clone(), released.clone())
1623 && !released_ids_connected_by_collapsed_rewrites(
1624 &collapsed_adjacency,
1625 &previous,
1626 released,
1627 )
1628 {
1629 return Err(serde_json::Error::io(std::io::Error::other(
1630 "released 0.8.10 transcript bodies collapse without a structurally proven rewrite path",
1631 )));
1632 }
1633 }
1634
1635 *head = remap.get(head).cloned().ok_or_else(|| {
1636 serde_json::Error::io(std::io::Error::other(
1637 "released 0.8.10 transcript head has no verified format-2 body",
1638 ))
1639 })?;
1640 for body in revisions.iter_mut() {
1641 body.revision = remap.get(&body.revision).cloned().ok_or_else(|| {
1642 serde_json::Error::io(std::io::Error::other(
1643 "released 0.8.10 transcript body lost its format-2 identity",
1644 ))
1645 })?;
1646 if let Some(parent) = body.parent_revision.as_mut() {
1647 *parent = remap.get(parent).cloned().ok_or_else(|| {
1648 serde_json::Error::io(std::io::Error::other(
1649 "released 0.8.10 transcript body parent has no verified format-2 identity",
1650 ))
1651 })?;
1652 }
1653 }
1654 let mut retained_revision_ids = BTreeSet::new();
1655 revisions.retain(|body| retained_revision_ids.insert(body.revision.clone()));
1656 *commits = remapped_commits;
1657 Ok(())
1658}
1659
1660fn released_ids_connected_by_collapsed_rewrites(
1661 adjacency: &BTreeMap<String, BTreeSet<String>>,
1662 start: &str,
1663 target: &str,
1664) -> bool {
1665 if start == target {
1666 return true;
1667 }
1668 let mut visited = BTreeSet::new();
1669 let mut pending = vec![start.to_string()];
1670 while let Some(current) = pending.pop() {
1671 if !visited.insert(current.clone()) {
1672 continue;
1673 }
1674 let Some(neighbors) = adjacency.get(¤t) else {
1675 continue;
1676 };
1677 if neighbors.contains(target) {
1678 return true;
1679 }
1680 pending.extend(neighbors.iter().cloned());
1681 }
1682 false
1683}
1684
1685impl TranscriptHistoryState {
1686 pub(in crate::session) fn from_store_replayed_compact_graph(
1694 anchor_revision: String,
1695 anchor_messages: Vec<Message>,
1696 anchor_row_prefix: SessionMessageRowPrefixAccumulator,
1697 edges: Vec<TranscriptRevisionEdge>,
1698 expected_rewrite_prefix: &TranscriptRewritePrefixAccumulator,
1699 expected_graph_prefix: &TranscriptGraphPrefixAccumulator,
1700 ) -> Result<Self, TranscriptEditError> {
1701 let first = edges.first().ok_or_else(|| {
1702 TranscriptEditError::HistoryStateMalformed(
1703 "store-replayed compact graph carries no rewrite edge".to_string(),
1704 )
1705 })?;
1706 if first.base_revision() != anchor_revision
1707 || first.messages_before_base() != anchor_messages.len()
1708 || anchor_row_prefix.row_count() != anchor_messages.len() as u64
1709 {
1710 return Err(TranscriptEditError::HistoryStateMalformed(
1711 "store-replayed compact graph anchor does not match its first edge".to_string(),
1712 ));
1713 }
1714 let actual_anchor_revision = transcript_messages_digest(&anchor_messages)
1715 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
1716 if actual_anchor_revision != anchor_revision {
1717 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1718 "store-replayed compact graph anchor revision {anchor_revision} has digest {actual_anchor_revision}"
1719 )));
1720 }
1721 let anchor = TranscriptRevisionAnchor {
1722 revision: anchor_revision,
1723 messages: anchor_messages,
1724 row_prefix: anchor_row_prefix,
1725 created_at: first.parent_created_at(),
1726 };
1727 let rewrite_prefix = edges
1728 .last()
1729 .map(TranscriptRevisionEdge::rewrite_prefix)
1730 .cloned()
1731 .ok_or_else(|| {
1732 TranscriptEditError::HistoryStateMalformed(
1733 "store-replayed compact graph lost its final rewrite prefix".to_string(),
1734 )
1735 })?;
1736 if &rewrite_prefix != expected_rewrite_prefix {
1737 return Err(TranscriptEditError::HistoryStateMalformed(
1738 "store-replayed compact graph rewrite prefix differs from the physical head"
1739 .to_string(),
1740 ));
1741 }
1742 let graph_prefix = TranscriptGraphPrefixAccumulator::from_graph(&anchor, &edges)
1743 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
1744 if &graph_prefix != expected_graph_prefix {
1745 return Err(TranscriptEditError::HistoryStateMalformed(
1746 "store-replayed compact graph prefix differs from the physical head".to_string(),
1747 ));
1748 }
1749 let persistent_edges = PersistentTranscriptEdges::from_vec(&anchor, edges)
1750 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
1751 let state = Self {
1752 format: TRANSCRIPT_HISTORY_FORMAT_CURRENT,
1753 anchor: Arc::new(anchor),
1754 edges: persistent_edges,
1755 rewrite_prefix,
1756 graph_prefix,
1757 digest_format: TRANSCRIPT_DIGEST_FORMAT_CURRENT,
1758 };
1759 validate_transcript_history_state(&state)?;
1760 Ok(state)
1761 }
1762
1763 fn from_legacy_full_bodies(
1764 legacy_head: String,
1765 commits: Vec<TranscriptRewriteCommit>,
1766 revisions: Vec<TranscriptRevisionBody>,
1767 ) -> Result<Self, TranscriptEditError> {
1768 let first = commits.first().ok_or_else(|| {
1769 TranscriptEditError::HistoryStateMalformed(
1770 "released 0.8.10 transcript graph carries no rewrite commit".to_string(),
1771 )
1772 })?;
1773 for body in &revisions {
1774 let digest = transcript_messages_digest(&body.messages)
1775 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
1776 if digest != body.revision {
1777 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1778 "released 0.8.10 transcript body {} has digest {digest}",
1779 body.revision
1780 )));
1781 }
1782 }
1783 let bodies_by_revision = revisions
1784 .iter()
1785 .map(|body| (body.revision.as_str(), body))
1786 .collect::<std::collections::HashMap<_, _>>();
1787 if bodies_by_revision.len() != revisions.len() {
1788 return Err(TranscriptEditError::HistoryStateMalformed(
1789 "released 0.8.10 graph repeats a revision body id its writer deduplicated"
1790 .to_string(),
1791 ));
1792 }
1793 let anchor_body = bodies_by_revision
1794 .get(first.parent_revision.as_str())
1795 .copied()
1796 .ok_or_else(|| {
1797 TranscriptEditError::HistoryStateMalformed(format!(
1798 "released 0.8.10 graph omits first parent body {}",
1799 first.parent_revision
1800 ))
1801 })?;
1802 let anchor = TranscriptRevisionAnchor {
1803 revision: anchor_body.revision.clone(),
1804 messages: anchor_body.messages.clone(),
1805 row_prefix: SessionMessageRowPrefixAccumulator::from_messages(&anchor_body.messages)
1806 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?,
1807 created_at: anchor_body.created_at,
1808 };
1809 let mut edges = Vec::with_capacity(commits.len());
1810 let mut rewrite_prefix = TranscriptRewritePrefixAccumulator::empty();
1811 let mut previous = anchor_body;
1812 let mut previous_witness = TranscriptEndpointWitness::from_messages(&anchor_body.messages)
1813 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
1814 for commit in commits {
1815 let parent = bodies_by_revision
1816 .get(commit.parent_revision.as_str())
1817 .copied()
1818 .ok_or_else(|| {
1819 TranscriptEditError::HistoryStateMalformed(format!(
1820 "released 0.8.10 graph omits parent body {}",
1821 commit.parent_revision
1822 ))
1823 })?;
1824 let revision = bodies_by_revision
1825 .get(commit.revision.as_str())
1826 .copied()
1827 .ok_or_else(|| {
1828 TranscriptEditError::HistoryStateMalformed(format!(
1829 "released 0.8.10 graph omits revision body {}",
1830 commit.revision
1831 ))
1832 })?;
1833 validate_transcript_rewrite_record(&commit, parent, revision)?;
1834 rewrite_prefix = rewrite_prefix
1835 .extend(&commit)
1836 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
1837 let edge = edge_from_materialized_bodies(
1838 previous,
1839 &previous_witness,
1840 parent,
1841 revision,
1842 commit,
1843 rewrite_prefix.clone(),
1844 MaterializedParentAdvanceSource::Released0810Import,
1845 )?;
1846 previous_witness = edge.result_witness().clone();
1847 edges.push(edge);
1848 previous = revision;
1849 }
1850 if legacy_head != previous.revision {
1851 let live_head = bodies_by_revision
1852 .get(legacy_head.as_str())
1853 .copied()
1854 .ok_or_else(|| {
1855 TranscriptEditError::HistoryStateMalformed(format!(
1856 "released 0.8.10 graph omits advertised head body {legacy_head}"
1857 ))
1858 })?;
1859 parent_advance_from_materialized(
1860 previous,
1861 live_head,
1862 u64::MAX,
1863 MaterializedParentAdvanceSource::Released0810Import,
1864 )?;
1865 }
1866 let graph_prefix = TranscriptGraphPrefixAccumulator::from_graph(&anchor, &edges)
1867 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
1868 let persistent_edges = PersistentTranscriptEdges::from_vec(&anchor, edges)
1869 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
1870 let state = Self {
1871 format: TRANSCRIPT_HISTORY_FORMAT_CURRENT,
1872 anchor: Arc::new(anchor),
1873 edges: persistent_edges,
1874 rewrite_prefix,
1875 graph_prefix,
1876 digest_format: TRANSCRIPT_DIGEST_FORMAT_CURRENT,
1877 };
1878 validate_transcript_history_state(&state)?;
1879 Ok(state)
1880 }
1881
1882 #[allow(clippy::too_many_arguments)]
1883 pub(in crate::session) fn from_authorized_first_rewrite(
1884 parent: TranscriptRevisionBody,
1885 parent_row_prefix: SessionMessageRowPrefixAccumulator,
1886 revision: &str,
1887 revision_messages: &[Message],
1888 revision_created_at: SystemTime,
1889 result_row_prefix: SessionMessageRowPrefixAccumulator,
1890 replacement: Vec<Message>,
1891 commit: TranscriptRewriteCommit,
1892 ) -> Result<Self, TranscriptEditError> {
1893 if parent_row_prefix.row_count() != parent.messages.len() as u64
1894 || result_row_prefix.row_count() != revision_messages.len() as u64
1895 || commit.parent_revision != parent.revision
1896 || commit.revision != revision
1897 || commit.messages_before != parent.messages.len()
1898 || commit.messages_after != revision_messages.len()
1899 {
1900 return Err(TranscriptEditError::HistoryStateMalformed(
1901 "authorized first rewrite carries inconsistent endpoint authority".to_string(),
1902 ));
1903 }
1904 let anchor = TranscriptRevisionAnchor {
1905 revision: parent.revision.clone(),
1906 messages: parent.messages,
1907 row_prefix: parent_row_prefix.clone(),
1908 created_at: parent.created_at,
1909 };
1910 let rewrite_prefix = TranscriptRewritePrefixAccumulator::empty()
1911 .extend(&commit)
1912 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
1913 let base_witness = TranscriptEndpointWitness::from_messages_with_row_prefix(
1914 &anchor.messages,
1915 parent_row_prefix.clone(),
1916 )
1917 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
1918 let result_witness = TranscriptEndpointWitness::from_messages_with_row_prefix(
1919 revision_messages,
1920 result_row_prefix,
1921 )
1922 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
1923 let (at, _) = commit.selection.bounds();
1924 let edge = TranscriptRevisionEdge {
1925 commit,
1926 rewrite_prefix: rewrite_prefix.clone(),
1927 base_revision: anchor.revision.clone(),
1928 messages_before_base: anchor.messages.len(),
1929 parent_advance: TranscriptParentAdvance::ExactAppend {
1930 appended: Vec::new(),
1931 },
1932 parent_row_prefix,
1933 rewrite: TranscriptRewritePatch { at, replacement },
1934 result_witness,
1935 parent_created_at: anchor.created_at,
1936 revision_created_at,
1937 };
1938 super::validate::validate_transcript_revision_edge(
1939 anchor.revision(),
1940 &base_witness,
1941 &edge,
1942 )?;
1943 let graph_prefix = TranscriptGraphPrefixAccumulator::from_anchor(&anchor)
1944 .and_then(|prefix| prefix.extend(&edge))
1945 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
1946 let persistent_edges = PersistentTranscriptEdges::from_vec(&anchor, vec![edge])
1947 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
1948 Ok(Self {
1949 format: TRANSCRIPT_HISTORY_FORMAT_CURRENT,
1950 anchor: Arc::new(anchor),
1951 edges: persistent_edges,
1952 rewrite_prefix,
1953 graph_prefix,
1954 digest_format: TRANSCRIPT_DIGEST_FORMAT_CURRENT,
1955 })
1956 }
1957
1958 #[allow(clippy::too_many_arguments)]
1959 pub(in crate::session) fn append_authorized_rewrite(
1960 &mut self,
1961 commit: TranscriptRewriteCommit,
1962 messages_before_base: usize,
1963 parent_advance: TranscriptParentAdvance,
1964 parent_row_prefix: SessionMessageRowPrefixAccumulator,
1965 replacement: Vec<Message>,
1966 result_witness: TranscriptEndpointWitness,
1967 parent_created_at: SystemTime,
1968 revision_created_at: SystemTime,
1969 ) -> Result<(), TranscriptEditError> {
1970 let base_revision = self.head().to_string();
1971 let (at, _) = commit.selection.bounds();
1972 let rewrite_prefix = self
1973 .rewrite_prefix()
1974 .extend(&commit)
1975 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
1976 let edge = TranscriptRevisionEdge {
1977 commit,
1978 rewrite_prefix,
1979 base_revision,
1980 messages_before_base,
1981 parent_advance,
1982 parent_row_prefix,
1983 rewrite: TranscriptRewritePatch { at, replacement },
1984 result_witness,
1985 parent_created_at,
1986 revision_created_at,
1987 };
1988 let base_witness = self.final_endpoint_witness().ok_or_else(|| {
1989 TranscriptEditError::HistoryStateMalformed(
1990 "current transcript graph has no audited endpoint".to_string(),
1991 )
1992 })?;
1993 super::validate::validate_transcript_revision_edge(
1994 self.edges
1995 .last()
1996 .map_or(self.anchor.revision(), |edge| edge.revision()),
1997 base_witness,
1998 &edge,
1999 )?;
2000 self.graph_prefix = self
2001 .graph_prefix
2002 .extend(&edge)
2003 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
2004 self.rewrite_prefix = edge.rewrite_prefix.clone();
2005 self.edges.push(edge, self.graph_prefix.clone());
2006 Ok(())
2007 }
2008
2009 #[must_use]
2011 pub fn format(&self) -> &str {
2012 self.format
2013 }
2014
2015 #[must_use]
2016 pub const fn digest_format(&self) -> u32 {
2017 self.digest_format
2018 }
2019
2020 #[must_use]
2021 pub fn head(&self) -> &str {
2022 self.edges
2023 .last()
2024 .map(TranscriptRevisionEdge::revision)
2025 .unwrap_or_else(|| self.anchor.revision())
2026 }
2027
2028 pub fn commits(&self) -> impl ExactSizeIterator<Item = &TranscriptRewriteCommit> {
2029 self.edges.ordered().iter().map(|edge| edge.commit())
2030 }
2031
2032 #[must_use]
2033 pub fn commit_count(&self) -> usize {
2034 self.edges.len()
2035 }
2036
2037 #[must_use]
2038 pub fn commit(&self, index: usize) -> Option<&TranscriptRewriteCommit> {
2039 self.edges.get(index).map(TranscriptRevisionEdge::commit)
2040 }
2041
2042 pub(crate) fn edge(&self, index: usize) -> Option<&TranscriptRevisionEdge> {
2043 self.edges.get(index)
2044 }
2045
2046 #[must_use]
2047 pub fn last_commit(&self) -> Option<&TranscriptRewriteCommit> {
2048 self.edges.last().map(TranscriptRevisionEdge::commit)
2049 }
2050
2051 #[must_use]
2052 pub fn rewrite_prefix(&self) -> &TranscriptRewritePrefixAccumulator {
2053 &self.rewrite_prefix
2054 }
2055
2056 #[must_use]
2057 pub fn anchor(&self) -> &TranscriptRevisionAnchor {
2058 self.anchor.as_ref()
2059 }
2060
2061 #[must_use]
2062 pub fn edges(&self) -> &[Arc<TranscriptRevisionEdge>] {
2067 self.edges.ordered()
2068 }
2069
2070 pub(super) fn edge_suffix(&self, start: usize) -> Option<Vec<Arc<TranscriptRevisionEdge>>> {
2071 self.edges.suffix(start)
2072 }
2073
2074 #[must_use]
2075 pub fn graph_prefix(&self) -> &TranscriptGraphPrefixAccumulator {
2076 &self.graph_prefix
2077 }
2078
2079 #[must_use]
2086 pub(crate) fn extends_exact_graph(&self, prefix: &Self) -> bool {
2087 let prefix_count = prefix.commit_count();
2088 if prefix_count > self.commit_count() {
2089 return false;
2090 }
2091 if prefix_count == 0 {
2092 let expected_graph_prefix =
2093 TranscriptGraphPrefixAccumulator::from_anchor(prefix.anchor()).ok();
2094 return self.anchor() == prefix.anchor()
2095 && prefix.rewrite_prefix() == &TranscriptRewritePrefixAccumulator::empty()
2096 && expected_graph_prefix.as_ref() == Some(prefix.graph_prefix());
2097 }
2098 self.graph_prefix_at(prefix_count) == Some(prefix.graph_prefix())
2099 }
2100
2101 #[must_use]
2112 pub(crate) fn unique_revision_position(&self, revision: &str) -> Option<usize> {
2113 fn observe(
2114 candidate: &str,
2115 candidate_position: usize,
2116 revision: &str,
2117 found: &mut Option<usize>,
2118 ambiguous: &mut bool,
2119 ) {
2120 if candidate == revision && found.replace(candidate_position).is_some() {
2121 *ambiguous = true;
2122 }
2123 }
2124
2125 let mut position = 0usize;
2126 let mut found = None;
2127 let mut ambiguous = false;
2128 let mut preceding_child = self.anchor.revision();
2129
2130 observe(
2131 self.anchor.revision(),
2132 position,
2133 revision,
2134 &mut found,
2135 &mut ambiguous,
2136 );
2137 for edge in self.edges.ordered() {
2138 if edge.parent_revision() != preceding_child {
2139 position += 1;
2140 observe(
2141 edge.parent_revision(),
2142 position,
2143 revision,
2144 &mut found,
2145 &mut ambiguous,
2146 );
2147 }
2148
2149 position += 1;
2153 observe(
2154 edge.revision(),
2155 position,
2156 revision,
2157 &mut found,
2158 &mut ambiguous,
2159 );
2160 preceding_child = edge.revision();
2161 }
2162 if ambiguous { None } else { found }
2163 }
2164
2165 #[must_use]
2171 pub(crate) fn revision_extends(&self, descendant: &str, ancestor: &str) -> bool {
2172 match (
2173 self.unique_revision_position(descendant),
2174 self.unique_revision_position(ancestor),
2175 ) {
2176 (Some(descendant), Some(ancestor)) => descendant >= ancestor,
2177 _ => false,
2178 }
2179 }
2180
2181 #[must_use]
2183 pub(crate) fn contains_exact_commit(&self, commit: &TranscriptRewriteCommit) -> bool {
2184 commit
2185 .rewrite_generation
2186 .checked_sub(1)
2187 .and_then(|index| usize::try_from(index).ok())
2188 .and_then(|index| self.commit(index))
2189 == Some(commit)
2190 }
2191
2192 pub(crate) fn graph_prefix_at(
2196 &self,
2197 occurrence_count: usize,
2198 ) -> Option<&TranscriptGraphPrefixAccumulator> {
2199 self.edges.graph_prefix_at(occurrence_count)
2200 }
2201
2202 pub fn final_endpoint_witness(&self) -> Option<&TranscriptEndpointWitness> {
2203 self.edges
2204 .last()
2205 .map(TranscriptRevisionEdge::result_witness)
2206 }
2207
2208 pub(in crate::session) fn derive_live_row_lineage_after_final_semantic_replay(
2211 &self,
2212 live: &[Message],
2213 ) -> Result<Option<SessionMessageRowPrefixAccumulator>, TranscriptEditError> {
2214 let endpoint_witness = self.final_endpoint_witness().ok_or_else(|| {
2215 TranscriptEditError::HistoryStateMalformed(
2216 "compact transcript graph has no final endpoint witness".to_string(),
2217 )
2218 })?;
2219 let endpoint = self.materialize_revision(self.head())?;
2220 if live.len() < endpoint.messages.len() {
2221 return Ok(None);
2222 }
2223 let advance = if live[..endpoint.messages.len()] == endpoint.messages {
2224 TranscriptParentAdvance::ExactAppend {
2225 appended: live[endpoint.messages.len()..].to_vec(),
2226 }
2227 } else {
2228 return Ok(None);
2229 };
2230 row_prefix_after_parent_advance(endpoint_witness.row_prefix(), &advance).map(Some)
2231 }
2232
2233 #[must_use]
2234 pub fn parent_transition(&self, index: usize) -> Option<TranscriptRewriteParentTransition> {
2235 self.edges
2236 .get(index)
2237 .map(|edge| edge.parent_advance.transition())
2238 }
2239
2240 #[must_use]
2241 pub fn parent_transitions(&self) -> Vec<TranscriptRewriteParentTransition> {
2242 self.edges
2243 .ordered()
2244 .iter()
2245 .map(|edge| edge.parent_advance.transition())
2246 .collect()
2247 }
2248
2249 #[must_use]
2250 pub fn contains_revision(&self, revision: &str) -> bool {
2251 self.anchor.revision == revision
2252 || self
2253 .edges
2254 .ordered()
2255 .iter()
2256 .any(|edge| edge.parent_revision() == revision || edge.revision() == revision)
2257 }
2258
2259 #[must_use]
2260 pub fn retained_revision_count(&self) -> usize {
2261 1usize.saturating_add(self.edges.len().saturating_mul(2))
2262 }
2263
2264 pub(crate) fn compact_mechanical_revision_bodies(&mut self) -> Result<(), TranscriptEditError> {
2267 validate_transcript_history_state(self)?;
2268 Ok(())
2269 }
2270
2271 pub(crate) fn canonicalize_to_latest_audited_head(&mut self) {
2273 }
2275
2276 pub(crate) fn prune_mechanical_revision_bodies(&mut self) {}
2277
2278 pub(super) fn proved_prefix(&self, edge_count: usize) -> Result<Self, TranscriptEditError> {
2279 if edge_count == 0 || edge_count > self.edges.len() {
2280 return Err(TranscriptEditError::HistoryStateMalformed(format!(
2281 "compact graph prefix {edge_count} is outside 1..={}",
2282 self.edges.len()
2283 )));
2284 }
2285 let edges = self.edges.prefix(edge_count).ok_or_else(|| {
2286 TranscriptEditError::HistoryStateMalformed(
2287 "compact graph prefix could not address its persistent tail".to_string(),
2288 )
2289 })?;
2290 let rewrite_prefix = edges
2291 .last()
2292 .map(TranscriptRevisionEdge::rewrite_prefix)
2293 .cloned()
2294 .ok_or_else(|| {
2295 TranscriptEditError::HistoryStateMalformed(
2296 "compact graph prefix lost its final edge".to_string(),
2297 )
2298 })?;
2299 let graph_prefix = TranscriptGraphPrefixAccumulator::from_graph(
2300 self.anchor.as_ref(),
2301 edges.ordered().iter().map(AsRef::as_ref),
2302 )
2303 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
2304 Ok(Self {
2305 format: TRANSCRIPT_HISTORY_FORMAT_CURRENT,
2306 anchor: Arc::clone(&self.anchor),
2307 edges,
2308 rewrite_prefix,
2309 graph_prefix,
2310 digest_format: TRANSCRIPT_DIGEST_FORMAT_CURRENT,
2311 })
2312 }
2313
2314 pub fn materialize_revision(
2316 &self,
2317 revision: &str,
2318 ) -> Result<TranscriptRevisionBody, TranscriptEditError> {
2319 if let Some(index) = self
2320 .edges
2321 .ordered()
2322 .iter()
2323 .rposition(|edge| edge.revision() == revision)
2324 {
2325 return self.materialize_occurrence(index, false);
2326 }
2327 if let Some(index) = self
2328 .edges
2329 .ordered()
2330 .iter()
2331 .rposition(|edge| edge.parent_revision() == revision)
2332 {
2333 return self.materialize_occurrence(index, true);
2334 }
2335 if self.anchor.revision == revision {
2336 TRANSCRIPT_HISTORY_FULL_BODY_MATERIALIZATIONS
2337 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2338 return Ok(TranscriptRevisionBody {
2339 revision: self.anchor.revision.clone(),
2340 parent_revision: None,
2341 messages: self.anchor.messages.clone(),
2342 created_at: self.anchor.created_at,
2343 });
2344 }
2345 Err(TranscriptEditError::HistoryStateMalformed(format!(
2346 "missing transcript revision {revision}"
2347 )))
2348 }
2349
2350 pub(crate) fn materialize_occurrence_parent(
2352 &self,
2353 edge_index: usize,
2354 ) -> Result<TranscriptRevisionBody, TranscriptEditError> {
2355 self.materialize_occurrence(edge_index, true)
2356 }
2357
2358 pub(crate) fn materialize_occurrence_child(
2359 &self,
2360 edge_index: usize,
2361 ) -> Result<TranscriptRevisionBody, TranscriptEditError> {
2362 self.materialize_occurrence(edge_index, false)
2363 }
2364
2365 fn materialize_occurrence(
2366 &self,
2367 edge_index: usize,
2368 parent: bool,
2369 ) -> Result<TranscriptRevisionBody, TranscriptEditError> {
2370 if edge_index >= self.edges.len() {
2371 return Err(TranscriptEditError::HistoryStateMalformed(format!(
2372 "rewrite occurrence index {edge_index} is outside the compact graph"
2373 )));
2374 }
2375 TRANSCRIPT_HISTORY_FULL_BODY_MATERIALIZATIONS
2376 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2377 let mut messages = self.anchor.messages.clone();
2378 for (index, edge) in self.edges.ordered().iter().enumerate().take(edge_index + 1) {
2379 apply_parent_advance(&mut messages, &edge.parent_advance)?;
2380 if index == edge_index && parent {
2381 let digest = transcript_messages_digest(&messages).map_err(|error| {
2382 TranscriptEditError::HistoryStateMalformed(error.to_string())
2383 })?;
2384 if digest != edge.parent_revision() {
2385 return Err(TranscriptEditError::HistoryStateMalformed(format!(
2386 "rewrite occurrence {} materialized parent has digest {digest}, expected {}",
2387 edge.rewrite_generation(),
2388 edge.parent_revision()
2389 )));
2390 }
2391 return Ok(TranscriptRevisionBody {
2392 revision: edge.parent_revision().to_string(),
2393 parent_revision: Some(edge.base_revision.clone()),
2394 messages,
2395 created_at: edge.parent_created_at,
2396 });
2397 }
2398 let (_, end) = edge.commit.selection.bounds();
2399 if end > messages.len() {
2400 return Err(TranscriptEditError::HistoryStateMalformed(format!(
2401 "rewrite occurrence {} patch exceeds materialized parent",
2402 edge.rewrite_generation()
2403 )));
2404 }
2405 let removed_digest = transcript_messages_digest(&messages[edge.rewrite.at..end])
2406 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
2407 if removed_digest != edge.commit.original_span_digest {
2408 return Err(TranscriptEditError::HistoryStateMalformed(format!(
2409 "rewrite occurrence {} materialized removed span has wrong digest",
2410 edge.rewrite_generation()
2411 )));
2412 }
2413 messages.splice(edge.rewrite.at..end, edge.rewrite.replacement.clone());
2414 if index == edge_index {
2415 let digest = transcript_messages_digest(&messages).map_err(|error| {
2416 TranscriptEditError::HistoryStateMalformed(error.to_string())
2417 })?;
2418 if digest != edge.revision() {
2419 return Err(TranscriptEditError::HistoryStateMalformed(format!(
2420 "rewrite occurrence {} materialized child has digest {digest}, expected {}",
2421 edge.rewrite_generation(),
2422 edge.revision()
2423 )));
2424 }
2425 return Ok(TranscriptRevisionBody {
2426 revision: edge.revision().to_string(),
2427 parent_revision: Some(edge.parent_revision().to_string()),
2428 messages,
2429 created_at: edge.revision_created_at,
2430 });
2431 }
2432 }
2433 Err(TranscriptEditError::HistoryStateMalformed(
2434 "rewrite occurrence materialization did not reach its target".to_string(),
2435 ))
2436 }
2437
2438 pub fn materialize_revision_bodies(
2440 &self,
2441 ) -> Result<Vec<TranscriptRevisionBody>, TranscriptEditError> {
2442 let mut seen = BTreeSet::new();
2443 let mut bodies = Vec::new();
2444 for revision in std::iter::once(self.anchor.revision.as_str()).chain(
2445 self.edges
2446 .ordered()
2447 .iter()
2448 .flat_map(|edge| [edge.parent_revision(), edge.revision()]),
2449 ) {
2450 if seen.insert(revision.to_string()) {
2451 bodies.push(self.materialize_revision(revision)?);
2452 }
2453 }
2454 Ok(bodies)
2455 }
2456}
2457
2458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2463enum MaterializedParentAdvanceSource {
2464 CurrentAudit,
2465 Released0810Import,
2466}
2467
2468fn parent_advance_from_materialized(
2469 base: &TranscriptRevisionBody,
2470 parent: &TranscriptRevisionBody,
2471 rewrite_generation: u64,
2472 source: MaterializedParentAdvanceSource,
2473) -> Result<TranscriptParentAdvance, TranscriptEditError> {
2474 if parent.messages.len() < base.messages.len() {
2475 return Err(TranscriptEditError::HistoryStateMalformed(format!(
2476 "rewrite occurrence {rewrite_generation} parent is shorter than its base"
2477 )));
2478 }
2479 if parent.messages[..base.messages.len()] == base.messages {
2480 return Ok(TranscriptParentAdvance::ExactAppend {
2481 appended: parent.messages[base.messages.len()..].to_vec(),
2482 });
2483 }
2484 if source == MaterializedParentAdvanceSource::CurrentAudit {
2485 return Err(TranscriptEditError::HistoryStateMalformed(format!(
2486 "current rewrite occurrence {rewrite_generation} parent is not an exact append"
2487 )));
2488 }
2489 let retained_parent = &parent.messages[..base.messages.len()];
2490 let at = base
2491 .messages
2492 .iter()
2493 .zip(retained_parent)
2494 .take_while(|(base, parent)| base == parent)
2495 .count();
2496 let common_suffix = base.messages[at..]
2497 .iter()
2498 .rev()
2499 .zip(retained_parent[at..].iter().rev())
2500 .take_while(|(base, parent)| base == parent)
2501 .count();
2502 let end = base.messages.len() - common_suffix;
2503 if at >= end {
2504 return Err(TranscriptEditError::HistoryStateMalformed(format!(
2505 "rewrite occurrence {rewrite_generation} importer could not derive an exact parent splice"
2506 )));
2507 }
2508 Ok(TranscriptParentAdvance::ExactSplice {
2509 at,
2510 replacement: retained_parent[at..end].to_vec(),
2511 appended: parent.messages[base.messages.len()..].to_vec(),
2512 })
2513}
2514
2515fn row_prefix_after_parent_advance(
2516 base: &SessionMessageRowPrefixAccumulator,
2517 advance: &TranscriptParentAdvance,
2518) -> Result<SessionMessageRowPrefixAccumulator, TranscriptEditError> {
2519 let advanced = match advance {
2520 TranscriptParentAdvance::ExactAppend { .. } => base.clone(),
2521 TranscriptParentAdvance::ExactSplice {
2522 at, replacement, ..
2523 } => {
2524 let replacement_rows = replacement
2525 .iter()
2526 .map(serde_json::to_vec)
2527 .collect::<Result<Vec<_>, _>>()
2528 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
2529 let end = at.checked_add(replacement.len()).ok_or_else(|| {
2530 TranscriptEditError::HistoryStateMalformed(
2531 "imported parent splice end overflowed".to_string(),
2532 )
2533 })?;
2534 let at = u64::try_from(*at).map_err(|_| {
2535 TranscriptEditError::HistoryStateMalformed(
2536 "imported parent splice start exceeds durable row coordinates".to_string(),
2537 )
2538 })?;
2539 let end = u64::try_from(end).map_err(|_| {
2540 TranscriptEditError::HistoryStateMalformed(
2541 "imported parent splice end exceeds durable row coordinates".to_string(),
2542 )
2543 })?;
2544 base.replace_serialized_range(at, end, &replacement_rows)
2545 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?
2546 }
2547 };
2548 let appended = advance
2549 .appended()
2550 .iter()
2551 .map(serde_json::to_vec)
2552 .collect::<Result<Vec<_>, _>>()
2553 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
2554 advanced
2555 .extend_serialized_rows(&appended)
2556 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))
2557}
2558
2559fn edge_from_materialized_bodies(
2560 base: &TranscriptRevisionBody,
2561 base_witness: &TranscriptEndpointWitness,
2562 parent: &TranscriptRevisionBody,
2563 revision: &TranscriptRevisionBody,
2564 commit: TranscriptRewriteCommit,
2565 rewrite_prefix: TranscriptRewritePrefixAccumulator,
2566 source: MaterializedParentAdvanceSource,
2567) -> Result<TranscriptRevisionEdge, TranscriptEditError> {
2568 let parent_advance =
2569 parent_advance_from_materialized(base, parent, commit.rewrite_generation, source)?;
2570 let parent_row_prefix =
2571 row_prefix_after_parent_advance(base_witness.row_prefix(), &parent_advance)?;
2572 let (at, end) = commit.selection.bounds();
2573 let removed_len = end.checked_sub(at).ok_or_else(|| {
2574 TranscriptEditError::HistoryStateMalformed("rewrite selection is inverted".to_string())
2575 })?;
2576 let retained = commit
2577 .messages_before
2578 .checked_sub(removed_len)
2579 .ok_or_else(|| {
2580 TranscriptEditError::HistoryStateMalformed(
2581 "rewrite removes more messages than parent carries".to_string(),
2582 )
2583 })?;
2584 let replacement_len = commit.messages_after.checked_sub(retained).ok_or_else(|| {
2585 TranscriptEditError::HistoryStateMalformed(
2586 "rewrite message counts cannot describe replacement".to_string(),
2587 )
2588 })?;
2589 let replacement_end = at.checked_add(replacement_len).ok_or_else(|| {
2590 TranscriptEditError::HistoryStateMalformed("rewrite replacement end overflow".to_string())
2591 })?;
2592 let replacement = revision.messages[at..replacement_end].to_vec();
2593 let replacement_rows = replacement
2594 .iter()
2595 .map(serde_json::to_vec)
2596 .collect::<Result<Vec<_>, _>>()
2597 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
2598 let result_row_prefix = parent_row_prefix
2599 .replace_serialized_range(at as u64, end as u64, &replacement_rows)
2600 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
2601 let edge = TranscriptRevisionEdge {
2602 rewrite_prefix,
2603 base_revision: base.revision.clone(),
2604 messages_before_base: base.messages.len(),
2605 parent_advance,
2606 parent_row_prefix,
2607 rewrite: TranscriptRewritePatch { at, replacement },
2608 result_witness: TranscriptEndpointWitness::from_messages_with_row_prefix(
2609 &revision.messages,
2610 result_row_prefix,
2611 )
2612 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?,
2613 parent_created_at: parent.created_at,
2614 revision_created_at: revision.created_at,
2615 commit,
2616 };
2617 super::validate::validate_transcript_revision_edge(&base.revision, base_witness, &edge)?;
2618 Ok(edge)
2619}
2620
2621fn apply_parent_advance(
2622 messages: &mut Vec<Message>,
2623 advance: &TranscriptParentAdvance,
2624) -> Result<(), TranscriptEditError> {
2625 match advance {
2626 TranscriptParentAdvance::ExactAppend { appended } => messages.extend_from_slice(appended),
2627 TranscriptParentAdvance::ExactSplice {
2628 at,
2629 replacement,
2630 appended,
2631 } => {
2632 let end = at.checked_add(replacement.len()).ok_or_else(|| {
2633 TranscriptEditError::HistoryStateMalformed(
2634 "imported parent splice end overflowed".to_string(),
2635 )
2636 })?;
2637 if replacement.is_empty() || end > messages.len() {
2638 return Err(TranscriptEditError::HistoryStateMalformed(
2639 "imported parent splice is empty or outside its base".to_string(),
2640 ));
2641 }
2642 messages.splice(*at..end, replacement.iter().cloned());
2643 messages.extend_from_slice(appended);
2644 }
2645 }
2646 Ok(())
2647}
2648
2649pub(super) fn record_is_proved_by(
2668 _proved: Option<&ValidatedTranscriptHistory>,
2669 _record: &TranscriptRewriteRecord,
2670) -> bool {
2671 false
2676}
2677
2678impl TranscriptHistoryState {
2679 pub fn from_rewrite_records<I>(records: I) -> Result<Option<Self>, TranscriptEditError>
2681 where
2682 I: IntoIterator<Item = TranscriptRewriteRecord>,
2683 {
2684 Self::from_rewrite_records_with_proved(records, None)
2685 }
2686
2687 pub fn from_rewrite_records_with_proved<I>(
2699 records: I,
2700 proved: Option<&ValidatedTranscriptHistory>,
2701 ) -> Result<Option<Self>, TranscriptEditError>
2702 where
2703 I: IntoIterator<Item = TranscriptRewriteRecord>,
2704 {
2705 let mut records = records.into_iter().collect::<Vec<_>>();
2706 if records.is_empty() {
2707 return Ok(None);
2708 }
2709 if records
2716 .iter()
2717 .any(|record| record.commit.rewrite_generation == 0)
2718 {
2719 return Err(TranscriptEditError::HistoryStateMalformed(
2720 "generation-less 0.8.10 rewrite records require checkpoint-bound EventStore reconciliation"
2721 .to_string(),
2722 ));
2723 }
2724 records.sort_by_key(|record| record.commit.rewrite_generation);
2725 let proved_count = proved.map_or(0, |history| history.state().commit_count());
2726 let first_generation = records[0].commit.rewrite_generation;
2727 if first_generation > 1 && first_generation != proved_count as u64 + 1 {
2728 return Err(TranscriptEditError::HistoryStateMalformed(format!(
2729 "rewrite record set starts at occurrence {first_generation} without the exact proved prefix"
2730 )));
2731 }
2732 let mut new_records = Vec::new();
2733 for record in records {
2734 let generation = record.commit.rewrite_generation;
2735 if generation <= proved_count as u64 {
2736 validate_transcript_rewrite_record(
2737 &record.commit,
2738 &record.parent_body,
2739 &record.revision_body,
2740 )?;
2741 let matches_prefix = proved.and_then(|history| {
2742 usize::try_from(generation - 1)
2743 .ok()
2744 .and_then(|index| history.state().commit(index))
2745 }) == Some(&record.commit);
2746 if !matches_prefix {
2747 return Err(TranscriptEditError::HistoryStateMalformed(format!(
2748 "rewrite record occurrence {generation} conflicts with proved graph prefix"
2749 )));
2750 }
2751 continue;
2752 }
2753 let expected = proved_count as u64 + new_records.len() as u64 + 1;
2754 if generation != expected {
2755 return Err(TranscriptEditError::HistoryStateMalformed(format!(
2756 "rewrite record occurrence {generation} is not expected contiguous generation {expected}"
2757 )));
2758 }
2759 new_records.push(record);
2760 }
2761 if new_records.is_empty() {
2762 return Ok(proved.map(|history| history.state().clone()));
2763 }
2764
2765 let mut state = proved.map(|history| history.state().clone());
2766 let mut previous = if let Some(history) = proved {
2767 Some(
2768 history
2769 .state()
2770 .materialize_revision(history.state().head())?,
2771 )
2772 } else {
2773 None
2774 };
2775 let mut previous_witness =
2776 proved.and_then(|history| history.state().final_endpoint_witness().cloned());
2777 for record in new_records {
2778 validate_transcript_rewrite_record(
2779 &record.commit,
2780 &record.parent_body,
2781 &record.revision_body,
2782 )?;
2783 if state.is_none() {
2784 let (start, end) = record.commit.selection.bounds();
2785 let removed = end.checked_sub(start).ok_or_else(|| {
2786 TranscriptEditError::HistoryStateMalformed(
2787 "rewrite record selection is inverted".to_string(),
2788 )
2789 })?;
2790 let retained = record
2791 .commit
2792 .messages_before
2793 .checked_sub(removed)
2794 .ok_or_else(|| {
2795 TranscriptEditError::HistoryStateMalformed(
2796 "rewrite record selection exceeds its parent".to_string(),
2797 )
2798 })?;
2799 let replacement_len = record
2800 .commit
2801 .messages_after
2802 .checked_sub(retained)
2803 .ok_or_else(|| {
2804 TranscriptEditError::HistoryStateMalformed(
2805 "rewrite record successor is shorter than retained spans".to_string(),
2806 )
2807 })?;
2808 let replacement_end = start.checked_add(replacement_len).ok_or_else(|| {
2809 TranscriptEditError::HistoryStateMalformed(
2810 "rewrite record replacement range overflow".to_string(),
2811 )
2812 })?;
2813 let replacement = record
2814 .revision_body
2815 .messages
2816 .get(start..replacement_end)
2817 .ok_or_else(|| {
2818 TranscriptEditError::HistoryStateMalformed(
2819 "rewrite record replacement range exceeds successor".to_string(),
2820 )
2821 })?
2822 .to_vec();
2823 let parent_prefix =
2824 SessionMessageRowPrefixAccumulator::from_messages(&record.parent_body.messages)
2825 .map_err(|error| {
2826 TranscriptEditError::HistoryStateMalformed(error.to_string())
2827 })?;
2828 let replacement_rows = replacement
2829 .iter()
2830 .map(serde_json::to_vec)
2831 .collect::<Result<Vec<_>, _>>()
2832 .map_err(|error| {
2833 TranscriptEditError::HistoryStateMalformed(error.to_string())
2834 })?;
2835 let result_prefix = parent_prefix
2836 .replace_serialized_range(start as u64, end as u64, &replacement_rows)
2837 .map_err(|error| {
2838 TranscriptEditError::HistoryStateMalformed(error.to_string())
2839 })?;
2840 state = Some(Self::from_authorized_first_rewrite(
2841 record.parent_body.clone(),
2842 parent_prefix,
2843 &record.revision_body.revision,
2844 &record.revision_body.messages,
2845 record.revision_body.created_at,
2846 result_prefix,
2847 replacement,
2848 record.commit.clone(),
2849 )?);
2850 } else {
2851 let base = previous.as_ref().ok_or_else(|| {
2852 TranscriptEditError::HistoryStateMalformed(
2853 "rewrite tail lost its preceding endpoint".to_string(),
2854 )
2855 })?;
2856 let next_rewrite_prefix = state
2857 .as_ref()
2858 .ok_or_else(|| {
2859 TranscriptEditError::HistoryStateMalformed(
2860 "rewrite state initialization failed".to_string(),
2861 )
2862 })?
2863 .rewrite_prefix
2864 .extend(&record.commit)
2865 .map_err(|error| {
2866 TranscriptEditError::HistoryStateMalformed(error.to_string())
2867 })?;
2868 let edge = edge_from_materialized_bodies(
2869 base,
2870 previous_witness.as_ref().ok_or_else(|| {
2871 TranscriptEditError::HistoryStateMalformed(
2872 "rewrite tail lost its preceding row-lineage witness".to_string(),
2873 )
2874 })?,
2875 &record.parent_body,
2876 &record.revision_body,
2877 record.commit.clone(),
2878 next_rewrite_prefix.clone(),
2879 MaterializedParentAdvanceSource::CurrentAudit,
2880 )?;
2881 let state = state.as_mut().ok_or_else(|| {
2882 TranscriptEditError::HistoryStateMalformed(
2883 "rewrite state initialization failed".to_string(),
2884 )
2885 })?;
2886 state.rewrite_prefix = next_rewrite_prefix;
2887 state.graph_prefix = state.graph_prefix.extend(&edge).map_err(|error| {
2888 TranscriptEditError::HistoryStateMalformed(error.to_string())
2889 })?;
2890 state.edges.push(edge, state.graph_prefix.clone());
2891 }
2892 previous_witness = state
2893 .as_ref()
2894 .and_then(TranscriptHistoryState::final_endpoint_witness)
2895 .cloned();
2896 previous = Some(record.revision_body);
2897 }
2898 Ok(state)
2899 }
2900}
2901
2902#[cfg(test)]
2903#[allow(clippy::unwrap_used, clippy::expect_used, clippy::redundant_clone)]
2904mod tests {
2905 use super::*;
2906
2907 #[test]
2908 fn rewrite_prefix_digest_binds_order_and_exact_commit_facts() {
2909 let records = rewrite_chain(2);
2910 let commits = records
2911 .iter()
2912 .map(|record| record.commit.clone())
2913 .collect::<Vec<_>>();
2914 let baseline =
2915 transcript_rewrite_prefix_digest(&commits).expect("canonical prefix serializes");
2916 let first = TranscriptRewritePrefixAccumulator::from_commits(&commits[..1])
2917 .expect("first prefix serializes");
2918 let extended = extend_transcript_rewrite_prefix_accumulator(first, &commits[1..])
2919 .expect("tail extension serializes");
2920 assert_eq!(extended.digest(), baseline);
2921 assert_eq!(
2922 extended.occurrence_count(),
2923 u64::try_from(commits.len()).expect("test length fits u64")
2924 );
2925
2926 let mut actor_changed = commits.clone();
2927 actor_changed[0].actor = Some("different-actor".to_string());
2928 assert_ne!(
2929 transcript_rewrite_prefix_digest(&actor_changed).expect("changed prefix serializes"),
2930 baseline,
2931 "the receipt must bind full commit facts, not only revisions"
2932 );
2933
2934 let mut reordered = commits.clone();
2935 reordered.reverse();
2936 for (index, commit) in reordered.iter_mut().enumerate() {
2937 commit.rewrite_generation = u64::try_from(index)
2938 .expect("test index fits u64")
2939 .saturating_add(1);
2940 }
2941 assert_ne!(
2942 transcript_rewrite_prefix_digest(&reordered).expect("reordered prefix serializes"),
2943 baseline,
2944 "the receipt must bind lineage order"
2945 );
2946 }
2947
2948 #[test]
2949 fn rewrite_generation_distinguishes_byte_equal_occurrences_and_legacy_order_normalizes() {
2950 let mut commits = rewrite_chain(1)
2951 .into_iter()
2952 .map(|record| record.commit)
2953 .collect::<Vec<_>>();
2954 let mut recurrence = commits[0].clone();
2955 recurrence.rewrite_generation = 2;
2956 commits.push(recurrence);
2957 let one =
2958 transcript_rewrite_prefix_digest(&commits[..1]).expect("one occurrence serializes");
2959 let two = transcript_rewrite_prefix_digest(&commits).expect("two occurrences serialize");
2960 assert_ne!(
2961 one, two,
2962 "byte-equal rewrite facts at distinct generations are distinct occurrences"
2963 );
2964
2965 for commit in &mut commits {
2966 commit.rewrite_generation = 0;
2967 }
2968 assert!(
2969 normalize_legacy_graph_rewrite_generations(&mut commits)
2970 .expect("proved 0.8.10 vector order normalizes")
2971 );
2972 assert_eq!(
2973 commits
2974 .iter()
2975 .map(|commit| commit.rewrite_generation)
2976 .collect::<Vec<_>>(),
2977 vec![1, 2]
2978 );
2979 }
2980
2981 #[test]
2982 fn generic_record_rebuild_cannot_authorize_generation_zero_from_input_order() {
2983 let mut records = rewrite_chain(2);
2984 for record in &mut records {
2985 record.commit.rewrite_generation = 0;
2986 }
2987 let error = TranscriptHistoryState::from_rewrite_records(records)
2988 .expect_err("audit row order is not legacy occurrence authority");
2989 assert!(
2990 matches!(error, TranscriptEditError::HistoryStateMalformed(ref message)
2991 if message.contains("checkpoint-bound EventStore reconciliation")),
2992 "unexpected error: {error}"
2993 );
2994 }
2995
2996 #[test]
2997 fn rewrite_prefix_wire_and_count_mismatch_refuse_fail_closed() {
2998 let malformed = serde_json::json!({
2999 "occurrence_count": 1,
3000 "digest": "sha256:ABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD"
3001 });
3002 assert!(
3003 serde_json::from_value::<TranscriptRewritePrefixAccumulator>(malformed).is_err(),
3004 "uppercase or otherwise non-canonical digest text must not enter authority"
3005 );
3006
3007 let records = rewrite_chain(2);
3008 let mut state = rebuild(&records);
3009 state.rewrite_prefix.occurrence_count = 1;
3010 assert!(
3011 validate_transcript_history_state(&state).is_err(),
3012 "the carried count/digest pair must match the exact graph occurrences"
3013 );
3014 }
3015 use crate::digest_observability::session_content_digest_bytes;
3016 use crate::session::{TranscriptRewriteReason, TranscriptRewriteSelection};
3017 use crate::types::UserMessage;
3018
3019 fn message(text: &str) -> Message {
3020 Message::User(UserMessage::text(text.to_string()))
3021 }
3022
3023 fn body(messages: Vec<Message>, parent: Option<&str>) -> TranscriptRevisionBody {
3024 let revision = transcript_messages_digest(&messages).expect("digest revision body");
3025 TranscriptRevisionBody {
3026 revision,
3027 parent_revision: parent.map(str::to_string),
3028 messages,
3029 created_at: SystemTime::UNIX_EPOCH,
3030 }
3031 }
3032
3033 #[test]
3034 fn released_importer_preserves_arbitrary_exact_parent_splice() {
3035 let retained_prefix = message("retained prefix");
3036 let retained_turn = message("retained turn");
3037 let released_replacement = message("released replacement");
3038 let released_appended_turn = message("released appended turn");
3039 let base = body(
3040 vec![
3041 retained_prefix.clone(),
3042 message("released base"),
3043 retained_turn.clone(),
3044 ],
3045 None,
3046 );
3047 let parent = body(
3048 vec![
3049 retained_prefix,
3050 released_replacement.clone(),
3051 retained_turn,
3052 released_appended_turn.clone(),
3053 ],
3054 Some(&base.revision),
3055 );
3056
3057 let advance = parent_advance_from_materialized(
3058 &base,
3059 &parent,
3060 2,
3061 MaterializedParentAdvanceSource::Released0810Import,
3062 )
3063 .expect("released historical relationship remains materializable");
3064 assert!(matches!(
3065 &advance,
3066 TranscriptParentAdvance::ExactSplice {
3067 at: 1,
3068 replacement,
3069 appended,
3070 } if replacement == &[released_replacement]
3071 && appended == &[released_appended_turn]
3072 ));
3073 let mut materialized = base.messages.clone();
3074 apply_parent_advance(&mut materialized, &advance)
3075 .expect("historical parent advance applies");
3076 assert_eq!(materialized, parent.messages);
3077 }
3078
3079 #[test]
3080 fn current_record_rebuild_refuses_non_append_parent_divergence() {
3081 let first_parent = body(vec![message("retained prefix"), message("question")], None);
3082 let first_revision = body(
3083 vec![message("retained prefix"), message("first edit")],
3084 Some(&first_parent.revision),
3085 );
3086 let first_commit = TranscriptRewriteCommit {
3087 rewrite_generation: 1,
3088 parent_revision: first_parent.revision.clone(),
3089 revision: first_revision.revision.clone(),
3090 selection: TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
3091 original_span_digest: transcript_messages_digest(&first_parent.messages[1..2])
3092 .expect("digest first original span"),
3093 replacement_digest: transcript_messages_digest(&first_revision.messages[1..2])
3094 .expect("digest first replacement span"),
3095 messages_before: first_parent.messages.len(),
3096 messages_after: first_revision.messages.len(),
3097 reason: TranscriptRewriteReason::new("unit-test"),
3098 actor: None,
3099 committed_at: SystemTime::UNIX_EPOCH,
3100 };
3101 let first_record =
3102 TranscriptRewriteRecord::new(first_commit, first_parent, first_revision.clone())
3103 .expect("first rewrite record is valid");
3104
3105 let second_parent = body(
3106 vec![message("divergent prefix"), message("first edit")],
3107 None,
3108 );
3109 let divergent_prefix = second_parent.messages[0].clone();
3110 let second_revision = body(
3111 vec![divergent_prefix, message("second edit")],
3112 Some(&second_parent.revision),
3113 );
3114 let second_commit = TranscriptRewriteCommit {
3115 rewrite_generation: 2,
3116 parent_revision: second_parent.revision.clone(),
3117 revision: second_revision.revision.clone(),
3118 selection: TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
3119 original_span_digest: transcript_messages_digest(&second_parent.messages[1..2])
3120 .expect("digest second original span"),
3121 replacement_digest: transcript_messages_digest(&second_revision.messages[1..2])
3122 .expect("digest second replacement span"),
3123 messages_before: second_parent.messages.len(),
3124 messages_after: second_revision.messages.len(),
3125 reason: TranscriptRewriteReason::new("unit-test"),
3126 actor: None,
3127 committed_at: SystemTime::UNIX_EPOCH,
3128 };
3129 let second_record =
3130 TranscriptRewriteRecord::new(second_commit, second_parent, second_revision)
3131 .expect("second rewrite record is internally valid");
3132
3133 let error = TranscriptHistoryState::from_rewrite_records([first_record, second_record])
3134 .expect_err("current audit reconstruction must reject a non-append parent bridge");
3135 assert!(
3136 matches!(error, TranscriptEditError::HistoryStateMalformed(ref message)
3137 if message.contains("current rewrite occurrence 2 parent is not an exact append")),
3138 "unexpected error: {error}"
3139 );
3140 }
3141
3142 #[test]
3143 fn current_record_cannot_request_legacy_compaction_semantic_healing() {
3144 let parent_body = body(vec![message("old turn"), message("newer turn")], None);
3145 let revision_body = body(
3146 vec![Message::User(UserMessage::compaction_summary("summary"))],
3147 Some(&parent_body.revision),
3148 );
3149 let commit = TranscriptRewriteCommit {
3150 rewrite_generation: 1,
3151 parent_revision: parent_body.revision.clone(),
3152 revision: revision_body.revision.clone(),
3153 selection: TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
3154 original_span_digest: transcript_messages_digest(&parent_body.messages)
3155 .expect("digest original transcript"),
3156 replacement_digest: transcript_messages_digest(&revision_body.messages)
3157 .expect("digest replacement transcript"),
3158 messages_before: parent_body.messages.len(),
3159 messages_after: revision_body.messages.len(),
3160 reason: TranscriptRewriteReason::new("current-untyped-selection"),
3161 actor: None,
3162 committed_at: SystemTime::UNIX_EPOCH,
3163 };
3164 let current_wire = serde_json::to_value(TranscriptRewriteRecord {
3165 commit,
3166 parent_body,
3167 revision_body,
3168 digest_format: TRANSCRIPT_DIGEST_FORMAT_CURRENT,
3169 })
3170 .expect("serialize current record");
3171
3172 let current: TranscriptRewriteRecord =
3173 serde_json::from_value(current_wire.clone()).expect("decode current record");
3174 assert!(
3175 current.commit.selection.is_legacy_untyped(),
3176 "a current-format record must not enter importer-only semantic healing"
3177 );
3178
3179 let mut explicit_released_wire = current_wire.clone();
3180 explicit_released_wire["digest_format"] =
3181 serde_json::json!(TRANSCRIPT_DIGEST_FORMAT_RELEASED_0810);
3182 let error = serde_json::from_value::<TranscriptRewriteRecord>(explicit_released_wire)
3183 .expect_err("explicit released records require the source-authorized importer");
3184 assert!(
3185 error
3186 .to_string()
3187 .contains("current transcript rewrite record digest format 2 is not supported"),
3188 "unexpected error: {error}"
3189 );
3190
3191 let mut released_wire = current_wire;
3192 released_wire
3193 .as_object_mut()
3194 .expect("record is an object")
3195 .remove("digest_format");
3196 let released: TranscriptRewriteRecord =
3197 serde_json::from_value(released_wire).expect("decode released record");
3198 assert!(
3199 !released.commit.selection.is_legacy_untyped(),
3200 "an unstamped released record retains its one-time typed compaction migration"
3201 );
3202 }
3203
3204 #[test]
3205 fn ordinary_current_graph_ingress_refuses_released_digest_format() {
3206 let current = rebuild(&rewrite_chain(1));
3207 let mut wire = serde_json::to_value(current).expect("current graph serializes");
3208 wire["digest_format"] = serde_json::json!(TRANSCRIPT_DIGEST_FORMAT_RELEASED_0810);
3209
3210 let error = serde_json::from_value::<TranscriptHistoryState>(wire)
3211 .expect_err("released format must cross only the explicit 0.8.10 importer");
3212 assert!(
3213 error
3214 .to_string()
3215 .contains("current transcript graph digest format 2 is not supported"),
3216 "unexpected error: {error}"
3217 );
3218 }
3219
3220 fn rewrite_chain(count: usize) -> Vec<TranscriptRewriteRecord> {
3224 let mut messages = (0..6)
3225 .map(|index| message(&format!("turn {index}")))
3226 .collect::<Vec<_>>();
3227 let mut records = Vec::with_capacity(count);
3228 for generation in 0..count {
3229 let parent_body = body(messages.clone(), None);
3230 messages[0] = message(&format!("system prompt generation {generation}"));
3231 let revision_body = body(messages.clone(), Some(&parent_body.revision));
3232 let commit = TranscriptRewriteCommit {
3233 rewrite_generation: u64::try_from(generation).expect("test generation fits u64")
3234 + 1,
3235 parent_revision: parent_body.revision.clone(),
3236 revision: revision_body.revision.clone(),
3237 selection: TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
3238 original_span_digest: transcript_messages_digest(&parent_body.messages[..1])
3239 .expect("digest original span"),
3240 replacement_digest: transcript_messages_digest(&revision_body.messages[..1])
3241 .expect("digest replacement span"),
3242 messages_before: parent_body.messages.len(),
3243 messages_after: revision_body.messages.len(),
3244 reason: TranscriptRewriteReason::new("adversarial-rewrite-chain"),
3245 actor: None,
3246 committed_at: SystemTime::UNIX_EPOCH,
3247 };
3248 records.push(
3249 TranscriptRewriteRecord::new(commit, parent_body, revision_body)
3250 .expect("chain record is valid"),
3251 );
3252 }
3253 records
3254 }
3255
3256 fn recurrence_chain() -> Vec<TranscriptRewriteRecord> {
3257 let a = body(vec![message("A")], None);
3258 let b = body(vec![message("B")], Some(&a.revision));
3259 let first = TranscriptRewriteRecord::new(
3260 TranscriptRewriteCommit {
3261 rewrite_generation: 1,
3262 parent_revision: a.revision.clone(),
3263 revision: b.revision.clone(),
3264 selection: TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
3265 original_span_digest: transcript_messages_digest(&a.messages).expect("digest A"),
3266 replacement_digest: transcript_messages_digest(&b.messages).expect("digest B"),
3267 messages_before: 1,
3268 messages_after: 1,
3269 reason: TranscriptRewriteReason::new("recurrence-test"),
3270 actor: None,
3271 committed_at: SystemTime::UNIX_EPOCH,
3272 },
3273 a.clone(),
3274 b.clone(),
3275 )
3276 .expect("A to B record");
3277 let returned_a = body(a.messages.clone(), Some(&b.revision));
3278 let second = TranscriptRewriteRecord::new(
3279 TranscriptRewriteCommit {
3280 rewrite_generation: 2,
3281 parent_revision: b.revision.clone(),
3282 revision: returned_a.revision.clone(),
3283 selection: TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
3284 original_span_digest: transcript_messages_digest(&b.messages).expect("digest B"),
3285 replacement_digest: transcript_messages_digest(&returned_a.messages)
3286 .expect("digest returned A"),
3287 messages_before: 1,
3288 messages_after: 1,
3289 reason: TranscriptRewriteReason::new("recurrence-test"),
3290 actor: None,
3291 committed_at: SystemTime::UNIX_EPOCH,
3292 },
3293 b,
3294 returned_a,
3295 )
3296 .expect("B to A record");
3297 vec![first, second]
3298 }
3299
3300 fn rebuild(records: &[TranscriptRewriteRecord]) -> TranscriptHistoryState {
3301 TranscriptHistoryState::from_rewrite_records(records.to_vec())
3302 .expect("rebuild from records")
3303 .expect("chain is non-empty")
3304 }
3305
3306 fn sealed(records: &[TranscriptRewriteRecord]) -> ValidatedTranscriptHistory {
3307 ValidatedTranscriptHistory::seal_owned(rebuild(records)).expect("rebuilt chain seals")
3308 }
3309
3310 fn hashed_bytes<T>(operation: impl FnOnce() -> T) -> (T, u64) {
3311 let before = session_content_digest_bytes();
3312 let value = operation();
3313 (value, session_content_digest_bytes() - before)
3314 }
3315
3316 fn assert_same_graph(left: &TranscriptHistoryState, right: &TranscriptHistoryState) {
3317 assert_eq!(
3318 serde_json::to_value(left).expect("left graph serializes"),
3319 serde_json::to_value(right).expect("right graph serializes"),
3320 "compact graph wires differ"
3321 );
3322 }
3323
3324 fn zero_edge_prefix(anchor_body: &TranscriptRevisionBody) -> TranscriptHistoryState {
3325 let anchor = TranscriptRevisionAnchor {
3326 revision: anchor_body.revision.clone(),
3327 messages: anchor_body.messages.clone(),
3328 row_prefix: SessionMessageRowPrefixAccumulator::from_messages(&anchor_body.messages)
3329 .expect("anchor row prefix"),
3330 created_at: anchor_body.created_at,
3331 };
3332 TranscriptHistoryState {
3333 format: TRANSCRIPT_HISTORY_FORMAT_CURRENT,
3334 graph_prefix: TranscriptGraphPrefixAccumulator::from_anchor(&anchor)
3335 .expect("anchor graph prefix"),
3336 anchor: Arc::new(anchor),
3337 edges: PersistentTranscriptEdges::empty(),
3338 rewrite_prefix: TranscriptRewritePrefixAccumulator::empty(),
3339 digest_format: TRANSCRIPT_DIGEST_FORMAT_CURRENT,
3340 }
3341 }
3342
3343 #[test]
3344 fn exact_graph_extension_accepts_only_the_same_zero_edge_anchor_authority() {
3345 let records = rewrite_chain(2);
3346 let full = rebuild(&records);
3347 let prefix = zero_edge_prefix(&records[0].parent_body);
3348 assert!(
3349 full.extends_exact_graph(&prefix),
3350 "the exact anchor is the zero-occurrence prefix of its rewrite graph"
3351 );
3352
3353 let foreign_anchor = body(vec![message("foreign anchor")], None);
3354 assert!(
3355 !full.extends_exact_graph(&zero_edge_prefix(&foreign_anchor)),
3356 "a different anchor cannot authorize a zero-occurrence prefix"
3357 );
3358
3359 let mut malformed = prefix;
3360 malformed.rewrite_prefix =
3361 TranscriptRewritePrefixAccumulator::from_commits(&[records[0].commit.clone()])
3362 .expect("one-commit prefix");
3363 assert!(
3364 !full.extends_exact_graph(&malformed),
3365 "zero edges cannot carry a non-empty rewrite prefix"
3366 );
3367 }
3368
3369 #[test]
3370 fn digest_only_ancestry_refuses_nonconsecutive_revision_recurrence() {
3371 let records = recurrence_chain();
3372 let state = rebuild(&records);
3373 let a = records[0].commit.parent_revision.as_str();
3374 let b = records[0].commit.revision.as_str();
3375
3376 assert_eq!(state.unique_revision_position(a), None);
3377 assert!(state.unique_revision_position(b).is_some());
3378 assert!(
3379 !state.revision_extends(a, b),
3380 "the later A cannot make digest-only A authorize the exact B occurrence"
3381 );
3382 assert!(
3383 !state.revision_extends(b, a),
3384 "the earlier A cannot make digest-only A authorize B in the other direction"
3385 );
3386 assert!(
3387 !state.revision_extends(a, a),
3388 "digest equality cannot bypass ambiguous A occurrence identity"
3389 );
3390 assert!(state.revision_extends(b, b));
3391 }
3392
3393 #[test]
3394 fn full_body_reconciliation_revalidates_bytes_even_when_commits_are_proved() {
3395 let records = rewrite_chain(6);
3396 let proved = sealed(&records);
3397 let (replayed, hashed) = hashed_bytes(|| {
3398 TranscriptHistoryState::from_rewrite_records_with_proved(records.clone(), Some(&proved))
3399 });
3400 let replayed = replayed.expect("replay succeeds").expect("non-empty");
3401 assert!(
3402 hashed > 0,
3403 "a compact edge cannot authorize arbitrary bytes in a legacy full-body record"
3404 );
3405 assert_same_graph(&replayed, &proved);
3406 }
3407
3408 #[test]
3409 fn full_body_reconciliation_cost_tracks_all_legacy_evidence() {
3410 let hash_one_new_record = |chain_len: usize| {
3411 let records = rewrite_chain(chain_len);
3412 let proved = sealed(&records[..chain_len - 1]);
3413 let (replayed, hashed) = hashed_bytes(|| {
3414 TranscriptHistoryState::from_rewrite_records_with_proved(
3415 records.clone(),
3416 Some(&proved),
3417 )
3418 });
3419 let replayed = replayed.expect("replay succeeds").expect("non-empty");
3420 assert_same_graph(&replayed, &rebuild(&records));
3421 assert!(
3422 hashed > 0,
3423 "the trailing record is not carried by the proved graph and must \
3424 be proved in full"
3425 );
3426 hashed
3427 };
3428 assert!(
3429 hash_one_new_record(8) > hash_one_new_record(2),
3430 "the one-time 0.8.10 reconciliation must validate every legacy full-body row"
3431 );
3432 }
3433
3434 #[test]
3439 fn a_corrupted_body_is_rejected_when_its_commit_is_proved() {
3440 let records = rewrite_chain(3);
3441 let proved = sealed(&records);
3442 let mut corrupted = records.clone();
3443 corrupted[1].revision_body.messages[3] = message("corrupted tail");
3444 let error =
3445 TranscriptHistoryState::from_rewrite_records_with_proved(corrupted, Some(&proved))
3446 .expect_err("a body that does not digest to its commit must be refused");
3447 assert!(
3448 matches!(error, TranscriptEditError::HistoryStateMalformed(_)),
3449 "unexpected error: {error}"
3450 );
3451 }
3452
3453 #[test]
3454 fn a_corrupted_new_record_is_rejected() {
3455 let records = rewrite_chain(3);
3456 let mut corrupted = records.clone();
3457 corrupted[2].parent_body.messages[3] = message("corrupted tail");
3458 let error = TranscriptHistoryState::from_rewrite_records(corrupted)
3459 .expect_err("a body that does not digest to its commit must be refused");
3460 assert!(
3461 matches!(error, TranscriptEditError::HistoryStateMalformed(_)),
3462 "unexpected error: {error}"
3463 );
3464 }
3465
3466 #[test]
3467 fn a_proved_prefix_cannot_launder_a_corrupted_tail_record() {
3468 let records = rewrite_chain(2);
3469 let proved = sealed(&records[..1]);
3470 let mut corrupted = records.clone();
3471 corrupted[1].revision_body.messages[3] = message("corrupted tail");
3472 let error =
3473 TranscriptHistoryState::from_rewrite_records_with_proved(corrupted, Some(&proved))
3474 .expect_err("a record whose endpoint the proved graph dropped is not proved");
3475 assert!(
3476 matches!(error, TranscriptEditError::HistoryStateMalformed(_)),
3477 "unexpected error: {error}"
3478 );
3479 }
3480
3481 #[test]
3482 fn a_body_mislabelled_against_its_commit_is_rejected_under_a_proved_graph() {
3483 let records = rewrite_chain(3);
3484 let proved = sealed(&records);
3485 let mut mislabelled = records.clone();
3486 mislabelled[1].parent_body.revision = "sha256:not-the-parent".to_string();
3487 let error =
3488 TranscriptHistoryState::from_rewrite_records_with_proved(mislabelled, Some(&proved))
3489 .expect_err("a body labelled with a revision it does not carry must be refused");
3490 assert!(
3491 matches!(error, TranscriptEditError::HistoryStateMalformed(_)),
3492 "unexpected error: {error}"
3493 );
3494 }
3495
3496 #[test]
3497 fn a_proved_replay_builds_the_same_graph_as_an_unproved_one() {
3498 let records = rewrite_chain(5);
3499 let proved = sealed(&records);
3500 let with_proof = TranscriptHistoryState::from_rewrite_records_with_proved(
3501 records.clone(),
3502 Some(&proved),
3503 )
3504 .expect("proved replay succeeds")
3505 .expect("non-empty");
3506 assert_same_graph(&with_proof, &rebuild(&records));
3507 validate_transcript_history_state(&with_proof)
3508 .expect("the proved replay's output is itself a valid graph");
3509 }
3510
3511 #[test]
3513 fn a_graph_does_not_serialize_the_retired_replay_cursor() {
3514 let state = rebuild(&rewrite_chain(3));
3515 let wire = serde_json::to_value(&state).expect("graph serializes");
3516 assert!(
3517 wire.get("replay_cursor").is_none(),
3518 "physical store position is not session-document authority: {wire}"
3519 );
3520 let decoded: TranscriptHistoryState = serde_json::from_value(wire).expect("graph decodes");
3521 assert_same_graph(&decoded, &state);
3522 }
3523
3524 #[test]
3528 fn a_replay_cursor_is_refused_by_the_current_graph_decoder() {
3529 let state = rebuild(&rewrite_chain(2));
3530 let mut wire = serde_json::to_value(&state).expect("graph serializes");
3531 wire["replay_cursor"] = serde_json::json!({
3532 "seq": 41,
3533 "commits": 2,
3534 "last_commit_revision": &state.commit(1).expect("second commit").revision,
3535 });
3536 assert!(
3537 serde_json::from_value::<TranscriptHistoryState>(wire).is_err(),
3538 "current graph ingress must reject the retired physical cursor"
3539 );
3540 }
3541
3542 #[test]
3543 fn current_graph_refuses_missing_parent_advance_evidence() {
3544 let state = rebuild(&rewrite_chain(3));
3545 let mut wire = serde_json::to_value(&state).expect("graph serializes");
3546 wire["edges"][0]
3547 .as_object_mut()
3548 .expect("edge wire is an object")
3549 .remove("parent_advance");
3550 assert!(
3551 serde_json::from_value::<TranscriptHistoryState>(wire).is_err(),
3552 "current ingress cannot synthesize missing parent-advance evidence"
3553 );
3554 }
3555
3556 #[test]
3557 fn null_parent_advance_evidence_is_malformed() {
3558 let state = rebuild(&rewrite_chain(2));
3559 let mut wire = serde_json::to_value(&state).expect("graph serializes");
3560 wire["edges"][0]["parent_advance"] = serde_json::Value::Null;
3561 assert!(
3562 serde_json::from_value::<TranscriptHistoryState>(wire).is_err(),
3563 "null parent-advance evidence is malformed current state"
3564 );
3565 }
3566}