Skip to content

refactor(channels): introduce ExternalThreadId newtype at channel boundary - #2685

Merged
ilblackdragon merged 8 commits into
stagingfrom
refactor/external-thread-id-newtype
Apr 20, 2026
Merged

ilblackdragon merged 8 commits into
stagingfrom
refactor/external-thread-id-newtype

Conversation

@ilblackdragon

Copy link
Copy Markdown
Member

Summary

PR 3 of 4 in the type-safety refactor series (enforcing .claude/rules/types.md).

  • Adds ExternalThreadId(String) newtype in crates/ironclaw_common/src/identity.rs with validation (non-empty, ≤ 512 bytes, no NUL), from_trusted escape hatch, #[serde(transparent)] wire compatibility, and no From<String> / From<&str> / Deref to force explicit boundary crossings.
  • Replaces Option<String> with Option<ExternalThreadId> on the channel-boundary structs: IncomingMessage.thread_id, OutgoingResponse.thread_id, PendingGate.scope_thread_id. Internal engine ThreadId(Uuid) is untouched.
  • Adds PendingGate::effective_wire_thread_id() so bridge/router stops repeating scope_thread_id.clone().or_else(|| Some(thread_id.to_string())) at every AppEvent::GateRequired / OnboardingStateDto::* build site.

This makes it a compile error to confuse a Telegram chat id or web-UI thread string with the engine's internal ThreadId(Uuid) — the exact shape behind #2349, #2444, #2517.

Test plan

  • cargo test -p ironclaw_common — 61 passed, 0 failed (includes new ExternalThreadId serde/validation tests)
  • cargo test --lib channels — 873 passed, 0 failed
  • cargo test — full unit-test suite passes
  • cargo clippy --all-features --tests — 0 warnings
  • cargo check --all-features — clean
  • cargo fmt --check — clean

Notes

  • AppEvent wire payloads keep thread_id: Option<String> (they are the channel-boundary JSON contract). Conversion happens via ExternalThreadId::into() / effective_wire_thread_id().
  • HookEvent, TuiEvent::Response, and the routine/heartbeat notification paths convert at the newtype boundary via .as_str().to_string() or from_trusted.
  • src/agent/thread_ops.rs::maybe_hydrate_thread still takes &str — a TODO is in place. Converting it requires moving SessionManager::resolve_thread's Option<&str> parameter in lockstep, which is the next PR's scope.

…ndary

External channel thread ids (Telegram chat id, web UUID, Slack thread_ts)
flow as raw Option<String> through IncomingMessage, StatusUpdate, and
pending-gate store. Wraps them in a validated ExternalThreadId so the
compiler distinguishes boundary-layer ids from the internal ThreadId(Uuid).

Maps to bug pattern from #2349, #2444, #2517 where thread-id confusion
crossed a layer silently.
Copilot AI review requested due to automatic review settings April 19, 2026 15:18
@github-actions github-actions Bot added scope: agent Agent core (agent loop, router, scheduler) scope: channel Channel infrastructure scope: channel/web Web gateway channel scope: channel/wasm WASM channel runtime scope: tool/builtin Built-in tools size: XL 500+ changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels Apr 19, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces a typed ExternalThreadId at the channels boundary to prevent mixing external channel thread identifiers with internal engine ThreadId(Uuid), and updates affected message/gate plumbing accordingly.

Changes:

  • Added ExternalThreadId (+ error/limits) as a serde-transparent newtype in ironclaw_common.
  • Replaced Option<String> with Option<ExternalThreadId> on channel-boundary structs (IncomingMessage, OutgoingResponse, PendingGate).
  • Centralized pending-gate “wire thread id” selection via PendingGate::effective_wire_thread_id() and updated call sites/tests.

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/ws_gateway_integration.rs Updates assertions to read ExternalThreadId as &str.
tests/telegram_auth_integration.rs Updates thread id assertions for newtype.
src/tools/builtin/message.rs Updates test assertions for newtype thread id.
src/gate/pending.rs Switches scope_thread_id to ExternalThreadId and adds effective_wire_thread_id().
src/channels/web/util.rs Updates metadata insertion to read thread id via as_str().
src/channels/web/tests/no_silent_drop.rs Updates test construction to use ExternalThreadId.
src/channels/web/server.rs Updates tests to assert on newtype thread id.
src/channels/web/platform/ws.rs Updates WS tests to assert on newtype thread id.
src/channels/web/mod.rs Converts ExternalThreadId to String for routing where required.
src/channels/wasm/wrapper.rs Passes optional thread id to WASM as Option<&str>.
src/channels/tui.rs Converts response thread id to Option<String> for TUI events; updates tests.
src/channels/signal.rs Updates tests to use ExternalThreadId in messages.
src/channels/relay/channel.rs Updates thread id extraction as Option<&str> via as_str().
src/channels/channel.rs Changes IncomingMessage.thread_id/OutgoingResponse.thread_id to ExternalThreadId and adds helpers.
src/bridge/router.rs Uses effective_wire_thread_id() and adjusts scope thread handling for newtype.
src/agent/thread_ops.rs Adds TODO comment about future ExternalThreadId plumbing.
src/agent/routine_engine.rs Wraps routine thread ids at boundary via from_trusted.
src/agent/job_monitor.rs Updates tests for ExternalThreadId.
src/agent/heartbeat.rs Wraps heartbeat thread ids at boundary via from_trusted.
src/agent/agent_loop.rs Converts ExternalThreadId to Option<String> for hook events.
crates/ironclaw_common/src/lib.rs Re-exports ExternalThreadId + related constants/errors.
crates/ironclaw_common/src/identity.rs Implements ExternalThreadId newtype, errors, constants, and tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/ironclaw_common/src/identity.rs Outdated
Comment on lines +264 to +266
#[error("external thread id exceeds {MAX_EXTERNAL_THREAD_ID_LEN} characters")]
TooLong,
#[error("external thread id must not contain NUL bytes")]

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExternalThreadIdError::TooLong says "exceeds … characters", but the validation uses s.len() which is a byte length in Rust (UTF-8). Please align the error message/docs with the actual constraint (bytes), or change the check to count Unicode scalar values if you truly mean characters.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 107160d. Updated error text and doc comments to say "bytes" (the check uses s.len() which is a byte count).

