Skip to content

The vetter sends producer drafts back to needs-work - #233

Merged
thedavidmeister merged 5 commits into
mainfrom
2026-08-08-vetter-sends-drafts-back
Aug 8, 2026
Merged

The vetter sends producer drafts back to needs-work#233
thedavidmeister merged 5 commits into
mainfrom
2026-08-08-vetter-sends-drafts-back

Conversation

@thedavidmeister

@thedavidmeister thedavidmeister commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Closes #232

What changed

VetAction::SkipDraft is deleted as a terminal skip and replaced by
VetAction::DraftNeedsWork. A draft producer PR is verdicted ai:needs-work by
the state-load itself, carrying the work order that the producer confirm the PR
is not a draft if it intends to merge something — clear the flag and hand it
off, or keep shaping it, but not sit where nobody owns it.

Why the tool writes it rather than offering it to the vetter. The verdict is
mechanical: the draft flag decides it, and no reading of the diff can change the
answer. Handing the draft to the vetter as work would spend a scarce vetting slot
(the run budget is 3 items) on a foregone conclusion, and record_verdict's lens
gate would first demand a pr_checkout plus an audit invocation scoped to a PR
nobody is asking to have reviewed. The state-load already performs one transition
of exactly this shape — the ai:blocked-on clearance writes a comment and edits
labels in-line — so the precedent and the placement are the existing ones.

Precedence (vet_action): human_sacredvetted_at_headis_draft
Vet. Two things follow from that order, and nothing had to be added for either:

  • Idempotence. A draft already carrying this state-load's own verdict at its
    head is vetted-at-head like any other PR, so it skips. A draft that sits for
    weeks costs one verdict, not one per run; a push that moves its head buys it a
    fresh send-back at the new head, exactly as it buys any other PR a fresh
    verdict. No second currency notion was introduced — one that existed would be
    free to disagree with vetted_at_head.
  • human_sacred still wins. The draft arm is below it, so a PR a person has
    decided is never dragged back to needs-work. Pinned in both currency states.

The write, and its order. draft_send_back_plan is a pure function returning
the gh argv to run, so the order, the label plan and the no-head-sha refusal are
values a test reads rather than a sequence buried in a network call. The label
edit comes before the comment — the mirror image of the blocked-on clearance's
order, for that order's own kind of reason. The label is what takes the PR out of
the leak population; the comment is the currency stamp that stops the next run
repeating the write. Posting the stamp first and then failing the label write
would leave a PR reading as vetted at its head while wearing no state label at
all — un-labelled, never re-derived, permanently leaked, which is the outcome this
whole rule exists to remove. Label first inverts that: a failed comment leaves
ai:needs-work with nothing trusted behind it, which is NeedsWorkState::Parked
— modeled, visible, safe — and the next state-load re-derives the send-back. Add
and strip ride in ONE gh pr edit, so the PR is never momentarily wearing two AI
verdicts; the edit is omitted entirely when the labels are already right, so the
retry after a half-landed write costs one call, not two.

The leak machinery is untouched. is_leak_candidate, leak_reason and
producer_pr_inventory are unchanged. The hole closes as a consequence: a draft
verdicted ai:needs-work carries an ai:* label, so ai_state_label(..).is_some()
and the leak arm is unreachable for it — with the producer's notes unchanged,
which is the point. No note text is classified anywhere.

Counters. skipDraft is gone. counts.draftNeedsWork is the number of drafts
this call SENT BACK, the summary line reads N draft->needs-work, and a new
draftNeedsWork array names them unconditionally (sentBack: false when the
write did not land). Unconditional for a stronger reason than openThreads has:
everything else in that document reports, and this one acts.

Drafts are no longer pre-filtered out of the per-PR fetch — the send-back's
currency check reads the comment thread, so the detail is needed. That is one
gh pr view per draft per run; the live population is one draft across 148 open
authored PRs.

The vetter prompt and the unvetted tool description both said drafts were
"already excluded"; both now say what happens to them instead.

next_design's own draft withholding (ND_WHY_DRAFT) — unchanged, deliberately

next_design withholds a draft from the presented head and names it in the
withheld list
with its reason. I checked it against this ruling and left it
alone, because it is not the same defect and the ruling does not reach it:

  • The starvation this issue is about is a PR in no state and in nobody's
    queue
    . An ai:design draft is in a modeled state, is enumerated by name every
    run, and its counts are a partition — it is owned and visible, which is the
    property next_design already argues for in its own doc comment.
  • What that queue withholds is a human's scarce attention, on the stated
    ground that a ruling would be spent against a tree its author is still shaping.
    That reason is untouched by whose queue the PR sits in.
  • It also self-corrects under this change rather than needing its own: an
    ai:design draft reaches the vetter's state-load un-vetted at head and is sent
    back, so it stops being a question waiting on a human and becomes producer work
    with a stated move.

Which brings me to the one consequence the issue does not mention, stated plainly
rather than buried: the send-back strips ai:design from a draft that carries
it
(labels_to_remove leaves exactly one AI verdict, as every verdict write
does). The raised question stays on the PR as a comment and the work order is to
un-draft — after which the question can be re-raised against settled code — but
the human's design queue loses that row in the meantime. No such PR exists today
(one draft in the orgs, carrying ai:needs-work), so this is a shape to be aware
of, not a live regression. Narrowing the rule to spare labelled drafts would
contradict "a draft from the producer is verdicted ai:needs-work, always", so I
implemented the ruling as written and am reporting the edge rather than quietly
carving it out.

Adversarial pass over this diff

Can a draft still reach Lane::Leak?

  • The write failed. Yes, until the next run: the PR stays unlabelled with
    sentBack: false on the row and named in draftNeedsWork. Self-healing, and
    loud. A repo that permanently refuses the write (without being in the archived
    set) would leak permanently — and be reported as a leak, which is the honest
    outcome.
  • A repo the vetter does not enumerate. No gap: unvetted_fetch and
    producer_pr_inventory use the same org_owner_args() + --author +
    --state open scope and both withhold archived repos, so the two populations
    cannot diverge by repo.
  • Verdicted, then the label hand-removed. Still leaks, and this pre-dates the
    change: with the vetter comment still current at head, vetted_at_head is true,
    so unvetted skips the PR and never re-applies a label. It is not draft-specific
    — a non-draft in the same state leaks identically. Not fixed here; worth its own
    issue.
  • Human-decided unlabelled draft. SkipHuman wins, so it stays unlabelled and
    can leak. That is constraint 3 of the issue working as specified, not a defect
    in the implementation.
  • No head sha. The plan refuses (None), the row says sentBack: false.

Can the idempotence wrongly suppress a needed verdict? It reuses vetted_at_head
verbatim: a moved head, or a protocol bump, makes the stored verdict non-current
and the send-back is re-derived. A force-push back to an already-verdicted sha
correctly reuses that verdict — same input, same answer.

One surviving starvation, one lane over, that this PR does not fix: a PR
verdicted ai:ready and then converted to draft with no push keeps a current
verdict at its head, so vet_action returns SkipVetted before the draft arm is
reached, while presentable_queue drops drafts from the human's queue. It is not
a leak (it carries a label, and human-queue still lists it under ai:ready), but
no actor moves it. Closing that would mean putting the draft arm above the currency
check, which re-posts a verdict per run on every draft — the thing constraint 2
forbids. The right shape is probably for presentable_queue to name its excluded
drafts rather than only count them; that is a separate issue.

