feat(resume): the section index gets its first caller β at the moment a probe is about to be re-run - #1295
Merged
Merged
Conversation
β¦ a probe is about to be re-run `scripts/lib/handoff_search.py` shipped in #1209 and NOTHING called it: `git grep handoff_search` found the module, its sibling writer, a README row and a shebang test. An index with no reader is a cost with no benefit, and it fails silently β every `/resume` went on re-deriving findings the corpus already held. WHERE, AND WHY THIS SITE `/resume` step 3, immediately beside the existing per-item pre-flight (`git -C <repo> log --since=<doc-date>`), not step 4's `cairn recall` block. * The value is the `Ruled out:` bullets, and they only discriminate against a QUESTION. At orientation time you have a topic slug, which mostly retrieves the doc you are already reading. At step 3 you have the open item in hand, so the cross-doc hits are the ones that stop a re-run. * Step 4 is a different corpus (the subsystem-index store), not this one. * The adjacent command already carries exactly this rhetoric β "the cost is one command; the cost of skipping it is a whole session". NOT the `/handoff` "Run this first" template: `test_handoff_skill_size.py` leaves ~291 B under its enforced floor, so the line would have cost an eviction of an instruction in the same commit β and a template only reaches docs written AFTER it, while this tool's whole value is retrieval over the 379 docs that already exist. NOT a wrapper script either: the CLI is already the right shape and already renders the banner, the `indexed_docs=` scope line and the five zero-causes; a wrapper would have to re-render or swallow them, and swallowing is the failure the tool was built to prevent. `--offline` deliberately: it answers from git refs with no database. The Postgres path has never been executed by any test and the sync unit ships disarmed (`enableHandoffIndexSync = false`), so wiring the consumer to it would have made the step a connection error. WHAT THE GUARD PINS (scripts/tests/test_resume_handoff_search_wiring.py) Three claims, each DERIVED from the tool rather than restated: * the command as a WHOLE NORMALISED STRING β an edit dropping `--offline` goes red instead of silently starting to require a database; * the SEAM β every flag the skill prescribes is probed behaviourally against `handoff_search.main()`, so a rename in the tool breaks the test rather than leaving the skill prescribing a dead command; * the silent-zero contract β the non-answer exit codes are read out of `EXIT_CODES` / `SCOPE_REASON_EXIT_CODES`, so carving out a sixth zero goes red until `/resume` is taught about it. Every contract assertion runs against a slice delimited by two pinned sentinels, so a hit elsewhere in the 40 KB body cannot satisfy it. RED -> GREEN, base d86b4e4: 3 failed / 3 passed before the skill edit; 6 passed after. MUTATION SWEEP β fresh minimal repo mirror per mutant, PYTHONDONTWRITEBYTECODE=1, 7 mutants, all killed by the SPECIFIC guard whose claim each violates: M1 skill drops --offline -> the shape pin M2 skill drops exit code 7 -> the derived ledger M3 skill drops POINTER TO VERIFY -> the posture check M4 whole block deleted -> 3 guards, incl. the sentinel M5 tool renames --offline -> the seam probe M6 tool adds a 6th non-answer code -> the derived ledger M7 tool stops printing indexed_docs= -> the posture check Controls: an unmutated mirror and a whitespace-only skill edit both green. The first build of that harness reported 8 kills off a RED no-op control β the mirror omitted a transitive import (`handoff_doc`), so every "kill" was a fact about the harness. Recorded because that is the whole reason the control exists. BYTES: claude/skills/resume/SKILL.md 40,581 -> 41,852 (+1,271). That file has no enforced ceiling (`git grep -l MIN_HEADROOM_BYTES scripts/`). ~40% of the delta is the four-code non-answer ledger, which is the honesty half of the wiring: a consumer that prints the hits and drops the distinction hands the reader "the corpus is silent" when the truth was "the index is empty". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session-Id: 90310ba1-6dc2-4779-a616-2ccfe89457d7
β¦earch-consumer Claude-Session-Id: 90310ba1-6dc2-4779-a616-2ccfe89457d7
ZacxDev
added a commit
that referenced
this pull request
Sep 6, 2026
π‘ THE DERIVED SCOPE PIN WAS DECORATION FOR ONE OF ITS FOUR ARMS. It searched the whole wiring block, and EXPECTED_COMMAND contains `~/workspace/devrc/scripts/lib/handoff_search.py`, so `"devrc" in block` was guaranteed true on every green run. Round 2's sweep picked DATAPACKET β a fixture that could only die β and read the kill as proof the pin worked. That is exactly RULES.md's "prove it REACHABLE, not merely breakable" and "pick fixtures distinct from any constant the assertion already names". Narrowing to the warning PARAGRAPH was NOT sufficient, and measuring said so: the paragraph itself says "a devrc-topic query" and "above the devrc ones", so dropping `devrc` from the enumerated list still left 8 passed. Fixed by matching BACKTICKED tokens β only the list is a code span. Now measured: control 8 passed Β· M6-devrc -> 1 failed, ['DEVRC'] Β· M7 (handle deleted from the list but mentioned in an unrelated sentence) -> 1 failed, ['CIVITAI'] Β· M5 and M1 still die with their own messages. All four arms are now reachable; before this, two of four were not. π‘ The `Claim:` line asserted a release that has not happened and dropped the command that performs it β wider on one axis, narrower on the other. Restored as STILL HELD with the explicit `claim-work --release`, and why an unreleased claim blocks the next rank 1 under a different identity. π‘ A THIRD site still said to build the fix: Open investigations -> Next probe told the reader to "promote the query to its own numbered step". Rounds 1-2 fixed State now and rank 1 and left this one, so the doc contradicted itself at the exact section /resume step 3 tells a session to read. Reworded to say the promotion landed and only the re-run remains. π’ The correction paragraph's own number was wrong: "~1.9 KB" was one commit's delta, not the PR's. Measured +3,688 B (41,852 -> 45,540, git cat-file -s at base 10d437c and head 00e803a) and labelled as a correction of a correction. π’ Rank 1 now says to raise check 1's CUT to #1332's merge time before re-running; left at #1295's, the 14 pre-fix runs stay in the denominator and a fully successful fix reports ~10/24 and reads as a failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7eZPAdX7nAeamShBnYNg2 Claude-Session-Id: 1de26f50-0e47-4964-b6ff-b87bea89b1fb
ZacxDev
added a commit
that referenced
this pull request
Sep 6, 2026
π‘ THE DERIVED SCOPE PIN WAS DECORATION FOR ONE OF ITS FOUR ARMS. It searched the whole wiring block, and EXPECTED_COMMAND contains `~/workspace/devrc/scripts/lib/handoff_search.py`, so `"devrc" in block` was guaranteed true on every green run. Round 2's sweep picked DATAPACKET β a fixture that could only die β and read the kill as proof the pin worked. That is exactly RULES.md's "prove it REACHABLE, not merely breakable" and "pick fixtures distinct from any constant the assertion already names". Narrowing to the warning PARAGRAPH was NOT sufficient, and measuring said so: the paragraph itself says "a devrc-topic query" and "above the devrc ones", so dropping `devrc` from the enumerated list still left 8 passed. Fixed by matching BACKTICKED tokens β only the list is a code span. Now measured: control 8 passed Β· M6-devrc -> 1 failed, ['DEVRC'] Β· M7 (handle deleted from the list but mentioned in an unrelated sentence) -> 1 failed, ['CIVITAI'] Β· M5 and M1 still die with their own messages. All four arms are now reachable; before this, two of four were not. π‘ The `Claim:` line asserted a release that has not happened and dropped the command that performs it β wider on one axis, narrower on the other. Restored as STILL HELD with the explicit `claim-work --release`, and why an unreleased claim blocks the next rank 1 under a different identity. π‘ A THIRD site still said to build the fix: Open investigations -> Next probe told the reader to "promote the query to its own numbered step". Rounds 1-2 fixed State now and rank 1 and left this one, so the doc contradicted itself at the exact section /resume step 3 tells a session to read. Reworded to say the promotion landed and only the re-run remains. π’ The correction paragraph's own number was wrong: "~1.9 KB" was one commit's delta, not the PR's. Measured +3,688 B (41,852 -> 45,540, git cat-file -s at base 10d437c and head 00e803a) and labelled as a correction of a correction. π’ Rank 1 now says to raise check 1's CUT to #1332's merge time before re-running; left at #1295's, the 14 pre-fix runs stay in the denominator and a fully successful fix reports ~10/24 and reads as a failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7eZPAdX7nAeamShBnYNg2 Claude-Session-Id: 1de26f50-0e47-4964-b6ff-b87bea89b1fb
ZacxDev
added a commit
that referenced
this pull request
Sep 6, 2026
β¦ it was (#1332) * feat(resume): move the corpus query into step 4 β measured 1/14 where it was The handoff section index shipped with exactly one consumer, a conditional buried in step 3's prose ("before working any open item..."). Over its first 34 h that consumer was invoked by 1 of 14 /resume runs across both hosts, and the one that fired was reading origin/main because the SKILL block told it its copy was stale β not because the step fired. Yield: 0. The non-firing was NOT correct restraint. All six workbench non-firing runs ran claim-work and five made edits, and five of six resumed a doc carrying an ## Open investigations section, so the trigger was met every time. DISCRIMINATOR β placement, not the tool, its cost, or how loudly it is marked: step 3's sibling check (git log --since; same trigger, same block, an ordinary command) fired 0/6, while step 4 (cairn recall; numbered, unconditional, fenced) fired 5/6. Two co-located conditionals at 0, one unconditional numbered step next door at 5. So the query becomes step 4's second command, keyed on the handoff's TOPIC rather than an open item β every resume has a topic, and keying on an item is the conditional this removes. It is NOT a new numbered step: ~15 prose cross-references across skills and scripts pin "step 4" (cairn) and "step 6" (claim-work), none machine-checked, and a renumber would falsify all of them. The guard is STRUCTURAL, not spelled: a grep for "unconditional" passes on a body that says the word and still gates the command. test_the_query_is_ UNCONDITIONAL__it_shares_a_numbered_step_with_cairn_recall asserts WHICH numbered step the command physically sits in. Watched to fail two ways: red at origin/main via the count arm, and an isolated mutant (query moved back into step 3, wording untouched) fires the step-identity arm with its own message. RESIDUAL, stated in the skill and the test: the 5/6 was measured with ONE command in that step. Co-locating a second is a prediction, not a measurement β re-measure with check 1 of the handoff's "How to verify". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7eZPAdX7nAeamShBnYNg2 Claude-Session-Id: 1de26f50-0e47-4964-b6ff-b87bea89b1fb * fix(resume): audit round 1 β widen the guard, and correct four false claims Round 1 of /audit-pr on #1332 returned six π‘, no π΄. Every one is fixed here. F1 (guard narrower than its name) β the test claimed to prove the command is UNCONDITIONAL and asserted only a shared step DIGIT. The audit built four isolated mutants; three survived a fully green suite. Renamed to what it checks, and widened: it now compares the FENCE, which kills M4 (a second list item re-labelled `4.`, gated, in its own block) with its own message. M2 and M3 β a gating sentence above the fence, a conditional comment inside it β STILL SURVIVE and are now named in the docstring as uncovered. They are prose-shaped; no structural check here sees them, and saying so is better than a keyword guard that would read as coverage. F2 (byte claim wrong by ~10x) β "a few hundred bytes" replaced with the measured figures: 3,941 B and 3,999 B for two real --limit 3 queries, 1,251 B for a no-match, which is the floor because the banner prints either way. Re-measured independently before changing it. F3 (the conditional came back) β the step-3 replacement sentence re-created an open-item-keyed instruction inside the block this PR measured at 0/6, told the reader to "re-run" something not yet run, was invisible from step 4, and dropped the tool's PURPOSE. Step 3 is now a pointer with no action; the item-level re-run and the "Ruled out: bullets stop you re-running a probe someone already ran" rationale both moved into step 4. F4 (asymmetric scope, unstated) β `cairn recall --repo` is repo-scoped while the query searches all four repos, two of them client repos, and routinely returns client sections above devrc ones. The step now says so and tells the reader to check each hit's repo before reusing it. Not narrowed to --repo: cross-repo reach is the point. F5 (report step named one surface) β step 5 said `from index`; handoff_search prints `from handoff docs`. Verified both banners by running them. Step 5 now carries each surface's own label. F6 (dangling referent) β "that doc's How to verify" had no antecedent and the nearest one was the wrong document. Named explicitly. F7 (denominators did not reconcile) β 8 - 1 = 7, not 6. The missing run is the measuring session itself, excluded as the instrument; that was true and never stated. Now stated in the skill, the test docstring and the handoff doc, with an as-of timestamp, since the corpus grows and a later re-run reports larger numbers rather than contradicting these. Also fixed the nit: a docstring said the recall posture sits "four steps earlier", which was wrong before this PR (cairn was one step LATER) and wrong after it (co-located). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7eZPAdX7nAeamShBnYNg2 Claude-Session-Id: 1de26f50-0e47-4964-b6ff-b87bea89b1fb * fix(resume): audit round 2 β the doc still said this fix did not exist Round 2 verified all eight of round 1's claims actually fixed, and found two π‘ plus one π’. All three fixed here. π‘ the handoff doc's STATUS contradicted the PR shipping it. `State now` said "No fix is built; awaits operator direction", and ranked next-step 1 still described this very change as pending. Both mitigations that made it survivable (the held claim, the open PR visible to step 6's sweep) end AT MERGE β so a /resume on that doc would have read "no fix is built" and re-implemented a merged change, which is precisely the re-derivation loop this PR exists to break. Status and rank 1 rewritten: rank 1 is now RE-MEASURE ADOPTION, which is the thing that is actually still open. π΄ That item also carried a FALSE claim I wrote and the audit caught: "SKILL.md is byte-capped, so promoting text means evicting text". It is not. The caps cover browser, handoff, prune-skill and RULES.md; this PR's own test says so. Corrected in place and labelled as a correction, not silently deleted. π‘ M5 β a fifth mutant the round-1 residual list did not anticipate: the command left in the fence but COMMENTED OUT. Every check in this module is a substring test, and `# python3 ...handoff_search.py ...` contains the pinned string, so the string pin, the step check and the fence check ALL went green over dead wiring β this module's founding failure, reachable by one character. Now killed by requiring the fence line to be live. The docstring no longer presents the mutant list as closed: M5 was found after round 1 called the residual settled. π’ the scope warning restated `handoff_index.REPO_ENV_HANDLES` membership instead of deriving it, in a module whose docstring says every claim is derived from the tool. Now pinned against the module. Scope of that pin stated: it checks the LABELS, not the "two of which are client repos" count, which is a human judgement no module holds. Mutants re-run under PYTHONDONTWRITEBYTECODE=1: control 8 passed; M5 killed by the live-line assert with its own message; M6 (drop a handle from the warning) killed by the derived pin, naming DATAPACKET. M2 and M3 still survive and are still named as uncovered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7eZPAdX7nAeamShBnYNg2 Claude-Session-Id: 1de26f50-0e47-4964-b6ff-b87bea89b1fb * fix(resume): audit round 3 β my own guard had an arm that could not fail π‘ THE DERIVED SCOPE PIN WAS DECORATION FOR ONE OF ITS FOUR ARMS. It searched the whole wiring block, and EXPECTED_COMMAND contains `~/workspace/devrc/scripts/lib/handoff_search.py`, so `"devrc" in block` was guaranteed true on every green run. Round 2's sweep picked DATAPACKET β a fixture that could only die β and read the kill as proof the pin worked. That is exactly RULES.md's "prove it REACHABLE, not merely breakable" and "pick fixtures distinct from any constant the assertion already names". Narrowing to the warning PARAGRAPH was NOT sufficient, and measuring said so: the paragraph itself says "a devrc-topic query" and "above the devrc ones", so dropping `devrc` from the enumerated list still left 8 passed. Fixed by matching BACKTICKED tokens β only the list is a code span. Now measured: control 8 passed Β· M6-devrc -> 1 failed, ['DEVRC'] Β· M7 (handle deleted from the list but mentioned in an unrelated sentence) -> 1 failed, ['CIVITAI'] Β· M5 and M1 still die with their own messages. All four arms are now reachable; before this, two of four were not. π‘ The `Claim:` line asserted a release that has not happened and dropped the command that performs it β wider on one axis, narrower on the other. Restored as STILL HELD with the explicit `claim-work --release`, and why an unreleased claim blocks the next rank 1 under a different identity. π‘ A THIRD site still said to build the fix: Open investigations -> Next probe told the reader to "promote the query to its own numbered step". Rounds 1-2 fixed State now and rank 1 and left this one, so the doc contradicted itself at the exact section /resume step 3 tells a session to read. Reworded to say the promotion landed and only the re-run remains. π’ The correction paragraph's own number was wrong: "~1.9 KB" was one commit's delta, not the PR's. Measured +3,688 B (41,852 -> 45,540, git cat-file -s at base 10d437c and head 00e803a) and labelled as a correction of a correction. π’ Rank 1 now says to raise check 1's CUT to #1332's merge time before re-running; left at #1295's, the 14 pre-fix runs stay in the denominator and a fully successful fix reports ~10/24 and reads as a failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7eZPAdX7nAeamShBnYNg2 Claude-Session-Id: 1de26f50-0e47-4964-b6ff-b87bea89b1fb --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the last open item of the handoff-search-index effort
(
claudedocs/handoff-handoff-search-index.md, ranked next-step 4): the indexshipped in #1209 and nothing called it.
git grep handoff_searchfound themodule, its sibling writer, a
scripts/README.mdrow and a shebang test β zeroreal callers. An index with no reader is a cost with no benefit, and it fails
silently: every
/resumewent on re-deriving findings the corpus already held.Where it is wired, and why this site
claude/skills/resume/SKILL.mdstep 3, immediately beside the existingper-item pre-flight (
git -C <repo> log --since=<doc-date> β¦).The value proposition is the
Ruled out:bullets inside## Open investigationsblocks β the content that stops the next session re-running aprobe someone already ran. Those only discriminate against a question:
mostly retrieves the doc you are already about to read. At step 3 you have
the open item in hand, so the cross-doc hits are the ones that change what you
do.
cairn recall. It is not this one.command; the cost of skipping it is a whole session." The new block is that
sentence's sibling, not a new section.
Sites considered and rejected
The
/handoff"Run this first" template. Two reasons, either sufficient:scripts/tests/test_handoff_skill_size.pyleaves roughly 291 B under itsenforced floor, so a line there costs an eviction of an instruction in the
same commit β and that test's own playbook records that trimming imperative
prose for bytes is how an instruction gets lost. Second, a template only reaches
docs written after it, while this tool's entire value is retrieval over the
~379 docs that already exist.
A thin wrapper script. It adds indirection with no capability. The CLI is
already the right shape and already renders the recall banner, the
indexed_docs=N indexed_sections=Mscope line and the five distinctzero-causes. A wrapper would have to re-render or swallow them β and swallowing
is precisely the failure the tool was built to prevent.
--offline, deliberatelyIt answers from git refs with no database. The Postgres path has never been
executed by any test (the handoff doc says so in its own words), and the sync
unit ships disarmed (
enableHandoffIndexSync = false). Wiring the consumer tothe DB path would have made the step a connection error on first use.
Honest degradation is in the block, not assumed
The block keeps every distinction the tool makes, because a consumer that drops
them reintroduces the silent zero:
reading, and may describe a gotcha already fixed;
indexed_docs=N indexed_sections=M;empty scope Β· 6 unmeasurable corpus Β· 7 repos resolved, zero docs derived),
and that only
NO MATCHat rc 0 means the corpus was asked and is silent;unavailable, and carry on with the item.
The guard (
scripts/tests/test_resume_handoff_search_wiring.py)Three claims, each derived from the tool rather than restated:
dropping
--offlinegoes red instead of silently starting to require adatabase;
against
handoff_search.main(), so a rename in the tool breaks the testrather than leaving the skill prescribing a dead command (with a negative
control asserting an unknown flag still
SystemExits);EXIT_CODES/SCOPE_REASON_EXIT_CODES, so carving out a sixth zero goesred until
/resumeis taught about it.Every contract assertion runs against a slice delimited by two pinned sentinels,
so a hit elsewhere in the 40 KB body cannot satisfy it β this file already
discusses "exit code 4" twice, for
cairnandsubsystem_recall.py, in acompletely different sense.
Red β green
Base
d86b4e45: 3 failed / 3 passed before the skill edit, 6 passedafter. The three reds are the wiring and contract assertions; the three that
passed at base are the guard-the-guard, the seam probe (tool-only) and the
sentinel control.
Mutation sweep
Fresh minimal repo mirror per mutant,
PYTHONDONTWRITEBYTECODE=1, narrowestexpression mutated. 7/7 killed, each by the specific guard whose claim it
violates:
--offlinePOINTER TO VERIFY--offlineindexed_docs=Controls: an unmutated mirror and a whitespace-only skill edit are both
green, so the kills are facts about the mutations and not about "the file
changed".
β The first build of that harness reported 8 kills off a RED no-op control β
the mirror omitted a transitive import (
handoff_doc), so every "kill" was afact about the harness. Recorded because that is the entire reason the control
exists.
Bytes
claude/skills/resume/SKILL.md40,581 β 41,852 (+1,271). That file has noenforced ceiling (
git grep -l MIN_HEADROOM_BYTES scripts/β RULES.md,browser-bridge, prune-skill and handoff have one; resume does not). ~40% of the
delta is the four-code non-answer ledger, which is the honesty half of the
wiring.
Not done here
The handoff doc's rank 4 is not struck through and rank 1 (arming the live
Postgres path) is untouched β that step cannot run in any sandbox and is
somebody's supervised operator act.
Gate β run on the MERGED tree
a6fdcc87(my commit6af2ac49+origin/main5793c0e5)Branch protection is off, so these are the only evidence there is. Both tiers,
named with the base sha, and the two nix derivations built one at a time.
Dev-host tier β
nix develop β¦ -c bash scripts/gate.sh --tier both(--set hermetic):Exactly one failing test, and it is environmental β not this diff:
π΄ Discriminating control run, not asserted.
/tmp/body.mdexists on thisbox β 21,181 B, mtime Sep 3 19:41, content about a mute-review queue, written by
a sibling agent. The guard reads the real file. Exporting pristine
origin/main5793c0e5(git archive | tar -x, none of my changes) andrunning that one file reproduces the identical failure:
1 failed, 309 passed.My diff touches
claude/skills/resume/SKILL.mdand one new file underscripts/tests/; it cannot reachscripts/claude-hooks/.Sandbox tier β the one Tekton runs, built from a
cp -r ${./.}store copy,one derivation at a time after waiting out three concurrent sibling
nix builds:π΄ The sandbox is the cross-check that settles it: the clawgate guard test
passes there, because the build sandbox has its own
/tmpand no sibling'sbody.mdin it. Same tree, same test, opposite result β that is the mechanism,measured, rather than a plausible story about load.
β
origin/mainhas moved to2b1d3552since this was gated.strictis falseon this repo by design, so re-gate the merged tree if it has drifted materially
before merging.