polyc-tools 2026.9.0

The in-process tool core for polychrome agents: local executors (coding, web fetch, wallet, ...), the tool registry, and MCP composition. The networked connectors live in polyc-connectors.
//! Spec for the agent-evaluable admin `unlink_identity` tool (`#1599`).
//!
//! Sibling of `revoke` (`#713`), but narrower: `revoke` severs EVERY identity
//! under a target's persona and tombstones it; this tool severs exactly ONE
//! named identity, leaving the persona and its other identities untouched.
//! An admin asking in natural language — "unlink @heavygweit", "remove that
//! test Slack handle from Christopher's persona" — should be handled this
//! way rather than reaching for `revoke`, which would take down the whole
//! persona.
//!
//! Distinct from the self-service `unlink_self` (`crate::unlink_self`): that
//! tool acts ONLY on the CALLER's own persona, unlinking either their own
//! linked email or their own linked wallet by a `target` argument. This tool
//! is admin-gated and targets a named PLATFORM identity (a Slack/Telegram/etc.
//! account) on any persona — the cleanup path for stray test/demo identities
//! an edge minted by first contact. A request to unlink a platform account
//! belongs here; a request to unlink the caller's own email or wallet does
//! not — a live incident hit exactly this confusion: a plainly-worded
//! request to unlink a platform identity was misrouted to the (now-removed)
//! separate `unlink_email`/`wallet_unlink` tools, executing an unrelated,
//! ungated mutation both times, because this tool's own spec previously read
//! as unusable for anything but a real Slack mention.
//!
//! The target id need not come from live mention markup: an admin may type
//! the platform-native id directly (e.g. a seeded test identity Slack can
//! never auto-linkify, since it names no real workspace member). What must
//! NEVER happen is resolving a display name or handle to an id by guessing —
//! that risk, not the id's literal source, is what the argument description
//! guards against.
//!
//! Like `revoke`/`demote` it has no in-process implementation: the
//! conversation sandbox can't reach the persona store. The harness advertises
//! it via the same control-plane proxy; the control plane runs it
//! (admin-gated). The unlink itself always requires an explicit human
//! approval naming the exact target, same as `revoke`.

use polyc_llm::ToolSpec;
use serde_json::json;

/// The `unlink_identity` tool name.
pub const TOOL_NAME: &str = "unlink_identity";

/// Every unlink-identity tool name, for allowlist checks and dispatch (one,
/// today).
pub const ALL: &[&str] = &[TOOL_NAME];

/// The required argument: the target identity's provider-native user id.
///
/// Usually lifted from mention markup already in the agent's input — same
/// field name as `invite`/`revoke`/`demote` — but a literal typed id (e.g.
/// naming a seeded test identity with no real Slack member to mention) is
/// equally valid; see [`unlink_identity_spec`]'s doc for why.
pub const ARG_TARGET_USER_ID: &str = "target_user_id";

/// Every unlink-identity tool spec.
#[must_use]
pub fn all_specs() -> Vec<ToolSpec> {
    vec![unlink_identity_spec()]
}

/// `unlink_identity` spec — an admin removes ONE identity from a persona,
/// leaving the rest of it untouched.
///
/// Admin-only: the control plane refuses a non-admin caller and removes
/// nothing. Not read-only (it mutates the target persona's identity set) and
/// not egress. No secret is ever produced — the tool result is a plain,
/// codeless confirmation.
#[must_use]
pub fn unlink_identity_spec() -> ToolSpec {
    ToolSpec::new(
        TOOL_NAME,
        "For an admin only: remove ONE specific linked PLATFORM identity (a Slack, Telegram, \
         or other provider account) from whatever persona it belongs to, leaving every other \
         identity and the persona itself untouched. Use it when an admin asks to unlink, \
         disconnect, or remove one specific linked platform account — for example \"unlink \
         @heavygweit\", \"remove that test Slack handle\", or \"remove U0PERSONADEMO2 from my \
         persona\" — as opposed to removing someone's access entirely (that's revoke), or \
         removing the CALLER's OWN linked email or wallet (that's unlink_self, not this tool). \
         Pass the target's real platform \
         user id: verbatim from mention markup (`<@U...>`) when the message names them that \
         way, or the literal id itself when the admin types it out directly (e.g. a seeded \
         test identity with no real Slack member to mention) — but NEVER a display name or \
         handle you would have to resolve or guess into an id. This refuses to unlink an \
         identity that is the only way its persona is reachable (unlink would leave zero \
         identities) — use revoke instead if the intent is to remove that persona's access \
         entirely. If the person asking isn't an admin, it returns a refusal rather than \
         unlinking anything.",
        json!({
            "type": "object",
            "properties": {
                ARG_TARGET_USER_ID: {
                    "type": "string",
                    "description": "The target's provider-native platform user id — verbatim \
                        from mention markup (`<@U...>`) when present, or the literal id as \
                        typed by the admin. Never a display name or handle to resolve or guess."
                }
            },
            "required": [ARG_TARGET_USER_ID],
            "additionalProperties": false
        }),
    )
    .titled("Unlink one identity from its persona (admin)")
}