Skip to content

fix(2526): the archive asserts the board is clean, not that its writes worked - #339

Merged
LukasWodka merged 3 commits into
developfrom
fix/2526-archive-asserts-the-board-is-clean
Aug 26, 2026
Merged

fix(2526): the archive asserts the board is clean, not that its writes worked#339
LukasWodka merged 3 commits into
developfrom
fix/2526-archive-asserts-the-board-is-clean

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

The daily archive has been reporting success for weeks while leaving most of the board untouched. Every guard in it answers "did my writes succeed?" — and every one of them can answer yes while the board is unchanged.

Measured today (2026-08-26). The 05:14 scheduled run:

Un-archived terminal items: 63
=== Archived: 63 ok, 0 failed ===

exit 0, green. At 06:24 a human PAT read the same board and found 668 un-archived terminal cards — 475 Prod, 153 Done, 40 Cancelled. A manual dispatch minutes after that reported Un-archived terminal items: 0.

Same board, same query shape, two credentials, two answers.

The read logic is not the bug. It paginates correctly (items(first:100) + hasNextPage/endCursor) and filters correctly (isArchived == false, status in Prod/Cancelled/Done). The bug is that "I archived everything I saw" was allowed to stand in for "the board is clean", and nothing measured the gap.

This file's header had already named the failure mode:

"the board could degrade for weeks behind an unbroken green history — at ~800 items/month reaching Prod, slowly enough to look like normal growth"

…and then guarded only against a failed archive, never against an archive that sees nothing.

What changed

1. Say what the credential actually saw. Items read, un-archived, and — the one that matters — how many returned a null Status. A null means the item came back but its field value was unreadable, so the filter silently drops it and nothing reports it. That count is precisely what separates "the board is clean" from "this credential cannot see the board", and nothing has ever printed it. Non-zero raises a warning rather than passing quietly.

2. Re-read after archiving, and fail on anything terminal still live.

Deliberately a second read rather than arithmetic over the first. The defect being caught is that the first read is incomplete, so comparing it against itself would agree and prove nothing — the test-a-list-against-itself trap. It also fails closed: an unreadable re-read exits 1 rather than reporting a clean board, because unverified is not verified-clean.

What this does NOT do

It does not fix the cause, and I want that explicit rather than implied.

  • Narrow repo visibility is refuted. The tracebloc-release-train App is installed with repository_selection: all and organization_projects: write.
  • It can read and write the project — it resolved the project id and archived 63 items successfully.

So the remaining candidate is the field read, and the diagnostic above is what will name it on the next scheduled run. Arming the assertion first is deliberate: whatever the cause, the next silent degradation cannot be silent.

Expect this to go red

Its next run should fail with ~668 items still terminal. That is the guard working, not a regression — the board was already in that state, just unreported. Two ways forward once the diagnostic prints:

  1. the null-Status count is high → the App's Projects v2 field read is the cause, fix the permission; or
  2. it is zero → the cause is elsewhere and the re-read count tells us where.

Either way the board can then be cleared once, knowingly.

Type

fix

Test plan

  • actionlint clean locally on the modified workflow.
  • yaml.safe_load parses.
  • Shell syntax of the modified run: blocks checked with bash -n.
  • Behaviour is verifiable only against the live board — which is the point of the diagnostic. The first scheduled run after merge is the test, and it is expected to be red with a number that matches the 668 measured above.

Checklist

  • Targets develop
  • One self-contained change (one workflow)
  • Assignee set, one reviewer requested
  • No secrets, tokens or customer data

Note

Medium Risk
Changes only CI workflow behavior (expected red runs until permissions/root cause are fixed) but alters failure semantics for a critical board-hygiene job.

Overview
The daily kanban archive workflow no longer treats “archived everything I saw” as “the board is clean.” It adds visibility into what the App credential can actually read and a post-archive assertion that can fail the job when hygiene cannot be verified.

During the initial project scan, each item’s archived flag and Status (or NULL_STATUS when unreadable) is recorded. The run logs and GitHub step summary now show items read, un-archived count, null-Status count, and terminal candidates; unreadable Status values emit a warning instead of being silently dropped from filters.

The Archive step no longer exits early when there is nothing to archive, so later checks still run. Archive failures already exit non-zero; that behavior is unchanged.

A new Assert the board is clean step (skipped on dry run) re-paginates the board after archiving and fails closed if any terminal (Prod / Done / Cancelled) items remain un-archived, if the re-read fails, if un-archived items have unreadable Status, or if the re-read sees fewer items than the first pass. This closes the case where the workflow went green while hundreds of terminal cards stayed on the board because the credential could not classify them.

Reviewed by Cursor Bugbot for commit 353db78. Bugbot is set up for automated code reviews on this repo. Configure here.

…s worked
Every guard in this workflow answered "did my writes succeed?" and every one of
them could answer yes while the board was untouched. That is what happened.
MEASURED, 2026-08-26. The 05:14 scheduled run logged:
Un-archived terminal items: 63
=== Archived: 63 ok, 0 failed ===
exit 0, green, as it had been for weeks. At 06:24 a human PAT read the same board
and found 668 un-archived terminal cards -- 475 Prod, 153 Done, 40 Cancelled. A
manual dispatch minutes later reported "Un-archived terminal items: 0". Same
board, same query shape, two credentials, two answers.
The read logic is not the bug: it paginates correctly and filters correctly. The
bug is that "I archived everything I saw" was allowed to stand in for "the board
is clean", and nothing measured the difference. This file's own header had already
named the failure mode -- "the board could degrade for weeks behind an unbroken
green history ... slowly enough to look like normal growth" -- and then guarded
only against a FAILED archive, not against an archive that sees nothing.
TWO CHANGES.
1. Say what the credential actually saw. Items read, un-archived, and -- the one
that matters -- how many returned a NULL Status. A null means the item came
back but its field value was unreadable, so it is silently dropped by the
filter and reported nowhere. That count is the measurement that separates "the
board is clean" from "this credential cannot see the board", and nothing has
ever printed it. A non-zero count raises a warning rather than passing quietly.
2. Re-read AFTER archiving and fail on anything terminal still live.
Deliberately a SECOND read rather than arithmetic over the first. The defect
being caught IS that the first read is incomplete, so comparing it against
itself would agree and prove nothing -- the test-a-list-against-itself trap
this org's rules name directly.
It fails closed: an unreadable re-read exits 1 rather than reporting a clean
board, because unverified is not verified-clean.
WHAT THIS DOES NOT DO. It does not fix the cause. The `tracebloc-release-train`
App is installed on ALL repos with `organization_projects: write`, so narrow repo
visibility is refuted; it can also read and write the project, since it resolved
the project id and archived 63 items successfully. The remaining candidate is the
field read, and the diagnostic above is what will name it on the next scheduled
run. Arming the assertion first is deliberate: it means the next silent
degradation cannot be silent.
Expect this to go RED on its next run, with 668 items still terminal. That is the
guard working, not a regression -- and the board it describes was already in that
state, unreported.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodkaLukasWodka self-assigned this Aug 26, 2026
Comment thread.github/workflows/kanban-archive.yml
Comment thread.github/workflows/kanban-archive.yml
@LukasWodka
LukasWodka requested review from aptracebloc and removed request for saadqbalAugust 26, 2026 08:10
Two Bugbot Highs on #339, both real.
UNREACHABLE. The board-is-clean re-read sat at the bottom of "Archive
them", below `if [ "$n" = "0" ]; then ... exit 0; fi`. When the first read
finds nothing terminal -- which is exactly what a credential that cannot
see the cards produces, and exactly the silent success this PR is about --
the step exited before asserting anything and the job went green having
checked nothing. A guard reachable only when the bug is absent is not a
guard. It is now its own step, so it runs whatever the archive did.
SAME CREDENTIAL, SAME BLIND SPOT. The old comment claimed a second read
avoids comparing the first read against itself. True of the item list;
false of what made the incident invisible. On 2026-08-26 the items WERE
returned -- their Status came back null for this credential, so they
matched no terminal filter and `left` stayed 0. A second read with the
same token reproduces that and agrees with itself. So a null Status on an
un-archived item is now a refusal rather than the ::warning:: it was, and
a re-read that saw fewer items than the first read is a refusal too.
Skipped in dry run: a dry run archives nothing, so the board is dirty by
construction and this would be permanently red.
Verified: make lint, make selftests (95 passed), make shellcheck clean,
actionlint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

