Training Lineage as a Governed Supply Chain
A model release is defensible only when every behaviour-affecting input and every approval-bearing judgement closes into one verifiable, time-valid manifest.
TLDR
- The releasable object is not a weight file. It is the exact model bundle plus the causal ancestry, evaluation evidence, authority and recovery state that make its intended use defensible.
- The mechanism is release closure: content-addressed artefacts connected by typed dependency and decision edges, checked transitively before a promotion identity can act.
- A tokenizer, calibration value, evaluation rubric or data-use condition can invalidate a release without changing its headline model name. Promotion must therefore bind the whole effective system.
- Hashes and signatures prove identity and custody, not truth, rights or fitness. Full upstream lineage is also impossible for some acquired models, so unknowns need explicit consequence limits.
- Promote a signed release manifest, never a registry tag alone. Reject candidates whose evidence, approval or rollback package does not refer to the exact dependency closure being deployed.
The Friday release with one checksum
At 17:40 on Friday, two candidates for a document-triage model are waiting at the production gate. Both point to a weight file with the same SHA-256 digest. Both registry entries say version 7. Both show a macro F1 score of 0.923. The release manager is told that the candidates are identical.
They are not. Candidate A packages tokenizer version 1, the exact runtime image used during evaluation, a calibration value fitted on the held-out cohort, and an approval that names the resulting bundle digest. Candidate B packages tokenizer version 2 after a late normalisation fix. Its evaluation still refers to the old bundle. One training collection also carried a data-use approval that expired the previous day.
The weight checksum cannot distinguish these releases because the changed facts sit outside the weights. The headline score cannot distinguish them because it was produced against only one bundle. The registry tag cannot distinguish them because tags are names, not immutable evidence. Even a valid signature on Candidate B would show that an identified signer endorsed those bytes, not that the evidence remained applicable or the data remained authorised.
The core answer is that a model release is a dependency-and-decision closure. Every artefact that can alter behaviour, every piece of evidence used to justify promotion, and every authority condition that permits the action must be bound to the same release identity. If one strand changes, downstream evidence becomes suspect until the impact is calculated and the required gates are rerun.
This goes beyond preserving a historical story. The lineage graph must do operational work. It should answer, before promotion, whether the candidate is exactly what was evaluated. After a defect or withdrawal, it should calculate which releases are exposed. During rollback, it should reconstruct a complete prior package rather than swap only the weights.
Three practical questions expose the gap. First, identity: are these the exact bytes and configurations named by the candidate? Second, applicability: do the test results, rights and approvals still apply to those exact bytes, this purpose and this time? Third, recovery: can operators replace or withdraw the same effective package without leaving a tokenizer, calibration value or routing rule behind?
A conventional registry can answer the first question for weights and perhaps an image. A model card may answer parts of the second in prose. A deployment runbook may answer pieces of the third. The causal failure occurs when those answers are stored independently and joined by mutable names. Release closure converts the join into an invariant: every supporting record names immutable subjects, and the root manifest can be verified without trusting a human to remember which version went with which evidence.
A release is a causal graph
A source repository encourages a comforting picture: code enters a build, a binary comes out, and a version points to the result. Training systems have more causal inputs. Data selection, preprocessing, random seeds, base weights, adaptation layers, tokenisation, decoding defaults, calibration and runtime libraries can all alter the effective function. Final weights compress the consequences of many of these choices without preserving a readable account of them.
NIST makes this asymmetry explicit. Model architecture code is amenable to familiar versioning and reproducibility controls, while final weights arise only after collection, processing and training, where complete tracking is harder. Its guidance asks organisations to document what remains uncovered rather than silently treating gaps as known.1 That is the correct starting posture: unknown lineage is a recorded system state, not an empty field to be ignored.
Thought experiment one: the tokenizer transplant
Take one language model, one weight file and one evaluation set. Run it with tokenizer A. Then vary only the tokenizer, replacing A with tokenizer B while keeping the weights and prompts byte-for-byte identical. The model name and weight hash have not changed, yet token boundaries, sequence lengths and token IDs may change. The function presented to users is therefore different.
This thought experiment identifies the relevant unit. A weight file is an internal component. The deployed model is a composition of preprocessing, weights, inference code and post-processing. The exact boundary should follow causal influence rather than organisational ownership. A tokenizer owned by a platform team still belongs in the release closure if it can alter the candidate’s outputs.
The same rule applies to less obvious state. A calibration temperature can change which cases cross an automation threshold. A prompt template can change a generative classifier’s label distribution. A quantisation recipe can alter numerical behaviour even when the logical architecture is unchanged. A policy file can turn an advisory output into an automatically executed route. These items need not all be called “model artefacts”, but they are release ancestors when they influence the observable or authorised outcome.
The boundary should remain discriminating. Monitoring dashboards, team documents and unused datasets do not enter closure merely because they are nearby. The test is counterfactual: if this item were changed while all other named inputs stayed fixed, could the produced bundle, its behaviour, the evidence interpretation, release authority or recovery path change? A credible “yes” creates a typed dependency. A “no” leaves the item in ordinary operational inventory.
From inventory to ancestry
A bill of materials tells us what components are present. A lineage graph adds how one artefact was derived from others, by which activity, under whose control. The W3C PROV model supplies a durable abstraction: entities are used or generated by activities, and agents influence those activities.2 SLSA adds verifiable build provenance describing where, when and how an artefact was produced, with dependencies and the trusted build platform made explicit.3
Those foundations are useful, but a training release needs one further step. It must connect build ancestry to evaluations and decisions. in-toto statements bind typed claims to immutable subjects by digest, and its test-result and release predicates show how test evidence and final release composition can be represented separately.45 The paper’s proposed release manifest composes these ideas rather than replacing them.
Typed edges make lineage executable
A lineage link should state more than “related to”. was_trained_from, contains, tested, used_rubric, approved and can_rollback_to carry different intervention semantics. If a training source is withdrawn, the policy follows was_trained_from towards descendants that may require retraining. If an evaluation rubric changes, the policy follows used_rubric and may permit re-scoring without rebuilding weights. Generic links erase that distinction.
Edges also need producers and evidence. A statement that adapter A “came from” dataset D is weaker than an attested training activity that identifies A as output, D and code C as resolved inputs, recipe H as an external parameter, and builder B as the executing platform. The richer record lets a verifier reject an untrusted local run even when its claimed inputs match. Lineage becomes a control when relations have machine-checkable meaning and provenance of their own.
Five strands, two ledgers
The braid contains five strands because training governance fails when unlike things are collapsed into one generic record. Data answers questions of origin, consent, licence, purpose, sensitivity and cohort. Code and configuration answer how a transformation ran. Weights and runtime identify the effective executable composition. Evaluation evidence states what was tested, against which rubric and population. Release authority records who could approve, what they approved, under which conditions and until when.
Standards already cover useful portions. SPDX 3.0.1 has distinct Dataset, AI and Build profiles, while CycloneDX’s machine-learning bill of materials represents models, datasets, configurations and dependencies.67 Model cards document intended uses and performance conditions; datasheets document dataset motivation, composition, collection and recommended use.89 These are complementary records. None should be stretched into pretending that it proves every other property.
Material provenance and decision provenance
Material provenance traces what the release was made from and how. Decision provenance traces why promotion was allowed: the intended use, evaluation subject, rubric, thresholds, exceptions, reviewer role, decision and expiry. A complete material graph can recreate a candidate that nobody was authorised to release. A complete decision log can show an approval whose subject cannot be reconstructed. A governed release needs both.
This distinction prevents a common category error. A cryptographic signature is evidence about identity and integrity. Sigstore, for example, binds short-lived signing certificates to identities and records signing events in transparency logs.10 Those are valuable chain-of-custody properties. They do not establish that a dataset’s licence label is correct, that an evaluation measures the intended capability, or that the signer held the relevant business authority at release time.
Decision provenance therefore needs a structured receipt rather than a free-text “approved” field. The receipt should identify the candidate digest, evidence digest, intended use, governing policy revision, reviewer or automated authority, decision, conditions, valid interval and unresolved exceptions. The signature authenticates the receipt. Policy determines whether that signer was entitled to make that claim. The release gate verifies both.
This matters when teams reorganise. A person’s identity can remain valid while their delegated role changes. Conversely, a new person may inherit the authorised role without invalidating scientific evidence. Keeping identity, role and decision subject separate allows precise revocation. It avoids two blunt responses: accepting every historically valid signature forever, or rerunning expensive model tests merely because an approver rotated.
Thought experiment two: the perfectly signed forbidden build
Start with Candidate A, whose artefacts, evidence and approval all align. Now vary only one feature: the data-use permission expires one hour before promotion. Do not alter a byte in the dataset, code, weights, evaluation or signed manifest. Every digest still verifies. The model’s measured behaviour is unchanged. Yet the release has become inadmissible for that purpose.
The experiment separates integrity from authority. A signature can prove that the manifest was not changed after an identified act. It cannot make the underlying act permissible. This is why a release manifest needs effective times and purpose constraints, and why promotion must evaluate them against the proposed release time rather than merely retain them for later audit.
Presence is not correctness
Documentation can be present and still wrong. A large-scale audit of 1,858 datasets used in instruction and alignment collections reported licence omission above 70 per cent and licence error above 50 per cent on popular hosting sites.11 The result is bounded to the audited corpus, but it establishes an important negative control: metadata existence is not evidence of metadata validity.
Consequently, the manifest should preserve declared claims and concluded judgements separately. “Declared licence: X” records what a supplier or host states. “Concluded use decision: allowed for purpose U until time T, reviewed under policy P” records the consuming organisation’s bounded judgement. The latter should cite the former without overwriting it. When a source is corrected, the dependency graph can identify affected descendants and reopen the judgement.
flowchart LR A["The Friday release with one<br/>checksum"] --> B["A release is a causal graph"] B --> C["Five strands, two ledgers"] C --> D["Release closure under change"] D -. "re-test" .-> A classDef first fill:#0a1424,stroke:#7B4CD8,stroke-width:2px,color:#e6f3ff classDef step fill:#0a1424,stroke:#3f7da0,stroke-width:1px,color:#e6f3ff class A first class B,C,D step
Release closure under change
We can now state the mechanism precisely. Let a lineage graph be G = (V, E), where V is the set of immutable artefacts and decision records, and E is the set of typed dependency relations. Edges point from a release or derived artefact to what it depends on. For release root R, its closure is:
The arrow means “reachable by zero or more typed dependency edges”. If the release points to a bundle, the bundle points to an adapter and tokenizer, and the adapter points to a prepared dataset and trainer run, all of those nodes belong to C(R). Direct links alone are insufficient because the most consequential defect may sit several transformations upstream.
A candidate is admitted for use u at time t only when six non-compensating predicates hold:
I verifies artefact identity and attestations. Cₒ checks that the required closure is complete and typed. A checks rights and release authority for the intended use. T checks effective time, expiry and supersession. E requires evaluation evidence to bind the exact bundle and use. K requires a verified rollback or recovery package. Increasing one term cannot compensate for a zero elsewhere. A very accurate model with expired authority still fails.
Completeness is declared before the candidate arrives
No system can record every physical cause of a training run. Processor microcode, ambient temperature and the history of every infrastructure dependency could be expanded without end. Release closure avoids this regress by making completeness relative to a predeclared release schema and threat model. The schema says which artefact kinds and relations are mandatory for this class of model and use.
For a low-consequence batch classifier, the required kinds might be training data, code, recipe, weights, tokenizer, evaluation, approval and rollback. A safety-critical vision system may additionally require sensor calibration, compiler and accelerator revisions, simulation scenarios, hardware validation and dual approval. The candidate does not choose its own standard after seeing which records are available. The product owner and assurance function define the closure contract in advance.
This turns incompleteness into a deterministic failure. A missing dataset edge, unbound evaluator configuration or absent rollback package cannot be waived by a high aggregate score. A bounded exception is possible, but the exception itself becomes a signed decision node with scope, compensating controls and expiry. In other words, uncertainty may be accepted, but it may not remain invisible.
Minimal worked example: five nodes that are enough
Consider a small sentiment classifier used only in a research sandbox. Its release root points to a model bundle, an evaluation result and an approval. The bundle points to one dataset snapshot, one training run and one tokenizer. The evaluation points back to the bundle digest and its held-out set. The approval points to the same bundle and evaluation digests.
In this bounded case, closure can be compact. It does not need a committee or a graph database. A canonical JSON manifest and a verifier in continuous integration are enough. Change the tokenizer, and the bundle digest changes. The old evaluation no longer binds. The verifier blocks promotion until a new evaluation and approval are attached. The control scales down because the invariant is structural, not bureaucratic.
Production-shaped worked example: document triage r7
Now expand the case into a synthetic service that classifies inbound documents into specialist queues. Release r7 contains base weights, an adapter, tokenizer, inference image and calibration state. The adapter depends on a prepared training collection, trainer commit, recipe and base-model revision. Its evidence pack includes a grouped held-out set, evaluation rubric, cohort metrics, security probes and a calibration review. A model-release authority approves the exact bundle and evidence digests. A prior package, r6, is verified as rollback-ready.
On the morning of promotion, the data office withdraws source B because its purpose record was attached to the wrong collection. A direct registry query against r7 will not find source B. A transitive lineage query will: source B contributed to prepared set P3, P3 fed adapter A7, A7 belongs to bundle B7, and B7 is the subject of release r7. The same query may find r5 and an experimental branch. The incident team receives an impact set rather than a repository-wide guess.
Next, the platform team asks whether a full retrain is always required. The graph alone cannot answer. The typed relation and artefact semantics matter. If source B’s rows were included in training, removing them requires a new prepared set and retraining. If source B appeared only in an unused candidate pool, the release may need no rebuild. If it appeared only in evaluation, the model can remain byte-identical while its evidence becomes invalid. Lineage finds affected descendants; policy decides the required intervention.
The evidence node also preserves denominator and exclusions. A score of 0.923 is uninterpretable unless the manifest resolves the 600 held-out cases, group-splitting rule, rubric version, cohort slices, failed cases and evaluator implementation. If three excluded document types later enter production scope, the old score remains historically true yet ceases to support the widened use. The intended-use edge is therefore as important as the metric value.
Recovery uses the same composition. Rolling back from r7 to r6 means restoring r6 weights, tokenizer, calibration, image and routing policy, then verifying the observed endpoint state. Replacing only the adapter could create a combination that never passed evaluation. The rollback package is a release closure in its own right, not a pointer to a favourite checkpoint.
The negative control: when a registry snapshot is enough
A serious mechanism needs a case in which it adds little. Imagine a deterministic teaching model trained from a tiny public dataset, built locally from a fully pinned script, used only in an isolated classroom, with no personal data, external licence conditions, consequential decisions or long-lived service. A repository commit, environment lockfile, dataset hash and test output may answer every plausible recovery question.
This negative control weakens any claim that all model work needs an elaborate enterprise lineage service. The thesis survives in narrower form: the release record must close over all behaviour-affecting and decision-bearing inputs. In a simple sandbox, that closure is small and ordinary version control may implement it. Governance complexity should follow causal and consequence complexity.
Operating the supply chain
A graph becomes governance only when it changes who can do what. Training jobs should write artefacts and attestations, but they should not possess production promotion authority. Evaluation services should attest to exact subjects and configurations. Release automation should verify closure against policy. A separate promotion identity should act only after all hard gates pass. This keeps a stochastic training process outside the deterministic authority kernel.
The operating sequence has four trust transitions. Ingestion resolves source identity and use conditions before data becomes eligible for preparation. Training consumes only resolved inputs and emits an attested candidate, never a mutable “latest” output. Evaluation retrieves the candidate by digest and writes evidence against that subject. Promotion reconstructs the transitive closure, evaluates current policy, obtains or verifies the approval receipt, and then writes the release root to the production registry.
Each transition should fail closed on ambiguity. If the artefact store times out after accepting a write, the job must read back the digest instead of creating a duplicate identity. If an evaluation cannot resolve its rubric revision, it should produce an incomplete evidence record rather than inherit a default. If promotion cannot determine whether an approval was superseded, the outcome is “not admitted”, not “probably approved”. These are ordinary distributed-systems controls applied to model governance.
The root manifest need not duplicate every standard record. It can reference a SLSA build attestation for the trainer output, SPDX or CycloneDX descriptions for components, dataset documentation, in-toto test results and a local decision predicate. What matters is stable subject binding and a verifier that understands each accepted predicate type. This allows interoperable evidence without reducing the release to the least expressive common schema. It also permits gradual adoption: begin with a small internal manifest, then replace home-grown claims with standard attestations while preserving the closure policy.
NIST SP 800-218A recommends collecting, safeguarding and maintaining provenance for all components of each release, including model derivatives, libraries, frameworks and pipelines. It also calls for known and unknown provenance of training, testing, fine-tuning and alignment data to be recorded.1 NIST’s generative-AI profile separately identifies non-transparent integration of upstream components as a value-chain risk.12 The operational lesson is to capture lineage during execution, not reconstruct it when a release review or incident begins.
Immutable nodes, mutable judgements
Artefact nodes should be content-addressed and immutable. Corrections create new nodes and supersession edges. Judgements are also versioned records, not editable fields on a model card. A data-use decision can be superseded. An evaluation rubric can be replaced. An approval can expire or be withdrawn. Retaining the old node preserves what was knowable at the earlier decision time while the current policy follows the superseding edge.
This design supports two distinct questions. “What did we release on that date?” follows the historical manifest and its then-applicable records. “Can we release the same bytes now?” re-evaluates current rights, policy and evidence. The answers may differ without contradiction.
Immutability does not imply indiscriminate replication. A manifest should contain digests, classifications, stable identifiers and controlled references, not raw personal records or sensitive prompts. Detailed evidence can remain in an entitled store while its digest proves what was reviewed. Retention policy may delete underlying material when lawful obligations require it, but the resulting tombstone should state what was removed, by which authority and how that limits future replay.
Change propagation is typed
A dependency graph should calculate a candidate impact radius, then a rule table should decide the response. Weight or tokenizer changes usually demand rebuild and re-evaluation. A rubric change may permit deterministic re-scoring when stored predictions and labels are sufficient, otherwise it demands a rerun. A signer rotation may require only signature-policy verification. A revoked training source can demand retraining, release withdrawal and incident review.
Test the mechanism through failure injection. Remove one transitive edge and expect closure failure. Replace a tokenizer while preserving the old evaluation and expect exact-subject failure. Backdate a rights expiry and expect temporal failure. Sign an approval with a valid identity that lacks the release role and expect authority failure. Mark rollback as unverified and expect recovery failure. A lineage platform that draws attractive graphs but passes these corruptions is an observability tool, not a release control.
| Changed ancestor | Rebuild | Re-evaluate | Re-approve | Consider withdrawal | Reason |
|---|---|---|---|---|---|
| Training rows removed | Yes | Yes | Yes | If already released | Weights no longer derive from an admissible set. |
| Tokenizer or normaliser | Bundle | Yes | Yes | On material regression | The effective input function changed. |
| Evaluation rubric | No | Re-score or rerun | Yes | If the old decision fails | The meaning of “pass” changed. |
| Runtime library | Image | Risk-based | If behaviour or control changes | For exploitable defects | Execution or security semantics may change. |
| Approver identity | No | No | Verify delegated role | Normally no | Custody changed, not model behaviour. |
| Data-use decision withdrawn | If training data affected | Depends on role | Yes | Yes | Authority can fail with unchanged bytes. |
Unknowns should narrow release authority
Unknown provenance should not be treated as an automatic universal ban, because acquired models and historical datasets often contain irreducible gaps. It should affect what the release may do. A model with opaque upstream data may be acceptable for reversible drafting with source verification, yet inappropriate for an irreversible eligibility decision. The manifest should make that constraint machine-readable so a downstream deployment cannot silently widen the use.
This produces a useful asymmetry. Known, verified lineage can expand the set of defensible uses only when evaluation and authority also support them. Unknown lineage can immediately narrow that set. Absence of evidence should reduce authority before it becomes an incident explanation.
Executable release manifest
The accompanying standard-library Python program implements the paper’s mechanism on synthetic data. It creates a positive release with fifteen reachable artefacts and a negative release in which the tokenizer changes, the evidence and approval remain bound to the old bundle, and the training-data permission expires before promotion.
The verifier checks referential integrity, artefact and graph digests, required artefact kinds, transitive reachability, valid-time conditions, known provenance for critical data, bundle composition, exact evaluation binding, authorised approval and rollback readiness. It deliberately simulates signer roles rather than real cryptography. A production implementation should use verifiable attestations and an organisational policy engine.
The negative case is intentionally more demanding than corrupting a file. It creates a legitimate new tokenizer and recomputes the bundle and manifest graph digests. Basic integrity therefore passes for the new bytes. The failure appears because the evaluation and approval still name the prior bundle, while the data-use condition has expired. This demonstrates the central claim in executable form: a cryptographically tidy candidate can remain an inadmissible release.
The key verification pattern is small enough to inspect:
assert digest(graph_material(manifest)) == manifest["manifest_digest"]
closure = reachable(manifest)
release_time = iso(release["release_time"])
for artefact_id in closure:
artefact = artefacts[artefact_id]
assert digest(artefact_material(artefact)) == artefact["digest"]
assert provenance_is_acceptable(artefact)
assert valid_at(artefact, release_time)
assert evaluation["subject_digest"] == bundle["digest"]
assert approval["candidate_digest"] == bundle["digest"]
assert approval["evidence_digest"] == evaluation_artifact["digest"]
assert approval["signer_role"] in release["authorised_roles"]
assert rollback["status"] == "verified-ready"
A full runnable copy is available as Inspect the implementation in this paper. The complete source is also included below so the article remains self-contained.
Open the complete runnable Python program
#!/usr/bin/env python3
"""Synthetic release-closure verifier for an AI model supply chain.
Assumptions:
- SHA-256 identifies immutable records but does not establish truth, rights, or fitness.
- Signatures are represented by signer roles; production use must verify real signatures.
- Dates are ISO-8601 UTC timestamps.
- The example uses synthetic artefacts and no private endpoints or data.
"""
from __future__ import annotations
import copy
import hashlib
import json
from datetime import datetime, timezone
from typing import Any
Manifest = dict[str, Any]
Artefact = dict[str, Any]
Edge = tuple[str, str, str]
def canonical_bytes(value: Any) -> bytes:
"""Return a deterministic JSON encoding for hashing."""
return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
def digest(value: Any) -> str:
return hashlib.sha256(canonical_bytes(value)).hexdigest()
def iso(value: str) -> datetime:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
def artefact_material(artefact: Artefact) -> dict[str, Any]:
"""Everything except the digest is part of the immutable claim."""
return {key: value for key, value in artefact.items() if key != "digest"}
def refresh_digest(artefact: Artefact) -> None:
artefact["digest"] = digest(artefact_material(artefact))
def node(kind: str, payload: dict[str, Any], **metadata: Any) -> Artefact:
artefact: Artefact = {"kind": kind, "payload": payload, **metadata}
refresh_digest(artefact)
return artefact
def graph_material(manifest: Manifest) -> dict[str, Any]:
"""Bind the root, typed edges, and every artefact record into one graph digest."""
return {
"root": manifest["root"],
"artefacts": {
artefact_id: artefact["digest"]
for artefact_id, artefact in sorted(manifest["artefacts"].items())
},
"edges": sorted([list(edge) for edge in manifest["edges"]]),
}
def seal_manifest(manifest: Manifest) -> Manifest:
manifest["manifest_digest"] = digest(graph_material(manifest))
return manifest
def make_positive_manifest() -> Manifest:
release_time = "2026-08-30T09:00:00Z"
artefacts: dict[str, Artefact] = {
"data:train-v3": node(
"training_data",
{"source": "synthetic-labelled-cases", "rows": 2400, "purpose": "document-triage"},
critical=True,
provenance_known=True,
valid_from="2026-01-01T00:00:00Z",
valid_to="2027-01-01T00:00:00Z",
),
"data:eval-v2": node(
"evaluation_data",
{"source": "synthetic-held-out-cases", "rows": 600, "group_split": True},
critical=True,
provenance_known=True,
valid_from="2026-01-01T00:00:00Z",
valid_to="2027-01-01T00:00:00Z",
),
"code:trainer-a31": node("training_code", {"git_commit": "a31c9e7", "entry": "train.py"}),
"config:recipe-v4": node(
"training_recipe",
{"seed": 17, "epochs": 3, "learning_rate": 0.0002, "base_model": "base:1"},
),
"base:1": node("base_weights", {"name": "synthetic-base", "revision": "1"}),
"adapter:7": node("adapter_weights", {"format": "safetensors", "rank": 16}),
"tokenizer:1": node("tokenizer", {"vocabulary": "synthetic-v1", "normaliser": "nfc"}),
"runtime:5": node("runtime_image", {"image": "triage-runtime", "revision": "5"}),
"calibration:3": node("calibration", {"method": "temperature", "temperature": 1.41}),
"rubric:4": node(
"evaluation_rubric",
{"intended_use": "document-triage", "minimum_macro_f1": 0.90, "maximum_critical_fn": 2},
),
"rollback:6": node("rollback_bundle", {"release": "r6", "status": "verified-ready"}),
}
bundle_payload = {
"components": {
"weights": artefacts["adapter:7"]["digest"],
"base": artefacts["base:1"]["digest"],
"tokenizer": artefacts["tokenizer:1"]["digest"],
"runtime": artefacts["runtime:5"]["digest"],
"calibration": artefacts["calibration:3"]["digest"],
}
}
artefacts["bundle:7"] = node("model_bundle", bundle_payload)
evaluation_payload = {
"subject_digest": artefacts["bundle:7"]["digest"],
"intended_use": "document-triage",
"rubric_digest": artefacts["rubric:4"]["digest"],
"result": "PASS",
"macro_f1": 0.923,
"critical_false_negatives": 1,
"evaluated_at": "2026-08-28T14:00:00Z",
}
artefacts["evaluation:7"] = node("evaluation_evidence", evaluation_payload)
approval_payload = {
"candidate_digest": artefacts["bundle:7"]["digest"],
"evidence_digest": artefacts["evaluation:7"]["digest"],
"decision": "APPROVE",
"signer_role": "model-release-authority",
"valid_until": "2026-09-15T00:00:00Z",
}
artefacts["approval:7"] = node("release_decision", approval_payload)
release_payload = {
"release_id": "candidate-r7",
"release_time": release_time,
"intended_use": "document-triage",
"required_kinds": [
"training_data",
"evaluation_data",
"training_code",
"training_recipe",
"base_weights",
"adapter_weights",
"tokenizer",
"runtime_image",
"calibration",
"evaluation_rubric",
"evaluation_evidence",
"release_decision",
"rollback_bundle",
],
"authorised_roles": ["model-release-authority"],
}
artefacts["release:7"] = node("release", release_payload)
edges: list[Edge] = [
("release:7", "packages", "bundle:7"),
("release:7", "is_supported_by", "evaluation:7"),
("release:7", "is_authorised_by", "approval:7"),
("release:7", "can_rollback_to", "rollback:6"),
("bundle:7", "contains", "adapter:7"),
("bundle:7", "contains", "base:1"),
("bundle:7", "contains", "tokenizer:1"),
("bundle:7", "contains", "runtime:5"),
("bundle:7", "contains", "calibration:3"),
("adapter:7", "was_trained_from", "data:train-v3"),
("adapter:7", "was_produced_by", "code:trainer-a31"),
("adapter:7", "used_recipe", "config:recipe-v4"),
("evaluation:7", "tested", "bundle:7"),
("evaluation:7", "used", "data:eval-v2"),
("evaluation:7", "used", "rubric:4"),
("approval:7", "considered", "evaluation:7"),
("approval:7", "approved", "bundle:7"),
]
return seal_manifest({"root": "release:7", "artefacts": artefacts, "edges": edges})
def targets(manifest: Manifest, source: str, relation: str) -> list[str]:
return [target for edge_source, edge_relation, target in manifest["edges"]
if edge_source == source and edge_relation == relation]
def reachable(manifest: Manifest) -> set[str]:
outgoing: dict[str, list[str]] = {}
for source, _relation, target in manifest["edges"]:
outgoing.setdefault(source, []).append(target)
seen: set[str] = set()
stack = [manifest["root"]]
while stack:
current = stack.pop()
if current in seen:
continue
seen.add(current)
stack.extend(outgoing.get(current, []))
return seen
def one_target(manifest: Manifest, source: str, relation: str, failures: list[str]) -> str | None:
matches = targets(manifest, source, relation)
if len(matches) != 1:
failures.append(f"{source}: expected one {relation!r} edge, found {len(matches)}")
return None
return matches[0]
def verify_release(manifest: Manifest) -> list[str]:
failures: list[str] = []
artefacts: dict[str, Artefact] = manifest["artefacts"]
root_id = manifest["root"]
if manifest.get("manifest_digest") != digest(graph_material(manifest)):
failures.append("manifest graph digest mismatch")
if root_id not in artefacts or artefacts[root_id].get("kind") != "release":
return sorted(set(failures + ["root release is missing or mistyped"]))
for source, relation, target in manifest["edges"]:
if source not in artefacts:
failures.append(f"edge {relation!r} has missing source {source}")
if target not in artefacts:
failures.append(f"edge {relation!r} has missing target {target}")
for artefact_id, artefact in artefacts.items():
if digest(artefact_material(artefact)) != artefact.get("digest"):
failures.append(f"{artefact_id}: artefact digest mismatch")
closure = reachable(manifest)
release = artefacts[root_id]["payload"]
release_time = iso(release["release_time"])
present_kinds = {artefacts[item]["kind"] for item in closure if item in artefacts}
for required in release["required_kinds"]:
if required not in present_kinds:
failures.append(f"release closure lacks required kind {required}")
for artefact_id in closure:
if artefact_id not in artefacts:
continue
artefact = artefacts[artefact_id]
if artefact.get("critical") and not artefact.get("provenance_known", False):
failures.append(f"{artefact_id}: critical artefact has unknown provenance")
valid_from = artefact.get("valid_from")
valid_to = artefact.get("valid_to")
if valid_from and release_time < iso(valid_from):
failures.append(f"{artefact_id}: not yet valid at release time")
if valid_to and release_time >= iso(valid_to):
failures.append(f"{artefact_id}: rights or applicability expired before release")
bundle_id = one_target(manifest, root_id, "packages", failures)
evidence_id = one_target(manifest, root_id, "is_supported_by", failures)
approval_id = one_target(manifest, root_id, "is_authorised_by", failures)
rollback_id = one_target(manifest, root_id, "can_rollback_to", failures)
if not all([bundle_id, evidence_id, approval_id, rollback_id]):
return sorted(set(failures))
if not all(item in artefacts for item in [bundle_id, evidence_id, approval_id, rollback_id]):
return sorted(set(failures))
bundle = artefacts[bundle_id]
component_ids = targets(manifest, bundle_id, "contains")
expected_components = {artefacts[item]["digest"] for item in component_ids if item in artefacts}
declared_components = set(bundle["payload"]["components"].values())
if expected_components != declared_components:
failures.append(f"{bundle_id}: declared components do not match typed contains edges")
evidence = artefacts[evidence_id]["payload"]
if evidence["subject_digest"] != bundle["digest"]:
failures.append(f"{evidence_id}: evidence does not bind the exact model bundle")
if evidence["intended_use"] != release["intended_use"]:
failures.append(f"{evidence_id}: evidence was produced for a different intended use")
if evidence["result"] != "PASS":
failures.append(f"{evidence_id}: evaluation result is not PASS")
approval = artefacts[approval_id]["payload"]
if approval["candidate_digest"] != bundle["digest"]:
failures.append(f"{approval_id}: decision does not bind the exact model bundle")
if approval["evidence_digest"] != artefacts[evidence_id]["digest"]:
failures.append(f"{approval_id}: decision does not bind the exact evidence")
if approval["decision"] != "APPROVE":
failures.append(f"{approval_id}: decision is not APPROVE")
if approval["signer_role"] not in release["authorised_roles"]:
failures.append(f"{approval_id}: signer role is not authorised")
if release_time >= iso(approval["valid_until"]):
failures.append(f"{approval_id}: approval expired before release")
rollback = artefacts[rollback_id]["payload"]
if rollback.get("status") != "verified-ready":
failures.append(f"{rollback_id}: rollback package has not been verified")
return sorted(set(failures))
def make_negative_manifest(positive: Manifest) -> Manifest:
broken = copy.deepcopy(positive)
artefacts: dict[str, Artefact] = broken["artefacts"]
# Build a legitimate new tokenizer, bundle, and release root.
artefacts["tokenizer:2"] = node("tokenizer", {"vocabulary": "synthetic-v2", "normaliser": "nfkc"})
bundle_payload = copy.deepcopy(artefacts["bundle:7"]["payload"])
bundle_payload["components"]["tokenizer"] = artefacts["tokenizer:2"]["digest"]
artefacts["bundle:8"] = node("model_bundle", bundle_payload)
release_payload = copy.deepcopy(artefacts["release:7"]["payload"])
release_payload["release_id"] = "candidate-r8"
artefacts["release:8"] = node("release", release_payload)
broken["root"] = "release:8"
new_edges: list[Edge] = []
for source, relation, target in broken["edges"]:
if source == "release:7":
source = "release:8"
if relation == "packages" and target == "bundle:7":
target = "bundle:8"
elif source == "bundle:7":
source = "bundle:8"
if relation == "contains" and target == "tokenizer:1":
target = "tokenizer:2"
new_edges.append((source, relation, target))
broken["edges"] = new_edges
# The data-use permission expires before promotion, and the record is re-digested.
artefacts["data:train-v3"]["valid_to"] = "2026-08-29T00:00:00Z"
refresh_digest(artefacts["data:train-v3"])
# Evaluation and approval deliberately remain bound to bundle:7. The new graph is
# resealed, so integrity passes while evidence, authority, and time still fail.
return seal_manifest(broken)
def print_result(label: str, manifest: Manifest) -> None:
failures = verify_release(manifest)
if not failures:
closure_size = len(reachable(manifest))
print(f"PASS {label}: release closure complete ({closure_size} artefacts)")
return
print(f"FAIL {label}: {len(failures)} blocking issue(s)")
for failure in failures:
print(f" - {failure}")
if __name__ == "__main__":
positive = make_positive_manifest()
negative = make_negative_manifest(positive)
print_result("candidate-r7", positive)
print_result("candidate-r8", negative)
How to adapt the manifest to a real platform
Replace synthetic payload digests with digests issued by the artefact store. Emit SLSA or in-toto attestations from trusted build and evaluation services. Store data-use and approval decisions as signed predicates with effective times. Resolve organisational roles through workload identity rather than user-supplied strings. Evaluate release policy in the promotion service, and give training jobs no credential that can write to the production registry.
Do not put confidential records into a widely replicated manifest. Record stable identifiers, digests, classification and controlled references. The evidence store can enforce entitlement while the manifest proves which evidence was considered.
stateDiagram-v2 [*] --> Claim state "Thought experiment one: the<br/>tokenizer transplant" as Claim state "From inventory to ancestry" as Test state "Typed edges make lineage<br/>executable" as Revision Claim --> Test: expose mechanism Test --> Decision: evidence survives Test --> Revision: rival survives Revision --> Claim: narrow or rebuild Decision --> [*]
Promotion decision instrument
Use the following hard-gate record for each candidate. A “no” is not averaged into a score. It blocks promotion or requires a formally authorised exception whose scope and expiry become part of the manifest.
Compact glossary
- Artefact
- An immutable, content-addressed dataset, code, configuration, weight, runtime, evidence or decision record.
- Attestation
- A typed claim by an identified producer about an artefact or activity, bound to a subject digest.
- Release closure
- The transitive set of behaviour-affecting and decision-bearing ancestors required to admit a release.
- Effective model bundle
- The weights plus every runtime component and configuration that can alter observable behaviour.
- Decision provenance
- The evidence, policy, role, conditions and time that explain why promotion was permitted.
- Impact radius
- The descendant set potentially affected when an upstream artefact or judgement changes.
Promote the closure, not the checkpoint
A checkpoint is evidence that training produced bytes. It is not yet evidence that those bytes are the exact system evaluated for a permitted use, approved by the right authority, valid at the release time and recoverable as one package.
The architecture decision is therefore simple: make the release manifest the object of promotion. The registry may still store weights and tags, but the production gate should accept only an immutable root whose transitive closure passes identity, completeness, authority, time, evidence and recovery checks. A changed ancestor opens only the affected paths, while a failed hard gate blocks the release.
This changes lineage from an audit appendix into a control surface. It gives builders a reproducible ancestry, reviewers an exact subject, incident teams a computable impact radius and operators a complete rollback unit. Most importantly, it makes a modest claim: not that the model is universally safe or correct, but that this particular release has a traceable, testable and authorised reason to exist.
sequenceDiagram participant Q as Question participant M as Mechanism participant E as Evidence participant D as Decision Q->>M: The Friday release with one checksum M->>E: Thought experiment one: the tokenizer transplant E-->>M: compare rival explanations E->>D: Sources D-->>Q: revise scope or act
Sources
- NIST SP 800-218A, Secure Software Development Practices for Generative AI and Dual-Use Foundation Models. Official guidance on model lineage, release provenance and known or unknown data provenance.
- W3C PROV-DM. Official data model for entities, activities, agents and provenance relations.
- SLSA v1.2 provenance. Official specification for verifiable source and build ancestry.
- in-toto Statement v1. Official attestation layer binding a typed predicate to immutable subjects.
- in-toto test-result predicate and release predicate. Official schemas for test and release claims.
- SPDX Specification 3.0.1 profiles. Official Dataset, AI and Build profile conformance points.
- CycloneDX AI/ML-BOM. Official capability description for models, datasets, configurations and dependencies.
- Mitchell et al., Model Cards for Model Reporting. Primary paper on intended-use and performance documentation.
- Gebru et al., Datasheets for Datasets. Primary paper on dataset motivation, composition, collection and recommended uses.
- Sigstore signing overview. Official documentation for identity-bound signing and transparency logging.
- Longpre et al., A large-scale audit of dataset licensing and attribution in AI. Peer-reviewed audit of 1,858 datasets.
- NIST AI 600-1, Generative Artificial Intelligence Profile. Official profile identifying value-chain and component-integration risk.