Institutional Approval Routing

A grant application that clears every compliance check can still miss its deadline for a reason that has nothing to do with the science: the internal sign-off chain never finished. Before a proposal reaches Grants.gov, it must pass through the principal investigator (PI), the department chair, the Sponsored Programs Office (SPO), and finally the one person legally allowed to press submit — the Authorized Organization Representative (AOR). When that routing is tracked in email threads and spreadsheets, approvals arrive out of order, a chair signs a version the SPO later rejected, and nobody can prove afterward who authorized what. This section, part of Document Assembly & Audit, treats institutional approval routing as an explicit, timestamped state machine so that the internal workflow is as auditable as the document it releases. The finished, fully-approved package is what feeds submission portal sync; the record of how it got approved is what feeds audit logging and provenance.

Routing is the last mile of assembly, and it is the mile where institutional liability concentrates. A federal submission carries certifications — on human subjects, financial conflict of interest, lobbying, and the accuracy of the budget — that only an institutional official can make on the organization’s behalf. Model the routing loosely and those certifications rest on nothing verifiable; model it as a guarded state machine and every certification is bound to a named approver, a timestamp, and the exact bytes they approved. The deepest walkthrough of that machine lives in modeling Sponsored Programs approval workflows in Python; this page establishes the model, the agency-specific rules that bend it, and the failure modes it must survive.

Prerequisites and environment setup

The patterns here assume Python 3.10 or newer for modern type-hint syntax (str | None, list[Step]) and the StrEnum-style enumerations that name states. You can hand-roll the finite state machine (FSM) from a transition table — that is the approach shown below, because it makes the audit contract explicit — or lean on the transitions library when you want hierarchical states and callbacks. Either way the state and step records are Pydantic v2 models so that an illegal configuration fails at construction rather than at a deadline:

bash
python -m venv .venv
source .venv/bin/activate
pip install "pydantic>=2.6" "transitions>=0.9"   # transitions is optional

Two assumptions shape everything that follows. First, routing state is durable: it outlives the process that created it, so the machine must serialize to a store (a row, a document, an event log) and rehydrate byte-for-byte. Second, routing is append-only. You never overwrite the record of a prior decision; a rejection followed by a re-approval produces two entries, not one mutated field. That discipline is what lets the tamper-evident audit log downstream prove the full history of a submission.

Core mechanism — approval as a finite state machine

An approval workflow is a finite state machine: a set of named states, a set of actors authorized to act in each state, and a set of allowed transitions between states. Every transition that is not explicitly permitted is illegal by construction — that single inversion (deny by default) is what makes the model auditable. A spreadsheet lets a chair “approve” a proposal that the SPO has already kicked back; a state machine cannot represent that, because no transition from the reworking state to the chair-approved state exists.

The states of a standard institutional route are the sign-off milestones, plus the two terminal outcomes:

python
from enum import Enum

class State(str, Enum):
    DRAFT = "draft"                    # assembled, not yet certified
    PI_CERTIFIED = "pi_certified"      # PI has certified scientific accuracy
    CHAIR_APPROVED = "chair_approved"  # department chair has endorsed
    SPO_REVIEW = "spo_review"          # under Sponsored Programs review
    SPO_CLEARED = "spo_cleared"        # SPO cleared; queued for the AOR
    AOR_APPROVED = "aor_approved"      # AOR authorized release
    SUBMITTED = "submitted"            # terminal: released to the portal
    REJECTED = "rejected"              # terminal: withdrawn from this cycle

# from-state -> set of legal next states. Deny by default: any pair
# absent from this table raises on attempt. SPO_REVIEW -> DRAFT is the
# rework loop; every non-terminal state may transition to REJECTED.
ALLOWED: dict[State, set[State]] = {
    State.DRAFT:          {State.PI_CERTIFIED, State.REJECTED},
    State.PI_CERTIFIED:   {State.CHAIR_APPROVED, State.REJECTED},
    State.CHAIR_APPROVED: {State.SPO_REVIEW, State.REJECTED},
    State.SPO_REVIEW:     {State.SPO_CLEARED, State.DRAFT, State.REJECTED},
    State.SPO_CLEARED:    {State.AOR_APPROVED, State.REJECTED},
    State.AOR_APPROVED:   {State.SUBMITTED},
    State.SUBMITTED:      set(),   # terminal — no outgoing transitions
    State.REJECTED:       set(),   # terminal — no outgoing transitions
}

def is_legal(src: State, dst: State) -> bool:
    """A transition is legal only if the table names it explicitly."""
    return dst in ALLOWED.get(src, set())

