Building a Data Management Plan Template for Researchers: FAIR Automation & Compliance Implementation

Static, manually authored Data Management Plans (DMPs) consistently fail runtime validation against evolving funder mandates and institutional retention policies. The structural deficiency stems from narrative formatting, absent machine-readable schemas, non-deterministic license mapping, and missing persistent identifier (PID) resolution pathways. This page is a build guide: it shows a Python automation engineer or research data manager how to replace a free-text DMP with a typed template that enforces strict field presence, controlled vocabularies, and cross-referenced compliance rules at write time. It assumes you can already run Pydantic schema validation and want the exact schema contract, funder-conditional logic, and routing dispatcher that a plan template needs. This template is the input artifact for the wider policy pipeline described in Data Governance Frameworks — the plan is where obligations are captured; that pipeline is where they are enforced against every deposit.

Decision flow from researcher input to a routed, compliant DMP A left-to-right pipeline. Researcher input enters Step 1 (schema contract: field presence and controlled vocabulary) then Step 2 (funder mandate gate: retention floor, license allowlist, sensitivity tier). Failing either gate routes the record up into a reject band that returns it to the researcher with a machine-readable reason. A record that passes both becomes Step 3, a compliant typed DMP artifact, and reaches Step 4, a routing dispatcher that reads access_protocol and fans the dataset out to three access tiers: open access for Public, an authenticated RBAC gateway for Controlled and Restricted, and secure storage behind a data use agreement for Confidential. Rejected — returned to researcher single ValidationError, machine-readable reason failfail Researcher input draft plan fields STEP 1 · STRUCTURE Schema contract field presence · controlled vocab STEP 2 · POLICY Funder mandate gate retention floor · license · sensitivity STEP 3 · ARTIFACT Compliant DMP typed · validated STEP 4 · ROUTE Routing dispatcher reads access_protocol pass Open access Public → open endpoint Authenticated Controlled · Restricted Secure store Confidential → DUA

The Schema Contract

The template must operate as a strict contract between researcher input and automated compliance engines. Use the JSON Schema serialization standard as the on-disk representation, but enforce validation through a typed object model so every field carries a machine-checkable constraint rather than free text. Separate metadata capture from policy enforcement to prevent cross-contamination of descriptive and compliance-critical fields. Each schema node maps to exactly one FAIR principle, so a failing field names the principle it breaks — the same principle-to-component mapping formalized in the FAIR Principle Breakdown.

The table below is the core reference artifact: it is the field-by-field contract the template enforces before a plan is ever accepted. Avoid free-text fields for any compliance-critical node — use enumerated types, regex constraints, and integer bounds to eliminate ambiguity.

Template field Type / constraint FAIR principle Failure action
dataset_title str, 5–255 chars Findable Reject — required field
creator_orcid ORCID iD, URI form https://orcid.org/XXXX-XXXX-XXXX-XXXX Findable Reject — malformed identifier
pid_assigned bool (triggers minting when False) Findable Route to PID minting service
funding_agency Literal["NSF","NIH","Horizon_Europe","UKRI","Internal"] Reusable Reject — unknown funder
grant_type str, regex ^[A-Z0-9\-]+$ Reusable Reject — unstructured grant code
access_protocol Literal["Public","Controlled","Restricted","Confidential"] Accessible Route to matching access tier
data_format Controlled vocabulary (e.g. CSV, NetCDF, FASTQ) Interoperable Reject — out-of-vocabulary format
spdx_license_id SPDX License List identifier, allowlist-checked Reusable Reject — invalid or prohibited license
retention_months int ≥ 0, funder minimum Reusable Reject — below funder floor
repository_target str, regex ^https?:// Accessible Reject — non-resolvable endpoint
embargo_end_date date or None; forbidden when access is Public Accessible Reject — embargo/sensitivity conflict

