{"record":{"id":"81bc071abc4b349c","repo":"linera-io/linera-protocol","slug":"must-contain-a-validation-certificate-if-and-only","errorCode":null,"errorMessage":"Must contain a validation certificate if and only if it contains the execution outcome from a previous round","messagePattern":"Must contain a validation certificate if and only if it contains the execution outcome from a previous round","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"linera-chain/src/data_types/mod.rs","lineNumber":1010,"sourceCode":"        )\n    }\n\n    /// Checks that the original proposal, if present, matches the new one and has a higher round.\n    pub fn check_invariants(&self) -> Result<(), &'static str> {\n        match (&self.original_proposal, &self.content.outcome) {\n            (None, None) => {}\n            (Some(OriginalProposal::Fast(_)), None) => ensure!(\n                self.content.round > Round::Fast,\n                \"The new proposal's round must be greater than the original's\"\n            ),\n            (None, Some(_))\n            | (Some(OriginalProposal::Fast(_)), Some(_))\n            | (Some(OriginalProposal::Regular { .. }), None) => {\n                return Err(\"Must contain a validation certificate if and only if \\\n                     it contains the execution outcome from a previous round\");\n            }\n            (Some(OriginalProposal::Regular { certificate }), Some(outcome)) => {\n                ensure!(\n                    self.content.round > certificate.round,\n                    \"The new proposal's round must be greater than the original's\"\n                );\n                let block = outcome.clone().with(self.content.block.clone());\n                let value = ValidatedBlock::new(block);\n                ensure!(\n                    certificate.check_value(&value),\n                    \"Lite certificate must match the given block and execution outcome\"\n                );\n            }\n        }\n        Ok(())\n    }\n}\n\nimpl LiteVote {\n    /// Uses the signing key to create a signed object.\n    pub fn new(value: LiteValue, round: Round, secret_key: &ValidatorSecretKey) -> Self {","sourceCodeStart":992,"sourceCodeEnd":1028,"githubUrl":"https://github.com/linera-io/linera-protocol/blob/6c226ddcb332ef55118dc8d0aafbd093d5420899/linera-chain/src/data_types/mod.rs#L992-L1028","documentation":"check_invariants (linera-chain/src/data_types/mod.rs:1003-1008) requires that a proposal carries the execution outcome from a previous round if and only if it carries an OriginalProposal::Regular validation certificate. The combinations (no original, some outcome), (Fast original, some outcome) and (Regular original, no outcome) are all rejected as structurally invalid — the outcome is exactly what lets validators skip re-execution when retrying a validated block, so it must be paired with the certificate that justifies it.","triggerScenarios":"Hand-constructing BlockProposal with mismatched original_proposal/outcome fields (e.g., setting outcome but not the certificate, or attaching a Regular certificate without its outcome); deserializing a truncated or foreign proposal; bugs in proposal copy/merge logic that drop one of the pair.","commonSituations":"Protocol tests assembling proposals field-by-field; serialization round-trips that lose optional fields; forked client code that builds retries differently than the SDK constructors.","solutions":["Always build proposals through the constructors — BlockProposal::new (neither field), new_retry_fast (Fast signature, no outcome), new_retry_regular (Regular certificate + outcome) — which keep the pair consistent","If constructing manually, match the shape: Regular retry must set BOTH the lite certificate and the outcome; a fresh or fast-retry proposal must set NEITHER outcome nor Regular certificate","Add a pre-send call to proposal.check_invariants() so the mismatch is caught locally"],"exampleFix":"// before: outcome carried without its certificate\nlet proposal = BlockProposal { content: ProposalContent { block, round, outcome: Some(outcome) },\n    original_proposal: None, signature }; // -> invariant error\n\n// after: retry properly via the constructor (certificate + outcome paired)\nlet proposal = BlockProposal::new_retry_regular(owner, new_round, validated_cert, &signer).await?;","handlingStrategy":"validation","validationCode":"// Mirror the pairing rule before sending (data_types/mod.rs:997-1008):\nuse linera_chain::data_types::OriginalProposal;\nfn proposal_shape_valid(p: &BlockProposal) -> bool {\n    matches!(\n        (&p.original_proposal, &p.content.outcome),\n        (None, None)\n            | (Some(OriginalProposal::Fast(_)), None)\n            | (Some(OriginalProposal::Regular { .. }), Some(_))\n    )\n}\nanyhow::ensure!(proposal_shape_valid(&proposal), \"outcome iff Regular certificate\");","typeGuard":"fn is_well_shaped_proposal(p: &BlockProposal) -> bool {\n    matches!(\n        (&p.original_proposal, &p.content.outcome),\n        (None, None)\n            | (Some(OriginalProposal::Fast(_)), None)\n            | (Some(OriginalProposal::Regular { .. }), Some(_))\n    )\n}","tryCatchPattern":"match result {\n    Err(WorkerError::InvalidBlockProposal(msg)) if msg.contains(\"if and only if\") => {\n        // structural fix: pair the Regular certificate WITH its outcome (or remove\n        // both); then re-sign and resend\n    }\n    other => other?,\n}","preventionTips":["Never hand-assemble BlockProposal; use the constructors","Round-trip proposals through serialization in tests to catch dropped optional fields","Run check_invariants() before every send"],"tags":["linera","proposal","invariants","retry","serialization","rust"],"backgroundTag":"invalid-proposal-structure","analyzedSha":"6c226ddcb332ef55118dc8d0aafbd093d5420899","analyzedAt":"2026-08-22T22:49:09.787Z","schemaVersion":2},"datasetVersion":"2026-08-23T01:17:44.959Z"}