Skip to content

feat(reborn)!: one slack extension — slack_bot and slack_personal retired (NEA-25 stack 4/7) - #5845

Closed
BenKurrek wants to merge 1 commit into
nea25/03-surface-discoveryfrom
nea25/04-slack-unified
Closed

BenKurrek wants to merge 1 commit into
nea25/03-surface-discoveryfrom
nea25/04-slack-unified

Conversation

@BenKurrek

Copy link
Copy Markdown
Collaborator

Summary

Stack PR 4/7 for NEA-25, on #5842. Slack becomes the validation case for the unified model: one slack extension, one manifest, both surfaces, provider slack.

  • Unified manifest (assets/slack/manifest.toml): the product_adapter.inbound channel section (Events API ingress, request-signature verification, host-ingress route descriptor, bot egress — projecting channel { inbound: true, outbound: true }) plus the capability_provider.tools section (the five user-scoped tools), under provider slack. No per-surface runtime schema was needed — the retired slack_bot manifest's first_party service declaration was descriptive-only (the host composes and mounts the channel service; the wasm runtime serves the tools), verified by consumer search before the merge.
  • Identities deleted, not aliased: slack_bot package/assets/digest/catalog-hiding (is_internal_extension_package_ref deleted crate-wide) and all lifecycle special cases; slack_personal provider id everywhere including the frontend OAuth-card display map. "personal" survives only in flow-named identifiers for the user-scoped OAuth flow (SlackPersonalBindingService, the IRONCLAW_REBORN_SLACK_PERSONAL_OAUTH_REDIRECT_URI env). The slack_bot_token / slack_signing_secret credential handles stay — workspace secrets, not identities.
  • Two one-time forward data migrations (Ben's no-alias rule; both pinned + idempotent):
    • installation state: load folds persisted slack_bot manifest records + installation rows into the unified slack extension (enabled-wins merge, credential-binding union, host-bundled manifest record seeded when the tools package was never installed) and persists the migrated snapshot immediately — load_at_migrates_retired_slack_bot_identity_forward;
    • credential accounts: boot sweep in the rooted factory builder rewrites provider slack_personal → slack (sweep_all_accounts extracted from the refresh-candidate walk so both consumers share one enumeration) — migrate_retired_slack_personal_provider_rewrites_accounts_forward.
  • ChannelSetupActivationCredentialGate: operator setup-save activation no longer fails closed on per-caller OAuth accounts (they're user-scoped; each caller auth-gates at tool-call time via auth_required). Previously the unified activation would have 503'd the setup route.
  • The per-caller channel-connection gate goes live: SetupRequired for an unconnected caller was provably unreachable under the split identities (connections key "slack" vs channel package slack_bot); with one identity the keys match and the mechanism works — pinned in unified_slack_extension_projects_surfaces_and_lists_auth_required_until_oauth_connected (surfaces: Tool + Channel{in,out} + Auth; state: SetupRequired until connected).

Security invariants preserved: signature verification config unchanged (same adapter section), host-ingress descriptor identical, delegated-tool authority still rides the caller's OAuth account, bot egress still host-authored via the workspace token handle.

Testing

  • ironclaw_reborn_composition --all-features: 1,512 lib tests green (incl. the two migration pins, unified-manifest surface pins through the real bundled manifest, setup-save activation, onboarding copy)
  • ironclaw_host_runtime github_wasm_runtime_contract (slack credential-injection + auth-gate contract) green
  • Frontend: 602/602 (OAuth-card display-name pin reworked — lowercase slack legitimately appears in the authorize URL host, so the raw-id-never-renders invariant now checks standalone values)
  • cargo check --workspace --all-targets --all-features clean; full clippy invocation clean
  • Not run: Postgres integration tier (migrations ride RootFilesystem, uniform across backends); live Slack E2E (needs workspace credentials — see deployment note)

Deployment note: operator env/docs referencing the slack_personal provider id need a one-word update (~/shared-env/ironclaw/slack-test.env style setups); the OAuth redirect env var name is unchanged.

🤖 Generated with Claude Code

@ironloopai

ironloopai Bot commented Jul 8, 2026 •

Copy link
Copy Markdown
Contributor

🔎 IronLoop Review Status

Head: 7006a7b32bb12b7d6a6657a8bcd2b6467f67fcee
Result: No reviewer jobs are scheduled yet.
Next: Run @ironloopai review to start reviewers.
Updated: 2026-07-09T17:40:46.460Z

Current reviewers:

Reviewer State Verdict Findings Last update
none Queued N/A No reviewer jobs scheduled yet. N/A
Reviewer summaries
Reviewer Detail
none No reviewer jobs scheduled yet.
Recent activity
Time Reviewer State Detail
N/A N/A Waiting No progress events recorded yet.
Available commands
  • @ironloopai help
  • @ironloopai agents
  • @ironloopai review
  • @ironloopai review --agent <agent>
  • @ironloopai status
Run metadata

Admission: webhook accepted the request and IronLoop persisted reviewer state before this projection.

@coderabbitai

coderabbitai Bot commented Jul 8, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • staging
  • reborn-integration

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c54d4b87-6957-4227-861b-0b452f288be8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5845 July 8, 2026 17:05 Destroyed
@github-actions github-actions Bot added size: XL 500+ changed lines risk: low Changes to docs, tests, or low-risk modules contributor: core 20+ merged PRs labels Jul 8, 2026

@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 unifies the previously separate Slack bot channel (slack_bot) and Slack personal tools (slack_personal) into a single, unified slack extension. This includes updating manifests, tests, and configurations, as well as introducing one-time forward migrations for legacy installation states and credential accounts. The review feedback highlights opportunities to make the TOML parsing more robust, handle potential errors during installation migration to prevent silent state loss, and use debug! logging instead of warn! to avoid corrupting REPL/TUI interfaces.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +207 to +209
state
.manifests
.retain(|record| !record.raw_toml.contains("\nid = \"slack_bot\""));

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

String matching on TOML using contains("\\nid = \"slack_bot\"") is fragile and can easily fail if formatting changes (e.g., spaces around =, different quote characters, or carriage returns). It is safer and more robust to parse the TOML line-by-line and normalize the key-value pair. When canonicalizing, perform cheaper validation checks on a trimmed slice before performing more expensive operations like replace that allocate a new string.

    state
        .manifests
        .retain(|record| {
            !record.raw_toml.lines().any(|line| {
                let trimmed = line.trim();
                if trimmed.is_empty() || !trimmed.contains("id") {
                    return false;
                }
                let clean = trimmed.replace(" ", "");
                clean == "id=\"slack_bot\"" || clean == "id='slack_bot'"
            })
        });
References
  1. When canonicalizing a string, perform cheaper validation checks (like length, emptiness, and character set) on a trimmed slice before performing more expensive operations like replace that allocate a new string. This avoids unnecessary allocations for already-invalid inputs.

Comment on lines +241 to +244
let has_unified_record = state
.manifests
.iter()
.any(|record| record.raw_toml.contains("\nid = \"slack\""));

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

Similarly, checking for the unified manifest using contains("\\nid = \"slack\"") is fragile. Normalize the line-by-line TOML matching here as well, performing cheaper validation checks on a trimmed slice before performing more expensive operations like replace that allocate a new string.

    let has_unified_record = state
        .manifests
        .iter()
        .any(|record| {
            record.raw_toml.lines().any(|line| {
                let trimmed = line.trim();
                if trimmed.is_empty() || !trimmed.contains("id") {
                    return false;
                }
                let clean = trimmed.replace(" ", "");
                clean == "id=\"slack\"" || clean == "id='slack'"
            })
        });
References
  1. When canonicalizing a string, perform cheaper validation checks (like length, emptiness, and character set) on a trimmed slice before performing more expensive operations like replace that allocate a new string. This avoids unnecessary allocations for already-invalid inputs.

Comment on lines +277 to +298
if let Ok(merged) = ExtensionInstallation::new(
existing.installation_id().clone(),
unified_id.clone(),
activation,
ExtensionManifestRef::new(unified_id, None),
bindings,
chrono::Utc::now(),
) {
*existing = merged;
}
} else if let Some(first) = retired.into_iter().next()
&& let Ok(renamed) = ExtensionInstallation::new(
first.installation_id().clone(),
unified_id.clone(),
first.activation_state(),
ExtensionManifestRef::new(unified_id, None),
first.credential_bindings().to_vec(),
chrono::Utc::now(),
)
{
state.installations.push(renamed);
}

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

If ExtensionInstallation::new fails, the error is silently ignored. Since the retired installation was already removed from state.installations, the user's Slack installation state will be silently lost. It is safer to log an error using tracing::debug! so that any migration failures are visible and traceable without corrupting the REPL/TUI.

        match ExtensionInstallation::new(
            existing.installation_id().clone(),
            unified_id.clone(),
            activation,
            ExtensionManifestRef::new(unified_id, None),
            bindings,
            chrono::Utc::now(),
        ) {
            Ok(merged) => *existing = merged,
            Err(error) => {
                tracing::debug!(%error, "failed to merge retired slack_bot installation into unified slack extension");
            }
        }
    } else if let Some(first) = retired.into_iter().next() {
        match ExtensionInstallation::new(
            first.installation_id().clone(),
            unified_id.clone(),
            first.activation_state(),
            ExtensionManifestRef::new(unified_id, None),
            first.credential_bindings().to_vec(),
            chrono::Utc::now(),
        ) {
            Ok(renamed) => state.installations.push(renamed),
            Err(error) => {
                tracing::debug!(%error, "failed to rename retired slack_bot installation to unified slack extension");
            }
        }
    }
