Skip to content

feat(portfolio-truth): govern default-attention cohort transitions - #241

Merged
saagpatel merged 1 commit into
mainfrom
feat/portfolio-cohort-transition-v1
Aug 24, 2026
Merged

feat(portfolio-truth): govern default-attention cohort transitions#241
saagpatel merged 1 commit into
mainfrom
feat/portfolio-cohort-transition-v1

Conversation

@saagpatel

Copy link
Copy Markdown
Owner

Root cause

The security collector derived its cohort from the published PortfolioTruth
(github-security-coverage.sh:36 reads portfolio-truth-latest.json), while the
producer derived its candidate cohort from current source at the pinned
revision. _validate_security_receipt_cohort_identity then required exact
equality 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-v1

current pinned source
↓ derive_candidate_cohort() (the producer's own machinery)
prospective cohort + prior published cohort
↓
collection = prospective ∪ prior
↓ bounded GitHub security collection
receipt: required = prospective
outgoing = prior − prospective
↓
producer independently recomputes the candidate cohort
↓
R1 receipt == required ∪ outgoing
R2 required ∩ outgoing == ∅
R3 candidate == required ← exact set equality, re-pointed
R4 outgoing ∩ candidate == ∅
R5 final ⊆ receipt
R6 every departure positively explained
↓
transactional publication

Invariants preserved

  • Exact membership control is not weakened. R3 stays an exact set equality;
    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).
  • Complete coverage of published attention (R5) unchanged.
  • No unattested departure (R6) is strengthened: departure evidence is now
    actually collected instead of inherited from the prior receipt.
  • Security evidence outranks lifecycle classification. An outgoing member
    whose fresh evidence shows an open high/critical stays in decision-needed,
    stays in the final cohort, and is recorded as retained_due_security — a
    successful outcome, not a failure.
  • Single-revision provenance: the plan joins the receipt and the truth in
    binding to one producer SHA.
  • Prior-truth binding: transition.prior_truth_sha256 is compared against
    the 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 symmetric
    difference 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

  • A receipt with no transition block is legacy-shaped: reconcile falls back to
    required = collected, outgoing = ∅, reproducing today's semantics verbatim
    including the size tripwire and its exact error strings.
  • --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
    argument.
  • cohort.expected_count keeps its legacy meaning in every mode (size of the
    collected set), so inputs.github_security.cohort_repository_count is
    unchanged. No PortfolioTruth SCHEMA_VERSION bump.

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-on
is a fourth branch, observed non-existence, which needs its own fixtures and
has no live instance today.

One candidate-derivation implementation

build_materialization_context and derive_candidate_cohort are extracted from
build_portfolio_truth_snapshot; the planner and the producer both call them.
test_planner_and_producer_share_one_candidate_derivation asserts this, because
two 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) and
tests/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_protocol
and test_case_03_expansion_is_red_without_the_transition_protocol assert the
legacy path still refuses the exact evidence the protocol admits.

Verification

  • uv run --extra dev ruff check . — clean.
  • Full suite: 3625 passed, 2 skipped.
  • Canonical truth generation run against the live ~/Projects workspace into an
    isolated output directory: 210 projects, registry and report rendered.
  • Planner dry-run against the live owner-path truth (read-only, no token):
    transition_kind: shrink, required 11, outgoing saagpatel/portfolio-index,
    incoming 0, collection 12, plan bound to producer 9b4bd45… and prior truth
    4647e7c4… — exactly the blocked transition this PR exists to cross.

One deliberate contract change: tests/test_cli_subcommands.py raises the
audit report --help flag budget from 45 to 47 for the two new bounded-cohort
controls, with the reason recorded in the assertion message.

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.
@saagpatel
saagpatel merged commit dda7b2c into mainAug 24, 2026
4 checks passed
@saagpatel
saagpatel deleted the feat/portfolio-cohort-transition-v1 branch August 24, 2026 01:26

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@saagpatel