Skip to content

fix(branchsync): settle wedged custody records via axi sync --recover --keep-local (#824) - #883

Open
hmcdaniel03 wants to merge 31 commits into
kunchenguid:mainfrom
hmcdaniel03:fm/nm-824-custody-release
Open

fix(branchsync): settle wedged custody records via axi sync --recover --keep-local (#824)#883
hmcdaniel03 wants to merge 31 commits into
kunchenguid:mainfrom
hmcdaniel03:fm/nm-824-custody-release

Conversation

@hmcdaniel03

@hmcdaniel03 hmcdaniel03 commented Aug 28, 2026

Copy link
Copy Markdown

Intent

Fixes #824: a custody record whose gate branch SHA and preserved pipeline head SHA disagree cannot currently be recovered or aborted — recover_custody keeps being advertised, both axi sync --recover and axi sync --recover --keep-local refuse with the same blocked_recover_gate_diverged, and axi abort --run <id> is an idempotent no-op on the already-failed run.

This branch implements the settlement described in the issue:

  • axi sync --recover --keep-local settles a wedged custody record when the bound run is terminal and the preserved head is either missing or in disagreement with the gate. Custody is returned at the kept local head, and where the gate branch still names a different head it is compare-and-swapped onto the kept head. Every still-reachable copy of the recorded head is pinned first, and the settlement refuses rather than proceeding when a surviving head cannot be anchored.
  • Terminal-run abort names that settlement instead of being a bare no-op.
  • Inspection and the TUI both advertise it, so the CLI and the TUI have the same exit.
  • Default --recover behavior is unchanged.

Addressing your review

Rebased onto current main. Only AGENTS.md conflicted, because main extracted that section into .agents/skills/; the resolution takes main's copy verbatim and re-homes our prose into .agents/skills/branch-sync-and-push-safety/SKILL.md. AGENTS.md is byte-identical to main. #883 is mergeable against kunchenguid:main.

Gap (1) is closed by construction rather than by cleanup. The displaced-gate-head anchor guards exactly one operation — the compare-and-swap that moves the gate branch off gateHead — and nothing before that swap can strand it, because refs/heads/<branch> still names it. So the write now happens immediately before the swap, and every refusal that can precede it leaves no anchor of its own. That is stronger than deleting the ref on the refusal path, because a cleanup can itself fail and leave the same false claim.

The accounting, since our earlier "four" caused confusion: at the previous tip recoverKeepLocal had 8 refusal returns, 5 of them after the anchor write, and 4 of those 5 made the untrue "no files or refs were changed" claim — the fifth said "no local files or refs", named the anchor, and was already honest. Both numbers were right about different things. It now has 10 refusal returns: 7 that leave no anchor of their own, and 3 after the swap that each name what they wrote. A failure to record custody after the Git side finished is a separate post-success failure, not a refusal, and it names its own complete_custody_return retry.

Gap (2) is closed. The TUI reaches the same settlement, keyed on the advertised next_action.code rather than a safety code so it cannot drift from the predicate that decides where the settlement can complete, behind its own confirmation because settling keeps the local head where recovery takes the preserved one.

Gaps (3), (4) and (5) are closed. (3) was not cosmetic: with the old probe the settlement completed on a head that was still present, because any non-zero git exit read as "absent". Only exit 1 now counts as proven absence; an unreadable store and a present-but-wrong-type object both refuse. (5) was worse than we described — a --run abort response carries no branch_sync object at all, so those tests could not have failed even with the clause deleted.

What the review rounds found, including our own near-misses

Five review rounds ran against this work. We think the misses are worth showing rather than presenting a clean result:

  • A stale-head custody stamp. The compare-and-swap published the head the gate-side fetch had copied without re-reading the branch, so a commit landing in that window stamped custody at a head the worktree had already moved past. This is the only defect in the set that silently reported success rather than lying in a message.
  • An invented race. A failed update-ref was reported as a concurrent gate push, when a held ref lock or an I/O error fails it identically. The gate head is now re-read and a race claimed only when it actually differs.
  • A dead end we created. Giving the stamp-failure retry its own action code was correct — reusing return_custody_keep_local told the operator, in five places, that a fully verified head could no longer be verified. But it left the TUI with no exit for that state, reintroducing the Custody record with disagreeing gate vs preserved head cannot be recovered or aborted #824 shape in the surface this PR fixes the TUI for. An exhaustive enumeration of every surface carrying those codes found it; five review rounds had not.
  • A defect on the path we scoped out, found by your automated reviewer. Default --recover writes refs/no-mistakes/recover/<run> — into the local gate, then into the invoking worktree — before its own refusals are reached, and those refusals reported "no files or refs were changed". One of them named the anchor and denied writing any ref in the same sentence. The keep-local siblings twenty lines away already disclosed it; these did not, so the claim was true only where we had looked. Fixed, with no behaviour change: same refusals, same next actions, same refs written.
  • A regression from our own fix. Surfacing a failed staging-ref cleanup set Error on a successful custody return, and callers read a non-empty Error as failure — so a successful recovery would have reported itself failed. Caught and corrected before merge; the note now lives only on paths that would otherwise make a false claim.

A structural observation, offered as data

Your note about blockedPlan nil-ing NextAction by default looks right, and this branch is evidence for the general shape rather than just that instance. The same propagation failure occurred four times, found by four different mechanisms: a review round, an exhaustive enumeration, a test whose population was scoped to a file family rather than to the claim, and a consent prompt missed by the guard built to stop exactly this. Each individual fix was correct; none of them generalised.

We have added a cross-surface guard, and it is honestly limited: it enumerates forbidden phrasings, so it can only ever chase — the next drift picks a phrasing not on the list, and one surface is already unguarded for that reason. A guard that checked the property structurally would close the class: derive the surface population from the code that emits the action, or assert the claim against observable behaviour rather than against a string. We have not built that here, and we are not proposing the larger blockedPlan inversion in this PR either. Both are your call.

The remaining red check

The automated reviewer's latest finding is a sixth instance of the disclosure class, and we are listing rather than fixing it — deliberately, and we would rather say why than quietly leave it.

recoverFastForward's post-fast-forward refusal ("HEAD reached the preserved pipeline head, but a Git hook left the worktree non-clean; custody was not recorded") does not name the invoking-worktree recovery ref. Its sibling in recoverAdoptPreserved does. We checked the distinction that decides it: this refusal omits the ref, it does not deny having written one — it makes no claim about refs at all. The invariant that blocks merge is about refusals denying writes they made, and that invariant holds.

It is also the site that defeated derivation 4 above, which is the clearest illustration we have of why we stopped: the method could not see a refusal that skipped the two constructors it scanned.

Known limitations

  1. The cross-surface guard for the gate-move promise matches phrasings from a list, so it lags new wording — one surface is already unguarded for that reason. A guard that checked the property structurally would close that class: derive the surface population from the code that emits the action, or assert the claim against observable behaviour rather than against a string. We did build exactly that for one other claim: the "no files or refs were changed" guarantee cannot be judged from text at all, because the anchor is written only when it does not already exist, so the identical sentence is honest on a re-run and false on a first run. Its guard induces each refusal, reads whether the ref actually exists, and requires the message to agree in either direction. That is one claim, not the class — the phrase-list limitation above still stands.
  2. The --keep-local paragraph in docs/reference/cli.md has grown long and narrates per-site message rationale that the recoverKeepLocal, keepLocalPostSwapNoChangeClause and releaseStagingRef doc comments already own. Follow-up: trim it back to the operator contract.
  3. On the compare-and-swap success path a failed staging-ref deletion leaves refs/no-mistakes/custody-return/<run> behind without reporting it. Deliberate: the next attempt's fetch force-overwrites that ref and the kept head is reachable from the gate branch regardless, and the alternative made a successful recovery look failed.
  4. settlementAnchorsFree and selfInconsistentCustodyRecord were hardened to fail closed on an unreadable probe. Both were already protected by a caller-side ordering, so these are hardening rather than live-bug fixes — but a helper that is only safe because of an ordering it never states is how the original defect got in.

What we can claim, and what we could not

The merge-blocking invariant is closed and behaviourally tested: a refusal must not leave refs/no-mistakes/recover-gate/<run> behind while reporting that nothing changed. That is the defect you named, and it is proven by tests that fail when the write-ordering fix is reverted.

We tried three times to publish a stronger claim — that every refusal reachable after any recovery ref is written discloses it — and all three derivations were wrong. They are worth naming, because the progression says something the claim itself would not have:

  1. Line-scoped. Checked a range of Recover we had been editing. Missed recoverFastForward and recoverAdoptPreserved entirely — different functions, same reachable set.

  2. Flow-blind. Treated position in the file as reachability. Produced six false positives, because the anchorReachablePreserved write sits inside a block every path of which returns, so it can never fall through to the refusals below it.

  3. Ref-blind. Asked whether a disclosure was present rather than whether the ref written on that path was named. Passed three refusals that denied writing refs/no-mistakes/recover-local/<run> while disclosing a different ref.

  4. Constructor-blind. Scanned refusals built through blockedPlan and recoverBlocked. recoverFastForward's post-fast-forward refusal assigns state.Error directly, so the analysis was structurally incapable of seeing it — and that is the sixth instance, found after this criterion was already retired.

Each attempt was subtler than the last, and each was caught by review rather than by us. We have stopped attempting a fourth. The honest reading is that this property — every path discloses the particular ref that path wrote — is not closable by the inspection methods available to us, and that a constructive approach would have to handle per-path ref identity rather than mere presence of a disclosure.

That is offered as evidence for your structural note, not as a claim that the redesign is necessary. You wrote it on intuition; this is three dated failures of the alternative.

Verification## Verification

make lint, go test -race ./..., and make e2e all green at 69caed2e.

Most fixes here are proven by reverting them and watching a test fail. Two are not, and we would rather say so than leave a blanket claim: recoverFastForward's blocked_recover_apply_failed and recoverAdoptPreserved's "adopting the preserved pipeline head did not reach it" are verified by reading the code. Neither is portably inducible with the seams available — the untracked-file trick trips the earlier clean re-check, and chmod is a no-op on the Windows leg this package runs on. Both are disclosure-only and neither denies writing anything.

CI is green on this head. Thank you for approving the runs: build, test on ubuntu / macos / windows-core / windows-git, e2e, the generated-files guard and the docs check all pass — 8 passed, 1 skipped. The only remaining red is the automated reviewer, discussed below.

@greptile-apps

greptile-apps Bot commented Aug 28, 2026

Copy link
Copy Markdown

Confidence Score: 4/5

The PR is not yet safe to merge because a reachable partial default-recovery failure still hides the recovery anchor created by that attempt.

A successful recovery fast-forward followed by a hook-dirtied worktree refuses without recording custody, but its error omits the invoking-worktree recovery ref needed to reconcile that partial operation.

Files Needing Attention: internal/branchsync/sync.go, internal/branchsync/recover_test.go

Reviews (5): Last reviewed commit: "no-mistakes(document): generalize recove..." | Re-trigger Greptile

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Thanks for the careful write-up and the known-gaps section — that honesty helps.

Status on tip 26f02bac:

  • Merge state is CONFLICTING against current main (includes the AGENTS.md → .agents/skills move).
  • Fork CI workflows approved this pass; CI/docs were still running after approve. Require no-mistakes opened-event fail is ignored when that check is not in the main ruleset (live HEAD mergeability governs).
  • Greptile 5/5, but your own gaps (1) leftover refs/no-mistakes/recover-gate/<run> after a refusal that claims “no files or refs were changed”, and (2) TUI still dead-ending on blocked_recover_preserved_head_missing, are real merge blockers for a custody settlement path.

We will not rebase or resolve conflicts from here while those safety gaps remain — conflict resolution is only for PRs that are otherwise auto-merge-ready.

Please: rebase/resolve onto latest main, close gap (1) fail-closed (and preferably (2) so TUI has the same exit as CLI), then ping. Until then this stays waiting on you. Closes-ready-for-pr link to #824 is noted; #824 stays ready-for-pr.

VISION (pre-merge, current tip — not merge-ready):

  • R1 aligns in intent (settlement is an exit, not a skipped check).
  • R2 cannot tell until gap (1) is closed — a refusal that leaves the wedging ref behind can recreate the hole.
  • R3 aligns (--keep-local is explicit human choice).
  • R4 cannot tell (no review-role change).
  • R5 aligns for CLI next_action naming; TUI gap weakens operator-visible exit.
  • R6 aligns.
  • R7 aligns (local custody settlement).

hmcdaniel03 and others added 17 commits August 28, 2026 19:49
A terminal run whose recorded pipeline head can no longer be verified -
missing from every reachable object store, or contradicted by the run's
own recovery ref - left the branch pipeline_owned with no command that
could settle it: every guarded recovery refused on the same unverifiable
head, and abort of the already-terminal run was an idempotent no-op
(kunchenguid#824).

--recover --keep-local now settles that self-inconsistent record. It
stays fail-closed: every reachable copy of the recorded head is pinned
at refs/no-mistakes/recover-stranded/<run> first, and a head that still
exists but cannot be pinned refuses rather than being stranded. The gate
branch moves by the existing compare-and-swap, never a force, so a
concurrent gate push still wins.

Inspection names that exit as next_action.code: return_custody_keep_local
instead of the dead-end manual-reconciliation pointer, and terminal-run
abort responses carry the same command. The kunchenguid#814 polarity is preserved:
an unverifiable record never advertises recover_custody.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four defects in the kunchenguid#824 keep-local settlement, all in edge sub-shapes
rather than the primary flow:

- selfInconsistentCustodyRecord advertised the settlement for a terminal
  run whose head was never verified, but Recover refuses that at its
  unverified-head guard before any keep-local interception - recreating
  the same "advertised action that always refuses" wedge. That shape is
  kunchenguid#707's scope, so the predicate now excludes it and status keeps its
  honest manual-reconciliation pointer.
- A conflicting worktree recovery anchor on the locally reachable path
  refused without settlement while the predicate advertised it. It is
  now intercepted like the gate-side shapes; keeping a head that already
  contains the preserved commits can lose nothing.
- The settlement treated any gate-branch rev-parse error as "branch
  missing" and stamped custody returned without the compare-and-swap.
  Only a branch proven absent via ExactRefTarget settles without one; an
  unreadable branch refuses like the sibling keep-local sites.
- A refusal claimed "no files or refs were changed" after the pin loop
  may already have written the stranded anchor. It now names where that
  anchor exists.

Each fix has a regression test that fails without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…help

Three findings from the review of 391b3d7:

- selfInconsistentCustodyRecord still treated every incompatible gate
  recovery ref as settleable, but SYMBOLIC evidence is refused before any
  keep-local interception. A dangling symref is invisible to for-each-ref
  while symbolic-ref still succeeds, so the predicate said "incompatible"
  while Recover's own probe saw no ref and then failed inside
  PreserveRecoveryHead's symbolic check - an un-intercepted refusal. A
  resolving symref reaches the ordinary keep-local path and refuses there.
  Symbolic gate-anchor evidence now disqualifies the record outright,
  keeping the honest manual-reconciliation pointer.
- The bare-abort no-op emitted the branch's next action unconditionally,
  so on a released branch it answered an abort by prescribing `axi run` -
  telling the operator to launch a pipeline. Abort help is now allowlisted
  to custody-settlement actions via custodySettlementHelp, applied to all
  three abort sites so the --run siblings cannot drift into the same
  overreach. The structured branch_sync object still reports the branch's
  own next_action, which is state rather than a prescription.
- cli.md claimed every listed refusal was settleable with --keep-local;
  the shapes refused before an interception are not.

The predicate's invariant is now stated once: it may name the settlement
only where Recover(keepLocal) actually reaches it. Each fix has a
regression test that fails without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two review findings, both narrowly scoped:

- The pipeline_owned gate added to the bare-abort help was never applied
  to the two `axi abort --run <id>` sites. Their only guard was
  state.Pipeline.RunID != runID, which proves the branch resolves to that
  run, not that a run still holds it - so a released, diverged branch
  reached custodySettlementHelp and answered an abort with `git log`
  reconciliation advice the bare site had already been taught not to
  emit. Both conditions are now required, matching the bare site.

- settlementAnchorsFree predicts whether PreserveRecoveryAnchor can write
  an anchor, but that write refuses any symbolic ref first while the
  probe used ExactRefTarget alone. A dangling symref is invisible to
  for-each-ref yet visible to symbolic-ref, so the probe reported "free"
  for a ref the settlement can never write, and inspection advertised a
  settlement that refuses every time. The probe now mirrors
  recoveryAnchorCompatible, covering both the stranded and gate anchors.

Each fix has a regression test that fails without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the merge blockers the maintainer named on kunchenguid#883, plus the three
smaller published gaps.

recoverKeepLocal wrote refs/no-mistakes/recover-gate/<run> before four
refusals that each reported "no files or refs were changed" - and that
ref is exactly what wedges every later attempt, so those refusals could
recreate the condition this settlement exists to clear. The anchor guards
one operation, the compare-and-swap that moves the gate branch off the
displaced head, and nothing before that swap can strand it because
refs/heads/<branch> still names it. So the write moved to immediately
before the swap and every refusal that can precede it now leaves nothing
behind by construction rather than by a cleanup that could itself fail.
The read-only anchor conflict check stays first. One post-write refusal
remains, the lost swap, deliberately: it failed because the gate moved,
so that pin may be the only ref still naming the displaced head.

The TUI keyed its u action on blocked_pipeline_owned_recoverable while a
self-inconsistent record carries the settlement next action, so it was
the one operator surface with no exit. It now reaches the same settlement
as the CLI, keyed on the advertised next action so it cannot drift from
the predicate deciding where the settlement can complete, behind its own
confirmation because settling KEEPS the local head where recovery takes
the preserved one.

The settlement's data-safety argument is "nothing still has this head, so
nothing can be lost", which only a proven absence supports. Its probe read
any non-zero git exit as absent, so an unreadable store and a present
non-commit object both settled the record while the head was still there.
git.CommitPresence now treats only exit 1 as absence; everything else
refuses, and settlementAnchorsFree mirrors it so the advertisement agrees
with the write.

finishRecover's stamp failure runs after the Git side already succeeded
and nilled NextAction, leaving a moved gate branch with no named exit -
the same shape one layer down. It now reports that the changes are applied
and names the same recovery command, which completes because every Git
step it repeats is idempotent once applied.

Also: --keep-local help no longer claims the gate always compare-and-swaps,
since the equal/ahead, user_owned, already-recovered and absent-gate-branch
paths return first; and the two --run abort tests regain the fixture
invariants that prove they exercise the StatePipelineOwned clause - their
responses carry no branch_sync object, so they could not have failed even
with that clause deleted.

Refs kunchenguid#824.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAsKnxZJbLEiUKMcUtCL2U
hmcdaniel03 and others added 6 commits August 28, 2026 22:48
Corrective round over the settlement's message and probe honesty. Each
fix was proven by reverting it and watching its new test fail.

Two were more than wording. The compare-and-swap published the head the
gate-side fetch had copied without re-reading the branch, so a commit
landing in that window stamped custody at a head the worktree had already
moved past - the test shows the old code returning custody_returned for
exactly that. And a failed update-ref was reported as a concurrent gate
push, when a held ref lock, a permission problem or an I/O error fails it
identically; the gate head is now re-read and a race claimed only when it
actually differs, with blocked_recover_swap_failed for everything else.

The stamp-failure retry gets its own complete_custody_return code.
recover_custody and return_custody_keep_local are each a claim about the
record - that a preserved head is importable, or that the recorded head
can no longer be verified - and the skill, the CLI guidance, the docs and
the TUI confirmation all restate that claim in their own words. Neither is
true of a retry whose recovery already ran, and the ordinary keep-local
path reaches it with a fully verified head. Its message is scoped to "any
Git changes this recovery makes" because three callers arrive having
applied nothing.

The advertisement predicates no longer conflate an error with a benign
answer: an unreadable gate branch is not a proven-absent one, and the
self-inconsistency probe now uses the same tri-state CommitPresence the
pin loop does, so advertisement and write cannot disagree. Both were
masked by a caller-side ordering the helpers never stated, which is how
the original defect got in.

Staging-ref cleanup failures are surfaced instead of swallowed, since the
refusals around them deny leaving anything behind.

The fixture work matters more than any single fix. Two defects survived a
whole round because the fixtures encoded the same assumption the code did:
every pre-swap refusal test used a fixture whose recorded head exists in no
store, so the settlement pinned nothing and the anchor note was always
empty; every stamp-failure test had the gate genuinely moving, so no test
constructed a no-op recovery. Both paths now have fixtures. The four
--keep-local help surfaces are pinned by a test driving real --help output,
because the drift that missed one of them was an executable contract with
no executable check.

Refs kunchenguid#824.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAsKnxZJbLEiUKMcUtCL2U
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

The two merge blockers on the previous tip are closed on 45a2a79b:

  1. recoverKeepLocal now writes refs/no-mistakes/recover-gate/<run> immediately before the CAS, so pre-swap refusals cannot leave that wedging ref. No-change claims name any caller-written stranded/recover anchor.
  2. TUI u reaches the same return_custody_keep_local settlement as the CLI, keyed on the advertised next action, with its own confirmation (not the recover confirmation).

Also inspected: git.CommitPresence treats only cat-file -e exit 1 as absence; finishRecover names a retry instead of nilling NextAction after the Git side already succeeded.

Fork CI approved this pass (runs 33228552615 / 33228552655 / 33228552712). Waiting on green CI. Not waiting on you for more product work unless CI fails.

contract-class: restore (documented --keep-local settlement; default --recover unchanged)

VISION (tip 45a2a79b):

  • R1 aligns (settlement is an exit, not a skipped check)
  • R2 aligns (pin surviving heads first, CAS not force, CommitPresence fail-closed, recover-gate only at swap)
  • R3 aligns (--keep-local / TUI confirm is explicit human choice)
  • R4 cannot tell (no review-role change)
  • R5 aligns (honest next_action; stamp-failure names a completable retry)
  • R6 aligns
  • R7 aligns (local custody settlement)

Comment thread internal/branchsync/sync.go Outdated
hmcdaniel03 and others added 4 commits August 29, 2026 01:40
An automated review caught this on the one path this branch had
deliberately scoped out. Before its own refusals are reached, the default
--recover flow fetches the preserved head and writes
refs/no-mistakes/recover/<run> into the invoking worktree - and three
refusals then reported "no files or refs were changed". The keep-local
siblings twenty lines away already appended the anchor note; these did
not, so the claim was true only where we had looked.

The three are the behind-and-dirty refusal, the diverged-but-contained
dirty refusal, and the genuinely diverged refusal. The last was the
clearest case: it named the anchor and denied writing any ref in the same
sentence.

Each now closes with the existing keepLocalNoChangeClause and the
recovery anchor note, so the sentence the keep-local path already used is
reused rather than reinvented. No behaviour changes: same refusals, same
next actions, same refs written. Only the wording stops contradicting
what the attempt did.

The guard is behavioural rather than textual, because this claim cannot be
judged from text. Whether it is true depends on whether the anchor was
written, which happens only when it did not already exist - so the same
sentence is honest on a re-run and false on a first run. The test induces
each refusal, reads whether the ref actually exists, and requires the
message to agree in either direction: the plain claim when nothing was
written, and the anchor named when something was.

Refs kunchenguid#824.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAsKnxZJbLEiUKMcUtCL2U
Comment thread internal/branchsync/sync.go
hmcdaniel03 and others added 3 commits August 29, 2026 03:09
The automated reviewer found four refusals that drop the anchor note:
the strict-fast-forward failure, and three in the adoption path. None of
them denied writing anything, so this was a disclosure gap rather than a
false claim and the invariant this branch establishes still held. They
disclose anyway, because the operator is left with a partly applied
recovery to reconcile by hand, the preserved head is sitting at exactly
the ref that reconciliation needs, and the sibling refusal ten lines above
already named it. A function that discloses in one arm and stays silent in
the next is not worth defending.

The completion criterion is re-derived over the REACHABLE SET rather than
over a line range, because the previous one was scoped to the region being
edited and these four sat outside it. Starting from every site that writes
a recovery ref and walking the call closure from Recover: 23 functions are
reachable, 5 of them write a recovery ref, and every refusal in that
closure that can be reached after a write now either discloses the anchor
or is wrapped so its claim stays true.

The refusals that still make the bare no-change claim are all on paths
where nothing has been written yet: the anchorReachablePreserved write
sits inside a block every path of which returns, so it cannot fall through
to them, and the gate and worktree writes come later in the function.

Refs kunchenguid#824.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAsKnxZJbLEiUKMcUtCL2U
// needs, and the sibling refusal in the same function already discloses it. A
// function that disclosed in one arm and stayed silent in the next is not a
// defensible resting place.
func (s *Service) recoverFastForward(ctx context.Context, run *db.Run, state State, preserved string, anchoredNote recoveryAnchorNote) State {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Fast-forward refusal hides anchor

When default recovery successfully fast-forwards to the preserved head but a Git hook leaves the worktree non-clean, this branch returns a fixed error without appending anchoredNote.clause(). The operator is told custody was not recorded but is not shown the refs/no-mistakes/recover/<run> anchor created by the same attempt, leaving the partial recovery without the ref location needed for manual reconciliation.

Knowledge Base Used: Git workspaces and branch synchronization

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Tip moved to 69caed2e after the prior waiting-ci stamp on 45a2a79b. Prior product gaps (recover-gate pre-swap stranding, TUI dead-end) stay closed, and earlier Greptile honesty P1s on dirty/diverged/FF-fail paths look addressed.

New Greptile P1 on this tip is real and blocking: after a successful recoverFastForward merge, when a Git hook leaves the worktree non-clean, the refusal at ~L1242 sets a fixed Error without appending anchoredNote.clause(), so the operator is not shown the refs/no-mistakes/recover/<run> anchor created by the same attempt. Please disclose that anchor the same way the FF-fail arm already does, then ping.

Fork CI is approved and still running on this tip (check/docs/Guard green so far; tests/e2e pending). Not merging until the new P1 is closed and CI is green. Waiting on you for the disclosure fix.

contract-class: restore (documented --keep-local settlement of self-inconsistent terminal custody; default --recover unchanged)

VISION (tip 69caed2e):

  • R1 aligns (settlement is an exit, not a skipped check)
  • R2 cannot tell until the post-FF dirty refusal names the anchor it wrote
  • R3 aligns (--keep-local / TUI confirm is explicit human choice)
  • R4 cannot tell (no review-role change)
  • R5 cannot tell until that refusal is honest about the written ref
  • R6 aligns
  • R7 aligns (local custody settlement)

Sign up for free to 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.

Custody record with disagreeing gate vs preserved head cannot be recovered or aborted

2 participants