From 48547d5eb1557f528f726c80adb899db833c04a4 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Tue, 4 Aug 2026 16:16:19 +0000 Subject: [PATCH] fix: the issue-side ruling clock reads comments only (#284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claiming a needs-ruling issue dated it through the assigned timeline event, silencing the 7-day escalation nudge at exactly the moment a builder started working through it. The ruling block now reads last_issue_comment_activity (D1); the reclaim clock keeps the assignment (D2) because there the assignment IS the claim; post-merge hands its evidence read to the ruling block instead of reading again (D6, D7); the claimed branch reads both clocks at its top, before anything it posts. LABELS.md and lib/ruling.sh now say what each surface's clock reads (D4, D5). The #257-era order compositions move to the comments read — the timeline is no longer an input the issue clocks take, and its unreadability no longer holds unrelated writes hostage; that narrowing is pinned rather than implied. Co-Authored-By: Claude Fable 5 --- LABELS.md | 7 +- .../issueflow-reconcile.sh | 75 +++--- changelog.d/284.md | 6 + lib/ruling.sh | 7 +- test/issueflow-reconcile.test.sh | 215 ++++++++++++++++-- 5 files changed, 263 insertions(+), 47 deletions(-) create mode 100644 changelog.d/284.md diff --git a/LABELS.md b/LABELS.md index 406455c..61ffcfc 100644 --- a/LABELS.md +++ b/LABELS.md @@ -154,8 +154,11 @@ 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. +never activity, or the sweep would reset itself — and each surface's clock +reads what exists on it: on a pull request, comments, reviews and commits; +on an issue, comments alone. An assignment is the claim clock's fact, not +the ruling's — claiming a flagged issue does not answer it, and buys the +escalation no quiet (#284). `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 diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index f6c6db1..9081297 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -515,19 +515,26 @@ issue_activity_at() { # $1 issue, $2 created_at, $3 with-assignment|comments-onl } last_issue_activity() { # $1 issue, $2 created_at → epoch; non-zero if a read failed - # The claim clock, and the ruling clock with it. Assignment is the claim + # The claim clock, and only the claim clock (#284). Assignment is the claim # itself. Ignoring it would let an old issue be reclaimed in the seconds - # between assignment and its required draft PR. + # between assignment and its required draft PR. The ruling clock no longer + # rides here: an assignment says nothing about whether the decider + # answered, and counting it let claiming a flagged issue buy its + # escalation another 7 quiet days. issue_activity_at "$1" "$2" with-assignment } last_issue_comment_activity() { # $1 issue, $2 created_at → epoch; non-zero on a failed read - # The evidence nudge's clock (#254). Same computation, one input fewer, and - # the input it drops is the one that would starve the criterion: on + # The evidence nudge's clock (#254), and the issue-side ruling clock with + # it (#284): on an issue, a comment is the only substantive activity + # toward a ruling. Same computation as the claim clock, one input fewer, + # and the input it drops is the one that would starve each criterion: on # `post-merge` there is no claim for an assignment to protect, and an # assignee there is the invalid composition the `post-merge-assigned` flag - # reports. Counting it would let a broken board buy the item another 7 days - # of silence — the failure direction of #254 taken backwards. + # reports; under `needs-ruling` the assignment is the *claim's* fact, and + # counting it silenced the escalation at exactly the moment somebody + # started working through it. Either way, a wider clock would let board + # state buy the wait another 7 days of silence. # # A comment the sweep itself wrote is still activity here, deliberately: # the nudge carries no marker, so its own comment is what rate-limits it, @@ -538,7 +545,7 @@ last_issue_comment_activity() { # $1 issue, $2 created_at → epoch; non-zero on } reconcile_issue() { - local n="$1" decision refs cross_refs states age evidence_age created assignees open_pr=false label owners + local n="$1" decision refs cross_refs states age evidence_age ruling_age created assignees open_pr=false label owners local merged_ref_pr="" transition_marker="" transition_handled=false parsed_set="" parse_marker="" local unchecked="" remove_claimed=claimed local attention_active=true attention_suppression="" @@ -557,6 +564,19 @@ reconcile_issue() { if has_issue_label claimed; then assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")" grep -qxF "$n" <<<"${OPEN_PR_ISSUES:-}" && open_pr=true + # Under a pending ruling, the ruling clock is read at the top of the + # branch, before anything either arm below can post — the derived + # transition comment, the reclaim notice and the claimed-unassigned flag + # are all comments, and a read taken after one would date the issue by + # this sweep's own writing (#284; the hazard #274 met from the other + # side). Two clocks on purpose: `age` below counts the assignment + # because the assignment IS the claim; the ruling waits on a human, and + # an assignment says nothing about whether the decider answered. + if has_issue_label needs-ruling; then + created="$(jq -r '.created_at' <<<"$ISSUE_JSON")" + guarded_read ruling_age last_issue_comment_activity "$n" "$created" \ + || skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")" + fi merged_ref_pr="$(post_merge_pr_for_issue "$n")" if [ -n "$merged_ref_pr" ]; then transition_marker="$(post_merge_transition_marker "$merged_ref_pr")" @@ -636,28 +656,18 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in # The evidence nudge's clock is read BEFORE any comment this branch # posts. `ensure_comment` below is itself activity, so reading after it # would let the assigned-flag comment silence the nudge for another 7 - # days — the same self-silencing the ruling nudge avoids by reading its - # facts once, at the top of the pass. - # - # Its own variable, not `age`: the ruling block below reuses `age` when - # it is already set, and the evidence clock is deliberately narrower than - # the ruling clock. Leaking it there would silently change what a ruling - # nudge means depending on which queue label the issue sits under. + # days — the same self-silencing the ruling nudge avoids by taking its + # clock from this same read, below. created="$(jq -r '.created_at' <<<"$ISSUE_JSON")" guarded_read evidence_age last_issue_comment_activity "$n" "$created" \ || skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")" - # The ruling clock is read HERE, not in the ruling block, for the same - # reason the evidence clock is: that block reads only when `age` is - # unset, and by the time it runs this branch may have posted the - # evidence nudge — so its read would date the issue by this sweep's own - # comment and silence the ruling nudge. Both waits are answered from - # facts that predate anything this pass writes. The cost is one extra - # comments read on `post-merge` + `needs-ruling`, and only there: an - # ordinary `post-merge` issue reads once. - if has_issue_label needs-ruling; then - guarded_read age last_issue_activity "$n" "$created" \ - || skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")" - fi + # On this surface the ruling clock IS this read (#284 D6): both nudges + # wait on comments and nothing else, so the evidence clock is handed to + # the ruling block rather than read again — and handed HERE, before the + # assigned-flag comment and the evidence nudge below, so neither wait is + # ever answered by anything this pass writes. `post-merge` + + # `needs-ruling` now costs one comments read where it cost three. + ruling_age="$evidence_age" if [ "$assignees" -gt 0 ] || has_issue_label attention; then ensure_comment "$n" post-merge-assigned \ 'This `post-merge` issue has an assignee or `attention`. The sweep will not undo hand-set intent; triage must clear the invalid composition or move the issue back into buildable queue state.' @@ -796,12 +806,19 @@ See \`$release_doctrine_path\`. The operator blessing the order is the one step run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null log "#$n: unstale (a ruling is pending)" fi - if [ -z "${age:-}" ]; then + # The ruling clock reads comments only (#284 D1): an `assigned` event is + # the claim clock's fact, and counting it here let claiming a flagged + # issue buy its escalation another 7 quiet days. The reclaim clock + # (`age`) must never reach this call — the branches that write comments + # before this block (`claimed`, `post-merge`) arrive holding + # `ruling_age` already, read before anything they post; the fresh read + # serves the paths that arrive empty-handed. + if [ -z "${ruling_age:-}" ]; then created="$(jq -r '.created_at' <<<"$ISSUE_JSON")" - guarded_read age last_issue_activity "$n" "$created" \ + guarded_read ruling_age last_issue_comment_activity "$n" "$created" \ || skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")" fi - reconcile_ruling "$n" "$age" "$NOW" + reconcile_ruling "$n" "$ruling_age" "$NOW" fi } diff --git a/changelog.d/284.md b/changelog.d/284.md new file mode 100644 index 0000000..ad98894 --- /dev/null +++ b/changelog.d/284.md @@ -0,0 +1,6 @@ +### Fixed + +- Claiming a `needs-ruling` issue no longer buys its escalation another 7 + quiet days: the issue-side ruling clock reads comments alone — an + assignment is the claim clock's fact — and LABELS.md now names what each + surface's clock reads (#284). diff --git a/lib/ruling.sh b/lib/ruling.sh index 9ac6b0b..626f4f8 100644 --- a/lib/ruling.sh +++ b/lib/ruling.sh @@ -175,8 +175,11 @@ ruling_default_decision() { # escalation body on stdin → DEADLINE | HARDB } ruling_nudge_decision() { # $1 now, $2 last real-activity epoch → NUDGE | KEEP - # Real activity only — comments, reviews, commits, never label churn, or - # the sweep would reset its own clock. The nudge needs NO marker: the + # Real activity only, as the caller's surface defines it: the PR sweep + # supplies comments, reviews and commits; the issue sweeps supply comments + # alone — an `assigned` event is the claim clock's fact, and counting it + # let a claim silence a pending ruling (#284). Never label churn, or the + # sweep would reset its own clock. The nudge needs NO marker: the # nudge comment is itself activity, so posting it resets this window and # the rule self-rate-limits to at most one nudge per 7 quiet days. That is # deliberate — a later refactor that "fixes" it by adding a marker breaks diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index fc0ed67..37f3682 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -900,6 +900,167 @@ check "...it is reused from lib/ruling.sh" 0 "" \ check "the evidence-nudge probes perform no issue edits" 0 "$nudge_edits_before" \ bash -c 'wc -l <"$1"' _ "$TMP/issue-edits" +# -- the issue-side ruling clock is comments-only (#284) --------------------- +# #52 D10's "reuse the activity computation" made the issue-side ruling nudge +# ride the claim-reclamation clock, `assigned` events included — so claiming +# a flagged issue dated it, and the escalation the flag exists to keep +# visible went quiet for another 7 days at exactly the moment somebody +# started working through it. The ruling clock is now the comments-only one +# (D1); the reclaim clock keeps the assignment (D2), because there the +# assignment IS the claim. Every must-nudge probe below was run against the +# pre-#284 sweep and went red — the #274 round-1 discipline: the fixture +# proves the defect, not merely the fix. +ruling_clock_edits_before="$(wc -l <"$TMP/issue-edits")" +ruling_quiet() { # $1 issue — conforming escalation, both rungs fired, ~9d of comment quiet + jq -n --arg l "$(iso_at $((INOW - 10 * 86400)))" \ + '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$l}]' \ + >"$(tfix "$1")" + jq -n --arg at "$(iso_at $((INOW - 10 * 86400 - 60)))" \ + --arg b $'Options: A — x B — y\nRecommend: A, because x.\nBlocked: z\nDefault: none — hard block' \ + --arg r12 "$(iso_at $((INOW - 10 * 86400 + 13 * 3600)))" \ + --arg r24 "$(iso_at $((INOW - 10 * 86400 + 25 * 3600)))" \ + '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc","body":$b}, + {"user":{"login":"sweep-bot"},"created_at":$r12,"html_url":"https://x/r12","body":"\nrung"}, + {"user":{"login":"sweep-bot"},"created_at":$r24,"html_url":"https://x/r24","body":"\nrung"}]' \ + >"$(cfix "$1")" +} +timeline_add() { # $1 issue, $2 event, $3 seconds ago + jq --arg e "$2" --arg at "$(iso_at $((INOW - $3)))" \ + '. + [{"event":$e,"created_at":$at}]' \ + "$(tfix "$1")" >"$(tfix "$1").tmp" && mv "$(tfix "$1").tmp" "$(tfix "$1")" +} +comment_add() { # $1 issue, $2 seconds ago + jq --arg at "$(iso_at $((INOW - $2)))" \ + '. + [{"user":{"login":"decider"},"created_at":$at,"html_url":"https://x/d","body":"still thinking"}]' \ + "$(cfix "$1")" >"$(cfix "$1").tmp" && mv "$(cfix "$1").tmp" "$(cfix "$1")" +} + +# The live shape, and the defect: a builder claims the flagged issue, the +# assignment is an hour old, the decider has been silent ~9 days. +ruling_quiet 101 +timeline_add 101 assigned 3600 +claimed_fresh="$(issue_probe 101 $'claimed\nneeds-ruling' 1 true)" +check "an hour-old claim does not silence a ruling 9 days quiet" 0 "" \ + grep -q 'ruling nudge' <<<"$claimed_fresh" +check "...and the fresh assignment is not reclaim bait either" 1 "" \ + grep -q 'reclaimed' <<<"$claimed_fresh" + +# The clock rule alone, no assignee in the way: an assigned/unassigned pair +# in the timeline is the claim's history, not activity toward the ruling. +ruling_quiet 102 +timeline_add 102 assigned 3600 +timeline_add 102 unassigned 3500 +ready_pair="$(issue_probe 102 $'ready\nneeds-ruling' 0)" +check "a ready issue nudges through an hour-old assignment pair" 0 "" \ + grep -q 'ruling nudge' <<<"$ready_pair" + +# post-merge + needs-ruling fires BOTH nudges in one sweep, from one read +# taken before either write. This probe is also the read-order pin: the +# evidence nudge posts first and the stub stamps it as fresh activity, so +# restoring a ruling-clock read below `ensure_comment` turns the second +# check red — the hazard #274 met and killed inside one round. +ruling_quiet 103 +timeline_add 103 assigned 3600 +timeline_add 103 unassigned 3500 +both_fresh="$(issue_probe 103 $'post-merge\nneeds-ruling' 0)" +check "a fresh assignment starves neither post-merge wait" 0 "" \ + grep -q 'post-merge evidence nudge' <<<"$both_fresh" +check "...the ruling nudge fires beside it, not behind it" 0 "" \ + grep -q 'ruling nudge' <<<"$both_fresh" + +# blocked composes the same way. The #252 parse echo is pre-seeded old so +# the probe isolates the clock rule — steady state, where the echo for this +# parse set already exists and the branch posts nothing before the tail. +ruling_quiet 104 +timeline_add 104 assigned 3600 +refs_104="$(blocked_references <<<'Blocked by #999.')" +cross_104="$(blocked_cross_references <<<'Blocked by #999.')" +marker_104="$(blocked_parse_marker "$(blocked_parse_set "$refs_104" "$cross_104")")" +jq --arg m "$marker_104" --arg at "$(iso_at $((INOW - 9 * 86400)))" \ + '. + [{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/echo","body":("\necho")}]' \ + "$(cfix 104)" >"$(cfix 104).tmp" && mv "$(cfix 104).tmp" "$(cfix 104)" +blocked_fresh="$(issue_probe 104 $'blocked\nneeds-ruling' 0 false "" 'Blocked by #999.')" +check "a blocked issue nudges through an hour-old assignment" 0 "" \ + grep -q 'ruling nudge' <<<"$blocked_fresh" + +# 6 days of comment quiet is 6, with or without an assignment inside it. +ruling_quiet 105 +timeline_add 105 assigned 3600 +comment_add 105 $((6 * 86400)) +six_days="$(issue_probe 105 $'claimed\nneeds-ruling' 1 true)" +check "6 days of comment quiet draws no nudge" 1 "" \ + grep -q 'ruling nudge' <<<"$six_days" + +# Label churn is not activity on this clock either — it reads no timeline +# at all, which closes the class rather than the spelling. +ruling_quiet 106 +timeline_add 106 labeled 1800 +timeline_add 106 unlabeled 1700 +churn="$(issue_probe 106 $'ready\nneeds-ruling' 0)" +check "hour-old label churn does not hold the nudge back" 0 "" \ + grep -q 'ruling nudge' <<<"$churn" + +# Self-rate-limiting, asserted as the property (#254's discipline): sweep +# again a day after probe 101's nudge and the nudge it posted is the +# activity that keeps it silent — no marker involved. +day_after="$(PROBE_NOW=$((INOW + 86400)) issue_probe 101 $'claimed\nneeds-ruling' 1 true)" +check "the sweep a day after its nudge holds its silence" 1 "" \ + grep -q 'ruling nudge' <<<"$day_after" + +# No flag, no nudge, whatever the clock says. +quiet_comment 107 $((60 * 86400)) +noflag="$(issue_probe 107 ready 0)" +check "an unflagged issue draws no ruling nudge at any age" 1 "" \ + grep -q 'ruling nudge' <<<"$noflag" + +# D2's input doing its job — the one thing a "the clocks are the same now, +# merge them" refactor would break. Red the instant `last_issue_activity` +# loses `assigned`. +printf '[]\n' >"$(cfix 108)" +jq -n --arg at "$(iso_at $((INOW - 600)))" \ + '[{"event":"assigned","created_at":$at}]' >"$(tfix 108)" +not_reclaimed="$(issue_probe 108 claimed 1 false)" +check "a 10-minute-old claim on a silent issue is not reclaimed" 1 "" \ + grep -q 'reclaimed' <<<"$not_reclaimed" + +# One fixture, two clocks, asserted directly and not by inspection: the +# newest event is the assignment; the reclaim clock returns it and the +# ruling clock returns the older comment. +jq -n --arg at "$(iso_at $((INOW - 8 * 86400)))" \ + '[{"user":{"login":"decider"},"created_at":$at,"html_url":"https://x/c9","body":"x"}]' >"$(cfix 109)" +jq -n --arg at "$(iso_at $((INOW - 3600)))" \ + '[{"event":"assigned","created_at":$at}]' >"$(tfix 109)" +clock_read() { # $1 clock fn — both against fixture 109 + ( REPO=owner/repo + # shellcheck disable=SC2317 # reached indirectly, through the clock under test + gh() { issue_stub_gh "$@"; } + "$1" 109 "$(iso_at $((INOW - 10 * 86400)))" ) +} +check "one fixture, two clocks: the reclaim clock returns the assignment" \ + 0 "$((INOW - 3600))" clock_read last_issue_activity +check "...and the ruling clock returns the older comment" \ + 0 "$((INOW - 8 * 86400))" clock_read last_issue_comment_activity + +# The mechanical call-site pins: the reclaim clock can never reach +# reconcile_ruling, on any path, asserted against the source and not the +# diff. Beside them, the one-spelling pins this issue inherits stay green. +# shellcheck disable=SC2016 # the call sites are asserted as literals +check "reconcile_ruling is never handed the reclaim clock" 1 "" \ + grep -E '^[^#]*reconcile_ruling.*\$age' \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" +# shellcheck disable=SC2016 # the call site is asserted as a literal +check "...its one call site is fed the comments-only clock" 0 "1" \ + grep -cF 'reconcile_ruling "$n" "$ruling_age" "$NOW"' \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" +# shellcheck disable=SC2016 # the guarded_read is asserted as a literal +check "...and ruling_age is never fed by the reclaim clock" 1 "" \ + grep -E 'ruling_age.*last_issue_activity ' \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" + +# shellcheck disable=SC2016 # positional parameter belongs to the isolated shell +check "the #284 probes perform no issue edits" 0 "$ruling_clock_edits_before" \ + bash -c 'wc -l <"$1"' _ "$TMP/issue-edits" + # -- non-triggers stay byte-for-byte outside the transition ------------------ recent_timeline() { jq -n --arg at "$(iso_at $((INOW - 60)))" \ @@ -1800,52 +1961,78 @@ check "...and crew#329's label is not written on the way out" 1 "" \ check "...and nothing claims it was" 1 "" \ grep -qF '#81: needs-triage (no queue state)' <<<"$broken_mint" -# -- 3. the blockers->ready flip, then a failed TIMELINE read ---------------- -# The wider class: the failing read is the second one inside -# last_issue_activity, so the comments read answers and the marker check and -# the flip both complete first. A comment AND a label edit are staged. +# -- 3. the blockers->ready flip, then a failed COMMENTS read ---------------- +# The read that guards this branch is the marker check ahead of the parse +# echo: a broken comments endpoint skips there, before the echo or the flip +# is staged. The timeline is no longer an input on this path at all (#284 +# D1 — the ruling clock reads comments alone), so the unreadable-timeline +# case moved from "skips everything" to its own pin below. printf '%s\n' '{"number":82,"state":"closed"}' \ >"$ORDER/repos_owner_repo_issues_82.json" order_fixture 83 '[{"name":"blocked"},{"name":"needs-ruling"}]' 'Blocked by #82.' order_board '[{"number":83}]' +order_heals 83 comments order_heals 83 timeline healthy_flip="$(order_run)" check "the control: a healthy pass really does flip cleared blockers to ready" 0 \ "issueflow: #83: blockers closed -> ready" printf '%s\n' "$healthy_flip" check "...writing the label edit" 0 "" order_wrote 83 edit check "...and posting the blockers-cleared comment" 0 "" order_wrote 83 comment -order_breaks 83 timeline +order_breaks 83 comments broken_flip="$(order_run)" -check "a failed timeline read skips the blockers->ready composition" 0 \ - "issueflow: #83: skipped this pass — could not read its activity history: $GH_STUB_STDERR" \ +check "a failed comments read skips the blockers->ready composition" 0 \ + "issueflow: #83: skipped this pass — could not read its comments: $GH_STUB_STDERR" \ printf '%s\n' "$broken_flip" check "...leaving the issue blocked" 1 "" order_wrote 83 edit check "...with no comment posted about it" 1 "" order_wrote 83 comment check "...and nothing claiming the flip happened" 1 "" \ grep -qF 'blockers closed -> ready' <<<"$broken_flip" +# The read this path no longer takes cannot skip it (#284): with comments +# healthy and the timeline broken, the flip commits, and only the ruling +# ladder's own soft-failing read goes without — no verdict is invented, and +# no unrelated write is held hostage by an input the clocks stopped reading. +order_heals 83 comments +order_breaks 83 timeline +narrowed_flip="$(order_run)" +check "a failed timeline read no longer skips the flip" 1 "" \ + grep -qF 'skipped this pass' <<<"$narrowed_flip" +check "...the flip commits" 0 "" order_wrote 83 edit +check "...and the ruling ladder says what it could not read" 0 \ + "issueflow: #83: ruling timeline unreadable — no verdict invented this pass" \ + printf '%s\n' "$narrowed_flip" -# -- 4. a posted nudge, then a failed TIMELINE read ------------------------- -# The comment-only half of the class: an epic nudge is staged, and the -# ruling tail's activity read fails after it. A comment is as much a -# mutation as a label — it is the thing markers exist to make idempotent. +# -- 4. a posted nudge, then a failed COMMENTS read ------------------------- +# The comment-only half of the class: the epic nudge's own marker check is +# the read that fails, so the nudge is never staged and the skip reports the +# truth. A comment is as much a mutation as a label — it is the thing +# markers exist to make idempotent. order_fixture 84 '[{"name":"epic"},{"name":"needs-ruling"}]' \ '## Task list - [x] #82' order_board '[{"number":84}]' +order_heals 84 comments order_heals 84 timeline healthy_nudge="$(order_run)" check "the control: a healthy pass really does nudge a completed epic" 0 \ "issueflow: #84: completed epic nudged" printf '%s\n' "$healthy_nudge" check "...by posting a comment" 0 "" order_wrote 84 comment -order_breaks 84 timeline +order_breaks 84 comments broken_nudge="$(order_run)" -check "a failed timeline read skips the epic-nudge composition" 0 \ - "issueflow: #84: skipped this pass — could not read its activity history: $GH_STUB_STDERR" \ +check "a failed comments read skips the epic-nudge composition" 0 \ + "issueflow: #84: skipped this pass — could not read its comments: $GH_STUB_STDERR" \ printf '%s\n' "$broken_nudge" check "...and the nudge comment is never posted" 1 "" order_wrote 84 comment check "...and nothing claims it was" 1 "" \ grep -qF 'completed epic nudged' <<<"$broken_nudge" +# The narrowed surface again (#284): a broken timeline neither skips nor +# suppresses the nudge; the ruling ladder alone goes without a verdict. +order_heals 84 comments +order_breaks 84 timeline +narrowed_nudge="$(order_run)" +check "a failed timeline read no longer skips the epic nudge" 1 "" \ + grep -qF 'skipped this pass' <<<"$narrowed_nudge" +check "...the nudge commits" 0 "" order_wrote 84 comment # -- the skip is still just a skip: counted, tailed, and green (D4, D6, D7) -- check "a mutation-bearing composition that skips is still not a crash" 1 "" \