Skip to content

Tracking Multi-Party Submittal Approval Chains

A submittal is rarely approved by one person. A shop drawing routes from the general contractor, who checks it for completeness, to the architect of record, who confirms design intent, to the engineer of record, who signs off on the calculations — and each handoff is a place where the record can lose track of whose turn it is. When the routing state lives in a spreadsheet or a mutable “current approver” column, a retried notification double-advances the chain, an out-of-turn approval slips through, and an audit cannot reconstruct who held the package when. This page solves one precise problem: how to model the multi-party approval routing as an ordered, typed workflow in Pydantic v2 — per-party status as a Literal, a canonical routing order, and a deterministic next-approver resolution — so that routing is auditable, out-of-turn actions are rejected, and re-applying an approval on a retry is a guaranteed no-op. It is the routing layer that sits on top of the submittal metadata frameworks: the framework says what the submittal is, and this workflow says where it is in review and who acts next. It targets Python builders who need approval state that a distributed queue can retry safely and an auditor can trust.

Multi-party submittal approval routing A left-to-right routing diagram. A submittal revision enters at the general contractor stage, advances on approval to the architect, then to the engineer, then to a for-record approval box. The general contractor step shows status approved, the architect step is marked the current approver with status pending, and the engineer step shows status waiting. A cursor labelled current approver points at the architect. Dashed arrows labelled reject or revise run from each approver down to a return-to-originator box that mints a new revision. A footer strip states that the deterministic next approver is the first party still pending and that replaying a decision already recorded is an idempotent no-op. current approver Submittal R01 enters General Contractor completeness check approved Architect (AOR) design intent pending Engineer (EOR) calculations waiting Approved for record approve approve approve reject / revise Return to originator new revision R02, chain restarts Deterministic next approver = first party still pending Idempotent: replaying a decision already recorded returns the workflow unchanged
Approval advances only through the current approver; a reject or revise at any party returns the package as a new revision, and replaying a recorded decision is a no-op the workflow guarantees.

Key Rules and Specification

The workflow is an ordered pipeline whose only legal transitions move it forward through one approver at a time. These rules are enforced in the model:

  • Routing order is canonical and fixed. The steps follow the general_contractorarchitectengineer order, validated against a single source of truth. A chain whose steps are out of order is rejected, so routing can never skip the general contractor’s completeness check.
  • Each party appears at most once. A role may occupy exactly one step. Duplicating an approver would make “whose turn is it?” ambiguous and let the same party act twice.
  • Per-party status is a Literal. Each step’s status is one of pending, approved, approved_as_noted, rejected, or revise_and_resubmit — never a free string — so the state machine cannot land in an unrecognized state that routing would mishandle.
  • The next approver is derived, not stored. The current approver is the first step still pending, computed from the ordered steps. There is no mutable cursor column to fall out of sync with the statuses.
  • Decisions are sequential. A step can only be decided when every earlier step has advanced (approved or approved_as_noted). An out-of-turn decision — the engineer approving while the architect is still pending — is rejected at construction time.
  • A halt returns the package. A rejected or revise_and_resubmit at any party moves the workflow to returned; the submittal goes back to the originator as a new revision rather than continuing down the chain.
  • Applying a recorded decision is idempotent. Re-submitting an approval a party has already given returns the identical workflow, so a redelivered queue message never double-advances the routing.
Field Type / pattern Rule Why it matters
role ApproverRole Literal Unique, in routing order Fixes who reviews and in what sequence
status StepStatus Literal Sequential transitions only Blocks out-of-turn and unknown states
decided_at timezone-aware datetime Present iff decided Time-stamps the audit trail
submittal_number XX XX XX-NNN Matches the package Binds the workflow to one submittal
revision_id ^R\d{2}$ The revision under review Ties routing to a specific revision

Production Code Example

A single step is an immutable record of one party’s decision. It validates that a decided step carries a timestamp and a pending step does not, so a half-recorded decision cannot exist. The routing order is a module-level constant — the one place the GC-architect-engineer sequence is defined.

from __future__ import annotations

import logging
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.approval_chain")

ApproverRole = Literal["general_contractor", "architect", "engineer"]
# Canonical routing order: GC coordinates, architect confirms intent, engineer signs off.
ROUTING_ORDER: tuple[ApproverRole, ...] = ("general_contractor", "architect", "engineer")

