Contracts

ci-yaml-shape.md

Contract: CI YAML shape (golden-YAML pins)

These are the exact resolved shapes the golden-YAML pytest assertions must enforce, using the canonical pattern from tests/architectural/test_dual_mode_contract.py (yaml.safe_load(path.read_text()); note PyYAML parses bare on: as boolean True, so read doc[True] for the trigger block).

C-YAML-1 — ci-router.yml top-level concurrency (Lever 1a)

# round-trip: skip: illustrative CI-router concurrency shape, not a Pydantic contract example
concurrency:
  group: ci-router-${{ github.event_name == 'push' && github.sha || github.ref }}
  cancel-in-progress: ${{ github.event_name != 'push' }}

Assertions (NEW pin, tests/architectural/test_dual_mode_contract.py) — EXACT equality, not substring (a substring pin like "github.sha" in group passes for a broken always-cancel expression such as ${{ github.event_name != 'push' || true }}, silently re-enabling the main cancel-cascade — the exact hole FR-001 closes; Renata HIGH / Debbie LOW):

  • workflow["concurrency"]["group"] == "ci-router-${{ github.event_name == 'push' && github.sha || github.ref }}".
  • workflow["concurrency"]["cancel-in-progress"] == "${{ github.event_name != 'push' }}".
  • The on: block still has push.branches == ['main'], the pull_request trigger, and workflow_dispatch (unchanged).

C-YAML-2 — ci-fleet-verdict.yml trigger types (Lever 2a.1)

# round-trip: skip: illustrative workflow_run trigger shape, not a Pydantic contract example
on:
  workflow_run:
    workflows: [ ...8 unchanged... ]
    types: [completed]

Assertions (UPDATE tests/ci/test_fleet_verdict.py around :125):

  • set(trigger["types"]) == {"completed"} ← was {"requested","in_progress","completed"}.
  • set(trigger["workflows"]) unchanged (still the full registered set).

C-YAML-3 — ci-fleet-verdict.yml top-level concurrency (Lever 2a.2)

> Fix round 1 (pre-merge squad MAJOR @0568119, 2026-09-15). The original per-head_sha key conflated verdict subjects on the CI Aggregate trigger path: a workflow_run child always executes on the default branch, so an aggregate completion's head_sha is the current main tip, not the PR head it verified — every PR's aggregate completion landed in the current tip's group and cancel-cascaded other subjects' runs (both the PR-stranding and the lost-main-evaluation channels NFR-002 forbids; cancel-in-progress: false does not close the pending channel either, because a newly queued run replaces a pending run in a group by default). The key is therefore subject-safe per path: the direct producers keep head_sha (their head IS the subject); the aggregate path keys on the aggregate run's own id, which its display_title binds to exactly one source CI Modules run, hence one PR head — a per-subject singleton group. Recorded in research.md D9.

# round-trip: skip: illustrative fleet-verdict top-level concurrency shape, not a Pydantic contract example
concurrency:
  group: ci-fleet-verdict-${{ github.event.workflow_run.path == '.github/workflows/ci-aggregate.yml' && format('aggregate-{0}', github.event.workflow_run.id) || github.event.workflow_run.head_sha }}
  cancel-in-progress: true

Assertions (pin in tests/ci/test_fleet_verdict.py) — EXACT equality (a substring head_sha in group passes for a broken expression that drops the workflow_run. path and collapses ALL tips into one shared cancel:true group = the cross-tip-cancel NFR-002 forbids; Renata/Debbie):

  • top-level workflow["concurrency"] == {"group": "ci-fleet-verdict-${{ github.event.workflow_run.path == '.github/workflows/ci-aggregate.yml' && format('aggregate-{0}', github.event.workflow_run.id) || github.event.workflow_run.head_sha }}", "cancel-in-progress": True}.

C-YAML-4 — report-main job concurrency — UNCHANGED (negative pin)

> Reversed post-plan squad (Alphonso MAJOR, operator-ratified 2026-09-15). Moving report-main to per-SHA coalesce opens a create-create race on the single from:ci incident issue. report-main is therefore left unchanged; the fan-out cap is delivered entirely by C-YAML-2 (types trim) + C-YAML-3 (top-level coalesce), which cut report-main inflow to ≈1/tip so the existing single-group queue drains trivially.

# round-trip: skip: illustrative report-main (unchanged) concurrency shape, not a Pydantic contract example
jobs:
  report-main:
    concurrency:
      group: ci-fleet-verdict-main
      cancel-in-progress: false

Assertion (existing tests/ci/test_fleet_main.py:166 stays green as-is — do NOT change it):

  • job["concurrency"] == {"group": "ci-fleet-verdict-main", "cancel-in-progress": False}.
  • This preserves cross-tip incident-issue serialization (edge case: two concurrent red tips cannot both create an incident).

C-YAML-5 — report (PR) job concurrency (unchanged — negative pin)

