Skip to content

Let the target decide when a bootstrap checkpoint cannot be split further - #118

Merged
nodo merged 8 commits into
mainfrom
nodo/ent-2060-bottom-out-server-verdict
Sep 1, 2026
Merged

Let the target decide when a bootstrap checkpoint cannot be split further#118
nodo merged 8 commits into
mainfrom
nodo/ent-2060-bottom-out-server-verdict

Conversation

@nodo

@nodo nodo commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #116 / ENT-2054, fixing the failure that landing #116 exposed in production. Tracked in ENT-2060.

Problem

Batched bootstrap pushes history in chunks split between commits. Once every gap is one commit there is nothing left to split, and a single commit whose pack exceeded the budget failed the whole run.

The budget it failed against was our own, and 20× stricter than the target's. TargetMaxPack defaults to 512 MiB; autoTargetMaxPackBytes derives 5 GiB from the target's announced 10 GiB limit and then discards it for being larger than that default. The observer then aborts at 95% of the 512 MiB. So the run gave up on a pack the target would likely have accepted, having never asked it.

Seen in production on a >10 GiB mirror once #116 got it onto the bootstrap route:

target rejected pack — switching to batched mode (limit 512 MB)
projected to exceed target limit (512 MB) — splitting 1 → 3 packs
Sync failed: push bootstrap batch for refs/heads/main:
             pack upload aborted early: projected to exceed target body limit

Zero batches landed, and every redelivery repeated it — a full multi-GiB source fetch each time.

Solution

The abort budget for a span you cannot split is the target's number, not ours.

The small budget stays for planning: it bounds the waste of a doomed push and makes the temp ref advance often — both of which need a smaller pack to be possible. On a one-commit gap neither is, so that push uses the target's announced limit instead, decided before the push rather than after a doomed attempt.

This costs no extra bytes — a ceiling is an abort threshold, not an upload size, so a pack that fits sends the same bytes either way — and it avoids fetching an indivisible multi-GiB commit twice. At that ceiling there is no 95% margin and no projection: an early cut would invent a rejection the target never issued.

That turns the failure into something classifiable:

Outcome Classification
Abort against our budget, no word from the target retryable — a larger budget or raised server limit could still work
Target refused it, or it overshot the target's own announced limit permanent: ErrCheckpointExceedsTargetLimit
Deadline (408/504) retryable — availability, not size; with zero bytes sent there is no size evidence

The sentinel is aliased into the root package so the mirror worker can match it with errors.Is and stop redelivering an identical pack ten times. It is also the evidence that would justify object-level splitting — the only remedy left for a genuinely oversized commit, and something nobody should design until a repo proves it is in that state.

Two guards keep this honest:

  • A measured cutoff is never escalated past. If the budget was inferred from bytes actually sent — a middlebox that cuts without announcing — that is evidence about size, and the ceiling stays put.
  • A deadline decides nothing, in either direction. A target that drained the body and then timed out told us about time, not size, so it neither claims that provenance nor erases it. Claiming it would disable escalation for the rest of the run; erasing it would let escalation jump past a demonstrated cutoff, where an abort is classified permanent. The rule is one named function, nextBudgetProvenance, because both directions are costly and easy to conflate.

Subdividing only happens when the current span can shrink. subdivideToFactor and subdivideCheckpoints split every remaining gap, so a splittable gap later in the branch used to grow the checkpoint list even for a one-commit span — and the retry then re-fetched and re-pushed a byte-identical pack, once per later split. Both the post-failure path and the pre-flight estimate now decline instead. The pre-flight declines rather than being skipped, so the pack header is still parsed and object_count stays real in the logs an operator reads when a giant commit stalls.

Also here

bootstrapWithInputs returned Result{} on error, discarding RelayMode, RelayReason, batch counts and temp refs. Those describe the route, not the outcome, and are set before anything can fail — zeroing them is why a failed sync could report its strategy only when it succeeded, the gap that made ENT-2054 a source read instead of a log query. Batching is now recorded before checkpoint planning (whose commit-graph fetch is the likeliest failure for exactly the repos that batch), and unstable.Client.Bootstrap no longer throws the result away either.