StepStatus = Literal["pending", "approved", "approved_as_noted", "rejected", "revise_and_resubmit"]
Decision = Literal["approved", "approved_as_noted", "rejected", "revise_and_resubmit"]
ADVANCING = ("approved", "approved_as_noted")
HALTING = ("rejected", "revise_and_resubmit")


class ApprovalStep(BaseModel):
    """One party's slot in the approval chain — immutable once decided."""

    model_config = ConfigDict(frozen=True)

    role: ApproverRole
    party_name: str = Field(min_length=2, max_length=120)
    status: StepStatus = "pending"
    decided_at: Optional[datetime] = None
    comment: Optional[str] = Field(default=None, max_length=400)

    @field_validator("decided_at")
    @classmethod
    def _require_tz(cls, v: Optional[datetime]) -> Optional[datetime]:
        if v is not None and v.tzinfo is None:
            raise ValueError("decided_at must be timezone-aware (ISO 8601)")
        return v.astimezone(timezone.utc) if v is not None else None

    @model_validator(mode="after")
    def _decided_has_timestamp(self) -> "ApprovalStep":
        decided = self.status != "pending"
        if decided and self.decided_at is None:
            raise ValueError(f"{self.role} is {self.status} but has no decided_at")
        if not decided and self.decided_at is not None:
            raise ValueError(f"{self.role} is pending but carries a decided_at")
        return self

The workflow owns the ordering invariants and the routing logic. Its validator enforces the canonical order, single occupancy, and sequential decisions; next_approver and state are pure derivations of the step tuple; and apply_decision is idempotent and turn-checked, returning a new validated workflow so the structure is safe to retry.

WorkflowState = Literal["in_review", "approved", "returned"]


class ApprovalWorkflow(BaseModel):
    """An ordered, immutable multi-party approval chain for one submittal revision."""

    model_config = ConfigDict(frozen=True)

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

    @model_validator(mode="after")
    def _ordered_and_sequential(self) -> "ApprovalWorkflow":
        roles = tuple(s.role for s in self.steps)
        if roles != tuple(r for r in ROUTING_ORDER if r in roles):
            raise ValueError(f"steps must follow routing order {ROUTING_ORDER}, got {roles}")
        if len(set(roles)) != len(roles):
            raise ValueError("each party may appear at most once in the chain")
        require_pending = False
        for s in self.steps:
            if require_pending and s.status != "pending":
                raise ValueError(f"{s.role} decided before an earlier step finished")
            if s.status in HALTING or s.status == "pending":
                require_pending = True  # nothing after a halt or a pending step may be decided
        return self

    @property
    def state(self) -> WorkflowState:
        if any(s.status in HALTING for s in self.steps):
            return "returned"
        if all(s.status in ADVANCING for s in self.steps):
            return "approved"
        return "in_review"

    @property
    def next_approver(self) -> Optional[ApprovalStep]:
        if self.state != "in_review":
            return None
        return next((s for s in self.steps if s.status == "pending"), None)

    def apply_decision(
        self, role: ApproverRole, decision: Decision, decided_at: datetime,
        comment: Optional[str] = None,
    ) -> "ApprovalWorkflow":
        target = next((s for s in self.steps if s.role == role), None)
        if target is None:
            raise ValueError(f"{role} is not part of this chain")
        if target.status == decision:
            logger.info("idempotent replay: %s already %s", role, decision)
            return self  # retry-safe no-op
        current = self.next_approver
        if current is None or current.role != role:
            raise ValueError(f"out-of-turn decision: {role} is not the current approver")
        updated = tuple(
            s.model_copy(update={"status": decision, "decided_at": decided_at, "comment": comment})
            if s.role == role else s
            for s in self.steps
        )
        return ApprovalWorkflow(
            submittal_number=self.submittal_number, revision_id=self.revision_id,
            wbs_node=self.wbs_node, steps=updated,
        )

An inbound approval message must be matched to the workflow it belongs to, and that match — the one non-deterministic step — uses the site-canonical confidence bands. An exact submittal_number hit auto-applies; a fuzzy match waits for a coordinator; anything weaker is quarantined rather than applied to the wrong package.

AUTO_ROUTE = Decimal("0.92")     # site-canonical: >= 0.92 applies 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_approval(
    workflow: ApprovalWorkflow, inbound_number: str, match_confidence: Decimal
) -> RouteState:
    """Decide whether an inbound approval message binds to this workflow."""
    confidence = Decimal("0.99") if inbound_number == workflow.submittal_number else match_confidence
    if confidence >= AUTO_ROUTE:
        return "auto_route"
    if confidence >= HUMAN_REVIEW:
        return "human_review"
    return "quarantine"