Licensing is recorded as an SPDX License List identifier so a legal permission set reduces to a single canonical token; selecting and encoding that token is covered in Open License Configuration. Discovery metadata carries a Schema.org Dataset shape on export so harvesters can index the plan’s dataset once published.

Funder Mandate Decision Table

Funder mandate misalignment is the primary cause of validation failure. NSF, NIH, Horizon Europe, and UKRI enforce divergent retention windows and license allowances, so hardcoding a single retention value or accepting an unstructured license string will break automated checks. The template resolves funding_agency and grant_type at initialization and applies the deterministic rules below. These are the obligations formalized in Funder Mandate Alignment; the NIH-specific crosswalk is worked end-to-end in aligning NIH data sharing policies with FAIR principles.

Funding agency Retention minimum (months) License rule Embargo allowance
NSF 36 SPDX open license; reject -NC/-ND variants Discipline-dependent, bounded
NIH 120 SPDX open license; reject -NC/-ND variants Justified access limits only
Horizon_Europe 60 SPDX open license; reject -NC/-ND variants Bounded embargo permitted
UKRI 120 SPDX open license; reject -NC/-ND variants Bounded embargo permitted
Internal 0 Any valid SPDX identifier Unrestricted

The retention minimums are the institutional floors used throughout this page; the underlying preservation window is set by the chosen repository and your records-retention policy, so treat the table as the lower bound the template must never fall below.

Production Python Implementation

The following implementation uses the Pydantic V2 API for strict type enforcement, field-level validation, and conditional mandate checking. The pipeline isolates validation from UI generation, enabling direct integration with CI/CD workflows and repository submission APIs. Both the SPDX allowlist and the funder-retention floors live in single shared constants so a policy change is one edit, not two drifting copies.

python
from pydantic import BaseModel, Field, field_validator, model_validator, ValidationError
from typing import Literal, Optional
from datetime import date

# SPDX License Registry (truncated for brevity; load from the full SPDX License
# List in production). NC/ND variants are valid SPDX identifiers, so they pass
# schema validation here but are blocked for public funders by the model-level
# policy check below.
VALID_SPDX_LICENSES: set[str] = {
    "CC0-1.0", "CC-BY-4.0", "CC-BY-SA-4.0", "CC-BY-NC-4.0", "CC-BY-ND-4.0",
    "MIT", "Apache-2.0", "BSD-3-Clause",
}

# Funder mandate configuration: retention floor in months, keyed by agency.
FUNDER_RETENTION_MIN: dict[str, int] = {
    "NSF": 36,
    "NIH": 120,
    "Horizon_Europe": 60,
    "UKRI": 120,
    "Internal": 0,
}

# Public funders reject non-commercial / no-derivatives licenses.
PUBLIC_FUNDERS: frozenset[str] = frozenset({"NSF", "NIH", "Horizon_Europe", "UKRI"})
PROHIBITED_PUBLIC_LICENSES: frozenset[str] = frozenset({"CC-BY-NC-4.0", "CC-BY-ND-4.0"})


class DMPComplianceError(ValueError):
    """Raised for funder / policy violations; wrapped by Pydantic's ValidationError."""


