Research: Shared battery collection and complete shard-timing provenance

Grounded on the primary branch (main) at b2c466d7d1. Findings come from two grounding scouts and two post-spec adversarial reviewers, each adjudicated against the code where they disagreed.

Findings that shaped the scope

#FindingSource
R-01Each per-PR job runs collect_universe() once. test_fast_tier_marker_completeness.py is in battery leg 1/2, test_same_tier_uniqueness.py in leg 2/2 (resolved with _gate_coverage._battery_partition()), and tests/ci/test_corpus_blocking_home.py in the ci module shard.tests/architectural/_gate_coverage.py:1576, :1952
R-02The legs run in ci-router.yml on Python 3.12; module shards run in ci-modules.yml → module-tests.yml on the project pin (3.11). Both trigger independently on pull_request..github/workflows/ci-router.yml:709-784, .github/workflows/ci-modules.yml
R-03The measured 114 / 166 / 170 s is one collection inside a module-scoped fixture under -n 4.kitty-specs/ci-runtime-stabilisation-01M3TZH6/evidence/ci-measurements.md
R-04collect_universe() passes the whole inherited environment, a fresh HOME, and -o addopts= to a pytest --collect-only subprocess, and records marker names per item._gate_coverage.py:1952-2010, _gate_collect_plugin.py
R-05tests/conftest.py adds a skip marker at collection from sys.platform, SPEC_KITTY_RUN_QUARANTINE and SPEC_KITTY_RUN_PERFORMANCE. These change recorded marker names.tests/conftest.py:316-344
R-06The mismatch allowlist holds 17 modules; _BASELINE_ALLOWLIST_COUNT = 17. charter, agent and consolidation are outside it and have drifted.tests/architectural/test_module_length_agreement.py:118-147, issue #5536
R-07The scheduled recapture is single-module by construction (MODULE = "charter", one in-process pytest run), computes drift only after a full capture, trusts exit status 0 or 1, publishes to one fixed branch, and skips publishing while a proposal is open. Its job is gated to the primary branch.scripts/ci/recapture_charter_shard_timings.py, .github/workflows/ci-charter-shard-recapture.yml:57-124
R-08The floor-constant and derived-set items of #5561 were ruled out in the parent issue's triage (2026-09-26) and the code agrees.triage comment on the closed parent issue; tests/architectural/test_mutation_ownership_routing.py:1018

Decisions

D-01 — Key is the committed tree plus the environment

  • Decision: key = digest of (git rev-parse HEAD^{tree}, interpreter version, sys.platform, digest of installed distributions, values of the declared environment family). The store is disabled when git status --porcelain is non-empty.
  • Rationale: collection depends on files well outside tests/ (parametrised ids built from src/, packs/, .github/, docs/, kitty-specs/, scripts/). A hand-kept input list would go stale; the tree id covers every tracked file in about 2 ms. Untracked files that affect collection make the checkout dirty, which disables the store.
  • Alternatives considered: hashing an enumerated file list (unsound, R-04/R-05); hashing file contents of the whole tree (slow, redundant with the tree id).

D-02 — Declared environment family

  • Decision: the key includes every variable named SPEC_KITTY_ and PYTEST_ADDOPTS, minus variables the collection itself sets (SK_GATE_), per-process pytest/xdist variables (PYTEST_CURRENT_TEST, PYTEST_XDIST_), and variables the test session sets for itself (tests/conftest.py writes SPEC_KITTY_REAL_HOME_FOR_TESTS and SPEC_KITTY_ENABLE_SAAS_SYNC into every pytest process; a pre-test step has neither). A test asserts that the key computed inside a pytest process equals the key computed in a plain process, so a new session-set variable cannot silently stop every match. The family and its exclusions are one constant with a test that a new SPEC_KITTY_ switch changes the key.
  • Rationale: R-05 shows two such switches change the universe today; keying the family, not a list of two, means a future switch is covered without an edit. Excluding per-process variables is required or no two workers would ever share a key.
  • Alternatives considered: hashing the whole environment (never matches between a step and a test); a two-variable list (stale on the next switch).

