Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)

## [Unreleased]

- Evaluate security-cohort R3 staleness at the receipt observation time; report clock-only arrivals as pending security coverage and covered clock departures explicitly. Preserve metadata-only R3 failure diagnostics beside publication outputs.

- Clarify locked contributor setup, optional test dependencies, and credential-free fixture verification without workstation discovery.

### Added
Expand Down
71 changes: 65 additions & 6 deletions docs/portfolio-cohort-transition-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ receipt: required = prospective
outgoing = prior − prospective
collection = required ∪ outgoing
↓
producer independently recomputes the candidate cohort
producer independently recomputes the candidate cohort as of receipt produced_at
↓
R1 receipt == required ∪ outgoing
R2 required ∩ outgoing == ∅
R3 candidate == required (exact set equality)
R4 outgoing ∩ candidate == ∅
R5 final ⊆ receipt
R5 final ⊆ receipt ∪ explicitly pending clock-only arrivals
R6 every departure positively explained
↓
transactional publication
Expand All @@ -43,6 +43,63 @@ hand-maintained integer. That is strictly stronger on membership — a same-size
swap passed the old count tripwire silently and now fails R3 with both sides
named.

## Receipt observation clock and pending coverage

R3 evaluates time-relative activity staleness at the receipt's timezone-aware
`produced_at` observation time. It uses the inputs observed by the current run:
activity timestamp, classification, and prior security evidence. Only the clock
changes; inputs are not restored from history. A moved HEAD, reclassification,
or real coverage gap that changes membership at that observation time still
fails the exact set comparison. Missing, invalid, or timezone-less receipt time
fails closed. Receipt freshness and publication guards still use the run/live
clock.

The same observed inputs are also evaluated at the run clock. Repositories that
enter attention only because of elapsed time are listed as **pending security
coverage**, remain in current attention, and are not counted as collected or
admitted security evidence. Covered repositories that leave attention only
because of elapsed time are recorded without failing. R1, R2, and R4 remain
strict; R4 checks the current candidate as before, so declared outgoing members
cannot become pending. Declared-outgoing departure evidence is unchanged. R6
accepts a clock-only departure only when both the pre-security and post-security
views show that departure from changing the clock alone.

`portfolio-cohort-transition-latest.json` adds these fields under `transition`:

- `receipt_observed_at`
- `pending_security_coverage_count` and `pending_security_coverage_repositories`
- `clock_departed_count` and `clock_departed_repositories`

For runs with clock differences, the same lists and counts appear under
`portfolio-truth-latest.json` → `source_summary.security_coverage_clock`, with
`receipt_observed_at` and `evaluated_at`. The portfolio Markdown report names
each pending repository and covered clock departure. These are coverage
annotations; the existing attention-state vocabulary remains unchanged.

On R3 publication failure, a unique
`portfolio-cohort-r3-failure-<run-stamp>-<unique-id>.json` is written in the
requested output directory before the error is returned. The error points to
it. It contains receipt identity/time, receipt and run evaluation clocks,
required/collected and both derived cohorts, and differing repositories'
activity timestamp/source, classification, local HEAD, and attention states.
It contains metadata only and is outside the publication transaction, so the
prior truth and reports remain untouched. In-memory snapshot builds carry the
same diagnostics on `CohortR3Error` without writing files.

Operator-scripts integration: advance `release-pins.json`'s
`auditor_truth_ref.value` to the reviewed producer commit containing this fix,
then regenerate and activate the governed release manifest. Both wrappers read
that shared pin through release preflight; keep one pin. Extend
`portfolio-maintenance.sh`'s `read_cohort_transition()` JSON projection with the
five new transition fields listed above, so its terminal-state/history
`cohort_transition` summary retains the pending repositories and clock departures.
Any digest or summary consumer should display
`pending_security_coverage_repositories` as pending (never covered/clean) and
`clock_departed_repositories` as already covered. Read the lists from the
transition outcome or the optional truth summary; absent lists mean empty for
older outputs. Exit status remains the failure gate. No new CLI flag, changed
receipt schema, attention-state enum, or hand-maintained cohort count is needed.

## Surfaces

| Component | Role |
Expand All @@ -59,8 +116,9 @@ two equivalent algorithms would silently drift apart and reproduce the deadlock.

## Departure evidence

A repository may leave the published default-attention cohort only with positive
evidence, through one of three branches:
A source or security change may remove a repository from the published
default-attention cohort only with positive evidence, through one of three
branches (covered clock departures are handled separately above):

1. **observed resolution** — prior high+critical > 0, fresh receipt, Dependabot
`observed`, current high = critical = 0.
Expand Down Expand Up @@ -99,8 +157,9 @@ Both are overridable per run (`--max-cohort-size`, `--max-cohort-delta`).
## Backward compatibility

