AUD-S10..S12: bounded reads, pagination caps, retry/backoff, marker resilience - #26
Merged
Conversation
… REL-03/SEC-08) Every forge/provider HTTP response read is now bounded and every pagination loop capped, so a hostile or broken endpoint can neither OOM the run nor spin it unbounded. Both bounds are FAIL-CLOSED. - internal/forge/gitlab: readBounded at the shared `do` seam caps a single response at maxResponseBytes (8 MiB, MB-order and generously above a KB-order discussions page or governed file). Over-limit DISCARDS the prefix and errors — truncated bytes never reach a decoder. - internal/provider: the same bound (exported MaxResponseBytes) on CallHTTP, so an over-limit provider body classifies as unavailable, never resolved. - ListBotThreads / ListBotNotes / hasApprovalRulesAPI: page loops capped at maxListPages (100 x 100 = 10 000 artifacts). Hitting the cap is an ERROR — reconcile must never run against a partial thread/note list. The approval-rules 404/403 -> Free-tier fail-safe is deliberately untouched. The AUD-S01 diff-enumeration ceiling (ADR-0020) is unchanged: it still degrades to REVIEW rather than erroring. Both polarities are pinned: over-limit errors AND an exactly-at-limit body still parses; the cap fires AND a short-page listing below the cap returns every artifact. REQ-AUD-S10-01, REQ-AUD-S10-02.
…ckoff (AUD-S11, REL-04) One transient 5xx or network wobble no longer loses a run that would have decided correctly — while non-idempotent writes stay strictly single-attempt. - Client gains a parent context (forge.Forge is a frozen port with no ctx params, so the grain is one context per client) and a RetryPolicy, both settable through a new variadic Option seam on New. - `do` retries ONLY GET/HEAD, only on a transport error / 429 / 5xx, up to 3 attempts, with an exponential window (200ms base, clamped at 2s) spread over its lower half by an injected jitter source, under a 30s per-request context deadline. The parent context is re-checked before every attempt, so a deadline that blows during a backoff issues no further request. - Exhausting the budget returns the LAST failure unchanged: every caller's existing fail-closed handling applies verbatim. Retries move availability, never a decision. - POST/PUT/PATCH/DELETE are never auto-retried (retryableMethod), pinned both by a table over the predicate and by driving all five write endpoints (create thread, create summary note, resolve, approve, merge CAS) through a 503 and asserting exactly one attempt each. Determinism: the sleeper and the jitter source are injected. No assertion reads the wall clock or math/rand, and internal/core stays untouched. Pre-existing test constructors take a no-op sleeper so the shipped retry budget still runs, at zero wall-clock cost. REQ-AUD-S11-01, REQ-AUD-S11-02.
…icking reconcile (AUD-S12, REL-06) RECONCILE-PROTOCOL BEHAVIOUR CHANGE against ADR-0019, pre-logged as judgment call (d) in the P5-AUD spec. Before: one bot note whose marker JSON had been corrupted made ListBotThreads/ListBotNotes return a hard error, so EVERY later reconcile on that MR failed until a human deleted the note. Fail-closed, but the MR was bricked. After: a bot-authored artifact with an undecodable marker payload is SKIPPED — treated as not-a-slot-note — and reconcile proceeds. A wrongly-parsed marker still cannot approve anything: markers are correlation metadata, never decision input. - gitlab.Client keeps a deduplicated warning SET (the step-9 rescan sees the same artifact twice) and exposes it, sorted, as forge.Warner. - forge.PublicationReceipt gains `warnings` (omitempty, top-level additionalProperties:true — no schema change, no golden moves). - forge.Reconcile copies the forge's warnings onto the receipt on the success path, and cmd/assent's summary appends a suffix ONLY when there are any, so the operator actually sees which artifact to repair and every existing summary line stays byte-identical. AUTHOR-IDENTITY FILTERING IS UNTOUCHED. The author check still runs BEFORE the marker is parsed, so a contributor note is invisible whether its marker is perfect or garbage, and it never reaches the warning channel. The spoof surface is unchanged; TestSpoofedMarkerStillIgnored pins both contributor polarities against a bot-authored positive control that WOULD warn. Convergence: for a corrupted SUMMARY note the duplicate-repair path does not apply (repair resolves threads, and the protocol never auto-deletes notes). Convergence comes from the upsert: run 1 posts one healthy summary, run 2 edits that one in place. Zero new duplicates, warnings stable, double run byte-identical — pinned by TestMalformedBotMarkerDoubleRunConverges. The pre-existing TestListBotThreadsMalformedMarker, which asserted the old hard error, is updated to the new contract. REQ-AUD-S12-01, REQ-AUD-S12-02.
…e (AUD-S10 x S11) The AUD-S10 bound and the AUD-S11 retry budget interacted badly: readBounded returns an error, and transient() retried on ANY error, so an over-limit response was fetched three times before failing. Memory stayed bounded and the run still failed closed, but an oversized document is a DETERMINISTIC failure like a 4xx — the same endpoint sends the same document again — and this lane's own 4xx_is_not_retried test encodes exactly that principle. The bound now wraps a sentinel (errBodyTooLarge) that transient() excludes. Both polarities pinned: TestBoundedReadOverLimitIsNotRetried asserts exactly one attempt and zero backoff, and TestTransientFailuresAreStillRetriedAfterThe Bound is the positive control that a 5xx is still retried — so "nothing is transient" does not pass.
Reconcile attached receipt.Warnings only at its single success return, so every
typed refusal — ErrArmingRefused, ErrIncompletePreconditions, ErrSHAMoved —
returned a bare PublicationReceipt{} and summarize printed no warning suffix.
Those refusals are expected, exit-0, advisory-only outcomes, and UNARMED IS THE
DEFAULT ADOPTER POSTURE: an APPROVE-decision run the forge will not arm skipped
a corrupt bot marker completely silently. That is the same invisibility the
warning channel exists to remove, on the path most adopters actually take.
Every Reconcile return now goes through withWarnings. The receipt is otherwise
untouched — no operations are invented on a refusal.
Pinned at the REAL entry point (runRun -> gitlab.Client -> Reconcile ->
summarize -> stdout), not the seam: TestRunSurfacesForgeWarningOnUnarmedRefusal
seeds a corrupt bot marker, asserts the run still exits 0 advisory with zero
writes AND that the summary names the artifact. Reverting the fix reds it.
TestRunEmitsNoWarningSuffixOnCleanRefusal is the positive control: the same
refusal with no corruption prints the same line as before, byte-identical.
…he surviving mutant (review F2) The provider bounded read had an UNDER-limit control only, so flipping `len(raw) > limit` to `>=` shifted the boundary by one byte and nothing noticed: internal/forge/gitlab went red on that mutation, internal/provider stayed green. TestBoundedReadAtLimitStillSucceeds serves a REAL, schema-valid FactResponse padded to exactly MaxResponseBytes with insignificant trailing whitespace (not a giant value, which would be schema-invalid and mask the read assertion behind a decode failure). It asserts the body both reads AND resolves through ResolveFacts, so a bound that errored at the boundary would also be caught as the auto-merge disarm it would cause. Verified: applying `>=` at BOTH read sites now reds both packages. No production change — the shipped behaviour was already correct; this closes a test gap.
…ADR-0019 (review F3)
The commit that changed the protocol said "RECONCILE-PROTOCOL BEHAVIOUR CHANGE
against ADR-0019", yet a reader of ADR-0019 learned neither that a malformed
bot marker is now skipped nor that receipts can carry `warnings` — while
`repairs` is documented in both the ADR and the state table.
ADR-0019 is an accepted, dated record, so this follows the repo's established
amendment convention (ADR-0003/0004/0007/0011...): a dated `## Amendment`
section appended rather than an edit to the accepted Decision text. ADR-0019
had no prior amendment, so it is unnumbered, matching every ADR's first one.
The amendment states what changed in step 2, and the three properties that make
the skip safe rather than a supersession: decision 1 (markers are correlation
metadata only) is unchanged; the author-identity filter is unchanged and still
runs first; and the worst case converges.
The state table gets the same content in two places, mirroring `repairs`
exactly: the skip rule inline in step 2, and a paragraph after the table. Note
that `repairs` is deliberately NOT a table row there ("not a sixth row"), and a
malformed marker is not one either — it is filtered during step 2's listing
before any slot is classified, so its slot simply presents as row 1 and takes
the ordinary `create` action. That is precisely why the worst case is a
duplicate post rather than a wrong decision. Adding a literal row would have
contradicted the mutual-exclusivity paragraph the table depends on.
No schema change: `warnings` rides the receipt schema's top-level
`additionalProperties: true`, exactly as `repairs` does.
…view F5) `do` reuses the same io.Reader across retry attempts, which is safe today only because all retryable call sites happen to pass nil — a property a future edit could quietly break, turning a retry into a replayed EMPTY request. The retry budget now requires `body == nil` as well as an idempotent method. This fails in the safe direction: a retryable request that somehow carries a body gets exactly one attempt, never a corrupted replay. TestRetryableRequestWithBodyIsNotReplayed pins both polarities — a GET with a body is attempted once, and the identical GET with a nil body still retries the full budget, so the guard cannot be mistaken for a disabled retry path.
…e-hardening # Conflicts: # CHANGELOG.md
…er (review F8)
The ADR-0019 amendment and reconciliation-state-table.md both asserted that a
skipped THREAD converges because "step 8's deterministic duplicate-repair
resolves the duplicate on the next run". That is FALSE, and it is false in a
frozen normative contract — the worst place for it.
Step 8 never fires. A corrupt thread is filtered out of ListBotThreads, so it
can never present as a VISIBLE duplicate for repair to act on;
PublicationReceipt.repairs stays empty. This is the same error I reported in
the spec text for the summary-note case, and I then wrote it into the amendment
for the thread case.
Convergence is real but comes from ordinary idempotent REUSE, identically for
both artifact kinds: run 1 posts one healthy artifact, and every later run
finds and reuses it — step 4's matching-occurrence no-op for a thread, step 3's
edit-in-place for a summary note. Behaviour is unaffected; only the explanation
was wrong.
Corrected in ADR-0019 Amendment bullet 3, the state table's step-2 paragraph,
and the state table's post-table paragraph — plus the same claim I had repeated
in the ListBotThreads skip comment and two test doc-comments.
Verified empirically rather than taken on trust, and now pinned:
TestMalformedBotThreadConvergesWithoutDuplicateRepair seeds a corrupt-ONLY bot
thread and runs Reconcile twice, asserting repairs stays empty on both runs and
that run 2 REUSES rather than re-posts:
run1: discussionPosts=1 repairs=[] | run2: discussionPosts=1 repairs=[]
Non-vacuity: mutating the listing so the healthy thread is also dropped reds
the reuse assertion. That `repairs` CAN be non-empty is pinned independently by
the conformance suite's TestConformanceDuplicateRepair, so "empty" here is a
finding rather than an artefact of repair being unreachable in general.
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.
Three serial stories on the same file (
internal/forge/gitlab/gitlab.go), one commit each, spec-first (failing test → implementation) peropenspec/specs/p5-aud-audit-remediation/spec.mdlines 543–654.Every fail-closed branch below is pinned in both polarities — the guard fires and legitimate traffic is provably unaffected — and every new test was mutation-verified (deliberately break the production code, confirm RED). Evidence is listed per story.
AUD-S10 — REL-03 / SEC-08: bounded response reads + pagination caps
Commit
6852ba5.What changed
internal/forge/gitlab/gitlab.go:readBoundedat the shareddoseam caps a single response atmaxResponseBytes(8 MiB — MB-order, constant, documented; a 100-item discussions page is KB-order and the largest read is a governed policy/registry file). Over-limit discards the prefix and errors, so truncated bytes never reach a decoder.internal/provider/transport.go: the same bound (exportedprovider.MaxResponseBytes) onCallHTTP. An over-limit provider body classifies asunavailable, neverresolved, and keeps auto-merge disarmed.ListBotThreads/ListBotNotes: page loops capped atmaxListPages(100 pages × 100 per page = 10 000 artifacts). Hitting the cap is an error — reconcile must never run against a partial thread/note list, because a silent partial reads as "that finding has no thread yet" and duplicates it.Fail-closed, both polarities
TestBoundedReadOverLimitFailsClosed,TestBoundedReadAppliesToRawFileReadsTestBoundedReadAtLimitStillSucceeds— a body of exactly the limit parsesTestBoundedReadOverLimitFailsClosedTestBoundedReadUnderLimitUnaffectedTestPaginationCapFailsClosed(discussions + notes)TestPaginationBelowCapUnchanged— a short-page listing returns every artifactScope note (beyond the REQ, deliberate and reported). The REQ names two loops. A third page loop existed in the same class:
hasApprovalRulesAPIinsnapshot.go, uncapped. Leaving it would have left "every pagination loop capped" untrue, so it is capped too — with its own both-polarity coverage (TestPaginationCapFailsClosedApprovalRulesandTestApprovalRulesFailSafeSurvivesCap). The cap is an error, not the probe's pre-existing 404/403 → Free-tier fail-safe: a never-shortening paginator is a forge anomaly, not evidence the instance lacks the API. Erroring aborts the run with zero forge writes, strictly safer than the previous unbounded spin (which literally hung the test suite before the fix — that hang was this story's first RED).The AUD-S01 diff-enumeration ceiling (ADR-0020-owned) is untouched: it still degrades to REVIEW rather than erroring.
Mutation evidence
len(raw) > limit→>=TestBoundedReadAtLimitStillSucceedsnilTestBoundedReadOverLimitFailsClosedpanic: test timed out after 20s(the spin the cap exists to stop)maxListPages100 → 1TestPaginationBelowCapUnchanged(both sub-tests)AUD-S11 — REL-04: retry/backoff + context deadlines
Commit
3848f39.What changed
Clientgains a parentcontext.Contextand aRetryPolicy, both settable through a new variadicOptionseam onNew(WithRetry,WithSleeper,WithJitter,WithContext). The context lives on the struct becauseforge.Forgeis a frozen port with no ctx parameters (ADR-0011 / ADR-0017) and the CLI owns one client per run.doretries only GET/HEAD, only on a transport error / 429 / 5xx, up to 3 attempts, with an exponential window (200 ms base, clamped at 2 s) spread over its lower half by an injected jitter source, under a 30 s per-request context deadline. The parent context is re-checked before every attempt, so a deadline that blows during a backoff issues no further request.Fail-safe on writes, both polarities
TestWritesNeverRetrieddrives all five write endpoints (create thread, create summary note, resolve thread, approve, merge CAS) through the same 503 that the GET cases retry, and asserts exactly one attempt and zero backoff spent for each.TestWriteMethodsAreNotRetryableis the table half: GET/HEAD must be retryable, POST/PUT/PATCH/DELETE must not — so neither "retry everything" nor "retry nothing" passes.TestIdempotentRetrycovers recovery after 5xx, 429, transport error, budget exhaustion → hard error, expired context → hard error with zero requests, and mid-budget cancellation → stops retrying.4xx_is_not_retriedis the negative control (a 404 gets one attempt and no backoff).Determinism. The sleeper and the jitter source are injected; no assertion reads the wall clock or
math/rand.jitter_widens_the_windowasserts the upper half of each window at jitter = 1 whileget_succeeds_after_transient_5xxasserts the lower half at jitter = 0 — a constant backoff cannot satisfy both.internal/coreis untouched, sopurity_test.gois unaffected;task determinismis green.Pre-existing test constructors (
newServer,badClient, the conformance gitlab harness, the twocmd/assentfactories) take a no-op sleeper, so the shipped retry budget still runs in every one of them at zero wall-clock cost.TestRetryDefaultspins the shipped policy so the defaults are not left unasserted behind the injection seam.Mutation evidence
retryableMethod→truefor all methodsTestWritesNeverRetriedcases +TestWriteMethodsAreNotRetryabledefaultMaxAttempts3 → 1TestRetryDefaults+ 3TestIdempotentRetrycasesjitter_widens_the_windowtransient()stops treating 5xx/429 as retryableTestIdempotentRetrycasesc.ctx.Err()checkexpired_context_is_a_hard_error_with_no_requestAUD-S12 —⚠️ REL-06: malformed BOT-marker skip-with-warning
Commit
31c1646. This is a reconcile-protocol behaviour change against ADR-0019, pre-logged as judgment call (d) in the spec — no new D-row needed, and I do not disagree with it at build time.The behaviour change, stated plainly. Before: one bot note whose marker JSON had been corrupted made
ListBotThreads/ListBotNotesreturn a hard error, so every later reconcile on that MR failed until a human deleted the note. That failed closed, but it bricked the MR. After: a bot-authored artifact with an undecodable marker payload is skipped — treated as not-a-slot-note — and reconcile proceeds. A wrongly-parsed marker still cannot approve anything, because ADR-0019 markers are correlation metadata and never decision input or authorization evidence.The pre-existing
TestListBotThreadsMalformedMarker, which asserted the old hard error, is updated to the new contract with a pointer to the deep coverage.Author-identity filtering is untouched — and pinned. This is the part that must not weaken. The author check still runs before the marker is looked at, so a contributor note is invisible whether its marker is perfect or garbage, and it never reaches the warning channel (otherwise anyone could spam the receipt, and the warning would leak that the filter had reached the marker at all).
TestSpoofedMarkerStillIgnored(conformance) asserts both contributor polarities — well-formed and malformed — against a bot-authored positive control with an identical corrupt body that DOES warn, so the contributor assertions cannot be vacuous.TestContributorMarkersAreInvisiblemirrors this at the adapter level. Mutating the ordering reds both.Where the warning goes (a receipt field nobody prints is a struct field, not a behaviour):
gitlab.Clientkeeps a deduplicated warning set — the step-9 rescan sees the same artifact more than once per run — exposed sorted via the new optionalforge.Warnerinterface.forge.Reconcilecopies it ontoPublicationReceipt.warningson the success path (omitempty, riding the schema's existing top-leveladditionalProperties: true, exactly likerepairs— no schema change, no golden moves).cmd/assent'ssummarize()appends a suffix only when there are warnings, so the operator sees which artifact to repair and every existing summary line stays byte-identical.Convergence — a spec deviation, reported rather than hidden. The spec says the duplicate slot post is "repaired by the existing duplicate-repair path (
TestConformanceDuplicateRepair)". It is not — for either artifact kind. (My first write-up said this held for threads and failed only for summary notes; review finding F8 corrected that, and commit2b239a3fixes the text everywhere I had repeated it.)Step 8 never fires: a corrupt artifact is filtered out of the listing, so it can never present as a visible duplicate for repair to act on, and
PublicationReceipt.repairsstays empty. Convergence is real but comes from ordinary idempotent reuse: run 1 posts one healthy artifact for the slot, and every later run finds and reuses it — step 4's matching-occurrence no-op for a thread, step 3's edit-in-place for a summary note. The corrupt artifact lingers, warning, until an operator deletes it (write minimization — it is deliberately never auto-deleted).Pinned by
TestMalformedBotThreadConvergesWithoutDuplicateRepair(run1: discussionPosts=1 repairs=[] | run2: discussionPosts=1 repairs=[]) andTestMalformedBotMarkerDoubleRunConverges(run 1 → exactly 1 note POST, run 2 → 0 POSTs, receipts byte-identical).TestConformanceDuplicateRepairremains green and unchanged, which is what makes "repairs stays empty" a finding rather than an artefact of repair being unreachable in general.Mutation evidence
return nil, erronparseMarkerfailureTestListBotThreadsMalformedMarker,TestMalformedBotMarkerSkipsWithWarning,TestSpoofedMarkerStillIgnored/bot/malformed-marker-does-warnTestSpoofedMarkerStillIgnored/contributor/malformed-marker+TestContributorMarkersAreInvisible/malformedReconciledrops the warnings (field never populated)TestSummarySurfacesReconcileWarningsTestHealthyReconcileEmitsNoWarningis the positive control for the whole channel: a clean reconcile carries no warnings, so the field cannot be a constant and no golden receipt gains a spurious entry.AUD-S10 × S11 interaction fix
Commit
15b7a33, found in review after the three stories landed.The two guards interacted badly:
readBoundedreturns an error, andtransient()retried on any error, so an over-limit response was fetched three times before failing. Memory stayed bounded and the run still failed closed, but an oversized document is a deterministic failure like a 4xx — the same endpoint sends the same document again — and this lane's own4xx_is_not_retriedtest encodes exactly that principle. The bound now wraps a sentinel (errBodyTooLarge) thattransient()excludes.Both polarities pinned:
TestBoundedReadOverLimitIsNotRetried(exactly one attempt, zero backoff) andTestTransientFailuresAreStillRetriedAfterTheBound(a 5xx is still retried — so "nothing is transient" does not pass).errBodyTooLargeexclusion fromtransient()TestBoundedReadOverLimitIsNotRetriedTestIdempotentRetry/transport_error_is_retried_then_fails_hardVerification notes
parseMarkercall sites:grep -rn "parseMarker" internal/forge/gitlab/confirms exactly two production sites (ListBotThreads,ListBotNotes) — both changed.resolve.gohas none, so REL-06 is fully closed, not half-closed.gitlab.Client.warningsaccumulates for the life of the client and is never drained. That is correct here because there is noservecommand — the CLI's only forge commands (run,doctor) build a client inside a per-invocation factory closure, one client per one-shot run, one MR. If a long-livedservemode (ADR-0019 §3) is ever added and reuses one client across MRs, this set must become per-MR or drained per reconcile.Gates
task check(full, incl.changelog-verify)task determinismgolangci-lint run ./...Final commit
1d32fa3istask changelog-write+ CHANGELOG.md, as required. (Re-run after the interaction fix;task checkexit 0 andtask determinismexit 0 at that tip.)One flake seen, not caused by this lane:
internal/provider TestExecDigestPin/match_allows_execandhack/spikes/providerintermittently fail withsignal: killedwhen the whole suite runs under load — both spawn a compiled child binary under a hardcodedTimeout: time.Second. They pass in isolation (go test -race ./internal/provider/...) and on a clean tree, and this lane touchesCallHTTP, notCallExec. Several other lanes were building concurrently on this machine. Worth a separate fix (raise or make the child timeout load-tolerant); flagging rather than silently retrying it away.Spec conflicts reported
TestConformanceDuplicateRepair. True for threads, not for summary notes (see above). Implemented and pinned via write-minimization convergence instead; nothing was skipped.Review round 2 — P2 fixes (F1, F2, F3) + P3 (F5)
Reviewer returned APPROVE with no P0/P1. All three P2s and the optional P3 are fixed on this branch. Each fix was mutation-verified the same way as the original lane: apply the mutation,
git diff-confirm it landed, watch RED, restore.F1 — warnings were dropped on every fail-closed refusal (
f457c78)Reconcileattachedreceipt.Warningsonly at its single success return, soErrArmingRefused/ErrIncompletePreconditions/ErrSHAMovedreturned a barePublicationReceipt{}andsummarizeprinted no suffix. Those refusals are exit-0 advisory outcomes and unarmed is the default adopter posture — so an APPROVE run the forge will not arm skipped a corrupt marker completely silently. EveryReconcilereturn now goes throughwithWarnings; the receipt is otherwise untouched (no operations invented on a refusal).Pinned at the real entry point —
runRun→ realgitlab.Client→forge.Reconcile→summarize→ stdout:TestRunSurfacesForgeWarningOnUnarmedRefusalseeds a corrupt bot marker, asserts the run still exits 0 advisory with zero writes, and that the summary namesdisc-corrupt.TestRunEmitsNoWarningSuffixOnCleanRefusalis the positive control: the same refusal without corruption prints the same line as before.PublicationReceipt{}(pre-fix behaviour)got "decision=APPROVE arm=true → advisory-only (arming precondition unmet, no approve/merge)"F2 — the surviving provider mutant is dead (
f4c0a5a)The provider side had an under-limit control only.
TestBoundedReadAtLimitStillSucceedsnow serves a real, schema-validFactResponsepadded to exactlyMaxResponseByteswith insignificant trailing whitespace — not a giant string value, which is schema-invalid and would have masked the read assertion behind a decode failure (my first two attempts did exactly that and failed honestly). It asserts the at-limit body both reads and resolves throughResolveFacts, so a bound that errored at the boundary is also caught as the auto-merge disarm it would cause.Confirmed with the reviewer's own mutation,
len(raw) > limit→>=at both sites simultaneously, diff-verified:internal/forge/gitlabinternal/providerNo production change — what shipped was already correct; this closes a test gap.
F3 — ADR-0019 + state table now record the change (
c0b8165)Convention used: a dated
## Amendmentsection appended to ADR-0019, matching the repo's established pattern (ADR-0003, 0004, 0007, 0008, 0009, 0010, 0011, 0012, 0014 all use it). ADR-0019 had no prior amendment, so it is unnumbered — matching every ADR's first amendment;Amendment 2/3are only used for subsequent ones. The accepted Decision text is left untouched, which is the point of the convention.The amendment states what changed in step 2 and the three properties that make this an amendment rather than a supersession: decision 1 (markers are correlation metadata only) unchanged; the author-identity filter unchanged and still first; and the worst case converges.
reconciliation-state-table.mdgets the same content in two places, mirroringrepairsexactly: inline in step 2, and a paragraph after the table. One deliberate judgment call to flag:repairsis explicitly not a table row there ("Pre-existing duplicates ... are not a sixth row of this table"), sowarningsisn't either — a malformed marker is filtered during step 2's listing before any slot is classified, so its slot presents as row 1 and takes the ordinarycreateaction. That is exactly why the worst case is a duplicate post rather than a wrong decision. Adding a literal row would have contradicted the mutual-exclusivity paragraph the table depends on, so I mirrored howrepairsis handled rather than the literal word "row".task docs-gatesgreen (incl. the ADR-index status pin) andtask docs-build(mkdocs--strict) exit 0.F5 (optional P3) — retry-body safety made structural (
bf72c9b)Cheap and low-risk, so done. The retry budget now requires
body == nilas well as an idempotent method, so a retryable request carrying a once-consumableio.Readergets one attempt instead of a replayed empty request. Fails in the safe direction.TestRetryableRequestWithBodyIsNotReplayedpins both polarities (GET with body → 1 attempt; identical GET with nil body → full budget), so the guard cannot be mistaken for a disabled retry path.body == nilguardattempts = 3, want 1Rebase
gh pr update-branch --rebase 26returnedRebaseConflictError(the generatedCHANGELOG.md, which both this lane and AUD-S16/S17 rewrite). Resolved with a merge oforigin/main(2748aad) rather than a local rebase — that keeps the no-force-push constraint, andmainalready uses merge commits. The conflict wasCHANGELOG.mdonly; since it is fully generated,task changelog-writewas re-run afterwards so the tree ends changelog-clean (7ba316a).Gates at
7ba316atask check(full)task determinismtask docs-gates/task docs-build --strictgolangci-lint run ./...The
TestExecDigestPin/hack/spikes/providersignal: killedflake appeared on two of fourtask checkruns and passes in isolation under-race; per coordinator instruction it is owned by its own lane and untouched here.Review round 3 — F8 (P2)
Reviewer returned APPROVE on the round-2 delta, with one new P2.
F8 — the ADR amendment and state table named the wrong convergence mechanism (
2b239a3)Both asserted that a skipped thread converges because "step 8's deterministic duplicate-repair resolves the duplicate on the next run". False, in a frozen normative contract. Step 8 never fires: a corrupt thread is filtered out of
ListBotThreads, so it is never a visible duplicate for repair to act on. This is the same error I caught in the spec for the summary-note case and then wrote into the amendment for the thread case.I verified it empirically rather than on trust — seeded a corrupt-only bot thread, ran
Reconciletwice, and reproduced the reviewer's numbers exactly:Now pinned by
TestMalformedBotThreadConvergesWithoutDuplicateRepair, which assertsrepairsstays empty on both runs and that run 2 reuses rather than re-posts. Non-vacuity: mutating the listing so healthy threads are also dropped reds the reuse assertion. Thatrepairscan be non-empty stays pinned byTestConformanceDuplicateRepair, so "empty" here is a finding, not an artefact of repair being unreachable.Corrected in all five places I had repeated the claim: ADR-0019 Amendment bullet 3, the state table's step-2 paragraph, the state table's post-table paragraph, the
ListBotThreadsskip comment, and two test doc-comments. Behaviour is unchanged — only the explanation was wrong.Gates at
ea7ade0— completed run, no early aborttask checkexit 0 on the first attempt, with all 13 stages confirmed executed (fmt, vet, lint, test, coverage, build, dogfood-comparison, compare-exitgate-test, changelog-verify, release-changelog-gate-test, release-verify-tag-gate-test, docs-gates, lint-depguard-test) — verified by grepping the stage banners, since an early flake abort would silently skip the later ones.task determinismexit 0 ·task docs-build --strictexit 0 · coverage 90.3% · working tree clean, changelog included.Not rebased, per instruction — merge with
--merge.