offsite — the label, the doctrine, and the claim-reclaim exemption #68

Closed
opened 2026-07-23 12:32:52 +00:00 by dan-claude-bot · 3 comments
dan-claude-bot commented 2026-07-23 12:32:52 +00:00 (Migrated from github.com)

Part of #56. Blocked by #52 (both edit the same reclaim-clock call site, and #52 lands first).

Accepted from discussion #67 — "offsite — an issue whose deliverable is a PR in another repo", filed from the sherpa session at @danmt's request, who ruled its three open questions in the thread. #57 landed the human-readable half of the cross-repo linkage rule this morning; this is the machine-readable half.

All line references pinned at cf69d8c.

Context — the failure, re-verified

The discussion is right about the bug and wrong about where it lives. Checked against the tree, not taken from the report:

The discussion said What is actually true Consequence
"hygiene.sh runs triage's hourly backlog sweep … and it sweeps repos.txt" hygiene.sh is the triage box's own script — operator-owned, inside the box, not in this repo (FLEET.md). The reclaim that actually runs on this board is claim_decision()issueflow-reconcile.sh L90-L98, wired at L224-L247 The deliverable is the in-repo sweep. The box script is the operator's; this issue writes the spec for it and never claims it done (epic #56's standing constraint on FLEET.md)
The exemption is a new kind of rule It is the family's third instance of one rule. blocked never reaches the reclaim branch at all; needs-ruling is exempted by #52 (ruling_stale_exempt) The build is an extension of an existing predicate, not a new mechanism — see D3

The rule as it stands: claim_decision returns RECLAIM when there is no linked open PR and no activity for 48h; the claimed branch then comments, unassigns the owner, and flips claimedready. OPEN_PR_ISSUES is built from closingIssuesReferences over this repo's open PRs (L307-L317) — and Closes #N does not cross repos, which is exactly why #57 wrote Part of <owner>/<repo>#N into BUILDER.md L28-L31.

So an issue whose deliverable is a PR in another repo has open_pr=false by construction, and is shaped exactly like an abandoned claim. Two are in that shape right now:

issue claimed by deliverable open PR in ceremony?
#13 Convert rig @claude-bot-andresmgsl heavy-duty/rig#112 (merged 11:58Z) no
#16 Adopt in incubator @claude-bot-andresmgsl dan-claude-bot/incubator#89 (ready-for-review 11:18Z) no

Nothing has misfired, because both saw comment traffic today. That is luck, not a guard. And reclamation is not a cheap mistake: it unassigns the owner and flips the issue back to ready, inviting a second builder onto work that already exists.

Spec

Decisions. A child of this issue may not reopen them.

  • D1 — the name is offsite. Ruled by @danmt in the thread. It names a property of the issue ("do not expect a PR here"), which is what the sweep needs to know; cross-repo would name the mechanism.
  • D2 — the exemption is the whole of this issue. Trust, not verification. @danmt: "we can verify, but only if we already trust so we dont have to verify everything." The label alone stops the clock — no API resolution, no proof required, nothing to fail closed. Detecting a stale flag is real and is its own follow-up (#69), deliberately after this, and it will only ever nudge.
  • D3 — one predicate, not a second spelling of the same rule. #52 puts ruling_stale_exempt (labels on stdin → EXEMPT|SWEEP) in lib/ruling.sh and consults it at the claim_decision_at call site. Add claim_clock_exempt() beside claim_decision() in issueflow-reconcile.sh — labels on stdin → EXEMPT|SWEEP, EXEMPT when offsite is present or when ruling_stale_exempt says so — and make it the branch's single consult. lib/ruling.sh keeps owning the ruling rule; the claim clock gets one gate instead of a growing if. If #52 merges in a different shape than its current head, follow the shape it merged in and preserve the invariant: one call site, no duplicated flag list.
  • D4 — the machine reads it and never writes it. Exactly needs-ruling's standing (LABELS.md L84-L92). The builder sets it in the same step that comments the draft-PR link on the authorizing issue, and clears it in the handoff — when the cross-repo PR merges or closes — reporting the outcome in that same comment, never as a side effect. No reconciler code path adds or removes it.
  • D5 — it exempts the claim-reclaim clock and nothing else. Enumerated, because "and nothing else" is the part that rots:
    • FLAG_UNASSIGNED still runs. An offsite claim with no assignee is still a broken claim, and #52 already established the shape (exempt runs the decision at age 0 rather than skipping it).
    • The epic-completion nudge is untouched, which answers @danmt's second question with a no interaction rather than a preference: epic_decision fires on child issues being closed (L172-L176), and an offsite child is open. It cannot suppress a nudge that its own openness already prevents. Nothing to build; recorded so nobody builds it.
    • The stale label is a PR-side label — applied only in labels-reconcile.sh L521-L531, never on the issue side. offsite never appears on a PR, so labels-reconcile.sh is not touched by this issue at all.
    • The one-of-three queue invariant already ignores it: queue_decision counts needs-triage, epic and QUEUE_LABELS only (L84-L98). No change — but a test pins it, because "no change needed" is a claim that must be provable.
  • D6 — colour CFD3D7, the light shade of blocked's 6A737D, in the grey "legitimately quiet" family. Same reasoning needs-ruling used for taking the light shade of state:needs-human — the axis should read as one family on the board.
  • D7 — the label row lands in core_label_rows(), not .github/labels.conf. It is core taxonomy shared by every governed repo; labels.conf carries only panel=, triage-actors= and the per-repo scope:* rows.
  • D8 — the polling saving is not a justification. The discussion notes notify.sh could watch ceremony alone if issues carried this flag. That is ~4 API calls a quarter-hour and it is not why this exists. Do not cite it in the changelog line or the doctrine.

Tasks

  • actions/labels-reconcile/labels-reconcile.sh — add the offsite|CFD3D7|… row to core_label_rows() next to blocked (bootstrap is dispatch-only; this creates nothing by itself).
  • actions/issueflow-reconcile/issueflow-reconcile.sh — add claim_clock_exempt() beside claim_decision(), make it the claimed branch's single exemption gate (D3), with the why in a comment carrying this issue number.
  • test/issueflow-reconcile.test.sh — the fixture rows below.
  • LABELS.md — one row in the Cross-cutting table (L63-L72) and a short paragraph after needs-ruling's: what it means, who sets and clears it, that it stops the reclaim clock and nothing else, and that it is issue-only.
  • BUILDER.md — two lines, both in prose that already exists rather than a new section: in Building (L28-L35), set offsite in the same step that comments the draft link; in Claiming (L20-L21), qualify "a claim with no PR and no activity is what the staleness sweep reclaims" so it stops being false for offsite claims, and name clearing the flag at handoff.
  • CHANGELOG.md — one line under ## Unreleased, inserted above the heading below it.
  • Post the operator ask on the PR: the offsite label itself is created by the labels workflow's workflow_dispatch bootstrap, which the triage identity cannot run (gh workflow run 403s — the gap that made #56's escalation invisible for an hour). Name @danmt, and link the run once it happens.
  • Comment on this issue with the spec line for the box-side hygiene.sh: triage's own hourly sweep must skip offsite issues for the same reason. It is the operator's file to change; this comment is the spec, not the change.

Acceptance criteria

  • A claimed + offsite issue with no linked open PR and no activity for well over 48h is not reclaimed: no comment, no unassign, no ready.
  • The same issue without offsite is reclaimed — the guard is shown to be the reason, not an unrelated code path.
  • A claimed + offsite issue with no assignee still gets the claimed-unassigned flag.
  • claimed + offsite does not trip FLAG_CONFLICT; offsite alone (no queue label) still gets needs-triage.
  • needs-ruling's exemption still behaves exactly as #52 merged it — proven by #52's own test rows still passing unmodified.
  • The flag list appears in exactly one place in the code (grep-level check in the test, as #52 did for its mutation calls).
  • No reconciler path adds or removes offsite (D4) — pinned the way #52 pinned "the machine never sets needs-ruling".
  • LABELS.md and BUILDER.md say all of the above, and offsite appears in core_label_rows() with colour CFD3D7.
  • test/run.sh green; shellcheck- and actionlint-clean.

Test plan

Fixture rows in test/issueflow-reconcile.test.sh, driving the pure functions directly (the file already sources the script and calls claim_decision that way):

# labels assignees open PR age expect
1 claimed 1 false 72h RECLAIM (unchanged — the control)
2 claimed offsite 1 false 72h KEEP
3 claimed offsite 0 false 72h FLAG_UNASSIGNED
4 claimed offsite 1 true 72h KEEP
5 claimed needs-ruling 1 false 72h KEEP (#52's row, re-run unmodified)
6 claimed offsite needs-ruling 1 false 72h KEEP
7 claimed offsite 1 false 1h KEEP — passes for the ordinary reason, so row 2 is not vacuous

Plus: claim_clock_exempt on offsite alone → EXEMPT, on blocked/ready/empty → SWEEP; queue_decision on claimed offsiteKEEP and on offsite alone → ADD_NEEDS_TRIAGE; a grep-level assertion that no gh issue edit line in either reconciler mentions offsite.

The case that must fail: delete the offsite arm of claim_clock_exempt and row 2 must go red. A guard that passes with its own logic removed is testing nothing — the family's standing rule.

Dependencies

  • Blocked by #52 — its PR #64 rewrites the exact call site this issue extends (decision="$(claim_decision_at …)" → an exemption gate) and edits the same LABELS.md table. Two builders in those lines is a guaranteed conflict; #64 is already carrying blocker:conflict.
  • Part of #56.
  • Blocks #69 (the stale-flag nudge).

Constraints

  • LABELS.md and BUILDER.md are vendored doctrine (the .ceremony/ set). This repo is the source, so no re-sync happens here — consumers get the change at their next pin bump.
  • hygiene.sh and the duty scripts live inside the boxes and are the operator's. A PR that claims the box-side sweep is fixed is lying; the spec comment is the deliverable.
Part of #56. Blocked by #52 (both edit the same reclaim-clock call site, and #52 lands first). Accepted from discussion [#67](https://github.com/heavy-duty/ceremony/discussions/67) — "`offsite` — an issue whose deliverable is a PR in another repo", filed from the sherpa session at @danmt's request, who ruled its three open questions [in the thread](https://github.com/heavy-duty/ceremony/discussions/67#discussioncomment-14840112). #57 landed the human-readable half of the cross-repo linkage rule this morning; this is the machine-readable half. All line references pinned at [`cf69d8c`](https://github.com/heavy-duty/ceremony/tree/cf69d8ce9ef0cb62a784e6fa823274141d7aa729). ## Context — the failure, re-verified The discussion is right about the bug and wrong about where it lives. Checked against the tree, not taken from the report: | The discussion said | What is actually true | Consequence | |---|---|---| | "`hygiene.sh` runs triage's hourly backlog sweep … and it sweeps `repos.txt`" | `hygiene.sh` is the **triage box's own** script — operator-owned, inside the box, not in this repo ([FLEET.md](https://github.com/heavy-duty/ceremony/blob/cf69d8ce9ef0cb62a784e6fa823274141d7aa729/FLEET.md#L70-L74)). The reclaim that actually runs on this board is `claim_decision()` — [issueflow-reconcile.sh L90-L98](https://github.com/heavy-duty/ceremony/blob/cf69d8ce9ef0cb62a784e6fa823274141d7aa729/actions/issueflow-reconcile/issueflow-reconcile.sh#L90-L98), wired at [L224-L247](https://github.com/heavy-duty/ceremony/blob/cf69d8ce9ef0cb62a784e6fa823274141d7aa729/actions/issueflow-reconcile/issueflow-reconcile.sh#L224-L247) | **The deliverable is the in-repo sweep.** The box script is the operator's; this issue writes the spec for it and never claims it done (epic #56's standing constraint on FLEET.md) | | The exemption is a new kind of rule | It is the family's **third** instance of one rule. `blocked` never reaches the reclaim branch at all; `needs-ruling` is exempted by #52 ([`ruling_stale_exempt`](https://github.com/heavy-duty/ceremony/pull/64/files)) | The build is an *extension of an existing predicate*, not a new mechanism — see D3 | The rule as it stands: `claim_decision` returns `RECLAIM` when there is no linked open PR and no activity for 48h; the `claimed` branch then comments, unassigns the owner, and flips `claimed` → `ready`. `OPEN_PR_ISSUES` is built from `closingIssuesReferences` over **this repo's** open PRs ([L307-L317](https://github.com/heavy-duty/ceremony/blob/cf69d8ce9ef0cb62a784e6fa823274141d7aa729/actions/issueflow-reconcile/issueflow-reconcile.sh#L307-L317)) — and `Closes #N` does not cross repos, which is exactly why #57 wrote `Part of <owner>/<repo>#N` into [BUILDER.md L28-L31](https://github.com/heavy-duty/ceremony/blob/cf69d8ce9ef0cb62a784e6fa823274141d7aa729/BUILDER.md#L28-L31). So an issue whose deliverable is a PR in another repo has `open_pr=false` **by construction**, and is shaped exactly like an abandoned claim. Two are in that shape right now: | issue | claimed by | deliverable | open PR in ceremony? | |---|---|---|---| | #13 Convert rig | @claude-bot-andresmgsl | `heavy-duty/rig#112` (merged 11:58Z) | no | | #16 Adopt in incubator | @claude-bot-andresmgsl | `dan-claude-bot/incubator#89` (ready-for-review 11:18Z) | no | Nothing has misfired, because both saw comment traffic today. That is luck, not a guard. And reclamation is not a cheap mistake: it unassigns the owner and flips the issue back to `ready`, inviting a second builder onto work that already exists. ## Spec Decisions. A child of this issue may not reopen them. - **D1 — the name is `offsite`.** Ruled by @danmt in the thread. It names a property of the *issue* ("do not expect a PR here"), which is what the sweep needs to know; `cross-repo` would name the mechanism. - **D2 — the exemption is the whole of this issue. Trust, not verification.** @danmt: *"we can verify, but only if we already trust so we dont have to verify everything."* The label alone stops the clock — no API resolution, no proof required, nothing to fail closed. Detecting a *stale* flag is real and is its own follow-up (#69), deliberately after this, and it will only ever nudge. - **D3 — one predicate, not a second spelling of the same rule.** #52 puts `ruling_stale_exempt` (labels on stdin → `EXEMPT`|`SWEEP`) in `lib/ruling.sh` and consults it at the `claim_decision_at` call site. Add `claim_clock_exempt()` beside `claim_decision()` in `issueflow-reconcile.sh` — labels on stdin → `EXEMPT`|`SWEEP`, `EXEMPT` when `offsite` is present **or** when `ruling_stale_exempt` says so — and make it the branch's single consult. `lib/ruling.sh` keeps owning the ruling rule; the claim clock gets one gate instead of a growing `if`. If #52 merges in a different shape than its current head, follow the shape it merged in and preserve the invariant: **one call site, no duplicated flag list.** - **D4 — the machine reads it and never writes it.** Exactly `needs-ruling`'s standing (LABELS.md [L84-L92](https://github.com/heavy-duty/ceremony/blob/cf69d8ce9ef0cb62a784e6fa823274141d7aa729/LABELS.md#L84-L92)). The **builder** sets it in the same step that comments the draft-PR link on the authorizing issue, and clears it in the handoff — when the cross-repo PR merges or closes — reporting the outcome in that same comment, never as a side effect. No reconciler code path adds or removes it. - **D5 — it exempts the claim-reclaim clock and nothing else.** Enumerated, because "and nothing else" is the part that rots: - `FLAG_UNASSIGNED` still runs. An offsite claim with no assignee is still a broken claim, and #52 already established the shape (exempt runs the decision at age 0 rather than skipping it). - The **epic-completion nudge is untouched**, which answers @danmt's second question with a *no interaction* rather than a preference: `epic_decision` fires on child issues being **closed** ([L172-L176](https://github.com/heavy-duty/ceremony/blob/cf69d8ce9ef0cb62a784e6fa823274141d7aa729/actions/issueflow-reconcile/issueflow-reconcile.sh#L172-L176)), and an offsite child is *open*. It cannot suppress a nudge that its own openness already prevents. Nothing to build; recorded so nobody builds it. - The `stale` label is a **PR-side** label — applied only in `labels-reconcile.sh` [L521-L531](https://github.com/heavy-duty/ceremony/blob/cf69d8ce9ef0cb62a784e6fa823274141d7aa729/actions/labels-reconcile/labels-reconcile.sh#L521-L531), never on the issue side. `offsite` never appears on a PR, so `labels-reconcile.sh` is not touched by this issue at all. - The one-of-three queue invariant already ignores it: `queue_decision` counts `needs-triage`, `epic` and `QUEUE_LABELS` only ([L84-L98](https://github.com/heavy-duty/ceremony/blob/cf69d8ce9ef0cb62a784e6fa823274141d7aa729/actions/issueflow-reconcile/issueflow-reconcile.sh#L84-L98)). No change — but a test pins it, because "no change needed" is a claim that must be provable. - **D6 — colour `CFD3D7`**, the light shade of `blocked`'s `6A737D`, in the grey "legitimately quiet" family. Same reasoning `needs-ruling` used for taking the light shade of `state:needs-human` — the axis should read as one family on the board. - **D7 — the label row lands in `core_label_rows()`, not `.github/labels.conf`.** It is core taxonomy shared by every governed repo; `labels.conf` carries only `panel=`, `triage-actors=` and the per-repo `scope:*` rows. - **D8 — the polling saving is not a justification.** The discussion notes `notify.sh` could watch ceremony alone if issues carried this flag. That is ~4 API calls a quarter-hour and it is **not** why this exists. Do not cite it in the changelog line or the doctrine. ## Tasks - [ ] `actions/labels-reconcile/labels-reconcile.sh` — add the `offsite|CFD3D7|…` row to `core_label_rows()` next to `blocked` (bootstrap is dispatch-only; this creates nothing by itself). - [ ] `actions/issueflow-reconcile/issueflow-reconcile.sh` — add `claim_clock_exempt()` beside `claim_decision()`, make it the `claimed` branch's single exemption gate (D3), with the *why* in a comment carrying this issue number. - [ ] `test/issueflow-reconcile.test.sh` — the fixture rows below. - [ ] `LABELS.md` — one row in the **Cross-cutting** table ([L63-L72](https://github.com/heavy-duty/ceremony/blob/cf69d8ce9ef0cb62a784e6fa823274141d7aa729/LABELS.md#L63-L72)) and a short paragraph after `needs-ruling`'s: what it means, who sets and clears it, that it stops the reclaim clock and nothing else, and that it is issue-only. - [ ] `BUILDER.md` — two lines, both in prose that already exists rather than a new section: in **Building** ([L28-L35](https://github.com/heavy-duty/ceremony/blob/cf69d8ce9ef0cb62a784e6fa823274141d7aa729/BUILDER.md#L28-L35)), set `offsite` in the same step that comments the draft link; in **Claiming** ([L20-L21](https://github.com/heavy-duty/ceremony/blob/cf69d8ce9ef0cb62a784e6fa823274141d7aa729/BUILDER.md#L20-L21)), qualify "a claim with no PR and no activity is what the staleness sweep reclaims" so it stops being false for offsite claims, and name clearing the flag at handoff. - [ ] `CHANGELOG.md` — one line under `## Unreleased`, inserted **above** the heading below it. - [ ] Post the **operator ask** on the PR: the `offsite` label itself is created by the labels workflow's `workflow_dispatch` bootstrap, which the triage identity cannot run (`gh workflow run` 403s — the gap that made #56's escalation invisible for an hour). Name @danmt, and link the run once it happens. - [ ] Comment on this issue with the **spec line for the box-side `hygiene.sh`**: triage's own hourly sweep must skip `offsite` issues for the same reason. It is the operator's file to change; this comment is the spec, not the change. ## Acceptance criteria - [ ] A `claimed` + `offsite` issue with no linked open PR and no activity for well over 48h is **not** reclaimed: no comment, no unassign, no `ready`. - [ ] The same issue **without** `offsite` is reclaimed — the guard is shown to be the reason, not an unrelated code path. - [ ] A `claimed` + `offsite` issue with **no assignee** still gets the `claimed-unassigned` flag. - [ ] `claimed` + `offsite` does **not** trip `FLAG_CONFLICT`; `offsite` alone (no queue label) still gets `needs-triage`. - [ ] `needs-ruling`'s exemption still behaves exactly as #52 merged it — proven by #52's own test rows still passing unmodified. - [ ] The flag list appears in **exactly one** place in the code (grep-level check in the test, as #52 did for its mutation calls). - [ ] No reconciler path adds or removes `offsite` (D4) — pinned the way #52 pinned "the machine never sets `needs-ruling`". - [ ] `LABELS.md` and `BUILDER.md` say all of the above, and `offsite` appears in `core_label_rows()` with colour `CFD3D7`. - [ ] `test/run.sh` green; shellcheck- and actionlint-clean. ## Test plan Fixture rows in `test/issueflow-reconcile.test.sh`, driving the pure functions directly (the file already sources the script and calls `claim_decision` that way): | # | labels | assignees | open PR | age | expect | |---|---|---|---|---|---| | 1 | `claimed` | 1 | false | 72h | `RECLAIM` (unchanged — the control) | | 2 | `claimed offsite` | 1 | false | 72h | `KEEP` | | 3 | `claimed offsite` | 0 | false | 72h | `FLAG_UNASSIGNED` | | 4 | `claimed offsite` | 1 | true | 72h | `KEEP` | | 5 | `claimed needs-ruling` | 1 | false | 72h | `KEEP` (#52's row, re-run unmodified) | | 6 | `claimed offsite needs-ruling` | 1 | false | 72h | `KEEP` | | 7 | `claimed offsite` | 1 | false | 1h | `KEEP` — passes for the ordinary reason, so row 2 is not vacuous | Plus: `claim_clock_exempt` on `offsite` alone → `EXEMPT`, on `blocked`/`ready`/empty → `SWEEP`; `queue_decision` on `claimed offsite` → `KEEP` and on `offsite` alone → `ADD_NEEDS_TRIAGE`; a grep-level assertion that no `gh issue edit` line in either reconciler mentions `offsite`. **The case that must fail:** delete the `offsite` arm of `claim_clock_exempt` and row 2 must go red. A guard that passes with its own logic removed is testing nothing — the family's standing rule. ## Dependencies - `Blocked by #52` — its PR #64 rewrites the exact call site this issue extends (`decision="$(claim_decision_at …)"` → an exemption gate) and edits the same `LABELS.md` table. Two builders in those lines is a guaranteed conflict; #64 is already carrying `blocker:conflict`. - `Part of #56`. - Blocks #69 (the stale-flag nudge). ## Constraints - **`LABELS.md` and `BUILDER.md` are vendored doctrine** (the `.ceremony/` set). This repo is the *source*, so no re-sync happens here — consumers get the change at their next pin bump. - **`hygiene.sh` and the duty scripts live inside the boxes** and are the operator's. A PR that claims the box-side sweep is fixed is lying; the spec comment is the deliverable.
github-actions[bot] commented 2026-07-23 12:41:31 +00:00 (Migrated from github.com)

Every issue named by Blocked by is closed. The sweep is moving this issue to ready.

<!-- issueflow:blockers-cleared --> Every issue named by `Blocked by` is closed. The sweep is moving this issue to `ready`.
codex-bot-andresmgsl commented 2026-07-23 12:46:14 +00:00 (Migrated from github.com)

Claimed by @codex-bot-andresmgsl. Starting the build now on build/68-offsite-claim-exemption; I will open a draft PR after the first commit.

Claimed by @codex-bot-andresmgsl. Starting the build now on `build/68-offsite-claim-exemption`; I will open a draft PR after the first commit.
codex-bot-andresmgsl commented 2026-07-23 12:48:36 +00:00 (Migrated from github.com)

Box-side specification for the operator-owned hygiene.sh: triage’s hourly sweep must skip any issue carrying offsite when evaluating stale claimed work, because its deliverable PR lives in another repository and cannot appear as a local closing PR. The flag is trusted as-is; do not resolve or verify the remote PR. This comment specifies the operator change only—the box file is outside this repository and this PR does not claim to modify it.

Box-side specification for the operator-owned `hygiene.sh`: triage’s hourly sweep must skip any issue carrying `offsite` when evaluating stale claimed work, because its deliverable PR lives in another repository and cannot appear as a local closing PR. The flag is trusted as-is; do not resolve or verify the remote PR. This comment specifies the operator change only—the box file is outside this repository and this PR does not claim to modify it.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
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: heavy-duty/ceremony#68
No description provided.