The transition table is the whole contract. Reading it top to bottom describes the route in one glance: a draft becomes PI-certified, a certified proposal is endorsed by the chair, an endorsed proposal enters SPO review, and SPO review either clears the package, sends it back to draft for rework, or rejects it. Only AOR_APPROVED may advance to SUBMITTED, which encodes the legal fact that no one but the AOR can release the submission. Because the terminal states have empty transition sets, a submitted or rejected proposal is frozen — any further attempt to act on it raises rather than silently mutating a closed record.

Deadline-aware, typed implementation

The transition table decides whether a move is structurally legal; a production router also decides whether it is permitted right now, by this actor, before the internal deadline. Those three guards — role, ordering, and time — are enforced together. Institutions run internal deadlines that fall days before the sponsor deadline precisely so the SPO and AOR have room to review; a router that ignores the internal clock lets a package sit until it is too late for the AOR to act. Encode each recorded decision and the routing state itself as Pydantic v2 models:

python
from datetime import datetime, timezone
from pydantic import BaseModel, Field, field_validator

class ApprovalStep(BaseModel):
    """One immutable decision in the route. Never mutated after creation."""
    from_state: State
    to_state: State
    actor_id: str                       # who acted (eRA Commons / SSO id)
    actor_role: str                     # "pi" | "chair" | "spo" | "aor"
    decided_at: datetime
    note: str | None = None

    @field_validator("decided_at")
    @classmethod
    def _tz_aware(cls, v: datetime) -> datetime:
        # Naive timestamps make an audit trail unfalsifiable across zones.
        if v.tzinfo is None:
            raise ValueError("decided_at must be timezone-aware (UTC)")
        return v

# role authorized to *enter* each destination state
REQUIRED_ROLE: dict[State, str] = {
    State.PI_CERTIFIED: "pi",
    State.CHAIR_APPROVED: "chair",
    State.SPO_REVIEW: "spo",
    State.SPO_CLEARED: "spo",
    State.AOR_APPROVED: "aor",
    State.SUBMITTED: "aor",
}

class RoutingState(BaseModel):
    """The live state of one proposal's route plus its full history."""
    proposal_id: str
    state: State = State.DRAFT
    internal_deadline: datetime          # earlier than the sponsor deadline
    sponsor_deadline: datetime
    history: list[ApprovalStep] = Field(default_factory=list)

    def transition(self, to: State, actor_id: str, role: str,
                   note: str | None = None) -> "RoutingState":
        """Apply one guarded transition, appending an immutable step."""
        if not is_legal(self.state, to):
            raise IllegalTransition(f"{self.state} -> {to} is not allowed")
        needed = REQUIRED_ROLE.get(to)
        if needed and role != needed:
            raise WrongApprover(f"{to} requires role {needed!r}, got {role!r}")
        now = datetime.now(timezone.utc)
        # a *rejection* is always allowed to beat the clock; a forward
        # approval past the internal deadline must be escalated, not silent.
        if to not in (State.REJECTED, State.DRAFT) and now > self.internal_deadline:
            raise DeadlineBreach(f"internal deadline passed for {self.proposal_id}")
        step = ApprovalStep(from_state=self.state, to_state=to,
                            actor_id=actor_id, actor_role=role,
                            decided_at=now, note=note)
        self.history.append(step)
        self.state = to
        return self

Three properties make this auditable. The history list only grows, so the record of a rejected-then-reworked proposal keeps both the rejection and the eventual approval. Every step carries a timezone-aware timestamp, because a naive datetime cannot be compared honestly against a sponsor deadline expressed in a specific time zone. And the guards raise typed exceptions — IllegalTransition, WrongApprover, DeadlineBreach — so the orchestrator can route each failure to the right human instead of swallowing it. Parallel approvals (for example, two co-PIs who must both certify) are modeled by requiring a set of steps before a transition is offered; the how-to guide implements that multi-signer variant in full.

Serial and parallel topology, delegation, and escalation

Most of an institutional route is serial by law: the AOR cannot certify on the organization’s behalf until the SPO has cleared the package, and the SPO will not review until the department has endorsed it. That strict ordering is exactly what the deny-by-default transition table encodes. But pockets of the route are naturally parallel, and forcing them serial wastes days of the internal runway. Co-PIs on a multi-investigator proposal can certify independently; a human-subjects office and the SPO can review concurrently; a sub-award institution’s approval can proceed alongside the prime’s departmental sign-off. Model a parallel segment as a join: the route holds at a barrier state until every required signer in the set has recorded a step, then advances once. The join keeps each approval as its own timestamped record while still guaranteeing the transition fires only when the set is complete — you get concurrency without losing the audit granularity that a single collapsed “approved” flag would destroy.

