Files
ironclaw/scripts
Benjamin Kurrek b215bf0651 feat(slack): bind the remaining eight core standard messaging ops (#7515)
* feat(slack): bind the remaining eight core standard messaging ops

Slack bound 8 of the 16 core standard messaging operations. The other
eight were a named fast-follow the framework design spec deferred
(§13 "Deliberately not built"); this lands them, so Slack now covers
the full core vocabulary.

Manifest: eight new standard_op-bound [[tools]] — edit_message,
delete_message, add_reaction, remove_reaction, open_dm, get_message,
resolve_user, list_members — appended after the v2-era eight so the
projection-parity gate's positional comparison still holds.

Guest: vendor mechanics over chat.update, chat.delete,
reactions.add/remove/get, conversations.open/members/history/replies,
and users.list. Three behaviours worth a reviewer's attention:

- get_message has no Slack endpoint. It reads the conversation at the
  exact ts and falls back to the thread when the message is a threaded
  reply, accepting only an exact match — a near miss is unknown_message,
  never the neighbouring message.
- remove_reaction's optional emoji is implemented, not rejected. Slack's
  endpoint requires a name, so the omit-emoji variant reads the message's
  reactions and removes each one the connected account added. That is why
  reactions:read joins the grant, and why that variant returns no emoji.
- already_reacted / no_reaction are treated as success. Slack returns
  them only for a message it resolved, so the requested end state holds;
  an error would push the model into retrying a no-op. It also makes the
  removal loop converge on retry after a partial failure.

Scopes: the [auth.slack] union gains reactions:read, reactions:write and
im:write.

COMPATIBILITY: there is no scope-upgrade re-consent flow, so an account
connected before this change holds a token without the new scopes, and
Slack answers those three tools with missing_scope -> mapped to
messaging.permission_denied (a model-visible denial, deliberately NOT an
AuthRequired re-auth gate, since re-running OAuth would request the same
manifest scopes). Widening an existing grant means disconnecting and
reconnecting Slack; the other 13 tools are unaffected. ROLLBACK: reverting
this commit restores the 8-tool surface and the narrower grant; already
-widened user tokens keep unused scopes, which is inert.

Gates: two pins were evolved rather than relaxed. The v2->v3 parity suite
gained a PackageAdditions declaration, so the v2-era eight still project
identically and positionally while the additions and the exact scope delta
are declared explicitly; the catalog scope pin moved from
assert!(matches!(..)) to assert_eq! so a future drift prints which scope
moved.

Tests: 24 guest unit tests — canonical input serde, output shapes, the
reaction authorship filter, exact-ts matching, the idempotent-success
arms, directory matching, the error taxonomy, and capability-id dispatch
— plus manifest-projection assertions that all 16 bind exactly the core
set with host-synthesized schema refs and external_write on writes. The
authorship filter and exact-ts matching were sabotage-verified, as were
both evolved gate assertions.

Not covered: the I/O orchestration inside each operation (which calls
run, in what order, and that a failed auth.test aborts the omit-emoji
removal) is unreachable without a host seam in the WASM guest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(slack): correct four guest edge cases and pin all eight new ops at the dispatch seam

Guest fixes (artifact rebuilt, digest re-recorded):
- reactions.get now sends full=true — without it Slack truncates per-reaction
  users arrays on popular messages and the omit-emoji remove_reaction could
  skip the connected account's own reaction while reporting success.
- open_dm validates user_ref with is_slack_user_id before calling Slack:
  conversations.open takes a comma-separated list, so an unvalidated
  "U1,U2" silently opened a group DM against the 1:1 contract.
- get_message's thread fallback pinches the range to the exact target ts
  (oldest=latest=ts&inclusive=true&limit=5) instead of scanning a fixed
  999-message page — finds a reply at any depth and stops shipping ~1MB
  pages to keep one message.
- resolve_user's users.list page size now equals the match limit (default
  raised to the full 200): Slack cursors are page-granular, so the old
  mid-page break dropped matches between the cap and next_cursor that no
  amount of paging could recover.
- next_cursor extraction deduplicated into one helper across all four
  paging ops; add_reaction's per-tool scopes drop reactions:read (it only
  calls reactions.add — the read scope belongs to remove_reaction alone,
  exactly as the scope table documents).

