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
+1
View File
@@ -256,3 +256,4 @@ are never rewritten or removed; corrections are new entries.
- 2026-09-14 UTC — coordinator (Claude) — Discord read-only tools (#1509, QUEUE row 21): `tools` binding key, host pi extension `list_dir`/`read_file`/`search` confined to declared roots, engine settles on `agent_end`, tool calls in the turn record; rev-code-02 round 2 APPROVE (26276) after four round 1 findings were fixed; suite 48/48, 116 node tests; committed locally, not pushed; live check in #sage-admin next.
2026-09-17T02:05:47Z | coordinator (Claude, #1509) | Row 23 Discord writes + web tools: built, round 3 pinned (comment 26361), SearXNG live on loopback, engine held-prompt defect fixed live; row 24 git verbs briefed with D5–D7 ruled | in review, uncommitted, no push
2026-09-18T12:46:17Z | coordinator (Claude, #1509) | Row 23 pushed (90cb31f5..1685deb4); row 24 git verbs, package credential helper, vault protocol and requester envelope built, suite 58/58, node 143, review requested from rev-code-02 | in review, uncommitted, no push
2026-09-21T00:20:45Z | coordinator (Claude, #1509) | Row 25 SetSpark client: part 2b built against shared-signals a5425a2 (eight model verbs, connector api client, button evidence by confirmation message per the coordinator), suite 63/63, node 162, review candidate frozen (aggregate 09140edc, tree 7872d8c5); Gitea 503 at post time, review request queued | in review, uncommitted, no push
+9 -2
View File
@@ -195,10 +195,17 @@ gains `git_status`, `git_commit` (explicit paths, seat author,
`Requested-by:` trailer, push at once), `git_pull` (ff-only) and
`git_push` (one branch, never force), with `reserve_id` under the vault
protocol; git runs with no host config and the package's own credential
helper reading a 0600 token file, never printed. `tools` is a fixed key: changing
helper reading a 0600 token file, never printed. `tools.setspark` names the
SetSpark record service and adds `record_list`, `record_get`,
`record_create`, `record_update`, `resolve_id`, `open_approval_request`,
`get_approval_request` and `create_document`, one fixed path each, writes
keyed per turn and call; approvals of its proposals are an Approve button
or an exact `approve` reply, checked by the connector against the
request's approvers, never by the model, with one Discord message per
approval as evidence. `tools` is a fixed key: changing
it needs a stop and start. Records
under `<dataRoot>/discord/<binding>/`: `inbox.jsonl`, `outbox.jsonl`,
`drops.jsonl`, `admissions.jsonl`, `notices.jsonl`, `reloads.jsonl`, write-once `turns/<id>.json`. Suite:
`drops.jsonl`, `admissions.jsonl`, `notices.jsonl`, `reloads.jsonl`, `approvals.jsonl`, write-once `turns/<id>.json`. Suite:
`scripts/test-discord.sh`.
Exit codes: 0 ok, 1 operation failed, 2 invalid data or configuration, 3
refused by a brake (a supervisor must not retry), 4 usage. Details:
@@ -0,0 +1,173 @@
# 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.
+1
View File
@@ -373,3 +373,4 @@ git history + Gitea issues.
- 2026-09-16 (coordinator, #1509 row 23): part 2 web built: `src/web.mjs` with `webFetch` (https only, public addresses only, connection pinned to the vetted address, three re-vetted redirects, 1 MiB cap, html to text) and `webSearch` (SearXNG json, ten results); enabled only when the binding `tools.web` key is set. The full-suite hang was a race in `tests/engine.test.mjs` (busy asserted before `agent_settled`, fake pi never stopped); the test now waits for the settle and stops in `finally`. Suite 52/52, node 128. Round 2 pinned for rev-code-02 (comment 26358, aggregate 287af5da, tree 1721584c). Next: verdict, local commit of my 17 paths only, then SearXNG container on 127.0.0.1:8888 and the live check.
- 2026-09-16 (coordinator, #1509 row 23): part 3 live: SearXNG container `mosaic-searxng` (image searxng/searxng:latest, settings in the data root, formats html and json, limiter off) on 127.0.0.1:8888; binding `tools.web` added (backup in the sage evidence dir); check ok; service restarted. Jason's first turn in #sage-admin searched, fetched who.is, listed the folder and wrote `vault/Businesses/naming.md`. Defect seen in the same exchange: his second message during the turn went to pi as a follow-up, pi folded it into the same run, the first answer was never posted and the second failed as settled-without-turn. Fixed in `engine-pi.mjs` (held prompts, one run each), fake pi now models real follow-up semantics, suite 52/52 node 129, round 3 pinned (comment 26361, aggregate e30c2319, tree dbd2ce9a), service restarted with the fix. Row 24 rulings: D5 seat identity with `[email protected]`, D6 push every commit (Jason: not pushing means stale data), D7 rev-code-02.
- 2026-09-18 (coordinator, #1509 rows 23–24): row 23 committed as 1685deb4 and pushed (`90cb31f5..1685deb4`) at Jason's word. Row 24 built: `src/git.mjs` (git_status, git_commit with explicit paths, seat author and `Requested-by:` trailer, push after every commit per D6, git_pull ff-only, git_push one branch; guard for branch, detached head, in-progress operations and conflicts; index must be empty so Jason's terminal work is never swept), `bin/git-credential.mjs` (the package's own helper: `get` over https from the 0600 token file, since `git-credential-mosaic` serves only the Gitea hosts and the global config routes github.com to Jason's `gh`), git children run with no host config; vault protocol (`protocol: "vault"`): per-write clone lock, `check` and `validate_vault.py` before a commit, `reserve_id`; the connector writes `requester="<server name>"` into the envelope and the extension reads it on `before_agent_start`. Suite 58/58, node 143. Review requested from rev-code-02; binding change and live check follow the verdict.
- 2026-09-20 (coordinator, #1509 row 25): part 2b built against shared-signals a5425a2. Eight fixed verbs for the model (record_list, record_get, record_create, record_update, resolve_id, open_approval_request, get_approval_request, create_document), the connector's own client for bind and add_approval, the envelope's author and message ids read by the extension for per-turn write keys. Button evidence per the SetSpark coordinator: the connector posts a confirmation line and submits its url and text; a reply is its own evidence. Sage's key is minted (id sage-3ff47150, file outside the repo, never read here). Suite 63/63, node 162. Review candidate frozen for rev-code-02 (aggregate 09140edc, tree 7872d8c5); Gitea answered 503 when the request was posted, so the post is queued and retried. Slip: a stray `git stash` during doc checks stashed the tree for under a minute; popped at once and verified, 44 files back, tests green.
+5
View File
@@ -48,6 +48,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
| 21 | Discord connector: read-only tools for the Discord Sage through a Mosaic pi extension confined to declared roots (MVP iteration 6) | coordinator; reviewer per Q12 | #1509 | approved: rev-code-02 round 2 verdict 26276 (tree 43f0329b); committed locally; live check in #sage-admin with Jason next; Jason ruled R1–R7 2026-09-14 (roots docs/ and agents/sage/, Carmen included) | Sage answers a question from a file under a declared root with the reads in the turn record; a read outside the roots is refused and recorded | `2026-09-14_discord-readonly-tools.md` |
| 23 | Discord connector: writes confined to the `shared-signals` root plus web fetch and search for the Discord Sage (MVP iteration 7) | coordinator; reviewer per Q12 | #1509 | in review: parts 1 and 2 (writes, web fetch and search) built 2026-09-16, suite 52/52, node 128; round 3 pinned for rev-code-02 on #1509 comment 26361 (supersedes rounds 1–2; adds the engine held-prompt fix); part 3 done live 2026-09-17 (SearXNG container on loopback, binding web key, restart; first live turn searched, fetched and wrote vault/Businesses/naming.md); Jason ruled D1–D4 2026-09-16 (SearXNG, Jason and Carmen write, any https host, rev-code-02) | Sage writes a naming shortlist into the repository from #ideas with the write and web calls in the turn record; a write outside the root is refused | `2026-09-16_discord-write-and-web-tools.md` |
| 24 | Discord connector: git verbs (status, commit, pull ff-only, push) for the Discord Sage on the `shared-signals` root, seat identity through the existing credential helper (MVP iteration 8) | coordinator; reviewer per Q12 | #1509 | done 2026-09-18: built (src/git.mjs, bin/git-credential.mjs, extension params, envelope requester, context paragraph, vault protocol via reserve_id and per-write locks); suite 58/58, node 143; rev-code-02 APPROVED round 1 (#1509 comment 26375, tree 82ab962f); D5 mechanism changed: the fleet helper declines github.com, so the package ships its own credential helper reading the 0600 seat token file; binding change and live check next | Sage commits and pushes a decision file from #ideas; GitHub shows Sage as author with a Requested-by trailer; no token in any record | `2026-09-16_discord-git-tools.md` |
| 25 | Discord connector: SetSpark record client for the Discord Sage, fixed verbs against setspark-api, connector-verified approvals (MVP iteration 9) | coordinator; reviewer per Q12 | #1509 | in progress 2026-09-20: Jason's go relayed by the SetSpark coordinator (plan v3.2, shared-signals 55b2515); part 2a built (config, key read per call, HTTP core, approval ledger, Approve button and `approve` reply resolution, bind and approval submission with restart retry); part 2b built against shared-signals a5425a2 (eight model verbs, connector client, button evidence by confirmation message); node 162 pass, suite 63 passed; rev-code-02 APPROVED round 1 (#1509 comment 26467, tree 7872d8c5); binding change and live check next | Sage creates one work item from #sage-admin and the API shows it with revision 1; a proposal is approved by the Approve button and the audit row carries Sage's key id and Jason's Discord id separately; rev-code-02 approves on #1509 | `2026-09-20_discord-setspark-client.md` |
Start message for row 6, sent from the board to darkwing:
"Read docs/plans/QUEUE.md, then the plan page section "Piece 5: darkwing on
@@ -104,3 +105,7 @@ Gate F or when blocked."
- 2026-09-16 — coordinator: row 23 part 3 live (SearXNG container mosaic-searxng on 127.0.0.1:8888, binding web key, restart); Jason's first web turn worked end to end but exposed a live defect: a second message during a turn was sent as a pi follow-up and lost the first answer. Engine now holds it until pi settles. Suite 52/52, node 129; round 3 pinned as #1509 comment 26361; service restarted with the fix. Row 24 rulings D5–D7 recorded.
- 2026-09-17 — coordinator: shared-signals-05 briefed the new id registry and file lock protocol (jetrich/shared-signals 8f0d946); folded into the row 24 brief section 6 (validate before commit, reserve_id tool, per-write clone lock, explicit-path staging). No row changed.
- 2026-09-18 — coordinator: row 23 pushed on Jason's word ("push as well"): refactor 90cb31f5..1685deb4, seven commits, identity jarvis. Row 24 built and under test: four git verbs plus reserve_id, package credential helper (the fleet helper cannot serve github.com), requester in the envelope, explicit-path staging; suite 58/58, node 143; review requested from rev-code-02 on #1509.
- 2026-09-20 — coordinator: SetSpark plan v3 reviewed (Phase 2 rules, Phase 3 verbs); all eight objections from the v2 review adopted; answered "phase 3 ok" with two non-blocking notes. Write target is setspark-api at api.setspark.io; row 25 brief waits on the accepted plan.
- 2026-09-20 — coordinator: plan v3.1 committed on shared-signals main (361380c). Approval flow for row 25: open_approval_request on proposal post; the connector resolves the Approve button or an exact "approve" reply, checks the required-approver list, and submits request id, author id and message id. REF-045 conflict cleared by the REF-047 renumber (9287d49); row 24 live check unblocked. Phase 3 code waits on Jason's go.
- 2026-09-20 — coordinator: Jason's go for phase 3 relayed (plan v3.2, shared-signals 55b2515). Row 25 part 2a built: setspark.mjs (config, key read per call, HTTP core, refusal rendering), approvals.mjs (ledger, reply and button resolution), connector posts the request message with the button, binds it, submits approvals, retries unknown ones on start. Tests 157 node, suite 62. Part 2b waits on stack/api/openapi.json. Contract needs (open without a message, bind, add_approval with request id, author id, message id) sent to the coordinator and adopted, with bound_message_id added on add_approval and a request_stale code; openapi.json being regenerated.
- 2026-09-20 — coordinator: contract committed (shared-signals a5425a2). Row 25 part 2b built: eight model verbs (record_list, record_get, record_create, record_update, resolve_id, open_approval_request, get_approval_request, create_document; get_counters left out), the connector's api client (bind, add_approval with kind button or reply, get), the envelope's author and message ids read by the extension for the write keys. Coordinator's ruling on button evidence: the connector posts a confirmation line and submits its url and text as source_url and statement; a reply is its own evidence. Node 162, suite 63. Next: rev-code-02 review on #1509, then a local commit.