bugbot run

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

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

Reviewed by Cursor Bugbot for commit f57d269. Configure here.

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The diagnosis is the valuable part: every guard in here answered "did my writes succeed?" and every one of them could answer yes while the board was untouched. 63 ok / green at 05:14 against 668 un-archived terminal cards at 06:24 on the same board with a different credential is the measurement that makes it undeniable, and pointing out that the file's own header had already named this failure mode — then guarded only against a failed archive — is the honest version of "why it shipped".

The null-Status count is the right instrument. An item returned with an unreadable field value is dropped by the filter and reported nowhere, so it's the one number that separates "the board is clean" from "this credential cannot see the board". Nothing printed it before.

Every new guard turns an absence into a refusal, which is the correct shape throughout: a failed re-read exits 1 rather than assuming clean, left != 0 is an error rather than a partial success, and nullst_after > 0 refuses because an item that can't be judged terminal means the run cannot show the board is clean. "Unverified is not verified-clean" is the whole principle in four words.

The shrink guard is the subtle one and I checked it can't false-positive: both first_total and reread_total count every item regardless of isArchived — which is exactly why the terminal filter tests isArchived == false separately — so a successful archive leaves the totals equal and only a genuine loss of visibility trips it.

@aptracebloc

Copy link
Copy Markdown
Contributor

Code review — xhigh recall, .github/workflows/kanban-archive.yml

The direction is right (assert board state, not just write-success) and the incident is genuinely caught via nullst_after. But the new "Assert the board is clean" step has a blocking defect and a few gaps. Empirical basis: the PR states 63 items were archived on 2026-08-26, yet a full read of project #2 (1252 items, complete pagination) returns zero isArchived==true items — so archived items are excluded from the items connection. That single fact drives finding 1.

1. [HIGH] reread_total < first_total fails every healthy run (line 310). Archiving removes items from the items connection, so the post-archive re-read necessarily sees first_total − (items archived). Any run that archives ≥1 item trips ::error:: the post-archive re-read saw N … first read saw M and exits 1. The first backlog-clearing run (currently 475 Prod + 166 Done + 41 Cancelled = 682 terminal items) and every daily run thereafter go red. The comment's premise — "a read that shrank mid-run … for a reason that is not archiving" — is exactly inverted: archiving is what shrinks the count. Compare against first_total − <successfully-archived count>, or count only still-live items instead of raw node totals.

2. [MED] Total-blindness passes as "clean" (line 259 / 309-313). Fail-closed only triggers on OUT=="" (gh error) or reread_total < first_total. If the credential can list the project but returns 0 nodes on both reads, then first_total=0, reread_total=0, left=0, nullst_after=0 → the job passes and reports the board clean. This contradicts the stated guarantee ("zero parsed items is not evidence of a clean board"). Add an absolute floor (e.g. re-read must return a plausible minimum item count).

3. [LOW-MED] nullst_after > 0 hard-fail also fires on legitimately No-Status cards (line 305). A null Status is returned both for the credential blind spot and for a card genuinely not yet placed in a column. Org standards say no card should sit at No Status, but .github/release-train/rfcs aren't wired for the auto-status trigger, so a just-added, untriaged card present at 05:00 would hard-fail the run. The check cannot distinguish "unreadable" from "unset."