Scope worth stating: this covers the bootstrap route only. A failed incremental or materialized relay still reports an empty transfer mode — incremental.Execute returns a zero result on every error path, and the syncer returns before copying its relay facts — and most other syncer error paths still return a zero result. Same blind spot, one strategy over; it needs its own tests and is filed rather than bundled here.

Tests

Each mutation-verified to fail when the behaviour it names is removed:

  • a divisible span still aborts at the small budget — pins that only unsplittable checkpoints escalate
  • an indivisible one converges at the announced ceiling, in one push
  • no safety margin at that ceiling: a pack in the top 5% must not be cut
  • the >= boundary, where an in-batching rejection leaves budget and announced limit equal and escalating still sheds the margin
  • a measured cutoff blocks escalation; a deadline neither claims nor erases one (table test over all four cases)
  • an indivisible span is not re-pushed while later gaps split, post-failure and in the pre-flight (asserted by source-fetch count: 9 vs 11)
  • a deadline stays retryable; a hard rejection is permanent immediately; unrelated errors (401/500/reset/hook decline) stay retryable
  • both AnnouncedTargetLimit capture sites — the feature's only production sources, since nothing configures it
  • a failed batched run still reports its route

Two fixtures aimed at the provenance guard through Execute turned out vacuous — the runs never reached an escalation decision, so they passed with the guard removed. They were deleted rather than kept, and the rule was extracted into a function so it could be tested directly.

Rollout — please read before merging

  1. The worker-side change must ship in the same release as the git-sync pin bump. Without it the permanent case still redelivers ten times, now paying a source re-fetch per attempt. It is pushed on nodo/ent-2060-worker-permanent-checkpoint in mirror-pipeline — sentinel classification, failure-route reporting on spans and logs, and the pin — currently pinned at this branch's head and needing a re-point at the merge commit.
  2. Needs an entiredb owner's sign-off: this sends a multi-GiB receive-pack POST from inside batched mode. Previously only the one-shot path approached the cap, and the runbook documents a push rejected there leaving "refs present, 0 objects".
  3. Scoped to entiredb→entiredb until the preflight-auth fix lands. lookupGitHubRepoSizeKB sends its api.github.com request unauthenticated, so it 404s on private repos; a GitHub-kind repo entering batching via that preflight never sees a rejection, so never learns an announced limit.

Notes

  • Rebased onto main after Keep the batched cutover's resume marker when the target refuses the branch create #117 and squashed: the five original commits were an iterative refinement of the same lines, and resolving their conflicts one at a time against the moved main produced worse results than resolving the net change once. Soph's cutover tests pass alongside these.
  • Whether the repo that motivated this converges depends on whether its one oversized commit fits under 10 GiB. This change is what will tell us, instead of failing against a number we picked. That placement is suspended meanwhile.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AriFousNMM5gxkW7Akq1SW

Comment thread internal/strategy/bootstrap/bootstrap.go
@nodo

nodo commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

All three blockers were real and are fixed in 703d9da. Each now has a regression test that I confirmed fails with the bug reintroduced.

1 — sentinel fired on unrelated errors. Correct and embarrassing: the gate sat outside the size-failure branch and keyed off !subdivide, which is true for everything that isn't a size rejection. The classification now lives inside if subdivide && len(chain) > 0, so a 401/500/reset/hook-decline can't reach it. TestExecuteBatchedUnrelatedErrorStaysRetryable covers four shapes; with the old gate restored it fails with your exact string (…cannot be subdivided further: http 401 unauthorized).

2 — a genuine 413 could never reach the sentinel. Also correct. Terminal now covers both verdicts the target can give: a rejection it actually sent (isBatchableTargetPushError), or a retry that already ran at its announced limit and still overshot. TestExecuteBatchedHardRejectionIsPermanentWithoutSelfImposedAbort pins the first case at 1 push, no retry.

3 — the relaxed ceiling leaked. Fixed by making it a per-attempt relaxedBudget that overrides the aborter for one push and is cleared when the checkpoint advances; selfImposedBudget is never written. TestExecuteBatchedRelaxedBudgetDoesNotLeakToLaterCheckpoints runs a 4-commit chain and asserts a later checkpoint still aborts on the small budget — it fails (aborts=[1]) with the leak restored. Your point that this also disabled the fix for branch 2+ was the one I'd have missed.

