Files
stack/docs/plans/2026-09-20_discord-setspark-client.md
T
jason.woltjeandClaude Fable 5.1 43d7574d6a feat(discord): SetSpark record client for the Discord Sage, fixed verbs against setspark-api, connector-verified approvals (#1509)
Row 25, parts 2a and 2b, against the shared-signals contract a5425a2.

Model side: eight fixed verbs in the pi extension (record_list, record_get,
record_create, record_update, resolve_id, open_approval_request,
get_approval_request, create_document), each one HTTP call with arguments
checked before any request. Writes carry an idempotency key
<principal>:<message id>:<call index> and an audit context. The seat key is
read from a 0600 file on every call and never cached, printed or journaled.

Connector side: append-only approval ledger, Approve button and exact
"approve" reply resolved by the connector against the required approvers,
confirmation message posted as button evidence, bind and add_approval through
the service under connector keys, retry of unknown entries on start.

Evidence: node tests 162 pass, scripts/test-discord.sh 63/63. Review by
rev-code-02, round 1 approved (#1509 comment 26467, tree 7872d8c5).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-22 12:59:39 -05:00

9.7 KiB

Discord Sage: SetSpark record client (#1509, QUEUE row 25)

Jason's decision, 2026-09-18, relayed by the SetSpark record-system coordinator (thread 8543de4b, confirmed by Jason as acting on his authority): record authority moves from the shared-signals Git vault to NocoDB (structured records) plus Outline (prose), behind one write service, setspark-api at api.setspark.io. Plan v3.2 is committed on shared-signals main as 55b2515. Jason gave the go for phase 3 on 2026-09-20. The row 24 git verbs stay live until cutover; the vault then becomes a read-only mirror.

1. Outcome

A record decided in Discord is created or updated in SetSpark by Sage in the same conversation, through fixed verbs against setspark-api, with the request, the idempotency key, the returned revision and any refusal in the turn record. A proposal that needs founder approval is posted by the connector as a message with an Approve button; the approval is the button or an exact approve reply from a required approver, verified by the connector, never asserted by the model. Sage holds no NocoDB or Outline token; the seat's API key is the only credential.

2. What is built

Two parts. The first does not depend on the API contract and is built first; the second waits for stack/api/README.md and stack/api/openapi.json on shared-signals main.

2a. Connector side (contract-independent)

  • packages/discord/src/setspark.mjs: the setspark key of the tools config (baseUrl, keyFile, principal), validated at load like a git key: https base url with no path, query, user or password; key file absolute, regular, not a symlink, mode 0600, non-empty. The key is read from the file on every call, never cached, never printed or journaled. One HTTP core: JSON body, Authorization: Bearer, fixed User-Agent, timeout, response capped, no redirects. The error body (code, message, and on 409 current_revision and changed_fields) becomes a refusal rendered from code and the fixed fields only; free text from the server is data, cut at a cap. The idempotency key is <principal>:<turn id>:<call index> where the turn id is the Discord message id from the envelope and the call index is the tool set's call counter for that turn.
  • packages/discord/src/approvals.mjs: the connector's approval ledger, approvals.jsonl under the binding's journal directory, appended only: opened (request id, decision id, proposal version, digest, required approver ids, the posted message id and channel), bind and approval (request id, author id, event id, message id, how: button or reply), each as intent before the service call and done, refused or unknown after it; start retries the unknown ones under their original keys. Resolution: a reply whose referenced message is an open request and whose content is exactly approve after trimming, or a button interaction whose custom id names the request and whose message id matches. The author must be in the request's required approvers; anyone else gets one fixed line and a drop entry. A second approval by the same author is ignored with a drop entry.
  • packages/discord/src/rest.mjs: createMessage accepts components (one Approve button); interactionCallback answers a component interaction within Discord's three-second window; editInteractionMessage edits the request message afterwards.
  • packages/discord/src/connector.mjs: INTERACTION_CREATE joins the dispatch switch; a reply message that resolves to an open request is handled as an approval before the normal admission path, so it never starts a model turn. After a turn whose tool calls include open_approval_request, the connector posts the request message with the fixed rendering (decision id, version, digest, who may approve, how) and the button, records opened, and binds the message id to the request through the API.
  • The API client used by the connector for bind and add_approval is passed in like rest, so the offline suite drives it with a fake.

2b. Model-side verbs (built 2026-09-20 against a5425a2)

Contract: shared-signals stack/api/openapi.json and stack/api/README.md at a5425a2. Fixed verbs registered in the extension, each one HTTP call: record_list, record_get, record_create, record_update, resolve_id, open_approval_request, get_approval_request, create_document. add_approval and bind_approval_message are the connector's, not the model's; get_counters is left out; the contract has no prose search or document read, so search_prose and get_document from the plan are not built. Paths, bodies and codes come from the contract. Arguments are checked in the client before any request (record type from the five, AA-1 ids, lower snake case property names, size caps); a write outside a turn is refused. record_update carries the revision from record_get; 409 renders the current revision and the changed fields. Writes send context (turn id, requester id and name, client version), audit only. Output caps: a record at 6000 characters, a property value at 500, a list at 50 items.

The extension now reads the author id and the message id from the envelope as well as the requester, and the tool set keeps them as the turn (setTurn); the write key is <principal>:<message id>:<call index> with the index counting every call in the turn.

Evidence per approval (coordinator's ruling, 2026-09-20): the service's source_url is one Discord message url unique to the approver and the export validator rejects a shared url or a fragment. A reply is its own evidence. For a button press the connector first posts a confirmation line in the request's channel and submits its url and exact text as source_url and statement, with message_id and bound_message_id both the request message; if the post fails nothing is submitted and the approver is told to press again. The ledger keeps the evidence id and statement so a retry on start submits the same evidence.

2c. Why the connector posts the approval message

The model's tool call runs mid-turn, before the connector delivers the reply, so no message id exists yet for open_approval_request to carry. The verb therefore creates the request without a message; the connector posts the message after the turn and binds its id to the request. That needs three server operations the plan's prose folds into one: open the request (model, returns request id and required approvers), bind a message id and channel to it (connector), add an approval (connector, with request id, author id, message id and the bound message id). Sent to the coordinator on 2026-09-20 and adopted the same day: open_approval_request, bind_approval_message, add_approval (codes not_open, not_approver, already_approved, message_mismatch, request_stale) and get_approval_request for reconciliation.

3. Not built

No NocoDB or Outline token in Sage. No delete verb. No prose update or append. No free-form HTTP: the base url and every path are fixed. No approval by the model's word. No approval from an author outside the request's required approvers. No key on a command line or in any journal.

4. Evidence

  • packages/discord/tests/setspark.test.mjs: config validation (bad url, http, path in url, key file mode, missing), key read per call through a fake server (rotation between two calls works without restart), idempotency key shape, 409 and 422 rendering, cap on server text, timeout, no key in any journal or output.
  • packages/discord/tests/approvals.test.mjs (8 tests): request validation and rendering (names, never ids), ledger append and fold, reply resolution (exact approve, wrong text, wrong message, wrong author, second approval, one in flight), button resolution (wrong message, wrong custom id, wrong author), connector flow with fake rest and fake api: message posted with the button, opened and bind recorded, one approver by reply and one by button, message edited and button disabled, fixed lines and drop entries for a non-approver, a repeat and a foreign custom id, a service refusal retried, an invalid request and a refused post recorded and nothing bound, no api client, start retrying an unknown bind and approval under the original keys.
  • Part 2a on 2026-09-20: node tests 157 pass, scripts/test-discord.sh 62 passed, unslop clean.
  • Part 2b on 2026-09-20 (/tmp/suites/discord-2b.log): node tests 162 pass, suite 63 passed. New: tests/setspark.test.mjs verbs through the tool set against a local server playing the contract (keys per call index, context on writes, no key on reads, 409 rendering, list query string, request view rendering, no api key in any result), no turn refuses every write before a request, twelve bad-argument cases refuse before a request, renderRecord caps; the connector client (integer request ids, bind and approval bodies, button and reply kinds, a 404 as a refusal); tests/approvals.test.mjs asserts the confirmation line, its url and text on the button call, the reply's own url and text, and the retry with the ledger's evidence; tests/context.test.mjs the SetSpark paragraph without the base url; the suite's real-pi probe with a setspark key lists the reads and the eight verbs and never shows the key.
  • Review by rev-code-02 on #1509 (D7), local commit after approval.
  • Live: Jason asks Sage in #sage-admin to create one work item; the API shows it with revision 1; the turn record shows the verb, key and revision. Then a proposal; the button approves it; the audit row carries Sage's key id and Jason's Discord id separately.

5. Boundaries

Push only on Jason's word. The binding's setspark key is fixed (stop and start). The seat's API key file is written by the infrastructure seat, never by this session. Cutover of record authority is a separate row.