D-03 — Store location and retention

  • Decision: one directory under the repository's git-ignored cache area, resolved by one helper; one file per key; a write evicts every other key's file.
  • Rationale: FR-012. A single current record bounds the size (one record is about 10.7 MB) and makes the actions/cache path a single directory.
  • Alternatives considered: the system temp directory (not restorable by path across CI steps in a stable way); keeping N records (no use case).

D-04 — Lock and write

  • Decision: reuse kernel.locks.machine_file_lock around "read-or-collect-then-write", called with blocking=True (its default is non-blocking) and a timeout above the 900 s collection timeout, on one fixed lock file; write to a temporary file and rename.
  • Rationale: FR-005; an existing, tested primitive (C-001 forbids changing it, not calling it).
  • Alternatives considered: a new lock helper (second authority); no lock (parallel workers each collect).

D-05 — Transport is a pre-test step plus actions/cache

  • Decision: per ruling 01M42VM0CEBBZHCJEAN4KTDYYC, each consuming job collects before pytest. actions/cache restores and saves the store directory keyed on the collection key so a re-run of the same checkout restores instead of collecting.
  • Rationale: R-02 makes a single shared producer impossible without aligning interpreters and chaining workflows. The content key is the guard, so a cache entry from another commit can never match.
  • Alternatives considered: producer job with artefact hand-off (adds 1.5–2 min to the legs, still two collections); local store only (does not address R-03).

D-06 — The consuming module shard is selected, not hard-coded

  • Decision: in module-tests.yml the pre-test step runs only in per-PR mode and only when the shard's selected test list contains the consuming test node (the selection is per test, and the consuming file spans both ci shards). A test pins that the named node still exists and still requests the universe. Full mode is excluded because its warm-up may rewrite the tracked lock file, which would make the checkout dirty.
  • Rationale: the workflow is shared by every module and shard; an unconditional step would add a collection to every shard.
  • Alternatives considered: if: module == 'ci' (runs on every ci shard, and breaks silently if the test moves).

D-07 — Fallback after a successful pre-test step is a failure

  • Decision: a post-pytest check reads the report lines and exits non-zero when the pre-test step stored a record and any test in the job reported collected.
  • Rationale: FR-011. Without it, a key that never matches is invisible, because the fallback keeps every gate green.
  • Alternatives considered: summary-only reporting (nobody reads a green job's summary).

D-08 — Recapture finds drift with a count-only pass

  • Decision: the generalised recapture first collects each module's count (one count-only pass per module, no test execution), compares them with committed counts and provenance, and captures only modules that differ, each in its own subprocess via the canonical producer. When a recapture proposal is open, the proposal branch's timings file is the baseline, so carry-over makes progress instead of repeating the same modules.
  • Rationale: R-07: today's script must run the full 18-minute capture to learn whether anything drifted. Count-only passes cost seconds per module.
  • Alternatives considered: capture everything every night (hours); keep charter-only and document manual recapture (the #5536 class).

D-09 — Time budget and carry-over

  • Decision: the script takes a budget; it stops starting captures when the budget is spent, reports the deferred modules, and orders candidates so the longest-deferred goes first. No state file: the next run recomputes drift.
  • Rationale: FR-019, NFR-007; statelessness keeps the script idempotent.
  • Alternatives considered: a persisted queue (new state to corrupt); raising the job timeout without a budget (unbounded).

D-10 — Refreshing an open proposal

  • Decision: when the proposal branch has an open pull request, the workflow adds an ordinary follow-up commit on top of it. It never force-pushes.
  • Rationale: FR-020 against the existing rule (R-07) that a branch with an open proposal is never force-pushed. A fast-forward push respects that rule.
  • Alternatives considered: skip while open (today's behaviour; proposals go stale and the strict check stays red); force-push (forbidden by the existing design).

D-11 — Valid capture

  • Decision: per ruling 01M42VM1YPDQPPGP1FK0W9AECK, a provenance record is valid when the capture run's exit status is 0 or 1 and it recorded at least one duration. The rule is one shared predicate used by both the recapture script and the provenance-completeness check.
  • Rationale: one authority for "trustworthy capture"; today it exists only inside the charter script.
  • Alternatives considered: exit status 0 only (holds the mission on unrelated red tests).

D-12 — Allowlist removal, not zeroing

  • Decision: delete _MISMATCH_ALLOWLIST, _BASELINE_ALLOWLIST_COUNT, and the tests that only police the allowlist. Keep the agreement check and its self-mutation proof.
  • Rationale: Burn-down Policy (a) and ADR 2026-09-30-1: a drained baseline entry is removed. An empty dict would leave the shape tests passing vacuously.
  • Alternatives considered: keep an empty dict and a zero baseline (vacuous tests, a mechanism waiting to be refilled).

D-13 — Capture order and environment

  • Decision: captures run from uv sync --frozen --all-extras on the pinned interpreter, serially, by the orchestrator, after every code-bearing work package is approved; ci and the module that owns the mission's new tests are captured last. A rebase after capture is followed by the count-only pass, recapturing only what moved.
  • Rationale: the mission adds tests to tests/ci and tests/architectural; capturing earlier would be stale on arrival.
  • Alternatives considered: dispatching the CI capture workflow (the generalised workflow is gated to the primary branch until it lands).

Planning checks

CheckResult
The CI cache can restore the store in a re-run, keyed on the collection keySupported: actions/cache restores by exact key within the same ref scope; both workflows can use the same key because the key already includes the interpreter. Verified in implementation by the workflow-shape test and the first re-run.
No parametrised test id embeds the temporary home directoryConfirmed: 0 differences across two passes (see "Two-pass collection" below).
Total serial capture timeNot measurable before implementation. Committed durations for the 17 sum to about 27 minutes but are stale; charter alone is about 18 minutes. Measured on one small and one large module first, then recorded in evidence/.
Which environment switches change collectionSPEC_KITTY_RUN_QUARANTINE, SPEC_KITTY_RUN_PERFORMANCE, sys.platform, and PYTEST_ADDOPTS (R-04, R-05). Covered by D-02.
Nightly job layout for the equivalence checkci-nightly.yml runs marker lanes as separate jobs; the comparison is added as its own small job so it survives an upstream red.

Two-pass collection

Two consecutive collect_universe() calls, each with its own fresh HOME, returned 54,723 records with 0 differences (95.3 s cold, 21.6 s warm, on a workstation). No test id or marker set depends on the temporary home directory. Details: evidence/two-pass-collection.md.

Brownfield findings (seam map, 2026-10-04)

#FindingConsequence
B-10The skew check passes trivially at shard_count == 1, and counts also encode the per-shard time cap (.github/ci-module-registry.yml:173-199).FR-016 never reduces a count: a count that fails skew on recaptured durations is raised to the nearest higher count that passes; a needed reduction goes to the operator.
B-01No shard-count derivation tool exists. scripts/ci/reconcile_shards.py reconciles coverage artefacts; shard_count is declared in the registry and validated by tests/architectural/test_module_shard_registry.py:274 (_lpt_bin_pack :116, _MAX_SKEW :59).FR-016 and C-004 were reworded: counts must pass the existing skew check on recaptured durations. No derivation tool is built (it would be a second authority beside scripts/ci/shard_select.py:lpt_assign).
B-02module-tests.yml:195 writes shard_tests.txt into the checkout; it is not git-ignored, so git status --porcelain is non-empty for the rest of the job.The shard list moves to the runner's temp directory, and check also fails on bypassed when the pre-test step stored a record (D-14).
B-03The #5536 publish failure is 403 Permission denied on git push --force to the proposal branch (runs 37182764995, 37097776055). The last six scheduled runs failed; three ran 30m19–21s against the 30-minute cap.FR-021 became "classify and make loud". The token is the operator's to fix. The time budget (D-09) addresses the cap.
B-04There is no minimum-universe constant today.The store adds one sanity floor (D-15); it is a corruption guard, not a ratchet.
B-05uv sync and pytest share one run: block in the heavy battery job (ci-router.yml:758-773).The block is split so the key can be computed after the environment exists and before pytest.
B-06Workflow step shape is pinned by tests/ci/test_ci_module_wiring.py:485-556, tests/ci/test_xdist_worker_policy.py, tests/architectural/test_no_duplicate_suite_execution.py:240,911, tests/architectural/test_dual_mode_contract.py:226-310, tests/architectural/test_module_tests_matrix.py:155-266, tests/ci/test_nightly_exit_code_honesty.py:58-302, tests/ci/test_fork_guard.py:124. gc.suite_invocations does not see a python -m step.The workflow work package runs exactly these files.
B-07actions/cache is pinned once, at .github/actions/warmup/action.yml:103 (55cc8345863c7cc4c66a329aec7e433d2d1c52a9, v6.1.0).Reuse the same pin.
B-08The recapture workflow's file name is listed in _gate_coverage.WORKFLOW_FILES (:138) and test_no_duplicate_suite_execution.NON_CHANGE_TRIGGERED_WORKFLOWS (:176); its job key is pinned in the script's test; the pinning inventory is re-derived by tests/release/test_pinning_inventory_fresh.py. The secret name is a repository secret.The script and its test are renamed in the recapture work package; the workflow file is renamed in the final code work package, after the store work has landed, with the inventory regenerated. The secret keeps its name.
B-09_gate_coverage.py is on the ruff-format exclude ratchet.Never run ruff format on it by explicit path without --force-exclude.

D-14 — bypassed after a successful pre-test step also fails the check

  • Decision: check exits non-zero when the pre-test step stored or reused a record and any later request in the job reported collected or bypassed for the repository root. bypassed / root-override is exempt.
  • Rationale: B-02 showed a dirty checkout would silently disable reuse while every gate stays green.
  • Extension (WP02 review, 2026-10-04): the same rule applies to the pre-test step's own line. A pre-test step that is bypassed for a dirty checkout, an unavailable git, or a lock or store failure, or that collects without storing, fails both collect and check. Otherwise a pre-test step that never stores would leave reuse permanently broken behind a green job. The only legitimate fallback is an unsupported platform.
  • Alternatives considered: ignoring untracked files in the dirty test (an untracked new test file changes collection); summary-only reporting.

D-16 — The key overlays the operator env file

  • Decision: before selecting the environment family, the key applies the product's own operator env-file loader to a copy of the environment (real values win, as in the product). An unreadable env file bypasses the store with reason env-file-unreadable.
  • Rationale: the command-line package seeds a git-ignored .kitty.env into the environment when it is imported. A pytest session imports it; a plain pre-test process may not. On a machine with such a file the two computed different keys, so reuse could never happen there. Found after merging the primary branch, in the repository root checkout, which has such a file; lane worktrees and CI do not.
  • Alternatives considered: excluding each affected variable by name (any operator-chosen variable would hit the same problem, and such a variable can change collection).

D-15 — Sanity floor for a stored universe

  • Decision: a stored record with fewer than 1,000 records is treated as absent. The universe holds 54,723 records today.
  • Rationale: FR-007; guards against a truncated-but-parseable file. It is deliberately far below the real size so it never needs maintenance.
  • Alternatives considered: a tight floor near the real count (a ratchet needing upkeep, and C-006 forbids new baselines).

Open risks carried into tasks

  • Existing architectural gates parse the two workflows; adding steps may trip a step-shape pin. The brownfield scout maps them before work is cut.
  • Renaming the recapture script and workflow touches workflow-inventory gates and documentation references.
  • A workstation capture feeds the skew check with workstation durations. Shard counts depend on relative durations within a module, so the skew is second-order; the first scheduled run on the primary branch corrects it.