# Requests Hub

Central system for consolidating, organizing, and dispatching project issues and requests across all of Reavo's projects. GitHub Issues (private, in each Project's own repo) is the system of record; Discord remains the capture and notification surface; spt is how agents reach it.

This system is built in the `spt-alchemy` project, whose DRI is the endpoint **flynn**. Its shell adapter is named **alchemy**, for the transmutation of raw request Seeds into shipped releases. It is spt-core's first shell consumer, so gaps in the shell API surface are expected and routed to doyle (spt-core's DRI) as feature requests.

## Language

### Core

**Request**:
A single actionable idea directed at a Project: a change, addition, feature, or issue. One Request = one GitHub issue = one Bot post in Discord.
_Avoid_: Post, item, ticket, idea

**Hub**:
The Requests of all Projects, considered as one body. Physically distributed across per-Project GitHub repos; logically central.
_Avoid_: Tracker, backlog (those describe views, not the store)

**Project**:
The target codebase or effort a Request applies to (e.g. spt-core). Every Request belongs to exactly one Project. Each Project has one GitHub repo, one Discord channel, and one DRI.

**Operator**:
Reavo. The human whose personal involvement some Requests require, distinct from any agent.
_Avoid_: User, owner

**DRI**:
The party accountable for a Project's direction — usually an agent endpoint (doyle, for spt-core), not necessarily a person. The DRI rules on a Request's direction and Milestone. One DRI per Project.
_Avoid_: Owner, lead, stakeholder

**Analyst**:
The party performing root-cause analysis on a Bugfix. The DRI delegates this (hertz, for spt-core) or performs it directly on smaller Projects.

<!-- [doc->REQ-TYPE-IDEA] -->
**Type**:
The form a Request arrived in — one of **Bugfix**, **Change**, **Addition**, **Feature**, or **Idea**. Named for the request, not the defect: the thing tracked is "fix this", so the Type is Bugfix, not Bug. Set when the Request is minted and **never changed**: Type records origin, kept intact for historical tracking, not current maturity — a Request rides its minted Type all the way to **Done** (Operator ruling 2026-07-31). Type determines what happens during **Eval**: a Bugfix gets root-cause analysis and a fix suggestion from the Analyst; the others get their implementation fleshed out in discussion.

Every Request the **capture** path mints carries a Type by construction. A Request **hand-filed straight onto GitHub** does not: the mint that guarantees one never ran, and *nothing on the shell could give it one* — `create --type` is the only writer of the namespace and `tag` refuses the reserved prefixes by design. Such a Request is permanently typeless while Type is the thing that chooses what **Eval** does with it. The **type** verb is the repair, and it is a **fill, not a change**: it writes `type:` only when the Request carries none and refuses when one is present, naming what is held. That refusal is deliberately the whole design — it is what makes "never changed" hold *by construction* rather than by discipline, and it is why this is not a second `state` verb with any-jump semantics. `tag`'s refusal of the `type:` namespace stands unchanged: there is now one path to a fill and still no path to a change. Filling **Idea** sets `flag: NEEDS-OPERATOR`, exactly as minting one does — a fill is the mint that never ran. <!-- [doc->REQ-VERB-TYPE-FILL] -->

A typeless Request must also be **loud**, because it was not: `spt-bs-releases#153` sat typeless for seventeen days on a board that read as fine. It read as fine because `state:` was present the whole time — state transitions apply normally to a hand-filed issue — and the facet the board **displays** was the one that was present. **One facet of the taxonomy being present is not evidence of the rest.** So the Bot card renders a missing Type as an explicit marker instead of collapsing it to a bare `?`, and reconcile carries the same fact in its cycle log beside the existing no-state-label skip. Because reconcile edits a card only on drift, that announcement is written **once, at the moment it becomes true**, persists until repaired, and clears itself the moment the fill runs — announced-once by construction, which matters because reconcile holds no state between cycles. <!-- [doc->REQ-TYPELESS-LOUD] -->

- **Bugfix**: correct existing behavior that is wrong.
- **Change**: modify existing behavior that is working as built.
- **Addition**: new capability, small and well-bounded.
- **Feature**: new capability with a large or user-facing surface.
- **Idea**: an exploratory thought that arrived rough and needs fleshing-out before it is committed work. Its reaction emoji is 💡, and every mint of one — reaction-mint or `create --type idea` — automatically sets **Needs Operator**: an Idea is a conversation to have before it is work to schedule (Operator ruling 2026-07-23). Once that conversation has happened and the flag clears, it schedules and builds like any other Request, Type intact (Operator ruling 2026-07-31).

<!-- [doc->REQ-MILESTONE-VERB] [doc->REQ-MILESTONE-CASCADE] [doc->REQ-MILESTONE-ADD-STATE-SYNC] -->
**Milestone**:
A parent GitHub issue tagged `kind: MILESTONE`, grouping the Requests slated for one release. A Request becomes a **sub-issue** of its Milestone while under evaluation; every buildable Request maps to one, even a Milestone holding only that Request. Greenlighting happens at the Milestone, never per-Request: moving the Milestone to **Greenlit** moves every sub-issue with it, as do its moves to **WIP** and to **Acceptance** — but never to **Done**, which each sub-issue must earn individually. A Request **joining** a Milestone already under way catches up the same direction: its State syncs to the later of its own and the Milestone's in lifecycle order, capped at **WIP** — a fresh joiner has not been through Acceptance — with a terminal child never dragged and a Needs-Operator child held at the Greenlit gate by name. Not a native GitHub milestone — an issue, so it carries a body, comments, State, and a native sub-issue completion rollup. A release's `fixes #<milestone-issue>` closes it alongside the child Requests its commits close. Grouping is always within one repo, so the cross-owner sub-issue limitation never applies.

**Tag**:
A freeform categorization label on a Request. A Request may carry any number of Tags. Distinct from **State**, which is single-valued and ordered, and from **Needs Operator**, which is a flag.
_Avoid_: Category, label

### States

A Request holds exactly one **State** at a time. State drives which Discord thread the Bot post lives in.

**Backlog**:
Recorded but not yet worked. Includes rough stubs the Operator is still thinking through.

**Eval**:
Under analysis or discussion. For a Bugfix, the Analyst is establishing root cause and a suggested fix; for the other Types, the implementation is being fleshed out.

**Greenlit**:
The Project's DRI has ruled on the Request's direction and greenlit its Milestone. This — and only this — means buildable. A Request cannot be greenlit individually — it enters Greenlit only by riding its Milestone's transition — and cannot enter Greenlit while **Needs Operator** is set.

**WIP**:
Handed to an agent; work in flight. Entered automatically when the Request is dispatched.

**Acceptance**:
Work reported complete but not yet verified as released.

**Done**:
Verified implemented and believed released.

**Cut**:
Decided against — or ruled not this Project's work at all, which is the resting place of a
Request routed away to another Project (see Resolved ambiguities).

**Shelved**:
Valid but deliberately deferred with no current intent to dispatch. Distinct from **Backlog** (never worked) and from **Cut** (declined outright).

### Flags

**Needs Routing**:
A flag meaning the Request's Project is not yet settled. Anything submitted under the SPT umbrella is filed to spt-core on arrival and flagged, because a submitter using a harness adapter and spt-core together cannot reliably tell which one their Request belongs to. Cleared when the Request is moved to its true Project, or confirmed to be in it.