References
  1. Do not use warn! or info! logging in REPL/TUI-reachable code as they can corrupt the REPL/TUI. Use debug! logging instead, and ensure only sanitized identifiers are logged.

Comment on lines +821 to +849
pub(crate) async fn migrate_retired_slack_personal_provider(&self) -> usize {
let retired: Vec<CredentialAccount> = self
.sweep_all_accounts()
.await
.into_iter()
.filter(|account| account.provider.as_str() == "slack_personal")
.collect();
let mut migrated = 0usize;
for mut account in retired {
match ironclaw_auth::AuthProviderId::new("slack") {
Ok(provider) => account.provider = provider,
Err(error) => {
tracing::warn!(%error, "slack provider migration: unified provider id invalid");
return migrated;
}
}
match self.write_account(&account, CasExpectation::Any).await {
Ok(_) => migrated += 1,
Err(error) => {
tracing::warn!(
account_id = %account.id,
%error,
"slack provider migration: failed to rewrite account record"
);
}
}
}
migrated
}

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

When implementing backward compatibility or migration logic, avoid hardcoding specific provider IDs like slack or slack_personal. Instead, prefer checking for a common property (such as a specific environment variable) that defines the group of providers needing the compatibility logic. Also, use debug! logging instead of warn! to avoid corrupting the REPL/TUI.

    pub(crate) async fn migrate_retired_providers(&self) -> usize {
        let Ok(target_provider_str) = std::env::var("UNIFIED_AUTH_PROVIDER") else {
            return 0;
        };
        let Ok(provider) = ironclaw_auth::AuthProviderId::new(&target_provider_str) else {
            return 0;
        };
        let retired_provider_str = std::env::var("RETIRED_AUTH_PROVIDER").unwrap_or_default();
        let retired: Vec<CredentialAccount> = self
            .sweep_all_accounts()
            .await
            .into_iter()
            .filter(|account| account.provider.as_str() == retired_provider_str)
            .collect();
        let mut migrated = 0usize;
        for mut account in retired {
            account.provider = provider.clone();
            match self.write_account(&account, CasExpectation::Any).await {
                Ok(_) => migrated += 1,
                Err(error) => {
                    tracing::debug!(
                        account_id = %account.id,
                        %error,
                        "slack provider migration: failed to rewrite account record"
                    );
                }
            }
        }
        migrated
    }
References
  1. When implementing backward compatibility logic, scope it precisely to the intended cases. Instead of hardcoding specific provider IDs, prefer checking for a common property (like a specific environment variable) that defines the group of providers needing the compatibility logic.
  2. Do not use warn! or info! logging in REPL/TUI-reachable code as they can corrupt the REPL/TUI. Use debug! logging instead, and ensure only sanitized identifiers are logged.

@ironloopai ironloopai 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.

❌ IronLoop Review: reviewer

Review at a glance

Verdict Blocking Notes Inline Head
❌ Changes requested 1 0 1 bfaf009834dc

Head: bfaf009834dc9ad1e28756eff82be3190236ca2c
Next: Fix the blocking findings, push the PR branch, then re-run this reviewer.

Run details

Status: Current
Needs human: no
Needs validation: no

Summary

Found a blocking migration bug in the Slack extension identity merge. Bot-only legacy installations can be left with the retired installation id, which breaks later lifecycle operations for the unified Slack extension.

Findings

Blocking: 1 / Notes: 0

Blocking findings

1. ❌ [MEDIUM] Use the unified installation id when renaming bot-only Slack installs

Location: crates/ironclaw_reborn_composition/src/extension_host/extension_installation_store.rs:289
When a legacy state has only the retired slack_bot installation and no existing slack installation, this branch creates an installation with extension_id = "slack" but keeps installation_id = "slack_bot". The lifecycle APIs derive the installation id from the package id (ExtensionInstallationId::new("slack")) in project, activate, and remove, so upgraded bot-only installs will appear in list output but cannot be projected/activated/removed as the unified Slack extension. The migration should create the renamed installation with the unified installation id (slack) and preserve the retired activation/bindings under that id; add a bot-only migration test because the current test only covers the case where a slack row already exists.

Developer follow-up

