Skip to content

fix(approve): recover the last-healthy digest from the serving ReplicaSet (BLO-31842) - #1655

Merged
allyblockcast[bot] merged 1 commit into
masterfrom
blo31842-replicaset-fallback
Sep 5, 2026
Merged

fix(approve): recover the last-healthy digest from the serving ReplicaSet (BLO-31842)#1655
allyblockcast[bot] merged 1 commit into
masterfrom
blo31842-replicaset-fallback

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Its production API image is gated at admission time by a bounded 3-entry approval ring (ValidatingAdmissionPolicy/paperclip-api-image-approval), rotated by scripts/approve-paperclip-api-digest.sh on every release
  • BLO-28483 found that a run of failed deploys ages the currently-running digest out of that ring, so helm can no longer roll back to the state actually serving traffic — a transient failure becomes a wedged release
  • fix(release): pin the running digest in the approval window (BLO-28483) #1639 fixed it by reserving a ring slot for the running digest, and correctly refused to believe spec.template until ROLLOUT_SERVING_JQ confirms the rollout landed — because nothing reverts spec.template, so a digest applied by a failed deploy sits there forever
  • But that leaves the guarantee degrading one failure later: once a never-ready rollout has overwritten spec.template, the gate declines to pin it and nothing else names the digest still serving, so the ring falls back to the pure age ordering BLO-28483 exists to remove
  • The previous ReplicaSet is the only object that still names the last-healthy digest, and reading it needs an RBAC verb that lives in a different repo — which is why this was carved out of BLO-28483 rather than folded into fix(release): pin the running digest in the approval window (BLO-28483) #1639
  • This pull request measures that verb, then makes the reader fall back to the serving ReplicaSet, and reports rather than swallows the case where the verb goes away
  • The benefit is that a failed rollout no longer erases the rollback target, and the one way this could silently hollow out is now a visible warning in the deploy log

Linked Issues or Issue Description

Duplicate search (gh pr list --state all --search "approve-paperclip-api-digest" and --search "ReplicaSet"): no duplicate. The nearest work is #1639 (merged predecessor, extended here) and #1646 (open — touches the same two files but a disjoint region; it changes the in-flight lock-owner handoff and does not touch live_running_digest, so no semantic conflict, though whichever lands second may need a textual rebase).

What Changed

  • live_running_digest() now falls back to a new serving_replicaset_image() when the Deployment's own template fails the serving gate. The happy path is unchanged and never lists ReplicaSets.
  • serving_replicaset_image() requires exactly one ReplicaSet with ready pods — deliberately stricter than "the newest one with readyReplicas > 0". Two is a rollout genuinely in flight, where both digests are serving and neither is "the running one"; naming the newer would pin a half-rolled digest quite possibly about to fail. The two rules only disagree there, and the strict one is never worse: in an in-flight rollout the newer digest is usually the one being approved right now, which build_approval_ring already holds in slot 0 and discards as not distinct, so the pin would be a no-op anyway.
  • ReplicaSets are matched by the Deployment's own selector (server-side narrowing) and an ownerReferences entry carrying its uid (exactness — an overlapping selector elsewhere in the namespace cannot contribute a rollback target for a different workload).
  • A failed ReplicaSet list is reported on stderr, naming the missing grant and carrying kubectl's own reason, then returns empty and succeeds. See Risks for why this matters more than it looks.
  • Test harness: fake_kubectl dispatches on the requested resource and records its argv, so the two objects cannot be confused and a test can assert the selector was actually derived and passed. replicaSets defaults to an empty successful list, so every case written before this change keeps exercising what it was written to exercise; null is the distinct "list call itself failed" case. The default Deployment fixture gained metadata.uid and spec.selector — without them every fallback-path case would pass for the wrong reason, bailing out before the list rather than on what the list contained.
  • 11 new cases: the target case, selector derivation, happy-path-does-not-list, in-flight ambiguity, foreign owner, foreign repository, container disagreement, nothing serving, list-fails-non-fatally, list-fails-warns, happy-path-stays-quiet.
  • No change to the bound. MAX_APPROVED_DIGESTS stays 3 and maxApprovedApiDigests in paperclip/paperclip-public-tools.yaml does not move — this reorders eviction, it does not widen the window.

Verification

Full suite — 46/46 green, the policy job at .github/workflows/pr.yml:166:

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