Delegation and escalation are the two pressure-release valves that keep a serial route from stalling on a single unavailable person. Delegation is a recorded reassignment of authority: an approver names a delegate who may act in the same state, and the resulting step stores both identities so the trail shows the delegate acted under a named official’s authority — never a shared credential, which would make the certification unattributable. Escalation is the time-driven valve: when a step sits past its internal sub-deadline, the router notifies the next level up (an SPO reviewer’s director, or the AOR’s backup Signing Official) and, if an override is granted, records that override as its own transition. Both mechanisms share one rule — they add entries to the history, they never erase the fact that the normal path was interrupted. That is what lets a post-award auditor reconstruct not just who approved, but why the route deviated from its default.

Agency-specific configuration

The shape of the route is institutional, but who the AOR is and how they are registered is agency-specific, because each portal recognizes a different authorized-submitter credential. A route that hard-codes one agency’s release step will fail the moment the same institution submits to another. The National Institutes of Health (NIH), the National Science Foundation (NSF), and the Department of Defense (DoD) each recognize a distinct authorized submitter in a distinct system, so keep the AOR binding, the registration system, and the internal-deadline convention in a per-agency profile — the same versioned approach the agency schema registry uses for parsing profiles.

Routing dimension NIH NSF DoD
Authorized submitter Signing Official (SO) / AOR in eRA Commons AOR / Sponsored Projects Officer in Research.gov AOR registered in Grants.gov (SAM.gov-backed)
Registration system eRA Commons institutional profile Research.gov organization roles SAM.gov + Grants.gov roles; some BAAs add a portal
Submission mechanism Grants.gov or ASSIST, SO releases Research.gov direct submission Grants.gov; DoD Broad Agency Announcement (BAA) portals vary
Typical internal deadline 3–5 business days before sponsor due date 5 business days (many campuses) 5–10 days; export-control review can extend it
Extra approval gate Human-subjects / IACUC sign-off if applicable Data-management and mentoring plan checks International Traffic in Arms Regulations (ITAR) / Export Administration Regulations (EAR) export-control review
Certification owner Institutional SO attests AOR attests AOR plus security/export officer attest

The Department of Defense row is the one most likely to break a naive router: a BAA can require an export-control determination before the AOR is even allowed to act, which adds a state the NIH and NSF routes do not have. Model that as an additional required transition (SPO_CLEARED -> EXPORT_CLEARED -> AOR_APPROVED) loaded from the agency profile, rather than branching in code. The internal-deadline column is equally load-bearing: it is set by institutional policy, not the sponsor, and it must be earlier than the sponsor deadline by the margin the SPO needs. Feed the sponsor deadline itself from the parsed solicitation record so the two clocks stay in sync.

Error handling and edge cases

Approval routing fails in human ways, and each failure has a defined resolution rather than an unhandled exception:

  • Approver unavailable. A chair on travel blocks the whole serial chain. Resolve with explicit delegation: the chair records a delegate who may act in their stead, and the delegated step stores both identities so the audit trail shows who acted and under whose authority. Delegation is a recorded transition, never a shared login.
  • Deadline breach. When the internal deadline passes with the package still mid-route, a forward approval must not proceed silently. Raise DeadlineBreach, escalate to the SPO director, and require an explicit override that is itself a logged step — the record must show the deadline was knowingly crossed and by whom.
  • Out-of-order approval. An AOR who tries to release a package the SPO has not cleared hits IllegalTransition, because no CHAIR_APPROVED -> AOR_APPROVED edge exists. The attempt is logged and rejected; the route cannot skip a gate.
  • Missing or unregistered AOR. If no actor holds the AOR role for the target agency, the package reaches SPO_CLEARED and stalls. Detect the empty-role case at that boundary and surface it as a configuration error, not a mysterious hang — an unregistered submitter in eRA Commons or Grants.gov is the single most common cause of a missed release.
  • Rework churn. Repeated SPO_REVIEW -> DRAFT loops can hide a package that will never converge. Cap or alert on the rework count so a proposal cycling three times gets human attention rather than quietly consuming the runway to the deadline.

The uniform rule mirrors the rest of the pipeline: fail loudly, attach the proposal id and the offending transition, and let the orchestrator decide between escalation, delegation, or rejection. A silent state mutation is the one outcome that must never happen, because it destroys the very record the route exists to produce.

Integration with the downstream pipeline