After fixing this feedback:

  1. Push the fix to this PR branch.
  2. Re-run this reviewer with @ironloopai review --agent reviewer if you only changed this reviewer's findings.
  3. Re-run all reviewers with @ironloopai review when the fix may affect multiple areas.
  4. Use @ironloopai status to check queued/running/completed/stale/stalled state while reviewers run.

}
} else if let Some(first) = retired.into_iter().next()
&& let Ok(renamed) = ExtensionInstallation::new(
first.installation_id().clone(),

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.

This preserves the retired slack_bot installation id even though the row is renamed to extension_id = "slack". Lifecycle lookups use ExtensionInstallationId::new("slack"), so a bot-only legacy install will be listed but later project/activate/remove for the unified Slack package won't find it. Please create the renamed row with the unified installation id and cover the bot-only migration case.

@railway-app

railway-app Bot commented Jul 8, 2026 •

Copy link
Copy Markdown

🚅 Deployed to the ironclaw-pr-5845 environment in ironclaw-ci-preview

Service Status Web Updated (UTC)
ironclaw ✅ Success (View Logs) Web Jul 13, 2026 at 8:29 pm

@BenKurrek
BenKurrek force-pushed the nea25/04-slack-unified branch from bfaf009 to de44310 Compare July 8, 2026 21:22
@BenKurrek
BenKurrek force-pushed the nea25/03-surface-discovery branch from 10c8f36 to 6f820a6 Compare July 8, 2026 21:22
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5845 July 8, 2026 21:22 Destroyed
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5845 July 9, 2026 17:40 Destroyed
@BenKurrek
BenKurrek force-pushed the nea25/03-surface-discovery branch from 9ba3bdf to f542fc5 Compare July 13, 2026 19:23
…ired

The Slack channel and the user-scoped Slack tools are one extension.
assets/slack/manifest.toml declares both surfaces: the
product_adapter.inbound channel section (Events API ingress, request
signature verification, host-authored bot egress, inbound+outbound
directions) and the capability_provider tools section (search, list,
history, user info, send-as-you), under provider `slack`. No per-surface
runtime was needed: the retired slack_bot manifest's first_party service
declaration was descriptive-only (the host mounts the channel service);
the wasm runtime serves the tools.

Deleted identities (no aliases): the slack_bot package, assets, digest,
catalog-hiding (is_internal_extension_package_ref), onboarding and
activation special cases; the slack_personal provider id everywhere
including the frontend OAuth-card display map ("personal" survives only
in flow-named identifiers for the user-scoped OAuth flow). The
slack_bot_token / slack_signing_secret credential HANDLES stay - they
are workspace secrets, not identities.

Two one-time forward data migrations, both pinned and idempotent:
- installation state: loading folds persisted slack_bot manifest records
  and installation rows into the unified slack extension (enabled-wins
  merge, credential bindings union, host-bundled manifest record seeded
  when absent) and persists the migrated snapshot immediately;
- credential accounts: a boot sweep in the rooted factory builder
  rewrites provider slack_personal -> slack via the durable account
  store (sweep_all_accounts extracted from the refresh-candidate walk).

Operator setup-save activation uses the new
ChannelSetupActivationCredentialGate: per-caller product-auth accounts
never gate operator channel activation - each caller auth-gates at
tool-call time (auth_required). With the identities unified, the
per-caller channel-connection SetupRequired gate is live for the first
time: the connections key and the extension id finally match.

NEA-25 stack PR 4. Deployment note: Slack operator env referencing the
slack_personal provider id needs a one-word update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@BenKurrek
BenKurrek force-pushed the nea25/04-slack-unified branch from 7006a7b to 22cb072 Compare July 13, 2026 20:18
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5845 July 13, 2026 20:18 Destroyed
BenKurrek added a commit that referenced this pull request Jul 13, 2026
…#5850)

Atomic roll-up of the 8-PR NEA-25 taxonomy stack onto current main. Extension is
the only installable product object; tool/channel/auth are derived capability
surfaces; runtime kind controls loading only; manifest projection (v2, host_api
contracts) is the sole surface-discovery source of truth. The connectable-channels
rail and the parallel `kind` taxonomy are removed and pinned by a zero-legacy gate.
slack_bot and slack_personal are retired into one `slack` extension with bounded
forward migrations. Extensions wire carries runtime + surfaces, not a conflated kind.

Supersedes #5833, #5839, #5842, #5845, #5847, #5848, #5849, #5850. Conflicts with
main since the train forked were reconciled preserving main's newer behavior
(#5851 unified slack cleanup, #6054 get_conversation_info DM resolution, #5499
extension import, #6057 TS source conventions). provider_identity domain
duplication removed; the residual is a legitimate up-layer port adapter. See the PR
description for the per-PR crosswalk, resolutions, placement audit, and verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@BenKurrek BenKurrek closed this Jul 20, 2026

This branch was successfully deployed

No deployments
ironclaw-ci-preview / ironclaw-pr-5845 — 22cb072d Deployed Jul 13, 2026 by railway-app[bot]
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: low Changes to docs, tests, or low-risk modules size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant