fix(discord): row 25 approvers are user names, never Discord ids in tool text (#1509)

Jason's live check after the 20:58Z restart posted no Approve button. The
Discord Sage wrote DEC-009's required_approvers as names; the SetSpark
service stores approvers as discord:<id> and accepted the names, and the
connector correctly refused the approval request ("bad approver id").

- binding.mjs derives setspark.approvers from the binding's users (name to
  id); a binding-set approvers key and duplicate names are refused. With
  setspark set, a user id or name change refuses the reload (pi's approvers
  are fixed at start).
- setspark.mjs: record_create/record_update map required_approvers names to
  discord:<id> and refuse unknown names, ids, duplicates and non-lists
  before any request, without echoing the value. hideIds turns mentions,
  discord: values and standalone 17-20 digit runs into the user's name or
  "unknown user" in every verb's text and refusal, including the service
  message and code before they are cut. The connector's approval request
  keeps the bare ids.
- tests: boundary test over nested, keyed, numeric, mention and cut ids;
  a local contract fixture from create through validateRequest, with the
  old name-stored shape still refused.

Rocko: R1 revise, R2 revise, R3 approve (81379830..., report da75219f...).
Suites on an index export: 24/90/43/17/14/15/63/18; Discord node tests 173/173.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-26 16:30:11 -05:00
co-authored by Claude Opus 5.5
parent 1c5f6bc3a0
commit 20ea5a0b64
11 changed files with 1651 additions and 26 deletions
@@ -0,0 +1,78 @@
# Row 25 approver names R2 — review
Verdict: **revise**, one remaining medium finding. Rocko, 2026-09-26.
Packet and staged diff verified as
`7da44a5975e6261dc524431df2a3d3f9093e064d26d67e9b1f5926467d9c00f9`.
## 1. Medium — two error paths still cut identities before sanitization
The fix correctly hides service messages before their 400-character cut,
and hides successful output deeply before its field/render limits. But:
- callApi still takes `err.code.slice(0, 64)` before renderRefusal hides
identities (setspark.mjs line 265).
- needObject and record_list's filter-name refusal still interpolate
`k.slice(0, 32)` before renderRefusal (lines 342 and 427).
**Executed reproductions:** with synthetic id `100000000000000100`:
1. A local fake service returns HTTP 422 with code consisting of 49 x's,
a space and the id. The 64-character cut retains 14 digits. Final text:
```text
refused: the record service refused the request (code xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 10000000000000)
refused
```
2. record_create receives an invalid property name consisting of 17 x's,
a space and the id. No request is sent. Final refusal:
```text
refused: the call's arguments are not valid
record has a property name that is not allowed: xxxxxxxxxxxxxxxxx 10000000000000
```
The filter-name diagnostic has the same cut-before-hide pattern. These
14-digit fragments survive because hideIds correctly targets complete
17–20 digit tokens. This is the same truncation problem R2 explicitly fixes
for service messages and tests for 12–16 digit remnants; the other early
cuts were missed.
**Fix:** sanitize the service code before its length cap, while retaining
any raw code needed privately for reason classification. For invalid
property/filter names, prefer a fixed or position-based diagnostic that
does not echo the invalid value, as already done for approvers. Alternatively
sanitize the complete value before slicing. Add regression cases for both
code and property/filter-name cut boundaries. Do not widen the final regex
to redact arbitrary shorter numbers; fix the operation order.
## Previous findings and requested checks
The rejected-approver echo is closed. All eight success renderers share the
new boundary, including resolve/document output. Embedded mentions, nested
keys/values and numeric values are handled before successful output is cut.
The deliberate longer-token exception is documented; this is a text-format
rule, not semantic recognition of every possible identity encoding.
The contract fixture now connects name-based create, stored discord:<id>,
bare-id approval view and validateRequest. It also retains the malformed
legacy-name refusal. The raw connector request remains intact; sanitization
operates on the rendered copy. That separation is correct. Existing bad
records still need a separately authorized correction.
The restart requirement for changed name/id mappings remains acceptable:
Pi's map is fixed at startup and hot-reloading only the connector would
split authority views. Channel changes remain reloadable.
## Verification
`timeout 30s node --test packages/discord/tests/setspark.test.mjs`:
16 passed, zero failed, exit 0. Both additional failures above were executed
independently, using a disposable local HTTP server and fake key for the
service case and a direct pre-send refusal for the property-name case.
Temporary fixtures were removed. No live token or service was accessed.
Only this report was written in the repository. No source/index edits,
commit, push or restart. This remaining finding should be fixed before the
approval requested for R2.
@@ -0,0 +1,40 @@
# Row 25 approver names R3 — review
Verdict: **approve**. Rocko for Sage, 2026-09-26.
Packet and complete staged diff both verified as
`81379830715f48ff3c8e8dac1c1556925aa6b599f5ffc8a0ae231eb7a3b1c2a9`.
The reviewed source/test working files match the index. Compared R3 with
the pinned R2 packet: changes are limited to the three truncation fixes
and their test fixtures/assertions.
The remaining R2 finding is closed. Service error codes now pass through
hideIds before the 64-character limit. Invalid record-property and filter
names pass through it before the 32-character limit. Their null config
correctly yields unknown user rather than echoing an identity. The final
render/refusal passes remain, and the regex was not broadened to hide
arbitrary shorter numbers. The new tests exercise all three early-cut
paths and check the resulting tool text for full ids and digit remnants.
The other slice sites do not reopen this finding: list/match slices limit
array counts; successful text limits follow hideDeep; service message cuts
follow hideIds. The approver-name config validation diagnostic is a startup
configuration error, not a model-facing tool return. The documented
longer-token exception remains the scope of the text-hiding rule.
The previous name mapping, fixed-config reload restriction, raw internal
approval request and connected service-contract fixture remain acceptable.
This approval does not repair existing malformed records automatically or
prove a live service round trip; Jason's planned acceptance check supplies
that evidence after the authorized integration/restart.
Independent verification:
`timeout 30s node --test packages/discord/tests/setspark.test.mjs`
returned 16 passed, zero failed, exit 0. This includes boundary and approval
contract fixtures. Sage's 173-test run, index-export suites and live check
are author evidence, not independent reruns. The disclosed Docker pool
failure and cleanup do not change the candidate reviewed here.
Only this report was written. No source/index edits, commit, push, live
credential access, Docker action or restart. Approval applies to the pinned
R3 diff; Sage owns the authorized integration and restart.
@@ -0,0 +1,104 @@
# Row 25 approver names fix — review
Verdict: **revise**. Rocko for Sage, 2026-09-26.
Packet and complete staged diff both verified as:
`c17e15cd2a7e398e72a1d4c5e69e35f42255facb621bde7aa786163a4f00c9ed`.
The name-to-identity write mapping addresses the reported malformed
approver failure, but the stated model-visible ID boundary is incomplete.
## 1. Medium — rejected raw IDs are echoed back to the model
`approverIds` embeds the rejected value in the bad-argument message.
`renderRefusal` prints that message. Executed with a synthetic id:
```text
refused: the call's arguments are not valid
required_approvers: "discord:100000000000000100" is not a known user; use names from: jason
```
No request or key read is needed for this reproduction. The existing raw-id
test asserts only `ok === false`, not that the returned text omits the id.
Server refusal messages, codes and changed_fields are also rendered without
this new mapping, so a service rejection that echoes an approver can expose
it even when the model supplied only a name.
**Fix:** never echo a raw identity in the invalid-name diagnostic; provide
known names and a fixed reason. Apply the intended model-facing identity
policy to all rendered refusal fields, including service error text. Test
the final tool text, not only the verb's rejection status.
## 2. Medium — namesFor matches whole string values only, and only two verbs use it
The anchored regex converts exact bare or discord-prefixed string values.
It does not remove an id inside prose, a Discord mention, an object key,
or a numeric JSON value. A local fake service returned a record containing
an exact required_approvers value plus nested text and a body mention.
`record_get` produced:
```text
nested: {"text":"approver discord:100000000000000100"}
required_approvers: jason
body:
Ask <@100000000000000100>
```
Thus the happy-path assertion for the simple required_approvers field does
not establish “no Discord id reaches the model.” `resolve_id` renders match
titles without mapping, and create_document renders title/URL without it.
Approval view rendering also interpolates service fields directly. Record
create/update return terse receipts, so their unconverted raw records are
not themselves proof of a current approver-list text leak; assess the final
rendered surface instead of merely the intermediate object.
**Fix:** centralize model-facing rendering/sanitization across all SetSpark
success and refusal text, with a defined rule for known identities,
unknown identities and embedded Discord mentions/prefixed values. Cover
nested fields and keys, numeric identity values, embedded text, resolve
results, document receipts and service errors in boundary tests. If the
intended promise is narrower than every Discord id in arbitrary service
text, state that narrower promise explicitly instead of claiming complete
ID concealment. Do not sanitize the connector's internal approval request
ids into names: that would break its authorization check.
## Requested checks that are fine
**Reload refusal is acceptable.** The derived map is part of tools, which
Pi receives once at startup. Allowing name/id edits only on the connector
side would split the identity views. Refusing those reloads until restart
is conservative and documented; channel-only changes remain reloadable.
JSON equality can also refuse user reordering or equivalent map insertion
orders, and case-only normalized names may compare unchanged. Those are
availability/normalization details, not authority widening. An urgent user
removal requires an operator stop/restart under this policy, not an assumed
successful hot reload.
**Keep the bare-id connector path.** Under the stated service contract,
create/update sends discord:<id> in record properties and the service's
approval-request view returns bare snowflakes. open_approval_request copies
those into request.approvers; setsparkDetails carries that request through
tool details to the engine/connector, and validateRequest requires 1..16
unique bare snowflakes. That is the correct internal representation.
Model-facing prose must be handled separately. If the service returns
names or prefixed ids instead, refusing is correct; do not weaken
validateRequest to make malformed views pass.
The added tests prove name mapping, rejection before requests, round-trip
configuration and reload restrictions. They do not prove the whole
create-decision → service view → connector validation/button path against
the service normalization contract. Add a local contract fixture connecting
those stages, while retaining malformed-view negatives. Existing malformed
records such as DEC-009 will not be repaired automatically by this code;
a separately authorized update can now express their approvers as names.
## Evidence and boundaries
Independent `timeout 30s node --test packages/discord/tests/setspark.test.mjs`:
14 passed, zero failed, exit 0. The two leaks above were independently
executed with synthetic ids and a temporary local HTTP fixture. Only a
throwaway fake key was read; no live credential, service or pane was used.
The temporary fixture was removed. Staged diff hash remained unchanged.
Only this report was written in the repository. No source/index edits,
commit, push or restart. The approval/restart gate should remain held for
these output-boundary fixes or an explicit narrower requirement.