Skip to main content

meerkat_core/session/transcript_history/
graph.rs

1//! Typed transcript revision-graph value types and their maintenance.
2//!
3//! Extracted verbatim from `session.rs`; the extraction commit changes
4//! no behaviour, only where the code lives.
5
6use 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/// Immutable rewrite commit that advances a session transcript head.
25#[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    /// Serialized occurrence identity within one session's rewrite lineage.
30    ///
31    /// Revision strings identify content, not occurrences: a legitimate
32    /// rewrite can return to earlier content, and two occurrences can even
33    /// carry byte-identical facts under a fixed/coarse clock. Current writers
34    /// therefore mint a strict contiguous `1..=n` generation. `0` exists only
35    /// as the decode marker for 0.8.10 audit rows/graphs, which are normalized
36    /// as an ordered set before they can enter current authority.
37    #[serde(default, skip_serializing_if = "rewrite_generation_is_unknown")]
38    pub rewrite_generation: u64,
39    /// Content-addressed audit label for the occurrence parent.
40    ///
41    /// Structural continuity is owned by the occurrence generation, compact
42    /// parent advance, exact row lineage, and graph prefix. This semantic
43    /// label is re-proved when the targeted parent is materialized; ordinary
44    /// append/commit validation never re-hashes every historical body.
45    pub parent_revision: String,
46    /// Content-addressed audit label for the occurrence result, with the same
47    /// lazy semantic-replay contract as [`Self::parent_revision`].
48    pub revision: String,
49    pub selection: TranscriptRewriteSelection,
50    /// Audit label for the removed semantic span. Exact selection coordinates
51    /// and row lineage are structural authority; this digest is checked on
52    /// targeted/final semantic materialization.
53    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/// Exact-byte relationship from one audited rewrite endpoint to the next
65/// commit's parent.
66///
67/// This evidence is aligned one-for-one with [`TranscriptHistoryState::commits`]
68/// and is validated against the retained bodies before a
69/// [`ValidatedTranscriptHistory`] may expose it. Current writers emit only
70/// `ExactAppend`; `ExactSplice` preserves a frozen same-cardinality
71/// relationship decoded by the explicit 0.8.10 importer.
72#[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/// Normalize one checkpoint-bound graph commit vector to current serialized
85/// occurrence identities.
86///
87/// A 0.8.10 graph's commit vector is itself the proved semantic order,
88/// including supported cycles such as `A -> B -> A`; assigning `1..=n` from
89/// that vector preserves rather than rediscovers its meaning. Current vectors
90/// must already carry the same strict contiguous sequence. Mixed zero/current
91/// vectors have no graph-writer provenance and refuse fail-closed.
92///
93/// Returns `true` only when a 0.8.10 all-zero vector was normalized.
94#[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// Domain policy: v1 is frozen over canonical `TranscriptRewriteCommit` JSON,
148// including `rewrite_generation`, plus the previous raw digest and big-endian
149// payload length below. Any change to occurrence semantics, commit
150// canonicalization, or step framing requires a new chain+step domain and a
151// coordinated EventStore sidecar schema bump; never reinterpret v1 bytes.
152
153/// Rolling, canonical identity of an ordered exact rewrite-commit prefix.
154///
155/// This is a semantic graph fact, not a replay-cursor assertion. It is carried
156/// by the graph, folded into checkpoint authority, and independently matched
157/// against the EventStore's receipt. One ordinary lineage-tail commit extends
158/// the accumulator with one commit serialization; it never re-hashes the
159/// accumulated prefix.
160#[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
286/// Canonical digest of an ordered, exact transcript-rewrite commit prefix.
287///
288/// Whole-blob validation and one-time 0.8.10 adoption use this rebuild seam.
289/// Ordinary writers extend [`TranscriptRewritePrefixAccumulator`] directly.
290pub 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
298/// Extend a previously bound rewrite-prefix digest over an ordered delta.
299pub 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/// Immutable transcript revision body retained by the session-local graph.
310#[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/// Exact released-0.8.10 commit shape.
347///
348/// The current commit carries a mandatory non-zero occurrence generation once
349/// it enters compact authority. Keeping the released decoder separate prevents
350/// a no-format candidate graph from laundering current-only fields through a
351/// defaulted generation.
352#[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
548/// Bounded diagnostic validator for the compact current graph only.
549///
550/// A released full-body graph is refused before its representation-specific
551/// decoder runs. Exact released semantic verification belongs to durable
552/// ingress and is necessarily O(graph).
553pub(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
587/// Explicit durable format of the compact transcript-history authority.
588///
589/// Released 0.8.10 graphs have no discriminator and carry full revision
590/// bodies. Current graphs must carry this exact value; a missing, empty, or
591/// unknown discriminator never falls through to current interpretation.
592pub 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/// Number of explicit historical-body materializations.
601///
602/// Ordinary current-format decode, encode, rewrite construction, checkpoint
603/// assembly, and WholeBlob preparation must leave this at zero. Restore and
604/// 0.8.10 transcode are the only expected producers.
605#[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/// The one full transcript body retained by the compact graph.
611#[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/// Exact delta from the preceding audited child to the next rewrite parent.
646///
647/// Current writers construct only [`Self::ExactAppend`]. [`Self::ExactSplice`]
648/// preserves an already-imported released 0.8.10 edge so historical audit
649/// materialization remains exact without assigning semantic privilege to any
650/// message role or transcript position.
651#[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/// Forward rewrite delta carried by one occurrence edge.
698///
699/// The removed span is committed by `original_span_digest` but is not retained
700/// here, so this patch can materialize the child from its parent; it cannot
701/// reconstruct the parent from the child.
702#[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/// Compact witness needed to relate an audited endpoint to a later live head.
724///
725/// The row lineage is mechanically derived from the preceding endpoint plus
726/// typed append/splice operations; it is never a producer-attested flat content
727/// root.
728#[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/// One ordered rewrite occurrence in the compact graph.
773///
774/// Structural authority is occurrence-first: generation, rolling graph and
775/// rewrite prefixes, exact parent/result row lineage, and the typed delta are
776/// sufficient to persist/replay the occurrence without reconstructing every
777/// preceding document. Commit revision/span digests remain checkpoint-bound
778/// semantic audit labels and are re-proved lazily when a body is requested;
779/// cold ingress performs one final-endpoint semantic replay.
780#[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    /// Decode one exact current compact edge for cold replay.
800    ///
801    /// This is a strict wire decoder (`deny_unknown_fields`). The returned
802    /// edge is not authority by itself: a caller must install it through a
803    /// checkpoint/graph-prefix proved replay sequence.
804    #[doc(hidden)]
805    pub fn from_replay_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
806        serde_json::from_slice(bytes)
807    }
808
809    /// Stable exact bytes persisted beside the physical rewrite delta.
810    #[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/// Rolling identity of the exact compact anchor and occurrence-edge sequence.
892#[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/// Self-contained append-only transcript rewrite record.
1019#[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    /// Digest-format generation of this record's revision strings. Records
1027    /// stamped with the current generation were written by the canonical
1028    /// content-addressed digest format, so
1029    /// decode skips the per-decode legacy-heal probe (a full-transcript hash
1030    /// of BOTH bodies); absent/0 means unknown provenance and the probe runs,
1031    /// exactly as it did before the marker existed. A compatibility
1032    /// convenience, not an integrity boundary: the record's own validation
1033    /// against its commit owns integrity, and a stamped record that does not
1034    /// validate is rejected exactly as an unstamped one is.
1035    ///
1036    /// Records are append-only and never restamped in place, so this skips
1037    /// the probe only for records minted from the version that added it.
1038    #[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        // Current records skip the O(document) heal. Only truly unstamped
1061        // pre-marker records enter the older generic healer. The explicit
1062        // released 0.8.10 format must cross its source-authorized importer,
1063        // which remaps every revision and span identity together.
1064        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            // The only non-current accepted input was fully healed above.
1093            digest_format: TRANSCRIPT_DIGEST_FORMAT_CURRENT,
1094        })
1095    }
1096}
1097
1098impl TranscriptRewriteRecord {
1099    /// Validate this already-owned record without cloning either transcript body.
1100    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/// Structurally shared append-only occurrence storage.
1141///
1142/// Session clones share one tail node. Appending a rewrite allocates exactly
1143/// one edge and one node; it never copies the accumulated edge vector. The
1144/// chronological `ordered` projection is populated only at explicit
1145/// validation/wire/restore boundaries.
1146#[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    /// Rolling compact graph identity after exactly `edge_count` occurrences.
1225    ///
1226    /// The backward walk visits only the suffix after that occurrence. Current
1227    /// terminal lookup is O(1); proving an observed predecessor for `k` pending
1228    /// edges is O(k), never O(accumulated history).
1229    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    /// Collect only the chronological suffix beginning at `start`.
1241    ///
1242    /// This is the ordinary hot-path projection. It walks and retains exactly
1243    /// `len - start` edge Arcs and never populates the full ordered cache.
1244    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/// Typed session-local transcript revision authority.
1279///
1280/// The anchor and ordered occurrence edges are the ONE in-memory and durable
1281/// representation. Full historical bodies are not cached beside them.
1282/// Structural validation is edge-local and occurrence-ordered; it does not
1283/// claim that every intermediate semantic label was eagerly re-hashed.
1284#[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 generation of the revision strings. Documents stamped
1292    /// equal to the current generation were written by the canonical
1293    /// content-addressed digest format, so decode
1294    /// skips the per-decode legacy-heal probe (a full-transcript hash);
1295    /// absent/0 means unknown provenance and the probe runs once — the next
1296    /// save persists the marker. A compatibility convenience, not an
1297    /// integrity boundary: validated graph ingress and store-owned physical
1298    /// authorities own integrity.
1299    digest_format: u32,
1300}
1301
1302/// Published shape of the durable transcript graph.
1303///
1304/// [`TranscriptHistoryState`] serializes through a hand-written impl, so a
1305/// DERIVED schema over the typed struct would advertise `revisions` as an
1306/// array of full [`TranscriptRevisionBody`] objects — bytes this writer no
1307/// longer produces. Deriving a schema over a custom `Serialize` is exactly how
1308/// a published schema comes to describe behaviour the code does not have, so
1309/// the schema is taken from this mirror of the real wire form and
1310/// [`TranscriptHistoryState`]'s `JsonSchema` forwards to it.
1311#[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
1339/// The digest-format generation minted by [`transcript_messages_digest`].
1340pub(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
1405/// One-time 0.8.10 importer for the released full-body graph wire.
1406///
1407/// Normal [`TranscriptHistoryState`] deserialization is deliberately
1408/// current-only. A released graph can cross this seam only after the enclosing
1409/// importer has validated its exact released shape under store-owned source
1410/// authority.
1411pub(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/// Semantic result of rebinding one proven released-0.8.10 rewrite.
1430#[derive(Debug)]
1431#[doc(hidden)]
1432pub enum ProvenReleased0810RewriteRemap {
1433    /// The rewrite remains semantically distinct under current identity.
1434    Retained(TranscriptRewriteRecord),
1435    /// The released endpoints differ only in spelling erased by current
1436    /// canonical identity. The occurrence is a proven semantic no-op.
1437    Collapsed {
1438        /// The singular current identity shared by both released endpoints.
1439        current_revision: String,
1440    },
1441}
1442
1443/// Rebind one exact released-0.8.10 rewrite record to current semantic ids.
1444///
1445/// This helper owns the semantic half of the one-time migration. Callers must
1446/// first bind the exact released document to store-issued physical source
1447/// authority. The helper validates the observable released topology, requires
1448/// canonical old digest labels and exact child lineage, then replaces both
1449/// endpoint ids and both span ids together.
1450///
1451/// Ordinary current ingress must never call this. It rejects explicit format-2
1452/// records instead of silently healing them.
1453#[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    // A singleton physical row does not carry the body preceding its parent,
1526    // so this non-authoritative reconstruction hint cannot be remapped here.
1527    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
1534/// Rebind one store-authorized released graph from format-2 semantic ids to
1535/// the current digest generation.
1536///
1537/// This is importer-only and necessarily O(document): every retained 0.8.10
1538/// body crossed the store-authorized source boundary and the exact retained
1539/// rewrite topology was proved. Each body then receives its current content
1540/// address exactly once before the compact occurrence graph is built. Lost
1541/// pre-buffer `RawValue` spelling is never guessed or re-proved.
1542fn 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(&current_revision)
1602                    || remap.get(&commit.revision) != Some(&current_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(&current) 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    /// Rebuild the exact compact graph already proved by a HeadCanonical
1687    /// store's physical row/edge replay.
1688    ///
1689    /// The store supplies only the original anchor document and the ordered
1690    /// compact edge vector. This constructor rebinds those facts to the
1691    /// store-owned rewrite/graph prefixes and performs one semantic graph
1692    /// validation. It never materializes one full document per rewrite.
1693    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    /// Current compact wire format.
2010    #[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    /// Explicit chronological projection for wire/cold validation/restore.
2063    ///
2064    /// The first call after an append is O(history). Ordinary persistence must
2065    /// use a sealed rewrite suffix instead.
2066    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    /// Whether `prefix` is the exact structural prefix of this graph.
2080    ///
2081    /// Both values are construction- or decode-validated, so matching the
2082    /// rolling graph prefix at the predecessor's occurrence count binds the
2083    /// anchor and every exact ordered edge without materializing historical
2084    /// bodies or comparing a parallel commit vector.
2085    #[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    /// Unique logical occurrence position of one content revision.
2102    ///
2103    /// The child of every rewrite edge is a new logical occurrence even when
2104    /// its content digest equals its parent (`A -> A`). The parent of a later
2105    /// edge reuses the preceding child only when both labels are equal; a
2106    /// different parent label is an append-derived intermediate endpoint.
2107    ///
2108    /// Any digest that labels more than one of those logical occurrences is
2109    /// ambiguous by digest alone and returns `None`; callers holding a commit
2110    /// or graph prefix must use that exact occurrence authority instead.
2111    #[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            // A rewrite always mints a new occurrence. Do not collapse an
2150            // exact-content no-op edge: its generation is still durable
2151            // authority and digest-only callers cannot choose either side.
2152            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    /// Digest-only ancestry, restricted to unique logical occurrences.
2166    ///
2167    /// Equality does not bypass occurrence resolution: in `A -> B -> A`, even
2168    /// `revision_extends(A, A)` is ambiguous. Exact commit/generation or graph
2169    /// prefix authority is required to distinguish those two `A` occurrences.
2170    #[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    /// Whether this graph contains this exact rewrite occurrence.
2182    #[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    /// Compact graph identity after an exact positive occurrence count.
2193    ///
2194    /// `None` for zero means the physical head predates any retained graph.
2195    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    /// Cold ingress: replay the final semantic endpoint exactly once, then
2209    /// derive the operation-lineage relation installed on the warm Session.
2210    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    /// The current graph is already canonical. Kept as a narrow compatibility
2265    /// seam for callers that previously pruned full-body snapshots.
2266    pub(crate) fn compact_mechanical_revision_bodies(&mut self) -> Result<(), TranscriptEditError> {
2267        validate_transcript_history_state(self)?;
2268        Ok(())
2269    }
2270
2271    /// Current compact construction never carries mechanical bodies.
2272    pub(crate) fn canonicalize_to_latest_audited_head(&mut self) {
2273        // `head()` is derived from the final edge.
2274    }
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    /// Explicit exceptional materialization for restore/audit consumers.
2315    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    /// Explicitly materialize the parent of one exact rewrite occurrence.
2351    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    /// Explicit compatibility view for diagnostics and 0.8.10 reconciliation.
2439    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/// Decode the relationship carried by already-materialized audit bodies.
2459/// Current audit reconstruction admits only
2460/// [`TranscriptParentAdvance::ExactAppend`]; the explicit released-0.8.10
2461/// importer alone may decode a frozen same-cardinality splice.
2462#[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
2649/// Whether `proved` already carries every fact
2650/// [`validate_transcript_rewrite_record`] would derive for `record`.
2651///
2652/// That validator proves relations among exactly three values: the commit and
2653/// the two endpoint message vectors. This returns true only when the proved
2654/// graph holds all three — a byte-equal commit, and endpoint bodies whose
2655/// messages equal the record's — so the relations it would derive are the ones
2656/// [`validate_transcript_history_state`] already derived over those same three
2657/// values when `proved` was sealed.
2658///
2659/// The message equality is what stands in for the hash, and it is a proof
2660/// rather than a heuristic: [`transcript_messages_digest`] is a pure function
2661/// of the message vector, so a vector equal to one already verified against a
2662/// revision string digests to that same string. A body that no longer digests
2663/// to its commit, a body the proved graph does not retain, a commit it does not
2664/// carry, or a body whose own revision label disagrees with the commit all
2665/// return false and take the full validation, which rejects them exactly as
2666/// before.
2667pub(super) fn record_is_proved_by(
2668    _proved: Option<&ValidatedTranscriptHistory>,
2669    _record: &TranscriptRewriteRecord,
2670) -> bool {
2671    // A compact edge proves its delta and checkpoint-bound identities. It
2672    // cannot prove arbitrary bytes elsewhere in a legacy record's two full
2673    // bodies. Released 0.8.10 reconciliation therefore validates those bodies
2674    // once; current receipt-only replay never presents this full-body type.
2675    false
2676}
2677
2678impl TranscriptHistoryState {
2679    /// Rebuild transcript revision graph state from append-only rewrite records.
2680    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    /// [`Self::from_rewrite_records`] against a graph that already proves some
2688    /// of the log.
2689    ///
2690    /// Every authoritative load used to re-prove EVERY record in the log, and
2691    /// a rewrite record carries two FULL transcript bodies, so resume cost grew
2692    /// as retained-revisions x transcript — quadratic over a session's life.
2693    /// `proved` is the session's own validated graph: already in memory,
2694    /// already hashed. A record it covers needs no second hash pass; a record
2695    /// it does not cover is validated in full, unchanged. Integrity is not
2696    /// traded for the saving — `record_is_proved_by` documents exactly what
2697    /// "covers" has to mean before a proof may be skipped.
2698    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        // A physical/audit vector is not occurrence authority. Exact-equal
2710        // 0.8.10 rows are information-theoretically ambiguous, so assigning
2711        // generation from caller order here could erase or invent a real
2712        // cycle occurrence. The one-time EventStore reconciliation must map
2713        // legacy rows against the checkpoint-bound graph Vec first and pass
2714        // only generation-bearing records to this generic rebuild seam.
2715        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    /// A chain of `count` rewrites over a fixed-length transcript, each
3221    /// replacing message 0. This is retained as adversarial audit-record input;
3222    /// new System messages are ordered appends and never mint it.
3223    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    /// The digest is unkeyed, so what a proved replay must still refuse is a
3435    /// body whose bytes no longer produce its revision string — accidental
3436    /// corruption, not a modification anyone able to write the log could not
3437    /// simply re-derive a matching digest for.
3438    #[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    /// Current graphs never serialize the retired store-position marker.
3512    #[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    /// A physical log cursor belongs only to the frozen 0.8.10 importer. The
3525    /// current compact graph decoder refuses it rather than silently accepting
3526    /// store position as session-document authority.
3527    #[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}