# round-trip: skip: illustrative report(PR) (unchanged) concurrency shape, not a Pydantic contract example
jobs:
  report:
    concurrency:
      group: ci-fleet-verdict-pr-${{ matrix.pr }}
      cancel-in-progress: false

Assertion (existing test_fleet_verdict.py:126-127 stays green): matrix.pr in group; cancel-in-progress is False. Must not change (FR-002 / invariant CK-2).

C-YAML-6 — report (PR) job comment refresh (Alphonso/Debbie INFO)

The report (PR) job's cancel-in-progress: false + its comment ("Global concurrency could lose another PR", ci-fleet-verdict.yml:40-41) become semantically stale once the top-level cancel:true block owns same-subject coalescing (different PR heads never share the top-level group — per-head_sha on the direct path, per-aggregate-run-id on the aggregate path, per fix round 1; the double-snapshot head-drift guard covers re-pushes). Implement task: update the comment at :40-44 so a future reader knows the top-level key now carries that guarantee. No behavior change; not a golden-YAML pin.

C-WIRE-1 — script-invocation wiring pins (mirror test_router_gate_step_wiring...)

New/kept assertions that the workflow steps still invoke the verdict scripts (so the fan-out cap cannot silently orphan them):

  • identify step run text contains scripts/ci/fleet_verdict.py (or -m scripts.ci.fleet_verdict) and attempts/ where a jobs-API call appears.
  • report step invokes fleet_verdict.py --pr.
  • report-main step invokes python3 -m scripts.ci.fleet_main.

verdict-invariants.md

Contract: Verdict invariants (dedup survivor — behavior pins)

Stage 1 changes no classify/report behavior; these are the invariants the new dedup unit test must pin, because the fan-out cap (coalesce-with-survivor) now depends on them.

VI-1 — Survivor re-reads live evidence (double-snapshot)

fleet_verdict.report() snapshots current evidence at entry (:317) and again immediately before publishing (:332-334); if the head SHA or evidence fingerprint drifted between the two reads it raises "changed before publication; later event will reconcile" and posts nothing.

  • Test: given a survivor whose second snapshot differs from the first, report() raises (does not post a stale verdict).
  • Rationale: under cancel-in-progress: true, only the last completion survives; it must recompute from live state. Pinned so a future edit cannot turn coalescing into a stale-post.
  • Non-tautology guard (Renata, implement-review): the drift between the two snapshot() calls MUST be driven through the in-memory API() stub's mutating state so report() actually calls snapshot twice and compares — NOT by patching snapshot itself (which would assert the mock, not the behavior). Reviewer verifies the test exercises the real double-read.

VI-2 — Newer verdict is never suppressed; only exact-duplicate running is

The running-suppression (:325-331, guard at :329) returns without posting only when the latest ledger comment is a bot comment carrying the same fingerprint, OR (state=="running" AND the latest body starts with [ci] running @<sha>). A newer terminal verdict (green/red) for the same head is not suppressed.

  • Test: test_duplicate_latest_evidence_is_suppressed_but_newer_verdict_is_not (existing, :209-219) stays green; ADD an explicit case: a running post followed by a green/red post for the same head does publish.
  • Rationale: NFR-002 — the last-writer terminal verdict a landed tip needs is never dropped by dedup.

VI-3 — Never-green preserved

classify never turns absent/cancelled/skipped/incomplete evidence into green (:87).

  • Test: test_truncated_files_and_deferred_pr_never_green (existing) stays green. Stage 1 adds no path that could green-wash.

VI-4 — Main path shares the same guarantee

fleet_main.report() re-reads main head + attempts at :124 (raises on drift) and dedups a single open incident issue.

  • Test: test_fleet_main.py main-intake tests stay green; the per-SHA report-main coalesce does not change the incident-issue idempotence.

VI-5 — Aggregate fail-closed guard untouched (NFR-001)

Stage 1 touches no source-eligibility or reconciliation logic in ci-aggregate.yml / scripts/ci/reconcile_shards.py.

  • Test: tests/ci/test_reconcile_shards.py and the aggregate guard tests stay green; the Stage-1 diff contains no ci-aggregate.yml / reconcile_shards.py / aggregate_source.py changes.

VI-6 — Coalescing depends on types:[completed] staying present (Renata LOW)

The survivor's "later event will reconcile" behavior (VI-1) relies on upstream completed workflow_run events remaining the trigger. The Stage-1 trim to types:[completed] preserves the reconciling event (completions are exactly what remain), so the assumption holds. But a future narrowing of types: (or removing a completing upstream workflow) could silently turn coalescing into a suppression path — the same class as the Stage-3 compat edge case.

  • Pin: not unit-testable in pytest (C-002); documented here + in the top-level-concurrency golden-YAML pin's neighborhood so a future types: edit is reviewed against this dependency. A wedged terminal survivor is caught on the merged tip by SC-006 (terminal-verdict presence).