The manifest's scope-widening compatibility comment now describes the real
mechanism: a pre-widening account is refused by the HOST's provider-scope
gate at credential staging and lands on the AuthRequired re-auth gate, which
reconnects the same account with the widened union (binding skips the scope
gate). Slack's own missing_scope only fires for server-side drift after
staging passed, and that maps to messaging.permission_denied.

Coverage (contradicting the "no host seam exists" claim this PR shipped
with): the existing host-runtime WASM harness drives the committed
slack_user_tool.wasm through invoke_capability with scripted egress, so the
conformance sweep now covers all sixteen ops, plus ten behavioral pins —
missing_scope-vs-AuthRequired layering, both reaction end-state codes, the
omit-emoji orchestration (ordering, full=true, ownership filter, fail-closed
identity), near-miss/threaded get_message flows, zero-egress input
rejections (blank query, empty emoji, malformed open_dm user_ref), missing
provider evidence, the list_members clamp, and loss-free resolve_user
paging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(e2e): classify the eight slack ops in the product-surface coverage map

The eight new slack.* capability ids were shipped unclassified, which is
what turned the "Validate product-surface evidence contracts" step red
(summary.missing == 8) and cascaded into the Reborn E2E roll-up.

- classifications.tested gains the eight ids.
- Eleven typed ProviderOperationCases: five writes with provider readback
  and cleanup against the never-reset Slack world (edit and delete seed
  their own subject message; remove_reaction seeds its own reaction as the
  connected account; open_dm proves idempotence by re-opening directly),
  get_message driving the history-miss -> thread-fallback path at the
  seeded reply, resolve_user with a natural empty, and proxy-served
  list_members pages (Emulate answers conversations.members POST-only at
  the pinned ref while the guest reads via GET, as real Slack allows).
- get_message's canonical output requires the message, so its empty class
  is the typed model-visible miss: ProviderOperationCase gains
  expected_status ("completed" default, "failed" for exactly this shape)
  and the runner asserts the declared status instead of hardcoding
  completed.

All three gate files pass under the hermetic wrapper (98 passed), and the
product-surface generator reports 131 capabilities, 0 missing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: update guidance for the sixteen-tool slack surface

- reborn-extension-surfaces no longer hardcodes a tool count for the slack
  manifest (the grep recipe is the source of truth) and names github as the
  plain schema-declaring exemplar now that every slack tool is
  standard_op-bound.
- standard-operations.md repoints slack_error_to_standard_code at the live
  crates/extensions path instead of the dead pre-restructure assets/ path.
- tests/e2e/CLAUDE.md stops pinning a literal capability count and defers
  to the generated product-surface report (131 as of this change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(e2e): declare the expected failed tool result on the get_message empty case

The mock-LLM trace replayer treats any failed capability result as a replay
error unless the step's request_hint names the expected failure — the same
mechanism the provider fault cases already use. slack_get_message_empty
declares expected_failed_tool_result_contains="messaging.unknown_message",
and the operation-case runner forwards it onto the synthesized trace; the
adjacent slack_list_members_empty failure in the provider-2-3 lane was
collateral from the poisoned replay state and needs no change of its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tests): widen the seeded slack accounts to the manifest scope union

The merge queue runs the root integration lanes that skip on PRs, and every
one of them seeded the slack account with the pre-widening eleven-scope
union. The manifest's [auth.slack] ceiling now includes reactions:read,
reactions:write, and im:write, so slack installs parked on the auth gate
(BlockedAuth) — the exact provider-scope-gate mechanism this PR documents,
biting its own lockstep fixtures. All six seeds (extension_delivery,
extension_runtime, delivery_user_journeys, tool_call, the slack lifecycle
group scenario, and the QA harness profile that reborn_qa_smoke_scenarios
composes) now carry the full union, and the group scenario's lockstep
comment names the write additions.

Locally green with Docker-backed postgres legs: extension_delivery 23/23,
extension_runtime 25/25, delivery_user_journeys 25/25, group_extensions
16/16, tool_call 38/38, and the bundled-extension-surface QA smoke.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 13:34:09 +00:00
..