Correctness findings, all taken:

  • 4isIndivisibleCheckpoint now takes current. You were right about the resume path specifically: with idx-1 it counted the gap from the chain root after the stale-temp-ref re-plan, on exactly the ENT-2054 route.
  • 5 — in-loop parsedLimit now feeds AnnouncedTargetLimit, so a run that enters batching without a one-shot attempt can still relax. This also drops the landing-order coupling with the preflight-auth fix that the PR description had to warn about.
  • 6Batching/RelayMode set where the route is chosen, not where it succeeds.
  • 8 — a budget ratcheted down from bytes actually sent is now flagged as a measured cutoff and the retry won't jump past it.
  • 9 — aliased into the root package with the errors.Is doc block, next to the other caller-facing sentinels. You're right that the internal one was unreachable from mirror-pipeline.
  • 10 / 7 — error-path Result now carries Plans and Measurement, and client.go / unstable/client.go no longer zero the result on the syncer.Run error path.
  • 11 — helper is now chainPosition(cp) - chainPosition(current) == 1, no chain-wide map per failure, and the doc matches what it does. Dropped the dead len(chain) == 0 disjunct.
  • 12 / 13 — the store is built once and both the desired hash and parent map derive from it; the fixed packBytes parameter is gone; there's now a multi-checkpoint case.
  • 14 — the double source fetch is stated in the comment.

One thing I did not do: your suggestion that a test should exercise the projection branch of shouldAbortPush rather than the absolute one. You're right that the current tests only hit bytesSent >= threshold (budgets are far below the 8 MiB minBytesBeforeAbort), and right that the fake pack's version field makes the scanner reject it so totalObjects stays 0. Reaching the projection path needs a valid multi-MiB packfile in a unit test, which I judged not worth the fixture weight for a branch that only picks which number sizes the next subdivision. Happy to be argued out of that.

Full suite and golangci-lint green.

@nodo
nodo force-pushed the nodo/ent-2060-bottom-out-server-verdict branch 2 times, most recently from b06c763 to 21f7e13 Compare August 31, 2026 14:43
@nodo

