1use 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#[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#[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 #[must_use]
49 pub fn run_id(&self) -> &str {
50 &self.run_id
51 }
52}
53
54impl PromotionIdentity {
55 #[allow(dead_code)] 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 #[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#[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 #[must_use]
232 pub const fn sequence(&self) -> u64 {
233 self.sequence
234 }
235
236 #[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 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#[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#[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 #[must_use]
536 pub const fn next_sequence(&self) -> u64 {
537 self.next_sequence
538 }
539
540 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
610pub 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}