4. [LOW] First-read nullst omits the isArchived==false filter (line 129), unlike nullst_after. Inconsistent measure, and the warning text "neither archived nor reported" is wrong for an archived null-status item. (Low impact given archived items don't appear in the connection — but that redundancy is the same wrong mental model behind finding 1: select(.isArchived==false) throughout is dead, since every returned item is already un-archived.)

5. [LOW] Efficiency (step at line 209). The assert step re-paginates the whole board a second time (13 pages / 1252 items) each run, with 5 separate jq subshells per page over the same $OUT. Derive the counts in one jq pass per page.

6. [LOW] Duplication (line 235). The re-read GraphQL/pagination loop is a near-verbatim copy of the collect loop (line 88). Two ~40-line copies will drift; extract a shared script/step.

7. [LOW] Stale label (line 80 / header line 4). Step name "Collect un-archived terminal items (Prod / Cancelled)" and the header comment "terminal column (Prod / Cancelled)" both omit Done, which the filter treats as terminal (and which the incident counts as 153-166 Done cards).

— drafted with Claude Code

@LukasWodka
LukasWodka merged commit e0b3ae4 into developAug 26, 2026
14 checks passed
@LukasWodka
LukasWodka deleted the fix/2526-archive-asserts-the-board-is-clean branch August 26, 2026 10:22

@aptraceblocaptracebloc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Requesting changes — the structural fix is right, but the new "Assert the board is clean" step has a blocking logic defect. Flagging up front that green CI is not reassurance here: no PR check runs this workflow against the live board (only actionlint/shellcheck/pipefail), so the runtime logic is entirely unexercised by CI.

Blocker — the shrink check reds every healthy run (line 310).if [ "$reread_total" -lt "$first_total" ]; then ::error::; exit 1. Archived items are excluded from the ProjectV2 items connection (verified: a full read of project #2 returns ~1260 items, 0 with isArchived==true), so after a run archives K terminal items the re-read necessarily sees first_total − K < first_total → exit 1. Every run that archives ≥1 item — i.e. the normal case, and especially the first backlog-clear (~682 terminal items today) — goes red. The comment's premise ("a read that shrank mid-run for a reason that is not archiving") is inverted: archiving is exactly what shrinks the count. Compare against first_total − <archived count>, or count only still-live items rather than raw node totals.

Likely also blocking — nullst_after > 0 hard-fail (line 305). This fires on any un-archived null-Status card, and 2 genuinely-untriaged (No-Status) cards exist on the board right now, so a real run reds on them too. I get the fail-closed intent (backend#2526: can't-read-Status ≠ verified-clean) and I'm not asking you to drop it — but as written it can't distinguish "the credential couldn't read the Status" from "the card genuinely has no Status yet," and the latter is a normal board state. Worth narrowing the guard to the unreadable case.

The rest — a vacuous "clean" pass when both reads return 0 nodes, a dead select(.isArchived==false) filter, DRY between the two ~40-line pagination loops, and a "Prod / Cancelled" label that omits Done — are in the detailed comment above and are non-blocking.

To be clear on what's good: extracting the assertion into its own step and dropping the n==0 early exit 0 genuinely closes the unreachable-guard bug this PR set out to fix. It's the two new predicates that need adjusting.

— drafted with Claude Code

LukasWodka added a commit that referenced this pull request Aug 26, 2026
…ount (#347)
Bugbot HIGH on .github#341, and correct. The post-archive assertion I added in
#339 treats a fully paginated `nodes` list as the whole board, and it never asks
the server how many items the connection actually holds.
Two ways that passes while the board is dirty:
* `nodes|length` renders a MISSING or NULL path as 0. A query that resolved to
nothing at all counts as "no terminal items left" and the job reports clean.
* Items omitted from `nodes` while `totalCount` still counts them are invisible
to every check in the step. Both the first read and the re-read are this same
credential's, so they agree on the same subset -- or on zero -- and agree
confidently.
That second one is the exact defect #339 was written to close, one layer further
in. The file even said so out loud: "Both reads are this credential's, so this
cannot see what neither can." I wrote that sentence, and then let the assertion
rest on those two reads anyway. Comparing a read against another read by the same
blind credential is the test-a-list-against-itself trap, and #339's own commit
message claimed to have avoided it.
`totalCount` is the fix because it is the ONLY number in this job that does not
come from this credential's view of `nodes`. Three guards now:
1. the connection must resolve to an array -- an unreadable read fails rather
than counting as zero;
2. `totalCount` must be present and numeric, or completeness is unestablished;
3. paginated count must EQUAL `totalCount`, or items are being omitted and the
run cannot claim a clean board.
All three fail closed, which is the same rule the rest of the step follows.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto 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.

3 participants

@LukasWodka@aptracebloc@saadqbal