Skip to content

Modeling Submittal Revision Chains in Pydantic

Submittals almost never clear on the first pass. A shop drawing comes back Revise and Resubmit, the subcontractor updates it, and the package cycles as R00, R01, R02 until it is approved — and every one of those revisions is a real document that once existed in the record. The failure mode is subtle and expensive: a query returns R01 when R02 is the version actually governing the work, and an installer builds to a superseded detail. This page solves one precise problem: how to model the resubmission lineage as an immutable, ordered chain in Pydantic v2 so that exactly one revision is ever the governing one, every earlier revision is provably superseded, and the ordering invariants — a monotonic revision_id, a backward supersedes pointer, and strictly increasing timezone-aware timestamps — are enforced at construction time rather than trusted by convention. It is the lineage layer of the submittal metadata frameworks that make a submittal routable, and it binds each package to the same work-breakdown node used by WBS mapping strategies. It targets Python builders who need an auditable answer to “which revision was in force on this date?” that the data model itself guarantees.

Submittal revision chain with a single governing revision Three revision cards sit left to right. R00 is dated May 1 with status revise and resubmit and a superseded badge. R01 is dated May 12 with status revise and resubmit and a superseded badge. R02 is dated May 20 with status approved and a governing badge. Solid forward arrows labelled resubmit run from R00 to R01 and from R01 to R02, representing new revisions moving forward in time. Dashed backward arrows labelled supersedes run from R01 to R00 and from R02 to R01, representing each revision's pointer to the one it replaces. A footer strip states the invariants enforced by the model validator: revision ids are monotonic from R00, each supersedes pointer equals the prior revision id, timestamps strictly increase, and exactly one revision, the newest, governs. R00 revise & resubmit 2026-05-01 09:00 UTC superseded R01 revise & resubmit 2026-05-12 14:30 UTC superseded R02 approved 2026-05-20 11:15 UTC GOVERNING resubmit resubmit supersedes supersedes Chain invariants — enforced by the model validator revision_id ^R\d{2}$ monotonic from R00 · supersedes = prior id · created_at strictly increasing · exactly one governing (newest)
Forward arrows are the resubmission timeline; the dashed backward pointers are each revision's supersedes link. Only the newest revision carries the governing badge, and the model validator makes every other arrangement unrepresentable.

Key Rules and Specification

The chain is a small state machine whose only legal shape is a strictly ordered lineage. These rules are the invariants the model enforces:

  • The chain starts at R00 with no predecessor. The first revision has revision_id equal to R00 and a null supersedes pointer. Any other opening is rejected, so a lineage can never begin mid-stream.
  • Revision ids are monotonic and gap-free. Each revision_id matches ^R\d{2}$ and its numeric index is exactly one greater than the previous. R00 is followed by R01, then R02 — never R00 then R02, which would imply a lost revision.
  • supersedes points strictly backward. Every revision after the first carries supersedes equal to the immediately prior revision_id. The pointer encodes the lineage explicitly, so “which revision does this replace?” is answered by the data, not inferred by sorting.
  • Timestamps are timezone-aware and strictly increasing. Every created_at is timezone-aware per ISO 8601 and strictly later than the revision it supersedes, which is what makes a point-in-time query (“what governed on May 15?”) well defined across project sites in different zones.
  • Exactly one revision governs. The governing revision is the newest one in the chain; every earlier revision is superseded by definition. There is no separate mutable “is_current” flag to drift out of sync — governance is a property of position in an immutable tuple.
  • An approved revision closes the chain. Once a revision is approved or approved_as_noted, nothing may supersede it; a later resubmission against an approved package is a data error the validator rejects rather than silently reopening a closed lineage.
Field Type / pattern Rule Why it matters
revision_id ^R\d{2}$ Monotonic from R00 Detects a lost or out-of-order revision
supersedes ^R\d{2}$ or None Equals the prior id Encodes lineage explicitly, not by sort
status RevisionStatus Literal Approved closes the chain Stops a resubmission reopening an approval
created_at timezone-aware datetime Strictly increasing Makes point-in-time governance well defined
wbs_node PROJ-NNN-DISC-NN Resolves in the WBS map Binds the lineage to a budgeted scope

Production Code Example

A single revision is an immutable node: it validates its own id pattern, requires a timezone-aware timestamp, and exposes its numeric index for the ordering check. Because the node is frozen, a revision that has entered the record can never be edited underneath an audit — a correction is a new revision, which is exactly the construction reality.

from __future__ import annotations

import logging
import re
from datetime import datetime, timezone
from decimal import Decimal
from typing import Literal, Optional

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

logging.basicConfig(level=logging.INFO, format="%(levelname)s | %(message)s")
logger = logging.getLogger("submittal.revision_chain")

REVISION_RE = re.compile(r"^R\d{2}$")

RevisionStatus = Literal[
    "submitted", "under_review", "approved", "approved_as_noted",
    "revise_and_resubmit", "rejected", "for_record",
]
# An approved revision closes the lineage; nothing may supersede it.
TERMINAL_APPROVED = ("approved", "approved_as_noted")


class SubmittalRevisionNode(BaseModel):
    """One immutable point in a submittal's resubmission lineage."""

    model_config = ConfigDict(frozen=True)

    revision_id: str = Field(pattern=r"^R\d{2}$")
    supersedes: Optional[str] = Field(default=None)
    status: RevisionStatus
    created_at: datetime

    @field_validator("supersedes")
    @classmethod
    def _valid_supersedes(cls, v: Optional[str]) -> Optional[str]:
        if v is not None and not REVISION_RE.match(v):
            raise ValueError(f"supersedes must match ^R\\d2$ or be None: {v!r}")
        return v

    @field_validator("created_at")
    @classmethod
    def _require_tz(cls, v: datetime) -> datetime:
        if v.tzinfo is None:
            raise ValueError("created_at must be timezone-aware (ISO 8601)")
        return v.astimezone(timezone.utc)

    @property
    def index(self) -> int:
        return int(self.revision_id[1:])

The chain owns the invariants that no single node can see. Its model_validator(mode="after") walks adjacent pairs and enforces monotonic ids, the backward supersedes pointer, strictly increasing timestamps, and the rule that an approved revision closes the lineage. The governing revision falls out for free as the last element, and append returns a new chain so the structure stays immutable and re-validates on every extension.

class SubmittalRevisionChain(BaseModel):
    """An ordered, immutable chain of revisions for one submittal package."""

    model_config = ConfigDict(frozen=True)

    submittal_number: str = Field(pattern=r"^\d{2} \d{2} \d{2}-\d{3}$")
    wbs_node: str = Field(pattern=r"^PROJ-\d{3}-(ARCH|STR|MEP|CIV|ELEC|PLMB)-\d{2}$")
    revisions: tuple[SubmittalRevisionNode, ...] = Field(min_length=1)

    @model_validator(mode="after")
    def _ordered_and_linked(self) -> "SubmittalRevisionChain":
        revs = self.revisions
        if revs[0].revision_id != "R00" or revs[0].supersedes is not None:
            raise ValueError("chain must start at R00 with a null supersedes pointer")
        for prev, cur in zip(revs, revs[1:]):
            if cur.index != prev.index + 1:
                raise ValueError(f"non-monotonic ids: {prev.revision_id} -> {cur.revision_id}")
            if cur.supersedes != prev.revision_id:
                raise ValueError(
                    f"{cur.revision_id} must supersede {prev.revision_id}, not {cur.supersedes!r}"
                )
            if cur.created_at <= prev.created_at:
                raise ValueError(f"{cur.revision_id} must be dated after {prev.revision_id}")
            if prev.status in TERMINAL_APPROVED:
                raise ValueError(f"{prev.revision_id} is {prev.status}; a closed chain cannot grow")
        return self

    @property
    def governing(self) -> SubmittalRevisionNode:
        # The newest revision governs; every earlier one is superseded by definition.
        return self.revisions[-1]

    def is_superseded(self, revision_id: str) -> bool:
        return revision_id != self.governing.revision_id

    def revision_in_force_on(self, moment: datetime) -> Optional[SubmittalRevisionNode]:
        """The revision that governed at a point in time — the latest one created
        on or before that moment. Answers the auditor's point-in-time question."""
        eligible = [r for r in self.revisions if r.created_at <= moment]
        return eligible[-1] if eligible else None

    def append(self, node: SubmittalRevisionNode) -> "SubmittalRevisionChain":
        # Immutable extension: build and re-validate a NEW chain, never mutate.
        return SubmittalRevisionChain(
            submittal_number=self.submittal_number,
            wbs_node=self.wbs_node,
            revisions=self.revisions + (node,),
        )

When an inbound resubmission arrives it has to be attached to the correct chain, and that is the one place a match decision — and the site-canonical confidence bands — apply. An exact submittal_number hit auto-attaches; a fuzzy match waits for a coordinator; anything weaker is quarantined rather than grafted onto the wrong lineage.

AUTO_ROUTE = Decimal("0.92")     # site-canonical: >= 0.92 attaches automatically
HUMAN_REVIEW = Decimal("0.75")   # 0.75-0.92 waits for a coordinator; < 0.75 quarantines

RouteState = Literal["auto_route", "human_review", "quarantine"]


def route_inbound_revision(
    chain: SubmittalRevisionChain, inbound_number: str, match_confidence: Decimal
) -> RouteState:
    """Decide whether an inbound resubmission attaches to this chain."""
    confidence = Decimal("0.99") if inbound_number == chain.submittal_number else match_confidence
    if confidence >= AUTO_ROUTE:
        return "auto_route"
    if confidence >= HUMAN_REVIEW:
        return "human_review"
    return "quarantine"


