This is the full developer documentation for SuperExchange # Board work in chat, and the answer back. > A gate, a question, a follow-up or a waiting card on a SuperPipeline board appears in that board's encrypted room. A person answers there, and the board hears it as that person. ## One exchange, carried both ways [Section titled “One exchange, carried both ways”](#one-exchange-carried-both-ways) Out to the room The board’s event arrives from the suite’s event relay. SuperExchange makes the board’s room once, invites its people, waits for one of them to join, and posts the card exactly once, encrypted. Back to the board A person answers in the room. Accounts says who they are; only a person may answer. The answer acts at SuperPipeline as that person, and one receipt is posted. Nothing lost between A sweep asks every opted-in board what it still waits on: cards the relay missed are posted, and a gate decided on the web or in `supi` is settled in chat with the same one receipt. SuperExchange is early: v0.0.2 runs in production, and no production board has moved to it yet. [Status and limits](/status/) says exactly what works and what is still owed by other products. For agents, the same docs are published as plain text: [`/llms.txt`](/llms.txt) indexes them, and [`/llms-full.txt`](/llms-full.txt) is every page in one file. # Encryption > Board rooms are end-to-end encrypted with SuperExchange's own keys. The speaker's device, the crypto store and its passphrase, what the homeserver registration must allow, and the staging-only plaintext switch. Board rooms are end-to-end encrypted, with SuperExchange’s own keys. The speaker, `@superexchange_board`, has one device and its own crypto store. Encryption uses `@matrix-org/matrix-sdk-crypto-nodejs`, the Rust state machine, fed what each homeserver transaction carries. ## The device [Section titled “The device”](#the-device) The speaker’s device is taken **once**, from an appservice login (`m.login.application_service`), and recorded in the store, so every restart reuses it. A device per boot would leave a trail of abandoned devices every client must still encrypt to, and throw away every room key the speaker had been given. The access token that comes with the login is dropped: every request is made as the appservice. ## The crypto store [Section titled “The crypto store”](#the-crypto-store) The keys live in an on-disk store under `SUPEREXCHANGE_DATA_DIR/crypto/`, encrypted with `SUPEREXCHANGE_CRYPTO_PASSPHRASE` (at least 32 characters). Back it up with the database The crypto store is as load-bearing as the database beside it. Lose it and the speaker loses every room key it holds, unrecoverably. Lose the passphrase and the store cannot be opened. A store that is gone while its device is still on record cannot be used with that device (new keys would not match the ones uploaded), so a new device is taken. A store left without a recorded device is moved aside, never deleted. ## What is encrypted [Section titled “What is encrypted”](#what-is-encrypted) A new board room is made with `m.room.encryption` (Megolm) in its initial state. The cards, their prose, replies and receipts are encrypted to everyone joined or invited: the room key is shared with everyone joined (read from the homeserver per send, so someone who joined a moment ago is not missed) and with the people invited and not yet joined, so a card is readable by whoever accepts the invite next. ## Encrypted answers [Section titled “Encrypted answers”](#encrypted-answers) A person’s encrypted answer is decrypted before it is read. One whose key has not arrived yet waits in the store, still encrypted, and is tried again whenever keys arrive and at boot. It is given up after an hour. At most 200 wait at once, so a room whose keys never come cannot grow the table without end. ## What the homeserver registration needs [Section titled “What the homeserver registration needs”](#what-the-homeserver-registration-needs) SuperExchange’s appservice registration must carry: | Key | Value | Why | | ------------------------------------ | ------- | ------------------------------------------------------------------------ | | `de.sorunome.msc2409.push_ephemeral` | `true` | To-device events arrive in transactions | | `org.matrix.msc3202` | `true` | Device-list changes and one-time-key counts arrive in transactions | | `io.element.msc4190` | `false` | MSC4190 turns appservice login off, and the device comes from that login | ## Plaintext rooms: staging only [Section titled “Plaintext rooms: staging only”](#plaintext-rooms-staging-only) `SUPEREXCHANGE_PLAINTEXT_ROOMS=on` makes new rooms in plaintext instead. It is refused on any homeserver whose server name does not start with `staging-`: the service stops before it listens. With plaintext rooms on, `SUPEREXCHANGE_CRYPTO_PASSPHRASE` may be unset, and then nothing is encrypted or decrypted and the encryption binding is never loaded. A room keeps what it was made as: * an encrypted room never gets plaintext, whatever the setting; * a room made in plaintext is left as it is, never converted; * with the switch off, nothing is posted into a plaintext room, encrypted or not. That board’s chat comes back only when it is re-opted in for a new room, which is not built yet. # Follow-ups and waiting cards > A manual delivery that still owes the board its live link, and a card parked for a person. How each is posted, answered from chat and settled. Two more cards reach the board room the same way a question does: recorded once, held until a person has joined, encrypted where the room is, posted exactly once. ## Delivery follow-ups [Section titled “Delivery follow-ups”](#delivery-follow-ups) A post approved for **manual delivery** still owes the board its live link. That arrives as `delivery.pending` (`com.superjackfruit.superpipeline.delivery.pending.v1`) and is recorded once per gate. **The card** is one message, treaty’s delivery.v2: the exact post, and two choices: * record the live link; * switch to automatic delivery, offered only when the whole post could be shown. **The answer** is a `dev.superpipeline.delivery.answer.v2` naming the gate and referencing that message. It is judged against what was recorded: an `https://` link within the limit, or automatic delivery only on a complete preview. It is carried as that person: ```http POST /v1/boards/:boardId/gates/:gateId/delivery { "liveUrl": "https://…" } or { "mode": "automatic" } ``` **One receipt**: a line naming who, and `dev.superpipeline.gate.outcome.v1` at the follow-up. ## Waiting cards [Section titled “Waiting cards”](#waiting-cards) A card parked for a person (repeated failure, blocked, not authorised) arrives as `attention.pending` (`com.superjackfruit.superpipeline.attention.pending.v1`). It is recorded once per wait: board, card and wait id. A card that parks again is a new waiting card. **The card** is one message, treaty’s attention.v2, offering three choices: resume here, resume at an earlier stage, or archive. **The answer** is a `dev.superpipeline.attention.answer.v2` naming that wait and referencing that message, carried as that person: ```http POST /v1/boards/:boardId/cards/:cardId/resume { "comment": "…", "toStageKey": "…", "expectedWaitId": "…" } POST /v1/boards/:boardId/cards/:cardId/archive { "expectedWaitId": "…", "comment": "…" } ``` `toStageKey` is given only to resume at an earlier stage; the archive’s `comment` is optional. A resume needs an instruction for the next attempt, at most 2730 characters. **One receipt**: a line, and `dev.superpipeline.attention.outcome.v1` at the card. When the board says the wait has changed (`WAIT_CHANGED`: the card was resumed, archived or moved on the board, or waits on something else now), the room is told so in words and the card is closed with its outcome only. ## Who may answer [Section titled “Who may answer”](#who-may-answer) For both, only a person. An agent, a sender linked to nobody, another message, another wait or another room is ignored without a word. A refusal the board gives is said in the room. ## The sweeps for these two [Section titled “The sweeps for these two”](#the-sweeps-for-these-two) The follow-up and waiting-card sweeps run on the same interval as the others, each with its own backoff, and read each enabled board’s `GET /v1/boards/:boardId/deliveries/pending` and `GET /v1/boards/:boardId/attention/pending` with SuperExchange’s own token. One the relay missed is posted. One the board no longer lists is settled with one receipt naming nobody (“settled on the board”), or never posted. These two reads need SuperPipeline v0.0.30 or later, which admits a service token on them under `gates:read`. Against an older SuperPipeline every board reads as unreadable to these sweeps: nothing is settled by them, and they back off. The relay path and the answers work without them. See [Status and limits](/status/). # Gates > How a gate is posted to the board's room, every check an answer in chat must pass before it acts at SuperPipeline, and the one receipt that follows. A gate is a decision a SuperPipeline board waits on before a card moves on. SuperExchange posts it in the board’s room and carries a person’s answer back. ## Posted once [Section titled “Posted once”](#posted-once) A `gate.pending` from the relay, for an opted-in and enabled board, is recorded once and posted after a person has joined the room (see [Board rooms](/board-chat/rooms/)). The post is two messages, both built by treaty: the gate.v2 prose message and the gate card. Gates not yet posted are tried again at boot, when a person joins, and by the [sweep](/board-chat/sweeps/). ## When an answer counts [Section titled “When an answer counts”](#when-an-answer-counts) Answering a gate is the one place a message in a room becomes authority somewhere else, so every step fails closed. An answer counts only when: 1. it is a gate decision (`dev.superpipeline.gate.decision.v1` or `.v2`, read by treaty’s `parseGateDecision`), an `m.reference` to what it answers; 2. its sender is outside SuperExchange’s namespace (`@superexchange_*`); 3. it was said in a board room SuperExchange owns; 4. it names a gate SuperExchange posted in that same room, and references that gate’s card or its prose, the two identifiers checked against each other; 5. the gate’s board is still opted in and enabled; 6. Accounts links the sender to a principal, and that principal is a person; 7. a gate.v2 answer names an option the gate offered, with a comment where the option needs one, bound to the subject (or one of the variants) the card showed; 8. no other answer holds or has used the gate’s outcome. Then Accounts asserts the sender for SuperPipeline (the assertion’s `sub` must be the principal found in step 6), SuperPipeline resolves the gate as that person, and one receipt is posted. ## What the person is told [Section titled “What the person is told”](#what-the-person-is-told) Steps 1 to 6 refuse without a word, so a stranger or an agent can never make SuperExchange speak. Two exceptions: * **Accounts cannot be reached.** “I cannot check who you are right now — the account service is unreachable. Nothing has changed; try again in a minute.” Down never reads as unlinked. * **The sender is linked to nobody.** Once a day per room, they are told their answer did not count and how to link their Matrix account. See [Linking a Matrix id](/operate/linking/). A refusal SuperPipeline gives back is said in the room, the gate stays open, and its claim is given back so another answer can act. The exception is `GATE_NOT_PENDING`: the gate is closed. A second answer to a gate already decided is told “That was already decided — the board has it.” ## The receipt [Section titled “The receipt”](#the-receipt) One receipt per gate: treaty’s prose line and `dev.superpipeline.gate.outcome.v1`, pointing at the card, which a client closes the card by. It is sent in either of two cases, whichever comes first: * a person’s answer in chat, once the board took it; * a decision made anywhere else (on the web, in `supi`, by another path), found by the gate sweep. Both claim the same outcome before anything is sent, and both send under the same transaction ids, so even a retry is answered by the homeserver with the first send’s event. A gate decided before it was ever posted is never posted. ## Quorum, pull-backs and held approvals [Section titled “Quorum, pull-backs and held approvals”](#quorum-pull-backs-and-held-approvals) A gate that needs more than one approval reports its progress in the room (“Approved by Ana — 1 of 2. Waiting for 1 more.”). A held approval that is released or pulled back is said in reply to the gate. A person can pull back an approval from chat. All of these are on [Notices, pull-backs, overrides](/board-chat/notices/). # Notices, pull-backs, overrides > What the board says about work already in its room (reminders, escalations, quorum progress, held approvals, checks), and the pull-backs and check overrides a person can send from chat. ## Board-room notices [Section titled “Board-room notices”](#board-room-notices) What the board says about work already in a room arrives from the relay as seven CloudEvents types, each `com.superjackfruit.superpipeline..v1`. Each is checked with treaty’s predicate and its `event`, kept for opted-in, enabled boards, recorded once, and said once as a reply to the card it concerns. Every message is built by treaty. | Event | What the room sees | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `decision.reminded` | treaty’s notice in reply to the waiting card (a gate, question, follow-up or waiting card). Overdue checks have no card: their reminder stands alone. | | `decision.escalated` | The workspace’s owners and admins are invited, then the notice in reply to the card. See below. | | `gate.approval_recorded` | A quorum gate’s progress (“Approved by Ana — 1 of 2. Waiting for 1 more.”) in reply to the gate, and `dev.agentpod.gate.progress.v2`. The count is recorded on the gate and never walked back. | | `decision.released` | In reply to the gate, settled or not (it follows a held approval’s receipt), and `dev.agentpod.decision.released.v2`. | | `decision.pulled_back` | In reply to the gate, and `dev.agentpod.decision.pulled_back.v2`. | | `check.failed` | A line in the room, in reply to the open checks.waiting card for that card and stage when there is one, and `dev.agentpod.check.failed.v2`. A failure that sent the card back closes that card. | | `check.overridden` | The same, with `dev.agentpod.check.overridden.v2`. | A notice about a card SuperExchange never posted, or one already settled (for a reminder, an escalation or progress), is answered `ignored` and never said. Who did something is named by Accounts’ label for them, never by an id. Each send is under a transaction id derived from the notice. One that fails keeps its claim and is sent again at boot, when a person joins, and every `SUPEREXCHANGE_SWEEP_SECONDS`. ## Escalations [Section titled “Escalations”](#escalations) An escalation invites the workspace’s **owners and admins who have a linked Matrix id** into the room, each once per board. One who leaves is not invited back; an invite the homeserver refuses is tried again at the next escalation. Accounts lists SuperExchange’s own workspace (`GET /api/principals?kind=human`, then `GET /api/principals/:id/identities?system=matrix`), so a board whose `orgId` is another workspace escalates to nobody. A Matrix id is never guessed: an admin who linked none is not invited. **Overdue checks** escalate with a `dev.agentpod.checks.waiting.v2` card listing them, read from `GET /v1/boards/:boardId/cards/:cardId/checks`; the notice replies to it. A board that refuses that read (SuperPipeline before v0.0.30) gets the notice alone, and the read is not retried. **When an invited admin joins**, each still-open escalated card is posted again below, from a fresh read of the board, so the admin can read it: a card sent before they were invited was not encrypted for them. Its record moves to the new copy only if the board still offers what was recorded. The old copy is told “Moved below, so the workspace admins who just joined can read it. Answer the copy below; this one no longer counts.”, and an answer to it then acts on nothing. The notice is posted again in reply to the new copy. A board that cannot be read gets the notice alone. ## Pull-backs and overrides from chat [Section titled “Pull-backs and overrides from chat”](#pull-backs-and-overrides-from-chat) Two things a person can send from the room: | Sent in chat | References | Carried to SuperPipeline as | | ------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------- | | `dev.superpipeline.gate.pull_back.v2` | A gate posted here, in its room | `POST /v1/boards/:boardId/gates/:gateId/pull-back` `{ comment }` | | `dev.superpipeline.check.override.v2` | A checks.waiting card posted here (its room, board and card) | `POST /v1/boards/:boardId/cards/:cardId/checks/:name/override` `{ comment }` | Each is carried as the person who sent it, with Accounts’ assertion. Only a person; a comment is required, within 8192 bytes, and its absence is said in the room (“Pulling back an approval needs a comment saying why. Nothing has changed.”). The board’s refusal is said in the room. Success says nothing by itself: the board’s `decision.pulled_back` or `check.overridden` notice does. # Questions and approvals > An agent's question and a SuperToolkit approval request in the board's room, the two ways to answer them, and the rules that keep an approval bound to what it showed. A board question is one of two things waiting on a person’s answer: * **an agent’s question**: an agent blocked until somebody answers it; * **an approval request**: a SuperToolkit approval waiting on a person, carrying the exact operation and every argument in full. Both arrive as `elicitation.pending` (CloudEvents type `com.superjackfruit.superpipeline.elicitation.pending.v1`). ## Posted once [Section titled “Posted once”](#posted-once) A question for an opted-in, enabled board is recorded once and posted like a gate: held until a person has joined, encrypted where the room is, exactly once. It is one message, built by treaty: the question.v2 prose with the v1 card (`dev.superpipeline.elicitation`) inside it. **An approval request is kept only for this service’s own workspace.** Because it carries every argument in full, one is kept only when the board’s `orgId` is the workspace of SuperExchange’s own token (its `org` claim). Any other is ignored, by the relay route and the sweep alike. ## Two ways to answer [Section titled “Two ways to answer”](#two-ways-to-answer) The answer is carried to SuperPipeline as the person who gave it (`POST /v1/boards/:boardId/elicitations/:elicitationId/answer`, with Accounts’ assertion, as for gates). ### A question.answer.v2 [Section titled “A question.answer.v2”](#a-questionanswerv2) A `dev.superpipeline.question.answer.v2` naming its question and referencing the message that asked it. It is judged against what was recorded when the question was posted: an option it offered, with the input that option asks for, bound to the subject it showed. Approving an incomplete preview, or approving after expiry, is refused. ### A typed reply [Section titled “A typed reply”](#a-typed-reply) An ordinary `m.text` message in the board room: the **number, label or id** of an option of the question open in the room. The open question is the newest one posted without a receipt, an agent’s question before an approval request. This is what makes a question answerable from any Matrix client. Nothing is guessed. Anything that is not plainly an option gives a linked person the options again. **A typed reply never approves a subject-bound question**: it can only decline it, and an attempt to approve is told to open it in an updated app or on the board. ## Who may answer [Section titled “Who may answer”](#who-may-answer) * **An approval request:** only a person (a `human` principal). An agent’s answer is refused without a word. * **An agent’s question:** any sender Accounts links to a principal. * **An approval request whose subject could not be carried** is never allowed from a room; only a decline goes on. A sender linked to nobody, a room SuperExchange does not own, a question it never posted there, a reference to another message, or a board no longer opted in is ignored without a word. ## What the room is told [Section titled “What the room is told”](#what-the-room-is-told) * An option the question did not offer: “That is not one of this question’s options. Nothing has changed.” * An option that needs text, or an `https://` link, without it. * The board’s refusals, in words: a decision answered for a different version, an expired decision, an approval only a workspace owner or admin may give, or the requester trying to decide their own request. * One receipt when the board takes the answer (“Answered: …”). ## When the board stops waiting [Section titled “When the board stops waiting”](#when-the-board-stops-waiting) The [question sweep](/board-chat/sweeps/) reads each enabled board’s pending questions. A question the relay missed is posted. A question the board no longer lists (answered on the board, or retired by the agent’s next question) is closed with one line, “That question is closed — it was answered on the board, or the agent has moved on.”, or never posted if it was not posted yet. A board that cannot be read closes nothing. A question with a receipt never reaches the board again. # Board rooms > One room per opted-in board, made once by SuperExchange's speaker, encrypted, its people invited, and why a card waits for someone to join. Each board SuperExchange carries has one room. Every card, notice and receipt for that board is said there. ## Names [Section titled “Names”](#names) The names are treaty’s, from `boardRoomNames("superexchange")`, inside SuperExchange’s exclusive namespace: | | Name | | --------- | ------------------------------------------------------------------ | | Speaker | `@superexchange_board:`, display name “superpipeline” | | Alias | `#superexchange_board_:` | | Room name | The board’s name when the event carries it, else the board id | | Topic | “Approvals for \. Answer here and the board hears it.” | The speaker holds power level 100 in its rooms. Because the names are in SuperExchange’s own namespace, a board’s room here is never the room AgentPod’s hub made for the same board: neither appservice can speak in or claim the other’s room. ## When it is made [Section titled “When it is made”](#when-it-is-made) The room is made the first time a card for the board is posted, not when the board is opted in. The speaker is registered and named once per process. If the alias is already taken (an earlier attempt made the room and did not live to record it), the room behind the alias is the board’s room, and its encryption state is read from the room itself. A new room is created with `m.room.encryption` (Megolm) in its initial state, unless `SUPEREXCHANGE_PLAINTEXT_ROOMS` is on (staging only). Whether a room is encrypted is recorded with it, and a room is never converted. See [Encryption](/board-chat/encryption/). ## Who is invited [Section titled “Who is invited”](#who-is-invited) The board’s `invitees`, as the operator set them when opting the board in (at most 50 Matrix ids). The first rides on the room’s creation; the rest are invited one by one, so one invite the homeserver refuses does not cost the others their room. Each time a card is posted to an existing room, any invitee not already joined or invited is invited, so someone added to the board later is not left out. An [escalation](/board-chat/notices/) also invites the workspace’s owners and admins who have a linked Matrix id, each once per board. ## The hold: a card waits for a join [Section titled “The hold: a card waits for a join”](#the-hold-a-card-waits-for-a-join) A card is held until a **person has joined** the room, not merely been invited. A person is anyone whose Matrix id does not start with `@superexchange_`. Memberships come from the homeserver’s transactions; when none is on record, the room’s joined members are asked once. Held cards are tried again at boot, when a person joins, and by every sweep. ## What a room will not do [Section titled “What a room will not do”](#what-a-room-will-not-do) * **Plaintext into an encrypted room:** never, whatever the setting. * **Speak in a plaintext room while plaintext rooms are off:** nothing is posted there, encrypted or not. Getting that board’s chat back needs a new room, which is not built yet. * **Accept answers from elsewhere:** an answer counts only in the board room SuperExchange owns, and only when it references a message SuperExchange posted there. # The sweeps > How SuperExchange asks every enabled board what it still waits on, so a card the relay missed is posted and a card settled elsewhere is settled in chat. The interval, the backoff and what a failed read does. The relay is the fast path, not the only one. Beneath it, SuperExchange asks each enabled board, one at a time, what it is still waiting on: once at boot, then every `SUPEREXCHANGE_SWEEP_SECONDS` (whole seconds from 15 to 86400; 60 when unset). There are four sweeps, each with its own backoff: | Sweep | Reads (with SuperExchange’s own token) | Needs | | ------------- | --------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | | Gates | `GET /v1/boards/:boardId/gates/pending`, and `GET /v1/boards/:boardId/gates/:gateId` for one that left the list | `gates:read` | | Questions | `GET /v1/boards/:boardId/elicitations/pending` | `approval-requests:relay` | | Follow-ups | `GET /v1/boards/:boardId/deliveries/pending` | `gates:read`, SuperPipeline v0.0.30 or later | | Waiting cards | `GET /v1/boards/:boardId/attention/pending` | `gates:read`, SuperPipeline v0.0.30 or later | ## The token [Section titled “The token”](#the-token) The sweeps read with SuperExchange’s own token, not a person’s: Accounts issues it (`POST /api/token/service`, audience `SUPERPIPELINE_URL`) and SuperExchange holds it until shortly before it expires. The scopes come from SuperExchange’s service credential at Accounts. ## The gate sweep [Section titled “The gate sweep”](#the-gate-sweep) * **A gate the relay never delivered** (it missed one, or gave up) is recorded as if it had been delivered and posted like any other. A delivered gate not yet posted is offered again; offering twice is harmless, since the post is claimed once. * **A gate decided elsewhere** (on the web, in `supi`, by another path) is settled: when the board reads it as resolved with a decision, the receipt an answer in chat would have posted is posted once, encrypted in an encrypted room. The sweep and the answer path share one claim, so a gate answered in chat and then seen resolved has one receipt; a later answer in chat is told it was already decided. * **A gate decided before it was ever posted** is never posted. * **Absence is not a decision.** A gate missing from the pending list that the board does not read as resolved with a decision is left as it is. ## The other three [Section titled “The other three”](#the-other-three) * **Questions:** a question the relay missed is posted; a question the board no longer lists (answered on the board, or retired by the agent’s next question) is closed with one line, or never posted. * **Follow-ups and waiting cards:** one the relay missed is posted; one the board no longer lists is settled with one receipt naming nobody (“settled on the board”), or never posted. ## When a board cannot be read [Section titled “When a board cannot be read”](#when-a-board-cannot-be-read) A board that cannot be read settles nothing and closes nothing. That covers 404 for a board outside SuperExchange’s workspace, 403 without the scope, a refused or missing token, and the board or Accounts being down. It is logged, and the boards after it are still asked. A pass in which **every** board failed waits twice as long before the next, up to eight times the interval. A pass that read any board goes back to the interval. Passes never overlap. ## Retries that ride on the same interval [Section titled “Retries that ride on the same interval”](#retries-that-ride-on-the-same-interval) Every `SUPEREXCHANGE_SWEEP_SECONDS`, notices that were kept but not yet said are tried again too. Reminders and escalations are not swept from the board: they arrive from the relay only, so a notice the relay never delivered is never said. # How it works > The path of one gate from the board to the room and back, and the parts it crosses on the way. The relay, the room, the hold, the answer, the receipt and the sweep. Every kind of card SuperExchange carries follows the same path. A gate is the first and the fullest, so this page follows one gate. ## 1. The board’s event reaches SuperExchange [Section titled “1. The board’s event reaches SuperExchange”](#1-the-boards-event-reaches-superexchange) SuperPipeline publishes `gate.pending` to the suite’s event relay. The relay delivers it to SuperExchange’s `POST /v1/relay/events` as a CloudEvent in HTTP binary mode, signed in `x-treaty-signature` with the secret the two share (`TREATY_RELAY_DELIVERY_SECRET`). SuperExchange checks the signature with treaty’s `verifyDelivery` before it reads the body. It keeps the event only when the board is **opted in and enabled** in its own store. An operator does that through SuperExchange’s own API: see [Opt a board in](/operate/boards/). An event for any other board is answered `ignored`, so the relay does not retry it. A kept gate is recorded once. A redelivery of the same gate is recorded once too, and offered for posting again if it was not posted yet. ## 2. The board’s room is made once [Section titled “2. The board’s room is made once”](#2-the-boards-room-is-made-once) The first card for a board makes its room: spoken by `@superexchange_board` (shown as “superpipeline”), with the alias `#superexchange_board_`, both names treaty’s (`boardRoomNames("superexchange")`). The room is end-to-end encrypted from its first state event. The board’s invitees are invited, each on their own, so one invite the homeserver refuses costs nobody else the room. See [Board rooms](/board-chat/rooms/). ## 3. The gate is held until a person has joined [Section titled “3. The gate is held until a person has joined”](#3-the-gate-is-held-until-a-person-has-joined) An invite is not enough: a card sent before anybody can read it is a card nobody answers. The gate waits until someone outside `@superexchange_*` has joined. It is tried again at boot, when a person joins, and by the sweep. ## 4. It is posted exactly once [Section titled “4. It is posted exactly once”](#4-it-is-posted-exactly-once) Two messages, both built by treaty: the gate.v2 prose message, and the gate card. Each is sent under a transaction id derived from the gate, so a retry is answered by the homeserver with the first send. In an encrypted room, the room key is shared with everyone joined or invited first. ## 5. A person answers in the room [Section titled “5. A person answers in the room”](#5-a-person-answers-in-the-room) A `gate.decision` (v1 or v2, read by treaty) in that room, naming a gate SuperExchange posted there and referencing its card or prose, from a sender outside `@superexchange_*`. Encrypted answers are decrypted first; one whose key has not arrived yet waits. See [Encryption](/board-chat/encryption/). ## 6. Accounts says who answered [Section titled “6. Accounts says who answered”](#6-accounts-says-who-answered) SuperExchange asks the organization plane (Accounts) which principal the sender’s Matrix id is linked to. Only a person (a `human` principal) may answer; an agent’s answer is refused. A sender linked to nobody is told how to link, at most once a day in that room. See [Linking a Matrix id](/operate/linking/). ## 7. The board acts as that person [Section titled “7. The board acts as that person”](#7-the-board-acts-as-that-person) Accounts issues an assertion for that person with SuperPipeline as its audience, and SuperExchange resolves the gate at SuperPipeline with it. The board records the decision as the person, not as SuperExchange. ## 8. One receipt [Section titled “8. One receipt”](#8-one-receipt) A prose line and `dev.superpipeline.gate.outcome.v1`, which a client closes the card by. An answer acts once: a repeat is told the gate was already decided. ## 9. The sweep keeps chat and board in step [Section titled “9. The sweep keeps chat and board in step”](#9-the-sweep-keeps-chat-and-board-in-step) Every `SUPEREXCHANGE_SWEEP_SECONDS` (60 by default), and once at boot, SuperExchange asks each enabled board what it still waits on, with its own service token. A gate the relay never delivered is posted; a gate decided on the web, in `supi` or anywhere else is settled in chat with the same receipt an answer would have posted. The sweep and the answer share one claim, so a gate has one receipt whichever path settles it. See [The sweeps](/board-chat/sweeps/). ## The parts [Section titled “The parts”](#the-parts) | Part | What SuperExchange uses it for | | --------------------------------- | ---------------------------------------------------------------------------------------------- | | SuperPipeline | The board: where cards come from, where answers act, and what the sweeps read | | The event relay | Delivers SuperPipeline’s events, signed | | Accounts (the organization plane) | Who a Matrix sender is, the assertion that carries them, and SuperExchange’s own service token | | The Matrix homeserver | The rooms; it calls SuperExchange with each transaction under the appservice registration | | treaty | The board-room event contract, the room names and the relay’s signature | | SuperExchange’s store | Opted-in boards, rooms, memberships, what was posted and settled, and the speaker’s device | # Opt a board in > The operator's board API. Opting a SuperPipeline board in to SuperExchange, changing its people, disabling it, and moving a board from AgentPod's hub one board at a time. SuperExchange carries nothing for a board until an operator opts it in. The mapping from a board to its tenant, its workspace and its people lives in SuperExchange’s own store; it is never inferred from anything else. ## The API [Section titled “The API”](#the-api) Every request carries `Authorization: Bearer `, whose SHA-256 must equal `SUPEREXCHANGE_OPERATOR_TOKEN_SHA256`. The check is constant-time and comes before the body is read. A refusal is `401 {"error":"unauthorized"}` and says nothing else. | Request | Does | | ---------------------------- | ------------------------------------------------------------------------------------------------------- | | `GET /v1/boards` | Every board, enabled or not: `{ "boards": [ … ] }` | | `GET /v1/boards/:boardId` | One board: `{ "board": … }`, or 404 | | `PUT /v1/boards/:boardId` | Opt the board in, or replace its settings, and enable it: `{ "board": … }` | | `DELETE /v1/boards/:boardId` | Disable it: `{ "board": … }`, or 404. The row stays, so it can be listed and enabled again with a `PUT` | The service listens where `PORT` says; in production that is loopback only, so the operator calls it from the host. ### The body of a PUT [Section titled “The body of a PUT”](#the-body-of-a-put) ```json { "spTenantId": "tnt_…", "orgId": "org_…", "invitees": ["@ana:example.test", "@ravi:example.test"] } ``` | Field | What it is | Checked | | ------------ | ------------------------------------------ | ------------------------------------------------------------------------------- | | `spTenantId` | The board’s SuperPipeline tenant | 1 to 128 letters, digits, `_`, `-`, `.` or `:` | | `orgId` | The board’s workspace at Accounts | The same shape | | `invitees` | The Matrix ids invited to the board’s room | A list of at most 50 Matrix user ids (`@localpart:server`); repeats are dropped | `:boardId` is a SuperPipeline board id: `brd_` and up to 64 letters, digits, `_` or `-`. Anything else is `400 {"error":"not a board id"}`. A body that is not JSON, or fails a check, is 400 with the first problem; a body over 64 KiB is refused. `orgId` matters beyond bookkeeping: approval requests are kept, and escalations invite anyone, only when it is the workspace of SuperExchange’s own service token. `spTenantId` is stored and listed; nothing in this release acts on it. ### A board, as the API shows it [Section titled “A board, as the API shows it”](#a-board-as-the-api-shows-it) ```json { "boardId": "brd_…", "spTenantId": "tnt_…", "orgId": "org_…", "invitees": ["@ana:example.test"], "enabled": true, "createdAt": "…", "updatedAt": "…" } ``` ### Example [Section titled “Example”](#example) ```sh curl -sS -X PUT http://127.0.0.1:8795/v1/boards/brd_0123456789abcdef \ -H "Authorization: Bearer $OPERATOR_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"spTenantId":"tnt_…","orgId":"org_…","invitees":["@ana:example.test"]}' ``` ## What happens next [Section titled “What happens next”](#what-happens-next) Nothing visible, yet: the room is made when the first card for the board is posted. The next sweep reads the board’s pending gates and questions, and every one SuperExchange has not seen is posted, so work that was already waiting appears in the new room. See [Board rooms](/board-chat/rooms/). Changing `invitees` with another `PUT` takes effect the next time a card is posted: anyone not yet joined or invited is invited. Removing someone from the list does not remove them from the room. Disabling a board stops everything for it: no new card is kept, nothing is posted, and answers in its room are ignored. ## Moving a board from AgentPod’s hub [Section titled “Moving a board from AgentPod’s hub”](#moving-a-board-from-agentpods-hub) Until SuperExchange, board chat was spoken by AgentPod’s hub. A board moves one at a time, so exactly one path speaks for each board: 1. **The hub lets go first.** Its setting `BOARD_CHAT_MOVED_BOARDS` lists the boards it no longer speaks for. For those it posts nothing, sweeps nothing and ignores answers in the old room, and posts one notice there that the board’s chat has moved. 2. **SuperExchange takes the board**: an operator opts it in here. 3. **A new room.** The board gets a new room under SuperExchange’s own names. Neither appservice can speak in or claim the other’s room, so the old room is never taken over. 4. **Nothing waiting is lost.** The sweep posts every pending gate it has not seen, and settles any gate decided elsewhere with one receipt. A board with no AgentPod fleet needs only steps 2 to 4. Taking a board off the hub’s list and disabling it here returns it to the hub. # Linking a Matrix id > Why an answer in chat counts only from a person whose Matrix id is linked at Accounts, how a person links their own, the 15-minute hold, and what SuperExchange says to someone who has not linked. SuperExchange never decides who someone is. For every answer it asks the organization plane (Accounts) which principal the sender’s Matrix id is linked to (`GET /api/identities/matrix/:mxid`), and what kind of principal that is. Only a linked person’s answer can act at SuperPipeline, and it acts as that person. ## A person links their own [Section titled “A person links their own”](#a-person-links-their-own) No service can link a Matrix id to a person: Accounts refuses it by design, so a service cannot make somebody’s chat account speak for them. The person does it themselves, with two proofs presented together: 1. their own Accounts session, freshly confirmed (Accounts asks them to sign in again if it is not recent); 2. an OpenID token from their homeserver, which Accounts verifies with that homeserver. In SuperMessage this is **Settings → Account → Link to your Super Jackfruit account**. The app gets the OpenID token from the homeserver and opens Accounts’ `/link/matrix` page with it; the person signs in if they need to and confirms. Any Matrix client that can open that page the same way can link. Accounts’ rules, which SuperExchange relies on: * **Configured homeservers only.** Accounts lists the homeservers whose users may link (`MATRIX_HOMESERVERS`). Unset, self-linking is off. * **One person per Matrix id.** An id linked to somebody else is refused until its owner unlinks it, or an Accounts operator moves it. * **Single use.** Each OpenID token links once. * **The person can see and remove the link** in their Accounts security settings. ## The 15-minute hold [Section titled “The 15-minute hold”](#the-15-minute-hold) A self-made link acts **15 minutes after it was made**. Until then Accounts answers that the identity is pending, and SuperExchange reads that as “linked to nobody”. An answer sent in those 15 minutes does not count; answer again once the hold is over. ## What SuperExchange says to someone not linked [Section titled “What SuperExchange says to someone not linked”](#what-superexchange-says-to-someone-not-linked) To a **gate** answer from a sender linked to nobody, once per UTC day in that room: > Your answer didn’t count: your Matrix account isn’t linked to your Super Jackfruit account yet. To approve from chat, link it in SuperMessage: Settings → Account → Link to your Super Jackfruit account. A new link starts working 15 minutes after you make it. Answers to questions, follow-ups and waiting cards from an unlinked sender are ignored without a word. When Accounts cannot be reached at all, SuperExchange says so (“the account service is unreachable. Nothing has changed; try again in a minute.”) rather than treating the person as unlinked. ## Agents [Section titled “Agents”](#agents) An agent’s Matrix id may be linked to an agent principal (AgentPod does that at station setup). That never lets it answer a gate, an approval request, a follow-up or a waiting card: those need a `human` principal. An agent’s question can be answered by any linked principal. # Production and backups > How a release reaches production, what it checks and how it rolls back, where the service keeps its state, and what must be backed up together. ## How a release deploys [Section titled “How a release deploys”](#how-a-release-deploys) A `vX.Y.Z` tag on `main` deploys production, through the suite’s shared release workflow (`.github/workflows/release.yml`, its settings in `.github/release-config.json`). It runs on the GitHub mirror only. Only the operator pushes tags. A merge to `main` deploys staging, once the repository’s `STAGING_DEPLOY` variable is `on` (`.github/workflows/staging.yml`). 1. **The gate.** The workflow checks that forge CI passed for the tag’s commit, or runs `bun run check` at the tag when it cannot read that. 2. **The deploy.** The production host runs that tag’s `deploy/production/release.sh` through its forced-command deploy key (`superexchange `). The workflow needs the mirror’s `PROD_HOST` variable and the `PROD_SSH_KEY` and `PROD_KNOWN_HOSTS` secrets. 3. **The notes.** The release notes go up on GitHub with the tag’s section of `CHANGELOG.md`. ## What release.sh does [Section titled “What release.sh does”](#what-releasesh-does) 1. Takes an **online copy of the store** (SQLite’s backup API), checked with `PRAGMA integrity_check`, before anything changes. 2. Checks out the tag, builds its image (`deploy/production/compose.yml`), and recreates the service. 3. Waits up to a minute for `/healthz`, then checks that the service reaches the homeserver (`/_matrix/client/versions`, from inside the container). 4. On success, records the tag in the deployed-tag file. 5. **On failure, puts the release before it back.** When the new build had moved the store’s schema, it first restores the snapshot (keeping the failed database beside it), because a build refuses a database written by a newer one. The crypto store is left as it is. If the rollback fails too, it says so and stops: the service needs a person. It refuses to start when the settings file is missing, or when the event relay’s network does not exist (deploy the relay first). ## Where things are [Section titled “Where things are”](#where-things-are) | What | Where | | -------------- | ----------------------------------------------------------------------------------------- | | Settings | `/etc/superexchange/superexchange.env`, written by the host’s provisioning | | Store | `/var/lib/superexchange` on the host, mounted at `/data`, owned by the image’s `bun` user | | Database | `/var/lib/superexchange/superexchange.sqlite` (WAL) | | Crypto store | `/var/lib/superexchange/crypto/` | | Listens | `127.0.0.1:8795`: the homeserver’s registration `url`, and the operator’s board API | | Relay delivery | as `superexchange` on the event relay’s Docker network (`treaty_relay`) | | Homeserver | at its public name (`MATRIX_HOMESERVER_URL`), which the container resolves to the host | | Deployed tag | `/etc/superexchange/deployed` | SuperExchange has no public hostname. The relay reaches it on the relay’s network, the homeserver and the operator on the host’s loopback. `SUPEREXCHANGE_PLAINTEXT_ROOMS` is forced `off` in the production compose file, and the service refuses it on a homeserver whose name does not start with `staging-` regardless. ## Backups [Section titled “Backups”](#backups) Three things are backed up, and they belong together: | What | Why | | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | The store (`superexchange.sqlite`) | Opted-in boards, rooms and memberships, what was posted and settled, the speaker’s device | | The crypto store (`crypto/`) | The speaker’s room keys. Lose it and every key it holds is gone, unrecoverably | | The settings (`/etc/superexchange`) | The tokens, the operator token’s hash, the service credential and the **crypto passphrase**, without which the crypto store cannot be opened | Take the SQLite files as online snapshots (SQLite’s backup API), not as copies of a live file: the store runs in WAL mode. That includes every SQLite file in the crypto store; its other files can be copied as they are. The pre-release snapshot `release.sh` takes is for its own rollback, not a backup. Restore the store and the crypto store from the same moment. A crypto store that is missing while its device is still on record cannot be used with that device, so the speaker takes a new one, and every room key the old one held is gone. # Run it > What SuperExchange needs from SuperPipeline, the event relay, Accounts and the homeserver, the appservice registration, and how to start it. SuperExchange is one Bun service (Bun 1.2.8, the version in `.bun-version`) with a SQLite store. It needs four things around it, and refuses to start until every [setting](/operate/settings/) it needs is present and well formed. ## What it needs [Section titled “What it needs”](#what-it-needs) ### A Matrix homeserver [Section titled “A Matrix homeserver”](#a-matrix-homeserver) * An **appservice registration** for SuperExchange (below), with its own `as_token` and `hs_token`, which must differ. * The homeserver must reach SuperExchange at the registration’s `url`, and SuperExchange must reach the homeserver’s client-server API at `MATRIX_HOMESERVER_URL`. * **Appservice login** must be on (`io.element.msc4190: false`), and the registration must ask for to-device events, device lists and key counts in transactions. See [Encryption](/board-chat/encryption/). ### The event relay [Section titled “The event relay”](#the-event-relay) treaty’s relay must deliver SuperPipeline’s board events to `POST /v1/relay/events`, signed with the secret SuperExchange holds as `TREATY_RELAY_DELIVERY_SECRET`. SuperExchange subscribes to `gate.pending`, `elicitation.pending`, `delivery.pending`, `attention.pending` and the seven board-room notices ([Events carried](/reference/events/)). ### Accounts (the organization plane) [Section titled “Accounts (the organization plane)”](#accounts-the-organization-plane) A **service credential** for SuperExchange, `svc_<20 hex>:`, in the workspace whose boards it carries. Accounts asserts only members of the credential’s own workspace. The credential’s scopes: | Scope | Used for | | ------------------------- | --------------------------------------------------------------------------------------------------------- | | `identities:read` | Who a Matrix sender is (`GET /api/identities/matrix/:mxid`), and the admins’ Matrix ids for an escalation | | `principals:read` | What kind of principal that is, how a receipt names them, and the workspace’s owners and admins | | `token:assert` | The assertion that carries a person’s answer to SuperPipeline | | `gates:read` | The gate, follow-up and waiting-card sweeps, and a card’s checks, on SuperPipeline’s service routes | | `approval-requests:relay` | The question sweep (`GET /v1/boards/:boardId/elicitations/pending`) | `gates:read` and `approval-requests:relay` are SuperPipeline’s scopes, carried in the service token Accounts issues for SuperPipeline’s audience. Without one, the sweeps that need it read every board as unreadable; the relay path and the answers still work. People must **link their own Matrix id** at Accounts before an answer from them counts. See [Linking a Matrix id](/operate/linking/). ### SuperPipeline [Section titled “SuperPipeline”](#superpipeline) `SUPERPIPELINE_URL` is SuperPipeline’s API: where answers act, what the sweeps read, and the audience of every assertion and service token. SuperPipeline v0.0.30 or later relays every event SuperExchange carries and admits its token on every read it makes; an older one degrades as [Status and limits](/status/) describes. ## The appservice registration [Section titled “The appservice registration”](#the-appservice-registration) An example, for a homeserver whose server name is `example.test`. The namespaces are exclusive and anchored at both ends (some homeservers do not anchor registration regexes themselves). They must not overlap any other appservice’s; AgentPod’s are `@agent_*` and `#agentpod_*`. ```yaml id: superexchange as_token: "" hs_token: "" url: "http://127.0.0.1:8795" sender_localpart: superexchange receive_ephemeral: true rate_limited: false namespaces: users: - exclusive: true regex: '^@superexchange_.*:example\.test$' aliases: - exclusive: true regex: '^#superexchange_.*:example\.test$' rooms: [] de.sorunome.msc2409.push_ephemeral: true org.matrix.msc3202: true io.element.msc4190: false ``` ## Start it [Section titled “Start it”](#start-it) From the repository root: ```sh bun install MATRIX_HOMESERVER_URL=http://127.0.0.1:6167 \ MATRIX_SERVER_NAME=example.test \ MATRIX_AS_TOKEN=… MATRIX_HS_TOKEN=… \ TREATY_RELAY_DELIVERY_SECRET=… \ SUPEREXCHANGE_OPERATOR_TOKEN_SHA256=$(printf %s "$OPERATOR_TOKEN" | shasum -a 256 | cut -d' ' -f1) \ SUPEREXCHANGE_DATA_DIR=$PWD/.data \ ORG_PLANE_URL=https://accounts.example ORG_PLANE_SERVICE_CREDENTIAL=svc_…:… \ SUPEREXCHANGE_CRYPTO_PASSPHRASE=$(openssl rand -base64 32) \ SUPERPIPELINE_URL=https://pipeline.example \ bun apps/exchange/src/main.ts curl http://127.0.0.1:8080/healthz ``` `/healthz` answers `{"ok":true,"service":"superexchange"}`. Keep the crypto passphrase: the crypto store cannot be opened without it. ### What happens at boot [Section titled “What happens at boot”](#what-happens-at-boot) In order: the settings are read and checked, the store is opened (and migrated), the speaker’s encryption is built, and the service listens. Then it gives back any answer a stopped run left unfinished, queues every card delivered but not yet posted, tries again every encrypted event still waiting for its key, and starts the sweeps: once now, then every `SUPEREXCHANGE_SWEEP_SECONDS`. ### In a container [Section titled “In a container”](#in-a-container) `apps/exchange/Dockerfile` builds the service from the repository root (`docker build -f apps/exchange/Dockerfile .`) on `oven/bun:1.2.8-alpine`: the encryption binding needs glibc 2.32 or newer, which no Debian image of that Bun has, so it uses the binding’s musl build. The image runs as `bun`, listens on 8795, keeps its store in `/data`, and has a health check on `/healthz`. ## Then [Section titled “Then”](#then) [Opt a board in](/operate/boards/). Nothing is carried for a board until an operator does. # Settings > Every environment variable SuperExchange reads, which are required, their defaults, and the checks it makes before it listens. SuperExchange reads its settings once, at boot, from the environment. A missing or malformed setting stops it before it listens, and the error names the setting, never its value. Every problem is listed at once. ## Required [Section titled “Required”](#required) | Setting | What it is | Checked | | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | | `MATRIX_HOMESERVER_URL` | Where the homeserver’s client-server API answers | An http(s) URL | | `MATRIX_SERVER_NAME` | The homeserver’s server name: the part of a Matrix id after the colon | Set | | `MATRIX_AS_TOKEN` | The `as_token` of this appservice’s registration (sent to the homeserver) | Set; not equal to `MATRIX_HS_TOKEN` | | `MATRIX_HS_TOKEN` | The `hs_token` of this appservice’s registration (sent by the homeserver) | Set | | `TREATY_RELAY_DELIVERY_SECRET` | The secret the event relay signs its deliveries to this service with | Set | | `SUPEREXCHANGE_OPERATOR_TOKEN_SHA256` | The SHA-256 of the operator’s bearer token for `/v1/boards`, as 64 hex digits. The token itself is never given to the service, so a leaked environment does not leak it | 64 hex digits | | `ORG_PLANE_URL` | Where the organization plane (Accounts) answers | An http(s) URL | | `ORG_PLANE_SERVICE_CREDENTIAL` | This service’s credential at Accounts. Sent only to Accounts, as a bearer; never logged | `svc_<20 hex digits>:` | | `SUPERPIPELINE_URL` | SuperPipeline’s API, where answers act and boards are read; also the audience of every assertion and service token | An http(s) URL | | `SUPEREXCHANGE_CRYPTO_PASSPHRASE` | Encrypts the speaker’s crypto store. Used exactly as given, not trimmed | At least 32 characters. Required while plaintext rooms are off | ## Optional [Section titled “Optional”](#optional) | Setting | Default | What it is | | ------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SUPEREXCHANGE_DATA_DIR` | `/data` | The directory the store’s database (`superexchange.sqlite`) and the crypto store (`crypto/`) live in. Must be an absolute path | | `PORT` | `8080` (`8795` in the container image) | Where it listens | | `SUPEREXCHANGE_SWEEP_SECONDS` | `60` | How often the gate, question, follow-up and waiting-card sweeps ask each enabled board, and unposted notices are tried again. Whole seconds from 15 to 86400 | | `SUPEREXCHANGE_PLAINTEXT_ROOMS` | `off` | `on` makes new rooms without end-to-end encryption. Refused unless `MATRIX_SERVER_NAME` starts with `staging-`. With it on, the crypto passphrase may be unset, and then nothing is encrypted or decrypted | ## Making the operator token [Section titled “Making the operator token”](#making-the-operator-token) The service holds only the hash. Make a token, keep it where operators keep secrets, and give the service its SHA-256: ```sh OPERATOR_TOKEN=$(openssl rand -hex 32) printf %s "$OPERATOR_TOKEN" | shasum -a 256 | cut -d' ' -f1 # SUPEREXCHANGE_OPERATOR_TOKEN_SHA256 ``` The token is then presented as `Authorization: Bearer ` on [`/v1/boards`](/operate/boards/). # What it calls > Every request SuperExchange makes to Accounts, SuperPipeline and the homeserver, with the credential each is made with and the scope it needs. SuperExchange reads SuperPipeline and Accounts only through their public APIs, and owns neither one’s data. ## Accounts (`ORG_PLANE_URL`) [Section titled “Accounts (ORG_PLANE_URL)”](#accounts-org_plane_url) Every call carries SuperExchange’s service credential (`ORG_PLANE_SERVICE_CREDENTIAL`) as a bearer. A plane that cannot answer is never read as “nobody”. | Request | For | | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `GET /api/identities/matrix/:mxid` | The principal a Matrix sender is linked to; 404 is nobody (a link still in its 15-minute hold reads as nobody too) | | `GET /api/principals/:id` | What kind of principal that is (`human`, `agent`, `service`), and how a receipt names them: display name, else handle | | `POST /api/token/assertion` `{ identity: { system: "matrix", externalId }, audience }` | An assertion of the sender for SuperPipeline. Its `sub` must be the principal resolved above, or the answer is refused | | `GET /api/principals?kind=human` | The workspace’s people with their `role`, for an escalation: owners and admins, not suspended | | `GET /api/principals/:id/identities?system=matrix` | Each such admin’s linked Matrix id; an admin who linked none is not invited | | `POST /api/token/service` `{ audience }` | SuperExchange’s own token for SuperPipeline (audience `SUPERPIPELINE_URL`), held until shortly before it expires | ## SuperPipeline (`SUPERPIPELINE_URL`) [Section titled “SuperPipeline (SUPERPIPELINE_URL)”](#superpipeline-superpipeline_url) ### As the person who answered [Section titled “As the person who answered”](#as-the-person-who-answered) Each with Accounts’ assertion for that person. | Request | For | | -------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `POST /v1/boards/:boardId/gates/:gateId/resolve` | A gate answer: `{ decision, comment?, approvalSubjectId?, approvalSubjectDigest? }` | | `POST /v1/boards/:boardId/elicitations/:elicitationId/answer` | A question’s answer | | `POST /v1/boards/:boardId/gates/:gateId/delivery` | A follow-up: `{ liveUrl }` or `{ mode: "automatic" }` | | `POST /v1/boards/:boardId/cards/:cardId/resume` | A waiting card resumed: `{ comment, toStageKey?, expectedWaitId }` | | `POST /v1/boards/:boardId/cards/:cardId/archive` | A waiting card archived: `{ expectedWaitId, comment? }` | | `POST /v1/boards/:boardId/gates/:gateId/pull-back` | A pull-back: `{ comment }` | | `POST /v1/boards/:boardId/cards/:cardId/checks/:name/override` | A check override: `{ comment }` | ### As itself [Section titled “As itself”](#as-itself) With its own service token, on SuperPipeline’s service routes. | Request | Scope | For | | ---------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------- | | `GET /v1/boards/:boardId/gates/pending` | `gates:read` | The gate sweep | | `GET /v1/boards/:boardId/gates/:gateId` | `gates:read` | One gate, and how it was decided | | `GET /v1/boards/:boardId/elicitations/pending` | `approval-requests:relay` | The question sweep | | `GET /v1/boards/:boardId/deliveries/pending` | `gates:read` | The follow-up sweep (SuperPipeline v0.0.30 or later) | | `GET /v1/boards/:boardId/attention/pending` | `gates:read` | The waiting-card sweep (SuperPipeline v0.0.30 or later) | | `GET /v1/boards/:boardId/cards/:cardId/checks` | `gates:read` | An escalation of overdue checks, and re-posting for a joining admin (SuperPipeline v0.0.30 or later) | ## The homeserver (`MATRIX_HOMESERVER_URL`) [Section titled “The homeserver (MATRIX_HOMESERVER_URL)”](#the-homeserver-matrix_homeserver_url) As the appservice, with `MATRIX_AS_TOKEN`, through the client-server API: registering and naming the speaker, the appservice login that gives the speaker its device, creating rooms, resolving an alias, reading a room’s encryption state and joined members, inviting, sending (encrypted or not), and the encryption machinery’s own requests (keys upload, query and claim, to-device messages). # Events carried > The CloudEvents SuperExchange takes from the relay, and the Matrix content it posts and reads in board rooms. Every shape is treaty's. SuperExchange defines none of these. The CloudEvents types and their bodies are SuperPipeline’s, catalogued by treaty; the Matrix content is treaty’s board-room contract, [`@superjackfruitlabs/treaty-matrix-events`](https://www.npmjs.com/package/@superjackfruitlabs/treaty-matrix-events), with one JSON Schema per type. Type strings never change; a breaking change is a new version published beside the old one. ## In from the relay [Section titled “In from the relay”](#in-from-the-relay) Every type is `com.superjackfruit.superpipeline..v1`. | Event | What it is | Page | | ------------------------ | ------------------------------------------- | ---------------------------------------------------------- | | `gate.pending` | A gate waiting on a decision | [Gates](/board-chat/gates/) | | `elicitation.pending` | An agent’s question, or an approval request | [Questions](/board-chat/questions/) | | `delivery.pending` | A manual delivery that owes its live link | [Follow-ups](/board-chat/follow-ups-and-waiting-cards/) | | `attention.pending` | A card parked for a person | [Waiting cards](/board-chat/follow-ups-and-waiting-cards/) | | `decision.reminded` | An unanswered decision, reminded | [Notices](/board-chat/notices/) | | `decision.escalated` | An unanswered decision, escalated | [Notices](/board-chat/notices/) | | `gate.approval_recorded` | One approval of a quorum gate | [Notices](/board-chat/notices/) | | `decision.released` | A held approval released | [Notices](/board-chat/notices/) | | `decision.pulled_back` | A held approval pulled back | [Notices](/board-chat/notices/) | | `check.failed` | A machine check failed | [Notices](/board-chat/notices/) | | `check.overridden` | A failed check overridden | [Notices](/board-chat/notices/) | ## Out to the room [Section titled “Out to the room”](#out-to-the-room) | Card or message | Content | | ------------------------------------------------ | ---------------------------------------------------------------------------------------- | | Gate | The gate.v2 prose message and the gate card | | Question | The question.v2 prose with the v1 card (`dev.superpipeline.elicitation`) inside it | | Delivery follow-up | treaty’s delivery.v2 | | Waiting card | treaty’s attention.v2 | | Gate receipt | A prose line and `dev.superpipeline.gate.outcome.v1` | | Follow-up receipt | A line naming who, and `dev.superpipeline.gate.outcome.v1` | | Waiting-card receipt | A line, and `dev.superpipeline.attention.outcome.v1` | | Quorum progress | A line, and `dev.agentpod.gate.progress.v2` | | Released, pulled back | A line, and `dev.agentpod.decision.released.v2` / `dev.agentpod.decision.pulled_back.v2` | | Checks waiting (an escalation of overdue checks) | `dev.agentpod.checks.waiting.v2` | | Check failed, overridden | A line, and `dev.agentpod.check.failed.v2` / `dev.agentpod.check.overridden.v2` | In an encrypted room each is sent as `m.room.encrypted`. ## In from the room [Section titled “In from the room”](#in-from-the-room) | Answer | Answers | | --------------------------------------------------------- | ------------------------------------------------------ | | `dev.superpipeline.gate.decision.v1`, `.v2` | A gate | | `dev.superpipeline.question.answer.v2` | A question | | `m.text` (a typed reply: an option’s number, label or id) | The question open in the room | | `dev.superpipeline.delivery.answer.v2` | A delivery follow-up | | `dev.superpipeline.attention.answer.v2` | A waiting card | | `dev.superpipeline.gate.pull_back.v2` | Pulls back an approval of a gate posted here | | `dev.superpipeline.check.override.v2` | Overrides a check on a checks.waiting card posted here | Each structured answer must reference the message it answers (`m.reference`), in the room SuperExchange posted it in. A typed reply references nothing: it is matched against the question open in that room, and can never approve a subject-bound question. # HTTP routes > Every route SuperExchange serves, who calls it, how each is authorised, and what it answers. One dispatcher serves every route. Anything not listed is `404 {"error":"not found"}`. | Route | Who calls it | Authorised by | What it does | | ---------------------------------------------------------- | --------------- | ---------------------- | ----------------------------------------------------------------------------- | | `GET /healthz` (or `HEAD`) | anyone | nothing | `{"ok":true,"service":"superexchange"}` | | `PUT /_matrix/app/v1/transactions/:txn` | the homeserver | `hs_token` | Events in, applied once per transaction id | | `POST /_matrix/app/v1/ping` | the homeserver | `hs_token` | `{}` | | `GET /_matrix/app/v1/users/…`, `rooms/…` | the homeserver | `hs_token` | `M_NOT_FOUND`: nothing is provisioned on demand | | `POST /v1/relay/events` | the event relay | the delivery signature | A board event, kept for opted-in boards | | `GET /v1/boards`, `GET`/`PUT`/`DELETE /v1/boards/:boardId` | the operator | the operator token | Opt a board in, disable it, list them. See [Opt a board in](/operate/boards/) | ## The homeserver’s routes [Section titled “The homeserver’s routes”](#the-homeservers-routes) The Application Service API v1. Authorisation is the spec’s: `Authorization: Bearer `, or the legacy `?access_token=` query. No token is `401 M_UNAUTHORIZED`; a wrong one is `403 M_FORBIDDEN`. Neither says anything else, and no token reaches a log. **Transactions.** A transaction’s crypto parts (to-device events, device-list changes, one-time-key counts) are handed to the speaker’s encryption first, on every delivery, a repeat included: a retried transaction still carries the current key counts. The events are applied once per transaction id, recorded in the store before they are handled, so a retry after a crash is not applied twice. Once authorised, the answer is always `{}`: the homeserver reads anything else as “not delivered” and retries the whole transaction, so one bad event must not block every one after it. ## The relay route [Section titled “The relay route”](#the-relay-route) Each delivery is a CloudEvent in HTTP binary mode, signed in `x-treaty-signature` over the timestamp, the event id and the exact body, and checked with treaty’s `verifyDelivery` before the data is read. A body over 256 KiB is refused. | Delivery | Answer | | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | Not a CloudEvent | `400`, before the signature is checked (there is no id to check it over) | | A bad, stale or missing signature | `401` | | A type SuperExchange does not handle | `200 {"status":"ignored"}`, so the relay does not retry it | | A body that is not what its type says | `400` | | A notice whose card was never posted here, or is settled, or a board with no room | `200 {"status":"ignored"}` | | One for an opted-in, enabled board | Recorded once, `200 {"status":"accepted"}`, and handed on to post (a redelivery too) | | One for any other board, or an approval request outside this service’s workspace | `200 {"status":"ignored"}` | The types handled are listed in [Events carried](/reference/events/). # Status and limits > Where SuperExchange stands. v0.0.2 in production with no production board moved yet, what works today, and the gaps other products still owe it. SuperExchange is early. **v0.0.2** is the first release in production (2026-11-12). It carries every card kind, but **no production board has moved to it yet**. Boards move one at a time; a board that has not moved is spoken for, if at all, by AgentPod’s hub, as before. Nothing on this page is a plan; it is what the current code does and does not do. ## What works [Section titled “What works”](#what-works) * Board rooms made once, end-to-end encrypted, with the board’s people invited. * Gates, questions and approval requests, delivery follow-ups and waiting cards, each posted exactly once after a person has joined, and answered from the room as that person. * Reminders, escalations, quorum progress, released and pulled-back approvals, failed and overridden checks, said once in reply to their card. * Pull-backs and check overrides sent from chat. * The gate and question sweeps: cards the relay missed are posted, and cards settled on the board are settled in chat. * A person linked to nobody is told how to link, at most once a day per room. * A production release snapshots the store first, and puts the previous release back when the new one fails its checks. ## What it needs from SuperPipeline [Section titled “What it needs from SuperPipeline”](#what-it-needs-from-superpipeline) Three things SuperExchange uses were added to SuperPipeline in **v0.0.30** (2026-11-12). Against an older SuperPipeline they are missing, and SuperExchange degrades as described: * **The seven notice types relayed** (`decision.reminded`, `decision.escalated`, `gate.approval_recorded`, `decision.released`, `decision.pulled_back`, `check.failed`, `check.overridden`). Without them no reminder, escalation or other notice reaches the room. * **A service token admitted on the follow-up and waiting-card reads** (`GET /v1/boards/:boardId/deliveries/pending` and `GET /v1/boards/:boardId/attention/pending`, under `gates:read`). Without it every board reads as unreadable to those two sweeps: they settle nothing and back off. Follow-ups and waiting cards that arrive from the relay, and answers to them, work without the sweeps. * **A service token admitted on a card’s checks** (`GET /v1/boards/:boardId/cards/:cardId/checks`, under `gates:read`). Without it overdue checks escalate with the notice alone (a refused read is not retried), and when an invited admin joins, only gates and questions can be posted again below for them. The question sweep needs one more scope on SuperExchange’s credential: `approval-requests:relay` for `GET /v1/boards/:boardId/elicitations/pending`. Without it, that sweep reads every board as unreadable; questions from the relay still post and answers still act. ## What is not built [Section titled “What is not built”](#what-is-not-built) * **Re-opting a board in for a new room.** A room keeps what it was made as. A room made in plaintext (on staging, before encryption) is not spoken in while plaintext rooms are off, and there is no way yet to give that board a new, encrypted room. * **The homeserver’s user and room queries.** `GET /_matrix/app/v1/users/…` and `rooms/…` answer `M_NOT_FOUND`: nothing in the namespace is provisioned on demand. * **Deleting the AgentPod hub’s gate code.** It follows once boards have moved. ## Things to know before relying on it [Section titled “Things to know before relying on it”](#things-to-know-before-relying-on-it) * **Only a person answers.** An agent, a sender linked to nobody, another message, another wait or another room is ignored; a gate answer from a sender linked to nobody gets the link hint. * **A new Matrix link waits 15 minutes.** Accounts holds a person’s self-made link for 15 minutes before it acts, so an answer sent sooner does not count. * **The crypto store is as important as the database.** Lose it and the speaker loses every room key it holds. See [Production and backups](/operate/production/). * **Approval requests are kept only for this service’s own workspace.** An approval request carries every argument in full, so one on a board whose `orgId` is not the workspace of SuperExchange’s own token is ignored. * **Escalations invite only this service’s workspace.** A board whose `orgId` is another workspace escalates to nobody. # What SuperExchange is > The suite's communication server, the Matrix application service for SuperPipeline's board rooms. What it does, what it leaves to other products, and what it needs. SuperExchange is the suite’s communication server: a Matrix application service that carries board work into chat and the answers back. A gate, a question, a follow-up or a waiting card on a SuperPipeline board appears in that board’s room; the board’s reminders, escalations and other notices about that work are said there; and an answer given in chat reaches the board, as the person who gave it. It takes those events from the suite’s event relay, and it speaks in board rooms under its own registration on the homeserver, with its own namespace (`@superexchange_*`, `#superexchange_*`) and its own encryption keys. ## What it does [Section titled “What it does”](#what-it-does) * **Carries board work into the board’s room.** Gates, questions (an agent’s question or a SuperToolkit approval request), delivery follow-ups and waiting cards, each posted once, in the board’s end-to-end encrypted room. See [Board rooms](/board-chat/rooms/). * **Carries answers back.** A person’s answer is checked against what was posted, the organization plane (Accounts) says who sent it, and SuperPipeline acts on it as that person. One receipt follows. See [Gates](/board-chat/gates/). * **Says what the board says about that work.** Reminders, escalations, quorum progress, held approvals released or pulled back, checks failed or overridden: each said once, in reply to its card. See [Notices](/board-chat/notices/). * **Sweeps.** It asks each opted-in board what it still waits on, so a card the relay missed is posted and a gate decided elsewhere is settled in chat. See [The sweeps](/board-chat/sweeps/). ## What it is not [Section titled “What it is not”](#what-it-is-not) * **Not agent puppeting.** Agents speaking in their own station rooms, inbound prompts and outbound turns stay in AgentPod, as its optional Matrix adapter. SuperExchange’s namespace and AgentPod’s do not overlap. * **Not a push gateway.** Push notifications move to SuperMessage, which holds the app’s keys. * **Not the event contract.** The shapes of every board-room event (the gate card, the question, the answers, the receipts) are treaty’s, published as [`@superjackfruitlabs/treaty-matrix-events`](https://www.npmjs.com/package/@superjackfruitlabs/treaty-matrix-events). SuperExchange builds and reads them with treaty’s code; it defines none of them. * **Not the board, the identity service or the homeserver.** It reads SuperPipeline and Accounts through their public APIs and owns neither one’s data. It decides its appservice registration and namespaces; the homeserver itself is run separately. * **Not a chat client.** People read and answer in any Matrix client. SuperMessage shows the cards as cards. ## Nothing requires it [Section titled “Nothing requires it”](#nothing-requires-it) SuperPipeline remains a board without SuperExchange: its gates are still decided on the web and in `supi`. SuperMessage remains a plain Matrix client for any homeserver. AgentPod’s puppeting works with SuperExchange absent. SuperExchange itself needs a SuperPipeline, the event relay, a Matrix homeserver and the organization plane (Accounts). It needs no AgentPod fleet. ## What it is made of [Section titled “What it is made of”](#what-it-is-made-of) One Bun service (`apps/exchange`) with a SQLite store under its data directory, and the speaker’s crypto store beside it. End-to-end encryption uses `@matrix-org/matrix-sdk-crypto-nodejs`. The board-room events come from treaty’s `treaty-matrix-events`, and the relay’s signed deliveries are checked with treaty’s `treaty-cloudevents`. It is MIT licensed.