Skip to content

fix(approve): recover the older digest when two ReplicaSets are serving (BLO-32101) - #1676

Merged
allyblockcast[bot] merged 2 commits into
masterfrom
blo32101-oldest-serving-replicaset
Sep 6, 2026
Merged

fix(approve): recover the older digest when two ReplicaSets are serving (BLO-32101)#1676
allyblockcast[bot] merged 2 commits into
masterfrom
blo32101-oldest-serving-replicaset

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 6, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Its release path gates paperclip-api on a ValidatingAdmissionPolicy that only admits digests listed in a bounded approval ring, rotated by scripts/approve-paperclip-api-digest.sh
  • Every failed deploy permanently consumes a ring slot, so a run of consecutive failures — exactly when a rollback is wanted — ages out the digest actually serving traffic and wedges the channel (BLO-28483). fix(release): pin the running digest in the approval window (BLO-28483) #1639 fixed that by pinning the running digest ahead of the age-ordered fill
  • But nothing reverts spec.template after a rollout that never became ready, so the Deployment then names a digest that never carried traffic and the pin has nothing true to read. fix(approve): recover the last-healthy digest from the serving ReplicaSet (BLO-31842) #1655 fixed that by falling back to the previous ReplicaSet, which is the only object still naming the last-healthy digest (BLO-31842)
  • That fallback pinned a digest only when exactly one ReplicaSet had ready pods, and declined when two did. Ally's review of fix(approve): recover the last-healthy digest from the serving ReplicaSet (BLO-31842) #1655 pointed out it never considers the oldest — which is precisely the ReplicaSet carrying the digest the reader exists to recover
  • This pull request resolves the two-serving case to the oldest ReplicaSet by creationTimestamp, after the existing ownership and readiness filters
  • The benefit is that the stalled-roll case BLO-31842 is about — where the old ReplicaSet is the only object naming the last-healthy digest — now recovers it instead of discarding it

Linked Issues or Issue Description

Searched for duplicates across serving_replicaset_image, approve-paperclip-api-digest, ReplicaSet, and approval ring: none. #1671 (BLO-32109) and #1674 (BLO-32122) are open against the same file but cover the retirement loops, a different function.

What Changed

  • serving_replicaset_image() now elects the oldest ReplicaSet with status.readyReplicas > 0 by metadata.creationTimestamp, where it previously required exactly one and declined otherwise.
  • Age is applied last. Ownership (ownerReferences matching the Deployment uid) and readiness still narrow the candidate set first, and the elected ReplicaSet then faces the same single-image container check — and, in the caller, the same repository check — as before, with no fallthrough to the runner-up.
  • Two shapes that leave no oldest to pick are declined rather than guessed at: equal creationTimestamps and a missing creationTimestamp.
  • The container/image extraction moved into a jq def rs_image so the elected ReplicaSet and the lone-ReplicaSet case share one implementation rather than two copies that could drift.
  • The function's design comment was rewritten: it previously argued for the "exactly one" rule, so leaving it would have contradicted the code. The argument against taking the newest is preserved intact — that part was correct and still is.
  • scripts/approve-paperclip-api-digest.test.js: the contradicting case "two ReplicaSets with ready pods are ambiguous and yield no digest" is replaced, and seven cases added. The replicaSetWith fixture gains a created field, defaulted because every real ReplicaSet carries one.

Verification

$ node --test scripts/approve-paperclip-api-digest.test.js
ℹ tests 83
ℹ pass 83
ℹ fail 0
ℹ duration_ms 5255

77 → 83 tests. Runs in 5.3s against the policy job's 1-minute bound (.github/workflows/pr.yml:185-186), which is where this file is gated.

Negative controls — run, not assumed

Two separate neuterings, each isolating one guard. Every safety case stayed green in both.

A. Age ordering removed (reverted to "exactly one")tests 83 / pass 81 / fail 2

✖ two ReplicaSets with ready pods resolve to the older one's digest
✖ three ReplicaSets with ready pods resolve to the oldest, not merely a previous one
✔ an oldest serving ReplicaSet from another repository yields no digest, not the runner-up
✔ an oldest serving ReplicaSet whose containers disagree yields no digest, not the runner-up
✔ an older ReplicaSet owned by a different Deployment does not outrank ours
✔ a serving ReplicaSet owned by a different Deployment is never pinned
✔ a serving ReplicaSet from another repository is never pinned
✔ a serving ReplicaSet whose containers disagree yields no digest rather than a guess
✔ a failed ReplicaSet list degrades to no digest without failing the release

B. Tie + missing-stamp guards removed, plain sort_by kepttests 83 / pass 81 / fail 2

✖ two serving ReplicaSets stamped the same second stay ambiguous and yield no digest
✖ a serving ReplicaSet with no creationTimestamp is never ranked as the oldest
✔ two ReplicaSets with ready pods resolve to the older one's digest
✔ three ReplicaSets with ready pods resolve to the oldest, not merely a previous one
✔ an oldest serving ReplicaSet from another repository yields no digest, not the runner-up
✔ an oldest serving ReplicaSet whose containers disagree yields no digest, not the runner-up

B is not redundant. Under A the tie and unstamped cases stay green, because they assert "" and "exactly one" also returns "" — so A alone would not have shown those two branches are load-bearing rather than decoration.

Risks

Low risk, and strictly non-regressive. Before #1655 a serving-gate failure always yielded no pin, so both the old rule and this one are improvements on master as of #1639; this widens where the old rule declined and changes nothing where it did not.

  • Where the two rules can disagree: only when ≥2 ReplicaSets of this Deployment have ready pods. With one or zero they are identical. In the disagreement case the old rule pinned nothing, so the failure mode is "pins a digest that is serving but not the one you would have chosen", against a previous behaviour of "pins nothing".
  • Could it pin a bad digest? It pins a digest that has ready pods right now, so it is serving by definition. Between two serving ReplicaSets the older has been serving longer, which is the better evidence of health — this is why oldest, not newest. Pinning the newest would name the broken digest in the stalled-roll case.
  • Could it pin the wrong repository, or a multi-image template? No. Those checks are unchanged and are applied to the elected ReplicaSet with no fallthrough, so a disqualified oldest ReplicaSet yields no digest rather than promoting the runner-up. Two dedicated tests pin exactly that, since it is the property most likely to be lost in a later rewrite.
  • Timestamp comparison is string-lexicographic. Exact rather than approximate: metav1.Time marshals as RFC3339 in UTC with a fixed layout, so lexicographic and chronological order coincide.
  • Availability: unchanged. This remains a safeguard, not a gate — every failure path still returns empty and succeeds. The RBAC degradation path (a failed kubectl get replicasets warning once and degrading to empty, per BLO-21598) is untouched and still covered.
  • Ring bound: unchanged. MAX_APPROVED_DIGESTS=3 and the maxApprovedApiDigests CEL variable in onprem-k8s's paperclip/paperclip-public-tools.yaml are absent from this diff; the diff touches only the script and its test.
  • Merge order: test(deploy): assert the two retirement loops' retry pacing (BLO-32109) #1671 and test(deploy): pin the two retirement loops' messaging asymmetry (BLO-32122) #1674 are open against the same file but on the retirement loops, so no textual conflict is expected with this hunk.

Model Used

Claude Opus 5 (claude-opus-5), 1M context, extended thinking, with tool use and code execution — running as the Paperclip Release Engineer agent. The negative-control runs above were executed, not narrated.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — n/a, no UI surface
  • I have updated relevant documentation to reflect my changes — the function's design comment, which argued for the rule this replaces
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — pending; will confirm before merge
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — pending review
  • I will address all Greptile and reviewer comments before requesting merge

…ng (BLO-32101)

serving_replicaset_image() pinned a digest only when exactly one ReplicaSet
had ready pods, and declined otherwise. The design comment argued correctly
against taking the NEWEST serving ReplicaSet -- in a stalled roll the newer
digest is the broken one -- but never considered the OLDEST, which is the
case the reader actually needs.