class DataManagementPlan(BaseModel):
    dataset_title: str = Field(..., min_length=5, max_length=255)
    funding_agency: Literal["NSF", "NIH", "Horizon_Europe", "UKRI", "Internal"]
    grant_type: str = Field(..., pattern=r"^[A-Z0-9\-]+$")
    retention_months: int = Field(..., ge=0)
    spdx_license_id: str
    sensitivity_classification: Literal["Public", "Controlled", "Restricted", "Confidential"]
    repository_target: str = Field(..., pattern=r"^https?://")
    pid_assigned: bool = False
    embargo_end_date: Optional[date] = None

    @field_validator("spdx_license_id")
    @classmethod
    def validate_spdx(cls, v: str) -> str:
        if v not in VALID_SPDX_LICENSES:
            raise DMPComplianceError(f"Invalid SPDX license: {v}.")
        return v

    @field_validator("retention_months")
    @classmethod
    def validate_retention(cls, v: int, info) -> int:
        # info.data holds already-validated fields; funding_agency is declared
        # before retention_months, so it is available here.
        agency = info.data.get("funding_agency")
        if agency in FUNDER_RETENTION_MIN:
            minimum = FUNDER_RETENTION_MIN[agency]
            if v < minimum:
                raise DMPComplianceError(
                    f"Retention {v} months violates {agency} minimum of {minimum} months."
                )
        return v

    @model_validator(mode="after")
    def enforce_cross_field_policy(self) -> "DataManagementPlan":
        if self.funding_agency in PUBLIC_FUNDERS and self.spdx_license_id in PROHIBITED_PUBLIC_LICENSES:
            raise DMPComplianceError(
                f"Public funder {self.funding_agency} prohibits {self.spdx_license_id}; "
                "use CC-BY-4.0 or CC0-1.0."
            )
        if self.sensitivity_classification == "Public" and self.embargo_end_date is not None:
            raise DMPComplianceError("Public datasets cannot carry an embargo period.")
        return self

    def generate_compliance_report(self) -> dict:
        return {
            "status": "VALID",
            "funder": self.funding_agency,
            "retention_months": self.retention_months,
            "license": self.spdx_license_id,
            "pid_required": not self.pid_assigned,
            "routing_target": self.repository_target,
        }


# Production usage: any validator failure surfaces as a single ValidationError.
try:
    dmp = DataManagementPlan(
        dataset_title="Genomic Sequencing Cohort Alpha",
        funding_agency="NIH",
        grant_type="R01-CA-2024",
        retention_months=120,
        spdx_license_id="CC-BY-4.0",
        sensitivity_classification="Controlled",
        repository_target="https://repository.institution.edu/ds/alpha",
        pid_assigned=False,
        embargo_end_date=None,
    )
    print(dmp.generate_compliance_report())
except ValidationError as e:
    # Pydantic V2 wraps every validator failure — including the custom
    # DMPComplianceError raised above — in one ValidationError. Catch it at the
    # submission gateway, log e.json() for audit, and reject before the payload
    # reaches the repository API. The original message is preserved in the wrapper.
    print(f"Validation failed: {e}")

The template enforces SPDX compliance via a strict allowlist, validates retention against funder-specific minimums, and blocks contradictory license and embargo states at the model level. Integrate this validation layer directly into your submission gateway so a non-compliant plan never advances.

Routing & PID Assignment

Once validation passes, the template must trigger automated routing — do not rely on manual curator intervention. A dispatcher reads repository_target, sensitivity_classification, and pid_assigned and drives the plan’s dataset through the lifecycle below.

Dataset publication lifecycle from draft to published A state diagram. Start leads to Draft. Draft transitions to Validated on passing schema and funder checks, or to Rejected on a compliance failure. Rejected transitions back to Draft when the researcher revises. Validated transitions to Published for immediate open access, or to Embargoed when an embargo date is set. Embargoed transitions to Published when the embargo expires. Published is the final state. Draft researcher edits Validated clears all gates Published dataset live Rejected compliance failure Embargoed timed hold schema + funder pass immediate open access terminal compliance fail revise embargo date set embargo expires
  1. PID resolution. When pid_assigned is False, invoke the institutional Handle/DOI registration API. Pass the validated dataset_title, creator_orcid, and spdx_license_id to the minting service, then store the returned PID on the plan artifact.
  2. Access-tier mapping. Route Public datasets to open-access endpoints; route Controlled and Restricted datasets to authenticated gateways with role-based access control; route Confidential datasets to secure institutional storage behind explicit data use agreements. The boundary enforcement for these tiers is detailed in security & access control.
  3. Retention enforcement. Use retention_months to schedule expiration or review triggers, and align them with the deposit target chosen under Institutional Repository Strategy so records are neither deleted early nor stored indefinitely out of policy.

