Skip to main content

presolve_compiler/
resume_capture.rs

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