Skip to main content

presolve_compiler/
resume_capture.rs

1//! J7 generated exact-slot capture programs and snapshot model v1.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use crate::{
6    build_resume_liveness_plan, build_resume_schema_registry, ApplicationSemanticModel,
7    ResumeBoundaryId, ResumeBuildId, ResumeCaptureProgramId, ResumeExistingSlot, ResumeSchemaId,
8    ResumeSchemaRegistry, ResumeSlotId, ResumeSnapshotId, ResumeValueCodec, ResumeValueRecordId,
9    SerializableValue, SourceProvenance,
10};
11
12pub const RESUME_CAPTURE_PLAN_VERSION: u32 = 1;
13pub const RESUME_SNAPSHOT_SCHEMA_VERSION: u32 = 1;
14pub const RESUME_CAPTURE_MANIFEST_VERSION: u32 = 6;
15
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
17pub enum ResumeCaptureInstruction {
18    ReadSlot {
19        slot_id: ResumeSlotId,
20    },
21    EncodeSlot {
22        slot_id: ResumeSlotId,
23        codec: ResumeValueCodec,
24    },
25    AppendValueRecord {
26        value_record_id: ResumeValueRecordId,
27        slot_id: ResumeSlotId,
28    },
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ResumeCaptureProgram {
33    pub program_id: ResumeCaptureProgramId,
34    pub boundary_id: ResumeBoundaryId,
35    pub instructions: Vec<ResumeCaptureInstruction>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ResumeEnvelopeWriterPlan {
40    pub snapshot_schema_version: u32,
41    pub manifest_version: u32,
42    pub captured_at_is_null: bool,
43    pub boundary_programs: Vec<ResumeCaptureProgramId>,
44    pub standalone_trailing_newline: bool,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
48pub enum ResumeCaptureBlockReason {
49    MissingBoundarySchema,
50    MissingRetainedSlotSchema,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ResumeCaptureBlock {
55    pub boundary: Option<ResumeBoundaryId>,
56    pub slot: Option<ResumeExistingSlot>,
57    pub reason: ResumeCaptureBlockReason,
58    pub provenance: Option<SourceProvenance>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct ResumeCapturePlan {
63    pub version: u32,
64    pub programs: Vec<ResumeCaptureProgram>,
65    pub envelope_writer: ResumeEnvelopeWriterPlan,
66    pub blocks: Vec<ResumeCaptureBlock>,
67    pub program_index: BTreeMap<ResumeBoundaryId, usize>,
68}
69
70impl ResumeCapturePlan {
71    #[must_use]
72    pub fn program(&self, boundary: &ResumeBoundaryId) -> Option<&ResumeCaptureProgram> {
73        self.program_index
74            .get(boundary)
75            .and_then(|index| self.programs.get(*index))
76    }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80#[allow(clippy::struct_excessive_bools)]
81pub struct RuntimeQuiescenceState {
82    pub action_batch_depth: usize,
83    pub scheduler_pending_program_count: usize,
84    pub dom_patch_queue_empty: bool,
85    pub structural_program_active: bool,
86    pub form_submission_active: bool,
87    pub validation_execution_active: bool,
88    pub effect_execution_active: bool,
89    pub chunk_activation_active: bool,
90}
91
92impl RuntimeQuiescenceState {
93    #[must_use]
94    pub const fn quiescent() -> Self {
95        Self {
96            action_batch_depth: 0,
97            scheduler_pending_program_count: 0,
98            dom_patch_queue_empty: true,
99            structural_program_active: false,
100            form_submission_active: false,
101            validation_execution_active: false,
102            effect_execution_active: false,
103            chunk_activation_active: false,
104        }
105    }
106
107    #[must_use]
108    pub const fn is_quiescent(self) -> bool {
109        self.action_batch_depth == 0
110            && self.scheduler_pending_program_count == 0
111            && self.dom_patch_queue_empty
112            && !self.structural_program_active
113            && !self.form_submission_active
114            && !self.validation_execution_active
115            && !self.effect_execution_active
116            && !self.chunk_activation_active
117    }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct ResumeEncodedValue(String);
122
123impl ResumeEncodedValue {
124    #[must_use]
125    pub fn as_str(&self) -> &str {
126        &self.0
127    }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct ResumeSnapshotValueRecordV1 {
132    pub value_record_id: ResumeValueRecordId,
133    pub slot_id: ResumeSlotId,
134    pub value: ResumeEncodedValue,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct ResumeSnapshotBoundaryV1 {
139    pub boundary_id: ResumeBoundaryId,
140    pub schema_id: ResumeSchemaId,
141    pub values: Vec<ResumeSnapshotValueRecordV1>,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct ResumeSnapshotV1 {
146    pub schema_version: u32,
147    pub build_id: ResumeBuildId,
148    pub snapshot_id: ResumeSnapshotId,
149    pub manifest_version: u32,
150    pub captured_at: Option<()>,
151    pub boundaries: Vec<ResumeSnapshotBoundaryV1>,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
155pub enum ResumeCaptureErrorKind {
156    NotQuiescent,
157    MissingExactSlot,
158    InvalidRuntimeShape,
159    InvalidNumber,
160    InvalidFormStableState,
161    ProgramIntegrity,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct ResumeCaptureError {
166    pub kind: ResumeCaptureErrorKind,
167    pub boundary: Option<ResumeBoundaryId>,
168    pub slot: Option<ResumeSlotId>,
169    pub message: String,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
173pub enum ResumeCaptureIntegrityCode {
174    ProgramCorrespondence,
175    InvalidInstruction,
176    InvalidCaptureState,
177    OrderingOrOutputDrift,
178}
179
180impl ResumeCaptureIntegrityCode {
181    #[must_use]
182    pub const fn code(self) -> &'static str {
183        match self {
184            Self::ProgramCorrespondence => "PSASM1355",
185            Self::InvalidInstruction => "PSASM1356",
186            Self::InvalidCaptureState => "PSASM1357",
187            Self::OrderingOrOutputDrift => "PSASM1358",
188        }
189    }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct ResumeCaptureIntegrityDiagnostic {
194    pub code: ResumeCaptureIntegrityCode,
195    pub boundary: Option<ResumeBoundaryId>,
196    pub message: String,
197}
198
199#[must_use]
200pub fn build_resume_capture_plan(model: &ApplicationSemanticModel) -> ResumeCapturePlan {
201    let schemas = build_resume_schema_registry(model);
202    let liveness = build_resume_liveness_plan(model);
203    let retained = liveness
204        .retained
205        .iter()
206        .map(|record| record.slot.existing_slot.clone())
207        .collect::<BTreeSet<_>>();
208    let mut captured = BTreeSet::new();
209    let mut programs = Vec::new();
210    let mut blocks = Vec::new();
211
212    for schema in &schemas.schemas {
213        let mut instructions = Vec::new();
214        for slot in &schema.slots {
215            if !retained.contains(&slot.existing_slot) {
216                continue;
217            }
218            captured.insert(slot.existing_slot.clone());
219            instructions.extend([
220                ResumeCaptureInstruction::ReadSlot {
221                    slot_id: slot.resume_slot_id.clone(),
222                },
223                ResumeCaptureInstruction::EncodeSlot {
224                    slot_id: slot.resume_slot_id.clone(),
225                    codec: slot.codec.clone(),
226                },
227                ResumeCaptureInstruction::AppendValueRecord {
228                    value_record_id: ResumeValueRecordId::for_slot(&slot.resume_slot_id),
229                    slot_id: slot.resume_slot_id.clone(),
230                },
231            ]);
232        }
233        programs.push(ResumeCaptureProgram {
234            program_id: ResumeCaptureProgramId::for_boundary(&schema.boundary),
235            boundary_id: schema.boundary.clone(),
236            instructions,
237        });
238    }
239
240    for retained_slot in &liveness.retained {
241        if !captured.contains(&retained_slot.slot.existing_slot) {
242            blocks.push(ResumeCaptureBlock {
243                boundary: retained_slot.slot.boundary_candidate.clone(),
244                slot: Some(retained_slot.slot.existing_slot.clone()),
245                reason: ResumeCaptureBlockReason::MissingRetainedSlotSchema,
246                provenance: Some(retained_slot.slot.provenance.clone()),
247            });
248        }
249    }
250    for block in &schemas.blocks {
251        if retained.contains(&block.slot) {
252            blocks.push(ResumeCaptureBlock {
253                boundary: block.boundary.clone(),
254                slot: Some(block.slot.clone()),
255                reason: ResumeCaptureBlockReason::MissingRetainedSlotSchema,
256                provenance: Some(block.provenance.clone()),
257            });
258        }
259    }
260    blocks.sort_by(|left, right| {
261        (&left.boundary, &left.slot, left.reason).cmp(&(&right.boundary, &right.slot, right.reason))
262    });
263    blocks.dedup_by(|left, right| {
264        left.boundary == right.boundary && left.slot == right.slot && left.reason == right.reason
265    });
266    let program_index = programs
267        .iter()
268        .enumerate()
269        .map(|(index, program)| (program.boundary_id.clone(), index))
270        .collect();
271    let envelope_writer = ResumeEnvelopeWriterPlan {
272        snapshot_schema_version: RESUME_SNAPSHOT_SCHEMA_VERSION,
273        manifest_version: RESUME_CAPTURE_MANIFEST_VERSION,
274        captured_at_is_null: true,
275        boundary_programs: programs
276            .iter()
277            .map(|program| program.program_id.clone())
278            .collect(),
279        standalone_trailing_newline: true,
280    };
281
282    ResumeCapturePlan {
283        version: RESUME_CAPTURE_PLAN_VERSION,
284        programs,
285        envelope_writer,
286        blocks,
287        program_index,
288    }
289}
290
291/// Executes the generated J7 program over exact runtime slot values.
292///
293/// # Errors
294///
295/// Returns an error when capture is not quiescent, a required exact slot is
296/// missing, a runtime value does not match its compiler-generated codec, a
297/// number is not finite/canonical, a Form submission state is pending, or the
298/// supplied program/schema products are not reciprocal.
299#[allow(clippy::too_many_lines)]
300pub fn capture_resume_snapshot(
301    plan: &ResumeCapturePlan,
302    schemas: &ResumeSchemaRegistry,
303    quiescence: RuntimeQuiescenceState,
304    build_id: ResumeBuildId,
305    runtime_values: &BTreeMap<ResumeSlotId, SerializableValue>,
306) -> Result<ResumeSnapshotV1, ResumeCaptureError> {
307    if !quiescence.is_quiescent() {
308        return Err(capture_error(
309            ResumeCaptureErrorKind::NotQuiescent,
310            None,
311            None,
312            "capture requires an immediate compiler-defined quiescent point",
313        ));
314    }
315    if plan.version != RESUME_CAPTURE_PLAN_VERSION
316        || plan
317            .blocks
318            .iter()
319            .any(|block| block.reason == ResumeCaptureBlockReason::MissingRetainedSlotSchema)
320        || !capture_products_reciprocal(plan, schemas)
321    {
322        return Err(capture_error(
323            ResumeCaptureErrorKind::ProgramIntegrity,
324            None,
325            None,
326            "capture plan contains unresolved schema correspondence",
327        ));
328    }
329    let slot_schemas = schemas
330        .schemas
331        .iter()
332        .flat_map(|schema| {
333            schema
334                .slots
335                .iter()
336                .map(|slot| (slot.resume_slot_id.clone(), slot))
337        })
338        .collect::<BTreeMap<_, _>>();
339    let mut boundaries = Vec::new();
340
341    for program in &plan.programs {
342        let schema = schemas.schema(&program.boundary_id).ok_or_else(|| {
343            capture_error(
344                ResumeCaptureErrorKind::ProgramIntegrity,
345                Some(program.boundary_id.clone()),
346                None,
347                "capture program lacks its exact boundary schema",
348            )
349        })?;
350        let mut values = Vec::new();
351        for instruction_group in program.instructions.chunks_exact(3) {
352            let (
353                ResumeCaptureInstruction::ReadSlot { slot_id: read_slot },
354                ResumeCaptureInstruction::EncodeSlot {
355                    slot_id: encode_slot,
356                    codec,
357                },
358                ResumeCaptureInstruction::AppendValueRecord {
359                    value_record_id,
360                    slot_id: append_slot,
361                },
362            ) = (
363                &instruction_group[0],
364                &instruction_group[1],
365                &instruction_group[2],
366            )
367            else {
368                return Err(capture_error(
369                    ResumeCaptureErrorKind::ProgramIntegrity,
370                    Some(program.boundary_id.clone()),
371                    None,
372                    "capture instructions are not closed read/encode/append triples",
373                ));
374            };
375            if read_slot != encode_slot
376                || read_slot != append_slot
377                || value_record_id != &ResumeValueRecordId::for_slot(read_slot)
378            {
379                return Err(capture_error(
380                    ResumeCaptureErrorKind::ProgramIntegrity,
381                    Some(program.boundary_id.clone()),
382                    Some(read_slot.clone()),
383                    "capture instruction identities are not reciprocal",
384                ));
385            }
386            let slot_schema = slot_schemas.get(read_slot).ok_or_else(|| {
387                capture_error(
388                    ResumeCaptureErrorKind::ProgramIntegrity,
389                    Some(program.boundary_id.clone()),
390                    Some(read_slot.clone()),
391                    "capture slot lacks its generated schema",
392                )
393            })?;
394            if &slot_schema.codec != codec {
395                return Err(capture_error(
396                    ResumeCaptureErrorKind::ProgramIntegrity,
397                    Some(program.boundary_id.clone()),
398                    Some(read_slot.clone()),
399                    "capture codec drifted from the generated slot schema",
400                ));
401            }
402            let value = runtime_values.get(read_slot).ok_or_else(|| {
403                capture_error(
404                    ResumeCaptureErrorKind::MissingExactSlot,
405                    Some(program.boundary_id.clone()),
406                    Some(read_slot.clone()),
407                    "capture could not read the exact required runtime slot",
408                )
409            })?;
410            validate_form_stable_state(slot_schema, value, &program.boundary_id)?;
411            let encoded = encode_resume_value(value, codec).map_err(|mut error| {
412                error.boundary = Some(program.boundary_id.clone());
413                error.slot = Some(read_slot.clone());
414                error
415            })?;
416            values.push(ResumeSnapshotValueRecordV1 {
417                value_record_id: value_record_id.clone(),
418                slot_id: read_slot.clone(),
419                value: ResumeEncodedValue(encoded),
420            });
421        }
422        if !program.instructions.chunks_exact(3).remainder().is_empty() {
423            return Err(capture_error(
424                ResumeCaptureErrorKind::ProgramIntegrity,
425                Some(program.boundary_id.clone()),
426                None,
427                "capture program has an incomplete instruction group",
428            ));
429        }
430        boundaries.push(ResumeSnapshotBoundaryV1 {
431            boundary_id: program.boundary_id.clone(),
432            schema_id: schema.id.clone(),
433            values,
434        });
435    }
436
437    Ok(ResumeSnapshotV1 {
438        schema_version: RESUME_SNAPSHOT_SCHEMA_VERSION,
439        snapshot_id: ResumeSnapshotId::for_build(&build_id),
440        build_id,
441        manifest_version: RESUME_CAPTURE_MANIFEST_VERSION,
442        captured_at: None,
443        boundaries,
444    })
445}
446
447fn capture_products_reciprocal(plan: &ResumeCapturePlan, schemas: &ResumeSchemaRegistry) -> bool {
448    let program_boundaries = plan
449        .programs
450        .iter()
451        .map(|program| &program.boundary_id)
452        .collect::<Vec<_>>();
453    let schema_boundaries = schemas
454        .schemas
455        .iter()
456        .map(|schema| &schema.boundary)
457        .collect::<Vec<_>>();
458    let program_ids = plan
459        .programs
460        .iter()
461        .map(|program| program.program_id.clone())
462        .collect::<Vec<_>>();
463    program_boundaries == schema_boundaries
464        && plan.envelope_writer.snapshot_schema_version == RESUME_SNAPSHOT_SCHEMA_VERSION
465        && plan.envelope_writer.manifest_version == RESUME_CAPTURE_MANIFEST_VERSION
466        && plan.envelope_writer.captured_at_is_null
467        && plan.envelope_writer.standalone_trailing_newline
468        && plan.envelope_writer.boundary_programs == program_ids
469        && plan.program_index
470            == plan
471                .programs
472                .iter()
473                .enumerate()
474                .map(|(index, program)| (program.boundary_id.clone(), index))
475                .collect()
476}
477
478fn validate_form_stable_state(
479    slot_schema: &crate::ResumeSlotSchema,
480    value: &SerializableValue,
481    boundary: &ResumeBoundaryId,
482) -> Result<(), ResumeCaptureError> {
483    if !matches!(
484        slot_schema.existing_slot,
485        ResumeExistingSlot::FormSubmission(_)
486    ) {
487        return Ok(());
488    }
489    let SerializableValue::String(state) = value else {
490        return Err(capture_error(
491            ResumeCaptureErrorKind::InvalidRuntimeShape,
492            Some(boundary.clone()),
493            Some(slot_schema.resume_slot_id.clone()),
494            "Form submission state must be a compiler-owned string value",
495        ));
496    };
497    if matches!(state.as_str(), "Idle" | "Invalid" | "Failed" | "Completed") {
498        Ok(())
499    } else {
500        Err(capture_error(
501            ResumeCaptureErrorKind::InvalidFormStableState,
502            Some(boundary.clone()),
503            Some(slot_schema.resume_slot_id.clone()),
504            "pending or unknown Form submission state cannot enter a snapshot",
505        ))
506    }
507}
508
509/// Encodes one already-selected runtime value through its generated closed
510/// codec without reflection or property enumeration as semantic authority.
511///
512/// # Errors
513///
514/// Returns an error for codec/value mismatch, unsupported object shape,
515/// non-finite numbers, or negative zero.
516pub fn encode_resume_value(
517    value: &SerializableValue,
518    codec: &ResumeValueCodec,
519) -> Result<String, ResumeCaptureError> {
520    match (value, codec) {
521        (
522            SerializableValue::Null,
523            ResumeValueCodec::NullCodec | ResumeValueCodec::NullableCodec(_),
524        ) => Ok("null".to_string()),
525        (SerializableValue::Boolean(value), ResumeValueCodec::BooleanCodec) => {
526            Ok(value.to_string())
527        }
528        (SerializableValue::Number(value), ResumeValueCodec::NumberCodec) => {
529            canonical_number(value)
530        }
531        (SerializableValue::String(value), ResumeValueCodec::StringCodec) => {
532            serde_json::to_string(value).map_err(|error| {
533                capture_error(
534                    ResumeCaptureErrorKind::InvalidRuntimeShape,
535                    None,
536                    None,
537                    &format!("string could not be canonically escaped: {error}"),
538                )
539            })
540        }
541        (SerializableValue::Array(values), ResumeValueCodec::ArrayCodec(element_codec)) => values
542            .iter()
543            .map(|value| encode_resume_value(value, element_codec))
544            .collect::<Result<Vec<_>, _>>()
545            .map(|values| format!("[{}]", values.join(","))),
546        (SerializableValue::Object(values), ResumeValueCodec::ObjectCodec(properties)) => {
547            if values.len() != properties.len()
548                || properties
549                    .iter()
550                    .any(|property| !values.contains_key(&property.name))
551            {
552                return Err(capture_error(
553                    ResumeCaptureErrorKind::InvalidRuntimeShape,
554                    None,
555                    None,
556                    "runtime object does not exactly match the generated property schema",
557                ));
558            }
559            properties
560                .iter()
561                .map(|property| {
562                    let Some(value) = values.get(&property.name) else {
563                        return Err(capture_error(
564                            ResumeCaptureErrorKind::InvalidRuntimeShape,
565                            None,
566                            None,
567                            "runtime object lost a generated schema property",
568                        ));
569                    };
570                    let name = serde_json::to_string(&property.name).map_err(|error| {
571                        capture_error(
572                            ResumeCaptureErrorKind::InvalidRuntimeShape,
573                            None,
574                            None,
575                            &format!("object property could not be escaped: {error}"),
576                        )
577                    })?;
578                    Ok(format!(
579                        "{name}:{}",
580                        encode_resume_value(value, &property.codec)?
581                    ))
582                })
583                .collect::<Result<Vec<_>, ResumeCaptureError>>()
584                .map(|properties| format!("{{{}}}", properties.join(",")))
585        }
586        (value, ResumeValueCodec::NullableCodec(inner)) => encode_resume_value(value, inner),
587        _ => Err(capture_error(
588            ResumeCaptureErrorKind::InvalidRuntimeShape,
589            None,
590            None,
591            "runtime value shape does not match the generated resume codec",
592        )),
593    }
594}
595
596fn canonical_number(value: &str) -> Result<String, ResumeCaptureError> {
597    let number = value.parse::<f64>().map_err(|_| {
598        capture_error(
599            ResumeCaptureErrorKind::InvalidNumber,
600            None,
601            None,
602            "snapshot number is not a finite JSON number",
603        )
604    })?;
605    if !number.is_finite() || (number == 0.0 && value.trim_start().starts_with('-')) {
606        return Err(capture_error(
607            ResumeCaptureErrorKind::InvalidNumber,
608            None,
609            None,
610            "snapshot numbers reject non-finite values and negative zero",
611        ));
612    }
613    Ok(number.to_string())
614}
615
616#[must_use]
617pub fn resume_snapshot_json(snapshot: &ResumeSnapshotV1) -> String {
618    let boundaries = snapshot
619        .boundaries
620        .iter()
621        .map(|boundary| {
622            let values = boundary
623                .values
624                .iter()
625                .map(|record| {
626                    format!(
627                        "{{\"valueRecordId\":{},\"slotId\":{},\"value\":{}}}",
628                        json_string(&record.value_record_id.to_string()),
629                        json_string(&record.slot_id.to_string()),
630                        record.value.as_str()
631                    )
632                })
633                .collect::<Vec<_>>()
634                .join(",");
635            format!(
636                "{{\"boundaryId\":{},\"schemaId\":{},\"values\":[{values}]}}",
637                json_string(&boundary.boundary_id.to_string()),
638                json_string(&boundary.schema_id.to_string())
639            )
640        })
641        .collect::<Vec<_>>()
642        .join(",");
643    format!(
644        "{{\"schemaVersion\":{},\"buildId\":{},\"snapshotId\":{},\"manifestVersion\":{},\"capturedAt\":null,\"boundaries\":[{boundaries}]}}",
645        snapshot.schema_version,
646        json_string(&snapshot.build_id.to_string()),
647        json_string(&snapshot.snapshot_id.to_string()),
648        snapshot.manifest_version,
649    )
650}
651
652#[must_use]
653pub fn resume_snapshot_artifact_json(snapshot: &ResumeSnapshotV1) -> String {
654    resume_snapshot_json(snapshot) + "\n"
655}
656
657fn json_string(value: &str) -> String {
658    serde_json::to_string(value).expect("resume identity string serializes")
659}
660
661/// # Errors
662///
663/// Returns J7 integrity diagnostics when programs do not correspond exactly to
664/// J2/J6, use malformed instruction triples, or drift from canonical order.
665pub fn validate_resume_capture_plan(
666    model: &ApplicationSemanticModel,
667    plan: &ResumeCapturePlan,
668) -> Result<(), Vec<ResumeCaptureIntegrityDiagnostic>> {
669    let mut diagnostics = Vec::new();
670    let schemas = build_resume_schema_registry(model);
671    let expected_boundaries = schemas
672        .schemas
673        .iter()
674        .map(|schema| schema.boundary.clone())
675        .collect::<Vec<_>>();
676    let actual_boundaries = plan
677        .programs
678        .iter()
679        .map(|program| program.boundary_id.clone())
680        .collect::<Vec<_>>();
681    if expected_boundaries != actual_boundaries {
682        diagnostics.push(integrity_diagnostic(
683            ResumeCaptureIntegrityCode::ProgramCorrespondence,
684            None,
685            "capture programs do not cover schemas in parent-before-child order",
686        ));
687    }
688    for program in &plan.programs {
689        if program.program_id != ResumeCaptureProgramId::for_boundary(&program.boundary_id)
690            || !program.instructions.chunks_exact(3).remainder().is_empty()
691        {
692            diagnostics.push(integrity_diagnostic(
693                ResumeCaptureIntegrityCode::InvalidInstruction,
694                Some(program.boundary_id.clone()),
695                "capture program identity or instruction grouping is invalid",
696            ));
697            continue;
698        }
699        for group in program.instructions.chunks_exact(3) {
700            let valid = matches!(
701                (&group[0], &group[1], &group[2]),
702                (
703                    ResumeCaptureInstruction::ReadSlot { slot_id: read },
704                    ResumeCaptureInstruction::EncodeSlot { slot_id: encode, .. },
705                    ResumeCaptureInstruction::AppendValueRecord {
706                        value_record_id,
707                        slot_id: append
708                    }
709                ) if read == encode
710                    && read == append
711                    && value_record_id == &ResumeValueRecordId::for_slot(read)
712            );
713            if !valid {
714                diagnostics.push(integrity_diagnostic(
715                    ResumeCaptureIntegrityCode::InvalidInstruction,
716                    Some(program.boundary_id.clone()),
717                    "capture instructions are not exact read/encode/append triples",
718                ));
719            }
720        }
721    }
722    if plan.envelope_writer.snapshot_schema_version != RESUME_SNAPSHOT_SCHEMA_VERSION
723        || plan.envelope_writer.manifest_version != RESUME_CAPTURE_MANIFEST_VERSION
724        || !plan.envelope_writer.captured_at_is_null
725        || !plan.envelope_writer.standalone_trailing_newline
726    {
727        diagnostics.push(integrity_diagnostic(
728            ResumeCaptureIntegrityCode::InvalidCaptureState,
729            None,
730            "capture envelope policy violates snapshot v1 determinism",
731        ));
732    }
733    if plan != &build_resume_capture_plan(model) {
734        diagnostics.push(integrity_diagnostic(
735            ResumeCaptureIntegrityCode::OrderingOrOutputDrift,
736            None,
737            "capture plan drifted from canonical J2/J6 construction",
738        ));
739    }
740
741    if diagnostics.is_empty() {
742        Ok(())
743    } else {
744        Err(diagnostics)
745    }
746}
747
748fn capture_error(
749    kind: ResumeCaptureErrorKind,
750    boundary: Option<ResumeBoundaryId>,
751    slot: Option<ResumeSlotId>,
752    message: &str,
753) -> ResumeCaptureError {
754    ResumeCaptureError {
755        kind,
756        boundary,
757        slot,
758        message: message.to_string(),
759    }
760}
761
762fn integrity_diagnostic(
763    code: ResumeCaptureIntegrityCode,
764    boundary: Option<ResumeBoundaryId>,
765    message: &str,
766) -> ResumeCaptureIntegrityDiagnostic {
767    ResumeCaptureIntegrityDiagnostic {
768        code,
769        boundary,
770        message: message.to_string(),
771    }
772}
773
774#[cfg(test)]
775mod tests {
776    use super::*;
777    use crate::{ObjectType, ResumeObjectPropertyCodec, SemanticType};
778
779    fn model(source: &str) -> ApplicationSemanticModel {
780        crate::build_application_semantic_model(&presolve_parser::parse_file(
781            "src/ResumeCapture.tsx",
782            source,
783        ))
784    }
785
786    fn value_for_codec(codec: &ResumeValueCodec) -> SerializableValue {
787        match codec {
788            ResumeValueCodec::NullCodec | ResumeValueCodec::NullableCodec(_) => {
789                SerializableValue::Null
790            }
791            ResumeValueCodec::BooleanCodec => SerializableValue::Boolean(false),
792            ResumeValueCodec::NumberCodec => SerializableValue::Number("1".to_string()),
793            ResumeValueCodec::StringCodec => SerializableValue::String(String::new()),
794            ResumeValueCodec::ArrayCodec(_) => SerializableValue::Array(Vec::new()),
795            ResumeValueCodec::ObjectCodec(properties) => SerializableValue::Object(
796                properties
797                    .iter()
798                    .map(|property| (property.name.clone(), value_for_codec(&property.codec)))
799                    .collect(),
800            ),
801        }
802    }
803
804    fn capture_values(
805        plan: &ResumeCapturePlan,
806        schemas: &ResumeSchemaRegistry,
807    ) -> BTreeMap<ResumeSlotId, SerializableValue> {
808        let captured = plan
809            .programs
810            .iter()
811            .flat_map(|program| &program.instructions)
812            .filter_map(|instruction| match instruction {
813                ResumeCaptureInstruction::ReadSlot { slot_id } => Some(slot_id),
814                ResumeCaptureInstruction::EncodeSlot { .. }
815                | ResumeCaptureInstruction::AppendValueRecord { .. } => None,
816            })
817            .collect::<BTreeSet<_>>();
818        schemas
819            .schemas
820            .iter()
821            .flat_map(|schema| &schema.slots)
822            .filter(|slot| captured.contains(&slot.resume_slot_id))
823            .map(|slot| (slot.resume_slot_id.clone(), value_for_codec(&slot.codec)))
824            .collect()
825    }
826
827    #[test]
828    fn encodes_every_closed_value_variant_in_canonical_order() {
829        let object_codec = ResumeValueCodec::ObjectCodec(vec![
830            ResumeObjectPropertyCodec {
831                name: "a".to_string(),
832                codec: ResumeValueCodec::NumberCodec,
833            },
834            ResumeObjectPropertyCodec {
835                name: "b".to_string(),
836                codec: ResumeValueCodec::StringCodec,
837            },
838        ]);
839        let fixtures = [
840            (SerializableValue::Null, ResumeValueCodec::NullCodec, "null"),
841            (
842                SerializableValue::Boolean(true),
843                ResumeValueCodec::BooleanCodec,
844                "true",
845            ),
846            (
847                SerializableValue::Number("1.50".to_string()),
848                ResumeValueCodec::NumberCodec,
849                "1.5",
850            ),
851            (
852                SerializableValue::String("a\nb".to_string()),
853                ResumeValueCodec::StringCodec,
854                "\"a\\nb\"",
855            ),
856            (
857                SerializableValue::Array(vec![
858                    SerializableValue::Number("1".to_string()),
859                    SerializableValue::Number("2".to_string()),
860                ]),
861                ResumeValueCodec::ArrayCodec(Box::new(ResumeValueCodec::NumberCodec)),
862                "[1,2]",
863            ),
864            (
865                SerializableValue::Object(BTreeMap::from([
866                    (
867                        "b".to_string(),
868                        SerializableValue::String("two".to_string()),
869                    ),
870                    ("a".to_string(), SerializableValue::Number("1".to_string())),
871                ])),
872                object_codec,
873                "{\"a\":1,\"b\":\"two\"}",
874            ),
875            (
876                SerializableValue::Null,
877                ResumeValueCodec::NullableCodec(Box::new(ResumeValueCodec::StringCodec)),
878                "null",
879            ),
880        ];
881        for (value, codec, expected) in fixtures {
882            assert_eq!(encode_resume_value(&value, &codec).as_deref(), Ok(expected));
883            let parsed: serde_json::Value =
884                serde_json::from_str(expected).expect("canonical fixture JSON");
885            assert_eq!(
886                serde_json::from_str::<serde_json::Value>(
887                    &encode_resume_value(&value, &codec).expect("encode")
888                )
889                .expect("encoded JSON"),
890                parsed
891            );
892        }
893    }
894
895    #[test]
896    fn rejects_negative_zero_non_finite_and_runtime_shape_guessing() {
897        for number in ["-0", "-0.0", "NaN", "inf", "-inf", "1e9999"] {
898            assert_eq!(
899                encode_resume_value(
900                    &SerializableValue::Number(number.to_string()),
901                    &ResumeValueCodec::NumberCodec,
902                )
903                .expect_err("invalid number")
904                .kind,
905                ResumeCaptureErrorKind::InvalidNumber
906            );
907        }
908        assert_eq!(
909            encode_resume_value(
910                &SerializableValue::Object(BTreeMap::new()),
911                &ResumeValueCodec::NumberCodec,
912            )
913            .expect_err("shape mismatch")
914            .kind,
915            ResumeCaptureErrorKind::InvalidRuntimeShape
916        );
917    }
918
919    #[test]
920    fn generates_one_exact_program_per_boundary_and_omits_recomputable_slots() {
921        let model = model(
922            r#"@component("x-counter") class Counter {
923  count = state(1);
924  @computed() get doubled() { return this.count * 2; }
925  render() { return <button>{this.doubled}</button>; }
926}"#,
927        );
928        let schemas = build_resume_schema_registry(&model);
929        let liveness = build_resume_liveness_plan(&model);
930        let plan = build_resume_capture_plan(&model);
931        assert_eq!(plan.programs.len(), schemas.schemas.len());
932        let captured = plan
933            .programs
934            .iter()
935            .flat_map(|program| &program.instructions)
936            .filter_map(|instruction| match instruction {
937                ResumeCaptureInstruction::ReadSlot { slot_id } => Some(slot_id.clone()),
938                ResumeCaptureInstruction::EncodeSlot { .. }
939                | ResumeCaptureInstruction::AppendValueRecord { .. } => None,
940            })
941            .collect::<BTreeSet<_>>();
942        assert!(liveness
943            .retained
944            .iter()
945            .all(|record| captured.contains(&record.slot.resume_slot_id)));
946        assert!(liveness
947            .recomputable
948            .iter()
949            .all(|record| !captured.contains(&record.slot.resume_slot_id)));
950        assert_eq!(validate_resume_capture_plan(&model, &plan), Ok(()));
951    }
952
953    #[test]
954    fn equal_quiescent_state_produces_equal_bytes_without_mutation_or_timestamp() {
955        let model = model(
956            r#"@component("x-counter") class Counter { count = state(1); render() { return <button>{this.count}</button>; } }"#,
957        );
958        let schemas = build_resume_schema_registry(&model);
959        let plan = build_resume_capture_plan(&model);
960        let values = capture_values(&plan, &schemas);
961        let before = values.clone();
962        let build = ResumeBuildId::for_public_inputs("capture-test");
963        let first = capture_resume_snapshot(
964            &plan,
965            &schemas,
966            RuntimeQuiescenceState::quiescent(),
967            build.clone(),
968            &values,
969        )
970        .expect("capture");
971        let second = capture_resume_snapshot(
972            &plan,
973            &schemas,
974            RuntimeQuiescenceState::quiescent(),
975            build,
976            &values,
977        )
978        .expect("capture");
979        assert_eq!(values, before);
980        assert_eq!(resume_snapshot_json(&first), resume_snapshot_json(&second));
981        assert!(resume_snapshot_json(&first).contains("\"capturedAt\":null"));
982        assert!(!resume_snapshot_json(&first).ends_with('\n'));
983        assert!(resume_snapshot_artifact_json(&first).ends_with('\n'));
984    }
985
986    #[test]
987    fn rejects_malformed_program_products_before_reading_slots() {
988        let model = model(
989            r#"@component("x-counter") class Counter { count = state(1); render() { return <button>{this.count}</button>; } }"#,
990        );
991        let schemas = build_resume_schema_registry(&model);
992        let mut plan = build_resume_capture_plan(&model);
993        plan.programs.pop();
994        let diagnostics = validate_resume_capture_plan(&model, &plan).expect_err("malformed plan");
995        assert!(diagnostics.iter().any(|diagnostic| {
996            diagnostic.code == ResumeCaptureIntegrityCode::ProgramCorrespondence
997        }));
998        assert_eq!(
999            capture_resume_snapshot(
1000                &plan,
1001                &schemas,
1002                RuntimeQuiescenceState::quiescent(),
1003                ResumeBuildId::for_public_inputs("malformed"),
1004                &BTreeMap::new(),
1005            )
1006            .expect_err("program integrity")
1007            .kind,
1008            ResumeCaptureErrorKind::ProgramIntegrity
1009        );
1010    }
1011
1012    #[test]
1013    fn rejects_non_quiescent_and_pending_form_capture() {
1014        let model = model(
1015            r#"@component("x-profile") class Profile {
1016  @form() profile!: Form;
1017  @field(this.profile) name = "";
1018  render() { return <input field={this.name} />; }
1019}"#,
1020        );
1021        let schemas = build_resume_schema_registry(&model);
1022        let plan = build_resume_capture_plan(&model);
1023        let mut values = capture_values(&plan, &schemas);
1024        let submission = schemas
1025            .schemas
1026            .iter()
1027            .flat_map(|schema| &schema.slots)
1028            .find(|slot| matches!(slot.existing_slot, ResumeExistingSlot::FormSubmission(_)))
1029            .expect("submission slot");
1030        values.insert(
1031            submission.resume_slot_id.clone(),
1032            SerializableValue::String("Submitting".to_string()),
1033        );
1034        assert_eq!(
1035            capture_resume_snapshot(
1036                &plan,
1037                &schemas,
1038                RuntimeQuiescenceState::quiescent(),
1039                ResumeBuildId::for_public_inputs("pending-form"),
1040                &values,
1041            )
1042            .expect_err("pending submission")
1043            .kind,
1044            ResumeCaptureErrorKind::InvalidFormStableState
1045        );
1046        let mut active = RuntimeQuiescenceState::quiescent();
1047        active.validation_execution_active = true;
1048        assert_eq!(
1049            capture_resume_snapshot(
1050                &plan,
1051                &schemas,
1052                active,
1053                ResumeBuildId::for_public_inputs("active-validation"),
1054                &values,
1055            )
1056            .expect_err("not quiescent")
1057            .kind,
1058            ResumeCaptureErrorKind::NotQuiescent
1059        );
1060    }
1061
1062    #[test]
1063    fn reserves_the_complete_j7_integrity_range() {
1064        assert_eq!(
1065            [
1066                ResumeCaptureIntegrityCode::ProgramCorrespondence,
1067                ResumeCaptureIntegrityCode::InvalidInstruction,
1068                ResumeCaptureIntegrityCode::InvalidCaptureState,
1069                ResumeCaptureIntegrityCode::OrderingOrOutputDrift,
1070            ]
1071            .map(ResumeCaptureIntegrityCode::code),
1072            ["PSASM1355", "PSASM1356", "PSASM1357", "PSASM1358"]
1073        );
1074    }
1075
1076    #[test]
1077    fn capture_plan_is_deterministic_under_source_reversal() {
1078        let first = presolve_parser::parse_file(
1079            "src/A.tsx",
1080            r#"@component("x-a") @route("/a") class A { value = state(1); render() { return <button>{this.value}</button>; } }"#,
1081        );
1082        let second = presolve_parser::parse_file(
1083            "src/B.tsx",
1084            r#"@component("x-b") @route("/b") class B { enabled = state(true); render() { return <button>{this.enabled}</button>; } }"#,
1085        );
1086        let forward = crate::build_application_semantic_model_for_unit(
1087            &crate::CompilationUnit::from_parsed_files(vec![first.clone(), second.clone()]),
1088        );
1089        let reverse = crate::build_application_semantic_model_for_unit(
1090            &crate::CompilationUnit::from_parsed_files(vec![second, first]),
1091        );
1092        assert_eq!(
1093            build_resume_capture_plan(&forward),
1094            build_resume_capture_plan(&reverse)
1095        );
1096    }
1097
1098    #[test]
1099    fn codec_fixture_type_remains_semantically_closed() {
1100        let semantic_type = SemanticType::Object(ObjectType {
1101            properties: BTreeMap::from([("value".to_string(), SemanticType::Number)]),
1102        });
1103        assert!(crate::resume_value_codec(&semantic_type).is_ok());
1104    }
1105}