Verification

Assert the template’s rejection paths the same way you assert application logic. The test below exercises the compliant case plus the three rules most likely to drift — the funder retention floor, the public-funder license ban, and the embargo/sensitivity conflict.

python
import pytest
from pydantic import ValidationError

BASE = dict(
    dataset_title="Genomic Sequencing Cohort Alpha",
    funding_agency="NIH",
    grant_type="R01-CA-2024",
    retention_months=120,
    spdx_license_id="CC-BY-4.0",
    sensitivity_classification="Controlled",
    repository_target="https://repository.institution.edu/ds/alpha",
)


def test_compliant_plan_validates() -> None:
    dmp = DataManagementPlan(**BASE)
    assert dmp.generate_compliance_report()["status"] == "VALID"


def test_retention_below_funder_floor_rejected() -> None:
    with pytest.raises(ValidationError):
        DataManagementPlan(**{**BASE, "retention_months": 24})  # < NIH 120


def test_public_funder_rejects_nc_license() -> None:
    with pytest.raises(ValidationError):
        DataManagementPlan(**{**BASE, "spdx_license_id": "CC-BY-NC-4.0"})


def test_public_dataset_forbids_embargo() -> None:
    from datetime import date
    with pytest.raises(ValidationError):
        DataManagementPlan(
            **{**BASE, "sensitivity_classification": "Public", "embargo_end_date": date(2027, 1, 1)}
        )

Run it with pytest -q dmp_template_test.py; a clean run proves the schema, the funder floor, and the cross-field policy all reject the payloads they are meant to reject.

Gotchas

  • Hardcoded retention overrides. Legacy intake scripts inject a static retention_months that silently undercuts the funder floor. Root cause: the value is set outside the model. Fix: only ever construct retention_months from FUNDER_RETENTION_MIN[funding_agency] as the lower bound, never from a caller-supplied default.
  • Free-text license fields. Accepting an arbitrary string breaks SPDX mapping and downstream license-compatibility checks. Root cause: no allowlist at the field boundary. Fix: validate against VALID_SPDX_LICENSES and reject anything not in the official SPDX License List.
  • Allowlist drift across the two constants. The prohibited-license set and the SPDX allowlist are separate; add a license to one and forget the other and you quarantine valid plans. Fix: change VALID_SPDX_LICENSES and PROHIBITED_PUBLIC_LICENSES in the same reviewed commit so the diff is the audit record.

Frequently Asked Questions

Should the DMP template be JSON Schema or a Pydantic model?

Both, at different layers. Serialize the on-disk plan as JSON Schema so it is tool-agnostic and reviewable, but enforce it through the Pydantic V2 model shown above, which adds cross-field policy rules — funder retention floors, license bans, embargo conflicts — that plain JSON Schema cannot express. The model is the runtime gate; the JSON Schema is the interchange format.

How do I add a new funder without touching the validators?

Add one row to FUNDER_RETENTION_MIN with the agency’s retention floor, extend the funding_agency Literal, and, if the funder is public, add it to PUBLIC_FUNDERS. The retention and license validators read those constants directly, so no validator body changes — and the diff on the constants is the audit record of the new obligation.

Where does the template sit relative to PID minting?

Before it. The template validates first; only a plan that clears schema, funder, and cross-field checks reaches the routing dispatcher, which then mints a Handle or DOI when pid_assigned is False. Minting an identifier for a plan that later fails validation would leave an orphaned, non-compliant PID in the registry.

What happens when a researcher submits a non-SPDX license string?

The validate_spdx field validator rejects it immediately, and Pydantic surfaces the failure as a single ValidationError at the gateway. Catch that error, log e.json() for the audit trail, and return the message to the depositor so they can pick a valid SPDX License List identifier before resubmitting.