Skip to main content

u280_hardware_promoter/
journal.rs

1//! Private, hash-chained promotion anti-replay journal model.
2
3use crate::canonical::{canonical_line, is_lower_hex, parse_canonical_line, sha256_hex};
4use crate::coordination::{
5    CoordinationBurnGrantHashInput, MAXIMUM_COORDINATION_INTENT_BYTES, VerifiedCoordinationBurn,
6    VerifiedCoordinationSnapshot, recompute_coordination_burn_record_sha256,
7};
8use crate::{FailureCode, PromotionError, PromotionRequest, PromotionResult};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeSet;
11
12const PREACCEPTANCE_JOURNAL_SCHEMA: u64 = 1;
13const PREACCEPTANCE_JOURNAL_CONTRACT: &str =
14    "hashsigs-u280-hardware-promotion-preacceptance-journal-v1";
15const LEGACY_JOURNAL_SCHEMA: u64 = 1;
16const LEGACY_JOURNAL_CONTRACT: &str = "hashsigs-u280-hardware-promotion-journal-v1";
17const GENESIS_SHA256: &str = "0000000000000000000000000000000000000000000000000000000000000000";
18const MAXIMUM_JOURNAL_BYTES: usize = 64 * 1024 * 1024;
19const MAXIMUM_RECORD_BYTES: usize = 128 * 1024;
20
21/// Immutable evidence identities consumed by one promotion attempt.
22#[derive(Clone, Debug, Eq, PartialEq)]
23pub(crate) struct PromotionIdentity {
24    run_id: String,
25    intent_bytes: u64,
26    intent_sha256: String,
27    grants: Vec<PromotionGrantBinding>,
28    route_seal_sha256: String,
29    transport_manifest_sha256: String,
30    oracle_receipt_sha256: String,
31    oracle_result_manifest_sha256: String,
32    coordination_burn_sequence: u64,
33    coordination_burn_previous_sha256: String,
34    coordination_burn_record_sha256: String,
35}
36
37/// Opaque run/intent/burn identity shared by the request-derived document,
38/// durable state burn, and atomic publication transaction.
39#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
40pub struct PromotionTransactionIdentity {
41    run_id: String,
42    intent_sha256: String,
43    coordination_burn_record_sha256: String,
44}
45
46impl PromotionTransactionIdentity {
47    /// Exact Phase-B run identity.
48    #[must_use]
49    pub fn run_id(&self) -> &str {
50        &self.run_id
51    }
52}
53
54impl PromotionIdentity {
55    /// Builds an anti-replay identity from a retained coordination snapshot
56    /// and exact digests revalidated by the eventual evidence combiner.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`FailureCode::CoordinationChain`] if retained snapshot
61    /// revalidation fails, [`FailureCode::IdentityJoin`] if the request and
62    /// selected burn disagree, or [`FailureCode::StateBurn`] if any supplied
63    /// artifact digest is not a canonical lowercase SHA-256.
64    #[allow(dead_code)] // Called only by the future complete evidence combiner.
65    pub(crate) fn from_verified_evidence(
66        request: &PromotionRequest,
67        snapshot: &VerifiedCoordinationSnapshot,
68        route_seal_sha256: &str,
69        transport_manifest_sha256: &str,
70        oracle_receipt_sha256: &str,
71        oracle_result_manifest_sha256: &str,
72    ) -> PromotionResult<Self> {
73        snapshot.revalidate()?;
74        let identity = Self::from_revalidated_burn(
75            request,
76            snapshot.selected_burn(),
77            route_seal_sha256,
78            transport_manifest_sha256,
79            oracle_receipt_sha256,
80            oracle_result_manifest_sha256,
81        )?;
82        snapshot.revalidate()?;
83        Ok(identity)
84    }
85
86    fn from_revalidated_burn(
87        request: &PromotionRequest,
88        burn: &VerifiedCoordinationBurn,
89        route_seal_sha256: &str,
90        transport_manifest_sha256: &str,
91        oracle_receipt_sha256: &str,
92        oracle_result_manifest_sha256: &str,
93    ) -> PromotionResult<Self> {
94        let digests = [
95            route_seal_sha256,
96            transport_manifest_sha256,
97            oracle_receipt_sha256,
98            oracle_result_manifest_sha256,
99        ];
100        if digests
101            .into_iter()
102            .any(|digest| !is_lower_hex(digest, 64, false))
103        {
104            return Err(PromotionError::new(
105                FailureCode::StateBurn,
106                "promotion identity contains a non-canonical artifact SHA-256",
107            ));
108        }
109        if request.run_id() != burn.run_id() {
110            return Err(PromotionError::new(
111                FailureCode::IdentityJoin,
112                "validated promotion request and selected coordination burn identify different runs",
113            ));
114        }
115        let grants = burn
116            .grants()
117            .iter()
118            .map(|grant| PromotionGrantBinding {
119                ledger_id: grant.ledger_id().to_owned(),
120                entry_id: grant.entry_id().to_owned(),
121                entry_bytes: grant.entry_bytes(),
122                entry_sha256: grant.entry_sha256().to_owned(),
123            })
124            .collect::<Vec<_>>();
125        let grant_hash_inputs = grants
126            .iter()
127            .map(|grant| {
128                CoordinationBurnGrantHashInput::new(
129                    &grant.ledger_id,
130                    &grant.entry_id,
131                    grant.entry_bytes,
132                    &grant.entry_sha256,
133                )
134            })
135            .collect::<Vec<_>>();
136        if recompute_coordination_burn_record_sha256(
137            burn.sequence(),
138            burn.run_id(),
139            burn.intent_bytes(),
140            burn.intent_sha256(),
141            &grant_hash_inputs,
142            burn.previous_sha256(),
143        )? != burn.record_sha256()
144        {
145            return Err(PromotionError::new(
146                FailureCode::IdentityJoin,
147                "verified coordination burn copied fields do not recompute",
148            ));
149        }
150        Ok(Self {
151            run_id: burn.run_id().to_owned(),
152            intent_bytes: burn.intent_bytes(),
153            intent_sha256: burn.intent_sha256().to_owned(),
154            grants,
155            route_seal_sha256: route_seal_sha256.to_owned(),
156            transport_manifest_sha256: transport_manifest_sha256.to_owned(),
157            oracle_receipt_sha256: oracle_receipt_sha256.to_owned(),
158            oracle_result_manifest_sha256: oracle_result_manifest_sha256.to_owned(),
159            coordination_burn_sequence: burn.sequence(),
160            coordination_burn_previous_sha256: burn.previous_sha256().to_owned(),
161            coordination_burn_record_sha256: burn.record_sha256().to_owned(),
162        })
163    }
164
165    /// Typed identity that must match the document, durable burn, and output.
166    #[must_use]
167    pub(crate) fn transaction_identity(&self) -> PromotionTransactionIdentity {
168        PromotionTransactionIdentity {
169            run_id: self.run_id.clone(),
170            intent_sha256: self.intent_sha256.clone(),
171            coordination_burn_record_sha256: self.coordination_burn_record_sha256.clone(),
172        }
173    }
174}
175
176#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
177#[serde(deny_unknown_fields)]
178struct PromotionGrantBinding {
179    ledger_id: String,
180    entry_id: String,
181    entry_bytes: u64,
182    entry_sha256: String,
183}
184
185#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
186#[serde(deny_unknown_fields)]
187struct PromotionGrantReplayKey {
188    ledger_id: String,
189    entry_id: String,
190    entry_sha256: String,
191}
192
193impl PromotionGrantBinding {
194    fn replay_key(&self) -> PromotionGrantReplayKey {
195        PromotionGrantReplayKey {
196            ledger_id: self.ledger_id.clone(),
197            entry_id: self.entry_id.clone(),
198            entry_sha256: self.entry_sha256.clone(),
199        }
200    }
201}
202
203/// One canonical preacceptance private-state journal record.
204///
205/// This transitional wire is not the final promotion-journal v2 contract and
206/// cannot carry a hardware-acceptance claim. The final wire remains undefined
207/// until `FinalEvidenceJoin` exists.
208#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
209#[serde(deny_unknown_fields)]
210pub struct PreacceptanceJournalRecord {
211    schema: u64,
212    contract: String,
213    sequence: u64,
214    run_id: String,
215    intent_bytes: u64,
216    intent_sha256: String,
217    grants: Vec<PromotionGrantBinding>,
218    route_seal_sha256: String,
219    transport_manifest_sha256: String,
220    oracle_receipt_sha256: String,
221    oracle_result_manifest_sha256: String,
222    coordination_burn_sequence: u64,
223    coordination_burn_previous_sha256: String,
224    coordination_burn_record_sha256: String,
225    previous_promotion_record_sha256: String,
226    record_sha256: String,
227}
228
229impl PreacceptanceJournalRecord {
230    /// Monotonic zero-based private journal sequence.
231    #[must_use]
232    pub const fn sequence(&self) -> u64 {
233        self.sequence
234    }
235
236    /// SHA-256 of the canonical record body.
237    #[must_use]
238    pub fn record_sha256(&self) -> &str {
239        &self.record_sha256
240    }
241
242    pub(crate) fn transaction_identity(&self) -> PromotionTransactionIdentity {
243        PromotionTransactionIdentity {
244            run_id: self.run_id.clone(),
245            intent_sha256: self.intent_sha256.clone(),
246            coordination_burn_record_sha256: self.coordination_burn_record_sha256.clone(),
247        }
248    }
249
250    /// Canonical newline-terminated bytes appended to private state.
251    ///
252    /// # Errors
253    ///
254    /// Returns an error only if serialization unexpectedly fails.
255    pub fn canonical_line(&self) -> PromotionResult<Vec<u8>> {
256        canonical_line(self)
257    }
258
259    fn validate_shape(&self) -> PromotionResult<()> {
260        let correct_grants = self.grants.len() == 2
261            && self.grants[0].ledger_id == "hashsigs-project-coordination"
262            && self.grants[1].ledger_id == "ed25519-agent-coordination"
263            && self.grants[0].entry_id != self.grants[1].entry_id
264            && self.grants.iter().all(|grant| {
265                !grant.entry_id.is_empty()
266                    && grant.entry_id.len() <= 128
267                    && grant.entry_id.bytes().all(|byte| {
268                        byte.is_ascii_lowercase()
269                            || byte.is_ascii_digit()
270                            || matches!(byte, b'.' | b'_' | b':' | b'-')
271                    })
272                    && grant.entry_bytes > 0
273                    && is_lower_hex(&grant.entry_sha256, 64, true)
274            });
275        let digests = [
276            self.intent_sha256.as_str(),
277            self.route_seal_sha256.as_str(),
278            self.transport_manifest_sha256.as_str(),
279            self.oracle_receipt_sha256.as_str(),
280            self.oracle_result_manifest_sha256.as_str(),
281            self.coordination_burn_previous_sha256.as_str(),
282            self.coordination_burn_record_sha256.as_str(),
283            self.previous_promotion_record_sha256.as_str(),
284            self.record_sha256.as_str(),
285        ];
286        if self.schema != PREACCEPTANCE_JOURNAL_SCHEMA
287            || self.contract != PREACCEPTANCE_JOURNAL_CONTRACT
288            || !is_lower_hex(&self.run_id, 32, true)
289            || self.intent_bytes == 0
290            || self.intent_bytes > MAXIMUM_COORDINATION_INTENT_BYTES
291            || !correct_grants
292            || digests
293                .into_iter()
294                .any(|digest| !is_lower_hex(digest, 64, false))
295        {
296            return Err(PromotionError::new(
297                FailureCode::StateBurn,
298                "promotion journal record has a non-canonical shape",
299            ));
300        }
301        if self.coordination_burn_sequence == 0
302            && self.coordination_burn_previous_sha256 != GENESIS_SHA256
303        {
304            return Err(PromotionError::new(
305                FailureCode::StateBurn,
306                "sequence-zero copied coordination burn does not use the fixed genesis predecessor",
307            ));
308        }
309        let grants = self
310            .grants
311            .iter()
312            .map(|grant| {
313                CoordinationBurnGrantHashInput::new(
314                    &grant.ledger_id,
315                    &grant.entry_id,
316                    grant.entry_bytes,
317                    &grant.entry_sha256,
318                )
319            })
320            .collect::<Vec<_>>();
321        if recompute_coordination_burn_record_sha256(
322            self.coordination_burn_sequence,
323            &self.run_id,
324            self.intent_bytes,
325            &self.intent_sha256,
326            &grants,
327            &self.coordination_burn_previous_sha256,
328        )? != self.coordination_burn_record_sha256
329        {
330            return Err(PromotionError::new(
331                FailureCode::StateBurn,
332                "preacceptance journal copied coordination burn does not recompute",
333            ));
334        }
335        Ok(())
336    }
337
338    fn recomputed_sha256(&self) -> PromotionResult<String> {
339        let body = PreacceptanceJournalBody {
340            schema: self.schema,
341            contract: self.contract.clone(),
342            sequence: self.sequence,
343            run_id: self.run_id.clone(),
344            intent_bytes: self.intent_bytes,
345            intent_sha256: self.intent_sha256.clone(),
346            grants: self.grants.clone(),
347            route_seal_sha256: self.route_seal_sha256.clone(),
348            transport_manifest_sha256: self.transport_manifest_sha256.clone(),
349            oracle_receipt_sha256: self.oracle_receipt_sha256.clone(),
350            oracle_result_manifest_sha256: self.oracle_result_manifest_sha256.clone(),
351            coordination_burn_sequence: self.coordination_burn_sequence,
352            coordination_burn_previous_sha256: self.coordination_burn_previous_sha256.clone(),
353            coordination_burn_record_sha256: self.coordination_burn_record_sha256.clone(),
354            previous_promotion_record_sha256: self.previous_promotion_record_sha256.clone(),
355        };
356        body.sha256()
357    }
358}
359
360#[derive(Serialize)]
361struct PreacceptanceJournalBody {
362    schema: u64,
363    contract: String,
364    sequence: u64,
365    run_id: String,
366    intent_bytes: u64,
367    intent_sha256: String,
368    grants: Vec<PromotionGrantBinding>,
369    route_seal_sha256: String,
370    transport_manifest_sha256: String,
371    oracle_receipt_sha256: String,
372    oracle_result_manifest_sha256: String,
373    coordination_burn_sequence: u64,
374    coordination_burn_previous_sha256: String,
375    coordination_burn_record_sha256: String,
376    previous_promotion_record_sha256: String,
377}
378
379impl PreacceptanceJournalBody {
380    fn sha256(&self) -> PromotionResult<String> {
381        let mut bytes = canonical_line(self)?;
382        let newline = bytes.pop();
383        debug_assert_eq!(newline, Some(b'\n'));
384        Ok(sha256_hex(&bytes))
385    }
386}
387
388/// The historical promotion-journal v1 is accepted only as an already-consumed
389/// replay prefix. New records are never emitted in this shape because it
390/// omitted intent and grant entry byte counts, so its copied burn cannot be
391/// independently recomputed.
392#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
393#[serde(deny_unknown_fields)]
394struct LegacyPromotionJournalRecord {
395    schema: u64,
396    contract: String,
397    sequence: u64,
398    run_id: String,
399    intent_sha256: String,
400    grants: Vec<PromotionGrantReplayKey>,
401    route_seal_sha256: String,
402    transport_manifest_sha256: String,
403    oracle_receipt_sha256: String,
404    oracle_result_manifest_sha256: String,
405    coordination_burn_sequence: u64,
406    coordination_burn_previous_sha256: String,
407    coordination_burn_record_sha256: String,
408    previous_promotion_record_sha256: String,
409    record_sha256: String,
410}
411
412impl LegacyPromotionJournalRecord {
413    fn validate_shape(&self) -> PromotionResult<()> {
414        let correct_grants = self.grants.len() == 2
415            && self.grants[0].ledger_id == "hashsigs-project-coordination"
416            && self.grants[1].ledger_id == "ed25519-agent-coordination"
417            && self.grants[0].entry_id != self.grants[1].entry_id
418            && self.grants.iter().all(|grant| {
419                canonical_entry_id(&grant.entry_id) && is_lower_hex(&grant.entry_sha256, 64, true)
420            });
421        let digests = [
422            self.intent_sha256.as_str(),
423            self.route_seal_sha256.as_str(),
424            self.transport_manifest_sha256.as_str(),
425            self.oracle_receipt_sha256.as_str(),
426            self.oracle_result_manifest_sha256.as_str(),
427            self.coordination_burn_previous_sha256.as_str(),
428            self.coordination_burn_record_sha256.as_str(),
429            self.previous_promotion_record_sha256.as_str(),
430            self.record_sha256.as_str(),
431        ];
432        if self.schema != LEGACY_JOURNAL_SCHEMA
433            || self.contract != LEGACY_JOURNAL_CONTRACT
434            || !is_lower_hex(&self.run_id, 32, true)
435            || !correct_grants
436            || digests
437                .into_iter()
438                .any(|digest| !is_lower_hex(digest, 64, false))
439        {
440            return Err(PromotionError::new(
441                FailureCode::StateBurn,
442                "legacy promotion journal record has a non-canonical shape",
443            ));
444        }
445        if self.coordination_burn_sequence == 0
446            && self.coordination_burn_previous_sha256 != GENESIS_SHA256
447        {
448            return Err(PromotionError::new(
449                FailureCode::StateBurn,
450                "sequence-zero legacy copied coordination burn does not use the fixed genesis predecessor",
451            ));
452        }
453        Ok(())
454    }
455
456    fn recomputed_sha256(&self) -> PromotionResult<String> {
457        LegacyPromotionJournalBody {
458            schema: self.schema,
459            contract: self.contract.clone(),
460            sequence: self.sequence,
461            run_id: self.run_id.clone(),
462            intent_sha256: self.intent_sha256.clone(),
463            grants: self.grants.clone(),
464            route_seal_sha256: self.route_seal_sha256.clone(),
465            transport_manifest_sha256: self.transport_manifest_sha256.clone(),
466            oracle_receipt_sha256: self.oracle_receipt_sha256.clone(),
467            oracle_result_manifest_sha256: self.oracle_result_manifest_sha256.clone(),
468            coordination_burn_sequence: self.coordination_burn_sequence,
469            coordination_burn_previous_sha256: self.coordination_burn_previous_sha256.clone(),
470            coordination_burn_record_sha256: self.coordination_burn_record_sha256.clone(),
471            previous_promotion_record_sha256: self.previous_promotion_record_sha256.clone(),
472        }
473        .sha256()
474    }
475}
476
477#[derive(Serialize)]
478struct LegacyPromotionJournalBody {
479    schema: u64,
480    contract: String,
481    sequence: u64,
482    run_id: String,
483    intent_sha256: String,
484    grants: Vec<PromotionGrantReplayKey>,
485    route_seal_sha256: String,
486    transport_manifest_sha256: String,
487    oracle_receipt_sha256: String,
488    oracle_result_manifest_sha256: String,
489    coordination_burn_sequence: u64,
490    coordination_burn_previous_sha256: String,
491    coordination_burn_record_sha256: String,
492    previous_promotion_record_sha256: String,
493}
494
495impl LegacyPromotionJournalBody {
496    fn sha256(&self) -> PromotionResult<String> {
497        let mut bytes = canonical_line(self)?;
498        let newline = bytes.pop();
499        debug_assert_eq!(newline, Some(b'\n'));
500        Ok(sha256_hex(&bytes))
501    }
502}
503
504#[derive(Deserialize, Serialize)]
505#[serde(untagged)]
506enum PromotionJournalWire {
507    Preacceptance(PreacceptanceJournalRecord),
508    Legacy(LegacyPromotionJournalRecord),
509}
510
511fn canonical_entry_id(entry_id: &str) -> bool {
512    !entry_id.is_empty()
513        && entry_id.len() <= 128
514        && entry_id.bytes().all(|byte| {
515            byte.is_ascii_lowercase()
516                || byte.is_ascii_digit()
517                || matches!(byte, b'.' | b'_' | b':' | b'-')
518        })
519}
520
521/// Reconstructed append position and global replay sets for private state.
522#[derive(Clone, Debug)]
523pub struct PromotionJournalState {
524    next_sequence: u64,
525    last_sha256: String,
526    run_ids: BTreeSet<String>,
527    intent_sha256s: BTreeSet<String>,
528    transport_manifest_sha256s: BTreeSet<String>,
529    oracle_result_manifest_sha256s: BTreeSet<String>,
530    grant_replay_keys: BTreeSet<PromotionGrantReplayKey>,
531}
532
533impl PromotionJournalState {
534    /// Sequence that the next durable record must use.
535    #[must_use]
536    pub const fn next_sequence(&self) -> u64 {
537        self.next_sequence
538    }
539
540    /// Builds the next explicitly preacceptance record and rejects every
541    /// cross-archive replay key.
542    ///
543    /// # Errors
544    ///
545    /// Returns [`FailureCode::CoordinationReplay`] if any globally unique key
546    /// has already appeared, or [`FailureCode::StateBurn`] on overflow.
547    pub(crate) fn next_record(
548        &self,
549        identity: &PromotionIdentity,
550    ) -> PromotionResult<PreacceptanceJournalRecord> {
551        if self.run_ids.contains(&identity.run_id)
552            || self.intent_sha256s.contains(&identity.intent_sha256)
553            || self
554                .transport_manifest_sha256s
555                .contains(&identity.transport_manifest_sha256)
556            || self
557                .oracle_result_manifest_sha256s
558                .contains(&identity.oracle_result_manifest_sha256)
559            || identity
560                .grants
561                .iter()
562                .any(|grant| self.grant_replay_keys.contains(&grant.replay_key()))
563        {
564            return Err(PromotionError::new(
565                FailureCode::CoordinationReplay,
566                "private promotion state already consumed this run, intent, archive, oracle result, or ledger grant",
567            ));
568        }
569        let body = PreacceptanceJournalBody {
570            schema: PREACCEPTANCE_JOURNAL_SCHEMA,
571            contract: PREACCEPTANCE_JOURNAL_CONTRACT.to_owned(),
572            sequence: self.next_sequence,
573            run_id: identity.run_id.clone(),
574            intent_bytes: identity.intent_bytes,
575            intent_sha256: identity.intent_sha256.clone(),
576            grants: identity.grants.clone(),
577            route_seal_sha256: identity.route_seal_sha256.clone(),
578            transport_manifest_sha256: identity.transport_manifest_sha256.clone(),
579            oracle_receipt_sha256: identity.oracle_receipt_sha256.clone(),
580            oracle_result_manifest_sha256: identity.oracle_result_manifest_sha256.clone(),
581            coordination_burn_sequence: identity.coordination_burn_sequence,
582            coordination_burn_previous_sha256: identity.coordination_burn_previous_sha256.clone(),
583            coordination_burn_record_sha256: identity.coordination_burn_record_sha256.clone(),
584            previous_promotion_record_sha256: self.last_sha256.clone(),
585        };
586        let record_sha256 = body.sha256()?;
587        let record = PreacceptanceJournalRecord {
588            schema: body.schema,
589            contract: body.contract,
590            sequence: body.sequence,
591            run_id: body.run_id,
592            intent_bytes: body.intent_bytes,
593            intent_sha256: body.intent_sha256,
594            grants: body.grants,
595            route_seal_sha256: body.route_seal_sha256,
596            transport_manifest_sha256: body.transport_manifest_sha256,
597            oracle_receipt_sha256: body.oracle_receipt_sha256,
598            oracle_result_manifest_sha256: body.oracle_result_manifest_sha256,
599            coordination_burn_sequence: body.coordination_burn_sequence,
600            coordination_burn_previous_sha256: body.coordination_burn_previous_sha256,
601            coordination_burn_record_sha256: body.coordination_burn_record_sha256,
602            previous_promotion_record_sha256: body.previous_promotion_record_sha256,
603            record_sha256,
604        };
605        record.validate_shape()?;
606        Ok(record)
607    }
608}
609
610/// Reparses a bounded legacy-plus-preacceptance private journal from genesis.
611///
612/// No final promotion-journal v2 wire is recognized or emitted here.
613///
614/// # Errors
615///
616/// Rejects truncated/non-canonical lines, chain gaps, body-hash mismatches, and
617/// replay of any required cross-archive uniqueness key.
618pub fn verify_promotion_journal(bytes: &[u8]) -> PromotionResult<PromotionJournalState> {
619    if bytes.len() > MAXIMUM_JOURNAL_BYTES || (!bytes.is_empty() && bytes.last() != Some(&b'\n')) {
620        return Err(PromotionError::new(
621            FailureCode::StateBurn,
622            "promotion journal is oversized or has a truncated final line",
623        ));
624    }
625    let mut state = PromotionJournalState {
626        next_sequence: 0,
627        last_sha256: GENESIS_SHA256.to_owned(),
628        run_ids: BTreeSet::new(),
629        intent_sha256s: BTreeSet::new(),
630        transport_manifest_sha256s: BTreeSet::new(),
631        oracle_result_manifest_sha256s: BTreeSet::new(),
632        grant_replay_keys: BTreeSet::new(),
633    };
634    let mut saw_preacceptance = false;
635    for line in bytes.split_inclusive(|byte| *byte == b'\n') {
636        let wire: PromotionJournalWire =
637            parse_canonical_line(line, MAXIMUM_RECORD_BYTES, "promotion journal record")
638                .map_err(|error| PromotionError::new(FailureCode::StateBurn, error.to_string()))?;
639        match wire {
640            PromotionJournalWire::Preacceptance(record) => {
641                record.validate_shape()?;
642                verify_chain_position(
643                    &state,
644                    record.sequence,
645                    &record.previous_promotion_record_sha256,
646                    &record.record_sha256,
647                    record.recomputed_sha256()?,
648                )?;
649                insert_common_replay_keys(
650                    &mut state,
651                    &record.run_id,
652                    &record.intent_sha256,
653                    &record.transport_manifest_sha256,
654                    &record.oracle_result_manifest_sha256,
655                )?;
656                for grant in &record.grants {
657                    if !state.grant_replay_keys.insert(grant.replay_key()) {
658                        return Err(replay_error());
659                    }
660                }
661                advance_journal_state(&mut state, record.record_sha256)?;
662                saw_preacceptance = true;
663            }
664            PromotionJournalWire::Legacy(record) => {
665                if saw_preacceptance {
666                    return Err(PromotionError::new(
667                        FailureCode::StateBurn,
668                        "legacy promotion records are only accepted before the preacceptance prefix transition",
669                    ));
670                }
671                record.validate_shape()?;
672                verify_chain_position(
673                    &state,
674                    record.sequence,
675                    &record.previous_promotion_record_sha256,
676                    &record.record_sha256,
677                    record.recomputed_sha256()?,
678                )?;
679                insert_common_replay_keys(
680                    &mut state,
681                    &record.run_id,
682                    &record.intent_sha256,
683                    &record.transport_manifest_sha256,
684                    &record.oracle_result_manifest_sha256,
685                )?;
686                for grant in &record.grants {
687                    if !state.grant_replay_keys.insert(grant.clone()) {
688                        return Err(replay_error());
689                    }
690                }
691                advance_journal_state(&mut state, record.record_sha256)?;
692            }
693        }
694    }
695    Ok(state)
696}
697
698fn verify_chain_position(
699    state: &PromotionJournalState,
700    sequence: u64,
701    previous_sha256: &str,
702    record_sha256: &str,
703    recomputed_sha256: String,
704) -> PromotionResult<()> {
705    if sequence != state.next_sequence
706        || previous_sha256 != state.last_sha256
707        || recomputed_sha256 != record_sha256
708    {
709        return Err(PromotionError::new(
710            FailureCode::StateBurn,
711            "promotion journal is not one contiguous recomputed genesis chain",
712        ));
713    }
714    Ok(())
715}
716
717fn insert_common_replay_keys(
718    state: &mut PromotionJournalState,
719    run_id: &str,
720    intent_sha256: &str,
721    transport_manifest_sha256: &str,
722    oracle_result_manifest_sha256: &str,
723) -> PromotionResult<()> {
724    if !state.run_ids.insert(run_id.to_owned())
725        || !state.intent_sha256s.insert(intent_sha256.to_owned())
726        || !state
727            .transport_manifest_sha256s
728            .insert(transport_manifest_sha256.to_owned())
729        || !state
730            .oracle_result_manifest_sha256s
731            .insert(oracle_result_manifest_sha256.to_owned())
732    {
733        return Err(replay_error());
734    }
735    Ok(())
736}
737
738fn advance_journal_state(
739    state: &mut PromotionJournalState,
740    record_sha256: String,
741) -> PromotionResult<()> {
742    state.next_sequence = state.next_sequence.checked_add(1).ok_or_else(|| {
743        PromotionError::new(
744            FailureCode::StateBurn,
745            "promotion journal sequence overflow",
746        )
747    })?;
748    state.last_sha256 = record_sha256;
749    Ok(())
750}
751
752fn replay_error() -> PromotionError {
753    PromotionError::new(
754        FailureCode::CoordinationReplay,
755        "promotion journal contains a repeated cross-archive identity",
756    )
757}
758
759#[cfg(test)]
760pub(crate) fn synthetic_identity(salt: u8) -> PromotionIdentity {
761    let digest = |n: u8| format!("{salt:02x}{n:02x}{}", "1".repeat(60));
762    let mut identity = PromotionIdentity {
763        run_id: format!("{salt:02x}{}", "2".repeat(30)),
764        intent_bytes: 100 + u64::from(salt),
765        intent_sha256: digest(1),
766        grants: vec![
767            PromotionGrantBinding {
768                ledger_id: "hashsigs-project-coordination".to_owned(),
769                entry_id: format!("project-{salt}"),
770                entry_bytes: 200 + u64::from(salt),
771                entry_sha256: digest(2),
772            },
773            PromotionGrantBinding {
774                ledger_id: "ed25519-agent-coordination".to_owned(),
775                entry_id: format!("ed25519-{salt}"),
776                entry_bytes: 300 + u64::from(salt),
777                entry_sha256: digest(3),
778            },
779        ],
780        route_seal_sha256: digest(4),
781        transport_manifest_sha256: digest(5),
782        oracle_receipt_sha256: digest(6),
783        oracle_result_manifest_sha256: digest(7),
784        coordination_burn_sequence: 0,
785        coordination_burn_previous_sha256: GENESIS_SHA256.to_owned(),
786        coordination_burn_record_sha256: String::new(),
787    };
788    let grants = identity
789        .grants
790        .iter()
791        .map(|grant| {
792            CoordinationBurnGrantHashInput::new(
793                &grant.ledger_id,
794                &grant.entry_id,
795                grant.entry_bytes,
796                &grant.entry_sha256,
797            )
798        })
799        .collect::<Vec<_>>();
800    let record_sha256 = recompute_coordination_burn_record_sha256(
801        identity.coordination_burn_sequence,
802        &identity.run_id,
803        identity.intent_bytes,
804        &identity.intent_sha256,
805        &grants,
806        &identity.coordination_burn_previous_sha256,
807    )
808    .unwrap();
809    identity.coordination_burn_record_sha256 = record_sha256;
810    identity
811}
812
813#[cfg(test)]
814fn legacy_record(identity: &PromotionIdentity) -> LegacyPromotionJournalRecord {
815    let body = LegacyPromotionJournalBody {
816        schema: LEGACY_JOURNAL_SCHEMA,
817        contract: LEGACY_JOURNAL_CONTRACT.to_owned(),
818        sequence: 0,
819        run_id: identity.run_id.clone(),
820        intent_sha256: identity.intent_sha256.clone(),
821        grants: identity
822            .grants
823            .iter()
824            .map(PromotionGrantBinding::replay_key)
825            .collect(),
826        route_seal_sha256: identity.route_seal_sha256.clone(),
827        transport_manifest_sha256: identity.transport_manifest_sha256.clone(),
828        oracle_receipt_sha256: identity.oracle_receipt_sha256.clone(),
829        oracle_result_manifest_sha256: identity.oracle_result_manifest_sha256.clone(),
830        coordination_burn_sequence: identity.coordination_burn_sequence,
831        coordination_burn_previous_sha256: identity.coordination_burn_previous_sha256.clone(),
832        coordination_burn_record_sha256: identity.coordination_burn_record_sha256.clone(),
833        previous_promotion_record_sha256: GENESIS_SHA256.to_owned(),
834    };
835    let record_sha256 = body.sha256().unwrap();
836    LegacyPromotionJournalRecord {
837        schema: body.schema,
838        contract: body.contract,
839        sequence: body.sequence,
840        run_id: body.run_id,
841        intent_sha256: body.intent_sha256,
842        grants: body.grants,
843        route_seal_sha256: body.route_seal_sha256,
844        transport_manifest_sha256: body.transport_manifest_sha256,
845        oracle_receipt_sha256: body.oracle_receipt_sha256,
846        oracle_result_manifest_sha256: body.oracle_result_manifest_sha256,
847        coordination_burn_sequence: body.coordination_burn_sequence,
848        coordination_burn_previous_sha256: body.coordination_burn_previous_sha256,
849        coordination_burn_record_sha256: body.coordination_burn_record_sha256,
850        previous_promotion_record_sha256: body.previous_promotion_record_sha256,
851        record_sha256,
852    }
853}
854
855#[cfg(test)]
856mod tests {
857    use super::{
858        GENESIS_SHA256, PREACCEPTANCE_JOURNAL_CONTRACT, PreacceptanceJournalRecord,
859        PromotionIdentity, legacy_record, synthetic_identity, verify_promotion_journal,
860    };
861    use crate::FailureCode;
862    use crate::canonical::canonical_line;
863    use crate::coordination::{
864        CoordinationBurnGrantHashInput, MAXIMUM_COORDINATION_INTENT_BYTES,
865        recompute_coordination_burn_record_sha256, synthetic_chain,
866    };
867    use crate::model::synthetic_request;
868
869    fn recompute_journal_hash_only(record: &mut PreacceptanceJournalRecord) {
870        record.record_sha256 = record.recomputed_sha256().unwrap();
871    }
872
873    fn recompute_copied_burn_and_journal(record: &mut PreacceptanceJournalRecord) {
874        let grants = record
875            .grants
876            .iter()
877            .map(|grant| {
878                CoordinationBurnGrantHashInput::new(
879                    &grant.ledger_id,
880                    &grant.entry_id,
881                    grant.entry_bytes,
882                    &grant.entry_sha256,
883                )
884            })
885            .collect::<Vec<_>>();
886        let copied_burn_sha256 = recompute_coordination_burn_record_sha256(
887            record.coordination_burn_sequence,
888            &record.run_id,
889            record.intent_bytes,
890            &record.intent_sha256,
891            &grants,
892            &record.coordination_burn_previous_sha256,
893        )
894        .unwrap();
895        record.coordination_burn_record_sha256 = copied_burn_sha256;
896        recompute_journal_hash_only(record);
897    }
898
899    fn assert_journal_only_mutation_rejected(record: &mut PreacceptanceJournalRecord) {
900        recompute_journal_hash_only(record);
901        assert_eq!(
902            verify_promotion_journal(&record.canonical_line().unwrap())
903                .unwrap_err()
904                .code(),
905            FailureCode::StateBurn
906        );
907    }
908
909    #[test]
910    fn journal_hash_chain_round_trips_and_advances() {
911        let empty = verify_promotion_journal(b"").unwrap();
912        let first = empty.next_record(&synthetic_identity(1)).unwrap();
913        let first_line = first.canonical_line().unwrap();
914        let one = verify_promotion_journal(&first_line).unwrap();
915        assert_eq!(one.next_sequence(), 1);
916        let second = one.next_record(&synthetic_identity(2)).unwrap();
917        let mut journal = first_line;
918        journal.extend(second.canonical_line().unwrap());
919        assert_eq!(
920            verify_promotion_journal(&journal).unwrap().next_sequence(),
921            2
922        );
923    }
924
925    #[test]
926    fn state_rejects_cross_archive_replay_before_append() {
927        let empty = verify_promotion_journal(b"").unwrap();
928        let first_identity = synthetic_identity(1);
929        let first = empty.next_record(&first_identity).unwrap();
930        let one = verify_promotion_journal(&first.canonical_line().unwrap()).unwrap();
931        assert_eq!(
932            one.next_record(&first_identity).unwrap_err().code(),
933            FailureCode::CoordinationReplay
934        );
935
936        let mut transport_replay = synthetic_identity(2);
937        transport_replay
938            .transport_manifest_sha256
939            .clone_from(&first_identity.transport_manifest_sha256);
940        assert_eq!(
941            one.next_record(&transport_replay).unwrap_err().code(),
942            FailureCode::CoordinationReplay
943        );
944    }
945
946    #[test]
947    fn preacceptance_records_reject_same_grant_digest_with_different_bytes() {
948        let empty = verify_promotion_journal(b"").unwrap();
949        let first = empty.next_record(&synthetic_identity(1)).unwrap();
950        let first_line = first.canonical_line().unwrap();
951        let one = verify_promotion_journal(&first_line).unwrap();
952        let mut second = one.next_record(&synthetic_identity(2)).unwrap();
953        second.grants[0]
954            .ledger_id
955            .clone_from(&first.grants[0].ledger_id);
956        second.grants[0]
957            .entry_id
958            .clone_from(&first.grants[0].entry_id);
959        second.grants[0]
960            .entry_sha256
961            .clone_from(&first.grants[0].entry_sha256);
962        second.grants[0].entry_bytes = first.grants[0].entry_bytes + 1;
963        recompute_copied_burn_and_journal(&mut second);
964
965        let mut journal = first_line;
966        journal.extend(second.canonical_line().unwrap());
967        assert_eq!(
968            verify_promotion_journal(&journal).unwrap_err().code(),
969            FailureCode::CoordinationReplay
970        );
971    }
972
973    #[test]
974    fn copied_burn_fields_cannot_be_changed_by_rehashing_only_the_journal() {
975        let empty = verify_promotion_journal(b"").unwrap();
976        let original = empty.next_record(&synthetic_identity(1)).unwrap();
977
978        let mut intent_bytes = original.clone();
979        intent_bytes.intent_bytes += 1;
980        assert_journal_only_mutation_rejected(&mut intent_bytes);
981
982        let mut grant_bytes = original.clone();
983        grant_bytes.grants[0].entry_bytes += 1;
984        assert_journal_only_mutation_rejected(&mut grant_bytes);
985
986        let mut burn_sequence = original.clone();
987        burn_sequence.coordination_burn_sequence = 1;
988        assert_journal_only_mutation_rejected(&mut burn_sequence);
989
990        let mut copied_burn_digest = original.clone();
991        copied_burn_digest.coordination_burn_record_sha256 = "e".repeat(64);
992        assert_journal_only_mutation_rejected(&mut copied_burn_digest);
993
994        let mut run_id = original;
995        run_id.run_id = "f".repeat(32);
996        assert_journal_only_mutation_rejected(&mut run_id);
997    }
998
999    #[test]
1000    fn copied_burn_caps_intent_and_requires_genesis_for_sequence_zero() {
1001        let empty = verify_promotion_journal(b"").unwrap();
1002        let original = empty.next_record(&synthetic_identity(1)).unwrap();
1003
1004        let mut oversized_intent = original.clone();
1005        oversized_intent.intent_bytes = MAXIMUM_COORDINATION_INTENT_BYTES + 1;
1006        recompute_copied_burn_and_journal(&mut oversized_intent);
1007        assert_eq!(
1008            verify_promotion_journal(&oversized_intent.canonical_line().unwrap())
1009                .unwrap_err()
1010                .code(),
1011            FailureCode::StateBurn
1012        );
1013
1014        let mut non_genesis = original;
1015        assert_eq!(
1016            non_genesis.coordination_burn_previous_sha256,
1017            GENESIS_SHA256
1018        );
1019        non_genesis.coordination_burn_previous_sha256 = "a".repeat(64);
1020        recompute_copied_burn_and_journal(&mut non_genesis);
1021        assert_eq!(
1022            verify_promotion_journal(&non_genesis.canonical_line().unwrap())
1023                .unwrap_err()
1024                .code(),
1025            FailureCode::StateBurn
1026        );
1027    }
1028
1029    #[test]
1030    fn legacy_prefix_is_consumed_but_never_reemitted() {
1031        let legacy_identity = synthetic_identity(1);
1032        let legacy_line = canonical_line(&legacy_record(&legacy_identity)).unwrap();
1033        let state = verify_promotion_journal(&legacy_line).unwrap();
1034
1035        let mut grant_replay = synthetic_identity(2);
1036        grant_replay.grants[0].ledger_id = legacy_identity.grants[0].ledger_id.clone();
1037        grant_replay.grants[0].entry_id = legacy_identity.grants[0].entry_id.clone();
1038        grant_replay.grants[0].entry_sha256 = legacy_identity.grants[0].entry_sha256.clone();
1039        grant_replay.grants[0].entry_bytes = legacy_identity.grants[0].entry_bytes + 1;
1040        assert_eq!(
1041            state.next_record(&grant_replay).unwrap_err().code(),
1042            FailureCode::CoordinationReplay
1043        );
1044
1045        let next = state.next_record(&synthetic_identity(2)).unwrap();
1046        assert_eq!(next.schema, 1);
1047        assert_eq!(next.contract, PREACCEPTANCE_JOURNAL_CONTRACT);
1048        assert!(next.grants.iter().all(|grant| grant.entry_bytes > 0));
1049    }
1050
1051    #[test]
1052    fn promotion_identity_requires_request_and_revalidated_burn_run_join() {
1053        let chain = synthetic_chain(7);
1054        let matching = synthetic_request(chain.selected_burn().run_id());
1055        assert!(
1056            PromotionIdentity::from_revalidated_burn(
1057                &matching,
1058                chain.selected_burn(),
1059                &"a".repeat(64),
1060                &"b".repeat(64),
1061                &"c".repeat(64),
1062                &"d".repeat(64),
1063            )
1064            .is_ok()
1065        );
1066
1067        let mismatched = synthetic_request(&"f".repeat(32));
1068        assert_eq!(
1069            PromotionIdentity::from_revalidated_burn(
1070                &mismatched,
1071                chain.selected_burn(),
1072                &"a".repeat(64),
1073                &"b".repeat(64),
1074                &"c".repeat(64),
1075                &"d".repeat(64),
1076            )
1077            .unwrap_err()
1078            .code(),
1079            FailureCode::IdentityJoin
1080        );
1081    }
1082}