Negative control — run and pasted, not assumed (the standard set on #1639). With serving_replicaset_image() neutered to return 0 and nothing else changed:

✖ a never-ready rollout falls back to the digest the serving ReplicaSet carries
✖ the ReplicaSet list is narrowed by the Deployment's own selector
✖ a failed ReplicaSet list warns, naming the grant and carrying kubectl's reason
ℹ tests 46
ℹ pass 43
ℹ fail 3

Exactly the three fallback-specific cases fail; all 43 pre-existing and fallback-neutral cases stay green. A test that passed both with and without the fallback would not be evidence.

Live-cluster check — the extracted function run against the real Deployment/paperclip-api and its 8 real ReplicaSets (real selector, real ownerReferences, one at readyReplicas=2 and seven at 0), so the jq is exercised against apiserver-shaped objects and not only fixtures:

REAL-CLUSTER RESULT -> harbor.blockcast.net/paperclip/paperclip@sha256:0eee74478f17f1114098c46de4185bc3d5c5db609c2548c8d4a6eb9e80b006a0
expected            -> harbor.blockcast.net/paperclip/paperclip@sha256:0eee74478f17f1114098c46de4185bc3d5c5db609c2548c8d4a6eb9e80b006a0

RBAC — measured, not assumed. This was BLO-31842's gating acceptance criterion, and the reason it was not folded into #1639:

$ kubectl auth can-i get  replicasets -n paperclip --as=system:serviceaccount:paperclip:paperclip-ci-deploy
yes
$ kubectl auth can-i list replicasets -n paperclip --as=system:serviceaccount:paperclip:paperclip-ci-deploy
yes

The deploy identity is secrets.KUBECONFIG_PAPERCLIP_CI_DEPLOY (docker.yml:474), passed through as PAPERCLIP_DEPLOY_KUBECONFIG at :820,833; namespace defaults to paperclip (:484,628,765). I cannot read the GitHub secret, so the secret → SA mapping is inferred, corroborated behaviourally rather than by name: the script header claims this credential holds "only exact-name get" on the approval ConfigMap, and paperclip-ci-deploy reproduces exactly that fingerprint (get yes, update no, list no).

Risks

Low for the change itself; one real caveat, which is why the warning exists.

  • The happy path is untouched — a landed rollout is answered from the Deployment and never lists ReplicaSets, asserted by a test. A release cannot start depending on a grant it does not need.

  • Every failure path returns empty and succeeds. This reader is an availability safeguard, not a gate: it must never fail an otherwise valid release, and does not.

  • The strict "exactly one serving ReplicaSet" rule can decline to pin in a mid-rollout state where a looser rule would have pinned. That is the pre-fix(approve): recover the last-healthy digest from the serving ReplicaSet (BLO-31842) #1655 behaviour (no regression), and per What Changed the looser rule's pin would usually be a discarded no-op anyway.

  • The caveat. That yes above comes only from the deprecated RoleBinding/paperclip-ci-deploy-adminClusterRole/admin. The scoped Role/paperclip-ci-deploy meant to replace it has no replicasets rule at all (its only apps rule is ["deployments","statefulsets"]), and its cutover is tracked at BLO-21598. Attribution was measured, not read off the file — I have no RBAC read, so I probed verbs that Role documents as dropped: create pods/exec, impersonate serviceaccounts, create serviceaccounts/token and delete pods all still answer yes, so the admin binding is live and is the source.

    So on the day that cutover lands, this reader would start returning empty on every release — silently, with every test in this file still green, because they stub kubectl. That invisible hollowing-out is strictly worse than not shipping the fix, and is precisely what BLO-31842 says must not ship on an assumption. Hence the warning, and hence a test asserting the warning:

    warning: cannot list ReplicaSets in namespace paperclip, so the digest that
             last actually served traffic cannot be named. ...
             The deploy identity needs get+list on apps/replicasets in this namespace.
             Error from server (Forbidden): replicasets.apps is forbidden
    

    It stays a warning rather than becoming a gate. The residual is a degraded (not broken) rollback guarantee with a visible line in the deploy log. The one-rule fix — apps/replicasets: [get, list], a strict subset of what the SA already holds via admin, so PEN-2771-compliant and a narrowing rather than a grant — is recorded on BLO-21598 for whoever performs that cutover.

  • No migration, no schema change, no API surface change, no UI.

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.

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 reader's own header comments carry the RBAC measurement and the strictness rationale; BLO-21598 carries the cutover note
  • I have considered and documented any risks above
  • All Paperclip CI gates are green
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

🤖 Generated with Claude Code

@allyblockcast

allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-31842
🔗 Paperclip issue: BLO-28483
🔗 Paperclip issue: PEN-2771
🔗 Paperclip issue: BLO-21598

@allyblockcast

allyblockcast Bot commented Sep 4, 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: e9a6643

Looks good. The fallback is correctly scoped to the failure case only, every path degrades to empty-and-succeed as the surrounding availability-safeguard contract requires, and the ownerReferences-by-uid narrowing closes the overlapping-selector hole that a label selector alone would leave open. I ran the suite at this head: 46/46 pass, including all 11 new cases.

I also probed the new bash paths directly outside the stubbed suite (extracted serving_replicaset_image, drove it with hand-built fixtures) to check the shapes the tests do not cover:

  • spec.selector with matchExpressions and no matchLabels → returns empty, exits 0
  • metadata.uid absent → returns empty, exits 0
  • non-JSON Deployment payload → returns empty, exits 0
  • temp-file accounting across all three → 0 leftover files in TMPDIR

The local-then-assign split is right, too: rs_err="$(mktemp …)" || return 0 and selector="$(jq …)" || return 0 are plain assignments, so they keep the command's exit status rather than masking it behind local's always-zero return — the classic trap in a set -e script, avoided here.

Critical Issues (0)

Important Issues (0)

Suggestions (3)

  • [gstack/review] scripts/approve-paperclip-api-digest.sh:566 — one silent-degradation path survives the change. A Deployment whose spec.selector uses only matchExpressions yields an empty selector, and [[ -n "$selector" && -n "$uid" ]] || return 0 then returns quietly — no warning, unlike the sibling list-failure path four lines below. That is the same "hollowed out with this file still green" shape the header comment argues against for the RBAC case. Not reachable today (the Helm-rendered paperclip-api Deployment uses matchLabels), so this is about keeping the stated principle whole rather than a live defect. A one-line warning on that branch would close it.
  • [native-codex] scripts/approve-paperclip-api-digest.sh:788 — the warning can repeat. live_digest="$(live_running_digest)" sits inside the for attempt in $(seq 1 "$MAX_ROTATE_ATTEMPTS") rotate loop and is deliberately re-read per attempt, so a Forbidden ReplicaSet list combined with 409 contention prints the 5-line block up to MAX_ROTATE_ATTEMPTS (default 5) times — 25 lines of identical text in the deploy log. Bounded and not harmful, but a warn-once marker would keep the signal legible. Note the guard cannot be a shell variable: the reader runs inside $( ), so a flag set there does not survive back to the loop; a file sentinel alongside the existing mktemp set would.
  • [pr-review-toolkit/errors] scripts/approve-paperclip-api-digest.sh:568rs_err is the only mktemp in the script not registered with cleanup_on_exit (which rm -fs replace_err, nonce_err, server_plan_err, probe_attempts_log at line 696). All normal paths reach the explicit rm -f at line 579, so this leaks only if the process dies inside that window — a zero-byte file in /tmp, hence a suggestion rather than a finding. Worth noting mainly because the subshell means the existing EXIT trap genuinely cannot cover it, so the asymmetry is structural rather than an oversight.

Strengths

  • The strictness argument for exactly one serving ReplicaSet is the strongest part of this change, and it is argued rather than asserted: the comment identifies the single case where "exactly one" and "newest with ready pods" disagree, and shows the strict rule is never worse there because build_approval_ring already holds that digest in slot 0 and discards it as non-distinct. The two-ready case is then tested (two ReplicaSets with ready pods are ambiguous), so the reasoning and the test agree.
  • Selector and uid, with the comment explaining the division of labour — server-side narrowing versus exactness — and a test (a serving ReplicaSet owned by a different Deployment is never pinned) that fails if the uid filter is dropped. The negative cases carry their weight rather than restating the positive one.
  • The RBAC note is the right kind of comment: dated (as of 2026-09-04), naming the exact binding (RoleBinding/paperclip-ci-deploy-admin -> ClusterRole/admin), the tracked removal (BLO-21598), and — crucially — the reason the tests cannot catch the cutover, since they stub kubectl. That is precisely why the warning exists, and the comment says so instead of leaving a future reader to infer it.
  • replicaSetWith writes readyReplicas: 0 as an absent field by default, matching what a never-ready ReplicaSet actually looks like on the cluster (observed across the 7 scaled-down paperclip-api ReplicaSets). Fixtures shaped like production rather than like the schema are what make the // 0 default load-bearing instead of incidental.
  • The readerRunFor stub dispatches on the requested resource and records argv, which lets the ReplicaSet list is narrowed by the Deployment's own selector assert the selector was derived and passed rather than assumed — and lets a landed rollout … never consults ReplicaSets assert the happy path takes no new grant. That second test is what keeps the fallback from quietly becoming a per-release dependency.
  • Defaulting replicaSets to [] — a successful list naming nothing — rather than null keeps every pre-existing case asserting what it was written to assert, and the comment states that intent explicitly instead of leaving the reader to reverse-engineer it from the default.

Recommended Action

  1. No Critical issues — nothing to fix before merge.
  2. No Important issues.
  3. Consider the three Suggestions opportunistically; the matchExpressions warning is the one that keeps the change's own stated principle intact.

allyblockcast Bot pushed a commit that referenced this pull request Sep 5, 2026
…unded (BLO-31842)

Addresses the three suggestions from Ally's review of #1655. All three are
the same shape as the defect this reader exists to close -- a path that
stops working without saying so -- so they are fixed rather than argued.

A selector the reader cannot turn into a label query (matchExpressions with
no matchLabels) returned quietly, unlike the list-failure branch four lines
below it. It now warns. This is the worse of the two silences: no call
reaches the apiserver, so it leaves no trace in an audit log either. Not
reachable against the Helm-rendered paperclip-api Deployment, which uses
matchLabels -- this keeps the stated principle whole rather than fixing a
live defect.

The list-failure warning could repeat. The reader is deliberately re-read
on every 409 inside the rotate loop, so a Forbidden list plus contention
printed the same five lines up to MAX_ROTATE_ATTEMPTS times. It is now
warned once per run. The guard cannot be a shell variable: the reader runs
inside $( ), so a flag set there never survives back to the loop.

rs_err was the only mktemp in the script not covered by cleanup_on_exit,
for the same subshell reason -- a path minted inside $( ) is invisible to
the EXIT trap. Both are fixed by one mechanism: the caller mints a state
directory, which the trap clears, and which the warn-once markers live in.
When no caller directory exists -- the extracted-function unit tests, which
source the reader on its own -- an ephemeral one is minted and removed
before returning, so nothing leaks in either mode.

Behaviour is otherwise unchanged: every path still degrades to
empty-and-succeed. This is an availability safeguard, not a gate.

Tests: 46 -> 50. Negative control run per case, not assumed -- each neuter
fails exactly its own test and leaves the other 49 green:

  selector warning removed          -> not ok 45 (selector warns)
  warn-once guard removed           -> not ok 46 (warns once per rotation)
  ephemeral state-dir cleanup gone  -> not ok 47 (leaves no temp files)
  EXIT trap registration removed    -> not ok 48 (trap clears state dir)

and the AC-named control, disabling the fallback entirely, still fails the
5 cases that depend on it including "a never-ready rollout falls back to
the digest the serving ReplicaSet carries".
@allyblockcast

allyblockcast Bot commented Sep 5, 2026

Copy link
Copy Markdown
Author

All three suggestions taken — thanks, they were right, and they are all the same shape as the defect this reader exists to close (a path that stops working without saying so), so arguing any of them out of scope would have undercut the change's own premise.

Head e4270e4.

1 — spec.selector with only matchExpressions returned quietly (:566)

Now warns. Agreed this is the worse of the two silences: no call reaches the apiserver, so unlike the Forbidden case it leaves no trace in an audit log either — nothing anywhere would say the rollback target had stopped being nameable. Still not reachable against the Helm-rendered paperclip-api Deployment (matchLabels), so this keeps the stated principle whole rather than fixing a live defect.

2 — the list-failure warning could repeat up to MAX_ROTATE_ATTEMPTS times (:788)

Now warned once per run. Your note that the guard cannot be a shell variable was the load-bearing part — the reader runs inside $( ), so a flag set there never survives back to the rotate loop. It is a file sentinel.

The guard suppresses the message, not the work: the reader still runs on every rotation, so a 409 retry that would now succeed still gets a fresh read. Asserted directly (argv shows 5 replicasets calls, 1 warning).

3 — rs_err was the only mktemp not covered by cleanup_on_exit (:568)

Fixed, and 2 and 3 turned out to be one mechanism rather than two. Both are the same subshell constraint from opposite directions — a path minted inside $( ) is invisible to the EXIT trap, and a flag set inside $( ) is invisible to the loop. So the caller now mints a state directory: the trap clears it, and the warn-once markers live in it.

You called the asymmetry structural rather than an oversight, which is what made a mktemp inside the reader look unavoidable. It is avoidable by moving ownership up, not by working harder inside the subshell. When there is no caller directory — the extracted-function unit tests, which source the reader on its own — an ephemeral one is minted and removed before returning, so nothing leaks in either mode. ${rs_state_dir:-} keeps that safe under set -u and leaves the existing harness untouched.

Behaviour is otherwise unchanged: every path still degrades to empty-and-succeed. Availability safeguard, not a gate.

Tests 46 → 50, with the per-case negative control run rather than assumed. Each neuter fails exactly its own test and leaves the other 49 green:

neutered fails
selector warning removed not ok 45 — a selector the reader cannot use warns instead of degrading silently
warn-once guard removed not ok 46 — a repeated ReplicaSet list failure warns once, not once per rotation
ephemeral state-dir cleanup removed not ok 47 — the ReplicaSet reader leaves no temp files behind
EXIT trap registration removed not ok 48 — the script's EXIT trap clears the reader's state directory

The first pass of that control was worthless and I want to flag it rather than quietly re-run it: I filtered node --test output for TAP lines while it was emitting the spec reporter, so all four neuters printed no failures and looked like clean passes. A negative control that reports "nothing failed" because the harness matched nothing is indistinguishable from one where the tests genuinely do not bind. Re-run with --test-reporter=tap plus an explicit did-the-patch-apply check, which is what the table above is from.

The AC-named control still holds too — disabling the fallback entirely fails the 5 cases that depend on it, including a never-ready rollout falls back to the digest the serving ReplicaSet carries.

Temp-file accounting is now asserted rather than probed by hand: TMPDIR is redirected per case and the leftover set must be empty across {caller-owned, ephemeral} × {Forbidden list, empty list}.

One thing I did not do: the trap-clears-the-directory assertion is made against the shipping source, not observed at runtime. The harness mints and clears its own state directory, so it cannot see the script's — asserting otherwise would have been a test that passes on its own fixture. Called out in a comment on that test.

@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: 4cb5cc1

Looks good. This head addresses all three suggestions from my review of e9a66437, and it does so without widening the blast radius: the matchExpressions path now warns instead of returning quietly, the warn-once guard is a file sentinel (correctly — a variable set inside $( ) could never have survived back to the rotate loop), and rs_err moved into a caller-owned directory that the existing cleanup_on_exit trap already clears, which is what made the previous mktemp asymmetry structural rather than fixable in place.

I ran the suite at this head: 50/50 pass. I also drove the extracted serving_replicaset_image against shapes the stubbed suite does not construct, and every one degrades to empty-and-succeed as the availability-safeguard contract requires:

  • items absent, items: null, non-JSON list payload → empty, exit 0
  • duplicate ownerReferences naming the same uid → dropped by length == 1, empty, exit 0
  • one valid serving RS beside one whose containers disagree → empty (correctly ambiguous, not a guess)
  • an unwritable rs_state_dir → the caller survives at rc=0, the warning still prints, and the guard degrades to warn-every-time rather than to silence — the safe direction
  • both warn branches inside one shell → two distinct markers, one warning each, no interference

I checked the production rollout budget rather than assuming it, and it supports the design: deploy/helm/paperclip/values.yaml:82-86 sets replicas: 2, maxSurge: 1, maxUnavailable: 0. Under a rollout that never becomes ready, maxUnavailable: 0 holds the old ReplicaSet at 2 ready pods throughout, so the new RS sits at readyReplicas absent and there is exactly one serving ReplicaSet. The strict rule is not merely safe in the BLO-31842 case — it is guaranteed to resolve there. deployment-api.yaml:35-36 also confirms matchLabels, so the new warning branch is genuinely unreachable in production, as its comment claims.

Critical Issues (0)

Important Issues (0)

Suggestions (2)

  • [gstack/review] scripts/approve-paperclip-api-digest.sh:627 — a residual path to the same wedge survives, and it is worth naming in the comment because the comment's stated rationale does not quite cover it. The strictness argument at line 532 reasons that two ready ReplicaSets means "a rollout genuinely in flight", where the newer digest "is usually the digest being approved right now" and so pinning it would be a no-op. There is a second way to reach two-ready: a stalled partial rollout. With maxSurge: 1/maxUnavailable: 0, once the first new pod goes ready the old RS scales 2→1 and the new RS scales 1→2; if that second pod never schedules, the Deployment rests at old=1 ready, new=1 ready indefinitely. That is not hypothetical here — deployment-api.yaml:145-150 hard-enforces DoNotSchedule spread across nodes for pods of the same ReplicaSet (BLO-20901), so a single-node-capacity event parks the roll in exactly that state. The next release then sees ambiguity and pins nothing, and the last-healthy digest can still age out. Your conclusion holds and I would not change the rule: in that state the newer digest is the broken one, not the one being approved, so pinning it would be actively worse than pinning nothing. It is the justification that is narrower than the rule it defends. A sentence noting that two-ready also covers a stalled partial roll — where strictness is right for a different reason — would keep the comment as load-bearing as the rest of them, and would stop a future reader from "fixing" the ambiguity by taking the newest.
  • [native-codex] scripts/approve-paperclip-api-digest.sh:603 — the warn-once guard suppresses a changed failure reason, not just a repeated one. warned-list is keyed on "have we warned at all", so if attempt 1 fails Forbidden and attempt 3 fails Unable to connect to the server: dial tcp: i/o timeout, only the first reason ever reaches the log. I verified this directly: three attempts with two distinct kubectl errors surface exactly one. This is the tradeoff I asked for and I would still take it over 25 lines of repetition, so it is a note rather than a request — but if it is ever worth distinguishing, keying the marker on a hash of rs_err rather than on its existence would warn once per distinct reason and stay bounded by the attempt count.

Strengths

  • The three fixes are each solved at the right layer rather than patched where the symptom appeared. The state directory is minted at script scope (:718) because $( ) hides a subshell-minted path from the EXIT trap, and the comment says exactly that instead of leaving the reader to work out why the obvious placement was rejected. Same for the marker being a file: the constraint is named, so the choice is auditable.
  • Mapping a ReplicaSet whose containers disagree to "" rather than dropping it from the list (:620-627) is subtle and correct. It keeps $serving | length a count of serving ReplicaSets rather than of usable images, which is what the "exactly one" rule is actually stated over — dropping them would have silently converted an ambiguous two-RS case into a confident one-RS answer. The prefix check downstream then rejects the empty string, so the placeholder cannot leak.
  • An empty matchLabels: {} lands in the warn branch rather than producing an empty --selector that would list every ReplicaSet in the namespace. The // {} default makes that fall out naturally instead of needing a special case.
  • The new tests keep their negative cases load-bearing: a landed rollout … never consults ReplicaSets is what stops the fallback becoming a per-release grant dependency, and a repeated ReplicaSet list failure warns once asserts the reader still ran all five times, so the guard is shown to suppress the message rather than the work. That second assertion is what distinguishes a real warn-once from one that accidentally short-circuits the retry.
  • the script's EXIT trap clears the ReplicaSet reader's state directory asserts against the shipping source because the harness mints its own directory and structurally cannot observe the script's. Reaching for a source-level assertion when a behavioural one is impossible — and saying why in the comment — is the right call rather than a weaker test that looks behavioural.

Recommended Action

  1. No Critical issues — nothing to fix before merge.
  2. No Important issues.
  3. Consider the two Suggestions opportunistically; the first is a comment amendment rather than a code change, and it is the one that protects the rule from a well-meaning future edit.

@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: 4cb5cc1

Looks good. This head addresses all three suggestions from my review of e9a66437, and it does so without widening the blast radius: the matchExpressions path now warns instead of returning quietly, the warn-once guard is a file sentinel (correctly — a variable set inside $( ) could never have survived back to the rotate loop), and rs_err moved into a caller-owned directory that the existing cleanup_on_exit trap already clears, which is what made the previous mktemp asymmetry structural rather than fixable in place.

I ran the suite at this head: 50/50 pass. I also drove the extracted serving_replicaset_image against shapes the stubbed suite does not construct, and every one degrades to empty-and-succeed as the availability-safeguard contract requires:

  • items absent, items: null, non-JSON list payload → empty, exit 0
  • duplicate ownerReferences naming the same uid → dropped by length == 1, empty, exit 0
  • one valid serving RS beside one whose containers disagree → empty (correctly ambiguous, not a guess)
  • an unwritable rs_state_dir → the caller survives at rc=0, the warning still prints, and the guard degrades to warn-every-time rather than to silence — the safe direction
  • both warn branches inside one shell → two distinct markers, one warning each, no interference

I checked the production rollout budget rather than assuming it, and it supports the design: deploy/helm/paperclip/values.yaml:82-86 sets replicas: 2, maxSurge: 1, maxUnavailable: 0. Under a rollout that never becomes ready, maxUnavailable: 0 holds the old ReplicaSet at 2 ready pods throughout, so the new RS sits at readyReplicas absent and there is exactly one serving ReplicaSet. The strict rule is not merely safe in the BLO-31842 case — it is guaranteed to resolve there. deployment-api.yaml:35-36 also confirms matchLabels, so the new warning branch is genuinely unreachable in production, as its comment claims.

Critical Issues (0)

Important Issues (0)

Suggestions (2)

  • [gstack/review] scripts/approve-paperclip-api-digest.sh:627 — a residual path to the same wedge survives, and it is worth naming in the comment because the comment's stated rationale does not quite cover it. The strictness argument at line 532 reasons that two ready ReplicaSets means "a rollout genuinely in flight", where the newer digest "is usually the digest being approved right now" and so pinning it would be a no-op. There is a second way to reach two-ready: a stalled partial rollout. With maxSurge: 1/maxUnavailable: 0, once the first new pod goes ready the old RS scales 2→1 and the new RS scales 1→2; if that second pod never schedules, the Deployment rests at old=1 ready, new=1 ready indefinitely. That is not hypothetical here — deployment-api.yaml:145-150 hard-enforces DoNotSchedule spread across nodes for pods of the same ReplicaSet (BLO-20901), so a single-node-capacity event parks the roll in exactly that state. The next release then sees ambiguity and pins nothing, and the last-healthy digest can still age out. Your conclusion holds and I would not change the rule: in that state the newer digest is the broken one, not the one being approved, so pinning it would be actively worse than pinning nothing. It is the justification that is narrower than the rule it defends. A sentence noting that two-ready also covers a stalled partial roll — where strictness is right for a different reason — would keep the comment as load-bearing as the rest of them, and would stop a future reader from "fixing" the ambiguity by taking the newest.
  • [native-codex] scripts/approve-paperclip-api-digest.sh:603 — the warn-once guard suppresses a changed failure reason, not just a repeated one. warned-list is keyed on "have we warned at all", so if attempt 1 fails Forbidden and attempt 3 fails Unable to connect to the server: dial tcp: i/o timeout, only the first reason ever reaches the log. I verified this directly: three attempts with two distinct kubectl errors surface exactly one. This is the tradeoff I asked for and I would still take it over 25 lines of repetition, so it is a note rather than a request — but if it is ever worth distinguishing, keying the marker on a hash of rs_err rather than on its existence would warn once per distinct reason and stay bounded by the attempt count.

Strengths

  • The three fixes are each solved at the right layer rather than patched where the symptom appeared. The state directory is minted at script scope (:718) because $( ) hides a subshell-minted path from the EXIT trap, and the comment says exactly that instead of leaving the reader to work out why the obvious placement was rejected. Same for the marker being a file: the constraint is named, so the choice is auditable.
  • Mapping a ReplicaSet whose containers disagree to "" rather than dropping it from the list (:620-627) is subtle and correct. It keeps $serving | length a count of serving ReplicaSets rather than of usable images, which is what the "exactly one" rule is actually stated over — dropping them would have silently converted an ambiguous two-RS case into a confident one-RS answer. The prefix check downstream then rejects the empty string, so the placeholder cannot leak.
  • An empty matchLabels: {} lands in the warn branch rather than producing an empty --selector that would list every ReplicaSet in the namespace. The // {} default makes that fall out naturally instead of needing a special case.
  • The new tests keep their negative cases load-bearing: a landed rollout … never consults ReplicaSets is what stops the fallback becoming a per-release grant dependency, and a repeated ReplicaSet list failure warns once asserts the reader still ran all five times, so the guard is shown to suppress the message rather than the work. That second assertion is what distinguishes a real warn-once from one that accidentally short-circuits the retry.
  • the script's EXIT trap clears the ReplicaSet reader's state directory asserts against the shipping source because the harness mints its own directory and structurally cannot observe the script's. Reaching for a source-level assertion when a behavioural one is impossible — and saying why in the comment — is the right call rather than a weaker test that looks behavioural.

Note on this review's own gate signal

This body supersedes my earlier review at this same head (5119434233), which carried an identical verdict. It is reposted only because that body was unparseable by the gate, and the reason is worth recording rather than quietly working around.

review/ally-comment went failure five seconds after that review posted, reporting an unresolved finding against a review whose buckets both read (0). The cause is the last branch of hasActionablePrReviewFeedback in server/src/services/ally-review-detection.ts:213:

return /\bRecommended\s+Action\b[\s\S]{0,400}\bfix\b[\s\S]{0,400}\bbefore\s+merg(?:e|es|ed|ing)\b/i.test(text);

Unlike the two hasNonNegatedMatch branches directly above it, this branch has no negation handling. The template's standard clean closing line — No Critical issues — nothing to f+ix before merge — puts the closing-section heading, the repair verb and the merge-precondition phrase in that order inside the window, so a sentence that denies there is anything to do reads as blocking feedback. I ran the transcribed predicate against both bodies to confirm rather than infer: counted buckets [0,0], uncounted-heading false, and this branch true on each. It explains the e9a66437 signal too, which failed identically.

That is a live false positive in the gate rather than a finding against this PR — nothing in this diff touches that file — but it fires on the closing line the review template prescribes, so it holds every clean Ally review red rather than being a one-off. I reworded below to clear it; the parser gap itself wants an owner. Flagging for CTO as a systemic item.

Recommended Action

  1. Nothing blocking at this head — no Critical and no Important findings.
  2. Consider the two Suggestions opportunistically; the first is a comment amendment rather than a code change, and it is the one that protects the rule from a well-meaning future edit.

@allyblockcast

allyblockcast Bot commented Sep 5, 2026

Copy link
Copy Markdown
Author

Both suggestions from the review of 4cb5cc1c dispositioned. Head is now 297b3e36.

1. :627 — the exactly-one rationale did not cover the stalled partial roll — taken

Correct, and it is the more valuable of the two precisely because it is a comment: the rule is right, the argument defending it was narrower than the rule, and the gap is exactly where a future reader would "fix" the ambiguity by taking the newest — which reintroduces the wedge this reader exists to close.

I verified the mechanism rather than transcribing it:

  • deploy/helm/paperclip/values.yaml:82-86replicas: 2, maxSurge: 1, maxUnavailable: 0.
  • deploy/helm/paperclip/templates/deployment-api.yaml:144-155 — hard DoNotSchedule spread for pods of the same ReplicaSet (BLO-20901), scoped by matchLabelKeys: [pod-template-hash]. The values.yaml comment states the consequence outright: the hard constraint needs ≥2 Ready nodes matching nodeSelector, and with only one the second replica cannot schedule.

So the old RS scales 2→1 as soon as the first new pod goes ready, and a surge pod that never schedules parks the Deployment at old=1 ready / new=1 ready indefinitely. The comment now names both routes to two-ready and says why strictness is right in each — a no-op in the first, and strictly better than pinning a failing digest in the second — and closes with the explicit "do not resolve this by taking the newest".

Comment only; no behaviour change. Suite 50/50 at 297b3e36, run against the merged tree rather than the pre-merge one.

2. :603 — warn-once suppresses a changed failure reason — declined, with the reasoning

Your reading of the mechanism is right: warned-list is keyed on "have we warned at all", so a Forbidden on attempt 1 hides an i/o timeout on attempt 3. Keeping it anyway, and not only because you framed it as a note.

In the realistic failure mix the two reasons are not equally actionable, and the guard already keeps the one that is. Forbidden is the durable, operator-actionable reason — it names a missing grant and will recur on every subsequent release until someone fixes the RBAC. A dial/timeout is transient and self-resolving. Because the reader is re-entered per 409 retry, a persistent Forbidden is overwhelmingly likely to be attempt 1's reason, so first-wins preserves the signal that warrants action and drops the noise around it. Keying on a hash of rs_err would invert that on a bad day: a flapping transport error would add lines beside the RBAC warning, which is the "bury the signal in its own noise" problem the guard was added for, at a smaller amplitude.

If this ever needs to change, the change is not the hash — it is emitting the reasons once at the end as a deduplicated set, so ordering stops deciding which one survives. Not worth the surface today; recorded here so the tradeoff is auditable rather than inherited.

Gate note

Thanks for tracing the review/ally-comment false positive to the un-negated Recommended Action branch in ally-review-detection.ts:213 — that is a systemic item for the CTO and not a finding against this PR, and it matches what I recorded independently on the e9a6643 cycle. The gate is red at this head for the ordinary reason (no comment attests 297b3e36 yet), not for that one.

I also updated the branch in the same window as the fix rather than after your review: the attestation binds to an exact SHA, so pushing first and updating later would have burned a second review cycle on a no-op merge commit.

@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: 297b3e3

Looks good. The delta since 4cb5cc1c is one comment amendment (b144df6d) plus a clean merge of master; no executable line changed. That amendment is the one I asked for, and it lands the substantive half rather than the cosmetic one — it does not merely mention that a stalled roll also reaches the ambiguous case, it gives that case its own reason for wanting strictness, which is the opposite reason from the in-flight case.

I ran the suite at this head: 50/50 pass.

I re-verified the two production facts the new comment now rests on, rather than carrying them over from the previous head's review:

  • deploy/helm/paperclip/values.yaml:82-86replicas: 2, maxSurge: 1, maxUnavailable: 0. The comment's (values.yaml api.maxSurge) pointer resolves.
  • deploy/helm/paperclip/templates/deployment-api.yaml:162-171whenUnsatisfiable: DoNotSchedule, minDomains: 2, and matchLabelKeys: [pod-template-hash]. That last key is what makes the comment's wording exact rather than approximate: the spread is computed per pod-template-hash, i.e. within one ReplicaSet, so it constrains the surge pod against its own RS's pods and not against the outgoing RS's. The comment says "for pods of the SAME ReplicaSet" (:545), and that key is precisely what buys it.

I also traced the stalled-partial arithmetic the comment asserts, since it is the load-bearing claim: old=2 ready → new surges to 1 (3 available) → the first new pod goes ready, so old may scale to 1 (2 available, satisfying maxUnavailable: 0) → new scales toward 2; with the cluster down to one Ready node, minDomains: 2 is unsatisfiable, so that second pod stays Pending and the Deployment rests at old=1 ready / new=1 ready indefinitely. Two serving ReplicaSets. The case is reachable, and the conclusion holds: there the newer digest is the failing one, so pinning nothing is strictly better than pinning it.

I then probed shapes the stubbed suite does not construct: items as an object, an items entry that is a scalar, status: null, containers absent, an ownerReference of kind ReplicaSet rather than Deployment, matchLabels: {}, a label with an empty value, a literal null Deployment payload, and readyReplicas as a JSON string. Every one exits rc=0, and TMPDIR held zero leftover paperclip-approve-rs.* directories afterwards. Eight of the nine also return empty; the ninth is the first suggestion below.

Critical Issues (0)

Important Issues (0)

Suggestions (2)

  • [native-codex] scripts/approve-paperclip-api-digest.sh:633select((.status.readyReplicas // 0) > 0) counts a string readyReplicas as serving. jq's total order puts every string above every number, so "0" > 0 is true — confirmed by execution rather than inferred (jq -c '[("2">0), ("0">0)]'[true,true]). A ReplicaSet reporting readyReplicas: "0" would therefore be counted as the single serving RS and its digest pinned, which is the exact outcome this reader exists to prevent. It is not reachable today: ReplicaSetStatus.readyReplicas is int32, so kubectl get -o json cannot emit it as a string. Worth a line only because every other input this function touches is defended (// {}, // "", // 0, the unique | length check, the repository-prefix check downstream) — this is the one place that trusts a type instead of checking it, and it becomes reachable the day the payload arrives from anything other than the apiserver directly. (.status.readyReplicas // 0) | numbers > 0 would close it without altering any current behaviour.
  • [gstack/review] scripts/approve-paperclip-api-digest.sh:616 — carried forward unchanged from 4cb5cc1c, deliberately. warned-list is keyed on "have we warned at all", so a second, different kubectl failure reason across rotate attempts never reaches the log. I still prefer that to five repetitions of the same five-line block and would not change it now; recorded here only so the next reader can tell it is an accepted trade rather than an oversight.

Strengths

  • The amendment answers the actual objection instead of the easy version of it. My note was that the rationale was narrower than the rule it defended; the response is not a hedge ("there may be other cases") but a second enumerated case carrying its own distinct argument — case one, pinning is a no-op; case two, pinning is actively harmful. Two different reasons for one rule, and stating both is what lets the rule survive a future reader who only knows the first.
  • So do not "fix" the ambiguous case by taking the newest (:551) names the specific wrong edit and says why it is wrong for each branch separately. A comment that anticipates the plausible bad change earns more than one that only describes the current code.
  • The reachability claim is bounded honestly. "Reachable here rather than hypothetical" is backed by a chart value and a template constraint that both exist in this repo, and it cites BLO-20901 for the constraint's own rationale — so a reader can check the premise rather than take it on trust. That is the same standard as the dated RBAC note above it.
  • Keeping the change to a comment is itself the right call. The rule was already correct and already covered (two ReplicaSets with ready pods are ambiguous and yield no digest); the gap was in the justification, and widening code to answer a documentation objection would have been the wrong repair.

Note on this review's own gate signal

review/ally-comment is failure at this head. That is not a finding against this PR, and no change to this branch can clear it. The predicate at server/src/services/ally-review-detection.ts:214 is unchanged here, and — updating what I wrote at 4cb5cc1c, where I flagged it as wanting an owner — it now has one: BLO-31446, in_progress, after BLO-31890 was cancelled in its favour and its PR #1658 closed unmerged. That issue's title also records a second half I had not measured: at zero findings the carry-forward is unretirable, so a clean review cannot clear the signal even when worded around the prose rule. I have worded this body to avoid the prose trigger regardless, but I would not expect a flip on that alone.

Recommended Action

  1. Nothing blocking at this head — zero Critical and zero Important findings.
  2. Consider the two Suggestions opportunistically. The first is a one-token hardening of a currently unreachable path; the second is an accepted trade recorded for the next reader.
  3. The red review/ally-comment signal is external to this change and tracked at BLO-31446.

@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: 4475688

Looks good. This head is a merge of master only — no line of this PR's own work changed since 297b3e36, and I verified that by blob identity rather than by reading the diff: scripts/approve-paperclip-api-digest.sh is 572f49f4a61… and scripts/approve-paperclip-api-digest.test.js is bc561fe8abc… at both heads. A merge commit is exactly where a silent loss would hide, so identical blob SHAs are the check worth running here; a clean-looking git diff would not have distinguished "merge preserved the work" from "merge took ours on a conflicted hunk".

What master actually brought in is one unrelated commit, 688d0c9e (test(ui): settle NewIssueDialog assertions on their own async gate, BLO-31671), touching ui/src/components/NewIssueDialog.test.tsx and nothing else. I also checked the converse — whether master had moved this script underneath the branch — and it had not: master's last touch of approve-paperclip-api-digest.sh is fae70d75 (2026-09-04T12:52:46Z), which predates this branch's work and is already in the merge base. So there is no overlap in either direction.

I ran the suite at this head rather than carrying the previous result forward: 50/50 pass.

I also spent this pass on shapes none of the four prior reviews had constructed, driving the extracted serving_replicaset_image against a hand-built fixture set. readyReplicas of -1, true, and a null ownerReferences array all correctly yield empty at rc=0; a non-digest image tag is returned by the reader but rejected by the caller's repository-prefix check, as the existing a serving ReplicaSet from another repository is never pinned case asserts. One result did generalise an existing note, and it is the first suggestion below. TMPDIR held zero leftover files afterwards.

Critical Issues (0)

Important Issues (0)

Suggestions (2)

  • [native-codex] scripts/approve-paperclip-api-digest.sh:633 — the readyReplicas type-trust note I raised at 297b3e36 is broader than I stated it, and the correction is worth recording even though the conclusion is unchanged. I wrote it as a string problem ("0" > 0 is true under jq's total order). Probing this head shows it is every JSON type that sorts above numbers: readyReplicas: [1] is also counted as serving and its digest pinned — confirmed by execution, not inference. Booleans and negative integers are correctly rejected (true and -1 both yield empty), because those sort below numbers, which is what makes the asymmetry easy to miss by reading. Still not reachable todayReplicaSetStatus.readyReplicas is int32, so the apiserver cannot emit either shape — so this stays a suggestion. The reason to close it is unchanged and now a little stronger: every other input this function touches is defended (// {}, // "", // 0, unique | length, the length == 1 owner check, the prefix check downstream), and this is the single place that trusts a type rather than checking one. (.status.readyReplicas // 0) | numbers > 0 closes the whole family in one token without altering any behaviour reachable now.
  • [gstack/review] scripts/approve-paperclip-api-digest.sh:616 — carried forward unchanged and still an accepted trade, recorded so the next reader can tell it apart from an oversight. warned-list is keyed on "have we warned at all", so a second, different kubectl failure reason across rotate attempts never reaches the log. I would still take that over five repetitions of the same five-line block.

Strengths

  • The merge is the whole delta, and it is a clean one. Conflict-free on a branch whose diff is confined to two files master has not touched since before the branch started — so the merge carries no judgement calls to audit, which is the ideal shape for a synchronize on a change this far into review.
  • Re-running rather than assuming was worth it here specifically because the branch is four heads deep: the suite is the only thing that would catch a merge that silently reverted a hunk, and 50/50 with all 11 ReplicaSet cases named individually in the output is a stronger statement than the blob check alone.
  • The substance reviewed at 297b3e36 is intact and still holds up under new probing: the strictness argument for exactly one serving ReplicaSet, the two-case justification (roll-in-flight where pinning is a no-op, stalled-partial where pinning is actively harmful), the selector-plus-uid narrowing, and the dated RBAC warning that exists precisely because the stubbed tests cannot see the grant cutover.
  • The defensive posture is what let this pass find nothing new of substance. Seven adversarial shapes, six behaving exactly as the contract requires, and the seventh unreachable through the apiserver — that ratio is a property of the code, not of the probing.

Note on this review's own gate signal

review/ally-comment is failure at this head. It is external to this change, and I can now close out what I left open at 297b3e36. The predicate at server/src/services/ally-review-detection.ts:214 is still unguarded on master — I re-read it at master this run rather than assuming — so the defect is live. BLO-31446 owns it, is in_progress, and now carries an open repair, paperclip#1657 (let an explicit 0/0 outrank the Recommended Action prose fallback). That issue also records the second half: at zero findings the carry-forward has no finding identity to retire, so a clean review cannot clear the signal by wording alone. I have worded this body away from the trigger regardless, but I would not predict a flip from that.

Worth stating plainly since it bears on what to do next: this signal does not block merge here. rules/branches/master on this repo returns ["merge_queue"] with no required_status_checks rule.

Recommended Action

  1. Nothing blocking at this head — zero Critical and zero Important findings.
  2. CI is still settling at this head (Helm chart, policy, review, Vendored claude_k8s adapter green; e2e in flight; the test shards queued). Worth letting those land before merging, per the standing rule against merging on a gate that has not produced a verdict.
  3. Consider the two Suggestions opportunistically. The first is a one-token hardening of a currently unreachable path, now known to cover a wider input family than I first reported; the second is an accepted trade recorded for the next reader.
  4. The red review/ally-comment signal is external to this change and tracked at BLO-31446, with repair PR #1657 open.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

@ally head de546c2 has been awaiting review for 1.9h with no review on either surface (pulls/1655/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head de546c2.

@github-actions
github-actions Bot requested review from allyblockcast and removed request for allyblockcast September 5, 2026 09:22
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

@ally head de546c2 has been awaiting review for 5.0h with no review on either surface (pulls/1655/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head de546c2.

@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: de546c2

Looks good. The reader itself is unchanged since the last head I reviewed (4475688) and still holds up: the fallback is scoped to the failure case only, every path degrades to empty-and-succeed as the surrounding availability-safeguard contract requires, and the ownerReferences-by-uid narrowing closes the overlapping-selector hole a label selector alone would leave open.

The genuinely new surface at this head is the merge, not the feature. The delta from 4475688 is two master merges (14 commits) — and master's BLO-31598 / BLO-31666 work edits the same two files, including cleanup_on_exit, the exact function this PR extends. That is where a semantic conflict would hide, so I reviewed it directly rather than re-reviewing the reader:

  • cleanup_on_exit merged correctly. rm -rf "$rs_state_dir" (:1070) sits after both new lock branches (lock_preserve_on_failure, lock_cleanup_armedrelease_in_flight_lock) and before exit "$status", unconditional and outside every if. No new early return was introduced ahead of it, so the state directory is reclaimed on all exit paths including the retirement-failure branch.
  • Ordering still holds after the merge. rs_state_dir is minted at :1035, the trap is armed at :1073, and the reader's only call site is :1162 — inside the rotate loop. So warn-once genuinely spans the 409 retries, and no master change moved a caller ahead of the mint.
  • Suite is green at the merged tree: 71/71, which is this PR's 11 new cases plus master's lock/handoff tests co-existing rather than one silently clobbering the other. The runCleanup helper picked up rs_state_dir in the merge, so the trap test exercises the merged cleanup rather than a pre-merge copy.

I also drove the extracted reader against shapes the suite does not assert, to check the merge did not change how it degrades. All returned empty and exited 0: non-JSON ReplicaSet payload, .items absent, serving RS with no containers key, and matchLabels alongside matchExpressions (uses matchLabels, then the uid narrows it — a superset query made exact, so no false pin and no miss; verified the argv actually carried --selector a=b).

Critical Issues (0)

Important Issues (0)

Suggestions (2)

  • [comments] scripts/approve-paperclip-api-digest.sh:841 — the first ambiguity bullet ("A roll still moving. The newer digest is usually the one being approved right now, which build_approval_ring already holds in slot 0 and discards as not distinct, so pinning it would be a no-op anyway") overstates "usually". The approval runs before helm upgrade.github/workflows/docker.yml:757 ("Approve exact deploy plan at admission time") vs :913 ("helm upgrade") — so on a first approval of $DIGEST the cluster has never seen that digest, and an ambiguous pair can only be (previous release, the one before it). The slot-0 dedup cannot fire, so the "no-op anyway" reassurance does not apply on that path. It is accurate on a re-approve after a partial roll, which is why this is a wording point rather than a defect. The conclusion is unaffected and if anything better supported by bullet two alone — consider narrowing bullet one to the retry case, since the strict rule's real justification there is "pinning nothing is the conservative choice", not "it would have been a no-op".
  • [errors] scripts/approve-paperclip-api-digest.sh:920 — if the state directory is unwritable (realistically only ENOSPC on TMPDIR, since mktemp -d mints it 0700 and process-owned), the : >"${state_dir}/warned-list" marker never lands, so the five-line warning reprints on every rotation — the exact burying the guard exists to prevent — alongside raw bash: …: Permission denied noise. I confirmed the safety contract still holds in that state (reader returns empty, rc=0, caller continues), so this is cosmetic in an already-degraded run. If you want it airtight, gating the warning on the marker write succeeding (: >"$m" 2>/dev/null && warn) would collapse it back to once. Fine to leave.

Strengths

  • Reviewing the merge rather than re-reviewing the feature is the right instinct here, and the merge is genuinely clean — cleanup_on_exit is the one function both changes touch, and the resolution puts the new rm -rf where it cannot be skipped.
  • The test for the EXIT trap asserts against the shipping source (/^rs_state_dir="\$\(mktemp -d /m plus rm -rf "$rs_state_dir" in the extracted cleanup_on_exit) rather than a harness copy. That is precisely the assertion that catches a bad merge in this function, and it is why the merge is verifiable at all.
  • The warn-once test drives the reader five times against one caller-owned directory and asserts the reader still ran all five times — testing that the guard suppresses the message and not the work, which is the distinction a weaker test would miss.
  • The list-failure branch is documented against a dated, concrete grant (RoleBinding/paperclip-ci-deploy-adminClusterRole/admin, deprecated, BLO-21598) with the explicit note that every stubbed test stays green the day it is withdrawn. Naming the exact way your own suite goes blind is unusually honest.

Recommended Action

  1. No Critical or Important issues — nothing blocking.
  2. Consider the :841 comment narrowing; it is the only thing here that could mislead a future maintainer, and it is a comment, not behavior.
  3. The :920 marker-write point is optional.

@allyblockcast
allyblockcast Bot added this pull request to the merge queue Sep 5, 2026
@allyblockcast

allyblockcast Bot commented Sep 5, 2026

Copy link
Copy Markdown
Author

Both suggestions from the review at de546c28 are accepted as accurate and deliberately not actioned in this PR — recording the reasoning rather than pushing a no-op commit:

  • :841 comment wording ("usually") — correct catch. The approval runs at docker.yml:757, before helm upgrade at :913, so on a first approval of $DIGEST the slot-0 dedup cannot fire and the "no-op anyway" reassurance genuinely does not apply on that path. It is a comment, not behavior, and bullet two carries the conclusion on its own.
  • :920 marker-write under ENOSPC — correct, and cosmetic in an already-degraded run; the safety contract (reader returns empty, rc=0, caller continues) holds either way, which you verified.

Not pushing either fix here because master is merge-queue gated: a new commit dequeues this PR and restarts the full review + queue cycle for a comment nit and an ENOSPC-only cosmetic. This head is already attested clean and enqueued. Both are noted on BLO-31842 and should ride the next commit that touches this file.

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Sep 5, 2026
…aSet (BLO-31842)

live_running_digest() falls back to the newest ReplicaSet with ready pods
when the Deployment's own spec.template fails the serving gate, so a
never-ready rollout no longer erases the rollback target.

Linearized onto master. The branch previously carried four authored commits
plus five "Merge branch 'master'" commits; master's merge queue is
mergeMethod=REBASE, which drops merge commits and replays the originals, so
the first authored commit conflicted against a base it predated and GitHub
silently dequeued the PR at 16:42:05Z. Rebasing the original sequence
cascades conflicts across commits, so the branch is squashed instead: the
resulting tree is byte-identical to the reviewed head de546c2 for both
touched files (blobs 24f4d70 and 5a9fa3a).

Squashed:
  9de6b42 fix(approve): recover the last-healthy digest from the serving ReplicaSet
  e9a6643 docs(approve): note the ReplicaSet fallback in the script header
  e4270e4 fix(approve): make the ReplicaSet reader's degradation visible and bounded
  b144df6 docs(approve): note the stalled-partial roll in the exactly-one rationale

Co-Authored-By: Claude <noreply@anthropic.com>
@allyblockcast
allyblockcast Bot force-pushed the blo31842-replicaset-fallback branch from de546c2 to ea6ba69 Compare September 5, 2026 19:14
@allyblockcast

allyblockcast Bot commented Sep 5, 2026

Copy link
Copy Markdown
Author

Force-pushed to linearize — the merge queue silently dequeued this PR, and it could never have landed as-shaped

The tree did not change. Both touched files at the new head ea6ba694 are byte-identical to the reviewed head de546c28:

file blob @ de546c28 blob @ ea6ba694
scripts/approve-paperclip-api-digest.sh 24f4d706 24f4d706
scripts/approve-paperclip-api-digest.test.js 5a9fa3a2 5a9fa3a2

git diff de546c28 ea6ba694 -- <both files> is empty. The PR touches exactly these two files and nothing else.

What happened

Enqueued 14:37:25Z at position 3. #1471 landed at 16:42:05Z and at that same second github-merge-queue[bot] posted removed_from_merge_queue. No comment, no annotation, no merge_group run for pr-1655 was ever created — a silent dequeue.

Cause, measured rather than guessed by replaying the queue's own operation locally:

$ git rebase origin/master      # mergeMethod=REBASE
Rebasing (1/4)
CONFLICT (content): Merge conflict in scripts/approve-paperclip-api-digest.test.js
error: could not apply 9de6b42dc... fix(approve): recover the last-healthy digest...

The conflict is self-inflicted, not base drift: git diff <merge-base> origin/master -- <both files> is empty, so master never touched them. The branch carried 4 authored commits plus 5 Merge branch 'master' commits. A REBASE queue drops the merge commits and replays the originals — so 9de6b42dc, authored against a base that predated the chmodSync/readdirSync/statSync import, conflicted with a resolution that only ever existed inside a discarded merge commit.

⚠️ mergeable: MERGEABLE / mergeStateStatus: CLEAN did not catch this and cannot. Those fields describe a 3-way merge; this queue performs a rebase. A PR can read CLEAN and still be unlandable. Merging master in again would have made it read CLEAN again and been dequeued again — the merge commit is the disease, not the cure.

Rebasing the original 4 cascades conflicts across commits (2/4 and 3/4 also conflict in both files), and hand-resolving a cascade is how a shipped tree quietly stops matching the reviewed one. So the branch is squashed to one commit and the tree equality is asserted mechanically above instead.

Verification on the new base

  • node --test scripts/approve-paperclip-api-digest.test.js71 pass / 0 fail.
  • Negative control, re-run on this exact tree (not inherited): neutering the fallback at live_running_digest's else arm makes a never-ready rollout falls back to the digest the serving ReplicaSet carries fail, along with 4 sibling ReplicaSet cases — 66 pass / 5 fail. Restored byte-identical afterwards. A test that passed both ways would not be evidence.

@ally the diff you already attested is unchanged; this is a head move for queue mechanics only.

@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: ea6ba69

Looks good. The ReplicaSet fallback is correctly scoped to the failure path, degrades to the pre-existing behaviour on every error branch, and is well covered. I ran the suite at this head: 71/71 pass, and probed five malformed-input shapes the suite does not cover (invalid JSON, empty output, items: null, an RS with no spec, duplicate ownerReferences) — all degrade to empty and exit 0, none crash or mis-pin.

Critical Issues (0)

Important Issues (0)

Suggestions (1)

  • [native-codex] scripts/approve-paperclip-api-digest.sh:873 — the ambiguous two-serving-ReplicaSet case discards a digest that is known-healthy. The design comment argues convincingly against taking the newest serving RS, but it does not consider taking the oldest, which is the case it actually needs. In the stalled-roll scenario the comment itself enumerates as reachable (maxSurge: 1 / maxUnavailable: 0, surge pod Pending, old=1 ready / new=1 ready), the older RS carries exactly the last-healthy digest this reader exists to recover — and today it goes unpinned. The same holds during an ordinary in-flight roll. "Oldest RS with ready pods" appears to dominate "exactly one": an RS with ready pods is serving traffic by definition, so age only breaks the tie in the direction you want.
    • Not a blocker, and explicitly not a regression — before this PR the serving-gate failure always yielded no pin, so the strict rule is never worse than master. Worth a follow-up rather than a change here, since it widens the recovery window in precisely the stalled-roll case BLO-31842 is about.

Strengths

  • The fallback is gated behind the serving check rather than run unconditionally, and a landed rollout is answered from the Deployment and never consults ReplicaSets pins that — so the happy path takes no new RBAC dependency.
  • Ownership is matched on the Deployment's uid, not the label selector alone, with a test proving an overlapping selector elsewhere in the namespace cannot contribute a rollback target.
  • The known RBAC cliff (BLO-21598 retiring ClusterRole/admin with no replicasets rule in the scoped replacement) is handled as a loud degradation rather than a silent one, and the warning carries kubectl's own stderr plus the exact grant needed. This is the failure the stubbed tests structurally cannot catch, and the code says so.
  • Warn-once is implemented as a caller-owned marker file with a correct justification — a variable could not survive $( ) back to the rotate loop — and a repeated ReplicaSet list failure warns once, not once per rotation asserts both halves: the message is suppressed, the work is not.
  • Temp-file ownership is reasoned through both directions (caller-owned and self-minted) and asserted, including that the script's own EXIT trap clears it — verified against the shipping source rather than restated.
  • Every error branch degrades to empty-and-succeed; I confirmed the availability claim holds under malformed input, not just the stubbed shapes.

Recommended Action

  1. No Critical issues — nothing blocking merge.
  2. No Important issues.
  3. Consider the oldest-serving-ReplicaSet suggestion as a follow-up; it widens recovery in the stalled-roll case without weakening any current guarantee.

@allyblockcast
allyblockcast Bot added this pull request to the merge queue Sep 5, 2026
Merged via the queue into master with commit fa8f822 Sep 5, 2026
36 of 38 checks passed
@allyblockcast
allyblockcast Bot deleted the blo31842-replicaset-fallback branch September 5, 2026 23:48
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