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]>
This commit is contained in:
2026-09-22 12:59:39 -05:00
co-authored by Claude Fable 5.1
parent 1949ed8d31
commit 43d7574d6a
24 changed files with 2178 additions and 32 deletions
+58 -2
View File
@@ -139,7 +139,7 @@ is `src/binding.mjs`.
| `engine` | `provider`, `model`, `thinking` for pi |
| `limits` | `turnsPerDay` (200), `turnTimeoutSeconds` (180), `replyChunkChars` (1900), `inboundMaxChars` (4000) |
| `context.files[]` | files appended to pi's system prompt in order, repository-relative and inside the repository (no absolute paths, `..` or symlinks); the Discord block is added after them |
| `tools` | optional. `roots[]` of `{name, path, write?, git?}`: absolute directories the seat may read through `list_dir`, `read_file` and `search`; a root with `"write": true` may also be written through `write_file` and `edit_file`; a writable root that is a git work tree may carry `git` `{branch, identity, tokenFile, author, protocol?}` and gains `git_status`, `git_commit`, `git_pull` and `git_push` (`protocol: "vault"` adds `reserve_id`); `maxFileBytes` (262144), `maxCallsPerTurn` (8); `web` (optional) `{searxng, maxFetchBytes}` enables `web_fetch` and `web_search` through the named SearXNG instance (https, or http on loopback; `maxFetchBytes` 1048576). Absent means no tools and a pi launch with `--no-tools`. A root may not be `/`, the home directory, a symlink, a path with a dot-prefixed segment, or anything inside or above the data root |
| `tools` | optional. `roots[]` of `{name, path, write?, git?}`: absolute directories the seat may read through `list_dir`, `read_file` and `search`; a root with `"write": true` may also be written through `write_file` and `edit_file`; a writable root that is a git work tree may carry `git` `{branch, identity, tokenFile, author, protocol?}` and gains `git_status`, `git_commit`, `git_pull` and `git_push` (`protocol: "vault"` adds `reserve_id`); `maxFileBytes` (262144), `maxCallsPerTurn` (8); `web` (optional) `{searxng, maxFetchBytes}` enables `web_fetch` and `web_search` through the named SearXNG instance (https, or http on loopback; `maxFetchBytes` 1048576); `setspark` (optional) `{baseUrl, keyFile, principal?}` names the SetSpark record service (https origin, or http on loopback; key file absolute, 0600, read per call, never printed) and turns on connector-verified approvals. Absent means no tools and a pi launch with `--no-tools`. A root may not be `/`, the home directory, a symlink, a path with a dot-prefixed segment, or anything inside or above the data root |
Unknown keys, missing fields, wrong types, empty allowlists, a user channel
that is not listed and a bot listed as a user all refuse with exit 2. A
@@ -268,6 +268,59 @@ record. These scripts belong to the shared-signals repository, are run as
fixed argv inside the root with the seat as owner, and never through a
shell. The tests stand in small Python scripts with the same command line.
SetSpark record client (row 25, `src/setspark.mjs` and
`src/approvals.mjs`, no dependencies). `tools.setspark` is validated at
load like a git key. One HTTP core, `callApi`, sends JSON with
`Authorization: Bearer` from the key file (one bare key line, or the
mint's JSON output with its `key` field, stored as minted; re-read and
re-checked on every call, so a rotation needs no restart), a fixed User-Agent, a 15 s timeout
and a 256 KiB response cap, no redirects. A write carries an
`idempotency-key` header, `<principal>:<turn id>:<call index>` for the
model's verbs and `<principal>:<event id>:<step>` for the connector's own
calls. Error bodies become refusals rendered from the status, the `code`
and, on 409, `current_revision` and `changed_fields`; free text from the
server is cut at 400 characters.
The model's verbs (contract: shared-signals `stack/api/openapi.json` at
a5425a2), each one fixed path, registered by the extension only with a
`setspark` key: `record_list`, `record_get`, `record_create`,
`record_update` (carries the revision from `record_get`; a stale one is
refused with the current revision and the changed fields), `resolve_id`,
`open_approval_request`, `get_approval_request`, `create_document`
(Outline). `get_counters` is left out. Arguments are checked before any
request (record type from the fixed five, ids `AA-1` shaped, property
names lower snake case, a record under 32 KiB, a document under 20000
characters); a write outside a turn is refused, since no key can be
formed. Writes carry a `context` object (turn id, the asserted requester's
id and name, the client version) that the service records next to the
verified key and never uses for authorization. A record renders as
`key: value` lines cut at 6000 characters; a list shows at most 50.
Approvals. The model never asserts an approval. When a turn's tool calls
include a successful `open_approval_request`, the connector posts the
proposal as its own message (decision id, version, digest, who may
approve, how) with an Approve button, appends `opened` to
`approvals.jsonl`, and binds the message id to the request at the service.
An approval is that button (`INTERACTION_CREATE`, answered with a deferred
update, then the message is edited to "Approved by <names>." and the button
is disabled once every approver has approved) or a reply to that message
whose content is exactly `approve`. The author must be one of the request's
approvers; anyone else gets one fixed line (private for a button, a reply
for a message) and a `drop` entry, and so does a repeat. The service wants
one Discord message per approval as evidence, its url and its exact text.
A reply is its own evidence. A button press has none, so the connector
first posts a confirmation line in the same channel ("Approval: <name>
approved DEC-012 v2 (digest 01234567) by button.") and submits that
message's url and text; if that post fails, nothing is submitted, the
request message says to press again, and a `drop` entry records it. The
connector submits kind (button or reply), request id, author id, message
id, the bound message id, the evidence url and the statement; the service
verifies against what it stored. Every bind and approval is journaled as
intent before the call (with the evidence message id and statement) and
done, refused or unknown after it; `start` retries the unknown ones under
their original keys and the same evidence. An approval reply never reaches the model;
any other reply to the request message does.
## What happens to a message
1. The gateway delivers `MESSAGE_CREATE`. `authorize` drops it unless the
@@ -321,6 +374,7 @@ start and names the nonces.
<dataRoot>/discord/<binding>/STOP stop switch
<dataRoot>/discord/<binding>/notices.jsonl once-per-day fixed lines already attempted
<dataRoot>/discord/<binding>/reloads.jsonl one line per reload attempt, applied or refused
<dataRoot>/discord/<binding>/approvals.jsonl open approval requests, binds and approvals, appended only
<dataRoot>/discord/<binding>/run.lock/ ownership directory (atomic mkdir) with owner.json {pid, start, boot}; stale ones need `unlock`
<dataRoot>/sessions/discord-<binding>/ the pi session, continued across runs
```
@@ -336,7 +390,9 @@ scripted stand-in for pi over stdio, a disposable data root. Groups: binding,
authorization table, gateway (hello, identify, heartbeat, missed ack, op 7,
op 9, close 4014), delivery and reconcile, engine (held prompt, timeout,
malformed line, tool runs), restart replay, stop and ceiling, tools
confinement. The suite also starts the real pi offline three times, with no
confinement, SetSpark config, key read and HTTP core against a local server
playing the service, approvals (ledger, reply and button resolution, the
connector flow with a fake api). The suite also starts the real pi offline three times, with no
model call, to show the extension exposes exactly the three tools, the pilot
flags expose none, and a missing `MOSAIC_DISCORD_TOOLS` makes pi exit.