diff --git a/BUILDER.md b/BUILDER.md index 11eb9ed..d966f86 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -76,64 +76,14 @@ CONTRIBUTING; everything below is the shared flow.) reviewer doubts behavior, add the test that settles it. 3. Never dismiss a review, never merge, never mark your own work as passed. A blocking point you disagree with is answered with evidence or escalated - in the PR β€” silence and force-forward are not options. A panel deadlock - is one kind of human-owned decision; use the ruling ask below - ([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). - -## The ruling ask - -Set `needs-ruling` whenever a decision belongs to a human: org policy, -published artifacts, secrets, prod, or any choice whose cost lands outside -the PR. A panel deadlock is one instance, not the definition. The builder is -the accountable flag-setter on a PR and consolidates the decision into one -comment rather than forwarding several reviewers' phrasings -([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). - -Keep at most these five lines above the fold and put all other analysis -inside the fold. The field labels are fixed because the ruling machinery -checks for them ([#50 D12](https://github.com/heavy-duty/ceremony/issues/50)): - -```text -🧭 needs-ruling β€” -Options: A β€” B β€” -Recommend: A, because . -Blocked: -Default: | none β€” hard block -
Analysis…everything else…
-``` - -The options must be exhaustive and mutually exclusive; more than three means -the question is not ready. `Recommend:` is mandatory β€” omitting it hands the -whole problem to the human. `Blocked:` names both what stops and what -continues. Write a timed `Default:` only when you are affirmatively confident -the decision is reversible inside the PR before merge. Unsure is not a tie: -it is a hard block. Published artifacts, secrets, prod, and org policy are -hard blocks by construction ([#50 D12–D13](https://github.com/heavy-duty/ceremony/issues/50)). - -The ladder is anchored to the current episode's `needs-ruling` **`labeled` -event**, not its `Default:` deadline or the last activity -([#50 D13–D14](https://github.com/heavy-duty/ceremony/issues/50)): - -- **0–12h:** proceed when a still-clear, reversible default expires, and say - out loud that you did. A hard block waits. -- **at 12h:** do not fire a stale default. Re-read it against what has landed - and ask whether it still holds and whether reasonable doubt remains. If - doubt has appeared, make it a hard block. -- **at 24h:** proceed regardless, **as a PR**. Pick an option and state in the - PR body which way you went and what doubt remains. Nothing merges by this; - the human still gates the merge. -- **past 24h:** hand the choice to triage. Triage picks the option, records it - as a decision, and remains accountable; the operator can overturn it at - merge. - -A re-flag starts a fresh ladder. The ladder applies whatever `Default:` says, -including a hard block, and an active back-and-forth still climbs it. This is -different from the 7-day nudge, which resets on real activity. The machine -observes both clocks but never sets, clears, or decides `needs-ruling`. - -The label stays until agreement is *reached*, not until the maintainer -replies. The setter records the ruling, removes the label, and returns the -item to its flow in the same comment ([LABELS.md](LABELS.md)). + in the PR β€” a maintainer can be asked for a ruling; silence and + force-forward are not options. The ruling ask has mechanics: set + `needs-ruling` on the PR, with one comment carrying the question, the + options, and your recommendation β€” a panel deadlock is consolidated into + that one question, never forwarded as three phrasings of it. The label + stays until agreement is *reached*, not until the maintainer replies; + you record the ruling, remove the label, and return the PR to its flow + ([LABELS.md](LABELS.md)). ## Handoff diff --git a/CHANGELOG.md b/CHANGELOG.md index e0c9250..1a58430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,6 @@ so entries say what changed, cite the issue, and stop. - `actions/runner-isolated` β€” a `pull_request`-triggered job may never run on a self-hosted runner (#58). - The sweep's `needs-ruling` invariants, one implementation for both surfaces: the issue-side staleness exemption, the bare-flag check (comment-only, the label is never removed), and the 7-day nudge to the decider (#52). - `offsite` β€” protect claimed issues whose PR lives in another repository from the claim-reclaim clock (#68). -- `issueflow-reconcile` β€” nudge once when an `offsite` flag outlives every visible cross-referenced PR (#69). -- Ruling doctrine β€” define every human-owned trigger, the fixed escalation shape, and the 0–24h builder-to-triage ladder (#72). ## 0.1.0 β€” 2026-07-22 diff --git a/LABELS.md b/LABELS.md index 6d86a5b..e5539ae 100644 --- a/LABELS.md +++ b/LABELS.md @@ -67,15 +67,12 @@ comments, unassigns the stale owner, and restores `ready`. | `stale` | `#B60205` | no activity for 48h β€” sweep-managed, never hand-applied | | `blocked` | `#6A737D` | (see above β€” same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it) | | `offsite` | `#CFD3D7` | issue deliverable is a PR in another repository; set by the builder with the draft link and cleared by the builder at handoff | -| `needs-ruling` | `#D4C5F9` | a human-owned decision is required; use BUILDER.md's ruling template and ladder. Set by triage or the builder; a state, not a signal β€” it clears on agreement, not on a reply | +| `needs-ruling` | `#D4C5F9` | a human decision is required; the question, options and a recommendation are in the flagging comment. Set by triage or the builder; a state, not a signal β€” it clears on agreement, not on a reply | | `release` | `#0E8A16` | release flow, versioning, packaging work β€” and the ceremony PR itself | | `merge-next` | `#0E8A16` | head of the merge queue β€” merge this one next. Queue order is *intent*: never set by the reconciler, only cleared by it | `needs-ruling` marks where the human's turn is when the pending thing is a -*decision*, not a merge ([#50 D1–D14](https://github.com/heavy-duty/ceremony/issues/50)). -It applies to any human-owned decision β€” org policy, published artifacts, -secrets, prod, or any choice whose cost lands outside the work. A panel -deadlock is one instance, not the definition (D11). It is not +*decision*, not a merge (#50 settled it, D1–D10). It is not `state:needs-human`: that label means exactly "this PR could be merged right now", and the retired `state:needs-rebase` is the family's proof that a label meaning two things lies about both. It is not a `blocker:*` either: @@ -84,49 +81,24 @@ and the flag must live on issues too, where blockers do not exist. On issues it coexists with the queue labels (the one-of-three invariant above ignores it); its color is the light shade of `state:needs-human`'s, so the human axis reads as one family. It is a state, not a signal: set only with the -[canonical escalation contract](BUILDER.md#the-ruling-ask) (D12). A bare -flag is noise. The comment carries exhaustive, mutually exclusive options -(at most three), a mandatory recommendation, what stops and what continues, -and either a default affirmatively known to be reversible inside the PR or -`none β€” hard block`. Unsure is a block; published artifacts, secrets, prod, -and org policy are hard blocks by construction (D13). - -The ruling ladder runs from the current episode's `needs-ruling` **`labeled` -event** (D13–D14): - -- **0–12h:** a clear, reversible decision may proceed when its stated default - expires, saying out loud that it did; anything with reasonable doubt waits - as a hard block. -- **at 12h:** the setter re-reads the default against what has landed and asks - whether it still holds and whether doubt remains. A stale default does not - fire; new doubt makes it a hard block. -- **at 24h:** the builder proceeds regardless, **as a PR**, stating the option - chosen and the doubt that remains. Nothing merges by this; the human still - gates the merge. -- **past 24h:** triage picks the option, records it as a decision, and remains - accountable. The operator may overturn it at merge. - -A re-flag starts a new ladder. The rungs apply whatever `Default:` says, -including a hard block. Active discussion still climbs the ladder; by -contrast, the separate 7-day nudge resets on real activity. The machine -observes the rungs but never sets, clears, or decides `needs-ruling`. - -The flag stays up until agreement is *reached* β€” a human reply alone does not -clear it β€” and its setter closes it out: records the ruling as a decision in -one comment, removes the label, and returns the item to its flow in that same -comment, never as a side effect. If the human disagrees that agreement was -reached, the label goes back on. The reconciler refuses `state:needs-human` +escalation contract (the question, the options, a recommendation β€” a bare +flag is noise), it stays up until agreement is *reached* β€” a human reply +alone does not clear it β€” and its setter closes it out: records the ruling +as a decision in one comment, removes the label, and returns the item to +its flow in that same comment, never as a side effect. If the human +disagrees that agreement was reached, the label goes back on. The machine +reads it and never writes it: the reconciler refuses `state:needs-human` while it stands (the PR falls to `state:addressing` β€” the ball on the PR is -the builder's, who carries the ruling in), and the staleness sweep skips it, -because waiting on a human is legitimately quiet. Quiet, but not unwatched -(#52, both surfaces): a flag set with no escalation comment from its setter -is called out by the sweep β€” comment-only, scoped to the labeled event, the -label never removed β€” and a ruling with no real activity for 7 days draws a -comment-only nudge addressed to the decider, linking the escalation. The -nudge carries no marker on purpose: the comment is itself activity, so it -resets its own window and never repeats within a quiet week. Label churn is -not activity β€” the clock reads comments, reviews and commits, or the sweep -would reset itself. +the builder's, who carries the ruling in), and the staleness sweep skips +it, because waiting on a human is legitimately quiet. Quiet, but not +unwatched (#52, both surfaces): a flag set with no escalation comment from +its setter is called out by the sweep β€” comment-only, scoped to the labeled +event, the label never removed β€” and a ruling with no real activity for 7 +days draws a comment-only nudge addressed to the decider, linking the +escalation. The nudge carries no marker on purpose: the comment is itself +activity, so it resets its own window and never repeats within a quiet +week. Label churn is not activity β€” the clock reads comments, reviews and +commits, or the sweep would reset itself. `offsite` is issue-only and records that a claimed issue's deliverable lives in another repository, where a closing reference cannot make a local open PR @@ -135,9 +107,7 @@ the cross-repo draft link, then clears it at handoff in the same comment that reports whether that PR merged or closed. The machine reads the flag and never writes it. It stops only the claim-reclaim clock: missing assignees are still flagged, queue-label conflicts and missing queue state are still -repaired, and epic-completion and PR-side stale behavior are unchanged. The -sweep tells the assignee once when every visible cross-referenced PR has -closed; it only tells, and never clears the flag or changes the claim. +repaired, and epic-completion and PR-side stale behavior are unchanged. ## Scope β€” which surface? (PRs and issues, any number) diff --git a/REVIEWER.md b/REVIEWER.md index 64b1b7b..f92006d 100644 --- a/REVIEWER.md +++ b/REVIEWER.md @@ -81,16 +81,13 @@ saw Y" outranks one that says "this looks like it might". - Convergence = every panel verdict approves the current head, no `blocker:*` standing. Then the builder hands off (`state:needs-human`) and the panel's job is done. -- Flag an unowned decision when it belongs to a human: org policy, published - artifacts, secrets, prod, or any choice whose cost lands outside the PR. A - disagreement within the panel is one instance, not the definition - ([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). Argue a - panel disagreement in the PR with evidence until one side concedes or the - builder escalates; two reviewers pulling a builder in opposite directions - without resolution is a panel failure, not a builder failure. - `needs-ruling` is set by the **builder**, never by you: one accountable - flag-setter per PR hands the human one consolidated question. State the - unowned decision precisely enough for the builder to write - [the canonical ruling ask](BUILDER.md#the-ruling-ask), including what - stops and what continues ([#50 D12](https://github.com/heavy-duty/ceremony/issues/50); - [LABELS.md](LABELS.md)). +- If a round exposes a disagreement **within the panel**, argue it in the PR + with evidence until one side concedes or the builder escalates to the + maintainer for a ruling. Two reviewers pulling a builder in opposite + directions without resolution is a panel failure, not a builder failure. + The escalation is flagged `needs-ruling` β€” by the **builder**, never by + you: one accountable flag-setter per PR is what keeps the escalation + contract enforceable and hands the human one consolidated question + instead of three phrasings of it. Your job is to state the disagreement + precisely enough that the builder can write that escalation + ([LABELS.md](LABELS.md)). diff --git a/TRIAGE.md b/TRIAGE.md index d245d72..65193e2 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -32,26 +32,17 @@ is the failure this whole flow exists to prevent. answers would let you write the issue β€” then stop and wait. Do not mint an issue that carries the ambiguity forward; that just moves your job onto the builder. -3. **Escalate.** The pending thing is a decision only a human owns β€” org - policy, published artifacts, secrets, prod, or any choice whose cost lands - outside the work. A panel deadlock is one instance, not the definition - ([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). Say - precisely what the decision is, name the decider, and use - [BUILDER.md's canonical ruling template](BUILDER.md#the-ruling-ask), - including its options, recommendation, blocked/continues statement, and - reversible-only default rules ([#50 D12–D13](https://github.com/heavy-duty/ceremony/issues/50)). - The discussion is where humans decide; wait there. When the decision - blocks something already on the board β€” an existing issue, or minted work - a discussion's ruling gates β€” set `needs-ruling` on it too, so the board - shows where the human's turn is; the issue keeps its queue label. - Past 24 hours from the current episode's `labeled` event, if the ruling - still stands and doubt remains, it is triage's duty to pick the option the - builder proceeds on, record that pick as a decision, and stay accountable - for it; the operator may overturn it at merge - ([#50 D13–D14](https://github.com/heavy-duty/ceremony/issues/50)). You set - the flag, so you also close it out ([LABELS.md](LABELS.md)): judge when - agreement is reached, record the ruling as a decision in one comment, - remove the label, and return the issue to its flow in that same comment. +3. **Escalate.** The blocker is a *decision* only a human owns β€” scope, + money, product direction, breaking a public contract. Say precisely what + the decision is, list the options with your recommendation, and name the + decider. The discussion is where humans decide; wait there. When the + decision blocks something already on the board β€” an existing issue, or + minted work a discussion's ruling gates β€” set `needs-ruling` on it too, + so the board shows where the human's turn is; the issue keeps its queue + label. You set the flag, so you close it out ([LABELS.md](LABELS.md)): + judge when agreement is reached, record the ruling as a decision in one + comment, remove the label, and return the issue to its flow in that same + comment. 4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link where it belongs if anywhere, close. A refusal with reasons is a good outcome; a zombie discussion is not. diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 31b12c0..920e9b1 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -192,27 +192,6 @@ epic_decision() { # $1 refs, $2 states fi } -offsite_cross_referenced_prs() { # timeline JSON on stdin -> owner/repo#N - jq -r ' - .[] - | select(.event == "cross-referenced") - | .source.issue - | select(.pull_request != null) - | select(.repository.full_name != null and .number != null) - | "\(.repository.full_name)#\(.number)" - ' | sort -u -} - -offsite_resolved_decision() { # PR states on stdin -> NUDGE | QUIET - local states - states="$(cat)" - if [ -n "$states" ] && ! grep -Eq '^(OPEN|UNKNOWN)$' <<<"$states"; then - echo NUDGE - else - echo QUIET - fi -} - # API edge. Marker comments make warnings and nudges idempotent across sweeps. ensure_comment() { # $1 issue, $2 marker, $3 message local n="$1" marker="$2" message="$3" @@ -231,21 +210,6 @@ reference_states() { done } -offsite_pr_states() { - local ref repo number state - while IFS= read -r ref; do - [ -n "$ref" ] || continue - repo="${ref%#*}" - number="${ref##*#}" - state="$(gh api "repos/$repo/pulls/$number" --jq '.state' 2>/dev/null || echo UNKNOWN)" - case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac - done -} - -offsite_timeline() { # unreadable timelines are deliberately silent - gh api --paginate "repos/$REPO/issues/$1/timeline" 2>/dev/null || return 1 -} - last_issue_activity() { local n="$1" created="$2" latest latest="$({ @@ -305,18 +269,6 @@ reconcile_issue() { fi log "#$n: stale claim reclaimed -> ready" ;; esac - if has_issue_label offsite; then - local timeline - if timeline="$(offsite_timeline "$n")"; then - refs="$(offsite_cross_referenced_prs <<<"$timeline")" - states="$(offsite_pr_states <<<"$refs")" - if [ "$(offsite_resolved_decision <<<"$states")" = NUDGE ]; then - ensure_comment "$n" offsite-resolved \ - "$(tr '\n' ' ' <<<"$refs" | sed 's/[[:space:]]*$//') is closed; this issue's \`offsite\` flag is still up. Clear it and close the issue, or say what is still outstanding. @$(jq -r '.assignees[0].login' <<<"$ISSUE_JSON")" - log "#$n: resolved offsite PRs nudged" - fi - fi - fi elif has_issue_label blocked; then refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index bea4151..38077fd 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -70,6 +70,8 @@ the machinery at all: - uses: heavy-duty/ceremony/actions/changelog-armed@ - uses: heavy-duty/ceremony/actions/changelog-monotonic@ - uses: heavy-duty/ceremony/actions/drill-recorded@ + # Unreleased: runner-isolated is not in 0.1.0. Adopt this step with + # the pin bump to the first tag that carries it; never mix refs. - uses: heavy-duty/ceremony/actions/runner-isolated@ ``` @@ -87,6 +89,13 @@ the machinery at all: one file; the unblock is splitting the workflow. A repo with **no** self-hosted runner still wants it: the guard's value is the day somebody adds one. + + This guide documents `main`. New machinery is marked **unreleased** + here until a release tag ships it. If an action does not exist at the + consumer's pinned tag, adopt it with the pin bump to the first tag that + carries it; never mix a moving or newer ref into an otherwise exact-pin + consumer. In particular, `0.1.0` carries the three release guards above + plus `docs-sync`, but not `runner-isolated`. 6. **Labels automation** (optional but recommended): the caller from [Labels automation](#labels-automation), plus `.github/labels.conf` (panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the @@ -242,6 +251,7 @@ on: workflow_dispatch: # bootstraps missing labels on a fresh repo pull_request_target: types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled] + # Unreleased β€” not in 0.1.0; add only with the first tag carrying ceremony#32. issues: types: [opened, labeled, unlabeled, assigned, unassigned, closed] permissions: @@ -253,6 +263,10 @@ jobs: uses: heavy-duty/ceremony/.github/workflows/labels.yml@ ``` +The `issues:` trigger is **unreleased** and is not in `0.1.0`. A consumer +pinned to `0.1.0` omits it. Add it only when bumping every ceremony reference +to the first tag carrying ceremony#32; never mix refs to adopt it early. + `pull_request_target` is intentional: fork PRs need the base repository's token to write labels. The reusable workflow executes no PR code. It checks out only the consumer's base branch and the pinned ceremony implementation. @@ -269,6 +283,12 @@ scope:cli|C5DEF5|The command-line surface scope:docs|C5DEF5|Documentation ``` +The mandatory `triage-actors=` setting is also **unreleased** and is not +accepted by `0.1.0`. At that tag the file contains `panel=` plus scope rows +only; adding `triage-actors=` is a parse failure, not an ignored setting. Add +it at the same pin bump as the `issues:` trigger, to the first tag carrying +ceremony#32 β€” never before it and never through mixed refs. + Both actor lists are whitespace-separated. `triage-actors` names the identities allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly `name|color|description`; blank lines are ignored and extra pipes are refused. @@ -338,11 +358,10 @@ Bumping the pin re-syncs the mirror in the same PR β€” [releases page](https://github.com/heavy-duty/ceremony/releases) is that section, verbatim). One bump PR updates **every** ceremony `uses:` reference in the repo to the new tag β€” the workflow callers *and* each - guard step; a release-only setup already has five (the - [release caller](#release-workflow) plus the - [four CI guards](#bootstrap-a-new-repo)), and changing only one line - leaves the consumer split across ceremony versions, which the same-tag - rule above forbids. A repo that has adopted the agent team flow + guard step. The exact count is tag-dependent: it is the workflow caller + or callers plus the guards that the pinned tag carries. Changing only + one line leaves the consumer split across ceremony versions, which the + same-tag rule above forbids. A repo that has adopted the agent team flow additionally bumps the mirror in the same PR β€” [the pin-bump procedure](#the-pin-bump-procedure). - **One pin governs machinery and doctrine.** The ref in the consumer's diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 52e65a5..d7e2f06 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -76,21 +76,6 @@ check "both quiet flags still produce one exemption verdict" 0 "EXEMPT" \ check "blocked does not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt <<<"blocked" check "ready does not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt <<<"ready" check "empty labels do not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt >"$TMP/api-calls" - [ ! -f "$file.error" ] || return 1 [ -f "$file" ] || { printf '[]\n'; return 0; } if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi elif [ "$1" = issue ] && [ "$2" = comment ]; then @@ -291,61 +274,10 @@ check "an offsite claim with an open PR stays claimed" 1 "" \ offsite_both="$(issue_probe 28 $'claimed\noffsite\nneeds-ruling')" check "offsite plus needs-ruling stays claimed" 1 "" \ grep -q 'reclaimed' <<<"$offsite_both" - -# -- resolved offsite work nudges once and only from complete evidence ------- -jq -n --arg at "$(iso_at $((INOW - 3600)))" \ - '[{"event":"assigned","created_at":$at}, - {"event":"cross-referenced","source":{"issue":{"number":112,"repository":{"full_name":"heavy-duty/rig"},"pull_request":{"url":"x"}}}}]' \ - >"$(tfix 29)" -printf '{"state":"closed"}\n' >"$TMP/repos_heavy-duty_rig_pulls_112.json" -printf '[]\n' >"$(cfix 29)" -resolved="$(issue_probe 29 $'claimed\noffsite')" -check "a closed cross-referenced PR nudges and names the PR" 0 "" \ - grep -q 'heavy-duty/rig#112 is closed' "$TMP/posted-29" -check "the resolved nudge leaves the claim untouched" 1 "" \ - grep -q 'reclaimed' <<<"$resolved" -issue_probe 29 $'claimed\noffsite' >/dev/null -check "the resolved nudge is idempotent across sweeps" 0 "1" \ - grep -cF '' "$TMP/posted-29" - -jq -n --arg at "$(iso_at $((INOW - 3600)))" \ - '[{"event":"assigned","created_at":$at}, - {"event":"cross-referenced","source":{"issue":{"number":112,"repository":{"full_name":"heavy-duty/rig"},"pull_request":{"url":"x"}}}}, - {"event":"cross-referenced","source":{"issue":{"number":9,"repository":{"full_name":"heavy-duty/box"},"pull_request":{"url":"x"}}}}]' \ - >"$(tfix 30)" -printf '{"state":"open"}\n' >"$TMP/repos_heavy-duty_box_pulls_9.json" -printf '[]\n' >"$(cfix 30)" -issue_probe 30 $'claimed\noffsite' >/dev/null -check "one open cross-referenced PR suppresses the nudge" 1 "" \ - test -f "$TMP/posted-30" - -printf '[]\n' >"$(tfix 31)" -printf '[]\n' >"$(cfix 31)" -issue_probe 31 $'claimed\noffsite' >/dev/null -check "no visible cross-referenced PR stays silent" 1 "" test -f "$TMP/posted-31" - -: >"$(tfix 32).error" -printf '[]\n' >"$(cfix 32)" -unreadable="$(issue_probe 32 $'claimed\noffsite')" -check "an unreadable timeline stays silent" 1 "" test -f "$TMP/posted-32" -check "...and leaves the sweep running without an alarming log" 1 "" \ - grep -qiE 'error|failed' <<<"$unreadable" - -: >"$TMP/api-calls" -printf '[]\n' >"$(tfix 33)" -printf '[]\n' >"$(cfix 33)" -issue_probe 33 claimed >/dev/null -check "a non-offsite claim performs only the ordinary timeline read" 0 "1" \ - grep -cF 'repos/owner/repo/issues/33/timeline' "$TMP/api-calls" -: >"$TMP/api-calls" -printf '[]\n' >"$(tfix 34)" -printf '[]\n' >"$(cfix 34)" -issue_probe 34 $'claimed\noffsite' >/dev/null -check "an offsite claim performs the one guarded verification read" 0 "2" \ - grep -cF 'repos/owner/repo/issues/34/timeline' "$TMP/api-calls" - check "a one-hour claim stays claimed for the ordinary age reason" 0 "KEEP" \ claim_decision 1 false 3600 +check "the offsite exemption flag is named once at its issueflow decision point" 0 "1" \ + grep -c 'offsite' "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" check "no reconciler mutation names offsite (#68 D4)" 1 "" \ grep -E 'gh (issue|pr) edit.*offsite' \ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" \