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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user