# When does a minor version bump break a parser that claims forward compatibility? **Date of Record:** 2026 November 1 **Scope:** Applies to parsers and consumers that read versioned payloads under a semver or semver-like contract where minor bumps are promised backward compatible. Excludes strict schema validators that intentionally reject unknown fields. ## Core Distinction **Predicate.** A consumer is forward compatible within a major version if and only if it ignores unknown fields, accepts any minor greater than or equal to the minor it was built against, and does not validate exhaustively. A parser that pins the exact minor, or that treats unknown fields as errors, is not forward compatible regardless of what the contract promises. **Failure.** The producer bumps the minor version and adds a field. The consumer validates against its pinned schema, encounters an unknown field, and rejects the payload. The producer's change was compatible by contract and breaking in practice. **Asymmetry.** A missing required field produces a clear error at the producer. An unknown field produces a clear error at the consumer, which is logged as malformed input rather than as a version incompatibility, so the diagnosis points at the wrong side. ## What Was Observed **Signature.** Consumer error rate rising immediately after a producer deploy, with errors naming unknown fields. The producer reports 100 percent success. Rollback of the consumer fixes it, which misidentifies the consumer as the faulty component. **Trace.** Producer at 1.4.0 emits field region. Consumer built against 1.3.x validates with additionalProperties false. Every payload is rejected. Consumer availability drops to zero for the duration, and the failure is total rather than partial because every message carries the new field. **Why monitoring stays green.** The producer's metrics are healthy. The consumer's error is classified as input validation, which is a normal category with a normal alert threshold. ## Condition of Validity A consumer is compatible with future minors only while all hold: 1. **Lenient parsing.** Unknown fields are ignored rather than rejected. 2. **Range acceptance.** The version check accepts any minor greater than or equal to the built version, not an exact match. 3. **Required-field discipline.** Only fields the consumer genuinely requires are marked required, and those are never removed or retyped within a major version. Condition 2 is the one most often broken, because exact-match version checks feel safer to write. ## Decision Boundary - Lenient parse plus range acceptance: forward compatible within the major version. - Strict parse with exact version pin: compatible only with the exact minor it was built against. Any bump breaks it. - Lenient parse with exact pin: rejects a version it could have handled. The pin, not the parser, is the defect. - Required field added in a minor bump: a contract violation by the producer. The consumer is correct to reject, and the fix belongs upstream. ## Operational Effect The invariant is a property of the consumer's parsing posture, which is usually written once and never revisited, while the producer's version advances continuously. Remediation is to make parsing lenient and version checks range-based. The trap is to coordinate deploys so the consumer ships first. That works for the next bump and fails for the one after, when the coordination is forgotten. ```python from dataclasses import dataclass class VersionCompatibilityError(RuntimeError): """Raised when a consumer's parsing posture cannot accept a forward minor bump.""" @dataclass(frozen=True) class ParserGate: built_major: int built_minor: int rejects_unknown_fields: bool accepts_minor_range: bool def verify(self, incoming_major: int, incoming_minor: int) -> bool: if self.rejects_unknown_fields: raise VersionCompatibilityError("parser rejects unknown fields: not forward compatible") if incoming_major != self.built_major: raise VersionCompatibilityError( f"major mismatch {incoming_major} vs built {self.built_major}" ) if not self.accepts_minor_range and incoming_minor > self.built_minor: raise VersionCompatibilityError( f"pinned minor {self.built_minor} rejects forward minor {incoming_minor}" ) return True def dispatch_parse_mode(gate: ParserGate, incoming_major: int, incoming_minor: int) -> str: try: gate.verify(incoming_major, incoming_minor) except VersionCompatibilityError: return "lenient-parse-range-version" return "accept" ```