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]>
174 lines
9.7 KiB
Markdown
174 lines
9.7 KiB
Markdown
# 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.
|