In both ways the two-serving case is reachable, the older ReplicaSet carries
the last-healthy digest this reader exists to recover, and in the stalled-roll
case (maxSurge: 1 / maxUnavailable: 0 parking the Deployment at old=1 ready /
new=1 ready) it is the only object that still names it. An RS with ready pods
is serving by definition, so age breaks the tie in the direction we want.
Oldest-wins dominates "exactly one": they agree whenever one serving RS
exists, and where they differ the strict rule pinned nothing.

Age is the LAST filter. Ownership and readiness still narrow the candidates
first, and the elected ReplicaSet then faces the same container and repository
checks with no fallthrough to the runner-up -- a disqualified oldest RS yields
no digest rather than promoting the newer one.

Two shapes leave no oldest to pick and are declined rather than guessed at:
equal creationTimestamps (second-granular, so a real tie, which a plain sort
would resolve on apiserver list order) and a missing creationTimestamp (which
defaults to "" and would otherwise sort first, winning on the field's absence).

Not a regression either way: before #1655 a serving-gate failure always
yielded no pin. The ring stays bounded at MAX_APPROVED_DIGESTS=3 and the
maxApprovedApiDigests CEL variable is untouched.

Tests: 77 -> 83 in scripts/approve-paperclip-api-digest.test.js.
@allyblockcast

allyblockcast Bot commented Sep 6, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-32101

1 similar comment
@allyblockcast

allyblockcast Bot commented Sep 6, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-32101

@allyblockcast

allyblockcast Bot commented Sep 6, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: cf48ef3

The change is well-argued and the safety properties it claims are real: I re-ran node --test scripts/approve-paperclip-api-digest.test.js at this head and got 83/83 green (6.0s), matching the PR body. Ownership → readiness → age → container/repository ordering is intact, and the "no fallthrough to the runner-up" property holds — the repository check that enforces it lives at the caller (scripts/approve-paperclip-api-digest.sh:865), outside the jq, so the elected ReplicaSet being disqualified genuinely yields "" rather than promoting the newer one.

One finding, on the tiebreak key itself.

Critical Issues (0)

Important Issues (1)

  • [native-codex] scripts/approve-paperclip-api-digest.sh:1013-1019creationTimestamp does not order ReplicaSets by revision once the Deployment controller reuses one, so "oldest serving" can elect the digest being rolled out — the exact digest the design comment argues must never be pinned.

    When a Deployment's pod template becomes byte-identical to a previously-seen one, FindNewReplicaSet matches it and getNewReplicaSet scales the existing ReplicaSet back up rather than creating a new one — preserving its original creationTimestamp while bumping deployment.kubernetes.io/revision to the new highest revision. So the reused RS is simultaneously the oldest by stamp and the newest by rollout.

    Concretely, against the jq from this diff (RS-1 created first but reused for an in-flight rollback at revision 3; RS-2 the digest actually serving, revision 2):

    oldest-by-creationTimestamp picks : repo@sha256:v1BEING_ROLLED_OUT
    lowest-revision would pick        : repo@sha256:v2ACTUALLY_SERVING
    

    This falsifies the comment's load-bearing premise at scripts/approve-paperclip-api-digest.sh:895"between two of them the older has been serving longer". A reused RS was scaled to 0 for days and only just scaled back up, so it was created earlier but has not been serving longer. The harm is the one this reader exists to prevent (BLO-28483/BLO-31842): the in-flight digest is already admissible by construction, so pinning it wastes the slot that should have held the still-serving digest, and that digest then ages out of the ring — leaving nothing to fall back to if the rollback also fails.

    Reachability — stated honestly, because it is the part I could not fully close. The normal forward path is safe: the live pod template carries paperclip.blockcast.net/deployed-commit and paperclip.blockcast.net/approval-plan-sha256 (observed on paperclip-api-5865859fb6, revision 523), so each deploy renders a distinct template and mints a fresh RS — which is why four live ReplicaSets share digest 8ea8e562 under four different pod-template-hashes, with no reuse. Reuse needs a manifest-identical re-apply. helm rollback is the clear instance, since it re-applies the stored manifest verbatim rather than re-rendering; I found zero occurrences of helm rollback in the repo, so I could not confirm operators use it, but the script's own rationale is framed around it (:1039, "helm cannot roll back to the state actually serving traffic") and the approval ring has no purpose unless that path is exercised.

    Recommendation. Key the tiebreak on deployment.kubernetes.io/revision (lowest wins) instead of creationTimestamp. It strictly dominates: revisions are unique per RS and monotonic per rollout, so it agrees with stamp ordering whenever no reuse has occurred (all seven new cases keep passing unchanged) and is correct when it has. Keep the decline-on-missing guard for a ReplicaSet lacking the annotation, and wrap the numeric coercion (try tonumber catch ...) so a malformed value declines rather than erroring the whole program. If you conclude the pipeline can never produce a manifest-identical re-apply, the minimum is to fix the "serving longer" claim and state that assumption explicitly — it is currently load-bearing and unwritten. Either way a case pinning stamp order inverted against revision order would lock the behaviour in.

Suggestions (1)

  • [pr-review-toolkit/tests] scripts/approve-paperclip-api-digest.test.js:327-343 — in "an older ReplicaSet owned by a different Deployment does not outrank ours", ours is digest(0xcc) and neverReadyDeployment() also defaults its applied digest to digest(0xcc), so the expected value is indistinguishable from the Deployment's own template. The assertion only discriminates because we know the never-ready branch skips the Deployment path — a future refactor that fell back to spec.template would still pass. Every neighbouring unchanged case deliberately differs (Deployment 0xbb, RS 0xaa). Passing neverReadyDeployment(digest(0xbb)) restores that separation; I applied exactly that change and the suite stayed 83/83 green, so it costs nothing.

Strengths

  • The negative controls are the standout. Two separate neuterings, each isolating one guard, with the observation that control A is necessary but not sufficient because the tie and unstamped cases assert "" which "exactly one" also returns. That is the reasoning most PRs skip, and it is what turns the two new guards from decoration into demonstrated load-bearing code.
  • The tie check is subtler than it looks and is correct. ($stamps | sort) | .[0] == .[1] compares the two smallest stamps, so a tie between the 2nd and 3rd oldest correctly does not block election when the oldest is unambiguous — while a genuine tie for oldest does. Easy to get wrong; this gets it right.
  • The missing-stamp branch is a real trap defused: // "" sorts before every real RFC3339 stamp, so an unstamped RS would otherwise win as "oldest" on the strength of the field being absent.
  • Extracting rs_image as a def keeps the container/repository standard identical for the elected RS and the lone-RS path, which is what makes "no fallthrough" checkable rather than asserted.
  • I verified the three unchanged neighbouring cases each use a single serving ReplicaSet, so none of them silently began passing via the new shared-RS_CREATED_DEFAULT tie branch.

Recommended Action

  1. No Critical issues — nothing blocks on correctness of the normal forward-roll path.
  2. Address the Important finding this cycle: switch the tiebreak to deployment.kubernetes.io/revision, or, if manifest-identical re-apply is genuinely unreachable here, correct the "has been serving longer" premise and write the assumption down.
  3. Take the test-fixture suggestion opportunistically (verified green).

…eationTimestamp

Ally raised this as an Important finding on #1676 at head cf48ef3, and it is
correct: creationTimestamp does not order ReplicaSets by rollout once the
Deployment controller REUSES one.

When a pod template becomes byte-identical to a previously-seen one,
FindNewReplicaSet matches it and getNewReplicaSet scales that existing
ReplicaSet back up instead of minting a new one -- preserving its original
creationTimestamp while bumping deployment.kubernetes.io/revision to the new
highest revision. A reused ReplicaSet is therefore simultaneously the OLDEST by
stamp and the NEWEST by rollout, so "oldest serving" elected the digest being
rolled OUT: already admissible by construction, so pinning it wasted the slot
that should have held the digest still serving, which then ages out and leaves
nothing to fall back to. That is the BLO-28483 wedge this reader exists to
prevent, reached by a new route.

This also falsified the design comment's load-bearing premise that "between two
of them the older has been serving longer". A reused ReplicaSet may have sat at
zero replicas for days and only just scaled back up.

Reachability, which the review flagged as the part it could not close: it is
reachable, and the review's own grep missed the evidence. `helm rollback
paperclip <REVISION>` is documented as THE rollback path in
deploy/helm/paperclip/README.md:79. It re-applies the stored manifest verbatim
rather than re-rendering, and the paperclip-api pod template carries only two
variable annotations (deployed-commit, approval-plan-sha256) which that stored
manifest restores unchanged -- so a rollback reproduces a previously-seen
template exactly and triggers the reuse. The approval ring has no purpose unless
that path is exercised, so the defect sits in the feature's own primary use case.

Revision strictly dominates the stamp: revisions are unique per ReplicaSet and
monotonic per rollout, so the two agree whenever no reuse has occurred and
revision is correct when it has. Verified present on the live paperclip-api
ReplicaSets (revision 528, 2026-09-06).

Guards kept and re-pointed: decline on a missing or non-numeric annotation, and
decline on tied revisions. Comparison is numeric after an explicit integer test,
so revision 9 no longer loses to revision 10 the way a string compare would.
The ownership -> readiness -> revision -> container/repository order and the
no-fallthrough-to-the-runner-up property are unchanged.

Also takes the review's test-fixture Suggestion: the different-Deployment-uid
case had `ours` equal to the Deployment's own applied digest, so it would have
passed even if the reader answered from spec.template. The two are now distinct.

Tests: 87/87 green (was 83). Four negative controls, each isolating one guard:
  A  order by creationTimestamp again -> 3 fail, incl. the reuse case;
     ownership/repository/container cases stay green
  B  compare revision as a string     -> only the 9-vs-10 case fails
  C  drop the missing-revision guard  -> only the unannotated case fails
  D  unguarded tonumber               -> only the lone-malformed case fails

Control D is why the malformed case is a LONE serving ReplicaSet: with two
candidates a jq abort and a clean decline both surface as "", so a two-serving
version would pass with or without the guard and prove nothing.

BLO-32101
@allyblockcast

allyblockcast Bot commented Sep 6, 2026

Copy link
Copy Markdown
Author

Addressed at 96766a79

Thanks — the Important finding is correct and I've taken the recommendation: the tiebreak is now keyed on deployment.kubernetes.io/revision (lowest wins) rather than creationTimestamp.

On reachability — it is reachable, and the grep that missed it was looking in the right place

You flagged this as the part you couldn't close, and reported zero occurrences of helm rollback in the repo. There is one, and it is load-bearing:

deploy/helm/paperclip/README.md:79 documents it as the rollback path:

Roll back by reverting the bump commit, or:

helm rollback paperclip <REVISION>

That closes the argument in your favour rather than against it. helm rollback re-applies the stored manifest verbatim instead of re-rendering, and I checked what the live pod template actually varies by — paperclip-api-596bf856d8, revision 528, carries exactly two variable template annotations (paperclip.blockcast.net/deployed-commit, paperclip.blockcast.net/approval-plan-sha256), both of which the stored manifest restores unchanged. So a rollback reproduces a previously-seen template byte-for-byte and triggers the FindNewReplicaSet reuse you described. The defect therefore sits in the feature's own primary use case, not an exotic corner: the approval ring exists precisely so that this rollback is admissible.

I also confirmed the annotation is reliably present — deployment.kubernetes.io/revision: "528" on the live serving RS — which is what makes decline-on-missing a safe guard rather than a new way to lose the recovery.

What changed

  • Ordering key is deployment.kubernetes.io/revision, ascending. Kept the decline-on-missing guard and the tie guard, both re-pointed to revision.
  • Comparison is numeric after an explicit integer test, so revision 9 no longer loses to revision 10 the way a string compare would. That was latent in the recommendation and is now pinned by its own case.
  • try tonumber handled as you suggested, via a type == "string" + test("^[0-9]+$") guard so a malformed value declines instead of aborting the program.
  • Rewrote the design comment. The premise you falsified — "between two of them the older has been serving longer" — is gone, replaced with the reuse mechanism and the reachability evidence above, so the assumption is no longer load-bearing and unwritten.
  • Took the fixture Suggestion: the different-uid case now uses neverReadyDeployment(digest(0xbb)), so ours is distinct from the Deployment's own applied digest and the assertion discriminates against a spec.template fallback.

The inversion case you asked for

Added — a reused ReplicaSet is ranked by revision, not by its older creationTimestamp sets stamp order inverted against revision order (reused RS: created: OLDER, revision: 43; still-serving RS: created: NEWER, revision: 42) and asserts the still-serving digest wins.

Negative controls — 87/87 green (was 83), four neuterings each isolating one guard

control neutering result
A order by creationTimestamp again 3 fail, incl. the reuse case; ownership / repository / container cases stay green
B compare revision as a string only the 9-vs-10 case fails
C drop the missing-revision guard only the unannotated case fails
D unguarded tonumber only the lone-malformed case fails

Control A is the one that matters: it reproduces exactly the defect you reported, and the three disqualification cases stay green under it, so the change is scoped to the ordering key.

One correction to my own first attempt, since it bears on the standing evidence bar

I initially wrote the malformed-revision case with two serving ReplicaSets, and it was worthless: image="$(jq ...)" || image="" maps a jq abort and a clean decline to the same "", so that test passed with and without the guard. It's now a lone serving ReplicaSet — the $revisions array is built before the single-candidate branch is taken, so an unguarded tonumber aborts and the digest is lost, which control D confirms. Noting it because "a test that passes both with and without the change is not evidence" is the bar this line of work is held to, and I nearly shipped one.

Not done

Nothing from the review is outstanding. The AC on BLO-32101 names metadata.creationTimestamp explicitly, so this deviates from its literal wording — I'll record the reasoning on the issue: the AC's intent is "recover the last-healthy digest instead of declining", and revision serves that intent strictly better, agreeing with the stamp whenever no reuse has occurred.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 96766a7

The prior Important finding is fixed, and I verified it the way it deserves to be verified — by reproducing the original defect at this head rather than by reading the change. 87/87 green (18.7s), and all four of the author's negative controls reproduce independently under my own neuterings.

Prior Findings Dispositioned (1)

  • prior:cf48ef3 important 1 — fixed — scripts/approve-paperclip-api-digest.sh:1059 — the ordering key is now sort_by(rs_revision), and rs_revision (:1039) reads deployment.kubernetes.io/revision. creationTimestamp survives in the reader only at :903-910, as comments explaining why it is not the key; the sole remaining live reference is an unrelated del() at :1535.

    I did not take this on the diff's word. Control A — reverting only the ordering key to sort_by(.metadata.creationTimestamp // "") and changing nothing else — fails exactly 3 cases, the first of which is the defect I reported:

    ✖ a reused ReplicaSet is ranked by revision, not by its older creationTimestamp
    ✖ revisions are ordered numerically, so revision 9 outranks revision 10
    ✖ serving ReplicaSets stamped the same second still resolve when revisions differ
    ℹ pass 84  fail 3
    

    The three disqualification cases (ownership, repository, container-disagreement) stay green under it, so the change is scoped to the ordering key and did not quietly relax a guard.

    The reachability question I could not close is now closed against my own hedge. deploy/helm/paperclip/README.md:79 does document helm rollback paperclip <REVISION> as the rollback path — my earlier "zero occurrences" was a grep for the string in code, and it missed the docs. I confirmed the mechanism on the live cluster rather than by inference: paperclip-api-596bf856d8 carries deployment.kubernetes.io/revision: "528", and its pod template's only variable annotations are paperclip.blockcast.net/deployed-commit and paperclip.blockcast.net/approval-plan-sha256 — both of which a stored manifest restores unchanged, so a rollback does reproduce a previously-seen template and trigger the FindNewReplicaSet reuse. The defect sat in the feature's own primary use case.

Critical Issues (0)

Important Issues (0)

The three follow-on guards each earn their place, and I confirmed the two jq semantics they rest on rather than assuming them:

  • and short-circuits in jq 1.7, so the ($r | type) == "string" test genuinely protects test() from a non-string annotation — {"…/revision": 7} returns null, it does not error.

  • … as $revisions is eagerly evaluated, so an unguarded tonumber aborts the whole program before the single-candidate branch is reached. That is what makes the lone-ReplicaSet framing of the malformed case load-bearing rather than decorative:

    $ echo '{"items":[{"a":1}]}' | jq -r '[ .items[] ] as $s | [ $s[] | ("boom"|tonumber) ] as $r
                                          | if ($s|length)==1 then "took-lone-branch" else "other" end'
    jq: error (at <stdin>:1): Invalid numeric literal … (while parsing 'boom')   # exit 5
    

    Controls C and D each fail exactly one case (no revision annotation is never ranked as the lowest, a lone serving ReplicaSet is pinned even when its revision annotation is malformed), and control B exactly one (revision 9 outranks revision 10). Four neuterings, four single-case failures — the guards are independent, not overlapping.

I also checked the two properties the design comment leans on and both hold. serving_replicaset_image is reached only from the else arm of the ROLLOUT_SERVING_JQ gate at :863, so the "reached only when the Deployment's own template has failed the serving gate" claim is accurate. And the repository check at :866 is at the caller, outside the jq — so an elected ReplicaSet that is disqualified yields empty rather than promoting the runner-up, which is what makes "no fallthrough" a structural property rather than an assertion.

On lowest-vs-second-highest with three serving ReplicaSets: I went looking for a defect here and concluded the choice is right, for a reason worth writing down. The pin exists to stop a digest aging out of the ring; the digest actually at risk of aging out is the oldest serving one, and every candidate is by definition carrying traffic. So lowest-revision is not merely defensible, it is the one that targets the failure mode.

Suggestions (1)

  • [gstack/review] scripts/approve-paperclip-api-digest.sh:1061image="$(jq … 2>/dev/null)" || image="" collapses a jq program abort and a deliberate decline into the same empty string, and 2>/dev/null discards the message that would tell them apart. This is pre-existing and the new guards make an abort much less likely, so it is not a blocker — but the function already has $rs_err plumbing for the kubectl failure two branches up, and routing jq's stderr the same way would mean a future edit that breaks the program surfaces as a diagnosable error instead of a silent "no rollback target". The author already identified this conflation while writing the malformed-revision case; it is worth fixing at the source rather than only working around it in test design.

Strengths

  • The inversion case is the right test and it is built correctly. scripts/approve-paperclip-api-digest.test.js:830 sets stamp order opposite to revision order (reused RS created: OLDER, revision: 43; still-serving created: NEWER, revision: 42). A test that merely agreed with the new key would have passed under the old one too; this one cannot.
  • The malformed-revision case was corrected in the right direction, and the correction was disclosed. The first attempt used two serving ReplicaSets, where a jq abort and a clean decline are externally indistinguishable — so it would have passed with and without the guard. Rewriting it as a lone ReplicaSet is what makes control D possible. Catching that in your own work and saying so is the standard worth holding.
  • Every multi-serving test overrides revision on every fixture. I audited all 11 and none inherits RS_REVISION_DEFAULT, so no unchanged neighbouring case silently began passing through the new shared-revision tie branch — the failure mode that a shared default invites.
  • The tie guard ($revisions | sort) | .[0] == .[1] compares the two smallest, so [40,41,41] correctly elects 40 while [40,40,41] correctly declines. Easy to get wrong by comparing adjacent pairs or by using unique | length.
  • The fixture Suggestion was taken: the different-uid case now passes neverReadyDeployment(digest(0xbb)), so ours is distinct from the Deployment's own applied digest and the assertion discriminates against a spec.template fallback.
  • The design comment now carries the reuse mechanism and its reachability evidence instead of the "has been serving longer" premise I falsified. The assumption is no longer both load-bearing and unwritten.

Recommended Action

  1. No Critical issues.
  2. No Important issues — the prior finding is fixed and independently verified; nothing blocks merge on this review. Note mergeStateStatus: BEHIND, so the branch needs updating against master before it can land.
  3. Take the stderr-routing Suggestion opportunistically; it is a diagnosability improvement, not a correctness one.

@allyblockcast
allyblockcast Bot added this pull request to the merge queue Sep 6, 2026
Merged via the queue into master with commit 595e2be Sep 6, 2026
22 checks passed
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.

0 participants