diff --git a/src/github_repo_auditor/github_security_coverage.py b/src/github_repo_auditor/github_security_coverage.py index 21456ab..fff301e 100644 --- a/src/github_repo_auditor/github_security_coverage.py +++ b/src/github_repo_auditor/github_security_coverage.py @@ -31,7 +31,9 @@ DEFAULT_ATTENTION_STATES = frozenset( {"active-product", "active-infra", "decision-needed"} ) -DEFAULT_EXPECTED_GITHUB_COHORT_COUNT = 11 +# Owner-confirmed 2026-08-21 after agent-session-replay legitimately entered +# decision-needed while remaining on its active finish path. +DEFAULT_EXPECTED_GITHUB_COHORT_COUNT = 12 PROVIDER_NAMES = ("dependabot", "code_scanning", "secret_scanning") ELIGIBILITY_SOURCE = "github-account-repository-preflight-v1" ELIGIBILITY_REASON = "private_user_repo_plan_unavailable" diff --git a/src/github_repo_auditor/portfolio_truth_validate.py b/src/github_repo_auditor/portfolio_truth_validate.py index 68ea09a..ab4952e 100644 --- a/src/github_repo_auditor/portfolio_truth_validate.py +++ b/src/github_repo_auditor/portfolio_truth_validate.py @@ -1887,12 +1887,13 @@ def canonicalize_prior_security_truth_payload( *, security_max_age_hours: int = 24, ) -> dict[str, Any]: - """Canonicalize prior security evidence across one bounded metadata upgrade. + """Canonicalize prior security evidence across bounded metadata upgrades. Current snapshots always take the ordinary strict path. The compatibility - path accepts only the immediately preceding discovery envelope, before - checkout-collision metadata existed, and reconstructs those two unrelated - metadata envelopes in memory before running the complete current validator. + paths accept only the immediately preceding additive schema or the earlier + discovery envelope before checkout-collision metadata existed. Each path + reconstructs only its named metadata in memory before running the complete + current validator. """ try: return canonicalize_truth_snapshot_payload( @@ -1900,6 +1901,36 @@ def canonicalize_prior_security_truth_payload( security_max_age_hours=security_max_age_hours, ) except ValueError as strict_error: + if payload.get("schema_version") == "0.11.0" and SCHEMA_VERSION == "0.12.0": + migrated = deepcopy(dict(payload)) + projects = migrated.get("projects") + if not isinstance(projects, list): + raise ValueError( + "Prior PortfolioTruth 0.11.0 project envelope is invalid." + ) from strict_error + for raw_project in projects: + if not isinstance(raw_project, dict) or not isinstance( + raw_project.get("derived"), dict + ): + raise ValueError( + "Prior PortfolioTruth 0.11.0 project envelope is invalid." + ) from strict_error + derived = raw_project["derived"] + if "degraded_dimensions" in derived: + raise ValueError( + "Prior PortfolioTruth 0.11.0 cannot declare " + "derived.degraded_dimensions." + ) from strict_error + derived["degraded_dimensions"] = None + migrated["schema_version"] = SCHEMA_VERSION + try: + return canonicalize_truth_snapshot_payload( + migrated, + security_max_age_hours=security_max_age_hours, + ) + except ValueError as migration_error: + raise migration_error from strict_error + summary = payload.get("source_summary") exclusions = payload.get("exclusions") is_bounded_legacy_payload = ( diff --git a/tests/test_github_security_coverage.py b/tests/test_github_security_coverage.py index ea60cfc..a16f920 100644 --- a/tests/test_github_security_coverage.py +++ b/tests/test_github_security_coverage.py @@ -745,6 +745,7 @@ def _remote_graphql_response(count: int) -> _Response: def test_default_attention_cohort_is_exact_and_fail_closed() -> None: + assert DEFAULT_EXPECTED_GITHUB_COHORT_COUNT == 12 truth = _truth(DEFAULT_EXPECTED_GITHUB_COHORT_COUNT) truth["projects"].append( { @@ -759,8 +760,10 @@ def test_default_attention_cohort_is_exact_and_fail_closed() -> None: assert len(cohort) == DEFAULT_EXPECTED_GITHUB_COHORT_COUNT assert "owner/parked" not in cohort - with pytest.raises(SecurityCoverageError, match="expected 11, observed 12"): - derive_default_attention_cohort(_truth(12)) + with pytest.raises(SecurityCoverageError, match="expected 12, observed 13"): + derive_default_attention_cohort( + _truth(DEFAULT_EXPECTED_GITHUB_COHORT_COUNT + 1) + ) def test_repo_less_non_supplementary_attention_identity_fails_closed() -> None: diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index 9be4612..06c647b 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -684,7 +684,7 @@ def test_prior_security_loader_accepts_bounded_legacy_truth( portfolio_catalog: Path, legacy_registry: Path, ) -> None: - import github_repo_auditor.portfolio_truth_publish as publish_mod + from github_repo_auditor import portfolio_truth_publish as publish_mod payload, metadata, _ = _legacy_prior_security_payload( portfolio_workspace=portfolio_workspace, @@ -715,7 +715,7 @@ def test_prior_security_loader_allows_same_receipt_truth_generated_after_receipt portfolio_catalog: Path, legacy_registry: Path, ) -> None: - import github_repo_auditor.portfolio_truth_publish as publish_mod + from github_repo_auditor import portfolio_truth_publish as publish_mod _, metadata, payload = _legacy_prior_security_payload( portfolio_workspace=portfolio_workspace, @@ -736,6 +736,54 @@ def test_prior_security_loader_allows_same_receipt_truth_generated_after_receipt assert evidence.alerts_by_full_name["d/Alpha"]["dependabot_high"] == 1 +def test_prior_security_loader_accepts_immediate_additive_schema_predecessor( + tmp_path: Path, + portfolio_workspace: Path, + portfolio_catalog: Path, + legacy_registry: Path, +) -> None: + from github_repo_auditor import portfolio_truth_publish as publish_mod + + _, metadata, payload = _legacy_prior_security_payload( + portfolio_workspace=portfolio_workspace, + portfolio_catalog=portfolio_catalog, + legacy_registry=legacy_registry, + ) + payload["schema_version"] = "0.11.0" + for project in payload["projects"]: + project["derived"].pop("degraded_dimensions") + latest = tmp_path / "portfolio-truth-latest.json" + latest.write_text(json.dumps(payload), encoding="utf-8") + + evidence = publish_mod._load_prior_security_alerts( + latest, + current_security_metadata=metadata, + security_max_age_hours=24, + ) + + assert evidence.final_cohort_repositories == ("d/Alpha",) + assert evidence.alerts_by_full_name["d/Alpha"]["dependabot_high"] == 1 + + +def test_immediate_additive_schema_predecessor_rejects_new_field( + portfolio_workspace: Path, + portfolio_catalog: Path, + legacy_registry: Path, +) -> None: + _, _, payload = _legacy_prior_security_payload( + portfolio_workspace=portfolio_workspace, + portfolio_catalog=portfolio_catalog, + legacy_registry=legacy_registry, + ) + payload["schema_version"] = "0.11.0" + + with pytest.raises( + ValueError, + match="0.11.0 cannot declare derived.degraded_dimensions", + ): + canonicalize_prior_security_truth_payload(payload) + + @pytest.mark.parametrize( ("binding_field", "replacement"), ( @@ -752,7 +800,7 @@ def test_prior_security_loader_refuses_future_truth_from_different_receipt( portfolio_catalog: Path, legacy_registry: Path, ) -> None: - import github_repo_auditor.portfolio_truth_publish as publish_mod + from github_repo_auditor import portfolio_truth_publish as publish_mod _, metadata, payload = _legacy_prior_security_payload( portfolio_workspace=portfolio_workspace,