1010from __future__ import annotations
1111
1212import copy
13+ import functools
1314import hashlib
1415import json
1516from pathlib import Path
1617import stat
18+ import subprocess
1719import tempfile
1820import unittest
1921from unittest import mock
2022
2123import test_release_bundle as bundle_fixture
24+ import _support
2225import test_release_v060_lifecycle as lifecycle_fixture
2326
2427
@@ -221,13 +224,79 @@ def provenance() -> dict[str, object]:
221224 }
222225
223226
227+ def _repo_git (* args : str ) -> str :
228+ completed = subprocess .run (
229+ ["git" , "-C" , _support .REPO_ROOT , * args ],
230+ check = True ,
231+ capture_output = True ,
232+ text = True ,
233+ )
234+ return completed .stdout .strip ()
235+
236+
237+ @functools .lru_cache (maxsize = None )
238+ def real_source_commit () -> str :
239+ """This checkout's HEAD — a real strict descendant of the ADR-0038 boundary.
240+
241+ The completion writer binds the qualification-policy derivation era to the
242+ CANDIDATE's bound source commit by git ancestry and fails closed when that
243+ ancestry is unprovable (ADR-0038). A hardcoded fake commit such as
244+ ``"1" * 40`` is unprovable by construction and can therefore never reach
245+ the happy path; the fixture must carry a real strict descendant of the
246+ superseded-source boundary, resolved from the repository's own history at
247+ test runtime rather than hardcoded, so it tracks this branch's history.
248+ """
249+
250+ return _repo_git ("rev-parse" , "HEAD" )
251+
252+
253+ def provision_qualification_root (root : Path ) -> None :
254+ """Provision a hermetic qualification root the completion writer accepts.
255+
256+ The writer reads firmware/versions.lock, the committed OI-1 policy, and
257+ the retained a8be631d baseline under the qualification root
258+ (release_bundle._load_qualification_policy), and proves the candidate's
259+ source-era ancestry with git against that same root. Copy the REAL files
260+ so the fixture exercises the production parse and threshold verification,
261+ and link the real git history through a gitfile — read-only: rev-parse and
262+ merge-base resolve through the shared object store, nothing is written.
263+ """
264+
265+ repo = Path (_support .REPO_ROOT )
266+ lock_dir = root / "firmware"
267+ lock_dir .mkdir ()
268+ (lock_dir / "versions.lock" ).write_bytes (
269+ (repo / "firmware" / "versions.lock" ).read_bytes ()
270+ )
271+ gates_dir = lock_dir / "qualification"
272+ gates_dir .mkdir ()
273+ (gates_dir / "oi1-gates.json" ).write_bytes (
274+ (repo / "firmware" / "qualification" / "oi1-gates.json" ).read_bytes ()
275+ )
276+ baseline_rel = Path ("docs" ) / "validation" / "firmware" / "oi1"
277+ baseline_dir = root / baseline_rel
278+ baseline_dir .mkdir (parents = True )
279+ baseline_name = "a8be631df46590166307aa41afaea30b39e29230.json"
280+ (baseline_dir / baseline_name ).write_bytes (
281+ (repo / baseline_rel / baseline_name ).read_bytes ()
282+ )
283+ (root / ".git" ).write_text (
284+ "gitdir: %s\n " % _repo_git ("rev-parse" , "--absolute-git-dir" ),
285+ encoding = "utf-8" ,
286+ )
287+
288+
224289@unittest .skipUnless (HAVE_RELEASE , RELEASE_LOAD_ERROR )
225290class V5CompletionAndPromotionContractTests (unittest .TestCase ):
226291 def test_completion_writer_derives_profile_and_gate_fields (self ) -> None :
227292 pending = pending_v5_payload ()
228293 release = {
229294 "identity" : {"version" : "0.6.0" , "tag" : "firmware-v0.6.0" },
230- "provenance" : {"pyble" : {"commit" : "1" * 40 }},
295+ # A REAL strict descendant of the superseded-source boundary: the
296+ # writer routes the policy derivation era by this bound commit's
297+ # ancestry and fails closed when it is unprovable, so a fake
298+ # commit can never reach the happy path — see real_source_commit().
299+ "provenance" : {"pyble" : {"commit" : real_source_commit ()}},
231300 "profiles" : lifecycle_fixture .pending_profiles (),
232301 }
233302 with tempfile .TemporaryDirectory () as temporary :
@@ -238,6 +307,7 @@ def test_completion_writer_derives_profile_and_gate_fields(self) -> None:
238307 (candidate / "HIL_REPORT.md" ).write_text (
239308 hil_report (pending ), encoding = "utf-8"
240309 )
310+ provision_qualification_root (root )
241311 profile_id = "esp32-c3-4mb"
242312 operator_path = root / "operator.json"
243313 observation_path = root / "observation.json"
@@ -259,6 +329,10 @@ def test_completion_writer_derives_profile_and_gate_fields(self) -> None:
259329 RELEASE ._V060_PROFILE_GATE ,
260330 "private_result_snapshot" ,
261331 return_value = ("fixture-private-snapshot" ,),
332+ ), mock .patch .object (
333+ RELEASE ._V060_PROFILE_GATE ,
334+ "validate_c3_result_for_observation" ,
335+ return_value = {"gates" : gates },
262336 ), mock .patch .object (
263337 RELEASE ._V060_PROFILE_GATE ,
264338 "validate_result_file" ,
@@ -302,7 +376,10 @@ def test_completion_writer_rejects_operator_gate_fields_or_wrong_private_phase(
302376 pending = pending_v5_payload ()
303377 release = {
304378 "identity" : {"version" : "0.6.0" , "tag" : "firmware-v0.6.0" },
305- "provenance" : {"pyble" : {"commit" : "1" * 40 }},
379+ # Real strict descendant, so every subtest fails on exactly the
380+ # forbidden operator field / wrong private phase it pins, never on
381+ # unprovable source ancestry — see real_source_commit().
382+ "provenance" : {"pyble" : {"commit" : real_source_commit ()}},
306383 "profiles" : lifecycle_fixture .pending_profiles (),
307384 }
308385 with tempfile .TemporaryDirectory () as temporary :
@@ -313,6 +390,7 @@ def test_completion_writer_rejects_operator_gate_fields_or_wrong_private_phase(
313390 (candidate / "HIL_REPORT.md" ).write_text (
314391 hil_report (pending ), encoding = "utf-8"
315392 )
393+ provision_qualification_root (root )
316394 observation_path = root / "observation.json"
317395 write_json (observation_path , {"fixture" : "esp32-4mb" })
318396
@@ -350,6 +428,121 @@ def test_completion_writer_rejects_operator_gate_fields_or_wrong_private_phase(
350428 )
351429 self .assertFalse (output .exists ())
352430
431+ def test_completion_binds_policy_era_to_candidate_source_ancestry (self ) -> None :
432+ """ADR-0037/ADR-0038: the completion writer's derivation era is a git
433+ ancestry fact of the CANDIDATE's bound source commit.
434+
435+ A strict descendant of the superseded-source boundary must verify the
436+ checked-in V4 fixed-SLO policy; the boundary and its ancestors route
437+ to the historical arithmetic (so the committed V4 policy is refused as
438+ the wrong source era); an unprovable commit fails closed with an
439+ error naming ancestry — never a baseline-threshold mismatch and never
440+ a silent fall back to historical arithmetic.
441+ """
442+
443+ pending = pending_v5_payload ()
444+ with tempfile .TemporaryDirectory () as temporary :
445+ root = Path (temporary ).resolve ()
446+ candidate = root / "candidate"
447+ candidate .mkdir ()
448+ (candidate / "release.json" ).write_bytes (b"{}\n " )
449+ (candidate / "HIL_REPORT.md" ).write_text (
450+ hil_report (pending ), encoding = "utf-8"
451+ )
452+ provision_qualification_root (root )
453+ # An ungated profile: no private gate result, so the era routing
454+ # itself is the only seam under test.
455+ profile_id = "esp32-4mb"
456+ operator_path = root / "operator.json"
457+ observation_path = root / "observation.json"
458+ write_json (operator_path , operator_input (profile_id ))
459+ write_json (observation_path , {"fixture" : profile_id })
460+
461+ def attempt (commit : str , output_name : str ) -> Path :
462+ release = {
463+ "identity" : {
464+ "version" : "0.6.0" ,
465+ "tag" : "firmware-v0.6.0" ,
466+ },
467+ "provenance" : {"pyble" : {"commit" : commit }},
468+ "profiles" : lifecycle_fixture .pending_profiles (),
469+ }
470+ with mock .patch .object (
471+ RELEASE , "validate_bundle" , return_value = release
472+ ), mock .patch .object (
473+ RELEASE ,
474+ "_validate_qualification_observation" ,
475+ side_effect = lambda value , * _args , ** _kwargs : value ,
476+ ):
477+ return RELEASE .create_hil_completion_fragment (
478+ candidate_dir = candidate ,
479+ profile_id = profile_id ,
480+ operator_input_path = operator_path ,
481+ oi1_observation_path = observation_path ,
482+ output_path = root / output_name ,
483+ qualification_repo_root = root ,
484+ profile_qualification_result = None ,
485+ )
486+
487+ descendant = real_source_commit ()
488+ self .assertEqual (
489+ RELEASE ._qualification_derivation_for_source (
490+ root , descendant , firmware_version = "0.6.0"
491+ ),
492+ RELEASE .QUALIFICATION_DERIVATION_V4 ,
493+ "the fixture root must prove the descendant's ancestry" ,
494+ )
495+ created = attempt (descendant , "descendant-completion.json" )
496+ self .assertEqual (Path (created ), root / "descendant-completion.json" )
497+ self .assertEqual (
498+ json .loads (created .read_text (encoding = "utf-8" )),
499+ completion_fragment (profile_id ),
500+ "a strict descendant of the boundary must verify the "
501+ "checked-in V4 fixed-SLO policy and complete" ,
502+ )
503+
504+ for superseded in (
505+ RELEASE .V060_SUPERSEDED_SOURCE_BOUNDARY ,
506+ RELEASE .V060_ABANDONED_CANDIDATE_SOURCE ,
507+ ):
508+ with self .subTest (superseded_commit = superseded ):
509+ output_name = "superseded-%s.json" % superseded [:8 ]
510+ with self .assertRaises (
511+ RELEASE .ReleaseError ,
512+ msg = "the boundary and its ancestors keep the "
513+ "historical arithmetic, which the committed V4 "
514+ "policy can never satisfy" ,
515+ ) as caught :
516+ attempt (superseded , output_name )
517+ message = str (caught .exception )
518+ self .assertIn (
519+ "does not match the firmware source era" ,
520+ message ,
521+ "a superseded-era candidate must be refused as the "
522+ "wrong source era for the committed V4 policy" ,
523+ )
524+ self .assertNotIn ("were not derived from baseline" , message )
525+ self .assertFalse ((root / output_name ).exists ())
526+
527+ for unprovable in (
528+ "1" * 40 ,
529+ hashlib .sha256 (b"pyble-unprovable-source" ).hexdigest ()[:40 ],
530+ ):
531+ with self .subTest (unprovable_commit = unprovable ):
532+ output_name = "unprovable-%s.json" % unprovable [:8 ]
533+ with self .assertRaises (
534+ RELEASE .ReleaseError ,
535+ msg = "unprovable candidate source ancestry must fail "
536+ "closed, never silently fall back to historical "
537+ "arithmetic" ,
538+ ) as caught :
539+ attempt (unprovable , output_name )
540+ message = str (caught .exception )
541+ self .assertIn ("ancestry" , message )
542+ self .assertIn ("unprovable" , message )
543+ self .assertNotIn ("were not derived from baseline" , message )
544+ self .assertFalse ((root / output_name ).exists ())
545+
353546 def test_promotion_envelope_uses_v5_discriminated_immutable_fields (self ) -> None :
354547 pending = pending_v5_payload ()
355548 completed = completed_v5_payload ()
0 commit comments