2026-07-23 11:48:20 +00:00
#!/usr/bin/env bash
2026-07-23 15:43:01 +00:00
# lib/ruling.sh — the `needs-ruling` sweep invariants (issues #52 and #73,
# epic #50).
2026-07-23 11:48:20 +00:00
#
2026-07-23 15:43:01 +00:00
# Both reconcilers source this file: the bare-flag check, the escalation
# shape check, the ladder's rung comments and the 7-day nudge are ONE
# implementation serving both surfaces — two copies of a 7-day rule is how
# the family got here in the first place (#50). Pure decisions sit above
# the divider (facts in, verdict out); the one impure orchestrator below
# talks to gh and posts through the sourcing script's run()/log().
2026-07-23 11:48:20 +00:00
#
# Standing rules this file lives under:
# - The machine never sets or clears `needs-ruling` (#50 D9). Nothing here
# mutates a label; the only writes are comments. Pinned by a sweep-probe
# test and a grep-level check over the mutation calls.
# - The machine never judges prose (#50 D4). "Did the escalation contract
# accompany the flag" is decided by a mechanical proxy: the actor who
# applied the label has a comment timestamped no earlier than 15 minutes
# before the `labeled` event. The back-window exists because the natural
# ordering is post-the-escalation-then-set-the-label, seconds apart; a
# strictly-after rule would flag every correctly-formed escalation.
# - The failure direction is always *flag*, never *act*: a bare flag is
# commented on, never removed (removing would delete somebody's
# escalation on the strength of a timestamp heuristic), and an
# unreadable timeline does nothing at all — an unreadable fact must
# never invent a verdict (the reconciler's standing rule).
# One constant, both surfaces — the whole reason this file exists. 7 days
# (#50 D10): long enough that an active back-and-forth is never nudged,
# short enough that a forgotten ruling surfaces within the week.
RULING_NUDGE_AFTER = $(( 7 * 24 * 3600 ))
# The escalation back-window: a comment this many seconds before the
# `labeled` event still accompanies it.
RULING_BARE_WINDOW = $(( 15 * 60 ))
2026-07-23 15:43:01 +00:00
# The ladder's rungs (#50 D13, via #72's doctrine): moments past the current
# episode's `labeled` event. Two timers only — the 24h rung's comment names
# triage's past-24h authority too, so there is no third timer to keep honest.
RULING_RUNG12_AT = $(( 12 * 3600 ))
RULING_RUNG24_AT = $(( 24 * 3600 ))
# The escalation contract's four field labels (#50 D12). Literal strings —
# the template in BUILDER.md ("the ruling ask") fixes them so this machinery
# can check for them; presence is all that is ever checked (#50 D4).
RULING_SHAPE_FIELDS = ( Options: Recommend: Blocked: Default:)
# The idempotency markers, one per comment kind, each scoped to the current
# `labeled` episode by ruling_bare_comment_needed. The nudge deliberately
2026-07-23 11:48:20 +00:00
# has NO marker — see ruling_nudge_decision.
RULING_BARE_MARKER = '<!-- ceremony:needs-ruling-bare -->'
2026-07-23 15:43:01 +00:00
RULING_SHAPE_MARKER = '<!-- ceremony:needs-ruling-shape -->'
RULING_RUNG12_MARKER = '<!-- ceremony:needs-ruling-rung12 -->'
RULING_RUNG24_MARKER = '<!-- ceremony:needs-ruling-rung24 -->'
2026-07-23 11:48:20 +00:00
# ---------------------------------------------------------------------------
# Pure decisions. Facts in (args/stdin), verdict out. No gh, no clock.
# ---------------------------------------------------------------------------
ruling_stale_exempt( ) { # labels on stdin → EXEMPT | SWEEP
# Waiting on a human is legitimately quiet (#50 D10): under a pending
# ruling the staleness clock does not run, the same treatment `blocked`
# gets. The PR-side skip is #51's and lives in labels-reconcile.sh; this
# verdict drives the ISSUE side (the claim-reclaim clock), so the rule has
# one spelling even though the two surfaces consult it in different places.
if grep -qxF needs-ruling; then echo EXEMPT; else echo SWEEP; fi
}
ruling_accompanies( ) { # $1 comment epoch, $2 labeled epoch → 0 iff in-window
# One spelling of the window rule, shared by the bare verdict and the
# escalation-comment lookup the nudge links — two comparisons drifting
# apart would let the nudge link a comment the bare check rejected.
[ " $1 " -ge " $(( $2 - RULING_BARE_WINDOW)) " ]
}
ruling_bare_decision( ) { # $1 setter, $2 labeled epoch; "login epoch" lines on stdin
# → ACCOMPANIED | BARE. Only the flag-setter's own comments count: the
# escalation contract (question, options, recommendation — #50 D4) is the
# flag-setter's to post, and somebody else's chatter must not satisfy it.
local setter = " $1 " labeled = " $2 " login epoch
while read -r login epoch; do
[ -n " $login " ] || continue
[ " $login " = " $setter " ] || continue
if ruling_accompanies " $epoch " " $labeled " ; then
echo ACCOMPANIED
return 0
fi
done
echo BARE
}
ruling_bare_comment_needed( ) { # $1 labeled epoch, $2 newest marked-comment epoch ("" = none)
# → POST | SKIP. Scoped to the CURRENT labeled event: a marked comment
# older than the event belongs to an earlier flag episode, so a genuine
# re-flag is re-checked while a 15-minute cron never repeats itself.
2026-07-23 15:43:01 +00:00
# Marker-agnostic — the caller tracks a newest epoch PER marker (#73), so
# this one comparison scopes every marked write (bare, shape, both rungs).
2026-07-23 11:48:20 +00:00
local labeled = " $1 " marked = " ${ 2 :- } "
if [ -n " $marked " ] && [ " $marked " -gt " $labeled " ] ; then
echo SKIP
else
echo POST
fi
}
2026-08-03 10:55:45 +00:00
ruling_shape_field_present( ) { # $1 field label; escalation body on stdin → 0 iff present
# THE one spelling of the field-presence test — the escalation selector
# scores by it and the shape check grades by it, on purpose in one place:
# a selector scoring by one grep while the check grades by another is how
# the crew#293 misgrade would come back from the other side (#226).
# Line-anchored, allowing leading whitespace and Markdown bold
# (`**Options:**` is how the live escalations write them): the labels
# appearing only mid-sentence is not the template.
local field = " $1 "
grep -Eq " ^[[:space:]]*(\*\*)? $field "
}
ruling_shape_score( ) { # escalation body on stdin → 0– 4, one point per field present
# The selection rule's metric (#226). An empty body scores 0 through the
# same loop — no special case, and never an error.
local body field score = 0
body = " $( cat) "
for field in " ${ RULING_SHAPE_FIELDS [@] } " ; do
if ruling_shape_field_present " $field " <<< " $body " ; then score = $(( score + 1 )) ; fi
done
echo " $score "
}
2026-07-23 15:43:01 +00:00
ruling_shape_decision( ) { # escalation body on stdin → SHAPED | MALFORMED <missing labels>
# Presence only (#50 D4): that `Recommend:` exists is checkable, that the
# recommendation is any good is not — no counting options, no parsing the
2026-08-03 10:55:45 +00:00
# prose. The per-field test is ruling_shape_field_present, shared with the
# selector (#226). The `🧭 needs-ruling` header line is deliberately
# unchecked — it is prose, and an emoji grep on an LC_ALL=C runner is a
# portability trap for zero enforcement value.
2026-07-23 15:43:01 +00:00
local body field missing = ""
body = " $( cat) "
for field in " ${ RULING_SHAPE_FIELDS [@] } " ; do
2026-08-03 10:55:45 +00:00
ruling_shape_field_present " $field " <<< " $body " || missing = " $missing $field "
2026-07-23 15:43:01 +00:00
done
if [ -z " $missing " ] ; then echo SHAPED; else echo " MALFORMED $missing " ; fi
}
ruling_deadline_decision( ) { # $1 now, $2 the current episode's labeled epoch → RUNG0 | RUNG12 | RUNG24
# The ladder anchors to the `labeled` event, never to activity (#50 D14:
# an active back-and-forth still climbs it; only the separate 7-day nudge
# resets). "At 12h" means AT: the boundary starts the rung — the rung is a
# moment whose duty exists the moment it strikes, unlike the strictly-past
# nudge horizon. RUNG24 covers "past 24h" too: the 24h comment names both
# the builder's rung and triage's past-24h authority, so there is no
# fourth timer to keep honest.
local age = $(( $1 - $2 ))
if [ " $age " -ge " $RULING_RUNG24_AT " ] ; then
echo RUNG24
elif [ " $age " -ge " $RULING_RUNG12_AT " ] ; then
echo RUNG12
else
echo RUNG0
fi
}
ruling_default_decision( ) { # escalation body on stdin → DEADLINE <ts> | HARDBLOCK | UNPARSEABLE
# Parsed only to *describe* the item in the rung comments, never to gate a
# rung (#50 D14: the rungs apply whatever `Default:` says). Mechanical or
# nothing: an ISO-8601 UTC timestamp anywhere on the `Default:` line is
# the deadline, the literal word `none` is a hard block, anything else is
# reported as unparseable rather than guessed at. Only the `Default:` line
# is read — a timestamp elsewhere in the body is somebody's prose.
local line ts
line = " $( grep -E '^[[:space:]]*(\*\*)?Default:' | head -n1) "
ts = " $( grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2})?Z' <<< " $line " | head -n1) "
if [ -n " $ts " ] ; then
echo " DEADLINE $ts "
elif grep -qE '(^|[^[:alnum:]])none([^[:alnum:]]|$)' <<< " $line " ; then
echo HARDBLOCK
else
echo UNPARSEABLE
fi
}
2026-07-23 11:48:20 +00:00
ruling_nudge_decision( ) { # $1 now, $2 last real-activity epoch → NUDGE | KEEP
2026-08-04 16:16:19 +00:00
# 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
2026-07-23 11:48:20 +00:00
# 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
# exactly the property that makes it safe on a 15-minute cron.
if [ " $(( $1 - $2 )) " -gt " $RULING_NUDGE_AFTER " ] ; then echo NUDGE; else echo KEEP; fi
}
ruling_newest_flag( ) { # "login<TAB>iso8601" lines on stdin → the newest line
# A re-flag after a removal is judged on its own escalation, never on the
# last one's — so every fact anchors to the MOST RECENT labeled event.
# ISO-8601 UTC sorts lexically, so no date parsing is needed here.
sort -t $'\t' -k2 | tail -n1
}
2026-07-23 15:43:01 +00:00
ruling_escalation_row( ) { # $1 setter, $2 labeled epoch; "login epoch url [b64]" lines on stdin
2026-08-03 10:55:45 +00:00
# → "url b64" of the BEST-SHAPED in-window comment by the setter, or
# nothing: highest ruling_shape_score wins, equal scores break to the
# earliest epoch. Earliest-wins outright was the rule until crew#293
# (2026-08-02): a builder answered its round whole and escalated 33
# seconds later — both in one window, the reply earlier — and the sweep
# graded the round reply, told a correct escalation it was malformed, and
# the setter re-posted a shape it had already met. Score resolves both
# orderings; the earliest tiebreak keeps escalation-then-follow-ups
# wherever the scores cannot tell candidates apart, including all-zero.
# An undecodable or absent body scores 0 and stays a legal candidate —
# an unreadable fact never invents a verdict, and never errors the sweep.
# The window and the setter gate candidacy before any score is taken.
local setter = " $1 " labeled = " $2 " login epoch url b64 body score
local best_score = -1 best_epoch = "" best = ""
2026-07-23 15:43:01 +00:00
while read -r login epoch url b64; do
2026-07-23 11:48:20 +00:00
[ -n " $login " ] || continue
[ " $login " = " $setter " ] || continue
ruling_accompanies " $epoch " " $labeled " || continue
2026-08-03 10:55:45 +00:00
if body = " $( base64 -d <<< " ${ b64 :- } " 2>/dev/null) " ; then
score = " $( ruling_shape_score <<< " $body " ) "
else
score = 0
fi
if [ " $score " -gt " $best_score " ] \
|| { [ " $score " -eq " $best_score " ] && [ " $epoch " -lt " $best_epoch " ] ; } ; then
best_score = " $score "
2026-07-23 11:48:20 +00:00
best_epoch = " $epoch "
2026-07-23 15:43:01 +00:00
best = " $url ${ b64 :- } "
2026-07-23 11:48:20 +00:00
fi
done
2026-07-23 15:43:01 +00:00
[ -z " $best " ] || printf '%s\n' " $best "
}
ruling_escalation_url( ) { # same contract, url column only — the nudge's link
local row
row = " $( ruling_escalation_row " $@ " ) "
[ -z " $row " ] || printf '%s\n' " ${ row %% * } "
2026-07-23 11:48:20 +00:00
}
# ---------------------------------------------------------------------------
# The impure orchestrator: fetch the facts, call the decisions, post through
# the caller's run(). Called by both reconcilers for every open item that
# carries the flag. Needs REPO; uses the caller's run() and log().
# ---------------------------------------------------------------------------
reconcile_ruling( ) { # $1 item number, $2 last real-activity epoch, $3 now
local n = " $1 " last_activity = " $2 " now = " $3 "
: " ${ REPO : ?reconcile_ruling : REPO is required } "
# The newest `labeled` event for the flag: actor + timestamp. A failed read
# skips BOTH checks — the nudge's specified content links the escalation
# comment, which only these facts identify, and half-verdicts on half-read
# facts is the exact shape the reconciler's standing rule forbids.
2026-08-03 15:13:30 +00:00
# forge_timeline projects both forges into the GitHub event shape
# (.event / .actor.login). Capture its status BEFORE jq: a pipeline's
# status is the last command's, so `forge_timeline | jq` would collapse
# an unreadable timeline into an empty one — the two states this function
# exists to tell apart (#188 / #4853).
local flags newest setter labeled_at labeled_epoch timeline
if ! timeline = " $( forge_timeline " $n " 2>/dev/null) " ; then
2026-07-23 11:48:20 +00:00
log " # $n : ruling timeline unreadable — no verdict invented this pass "
return 0
fi
2026-08-03 15:13:30 +00:00
flags = " $( jq -r '
.[ ] | select ( .event = = "labeled" and .label.name = = "needs-ruling" )
| [ .actor.login, .created_at] | @tsv
' <<< " $timeline " ) "
2026-07-23 11:48:20 +00:00
if [ -z " $flags " ] ; then
# The label is on the item but no labeled event is visible (a timeline
# hiccup, or an import). Same treatment as unreadable: do nothing.
log " # $n : ruling flag has no visible labeled event — no verdict invented this pass "
return 0
fi
newest = " $( ruling_newest_flag <<< " $flags " ) "
setter = " ${ newest %% $'\t' * } "
labeled_at = " ${ newest ##* $'\t' } "
labeled_epoch = " $( date -d " $labeled_at " +%s) "
2026-07-23 15:43:01 +00:00
# The body travels as jq's @base64 — bodies carry newlines and tabs, and
# the whole file is line-oriented, so the row format stays TSV and the
# body is decoded at its points of use (#73). Do not switch rows to JSON.
2026-07-23 11:48:20 +00:00
local comments
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
if ! comments = " $( forge_api --paginate " repos/ $REPO /issues/ $n /comments " \
2026-07-23 11:48:20 +00:00
--jq ' .[ ] | [ .user.login, .created_at, .html_url,
2026-07-23 15:43:01 +00:00
( ( .body // "" ) | @base64) ] | @tsv' 2>/dev/null) " ; then
2026-07-23 11:48:20 +00:00
log " # $n : ruling comments unreadable — no verdict invented this pass "
return 0
fi
2026-07-23 15:43:01 +00:00
# One pass over the comments builds every fact the decisions consume: who
# commented when (for the bare verdict), the newest marked comment PER
# MARKER (each write is idempotent per episode — one pass, not one pass
# per marker), and the "login epoch url b64" rows the escalation lookup
# reads. A body that fails to decode counts as unmarked — for idempotency
# that risks a repeat, never an invented verdict.
local login at url b64 body epoch authored = "" rows = ""
local marked_bare = "" marked_shape = "" marked_rung12 = "" marked_rung24 = ""
while IFS = $'\t' read -r login at url b64; do
2026-07-23 11:48:20 +00:00
[ -n " $login " ] || continue
epoch = " $( date -d " $at " +%s) "
authored = " $authored $login $epoch " $'\n'
2026-07-23 15:43:01 +00:00
rows = " $rows $login $epoch $url ${ b64 :- } " $'\n'
body = " $( base64 -d <<< " ${ b64 :- } " 2>/dev/null) " || body = ""
case " $body " in *" $RULING_BARE_MARKER " *)
if [ -z " $marked_bare " ] || [ " $epoch " -gt " $marked_bare " ] ; then marked_bare = " $epoch " ; fi ; ;
esac
case " $body " in *" $RULING_SHAPE_MARKER " *)
if [ -z " $marked_shape " ] || [ " $epoch " -gt " $marked_shape " ] ; then marked_shape = " $epoch " ; fi ; ;
esac
case " $body " in *" $RULING_RUNG12_MARKER " *)
if [ -z " $marked_rung12 " ] || [ " $epoch " -gt " $marked_rung12 " ] ; then marked_rung12 = " $epoch " ; fi ; ;
esac
case " $body " in *" $RULING_RUNG24_MARKER " *)
if [ -z " $marked_rung24 " ] || [ " $epoch " -gt " $marked_rung24 " ] ; then marked_rung24 = " $epoch " ; fi ; ;
esac
2026-07-23 11:48:20 +00:00
done <<< " $comments "
# ---- the bare-flag check (#50 D4, mechanical proxy) ----
2026-07-23 15:43:01 +00:00
if [ " $( ruling_bare_decision " $setter " " $labeled_epoch " <<< " $authored " ) " = BARE ] ; then
if [ " $( ruling_bare_comment_needed " $labeled_epoch " " $marked_bare " ) " = POST ] ; then
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
run forge_issue_comment " $n " " $RULING_BARE_MARKER
2026-07-23 11:48:20 +00:00
The ruling flag on this item was set by @$setter with no accompanying
escalation comment. Setting it requires the escalation contract — the
**question**, the **options**, and a **recommendation** — posted by the
flag-setter no more than 15 minutes before applying the label, or any time
after ( [ LABELS.md] ( https://github.com/heavy-duty/ceremony/blob/main/LABELS.md)
carries the flag-setter' s obligations; heavy-duty/ceremony#50 D4) . The label stays — this machine never removes an
escalation on the strength of a timestamp heuristic — but the contract is
still owed." >/dev/null
2026-07-23 15:43:01 +00:00
log " # $n : ruling flag is bare — commented (the label is never removed) "
fi
# Bare stops here (#73): there is no shape to check when there is no
# escalation comment, and a rung comment beside the bare comment would be
# two comments about the same omission — noise. The 7-day nudge below is
# deliberately untouched by this exclusion; it predates the ladder and
# already words the bare case itself.
else
# ---- the shape check (#50 D12): the contract's four field labels ----
local esc_row esc_url esc_body shape
esc_row = " $( ruling_escalation_row " $setter " " $labeled_epoch " <<< " $rows " ) "
esc_url = " ${ esc_row %% * } "
if ! esc_body = " $( base64 -d <<< " ${ esc_row #* } " 2>/dev/null) " ; then
# An undecodable body must not become "malformed" — an unreadable fact
# never invents a verdict. The rungs read the same body, so they wait
# for a readable pass too.
log " # $n : escalation body unreadable — no verdict invented this pass "
else
shape = " $( ruling_shape_decision <<< " $esc_body " ) "
if [ " $shape " != SHAPED ] \
&& [ " $( ruling_bare_comment_needed " $labeled_epoch " " $marked_shape " ) " = POST ] ; then
local missing = " ${ shape #MALFORMED } "
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
run forge_issue_comment " $n " " $RULING_SHAPE_MARKER
2026-07-23 15:43:01 +00:00
@$setter — the [ escalation comment] ( $esc_url ) accompanying this ruling flag
is missing required field labels: **$missing **. The contract' s shape is
fixed because this machinery checks for it ( heavy-duty/ceremony#50 D12) :
four line-anchored field labels — \` Options:\` , \` Recommend:\` , \` Blocked:\` ,
\` Default:\` — per the canonical template in
[ BUILDER.md — the ruling ask] ( https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask) .
Presence is all that is checked; the machine never judges the prose
( heavy-duty/ceremony#50 D4) . The label stays — the shape is owed, not
enforced." >/dev/null
log " # $n : escalation malformed (missing: $missing ) — commented (the shape is owed, not enforced) "
fi
# ---- the ladder's rungs (#50 D13– D14), observed never decided ----
# Each rung's comment fires once per episode, AT its moment: a rung
# whose moment passed unobserved (the sweep was down through 12h– 24h)
# is not paged after the fact — the later rung's comment carries the
# whole remaining duty.
local rung state described
rung = " $( ruling_deadline_decision " $now " " $labeled_epoch " ) "
state = " $( ruling_default_decision <<< " $esc_body " ) "
case " $state " in
DEADLINE\ *) described = " a stated default deadline of \` ${ state #DEADLINE } \` " ; ;
HARDBLOCK) described = "\`Default: none\` — a hard block; no default ever fires" ; ;
*) described = "a missing or unparseable \`Default:\` line — reported as-is, never guessed at (and the contract's own rule is that unsure is a hard block)" ; ;
esac
if [ " $rung " = RUNG12 ] \
&& [ " $( ruling_bare_comment_needed " $labeled_epoch " " $marked_rung12 " ) " = POST ] ; then
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
run forge_issue_comment " $n " " $RULING_RUNG12_MARKER
2026-07-23 15:43:01 +00:00
@$setter — this ruling is 12 hours past its \` labeled\` event: the ladder' s
12h rung ( [ BUILDER.md — the ruling ask] ( https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask) ,
heavy-duty/ceremony#50 D13) . Mechanically read, the escalation carries
$described .
The rung's duty is the flag-setter' s: re-read the \` Default:\` against
everything that has landed since the flag went up — does it still hold, and
has reasonable doubt appeared? A stale default does not fire, and new doubt
makes it a hard block. The rungs run on the \` labeled\` clock and do not
reset on activity; this comment fires once per flag episode." >/dev/null
log " # $n : ruling at the 12h rung — commented (the setter re-reads the default) "
fi
if [ " $rung " = RUNG24 ] \
&& [ " $( ruling_bare_comment_needed " $labeled_epoch " " $marked_rung24 " ) " = POST ] ; then
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
run forge_issue_comment " $n " " $RULING_RUNG24_MARKER
2026-07-23 15:43:01 +00:00
@$setter — this ruling is 24 hours past its \` labeled\` event: the ladder' s
24h rung ( [ BUILDER.md — the ruling ask] ( https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask) ,
heavy-duty/ceremony#50 D13) . Mechanically read, the escalation carries
$described .
At 24h the builder proceeds 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 the choice is
triage' s to make: triage picks the option, records it as a decision, and
remains accountable; the operator may overturn it at merge. The rungs run on
the \` labeled\` clock and do not reset on activity; this comment fires once
per flag episode and covers everything past 24h — there is no further
timer." >/dev/null
log " # $n : ruling at the 24h rung — commented (the builder proceeds as a PR; past 24h is triage's) "
fi
fi
2026-07-23 11:48:20 +00:00
fi
# ---- the 7-day nudge (#50 D10) ----
if [ " $( ruling_nudge_decision " $now " " $last_activity " ) " = NUDGE ] ; then
# The decider is the repo's human reviewer — the same knob the PR
# reconciler trusts for the merge gate, defaulted the same way. The
# flag-setter is named but deliberately not tagged: address the decider,
# never the whole thread's cast (#50 D10).
local decider = " ${ HUMAN_REVIEWER :- danmt } " days esc_url esc_line
days = $(( ( now - last_activity) / 86400 ))
esc_url = " $( ruling_escalation_url " $setter " " $labeled_epoch " <<< " $rows " ) "
if [ -n " $esc_url " ] ; then
esc_line = " The escalation is here: $esc_url "
else
esc_line = "No escalation comment accompanies the flag — the contract (question, options, recommendation) is still owed by the flag-setter."
fi
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
run forge_issue_comment " $n " " @ $decider — a ruling on this item has been pending with no activity for ${ days } days. $esc_line
2026-07-23 11:48:20 +00:00
Per heavy-duty/ceremony#50 D6/D7 the flag-setter ( $setter ) owns closing this out: judge when agreement is reached, record the ruling as a decision in one comment, remove the label, and return the item to its flow in that same comment.
*This nudge is comment-only and carries no idempotency marker on purpose: the comment itself is activity, so posting it resets the 7-day window and the rule self-rate-limits. Do not add a marker.*" >/dev/null
log " # $n : ruling nudge ( ${ days } d quiet — the decider owes an answer) "
fi
}