**Needs Operator**:
A flag meaning the Operator must personally take part before the Request can be greenlit. Decided when the Request is minted; typical on a Change, Addition, or Feature, atypical on a Bugfix, but valid on any. It exists only in **Backlog** and **Eval**, and must be cleared — which signals the discussion happened — before the Request can reach **Greenlit**. It carries no other meaning; **Eval** and **Greenlit** are defined independently of it.

### Agent surface

**Alchemy** (the Requests Shell):
The spt shell adapter, named `alchemy` — a driven surface whose declared capability verbs are the Request vocabulary: `create`, `comment`, `view`, `list`, `state`, `type`, `tag`, `flag`, `link`, `dispatch`, `milestone`, `bags`, `board`, `watch`, `unwatch`, `sweep`, `release`, `bind-repo`, `daemon`, `help`, `process-guide`. This enumeration is the vocabulary — a verb named here and missing from the adapter manifest is a defect in the shell, and a capability the manifest ships and this line never names is a defect in the glossary; neither is discovered by an agent needing it. It holds GitHub credentials so no agent needs setup. An instance lives and dies with the endpoint that owns it. <!-- [doc->REQ-VOCAB-PARITY] -->

<!-- [doc->REQ-MANIFEST-HINTS] -->
The adapter also declares **keyword hints**: short manifest rows that spt-core surfaces to an agent, once per session each, when the agent's own words touch the Hub — `request`, `alchemy`, `backlog`, `bag`, `milestone`, `dispatch`, `watch`, `release`, the `#<` tag — naming the verb those words were reaching for. They exist because an agent's first instinct for an issue is raw `gh`, and a hint that lands in the same breath corrects that where a lookup chain would not. A reader holding an instance sees the text; one holding none sees only that a hint exists and how to read it. Every hint names verbs from this vocabulary and nothing else, checked by the same kind of test that holds the vocabulary to the manifest.

An instance is bound to exactly one Project's repo at configuration, and its alias names that repo, so **repo is never a verb argument** — it is implicit. Every verb operates on the bound repo by default, and a bound shell may never *write* to a foreign repo. The three intrinsically cross-repo verbs — **watch**, **view**, and the foreign half of **link** — may name a qualified foreign ref; everything else takes a bare ref. Ref grammar: `#42` is the bound repo, `spt-core#42` is a sibling repo under the bound repo's account, and `BigscreenVR/spt-core#42` is fully qualified to any account.

<!-- [doc->REQ-ACK-NAMES-REPO] -->
Because repo stays out of the argument vector, the **answer** is where an agent learns where its write landed: **every write verb echoes the qualified `owner/repo#N` even though it took a bare `#N`**. Read verbs already named their repo and are unchanged. This is not decoration — an ack reading `comment added to #23` is equally true of every instance on the node, so an agent driving the wrong one is told nothing at all, which is how three writes reached the wrong Project's `#23` on 2026-07-28 with every ack reading correct. A roundup with no ref of its own (**sweep**, **release**) names the repo on its header line instead, and a list of members under a qualified subject stays bare.

<!-- [doc->REQ-BIND-SELF-ALIAS] -->
The alias is the input-side half of the same defense, and **bind-repo makes it true by construction**: a successful bind renames its own instance to the bound repo, so "its alias names that repo" is not something an owner has to remember to do — and it is repaired automatically after a `spt shell teardown`, which frees the alias and would otherwise leave the defense absent exactly when a node has just churned its instances. The rename rides the owner-facing spt CLI under the owner id the bind frame itself carried. **A rename that fails never fails the bind** — the bind is what was asked for and has already happened — but the answer says so and names the command to finish it by hand: a roster that changed under the owner must be readable in the breath that changed it.

<!-- [doc->REQ-BIND-FOLLOWUP] -->
The same answer **names what is still missing** before the bound repo has a board. A repo the node's Hub Daemon does not carry gets the verb that registers it, plus the statement that offering it is the user's call and not the agent's; a node with no Hub Daemon configured at all gets *that* fact instead, because there is no bot to provision anything; a registry that cannot be read is reported as unreadable rather than read as "no board". Never silence: a bind that succeeds and a board that never appears is precisely the silent degradation this project exists to refuse.

<!-- [doc->REQ-VERB-PROCESS-GUIDE] -->
**Process guide**:
The Hub's usage doctrine, read through the `process-guide` verb: how Requests, Milestones, and
releases are *meant* to flow, and what to reach for at each point. Four sections — `lifecycle`
(the intended flow, no usage advice), `operator-agent` (directional tips and prompt starters),
`operator-discord` (the board's features and when to use them), `shell` (driving alchemy as an
agent) — behind an index that summarizes each; asking for a section the guide does not have is
refused by name. Deliberately role-free: agent role divisions belong to individual Projects, so
the guide speaks only the Hub's own language. Static doctrine, distinct from `help` (the verb
reference) and from any live view of a board.

Verbs carrying free text or a file — **create** and **comment** — ride `spt shell send` (its `[TEXT]` payload, with any file as `--file`). The short structured verbs ride `spt shell cmd`. `--json` gives read verbs machine-readable output for a tool-restricted consumer; action verbs answer later over `spt send`.

<!-- [doc->REQ-CREATE-FILE-BODY] -->
A `--file` blob **fills the next create's body by default** — that is what an agent sending a
file with a mint intuitively means (Operator ruling 2026-07-31) — its text appended after any
inline body, with no upload and no attachments block. `create --attach` opts into the
**attachment** flow instead; a **comment** always attaches. A blob that is not text cannot be a
body, so it attaches under either verb and the answer **names the reroute** — proceeding and
saying so, never a silent change of meaning.

<!-- [doc->REQ-CREATE-BODY-LIMIT] -->
**A Request holds the ask; the discussion goes on it, not inside it.** `create` refuses a body past a fixed budget instead of minting it, and the refusal names the length, the limit, and where the detail belongs: a follow-up `comment`. This is a *shape* rule, not a storage one — an agent that arrives with a page of analysis has written the Eval conversation into the artifact that exists to record what is wanted, and every reader of that board then pays for it. The budget sits under the card body budget, so a Request minted through the verb renders **whole** on its card rather than being clipped by a second rule downstream.

The budget counts **the requester's own prose**, in characters, and never the machine-written Requester footer or Attachments block — charging a caller for bytes they did not write would refuse a create nobody could shorten. It binds a body filled from a staged file exactly as it binds a typed one: they are the same act arriving by different routes, and a rule that let a file past it would only relocate the page of analysis.

Two exemptions, both deliberate. **`comment` has no limit** — it is the surface the refusal points at, and limiting it would leave the detail nowhere to go. **Seed capture is untouched**: a Seed is a person writing in Discord and is minted by the daemon, which does not run this verb. The rule is aimed at the agent path, which is where the page of analysis comes from.

