Skip to content

Commit d1aca40

Browse files
yyodsclaude
andcommitted
[red] pin completion era to candidate source ancestry
Witnessed RED for the just-discovered v0.6.0 replacement-contract gap on the HIL completion path. Repairing the promotion-lifecycle fixture (real versions.lock, committed V4 oi1-gates.json, retained a8be631 baseline, and a read-only gitfile into this checkout's history) exposed that create_hil_completion_fragment never routes the qualification-policy derivation era by the CANDIDATE's bound source commit: era selection happens only through the qualification root's checkout HEAD inside _load_qualification_policy, and a git-less root silently falls back to the historical V3 arithmetic (the misleading 'OI-1 policy thresholds were not derived from baseline esp32-4mb' failure this fixture repair uncovered). New focused test test_completion_binds_policy_era_to_candidate_source_ancestry pins the ADR-0037/ADR-0038 contract on this path: - a strict descendant of 5620f2f verifies the checked-in V4 fixed-SLO policy and completes; - the boundary and the abandoned-candidate ancestor route to the historical arithmetic, so the committed V4 policy is refused as the wrong source era (witnessed red: both currently complete); - an unprovable commit ('1'*40 or a random sha) fails closed with a ReleaseError naming ancestry — never a baseline-threshold mismatch and never a silent historical fallback (witnessed red: both currently complete). Fixture repair kept and tidied: provisioning moved to provision_qualification_root(), the payload provenance commit is now the repository's own HEAD resolved at test runtime (a real strict descendant is required because the writer must fail closed on unprovable ancestry), and the happy-path test mocks the C3 observation-bound evidence seam it now reaches. Suite state at this commit: 7 tests, the 4 ancestry scenarios of the new test FAIL, everything else OK. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAVghCmvKjF4BZFBUjAMwc Signed-off-by: Viwat Vchirawongkwin <viwat.v@chula.ac.th>
1 parent 8b03630 commit d1aca40

1 file changed

Lines changed: 195 additions & 2 deletions

File tree

‎tests/firmware_tests/host/test_release_v060_promotion_lifecycle.py‎

Lines changed: 195 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,18 @@
1010
from __future__ import annotations
1111

1212
import copy
13+
import functools
1314
import hashlib
1415
import json
1516
from pathlib import Path
1617
import stat
18+
import subprocess
1719
import tempfile
1820
import unittest
1921
from unittest import mock
2022

2123
import test_release_bundle as bundle_fixture
24+
import _support
2225
import 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)
225290
class 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

Comments
 (0)