QA

  • Discriminating tests: vetter_state_load_tests::{a_draft_is_sent_back_rather_than_left_in_nobody_s_queue, a_draft_verdicted_at_its_head_is_not_verdicted_again, a_draft_row_carries_the_send_back_action_and_the_draft_flag, the_send_back_writes_the_label_before_the_currency_stamp, the_send_back_comment_is_the_work_order_and_the_currency_stamp, the_send_back_is_written_once_per_head_and_read_back_as_current, a_sent_back_draft_is_no_longer_a_leak_candidate, the_send_back_leaves_exactly_one_ai_verdict_and_touches_nothing_else, a_retry_after_a_failed_comment_re_posts_only_the_comment, a_draft_with_no_head_sha_is_not_written_to_at_all, only_a_draft_row_is_ever_written_to, the_write_is_driven_by_the_rows_own_head_and_labels, a_failed_send_back_is_stated_on_the_row_and_named_in_the_doc, a_human_decided_draft_is_never_written_to, a_human_decision_survives_a_moved_head} — they cannot be run against base (they name DraftNeedsWork / draft_send_back_plan / send_back_draft, which base does not have), so base-discrimination is established by mutation instead: M2 and M1 below restore base's exact behaviour ("a draft is never verdicted", and the draft arm above the currency check) and both are killed.
  • Mutations applied (each applied to main.rs, whole suite re-run, source restored; the pre-existing state_descriptor_tests::the_needs_work_series_draws_every_spelling_the_state_has_had failure was excluded when attributing killers):
    • vet_action — draft arm moved above the vetted_at_head check (base's order) -> killed by a_draft_verdicted_at_its_head_is_not_verdicted_again, the_send_back_is_written_once_per_head_and_read_back_as_current
    • vet_actionis_draft no longer routes to the send-back (base's skip) -> killed by a_draft_is_sent_back_rather_than_left_in_nobody_s_queue + 6 others
    • vet_actionhuman_sacred demoted below the draft arm -> killed by a_human_decision_survives_a_moved_head, a_human_decided_draft_is_never_written_to
    • draft_send_back_plan — plan reversed, so the comment is posted before the label edit -> killed by the_send_back_writes_the_label_before_the_currency_stamp (+2)
    • draft_send_back_plan — the --remove-label loop dropped -> killed by the_send_back_leaves_exactly_one_ai_verdict_and_touches_nothing_else
    • draft_send_back_plan — empty-head guard disabled -> killed by a_draft_with_no_head_sha_is_not_written_to_at_all
    • draft_send_back_plan — label edit always issued -> killed by a_retry_after_a_failed_comment_re_posts_only_the_comment
    • draft_send_back_plan — target label switched to STATE_READY.key -> killed by a_sent_back_draft_is_no_longer_a_leak_candidate (+3)
    • verdict_comment call — DRAFT_SEND_BACK_NOTE replaced by "" -> killed by the_send_back_comment_is_the_work_order_and_the_currency_stamp
    • send_back_draft — action guard disabled (writes to every row) -> killed by only_a_draft_row_is_ever_written_to (+2)
    • send_back_draftsentBack hardcoded to true -> killed by a_failed_send_back_is_stated_on_the_row_and_named_in_the_doc
    • unvetted_docDraftNeedsWork folded into n_vetted (the _-arm regression) -> killed by doc_lists_only_vet_rows_in_vet_first_order_and_counts_the_rest (+2)
    • run_draft_send_back — runner always reports success, so the plan runs on past a failed label write -> killed by the_write_stops_at_the_first_failure_so_the_stamp_never_outruns_the_label (added in the third commit, after CodeRabbit rightly pointed out the short-circuit was untestable inside a function that shells out).
  • Oracle: the FSM's own predicates, not restatements of the new code — is_leak_candidate / classify_lane decide whether the composition worked, needs_work_state decides whether the producer receives a work order, vetted_at_head / verdict_protocol / pr_verdict_line decide whether the comment is current, and the label outcome is computed by replaying the planned gh pr edit argv rather than by asserting a literal list. The issue's own evidence (flow#475: unlabelled draft + merge-update notes) is the leak fixture.
  • Category check: issue asks for (a) drafts routed to needs-work, (b) idempotence via vetted_at_head, (c) human_sacred precedence intact, (d) counters renamed, (e) no leak_reason note-matching, (f) a next_design consistency verdict. Covered: (a) tests 1-2 + the plan tests, (b) a_draft_verdicted_at_its_head_is_not_verdicted_again and the round-trip test, (c) a_human_decision_survives_a_moved_head + a_human_decided_draft_is_never_written_to, (d) a_failed_send_back_is_stated_on_the_row_and_named_in_the_doc + doc_lists_only_vet_rows_..., (e) a_sent_back_draft_is_no_longer_a_leak_candidate asserts leak_reason is unchanged across the transition and the leak functions are untouched in the diff, (f) prose above, no code change.

A second commit: a main-red the branch fixes

state_descriptor_tests::the_needs_work_series_draws_every_spelling_the_state_has_had
was failing on main before this branch existed, and would have failed here for
the same reason. It asserted that no committed human-queue-history.jsonl line
carries the post-rename needsWork key — true only until the first rollup after
#231 was committed, and there are sixteen such lines on main today. The gate was
firing on the calendar rather than on a defect.

The second commit replaces that assertion with the property that is actually
invariant over an append-only history: each folded spelling was measured on
committed lines where needsWork is ABSENT, which is exactly the span a series
folding nothing would lose. Those lines exist and no later rollup can un-write
them. (relink is still emitted alongside needsWork today at count 0, so a
strict non-overlap rule would not have held either — that is worth knowing, and it
is why the gate is stated as "the folded span is non-empty".)

cargo test on the branch: 1060 passing, 0 failing.

One local-only note that does NOT fail CI: nixpkgs' current clippy reports a
collapsible_match warning at main.rs:4096, in code this PR does not touch.
rs-static (which pins its own toolchain) is green.

Review threads

All four CodeRabbit threads are answered and resolved. Two were real and are fixed
in 3e52132: the run loop moved behind an injectable runner so "a failed label
write abandons the currency stamp" is a tested property rather than a reliance on
Iterator::all inside a function that shells out; and the labels_after test
helper peeked at each flag's value instead of consuming it, which made the argv
replay correct only because no ai:* label is spelled like a flag. Two were
answered without a change: the missing-head-sha state is VerdictPlan::NoSha's
handling of the identical condition (loud, never silent, self-healing on the next
readable fetch), and the label_names note reads the detail JSON where the code
reads the already-flattened row.

A draft leaked permanently: the vetter structurally could not label one
(`vet_action` returned a terminal `SkipDraft`), the leak population never
asks whether a PR is a draft, and `leak_reason` fires on any newest
producer note. An unlabelled draft plus one required merge-update note
sat in `Lane::Leak` with no defect for any ruling to fix.

`SkipDraft` becomes `DraftNeedsWork`: the state-load verdicts the draft
`ai:needs-work` itself, with the work order that the producer confirm the
PR is not a draft if it intends to merge something. The verdict is
mechanical because the draft flag decides it, so it is written by the
tool rather than offered to the vetter as work no reading could change.

The draft arm sits under the `vetted_at_head` check, so the send-back
costs one verdict per head rather than one per run, and under
`human_sacred`, which still wins. The label edit precedes the comment:
the label is what leaves the leak population and the comment is the
currency stamp, so writing the stamp first would strand an unlabelled PR
that reads as vetted. `leak_reason`, `is_leak_candidate` and the leak
population are untouched — the hole closes because the PR now carries an
`ai:*` label.

`skipDraft` is now `draftNeedsWork` in both the counts and the summary
line, and the drafts sent back are named unconditionally in the document:
they are the one class of row this call writes to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thedavidmeister thedavidmeister self-assigned this Aug 8, 2026
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@thedavidmeister, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 16 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d465e306-9bc6-44e2-8bdb-513fb53f5298

📥 Commits

Reviewing files that changed from the base of the PR and between 32ba173 and 92492de.

📒 Files selected for processing (2)
  • pr-review-report-rs/src/main.rs
  • review-prompt.txt

Walkthrough

Draft pull requests are now classified as DraftNeedsWork, labeled ai:needs-work, and sent a fixed producer work order. Writes are ordered and retry-aware. Results expose successful and failed send-backs through draftNeedsWork.

Changes

Draft send-back workflow

Layer / File(s) Summary
Draft action and ordered writes
pr-review-report-rs/src/main.rs
Drafts now produce DraftNeedsWork unless human decisions or current-head verdicts take precedence. Label and comment writes run in order, stop after the first failure, and report sentBack.
Draft loading and classification integration
pr-review-report-rs/src/main.rs
Live unvetted loading includes drafts. Detailed pull request data drives classification for ordinary and blocked-on rows.
Draft result reporting and queue contract
pr-review-report-rs/src/main.rs, review-prompt.txt
Counters, pagination, CLI output, tool descriptions, and queue instructions use draftNeedsWork and describe failed-write reporting.
Draft send-back validation
pr-review-report-rs/src/main.rs
Tests cover precedence, current-head idempotence, write ordering, label handling, missing heads, failures, and updated counts.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Vetter
  participant PRClassifier
  participant GitHub
  participant Report
  Vetter->>PRClassifier: load open pull requests including drafts
  PRClassifier->>GitHub: apply ai:needs-work label
  GitHub-->>PRClassifier: label write result
  PRClassifier->>GitHub: create producer work-order comment
  GitHub-->>PRClassifier: comment write result
  PRClassifier->>Report: record draftNeedsWork and sentBack
Loading

Possibly related PRs

Suggested labels: ai:ready

Suggested reviewers: claude

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed [#232] The changes implement draft send-back verdicts, idempotence, human precedence, updated outputs, and unchanged leak logic.
Out of Scope Changes check ✅ Passed The changes remain within issue #232 and support the draft send-back behavior, including tests, prompts, counters, and write handling.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: producer drafts are sent back with a needs-work status.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 2026-08-08-vetter-sends-drafts-back

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The gate asserted that no committed rollup line carries the post-rename
`needsWork` key. That was the same fact as "the fold is load-bearing"
only until the first rollup after #231 was committed; sixteen such lines
are on main today, so the gate fires on the date rather than on a defect.

It now asserts the property that is actually invariant over an
append-only history: each folded spelling was measured on committed lines
where `needsWork` is absent, which is exactly the span a series folding
nothing would lose. Those lines exist and no later rollup can un-write
them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pr-review-report-rs/src/main.rs`:
- Around line 54287-54295: Refactor record_draft_send_back to execute its
generated plan through an injectable runner, preserving Iterator::all
short-circuit behavior. Add a test alongside
the_send_back_writes_the_label_before_the_currency_stamp that uses a runner
returning false for the label-edit command and verifies the comment command is
never invoked.
- Around line 21304-21310: Track the number of gh API calls made by the live
unvetted state-load, including the per-open-PR gh pr view calls for drafts, and
expose that count for orgs with many long-lived drafts. Add a rate-limit alert
when frequent state-load runs drive the call volume toward the available limit,
while preserving draft inclusion and vetted_at_head behavior.
- Around line 54231-54256: Update labels_after so the --add-label and
--remove-label branches consume their following label value directly from the
main iterator instead of using the cloning arg closure; preserve the existing
missing-value expectation and label update behavior.
- Around line 20134-20158: Update send_back_draft and its surrounding
draft-send-back flow to handle an empty headRefOid deterministically: either add
a transition after clearing draft flags and pushing to populate the head SHA, or
emit a distinct terminal report instead of repeatedly recording sentBack: false.
Normalize labels into the label_names shape before extraction, supporting label
objects via their name field so removal requests are preserved.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c5fdfcb2-451f-458f-8bac-f951e4fc66e4

📥 Commits

Reviewing files that changed from the base of the PR and between 8daedf5 and 32ba173.

📒 Files selected for processing (2)
  • pr-review-report-rs/src/main.rs
  • review-prompt.txt

Comment thread pr-review-report-rs/src/main.rs
Comment thread pr-review-report-rs/src/main.rs
Comment thread pr-review-report-rs/src/main.rs
Comment thread pr-review-report-rs/src/main.rs
thedavidmeister and others added 2 commits August 8, 2026 09:04
`record_draft_send_back` relied on `Iterator::all` short-circuiting
inside a function that shells out, so the one property the plan's order
buys — a failed label write abandons the currency stamp — rested on
nothing any test could reach. The run loop is now `run_draft_send_back`,
which takes the runner as an argument, and a test drives both outcomes.

The `labels_after` test helper peeked at each flag's value instead of
consuming it, so the label was re-read as a flag on the next pass and
fell through the catch-all arm. The replay was right only because no
`ai:*` label is spelled like a flag; it now consumes, as an argv parser
does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The send-back's label edit leaves exactly one `ai:*` verdict, so on a draft
already carrying a modeled state it does not fill a hole — it DELETES that
state. `ai:close-candidate` and `ai:design` are enumerated BY LABEL, so
stripping one retracts a producer's request to destroy work, or a design
question, out of the human's queue with nothing on the record but a needs-work
comment; `ai:blocked-infra` is the third reachable case. Before this branch a
draft was skipped entirely — no write, no strip — so the collapse was new here.

The ruling stands: a draft must never sit in nobody's queue. A draft in a
modeled state is already in somebody's, and it was never in the leak population
the ruling closes — `is_leak_candidate` requires `ai_state_label(..).is_none()`,
so a draft carrying an `ai:*` label is not in the leak bucket at all. The leak
is the UNLABELLED draft, and that case is unchanged.

The guard is DERIVED from `classify_lane`, never a list of labels, for
`is_leak_candidate`'s own reason: a hand list drifts the first time a state is
added, and this one would drift silently, into destroying the new state's rows.
It asks about the WRITE rather than about the PR — every label the plan would
REMOVE must name no state, i.e. the classifier asked about that label ALONE
still answers `un-vetted`. `Some(false)` for the ready currency is a fact at
this arm, not a supposition: the draft arm sits below the currency check. That
is why a stale `ai:ready` is still written over and `ai:design` is not — the
line is whether the label still names a state, not who wrote it. Per label
rather than over the set, because precedence would otherwise SHADOW the state
being destroyed (`ai:ready` beside `ai:design` classifies as `un-vetted` by the
set). `ai:needs-work` is written over too: it is the send-back's own target, so
nothing is stripped and the write is only the currency stamp — which is what
heals a send-back whose comment failed and left the PR `Parked` on a human.

The withheld draft is `VetAction::SkipDraftInState`, counted under
`counts.skipDraftInState` and named nowhere as a write: every PR in it carries
a modeled `ai:*` label, so it is already inventoried by the state that label
names, which is the entire reason the write was withheld.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thedavidmeister

thedavidmeister commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

The send-back no longer destroys a state a human owes a decision on

Folded into this branch. The rule stands as ruled — the vetter sends producer
drafts back as a matter of principle — and this is about the WRITE that rule
makes, not about the rule.

The defect

draft_send_back_plan calls labels_to_remove(labels, target), which strips
every ai:* label except ai:needs-work. Before this PR a draft was skipped
entirely — no write, no strip — so this PR newly widened that collapse to drafts.

The consequence is a draft carrying an ai:* label that a HUMAN owes a decision
on having it silently deleted and replaced with ai:needs-work. Three reachable
cases, not the one I reported in the PR body:

  • ai:close-candidate — a producer flag awaiting a human ruling. It is
    enumerated BY LABEL (next_close_candidate), so stripping it removes the
    subject from the /ncc queue with nothing on the record but a needs-work
    comment. The producer's request to destroy the work simply evaporates.
  • ai:design — the case I did report. Enumerated by label by next_design,
    same silent loss from /nd.
  • ai:blocked-infra — "blocked on an infra/tooling gap or can't classify
    (human)".

ai:blocked-on was already safe by another mechanism: that path returns its own
action and the writer is never reached.

Latent, not active: zero live instances of any of the three across rainlanguage,
cyclofinance and S01-Issuer. It arms the moment a producer flags a draft.

Why this is not a narrowing of the ruling

The ruling's intent is that a draft must never sit in nobody's queue. A draft
carrying ai:close-candidate or ai:design is already in somebody's queue — the
human's — so sending it back does not serve that ruling, it defeats an adjacent
one.

The structural fact that makes it clean: is_leak_candidate already requires
ai_state_label(labels).is_none(), so a draft carrying a modeled ai:* state
is not in the leak population at all
. The send-back was never needed for it.
The leak this PR fixes is specifically the UNLABELLED draft, and that case is
untouched — flow#475 is still sent back.

The predicate, and how it is derived

Not a list of three label strings — that drifts the first time a state is added,
and it would drift silently, into destroying the new state's rows.
is_leak_candidate's doc comment already argues this at length for the leak
population, and the same argument applies here.

The question is asked about the write rather than about the PR:

The send-back strips every other ai:* label. It may strip a label only if
that label names no state — i.e. classify_lane, asked about that label alone,
still answers un-vetted.

fn draft_send_back_strips_no_state(labels: &[String]) -> bool {
    labels_to_remove(labels, STATE_NEEDS_WORK.key)
        .iter()
        .all(|l| classify_lane(std::slice::from_ref(l), Some(false), false).1 == STATE_UN_VETTED.key)
}

Three choices in there, each with a reason:

  • Some(false) for the ai:ready currency is a fact, not a supposition: the
    draft arm sits BELOW the currency check in vet_action, so a draft that reaches
    this question is not vetted at its head. classify_lane then reports a stale
    ai:ready as un-vetted — which is exactly why ai:ready on a draft is
    still sent back
    . The dividing line is not who wrote the label; it is whether
    the label still names a state. A stale verdict does not, and the human's merge
    queue never held that PR anyway (presentable_queue drops drafts, and the
    ready lane cell already demotes a head-drifted verdict to un-vetted).
  • false for producer_commented decides only leak vs un-vetted, and a
    label the classifier models as neither names no state either way. That keeps
    RULED: migrate the ai:blocked-deploy residue to ai:reject — rework each PR for the split release lifecycle #221's ruling unchanged: a DELETED ai:* string (ai:blocked-deploy) is
    ABSORBED by the next verdict rather than triaged by a human.
  • Per label rather than over the whole set, because precedence would otherwise
    SHADOW the state being destroyed. A PR wearing ai:ready beside ai:design
    classifies as un-vetted by the set (the ai:ready arm returns first) while
    the label the write would actually destroy is the design question. Off-protocol,
    and precisely the hand-state a rule about destroying states must not itself eat.

Two labels are therefore still written over, each for a reason about the write:

  • ai:needs-work — the send-back's own target. labels_to_remove leaves it, so
    the write strips nothing and is only the currency stamp. Withholding here
    would have been a regression
    : it is what heals a send-back whose comment
    failed and left the PR NeedsWorkState::Parked, which routes to a human.
  • ai:ready — as above.

The new outcome is VetAction::SkipDraftInState, counted as
counts.skipDraftInState and named nowhere as a write. It gets no list of its
own on purpose: every PR in it carries a modeled ai:* label, so it is already
inventoried by the state that label names — which is the entire reason the write
was withheld. draftNeedsWork continues to mean "PRs this call wrote to".

Tests

Inline in vetter_state_load_tests, beside the existing send-back tests:

  • a_draft_already_in_a_modeled_state_is_not_written_to — all three, plus
    ai:relink and ai:blocked-on (belt and braces on the path that already
    guards it).
  • a_draft_in_a_modeled_state_keeps_its_label_and_is_never_written_to — the same
    at the ROW level: own action, writer never called, label unchanged, no
    sentBack field to lie with.
  • a_draft_in_no_state_of_its_own_is_still_sent_backai:ready and
    ai:needs-work, with the reason for each.
  • a_draft_is_sent_back_rather_than_left_in_nobody_s_queue — the unlabelled
    flow#475 case, unchanged.
  • the_guard_protects_every_ai_state_the_classifier_models — swept over
    STATE_DESCRIPTORS, so a state added later is protected with nothing to update
    here, plus the row-less ai:close-candidate hand-off and the DELETED_LABELS
    absorb case.
  • the_guard_withholds_nothing_the_leak_population_contains — the safety
    property, both directions, over the same sweep: withholding REQUIRES an ai:*
    label to strip, and any ai:* label already excludes the PR from
    is_leak_candidate, so the two sets cannot overlap.
  • a_shadowed_state_is_still_protected — the ai:ready + ai:design pair, with
    the set-level classification asserted so the test says why the guard does not
    ask about the set.
  • a_withheld_draft_is_counted_under_its_own_key_and_never_as_a_send_back.

a_sent_back_draft_is_no_longer_a_leak_candidate (the composition this PR rests
on) is untouched and passes.

the_send_back_leaves_exactly_one_ai_verdict_and_touches_nothing_else asserted
the old behaviour with ai:design in its before list. It now uses
["ai:ready", "ai:blocked-deploy", "human:keep-open", "bug"] — a stale verdict
and a deleted string, both labels the guard admits — and additionally asserts
that the fixture IS a set the guard admits. What it was there to check is
unchanged and unweakened: two ai:* labels go in, exactly one survives, and the
non-ai: labels are untouched. Asserting the shape of a write the guard now makes
unreachable is how a test outlives the behaviour it was written for.

the_write_is_driven_by_the_rows_own_head_and_labels used ai:design for the
same reason and now uses ai:ready, so the row still reaches the writer.

Adversarial pass over the fix

Can a label still be destroyed?

  • A state added to classify_lane with no descriptor row would not be swept
    by the property test. The GUARD still protects it — it asks classify_lane
    directly — so this is a gap in the test's coverage, not in the behaviour, and
    ai:close-candidate (the one such state today) is asserted by name with that
    reason stated.
  • ai:ready is still stripped, deliberately. I could not construct a case where
    that loses something a human holds: a CURRENT ai:ready never reaches the draft
    arm (SkipVetted wins), and a stale one is already un-vetted to every
    consumer.
  • human:* and plain labels cannot be destroyed at all — labels_to_remove never
    touches them.
  • The comment is never deleted by anything here, so a design question raised
    in prose survives either way. What was being destroyed was only ever the label,
    which is exactly what the queues enumerate by.

Can the guard wrongly suppress a send-back a leaked draft needs? No, and it is
a composition rather than a judgement: withholding requires at least one ai:*
label the write would strip, so ai_state_label(..).is_some(), so
is_leak_candidate is false. Asserted in both directions.

Still broken, unchanged by this fix (both pre-date it and neither is
draft-specific):

  • A PR verdicted, then hand-stripped of its label, still leaks — vetted_at_head
    is true so unvetted skips it and never re-applies one. Worth its own issue.
  • A PR verdicted ai:ready and THEN converted to draft with no push keeps a
    current verdict, so SkipVetted wins and no actor moves it while
    presentable_queue drops it from the human's queue. Called out in the PR body
    already; still a separate issue.

The two other things, assessed

1. Protocol-bump write storm — I do not think it needs bounding. A
VET_PROTOCOL bump makes vetted_at_head false everywhere, so every draft is
re-sent-back with a duplicate needs-work comment. It is bounded at one comment
per draft per bump
(the label edit is already correct by then, so the plan is
comment-only), bumps are rare, and the live draft population is 1 across 148 open
authored PRs. More importantly the re-post is not waste: it is the stamp that
re-establishes idempotence under the new protocol. Suppress it and
vetted_at_head stays false for that draft for ever, which is a write per RUN
instead of per BUMP — strictly worse. The only alternative is a draft-specific
currency notion, which is exactly the "second currency free to disagree with
vetted_at_head" this PR deliberately refused. This fix also shrinks it: drafts
in a modeled state are now never written to, at any protocol.

2. Per-run fetch cost — inherent, and bounded. Confirmed: the bound is one
gh pr view per OPEN AUTHORED PR per run, which is already the loop's bound
(~148–170), and drafts are a subset of it, not an addition to it. The marginal
cost the pre-filter removal added is (drafts) × (6 runs/day) = ~6 calls/day today.
It cannot be reclaimed: isDraft and labels are in the search JSON, but
human_sacred — which dominates the draft arm — is a native review or a ruling
comment, and neither is visible to search. The file already states the rule
("NOTHING is classified off the search JSON") and it holds here. Skipping the
fetch would reintroduce classification off stale search data to save a call that
scales with a population the run already walks.

One residual this guard CREATES, found on the adversarial pass

next_design withholds drafts from its presented head (ND_WHY_DRAFT) — it
names them in withheld with the reason, but does not present them. The PR body
argued the ai:design draft "self-corrects under this change rather than needing
its own: it reaches the vetter's state-load un-vetted at head and is sent back".
This guard removes that self-correction. An ai:design DRAFT is now withheld
from /nd, not sent back, and counted as skipDraftInState — no actor owns the
next move.

It is not a leak and it is not silent: it carries a label, it is inventoried in
the design lane cell, and next_design NAMES it in withheld with its reason.
It is the same shape as the surviving starvation the PR body already records one
lane over (an ai:ready PR converted to draft with no push). The trade is still
plainly right — a visible named row beats a design question deleted out of the
human's queue with no record — but it deserves its own issue, and I would expect
the fix on next_design's side: its stated reason for withholding is that a
ruling would be spent against a tree still being shaped, which is a reason to
deprioritise, not a reason to make the row unownable.

The other two protected states do NOT have this shape. next_close_candidate
has no draft withholding at all (FlagQueueCounts has no draft partition), so a
flagged draft is presented to the human normally; ai:blocked-infra residue is
owned by retire-blocked-infra; ai:relink by the human re-recording the
verdict.

Live populations across rainlanguage/cyclofinance/S01-Issuer, checked just
now: ai:relink 0, ai:blocked-infra 0, ai:close-candidate 0, ai:design 23
PRs of which 0 are drafts. So this residual is latent in exactly the way the
defect it replaces was.

Both this branch and #234 replaced
`the_needs_work_series_draws_every_spelling_the_state_has_had` after its
premise expired, so main arrived carrying a different fix for the same test.

Git's automatic resolution combined them into something neither wrote, and it
would not have failed: this branch's `if counts.contains_key("needsWork")
{ continue; }` line filter survived alongside main's
`first_seen.get("needsWork")`. With that filter in place `first_seen` never
observes a `needsWork` line, so `native_first` is always `None` and main's
ordering assertion takes its `continue` arm for every fold key. The test
compiles, passes, and checks nothing about ordering. No conflict marker covered
that interaction — the markers were on the doc comment and the accumulator
line, and the contradiction sat in the auto-merged region between them.

Main's version is taken whole. It is the stronger of the two: it keeps the
"every folded spelling has samples" check, adds the ordering property that each
folded spelling's earliest measurement strictly precedes the new name's first,
and compares stamps by instant so the `Z` and `+00:00` spellings in the
committed history cannot order two samples by their zone suffix. This branch's
"measured on a line without `needsWork`" is a weaker proxy for the same claim
and is now redundant.

The draft send-back this branch exists for is untouched; the conflict was
confined to that one test. Full suite green on the merge: 1147 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thedavidmeister

Copy link
Copy Markdown
Contributor Author

Reviewed 92492de: approve

Read every commit against the tree, plus the merge I resolved myself.

The rule. SkipDraft stops being a terminal skip; the state-load verdicts a draft ai:needs-work with a fixed work order. vet_action is ordered human_sacred → vetted_at_head → is_draft → Vet, so idempotence and human precedence both fall out of the ordering with no new currency notion invented — a draft costs one verdict per head, not one per run. The leak closes as a consequence rather than a second mechanism: the PR gains an ai:* label, so is_leak_candidate is false. leak_reason and the leak population are untouched, which is what a_sent_back_draft_is_no_longer_a_leak_candidate pins with the producer notes held constant.

The write order. Label before comment, and the argument holds: the label is what leaves the leak population, the comment is the currency stamp. Stamp-first plus a failed label write leaves a PR reading as vetted at its head with no state label — un-labelled, never re-derived, permanently leaked, the exact outcome the rule removes. Label-first inverts it into NeedsWorkState::Parked, which is modeled and re-derived next run. The executor's short-circuit is driven by a test rather than left implicit in Iterator::all.

The guard (9867aa5). I checked this one line by line because I asked for it. draft_send_back_strips_no_state asks the question about the WRITE — every label the plan would remove must, asked about ALONE, still classify un-vetted. Three choices are each load-bearing and each correct: per-label rather than over the set, because precedence would otherwise shadow the very state being destroyed (ai:ready beside ai:design classifies as un-vetted for the set, and the ai:design would have been stripped anyway) — that is a trap I did not see when I briefed it; Some(false) for the ready currency is a fact at this arm rather than a guess, since the draft arm sits below the vetted_at_head check; and false for producer_commented collapses leak and un-vetted, both meaning no state, preserving #221's absorb ruling for deleted ai:* strings. ai:needs-work is still written over because it is the target, so nothing is stripped and the write is only the stamp that heals a half-landed send-back. Derived from classify_lane, never a label list, so a state added later is protected with nothing to update — which the_guard_protects_every_ai_state_the_classifier_models asserts off STATE_DESCRIPTORS.

The merge (92492de) — I resolved it and it needed judgement. This branch and #234 each replaced the_needs_work_series_… after its premise expired. Git's automatic resolution combined them into something neither wrote and it would NOT have failed: this branch's if counts.contains_key("needsWork") { continue; } filter survived beside main's first_seen.get("needsWork"), so native_first is always None and the ordering assertion takes its continue arm for every fold key — compiling, passing, asserting nothing. No conflict marker covered that interaction. Main's version is taken whole as the stronger of the two. Full suite run on the merge result: 1147 passed, 0 failed.

Known and not fixed, both pre-dating this branch and neither draft-specific: a PR verdicted then hand-stripped of its label still leaks permanently, and a PR verdicted ai:ready then converted to draft with no push keeps a current verdict that no actor moves. Also flagged: an ai:design draft is now withheld from /ndd and not sent back, so it is labelled, inventoried and named in the withheld list but owned by nobody — zero live instances, and the fix belongs on next_design's side.

Rulings-conformance: graded against the repo's CLAUDE.md north star and every ruling the human stated for this work, each named with how the artifact obeys it.

  • "vetter should always just move drafts from the producer back to needs work as a matter of principle — at the least the work is the producer confirming that it's not a draft if it intends to merge something" — obeyed, including its narrowing, which I authorized explicitly rather than the agent inventing. A draft already carrying a modeled ai:* state is already in somebody's queue and was never in the leak population the ruling closes, so withholding the write there serves the ruling's intent; applying its letter to destroy a pending human decision would have defeated an adjacent one. The unlabelled draft — the case the ruling is about — is sent back unconditionally.
  • "fix 233 in an agent" — obeyed: implemented by an agent in an isolated clone, folded into this branch rather than a second PR, with the live install never touched.
  • "merge" — obeyed: merging on the human's explicit per-PR word, at this reviewed SHA.
  • "producer resolves merge conflicts — merge base in, never rebase" — obeyed: git merge origin/main, no rebase, no force-push.
  • "semantic conflicts hide outside the markers — run the full suite on the merge commit" — obeyed, and it is why the auto-merge defect above was caught rather than shipped.
  • Merge-form rulings — obeyed: --merge --admin, never squash, no --delete-branch.
  • "comments describe CURRENT behaviour only" — obeyed: I read the added comments; they state what each rule holds and what breaks without it, with no process narration.
  • CLAUDE.md north star (the Rust tool is the ONLY transition function; a gap is closed by adding a subcommand, never by reaching for gh in a prompt) — obeyed in the strong direction: the send-back is a new transition inside the binary, tested and typed, and review-prompt.txt only describes it. No prompt gained a gh call.

@thedavidmeister
thedavidmeister merged commit f200c73 into main Aug 8, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Vetter must send producer drafts back to needs-work: SkipDraft strands drafts in a permanent, unhealable leak

1 participant