A receipt without the transition block is legacy-shaped: reconcile falls back to
`required = collected`, `outgoing = ∅`, which reproduces the pre-transition
semantics verbatim, size tripwire included. `--portfolio-truth-require-cohort-transition`
`required = collected`, `outgoing = ∅`, retaining the pre-transition partition
and size tripwire. Receipt-time recency evaluation applies in this mode too.
`--portfolio-truth-require-cohort-transition`
turns "a stale collector silently reverted us to a deadlock" into a named
refusal. `--portfolio-truth-security-cohort-count` is retained as a deprecated
alias for one release so a stale deployed wrapper cannot hard-error on an unknown
Expand Down
94 changes: 58 additions & 36 deletions src/github_repo_auditor/portfolio_truth_publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from datetime import datetime
from pathlib import Path
from typing import Iterable, Iterator
from uuid import uuid4

from github_repo_auditor.github_security_coverage import (
DEFAULT_ATTENTION_STATES,
Expand All @@ -23,6 +24,7 @@
COHORT_TRANSITION_PROTOCOL,
)
from github_repo_auditor.portfolio_truth_reconcile import (
CohortR3Error,
CohortTransitionOutcome,
build_portfolio_truth_snapshot,
load_prior_notion_context,
Expand Down Expand Up @@ -129,9 +131,12 @@ def _load_prior_security_alerts(
current_security_metadata.get("produced_at"),
field="Current security receipt produced_at",
)
if evidence.generated_at > current_produced_at and not _is_same_bound_security_receipt(
evidence.prior_security_metadata or {},
current_security_metadata,
if (
evidence.generated_at > current_produced_at
and not _is_same_bound_security_receipt(
evidence.prior_security_metadata or {},
current_security_metadata,
)
):
raise PortfolioTruthPublishError(
"Prior PortfolioTruth was generated after the current security receipt."
Expand Down Expand Up @@ -499,10 +504,7 @@ def _publish_portfolio_truth_locked(
and security_alerts_by_name is not None
else None
)
if (
prior_security_evidence is not None
and security_cohort_transition is not None
):
if prior_security_evidence is not None and security_cohort_transition is not None:
declared_prior_truth_sha256 = security_cohort_transition.get(
"prior_truth_sha256"
)
Expand All @@ -513,35 +515,55 @@ def _publish_portfolio_truth_locked(
f"receipt={declared_prior_truth_sha256!r}; "
f"observed={prior_security_evidence.content_sha256!r}."
)
build_result = build_portfolio_truth_snapshot(
workspace_root=workspace_root,
catalog_path=catalog_path,
legacy_registry_path=legacy_registry_path,
include_notion=include_notion,
notion_context_fallback=notion_context_fallback,
release_count_by_name=release_count_by_name,
degraded_dimensions_by_name=degraded_dimensions_by_name,
security_alerts_by_name=security_alerts_by_name,
security_coverage_metadata=security_coverage_metadata,
prior_security_alerts_by_name=(
prior_security_evidence.alerts_by_full_name
if prior_security_evidence is not None
else None
),
prior_security_cohort_repositories=(
prior_security_evidence.final_cohort_repositories
if prior_security_evidence is not None
else None
),
security_cohort_required=security_cohort_required,
security_cohort_outgoing=security_cohort_outgoing,
security_cohort_transition=security_cohort_transition,
require_cohort_transition=require_cohort_transition,
repo_status_by_name=repo_status_by_name,
producer=producer_evidence.to_dict() if producer_evidence else {},
prior_notion_generated_at=prior_notion_generated_at,
now=now,
)
try:
build_result = build_portfolio_truth_snapshot(
workspace_root=workspace_root,
catalog_path=catalog_path,
legacy_registry_path=legacy_registry_path,
include_notion=include_notion,
notion_context_fallback=notion_context_fallback,
release_count_by_name=release_count_by_name,
degraded_dimensions_by_name=degraded_dimensions_by_name,
security_alerts_by_name=security_alerts_by_name,
security_coverage_metadata=security_coverage_metadata,
prior_security_alerts_by_name=(
prior_security_evidence.alerts_by_full_name
if prior_security_evidence is not None
else None
),
prior_security_cohort_repositories=(
prior_security_evidence.final_cohort_repositories
if prior_security_evidence is not None
else None
),
security_cohort_required=security_cohort_required,
security_cohort_outgoing=security_cohort_outgoing,
security_cohort_transition=security_cohort_transition,
require_cohort_transition=require_cohort_transition,
repo_status_by_name=repo_status_by_name,
producer=producer_evidence.to_dict() if producer_evidence else {},
prior_notion_generated_at=prior_notion_generated_at,
now=now,
)
except CohortR3Error as exc:
stamp = now.strftime("%Y-%m-%dT%H%M%SZ") if now else "unknown-clock"
diagnostics_path = (
output_dir / f"portfolio-cohort-r3-failure-{stamp}-{uuid4().hex}.json"
)
payload = {**exc.diagnostics, "error": str(exc)}
try:
staged = _stage_text(
diagnostics_path, json.dumps(payload, indent=2, sort_keys=True) + "\n"
)
staged.replace(diagnostics_path)
_fsync_directory(output_dir)
except OSError as write_error:
raise PortfolioTruthPublishError(
f"{exc} R3 diagnostics could not be written to {diagnostics_path}: {write_error}"
) from exc
raise CohortR3Error(
f"{exc} R3 diagnostics: {diagnostics_path}", exc.diagnostics
) from exc
validate_truth_snapshot(
build_result.snapshot,
security_max_age_hours=security_max_age_hours,
Expand Down
Loading
Loading