nodo commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (post-#117) and addressed the adversarial review. Head is now 0cf1d733, mergeable.

Rebase note: main had moved 8 commits including #117, which touches the same executeBatched code. Cherry-picking my five commits individually kept colliding — they were an iterative refinement of the same lines, so each resolution fought the next — so I applied the net change once and squashed. Params keeps both sides' fields; the test file I rebuilt deterministically from main plus my new functions rather than trusting conflict boundaries that cut mid-function, then re-applied the makePackHeader hoist since main still had it as a local closure. Verified Soph's cutover tests pass alongside mine, and my behaviours are still mutation-caught on the merged tree.

The review's core finding was coverage, not correctness — three mutants survived the entire suite. All three now fail:

  • The gate itself was untested. Dropping isIndivisibleCheckpoint from the ceiling decision left everything green, so nothing pinned the feature's central claim that only an unsplittable checkpoint escalates. That mutant pushes every checkpoint at the announced ceiling with no margin and no projection — silently destroying the bound on wasted upload that is the whole reason TargetMaxPack is small. The budget-leak test I deleted during the simplification had been the closest thing to coverage, and I replaced it with nothing. Now pinned by a divisible span that must still abort at the small budget.
  • The >= boundary was untested. Now pinned by a pack at 97.8% of an announced limit the run learns from its own one-shot rejection.
  • The measured-cutoff guard is covered at last, using the reviewer's fixture. They also diagnosed why mine couldn't bite, which I'd failed to explain: on a one-commit chain the guard is structurally unreachable — any failure that would set it ends the run on that same checkpoint, leaving no later iteration to read it, and a pusher that never drains leaves sentBytes at 0 so it is never set at all. It needs two commits: a divisible span to take the observation, then an indivisible one to consult it. That reasoning is now recorded in the test.

Worth flagging for anyone else mutation-testing this file: removing that guard from the condition makes the variable unused, so the mutant doesn't compile, and a grep for --- FAIL reports a false negative. That bit me once here.

Also fixed the three comments still describing the retry this design removed — including a dangling paragraph about the deleted relaxedBudget fused onto the new comment — and the announced-ceiling notice now fires once per branch instead of per push, since the ceiling is chosen per push and an uneven-gap stretch would otherwise repeat it for packs nowhere near either number. Structured log still records every push.

Full suite + golangci-lint green.

nodo added 2 commits August 31, 2026 16:55
…ther

Rebased onto main after #117 (Soph's batched-cutover marker fix) and squashed:
the five original commits were an iterative refinement of the same code, and
resolving their conflicts one at a time against the moved main produced worse
results than resolving the net change once. The review narrative lives in the
PR discussion.

Once subdivision bottoms out — checkpoints split BETWEEN commits, so at one
commit per gap there is nothing left to split — a single commit whose pack
exceeded git-sync's own budget failed the run. That budget is self-imposed and
far below what the target accepts: TargetMaxPack defaults to 512 MiB while the
target announces 10 GiB, and autoTargetMaxPackBytes derives 5 GiB from that
announcement and then discards it for being larger than the default. So the
run gave up against a number git-sync chose, having never asked the server.
The mirror that motivated this dies exactly here.

The batching budget stays small on purpose — it bounds the waste of a doomed
push and makes the temp ref advance often, both of which require a smaller
pack to be possible. On a one-commit gap neither is, so the ceiling for that
push is the target's announced limit instead, chosen before the push rather
than after a doomed attempt. This costs nothing: a ceiling is an abort
threshold, not an upload size, so a pack that fits sends identical bytes
either way — and it avoids fetching an indivisible multi-GiB commit twice.

That gives the failure a verdict worth classifying. An abort against our own
budget stays retryable: a larger budget or a raised server limit could still
mirror the repo. A checkpoint that is indivisible AND refused by the target —
a parsed body-limit rejection, or an attempt at its announced limit that still
overshot — returns ErrCheckpointExceedsTargetLimit, aliased into the root
package so the mirror worker can match it with errors.Is and stop redelivering
an identical pack ten times. A deadline (408/504) is availability, not size,
so it stays retryable; classifying it permanent would let one target rolling
restart permafail every large bootstrap in flight.

Also stops discarding the bootstrap Result when Execute fails. The route facts
(RelayMode, RelayReason, batch counts, temp refs, Plans) are set before
anything can fail and describe the route rather than the outcome; zeroing them
is why a failed sync could report its strategy only when it succeeded — the
gap that made ENT-2054 a source read instead of a log query. Batching is
recorded before checkpoint planning, whose commit-graph fetch is the likeliest
failure for exactly the repos that batch, and unstable.Client.Bootstrap no
longer throws the result away either.

budgetFromObservation guards against escalating past a cutoff MEASURED from
bytes actually sent (a middlebox that cuts without announcing) rather than one
the target stated. It lives beside selfImposedBudget, which spans branches:
provenance has to travel with the value, or a later branch escalates past a
limit an earlier one demonstrated. It has no test coverage — a fixture I built
did not bite when the guard was removed, and I deleted it rather than keep a
test passing for reasons I could not explain.

Verified on the rebased tree: full suite and golangci-lint green, Soph's
cutover tests pass alongside these, and removing the escalation or treating a
deadline as a size verdict each still break the new tests.

Entire-Checkpoint: 01M1C4BS40PK95XMCB8MF5YH3Q
…f guard

The rewrite had no correctness bug, but mutation testing found it had deleted
the only coverage of its own central claim. Three mutants survived the whole
suite; all three now fail.

The gate itself was untested: dropping `isIndivisibleCheckpoint` from the
ceiling decision left every test green, so nothing pinned that ONLY an
unsplittable checkpoint escalates. That mutant pushes every checkpoint at the
announced ceiling with no margin and no projection, silently destroying the
bound on wasted upload that is the entire reason TargetMaxPack is small. The
deleted budget-leak test had been the closest thing to coverage. Replaced with
a divisible span that must still abort at the small budget.

The >= boundary was untested too. An in-batching rejection ratchets the budget
down to the announced limit, leaving them equal, and escalating there still
sheds the 95% margin and the projection — a pack sized inside that last 5%
would otherwise abort on every delivery forever. Now pinned by a pack at 97.8%
of an announced limit the run learns from its own one-shot rejection.

And the measured-cutoff guard is covered at last. The reviewer worked out why
my earlier fixture could not bite: on a one-commit chain the guard is
structurally unreachable, because any failure that would set it ends the run on
that same checkpoint and no later iteration remains to read it — and a pusher
that never drains leaves sentBytes at 0, so it is never set at all. Reaching it
needs two commits: a divisible span to take the observation, then an
indivisible one to consult it. Their fixture, with that reasoning recorded in
the test so the next person does not repeat the dead end.

Note for anyone mutation-testing this file: removing the guard from the
condition makes the variable unused, so that mutant does not compile and a
grep for test failures reports a false negative. Keep it referenced.

Also fixes three comments left describing the retry this design removed —
including a dangling paragraph about the deleted relaxedBudget fused onto the
new comment — and emits the announced-ceiling notice once per branch rather
than per push, since the ceiling is now chosen per push and an uneven-gap
stretch would otherwise repeat it for packs nowhere near either number. The
structured log still records every push.

Entire-Checkpoint: 01M1C4M6DRCWHBCR4MNJ0X95YT
@nodo
nodo force-pushed the nodo/ent-2060-bottom-out-server-verdict branch from 0cf1d73 to 3e55d75 Compare August 31, 2026 14:56
@nodo

nodo commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

bugbot run

Comment thread internal/strategy/bootstrap/bootstrap.go
subdivideToFactor splits EVERY remaining gap, so a splittable gap later in the
branch grows the checkpoint list even when the current span is already one
commit. The growth branch ran before classification, so that growth triggered
a retry — and a one-commit gap has no midpoint to gain, so the retry re-fetched
and re-pushed a byte-identical pack. Repeated once per later split, that is the
same doomed upload several times in one run, and on a deadline at the announced
ceiling each repeat is multi-GiB.

Subdividing is now attempted only when the current span can actually shrink.
When it cannot, there is nothing to retry for: fall through and classify, which
either returns the target's size verdict or a retryable error whose redelivery
re-plans from the temp ref.

Pinned by a 5-commit chain planned into 4 batches, which yields gaps of 1,1,1,2
— the first checkpoint indivisible while the last gap can still split — failing
on a deadline so the old code took the growth path. Two pushes of the identical
pack before, one after.

Entire-Checkpoint: 01M1C6YZ80MHFE69RAW9ZJPCP2
@nodo

nodo commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit ca89abb. Configure here.

…tion

A target that drains the body and then times out — GitHub's 408 shape — leaves
abortedEarly false and sentBytes above zero, so the budget ratcheted to those
bytes AND recorded itself as a measured server cutoff. That flag gates
escalation and is cleared only by a later parseable 413, so one deadline
disabled the feature for the remainder of the run: exactly the wrong outcome on
the flaky multi-GiB targets this path exists to serve.

It also contradicted the design's own principle. Classification already treats
a deadline as availability rather than size; provenance was treating the same
error as measured size evidence. The ratchet stays — smaller packs genuinely do
finish inside the window, so the smaller budget is useful information about
time — but it no longer masquerades as a size limit.

Pinned by a run that takes a one-shot 413 announcing 1 MiB, then a
drain-then-408 on a divisible span, and must still escalate an indivisible span
afterwards. Without the fix it fails with the same "aborted early: projected to
exceed target body limit" the reviewer's probe produced.

The pre-flight subdivide had the same asymmetry the previous commit fixed after
a failed push: subdivideCheckpoints splits every remaining gap, so a splittable
later gap grew the list and re-planned an identical checkpoint even for a span
that cannot shrink. Cheap there — a 12-byte header read, then Close — but the
same shape, so it is closed the same way, which also sidesteps comparing the
estimate against TargetMaxPack rather than the ceiling an indivisible span is
actually pushed at.

Docs: the sentinel's comment claimed a Bootstrap method the root package does
not have (it is unstable.Client.Bootstrap, and that comment is the mirror
worker's contract); Plan, Sync and Replicate now document that the result is
populated on error, what may be read from it, and that a validation or config
failure still returns a zero result; and a duplicated doc comment from the
rebase is removed.

Entire-Checkpoint: 01M1CG7DAC6WEPQGWG55D25GN4
@nodo

nodo commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

bugbot run

Comment thread internal/strategy/bootstrap/bootstrap.go Outdated
The previous commit stopped a deadline from CLAIMING a measured cutoff, but
wrote the answer unconditionally — so a deadline that ratcheted the budget also
assigned false, erasing a cutoff an earlier unparseable size rejection had
recorded. Escalation would then jump past a limit the server had already
demonstrated, and an abort at that ceiling is classified permanent: a flaky
target turned into a false permanent failure. Strictly worse than the bug it
replaced.

The rule is now a named function rather than an inline expression, because
getting it wrong in either direction is costly and the two directions are easy
to conflate. A deadline preserves whatever was known; a parsed limit means the
target stated its own bound and supersedes any measurement; anything else that
ratcheted the budget did so from observed bytes, which is a measurement.

Extracting it also made it testable. Two fixtures aimed at this through
Execute were vacuous — the runs never reached an escalation decision, so they
passed with the guard removed — and I deleted them rather than keep tests that
prove nothing. The table test on the function covers all four cases and fails
when the deadline branch is removed.

Entire-Checkpoint: 01M1CMYZYQ31YWA5NR9PG91CGT
@nodo

nodo commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit fcd1aa2. Configure here.

…behaviour

Skipping checkPackSizeAndSubdivide for an unsplittable span also skipped the
only thing that parses the pack header, so packObjectCount fell to 0 on exactly
the push this feature exists for: object_count and estimated_bytes logged as
zero on both lines, and calibrateBytesPerObject got a zero denominator. The
calibration loss was inert — an indivisible span never takes the growth path
now, so no later iteration consumes it — but the logs are the ones an operator
reads when a giant commit stalls.

The call is made unconditionally again; the callback declines instead. Same
outcome for subdivision, header still parsed, counts still real.

The skip was also unpinned: removing it passed the entire suite, which stands
out in a change whose every other claim is mutation-verified. Now pinned by
fetch count — 5 commits into 4 batches gives gaps of 1,1,1,2, and a header
declaring 200 objects makes the estimate exceed the budget so the pre-flight
actually fires. Nine fetches with the decline, eleven without: each wasted
fetch is an indivisible span re-planned before pushing.

Docs: the sentinel's comment had a 107-character line in a block that wraps at
79; observedSubdivisionFactor's doc block sat orphaned above a different
function and had started documenting nextBudgetProvenance, so it moves down to
the function it describes; and the client comment claimed Counts describe
attempted work when the error path does not populate them at all — Applied is 0
even where refs were pushed before the failure. That comment is the mirror
worker's contract, so it now says to read Refs and ignore Counts.

Entire-Checkpoint: 01M1DY4KRMG0JM8ZA6NC56Z3AZ
@nodo

nodo commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit fcf98d1. Configure here.

nodo added 2 commits September 1, 2026 10:01
Entire-Checkpoint: 01M1DZSQFYVETZC6XCV1AE6YAN
The changelog claimed callers could "distinguish a failed relay from a failed
bootstrap". The relay half is false. incremental.Execute returns a zero Result
on every error path — including push target refs, which fails after the relay
decision — and the syncer returns before copying incResult's relay facts
anyway, so a failed relay reports an empty TransferMode, indistinguishable
from a pre-execution failure. Only the bootstrap route was fixed. The entry now
says that, and names the gap rather than implying it away; the relay path is
worth the same treatment but needs its own tests.

"errors returned after planning has begun" was similarly broad: sixteen error
paths in the syncer still return a zero result.

Also: the Added entry re-introduced the ambiguous "Bootstrap" that this same
commit range corrected in errors.go — the root package has no such method, it
is unstable.Client.Bootstrap. The claim that cancellation errors keep their
classification is removed: nothing classifies cancellation, and naming it
beside the deadline implies handling that does not exist. The deadline half is
real and tested.

Unreleased now runs Added -> Changed -> Fixed, matching every released block;
the new Added section had been appended below Fixed. And the sentinel's doc
paragraph is reflowed rather than left ragged after the earlier line-length fix.

Entire-Checkpoint: 01M1EK3KNW2X1HYE8VVF91YJ3S
@nodo
nodo merged commit 5270fb0 into main Sep 1, 2026
4 checks passed
@nodo
nodo deleted the nodo/ent-2060-bottom-out-server-verdict branch September 1, 2026 13:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants