Requirements: a Slack kickoff asks which repository, with the declared set as options
Phase 1 of 3 (requirements → design → tasks). Following the Kiro spec approach (https://kiro.dev/docs/specs/). This phase MUST be reviewed and approved by the required collaborators before moving to design.
Introduction
Issue-349. A top-level Slack message that cannot be resolved to a repository is refused today, with the declared repositories listed as prose. The refusal is correct and safe; it is also a demand that the member read a list on a phone, pick from it, and retype their whole message with a prefix they have never seen before.
This work item turns that refusal into a question with the repositories as pickable options — and answers the question by letting the pick finish the kickoff that was already in flight.
Three things stay exactly as they are, and the requirements below are written so that a reviewer can check each one at a glance:
- The universe of targets.
declared_repositories()over the top-levelrepositorieskey (issue-348), and nothing else. A pick is a selector into a closed set, never a string from a member that reachesgh --repo. - The authority. The existing
work-item.creategrant and the existing allow-list. No new grant, no new scope, no new config key. - The ledger path. A pick produces the same
work-item.createevent, through the samepublish→GitHubLedgerseam, with the same record.
What is genuinely new is one thing: the-loop asks a question of its own, and the answer is an argument to an action it has not taken yet. Every button the channel renders today either answers a graph gate (Approve / Request changes) or relays a control keyword (Execute / Start) — both about a work item that already exists. This one is not, which is why it needs state, and the state is the bulk of what follows.
flowchart TD
M["top-level message<br/>in the configured channel"] --> A{"authorized<br/>member?"}
A -- no --> D0["dropped, silently<br/>(unchanged, R2.6 of #341)"]
A -- yes --> R["resolve_target()<br/>against the declared set"]
R -- "resolved / fallback" --> C["work-item.create<br/>→ issue → bind → reply"]
R -- "empty-message" --> X["refused<br/>(no pick puts words in it)"]
R -- "no-target · ambiguous-repo · unknown-repo" --> Q{"can a pick<br/>be received?"}
Q -- "read.mode ≠ socket" --> X2["refused, as today"]
Q -- "read.mode: socket" --> P["PENDING record + the question,<br/>options = the declared set"]
P --> K["the member picks"]
K --> C
style C fill:#dff0d8,stroke:#3c763d
style P fill:#fcf8e3,stroke:#8a6d3b
style X fill:#f2dede,stroke:#a94442
style X2 fill:#f2dede,stroke:#a94442
style D0 fill:#f2dede,stroke:#a94442Requirements
Requirement 1 — the unresolved kickoff becomes a question, not a refusal
User story: As a member filing work from my phone, I want the-loop to ask me which repository and show me the ones it knows, so that I do not have to learn a prefix format to file my first issue.
The rule is one sentence, and R1.1–R1.4 are its four readings: if a pick could answer it, ask; otherwise refuse.
Acceptance criteria (EARS)
- WHEN an authorized member's top-level message resolves to no target (
no-target— no prefix read and nokickoff.repo) AND a pick can be received THEN the system SHALL post a question in that message's thread offering every declared repository as a pickable option, and SHALL create nothing. - WHEN a read prefix names several declared repositories (
ambiguous-repo) THEN the system SHALL ask the same question offering only the repositories that prefix matched, so the question is the narrow one the member already half-answered. - WHEN a read prefix names no declared repository (
unknown-repofrom a qualified prefix) ORkickoff.reponames none (unknown-repofrom the fallback) THEN the system SHALL ask the question offering every declared repository. - WHEN the message resolves but nothing is left of it once the prefix is stripped (
empty-message) THEN the system SHALL refuse exactly as it does today, because no pick puts words in an empty message. - WHEN a prefix already resolves to exactly one declared repository (
resolved) OR thekickoff.repofallback takes the message (fallback) THEN the system SHALL create the work item immediately and SHALL ask nothing — a member who typed a prefix has already answered the question. - WHILE a question is unanswered the system SHALL hold the message's text unchanged, so that the issue a pick opens is composed from exactly what was written, minus any prefix that was read.
Requirement 2 — the question is rendered as options, sized to the set
User story: As a member on a phone, I want one tap, so that picking a repository is cheaper than retyping my message.
Acceptance criteria (EARS)
- WHEN the question offers at most
BUTTON_CHOICE_LIMIT(5) repositories THEN the system SHALL render them as Block Kit buttons, one per repository. - WHEN the question offers more than
BUTTON_CHOICE_LIMITrepositories THEN the system SHALL render a Block Kit static select menu instead. - WHEN more repositories are declared than a select menu may carry (
OPTION_LIMIT, 100 — Slack's own ceiling) THEN the system SHALL offer the firstOPTION_LIMITin declaration order and SHALL say in the question that the rest are reachable by typing the<repo>:prefix. - The rendered label of every option SHALL be the operator's declared slug (
DeclaredRepo.declared), and the option's value SHALL be that same string — never anything derived from the member's text. - WHEN the question is rendered THEN the system SHALL also say that a
<repo>:prefix skips the question next time, so the ask teaches the shortcut rather than replacing it.
Requirement 3 — a pending question is held, expires, and is answered once
User story: As an operator, I want the state this introduces to be bounded and self-clearing, so that an unanswered question is never a leak, a replay or a surprise.
Acceptance criteria (EARS)
- WHEN a question is asked THEN the system SHALL persist a pending kickoff record keyed by the message's
ts, carrying the channel, the asking member, the message text as the issue will be composed from it, the offered slugs, and the time it was asked. - WHEN a pending record is older than
PENDING_TTL_SECONDS(24 hours) THEN the system SHALL treat it as absent, and SHALL remove it the next time the state is written. - WHEN more than
PENDING_CAP(50) questions are outstanding THEN the system SHALL drop the oldest record, exactly asTHREAD_CAPdrops the oldest binding. - WHEN a pick is accepted THEN the system SHALL remove the pending record under the state lock before publishing
work-item.create, so that two presses of the same question cannot open two issues. - IF the create then fails THEN the system SHALL restore the pending record, so the question stays answerable — matching the existing rule that a failed press keeps its buttons and a landed one removes them.
- WHEN a pick names a repository that is not in the record's offered set, or is no longer declared, THEN the system SHALL create nothing and SHALL leave the record untouched.
- WHEN a pending record exists for a message THEN the system SHALL NOT ask a second question about that same message.
Requirement 4 — the pick rides the pipeline that already exists
User story: As a reviewer, I want to be able to check that a press buys nothing a typed prefix would not, so that I can approve this without re-auditing the ingress.
Acceptance criteria (EARS)
- WHEN a
block_actionspayload carries the repository picker'saction_idTHEN the system SHALL handle it throughhandle_socket_action, the same entry point every other press uses. - WHEN the picker's action is a select menu THEN the system SHALL read the chosen value from
selected_option.value, because astatic_selectcarries no top-levelvalue. - WHEN a pick is accepted THEN the system SHALL publish exactly the
work-item.createeventprocess_kickoffpublishes today — samerepo,labels,threaddetail, sameprincipal_foractor, sameGitHubLedger, same record. - WHEN a pick is accepted THEN the system SHALL bind the thread with origin
kickoffand reply with the same "Opened …, this thread is now that work item's conversation" message and Start buttonprocess_kickoffposts today. - WHEN a press gets past the allow-list THEN the system SHALL write its outcome onto the question message through the existing
report_presspath — opened, failed to open, or refused — removing the picker once the work item is open and keeping it when it is not. The words SHALL name no repository, no other member and no config value. - WHEN a press arrives THEN the system SHALL re-read the channel's own permission (
read.mode: socketand thework-item.creategrant) at that moment, and SHALL create nothing if either has since been taken away.
Requirement 5 — what cannot receive a pick says so
User story: As an operator, I want channels status to tell me whether my configuration can ask this question, so that I am not told by a member who did not get one.
Acceptance criteria (EARS)
- IF
channels.slack.read.modeis notsocketTHEN the system SHALL NOT ask the question, and SHALL refuse an unresolved kickoff with today's text — a question nobody can answer is worse than a refusal that says what to type. - WHEN
the-loop channels statusruns THEN thekickoffline SHALL say whether the repository question is available and, when it is not, name the reason (read.modeis notsocket, or nothing is declared). - WHEN nothing at all is declared THEN the system SHALL refuse rather than ask, because a question with no options is not a question.
Non-functional requirements
- No new I/O on the hot path. The pending read is one key in a file the pipeline already loads (
ChannelState), under the lock it already takes. - Observability. The two new outcomes emit through the existing
eventlogseam:channel.kickoff_askedwhen a question goes out, and the existingchannel.createdwhen a pick opens the issue. A rejected pick is achannel.droppedwith its own reason, like every other refusal. - Backward compatibility. A configuration that is not
read.mode: socket, or that resolves every message through a prefix or fallback, behaves byte-for-byte as it does on 14.0.0. No config key is added, so a 14.0.0 config upgrades untouched. - State file compatibility. A
slack.jsonwritten before this change has nopendingmap; it loads as an empty one, and is written back with the key the next time any writer saves.
Security considerations
- Actors & trust:
- Untrusted: any Slack workspace member — their message text, their
block_actionspayload, thevalueinside it, theaction_id, and the user id the payload claims. - Trusted: the operator's CLI config (the declared repositories, the allow-list, the grants) and the state file the daemon itself writes.
- Untrusted: any Slack workspace member — their message text, their
- Trust boundaries & data: the boundary is unchanged and sits in the same place — a member's text may become an issue body; it may never become a repository argument. A pick does not move that boundary: the value is matched against the offered slugs and the currently declared set, and what reaches the ledger is
DeclaredRepo.declared, a string from the operator's own file. The new state holds one new class of data — a member's unsent message text — in a file that already holds thread bindings; it is local, never portable, and expires. - Abuse cases (EARS):
- WHEN a crafted
block_actionspayload carries the picker'saction_idand avalueof../../etc(or any string outside the offered set) THEN the system SHALL create nothing and SHALL record a drop. - WHEN a crafted payload carries a value that is a real repository but was never offered for that message THEN the system SHALL create nothing — the record's own offered set is the bound, not the declared set alone.
- WHEN an unauthorized member presses the picker THEN the system SHALL create nothing, SHALL leave the pending record untouched, SHALL edit no message, add no reaction and post nothing — so they learn not even that a question exists.
- WHEN an authorized member presses the picker on a question asked about another member's message THEN the system SHALL create nothing, because the message is not theirs to direct and the issue would be attributed to its author.
- WHEN an unauthorized member posts a top-level message THEN the system SHALL ask nothing — the question is a bigger disclosure than a refusal, so it sits below the allow-list, exactly where the refusal already sits.
- WHEN the same picker is pressed twice in quick succession THEN the system SHALL open exactly one work item, because the record is claimed under the state lock before the create.
- WHEN a pick arrives for a record that has expired THEN the system SHALL create nothing and SHALL say the question has expired, naming no repository.
- WHEN a member's message text contains Block Kit markup, a pyramid of
@channel, or 3000 characters THEN the system SHALL neither render it into the question nor let it size the message — the question is fixed words plus declared slugs. - WHEN the state file is corrupt or unwritable THEN the system SHALL behave as though nothing is pending: the question is asked (and simply cannot be answered) rather than an issue being created without one.
- WHEN a pick arrives after the operator has revoked
work-item.createor left Socket Mode THEN the system SHALL create nothing — a pending record SHALL NOT carry a member past a permission the channel no longer has.
- WHEN a crafted
- Fail closed: no
read.mode: socket→ no question. No declared repositories → no question. A revoked grant, no pending record, an expired one, a value outside the offered set, an unauthorized presser, or a presser who is not the message's author → no work item. The only direction a fault can move the system is toward refusing, never toward creating an issue somewhere the operator did not declare.
Out of scope
- A text fallback in
pollmode (reply 1, 2 or 3) — settled as D1 on the ticket: the typed prefix stays the only route there, andchannels statussays so. - Any second question — labels, phase selection, assignees. Settled as D2: one question, one record, one answer.
- Per-repository labels. Still the separate ask decision-120 parked.
- A Request URL / HTTP interactivity endpoint. decision-116 D5 stands: Socket Mode is the only way an interactive payload reaches the-loop.
- Editing the pending message's text. A member who wants to change their wording posts a new message; the old question expires.
Open questions
None outstanding. The ticket's two named design decisions (D1 poll mode, D2 the scope of "a series") were settled before this artifact was written, with the reasoning posted to the ticket as the paper trail: issue-349, phase-selection comment.
Review comments
Appended by the-loop's
record-feedbackhook when a human gate approves with comments (issue-109).