feat(release): nothing watches for unpublished drafts — a cut opens a window only a human closes #811

Closed
opened 2026-08-21 15:03:49 +02:00 by bosun · 2 comments
Owner

Motivation

Neither option on release-toolkit#805 makes anyone click Publish. @engineer stated this
explicitly rather than letting the manifest fix absorb it, and he is right that it is a
separate unit.

purser v0.4.0   draft created 2026-08-17 13:39:34Z
                UNPUBLISHED for 4 days
                found by an org-wide sweep, NOT by anyone noticing

Draft-first is the design (ADR-0003 Gate-3, publish_mode defaults to draft), so
every cut on every adopter opens a window that only a human action closes. Three of four
adopters closed theirs because somebody happened to click. Nothing watches.

⚠️ #805's fix makes the state legible, not attended. Under its recommended option the
manifest would correctly say "a draft exists, unpublished" — and purser's draft would still
be sitting there, correctly labelled, unclicked, for four days.

Scope

An unpublished draft older than some threshold should surface somewhere a human looks.
Options, none chosen:

  1. A sweep — a scheduled job across adopters listing drafts older than N days
  2. A nag — the next cut refuses, or warns loudly, when a prior draft is still unpublished
  3. A dashboard/metric — count of open drafts per repo
  4. Nothing — decide the operator will notice, and record that as the decision

📌 Option 2 has a property the others lack: it needs no cross-repo permission. The cut
already knows its own repo's release state. Anything reading other repos hits the
branch_protections-style 403 wall that blocked two chambers today.

⚠️ A metric that nobody consumes is not a remedy/srv/CLAUDE.md records
system_configs_drift as emitted-but-unconsumed, "visible only to someone who already
suspects the drift."
Option 3 is that shape unless an alert consumes it.

Acceptance criteria

  • Decided which of the four — DECIDED 2026-08-27 (operator): option 2, STRENGTHENED from warn to REFUSE. The cut is rejected when a prior release is unpublished, and the block is made legible on the rolling PR itself — title chore(release): vX.Y.Z [BLOCKED] plus a body line naming the unpublished release. Spec + constraints: #issuecomment-102380.
  • If a mechanism: it fires without anyone remembering to look — DONE. The gate runs inside rt release (refusal) and rt prep (the [BLOCKED] title and body), both of which fire on the next push to main. No human trigger, no schedule to forget. Verified live against a real forge, three arms: cid-probe BLOCKS, purser REFUSES as could-not-grade (push=false), release-toolkit proceeds. Stated limit, which does not falsify the AC: a repo with no further cuts gets no warning — and a repo with no further cuts has no one to warn. Scope note, not unfinished work.
  • purser's v0.4.0 draft dispositioned — PUBLISHED 2026-08-21T17:12:56, four days after opening. Measured against the release object, not assumed.
  • release-toolkit#805 — makes the manifest legible; deliberately does NOT cover this
  • purser#62 / purser#60 — the four-day instance
  • release-toolkit#806 — the adopter-CI gap found by the same sweep

Anchor

Named by @engineer while working #805, stated as out of scope rather than folded in:
"it does not make anyone click … it wants a sweep or a nag and it is a separate unit."
Filed by Bosun per the one-filer rule; the finding and the scoping are @engineer's. The
four-day instance was surfaced by @surveyor.

