22
33import hashlib
44from collections import OrderedDict
5+ from dataclasses import replace
56from pathlib import Path
67from types import MappingProxyType
78
@@ -61,13 +62,15 @@ def binding_for(
6162 display_order : int = 0 ,
6263 include_md : bool = False ,
6364 include_manifest : bool = False ,
65+ identity_class : str | None = None ,
6466):
6567 period_key , semantic , artifact , contract = metadata (report )
6668 artifact_digest = artifact_digest or artifact
6769 semantic_digest = semantic_digest or semantic
6870 as_of = report ["as_of" ]
6971 cadence = report ["cadence" ]
70- suffix = "" if canonical else f".variant-{ artifact_digest } "
72+ identity_class = identity_class or ("V3_CANONICAL" if canonical else "V3_VARIANT" )
73+ suffix = "" if canonical else f".variant-{ semantic_digest if identity_class == 'LEGACY_V2' else artifact_digest } "
7174 entry = {
7275 "period_key" : period_key ,
7376 "as_of" : as_of ,
@@ -80,7 +83,7 @@ def binding_for(
8083 "artifact_integrity_digest" : artifact_digest ,
8184 "json" : f"advisory_report_{ as_of } { suffix } .json" ,
8285 "html" : f"{ as_of } -{ cadence } -model-recommendations{ suffix } .html" ,
83- "identity_class" : "V3_CANONICAL" if canonical else "V3_VARIANT" ,
86+ "identity_class" : identity_class ,
8487 "canonical_identity" : canonical ,
8588 "display_primary" : display_primary ,
8689 "display_order" : display_order ,
@@ -199,7 +202,7 @@ def test_exact_artifact_reuse_is_immutable_and_requires_full_match() -> None:
199202
200203 changed = dict (report )
201204 changed ["generated_at" ] = "2026-07-15T00:00:00Z"
202- with pytest .raises (IdentityMetadataError , match = "identity_reuse_not_found|identity_reuse_mismatch " ):
205+ with pytest .raises (IdentityMetadataError , match = "exact_artifact_not_found " ):
203206 allocate_v3_identity (
204207 changed ,
205208 inventory = inventory ,
@@ -276,6 +279,83 @@ def test_inventory_accepts_declared_v3_schema_version() -> None:
276279 assert inventory .index .schema_version == 3
277280
278281
282+ def test_legacy_exact_match_is_not_reused_or_migrated () -> None :
283+ report = build_report ()
284+ legacy = binding_for (report , identity_class = "LEGACY_V2" )
285+
286+ with pytest .raises (IdentityMetadataError , match = "exact_artifact_not_found" ):
287+ allocate_v3_identity (
288+ report ,
289+ inventory = inventory_for ((legacy , report )),
290+ context = AllocationContext .exact_artifact_reuse (),
291+ requested_artifacts = REQUESTED ,
292+ display_placement = DISPLAY ,
293+ )
294+
295+
296+ def test_legacy_only_current_bootstraps_v3_canonical () -> None :
297+ report = build_report ()
298+ legacy = binding_for (report , identity_class = "LEGACY_V2" )
299+ period_key = metadata (report )[0 ]
300+
301+ result = allocate_v3_identity (
302+ report ,
303+ inventory = inventory_for ((legacy , report )),
304+ context = AllocationContext .current_mandatory (period_key ),
305+ requested_artifacts = REQUESTED ,
306+ display_placement = DISPLAY ,
307+ )
308+
309+ assert result .reused_existing is False
310+ assert result .binding .identity_class == "V3_CANONICAL"
311+ assert result .binding .canonical_identity is True
312+
313+
314+ def test_legacy_only_historical_recovery_requires_v3_canonical () -> None :
315+ report = build_report ()
316+ legacy = binding_for (report , identity_class = "LEGACY_V2" )
317+
318+ with pytest .raises (IdentityMetadataError , match = "canonical_bootstrap_required" ):
319+ allocate_v3_identity (
320+ report ,
321+ inventory = inventory_for ((legacy , report )),
322+ context = AllocationContext .historical_recovery (),
323+ requested_artifacts = REQUESTED ,
324+ display_placement = DISPLAY ,
325+ )
326+
327+
328+ def test_mixed_legacy_and_v3_uses_v3_canonical_for_variant_allocation () -> None :
329+ legacy_report = build_report ()
330+ current_report = dict (legacy_report )
331+ current_report ["generated_at" ] = "2026-07-15T00:00:00Z"
332+ candidate_report = dict (current_report )
333+ candidate_report ["generated_at" ] = "2026-07-16T00:00:00Z"
334+ legacy_canonical = binding_for (legacy_report , identity_class = "LEGACY_V2" )
335+ legacy = replace (
336+ legacy_canonical ,
337+ canonical_identity = False ,
338+ json_name = f"advisory_report_{ legacy_report ['as_of' ]} .variant-{ metadata (legacy_report )[1 ]} .json" ,
339+ html_name = (
340+ f"{ legacy_report ['as_of' ]} -{ legacy_report ['cadence' ]} -model-recommendations"
341+ f".variant-{ metadata (legacy_report )[1 ]} .html"
342+ ),
343+ )
344+ canonical = binding_for (current_report )
345+ period_key = metadata (candidate_report )[0 ]
346+
347+ result = allocate_v3_identity (
348+ candidate_report ,
349+ inventory = inventory_for ((legacy , legacy_report ), (canonical , current_report )),
350+ context = AllocationContext .current_mandatory (period_key ),
351+ requested_artifacts = REQUESTED ,
352+ display_placement = DISPLAY ,
353+ )
354+
355+ assert result .binding .identity_class == "V3_VARIANT"
356+ assert result .reused_existing is False
357+
358+
279359def test_semantic_same_artifact_different_is_not_reused () -> None :
280360 report = build_report ()
281361 changed = dict (report )
@@ -284,7 +364,7 @@ def test_semantic_same_artifact_different_is_not_reused() -> None:
284364 period_key , semantic , artifact , _contract = metadata (changed )
285365
286366 assert semantic == metadata (report )[1 ]
287- with pytest .raises (IdentityMetadataError , match = "identity_reuse_not_found|identity_reuse_mismatch " ):
367+ with pytest .raises (IdentityMetadataError , match = "exact_artifact_not_found " ):
288368 allocate_v3_identity (
289369 changed ,
290370 inventory = inventory ,
0 commit comments