Versioning agency schema profiles with semantic versions
When the National Science Foundation (NSF) publishes a new Proposal & Award Policies & Procedures Guide (PAPPG) edition mid-cycle, a proposal already in flight must still be judged against the edition that governed it on its deadline — not the one that took effect a week later. Getting that right means treating every agency requirement profile as a versioned artifact with a MAJOR.MINOR.PATCH semantic version and an effective-date window, so a resolver can pick the historically correct rule set every time. This guide is the deep implementation of the versioning contract introduced in the agency schema registry: it defines what counts as a breaking versus an additive change to a rule set, wires up version parsing and comparison, and builds an effective-dated resolver that maps (agency, mechanism, deadline) onto exactly one profile. Get this wrong and you either reject a compliant proposal against a rule that did not yet exist, or clear a non-compliant one against a rule that has already been superseded — both deadline-fatal in their own way.
Phase 1 — Define the versioning policy and set up
Semantic versioning only helps if the three digits mean something consistent for a rule set, which is not the same as for a software library. Before writing code, pin the policy: decide, per digit, which kinds of agency change trigger a bump. The rule of thumb is whether an existing, previously compliant proposal could newly fail — if yes, it is breaking.
- MAJOR — a change that can make a previously valid proposal invalid, or that changes the shape of the profile a consumer must read. A page limit dropping from 15 to 12, a newly mandatory Data Management Plan, a removed section, or a new required field on
SchemaProfileall bump MAJOR. Consumers must be assumed to break until updated. - MINOR — an additive change that loosens or extends without invalidating anything that passed before. A raised page ceiling, a newly allowed font, or an optional field bumps MINOR. Old proposals stay compliant; new capabilities appear.
- PATCH — a clarification with no behavioral effect on validation: a corrected typo in the governing-document label, a reworded rule description, tightened metadata. No consumer needs to change behavior.
Record this as a written policy your team can point to, because the classification is a judgment call the first time every new agency change appears. Then set up the environment — the packaging library parses and orders semantic versions with the exact algorithm the Python ecosystem uses, so version comparison is never hand-rolled string math.
python -m venv .venv
source .venv/bin/activate
pip install "pydantic>=2.6" "packaging>=24.0"
The tracking of when each agency actually ships these changes is a related discipline: the tracking versioned NSF PAPPG updates in code workflow feeds the effective dates this resolver consumes, and the numeric limits themselves live in the threshold tuning for compliance layer so a bumped version is a data edit rather than a redeploy.
Phase 2 — Implement version parsing, comparison, and an effective-dated resolver
With the policy fixed, the code does two jobs: order versions correctly, and select the one in force on a given deadline. Ordering comes free from packaging.version.Version, which sorts 2.10.0 above 2.9.0 (a naive string sort gets this backwards). Selection is a filter on effective-date windows followed by a max over the eligible versions. The classify_bump helper closes the loop from Phase 1, computing which digit changed between two versions so a mislabeled release — one that changes a limit but only bumps PATCH — can be flagged in review.
from __future__ import annotations
from datetime import date
from packaging.version import Version, InvalidVersion
from pydantic import BaseModel, field_validator
class ProfileVersion(BaseModel):
"""A semantically versioned, effective-dated agency rule set."""
model_config = {"frozen": True}
agency: str
mechanism: str
version: str # MAJOR.MINOR.PATCH
effective_from: date
effective_until: date | None = None
governing_document: str
max_narrative_pages: int
min_font_pt: float
requires_data_management_plan: bool = False
@field_validator("version")
@classmethod
def _semver(cls, v: str) -> str:
try:
parsed = Version(v)
except InvalidVersion as exc:
raise ValueError(f"{v!r} is not MAJOR.MINOR.PATCH") from exc
if len(parsed.release) != 3: # reject "2.0" or "2.0.0.1"
raise ValueError(f"{v!r} must have exactly three components")
return v
@property
def key(self) -> Version:
return Version(self.version)
def classify_bump(old: str, new: str) -> str:
"""Return 'major' | 'minor' | 'patch' | 'invalid' for a version transition."""
a, b = Version(old), Version(new)
if b <= a:
return "invalid" # a successor must increase
(a_maj, a_min, a_pat) = a.release
(b_maj, b_min, b_pat) = b.release
if b_maj != a_maj:
return "major"
if b_min != a_min:
return "minor"
return "patch"
def resolve_version(
profiles: list[ProfileVersion],
agency: str,
mechanism: str,
deadline: date,
) -> ProfileVersion:
"""Select the profile version in force on `deadline` for one identity.
Filters to the matching (agency, mechanism), keeps only versions whose
effective window contains the deadline, and returns the highest SemVer
among them. The deadline — not today's date — is the pivot, so re-running
a past cycle yields the version that historically governed it.
"""
eligible = [
p for p in profiles
if p.agency == agency and p.mechanism == mechanism
and p.effective_from <= deadline
and (p.effective_until is None or deadline <= p.effective_until)
]
if not eligible:
raise LookupError(f"no {agency}/{mechanism} version effective on {deadline}")
return max(eligible, key=lambda p: p.key)
The classify_bump function is what makes the policy enforceable rather than aspirational: a continuous-integration check can compare a new profile against its predecessor, compute the actual change to the rule payload, and assert that the version bump matches — refusing a release that changes a page limit while claiming to be a PATCH. Resolution by deadline is the mirror image of the effective-dated resolver in the parent registry, and the timeline below shows why the deadline, not the current date, has to be the selector.
Phase 3 — Edge cases and agency-specific overrides
The happy path is a clean succession of non-overlapping windows. Real agency behavior is messier, and each irregular case needs an explicit rule rather than whatever the resolver happens to do by accident.
- Mid-cycle PAPPG update. NSF occasionally issues a correction between annual editions. If it changes a validation-relevant value, it is a MINOR or MAJOR bump with its own
effective_from; the outgoing version’seffective_untilcloses the day before, so a proposal due before the correction still resolves to the prior version. - Back-dated reprocessing. Re-validating last cycle’s portfolio after a code change must reproduce the original verdicts. Because
resolve_versionpivots on the stored deadline and versions are immutable, replaying old proposals is safe — never substitute today’s date for the deadline as a convenience. - Yanked or deprecated versions. A version published in error must not silently vanish, because past verdicts cite it. Mark it deprecated and exclude it from new resolution while keeping it retrievable for audit, exactly as a package index yanks a release without deleting it.
- Agency overrides. A specific Funding Opportunity Announcement (FOA) or Broad Agency Announcement (BAA) can override the default rule set for one mechanism — a National Institutes of Health (NIH) FOA that tightens a limit below the standard, or a Department of Defense (DoD) BAA that sets its own volume caps. Model the override as a higher-precedence profile scoped to that opportunity id, resolved before the agency-wide default.
| Edge case | Trigger | Version action | Resolver behavior |
|---|---|---|---|
| Mid-cycle correction | Agency errata changes a limit | New MINOR/MAJOR; close prior window | Deadline before errata → prior version |
| Back-dated reprocess | Replay a past cycle | None; versions immutable | Pivot on stored deadline, reproduce verdict |
| Yanked version | Version published in error | Flag deprecated, keep for audit |
Excluded from new resolution, retrievable by id |
| Opportunity override | FOA/BAA overrides default | Add opportunity-scoped profile | Override wins over agency-wide default |
| Future-dated edition | Edition announced ahead of effect | Pre-load with future effective_from |
Not selected until deadline reaches window |
def resolve_with_override(
profiles: list[ProfileVersion],
agency: str,
mechanism: str,
deadline: date,
opportunity_id: str | None = None,
deprecated: frozenset[str] = frozenset(),
) -> ProfileVersion:
"""Resolve honoring opportunity overrides and excluding yanked versions."""
live = [p for p in profiles if p.version not in deprecated]
if opportunity_id is not None:
scoped = [p for p in live if getattr(p, "opportunity_id", None) == opportunity_id]
if scoped: # an override exists for this call
return resolve_version(scoped, agency, mechanism, deadline)
return resolve_version(live, agency, mechanism, deadline)
Phase 4 — Validation and verification
Two properties must hold under test: the deadline selects the correct version across every boundary, and a mislabeled bump is caught. Build fixtures with adjacent, non-overlapping windows and assert at the day of transition — the off-by-one at a cutover is the defect that ships a wrong verdict.
import pytest
from datetime import date
FIXTURES = [
ProfileVersion(agency="NSF", mechanism="CAREER", version="1.0.0",
effective_from=date(2023, 1, 1), effective_until=date(2024, 5, 19),
governing_document="PAPPG 23-1", max_narrative_pages=15, min_font_pt=10.0),
ProfileVersion(agency="NSF", mechanism="CAREER", version="2.0.0",
effective_from=date(2024, 5, 20), effective_until=None,
governing_document="PAPPG 24-1", max_narrative_pages=15, min_font_pt=11.0),
]
def test_deadline_on_cutover_day_picks_new_version() -> None:
# The first day of the new window must already resolve to 2.0.0.
got = resolve_version(FIXTURES, "NSF", "CAREER", date(2024, 5, 20))
assert got.version == "2.0.0"
def test_deadline_day_before_cutover_picks_old_version() -> None:
got = resolve_version(FIXTURES, "NSF", "CAREER", date(2024, 5, 19))
assert got.version == "1.0.0" and got.min_font_pt == 10.0
def test_deadline_before_any_version_raises() -> None:
with pytest.raises(LookupError):
resolve_version(FIXTURES, "NSF", "CAREER", date(2019, 1, 1))
def test_breaking_change_is_classified_major() -> None:
# A dropped page limit is breaking and must read as a MAJOR bump.
assert classify_bump("1.4.2", "2.0.0") == "major"
def test_added_optional_font_is_minor() -> None:
assert classify_bump("2.0.0", "2.1.0") == "minor"
def test_backwards_version_is_invalid() -> None:
assert classify_bump("2.0.0", "1.9.0") == "invalid"
Pair the suite with a review checklist: confirm every succession closes the prior window exactly one day before the next opens, that classify_bump matches the human classification recorded in Phase 1, that no deprecated version is reachable by new resolution while remaining retrievable for audit, and that a deadline exactly on a boundary resolves deterministically. Only then is the version policy safe to drive the compliance verdicts the rest of the pipeline depends on.
Frequently asked questions
How do I decide whether an agency change is a MAJOR or a MINOR bump?
Ask whether a proposal that passed under the old version could newly fail under the new one. If yes — a lowered page limit, a newly mandatory section, a removed field — it is breaking and bumps MAJOR. If the change only loosens a rule or adds an optional capability, so nothing that passed before now fails, it is MINOR. Pure clarifications with no effect on any verdict are PATCH.
Why resolve by the proposal's deadline instead of the current date?
Because a proposal is judged against the rules in force on its submission deadline, not the rules in force when your code happens to run. Pivoting on the deadline makes validation reproducible: re-checking a proposal from a past cycle, or reprocessing a whole portfolio after a fix, yields the same historically correct version every time. Using “today” would silently re-judge old work against rules that did not yet exist.
What happens to proposals in flight when a new PAPPG edition takes effect mid-cycle?
Each proposal resolves against the version whose effective window contains its own deadline. A proposal due before the new edition’s effective_from still resolves to the prior version; one due after resolves to the new edition. Because the outgoing version’s window closes the day before the new one opens, there is never an overlap where a single deadline could match two versions.
Should I ever edit a published profile version in place?
No. Versions are immutable. A correction is a new version with its own effective date, and a version published in error is marked deprecated but kept retrievable, because past compliance verdicts cite it by id. Editing in place would rewrite history and make old audit records irreproducible.
How does an FOA- or BAA-specific override fit the semantic-versioning scheme?
Model the override as a separate profile scoped to the opportunity id and resolve it ahead of the agency-wide default. It still carries its own semantic version and effective window, so an NIH FOA that tightens a limit or a DoD BAA that sets its own volume caps is versioned like any other rule set — it simply wins for calls that name that opportunity.
Related
- Agency schema registry — the store and resolver this versioning policy plugs into.
- Tracking versioned NSF PAPPG updates in code — where the effective dates for each edition come from.
- Threshold tuning for compliance — keeps the numeric limits a version bump changes in external data.
- NIH FOA schema mapping — a consumer of the resolved, versioned profile.
Up one level: Agency Schema Registry & Versioning