**A file sent in the same command as its write belongs to that write** — the create it fills or
the comment it attaches to — and this holds because **spt-core orders the frames**: within one
`send <text> --file <path>` the file frame precedes the text frame (spt-core 0.54.0, which is
alchemy's `min_spt_core_version` floor for exactly this reason). The blob is therefore already
staged when the write runs, and lands in the SAME write rather than being edited in afterwards.

Alchemy carried a mitigation for the previous ordering from v0.16.0 to v0.20.0: a short
per-requester window that retro-applied a late file frame to the write it had just missed. It is
**gone** as of v0.21.0, deleted in the same commit that raised the floor. It could not be kept —
under the corrected ordering the identical window delivers the *next* invocation's file to the
*previous* Request. That is the general rule this project now holds as an invariant, not as
craft advice: a mitigation whose correctness depends on the defect it mitigates must not outlive
the defect, and it records that retirement coupling when it is written, not when it breaks. See
`docs/KNOWN-HAZARDS.md` **2.10**.

<!-- [doc->REQ-VERB-ARITY-REFUSE] -->
Line 1 of a payload is the verb, its flags, and its positional args — one vector, all of it. **A verb that would not consume a positional refuses it by name** (`comment takes 1 positional arg (got 'this') — the body goes on line 2 and below`) instead of acting on the part it understood and dropping the rest: free text put on line 1 by mistake is content, and content this system loses in silence is the defect class it exists to prevent. The refusal is per-verb, because arity is per-verb: **tag**, **flag**, **milestone** and **dispatch**'s trailing note consume every token after their ref by design, and a blanket refusal of extras would break correct input.

**Flags** are set and cleared by the **flag** verb (`flag #42 +needs-operator -needs-routing`), which takes the `tag` verb's `+add`/`-remove`/bare-add token grammar but accepts only the flag vocabulary and refuses an unknown flag by name rather than inventing a label. `tag` is not a second path to them: it refuses the reserved `flag:` namespace, and that refusal stands. **Needs Operator**'s Backlog/Eval rule binds one direction only — *setting* it outside those States is refused, because the flag does not exist there; *clearing* it is legal in every State, because a flag outliving its own question is the violation and removing it is the repair. The Bot post's Needs Operator toggle is the same act by the same rule, reached from Discord. Clearing **Needs Operator** asserts the discussion happened, so the verb records an audit comment naming the requester: alchemy has no Operator identity to check an agent against, and a claim that leaves a trace is what it can offer instead. Setting a flag is audited nowhere — asking a question is nobody's privilege. <!-- [doc->REQ-VERB-FLAG] -->

**Hub Daemon**:
The single long-running process that bridges Discord, GitHub, and spt: it runs the Discord bot, polls GitHub for changes, holds the Watch registry, and delivers notifications. It ships inside the Alchemy adapter package and is a **ResidentService**: a binary declared in the manifest's `[service]` section that **spt's own daemon supervises**, spawned job-neutrally and core-owned from birth. **Its lifetime is independent of any shell instance** — mandated, not aspirational, and *independent* now means what it says by construction: the daemon is never a shell's child and never inside a launching terminal's Job Object, so neither a process-tree kill nor a job close has anything to reach. **Alchemy holds no start path of its own** — it never spawns, re-parents, restarts or stops the daemon, and the number of start mechanisms inside alchemy is zero. `start = "boot"` is desired-state-running, so the supervisor reconciles at daemon boot, at adapter registration against a live daemon, at update-hold release, and at first shell bind as a defensive ensure. <!-- [doc->REQ-HAZARD-DAEMON-ORPHAN-DEATH] --> <!-- [doc->REQ-DAEMON-SUPERVISED] -->

Two mechanisms were retired to get here, and both are kept in `docs/adr/` rather than deleted because the reasoning in them is why this shape won. The **relay hop** (ADR-0013) re-parented the daemon so a tree kill found nothing to walk — correct, and narrower than the job membership that survived it. **Auto-ensure at bind** (ADR-0012) made "whichever shell binds next" the start authority, which is the accident this replaces. Requiring spt-core **0.44.0** is therefore not a preference: an older node installs an adapter whose daemon nothing supervises.

<!-- [doc->REQ-DAEMON-CLI-USAGE] -->
The `daemon` subcommands are the one part of alchemy an operator runs **outside a shell**, and they are reached for when the Hub is already in trouble — so a missing or unknown subcommand answers with the vocabulary and a usage exit code, never a panic. The free-form option tail is read with a checked slice: an argument list that stops early is empty options, not an index out of range.

The set of Projects the Hub carries is the daemon's **registry** — the list a Project must appear in before it has a Discord presence, distinct from the per-instance repo binding a shell gets from `bind-repo`. A Project joining the Hub is a routine event, so the registry is edited by verb (`daemon project list|add|remove`) rather than by hand-editing the daemon's config, and the running daemon adopts the change on its next cycle: it provisions a newly registered Project in place, without a restart that would drop the Discord gateway for every other Project on the node. Removing a Project un-registers it — the daemon stops polling and stops delivering its Watches — and never deletes the Discord channel or its history. <!-- [doc->REQ-DAEMON-PROJECT-REGISTRY] -->

<!-- [doc->REQ-DAEMON-PROJECT-BOUND-REPO] -->
The registry verb obeys the same rule as every other: **`add` takes no repo**. It registers the invoking instance's *bound* repo — `add` is a claim a shell makes about itself — and a supplied repo is refused **by name**, never ignored, because an argument dropped in silence is how an owner comes to believe they registered a Project they did not. **`remove` keeps an optional explicit ref**, and the asymmetry is deliberate: un-registering is cleanup that outlives its subject. An archived, renamed or deleted repo can no longer be *bound*, so a bound-repo-only `remove` would strand a dead entry the daemon keeps polling every cycle with no verb able to reach it. Neither form is available from the `alchemy daemon` CLI's `add` — outside a shell there is no instance and therefore no binding — and it says so rather than asking for the argument it no longer takes.

**Dispatch**:
Sending a Request to an agent endpoint to be worked. Moves the Request to **WIP**. Any endpoint may dispatch to any other.

**Watch**:
A registered interest in a Request's State changes, owned by an spt endpoint id rather than a session — an endpoint may also register one on another endpoint's behalf, and the owner is who hears it. By default a Watch reports every State change; it may instead be narrowed to named States, and a narrowed Watch fires at registration when a named State is already reached — or already passed or unreachable in the lifecycle (a Shelved Request never turns Done) — reporting the actual current State, so awaiting an already-cleared or never-clearing blocker cannot wait forever. One-shot by default: it expires after its first delivered notification, non-matching changes passing silently; a persistent Watch keeps reporting until cancelled. Registered through Alchemy, held by the Hub Daemon, and delivered as a durable spt message — never as shell sensory, which is live-only and would be dropped whenever the agent is not listening. Watches fire from GitHub truth alone, so a Discord outage never pauses them. Watches also stand in for cross-Project blocking, which GitHub cannot express between repositories.

<!-- [doc->REQ-LIAISON] -->
**Liaison**:
A dedicated endpoint that accepts Requests submitted in conversation and mints them. One per Project at most, and optional. A Liaison may register Watches on a submitter's behalf so they hear when their Request progresses.

**Requester**:
The party a Request came from, recorded in the issue body. Written as the bare endpoint id when node-local, and as `<subnet>:<endpoint>@<node>` otherwise.

<!-- [doc->REQ-TAG-SCANNER] -->
**Request tag** (agent output syntax):
A `#< … #>` call an agent writes in its own turn to drive Alchemy without a tool — the read/write counterpart to spt's `@< … @>` peer-message shortform, and the only way a tool-restricted endpoint (a Liaison) reaches the shell. The shell reads its owner's session digest and acts on the tags it finds. A tag inside backticks or a code block is a quotation and never fires — the test is where the **opener** sits, so a tag whose opener is bare fires normally even when its body quotes code. The quotation rule decides *which* tags fire; it never edits the ones that do, and a firing tag's body is delivered **verbatim**, code spans and fences intact. A body is authored text: silently dropping the part of it an author marked as code corrupts the Request rather than formatting it. The quotation rule is not alchemy's own invention: `#<` obeys the **same suppression grammar spt-core publishes for its `@<` and `;;` shortforms** — a tag inside backticks or a code fence is a quotation there too — so an agent that has learned one has learned all three. Write verbs that destroy or decline require confirmation before they act; create and read do not.

Replay safety rides the digest's `--after <seq>` cursor, anchored on the **turn's `input_seq`** — the key that never moves once a turn closes. The scanner walks turns rather than entries, fires the tags in sealed turns past its cursor, and advances the cursor over sealed `input_seq` only. An entry's own `seq` is deliberately not the anchor: spt-core may advance entry seqs *forward* within a turn when a late record grows a trailing tool-sprint, which re-delivers an already-fired entry to an `--after` cursor and would double-fire the tag (`REQ-HAZARD-TAG-SEQ-DRIFT`). The scanner honours `after_predates_window` as a signal that it missed rows. No separate dedupe store is kept — the cursor *is* the dedupe. Only sealed turns fire: a turn still open carries no `input_seq`, so the scanner neither fires it nor advances past it, and picks it up once it seals (a turn seals when the owner goes idle, or when their next input arrives). At first arm (or a fresh owner) the scanner baselines its cursor at the digest's current tip and never fires on history; the arming turn itself is partial at arm time, so it still fires when it seals.

<!-- [doc->REQ-TAG-CURSOR-JUMP-ANNOUNCE] -->
A turn the scanner **advances past without being able to fire** is announced to the owner, naming what was skipped, rather than lost in silence. The distinction is what the turn carries: a **preamble** turn (`input: null` — where spt's own Boundary and Context entries live) is seq-less by design and tagless by construction, so it is jumped silently and correctly, and holding on it would wedge the cursor at every session boot. An **input-bearing** turn that is sealed and still carries no `input_seq` could have held a tag, and advancing past that one quietly is exactly the silent loss this project refuses to ship. It **announces and advances** rather than holding: telling a turn that will seal from one that never will is a negative over the future, and a scanner that waits on it stops firing everything else. The announce is triggered by the advance itself — a seq-less turn at the tip is not a loss yet, and one behind an already-consumed turn was announced when the cursor passed it — because a turn with no seq cannot be compared against the cursor and would otherwise re-announce on every scan. The notice rides the owner's own poll (durable, never dropped, but never waking an idle owner): it is a diagnostic, and the scan gate means the owner was recently active anyway. This is a **tripwire against a digest-producer regression**, not a live leak — under the published turn contract the shape does not occur, which is exactly why it must announce if it ever does.

<!-- [doc->REQ-TAG-SCAN-GATE] -->
The scan runs on a tick, **gated on the owner's activity** (ADR-0011): tags only ever appear in the owner's own turns, so the shell stops pulling the digest while its owner is idle. It reads the owner's pushed busy/idle state off the ephemeral drain, scans once on the busy→idle edge — before quiescing, because going idle is the event that seals the trailing turn and makes its tags visible — and resumes the tick when the owner goes busy again. Edges are derived from the frame's `since` stamp rather than the state word, since the drain is latest-wins and a busy→idle round trip between two reads collapses into one idle frame. A lost frame costs latency, never silence: a quiesced scanner still scans on a heartbeat floor, any inbound owner frame resumes it at once, and a shell that has never seen an activity frame never quiesces at all. Confirm expiry is wall-clock and deliberately outside the gate — it needs no digest, so an owner who never returns to idle still hears that their parked call lapsed. The gate is observable on demand: a `scan-log.on` file in an instance's perch dir makes it append one line per scan decision (skips included) to `scan.log` there, which is how its behavior is checked in the field rather than inferred from process timing.

<!-- [doc->REQ-TAG-SCAN-IO-HINT] -->
On top of the gate, and changing none of it, the owner's durable **`io` frames are an additive scan hint** (ADR-0014, the hint ADR-0010 reserved): a `USER_INPUT` frame or a non-`mid` `AGENT_OUTPUT` frame asks for a scan **now** rather than at the next tick, a `mid` span only notes inbound activity, and the tick, the activity gate, the idle-edge scan and the heartbeat floor stay exactly as ADR-0011 left them. The hint is single-flight — a trigger arriving while one is pending is absorbed, never queued or debounced — and it buys latency, never correctness: the frame's body is never read for tags and its `seq` is an entry seq the scanner must never anchor on, so the digest cursor stays the sole replay authority; the design never depends on a close frame existing, since an adapter that streams a whole turn as spans reports `idle` without a payload; and a scan fired on `USER_INPUT` may land in core's one-blink idle-seal and see the trailing turn as `partial`, in which case it fires nothing and the tick or the next hint fires the reclosed turn once. `scan.log` names the reason (`scan:io-input`, `scan:io-close`).

Every instance an owner runs scans the same digest, so a bare tag fires on each of them. A leading `@<instance-id>` (e.g. `#< @alchemy-1 create … #>`) addresses exactly one: the named instance fires, every other skips silently. An owner running more than one instance should address every write; confirm challenges arrive pre-addressed.

<!-- [doc->REQ-TAG-CONFIRM] -->
**Confirm round-trip** (destructive tags):
The destroy-or-decline writes — `state … cut|shelved`, `milestone remove`, `unwatch` — never fire straight from a tag. The scanner parks the call, sends the owner a short confirm token, and acts only on a later `#< confirm <token> #>`. A parked call expires unconfirmed after 24 hours, and the owner is told when that happens. `create`, reads, and every other write fire immediately.

<!-- [doc->REQ-BOARD-FILES] -->
**Board Files**:
One Markdown file per State per Project, plus one for the Project's Milestones, that the **Hub Daemon writes to the node each cycle** — the board's State threads, mirrored as text an agent can `grep` and read without a verb round-trip per question. They are a **derived view, never a store**: GitHub stays the system of record, a Board File is rewritten from GitHub truth every cycle and only when its content changed, and it carries its **as-of time** in its header for the same reason the `bags` answer does — it is cycle-fresh, not live, and a reader who cannot tell the difference will trust a stale list. They exist only for the Projects the daemon serves on that node: a shell bound to a repo with no Hub Project has no Board Files, and says so by name rather than pointing at an empty directory. A file the daemon has not written yet is likewise said so, never rendered empty. The **`board` verb** is how an agent learns they exist and where: its answer leads with `Board Files for <owner/repo>` and the as-of time, then one line per State in lifecycle order (terminal States after) and one for the Milestones file, each carrying its entry count and **absolute path** — the path is the deliverable, because the point is that the agent's next move is `grep`, not another verb. A file lists **titles and facets, never bodies** — one line per Request naming its ref, Type, Flags, Tags, Milestone, last-updated day and GitHub link — because a body belongs to `view` and a file that carried every body would bury the line someone is searching for; the Milestones file names each Milestone's State and members the same way. Their v2 — the same files served over an adapter-hosted webservice so an agent on another node can read them — is a separate Request (#83) and not part of this term.
_Avoid_: living files (the Request's working name), index (taken by the Bag index), ledger (a shipper's hand-written release comment elsewhere)

### Discord surface

**Alchemist**:
A member granted a Project channel's write-control capability: their Type reactions mint Seeds, and the Bot's state-changing controls (Needs Operator toggle, Change State, Edit, Approve) act for them. Held as a per-Project Discord role managed by a bot slash command; defaults to the bot owner. Read access is ungated. The Operator is an Alchemist everywhere; **Needs Operator** still means the Operator personally, never any Alchemist.
_Avoid_: Conductor, channel operator, moderator

**Seed**:
A message the Operator writes in a Project's channel beginning with `#`, before it has been promoted. A Seed is not a Request: it has no GitHub issue and no State, and exists only in Discord — deliberately, so there is no second sync surface to maintain. An Alchemist clicking a Type reaction on a Seed mints it into a Request; other members' reactions are inert.

<!-- [doc->REQ-SEED-ATTACHMENT-CARRY] -->
A Seed's **attachments are carried across the mint**, because the mint deletes the Seed and Discord's own file URLs expire — so a file left behind is not merely unlinked, it is destroyed. Each blob is re-uploaded to the same rolling `alchemy-attachments` draft release a `--file` payload uses (ADR-0006) and linked from the issue body, in the write that creates the issue and before the Seed is deleted: a reaction-minted Request carries its files exactly as a `create --file` one does, and no repair depends on the Seed surviving. A blob that cannot be carried is **named in the body and logged**, and the mint proceeds regardless — refusing would strand the Seed with no Operator repair path, and silence is the failure this rule exists to prevent.

<!-- [doc->REQ-ATTACH-IMAGE-EMBED] -->
An attachment of a known image type is linked with markdown **image** syntax, so GitHub renders the picture in a frame rather than a filename. It is the one attachments composer that decides this, so a `--file` upload and a Seed carried across a mint agree by construction; anything that is not an image keeps the plain link.

<!-- [doc->REQ-SEED-RECOVERY] -->
A Seed posted **while the bot was offline is recovered**, not lost. Capture is gateway-driven, so an outage costs a Seed its Type reactions and its Seeds-thread entry, and an unreacted Seed cannot be promoted at all — the loss is silent and total. So capture converges like every other surface here (ADR-0008): each cycle re-derives the channel's un-mirrored Seeds from Discord — the human `#` messages with no Seeds-thread entry, keyed the same way the edit and delete mirrors key theirs — and runs the capture path over exactly those. On a channel missing nothing, that set is empty and the pass makes no writes.

<!-- [doc->REQ-MINT-RECEIPT] -->
A mint answers the Alchemist who reacted with a **receipt in the channel**: the emoji they pressed, the new Request's ref as a link to its fresh Backlog card, and the title. Without it the mint is a disappearance — the Seed and its mirror entry are both deleted, leaving the person who just promoted it with nothing to click. The receipt removes itself after a bounded delay, because a Project's channel holds only un-promoted Seeds. It is not a Discord ephemeral: ephemerals are interaction-scoped and a reaction carries no interaction token, so the transience is a self-delete and the receipt is visible to the channel while it lasts.

<!-- [doc->REQ-RECEIPT-SWEEP] -->
That removal is **reconcile-owned, not a timer**. The receipt lasts twelve hours, and an in-process sleep of that length does not survive a daemon restart — a restart inside the window is ordinary, and each one would strand a receipt in the channel permanently, which is a standing violation of the very property the expiry exists to keep. So each cycle re-derives which of the channel's own bot messages are receipts past their time, reading each message's age out of its own id. Nothing is remembered between cycles, and a restart costs at most one cycle of lateness.

<!-- [doc->REQ-EXPIRING-POST-FOOTER] -->
**A message that expires says so on its own face.** Every bot post governed by a TTL — the mint receipt and the bag-drop notice — carries a subtext footer naming the instant it goes, so a reader can tell at a glance whether what they are looking at is the record or a courtesy that is about to vanish. The instant renders as a **Discord timestamp**, not a literal clock time: a literal would be the daemon host's clock printed into a message read by people in other timezones, right for the machine and wrong for every reader, where the timestamp renders as short local time in each reader's own zone.

The footer is **display only**. The sweep keeps deriving each message's age from its own snowflake and never reads the footer back, so the two cannot disagree about when a message dies — and the footer is written from the same TTL constant the sweep enforces. What the footer *does* change is the text the sweep's recognizer must still match, which is why both recognizers are tested against the really composed message rather than a literal: a recognizer that drifts off the composed shape fails nothing loudly, it simply leaves the message in the channel for ever.

<!-- [doc->REQ-CARD-COLORS] -->
**Bag**:
The set of Requests an Alchemist has marked for inclusion in their next **Milestone** assembly, by
reacting 🎒 to a Bot post in the Backlog or Eval threads — a bag of Requests, carried to Finalize
and emptied there. A **bagged** Request is to Milestone membership what a **Seed** is to a
Request: it lives only in Discord, has no GitHub representation, and is consumed when the assembly
is finalized. A Bag is **per-Alchemist** — two Alchemists assemble two Milestones without seeing
into each other's bags — and other members' reactions are inert, exactly as on a Seed. The
reactions themselves are the store; nothing is remembered between reconcile cycles.
_Avoid_: **Target** (the retired name, ruled out 2026-07-29: in ordinary prose it collides with a
build target or a release target, and that collision misread a live instruction), Selected, slated,
staged (the first is a UI state, the others collide with **Milestone** and with the staging post)

<!-- [doc->REQ-TARGET-REACTION] -->
<!-- [doc->REQ-SEED-BAG-REACTION-AT-MINT] -->
The bot **seeds the Bag reaction** on every Bot post in the Backlog and Eval threads, so bagging a
Request is one click and never a search for the right emoji. The seed lands **in the act that
posts the card** — a mint or a relocation seeds its fresh card itself, and the reconcile cycle is
the converging repair rather than the first delivery, because a new Backlog card that waits a
cycle for its 🎒 reads as un-baggable exactly while its mint receipt is pointing someone at it. Only an **Alchemist's** reaction
counts; anyone else's sits there inert and is never removed, exactly as a non-Alchemist's Type
reaction on a Seed is. Which cards a cycle has to ask about is bounded by the resting shape: a
message's own fetch already carries its reaction **counts**, so the per-card roster call fires
only where the count exceeds the bot's own seed. A settled board asks nothing and writes nothing.

<!-- [doc->REQ-HAZARD-TARGET-RELOCATION-CARRY] -->
A Request **relocating between State threads stays bagged**, because relocation is a new
message plus a delete (ADR-0008 ruling 10) and reactions die with the message — a Request moving
mid-assembly would otherwise drop out of every Bag holding it, in silence. The carry
cannot be the reactions themselves: **a bot may only create a reaction as itself**. Measured in
the client this daemon links — serenity 0.12.5's `create_reaction` takes no user id and routes to
`PUT …/reactions/{emoji}/@me` — there is no add-as-user endpoint to call. Removal as another user
*is* available (`delete_reaction` with a user id), so the store can be cleared on a member's
behalf but never restored. So the carry rides the **destination card**, as a machine-readable
`🎒 carried:` line naming the Alchemists as mentions, and a bagged Request is read as the **union** of a
card's live reactors and that line. The property the reaction-store rule protects is untouched —
the daemon still remembers nothing between cycles, and Discord still holds the whole state; only
*which* Discord field holds it changes after a move. A relocation **out** of Backlog and Eval
drops it from every Bag instead of carrying it — Greenlit is Milestone-owned and a bagged Request there means
nothing — and says so in an Updates note, because a silent drop is the loss this rule exists to
prevent.

Because a carried Bag entry has no reaction of its own to take back, the gesture is a **toggle**: a
live reaction *and* a carried mention for the same Alchemist means **un-bag**, and the bot
clears both. The four combinations of (reaction, carried) are the whole vocabulary, all four
derived from Discord alone, and three of them are "do nothing" — which is what keeps a settled
board at zero mutations.

<!-- [doc->REQ-DISCORD-REQUEST-COMMAND] -->
**`/request <n>`**:
One Request at a glance, answered as an ephemeral: its title as a header, its current State, and links to **both** places it lives — its card on the board and its issue on GitHub. It exists because the board is organized by State, not by number: a person holding a ref has no way to reach that Request without knowing which thread it currently sits in, which is exactly the thing they were asking.

Title, State and issue link are read **from GitHub**, not off the card. GitHub is the system of record and a card can be a cycle behind, so answering a direct question with the board's copy would make this command a supported way to read stale State. The board is consulted for the one fact it alone holds — **where the card is** — and a Request with no card yet (minted seconds ago, before the next cycle) is a normal answer that says so and still carries the GitHub link, never an error.

It is **read-only, so there is no capability gate**: everything it reports is already visible to anyone who can see the board. It does require a Project's channel or one of its threads, because that is what says which Project the number belongs to. And it is **discoverable from `/alchemy-help`**, held there by the same drift gate every other board feature is: a command nobody can find is a command nobody has.

<!-- [doc->REQ-MILESTONE-STAGING] -->
**Staging post**:
The pinned post in the Backlog thread where a Milestone is assembled. `/new-milestone` and
`/pick-milestone` each open one — the intent is declared **before** staging rather than at
Finalize, which is what lets `/pick-milestone` refuse immediately, with an ephemeral, when no
Milestone sits in Backlog or Eval to pick. The post lists **the invoker's own Bag**, one
Discord link and one GitHub link per line, and carries **Finalize** and **Cancel**. There is one
per Alchemist: a re-invoke **edits the existing post** rather than posting a second, and the
reconcile cycle refreshes it, so a Request bagged or dropped after it opened is on it. Cancel
removes the post and leaves the Bag alone — abandoning an assembly is not un-bagging.

<!-- [doc->REQ-MILESTONE-FINALIZE] -->
**Finalize**:
The act that spends an assembly. It assigns the invoker's Bag **read at submit** — from
Discord, in that moment — and never the list the post shows: the post is a view refreshed on a
cycle, and between its last refresh and the click a Request may have been bagged or taken back.
`/new-milestone`'s Finalize opens a **title+body modal** and mints the Milestone; `/pick-milestone`'s
answers with an **ephemeral select** over Backlog and Eval Milestones only, refusing by name when
that list has gone empty since the invoke. Free text belongs in a modal and a choose-from-a-
vocabulary does not — serenity 0.12.5 cannot put a select in a modal on either leg.

The Requests attach as **native sub-issues** first, and the Bag is consumed second: a crash
between the two leaves a Request assigned *and* still bagged, which is visible and re-assigns onto the
same parent next round, where the reverse order can empty a Bag with nothing to show for it.
Consumption clears **both shapes a bagged Request has** — the carried mention comes off the card, then the
reaction comes off with it — because the Bag is the union of the two and clearing one leaves the
other standing. A consumption that fails is **announced**, in the invoker's ephemeral and in the
Updates note both: a Request left in the Bag silently re-assigns next round, and an ephemeral is gone in
a minute while the consequence is not. **One Updates note per Finalize**, naming the Milestone and
every ref it took. A Finalize with an empty Bag refuses and writes nothing; one that assigned
something removes the staging post, because an assembly that is finished shows an empty list
otherwise and an empty list reads like a failure.

<!-- [doc->REQ-TARGETS-VERB] -->
The **`bags` verb** is how an agent reads the same board an Alchemist stages from: the Requests
currently bagged in Discord, **grouped by Alchemist and by State**, with either grouping omitted
when it is uniform — a single Alchemist's Bag does not need their name printed on every line. Its
answer leads with the noun phrase — `Bagged Requests in <owner/repo>` — and never the bare word,
which is the ambiguity the rename exists to close. It
answers from an **index the daemon writes to the config dir each cycle**, because the shell has no
IPC to the daemon and a verb answering from GitHub truth would be answering about a different
store: GitHub does not hold Bags at all. So the answer **states its as-of time** — it is
cycle-fresh, not live, and a reader who cannot tell the difference will trust a stale list. An
index the daemon has not written yet is said so by name, never rendered as an empty board.

**Bot post**:
The bot-authored message that represents a Request in Discord. It replaces the Seed and carries action buttons — including the Needs Operator toggle — plus a GitHub footer (created, last updated, comment count). Its embed color is its current State label's registry color, so the board reads at a glance. It relocates between State threads as the Request's State changes.

<!-- [doc->REQ-CARD-MILESTONE-LINE] -->
A Request that belongs to a Milestone shows **which one** on its card: a masked ref link with the
Milestone's title as a plain tail, in the card's head block where the daemon writes. A Request in
no Milestone renders **no line at all** — a line saying "none" is noise on every card that has not
been assembled yet, which is most of the board. The membership is read from the **sub-issue data
the reconcile already fetches** to render Milestone cards, not from a call of its own; that fetch
is all-or-nothing, so a half-read map cannot quietly strip the line off a card that has one.

<!-- [doc->REQ-CARD-IMAGE] -->
A Request whose body carries an image attachment shows that **picture on its card** — the first one, since an embed holds one image. Both link shapes are read, so a Request minted before the image syntax existed lights up on its next reconcile without being edited.

<!-- [doc->REQ-CARD-IMAGE-REACHABLE] -->
The picture is **uploaded with the card**, not linked. The attachments release is a draft release on a private repo, so its asset URLs resolve only for an authenticated caller — and the party that has to fetch a linked image is Discord's own proxy, which is not one. Measured, not assumed: testbed [#37](https://github.com/BigscreenVR/alchemy-testbed/issues/37) carried a correctly-set embed image and rendered no picture at all, and that asset URL answers 404 unauthenticated. So the bytes ride the card message and the embed names them by filename. Two consequences follow. Discord rewrites the stored URL to its own CDN, so **drift detection compares the filename, never the URL** — comparing URLs would diff every cycle and re-upload the picture every cycle, which is worse than the defect. And because Discord renders no markdown images in text, the mirrored body **drops the image `!`** that GitHub needs; the GitHub body keeps it, where it does work.

<!-- [doc->REQ-CARD-STATE-CONTROL] -->
A Request's card carries a **Change State** control: a button that answers with an ephemeral **select** over every State except **Greenlit**. Greenlit is Milestone-owned — a Request enters it only by riding its Milestone's transition — so it is not offered, and it is **refused by name** when it arrives anyway: a select's values are supplied by the client, so the offered vocabulary is what a member sees, never what the interaction is limited to. **Cut** and **Shelved** take a **second confirming click** before anything is written, because the tag path already parks a destroy-or-decline call behind a confirm round-trip, and a human surface that is a looser gate than the agent surface inverts the safety of the whole board. Every other State writes on the first click. The picker also omits **the State the Request is already in** — an option that no-ops reads as a broken control — taken from the card's own badge line, so it costs no fetch, and refused by name on arrival for the same reason Greenlit is. And the panel is **spent at every exit**: its components are removed and its text replaced in place, then it is deleted once the change lands, because a select left live keeps writing to a Request that has already relocated and a panel that outlives its own answer is a second answer. The write is the **same transition the `state` verb makes** — the same labels, the same open/closed state, the same close reason (ADR-0004) — decided in one place and sent as one patch, because a second authority that disagreed about the close reason would be invisible on the board: the label would look right and the issue would be wrong. The control is a **Request's**; a Milestone's State cascades to its sub-issues, and a cascade behind a single click with no preview is a different Request. The picker is a select in an ephemeral rather than a dropdown on the Edit form because serenity 0.12.5 cannot put a select in a modal on either leg — measured, not assumed. What the control says back is not its own rule: it inherits the Bot post's answer policy below.

A card's **control rail is part of the card**, compared like its title and its footer are. The reconcile edits on a difference, and adding a control changes no embed field — so a rail that lived only in the render path would reach cards posted *after* the change and no others, and a Request nothing else ever happens to would keep an obsolete rail forever. The rail is read back off the post's own buttons, so a card whose controls are current still diffs to nothing.

<!-- [doc->REQ-EDIT-PROGRESS-MARK] -->
<!-- [doc->REQ-EDIT-MARK-SPANS-REFRESH] -->
An **Edit-modal submit marks its Bot post** with a progress reaction, and clears it when **the post has been re-rendered** — not when the GitHub write returns. At that earlier moment the card still shows the old text, so a mark cleared there reports the wrong thing finishing. The clear is unconditional: a failed write that left the marker on would have the board claiming work is still running, which is worse than never marking it.

<!-- [doc->REQ-EDIT-QUIET-ON-SUCCESS] -->
A write that **landed says nothing**: the mark ran for its whole duration and the refreshed card is the acknowledgement, so an ephemeral notice on top is a second and worse answer to the same question. A **failure or a refusal still speaks** — Discord requires an interaction response, and silence on a failure would be exactly the silent loss this project refuses to ship.

<!-- [doc->REQ-BOARD-CHRONOLOGICAL] -->
The board reads **oldest-first** — in every State thread and in the Releases thread — whether it was built one mint at a time or all at once. GitHub's listing endpoints answer newest-first and Discord renders a thread oldest-at-top, so posting in listing order lays a Project's whole history out reversed on the cycle that adopts it. The order is imposed where the posts are written, not where the data is fetched, so it holds for the targeted after-write refresh too.

<!-- [doc->REQ-HAZARD-BOARD-SCAN-WINDOW] -->
The scan that reads the board reads each thread **whole**. Discord answers a history read one page at a time and caps a page at a hundred messages, and the daemon keeps nothing between cycles — Discord *is* the store — so a page is not a smaller answer than the thread, it is a different one. A card deeper than the newest page reads as "this Request has never been posted", and the cycle that believes it posts a second card and announces the transition again; the re-post then pushes another card out of the window, so a thread that crosses the cap re-posts for ever rather than once. Terminal threads are where it lands first, because nothing prunes them: **Done** grows by one card per Request a Project ever ships. The same completeness binds every count the daemon publishes — a thread's entry count in the Contents post is a count of the thread, not of a page of it.

<!-- [doc->REQ-SCAN-KEEP-OLDEST] -->
When a Request holds more than one card in the thread it belongs in, the **oldest survives** and the rest are cleared. Snowflakes order by time, so the oldest card is the one standing where the Request's own history put it, and it is the permalink anything already pointing at the card is pointing at. Keeping the newest instead would satisfy "one card per Request" while re-sorting a thread that reads oldest-first into the order its duplicates were created — repair by rewrite, which is what a scan that just stopped duplicating owes least.

**State thread**:
A thread in a Project's channel holding the Bot posts of every Request currently in one State.

<!-- [doc->REQ-THREAD-NAME-PROJECT-INDEX] -->
**Thread names carry their Project's index.** Every daemon-managed thread — the eight State threads and the five aux threads alike — displays its base name followed by the Project's position in the configured registry: `Backlog 0`, `Backlog 1`. Without it a Discord search for "Backlog" matches one thread per Project and answers a question nobody asked; the number is what makes the search land. The index is read off the registry's order rather than stored, because the order already is that fact and a second copy of it would be a second thing to keep true.

A managed thread is **found by its base name, with any trailing index ignored**, and then renamed to the name it should be wearing. Matching on the full display name would mean that an index which *moves* — a Project leaves the registry and the ones after it shift down — reads as "no such thread", and the next cycle would build an empty `Backlog 0` beside the `Backlog 1` holding the Project's entire history. That is the channel-adoption hazard one level down, and the same base-name rule that closes it also adopts every thread named before the numbering existed, at no extra cost. Nothing is ever created to satisfy a rename.

The number is a **display** fact only. The base name stays the thread's identity everywhere the daemon reasons about it — State mapping, hide times, relocation notes — and only the Discord name and the Contents post's link text carry the index, the Contents post because a pinned index that named threads differently from the list it indexes would be worse than no index.

**Contents post**:
The pinned bot post in a Project's channel, opening with a header naming the Project as a link to its repo, then linking the Seeds, Active Milestones, Inactive Milestones, Updates, and Releases threads as one group and, below it, every State thread in lifecycle order — each link carrying its thread's live entry count (Updates excepted) — followed by the Project's living Alchemists roster. So members can jump straight to the thread they want, reach the repo, and see who holds write control.
_Avoid_: TOC, index post

<!-- [doc->REQ-TOC-PROJECT-HEADER] -->
The post is recognized by a predicate that accepts its header shape **and every shape it has been written in before**. A marker that stops matching does not merely fail to find the post — the next cycle posts a second one, and the channel carries two pinned Contents posts that disagree.

**Seeds thread**:
A thread holding one bot-maintained entry per un-promoted Seed: a link to the Seed and its first line, kept current when the author edits the Seed. Its purpose is to keep Seeds visible once the channel scrolls. A Seed is edited only by its author, directly on their own message — `#edit` and the Edit modal are Request surfaces, not Seed surfaces.

<!-- [doc->REQ-MILESTONE-THREADS] -->
**Active Milestones thread**:
A thread holding the Bot post of every Milestone currently in **Backlog** through **Acceptance**. Milestones never appear in State threads — the board's State threads hold Requests only. A Milestone's card lists its sub-issues, one linked line each.

**Inactive Milestones thread**:
A thread holding the Bot post of every Milestone in **Done**, **Cut**, or **Shelved**.

**Updates thread**:
A thread receiving the Project's Request status-change notifications, kept out of the channel so Seeds are never buried by them.

<!-- [doc->REQ-LINK-TEXT-MACHINE] -->
Every masked link the daemon writes — Seeds-thread entries, Updates notes, Milestone sub-issue lines — puts **only machine-generated text in the text half**: the computed `#N` ref, or a fixed word where there is no ref yet. Operator-authored text never enters it. A Request title is the Operator's own words and may contain markdown link syntax, and **Discord does not honor backslash escapes inside a masked link's text half** — an escaped `\]` in there is a backslash and a bracket, which malforms the construct and drops the whole line, URL included, to literal text. So the title rides *outside* the closed construct as plain text: `- [#42](url) | title`.

<!-- [doc->REQ-LINK-TAIL-NO-TERMINATOR] -->
Machine-only text in the text half is necessary and **not sufficient**, and the daemon owns the plain text it writes beside a link too: **no tail may contain a `](` sequence.** Discord's link rule does not stop at the construct's own close — its text-half scan admits a `]` while another `]` waits later in the message, so it runs past the intended `](url)`, past the separator, and terminates on the first later `](`, taking what follows as the url. That url resolves to nothing, and the whole line — the ref, the real url, the title — falls back to literal. The scan crosses newlines, so one bad title flattens every link sharing its message (a Milestone card's sub-issue block is one message). The tail therefore goes out with that one adjacency broken by a space; every other character is the Operator's own.

Measured live, not assumed: testbed [#30](https://github.com/BigscreenVR/alchemy-testbed/issues/30) (a code span) and [#32](https://github.com/BigscreenVR/alchemy-testbed/issues/32) (brackets and `](`) both rendered literal with the escapes visible, while [#33](https://github.com/BigscreenVR/alchemy-testbed/issues/33) (no symbols at all) rendered as one clickable link — so the construct works and escaping it is what breaks it. v0.4.2 shipped the escaping version on the opposite assumption and made every one of these lines render literal. W26 then moved the title out of the text half and [#34](https://github.com/BigscreenVR/alchemy-testbed/issues/34) — a title carrying `](`, posted through the 0.5.0 daemon — **still rendered all-literal**, with no backslashes anywhere: proof that the escapes were gone and that the title's own `](` was doing it from outside the brackets. This paragraph previously claimed markdown in a title "renders as the Operator wrote it, not a broken link" out there; #34 took that claim away. #33 rendering clickable is the same mechanism seen from the other side — it had no later `](` to terminate on.

One boundary the rule does not cross: a Request body mirrored into a card renders as the Operator's own markdown, unsanitized. The only daemon-composed link in a body is the attachments block, which is appended *after* the Operator's text, so nothing they write precedes it to hijack.

The url half stays byte-exact under either rule: it is the key the Seeds-thread mirror lookup finds an entry by. Emoji stay outside the brackets too — Discord refuses to render a masked link whose text contains one.

**Releases thread**:
A thread receiving the Project's release announcements.

<!-- [doc->REQ-RELEASE-CARD] -->
A release announcement is a **Bot card**, keyed by a **machine footer** rather than plain text keyed by a content line — the footer is where every other Bot post already keeps its machine field. This paragraph is about the announcement's **form**; what it *says* is the roundup window below, which this shape does not touch. The card carries **its own color**, not a State's: a release is not in a State, and borrowing one makes the thread lie by palette. Recognition accepts the **footer key AND the legacy content marker**, because a live board's thread is full of plain posts and a recognizer that knew only the new shape would re-announce every release the Project has ever shipped, in one cycle, at every Alchemist — the cycle that adopts the shape would be the cycle that floods. An existing plain post is **edited into a card in place**: same message, same permalink, same position in a thread that reads oldest-first — never deleted and re-posted. And the card **drift-corrects each cycle** like a Request's, so an edited release updates instead of fossilizing.

<!-- [doc->REQ-RELEASE-CARD-MILESTONE-GROUP] -->
<!-- [doc->REQ-RELEASE-CARD-REF-LINKS] -->
The roundup groups **shipped Milestones under their own heading, preceding the Requests** — a
Milestone is not a Request and reading it as one miscounts what a release delivered. Every ref in
either group is a **masked link to its GitHub page**: the machine `#N` alone inside the construct,
the Operator's title trailing outside it with its `](` adjacency broken, per the masked-link rules
above.

<!-- [doc->REQ-RELEASE-ROUNDUP-WINDOW] -->
A release announcement names the Requests that shipped **in that release** — those closed after the previous published release, up to this one's publish time — never the cumulative set of everything Done before it, which makes every post repeat its predecessor's roundup and the board claim the same Request shipped twice. The first announced release keeps an open lower bound so nothing that shipped before it is dropped, and a draft release is not a release: it is neither announced nor a window boundary.

<!-- [doc->REQ-RELEASE-ATTRIBUTION-ONE-RULE] -->
**Which release a Request shipped in is one rule with two readers.** The `release` verb promoting Requests to Done and the daemon composing a release card must answer the same question the same way, and did not: the verb admits a close landing within a **grace** period after publish — the documented repair path for a cut that races its own close cascade — while the card read a strict window, so a Milestone whose closes landed a minute after publish was attributed by the record to one release and by the board to the next (spt-bs-releases, v0.65.0/v0.66.0, 2026-08-30). The rule is therefore shared: a Request ships in release N when it closed after N's predecessor published and no later than N's publish time **plus the grace**, and a close that N's grace claimed is excluded from N+1's window — grace gives the **earlier** release priority, so a boundary close lands on exactly one card. Cards recompute from GitHub truth each cycle, so the rule heals a mis-bucketed board on deploy without any State being edited; the States were right.

<!-- [doc->REQ-RELEASE-CLAIM-ONCE] -->
**A Request claimed by one release's card is never re-listed by a later one.** The roundup is a union of two halves — the closure window above and the Requests a release's own commits name (which join *whatever* their State) — and the second half had no notion of *already claimed*: a later docs or follow-up commit whose subject named a shipped Request re-announced it, and the board said the same Request shipped twice (v0.23.0 re-listing #73, 2026-08-21). The claim is **derived, not stored**: each cycle walks the published releases oldest-first and a Request on an earlier card is out of every later one, first claim winning in publish order. Nothing is written to the Request — the daemon still remembers nothing between cycles (ADR-0008) — and an over-claiming card heals itself the cycle the rule ships. A bare `#N` in a commit **subject** keeps correlating without a closing keyword; the dedup, not a keyword rule, is what closes the re-ship, and the residual case (a bare *foreign* number in a subject) has never occurred in this Project's history.

A Project's channel therefore holds only un-promoted Seeds; everything else lives in a thread.

<!-- [doc->REQ-CHANNEL-NAME-GLYPH] -->
A Project's channel is named `⚙<repo>`, lowercased. The prefix is a collision guard, not decoration: it is what marks a channel as the daemon's, and both it and the superseded `_` are recognized as ours, because a guild is not renamed the instant the rule changes. The per-Project Alchemist role is keyed to a **stable identifier derived from the repo**, never to the display name — a channel that can be renamed must not be the string a member's write capability is held under.

### Import

**Requests server**:
The Discord server the system runs in, separate from the Operator's personal server so teammates can be invited without exposing anything else.

## Resolved ambiguities

**Routing away (resolved 2026-07-27)**: a Request whose true **Project** turns out to be another
repo is re-minted there **by that Project's DRI** — never filed into a foreign repo over their
head — and the local one rests in **Cut**, its closing comment naming the foreign ref. **Needs
Routing** clears on the same act, since the Request has reached its true Project. How this Project
then hears the outcome is a **Watch** on the foreign Request; nothing is left open locally to track
it. Coining a ninth State for this was declined: it would cost a label, a color, a thread, cascade
rules and every filter for a case with one instance. Precedent: #23 → `BigscreenVR/spt-bs-releases#2`.

**Legacy import (resolved 2026-07-23)**: there is no automated import. The Operator carries legacy items over by hand as ordinary new Requests through the normal capture flow; the earlier Legacy-channel / Imported-thread / Verification-pass design and the legacy reaction mappings (Loading→Acceptance, ✅→Done, ❌→Cut, unmarked→Backlog) are retired unbuilt.

## Example dialogue

**Operator**: `# relay drops frames after suspend` — marking it a Bugfix.
**Bot**: Minted. State **Backlog**, Type Bugfix, Project spt-core, no Needs Operator flag.
**DRI (doyle)**: Sending it to hertz for root cause.
**Bot**: State **Eval**.
**Analyst (hertz)**: Root cause is the reconnect path skipping the resume handshake. Suggested fix attached.
**DRI**: Direction accepted, adding it to milestone v0.32.0.
**Bot**: State **Greenlit** — buildable.
**Operator**: Now an Addition: `# expose relay metrics endpoint`. That one needs me.
**Bot**: Minted, Type Addition, **Needs Operator** set. It can reach **Eval**, but not **Greenlit**, until you and the DRI have talked and the flag clears.