if __name__ == "__main__":
    wf = ApprovalWorkflow(
        submittal_number="05 12 00-007", revision_id="R01", wbs_node="PROJ-014-STR-02",
        steps=(
            ApprovalStep(role="general_contractor", party_name="Turner Construction"),
            ApprovalStep(role="architect", party_name="HOK"),
            ApprovalStep(role="engineer", party_name="Thornton Tomasetti"),
        ),
    )
    now = datetime(2026, 6, 1, 10, 0, tzinfo=timezone.utc)
    print("next:", wf.next_approver.role)
    wf = wf.apply_decision("general_contractor", "approved", now)
    replay = wf.apply_decision("general_contractor", "approved", now)  # retry
    print("replay is a no-op:", replay is wf, "| next:", wf.next_approver.role)
    wf = wf.apply_decision("architect", "approved_as_noted", now)
    wf = wf.apply_decision("engineer", "approved", now)
    print("final state:", wf.state)

Common Mistakes and Gotchas

A mutable “current approver” column. Storing the active party in its own field means every advance is two writes — mark this step decided, point the cursor at the next — and a crash between them leaves the cursor lying about the state. Derive the next approver from the ordered statuses instead: the current party is simply the first step still pending, so there is no second write to fail and the cursor can never contradict the record.

Non-idempotent decision handlers. Approval events arrive over a queue that redelivers on transient faults, so apply_decision must be safe to call twice. If the handler blindly advances, a redelivered “architect approved” message pushes the package to the engineer twice — or worse, past a reviewer entirely. Making a replay of an already-recorded decision return the unchanged workflow turns retries into no-ops, which is what lets the routing run behind an at-least-once broker.

Trusting the message to say who may approve. An inbound event can claim any role, but authorization is not the message’s to assert — the party permitted to act at each step is governed by security boundary configuration, and the turn is enforced by next_approver. Applying a decision because the payload named a role, without checking that the role is the current approver and is authorized, is how an out-of-turn or forged approval enters the ledger. Reject the out-of-turn action and let a stalled or disputed approval escalate through fallback alert routing.

Where This Fits in the Pipeline

This workflow is the routing layer on top of the submittal metadata frameworks: once a submittal revision is a typed, routable record, the approval chain governs its passage from general contractor to architect to engineer. Which party is authorized to act at each step is enforced by security boundary configuration, and an approval whose review SLA lapses — a package sitting unactioned past its clock — is escalated by fallback alert routing rather than being handled inline, so escalation policy lives in one place. The same ordered, idempotent routing discipline models the question-and-response threads in RFI schema design. The confidence bands that bind an inbound approval message to its workflow are the same 0.92 and 0.75 thresholds applied across every routing decision on the site, so a fuzzy match is treated with identical caution here as at document intake.

Frequently Asked Questions

Why derive the next approver instead of storing a cursor?

A stored cursor is a second piece of state that has to be updated in lockstep with each step’s status, and any failure between the two writes leaves it pointing at the wrong party. Deriving the next approver as the first step still pending makes the cursor a pure function of the ordered statuses, so it can never contradict the record and there is no extra write to lose on a crash.

How is the workflow made idempotent for a retrying queue?

apply_decision checks whether the target step already holds the incoming decision and, if so, returns the identical workflow unchanged. A redelivered approval message therefore advances the chain exactly once no matter how many times it is processed, which is what makes the routing safe behind an at-least-once broker that redelivers on transient faults.

What stops the engineer from approving before the architect?

The workflow validator enforces sequential decisions: a step can only be decided when every earlier step has advanced, and apply_decision additionally rejects any action from a party that is not the current approver. An out-of-turn decision raises rather than silently reordering the chain, so the engineer’s sign-off can never precede the architect’s review.

What happens when a party rejects or asks for a revision?

A rejected or revise_and_resubmit decision moves the workflow to the returned state, which stops the chain and sends the package back to the originator as a new revision rather than continuing to the next approver. The routing does not skip ahead on a halt, so a design concern raised by the architect is resolved before the engineer ever sees the package.

Why keep each step immutable?

Freezing each step means a recorded decision cannot be quietly edited, which preserves the audit trail of who acted and when. A change to the routing produces a new validated workflow through apply_decision, so every transition is re-checked against the ordering invariants and a serialized snapshot always reflects a legal routing state.

← Back to Submittal Metadata Frameworks