## Motivation **Neither option on `release-toolkit#805` makes anyone click Publish.** @engineer stated this explicitly rather than letting the manifest fix absorb it, and he is right that it is a separate unit. ``` purser v0.4.0 draft created 2026-08-17 13:39:34Z UNPUBLISHED for 4 days found by an org-wide sweep, NOT by anyone noticing ``` **Draft-first is the design** (ADR-0003 Gate-3, `publish_mode` defaults to `draft`), so *every* cut on *every* adopter opens a window that only a human action closes. Three of four adopters closed theirs because somebody happened to click. **Nothing watches.** ⚠️ **`#805`'s fix makes the state legible, not attended.** Under its recommended option the manifest would correctly say *"a draft exists, unpublished"* — and purser's draft would still be sitting there, correctly labelled, unclicked, for four days. ## Scope An unpublished draft older than some threshold should surface somewhere a human looks. Options, none chosen: 1. **A sweep** — a scheduled job across adopters listing drafts older than N days 2. **A nag** — the next cut refuses, or warns loudly, when a prior draft is still unpublished 3. **A dashboard/metric** — count of open drafts per repo 4. **Nothing** — decide the operator will notice, and record that as the decision 📌 **Option 2 has a property the others lack: it needs no cross-repo permission.** The cut already knows its own repo's release state. *Anything reading other repos hits the `branch_protections`-style 403 wall that blocked two chambers today.* ⚠️ **A metric that nobody consumes is not a remedy** — `/srv/CLAUDE.md` records `system_configs_drift` as emitted-but-unconsumed, *"visible only to someone who already suspects the drift."* **Option 3 is that shape unless an alert consumes it.** ## Acceptance criteria - [x] Decided which of the four — **DECIDED 2026-08-27 (operator): option 2, STRENGTHENED from warn to REFUSE.** The cut is rejected when a prior release is unpublished, and the block is made legible on the rolling PR itself — title `chore(release): vX.Y.Z [BLOCKED]` plus a body line naming the unpublished release. Spec + constraints: `#issuecomment-102380`. - [x] If a mechanism: it fires without anyone remembering to look — **DONE.** The gate runs inside `rt release` (refusal) and `rt prep` (the `[BLOCKED]` title and body), both of which fire on the next push to `main`. No human trigger, no schedule to forget. Verified live against a real forge, three arms: `cid-probe` BLOCKS, `purser` REFUSES as could-not-grade (`push=false`), `release-toolkit` proceeds. **Stated limit, which does not falsify the AC:** a repo with no further cuts gets no warning — and a repo with no further cuts has no one to warn. Scope note, not unfinished work. - [x] purser's `v0.4.0` draft dispositioned — **PUBLISHED 2026-08-21T17:12:56**, four days after opening. Measured against the release object, not assumed. ## Related - `release-toolkit#805` — makes the manifest legible; deliberately does NOT cover this - `purser#62` / `purser#60` — the four-day instance - `release-toolkit#806` — the adopter-CI gap found by the same sweep ## Anchor Named by @engineer while working `#805`, stated as out of scope rather than folded in: *"it does not make anyone click … it wants a sweep or a nag and it is a separate unit."* Filed by Bosun per the one-filer rule; the finding and the scoping are @engineer's. The four-day instance was surfaced by @surveyor.
Owner

Claimed. Premise re-measured — AC3 is satisfied, and zero drafts today is not evidence the gap is closed

unpublished drafts, all four consumers, 2026-08-26:   0
purser v0.4.0:  draft=false · published 2026-08-21T17:12:56 · assets=1

AC3 is done — purser's draft was dispositioned five days ago, roughly four days after it opened, which matches the figure in the body.

🔴 But the zero is the tracker's own thesis, not a refutation of it

A naive read closes this: "no drafts are sitting, therefore nothing to fix." That inverts the finding. The body already says three of four adopters closed theirs "because somebody happened to click" — so a zero is exactly what a human-dependent process looks like while the human is present.

This is the family that never fails first. It functions until someone is absent, and its healthy state and its broken state are the same reading taken at different times.

The correct question is not "are there drafts now?" but "what would tell us if there were, and who would it tell?" — and today's answer is still an org-wide sweep somebody chose to run.

⚠️ A measurement problem that decides the design — the dwell time is destroyed at publish

The body records purser's draft as created 2026-08-17 13:39:34Z. The API today reports:

created_at = 2026-08-21T17:12   ← equal to published_at

Whatever the mechanism, the four-day window is no longer recoverable from the API. Either created_at is rewritten at publish or the original reading was of a different field — I cannot distinguish those retrospectively, and that is the point.

🔑 So a sweep can only ever see CURRENTLY-open drafts. There is no way to ask "how long have our drafts historically sat?" after the fact — which means we cannot measure the size of this problem retrospectively, only catch instances live. Any option that depends on knowing the historical dwell time is unbuildable.

Recommendation: option 2, with option 4 recorded as the fallback

Option 2 — the next cut warns loudly when a prior draft is still unpublished.

needs no cross-repo permission   the cut already knows its own repo's release state
fires without anyone looking     it runs on the next push, which is the one event
                                 guaranteed to happen in a repo that is still alive
degrades honestly                a repo with no further cuts gets no warning — and a
                                 repo with no further cuts has no one to warn

⚠️ Not option 3. /srv/CLAUDE.md records system_configs_drift as emitted-but-unconsumed — "visible only to someone who already suspects the drift." A metric without an alert reproduces this defect one layer up, and this tracker exists because the state was already legible and unattended.

⚠️ And not option 1 alone. A scheduled cross-repo sweep hits the branch_protections-style 403 wall — the same wall that meant the reviewers who found #806's instances could not read the field that decided whether they mattered.

📌 WARN, not refuse. Per §Mechanism design a disclosure that cannot alter exit status is decoration — but refusing a cut because a previous release is unpublished blocks work on a condition the author may not own. The disclosure belongs where the cut's output is read, and its scope note should say what it did NOT check: only this repo, only at cut time.

Not building until AC1 is decided — it is worded as a decision, and "nothing" is explicitly on the menu.

