CEO digest steering is unreachable: the footer says "comment", the fast path listens for "unassign" #433
Labels
No labels
Agent
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No assignees
1 participant
Notifications
Total time spent: 15 minutes 16 seconds
Due date
igor
15 minutes 16 seconds
No due date set.
Dependencies
No dependencies set
Reference
joshtronic/igor#433
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The problem
A board comment on a CEO digest is not picked up until the next weekly digest composition -- up to 7 days. Live example:
joshtronic/idolidle.com#27, commented 2026-07-27 16:06 with an explicit unblock ("I am not a blocker on this, the agent can write the content if you give the direction to do so"). It will not be read until 2026-W32, becauseidolidle.comis already stamped2026-W31in.ceostate.The CEO already HAS a same-day response path -- it used it today at 12:18 (
ceo: acted on 1 answered board question(s) on joshtronic/idolidle.com). Digests just cannot reach it.Root cause: two conventions that disagree
ceo_file_digest()(lib/ceo.sh) stamps the digest with this footer:But the fast path keys on something else entirely.
ceo_answered_question_numbers()selects on the reviewer having UNASSIGNED themselves -- its own comment says "their go-signal: answered, hand it back".ceo_responded_proposal_numbers()likewise requiresselect(([.assignees[]?.login] | index($r)) | not).A digest is filed assigned to the reviewer and the footer never mentions unassigning. So the board does exactly what the footer says, and the machinery never sees it. The only consumer of a digest comment is
ceo_prior_digest_steering(), whose sole caller is the next weekly composition.What "fixed" looks like
A board comment on an open digest is acted on at the next CEO pass, the same as an answered question -- not the next week.
Requirements, not an implementation:
The digest's stated convention and the machinery must agree. Either the fast path learns to accept a comment as the go-signal for digests, or the footer starts telling the board to unassign. Whichever way it goes, the footer text and the selector must be changed together, in this ticket. They are currently a trap.
Must NOT be gated by the weekly ISO-week stamp.
.ceo[<repo>](e.g."joshtronic/idolidle.com":"2026-W31") gates composing a new digest. Responding to steering is a different action and must run regardless -- otherwise this fix lands and idolidle#27 still waits until W32, which is the entire bug.The digest stays OPEN and stays assigned (if unassignment is not chosen as the signal).
ceo_prior_digest_steering()still needs it open for the weekly fold-in, and closing is already defined as "acknowledge or drop".Idempotence. Whatever the signal, the same comment must not be re-acted on every tick. A CEO reply on the thread is the cheapest watermark -- "a board comment newer than the CEO's last reply is unhandled" -- and needs no new state.
The CEO replies on the thread when it acts, naming what it queued or did. This is a requirement, not a nicety: today the board comments into a channel that never acknowledges, so "acted on" and "ignored" look identical from the outside. That is half of what makes the current behaviour feel broken.
The CEO acts on its own judgment. Once the board has responded, it should file the Agent-labeled work or do the thing -- NOT file a fresh proposal asking the board to approve what it just approved. The human merge gate remains the backstop; a second approval round-trip is the loop this ticket exists to remove.
Acceptance criteria
bin/test-ceo.sh(92 assertions today) covers: the selector matches a commented digest; it does NOT match one already answered by a CEO reply; the weekly stamp does not suppress it; and the footer text matches whatever signal the selector implements.Live verification
joshtronic/idolidle.com#27is open right now with an unanswered board comment. Once this lands, that ticket is the end-to-end test -- it should be picked up on the next CEO pass and receive a reply. Do not close it as part of this work.Explicitly NOT in scope
Cascade starvation. As of 2026-07-27 there were zero cascade-stage lines in a 2.5-hour window -- no tick got deep enough to reach the CEO stage at all, because automerge, deploy-watching, PR review and issue work consumed them higher up. That limits how fast this fix delivers, but it is a separate concern with a separate fix and must not be bundled here.
REQUIRED: authorization gate (added after the first attempt was blocked)
The first implementation was correctly rejected by the harness security review. Read this before rebuilding — it is in the body because the issue-work prompt receives only
.body; issue comments are never passed to the model, so the review comment on this ticket is invisible to you.What went wrong: the pending-steering selector tested
.user.login != $bot— i.e. any comment author who is not the bot counted as "the board". Combined with requirement 6 above (file Agent-labeled work directly, no greenlight round), that turned a comment into a way to inject work into the autonomous execution queue. Previously that required adding theAgentlabel, which is a write action on the tracker. The diff silently converted an authorization gate into a triage stamp.That is my spec's fault as much as the implementation's: requirement 6 asks for the greenlight round to be skipped, and I did not notice the label was doing double duty as the authorization step.
The requirement:
FORGEJO_REVIEWER) counts as board steering —select(.user.login == $reviewer), in BOTH the pending selector and wherever comments are markedNEW --.Severity note for context, not an excuse to skip it: this instance is private with two accounts, so today "any commenter" is effectively the reviewer or the bot. The fix is one predicate and closes the gap before the trust boundary ever widens. Take it.
The harness security review flagged a material issue in this change, so it was NOT pushed:
Findings
lib/ceo.sh:ceo_digest_steering_pending()/bin/tick.shPath 1c.any(.[]?; .user.login != $b and .created_at > $w)— i.e. any comment author other than the bot itself counts as "the board." There is no allowlist against the digest's assigned reviewer (CEO_REVIEWER/.assignees[0]).ceo_file_agent_work(), which deliberately posts an issue already labeledAgentand skips the proposal/greenlight round ("no separate greenlight needed").Agentlabel — a write-permission action on the tracker. Now a comment — a read/comment-permission action, available to any account that can see the tracker — is sufficient to inject work into the autonomous execution queue. On a public or multi-user Forgejo instance this is a direct escalation from "can comment" to "can make the agent do work."ceo_digest_steering_thread→ceo_build_digest_steering_prompt) with a system message that explicitly tells the model "The board already gave the direction — act on it, do not ask them to re-approve it," so untrusted comment text is treated as pre-approved instruction.ceo_codecheck_proposalis a redundancy check, not an authorization check.select(.user.login == $reviewer)(digest assignee or configuredCEO_REVIEWER) instead of!= $bot, both in the pending selector and in theNEW --marking.Non-findings (checked, clean): all
printfcalls use literal format strings with untrusted data passed as%sargs (no format-string injection); all API payloads and prompt data go throughjq --arg/--argjson(no JSON/shell injection); noeval, no shell interpolation of comment/model text into commands; no credentials, tokens, or TLS/verification changes;mktempused correctly in tests.Address it, then remove
Status/Blockedto re-queue. (If the note above says the gate could not complete, that's a transient error -- just re-queue.)