ROOT CAUSE (run 11081, sha a604dd3): ci_event_wait defaulted to the
master_ci operation, whose authority (deploy_qualify) requires a
ci.yml@refs/heads/master run for the exact SHA. A candidate pushed to a
BRANCH has no such run, so independent_jobs_verdict matched nothing →
pending → combine(success, pending) = pending → the waiter re-armed until
it was killed BY HAND, while the runs/jobs API already showed every job
green. The event transport was never at fault: the event fired, matched
(matched=1) and the durable completion file was written BEFORE the kill.
Repairs, each at the authority that owns the decision:
1. OP-TYPE RESOLUTION (ci_event_wait.op_type_for_sha): when the caller does
not name the operation, it is resolved from the SHA's OWN runs (master
run → master_ci; otherwise branch_ci) and the choice is PRINTED, never
assumed. deploy.yml's explicit master guard is untouched.
2. NO WAIT ON AN UNANSWERABLE QUESTION (op_policy_mismatch): when the op's
declared ref policy excludes every run the SHA actually has, the waiter
returns verdict=policy_mismatch with the reason instead of parking
forever. It is neither success nor pending, so no caller can read it as
qualification. No runs at all stays honestly pending (CI may not have
started).
3. REPLAYED PENDING RE-ARMS: a replayed wake whose authority is still
pending used to TERMINATE the wait as authority_unresolved. A replayed
event proves an event happened, not that qualification is decidable —
it now re-arms inside the same logical await.
4. LIFECYCLE RUNNER LEG WIRED (§19/§28): BuildLifecycle accepted a runner
and never invoked it. qualify_candidate() now calls the canonical waiter
for the EXACT candidate SHA, records the verdict durably on the issue
link (a projection, not a second CI truth), and next_action sequences it:
success → complete_gates, failure → repair_candidate (issue stays open),
policy_mismatch → re_qualify, pending → wait_for_runner. ci_state()
projects CI_NOT_STARTED/RUNNING/WAITING_FOR_EVENT/QUALIFIED/FAILED/
RECONCILING for clients.
5. DEPENDENCY LOCK DETERMINISM (§14-§17): the gate compared committed pins
against a FRESH resolve against the live index, so an unchanged candidate
failed because wall-clock time passed (platformdirs 4.12.0 released
minutes after regeneration). The lock now DECLARES a resolution boundary
(uv --exclude-newer, recorded in uv's own header command line); the
checker applies that same boundary and the printed remedy reproduces it,
so the resolve is a pure function of (pyproject, boundary) and drift
detection is undiminished (unpinned lines still fail loudly).
Evidence: with --op-type branch_ci the same SHA returns success in 0.4s; the
REAL red candidate 6fe6c90 returns failure/RС=1 (no manufactured red run);
an earlier boundary excludes today's releases (nodeenv 1.10.0,
platformdirs 4.11.14), proving the boundary actually binds.
Tests: tests/test_ci_event_correlation.py (17), test_dep_lock_determinism.py
(10), test_runner_qualification.py (9); affected suites 94 passed.