Problem
When you need this. Two runs both claim to use “events v1” and “feature v1,” yet produce different counts. One input file was edited in place. Another developer changed the count from a one-day to a two-day window without changing its version. The names stayed the same; their meaning did not.
You will bind those names to content fingerprints and select a complete snapshot by its manifest identity. Before computing, the runner checks that the selected inputs and implementation still match. A small SQLite registry then prevents an existing version from acquiring a different meaning.
The example reuses the feature-history store and its eight synthetic user events. It adds version selection around that implementation, without changing the aggregation or teaching another training pipeline.
Concepts
A version name is a label; a content fingerprint identifies bytes or a defined serialization. A manifest binds component names to those identities. A separately selected expected manifest identity prevents an edited manifest from becoming its own approval.
The registry records which contents already belong to a version. Reusing a version with different contents is a conflict, even when the new manifest is internally consistent. These checks establish which inputs were selected, not whether the data is useful or the writer is authorized.
Architecture
The wrapper fingerprints raw data, supported schema, feature definition and the fixed source inventory. It compares the manifest with the selected expected identity before computing. A SQLite transaction binds component versions or refuses conflicts without leaving partial new bindings. The existing feature-history computation then runs under that selected snapshot.
Working code
Separate the three component identities
The dataset version identifies a particular event snapshot. The schema version identifies the fields and time rules used to interpret those events. The feature version identifies the aggregation definition and its bound implementation. Changing one component does not always require changing the others: a new dataset can use the existing schema and feature definition.
dataset.json contains a name, a positive integer version and the event list. Its fingerprint covers the original file bytes, including JSON formatting. This is a byte-identity rule; it does not claim that whitespace changes the events’ meaning.
The schema describes the existing validator’s supported contract:
{
"name": "user-event-schema",
"version": 1,
"fields": [
"id",
"entity",
"event_at",
"arrived_at"
],
"time_zone": "UTC",
"time_resolution": "minute"
}The schema fingerprint binds both this descriptor and the validator’s source. The wrapper accepts this implemented contract only. Changing version to two does not add support for new fields or perform a migration; it is refused until the corresponding interpretation is implemented.
The feature has a stable logical name, with its time window and availability rule stated separately:
{
"name": "user_event_count",
"version": 1,
"entity": "user",
"window_days": 1,
"aggregation": "count",
"availability": "as_known"
}user_event_count@v1 uses a one-day window and as_known arrivals. A two-day version will use the same logical name with version two. The feature fingerprint includes the complete descriptor and a fixed inventory of computation, validation, history-query and wrapper source files. This conservative policy requires a new feature version after a bound source change, even if its author expects identical behavior.
Lock a snapshot before changing the workspace
A manifest lists the input-file fingerprints, implementation-file fingerprints and component identities. Its own SHA-256 digest is the snapshot’s manifest ID. Selecting that ID fixes the exact bundle you intend to use. A filename such as latest.json would not provide that reference if its contents could change.
Download the complete lab and enter mlops-feature-versions/:
python3.12 -m venv .venv
source .venv/bin/activateThe ZIP uses the standard library. feature_history/ contains unchanged copies of the previous lesson’s store, SQL query and vendored computation/validation. versions.py adds snapshot selection and the immutable component registry. The original lab’s records remain unchanged.
run_locked() first checks the supplied manifest against the separately expected ID. It then captures the current input bytes, validates them and reconstructs their manifest. A mismatch is refused before History.materialize() is called. The computation uses the parsed input bytes just checked, rather than reopening those files afterwards.
Inspect the fingerprint, binding and execution code
"""Bind dataset/schema/feature identities before calling the existing feature store.
Local trusted workspace only: hashes detect changes against an expected identity;
they do not authenticate the author or prove quality of data or feature code.
"""
import hashlib
import json
from pathlib import Path
import sqlite3
import sys
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE / 'feature_history'))
from history import History, Feature, source_rows, minute
INPUTS = ('dataset.json', 'schema.json', 'feature.json')
CODE = ('versions.py', 'feature_history/history.py', 'feature_history/join.sql',
'feature_history/vendor/availability.py', 'feature_history/vendor/feature_math.py')
SCHEMA = {'name': 'user-event-schema', 'version': 1,
'fields': ['id', 'entity', 'event_at', 'arrived_at'],
'time_zone': 'UTC', 'time_resolution': 'minute'}
def digest(raw):
return hashlib.sha256(raw).hexdigest()
def encoded(value):
return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False).encode()
def unique_pairs(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError('duplicate JSON object key')
result[key] = value
return result
def parse(raw):
def reject(value):
raise ValueError('nonstandard JSON constant: ' + value)
return json.loads(raw, object_pairs_hook=unique_pairs, parse_constant=reject)
def identity(value):
if (not isinstance(value, dict) or not isinstance(value.get('name'), str)
or not 1 <= len(value['name']) <= 100
or type(value.get('version')) is not int or value['version'] < 1):
raise ValueError('identity needs a bounded name and positive integer version')
return value['name'], value['version']
def inputs(root):
raw = {name: (Path(root) / name).read_bytes() for name in INPUTS}
data, schema, spec = (parse(raw[name]) for name in INPUTS)
for value in (data, schema, spec):
identity(value)
if schema != SCHEMA:
raise ValueError('unsupported schema contract; changing its version does not add a validator')
if set(data) != {'name', 'version', 'events'} or not isinstance(data['events'], list):
raise ValueError('dataset needs identity and an event list')
source_rows(data['events']) # Reuse the existing event/UTC/minute validation.
required = {'name', 'version', 'entity', 'window_days', 'aggregation', 'availability'}
if (set(spec) != required or spec['entity'] != 'user'
or type(spec['window_days']) is not int or spec['window_days'] < 1
or spec['aggregation'] not in ('count', 'distinct_days')
or spec['availability'] not in ('as_known', 'corrected')):
raise ValueError('unsupported feature definition')
return raw, data, schema, spec
def capture(root):
raw, data, schema, spec = inputs(root)
code = {name: digest((HERE / name).read_bytes()) for name in CODE}
feature_digest = digest(encoded({'definition': spec, 'implementation': code}))
manifest = {'format': 1, 'files': {name: digest(blob) for name, blob in raw.items()},
'code': code,
'components': [
{'kind': 'dataset', 'name': data['name'], 'version': data['version'],
'digest': digest(raw['dataset.json'])},
{'kind': 'schema', 'name': schema['name'], 'version': schema['version'],
'digest': digest(encoded({'schema': schema, 'validator': code['feature_history/vendor/availability.py']}))},
{'kind': 'feature', 'name': spec['name'], 'version': spec['version'],
'digest': feature_digest}]}
return manifest, (data, schema, spec)
def snapshot(root):
return capture(root)[0]
def manifest_id(manifest):
return digest(encoded(manifest))
class Registry:
def __init__(self, path=':memory:'):
self.db = sqlite3.connect(path)
self.db.execute('''CREATE TABLE IF NOT EXISTS identities (
kind TEXT, name TEXT, version INTEGER, digest TEXT NOT NULL,
PRIMARY KEY(kind,name,version))''')
def close(self):
self.db.close()
def bind(self, manifest):
# All component bindings succeed together, or none of the new ones remain.
with self.db:
for item in manifest['components']:
key = (item['kind'], item['name'], item['version'])
old = self.db.execute('SELECT digest FROM identities WHERE kind=? AND name=? AND version=?', key).fetchone()
if old and old[0] != item['digest']:
raise ValueError('identity already bound: ' + '/'.join(map(str, key)))
self.db.execute('INSERT OR IGNORE INTO identities VALUES (?,?,?,?)', (*key, item['digest']))
def run_locked(root, manifest, expected_id, registry, cutoff, published_at):
if manifest_id(manifest) != expected_id:
raise ValueError('manifest differs from the expected identity')
current, (data, _, spec) = capture(root)
if current != manifest:
raise ValueError('workspace differs from the locked manifest')
# Use the parsed bytes that were hashed, without reopening mutable input files.
registry.bind(manifest)
feature = Feature(**{key: spec[key] for key in ('name', 'version', 'entity', 'window_days', 'aggregation')})
store = History()
try:
key = store.materialize(data['events'], cutoff, published_at, spec['availability'], feature)
return {'manifest_id': expected_id, 'feature': key,
'user_02': store.point_in_time(key, 'user-02', published_at)}
finally:
store.close()
def main():
import argparse
parser = argparse.ArgumentParser()
commands = parser.add_subparsers(dest='command', required=True)
lock = commands.add_parser('lock')
lock.add_argument('--inputs', type=Path, default=HERE)
lock.add_argument('--out', type=Path, required=True)
run = commands.add_parser('run')
run.add_argument('--inputs', type=Path, default=HERE)
run.add_argument('--manifest', type=Path, required=True)
run.add_argument('--expected-id', required=True)
run.add_argument('--registry', type=Path, required=True)
run.add_argument('--cutoff', default='2026-01-08T12:00:00Z')
run.add_argument('--published-at', default='2026-01-08T12:30:00Z')
args = parser.parse_args()
if args.command == 'lock':
manifest = snapshot(args.inputs)
with args.out.open('x') as file:
file.write(json.dumps(manifest, indent=2) + '\n')
print(manifest_id(manifest))
else:
manifest = parse(args.manifest.read_bytes())
# Do not create/open a registry until the expectation and bytes match.
if manifest_id(manifest) != args.expected_id or snapshot(args.inputs) != manifest:
raise SystemExit('locked identity or workspace differs; computation refused')
registry = Registry(args.registry)
try:
result = run_locked(args.inputs, manifest, args.expected_id, registry,
minute(args.cutoff), minute(args.published_at))
finally:
registry.close()
print(json.dumps(result, indent=2))
if __name__ == '__main__':
main()The implementation files come from this lab’s fixed code directory. An alternate --inputs directory supplies data and descriptors; it does not supply Python code to import. This exercise assumes the trusted code workspace stays unchanged while the process runs. Capturing a package environment and source-control provenance belongs to the following training-job module.
Preserve the expected identity outside an editable manifest
Create a new lock file and keep its printed identity in the current shell:
SNAPSHOT_ID=$(python versions.py lock --out my-lock.json)
python versions.py run --manifest my-lock.json --expected-id "$SNAPSHOT_ID" --registry my-identities.sqliteRun both commands in the same shell. lock refuses to overwrite an existing file. run uses the fixture’s noon cutoff and 12:30 publication, then prints the manifest ID, feature version and selected user-02 value. The first count is one. The SQLite file preserves component bindings across later invocations.
If the manifest is edited, the saved expected ID no longer matches. If an input changes while the manifest stays fixed, the reconstructed workspace manifest differs. Replacing both the manifest and its expected ID is an explicit selection of another snapshot; it is not verification against the earlier reference.
A digest detects changes relative to that reference. It is not a signature, an authorship claim or an indication that the data is useful. The earlier dataset-validation and availability rules still apply.
Refuse redefinition even when a new manifest is selected
Suppose a developer changes window_days from one to two and creates a new manifest. The bundle now has a new ID, but it still claims the existing feature name and version one.
The registry looks up (kind, name, version). If that key already has a different fingerprint, it raises ValueError. All new component bindings for the candidate occur in one transaction: a later feature conflict rolls back any newly inserted dataset binding from that attempt. Identical bindings are no-ops.
To introduce the two-day definition, select feature version two. Version one remains bound to its original definition. On these events, version two counts two events for user-02; the one-day version counts one. Both are valid computations of different definitions. The version number does not say which definition is better for a model.
python inspect_versions.py
python inspect_versions.py --check
python -m unittest -v test_versions.pyDataset v1 + schema v1 + feature v1: user-02 count = 1
Changed dataset: refused=True; compute_calls=0
Changed schema: refused=True; compute_calls=0
Changed feature: refused=True; compute_calls=0
New manifest with reused feature v1: refused; immutable identity conflict
Explicit feature v2 (two-day window): user-02 count = 2
Manifest identities differ: True
Hashes bind inputs and implementation; they do not prove data quality or authorship.The three input-edit probes use temporary copies. The reported zero calls refer to the actual History.materialize() boundary. Another test edits a copied implementation file and runs a new process against the original lock; that process refuses before creating its registry file. The transaction test checks that a refused candidate leaves no partial new dataset binding.
Inspect the experiment and complete manifest
"""Run actual local version checks; rejected candidates never call materialize."""
import argparse
import json
from pathlib import Path
import shutil
import tempfile
from unittest.mock import patch
from versions import HERE, INPUTS, Registry, History, snapshot, manifest_id, run_locked, minute
CUTOFF = minute('2026-01-08T12:00:00Z')
PUBLISHED = minute('2026-01-08T12:30:00Z')
def record():
registry = Registry()
try:
locked = snapshot(HERE)
expected = manifest_id(locked)
baseline = run_locked(HERE, locked, expected, registry, CUTOFF, PUBLISHED)
probes = []
for name in ('dataset', 'schema', 'feature'):
with tempfile.TemporaryDirectory() as tmp:
for file in INPUTS:
shutil.copyfile(HERE/file, Path(tmp)/file)
target = Path(tmp)/(name+'.json')
value = json.loads(target.read_text())
if name == 'dataset':
value['events'][0]['arrived_at'] = '2026-01-07T16:00:00Z'
elif name == 'schema':
value['version'] = 2
else:
value['window_days'] = 2
target.write_text(json.dumps(value, indent=2)+'\n')
with patch.object(History, 'materialize') as materialize:
try:
run_locked(tmp, locked, expected, registry, CUTOFF, PUBLISHED)
except ValueError as error:
probes.append({'changed': name, 'refused': True,
'reason': str(error), 'compute_calls': materialize.call_count})
else:
raise AssertionError('changed input was accepted')
with tempfile.TemporaryDirectory() as tmp:
for file in INPUTS:
shutil.copyfile(HERE/file, Path(tmp)/file)
path=Path(tmp)/'feature.json';spec=json.loads(path.read_text());spec['window_days']=2
path.write_text(json.dumps(spec,indent=2)+'\n')
renamed=snapshot(tmp)
try:
run_locked(tmp,renamed,manifest_id(renamed),registry,CUTOFF,PUBLISHED)
except ValueError as error:
conflict=str(error)
else:
raise AssertionError('same feature version was rebound')
spec['version']=2;path.write_text(json.dumps(spec,indent=2)+'\n')
next_manifest=snapshot(tmp)
next_result=run_locked(tmp,next_manifest,manifest_id(next_manifest),registry,CUTOFF,PUBLISHED)
return {'mode':'local version checks; synthetic events; no model training',
'baseline':baseline, 'changed_input_probes':probes,
'same_version_conflict':conflict,'explicit_feature_v2':next_result,
'manifest':locked}
finally:
registry.close()
def main():
parser=argparse.ArgumentParser();parser.add_argument('--check',action='store_true');parser.add_argument('--save',type=Path)
args=parser.parse_args();result=record()
if args.check and result!=json.loads((HERE/'result.json').read_text()):
raise SystemExit('version checks differ from bundled result')
if args.save:args.save.write_text(json.dumps(result,indent=2)+'\n')
print('Dataset v1 + schema v1 + feature v1: user-02 count =',result['baseline']['user_02']['value'])
for row in result['changed_input_probes']:
print(f"Changed {row['changed']}: refused={row['refused']}; compute_calls={row['compute_calls']}")
print('New manifest with reused feature v1: refused; immutable identity conflict')
print('Explicit feature v2 (two-day window): user-02 count =',result['explicit_feature_v2']['user_02']['value'])
print('Manifest identities differ:',result['baseline']['manifest_id']!=result['explicit_feature_v2']['manifest_id'])
print('Hashes bind inputs and implementation; they do not prove data quality or authorship.')
if __name__=='__main__':main(){
"mode": "local version checks; synthetic events; no model training",
"baseline": {
"manifest_id": "fc5aada5f3f73dd7cd20cfec17c32079c44e586ee10620064fa1fbbee2edb9dd",
"feature": "user_event_count@v1",
"user_02": {
"value": 1,
"cutoff": 10800,
"published_at": 10830
}
},
"changed_input_probes": [
{
"changed": "dataset",
"refused": true,
"reason": "workspace differs from the locked manifest",
"compute_calls": 0
},
{
"changed": "schema",
"refused": true,
"reason": "unsupported schema contract; changing its version does not add a validator",
"compute_calls": 0
},
{
"changed": "feature",
"refused": true,
"reason": "workspace differs from the locked manifest",
"compute_calls": 0
}
],
"same_version_conflict": "identity already bound: feature/user_event_count/1",
"explicit_feature_v2": {
"manifest_id": "e598e7ffcd3bae4be5dc662713beb3def18c51f130be5004d1b9bb9b5dbd4617",
"feature": "user_event_count@v2",
"user_02": {
"value": 2,
"cutoff": 10800,
"published_at": 10830
}
},
"manifest": {
"format": 1,
"files": {
"dataset.json": "4123c616d7f73a649892ce9940cfa30124a9f8c3dd9d70b89652e93fbb4c9a4e",
"schema.json": "cc7735fa04664007d8b8fa388cedda43421f296c9c945d9ab102edd202631b1a",
"feature.json": "1b980277f121c3f209772bd9455e6af8069bb49a41678de56437ec7515259291"
},
"code": {
"versions.py": "9b9a1ce8d44ba9d73e5159ef86510fe171fbf3f067bcf34c58374a2ebdcc31d3",
"feature_history/history.py": "83349b3fc9877350fd3b5047d9d3f08f47b9e80de0326914e1ec8eeba8175f07",
"feature_history/join.sql": "4add0ce2bcad1981f8ae708899b4b8a878651114452bd133b6d456590ab5bbc2",
"feature_history/vendor/availability.py": "3039969944686e05ee6d2fc9990807d9f576f653403681671e8d656f717a3efb",
"feature_history/vendor/feature_math.py": "477c12bf3d2cea0f8493e3ecb26af6a14092e4359dfecb01ba32100522e889fd"
},
"components": [
{
"kind": "dataset",
"name": "user-return-events",
"version": 1,
"digest": "4123c616d7f73a649892ce9940cfa30124a9f8c3dd9d70b89652e93fbb4c9a4e"
},
{
"kind": "schema",
"name": "user-event-schema",
"version": 1,
"digest": "46d5d36d879b14c4543c9dac401239886763d4ff24838fe371b20b5ffba78731"
},
{
"kind": "feature",
"name": "user_event_count",
"version": 1,
"digest": "2695b2e561994c85a2221a0be47f7bdd7227db5618ab28a94eca1f32625d2f58"
}
]
}
}Treat a binding as identity, not as a successful run
The local registry records what each version means. It does not record training success or model quality. Binding happens before feature materialization; a later computation failure can leave valid component bindings with no successful result. A run record needs its own status and output identity.
This lesson also leaves out multi-user catalog permissions, schema migration, distributed publication and artifact retention. The next MLOps module adds dependency/configuration capture and job lineage around an existing trainer. Those records will reference dataset and feature identities rather than repeat their computation.
Design exercise: an event correction changes dataset.json, while the field contract and feature code stay unchanged. Decide which version must change, which two can stay fixed, and why selecting a new manifest should not let you reuse the old dataset version. Then explain what additional checks you need before calling the resulting feature suitable for training.
Compare your reasoning
Give the corrected dataset a new dataset version and select its new manifest ID. The schema and feature versions can remain unchanged when their contents and bound implementation stay the same. Selecting another manifest does not permit rebinding the old dataset version. Validate the corrected records and their historical availability before treating the resulting features as suitable training inputs.
Failure cases
Editing a file and regenerating its manifest must not bypass the selected identity. The registry also refuses a new manifest that reuses an already bound feature version for a different window. The rollback test ensures an earlier successful insertion in the same transaction does not survive a later conflict.
Check the input and failure boundaries
| Trigger | Required behavior |
|---|---|
| Changed data before computation | Refuse changed data before computing or updating bindings. |
| Unsupported schema version | Refuse a schema version the implementation does not support. |
| Changed definition against old lock | Reject a changed feature definition against the selected original lock. |
| Edited manifest replaces expected identity | Require the separately selected expected identity. |
| New manifest rebinds feature version | Refuse assigning new contents to an existing feature version. |
| Partial registry binding after conflict | Roll back new component bindings if another component conflicts. |
| Changed source file | Refuse the original lock when a bound source file changes. |
| Changed dataset reuses data version | Require a new data version for different dataset bytes. |
| Malformed JSON | Reject duplicate fields and nonfinite JSON constants. |
Deployment
Store version bindings in a controlled registry and pass an explicit expected snapshot identity to each job. Keep approval and access control outside the content hash: a valid fingerprint does not authenticate a submitter. When a schema changes, implement and test its support before declaring the new version usable.
Scaling
Fingerprinting requires reading the bound inputs, while the registry grows with component versions. For larger datasets, use immutable object or partition identities with a documented manifest contract. Preserve the distinction between a new snapshot and a redefinition of an existing component version.
Key takeaways
- Version labels need stable content bindings.
- The expected snapshot must be selected outside the editable manifest.
- Transactional conflicts must leave no partial version registration.
Read the explanation
A new manifest cannot rebind an existing version to different contents. The registry preserves version one’s original fingerprint.
Best practices
- Assign a new version when a feature’s meaning changes.
- Bind the complete source inventory that affects the computation.
- Keep identity, authorization and data-quality checks separate.
References
- Python, hashlib: SHA-256 digests are computed from input bytes.
- SQLite, transactions: transaction boundaries group writes and support rollback.
Sources checked October 24, 2026. All displayed counts and refused edits come from the local synthetic experiment.