feat(portfolio-truth): govern default-attention cohort transitions - #241
Conversation
Adds `portfolio-default-attention-transition-v1`, removing the circular dependency that made every default-attention cohort boundary uncrossable in both directions. Root cause: the collector derived its denominator from the *published* truth while the producer derived its candidate cohort from *current source*, and reconcile required exact equality between them. The published truth can only advance through that check, so any revision that changed cohort membership was unsatisfiable. No cohort-count value resolves it, because after the size check the comparison is a set comparison. Protocol: derive the prospective cohort from current source using the producer's own candidate machinery, collect `prospective ∪ prior_published`, and bind reconcile to the receipt's declared `required` set. - `portfolio_cohort_plan_contract`: `PortfolioCohortPlanV1` schema, digest rule, validator; shared by planner and collector with no import cycle. - `portfolio_cohort_plan`: read-only planner emitting the plan atomically. - `portfolio_truth_reconcile`: extracts `build_materialization_context` and the single `derive_candidate_cohort` implementation both callers use; rewrites cohort identity to R1-R6 and adds the declared-source-departure branch. - `github_security_coverage`: consumes the plan, collects the union, emits additive `required_repositories`/`outgoing_repositories`/`transition` receipt fields with every count validated against its named set. - `portfolio_truth_publish`: binds `transition.prior_truth_sha256` to the prior truth in use and publishes `portfolio-cohort-transition-latest.json` inside the same transaction. Invariants preserved: exact set equality is re-pointed, not relaxed (R3); complete coverage of published attention (R5); no unattested departure (R6); bounded collection via policy bounds defined once (24 size / 3 churn); security evidence still outranks lifecycle classification, and an outgoing member held in attention by fresh findings is recorded as `retained_due_security`. Backward compatible: a receipt with no transition block reproduces today's semantics verbatim; `--portfolio-truth-require-cohort-transition` turns a stale collector into a named refusal. No PortfolioTruth schema bump. Deferred: the non-collectable (`not_found`/`gone`) departure branch fails closed with a named reason in this slice.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:908d88e978
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| latest_path: True, | ||
| project_registry_path: True, | ||
| } | ||
| if build_result.cohort_transition is not None: |
There was a problem hiding this comment.
Remove stale transition evidence when truth advances
When a governed publication creates portfolio-cohort-transition-latest.json, a later publication without a security receipt has cohort_transition is None, so this condition neither replaces nor removes the old file while portfolio-truth-latest.json advances. The latest transition artifact then describes an older snapshot as if it were the current producer evidence; clear it transactionally or emit an explicit no-transition record for the new truth.
AGENTS.md reference: src/AGENTS.md:L5-L10
Useful? React with 👍 / 👎.
| metadata = dict(security_coverage_metadata or {}) | ||
| payload = { | ||
| "schema_version": PORTFOLIO_COHORT_TRANSITION_SCHEMA_VERSION, | ||
| "protocol": outcome.protocol or COHORT_TRANSITION_PROTOCOL, |
There was a problem hiding this comment.
Preserve legacy protocol identity in outcome
A valid legacy receipt is intentionally represented by outcome.protocol is None and outcome.kind == "legacy", but this fallback labels the resulting artifact with portfolio-default-attention-transition-v1. In the default backward-compatible mode, downstream readers therefore see a legacy collection falsely advertised as having followed the governed transition protocol; omit the artifact for legacy outcomes or retain an explicit legacy/null protocol.
AGENTS.md reference: src/AGENTS.md:L17-L19
Useful? React with 👍 / 👎.
| # the plan and the 02:00 candidate pass see the same repository set. | ||
| repo_status_by_name = load_live_repo_status_by_name( | ||
| username=username, | ||
| token=None, |
There was a problem hiding this comment.
Match planner credentials to publisher
When the owner has private repositories, this unauthenticated planner request returns only public repository statuses, while the normal report path recomputes the candidate using args.token in app/portfolio_truth.py. For example, after a private repository previously observed as archived is unarchived, the planner can omit it from required_repositories but the authenticated producer includes it, causing R3 to fail permanently even though both stages use the same source revision; use the same credential posture or the same complete status snapshot in both derivations.
AGENTS.md reference: AGENTS.md:L58-L63
Useful? React with 👍 / 👎.
Root cause
The security collector derived its cohort from the published PortfolioTruth
(
github-security-coverage.sh:36readsportfolio-truth-latest.json), while theproducer derived its candidate cohort from current source at the pinned
revision.
_validate_security_receipt_cohort_identitythen required exactequality between the two, and publication is the only writer of the artifact the
collector reads.
The published truth can therefore only advance through the check it cannot
satisfy. Any revision that changes default-attention membership deadlocks, in
both directions, and no value of any cohort-count parameter resolves it — after
the size check the comparison is a set comparison.
Protocol —
portfolio-default-attention-transition-v1Invariants preserved
it is re-pointed from a hand-maintained integer to a derived, receipt-declared
set. Strictly stronger on membership: a same-size swap passed the old count
tripwire silently and now fails R3 with both sides named
(
test_case_16_same_size_swap_with_stale_membership_is_refused).actually collected instead of inherited from the prior receipt.
outgoingmemberwhose fresh evidence shows an open high/critical stays in
decision-needed,stays in the final cohort, and is recorded as
retained_due_security— asuccessful outcome, not a failure.
binding to one producer SHA.
transition.prior_truth_sha256is compared againstthe prior truth actually authorizing the publication; a mismatch fails closed
with a named error instead of an opaque cohort mismatch.
Bounds
Defined exactly once, in
github_security_coverage:DEFAULT_MAX_COHORT_SIZE = 24— fail-closed size bound on the collected union.DEFAULT_MAX_COHORT_DELTA = 3— fail-closed churn bound on the symmetricdifference between prospective and prior published.
Every other surface propagates them rather than restating the number; the
four-literal synchronization problem that produced the deadlock is the reason.
Both are overridable per run.
Migration behavior
required = collected,outgoing = ∅, reproducing today's semantics verbatimincluding the size tripwire and its exact error strings.
--portfolio-truth-require-cohort-transitionturns "a stale collectorsilently reverted us to a deadlock" into a named refusal.
--portfolio-truth-security-cohort-countis retained as a deprecated alias forone release so a stale deployed wrapper cannot hard-error on an unknown
argument.
cohort.expected_countkeeps its legacy meaning in every mode (size of thecollected set), so
inputs.github_security.cohort_repository_countisunchanged. No PortfolioTruth
SCHEMA_VERSIONbump.Deferred: non-collectable departure
A departing repository that no longer exists on GitHub (
not_found/gone)cannot satisfy any departure branch. This slice fails closed with a named reason
(
test_case_18_non_collectable_departure_is_named_and_deferred). The follow-onis a fourth branch, observed non-existence, which needs its own fixtures and
has no live instance today.
One candidate-derivation implementation
build_materialization_contextandderive_candidate_cohortare extracted frombuild_portfolio_truth_snapshot; the planner and the producer both call them.test_planner_and_producer_share_one_candidate_derivationasserts this, becausetwo equivalent algorithms would silently drift apart and reproduce the deadlock.
The extraction is mechanically scoped and behavior-preserving — the full
pre-existing suite passes unchanged.
Regression matrix
tests/test_portfolio_cohort_transition.py(43 cases) andtests/test_portfolio_cohort_plan.py(17 cases) cover the reviewed matrix:steady / shrink / expansion / swap; local-only project added and gaining repo
identity; repo-backed to manual-only; outgoing member retained by fresh
findings; stale receipt; receipt or plan bound to the wrong SHA; candidate
source drift; prior truth replaced between collection and production; missing
incoming evidence; undeclared receipt member; outgoing still in candidate;
non-collectable departure; interrupted publication retry; idempotent rerun;
owner-path artifact unchanged on refusal; legacy receipt with and without the
protocol required; churn and size bounds; credential-posture parity; and a
supp:identity declaring a repository.Red-on-current / green-on-fixed proof set: shrink, expansion, same-size wrong
membership, idempotent rerun.
test_case_02_shrink_is_red_without_the_transition_protocoland
test_case_03_expansion_is_red_without_the_transition_protocolassert thelegacy path still refuses the exact evidence the protocol admits.
Verification
uv run --extra dev ruff check .— clean.~/Projectsworkspace into anisolated output directory: 210 projects, registry and report rendered.
transition_kind: shrink, required 11, outgoingsaagpatel/portfolio-index,incoming 0, collection 12, plan bound to producer
9b4bd45…and prior truth4647e7c4…— exactly the blocked transition this PR exists to cross.One deliberate contract change:
tests/test_cli_subcommands.pyraises theaudit report --helpflag budget from 45 to 47 for the two new bounded-cohortcontrols, with the reason recorded in the assertion message.