Comment thread crates/ironclaw_common/src/identity.rs Outdated
impl TryFrom<String> for ExternalThreadId {
type Error = ExternalThreadIdError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

impl TryFrom<String> for ExternalThreadId currently calls Self::new(value), which clones the string again inside new (to_string()). Consider validating value.as_str() and, if valid, returning ExternalThreadId(value) to avoid an extra allocation/copy.

Suggested change
Self::new(value)
Self::validate(value.as_str())?;
Ok(Self(value))

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 107160d. Extracted a private validate(&str) helper on both ExternalThreadId and McpServerName; TryFrom now consumes the owned String without the to_string() round-trip.

Comment thread src/channels/channel.rs Outdated
Comment on lines +172 to +182
/// Set the thread ID.
///
/// Accepts raw strings — the value is wrapped with
/// [`ExternalThreadId::from_trusted`] because callers are typically
/// channel adapters forwarding a value already emitted by their external
/// platform. The `conversation_scope_id` shadow mirrors the raw string.
pub fn with_thread(mut self, thread_id: impl Into<String>) -> Self {
let thread_id = thread_id.into();
self.conversation_scope_id = Some(thread_id.clone());
self.thread_id = Some(ExternalThreadId::from_trusted(thread_id));
self

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IncomingMessage::with_thread wraps the provided string with ExternalThreadId::from_trusted, which bypasses the newtype's validation. This method is used for external inputs (e.g. the HTTP webhook request thread_id), so NUL/empty/oversized values can still flow through. Consider adding a validating variant (e.g. try_with_thread(...) -> Result<Self, ExternalThreadIdError>) and migrating untrusted call sites to it, or have with_thread validate and introduce an explicit with_thread_unchecked for the escape hatch.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 107160d. Added try_with_thread (IncomingMessage) and try_in_thread (OutgoingResponse) validating variants returning Result<(), ExternalThreadIdError>. Migrated untrusted call sites: src/channels/http.rs (webhook, returns 400 on invalid) and src/channels/relay/channel.rs (logs warning, drops thread_id on invalid). Left with_thread/in_thread as trusted-path conveniences with updated docs pointing to try_ variants.

Comment thread src/channels/channel.rs Outdated
Comment on lines +315 to +322
/// Set the thread ID for the response.
///
/// Accepts raw strings — the value is wrapped with
/// [`ExternalThreadId::from_trusted`] because callers typically forward
/// an identifier that a channel adapter already accepted.
pub fn in_thread(mut self, thread_id: impl Into<String>) -> Self {
self.thread_id = Some(thread_id.into());
self.thread_id = Some(ExternalThreadId::from_trusted(thread_id.into()));
self

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OutgoingResponse::in_thread also uses ExternalThreadId::from_trusted, so callers can attach invalid external thread identifiers even though ExternalThreadId::new exists. If responses can be influenced by untrusted metadata (e.g. relays), prefer a validating setter (returning Result) and reserve from_trusted for clearly-internal sources.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 107160d. Added try_with_thread (IncomingMessage) and try_in_thread (OutgoingResponse) validating variants returning Result<(), ExternalThreadIdError>. Migrated untrusted call sites: src/channels/http.rs (webhook, returns 400 on invalid) and src/channels/relay/channel.rs (logs warning, drops thread_id on invalid). Left with_thread/in_thread as trusted-path conveniences with updated docs pointing to try_ variants.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces ExternalThreadId and McpServerName newtypes to provide typed boundaries and validation for external identifiers, replacing raw String usage throughout the agent, bridge, and channel modules. Review feedback highlights opportunities to improve technical accuracy in error messages by specifying "bytes" instead of "characters," and to optimize performance by extracting validation logic to avoid redundant allocations in TryFrom<String> implementations. Additionally, it is suggested to use #[serde(try_from = "...")] to ensure data integrity during deserialization from external sources.

Comment thread crates/ironclaw_common/src/identity.rs Outdated
Comment on lines +264 to +265
#[error("external thread id exceeds {MAX_EXTERNAL_THREAD_ID_LEN} characters")]
TooLong,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The error message mentions 'characters', but the implementation in ExternalThreadId::new uses s.len(), which returns the number of bytes in Rust. For UTF-8 strings, these are not equivalent. Please update the error message to say 'bytes' to be technically accurate and consistent with the implementation.

Suggested change
#[error("external thread id exceeds {MAX_EXTERNAL_THREAD_ID_LEN} characters")]
TooLong,
#[error("external thread id exceeds {MAX_EXTERNAL_THREAD_ID_LEN} bytes")]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 107160d. Updated error text and doc comments to say "bytes" (the check uses s.len() which is a byte count).

Comment on lines +280 to +282
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ExternalThreadId(String);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using #[serde(transparent)] with a derived Deserialize implementation bypasses the validation logic defined in new(). This violates the general rule: 'When constructing a domain-specific type... from an external, untrusted source... use a validating constructor (e.g., ::new)'. If this type is used in any struct deserialized from external input, invalid data could enter the system. Consider using #[serde(try_from = "String")] to ensure validation at the boundary.

References
  1. When constructing a domain-specific type from an external, untrusted source, use a validating constructor (e.g., ::new) instead of a trusted constructor to ensure data is canonicalized and validated at the boundary.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining this change. .claude/rules/types.md explicitly states: "Don't use #[serde(try_from = \"String\")] for identity newtypes without a migration plan. Existing persisted rows may not satisfy the current rule; transparent + explicit validation at construction preserves them while still gaining type distinctness." Callers that read from untrusted input already go through ::new or TryFrom<String> after deserialize (see e.g. the new try_with_thread/try_in_thread variants on IncomingMessage/OutgoingResponse).

Comment on lines +287 to +299
pub fn new(raw: impl AsRef<str>) -> Result<Self, ExternalThreadIdError> {
let s = raw.as_ref();
if s.is_empty() {
return Err(ExternalThreadIdError::Empty);
}
if s.len() > MAX_EXTERNAL_THREAD_ID_LEN {
return Err(ExternalThreadIdError::TooLong);
}
if s.contains('\0') {
return Err(ExternalThreadIdError::ContainsNul);
}
Ok(Self(s.to_string()))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation of new and TryFrom<String> results in an unnecessary allocation when an owned String is provided, as new always calls to_string(). Extracting the validation logic into a private method allows TryFrom<String> to consume the string without re-allocating.

Suggested change
pub fn new(raw: impl AsRef<str>) -> Result<Self, ExternalThreadIdError> {
let s = raw.as_ref();
if s.is_empty() {
return Err(ExternalThreadIdError::Empty);
}
if s.len() > MAX_EXTERNAL_THREAD_ID_LEN {
return Err(ExternalThreadIdError::TooLong);
}
if s.contains('\0') {
return Err(ExternalThreadIdError::ContainsNul);
}
Ok(Self(s.to_string()))
}
pub fn new(raw: impl AsRef<str>) -> Result<Self, ExternalThreadIdError> {
let s = raw.as_ref();
Self::validate(s)?;
Ok(Self(s.to_string()))
}
fn validate(s: &str) -> Result<(), ExternalThreadIdError> {
if s.is_empty() {
return Err(ExternalThreadIdError::Empty);
}
if s.len() > MAX_EXTERNAL_THREAD_ID_LEN {
return Err(ExternalThreadIdError::TooLong);
}
if s.contains('\0') {
return Err(ExternalThreadIdError::ContainsNul);
}
Ok(())
}
References
  1. When canonicalizing a string, perform cheaper validation checks on a trimmed slice before performing more expensive operations that allocate a new string to avoid unnecessary allocations for already-invalid inputs.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 107160d. Extracted a private validate(&str) helper on both ExternalThreadId and McpServerName; TryFrom now consumes the owned String without the to_string() round-trip.

Comment on lines +345 to +350
impl TryFrom<String> for ExternalThreadId {
type Error = ExternalThreadIdError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This implementation performs an unnecessary string clone because Self::new calls to_string(). By using a shared validation method, you can wrap the existing String directly.

Suggested change
impl TryFrom<String> for ExternalThreadId {
type Error = ExternalThreadIdError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl TryFrom<String> for ExternalThreadId {
type Error = ExternalThreadIdError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::validate(&value)?;
Ok(Self(value))
}
}
References
  1. When canonicalizing a string, perform cheaper validation checks on a trimmed slice before performing more expensive operations that allocate a new string to avoid unnecessary allocations for already-invalid inputs.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 107160d. Extracted a private validate(&str) helper on both ExternalThreadId and McpServerName; TryFrom now consumes the owned String without the to_string() round-trip.

Comment thread crates/ironclaw_common/src/identity.rs Outdated
Comment on lines +390 to +391
#[error("MCP server name exceeds {MAX_MCP_SERVER_NAME_LEN} characters")]
TooLong,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to ExternalThreadId, the error message here refers to 'characters' while the check uses s.len() (bytes). Please update for consistency.

Suggested change
#[error("MCP server name exceeds {MAX_MCP_SERVER_NAME_LEN} characters")]
TooLong,
#[error("MCP server name exceeds {MAX_MCP_SERVER_NAME_LEN} bytes")]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 107160d. Updated error text and doc comments to say "bytes" (the check uses s.len() which is a byte count).

Comment on lines +417 to +419
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct McpServerName(String);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

As with ExternalThreadId, deriving Deserialize with #[serde(transparent)] bypasses validation. Use #[serde(try_from = "String")] to enforce the alphanumeric/dash/underscore invariants when loading from external sources.

References
  1. When constructing a domain-specific type from an external, untrusted source, use a validating constructor (e.g., ::new) instead of a trusted constructor to ensure data is canonicalized and validated at the boundary.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining this change. .claude/rules/types.md explicitly states: "Don't use #[serde(try_from = \"String\")] for identity newtypes without a migration plan. Existing persisted rows may not satisfy the current rule; transparent + explicit validation at construction preserves them while still gaining type distinctness." Callers that read from untrusted input already go through ::new or TryFrom<String> after deserialize (see e.g. the new try_with_thread/try_in_thread variants on IncomingMessage/OutgoingResponse).

Comment on lines +429 to +444
pub fn new(raw: impl AsRef<str>) -> Result<Self, McpServerNameError> {
let s = raw.as_ref();
if s.is_empty() {
return Err(McpServerNameError::Empty);
}
if s.len() > MAX_MCP_SERVER_NAME_LEN {
return Err(McpServerNameError::TooLong);
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(McpServerNameError::InvalidChar(s.to_string()));
}
Ok(Self(s.to_string()))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Extracting validation logic here will also prevent double allocations in the TryFrom<String> implementation for McpServerName.

    pub fn new(raw: impl AsRef<str>) -> Result<Self, McpServerNameError> {
        let s = raw.as_ref();
        Self::validate(s)?;
        Ok(Self(s.to_string()))
    }

    fn validate(s: &str) -> Result<(), McpServerNameError> {
        if s.is_empty() {
            return Err(McpServerNameError::Empty);
        }
        if s.len() > MAX_MCP_SERVER_NAME_LEN {
            return Err(McpServerNameError::TooLong);
        }
        if !s
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
        {
            return Err(McpServerNameError::InvalidChar(s.to_string()));
        }
        Ok(())
    }
References
  1. When canonicalizing a string, perform cheaper validation checks on a trimmed slice before performing more expensive operations that allocate a new string to avoid unnecessary allocations for already-invalid inputs.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 107160d. Extracted a private validate(&str) helper on both ExternalThreadId and McpServerName; TryFrom now consumes the owned String without the to_string() round-trip.

Comment on lines +492 to +497
impl TryFrom<String> for McpServerName {
type Error = McpServerNameError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Optimize this TryFrom implementation to avoid cloning the string by calling a shared validation method.

Suggested change
impl TryFrom<String> for McpServerName {
type Error = McpServerNameError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl TryFrom<String> for McpServerName {
type Error = McpServerNameError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::validate(&value)?;
Ok(Self(value))
}
}
References
  1. When canonicalizing a string, perform cheaper validation checks on a trimmed slice before performing more expensive operations that allocate a new string to avoid unnecessary allocations for already-invalid inputs.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 107160d. Extracted a private validate(&str) helper on both ExternalThreadId and McpServerName; TryFrom now consumes the owned String without the to_string() round-trip.

…thread-id-newtype

# Conflicts:
#	src/gate/pending.rs
Post-merge fix: a test added in staging (insert_and_notify_pending_gate_uses_extension_manager_for_auth_display_name) assigned a raw String to message.thread_id, but the field type became ExternalThreadId on this branch. Wrap with ExternalThreadId::from_trusted to match the other tests in the same module.
…thread-id-newtype

# Conflicts:
#	crates/ironclaw_common/src/lib.rs
#	src/channels/channel.rs
Copilot AI review requested due to automatic review settings April 20, 2026 03:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/channels/channel.rs
Comment on lines +176 to +183
/// Accepts raw strings — the value is wrapped with
/// [`ExternalThreadId::from_trusted`] because callers are typically
/// channel adapters forwarding a value already emitted by their external
/// platform. The `conversation_scope_id` shadow mirrors the raw string.
pub fn with_thread(mut self, thread_id: impl Into<String>) -> Self {
let thread_id = thread_id.into();
self.conversation_scope_id = Some(thread_id.clone());
self.thread_id = Some(ExternalThreadId::from_trusted(thread_id));

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IncomingMessage::with_thread wraps the raw thread_id via ExternalThreadId::from_trusted, which bypasses the newtype’s length/NUL/empty validation. This method is used with untrusted inputs (e.g. HTTP webhook req.thread_id), so it should validate (ExternalThreadId::new) and handle invalid values (e.g. drop thread_id and/or return an error) rather than accepting arbitrary strings.

Suggested change
/// Accepts raw strings — the value is wrapped with
/// [`ExternalThreadId::from_trusted`] because callers are typically
/// channel adapters forwarding a value already emitted by their external
/// platform. The `conversation_scope_id` shadow mirrors the raw string.
pub fn with_thread(mut self, thread_id: impl Into<String>) -> Self {
let thread_id = thread_id.into();
self.conversation_scope_id = Some(thread_id.clone());
self.thread_id = Some(ExternalThreadId::from_trusted(thread_id));
/// Accepts raw strings and validates them with [`ExternalThreadId::new`].
/// If the value is invalid, the thread ID is not set. When valid, the
/// `conversation_scope_id` shadow mirrors the validated raw string.
pub fn with_thread(mut self, thread_id: impl Into<String>) -> Self {
let thread_id = thread_id.into();
if let Ok(thread_id) = ExternalThreadId::new(thread_id) {
self.conversation_scope_id = Some(thread_id.as_str().to_string());
self.thread_id = Some(thread_id);
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 107160d. Added try_with_thread (IncomingMessage) and try_in_thread (OutgoingResponse) validating variants returning Result<(), ExternalThreadIdError>. Migrated untrusted call sites: src/channels/http.rs (webhook, returns 400 on invalid) and src/channels/relay/channel.rs (logs warning, drops thread_id on invalid). Left with_thread/in_thread as trusted-path conveniences with updated docs pointing to try_ variants.

Comment thread src/channels/channel.rs Outdated
Comment on lines +317 to +325
/// Set the thread ID for the response.
///
/// Accepts raw strings — the value is wrapped with
/// [`ExternalThreadId::from_trusted`] because callers typically forward
/// an identifier that a channel adapter already accepted.
pub fn in_thread(mut self, thread_id: impl Into<String>) -> Self {
self.thread_id = Some(thread_id.into());
self.thread_id = Some(ExternalThreadId::from_trusted(thread_id.into()));
self
}

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OutgoingResponse::in_thread also uses ExternalThreadId::from_trusted for raw strings, which means outbound routing can carry empty/NUL/oversized thread IDs without any guard. Consider validating here too (or adding a separate fallible constructor) so callers can’t accidentally propagate malformed external thread IDs across channel boundaries.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 107160d. Added try_with_thread (IncomingMessage) and try_in_thread (OutgoingResponse) validating variants returning Result<(), ExternalThreadIdError>. Migrated untrusted call sites: src/channels/http.rs (webhook, returns 400 on invalid) and src/channels/relay/channel.rs (logs warning, drops thread_id on invalid). Left with_thread/in_thread as trusted-path conveniences with updated docs pointing to try_ variants.

Comment thread src/gate/pending.rs
Comment on lines 126 to 132
request_id: gate.request_id.to_string(),
thread_id: gate
.scope_thread_id
.clone()
.as_ref()
.map(|t| t.as_str().to_string())
.unwrap_or_else(|| gate.thread_id.to_string()),
gate_name: gate.gate_name.clone(),

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PendingGateView::from re-implements the same fallback logic as PendingGate::effective_wire_thread_id(). Consider calling gate.effective_wire_thread_id() here to keep a single source of truth and avoid drift if the routing semantics change again.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 107160d. PendingGateView::from now calls gate.effective_wire_thread_id() — single source of truth.

…thread-id-newtype

Resolves modify/delete conflict: staging deleted src/channels/web/server.rs
(ironclaw#2599 stage 6) — the four thread-id test assertions this branch
had added there already live at their new homes in
src/channels/web/features/chat/mod.rs and features/pairing/mod.rs.
Ported the thread_id.as_deref() -> .as_ref().map(|t| t.as_str()) fix
to those new locations since incoming.thread_id is now Option<ExternalThreadId>.
Copilot AI review requested due to automatic review settings April 20, 2026 05:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

src/channels/relay/channel.rs:253

  • RelayChannel builds a validated msg.thread_id via try_with_thread(candidate), but the metadata field thread_id is set to event.thread_id.unwrap_or(&event.id) and can be empty / diverge from the chosen candidate (e.g., when event.id is empty and you fall back to channel_id). This can later cause responses to be sent with an empty/incorrect thread_ts. Consider setting metadata thread_id from the same candidate you actually accept, and omit it when empty/invalid.
                let mut msg = IncomingMessage::new(&relay_name, &event.sender_id, event.text())
                    .with_user_name(event.display_name())
                    .with_metadata(serde_json::json!({
                        "team_id": event.team_id(),
                        "channel_id": event.channel_id,
                        "sender_id": event.sender_id,
                        "sender_name": event.display_name(),
                        "event_type": event.event_type,
                        "thread_id": event.thread_id.as_deref().unwrap_or(&event.id),
                        "provider": event.provider,
                    }));

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/bridge/router.rs Outdated
Comment on lines +3797 to +3799
scope_thread_id: message
.conversation_scope()
.map(|s| ironclaw_common::ExternalThreadId::from_trusted(s.to_string())),

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scope_thread_id is being set from message.conversation_scope() using ExternalThreadId::from_trusted, which bypasses validation. conversation_scope_id can be sourced from untrusted/extension-provided metadata (e.g., WASM-emitted routing targets), so this may allow empty/NUL/oversized values to be persisted into pending gates. Prefer constructing with ExternalThreadId::new(...) and, on error, log + store None (or otherwise handle the invalid scope) instead of bypassing validation here.

Suggested change
scope_thread_id: message
.conversation_scope()
.map(|s| ironclaw_common::ExternalThreadId::from_trusted(s.to_string())),
scope_thread_id: message.conversation_scope().and_then(|s| {
let scope = s.to_string();
match ironclaw_common::ExternalThreadId::new(scope.clone()) {
Ok(scope_thread_id) => Some(scope_thread_id),
Err(err) => {
tracing::warn!(
thread_id = %thread_id,
conversation_scope = %scope,
error = %err,
"dropping invalid conversation scope for pending authentication gate"
);
None
}
}
}),

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f9d1415. Same validation applied here — ExternalThreadId::new with debug log + None on error — closing the from_trusted bypass for WASM/metadata-sourced conversation_scope_id values.

Comment thread src/bridge/router.rs Outdated
Comment on lines +3888 to +3890
scope_thread_id: message
.conversation_scope()
.map(|s| ironclaw_common::ExternalThreadId::from_trusted(s.to_string())),

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as above: scope_thread_id is built with ExternalThreadId::from_trusted from message.conversation_scope(), which can carry untrusted strings. Consider validating via ExternalThreadId::new and dropping/logging invalid scope ids rather than persisting unvalidated values into PendingGate.

Suggested change
scope_thread_id: message
.conversation_scope()
.map(|s| ironclaw_common::ExternalThreadId::from_trusted(s.to_string())),
scope_thread_id: message.conversation_scope().and_then(|s| {
match ironclaw_common::ExternalThreadId::new(s.to_string()) {
Ok(scope_thread_id) => Some(scope_thread_id),
Err(e) => {
tracing::debug!(
scope = %s,
error = %e,
"dropping invalid conversation scope thread id"
);
None
}
}
}),

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f9d1415. scope_thread_id now validates via ExternalThreadId::new; invalid values are logged at debug and stored as None instead of being wrapped via from_trusted. Applied at both this call site and the authentication-fallback path at :3797.

Comment thread src/channels/relay/channel.rs Outdated
Comment on lines 313 to 318
// Determine thread_id from response or metadata
let thread_id = response
.thread_id
.as_deref()
.as_ref()
.map(|t| t.as_str())
.or_else(|| metadata.get("thread_id").and_then(|v| v.as_str()));

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

respond() currently derives thread_id from response.thread_id or metadata["thread_id"], but ignores the (now-typed/validated) msg.thread_id. This can produce incorrect routing when metadata is missing/stale. Prefer using msg.thread_id.as_ref().map(|t| t.as_str()) before falling back to metadata, and consider filtering out empty strings so you don't emit thread_ts: "".

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f9d1415. respond() now prefers response.thread_id → msg.thread_id (the newly-validated ExternalThreadId) → metadata["thread_id"], with a trailing .filter(|s| !s.is_empty()) so we never emit thread_ts: "".

ilblackdragon added a commit that referenced this pull request Apr 20, 2026
Synthesizes recurring patterns from ~30 merged PRs, 147 bot review
comments (Copilot/Gemini), human reviews, and ~50 issues filed in the
past 2 weeks. Each rule cites the motivating PR/issue numbers.

New files:
- error-handling.md — silent-failure taxonomy (unwrap_or_default, .ok()?,
  poisoned caches), persist-then-reload atomicity, channel-edge error
  mapping. (#2526, #2633, #2653, #2673, #2546, #2407, #2408)
- agent-evidence.md — side-effect claims must cite tool evidence,
  empty-fast outputs are errors, external-effect tools must read back,
  setup UI round-trip. (#2544, #2580, #2582, #2541, #2545, #2411, #2543,
  #2586)
- lifecycle.md — discovery vs. activation, terminal auth rejection,
  list_installed vs. list_active, deactivation unwinds, snapshot
  rehydrate must re-validate. (#2556, #2557, #2558, #2564, #2419,
  PR #2617, PR #2631)

Extended:
- types.md — from_trusted boundary rule, validated-newtype template with
  shared validate(&str), serde(try_from) required for validated types,
  wire-stable enums (no Debug; serde alias for migrations), canonical
  wire-contract field naming. (PR #2685, #2681, #2687, #2678, #2669,
  #2665, #2683, #2702)
- safety-and-sandbox.md — every new ingress scans pre-transform/pre-
  injection, bounded resources (interners/streams/fan-out caps), cache
  keys must be complete. (#2491, #2676, #2470, #2633, #2673, #2710,
  PR #2702)
- review-discipline.md — PR scope discipline, guardrail scripts are
  code (regression tests, grouped-import parsing, CI has_code inclusion),
  absolute-path ban in committed docs, stale comments after refactors.
  (PR #2668, #2628, #2680, #2687, #2647, #2689, #2701)

All new files carry paths: frontmatter so they auto-load only on
matching files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ed msg.thread_id

- router.rs: scope_thread_id written to PendingGate was wrapped via
  ExternalThreadId::from_trusted from message.conversation_scope(), which
  can carry untrusted WASM/metadata-sourced strings. Now validates via
  ExternalThreadId::new; invalid values log at debug and store None.
  Applied at both call sites (authentication-fallback path and generic
  gate-insertion path).
- relay/channel.rs: respond() derived thread_id only from response or
  metadata — now also consults the validated msg.thread_id as the second
  fallback (before raw metadata) and filters empty strings so we never
  emit thread_ts: "" to Slack.
@ilblackdragon
ilblackdragon merged commit 833cb48 into staging Apr 20, 2026
18 checks passed
@ilblackdragon
ilblackdragon deleted the refactor/external-thread-id-newtype branch April 20, 2026 06:29
This was referenced Apr 22, 2026
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…ndary (nearai#2685)

* refactor(channels): introduce ExternalThreadId newtype at channel boundary

External channel thread ids (Telegram chat id, web UUID, Slack thread_ts)
flow as raw Option<String> through IncomingMessage, StatusUpdate, and
pending-gate store. Wraps them in a validated ExternalThreadId so the
compiler distinguishes boundary-layer ids from the internal ThreadId(Uuid).

Maps to bug pattern from nearai#2349, nearai#2444, nearai#2517 where thread-id confusion
crossed a layer silently.

* fix(bridge): adapt test thread_id to ExternalThreadId newtype

Post-merge fix: a test added in staging (insert_and_notify_pending_gate_uses_extension_manager_for_auth_display_name) assigned a raw String to message.thread_id, but the field type became ExternalThreadId on this branch. Wrap with ExternalThreadId::from_trusted to match the other tests in the same module.

* refactor(types): address review feedback — byte units, shared validate, try_-variants, dedup pending-gate

* refactor(types): validate scope_thread_id + relay respond prefers typed msg.thread_id

- router.rs: scope_thread_id written to PendingGate was wrapped via
  ExternalThreadId::from_trusted from message.conversation_scope(), which
  can carry untrusted WASM/metadata-sourced strings. Now validates via
  ExternalThreadId::new; invalid values log at debug and store None.
  Applied at both call sites (authentication-fallback path and generic
  gate-insertion path).
- relay/channel.rs: respond() derived thread_id only from response or
  metadata — now also consults the validated msg.thread_id as the second
  fallback (before raw metadata) and filters empty strings so we never
  emit thread_ts: "" to Slack.
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
* docs(rules): add review-driven guidance for Claude Code

Synthesizes recurring patterns from ~30 merged PRs, 147 bot review
comments (Copilot/Gemini), human reviews, and ~50 issues filed in the
past 2 weeks. Each rule cites the motivating PR/issue numbers.

New files:
- error-handling.md — silent-failure taxonomy (unwrap_or_default, .ok()?,
  poisoned caches), persist-then-reload atomicity, channel-edge error
  mapping. (nearai#2526, nearai#2633, nearai#2653, nearai#2673, nearai#2546, nearai#2407, nearai#2408)
- agent-evidence.md — side-effect claims must cite tool evidence,
  empty-fast outputs are errors, external-effect tools must read back,
  setup UI round-trip. (nearai#2544, nearai#2580, nearai#2582, nearai#2541, nearai#2545, nearai#2411, nearai#2543,
  nearai#2586)
- lifecycle.md — discovery vs. activation, terminal auth rejection,
  list_installed vs. list_active, deactivation unwinds, snapshot
  rehydrate must re-validate. (nearai#2556, nearai#2557, nearai#2558, nearai#2564, nearai#2419,
  PR nearai#2617, PR nearai#2631)

Extended:
- types.md — from_trusted boundary rule, validated-newtype template with
  shared validate(&str), serde(try_from) required for validated types,
  wire-stable enums (no Debug; serde alias for migrations), canonical
  wire-contract field naming. (PR nearai#2685, nearai#2681, nearai#2687, nearai#2678, nearai#2669,
  nearai#2665, nearai#2683, nearai#2702)
- safety-and-sandbox.md — every new ingress scans pre-transform/pre-
  injection, bounded resources (interners/streams/fan-out caps), cache
  keys must be complete. (nearai#2491, nearai#2676, nearai#2470, nearai#2633, nearai#2673, nearai#2710,
  PR nearai#2702)
- review-discipline.md — PR scope discipline, guardrail scripts are
  code (regression tests, grouped-import parsing, CI has_code inclusion),
  absolute-path ban in committed docs, stale comments after refactors.
  (PR nearai#2668, nearai#2628, nearai#2680, nearai#2687, nearai#2647, nearai#2689, nearai#2701)

All new files carry paths: frontmatter so they auto-load only on
matching files.

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

* refactor(rules): split agent-evidence into prompt + code rule

agent-evidence.md mixed two concerns: runtime agent instruction (what
the LLM should do when concluding a turn) and code-enforcement rules
(what the dispatcher, engine, and tools must implement). Rules under
.claude/rules/ only guide Claude Code when editing the repo — the
runtime agent never reads them.

Splits the two:

- crates/ironclaw_engine/prompts/codeact_postamble.md — new section
  "Evidence before claiming side effects". Sits next to the existing
  "FINAL() answer quality" guidance; loaded via include_str! in
  executor/prompt.rs (no Rust change needed).
- .claude/rules/tool-evidence.md — renamed from agent-evidence.md,
  keeps only the code invariants (engine v2 side-effect gate,
  empty-fast ToolError::EmptyResult, external-effect tools must read
  back, setup UI round-trip).

Prompt tests pass unchanged; the postamble addition is pure text.

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

* prompt: tighten evidence rule to FINAL() claims only, not tool use

Live-test validation of the "Evidence before claiming side effects"
section (added in the prior commit) showed it inhibited legitimate
tool use. With the original wording, `zizmor_scan_v2` live-recording
timed out at 302s with zero responses; reverting the postamble
restored healthy behavior (88s run, 8 shell calls including
`cargo install zizmor` and full workflow analysis).

The original phrasing conflated two things: what the agent should
claim and what tools it should call. The rule is only about the
claim. Re-tunes the section to:

- Open with an explicit "this does not restrict tool calls" scope.
- Drop the "<1ms = failure" heuristic (too broad — normal tools like
  `tool_info(schema)` are legitimately fast).
- Drop the full enumeration of forbidden side-effect verbs; keep the
  rule narrower and clearer.
- Shorten the code example (remove redundant early-return).

Re-tuned run: agent is active (shell calls, real reasoning), live
recording completes in ~9s. The remaining test failure is a
pre-existing assertion bug (exact `t == "shell"` match against tool
strings that now carry arguments like `"shell(cmd)"`) — reproduces
with the old postamble too.

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

* test(live): fix tool-name assertions + re-record zizmor traces

The two `zizmor_scan*` live tests had four broken tool-name assertions
that silently failed to match: `tools.iter().any(|t| t == "shell")`
against a tool list that now contains `"shell(cmd)"` strings (tool
events carry args via `format_action_display_name` in
`src/bridge/router.rs`). Two of the four were negative assertions
checking for the absence of `tool_install` recovery loops — those
silently passed even when a recovery loop actually ran. `sandbox_live_e2e.rs:203`
already used the correct `t == "shell" || t.starts_with("shell(")`
pattern; applied it consistently to all four sites.

Verified live:

- `IRONCLAW_LIVE_TEST=1 cargo test --test e2e_live -- zizmor_scan --ignored --test-threads=1`
  → 2 passed, 0 failed, 51.78s. Agent installs and runs zizmor
  end-to-end, producing real findings (exit code 14, dangerous
  triggers, excessive permissions, etc.).

Traces re-recorded with the tuned postamble (commit 50d8517) and
scrubbed: replaced `/home/illia/.cargo/bin/zizmor` with
`/home/user/.cargo/bin/zizmor` per the developer-local-path ban in
`.claude/rules/review-discipline.md`. No credentials, PII, or
high-entropy secrets in either trace (only git SHAs from zizmor's
workflow analysis output).

Replay still passes: `cargo test --test e2e_live -- zizmor_scan --ignored`
→ 2/2 ok.

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

* test(replay): update zizmor_scan_v2 insta snapshot

The engine v2 replay-snapshot gate (`engine_v2_tests::snapshot_zizmor_scan_v2`)
failed against the re-recorded trace from 1691efe because the old
snapshot encoded a broken run:

- final_state: Failed
- Missing Assistant message role
- 3 issues: thread_failure (error), no_response (warning), llm_error (error)
- 6 tool calls that never produced a final answer

The new trace completes cleanly:

- final_state: Done
- System / User / Assistant roles present
- 1 issue: mixed_mode (info)
- 3 shell tool calls + successful `FINAL()` with real findings

The snapshot was pinning a regression. Regenerated with
`INSTA_UPDATE=always cargo test --test e2e_engine_v2 -- snapshot_zizmor_scan_v2`;
passes on replay.

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

* docs(rules): address PR nearai#2714 review feedback

- review-discipline: reword "Doc Absolute Paths" as a review convention
  (pre-commit only scans .rs; the rule misleadingly claimed enforcement).
- safety-and-sandbox: broaden `paths:` frontmatter to include the actual
  ingress owners (`bridge`, `channels`, `workspace`, `agent`, engine
  crate) so the rule auto-loads where it applies.
- tool-evidence: mark the side-effect gate, empty-fast rule, and
  `unverified` flag as target/aspirational invariants — neither
  `ToolError::EmptyResult`, an `unverified` field on `ToolOutput`, nor a
  byte-count field on `ActionRecord` exist today. Point at concrete
  interim conventions (`ToolError::ExecutionFailed`, `unverified: true`
  in the JSON result body).
- types: scope "Validated newtypes must gate Deserialize" to *new*
  types, document the `CredentialName`/`ExtensionName` exception (they
  intentionally use `#[serde(transparent)]` + derived `Deserialize`
  under the `serde_does_not_revalidate` test). Clarify the
  `from_trusted` trust boundary (trusted = typed upstream, untrusted =
  raw JSON field even if the field *name* is "registry entry").
  Switch `new` template to `impl Into<String>` to avoid an unnecessary
  clone when an owned `String` is passed.

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

* docs(rules): simplify types.md + split doc-hygiene; address review round 2

- types: collapse two templates into one canonical validated-newtype
  shape. New types use `#[serde(try_from = "String")]` with a shared
  `validate(&str)` helper — no more dual "transparent for some /
  try_from for others" guidance. `CredentialName`/`ExtensionName` are
  documented as the sole legacy exception (locked in by the
  `serde_does_not_revalidate` test); new code must not copy their
  `transparent` + `from_trusted` pattern. Removes the long "Using
  `from_trusted` safely" section and the separate "Validated newtypes
  must gate Deserialize" subsection that contradicted the Don'ts list.
- doc-hygiene: new tiny rule file scoped to `**/*.md`, `**/*.py`,
  `docs/**` that carries the "no developer-local absolute paths in
  committed docs" convention. Removed from review-discipline.md where
  its `src/**/*.rs` scope meant the rule never loaded on the files it
  governed.

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

* test(live): match hyphenated tool-install in attempted_relevant_tool

The engine records `action_name` as the raw string the LLM emitted
(`crates/ironclaw_engine/src/executor/structured.rs:381`), and the
registry's lookup canonicalization only affects dispatch — not the
name that reaches `StatusUpdate::ToolStarted`. The two other predicates
in this file (`bad_recovery` at :420, `phase_b_recovery` at :531)
already defend against both forms; this one should too, for
consistency. Addresses PR nearai#2714 review.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: channel/wasm WASM channel runtime scope: channel/web Web gateway channel scope: channel Channel infrastructure scope: tool/builtin Built-in tools size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants