60%

S2 - Preserve hard halts

Goal

  • Prove the “(b)” half of the plan mission: every failure that is NOT safely retriable stays a loud, bannered terminal halt, and that halt tells the truth about which repos reached origin vs which are stranded committed-but-local.
  • Named failure classes: message/manifest invalidity, mod-resolution invalidity, git/index + lock/provenance ambiguity, non-FF, push rejection, auth-required.
  • Falsifier (b): refuse-push -> bannered halt naming pushed + still-local SHAs, NEVER a dirty-commit success and NEVER a bare payload.
  • Classification of these classes is ALREADY correct in code — S2 does NOT re-implement it; S2 adds a matrix TEST plus the one omitted halt-payload wiring (falsifier (b)).

Implementation Sketch

Classification is already correct — do NOT re-implement

  • BLOCKER_HALT_REASONS (rules/halt_classification.py:160-172) + _NEVER_DEFERRABLE_STRUCTURAL subset of CORRECTNESS_HALT_REASONS (:123-134, :63-92) already enumerate every named failure class.
  • classify_halt_reason -> HaltClass.BLOCKER (:254-283) already routes each to a non-retried _halt via apply()’s hook-failure else branch and the push-failure branches.
  • Module-load disjointness guards (:190-216, exhaustiveness guard :175-201) mechanically prove DEFERRABLE_HALT_REASONS is disjoint from BLOCKER/_NEVER_DEFERRABLE_STRUCTURAL.
  • S2 adds a consolidated matrix TEST asserting each member routes to _halt; S2 MUST NOT add a new classifier or special-case any reason (e.g. push_rejected_non_ff) outside classify_halt_reason’s existing HaltClass.BLOCKER bucket (GAP:conditional-fallthrough-not-exhaustive — preserve the guard, never work around it).

The real gap — falsifier (b) at the two except PushFailure sites

  • Point of action: apply() Step 7 except PushFailure hard-halt else branch (state_machine.py:1798-1803) + Step 7.5 clean-ahead recovery branch (:1817-1822).
  • Both branches today return _halt(..., payload=dict(exc.payload_extra) if exc.payload_extra else None); for push_rejected_non_ff (and every BLOCKER reason lacking a dedicated payload_extra) payload_extra is {}, so the halt names NEITHER the repos pushed-before-the-failure NOR the repo whose own commit is stranded local-only.
  • This is an OMITTED code path, not a duplicated-and-diverged one (MISSING_ABSTRACTION audit found no dedup twin) — the whole fix is wiring already-computed data into the halt payload, no new plumbing.
  • Halt-payload contract (extend, do NOT invent a fresh descriptor builder): payload["pushed"] = repos pushed this run before the failure (from PushFailure.partial_results, push.py:139-161); payload["commits"] = still-local committed SHAs (Step-5 stage_and_commit_all result at :1513 diffed against partial_results).
  • Reuse _recover_stranded_siblings + _build_recovery_commit_descriptors (state_machine.py:498-566) — the BUG-07-532 pattern, today HookFailure-branch-only (exactly ONE call site at :1705, graph-confirmed). Extend its call sites into the two PushFailure branches; do NOT re-derive its logic (LEAK:algorithmic-duplication).
  • The pushed/commits field shape is the DATA S3 (per-repo-journal, s-117cfbf9) will persist — fix the shape here so S3 does not re-derive it; the shape is already emitted by the HookFailure hard-halt branch, so it stays next_action.schema.json-valid.

Preservation constraints — do NOT

  • Do NOT widen DEFERRABLE_HALT_REASONS (rules/halt_classification.py:104-117) to swallow any S2-protected reason — S1’s commit-dirty gate keys off it; the BLOCKER/structural-vs-deferrable disjointness must continue to hold (S1 s-8cf8f007 left this as an explicit scope boundary).
  • Do NOT resurrect _try_defer_push_failure — a structural no-op deleted during S1’s landing; still named in S1 prose, not a live extension point (is_snapshot_deferrable is never true for a push halt_reason).
  • Do NOT touch stale_lock.classify_lock — used only by scripts/plan_orchestrator/block_wait.py, zero import sites in scripts/commit_push/; unrelated to commit_push’s “lock/provenance ambiguity” (which maps to messages_invalid via MessagesInvalidDetail drift sub-reasons head_drift/diff_digest_mismatch/stale_run_id/staged_set_drift).

Sequencing with BUG-07-532

  • BUG-07-532 (bug-tracker/plans/BUG-07-532, open/in-progress) fixed the analogous gap for the two except HookFailure exits ONLY; its root-cause scope explicitly excludes the Step-7/7.5 PushFailure branches — S2’s falsifier (b) is the correctly-scoped, currently-unclaimed sibling.
  • Both edit the same 765-line apply() this review cycle: read BUG-07-532’s landed diff (content/root-cause-analysis--s-c7f5a90c.md + implementation--s-cdc33d68.md) before touching the shared helper to avoid a conflicting diff.
  • Update _recover_stranded_siblings’s docstring (state_machine.py:536-541, currently claims it is shared by both HookFailure exits while only one call site exists) in the SAME edit that adds the PushFailure callers, naming its now 2-3 real callers (COMMENT_HYGIENE_DRIFT:inaccurate-comment).

Test discipline

  • Positive matrix (work item 1): one consolidated test enumerating BLOCKER_HALT_REASONS | _NEVER_DEFERRABLE_STRUCTURAL, asserting every member routes to _halt via classify_halt_reason — no such single matrix exists today (coverage is scattered across per-reason pins).
  • Close invalid_repo_selector in the SAME matrix — a BLOCKER halt_reason with zero test-file references anywhere in scripts/commit_push/tests/ (the sole unpinned BLOCKER member).
  • Negative pins (work item 2): pair every “halts correctly” positive with a disjointness assertion that the reason is NOT in DEFERRABLE_HALT_REASONS/WARNING_ELIGIBLE_HALT_REASONS — the module-load guard is a SET invariant, not a behavioral pin that apply() actually halts (INVERTED-TDD negative-pin discipline, per S1’s precedent).
  • L12 production entry point (falsifier (b)): drive the REAL apply()/run_state_machine() (what python -m scripts.commit_push run invokes), never a mocked _halt seam — mirror test_hard_halt_recovers_and_surfaces_earlier_committed_sibling (tests/test_clean_ahead_push_path.py:453-530, same payload["pushed"]/payload["commits"] assertions) with a new pin triggered by push_rejected_non_ff (LAYER_GAP:production-entry-point guard).
  • Extend tests/test_push_failure_classification.py (11+ existing per-reason pins) rather than duplicate; there is no dedicated test_halt_classification.py.
  • Regression scope: apply() is the hottest, highest-betweenness, widest-fanout function in the file — re-run the FULL test_clean_ahead_push_path.py + test_push_failure_classification.py + test_state_machine.py suites, not a scoped subset; run python -m scripts.commit_push run --self-test before + after as a cheap end-to-end smoke.

Ownership + terrain

  • Terrain is contained in one community ([128], scripts.commit_push.state_machine) — the fix is architecturally local, no cross-boundary contract to reconcile.
  • Live claimants of apply()’s failure-exit branches: this section (S2) + BUG-07-532; no other plan/bug references this code (symbol-plans empty, expected for scripts/** Python).
  • scripts/** is tooling-scope: universal SRP/SSOT + STRUCTURE:*/LEAK:*/GAP:* families apply; compiler-only deep TDD-matrix/dual-exec families do NOT.
  • No external design doc exists (scripts/commit_push/design/2026-07-24-durability-redesign-tp-discuss.md is absent from the tree) — the load-bearing contract is this section body + falsifier (b) + the live code; tp-discuss host binding is discussions/01-commit-push-durability/discussion.md:95.

Coverage caveats (intel-graph staleness at package time)

  • ori-platform derived facets were ~8.5h stale (embedding/cpg/call-resolution null; co-change/test-topology/sibling-divergence stamped 2026-07-24T08:07 vs code sync 16:39) — every similar/test-posture/history-coupling/parallel-drift row in the dossier is UNKNOWN-coverage, corroborated against live source, not clean. Re-sync ori-platform embedding/cpg before TDD-matrix authoring to sharpen similar and close the test-posture false-zero (direct_tests: 0 on halt_classification.py is an extractor artifact, not real).
  • Python free-name callers/callees resolution is systematically weak for this module — treat any 0-edge Python callers result as an extractor gap requiring grep corroboration, never as dead code.

Work Items

  • Verify (via one consolidated matrix test enumerating BLOCKER_HALT_REASONS | _NEVER_DEFERRABLE_STRUCTURAL from scripts/commit_push/rules/halt_classification.py) that every structural/durability failure class named in this section’s goal — message/manifest invalidity (messages_invalid), mod-resolution invalidity (mod_resolution_fail), git/index and provenance-ambiguity failures (the MessagesInvalidDetail drift sub-reasons), non-FF (push_rejected_non_ff), push rejection (push_network_failure/push_unclassified/push_upstream_name_mismatch/push_no_upstream), and auth-required (auth_required) — routes to a terminal bannered halt via classify_halt_reason. Close the untested invalid_repo_selector gap (currently zero test-file references in scripts/commit_push/tests/) in the same matrix rather than leaving it the sole unpinned BLOCKER member.
  • Assert these are NEVER relabeled as dirty-commit success
  • Falsifier (b): extend the existing sibling-recovery pattern (_build_recovery_commit_descriptors + _recover_stranded_siblings, state_machine.py:498-566) into apply()‘s two except PushFailure hard-halt sites (Step 7 :1798-1803, Step 7.5 :1817-1822) so the halt payload names both the repos pushed successfully earlier this run (from PushFailure.partial_results, push.py:139-161) and the repo(s) left stranded committed-but-local (diffed against the commits bound at Step 5, :1513) — never a bare {} payload_extra. Mirror the existing test_hard_halt_recovers_and_surfaces_earlier_committed_sibling shape (tests/test_clean_ahead_push_path.py:453-530: payload[“pushed”]/payload[“commits”] assertions) with a new pin triggered by push_rejected_non_ff. Sequence this edit against BUG-07-532 (bug-tracker/plans/BUG-07-532, in-progress, same apply() function, HookFailure branches only) to avoid a conflicting diff on the shared helper; update _recover_stranded_siblings’s docstring (currently claims exactly 2 callers when there is 1 today) to name its real callers once this item adds 2-3 more.

Intel dossier — pointer and tier disposition

Dossier: preserve-hard-halts--s-4ddb5995.intel.md (this section’s content/intel/ sidecar)

Read that dossier from line 1 through EOF before acting on this section. This block is a pointer and an audit record, never a substitute: not the BLUF, not a summary, not selected tiers.

Dossier tierTitleLinesDispositionWhere / why
00. Bottom line up front21integratedGoal + Implementation Sketch §Classification is already correct — do NOT re-implement + §The real gap — falsifier (b) at the two except PushFailure sites
11. The target, verbatim17integratedGoal (restated as the (b) half of the mission disjunction) + Implementation Sketch §Ownership + terrain (tp-discuss host binding; no external design doc exists)
22. Terrain — symbols in the objective area (with files)64integratedImplementation Sketch §Classification is already correct + §The real gap (file:line pointers for BLOCKER_HALT_REASONS/_NEVER_DEFERRABLE_STRUCTURAL/classify_halt_reason in halt_classification.py, the two except PushFailure sites + _recover_stranded_siblings/_build_recovery_commit_descriptors in state_machine.py, PushFailure.partial_results in push.py) + §Preservation constraints (MessagesInvalidDetail drift sub-reasons)
33. Code-graph recon77integratedImplementation Sketch §The real gap (single call site graph-confirmed) + §Test discipline (apply() hottest/highest-betweenness → full-suite regression scope; test-posture false-zero) + §Ownership + terrain (community [128] containment) + §Coverage caveats (Python callers resolution weak; co-change push.py corroboration)
3D3D. Diagnostic / observability surface19integratedImplementation Sketch §Test discipline (L12 production entry point drives real apply()/run_state_machine via python -m scripts.commit_push run; run —self-test smoke before/after)
3H3H. Hygiene constraints17integratedImplementation Sketch §Classification is already correct (GAP:conditional-fallthrough exhaustiveness guard) + §The real gap (LEAK:algorithmic-duplication — extend not re-derive) + §Test discipline (INVERTED-TDD negative pins; LAYER_GAP:production-entry-point) + §Sequencing with BUG-07-532 (COMMENT_HYGIENE_DRIFT docstring update)
44. Cluster / family — sibling sections + related bugs25integratedImplementation Sketch §Sequencing with BUG-07-532 (coordinate, do-not-duplicate, shared apply() this cycle) + §Preservation constraints (S1 disjointness boundary) + §The real gap (S3 per-repo-journal field-shape dependency)
55. Conformance audit — MISSING_ABSTRACTION (ori-platform, stale facet)10integratedImplementation Sketch §The real gap (framed as an OMITTED/MISSING code path, not a duplicated-and-diverged one — no confirmed dedup twin; reinforces extend-existing-helper over new descriptor builder)
66. Plan ownership — who owns this code now11integratedImplementation Sketch §Ownership + terrain (S2 + BUG-07-532 are the two live claimants of apply()‘s failure-exit branches; symbol-plans empty for scripts/** Python is expected) + §Sequencing with BUG-07-532
77. Prior art — declared N/A (evidence-backed, not assumed)11not_applicableDossier declares prior art N/A — cross-repo similar/search returned only weak, semantically-coincidental matches; scripts/commit_push is internal wrapper-tooling with no compiler analog. The only relevant precedent is in-house BUG-07-532’s _recover_stranded_siblings pattern, already integrated via the reuse directive in §The real gap + §Sequencing. This tier introduces no external technique or constraint the section must answer to.
88. Sentiment — declared N/A5not_applicableSentiment corpus (pain_pctile/controversy_pctile) is scoped to the 11 reference-language PUBLIC issue trackers; scripts/commit_push is internal AI-seam tooling with no public tracker and no external community-heat signal — structurally empty by construction. Imposes nothing on the section.
99. Declared coverage gaps17integratedImplementation Sketch §Coverage caveats (ori-platform ~8.5h derived-facet staleness; Python callers/free-name resolution weak) + §Preservation constraints (_try_defer_push_failure dead/do-not-resurrect; stale_lock.classify_lock unrelated/do-not-touch; lock/provenance ambiguity maps to messages_invalid via MessagesInvalidDetail)
1010. Recommended recon entry points (for /continue-roadmap execution)20integratedImplementation Sketch §Sequencing with BUG-07-532 (read BUG-07-532 root-cause + implementation first) + §The real gap (the two except PushFailure sites :1770-1822; recovery helpers :498-566) + §Test discipline (mirror test_hard_halt_recovers_and_surfaces_earlier_committed_sibling; extend test_push_failure_classification.py; halt_classification.py full read; run —self-test) — the recon file:line pointers are now section-resident