<!-- Authored per the the-loop:writing skill. -->
Requirements: a work item is armed by a set of labels, all of which must be present
Phase 1 of 4 (requirements → design → testing plan → 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-381: "Currently if multiple people are using the-loop and have autoExecuteLabel: 'the-loop: auto-execute' in their config, everyone's issues are being tracked by everyone's instances of the-loop. We want to make this a list so that each user can add the common label as well as a specific label for themselves, and the-loop only looks at the issue if all the labels in the list are present."
routing.autoExecuteLabel is one string, read in four places — the receiver's router, the dispatcher's arming check, the poller's gh … --label listing, and the poller's presence event — and every operator ships the same default. Two operators on one repository therefore arm every labelled item on both machines, and the only remedy is for one of them to rename the label everywhere, which breaks the shared convention that /the-loop:work-on and the Slack kickoff rely on.
| Today | Consequence |
|---|---|
| One label arms an item | An item labelled for one operator is armed for every instance that watches the repository |
| The default is shared by every install | Renaming it to escape another instance breaks the convention the commands and the Slack kickoff apply |
polling.sources[].label is one string too | The poll ingress cannot express a narrower set than the receiver's either |
The unit of the change is one key becoming a list, with every-label-present semantics, on both ingresses, with the old key migrated rather than silently honoured. The instance scope of issue-322 answers which instance takes a work item that already names one; this answers which items an instance looks at in the first place.
Requirements
R1 — the arming gate is a set of labels, and every one must be present
User story: As an operator sharing a repository with other the-loop instances, I want to declare the common label plus one of my own, so that only the items carrying both are armed on my machine.
Acceptance criteria (EARS)
routing.autoExecuteLabelsSHALL be a list of label names, default["the-loop: auto-execute"]; an operator who never set the key SHALL see exactly the behaviour of the single-label default.- WHEN an issue or pull-request event's item carries every label in the list THEN the router SHALL flag the event as labelled and the dispatcher SHALL treat the item as armed under
spawnOnUnmatched: labeled. - WHEN the item carries only some of the labels THEN the event SHALL NOT be flagged and the item SHALL NOT be armed, whatever the missing label is.
- WHEN a
labeledaction adds the last missing label THEN the item SHALL count as armed on that event: the label being added is read together with the item's label set, as the single label was. - IF the list is empty THEN nothing SHALL be armed (fail closed); the schema SHALL require at least one entry so an empty list is a validation error rather than a silent
never. - The dispatcher's spawn-policy refusal and the poller's start-up line SHALL name the whole list, so an operator reading a refusal sees which labels the item was measured against.
R2 — the poll ingress lists by the same set
User story: As an operator on a host a webhook cannot reach, I want the poller to discover exactly the items the receiver would arm, so that the two ingresses never disagree about which items are mine.
Acceptance criteria (EARS)
- WHEN a
githubsource lists a repository THEN it SHALL pass every configured label togh(one--labelper label) AND SHALL drop from the listing any returned item that does not carry every label — so the gate holds whateverghreturns. polling.sources[].labelsSHALL be a list; an empty or absent list reusesrouting.autoExecuteLabels, a non-empty one replaces it for that source.- The presence event a listed item produces SHALL carry
labeledcomputed against the full set, so the dispatcher's arming check and the poller's listing agree.
R3 — the old keys are migrated, never honoured silently
User story: As an operator upgrading, I want a config that still says autoExecuteLabel to be refused with the fix named, so that my custom label is never quietly replaced by the default.
Acceptance criteria (EARS)
- WHEN a CLI config declares
routing.autoExecuteLabelorpolling.sources[].labelTHEN the runtime SHALL refuse to start, naming the key, its replacement and/the-loop:upgrade-the-loop. the-loop migrate-configSHALL moveautoExecuteLabel: xtoautoExecuteLabels: [x]andlabel: xtolabels: [x], remove an emptylabel: ""(it meant "reuse", which an absentlabelsstill means), bumpversionto0.10.0, and report each move.- The migration SHALL be idempotent, and
needs_migrationSHALL detect the old keys by name as well as by version. - WHEN both the old and the new key are present THEN the migration SHALL keep the new one, drop the old one, and say so in a note.
R4 — the change ships with its documentation
Acceptance criteria (EARS)
- The config reference (
routing-options.md,polling-options.md, and every page that links#autoexecutelabel), both schema copies, the shipped template and this repository's owncli-config.yamlSHALL name the new keys; the docs-parity and schema-parity suites SHALL pass. commands/work-on.mdandcommands/execute-tasks.mdSHALL tell the agent to apply every label in the list to the issue or pull request it arms.docs/capabilities/webhook-triggers.mdSHALL state the every-label-present rule and carry a history row; a decision record SHALL record the all-of choice and the breaking migration.
Non-functional requirements
- No new API calls. Label presence stays a read of the webhook payload or the poll listing, as it is today.
- No wider queries. The poller still asks
ghfor labelled open items only; adding labels narrows the listing. - Observability unchanged.
routing.routedkeeps itslabeledflag; no new event type.
Security considerations
- Actors & trust: anyone with triage rights on the repository can apply labels; the label set is therefore a narrowing of what an instance looks at, never an authorization.
routing.authorizedUsersandcontrol.requireStartCommandare untouched and still decide who may start an armed item. - Trust boundaries & data: the list is operator configuration, read from the CLI config only; label names are compared by exact string equality and never interpolated into a shell —
ghreceives them as separate argv entries, as the single label was. - The upgrade is the boundary that matters. An operator who had set a custom
autoExecuteLabeland whose config were silently read under the new key would fall back to the shared default, which is wider than what they set — the exact drift the migration doctrine exists to prevent. Hence R3.1 refuses rather than reads. - Abuse cases (EARS):
- WHEN an item carries a subset of the configured labels THEN the system SHALL NOT arm it, on either ingress.
- WHEN a
labeledaction adds a label that is not the missing one THEN the system SHALL NOT arm the item on that event. - WHEN the list resolves to empty at runtime THEN the system SHALL arm nothing.
- WHEN a config still carries the old key THEN the system SHALL refuse to start rather than read the default.
- Fail closed: an empty list arms nothing; an un-migrated config runs nothing.
Out of scope
- Any-of (OR) semantics, or a per-label policy. The ticket asks for all-of.
- Deriving a per-instance label from
instance.nameautomatically; the operator declares the labels they want. - A migration of the labels already applied on GitHub; the operator's own items already carry the common label and gain the specific one by hand.
- Jira or any provider other than
githubfor the poll source.
Open questions
None raised on the ticket; the owner's request names the semantics (all labels present).
Review comments
Appended by the-loop's
record-feedbackhook when a human gate approves with comments (issue-109). Append-only and attributed.