if __name__ == "__main__":
    r0 = SubmittalRevisionNode(
        revision_id="R00", status="revise_and_resubmit",
        created_at=datetime(2026, 5, 1, 9, 0, tzinfo=timezone.utc),
    )
    r1 = SubmittalRevisionNode(
        revision_id="R01", supersedes="R00", status="revise_and_resubmit",
        created_at=datetime(2026, 5, 12, 14, 30, tzinfo=timezone.utc),
    )
    r2 = SubmittalRevisionNode(
        revision_id="R02", supersedes="R01", status="approved",
        created_at=datetime(2026, 5, 20, 11, 15, tzinfo=timezone.utc),
    )
    chain = SubmittalRevisionChain(
        submittal_number="08 41 13-002", wbs_node="PROJ-014-ARCH-05", revisions=(r0, r1, r2),
    )
    print("governing:", chain.governing.revision_id, "| R00 superseded:", chain.is_superseded("R00"))
    on_may_15 = chain.revision_in_force_on(datetime(2026, 5, 15, tzinfo=timezone.utc))
    print("in force on 2026-05-15:", on_may_15.revision_id if on_may_15 else None)

Common Mistakes and Gotchas

A mutable “is_current” flag on every revision. The intuitive design puts a boolean on each row and flips the old one off when a new revision lands. Two writes now have to stay consistent, and the moment one fails — a crashed task, a missed update — the record has zero or two “current” revisions and no way to tell which is right. Governance should be derived from position in an immutable ordered chain, not stored redundantly; the newest element governs, full stop, so there is no second write to drift.

Sorting by timestamp to find the latest revision. Ordering by created_at seems equivalent to ordering by revision_id, until a backdated correction or a clock-skewed upload lands R02 with a timestamp earlier than R01. The supersedes pointer and monotonic id are the authoritative lineage; the timestamp is a validated constraint on top of them, not the ordering key. Enforce that the id order and the time order agree, and reject the record when they do not rather than silently trusting the clock.

Mutating a revision in place to “fix” it. Editing an approved or superseded revision destroys the audit trail — the record now claims a value that was never reviewed. Freezing the node makes this impossible: a correction is a new revision that supersedes the last, which is both the contractual reality and what keeps change order schema validation able to prove what governed on any date.

Where This Fits in the Pipeline

The revision chain is the lineage backbone inside submittal metadata frameworks, which turn an inbound submittal package into a routable record. Each chain binds to a canonical work-breakdown node via WBS mapping strategies, so a superseded delta never double-counts against a budget, and the same immutable-lineage discipline underpins RFI schema design, where question-and-response threads resubmit the same way. Every extension of a chain re-runs the invariant validator, and the confidence bands used to attach an inbound resubmission are the same 0.92 and 0.75 thresholds applied across every routing decision on the site — so a fuzzy submittal-number match is treated with the identical caution whether it appears here or at document intake, and a record that cannot be attached with confidence hands off to change order schema validation rather than being grafted onto the wrong package.

Frequently Asked Questions

Why derive the governing revision instead of storing a current flag?

A stored flag requires two writes to stay consistent — turning the old revision off and the new one on — and any failure between them leaves the record with zero or two current revisions. Deriving governance as the newest element of an immutable ordered chain removes that second write entirely, so there is nothing to drift and no reconciliation to run. The data structure guarantees exactly one governing revision by construction.

Why order by revision id rather than by timestamp?

The monotonic revision_id and the backward supersedes pointer are the authoritative lineage; the timestamp is a constraint layered on top. A backdated correction or a clock-skewed upload can produce a later revision with an earlier timestamp, so sorting by time would reorder the chain incorrectly. The model enforces that id order and time order agree and rejects any record where they do not.

How does the model answer "which revision governed on a given date?"

revision_in_force_on returns the latest revision created on or before the queried moment, which is well defined precisely because timestamps are timezone-aware and strictly increasing. That makes point-in-time governance a deterministic lookup an auditor can trust, rather than a judgment call about which version was live when the work was built.

What stops a resubmission from reopening an approved package?

The chain validator treats approved and approved_as_noted as terminal: if any revision other than the last carries one of those statuses, construction fails. So a new revision can never be appended after an approval, and an attempt to do so is surfaced as a data error for a coordinator instead of silently reopening a closed lineage.

Why make the nodes and chain immutable?

Immutability is what preserves the audit trail. A frozen node cannot be edited underneath a review, so a correction must take the form of a new superseding revision — exactly the construction reality. Because append builds and re-validates a fresh chain rather than mutating in place, every extension is re-checked against the full set of invariants, and a serialized snapshot always reflects a legally ordered lineage.

← Back to Submittal Metadata Frameworks