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]>
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: thesetsparkkey 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 409current_revisionandchanged_fields) becomes a refusal rendered fromcodeand 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.jsonlunder the binding's journal directory, appended only:opened(request id, decision id, proposal version, digest, required approver ids, the posted message id and channel),bindandapproval(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;startretries the unknown ones under their original keys. Resolution: a reply whose referenced message is an open request and whose content is exactlyapproveafter 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 adropentry. A second approval by the same author is ignored with a drop entry.packages/discord/src/rest.mjs:createMessageacceptscomponents(one Approve button);interactionCallbackanswers a component interaction within Discord's three-second window;editInteractionMessageedits the request message afterwards.packages/discord/src/connector.mjs:INTERACTION_CREATEjoins 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 includeopen_approval_request, the connector posts the request message with the fixed rendering (decision id, version, digest, who may approve, how) and the button, recordsopened, and binds the message id to the request through the API.- The API client used by the connector for
bindandadd_approvalis passed in likerest, 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 (exactapprove, 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,openedandbindrecorded, 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,startretrying an unknown bind and approval under the original keys.- Part 2a on 2026-09-20: node tests 157 pass,
scripts/test-discord.sh62 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.mjsverbs through the tool set against a local server playing the contract (keys per call index,contexton 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,renderRecordcaps; the connector client (integer request ids, bind and approval bodies, button and reply kinds, a 404 as a refusal);tests/approvals.test.mjsasserts 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.mjsthe 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.