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:
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:
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:
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 noCHAIR_APPROVED -> AOR_APPROVEDedge 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_CLEAREDand 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 -> DRAFTloops 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 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:
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.
Related
- Modeling Sponsored Programs approval workflows in Python — the concrete, testable FSM this section frames, with delegation and multi-PI variants.
- Audit logging and provenance — where the immutable routing history is stored for the institutional record.
- Submission portal sync — the consumer that transmits a fully-approved package to the sponsor.
- Agency form population — produces the SF424 package that enters the route as a draft.
- Agency schema registry — the versioning pattern that keeps per-agency AOR and deadline profiles reproducible.
Up one level: Document Assembly & Audit Logging