## Claimed. Premise re-measured — AC3 is satisfied, and **zero drafts today is not evidence the gap is closed** ``` unpublished drafts, all four consumers, 2026-08-26: 0 purser v0.4.0: draft=false · published 2026-08-21T17:12:56 · assets=1 ``` **AC3 is done** — purser's draft was dispositioned five days ago, roughly four days after it opened, which matches the figure in the body. ## 🔴 But the zero is the tracker's own thesis, not a refutation of it **A naive read closes this: "no drafts are sitting, therefore nothing to fix."** That inverts the finding. The body already says three of four adopters closed theirs *"because somebody happened to click"* — so **a zero is exactly what a human-dependent process looks like while the human is present.** > **This is the family that never fails first. It functions until someone is absent, and its healthy state and its broken state are the same reading taken at different times.** The correct question is not *"are there drafts now?"* but *"what would tell us if there were, and who would it tell?"* — and today's answer is still **an org-wide sweep somebody chose to run.** ## ⚠️ A measurement problem that decides the design — the dwell time is destroyed at publish The body records purser's draft as created **2026-08-17 13:39:34Z**. The API today reports: ``` created_at = 2026-08-21T17:12 ← equal to published_at ``` **Whatever the mechanism, the four-day window is no longer recoverable from the API.** Either `created_at` is rewritten at publish or the original reading was of a different field — I cannot distinguish those retrospectively, and *that is the point*. 🔑 **So a sweep can only ever see CURRENTLY-open drafts. There is no way to ask "how long have our drafts historically sat?" after the fact** — which means we cannot measure the size of this problem retrospectively, only catch instances live. **Any option that depends on knowing the historical dwell time is unbuildable.** ## Recommendation: option 2, with option 4 recorded as the fallback **Option 2 — the next cut warns loudly when a prior draft is still unpublished.** ``` needs no cross-repo permission the cut already knows its own repo's release state fires without anyone looking it runs on the next push, which is the one event guaranteed to happen in a repo that is still alive degrades honestly a repo with no further cuts gets no warning — and a repo with no further cuts has no one to warn ``` ⚠️ **Not option 3.** `/srv/CLAUDE.md` records `system_configs_drift` as emitted-but-unconsumed — *"visible only to someone who already suspects the drift."* **A metric without an alert reproduces this defect one layer up**, and this tracker exists because the state was already legible and unattended. ⚠️ **And not option 1 alone.** A scheduled cross-repo sweep hits the `branch_protections`-style 403 wall — the same wall that meant the reviewers who found `#806`'s instances could not read the field that decided whether they mattered. 📌 **WARN, not refuse.** Per §Mechanism design a disclosure that cannot alter exit status is decoration — but refusing a cut because a *previous* release is unpublished blocks work on a condition the author may not own. **The disclosure belongs where the cut's output is read, and its scope note should say what it did NOT check: only this repo, only at cut time.** **Not building until AC1 is decided** — it is worded as a decision, and "nothing" is explicitly on the menu.
Owner

AC1 DECIDED — option 2, and the operator has strengthened it from WARN to REFUSE

Operator, 2026-08-27. Option 2 chosen, with a refinement that goes past what I recommended:

"I would prefer a solution that would reject a cut if the previous release was not published yet. Maybe using a CI gate? And ideally displaying the fact prominently on the rolling cut PR itself. For example by an adjusted title, and eventually a brief explanation in the body. E.g. chore(release): v0.53.1 [BLOCKED], **Previous release v0.53.0 was not published yet.**"

🔑 This answers the objection I raised against refusing, rather than overriding it

I recommended WARN, not refuse, on the grounds that "refusing a cut because a PREVIOUS release is unpublished blocks work on a condition the author may not own." The refinement dissolves that: the block is not a silent refusal the author has to diagnose — it is named in the PR title and explained in the body, at the surface the person who would merge it is already looking at.

BLOCKED, MYSTERIOUS   a gate refuses; the author reads a log to find out why
BLOCKED, LEGIBLE      "chore(release): v0.53.1 [BLOCKED]"
                      "**Previous release v0.53.0 was not published yet.**"

And /srv/CLAUDE.md §Mechanism design already prefers this: "a refusal is a wrong answer that costs nothing; a warning is a correct answer that costs whatever the reader decides. When a mechanism can tell that the caller is about to do damage, prefer refusing over disclosing." The cut CAN tell — it reads its own repo's release state. So the warn form was the weaker choice and I was wrong to prefer it.

Design constraints for whoever implements

1 — The signal must live on the rolling PR, not only in the run log. The rolling PR is rewritten on every push to main, so the title and body are already regenerated each cycle — this rides an existing write rather than adding one.

2 — A refusal that cannot change the exit status is decoration. Per the same section: this must actually refuse the cut, not annotate and proceed.

3 — It needs no cross-repo permission. The cut already knows its own repo's release state. Anything reading other repos hits the branch_protections-style 403 wall.

4 — ⚠️ The unblock path must be stated in the body. A [BLOCKED] title with no stated remedy is a refusal someone routes around — this repo has a nine-day anchor where a density gate named its defect six times and three cuts were unstuck by hand-writing the manifest instead. Say what to do: publish v0.53.0, or discard it.

5 — ⚠️ Decide what happens to a DRAFT that is deliberately held. publish_mode: 'draft' is the documented default and some adopters may sit on a draft intentionally. If so, the block is correct but needs an escape that is a decision rather than a workaround.

📌 A measurement that constrains the implementation

Dwell time is destroyed at publishcreated_at becomes published_at, so "how long has this draft sat?" is unanswerable after the fact. The gate can only ever see a CURRENTLY-open draft. That is sufficient for this design (it asks "is one open now?"), but it rules out any threshold expressed in days-since-creation for drafts already closed.

AC dispositions

  • AC1 — DONE. Option 2, strengthened to a refusal with a legible PR surface.
  • AC2 — open. The mechanism does not exist yet; this is the implementation.
  • AC3 — DONE. purser v0.4.0 was published 2026-08-21T17:12:56, four days after opening. Measured, not assumed.

Not implementing tonight — this is now a CI-gate change on the release path and wants its own PR and review row.

## AC1 DECIDED — option 2, and the operator has strengthened it from WARN to REFUSE **Operator, 2026-08-27.** Option 2 chosen, with a refinement that goes past what I recommended: > *"I would prefer a solution that would reject a cut if the previous release was not published yet. Maybe using a CI gate? And ideally displaying the fact prominently on the rolling cut PR itself. For example by an adjusted title, and eventually a brief explanation in the body. E.g. `chore(release): v0.53.1 [BLOCKED]`, `**Previous release v0.53.0 was not published yet.**`"* ### 🔑 This answers the objection I raised against refusing, rather than overriding it I recommended **WARN, not refuse**, on the grounds that *"refusing a cut because a PREVIOUS release is unpublished blocks work on a condition the author may not own."* **The refinement dissolves that**: the block is not a silent refusal the author has to diagnose — **it is named in the PR title and explained in the body, at the surface the person who would merge it is already looking at.** ``` BLOCKED, MYSTERIOUS a gate refuses; the author reads a log to find out why BLOCKED, LEGIBLE "chore(release): v0.53.1 [BLOCKED]" "**Previous release v0.53.0 was not published yet.**" ``` **And `/srv/CLAUDE.md` §Mechanism design already prefers this**: *"a refusal is a wrong answer that costs nothing; a warning is a correct answer that costs whatever the reader decides. When a mechanism can tell that the caller is about to do damage, prefer refusing over disclosing."* **The cut CAN tell** — it reads its own repo's release state. So the warn form was the weaker choice and I was wrong to prefer it. ### Design constraints for whoever implements **1 — The signal must live on the rolling PR, not only in the run log.** The rolling PR is rewritten on every push to `main`, so the title and body are already regenerated each cycle — this rides an existing write rather than adding one. **2 — A refusal that cannot change the exit status is decoration.** Per the same section: this must actually refuse the cut, not annotate and proceed. **3 — It needs no cross-repo permission.** The cut already knows its own repo's release state. Anything reading *other* repos hits the `branch_protections`-style 403 wall. **4 — ⚠️ The unblock path must be stated in the body.** A `[BLOCKED]` title with no stated remedy is a refusal someone routes around — this repo has a nine-day anchor where a density gate named its defect six times and three cuts were unstuck by hand-writing the manifest instead. **Say what to do: publish `v0.53.0`, or discard it.** **5 — ⚠️ Decide what happens to a DRAFT that is deliberately held.** `publish_mode: 'draft'` is the documented default and some adopters may sit on a draft intentionally. If so, the block is correct but needs an escape that is a decision rather than a workaround. ### 📌 A measurement that constrains the implementation **Dwell time is destroyed at publish** — `created_at` becomes `published_at`, so *"how long has this draft sat?"* is unanswerable after the fact. **The gate can only ever see a CURRENTLY-open draft.** That is sufficient for this design (it asks "is one open now?"), but it rules out any threshold expressed in days-since-creation for drafts already closed. ### AC dispositions - **AC1 — DONE.** Option 2, strengthened to a refusal with a legible PR surface. - **AC2 — open.** The mechanism does not exist yet; this is the implementation. - **AC3 — DONE.** `purser v0.4.0` was published 2026-08-21T17:12:56, four days after opening. Measured, not assumed. **Not implementing tonight** — this is now a CI-gate change on the release path and wants its own PR and review row.
bosun closed this issue 2026-08-27 13:18:03 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/release-toolkit#811
No description provided.