Institutional approval routing sits between document assembly and submission. Upstream, agency form population and PDF package generation produce the exact bytes that enter the route as a DRAFT. The router’s only job is to advance that package through the sign-off gates and, on reaching AOR_APPROVED, release it. Release forks into two consumers: the approved package is handed to submission portal sync for transmission — via the Grants.gov system-to-system API or a portal upload — while the immutable routing history is written to audit logging and provenance. Once submitted, status is tracked by polling eRA Commons for submission updates, closing the loop.

The institutional approval routing state machine and its two downstream consumers A proposal moves left to right through five sign-off states: a draft package is certified by the principal investigator, endorsed by the department chair, reviewed by the Sponsored Programs Office, and approved by the Authorized Organization Representative. Each forward transition is labelled with its action — submit, certify, endorse, approve. A rework loop arcs over the top from the Sponsored Programs review state back to the PI-certified state, labelled as a rejection returned for rework. Internal deadlines fire before the sponsor deadline. When the AOR approves, the fully-approved package is released and forks to two consumers: submission portal sync, which submits to Grants.gov, and audit logging and provenance, which stores the immutable routing record. Internal deadline fires before the sponsor deadline · deny-by-default transitions rejected → returned for rework certify endorse review approve DRAFT assembled package PI CERTIFIED principal investigator CHAIR APPROVED department chair SPO REVIEW sponsored programs AOR APPROVED authorized rep · release fully-approved package released Submission portal sync submit to Grants.gov Audit logging & provenance immutable routing record
The AOR-approved state is the only gate that may release; release forks to the portal for submission and to the audit log for the permanent record.

The contract between stages is deliberately narrow: routing never edits the document, and submission never re-checks the approvals. Each stage trusts the recorded state of the one before it, which is exactly what keeps the whole chain auditable — a reviewer can trace any submission back to the sequence of timestamped approvals that authorized it.

Testing and verification

Routing logic earns trust the same way the compliance engines do: by proving that illegal moves are impossible and that the terminal state is only reachable with a complete approval set. Assert the guards directly, using constructed states rather than a live institutional system:

python
import pytest
from datetime import datetime, timedelta, timezone

def _state(**kw) -> RoutingState:
    now = datetime.now(timezone.utc)
    return RoutingState(proposal_id="R01-2026-001",
                        internal_deadline=now + timedelta(days=3),
                        sponsor_deadline=now + timedelta(days=8), **kw)

def test_illegal_transition_is_rejected() -> None:
    st = _state(state=State.CHAIR_APPROVED)
    with pytest.raises(IllegalTransition):
        st.transition(State.AOR_APPROVED, "aor-9", "aor")  # skips SPO

def test_wrong_role_is_rejected() -> None:
    st = _state(state=State.DRAFT)
    with pytest.raises(WrongApprover):
        st.transition(State.PI_CERTIFIED, "chair-2", "chair")  # PI-only step

def test_terminal_requires_full_approval_chain() -> None:
    st = _state()
    st.transition(State.PI_CERTIFIED, "pi-1", "pi")
    st.transition(State.CHAIR_APPROVED, "chair-2", "chair")
    st.transition(State.SPO_REVIEW, "spo-3", "spo")
    st.transition(State.SPO_CLEARED, "spo-3", "spo")
    st.transition(State.AOR_APPROVED, "aor-9", "aor")
    st.transition(State.SUBMITTED, "aor-9", "aor")
    assert st.state is State.SUBMITTED
    # the audit trail records every gate, in order
    assert [s.to_state for s in st.history][0] is State.PI_CERTIFIED
    assert st.history[-1].to_state is State.SUBMITTED

def test_submitted_state_is_frozen() -> None:
    st = _state(state=State.SUBMITTED)
    with pytest.raises(IllegalTransition):
        st.transition(State.REJECTED, "spo-3", "spo")

A manual acceptance checklist catches what unit tests miss: confirm that every recorded step carries a timezone-aware timestamp and a named actor, that a delegated approval stores both the delegate and the delegating official, that the internal deadline is strictly earlier than the sponsor deadline, and that a package with no registered AOR for the target agency stalls with a configuration error rather than a hang. Only a route that passes those checks should be trusted to release a submission, because the routing record becomes the institution’s evidence of who authorized the certification.

Modeled this way, institutional approval routing stops being the fragile, informal step that quietly loses proposals to the clock and becomes a deterministic, replayable state machine. Paired with typed guards, deadline awareness, agency-specific AOR profiles, and an append-only history, it turns internal sign-off into audit-ready evidence — the same standard the rest of the document assembly and audit pipeline holds itself to.

Up one level: Document Assembly & Audit Logging