diff --git a/Cargo.lock b/Cargo.lock index e119409ab9f..eedef8d18d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3798,6 +3798,7 @@ dependencies = [ "toml 1.1.3+spec-1.1.0", "tower 0.5.3", "tracing", + "tracing-subscriber", "url", "zip", ] @@ -4411,6 +4412,7 @@ dependencies = [ "chrono", "ironclaw_extension_contracts", "ironclaw_host_api", + "secrecy", "serde", "serde_json", "static_assertions", diff --git a/crates/AGENTS.md b/crates/AGENTS.md index b0159e14122..15d0d998138 100644 --- a/crates/AGENTS.md +++ b/crates/AGENTS.md @@ -67,7 +67,7 @@ Boundary rule: if you need an upstream crate in a low-level crate, stop and chec | `ironclaw_common` | `ironclaw_common/AGENTS.md`, `Cargo.toml` | Low-dependency shared types/utilities: app events, identity, trust-boundary helpers, paths, platform/env/timezone, attachment helpers. | Runtime orchestration, persistence, clients, policy, product domain logic. | | `ironclaw_host_api` | `ironclaw_host_api/AGENTS.md`, `ironclaw_host_api/CLAUDE.md`, `docs/reborn/contracts/host-api.md` | Neutral authority vocabulary: IDs, scopes, paths, actions, decisions, resources, approvals, audit, HTTP, dispatch, runtime-policy, trust types. | Runtime execution, persistence, HTTP clients, product workflow, policy engines. | | `ironclaw_extension_contracts` | `ironclaw_extension_contracts/CLAUDE.md`, `docs/reborn/target-architecture/families/contracts.md` | The extension tier's contract: what an installable extension declares and exposes — `ChannelAdapter`/`ToolAdapter`/`RestrictedEgress` and their DTO families, channel egress transport vocabulary, the external vendor refs, the channel-rendered auth-prompt views, the hosted-MCP registration input, the bounded package-identity newtypes, channel manifest-surface descriptors, channel-identity hooks, the `Extension` trait, the `[memory]` surface, the auth recipe schema, the installation state machine, `CapabilitySurfaceKind`, and the vendor-implemented `PreferenceTargetCodec`. | Any implementation of a port declared here, the registry or installation stores, lifecycle execution or ingress routing, product workflow, vendor names, any framework or driver crate. | -| `ironclaw_product_contracts` | `ironclaw_product_contracts/CLAUDE.md`, `docs/reborn/target-architecture/families/contracts.md` | The product tier's contract: the `ProductSurface`/`BoundProductSurface`/caller membrane and its invoke/query/stream DTOs, `ChannelInboundProductSurface`, the inbound/outbound/projection product wire DTOs, the interaction-reply grammar, the operator LLM menu vocabulary, and the package-lifecycle projection vocabulary. | The `ProductSurface` implementation or the frozen command/view inventory (those are `ironclaw_product`), any handler/admission/delivery logic, projection reducers, HTTP of any kind, vendor names, any framework or driver crate. | +| `ironclaw_product_contracts` | `ironclaw_product_contracts/CLAUDE.md`, `docs/reborn/target-architecture/families/contracts.md` | The product tier's contract: the `ProductSurface`/`BoundProductSurface`/caller membrane and its invoke/query/stream DTOs, `ChannelInboundProductSurface`, the inbound/outbound/projection product wire DTOs, the interaction-reply grammar, the operator LLM menu vocabulary, the package-lifecycle projection vocabulary, and — since WS2's first row — the **product-side ports the extension host implements**: delivery resolution + reply context, account-connection status, channel config, the view-provider conduit, command context + actor-role admission, gate-prompt enrichment, the lifecycle product service, the admin-user directory, and the operator tool catalog. | The `ProductSurface` implementation or the frozen command/view inventory (those are `ironclaw_product`), any handler/admission/delivery logic, projection reducers, HTTP of any kind, vendor names, any framework or driver crate. **Any implementation of a port declared here** — including a fail-closed default; those stay with their owner. | | `ironclaw_prompt_envelope` | `Cargo.toml`, `src/lib.rs` | Leaf prompt-envelope helper: wraps model-visible snippets with closed-vocabulary source/trust labels, size limits, and instruction-hijack rejection. | Runtime orchestration, model routing, policy decisions, or free-form source labels. | | `ironclaw_architecture` | `ironclaw_architecture/AGENTS.md`, `ironclaw_architecture/CLAUDE.md` | Workspace architecture tests, Reborn dependency boundaries, composition-boundary checks. | Production runtime code or production deps. | | `ironclaw_observability` | `Cargo.toml`, `src/lib.rs` | Shared latency-tracing macros (`live_latency_trace*`) over the `ironclaw_latency` tracing target. | Policy, state, or runtime behavior. | @@ -144,7 +144,7 @@ Boundary rule: if you need an upstream crate in a low-level crate, stop and chec | `ironclaw_telegram_v2_adapter` | `ironclaw_telegram_v2_adapter/AGENTS.md`, `Cargo.toml`, `src/lib.rs` | Telegram Bot API **protocol engine only**: payload normalization (`payload.rs`) and outbound request rendering (`render.rs`). No I/O, no secrets. | The `ChannelAdapter` impl itself (that is `ironclaw_telegram_extension`); host verification/egress. | | `ironclaw_telegram_extension` | `ironclaw_telegram_extension/AGENTS.md`, `Cargo.toml`, `src/lib.rs` | The Telegram **`ChannelAdapter`**: live inbound/outbound, webhook registration hooks, preference targets, attachment transfer — layered on `ironclaw_telegram_v2_adapter`'s protocol work. Stays free of raw token bytes. | Bot API payload/render logic; host signing secrets, admission, or egress credentials. | | `ironclaw_webui` | `ironclaw_webui/AGENTS.md`, `ironclaw_webui/CLAUDE.md`, `ironclaw_webui/README.md` | The whole WebUI host stack for Reborn WebChat v2: the `webui_v2` route surface + axum handlers + descriptor table + redacted `WebUiV2HttpError` (folded up from the former `ironclaw_webui_v2` crate), the Vite SPA bundle (`frontend/`), the `webui_v2_app` gateway assembly + middleware stack, the listener/serve loop, and host authentication (Env/Session/OIDC authenticators, `SessionStore`, `/auth/*` OAuth login). | Product/API business logic, product services, lower substrates, transcript storage, and v1 channel code. Use `ProductSurface`; direct `ironclaw_product` imports are DTOs/descriptors only. | -| `ironclaw_extension_host` | `Cargo.toml`, `src/lib.rs` | Generic channel-host assembly binding installed extensions to inbound/outbound channel surfaces for the Reborn product surface: ingress registration, extension lifecycle command execution, delivery, and per-extension idempotency ledgers. Also the **hosted-MCP registration pipeline** (`hosted_mcp_admission`, `hosted_mcp_manifest`, `hosted_mcp_preparation`, `mcp_catalog_safety`): endpoint admission, synthesized manifests, tool discovery, and catalog safety for user-registered remote MCP servers. That pipeline is deliberately separate from the shared install→activate→remove lifecycle — `crates/ironclaw_architecture/tests/reborn_registration_pipeline_boundary.rs` fails the build if registration vocabulary (`PreparationRequirement`, `initial_preparation`) appears outside `src/hosted_mcp_*`, so put "registered but not yet discovered" state inside the pipeline, never in generic lifecycle code. | Host authority (signing secrets, bot tokens, network egress) and workflow admission; keep those in lower host crates and `ironclaw_reborn_composition`. | +| `ironclaw_extension_host` | `Cargo.toml`, `src/lib.rs` | Generic channel-host assembly binding installed extensions to inbound/outbound channel surfaces for the Reborn product surface: ingress registration, extension lifecycle command execution, delivery, and per-extension idempotency ledgers. Also the **hosted-MCP registration pipeline** (`hosted_mcp_admission`, `hosted_mcp_manifest`, `hosted_mcp_preparation`, `mcp_catalog_safety`): endpoint admission, synthesized manifests, tool discovery, and catalog safety for user-registered remote MCP servers. That pipeline is deliberately separate from the shared install→activate→remove lifecycle — `crates/ironclaw_architecture/tests/reborn_registration_pipeline_boundary.rs` fails the build if registration vocabulary (`PreparationRequirement`, `initial_preparation`) appears outside `src/hosted_mcp_*`, so put "registered but not yet discovered" state inside the pipeline, never in generic lifecycle code. | Host authority (signing secrets, bot tokens, network egress) and workflow admission; keep those in lower host crates and `ironclaw_reborn_composition`. **New product-side ports**: this crate is below product in the target tree, so a port it implements is declared in `ironclaw_product_contracts`, never in `ironclaw_product` — `reborn_extension_host_port_inversion.rs` fails on a new one and its residue list is shrink-only. | | `ironclaw_slack_extension` | `ironclaw_slack_extension/AGENTS.md` | Slack `ChannelAdapter`: protocol parsing/rendering (payloads, mrkdwn, delivery DTOs, preference targets, attachment transfer). Host-side ingress — signature verification and delivery — is generic and lives in `ironclaw_extension_host` (`src/ingress/verifier.rs`, `src/channel_host.rs`), driven by the manifest recipe, not by Slack-specific host code. | Signing secrets, bot tokens, network, workflow admission — the boundary test bans host concerns here. | | `ironclaw_reborn_identity` | `Cargo.toml`, `src/lib.rs` | Canonical identity mapping: every external identity (OAuth login, channel actor) → stable `UserId` before runtime state; filesystem-backed resolver fronted through composition. | Auth flows, session storage, provider HTTP. | diff --git a/crates/ironclaw_architecture/tests/reborn_extension_host_port_inversion.rs b/crates/ironclaw_architecture/tests/reborn_extension_host_port_inversion.rs new file mode 100644 index 00000000000..3d3e2de05f6 --- /dev/null +++ b/crates/ironclaw_architecture/tests/reborn_extension_host_port_inversion.rs @@ -0,0 +1,457 @@ +//! CHECKLIST WS2, row 1 — the `extension_host` port inversion +//! (PROPOSAL §6.1.3, §6.8.2, ordering constraint §12.1c). +//! +//! `ironclaw_extension_host` sits *below* product in the target tree, so a +//! product-side port it satisfies must be **defined at the product boundary** +//! (`ironclaw_product_contracts`) and implemented here — never defined in +//! `ironclaw_product` and reached upward. Every trait the extension host +//! implements that is still declared inside `ironclaw_product` is a live +//! instance of the inverted edge, and the layer flip (`products` → `loops`) +//! cannot land while any remain: the crate would not compile. +//! +//! Two halves: +//! +//! - **The residue is frozen and shrink-only.** The traits still in the wrong +//! place are enumerated with the reason each could not move and the WS2 +//! slice that removes it. A new one fails; a stale one fails too, so the +//! entry has to be deleted in the same change that removes the edge. That is +//! the same update-never-relax shape as the extension-specificity allowlist. +//! - **The inverted ports are pinned where they landed.** Each port this row +//! moved is asserted to be defined in `ironclaw_product_contracts` and +//! implemented in `ironclaw_extension_host`, so a revert is loud rather than +//! a silent re-inversion. (`reborn_product_contract_location_scan.rs` already +//! pins that no *other* crate defines or re-exports them; this pins that the +//! implementation stayed below the contract, which that scan cannot see.) + +// The shared walker is compiled per test binary; each binary uses a subset. +#[allow(dead_code)] +mod ratchet_support; + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use ratchet_support::{ + TypeDefOccurrence, collect_type_defs, strip_comments_and_strings, workspace_root, +}; + +const PRODUCT: &str = "ironclaw_product"; +const PRODUCT_CONTRACTS: &str = "ironclaw_product_contracts"; +const EXTENSION_HOST: &str = "ironclaw_extension_host"; + +/// Product-declared traits `ironclaw_extension_host` still implements, each +/// with the reason the WS2 port-inversion row could not move it and the slice +/// that will. **Shrink-only**: adding an entry re-inverts the edge, and an +/// entry that no longer matches is deleted in the change that fixes it. +/// +/// Every reason below is a *contract-purity* fact, not a preference: +/// `ironclaw_product_contracts` may depend on `ironclaw_host_api` and +/// `ironclaw_extension_contracts` and nothing else internal +/// (`reborn_dependency_boundaries.rs`), so a port whose signature names a type +/// from `ironclaw_auth`, `ironclaw_threads`, `ironclaw_turns`, or +/// `ironclaw_conversations` cannot be declared there until that type is +/// narrowed out of the signature. +const PRODUCT_DEFINED_TRAITS_EXTENSION_HOST_STILL_IMPLEMENTS: &[(&str, &str)] = &[ + ( + "AuthChallengeProvider", + "signature returns Result<_, ironclaw_auth::AuthProductError> and carries \ + ironclaw_auth::{AuthProviderId, CredentialAccountLabel, OAuthAuthorizationUrl}; \ + moving it needs the auth vocabulary narrowed out of the port first", + ), + ( + "ChannelConnectionService", + "returns ChannelAuthAccountState, whose fields are \ + ironclaw_auth::{AuthFlowStatus, CredentialAccountStatus}", + ), + ( + "ConversationBindingService", + "errors with ironclaw_product::ProductSurfaceFailure, which carries \ + ironclaw_turns::TurnError on two variants", + ), + ( + "ExtensionCredentialSetupService", + "request/response types are ironclaw_auth credential-account projections", + ), + ( + "ProductActorUserResolver", + "errors with ProductSurfaceFailure and resolves to ResolvedProductActorUser, \ + which carries ironclaw_conversations::ExternalActorBindingEpoch", + ), + ( + "ProductConversationSubjectRouteResolver", + "errors with ProductSurfaceFailure (see ConversationBindingService)", + ), +]; + +/// The ports this row inverted: defined in `ironclaw_product_contracts`, +/// implemented in `ironclaw_extension_host`. Enumerated so a rename or a +/// relocation has to come through this file. +const INVERTED_PORTS: &[&str] = &[ + "AccountConnectionStatusSource", + "ApprovalPromptContextSource", + "BlockedAuthPromptSource", + "ChannelConfigProductService", + "ChannelDeliveryResolver", + "CommandActorRoleResolver", + "DeliveryReplyContextSource", + "LifecycleProductService", + "RebornViewProvider", +]; + +/// Ceiling on the residue. Only ever moves down. +const WS2_PRODUCT_DEFINED_TRAIT_RESIDUE_BASELINE: usize = 6; + +fn crate_src(root: &Path, name: &str) -> PathBuf { + root.join("crates").join(name).join("src") +} + +fn is_rust_identifier(ident: &str) -> bool { + let mut chars = ident.chars(); + match chars.next() { + Some(first) if first.is_ascii_alphabetic() || first == '_' => {} + _ => return false, + } + chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') +} + +fn traits_defined_in(root: &Path, crate_name: &str) -> BTreeSet { + let mut found: BTreeMap> = BTreeMap::new(); + collect_type_defs( + &crate_src(root, crate_name), + &["trait "], + &is_rust_identifier, + &[], + &mut found, + ); + assert!( + !found.is_empty(), + "no traits discovered in {crate_name} — the walk is broken, not the crate" + ); + found.into_keys().collect() +} + +/// Every production `.rs` file under `dir`. **Every I/O error is fatal**: a +/// scan that silently shrinks its input passes while enforcing nothing, which +/// is the failure mode this whole file exists to prevent. A missing directory, +/// an unreadable entry, or a permission error must red the gate, not thin it. +fn rust_files(dir: &Path, out: &mut Vec) { + let entries = std::fs::read_dir(dir) + .unwrap_or_else(|error| panic!("cannot read {}: {error}", dir.display())); + for entry in entries { + let entry = entry.unwrap_or_else(|error| { + panic!("cannot read an entry under {}: {error}", dir.display()) + }); + let path = entry.path(); + if path.is_dir() { + if matches!( + path.file_name().and_then(|name| name.to_str()), + Some("target") | Some("node_modules") | Some("tests") + ) { + continue; + } + rust_files(&path, out); + } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + if name == "tests.rs" || name.ends_with("_tests.rs") { + continue; + } + out.push(path); + } + } +} + +/// Remove `#[cfg(test)]`-gated items. Only the *production* edge blocks the +/// layer flip: a test double may implement a product trait through the crate's +/// dev-dependency without the shipped artifact depending on product. Same +/// stripping shape as `reborn_registration_pipeline_boundary.rs`. +/// +/// **Callers must strip comments and string literals first.** This walk finds +/// the block by counting raw `{`/`}` bytes, so a brace inside a doc comment or +/// a string literal in a gated block desynchronizes the depth and either leaks +/// a test-only `impl` into the production set or swallows production code that +/// follows. `implemented_trait_names` composes them in that order and +/// `cfg_test_stripping_survives_braces_in_comments_and_strings` pins it. +/// +/// `#[cfg(feature = "test-support")]` items are deliberately **not** stripped: +/// that feature compiles into a real build (CI's `--all-features` lanes enable +/// it), so an `impl` behind it is a genuine normal-dependency edge that would +/// block the layer flip. Only `#[cfg(test)]` is invisible to a shipped +/// artifact. +fn strip_cfg_test_blocks(source: &str) -> String { + const MARKER: &str = "#[cfg(test)]"; + let mut out = String::with_capacity(source.len()); + let mut rest = source; + while let Some(at) = rest.find(MARKER) { + out.push_str(&rest[..at]); + let after = &rest[at + MARKER.len()..]; + let Some(open) = after.find('{') else { + // A `#[cfg(test)] use …;` line: drop through the statement end. + match after.find(';') { + Some(semi) => { + rest = &after[semi + 1..]; + continue; + } + None => return out, + } + }; + // An attribute followed by a `;` before any `{` is a gated statement. + if let Some(semi) = after.find(';') + && semi < open + { + rest = &after[semi + 1..]; + continue; + } + let bytes = after.as_bytes(); + let mut depth = 0usize; + let mut idx = open; + while idx < bytes.len() { + match bytes[idx] { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + break; + } + } + _ => {} + } + idx += 1; + } + if idx >= bytes.len() { + return out; + } + rest = &after[idx + 1..]; + } + out.push_str(rest); + out +} + +/// Byte index of the `>` that closes the `<` at index 0, counting nesting. +/// `None` when the brackets never balance (a truncated slice), which the +/// caller treats as "not an impl header I can read" rather than guessing. +fn balanced_angle_close(text: &str) -> Option { + let bytes = text.as_bytes(); + let mut depth = 0usize; + for (index, ch) in text.char_indices() { + match ch { + '<' => depth += 1, + // A `->` inside a bound (`impl bool>`) is a return + // arrow, not a closing bracket. `-` never opens one, so a `>` + // preceded by `-` is skipped. + '>' if index > 0 && bytes[index - 1] == b'-' => {} + '>' => { + depth -= 1; + if depth == 0 { + return Some(index); + } + } + _ => {} + } + } + None +} + +/// Trait names appearing as the *implemented* trait of an `impl … for …` item, +/// with any leading path qualifier and generic arguments dropped +/// (`impl ironclaw_product::Foo for Bar` → `Foo`). Inherent impls +/// (`impl Bar {`) have no `for` and never match. +fn implemented_trait_names(source: &str) -> BTreeSet { + let cleaned = strip_cfg_test_blocks(&strip_comments_and_strings(source)); + let mut names = BTreeSet::new(); + for segment in cleaned.split("impl").skip(1) { + let Some(head) = segment.split_once(" for ") else { + continue; + }; + let mut candidate = head.0.trim(); + // Drop an `<'a, T>` generic-parameter list that binds the impl itself. + // The close must be found by *balancing*, not by the first `>`: a bound + // may itself be generic (`impl> Port for Host`), + // and taking the first `>` would leave `> Port` — not an identifier, so + // the impl would be skipped and the gate would enforce nothing for it. + if candidate.starts_with('<') { + let Some(close) = balanced_angle_close(candidate) else { + continue; + }; + candidate = candidate[close + 1..].trim(); + } + // Drop generic arguments on the trait itself, then the path qualifier. + let candidate = candidate.split('<').next().unwrap_or(candidate).trim(); + let Some(last) = candidate.rsplit("::").next() else { + continue; + }; + let last = last.trim(); + if is_rust_identifier(last) { + names.insert(last.to_string()); + } + } + names +} + +fn traits_implemented_by(root: &Path, crate_name: &str) -> BTreeSet { + let mut files = Vec::new(); + rust_files(&crate_src(root, crate_name), &mut files); + assert!( + files.len() > 20, + "expected to walk {crate_name}'s source tree; found {} files", + files.len() + ); + let mut names = BTreeSet::new(); + for file in files { + let source = std::fs::read_to_string(&file) + .unwrap_or_else(|error| panic!("cannot read {}: {error}", file.display())); + names.extend(implemented_trait_names(&source)); + } + names +} + +#[test] +fn extension_host_implements_only_the_frozen_residue_of_product_defined_traits() { + let root = workspace_root(); + let product_traits = traits_defined_in(&root, PRODUCT); + let implemented = traits_implemented_by(&root, EXTENSION_HOST); + + let found: BTreeSet = implemented.intersection(&product_traits).cloned().collect(); + let frozen: BTreeSet = PRODUCT_DEFINED_TRAITS_EXTENSION_HOST_STILL_IMPLEMENTS + .iter() + .map(|(name, _)| (*name).to_string()) + .collect(); + + let mut violations = Vec::new(); + for name in found.difference(&frozen) { + violations.push(format!( + "{EXTENSION_HOST} implements {PRODUCT}::{name}, which re-inverts the \ + extension_host -> product edge. Define the port in {PRODUCT_CONTRACTS} \ + (PROPOSAL §6.1.3) instead of adding a row here" + )); + } + for name in frozen.difference(&found) { + violations.push(format!( + "{name} is listed as residue but {EXTENSION_HOST} no longer implements a \ + {PRODUCT}-defined trait by that name — delete its row in the same change" + )); + } + + assert!( + violations.is_empty(), + "WS2 port-inversion rule violated (CHECKLIST WS2 row 1):\n{}", + violations.join("\n") + ); + assert!( + found.len() <= WS2_PRODUCT_DEFINED_TRAIT_RESIDUE_BASELINE, + "the product-defined trait residue is shrink-only: {} > baseline {}", + found.len(), + WS2_PRODUCT_DEFINED_TRAIT_RESIDUE_BASELINE + ); +} + +#[test] +fn inverted_ports_are_declared_in_contracts_and_implemented_below_product() { + let root = workspace_root(); + let contract_traits = traits_defined_in(&root, PRODUCT_CONTRACTS); + let product_traits = traits_defined_in(&root, PRODUCT); + let implemented = traits_implemented_by(&root, EXTENSION_HOST); + + let mut violations = Vec::new(); + for port in INVERTED_PORTS { + if !contract_traits.contains(*port) { + violations.push(format!( + "{port} must be declared in {PRODUCT_CONTRACTS}; the WS2 inversion moved it there" + )); + } + if product_traits.contains(*port) { + violations.push(format!( + "{port} is declared again in {PRODUCT} — the inversion gives it exactly one home" + )); + } + if !implemented.contains(*port) { + violations.push(format!( + "{port} has no implementation in {EXTENSION_HOST}; if the implementor moved, \ + move this row with it rather than deleting the pin" + )); + } + } + + assert!( + violations.is_empty(), + "WS2 inverted-port placement violated (PROPOSAL §6.1.3):\n{}", + violations.join("\n") + ); +} + +#[test] +fn cfg_test_stripping_survives_braces_in_comments_and_strings() { + // A brace inside a comment or a string literal in a gated block would + // desynchronize a raw-byte depth count. Composed in the wrong order, the + // stray `{` here swallows `Production` (or leaks `TestOnly`); composed as + // `implemented_trait_names` does, neither happens. + let source = r#" + #[cfg(test)] + mod tests { + // an unbalanced brace in a comment: { + const PATTERN: &str = "unbalanced { in a string"; + impl TestOnly for Double {} + } + impl Production for Real {} + "#; + let found = implemented_trait_names(source); + assert!( + found.contains("Production"), + "production impl after a brace-carrying gated block must survive: {found:?}" + ); + assert!( + !found.contains("TestOnly"), + "gated impl must still be stripped: {found:?}" + ); +} + +#[test] +fn impl_scanner_reads_the_trait_out_of_real_impl_shapes() { + let source = r#" + impl Plain for Thing {} + impl<'a, T> Generic for Other<'a, T> {} + impl ironclaw_product::Qualified for Third {} + impl Inherent { fn f() {} } + // impl Commented for Ignored {} + impl async_trait::Marker for Fourth {} + impl> NestedBound for Host {} + impl bool> ArrowBound for Guard {} + #[cfg(test)] + mod tests { + impl TestOnly for Double {} + } + "#; + let found = implemented_trait_names(source); + assert!(found.contains("Plain"), "plain impl: {found:?}"); + assert!(found.contains("Generic"), "generic impl: {found:?}"); + assert!( + found.contains("Qualified"), + "path-qualified impl: {found:?}" + ); + assert!(found.contains("Marker"), "crate-qualified impl: {found:?}"); + assert!( + !found.contains("Inherent"), + "inherent impl must not match: {found:?}" + ); + assert!( + !found.contains("Commented"), + "commented-out impl must not match: {found:?}" + ); + assert!( + !found.contains("TestOnly"), + "a #[cfg(test)] impl is not a production edge: {found:?}" + ); + // The generic-parameter list must be closed by balancing, not by the first + // `>`: a nested bound or an `Fn(..) -> T` return arrow both put a `>` + // inside the list, and taking the first one silently drops the impl — a + // hole through which a new product-defined port could enter unenforced. + assert!( + found.contains("NestedBound"), + "a nested generic bound must not hide the trait: {found:?}" + ); + assert!( + found.contains("ArrowBound"), + "an `Fn(..) -> T` bound must not hide the trait: {found:?}" + ); +} diff --git a/crates/ironclaw_extension_host/Cargo.toml b/crates/ironclaw_extension_host/Cargo.toml index 6bc33dd2b36..377eb2274d0 100644 --- a/crates/ironclaw_extension_host/Cargo.toml +++ b/crates/ironclaw_extension_host/Cargo.toml @@ -105,6 +105,10 @@ http-body-util = "0.1" tempfile = "3" tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "sync", "time"] } tower = { version = "0.5", features = ["util"] } +# Installs a DEBUG-level subscriber so a `tracing::debug!` body actually runs +# under test. Without one, `tracing` short-circuits on the null dispatcher and +# the log a sanitization test needs to inspect is never formatted. +tracing-subscriber = "0.3" [[test]] name = "lifecycle_contract" diff --git a/crates/ironclaw_extension_host/src/admin_configuration.rs b/crates/ironclaw_extension_host/src/admin_configuration.rs index fc482c91619..1abe0a9b5b0 100644 --- a/crates/ironclaw_extension_host/src/admin_configuration.rs +++ b/crates/ironclaw_extension_host/src/admin_configuration.rs @@ -9,12 +9,12 @@ use ironclaw_filesystem::RootFilesystem; use ironclaw_host_api::{ids::InvocationId, resource::ResourceScope}; use ironclaw_product::{ ADMIN_CONFIGURATION_VIEW, RebornAdminConfigurationField, RebornAdminConfigurationGroup, - RebornAdminConfigurationListResponse, RebornAdminConfigurationUse, RebornViewDescriptor, - RebornViewPage, RebornViewProvider, + RebornAdminConfigurationListResponse, RebornAdminConfigurationUse, }; use ironclaw_product_contracts::surface::{ ProductSurfaceCaller, ProductSurfaceError, ProductSurfaceErrorCode, ProductSurfaceErrorKind, }; +use ironclaw_product_contracts::views::{RebornViewDescriptor, RebornViewPage, RebornViewProvider}; use ironclaw_extension_host::AdminConfigurationCatalogUse; diff --git a/crates/ironclaw_extension_host/src/available_extension_import.rs b/crates/ironclaw_extension_host/src/available_extension_import.rs index f4138d9430d..5c312382361 100644 --- a/crates/ironclaw_extension_host/src/available_extension_import.rs +++ b/crates/ironclaw_extension_host/src/available_extension_import.rs @@ -6,7 +6,8 @@ use ironclaw_extensions::{ }; use ironclaw_filesystem::{FileType, FilesystemError, RootFilesystem}; use ironclaw_host_api::{ids::ExtensionId, path::VirtualPath, runtime::RuntimeKind}; -use ironclaw_product::{LifecyclePackageKind, LifecyclePackageRef, ProductSurfaceFailure}; +use ironclaw_product::ProductSurfaceFailure; +use ironclaw_product_contracts::package_lifecycle::{LifecyclePackageKind, LifecyclePackageRef}; use crate::product_extension_host_api_contract_registry; diff --git a/crates/ironclaw_extension_host/src/available_extensions.rs b/crates/ironclaw_extension_host/src/available_extensions.rs index ec4208fbaa1..2ee3c3f084f 100644 --- a/crates/ironclaw_extension_host/src/available_extensions.rs +++ b/crates/ironclaw_extension_host/src/available_extensions.rs @@ -8,19 +8,19 @@ use ironclaw_extensions::{ ManifestSource, }; use ironclaw_filesystem::{DirEntry, FileType, FilesystemError, RootFilesystem}; +use ironclaw_host_api::product_adapter::{ProductCapabilityFlag, ProductSurfaceKind}; use ironclaw_host_api::{ host_port::HostPortCatalog, ids::{CapabilityId, ExtensionId, VendorId}, path::VirtualPath, }; -use ironclaw_product::{ +use ironclaw_product::{ProductSurfaceFailure, RebornChannelConnectStrategy}; +use ironclaw_product_contracts::package_lifecycle::{ ChannelConnectionRequirement, LifecycleChannelDirections, LifecycleExtensionCredentialRequirement, LifecycleExtensionCredentialSetup, LifecycleExtensionOnboarding, LifecycleExtensionRuntimeKind, LifecycleExtensionSource, - LifecycleExtensionSummary, LifecyclePackageKind, LifecyclePackageRef, ProductSurfaceFailure, - RebornChannelConnectStrategy, + LifecycleExtensionSummary, LifecyclePackageKind, LifecyclePackageRef, }; -use ironclaw_product::{ProductCapabilityFlag, ProductSurfaceKind}; use std::{collections::BTreeMap, sync::Arc}; use toml::Value; diff --git a/crates/ironclaw_extension_host/src/channel_command_roles.rs b/crates/ironclaw_extension_host/src/channel_command_roles.rs index d06490dae19..08515be6c58 100644 --- a/crates/ironclaw_extension_host/src/channel_command_roles.rs +++ b/crates/ironclaw_extension_host/src/channel_command_roles.rs @@ -7,10 +7,10 @@ use ironclaw_host_api::{ ids::{TenantId, UserId}, user_identity::{RebornUserIdentityLookup, installation_scoped_provider_user_id}, }; -use ironclaw_product::{ - AdminUserError, AdminUserRole, AdminUserService, AdminUserStatus, CommandActorRoleResolver, - ProductCommandContext, +use ironclaw_product_contracts::admin_users::{ + AdminUserError, AdminUserRole, AdminUserService, AdminUserStatus, }; +use ironclaw_product_contracts::command::{CommandActorRoleResolver, ProductCommandContext}; use ironclaw_product_contracts::surface::ProductSurfaceError; use std::sync::Arc; @@ -120,18 +120,22 @@ impl CommandActorRoleResolver for ChannelActorRoleResolver { #[cfg(test)] mod tests { use super::*; + use ironclaw_extension_contracts::channel_adapter::ProductTriggerReason; use ironclaw_extension_contracts::external::{ ExternalActorRef, ExternalConversationRef, ExternalEventId, }; + use ironclaw_host_api::product_adapter::AuthRequirement; use ironclaw_host_api::product_adapter::{ AdapterInstallationId, ProductAdapterId, ProtocolAuthEvidence, }; use ironclaw_host_api::user_identity::RebornUserIdentityLookupError; - use ironclaw_product::{ - ActionFingerprintKey, AdminCreateUserFields, AdminCreatedUser, AdminUserRecord, - AdminUserSecretMeta, AuthRequirement, InboundCommandPayload, ProductActionId, - ProductTriggerReason, SourceBindingKey, + use ironclaw_product_contracts::action::{ + ActionFingerprintKey, ProductActionId, SourceBindingKey, }; + use ironclaw_product_contracts::admin_users::{ + AdminCreateUserFields, AdminCreatedUser, AdminUserRecord, AdminUserSecretMeta, + }; + use ironclaw_product_contracts::inbound::InboundCommandPayload; use ironclaw_product_contracts::inbound::{ ParsedProductInbound, ProductInboundEnvelope, ProductInboundPayload, TrustedInboundContext, }; diff --git a/crates/ironclaw_extension_host/src/channel_config.rs b/crates/ironclaw_extension_host/src/channel_config.rs index 0d787c63e0c..85db1b3ccb6 100644 --- a/crates/ironclaw_extension_host/src/channel_config.rs +++ b/crates/ironclaw_extension_host/src/channel_config.rs @@ -29,6 +29,8 @@ use ironclaw_host_api::{ ids::{ExtensionId, SecretHandle}, resource::ResourceScope, }; +use ironclaw_product_contracts::channel_config::ChannelConfigProductService; +use ironclaw_product_contracts::package_lifecycle::ChannelConfigField; use ironclaw_product_contracts::surface::{ ProductSurfaceError, ProductSurfaceErrorCode, ProductSurfaceErrorKind, }; @@ -663,7 +665,7 @@ fn channel_config_admin_idempotency_key( }) } -/// The production [`ironclaw_product::ChannelConfigProductService`] port +/// The production [`ChannelConfigProductService`] port /// over [`ChannelConfigService`] — the surface the WebUI setup service and /// the lifecycle configure action route through. pub struct RebornChannelConfigProductService { @@ -677,11 +679,11 @@ impl RebornChannelConfigProductService { } #[async_trait] -impl ironclaw_product::ChannelConfigProductService for RebornChannelConfigProductService { +impl ChannelConfigProductService for RebornChannelConfigProductService { async fn field_status( &self, extension_id: &ExtensionId, - ) -> Result, ProductSurfaceError> { + ) -> Result, ProductSurfaceError> { if let Ok(manifest) = self.service.resolved_manifest(extension_id).await && !manifest.admin_configuration.is_empty() { @@ -690,7 +692,7 @@ impl ironclaw_product::ChannelConfigProductService for RebornChannelConfigProduc match self.service.status(extension_id).await { Ok(statuses) => Ok(statuses .into_iter() - .map(|status| ironclaw_product::RebornChannelConfigField { + .map(|status| ChannelConfigField { name: status.handle, label: status.label, secret: status.secret, diff --git a/crates/ironclaw_extension_host/src/channel_connection.rs b/crates/ironclaw_extension_host/src/channel_connection.rs index b971f0505b1..2ec505b8a1f 100644 --- a/crates/ironclaw_extension_host/src/channel_connection.rs +++ b/crates/ironclaw_extension_host/src/channel_connection.rs @@ -521,7 +521,7 @@ mod tests { use std::sync::Mutex; use ironclaw_host_api::ids::{AgentId, UserId}; - use ironclaw_product::AdapterInstallationId; + use ironclaw_host_api::product_adapter::AdapterInstallationId; use super::*; use ironclaw_extension_host::product_extension_host_api_contract_registry; diff --git a/crates/ironclaw_extension_host/src/channel_delivery.rs b/crates/ironclaw_extension_host/src/channel_delivery.rs index 49cd699b900..17d4654b3f4 100644 --- a/crates/ironclaw_extension_host/src/channel_delivery.rs +++ b/crates/ironclaw_extension_host/src/channel_delivery.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use async_trait::async_trait; -use ironclaw_product::{ +use ironclaw_product_contracts::delivery::{ ChannelDeliveryResolver, DeliveryReplyContextSource, ResolvedChannelDelivery, }; diff --git a/crates/ironclaw_extension_host/src/channel_dm_provisioning.rs b/crates/ironclaw_extension_host/src/channel_dm_provisioning.rs index 561d83805b9..251b1ddf908 100644 --- a/crates/ironclaw_extension_host/src/channel_dm_provisioning.rs +++ b/crates/ironclaw_extension_host/src/channel_dm_provisioning.rs @@ -18,11 +18,11 @@ use std::{sync::Arc, time::Duration}; use ironclaw_host_api::ids::UserId; +use ironclaw_extension_contracts::channel_adapter::TargetQuery; use ironclaw_extension_contracts::channel_identity::{ ChannelIdentityPostBind, ChannelIdentityPostBindFactory, }; -use ironclaw_product::ChannelDeliveryResolver; -use ironclaw_product::TargetQuery; +use ironclaw_product_contracts::delivery::ChannelDeliveryResolver; use ironclaw_extension_host::{FilesystemChannelDmTargetStore, dm_target_payload}; @@ -181,7 +181,9 @@ async fn provision_dm_target( .await { Ok(candidates) => candidates, - Err(ironclaw_product::ChannelError::Unsupported) => return Ok(false), + Err(ironclaw_extension_contracts::channel_adapter::ChannelError::Unsupported) => { + return Ok(false); + } Err(error) => { return Err(DmTargetProvisioningError::TargetDiscovery( error.to_string(), @@ -215,16 +217,17 @@ mod tests { use async_trait::async_trait; use ironclaw_extension_contracts::channel_adapter::ChannelAdapter; + use ironclaw_extension_contracts::channel_adapter::{ + ChannelError, DeliveryReport, InboundOutcome, OutboundEnvelope, TargetCandidate, + VerifiedInbound, + }; + use ironclaw_extension_contracts::external::ExternalConversationRef; use ironclaw_extension_contracts::tool_adapter::{ RestrictedEgress, RestrictedEgressError, RestrictedEgressRequest, RestrictedEgressResponse, }; use ironclaw_filesystem::InMemoryBackend; use ironclaw_host_api::ids::TenantId; - use ironclaw_product::ResolvedChannelDelivery; - use ironclaw_product::{ - ChannelError, DeliveryReport, ExternalConversationRef, InboundOutcome, OutboundEnvelope, - TargetCandidate, VerifiedInbound, - }; + use ironclaw_product_contracts::delivery::ResolvedChannelDelivery; use super::*; diff --git a/crates/ironclaw_extension_host/src/channel_host.rs b/crates/ironclaw_extension_host/src/channel_host.rs index 62f803cb002..efd7adfffca 100644 --- a/crates/ironclaw_extension_host/src/channel_host.rs +++ b/crates/ironclaw_extension_host/src/channel_host.rs @@ -24,6 +24,7 @@ use std::sync::{Arc, Mutex as StdMutex}; use async_trait::async_trait; use ironclaw_conversations::RebornFilesystemConversationServices; +use ironclaw_extension_contracts::external::{ExternalConversationRef, ExternalEventId}; use ironclaw_extension_contracts::preference_target::PreferenceTargetCodec; use ironclaw_extension_contracts::recipe::IngressVerificationRecipe; use ironclaw_extension_contracts::recipe::RecipeSecretField; @@ -33,6 +34,7 @@ use ironclaw_extension_host::ingress::{ }; use ironclaw_extension_host::{DeploymentChannelBinding, DeploymentChannelRegistry, SnapshotWatch}; use ironclaw_filesystem::{RootFilesystem, ScopedFilesystem}; +use ironclaw_host_api::product_adapter::{AdapterInstallationId, ProductAdapterId}; use ironclaw_host_api::{ ids::{AgentId, ExtensionId, ProjectId, SecretHandle, TenantId, ThreadId, UserId}, mount::{MountGrant, MountPermissions, MountView}, @@ -40,13 +42,9 @@ use ironclaw_host_api::{ resource::ResourceScope, }; use ironclaw_outbound::{CommunicationPreferenceRepository, DeliveredGateRouteStore}; +use ironclaw_product::ProjectFilesystemReader; use ironclaw_product::{ - AdapterInstallationId, ExternalConversationRef, ExternalEventId, ProductAdapterId, - ProductInboundAck, ProductInboundEnvelope, ProjectFilesystemReader, -}; -use ironclaw_product::{ - ApprovalInteractionService, ApprovalPromptContextSource, AuthInteractionService, - BlockedAuthFlowCanceller, BlockedAuthPromptSource, ChannelConnectionNoticePolicy, + ApprovalInteractionService, AuthInteractionService, BlockedAuthFlowCanceller, ConversationBindingService, DefaultInboundTurnService, DefaultProductSurface, DeliveryCoordinator, IdempotencyLedger, InboundAttachmentLander, ProductActorUserResolutionRequest, ProductActorUserResolver, @@ -55,6 +53,11 @@ use ironclaw_product::{ RunDeliveryObserver, RunDeliveryServices, RunDeliverySettings, StaticProductInstallationResolver, }; +use ironclaw_product_contracts::account_setup::ChannelConnectionNoticePolicy; +use ironclaw_product_contracts::inbound::{ProductInboundAck, ProductInboundEnvelope}; +use ironclaw_product_contracts::prompt_source::{ + ApprovalPromptContextSource, BlockedAuthPromptSource, +}; use ironclaw_product_contracts::surface::ChannelInboundProductSurface; use ironclaw_threads::SessionThreadService; use ironclaw_turns::{TurnCoordinator, TurnScope}; @@ -66,6 +69,7 @@ use crate::extension_ingress::{ ManagedRegistrationOutcome, PostAdmissionObserver, VerifiedEvidenceMint, }; use ironclaw_extension_host::ChannelConfigService; +use ironclaw_product_contracts::admin_users::AdminUserService; const CHANNEL_IDEMPOTENCY_LEDGER_SETTLED_LIMIT: usize = 10_000; const CHANNEL_IDEMPOTENCY_LEDGER_PRUNE_INTERVAL: usize = 1_000; @@ -363,7 +367,7 @@ pub struct GenericChannelHostDeps { /// for extensions that pair without an OAuth vendor. pub channel_pairing: Option>, /// Admin-users directory backing channel-command role gating. - pub admin_users: Arc, + pub admin_users: Arc, } /// What the assembly last reconciled for one extension id. @@ -1269,7 +1273,7 @@ impl PostAdmissionObserver for RunDeliveryPostAdmissionObserver { async fn observe_error( &self, envelope: ProductInboundEnvelope, - error: ironclaw_product::ProductAdapterError, + error: ironclaw_host_api::product_adapter_error::ProductAdapterError, ) { self.observer.observe_error(envelope, error).await; } diff --git a/crates/ironclaw_extension_host/src/channel_host/e2e_auth_challenge.rs b/crates/ironclaw_extension_host/src/channel_host/e2e_auth_challenge.rs index 891ef374909..c026002b438 100644 --- a/crates/ironclaw_extension_host/src/channel_host/e2e_auth_challenge.rs +++ b/crates/ironclaw_extension_host/src/channel_host/e2e_auth_challenge.rs @@ -2,8 +2,8 @@ use std::sync::Mutex; use async_trait::async_trait; use ironclaw_auth::{AuthProductError, AuthProviderId, OAuthAuthorizationUrl}; +use ironclaw_extension_contracts::auth_prompt::AuthPromptChallengeKind; use ironclaw_host_api::ids::{AgentId, ProjectId, UserId}; -use ironclaw_product::AuthPromptChallengeKind; use ironclaw_turns::{TurnRunId, TurnScope}; use ironclaw_product::{AuthChallengeProvider, AuthChallengeView}; diff --git a/crates/ironclaw_extension_host/src/channel_host/e2e_tests.rs b/crates/ironclaw_extension_host/src/channel_host/e2e_tests.rs index 60844438a61..4b27c32e7c2 100644 --- a/crates/ironclaw_extension_host/src/channel_host/e2e_tests.rs +++ b/crates/ironclaw_extension_host/src/channel_host/e2e_tests.rs @@ -32,11 +32,17 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; use hmac::{Hmac, KeyInit, Mac}; use http_body_util::BodyExt; +use ironclaw_extension_contracts::external::{ + ExternalActorRef, ExternalConversationRef, ExternalEventId, +}; use ironclaw_extensions::{ ExtensionInstallation, ExtensionInstallationId, ExtensionInstallationStorePort as _, ExtensionManifestRecord, ExtensionManifestRef, ManifestSource, }; use ironclaw_filesystem::{InMemoryBackend, RootFilesystem, ScopedFilesystem}; +use ironclaw_host_api::product_adapter::{ + AdapterInstallationId, AuthRequirement, ProductAdapterId, ProtocolAuthEvidence, +}; use ironclaw_host_api::turn::{ AcceptedMessageRef, EventCursor, ReplyTargetBindingRef, RunProfileId, RunProfileVersion, TurnActor, TurnGateRef, TurnId, TurnRunId, TurnScope, TurnStatus, @@ -56,16 +62,6 @@ use ironclaw_outbound::{ DeliveryDefaultScope, OutboundDeliveryTargetEntry, RunFinalReplyDestination, WriteCommunicationPreferenceRequest, }; -use ironclaw_product::{ - AdapterInstallationId, AuthRequirement, AuthResolutionPayload, AuthResolutionResult, - ExternalActorRef, ExternalConversationRef, ExternalEventId, ParsedProductInbound, - ProductAdapterId, ProductInboundAck, ProductInboundEnvelope, ProductInboundPayload, - ProtocolAuthEvidence, TrustedInboundContext, -}; -use ironclaw_product::{ - AdminCreateUserFields, AdminCreatedUser, AdminUserError, AdminUserRecord, AdminUserRole, - AdminUserSecretMeta, AdminUserService, AdminUserStatus, -}; use ironclaw_product::{ ApprovalInteractionActionView, ApprovalInteractionDecision, ApprovalInteractionScope, ApprovalInteractionService, AuthInteractionDecision, AuthInteractionService, @@ -77,6 +73,14 @@ use ironclaw_product::{ ResolvedBinding, RunDeliveryServices, RunDeliverySettings, TriggeredRunDeliveryDriver, TriggeredRunDeliveryRequest, }; +use ironclaw_product_contracts::admin_users::{ + AdminCreateUserFields, AdminCreatedUser, AdminUserError, AdminUserRecord, AdminUserRole, + AdminUserSecretMeta, AdminUserService, AdminUserStatus, +}; +use ironclaw_product_contracts::inbound::{ + AuthResolutionPayload, AuthResolutionResult, ParsedProductInbound, ProductInboundAck, + ProductInboundEnvelope, ProductInboundPayload, TrustedInboundContext, +}; use ironclaw_secrets::{SecretStore, SecretStorePort}; use ironclaw_slack_extension::{ SLACK_USER_ACTOR_KIND, SLACK_V2_ADAPTER_ID, SlackPreferenceTargetCodec, @@ -114,7 +118,7 @@ use ironclaw_extension_host::{IngressReplyContextSource, SnapshotChannelDelivery use ironclaw_host_api::user_identity::{RebornUserIdentityLookup, RebornUserIdentityLookupError}; use ironclaw_host_ingress::PublicRouteMount; use ironclaw_product::AuthChallengeProvider; -use ironclaw_product::BlockedAuthPromptSource; +use ironclaw_product_contracts::prompt_source::BlockedAuthPromptSource; #[path = "e2e_auth_challenge.rs"] mod e2e_auth_challenge; @@ -2309,7 +2313,8 @@ async fn shared_channel_admission_follows_saved_channel_config() { let expected_managed_subject = ironclaw_extension_host::managed_channel_subject_user_id( ADAPTER, &TenantId::new(TENANT).expect("tenant"), // safety: static test tenant id is valid. - &ironclaw_product::AdapterInstallationId::new(INSTALLATION).expect("installation"), // safety: static test installation id is valid. + &ironclaw_host_api::product_adapter::AdapterInstallationId::new(INSTALLATION) + .expect("installation"), // safety: static test installation id is valid. Some(TEAM), "C777", ) diff --git a/crates/ironclaw_extension_host/src/channel_lifecycle.rs b/crates/ironclaw_extension_host/src/channel_lifecycle.rs index 93b56e6b764..b59ae9c77d2 100644 --- a/crates/ironclaw_extension_host/src/channel_lifecycle.rs +++ b/crates/ironclaw_extension_host/src/channel_lifecycle.rs @@ -1,9 +1,9 @@ use ironclaw_extensions::ExtensionPackage; use ironclaw_host_api::capability::RuntimeCredentialAccountSetup; +use ironclaw_product::RebornChannelConnectStrategy; use ironclaw_product::adapter_registry::PRODUCT_ADAPTER_HOST_API_ID; -use ironclaw_product::{ - ChannelConnectionRequirement, ExtensionAccountSetupDescriptor, RebornChannelConnectStrategy, -}; +use ironclaw_product_contracts::account_setup::ExtensionAccountSetupDescriptor; +use ironclaw_product_contracts::package_lifecycle::ChannelConnectionRequirement; use crate::package_runtime_credential_auth_requirements; diff --git a/crates/ironclaw_extension_host/src/channel_outbound_targets.rs b/crates/ironclaw_extension_host/src/channel_outbound_targets.rs index 7e18a2bd4b2..4b855aed211 100644 --- a/crates/ironclaw_extension_host/src/channel_outbound_targets.rs +++ b/crates/ironclaw_extension_host/src/channel_outbound_targets.rs @@ -23,6 +23,7 @@ use std::collections::BTreeMap; use std::sync::Arc; use async_trait::async_trait; +use ironclaw_extension_contracts::external::ExternalConversationRef; use ironclaw_extension_contracts::preference_target::{ PreferenceTargetCodec, PreferenceTargetEncodeRequest, }; @@ -30,8 +31,8 @@ use ironclaw_extension_contracts::recipe::RecipeSecretField; use ironclaw_extension_host::SnapshotWatch; use ironclaw_extension_host::active::ActiveExtension; use ironclaw_host_api::ids::{AgentId, ExtensionId, ProjectId, TenantId, UserId}; +use ironclaw_host_api::product_adapter::AdapterInstallationId; use ironclaw_outbound::{OutboundError, RunFinalReplyDestination}; -use ironclaw_product::{AdapterInstallationId, ExternalConversationRef}; use ironclaw_turns::ReplyTargetBindingRef; use crate::channel_host::GenericChannelHostAssembly; diff --git a/crates/ironclaw_extension_host/src/channel_pairing.rs b/crates/ironclaw_extension_host/src/channel_pairing.rs index 543ae312540..c23782d66b2 100644 --- a/crates/ironclaw_extension_host/src/channel_pairing.rs +++ b/crates/ironclaw_extension_host/src/channel_pairing.rs @@ -32,6 +32,7 @@ use ironclaw_conversations::{ use ironclaw_filesystem::{ CasApply, ContentType, Entry, FilesystemError, RootFilesystem, ScopedFilesystem, cas_update, }; +use ironclaw_host_api::product_adapter::AdapterInstallationId; use ironclaw_host_api::{ error::HostApiError, ids::{AgentId, ExtensionId, InvocationId, ProjectId, TenantId, UserId}, @@ -39,8 +40,11 @@ use ironclaw_host_api::{ path::{MountAlias, ScopedPath, VirtualPath}, resource::ResourceScope, }; -use ironclaw_product::AdapterInstallationId; -use ironclaw_product::{ChannelConnectionNoticePolicy, ChannelConnectionRequirement}; +use ironclaw_product_contracts::account_setup::ChannelConnectionNoticePolicy; +use ironclaw_product_contracts::account_setup::{ + AccountConnectionStatusError, AccountConnectionStatusSource, +}; +use ironclaw_product_contracts::package_lifecycle::ChannelConnectionRequirement; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -1118,7 +1122,9 @@ impl crate::extension_ingress::ChannelPairingInterceptor for ChannelPairingServi ) -> crate::extension_ingress::ChannelPairingInterception { use crate::extension_ingress::ChannelPairingInterception; - if message.trigger != ironclaw_product::ProductTriggerReason::DirectChat { + if message.trigger + != ironclaw_extension_contracts::channel_adapter::ProductTriggerReason::DirectChat + { return ChannelPairingInterception::NotHandled; } let Some(code) = candidate_code(&message.text, &self.inbound_code_prefixes) else { @@ -1176,20 +1182,15 @@ impl crate::extension_ingress::ChannelPairingInterceptor for ChannelPairingServi /// entry so activation can gate on the caller's pairing state without /// holding the full pairing surface. #[async_trait] -impl ironclaw_product::AccountConnectionStatusSource for ChannelPairingService { - async fn connected( - &self, - user_id: &UserId, - ) -> Result { +impl AccountConnectionStatusSource for ChannelPairingService { + async fn connected(&self, user_id: &UserId) -> Result { let status = self.status_for(user_id).await.map_err(|error| { tracing::debug!( target: "ironclaw::reborn::channel_pairing", error = %error, "channel pairing status lookup failed" ); - ironclaw_product::AccountConnectionStatusError::new( - "channel pairing status unavailable", - ) + AccountConnectionStatusError::new("channel pairing status unavailable") })?; Ok(status.connected) } diff --git a/crates/ironclaw_extension_host/src/channel_pairing/tests.rs b/crates/ironclaw_extension_host/src/channel_pairing/tests.rs index 5e785efe165..d620da21d26 100644 --- a/crates/ironclaw_extension_host/src/channel_pairing/tests.rs +++ b/crates/ironclaw_extension_host/src/channel_pairing/tests.rs @@ -12,15 +12,21 @@ use ironclaw_auth::{AuthProductError, RebornAuthContinuationDispatcher}; use ironclaw_conversations::{ ConditionalUnpairOutcome, ExternalActorRef as ConversationActorRef, InboundTurnError, }; +use ironclaw_extension_contracts::auth_prompt::AuthPromptChallengeKind; use ironclaw_extension_contracts::channel_adapter::NormalizedInboundMessage; +use ironclaw_extension_contracts::channel_adapter::ProductTriggerReason; +use ironclaw_extension_contracts::external::{ + ExternalActorRef, ExternalConversationRef, ExternalEventId, +}; use ironclaw_extension_host::ingress::{InboundAdmission, InboundAdmissionAck, InboundSink}; use ironclaw_filesystem::InMemoryBackend; +use ironclaw_host_api::product_adapter::ProductAdapterId; use ironclaw_host_api::user_identity::RebornUserIdentityLookupError; -use ironclaw_product::{ - AuthPromptChallengeKind, BlockedAuthPromptRequest, BlockedAuthPromptSource, - ChannelConnectionNoticePolicy, ChannelConnectionRequirement, ExternalActorRef, - ExternalConversationRef, ExternalEventId, ProductAdapterId, ProductTriggerReason, - RebornChannelConnectStrategy, +use ironclaw_product::RebornChannelConnectStrategy; +use ironclaw_product_contracts::account_setup::ChannelConnectionNoticePolicy; +use ironclaw_product_contracts::package_lifecycle::ChannelConnectionRequirement; +use ironclaw_product_contracts::prompt_source::{ + BlockedAuthPromptRequest, BlockedAuthPromptSource, }; use tokio::sync::Notify; @@ -46,6 +52,20 @@ impl ChannelPairingInstallationSource for StaticInstallation { } } +/// An installation source whose backend is down. Used to drive the one +/// `AccountConnectionStatusSource::connected` path that must sanitize. +struct UnavailableInstallation; + +#[async_trait] +impl ChannelPairingInstallationSource for UnavailableInstallation { + async fn current_installation( + &self, + _caller: &UserId, + ) -> Result, String> { + Err("postgres: connection refused at 10.0.0.7:5432".to_string()) + } +} + struct StaticTemplateValues(BTreeMap); #[async_trait] @@ -309,6 +329,22 @@ fn fixture_with_prefixes( deep_link_template: Option<&str>, template_values: BTreeMap, inbound_code_prefixes: &[&str], +) -> Fixture { + fixture_with_installation_source( + Arc::new(StaticInstallation(installation.map(|id| { + AdapterInstallationId::new(id).expect("installation id") + }))), + deep_link_template, + template_values, + inbound_code_prefixes, + ) +} + +fn fixture_with_installation_source( + installation_source: Arc, + deep_link_template: Option<&str>, + template_values: BTreeMap, + inbound_code_prefixes: &[&str], ) -> Fixture { let backend: Arc = Arc::new(InMemoryBackend::new()); let tenant = TenantId::new("tenant-alpha").expect("tenant"); @@ -343,9 +379,7 @@ fn fixture_with_prefixes( .map(|prefix| (*prefix).to_string()) .collect(), store, - installation: Arc::new(StaticInstallation( - installation.map(|id| AdapterInstallationId::new(id).expect("installation id")), - )), + installation: installation_source, template_values: Arc::new(StaticTemplateValues(template_values)), identity_bind: Arc::clone(&identity) as Arc, identity_lookup: Arc::clone(&identity) as Arc, @@ -1222,3 +1256,59 @@ async fn interceptor_accepts_another_manifest_declared_prefix() { }) ); } + +/// The activation-preflight probe is the one place a pairing backend failure +/// crosses into product-facing vocabulary, so it must fail **closed** and +/// **sanitized**: activation cannot be allowed to proceed on an unknown +/// connection state, and the concrete backend string (host, port, driver) must +/// not ride out on the error a product surface renders. +#[tokio::test] +async fn connection_probe_fails_closed_and_sanitizes_the_backend_error() { + use ironclaw_product_contracts::account_setup::AccountConnectionStatusSource; + + let fixture = fixture_with_installation_source( + Arc::new(UnavailableInstallation), + None, + BTreeMap::new(), + &[], + ); + let caller = UserId::new("user-1").expect("user"); + + let error = fixture + .service + .connected(&caller) + .await + .expect_err("an unavailable pairing backend must not answer 'not connected'"); + + let rendered = error.to_string(); + assert!( + rendered.contains("channel pairing status unavailable"), + "probe must report the sanitized reason: {rendered}" + ); + for leak in ["postgres", "10.0.0.7", "5432", "connection refused"] { + assert!( + !rendered.contains(leak), + "backend detail {leak:?} leaked into the product-facing error: {rendered}" + ); + } +} + +/// The success half of the same probe: a resolvable installation answers with +/// the pairing state itself, so an unpaired caller reports `false` rather than +/// erroring — activation must be able to tell "not connected yet" apart from +/// "cannot tell". +#[tokio::test] +async fn connection_probe_reports_not_connected_for_an_unpaired_caller() { + use ironclaw_product_contracts::account_setup::AccountConnectionStatusSource; + + let fixture = fixture_with(Some(INSTALL), None, BTreeMap::new()); + let caller = UserId::new("user-1").expect("user"); + + assert!( + !fixture + .service + .connected(&caller) + .await + .expect("a resolvable installation answers rather than erroring"), + ); +} diff --git a/crates/ironclaw_extension_host/src/channel_subject_routes.rs b/crates/ironclaw_extension_host/src/channel_subject_routes.rs index a321fe7e8f0..24b992035be 100644 --- a/crates/ironclaw_extension_host/src/channel_subject_routes.rs +++ b/crates/ironclaw_extension_host/src/channel_subject_routes.rs @@ -28,7 +28,7 @@ use std::sync::Arc; use async_trait::async_trait; use ironclaw_extension_contracts::recipe::RecipeSecretField; use ironclaw_host_api::ids::{ExtensionId, TenantId, UserId}; -use ironclaw_product::{AdapterInstallationId, ProductAdapterId}; +use ironclaw_host_api::product_adapter::{AdapterInstallationId, ProductAdapterId}; use ironclaw_product::{ ProductConversationSubjectRouteResolutionRequest, ProductConversationSubjectRouteResolver, ProductSurfaceFailure, diff --git a/crates/ironclaw_extension_host/src/extension_credential_requirements.rs b/crates/ironclaw_extension_host/src/extension_credential_requirements.rs index d89eab338a4..3fda57d63db 100644 --- a/crates/ironclaw_extension_host/src/extension_credential_requirements.rs +++ b/crates/ironclaw_extension_host/src/extension_credential_requirements.rs @@ -6,7 +6,7 @@ use ironclaw_host_api::{ decision::RuntimeCredentialAuthRequirement, ids::VendorId, }; -use ironclaw_product::LifecycleExtensionCredentialSetup; +use ironclaw_product_contracts::package_lifecycle::LifecycleExtensionCredentialSetup; pub fn package_runtime_credential_auth_requirements( package: &ExtensionPackage, diff --git a/crates/ironclaw_extension_host/src/extension_ingress.rs b/crates/ironclaw_extension_host/src/extension_ingress.rs index 116889ccd2b..751cdecde27 100644 --- a/crates/ironclaw_extension_host/src/extension_ingress.rs +++ b/crates/ironclaw_extension_host/src/extension_ingress.rs @@ -18,6 +18,7 @@ use std::sync::{Arc, RwLock}; use async_trait::async_trait; use chrono::Utc; use ironclaw_extension_contracts::channel_adapter::NormalizedInboundMessage; +use ironclaw_extension_contracts::external::{ExternalConversationRef, ExternalEventId}; use ironclaw_extension_contracts::verified_inbound; use ironclaw_extension_host::ingress::{ ExtensionIngressRouter, InboundAdmission, InboundAdmissionAck, InboundSink, InboundSinkError, @@ -25,15 +26,16 @@ use ironclaw_extension_host::ingress::{ }; use ironclaw_host_api::ids::SecretHandle; use ironclaw_host_api::product_adapter::auth::ChannelIngressVerifier; -use ironclaw_product::{ - AdapterInstallationId, ExternalConversationRef, ExternalEventId, ProductAdapterId, - ProductInboundAck, ProductInboundEnvelope, ProductSourceChannel, ProtocolAuthEvidence, - classify_channel_inbound_text, +use ironclaw_host_api::product_adapter::{ + AdapterInstallationId, ProductAdapterId, ProtocolAuthEvidence, }; use ironclaw_product::{ ChannelInboundSurfaceOutcome, ChannelInboundSurfaceRejectedAdmission, ChannelInboundSurfaceRequest, }; +use ironclaw_product_contracts::inbound::{ + ProductInboundAck, ProductInboundEnvelope, ProductSourceChannel, classify_channel_inbound_text, +}; use ironclaw_product_contracts::surface::ChannelInboundProductSurface; use tokio::task::JoinSet; @@ -65,7 +67,7 @@ pub trait PostAdmissionObserver: Send + Sync { async fn observe_error( &self, _envelope: ProductInboundEnvelope, - _error: ironclaw_product::ProductAdapterError, + _error: ironclaw_host_api::product_adapter_error::ProductAdapterError, ) { } } @@ -877,16 +879,22 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use ironclaw_extension_contracts::channel_adapter::ChannelAdapter; + use ironclaw_extension_contracts::channel_adapter::{ + ChannelAttachmentRef, ProductTriggerReason, + }; + use ironclaw_extension_contracts::external::{ + ExternalActorRef, ExternalConversationRef, ExternalEventId, ProductAttachmentDescriptor, + ProductAttachmentKind, + }; use ironclaw_extension_contracts::tool_adapter::{ RestrictedEgress, RestrictedEgressError, RestrictedEgressRequest, RestrictedEgressResponse, }; use ironclaw_host_api::ids::UserId; - use ironclaw_product::{ - AuthResolutionPayload, AuthResolutionResult, ChannelAttachmentRef, - ChannelInboundClassification, ChannelInboundSurfaceAdmission, ChannelInboundSurfaceOutcome, - ExternalActorRef, ExternalConversationRef, ExternalEventId, InboundCommandPayload, - ParsedProductInbound, ProductAttachmentDescriptor, ProductAttachmentKind, - ProductInboundPayload, ProductTriggerReason, TrustedInboundContext, UserMessagePayload, + use ironclaw_product::{ChannelInboundSurfaceAdmission, ChannelInboundSurfaceOutcome}; + use ironclaw_product_contracts::inbound::{ + AuthResolutionPayload, AuthResolutionResult, ChannelInboundClassification, + InboundCommandPayload, ParsedProductInbound, ProductInboundPayload, TrustedInboundContext, + UserMessagePayload, }; use ironclaw_turns::{AcceptedMessageRef, TurnRunId}; diff --git a/crates/ironclaw_extension_host/src/extension_lifecycle_capabilities.rs b/crates/ironclaw_extension_host/src/extension_lifecycle_capabilities.rs index fb2a0aa851d..0e633e93872 100644 --- a/crates/ironclaw_extension_host/src/extension_lifecycle_capabilities.rs +++ b/crates/ironclaw_extension_host/src/extension_lifecycle_capabilities.rs @@ -21,15 +21,16 @@ use ironclaw_host_runtime::{ FirstPartyCapabilityError, FirstPartyCapabilityHandler, FirstPartyCapabilityRegistry, FirstPartyCapabilityRequest, FirstPartyCapabilityResult, }; -use ironclaw_product::{ +use ironclaw_product::{ProductSurfaceFailure, RebornChannelConnectStrategy}; +use ironclaw_product_contracts::package_lifecycle::{ LifecyclePackageKind, LifecyclePackageRef, LifecycleProductPayload, LifecycleProductResponse, - ProductSurfaceFailure, RebornChannelConnectStrategy, }; use serde::Deserialize; use crate::extension_activation_credentials::RuntimeExtensionActivationCredentialGate; use crate::extension_lifecycle::RebornLocalExtensionManagementPort; use ironclaw_auth::RuntimeCredentialAccountSelectionService; +use ironclaw_product_contracts::package_lifecycle::public_lifecycle_response_json; pub const EXTENSION_SEARCH_CAPABILITY_ID: &str = "builtin.extension_search"; pub const EXTENSION_INSTALL_CAPABILITY_ID: &str = "builtin.extension_install"; @@ -201,6 +202,23 @@ struct ExtensionIdInput { extension_id: String, } +/// Sanitizes a lifecycle-projection serialization failure into the capability +/// error the model sees. +/// +/// Extracted from an inline closure so the mapping is reachable from a test: +/// the failure itself is a defensive guard (a well-formed +/// [`LifecycleProductResponse`] does not fail `serde_json`), but *what it maps +/// to* is a live contract — the model must get `OutputDecode`, and the serde +/// error, which can quote projection contents, must stay in the debug log. +fn lifecycle_output_decode_error(error: impl std::fmt::Debug) -> FirstPartyCapabilityError { + tracing::debug!( + target: "ironclaw::reborn::extension_lifecycle", + ?error, + "extension lifecycle output serialization failed" + ); + FirstPartyCapabilityError::new(RuntimeDispatchErrorKind::OutputDecode) +} + #[async_trait] impl FirstPartyCapabilityHandler for ExtensionLifecycleToolHandler { async fn dispatch( @@ -348,14 +366,7 @@ impl FirstPartyCapabilityHandler for ExtensionLifecycleToolHandler { ); let response = without_model_visible_connection_chrome(response); let output = - ironclaw_product::public_lifecycle_response_json(&response).map_err(|error| { - tracing::debug!( - target: "ironclaw::reborn::extension_lifecycle", - ?error, - "extension lifecycle output serialization failed" - ); - FirstPartyCapabilityError::new(RuntimeDispatchErrorKind::OutputDecode) - })?; + public_lifecycle_response_json(&response).map_err(lifecycle_output_decode_error)?; Ok( FirstPartyCapabilityResult::new(output, resource_usage(started)) .with_display_preview(connection_preview), @@ -674,6 +685,73 @@ fn lifecycle_error(error: ProductSurfaceFailure) -> FirstPartyCapabilityError { #[cfg(test)] mod tests { + /// The serialization guard is defensive — a well-formed projection does + /// not fail `serde_json` — but the mapping is a live contract with two + /// halves, and this asserts both: the model sees `OutputDecode` and never + /// the serde error (which can quote the projection contents it failed on), + /// *and* the detail is not simply discarded — it reaches the debug log, + /// which is where an operator diagnoses it from. + /// + /// The DEBUG subscriber is load-bearing, not decoration: with no + /// subscriber installed `tracing` short-circuits on the null dispatcher + /// and the macro body never runs, so a test without one cannot tell + /// "logged the detail" from "dropped it". + #[test] + fn output_serialization_failure_maps_to_output_decode_and_logs_the_detail() { + use std::io::Write as _; + use std::sync::{Arc, Mutex}; + + #[derive(Clone, Default)] + struct SharedLog(Arc>>); + struct SharedLogGuard(Arc>>); + + impl std::io::Write for SharedLogGuard { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + self.0.lock().expect("log lock").extend(buffer); + Ok(buffer.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SharedLog { + type Writer = SharedLogGuard; + fn make_writer(&'a self) -> Self::Writer { + SharedLogGuard(Arc::clone(&self.0)) + } + } + + let logs = SharedLog::default(); + let subscriber = tracing_subscriber::fmt() + .without_time() + .with_max_level(tracing::Level::DEBUG) + .with_writer(logs.clone()) + .finish(); + + let error = tracing::subscriber::with_default(subscriber, || { + super::lifecycle_output_decode_error("key must be a string") + }); + + assert_eq!(error.kind(), Some(RuntimeDispatchErrorKind::OutputDecode)); + assert!( + !format!("{error:?}").contains("key must be a string"), + "the serde detail must not ride out on the capability error" + ); + + let rendered = String::from_utf8(logs.0.lock().expect("log lock").clone()) + .expect("tracing output is UTF-8"); + assert!( + rendered.contains("extension lifecycle output serialization failed"), + "the guard must leave a diagnosable trace: {rendered}" + ); + assert!( + rendered.contains("key must be a string"), + "the detail belongs in the debug log, not nowhere: {rendered}" + ); + let _ = std::io::sink().flush(); + } + use ironclaw_auth::{ AuthProductScope, AuthProviderId, AuthSurface, CredentialAccountLabel, CredentialAccountStatus, CredentialOwnership, NewCredentialAccount, ProviderScope, @@ -709,10 +787,11 @@ mod tests { invoke_json_with_standalone_approval, invoke_with_standalone_approval, }; use ironclaw_extension_contracts::state::InstallationState; - use ironclaw_product::{ + use ironclaw_product::RebornChannelConnectStrategy; + use ironclaw_product_contracts::package_lifecycle::{ ChannelConnectionRequirement, LifecycleExtensionRuntimeKind, LifecycleExtensionSource, LifecycleExtensionSummary, LifecyclePackageKind, LifecyclePackageRef, - LifecycleSearchExtensionSummary, RebornChannelConnectStrategy, + LifecycleSearchExtensionSummary, }; const TEST_OWNER_ID: &str = "extension-tool-test-user"; diff --git a/crates/ironclaw_extension_host/src/extension_lifecycle_command.rs b/crates/ironclaw_extension_host/src/extension_lifecycle_command.rs index f4e738c0c3a..892bea67be5 100644 --- a/crates/ironclaw_extension_host/src/extension_lifecycle_command.rs +++ b/crates/ironclaw_extension_host/src/extension_lifecycle_command.rs @@ -3,11 +3,13 @@ use std::sync::Arc; use ironclaw_auth::RuntimeCredentialAccountSelectionService; use ironclaw_extension_contracts::hosted_mcp::RegisterHostedMcpRequest; use ironclaw_extension_contracts::state::InstallationState; -use ironclaw_product::{ +use ironclaw_product::ProductSurfaceFailure; +use ironclaw_product_contracts::lifecycle_service::{ + LifecycleProductContext, LifecycleProductService, LifecycleProductSurfaceContext, +}; +use ironclaw_product_contracts::package_lifecycle::{ LifecycleExtensionSource, LifecyclePackageKind, LifecyclePackageRef, LifecycleProductAction, - LifecycleProductContext, LifecycleProductPayload, LifecycleProductResponse, - LifecycleProductService, LifecycleProductSurfaceContext, LifecycleSearchExtensionSummary, - ProductSurfaceFailure, + LifecycleProductPayload, LifecycleProductResponse, LifecycleSearchExtensionSummary, }; use ironclaw_product_contracts::surface::ProductSurfaceError; use thiserror::Error; @@ -257,7 +259,8 @@ mod tests { ids::{AgentId, InvocationId, TenantId, UserId}, resource::ResourceScope, }; - use ironclaw_product::LifecycleExtensionSummary; + use ironclaw_product_contracts::package_lifecycle::LifecycleExtensionRuntimeKind; + use ironclaw_product_contracts::package_lifecycle::LifecycleExtensionSummary; use secrecy::SecretString; use super::*; @@ -365,7 +368,7 @@ mod tests { version: "0.1.0".to_string(), description: "line\rrewrite".to_string(), source: LifecycleExtensionSource::HostBundled, - runtime_kind: ironclaw_product::LifecycleExtensionRuntimeKind::WasmTool, + runtime_kind: LifecycleExtensionRuntimeKind::WasmTool, surface_kinds: Vec::new(), channel_directions: None, channel_connection: None, diff --git a/crates/ironclaw_extension_host/src/generic_host.rs b/crates/ironclaw_extension_host/src/generic_host.rs index 51e6ea10a12..6fb4c168ad5 100644 --- a/crates/ironclaw_extension_host/src/generic_host.rs +++ b/crates/ironclaw_extension_host/src/generic_host.rs @@ -28,6 +28,9 @@ use std::time::Duration; use async_trait::async_trait; use ironclaw_extension_contracts::channel_adapter::ChannelAdapter; +use ironclaw_extension_contracts::channel_adapter::{ + ChannelContext, ChannelError, DeliveryReport, InboundOutcome, OutboundEnvelope, VerifiedInbound, +}; use ironclaw_extension_contracts::extension::ExtensionHostAssemblyConfig; use ironclaw_extension_contracts::tool_adapter::{ RestrictedEgress, RestrictedEgressError, RestrictedEgressRequest, RestrictedEgressResponse, @@ -39,9 +42,6 @@ use ironclaw_extensions::{ }; use ironclaw_host_api::path::VirtualPath; use ironclaw_host_runtime::{ExtensionLaneToolBinder, ExtensionToolBindError}; -use ironclaw_product::{ - ChannelContext, ChannelError, DeliveryReport, InboundOutcome, OutboundEnvelope, VerifiedInbound, -}; use ironclaw_resources::ResourceGovernor; use crate::{ diff --git a/crates/ironclaw_extension_host/src/hosted_mcp_manifest.rs b/crates/ironclaw_extension_host/src/hosted_mcp_manifest.rs index d80355cf3b5..02dc77ea298 100644 --- a/crates/ironclaw_extension_host/src/hosted_mcp_manifest.rs +++ b/crates/ironclaw_extension_host/src/hosted_mcp_manifest.rs @@ -21,9 +21,9 @@ use ironclaw_host_api::{ http::RuntimeCredentialTarget, ids::{ExtensionId, SecretHandle, VendorId}, }; -use ironclaw_product::{ +use ironclaw_product::ProductSurfaceFailure; +use ironclaw_product_contracts::package_lifecycle::{ LifecyclePackageKind, LifecyclePackageRef, LifecycleProductPayload, LifecycleProductResponse, - ProductSurfaceFailure, }; use crate::{ diff --git a/crates/ironclaw_extension_host/src/hosted_mcp_preparation.rs b/crates/ironclaw_extension_host/src/hosted_mcp_preparation.rs index fee7dd60e56..a41fc925f40 100644 --- a/crates/ironclaw_extension_host/src/hosted_mcp_preparation.rs +++ b/crates/ironclaw_extension_host/src/hosted_mcp_preparation.rs @@ -17,13 +17,17 @@ use ironclaw_host_api::{ ids::{CapabilityId, UserId}, resource::ResourceScope, }; -use ironclaw_product::{LifecyclePackageRef, LifecycleProductResponse, ProductSurfaceFailure}; +use ironclaw_product::ProductSurfaceFailure; +use ironclaw_product_contracts::package_lifecycle::{ + LifecyclePackageRef, LifecycleProductResponse, +}; use tokio::sync::{Mutex, RwLock}; use crate::{ AvailableExtensionCatalog, ExtensionActivationCredentialGate, ExtensionActivationCredentialReadiness, package_runtime_credential_auth_requirements, }; +use ironclaw_product_contracts::package_lifecycle::LifecyclePackageKind; pub struct HostedMcpPreparationService { installation_store: Arc, @@ -76,8 +80,8 @@ impl HostedMcpPreparationService { tracing::debug!(%error, "hosted MCP registration rejected: invalid desired id"); crate::hosted_mcp_manifest::name_unavailable() })?; - let package_ref = ironclaw_product::LifecyclePackageRef::new( - ironclaw_product::LifecyclePackageKind::Extension, + let package_ref = ironclaw_product_contracts::package_lifecycle::LifecyclePackageRef::new( + LifecyclePackageKind::Extension, extension_id.as_str(), )?; // Lock order invariant: catalog write guard BEFORE operation_lock, diff --git a/crates/ironclaw_extension_host/src/inbound_batches.rs b/crates/ironclaw_extension_host/src/inbound_batches.rs index 38155939ff8..317ca640168 100644 --- a/crates/ironclaw_extension_host/src/inbound_batches.rs +++ b/crates/ironclaw_extension_host/src/inbound_batches.rs @@ -14,6 +14,12 @@ use std::time::Duration; use async_trait::async_trait; use chrono::{DateTime, TimeDelta, Utc}; use ironclaw_extension_contracts::channel_adapter::NormalizedInboundMessage; +use ironclaw_extension_contracts::channel_adapter::{ + ChannelAttachmentRef, InboundBatchFragment, ProductTriggerReason, +}; +use ironclaw_extension_contracts::external::{ + ExternalActorRef, ExternalConversationRef, ExternalEventId, ProductAttachmentDescriptor, +}; use ironclaw_filesystem::{ CasApply, CasUpdateError, ContentType, Entry, FilesystemError, RootFilesystem, ScopedFilesystem, cas_update, @@ -25,10 +31,6 @@ use ironclaw_host_api::{ path::{MountAlias, ScopedPath, VirtualPath}, resource::{ResourceScope, resource_scope_path_segment}, }; -use ironclaw_product::{ - ChannelAttachmentRef, ExternalActorRef, ExternalConversationRef, ExternalEventId, - InboundBatchFragment, ProductAttachmentDescriptor, ProductTriggerReason, -}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -832,8 +834,8 @@ fn store_unavailable() -> InboundBatchStoreError { #[cfg(test)] mod tests { use super::*; + use ironclaw_extension_contracts::external::ProductAttachmentKind; use ironclaw_filesystem::InMemoryBackend; - use ironclaw_product::ProductAttachmentKind; fn store() -> FilesystemInboundBatchStore { FilesystemInboundBatchStore::new( diff --git a/crates/ironclaw_extension_host/src/ingress/router.rs b/crates/ironclaw_extension_host/src/ingress/router.rs index 0319cb6c7de..3624e8c35fc 100644 --- a/crates/ironclaw_extension_host/src/ingress/router.rs +++ b/crates/ironclaw_extension_host/src/ingress/router.rs @@ -17,10 +17,12 @@ use async_trait::async_trait; use chrono::Utc; use ironclaw_extension_contracts::channel::{ChannelIngressDescriptor, ChannelIngressMethod}; use ironclaw_extension_contracts::channel_adapter::{ChannelAdapter, NormalizedInboundMessage}; +use ironclaw_extension_contracts::channel_adapter::{ + ChannelError, InboundBatchFragment, InboundOutcome, VerifiedInbound, +}; use ironclaw_extension_contracts::tool_adapter::RestrictedEgress; use ironclaw_extensions::ResolvedExtensionManifest; use ironclaw_host_api::ids::SecretHandle; -use ironclaw_product::{ChannelError, InboundBatchFragment, InboundOutcome, VerifiedInbound}; use sha2::{Digest, Sha256}; use crate::active::ActiveExtension; @@ -128,7 +130,7 @@ pub struct ReplyContextKey { pub extension_id: String, pub installation_id: String, /// The conversation fingerprint - /// ([`ironclaw_product::ExternalConversationRef::conversation_fingerprint`]). + /// ([`ironclaw_extension_contracts::external::ExternalConversationRef::conversation_fingerprint`]). pub conversation: String, } diff --git a/crates/ironclaw_extension_host/src/install_policy.rs b/crates/ironclaw_extension_host/src/install_policy.rs index 3039a4d4dc5..15bff92f479 100644 --- a/crates/ironclaw_extension_host/src/install_policy.rs +++ b/crates/ironclaw_extension_host/src/install_policy.rs @@ -16,7 +16,8 @@ use std::collections::BTreeSet; use ironclaw_extensions::{ExtensionInstallation, InstallationOwner}; use ironclaw_host_api::ids::UserId; -use ironclaw_product::{LifecycleInstallScope, ProductSurfaceFailure}; +use ironclaw_product::ProductSurfaceFailure; +use ironclaw_product_contracts::package_lifecycle::LifecycleInstallScope; /// Derive who a NEW install belongs to (#6520): every lifecycle install, /// including one initiated by the operator, is private to the caller. diff --git a/crates/ironclaw_extension_host/src/ironhub/model.rs b/crates/ironclaw_extension_host/src/ironhub/model.rs index 421328af8e9..e87b76fd7db 100644 --- a/crates/ironclaw_extension_host/src/ironhub/model.rs +++ b/crates/ironclaw_extension_host/src/ironhub/model.rs @@ -1,6 +1,7 @@ use std::time::Duration; -use ironclaw_product::{LifecycleProductResponse, ProductSurfaceFailure}; +use ironclaw_product::ProductSurfaceFailure; +use ironclaw_product_contracts::package_lifecycle::LifecycleProductResponse; use serde::{Deserialize, Serialize}; use thiserror::Error; diff --git a/crates/ironclaw_extension_host/src/ironhub/service.rs b/crates/ironclaw_extension_host/src/ironhub/service.rs index f987ee58c7f..9fda4e9e64a 100644 --- a/crates/ironclaw_extension_host/src/ironhub/service.rs +++ b/crates/ironclaw_extension_host/src/ironhub/service.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, LazyLock}; use std::time::Instant; use chrono::{DateTime, Utc}; +use ironclaw_extension_contracts::lifecycle_id::LifecyclePackageId; use ironclaw_extension_contracts::state::InstallationState; use ironclaw_host_api::{ action::NetworkMethod, @@ -17,9 +18,9 @@ use ironclaw_host_api::{ use ironclaw_host_runtime::{ BUILTIN_FIRST_PARTY_PROVIDER, HostRuntimeHttpEgressPort, HostRuntimeHttpEgressRequest, }; -use ironclaw_product::{ - LifecyclePackageId, LifecyclePackageKind, LifecyclePackageRef, LifecycleProductPayload, - LifecycleProductResponse, LifecycleProductSurfaceContext, +use ironclaw_product_contracts::lifecycle_service::LifecycleProductSurfaceContext; +use ironclaw_product_contracts::package_lifecycle::{ + LifecyclePackageKind, LifecyclePackageRef, LifecycleProductPayload, LifecycleProductResponse, }; use ironclaw_skills::{ ManagedSkillSource, ScopedSkillManagementError, ScopedSkillManagementPort, diff --git a/crates/ironclaw_extension_host/src/lifecycle.rs b/crates/ironclaw_extension_host/src/lifecycle.rs index a01f1b6bad7..300ce50b30c 100644 --- a/crates/ironclaw_extension_host/src/lifecycle.rs +++ b/crates/ironclaw_extension_host/src/lifecycle.rs @@ -236,7 +236,7 @@ impl ExtensionHost { .map(|channel| channel.egress.as_slice()) .unwrap_or(&[]), ); - let ctx = ironclaw_product::ChannelContext { + let ctx = ironclaw_extension_contracts::channel_adapter::ChannelContext { extension_id: &record.extension_id, installation_id: &record.installation_id, config: &record.config, diff --git a/crates/ironclaw_extension_host/src/lifecycle_product_service.rs b/crates/ironclaw_extension_host/src/lifecycle_product_service.rs index 2232548429f..5e2509a95a9 100644 --- a/crates/ironclaw_extension_host/src/lifecycle_product_service.rs +++ b/crates/ironclaw_extension_host/src/lifecycle_product_service.rs @@ -1,16 +1,20 @@ use std::sync::Arc; use async_trait::async_trait; +use ironclaw_extension_contracts::lifecycle_id::LifecyclePackageId; use ironclaw_extension_contracts::state::InstallationState; use ironclaw_host_api::{ ids::{ExtensionId, InvocationId, UserId}, resource::ResourceScope, }; -use ironclaw_product::{ - LifecyclePackageId, LifecyclePackageKind, LifecyclePackageRef, LifecycleProductAction, - LifecycleProductContext, LifecycleProductPayload, LifecycleProductResponse, - LifecycleProductService, LifecycleReadinessBlocker, LifecycleSkillSource, - LifecycleSkillSummary, ProductSurfaceFailure, lifecycle_product_surface_error, +use ironclaw_product::{ProductSurfaceFailure, lifecycle_product_surface_error}; +use ironclaw_product_contracts::lifecycle_service::{ + LifecycleProductContext, LifecycleProductService, +}; +use ironclaw_product_contracts::package_lifecycle::{ + LifecyclePackageKind, LifecyclePackageRef, LifecycleProductAction, LifecycleProductPayload, + LifecycleProductResponse, LifecycleReadinessBlocker, LifecycleSkillSource, + LifecycleSkillSummary, }; use ironclaw_product_contracts::surface::ProductSurfaceError; #[cfg(test)] @@ -686,7 +690,7 @@ mod tests { mount::{MountGrant, MountPermissions, MountView}, path::{HostPath, MountAlias, VirtualPath}, }; - use ironclaw_product::LifecycleProductSurfaceContext; + use ironclaw_product_contracts::lifecycle_service::LifecycleProductSurfaceContext; #[tokio::test] async fn skill_lifecycle_service_installs_lists_and_removes_via_skill_management() { diff --git a/crates/ironclaw_extension_host/src/lifecycle_restore.rs b/crates/ironclaw_extension_host/src/lifecycle_restore.rs index 6fb8f1322c0..4392cb8c99f 100644 --- a/crates/ironclaw_extension_host/src/lifecycle_restore.rs +++ b/crates/ironclaw_extension_host/src/lifecycle_restore.rs @@ -8,7 +8,8 @@ use ironclaw_extensions::{ }; use ironclaw_filesystem::RootFilesystem; use ironclaw_host_api::{approval::sha256_digest_token, ids::UserId}; -use ironclaw_product::{LifecyclePackageKind, LifecyclePackageRef, ProductSurfaceFailure}; +use ironclaw_product::ProductSurfaceFailure; +use ironclaw_product_contracts::package_lifecycle::{LifecyclePackageKind, LifecyclePackageRef}; use tokio::sync::Mutex; use crate::{ diff --git a/crates/ironclaw_extension_host/src/operator_config_capability.rs b/crates/ironclaw_extension_host/src/operator_config_capability.rs index 39dd313ece1..58b57c65cb4 100644 --- a/crates/ironclaw_extension_host/src/operator_config_capability.rs +++ b/crates/ironclaw_extension_host/src/operator_config_capability.rs @@ -27,8 +27,10 @@ use ironclaw_host_runtime::{ }; use ironclaw_product::{ OPERATOR_CONFIG_SET_AUTO_APPROVE_CAPABILITY_ID, - OPERATOR_CONFIG_SET_TOOL_PERMISSION_CAPABILITY_ID, RebornOperatorToolCatalog, - RebornOperatorToolInfo, + OPERATOR_CONFIG_SET_TOOL_PERMISSION_CAPABILITY_ID, +}; +use ironclaw_product_contracts::operator_tools::{ + RebornOperatorToolCatalog, RebornOperatorToolInfo, }; pub fn extend_builtin_first_party_package( diff --git a/crates/ironclaw_extension_host/src/product_lifecycle.rs b/crates/ironclaw_extension_host/src/product_lifecycle.rs index 7be7af8bbaa..2cd205c3c31 100644 --- a/crates/ironclaw_extension_host/src/product_lifecycle.rs +++ b/crates/ironclaw_extension_host/src/product_lifecycle.rs @@ -10,6 +10,7 @@ use ironclaw_auth::{ SecretCleanupRequest, }; use ironclaw_extension_contracts::hosted_mcp::RegisterHostedMcpRequest; +use ironclaw_extension_contracts::lifecycle_id::LifecycleBlockerRef; use ironclaw_extension_contracts::{state::InstallationState, surface::CapabilitySurfaceKind}; use ironclaw_extensions::{ CapabilityVisibility, ExtensionError, ExtensionInstallation, ExtensionInstallationError, @@ -23,11 +24,16 @@ use ironclaw_host_api::{ resource::ResourceScope, }; use ironclaw_product::{ - ChannelConnectionService, ExtensionAccountSetupDescriptor, ExtensionAccountSetupError, - ExtensionAccountSetupRegistry, LifecycleBlockerRef, LifecycleExtensionSummary, - LifecycleInstalledExtensionSummary, LifecyclePackageKind, LifecyclePackageRef, - LifecycleProductPayload, LifecycleProductResponse, LifecycleReadinessBlocker, - LifecycleSearchExtensionSummary, ProductSurfaceFailure, RebornChannelConnectStrategy, + ChannelConnectionService, ExtensionAccountSetupRegistry, ProductSurfaceFailure, + RebornChannelConnectStrategy, +}; +use ironclaw_product_contracts::account_setup::{ + ExtensionAccountSetupDescriptor, ExtensionAccountSetupError, +}; +use ironclaw_product_contracts::package_lifecycle::{ + LifecycleExtensionSummary, LifecycleInstalledExtensionSummary, LifecyclePackageKind, + LifecyclePackageRef, LifecycleProductPayload, LifecycleProductResponse, + LifecycleReadinessBlocker, LifecycleSearchExtensionSummary, }; use ironclaw_product_contracts::surface::{ProductSurfaceCaller, ProductSurfaceError}; use tokio::sync::{Mutex, RwLock, Semaphore}; @@ -3142,7 +3148,9 @@ mod tests { path::VirtualPath, resource::ResourceScope, }; - use ironclaw_product::{LifecyclePackageKind, LifecyclePackageRef}; + use ironclaw_product_contracts::package_lifecycle::{ + LifecyclePackageKind, LifecyclePackageRef, + }; use ironclaw_trust::{HostTrustPolicy, InvalidationBus}; use super::*; diff --git a/crates/ironclaw_extension_host/src/provider_identity.rs b/crates/ironclaw_extension_host/src/provider_identity.rs index 1aea0f507d8..9b9b669dfa6 100644 --- a/crates/ironclaw_extension_host/src/provider_identity.rs +++ b/crates/ironclaw_extension_host/src/provider_identity.rs @@ -205,7 +205,8 @@ impl ProductActorUserResolver for ProviderIdentityActorResolver { #[cfg(test)] mod tests { - use ironclaw_product::{AdapterInstallationId, ExternalActorRef, ProductAdapterId}; + use ironclaw_extension_contracts::external::ExternalActorRef; + use ironclaw_host_api::product_adapter::{AdapterInstallationId, ProductAdapterId}; use super::*; diff --git a/crates/ironclaw_extension_host/src/run_delivery_ports.rs b/crates/ironclaw_extension_host/src/run_delivery_ports.rs index e91c20279dc..36fb0537b41 100644 --- a/crates/ironclaw_extension_host/src/run_delivery_ports.rs +++ b/crates/ironclaw_extension_host/src/run_delivery_ports.rs @@ -9,17 +9,20 @@ use std::sync::Arc; use async_trait::async_trait; use ironclaw_auth::{AuthProductError, AuthProviderId}; +use ironclaw_extension_contracts::auth_prompt::AuthPromptView; +use ironclaw_host_api::product_adapter_error::ProductAdapterError; use ironclaw_host_api::turn::{TurnGateRef, TurnScope}; use ironclaw_host_api::{capability::RuntimeCredentialAccountSetup, ids::UserId}; -use ironclaw_product::{ - ApprovalPromptContextSource, AuthChallengeProvider, AuthChallengeView, BlockedAuthPromptSource, - PairingAuthChallengeView, +use ironclaw_product::{AuthChallengeProvider, AuthChallengeView, PairingAuthChallengeView}; +use ironclaw_product_contracts::outbound::ApprovalPromptContextView; +use ironclaw_product_contracts::prompt_source::{ + ApprovalPromptContextSource, BlockedAuthPromptSource, }; -use ironclaw_product::{ApprovalPromptContextView, AuthPromptView, ProductAdapterError}; use ironclaw_product::auth_prompt_view_for_blocked_auth; use crate::channel_pairing::ChannelPairingRegistry; +use ironclaw_product_contracts::prompt_source::BlockedAuthPromptRequest; /// One recipe-driven challenge materializer for every product surface. /// Product auth owns OAuth/manual challenges; the canonical channel-pairing @@ -79,7 +82,7 @@ impl AuthChallengeProvider for RecipeAuthChallengeProvider { return Ok(None); }; return Ok(Some(AuthChallengeView { - kind: ironclaw_product::AuthPromptChallengeKind::Pairing, + kind: ironclaw_extension_contracts::auth_prompt::AuthPromptChallengeKind::Pairing, provider: AuthProviderId::new(requirement.provider.as_str().to_string()).map_err( |error| { // `MalformedConfig` is a unit variant, so the cause has @@ -167,7 +170,7 @@ impl ProductAuthBlockedAuthPromptSource { impl BlockedAuthPromptSource for ProductAuthBlockedAuthPromptSource { async fn auth_prompt_for_blocked_run( &self, - request: ironclaw_product::BlockedAuthPromptRequest<'_>, + request: BlockedAuthPromptRequest<'_>, ) -> Result { auth_prompt_view_for_blocked_auth(request, self.auth_challenges.as_deref()).await } diff --git a/crates/ironclaw_extension_host/src/test_support.rs b/crates/ironclaw_extension_host/src/test_support.rs index 22807b9a594..06c5f79de8f 100644 --- a/crates/ironclaw_extension_host/src/test_support.rs +++ b/crates/ironclaw_extension_host/src/test_support.rs @@ -11,6 +11,9 @@ use std::time::Duration; use async_trait::async_trait; use ironclaw_extension_contracts::channel_adapter::ChannelAdapter; +use ironclaw_extension_contracts::channel_adapter::{ + ChannelContext, ChannelError, DeliveryReport, InboundOutcome, OutboundEnvelope, VerifiedInbound, +}; use ironclaw_extension_contracts::tool_adapter::{ RestrictedEgress, RestrictedEgressError, RestrictedEgressRequest, RestrictedEgressResponse, ToolAdapter, ToolCall, ToolError, ToolPorts, ToolResult, @@ -19,9 +22,6 @@ use ironclaw_extensions::{ExtensionManifestRecord, ManifestSource, ResolvedExten use ironclaw_host_api::host_port::{ HOST_RUNTIME_HTTP_EGRESS_PORT_ID, HostPortCatalog, HostPortCatalogEntry, HostPortId, }; -use ironclaw_product::{ - ChannelContext, ChannelError, DeliveryReport, InboundOutcome, OutboundEnvelope, VerifiedInbound, -}; use crate::entrypoint::{BindContext, BindError, ExtensionBindings, ExtensionEntrypoint}; use crate::lifecycle::{DrainController, EgressFactory, HookError}; @@ -469,8 +469,8 @@ pub struct RecordingPairingOutcomeObserver { impl crate::extension_ingress::ChannelPairingOutcomeObserver for RecordingPairingOutcomeObserver { async fn observe_pairing_outcome( &self, - _conversation: ironclaw_product::ExternalConversationRef, - _event_id: ironclaw_product::ExternalEventId, + _conversation: ironclaw_extension_contracts::external::ExternalConversationRef, + _event_id: ironclaw_extension_contracts::external::ExternalEventId, outcome: crate::channel_pairing::ChannelPairingConsumeOutcome, ) { match self.outcomes.lock() { diff --git a/crates/ironclaw_extension_host/src/test_support/lifecycle.rs b/crates/ironclaw_extension_host/src/test_support/lifecycle.rs index a40dba1fc0d..b958b321147 100644 --- a/crates/ironclaw_extension_host/src/test_support/lifecycle.rs +++ b/crates/ironclaw_extension_host/src/test_support/lifecycle.rs @@ -36,7 +36,9 @@ use ironclaw_host_runtime::{ RuntimeCredentialAccountRequest, RuntimeCredentialAccountResolver, }; use ironclaw_processes::ProcessServices; -use ironclaw_product::{LifecycleProductService, LifecycleProductSurfaceContext}; +use ironclaw_product_contracts::lifecycle_service::{ + LifecycleProductService, LifecycleProductSurfaceContext, +}; use ironclaw_resources::InMemoryResourceGovernor; use ironclaw_secrets::{SecretStore, SecretStorePort}; use ironclaw_trust::{AdminConfig, HostTrustPolicy, InvalidationBus}; @@ -53,6 +55,7 @@ use crate::{ product_extension_host_api_contract_registry, provider_instance_readiness_map, restore_extension_lifecycle_state, }; +use ironclaw_product_contracts::lifecycle_service::LifecycleProductContext; use ironclaw_skills::ScopedSkillManagementPort; pub type TestApprovalRequestStore = ApprovalRequestStore>; @@ -476,10 +479,8 @@ pub async fn invoke_with_standalone_approval( } } -pub fn lifecycle_product_context( - scope: ResourceScope, -) -> ironclaw_product::LifecycleProductContext { - ironclaw_product::LifecycleProductContext::Surface(LifecycleProductSurfaceContext { +pub fn lifecycle_product_context(scope: ResourceScope) -> LifecycleProductContext { + LifecycleProductContext::Surface(LifecycleProductSurfaceContext { tenant_id: scope.tenant_id, user_id: scope.user_id, agent_id: scope.agent_id, diff --git a/crates/ironclaw_extension_host/src/webui_extension_credentials.rs b/crates/ironclaw_extension_host/src/webui_extension_credentials.rs index 841c0fd7150..23a965cd72e 100644 --- a/crates/ironclaw_extension_host/src/webui_extension_credentials.rs +++ b/crates/ironclaw_extension_host/src/webui_extension_credentials.rs @@ -10,8 +10,9 @@ use ironclaw_auth::{ }; use ironclaw_product::{ ExtensionCredentialSetupService, ExtensionCredentialStatusRequest, - ExtensionCredentialSubmitRequest, LifecycleExtensionCredentialSetup, + ExtensionCredentialSubmitRequest, }; +use ironclaw_product_contracts::package_lifecycle::LifecycleExtensionCredentialSetup; use ironclaw_product_contracts::surface::{ ProductSurfaceError, ProductSurfaceErrorCode, ProductSurfaceErrorKind, }; @@ -196,10 +197,8 @@ mod tests { ids::{ExtensionId, InvocationId, SecretHandle, TenantId, UserId}, resource::ResourceScope, }; - use ironclaw_product::{ - ExtensionCredentialSetupService, ExtensionCredentialStatusRequest, - LifecycleExtensionCredentialSetup, - }; + use ironclaw_product::{ExtensionCredentialSetupService, ExtensionCredentialStatusRequest}; + use ironclaw_product_contracts::package_lifecycle::LifecycleExtensionCredentialSetup; struct NoopDispatcher; diff --git a/crates/ironclaw_extension_host/tests/ingress_router_contract.rs b/crates/ironclaw_extension_host/tests/ingress_router_contract.rs index a872eb12785..c14b6d35d20 100644 --- a/crates/ironclaw_extension_host/tests/ingress_router_contract.rs +++ b/crates/ironclaw_extension_host/tests/ingress_router_contract.rs @@ -18,6 +18,14 @@ use hmac::{Hmac, KeyInit, Mac}; use sha2::{Digest, Sha256}; use ironclaw_extension_contracts::channel_adapter::{ChannelAdapter, NormalizedInboundMessage}; +use ironclaw_extension_contracts::channel_adapter::{ + ChannelAttachmentRef, ChannelError, DeliveryReport, ImmediateResponse, InboundBatchFragment, + InboundOutcome, OutboundEnvelope, ProductTriggerReason, VerifiedInbound, +}; +use ironclaw_extension_contracts::external::{ + ExternalActorRef, ExternalConversationRef, ExternalEventId, ProductAttachmentDescriptor, + ProductAttachmentKind, +}; use ironclaw_extension_host::inbound_batches::{ InboundBatchKey, InboundBatchStageOutcome, InboundBatchStageRequest, InboundBatchStore, }; @@ -35,11 +43,6 @@ use ironclaw_extension_host::{ }; use ironclaw_filesystem::InMemoryBackend; use ironclaw_host_api::ids::{SecretHandle, TenantId, UserId}; -use ironclaw_product::{ - ChannelAttachmentRef, ChannelError, DeliveryReport, ExternalActorRef, ExternalConversationRef, - ExternalEventId, ImmediateResponse, InboundBatchFragment, InboundOutcome, OutboundEnvelope, - ProductAttachmentDescriptor, ProductAttachmentKind, ProductTriggerReason, VerifiedInbound, -}; /// What the scripted adapter observed per call: forwarded headers, body, /// resolved installation id, and host-selected non-secret configuration. diff --git a/crates/ironclaw_product/CLAUDE.md b/crates/ironclaw_product/CLAUDE.md index ddb53a7040d..9918e51aaa0 100644 --- a/crates/ironclaw_product/CLAUDE.md +++ b/crates/ironclaw_product/CLAUDE.md @@ -29,6 +29,42 @@ handling, gate routing, mission routing, and redacted acknowledgements. | `AuthInteractionService` / `DefaultAuthInteractionService` | Auth-required product/WebUI boundary for listing redacted pending auth gates and resolving credential/callback/cancel decisions through typed auth-flow manager + turn coordinator ports | | `ProductSurface` / `RebornServices` | Native WebChat v2 service — stable surface beta WebUI route handlers consume in place of reaching into turn coordination, thread stores, runtime lanes, dispatchers, or capability hosts. Enforces caller ownership of the thread before any turn mutation; projects channel discovery as extension-surface data on the extensions list (typed direction + connect affordance; no separate channel registry); rejects stale or attacker-supplied `gate_ref` on denied/cancelled gate resolutions; routes approval-gate `always: true` resolutions through the approval interaction policy path while keeping generic gate fallback one-shot only | +## Ports that are no longer declared here + +WS2's `extension_host` port-inversion row (PROPOSAL §6.1.3) moved the eleven +product-side ports this crate declared whose implementation sits outside it — +nine implemented by `ironclaw_extension_host` (the set +`crates/ironclaw_architecture/tests/reborn_extension_host_port_inversion.rs` +enumerates and pins as `INVERTED_PORTS`) and two by +`ironclaw_reborn_composition` (`AdminUserService`, `RebornOperatorToolCatalog`). +That test is the enforced inventory; this list is prose and defers to it. +They now live in `ironclaw_product_contracts` and this crate imports them like +any other consumer — there is deliberately **no re-export** (the port half of +`reborn_product_contract_location_scan.rs` fails on one): + +`delivery::{ChannelDeliveryResolver, ResolvedChannelDelivery, DeliveryReplyContextSource}` · +`account_setup::{AccountConnectionStatusSource, ChannelConnectionNoticePolicy, ExtensionAccountSetupDescriptor, ExtensionAccountSetupError, AccountConnectionStatusError}` · +`channel_config::ChannelConfigProductService` · +`views::{RebornViewProvider, RebornViewDescriptor, RebornViewQuery, RebornViewPage}` · +`command::{ProductCommandContext, CommandActorRoleResolver}` · +`action::{ProductActionId, ActionFingerprintKey, SourceBindingKey, ProductCommandName, AuthRequestRef, LinkedThreadActionId}` · +`prompt_source::{ApprovalPromptContextSource, BlockedAuthPromptSource, BlockedAuthPromptRequest}` · +`lifecycle_service::{LifecycleProductService, LifecycleProductContext, LifecycleProductSurfaceContext}` · +`admin_users::{AdminUserService, AdminUser*, AdminCreate*}` · +`operator_tools::{RebornOperatorToolCatalog, RebornOperatorToolInfo}`. + +What stayed, and why: the **implementations** (`DeliveryCoordinator`, +`NoReplyContext`, `ExtensionAccountSetupRegistry`, `UnsupportedLifecycleProductService`, +`RejectingAdminUserService`, `UnavailableRebornViewProvider`, +`DirectConversationCommandAdmission`), the frozen wire DTOs +(`RebornAdmin*`, `ProductView`), the ledger record and saga (`ProductInboundAction`), +and six ports whose signatures name `ironclaw_auth`/`ironclaw_turns`/`ironclaw_conversations` +types that a contracts crate may not depend on — see the residue list in +`crates/ironclaw_architecture/tests/reborn_extension_host_port_inversion.rs`. +`ProductSurfaceFailure` is the crate's *internal* workflow error and stays here; +that it is also `ironclaw_extension_host`'s lifecycle error vocabulary is the +single largest remaining blocker to that crate's layer flip. + ## Dependencies - `ironclaw_approvals` / `ironclaw_authorization` — canonical approval resolution, the approval request store contract surfaced through the approval resolution/read-model ports, and scoped lease issue ports used by approval interactions diff --git a/crates/ironclaw_product/src/action.rs b/crates/ironclaw_product/src/action.rs index f59d5a9f8c5..4668af0e5d2 100644 --- a/crates/ironclaw_product/src/action.rs +++ b/crates/ironclaw_product/src/action.rs @@ -4,162 +4,16 @@ //! workflow service. It is keyed by tenant + installation + external event fingerprint //! so that retried/duplicated webhook deliveries are idempotent. -use crate::{ - AdapterInstallationId, ExternalActorRef, ExternalEventId, ProductAdapterId, ProductInboundAck, - ProductInboundPayload, ProductRejectionKind, -}; +use crate::{ProductInboundAck, ProductInboundPayload, ProductRejectionKind}; use chrono::{DateTime, Utc}; +use ironclaw_product_contracts::action::{ + ActionFingerprintKey, AuthRequestRef, LinkedThreadActionId, ProductActionId, ProductCommandName, +}; use ironclaw_turns::{LoopGateRef, TurnRunId}; use serde::{Deserialize, Serialize}; -use uuid::Uuid; use crate::error::ProductSurfaceFailure; -/// Unique identifier for a product inbound action ledger entry. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(transparent)] -pub struct ProductActionId(Uuid); - -impl ProductActionId { - pub fn new() -> Self { - Self(Uuid::new_v4()) - } - - pub fn as_uuid(&self) -> Uuid { - self.0 - } -} - -impl Default for ProductActionId { - fn default() -> Self { - Self::new() - } -} - -impl std::fmt::Display for ProductActionId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -const SOURCE_BINDING_KEY_MAX_BYTES: usize = 2_048; -const PRODUCT_COMMAND_NAME_MAX_BYTES: usize = 256; -const INTERACTION_REF_MAX_BYTES: usize = 512; - -fn validate_typed_token(kind: &'static str, value: &str, max_bytes: usize) -> Result<(), String> { - if value.is_empty() { - return Err(format!("{kind} must not be empty")); - } - if value.len() > max_bytes { - return Err(format!("{kind} exceeds {max_bytes}-byte limit")); - } - if value.chars().any(|c| c == '\0' || c.is_control()) { - return Err(format!("{kind} contains unsupported control characters")); - } - Ok(()) -} - -macro_rules! typed_token { - ($name:ident, $kind:literal, $max_bytes:expr) => { - #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] - #[serde(try_from = "String")] - pub struct $name(String); - - impl $name { - pub fn new(value: impl Into) -> Result { - let value = value.into(); - validate_typed_token($kind, &value, $max_bytes)?; - Ok(Self(value)) - } - - pub fn as_str(&self) -> &str { - &self.0 - } - - pub fn into_inner(self) -> String { - self.0 - } - } - - impl TryFrom for $name { - type Error = String; - - fn try_from(value: String) -> Result { - Self::new(value) - } - } - - impl AsRef for $name { - fn as_ref(&self) -> &str { - self.as_str() - } - } - - impl std::fmt::Display for $name { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } - } - - impl From<$name> for String { - fn from(value: $name) -> Self { - value.0 - } - } - }; -} - -typed_token!( - SourceBindingKey, - "source binding key", - SOURCE_BINDING_KEY_MAX_BYTES -); -typed_token!( - ProductCommandName, - "product command name", - PRODUCT_COMMAND_NAME_MAX_BYTES -); -typed_token!( - AuthRequestRef, - "auth request ref", - INTERACTION_REF_MAX_BYTES -); -typed_token!( - LinkedThreadActionId, - "linked thread action id", - INTERACTION_REF_MAX_BYTES -); - -/// Composite deduplication key for inbound actions. Two envelopes with the same -/// fingerprint are considered duplicates and the second will replay the first -/// outcome. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct ActionFingerprintKey { - pub adapter_id: ProductAdapterId, - pub installation_id: AdapterInstallationId, - pub external_actor_ref: ExternalActorRef, - pub source_binding_key: SourceBindingKey, - pub external_event_id: ExternalEventId, -} - -impl ActionFingerprintKey { - pub fn new( - adapter_id: ProductAdapterId, - installation_id: AdapterInstallationId, - external_actor_ref: ExternalActorRef, - source_binding_key: SourceBindingKey, - external_event_id: ExternalEventId, - ) -> Self { - Self { - adapter_id, - installation_id, - external_actor_ref, - source_binding_key, - external_event_id, - } - } -} - /// Current phase of an inbound action saga. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -290,6 +144,9 @@ impl ProductInboundAction { #[cfg(test)] mod tests { use crate::{ProductInboundAck, ProductRejection, ProductRejectionKind}; + use ironclaw_extension_contracts::external::{ExternalActorRef, ExternalEventId}; + use ironclaw_host_api::product_adapter::{AdapterInstallationId, ProductAdapterId}; + use ironclaw_product_contracts::action::SourceBindingKey; use super::*; @@ -304,25 +161,6 @@ mod tests { ) } - #[test] - fn typed_tokens_reject_empty_oversized_and_control_values() { - assert!(SourceBindingKey::new("").is_err()); - assert!(ProductCommandName::new("x".repeat(PRODUCT_COMMAND_NAME_MAX_BYTES + 1)).is_err()); - assert!(AuthRequestRef::new("auth\nrequest").is_err()); - - let linked = LinkedThreadActionId::new("open-thread").expect("valid action id"); - assert_eq!(linked.as_str(), "open-thread"); - assert_eq!(linked.clone().into_inner(), "open-thread"); - assert_eq!(String::from(linked), "open-thread"); - } - - #[test] - fn product_action_id_round_trips_display_and_uuid() { - let action_id = ProductActionId::new(); - assert_eq!(action_id.to_string(), action_id.as_uuid().to_string()); - assert_ne!(ProductActionId::default().as_uuid(), action_id.as_uuid()); - } - #[test] fn inbound_action_tracks_dispatch_settle_and_terminal_state() { let mut action = ProductInboundAction::begin(fingerprint(), Utc::now()); diff --git a/crates/ironclaw_product/src/auth_continuation.rs b/crates/ironclaw_product/src/auth_continuation.rs index b109861f2a6..012aa96b0d8 100644 --- a/crates/ironclaw_product/src/auth_continuation.rs +++ b/crates/ironclaw_product/src/auth_continuation.rs @@ -5,6 +5,10 @@ //! boundary as the WebUI gate-resolution path. It intentionally does not define //! another auth-flow model or handle non-turn continuation variants. +use ironclaw_product_contracts::lifecycle_service::{ + LifecycleProductContext, LifecycleProductService, LifecycleProductSurfaceContext, +}; + use std::sync::Arc; use async_trait::async_trait; @@ -22,8 +26,7 @@ use crate::binding_ref::{ }; use crate::{ AuthContinuationRejectionKind, LifecyclePackageKind, LifecyclePackageRef, - LifecycleProductAction, LifecycleProductContext, LifecycleProductService, - LifecycleProductSurfaceContext, ProductSurfaceFailure, + LifecycleProductAction, ProductSurfaceFailure, }; struct LifecycleAuthContinuationDispatcher { diff --git a/crates/ironclaw_product/src/auth_prompt.rs b/crates/ironclaw_product/src/auth_prompt.rs index f528f3aeaa1..c85b2994126 100644 --- a/crates/ironclaw_product/src/auth_prompt.rs +++ b/crates/ironclaw_product/src/auth_prompt.rs @@ -15,11 +15,11 @@ use ironclaw_auth::{ }; use ironclaw_extension_contracts::auth_prompt::PairingPromptView; use ironclaw_host_api::{ - capability::RuntimeCredentialAccountSetup, - decision::RuntimeCredentialAuthRequirement, - ids::{InvocationId, UserId}, + capability::RuntimeCredentialAccountSetup, decision::RuntimeCredentialAuthRequirement, + ids::UserId, }; use ironclaw_product_contracts::package_lifecycle::ChannelConnectionRequirement; +use ironclaw_product_contracts::prompt_source::BlockedAuthPromptRequest; use ironclaw_turns::{TurnRunId, TurnScope}; /// Map a manifest display string onto the projection's optional field: a blank @@ -166,21 +166,6 @@ pub trait BlockedAuthFlowCanceller: Send + Sync { ) -> Result<(), AuthProductError>; } -/// Inputs for resolving a blocked-auth run's prompt view. One request shape -/// for every renderer (delivery path, projection layer); the challenge -/// provider is a separate argument, not request data. -pub struct BlockedAuthPromptRequest<'a> { - pub fallback_owner_user_id: &'a UserId, - pub scope: &'a TurnScope, - pub run_id: TurnRunId, - pub gate_ref: &'a str, - /// Invocation the blocked capability ran under, when the renderer has it - /// (the projection layer does; the delivery path renders without one). - pub invocation_id: Option, - pub body: String, - pub credential_requirements: &'a [RuntimeCredentialAuthRequirement], -} - /// Build the full blocked-auth prompt view: challenge enrichment when the /// provider can resolve the durable flow, credential-requirement fallback /// otherwise. diff --git a/crates/ironclaw_product/src/command_admission.rs b/crates/ironclaw_product/src/command_admission.rs index 27c572a8379..928606264d3 100644 --- a/crates/ironclaw_product/src/command_admission.rs +++ b/crates/ironclaw_product/src/command_admission.rs @@ -11,32 +11,18 @@ use std::collections::BTreeSet; use std::sync::Arc; use async_trait::async_trait; +use ironclaw_product_contracts::admin_users::AdminUserRole; +use ironclaw_product_contracts::command::{CommandActorRoleResolver, ProductCommandContext}; use ironclaw_product_contracts::surface::ProductSurfaceError; use crate::binding::route_kind_for_trigger; -use crate::command_dispatch::{ - ProductCommandAdmission, ProductCommandAdmissionService, ProductCommandContext, -}; +use crate::command_dispatch::{ProductCommandAdmission, ProductCommandAdmissionService}; use crate::commands::{ CommandAudience, ProductCommand, UnknownProductCommandName, declared_command_help_text, required_audience, validate_declared_product_command, }; -use crate::reborn_services::AdminUserRole; use crate::{ProductConversationRouteKind, ProductRejection, ProductRejectionKind}; -/// Resolves the admin-boundary role of the ACTIVE bound user behind an -/// inbound channel actor. `Ok(None)` means unbound actor, missing record, or -/// suspended account — all treated as not-admin (fail closed). `Err` means -/// transient resolution failure; the command fails retryable rather than -/// silently degrading to member or admin treatment. -#[async_trait] -pub trait CommandActorRoleResolver: Send + Sync { - async fn actor_role( - &self, - context: &ProductCommandContext, - ) -> Result, ProductSurfaceError>; -} - /// Admit only manifest-enabled commands from direct conversations, then gate /// admin-audience commands on the actor's admin-users role. pub struct DirectConversationCommandAdmission { diff --git a/crates/ironclaw_product/src/command_dispatch.rs b/crates/ironclaw_product/src/command_dispatch.rs index 8792c1cbf3b..3cd92d7e135 100644 --- a/crates/ironclaw_product/src/command_dispatch.rs +++ b/crates/ironclaw_product/src/command_dispatch.rs @@ -4,62 +4,13 @@ //! authority-bearing workflow context and the service boundary that decides //! whether a command may execute from that context. -use crate::{ - AdapterInstallationId, ExternalActorRef, ExternalConversationRef, ProductAdapterId, - ProductInboundEnvelope, ProductInboundPayload, ProductRejection, ProductRejectionKind, - ProductTriggerReason, VerifiedAuthClaim, -}; +use crate::{ProductRejection, ProductRejectionKind}; use async_trait::async_trait; -use chrono::{DateTime, Utc}; +use ironclaw_product_contracts::command::ProductCommandContext; +use ironclaw_product_contracts::surface::ProductSurfaceError; use serde::{Deserialize, Serialize}; -use crate::action::{ActionFingerprintKey, ProductActionId}; use crate::commands::ProductCommand; -use ironclaw_product_contracts::surface::{ProductSurfaceError, ProductSurfaceErrorCode}; - -/// Authority-bearing command dispatch context built by the workflow. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct ProductCommandContext { - pub action_id: ProductActionId, - pub fingerprint: ActionFingerprintKey, - /// Exact raw inbound command token, verbatim from the payload. - pub requested_command: String, - pub adapter_id: ProductAdapterId, - pub installation_id: AdapterInstallationId, - pub external_actor_ref: ExternalActorRef, - pub external_conversation_ref: ExternalConversationRef, - pub auth_claim: VerifiedAuthClaim, - pub trigger: ProductTriggerReason, - pub received_at: DateTime, -} - -impl ProductCommandContext { - pub fn from_envelope( - envelope: &ProductInboundEnvelope, - action_id: ProductActionId, - fingerprint: ActionFingerprintKey, - ) -> Result { - let ProductInboundPayload::Command(command) = envelope.payload() else { - return Err(ProductSurfaceError::from_status( - ProductSurfaceErrorCode::InvalidRequest, - 400, - false, - )); - }; - Ok(Self { - action_id, - fingerprint, - requested_command: command.command.clone(), - adapter_id: envelope.adapter_id().clone(), - installation_id: envelope.installation_id().clone(), - external_actor_ref: envelope.external_actor_ref().clone(), - external_conversation_ref: envelope.external_conversation_ref().clone(), - auth_claim: envelope.auth_claim().clone(), - trigger: command.trigger, - received_at: envelope.received_at(), - }) - } -} #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/crates/ironclaw_product/src/communication_context.rs b/crates/ironclaw_product/src/communication_context.rs index 31e0885ee54..f604a76ff33 100644 --- a/crates/ironclaw_product/src/communication_context.rs +++ b/crates/ironclaw_product/src/communication_context.rs @@ -1,8 +1,10 @@ +use ironclaw_product_contracts::lifecycle_service::{ + LifecycleProductContext, LifecycleProductService, LifecycleProductSurfaceContext, +}; use std::{sync::Arc, time::Duration}; use crate::{ - LifecycleProductAction, LifecycleProductContext, LifecycleProductPayload, - LifecycleProductService, LifecycleProductSurfaceContext, OutboundPreferencesProductService, + LifecycleProductAction, LifecycleProductPayload, OutboundPreferencesProductService, RebornOutboundDeliveryTargetStatus, }; use ironclaw_extension_contracts::{state::InstallationState, surface::CapabilitySurfaceKind}; @@ -228,11 +230,11 @@ mod tests { use crate::{ LifecycleExtensionRuntimeKind, LifecycleExtensionSource, LifecycleExtensionSummary, LifecycleInstalledExtensionSummary, LifecyclePackageKind, LifecyclePackageRef, - LifecycleProductAction, LifecycleProductContext, LifecycleProductPayload, - LifecycleProductResponse, LifecycleProductService, OutboundPreferencesProductService, - RebornOutboundDeliveryTargetId, RebornOutboundDeliveryTargetListResponse, - RebornOutboundDeliveryTargetStatus, RebornOutboundDeliveryTargetSummary, - RebornOutboundPreferencesResponse, RebornSetOutboundPreferencesRequest, + LifecycleProductAction, LifecycleProductPayload, LifecycleProductResponse, + OutboundPreferencesProductService, RebornOutboundDeliveryTargetId, + RebornOutboundDeliveryTargetListResponse, RebornOutboundDeliveryTargetStatus, + RebornOutboundDeliveryTargetSummary, RebornOutboundPreferencesResponse, + RebornSetOutboundPreferencesRequest, }; use async_trait::async_trait; use ironclaw_extension_contracts::{state::InstallationState, surface::CapabilitySurfaceKind}; @@ -241,6 +243,9 @@ mod tests { use ironclaw_loop_contracts::{ CommunicationContextProvider, ConnectedChannelsState, DeliveryTargetState, }; + use ironclaw_product_contracts::lifecycle_service::{ + LifecycleProductContext, LifecycleProductService, + }; use ironclaw_product_contracts::surface::{ ProductSurfaceCaller, ProductSurfaceError, ProductSurfaceErrorCode, ProductSurfaceErrorKind, }; diff --git a/crates/ironclaw_product/src/delivery_coordinator.rs b/crates/ironclaw_product/src/delivery_coordinator.rs index 25df7774df4..aadcba1d415 100644 --- a/crates/ironclaw_product/src/delivery_coordinator.rs +++ b/crates/ironclaw_product/src/delivery_coordinator.rs @@ -29,8 +29,6 @@ use crate::{ }; use async_trait::async_trait; use ironclaw_attachments::DEFAULT_ATTACHMENT_BUDGETS; -use ironclaw_extension_contracts::channel_adapter::ChannelAdapter; -use ironclaw_extension_contracts::tool_adapter::RestrictedEgress; use ironclaw_host_api::path::ScopedPath; use ironclaw_outbound::{ CommunicationPreferenceRepository, DeliveryFailureKind, OutboundDeliveryAttempt, @@ -38,6 +36,9 @@ use ironclaw_outbound::{ OutboundPushKind, OutboundStateStorePort, PrepareCommunicationDeliveryRequest, ReplyAttachmentIntent, UpdateDeliveryStatusRequest, ValidatedReplyTargetBinding, }; +use ironclaw_product_contracts::delivery::{ + ChannelDeliveryResolver, DeliveryReplyContextSource, ResolvedChannelDelivery, +}; use ironclaw_threads::{AttachmentRef, ThreadScope}; use ironclaw_turns::{TurnRunId, TurnScope}; use sha2::{Digest, Sha256}; @@ -110,38 +111,6 @@ impl DeliveryIntent { } } -/// One channel's delivery half, resolved from a single active-snapshot read -/// (generation-pinned: an in-flight delivery keeps these `Arc`s across an -/// upgrade). -#[derive(Clone)] -pub struct ResolvedChannelDelivery { - pub extension_id: String, - pub installation_id: String, - pub adapter: Arc, - /// Policy-enforced egress built from the same snapshot read. - pub egress: Arc, -} - -/// Resolver port: the coordinator's view of the active extension set. -/// Defined here (the coordinator is the consumer); implemented over the -/// extension host's snapshot by composition. -pub trait ChannelDeliveryResolver: Send + Sync { - fn resolve_channel_delivery(&self, extension_id: &str) -> Option; -} - -/// Read half of the host-side `reply_context` storage (ING-11): the opaque -/// vendor context an adapter attached to the originating inbound message, -/// handed back at delivery time. -#[async_trait] -pub trait DeliveryReplyContextSource: Send + Sync { - async fn reply_context( - &self, - extension_id: &str, - installation_id: &str, - conversation_fingerprint: &str, - ) -> Option>; -} - /// A no-context source for channels/tests without stored contexts. pub struct NoReplyContext; diff --git a/crates/ironclaw_product/src/extension_account_setup.rs b/crates/ironclaw_product/src/extension_account_setup.rs index 23170d1ce95..5a576b07fdb 100644 --- a/crates/ironclaw_product/src/extension_account_setup.rs +++ b/crates/ironclaw_product/src/extension_account_setup.rs @@ -8,98 +8,13 @@ use std::collections::{BTreeMap, btree_map::Entry as MapEntry}; use std::sync::{Arc, OnceLock, RwLock, RwLockReadGuard, RwLockWriteGuard}; -use async_trait::async_trait; use ironclaw_host_api::{ decision::RuntimeCredentialAuthRequirement, ids::{ExtensionId, UserId}, }; -use thiserror::Error; - -use crate::ChannelConnectionRequirement; - -/// A connection-status read failed inside the extension-owned host service. -#[derive(Debug, Clone, PartialEq, Eq, Error)] -#[error("account connection status read failed: {reason}")] -pub struct AccountConnectionStatusError { - reason: String, -} - -impl AccountConnectionStatusError { - pub fn new(reason: impl Into) -> Self { - Self { - reason: reason.into(), - } - } -} - -/// Narrow per-user account-connection probe used during activation preflight. -#[async_trait] -pub trait AccountConnectionStatusSource: Send + Sync + std::fmt::Debug { - async fn connected(&self, user_id: &UserId) -> Result; -} - -/// Product-owned copy for a channel account's pairing lifecycle. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ChannelConnectionNoticePolicy { - pub connect_required: String, - pub paired: String, - pub already_paired_same_user: String, - pub already_bound_to_other_user: String, - pub expired_or_unknown: String, -} - -impl ChannelConnectionNoticePolicy { - pub fn generic(display_name: &str) -> Self { - Self { - connect_required: format!( - "👋 To use {display_name}, connect it in the Ironclaw web app, then message me here again." - ), - paired: format!("✅ {display_name} is paired. You can talk to Ironclaw here."), - already_paired_same_user: format!( - "✅ This {display_name} account is already paired to you." - ), - already_bound_to_other_user: format!( - "This {display_name} account is already paired to another Ironclaw user." - ), - expired_or_unknown: format!( - "That {display_name} pairing code is invalid or expired. Get a fresh code from Ironclaw and try again." - ), - } - } -} - -/// Immutable product metadata for an extension whose activation depends on a -/// user-scoped external-account connection. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ExtensionAccountSetupDescriptor { - pub extension_id: ExtensionId, - pub auth_requirement: RuntimeCredentialAuthRequirement, - pub connection_requirement: ChannelConnectionRequirement, - pub connection_notices: ChannelConnectionNoticePolicy, - pub activation_success_message: String, - /// `WebGeneratedCode` presentation: an optional deep-link template with - /// `{code}` plus non-secret `[channel.config]` field-handle placeholders - /// (e.g. `https://vendor.example/{bot_username}?start={code}`). `None` - /// presents the minted code alone. - pub pairing_deep_link_template: Option, - /// Exact message prefixes the channel's pairing parser may strip before - /// validating a host-issued proof code. - pub inbound_code_prefixes: Vec, -} - -/// Sanitized lifecycle classification for an unavailable setup host or status -/// backend. The concrete backend error never crosses this boundary. -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum ExtensionAccountSetupError { - #[error("account setup host is unavailable for extension {extension_id}")] - HostUnavailable { extension_id: ExtensionId }, - #[error("account connection status is unavailable for extension {extension_id}")] - StatusUnavailable { - extension_id: ExtensionId, - #[source] - source: AccountConnectionStatusError, - }, -} +use ironclaw_product_contracts::account_setup::{ + AccountConnectionStatusSource, ExtensionAccountSetupDescriptor, ExtensionAccountSetupError, +}; #[derive(Debug)] struct RegistryEntry { diff --git a/crates/ironclaw_product/src/fakes.rs b/crates/ironclaw_product/src/fakes.rs index 63a45c2d7f8..60c1025b4cb 100644 --- a/crates/ironclaw_product/src/fakes.rs +++ b/crates/ironclaw_product/src/fakes.rs @@ -1,5 +1,7 @@ //! In-memory fakes for contract tests and downstream integration tests. +use ironclaw_product_contracts::action::ActionFingerprintKey; + use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -13,7 +15,7 @@ use ironclaw_product_contracts::surface::{ }; use ironclaw_turns::{AcceptedMessageRef, TurnRunId}; -use crate::action::{ActionFingerprintKey, ProductInboundAction}; +use crate::action::ProductInboundAction; use crate::binding::{ ConversationBindingService, ProductConversationRouteKind, ResolveBindingRequest, ResolvedBinding, diff --git a/crates/ironclaw_product/src/filesystem_ledger.rs b/crates/ironclaw_product/src/filesystem_ledger.rs index cdbd6d27f33..5f319e2b955 100644 --- a/crates/ironclaw_product/src/filesystem_ledger.rs +++ b/crates/ironclaw_product/src/filesystem_ledger.rs @@ -1,12 +1,14 @@ //! Filesystem-backed product workflow [`IdempotencyLedger`] storage adapters. +use ironclaw_product_contracts::action::ActionFingerprintKey; + use std::num::NonZeroUsize; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use crate::{ - ActionFingerprintKey, ActionPhase, IdempotencyDecision, IdempotencyLedger, - ProductInboundAction, ProductSurfaceFailure, + ActionPhase, IdempotencyDecision, IdempotencyLedger, ProductInboundAction, + ProductSurfaceFailure, }; use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; diff --git a/crates/ironclaw_product/src/filesystem_ledger/path.rs b/crates/ironclaw_product/src/filesystem_ledger/path.rs index e3f7ebd4869..9dc7e1f877c 100644 --- a/crates/ironclaw_product/src/filesystem_ledger/path.rs +++ b/crates/ironclaw_product/src/filesystem_ledger/path.rs @@ -1,5 +1,6 @@ -use crate::{ActionFingerprintKey, ProductSurfaceFailure}; +use crate::ProductSurfaceFailure; use ironclaw_host_api::{path::ScopedPath, resource::ResourceScope}; +use ironclaw_product_contracts::action::ActionFingerprintKey; use super::durable_error; diff --git a/crates/ironclaw_product/src/in_memory_ledger.rs b/crates/ironclaw_product/src/in_memory_ledger.rs index 5c6102b5f82..acdfd95ab87 100644 --- a/crates/ironclaw_product/src/in_memory_ledger.rs +++ b/crates/ironclaw_product/src/in_memory_ledger.rs @@ -4,6 +4,8 @@ //! integration tests. Durable production deployments should wire a database //! ledger with the same lease semantics. +use ironclaw_product_contracts::action::ActionFingerprintKey; + use std::{ collections::HashMap, num::NonZeroUsize, @@ -13,10 +15,7 @@ use std::{ use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; -use crate::{ - ActionFingerprintKey, IdempotencyDecision, IdempotencyLedger, ProductInboundAction, - ProductSurfaceFailure, -}; +use crate::{IdempotencyDecision, IdempotencyLedger, ProductInboundAction, ProductSurfaceFailure}; const DEFAULT_IN_FLIGHT_LEASE: Duration = Duration::seconds(60); diff --git a/crates/ironclaw_product/src/inbound_turn/tests.rs b/crates/ironclaw_product/src/inbound_turn/tests.rs index acfd5e75609..12a5fc6ffda 100644 --- a/crates/ironclaw_product/src/inbound_turn/tests.rs +++ b/crates/ironclaw_product/src/inbound_turn/tests.rs @@ -1,3 +1,4 @@ +use ironclaw_product_contracts::action::SourceBindingKey; use std::{ collections::VecDeque, future::pending, @@ -38,8 +39,6 @@ use ironclaw_turns::{ TurnError, TurnOriginKind, TurnRunState, TurnSurfaceType, }; -use crate::action::SourceBindingKey; - use super::*; // --- Minimal stubs for submit path tests --- diff --git a/crates/ironclaw_product/src/ledger.rs b/crates/ironclaw_product/src/ledger.rs index 8b1be91b394..856f85d6546 100644 --- a/crates/ironclaw_product/src/ledger.rs +++ b/crates/ironclaw_product/src/ledger.rs @@ -4,9 +4,11 @@ //! deliveries or client retries are detected and replay the prior outcome //! instead of re-executing side effects. +use ironclaw_product_contracts::action::ActionFingerprintKey; + use async_trait::async_trait; -use crate::action::{ActionFingerprintKey, ProductInboundAction}; +use crate::action::ProductInboundAction; use crate::error::ProductSurfaceFailure; /// Port for the durable inbound action idempotency ledger. diff --git a/crates/ironclaw_product/src/lib.rs b/crates/ironclaw_product/src/lib.rs index 55759a766d7..2f84096fc04 100644 --- a/crates/ironclaw_product/src/lib.rs +++ b/crates/ironclaw_product/src/lib.rs @@ -69,10 +69,7 @@ pub use product_auth_prompt::{blocked_auth_flow_canceller, product_auth_challeng pub use project_create_capability::{PROJECT_CREATE_CAPABILITY_ID, project_create_capability}; pub use project_service::RebornProjectService; -pub use action::{ - ActionDispatchKind, ActionFingerprintKey, ActionPhase, AuthRequestRef, LinkedThreadActionId, - ProductActionId, ProductCommandName, ProductInboundAction, SourceBindingKey, -}; +pub use action::{ActionDispatchKind, ActionPhase, ProductInboundAction}; pub use approval_interaction::{ ApprovalBlockedTurnRun, ApprovalGateRecord, ApprovalInteractionActionView, ApprovalInteractionDecision, ApprovalInteractionReadModel, ApprovalInteractionRejectionKind, @@ -103,8 +100,8 @@ pub use auth_interaction::{ ResolveAuthInteractionResponse, is_auth_gate_ref, }; pub use auth_prompt::{ - AuthChallengeProvider, AuthChallengeView, BlockedAuthFlowCanceller, BlockedAuthPromptRequest, - PairingAuthChallengeView, auth_prompt_view_for_blocked_auth, + AuthChallengeProvider, AuthChallengeView, BlockedAuthFlowCanceller, PairingAuthChallengeView, + auth_prompt_view_for_blocked_auth, }; pub use automation_product_service::RebornAutomationProductService; pub use automation_thread_metadata::{ @@ -115,9 +112,9 @@ pub use binding::{ ConversationBindingService, ProductConversationRouteKind, ResolveBindingRequest, ResolvedBinding, route_kind_for_inbound_payload, }; -pub use command_admission::{CommandActorRoleResolver, DirectConversationCommandAdmission}; +pub use command_admission::DirectConversationCommandAdmission; pub use command_dispatch::{ - ProductCommandAdmission, ProductCommandAdmissionService, ProductCommandContext, + ProductCommandAdmission, ProductCommandAdmissionService, RejectingProductCommandAdmissionService, }; pub use commands::{ @@ -139,10 +136,7 @@ pub use conversation_binding::{ pub use error::{ AuthContinuationRejectionKind, ProductSurfaceFailure, lifecycle_product_surface_error, }; -pub use extension_account_setup::{ - AccountConnectionStatusError, AccountConnectionStatusSource, ChannelConnectionNoticePolicy, - ExtensionAccountSetupDescriptor, ExtensionAccountSetupError, ExtensionAccountSetupRegistry, -}; +pub use extension_account_setup::ExtensionAccountSetupRegistry; #[cfg(any(test, feature = "test-support"))] pub use fakes::{ FakeBeforeInboundPolicy, FakeConversationBindingService, FakeIdempotencyLedger, @@ -269,8 +263,7 @@ pub use lifecycle::{ LifecycleExtensionCredentialSetup, LifecycleExtensionOnboarding, LifecycleExtensionRuntimeKind, LifecycleExtensionSource, LifecycleExtensionSummary, LifecycleInstallScope, LifecycleInstalledExtensionSummary, LifecyclePackageId, LifecyclePackageKind, - LifecyclePackageRef, LifecycleProductAction, LifecycleProductContext, LifecycleProductPayload, - LifecycleProductResponse, LifecycleProductService, LifecycleProductSurfaceContext, + LifecyclePackageRef, LifecycleProductAction, LifecycleProductPayload, LifecycleProductResponse, LifecycleReadinessBlocker, LifecycleSearchExtensionSummary, LifecycleSkillSource, LifecycleSkillSummary, UnsupportedLifecycleProductService, project_public_lifecycle_states, public_lifecycle_response_json, @@ -278,9 +271,9 @@ pub use lifecycle::{ // Product hosts use this outbound orchestration seam to wire outbound policy // decisions to adapter rendering without reaching into module internals. pub use delivery_coordinator::{ - ChannelDeliveryResolver, CoordinatedDeliveryError, CoordinatedDeliveryOutcome, - CoordinatedDeliveryRequest, DeliveryCoordinator, DeliveryIntent, DeliveryReplyContextSource, - DeliveryRetryPolicy, NoReplyContext, NoticeDeliveryRequest, ResolvedChannelDelivery, + CoordinatedDeliveryError, CoordinatedDeliveryOutcome, CoordinatedDeliveryRequest, + DeliveryCoordinator, DeliveryIntent, DeliveryRetryPolicy, NoReplyContext, + NoticeDeliveryRequest, }; pub use outbound_delivery::{ProductOutboundTargetResolver, VerifiedProductOutboundTargetMetadata}; // The generic run-delivery components (§5.4): channel hosts wire these over @@ -290,9 +283,9 @@ pub use policy::{ NoopBeforeInboundPolicy, }; pub use run_delivery::{ - ApprovalPromptContextSource, BlockedAuthPromptSource, DeliveredChannelMessage, - RunDeliveryError, RunDeliveryObserver, RunDeliveryServices, RunDeliverySettings, - TriggeredRunDeliveryDriver, TriggeredRunDeliveryRequest, triggered_run_delivery_settings, + DeliveredChannelMessage, RunDeliveryError, RunDeliveryObserver, RunDeliveryServices, + RunDeliverySettings, TriggeredRunDeliveryDriver, TriggeredRunDeliveryRequest, + triggered_run_delivery_settings, }; // Adapter, projection, and event DTOs are re-exported from // `ironclaw_host_api::product_adapter` above so product terminals consume a @@ -313,10 +306,8 @@ pub use reborn_services::{ AUTOMATION_RENAME_COMMAND, AUTOMATION_RESUME_CAPABILITY, AUTOMATION_RESUME_CAPABILITY_ID, AUTOMATION_RESUME_COMMAND, AUTOMATION_RUN_HISTORY_DEFAULT_PAGE_SIZE, AUTOMATION_RUN_HISTORY_MAX_PAGE_SIZE, AUTOMATIONS_VIEW, ActiveModelReader, - AdminCreateUserFields, AdminCreatedUser, AdminUserError, AdminUserRecord, AdminUserRole, - AdminUserSecretMeta, AdminUserService, AdminUserStatus, AttachmentCleanupReport, - AutomationListRequest, AutomationProductService, CANCEL_RUN_COMMAND, CREATE_THREAD_COMMAND, - ChannelAuthAccountState, ChannelConfigProductService, ChannelConnectionService, + AttachmentCleanupReport, AutomationListRequest, AutomationProductService, CANCEL_RUN_COMMAND, + CREATE_THREAD_COMMAND, ChannelAuthAccountState, ChannelConnectionService, ChannelInboundSurfaceAdmission, ChannelInboundSurfaceOutcome, ChannelInboundSurfaceRejectedAdmission, ChannelInboundSurfaceRequest, CodexLoginStart, EXTENSION_ACTIVATE_CAPABILITY, EXTENSION_ACTIVATE_CAPABILITY_ID, EXTENSION_IMPORT_CAPABILITY, @@ -378,16 +369,16 @@ pub use reborn_services::{ RebornAutomationInfo, RebornAutomationMutationResponse, RebornAutomationRecentRunInfo, RebornAutomationRecentRunStatus, RebornAutomationRequest, RebornAutomationRunStatus, RebornAutomationSource, RebornAutomationState, RebornCancelRunResponse, - RebornChannelConfigField, RebornChannelConnectAction, RebornChannelConnectStrategy, - RebornCommandRejection, RebornCreateProjectRequest, RebornCreateThreadResponse, - RebornDeleteProjectRequest, RebornDeleteThreadRequest, RebornDeleteThreadResponse, - RebornExecuteProductCommandRequest, RebornExecuteProductCommandResponse, - RebornExtensionActionResponse, RebornExtensionCredentialSetup, RebornExtensionInfo, - RebornExtensionListResponse, RebornExtensionOnboardingPayload, RebornExtensionOnboardingState, - RebornExtensionRegistryEntry, RebornExtensionRegistryResponse, RebornExtensionSetupField, - RebornExtensionSetupSecret, RebornExtensionSurface, RebornFsListRequest, RebornFsListResponse, - RebornFsMountInfo, RebornFsMountsRequest, RebornFsMountsResponse, RebornFsReadRequest, - RebornFsStatRequest, RebornFsStatResponse, RebornGetProjectRequest, RebornGetRunStateRequest, + RebornChannelConnectAction, RebornChannelConnectStrategy, RebornCommandRejection, + RebornCreateProjectRequest, RebornCreateThreadResponse, RebornDeleteProjectRequest, + RebornDeleteThreadRequest, RebornDeleteThreadResponse, RebornExecuteProductCommandRequest, + RebornExecuteProductCommandResponse, RebornExtensionActionResponse, + RebornExtensionCredentialSetup, RebornExtensionInfo, RebornExtensionListResponse, + RebornExtensionOnboardingPayload, RebornExtensionOnboardingState, RebornExtensionRegistryEntry, + RebornExtensionRegistryResponse, RebornExtensionSetupField, RebornExtensionSetupSecret, + RebornExtensionSurface, RebornFsListRequest, RebornFsListResponse, RebornFsMountInfo, + RebornFsMountsRequest, RebornFsMountsResponse, RebornFsReadRequest, RebornFsStatRequest, + RebornFsStatResponse, RebornGetProjectRequest, RebornGetRunStateRequest, RebornGetRunStateResponse, RebornGlobalAutoApproveRequest, RebornGlobalAutoApproveResponse, RebornListAutomationsResponse, RebornListMembersRequest, RebornListMembersResponse, RebornListProjectsRequest, RebornListProjectsResponse, RebornListThreadsResponse, @@ -401,8 +392,7 @@ pub use reborn_services::{ RebornOperatorServiceLifecycleRequest, RebornOperatorSetupRequest, RebornOperatorSetupResponse, RebornOperatorSetupStatus, RebornOperatorSetupStep, RebornOperatorSetupStepStatus, RebornOperatorStatusCheck, RebornOperatorStatusResponse, RebornOperatorStatusSeverity, - RebornOperatorStatusState, RebornOperatorSurfaceStatus, RebornOperatorToolCatalog, - RebornOperatorToolInfo, RebornOutboundDeliveryModality, + RebornOperatorStatusState, RebornOperatorSurfaceStatus, RebornOutboundDeliveryModality, RebornOutboundDeliveryTargetCapabilities, RebornOutboundDeliveryTargetChannel, RebornOutboundDeliveryTargetDescription, RebornOutboundDeliveryTargetDisplayName, RebornOutboundDeliveryTargetId, RebornOutboundDeliveryTargetListResponse, @@ -423,8 +413,7 @@ pub use reborn_services::{ RebornThreadArtifact, RebornThreadArtifactRequest, RebornTimelineRequest, RebornTimelineResponse, RebornTraceCreditsResponse, RebornTraceHoldAuthorizeProductRequest, RebornTraceHoldAuthorizeResponse, RebornUpdateMemberRoleRequest, RebornUpdateProjectRequest, - RebornVendorAuthAccounts, RebornViewDescriptor, RebornViewPage, RebornViewProvider, - RebornViewQuery, RunArtifactLogs, RunArtifactMessage, RunArtifactRedaction, + RebornVendorAuthAccounts, RunArtifactLogs, RunArtifactMessage, RunArtifactRedaction, RunArtifactToolCall, SKILL_AUTO_ACTIVATE_LEARNED_SET_CAPABILITY, SKILL_AUTO_ACTIVATE_LEARNED_SET_CAPABILITY_ID, SKILL_AUTO_ACTIVATE_SET_CAPABILITY, SKILL_AUTO_ACTIVATE_SET_CAPABILITY_ID, SKILL_CONTENT_VIEW, SKILL_INSTALL_CAPABILITY, diff --git a/crates/ironclaw_product/src/lifecycle.rs b/crates/ironclaw_product/src/lifecycle.rs index 2a33fce4ec5..8c56fcb962b 100644 --- a/crates/ironclaw_product/src/lifecycle.rs +++ b/crates/ironclaw_product/src/lifecycle.rs @@ -7,11 +7,10 @@ use async_trait::async_trait; use ironclaw_extension_contracts::state::InstallationState; -use ironclaw_host_api::ids::{AgentId, ProjectId, TenantId, UserId}; +use ironclaw_product_contracts::lifecycle_service::{ + LifecycleProductContext, LifecycleProductService, +}; use ironclaw_product_contracts::surface::{ProductSurfaceError, ProductSurfaceErrorCode}; -use serde::Serialize; - -use crate::ProductCommandContext; pub use ironclaw_extension_contracts::lifecycle_id::{LifecycleBlockerRef, LifecyclePackageId}; pub use ironclaw_product_contracts::package_lifecycle::{ @@ -27,71 +26,6 @@ pub use ironclaw_product_contracts::package_lifecycle::{ const LIFECYCLE_REF_MAX_BYTES: usize = 512; -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct LifecycleProductSurfaceContext { - pub tenant_id: TenantId, - pub user_id: UserId, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub project_id: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(tag = "source", rename_all = "snake_case")] -pub enum LifecycleProductContext { - Command(Box), - Surface(LifecycleProductSurfaceContext), -} - -#[async_trait] -pub trait LifecycleProductService: Send + Sync { - async fn execute( - &self, - context: LifecycleProductContext, - action: LifecycleProductAction, - ) -> Result; - - async fn project_package( - &self, - context: LifecycleProductContext, - package_ref: LifecyclePackageRef, - ) -> Result; - - /// Import a standalone extension from an uploaded bundle (zip bytes) — the - /// WebUI "Install Tool" path. Default is unavailable; only the local runtime - /// service implements it. - async fn import_extension_bundle( - &self, - _context: LifecycleProductContext, - _bundle: Vec, - ) -> Result { - Err(ProductSurfaceError::from_status( - ProductSurfaceErrorCode::InvalidRequest, - 400, - false, - )) - } - - /// Redacted activation error for each installed extension whose activation - /// failed, keyed by extension id — sourced from the durable installation - /// record's typed `last_error`. The extensions-list service threads this - /// into `RebornExtensionInfo::activation_error` so a failed extension shows - /// *why* it failed instead of collapsing to a bare `installed`/`failed` - /// state with no reason. - /// - /// Default: none. A service that does not surface durable installation - /// errors reports no reason and the wire's `activation_error` stays absent; - /// the production extension-host service overrides this to read the - /// installation records' `last_error`. - async fn installed_activation_errors( - &self, - _context: LifecycleProductContext, - ) -> Result, ProductSurfaceError> { - Ok(std::collections::HashMap::new()) - } -} - #[derive(Debug, Clone)] pub struct UnsupportedLifecycleProductService { runtime_ref: String, diff --git a/crates/ironclaw_product/src/policy.rs b/crates/ironclaw_product/src/policy.rs index d6a7e71544e..c2aa6da802d 100644 --- a/crates/ironclaw_product/src/policy.rs +++ b/crates/ironclaw_product/src/policy.rs @@ -4,13 +4,14 @@ //! before accepted-message staging. They may allow, rewrite, or reject a //! user-message payload without exposing raw policy internals to adapters. +use ironclaw_product_contracts::action::SourceBindingKey; + use crate::{ AdapterInstallationId, ExternalActorRef, ExternalConversationRef, ProductAdapterId, ProductInboundEnvelope, ProductRejection, UserMessagePayload, }; use async_trait::async_trait; -use crate::action::SourceBindingKey; use crate::error::ProductSurfaceFailure; /// Request passed to before-inbound policy implementations. diff --git a/crates/ironclaw_product/src/projection/turn_events.rs b/crates/ironclaw_product/src/projection/turn_events.rs index 3acc698be6b..3ce3d78f173 100644 --- a/crates/ironclaw_product/src/projection/turn_events.rs +++ b/crates/ironclaw_product/src/projection/turn_events.rs @@ -1,3 +1,4 @@ +use ironclaw_product_contracts::prompt_source::BlockedAuthPromptRequest; use std::{ collections::{HashMap, VecDeque}, sync::Arc, @@ -34,7 +35,7 @@ use ironclaw_turns::{ use tokio::sync::{Mutex, OnceCell, Semaphore}; use crate::AuthChallengeProvider; -use crate::{BlockedAuthPromptRequest, auth_prompt_view_for_blocked_auth}; +use crate::auth_prompt_view_for_blocked_auth; use ironclaw_host_api::failure::categories::CHECKPOINT_REJECTED_CATEGORY; use ironclaw_host_api::failure::summary::{ checkpoint_rejection_host_explanation_from_detail, pinned_failure_summary_for_category, diff --git a/crates/ironclaw_product/src/reborn_services.rs b/crates/ironclaw_product/src/reborn_services.rs index 46d17a8e503..9f3b4b11a22 100644 --- a/crates/ironclaw_product/src/reborn_services.rs +++ b/crates/ironclaw_product/src/reborn_services.rs @@ -14,7 +14,19 @@ use std::{ time::Duration, }; +use ironclaw_product_contracts::admin_users::{ + ADMIN_USER_LIST_DEFAULT_LIMIT, ADMIN_USER_LIST_MAX_LIMIT, AdminCreateUserFields, + AdminUserError, AdminUserRecord, AdminUserService, AdminUserStatus, +}; +use ironclaw_product_contracts::channel_config::ChannelConfigProductService; +use ironclaw_product_contracts::lifecycle_service::{ + LifecycleProductContext, LifecycleProductService, LifecycleProductSurfaceContext, +}; +use ironclaw_product_contracts::operator_tools::{ + RebornOperatorToolCatalog, RebornOperatorToolInfo, +}; use ironclaw_product_contracts::projection::ProjectionStream; +use ironclaw_product_contracts::views::{RebornViewPage, RebornViewProvider, RebornViewQuery}; use crate::{ ProductAdapterError, ProductSurfaceRejectionKind, ProjectionCursor, @@ -69,16 +81,15 @@ use uuid::Uuid; use crate::{ ApprovalInteractionDecision, ApprovalInteractionService, AuthInteractionDecision, AuthInteractionRejectionKind, AuthInteractionService, CommandAudience, CommandResultField, - CommandResultView, LifecycleProductContext, LifecycleProductService, - LifecycleProductSurfaceContext, ListPendingApprovalsRequest, - PRODUCT_LIFECYCLE_COMMAND_OPERATION_ID, PRODUCT_MODEL_COMMAND_OPERATION_ID, - PRODUCT_STATUS_COMMAND_OPERATION_ID, ProductCancelRunRequest, ProductCommand, - ProductCommandDescriptor, ProductCreateThreadRequest, ProductGateResolution, - ProductInboundCommand, ProductLifecycleCommandInput, ProductListAutomationsRequest, - ProductListThreadsRequest, ProductModelCommand, ProductModelCommandInput, ProductRejectionKind, - ProductRenameAutomationRequest, ProductResolveGateRequest, ProductRetryRunRequest, - ProductStatusCommandInput, ProductSubmitTurnRequest, ProductSurfaceFailure, - ProductTriggerReason, ResolveApprovalInteractionRequest, ResolveApprovalInteractionResponse, + CommandResultView, ListPendingApprovalsRequest, PRODUCT_LIFECYCLE_COMMAND_OPERATION_ID, + PRODUCT_MODEL_COMMAND_OPERATION_ID, PRODUCT_STATUS_COMMAND_OPERATION_ID, + ProductCancelRunRequest, ProductCommand, ProductCommandDescriptor, ProductCreateThreadRequest, + ProductGateResolution, ProductInboundCommand, ProductLifecycleCommandInput, + ProductListAutomationsRequest, ProductListThreadsRequest, ProductModelCommand, + ProductModelCommandInput, ProductRejectionKind, ProductRenameAutomationRequest, + ProductResolveGateRequest, ProductRetryRunRequest, ProductStatusCommandInput, + ProductSubmitTurnRequest, ProductSurfaceFailure, ProductTriggerReason, + ResolveApprovalInteractionRequest, ResolveApprovalInteractionResponse, ResolveAuthInteractionRequest, ResolveAuthInteractionResponse, UnsupportedLifecycleProductService, approval_interaction::RejectingApprovalInteractionService, @@ -123,16 +134,12 @@ pub use admin_configuration::{ ADMIN_CONFIGURATION_VIEW, RebornAdminConfigurationField, RebornAdminConfigurationGroup, RebornAdminConfigurationListResponse, RebornAdminConfigurationUse, }; -use admin_users::{ - ADMIN_USER_LIST_DEFAULT_LIMIT, ADMIN_USER_LIST_MAX_LIMIT, RejectingAdminUserService, -}; +use admin_users::RejectingAdminUserService; pub use admin_users::{ - AdminCreateUserFields, AdminCreatedUser, AdminUserError, AdminUserRecord, AdminUserRole, - AdminUserSecretMeta, AdminUserService, AdminUserStatus, RebornAdminCreateUserRequest, - RebornAdminDeleteSecretProductRequest, RebornAdminPutSecretProductRequest, - RebornAdminPutSecretRequest, RebornAdminSecretDeletedResponse, RebornAdminSecretResponse, - RebornAdminSetRoleProductRequest, RebornAdminSetRoleRequest, - RebornAdminSetStatusProductRequest, RebornAdminSetStatusRequest, + RebornAdminCreateUserRequest, RebornAdminDeleteSecretProductRequest, + RebornAdminPutSecretProductRequest, RebornAdminPutSecretRequest, + RebornAdminSecretDeletedResponse, RebornAdminSecretResponse, RebornAdminSetRoleProductRequest, + RebornAdminSetRoleRequest, RebornAdminSetStatusProductRequest, RebornAdminSetStatusRequest, RebornAdminUpdateUserProductRequest, RebornAdminUpdateUserRequest, RebornAdminUserCreatedResponse, RebornAdminUserDeletedResponse, RebornAdminUserListQuery, RebornAdminUserListResponse, RebornAdminUserRequest, RebornAdminUserResponse, @@ -252,10 +259,7 @@ pub use types::{ RebornTimelineRequest, RebornTimelineResponse, RebornTraceHoldAuthorizeProductRequest, RebornVendorAuthAccounts, }; -pub use views::{ - ProductView, RebornViewDescriptor, RebornViewPage, RebornViewProvider, RebornViewQuery, - UnavailableRebornViewProvider, -}; +pub use views::{ProductView, UnavailableRebornViewProvider}; type SkillActivationRecorder = dyn Fn(&TurnScope, &AcceptedMessageRef, &str) -> Result<(), ProductSurfaceError> + Send + Sync; @@ -546,29 +550,6 @@ pub const SKILL_SEARCH_VIEW: ProductView = ProductView::unpaginated("skill_content"); -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RebornOperatorToolInfo { - pub capability_id: CapabilityId, - pub provider: ExtensionId, - pub description: Arc, - pub default_permission: PermissionMode, - pub effects: Arc<[EffectKind]>, -} - -#[async_trait] -pub trait RebornOperatorToolCatalog: Send + Sync { - /// Tools visible to `caller` in the operator/settings surface (#5459 P1). - /// - /// The settings/tools routes are authenticated-caller routes (not - /// operator-gated), so a member reads this catalog. It MUST therefore be - /// filtered by installation owner exactly like the model capability - /// surface: tenant-shared tools for everyone, user-private tools only for - /// their owner. An unfiltered catalog would disclose another user's - /// private install (its capability id, description, effects) — the leak - /// this parameter closes. - async fn list_operator_tools(&self, caller: &UserId) -> Vec; -} - #[derive(Clone)] struct RebornOperatorApprovalConfig { overrides: Arc, @@ -742,34 +723,6 @@ impl ChannelConnectionService for StaticChannelConnectionService { } } -pub use ironclaw_product_contracts::package_lifecycle::ChannelConfigField as RebornChannelConfigField; - -/// The generic channel-config configure port: per-extension operator config -/// declared by the extension manifest's channel-config fields. Host -/// composition implements it over the durable installation store and the -/// scoped secret store; the setup service routes submitted values through it -/// and derives config completeness from the field status. -#[async_trait] -pub trait ChannelConfigProductService: Send + Sync { - /// Per-field presence for the extension's declared channel config. - /// Empty when the extension declares none (or is not installed yet). - async fn field_status( - &self, - extension_id: &ExtensionId, - ) -> Result, ProductSurfaceError>; - - /// Validate submitted `(handle, value)` pairs against the installed - /// manifest's declared fields and persist them (non-secret values - /// durably per installation, secret values into the scoped secret - /// store). Saving while the extension is active re-runs its activation - /// with the new values. - async fn save_values( - &self, - extension_id: &ExtensionId, - values: Vec<(String, String)>, - ) -> Result<(), ProductSurfaceError>; -} - #[async_trait] pub trait OperatorStatusService: Send + Sync { async fn status( diff --git a/crates/ironclaw_product/src/reborn_services/admin_configuration.rs b/crates/ironclaw_product/src/reborn_services/admin_configuration.rs index f630904f63c..d8dc3afe38c 100644 --- a/crates/ironclaw_product/src/reborn_services/admin_configuration.rs +++ b/crates/ironclaw_product/src/reborn_services/admin_configuration.rs @@ -1,8 +1,10 @@ //! Product DTOs for the manifest-declared administrator configuration view. +use ironclaw_product_contracts::views::RebornViewDescriptor; + use serde::{Deserialize, Serialize}; -use super::{ProductCapabilityDescriptor, RebornViewDescriptor}; +use super::ProductCapabilityDescriptor; pub const ADMIN_CONFIGURATION_VIEW: RebornViewDescriptor = RebornViewDescriptor { id: "admin_configuration", diff --git a/crates/ironclaw_product/src/reborn_services/admin_users.rs b/crates/ironclaw_product/src/reborn_services/admin_users.rs index 7b8aa7fa51f..70365ce5482 100644 --- a/crates/ironclaw_product/src/reborn_services/admin_users.rs +++ b/crates/ironclaw_product/src/reborn_services/admin_users.rs @@ -1,205 +1,25 @@ -//! Admin user-management port + wire contract. +//! The admin user-management wire contract + the fail-closed default. //! -//! The [`AdminUserService`] port is defined here (the contract owner) and -//! implemented by an adapter in `ironclaw_reborn_composition` that wraps the -//! identity user-directory and the per-user secret store. Defining the port -//! here keeps `ironclaw_product` and `ironclaw_webui` free of a -//! dependency on `ironclaw_reborn_identity` (the crate boundary the -//! architecture tests enforce) — this is dependency inversion, a single-impl -//! trait by design. -//! -//! The `Reborn*` request/response types are the stable HTTP wire contract the -//! WebChat v2 admin routes serialize; both the service and the route handlers -//! import them from here. +//! The [`AdminUserService`] port and its record vocabulary moved to +//! `ironclaw_product_contracts::admin_users` (PROPOSAL §6.1.3): its only +//! production implementation is a `ironclaw_reborn_composition` adapter over +//! the identity user-directory, and `ironclaw_extension_host` reads the same +//! directory to resolve a channel actor's admin role. What stays here is +//! product's own surface: the `Reborn*` request/response types the WebChat v2 +//! admin routes serialize, and the fail-closed default `RebornServices` wires +//! before composition installs the real adapter. use std::collections::BTreeMap; use async_trait::async_trait; use ironclaw_host_api::ids::{SecretHandle, TenantId, UserId}; +use ironclaw_product_contracts::admin_users::{ + AdminCreateUserFields, AdminCreatedUser, AdminUserError, AdminUserRecord, AdminUserRole, + AdminUserSecretMeta, AdminUserService, AdminUserStatus, +}; use secrecy::SecretString; use serde::{Deserialize, Serialize}; -/// Account status. Wire-stable snake_case. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AdminUserStatus { - Active, - Suspended, -} - -/// Account role. Wire-stable snake_case. `Owner` and `Admin` clear the admin -/// authorization boundary; `Member` does not. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AdminUserRole { - Owner, - Admin, - Member, -} - -impl AdminUserRole { - /// Whether this role clears the admin authorization boundary. - pub fn is_admin(self) -> bool { - matches!(self, AdminUserRole::Owner | AdminUserRole::Admin) - } -} - -/// One user as seen by the admin surface — doubles as the domain record the -/// port returns and the JSON body the WebUI renders. Never carries an API -/// token: a freshly minted token is exposed exactly once via -/// [`RebornAdminUserCreatedResponse`]. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AdminUserRecord { - pub user_id: UserId, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub email: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub display_name: Option, - pub status: AdminUserStatus, - pub role: AdminUserRole, - pub created_at: String, - pub updated_at: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub created_by: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_login_at: Option, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub metadata: BTreeMap, -} - -/// Metadata for one provisioned per-user secret. Never carries the material. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AdminUserSecretMeta { - pub handle: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub created_at: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub updated_at: Option, -} - -/// Fields for admin-minting a new user. -#[derive(Debug, Clone)] -pub struct AdminCreateUserFields { - pub email: Option, - pub display_name: Option, - pub role: AdminUserRole, -} - -/// A newly created user plus its one-time API token. The token is a session -/// bearer minted by the composition adapter; it is returned exactly once and -/// never persisted in plaintext. -pub struct AdminCreatedUser { - pub record: AdminUserRecord, - pub api_token: SecretString, -} - -/// Failure modes of the admin user port. Deliberately coarse and free of -/// backend detail — the composition adapter maps identity/secret errors into -/// these, and the service maps these into the sanitized `ProductSurfaceError` -/// wire taxonomy. Authorization and last-admin protection are enforced in the -/// service, not here, so they are not modeled as port errors. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AdminUserError { - /// The targeted user id has no record. - NotFound, - /// A caller-supplied value is malformed (e.g. an invalid secret handle). - /// Maps to a 400, not a 500 — it is the client's input at fault, not the - /// backend. - InvalidInput, - /// A transient backend failure; the caller may retry. - Unavailable, - /// A backend inconsistency or unexpected failure; not retryable. - Internal, -} - -/// Admin user-management operations. Implemented by the composition adapter -/// over the identity user-directory + per-user secret store. -/// -/// Every method is tenant-scoped from the trusted caller (never a request -/// body). `get_user` must return `Ok(None)` — not `Err(NotFound)` — for a user -/// that does not exist in the tenant, so the service can distinguish "no such -/// user" (404) from "exists but you may not" (403) at the authorization seam. -/// Default page size for `list_users` when the caller omits `limit`. -pub const ADMIN_USER_LIST_DEFAULT_LIMIT: usize = 100; -/// Hard ceiling on the `list_users` page size, so a caller cannot widen the -/// response (and the backing directory scan) by passing a huge `limit`. -pub const ADMIN_USER_LIST_MAX_LIMIT: usize = 200; - -#[async_trait] -pub trait AdminUserService: Send + Sync { - /// One bounded page of users in `tenant`, optionally filtered by `status`, - /// ordered by `user_id` ascending and starting strictly after the `after` - /// cursor. At most `limit` records are returned; the service derives the - /// next cursor from the last record when a full page comes back. - async fn list_users( - &self, - tenant: &TenantId, - status: Option, - after: Option<&UserId>, - limit: usize, - ) -> Result, AdminUserError>; - - async fn get_user( - &self, - tenant: &TenantId, - user_id: &UserId, - ) -> Result, AdminUserError>; - - async fn create_user( - &self, - tenant: &TenantId, - actor: &UserId, - fields: AdminCreateUserFields, - ) -> Result; - - async fn update_profile( - &self, - tenant: &TenantId, - user_id: &UserId, - display_name: Option, - metadata: Option>, - ) -> Result; - - async fn set_status( - &self, - tenant: &TenantId, - user_id: &UserId, - status: AdminUserStatus, - ) -> Result; - - async fn set_role( - &self, - tenant: &TenantId, - user_id: &UserId, - role: AdminUserRole, - ) -> Result; - - async fn delete_user(&self, tenant: &TenantId, user_id: &UserId) -> Result<(), AdminUserError>; - - async fn count_active_admins(&self, tenant: &TenantId) -> Result; - - async fn list_secrets( - &self, - tenant: &TenantId, - user_id: &UserId, - ) -> Result, AdminUserError>; - - async fn put_secret( - &self, - tenant: &TenantId, - user_id: &UserId, - handle: SecretHandle, - material: SecretString, - ) -> Result; - - async fn delete_secret( - &self, - tenant: &TenantId, - user_id: &UserId, - handle: SecretHandle, - ) -> Result; -} - /// Fail-closed default wired into `RebornServices` before composition installs /// the real adapter. Every operation reports the service unavailable, so a /// deployment that never wires the admin surface serves 503s rather than diff --git a/crates/ironclaw_product/src/reborn_services/extensions.rs b/crates/ironclaw_product/src/reborn_services/extensions.rs index 9917f32b794..ab519328c8a 100644 --- a/crates/ironclaw_product/src/reborn_services/extensions.rs +++ b/crates/ironclaw_product/src/reborn_services/extensions.rs @@ -1,3 +1,6 @@ +use ironclaw_product_contracts::lifecycle_service::{ + LifecycleProductContext, LifecycleProductService, LifecycleProductSurfaceContext, +}; use std::{ collections::{HashMap, HashSet}, sync::Arc, @@ -19,9 +22,8 @@ use ironclaw_product_contracts::surface::{ use crate::{ ChannelAuthAccountState, ChannelConnectionService, LifecycleExtensionSummary, - LifecycleInstalledExtensionSummary, LifecycleProductAction, LifecycleProductContext, - LifecycleProductPayload, LifecycleProductResponse, LifecycleProductService, - LifecycleProductSurfaceContext, ProductView, RebornAccountBindingSource, RebornAuthAccount, + LifecycleInstalledExtensionSummary, LifecycleProductAction, LifecycleProductPayload, + LifecycleProductResponse, ProductView, RebornAccountBindingSource, RebornAuthAccount, RebornExtensionInfo, RebornExtensionListResponse, RebornExtensionRegistryEntry, RebornExtensionRegistryResponse, RebornExtensionSurface, RebornVendorAuthAccounts, }; diff --git a/crates/ironclaw_product/src/reborn_services/lifecycle_setup.rs b/crates/ironclaw_product/src/reborn_services/lifecycle_setup.rs index 56e2ea378fd..eb94e5e51b5 100644 --- a/crates/ironclaw_product/src/reborn_services/lifecycle_setup.rs +++ b/crates/ironclaw_product/src/reborn_services/lifecycle_setup.rs @@ -1,3 +1,9 @@ +use ironclaw_product_contracts::channel_config::ChannelConfigProductService; +use ironclaw_product_contracts::lifecycle_service::{ + LifecycleProductContext, LifecycleProductService, LifecycleProductSurfaceContext, +}; +use ironclaw_product_contracts::package_lifecycle::ChannelConfigField as RebornChannelConfigField; +use ironclaw_product_contracts::views::RebornViewDescriptor; use std::collections::BTreeSet; use ironclaw_auth::AuthProductScope; @@ -8,12 +14,10 @@ use ironclaw_product_contracts::surface::{ }; use crate::{ - ChannelConfigProductService, LifecycleExtensionCredentialRequirement, LifecyclePackageKind, - LifecyclePackageRef, LifecycleProductAction, LifecycleProductContext, LifecycleProductResponse, - LifecycleProductService, LifecycleProductSurfaceContext, ProductSetupExtensionRequest, - ProductSurfaceFailure, RebornChannelConfigField, RebornExtensionCredentialSetup, - RebornExtensionSetupField, RebornExtensionSetupSecret, RebornSetupExtensionResponse, - RebornViewDescriptor, lifecycle_product_surface_error, + LifecycleExtensionCredentialRequirement, LifecyclePackageKind, LifecyclePackageRef, + LifecycleProductAction, LifecycleProductResponse, ProductSetupExtensionRequest, + ProductSurfaceFailure, RebornExtensionCredentialSetup, RebornExtensionSetupField, + RebornExtensionSetupSecret, RebornSetupExtensionResponse, lifecycle_product_surface_error, }; use super::{ diff --git a/crates/ironclaw_product/src/reborn_services/llm_config.rs b/crates/ironclaw_product/src/reborn_services/llm_config.rs index 4230668d943..b0cdff1397c 100644 --- a/crates/ironclaw_product/src/reborn_services/llm_config.rs +++ b/crates/ironclaw_product/src/reborn_services/llm_config.rs @@ -14,6 +14,8 @@ //! key can't be serialized back out). Response snapshots never carry a key //! value — only a boolean `api_key_set`. +use ironclaw_product_contracts::views::{RebornViewDescriptor, RebornViewProvider}; + use async_trait::async_trait; use ironclaw_product_contracts::surface::{ ProductSurfaceCaller, ProductSurfaceError, ProductSurfaceErrorCode, ProductSurfaceErrorKind, @@ -22,7 +24,7 @@ use ironclaw_product_contracts::surface::{ use secrecy::SecretString; use serde::{Deserialize, Serialize}; -use super::{ProductCapabilityInvoker, RebornServices, RebornViewDescriptor, RebornViewProvider}; +use super::{ProductCapabilityInvoker, RebornServices}; pub const LLM_CONFIG_VIEW: RebornViewDescriptor = RebornViewDescriptor { id: "llm_config", diff --git a/crates/ironclaw_product/src/reborn_services/log_views.rs b/crates/ironclaw_product/src/reborn_services/log_views.rs index 5bb54de17f8..d9364dfa28a 100644 --- a/crates/ironclaw_product/src/reborn_services/log_views.rs +++ b/crates/ironclaw_product/src/reborn_services/log_views.rs @@ -1,10 +1,12 @@ //! Descriptor-backed caller and operator log projections. +use ironclaw_product_contracts::views::{RebornViewDescriptor, RebornViewProvider}; + use super::{ ProductCapabilityInvoker, RebornLogQueryRequest, RebornLogQueryResponse, RebornOperatorArea, RebornOperatorCommandPlaneResponse, RebornOperatorLogsQuery, RebornOperatorSurfaceStatus, - RebornServices, RebornViewDescriptor, RebornViewProvider, bounded_log_query, - bounded_operator_logs_query, parse_thread_id_field, validate_log_query_modes, + RebornServices, bounded_log_query, bounded_operator_logs_query, parse_thread_id_field, + validate_log_query_modes, }; use ironclaw_product_contracts::surface::{ ProductSurfaceCaller, ProductSurfaceError, ProductSurfaceValidationCode, diff --git a/crates/ironclaw_product/src/reborn_services/operator_command_views.rs b/crates/ironclaw_product/src/reborn_services/operator_command_views.rs index fb89308a23c..c2cae977984 100644 --- a/crates/ironclaw_product/src/reborn_services/operator_command_views.rs +++ b/crates/ironclaw_product/src/reborn_services/operator_command_views.rs @@ -1,10 +1,12 @@ //! Descriptor-backed operator command-plane read projections. +use ironclaw_product_contracts::views::{RebornViewDescriptor, RebornViewProvider}; + use super::{ OperatorSetupHostState, ProductCapabilityInvoker, ProductSurfaceCaller, ProductSurfaceError, RebornOperatorArea, RebornOperatorCommandPlaneResponse, RebornOperatorSetupResponse, - RebornOperatorSurfaceStatus, RebornServices, RebornViewDescriptor, RebornViewProvider, - llm_config, operator_config_surface_not_wired_diagnostic, operator_diagnostics_surface_status, + RebornOperatorSurfaceStatus, RebornServices, llm_config, + operator_config_surface_not_wired_diagnostic, operator_diagnostics_surface_status, operator_doctor_setup_unavailable_diagnostic, operator_doctor_status_diagnostic, operator_doctor_status_response, operator_doctor_status_unavailable_diagnostic, setup_response_from_llm_snapshot, diff --git a/crates/ironclaw_product/src/reborn_services/operator_config_views.rs b/crates/ironclaw_product/src/reborn_services/operator_config_views.rs index 37e8dd532bb..b8bcf7b66fb 100644 --- a/crates/ironclaw_product/src/reborn_services/operator_config_views.rs +++ b/crates/ironclaw_product/src/reborn_services/operator_config_views.rs @@ -1,5 +1,7 @@ //! Descriptor-backed operator configuration read projections. +use ironclaw_product_contracts::views::{RebornViewDescriptor, RebornViewProvider}; + use futures::future::try_join_all; use serde::Deserialize; @@ -7,10 +9,10 @@ use super::{ AUTO_APPROVE_CONFIG_KEY, ProductCapabilityInvoker, ProductSurfaceCaller, ProductSurfaceError, RebornOperatorConfigGetResponse, RebornOperatorConfigListResponse, RebornOperatorConfigValidateRequest, RebornOperatorConfigValidateResponse, RebornServices, - RebornViewDescriptor, RebornViewProvider, TOOL_CONFIG_PREFIX, auto_approve_config_entry, - caller_resource_scope, find_operator_tool, operator_config_not_wired_response, - operator_config_unknown_key_error, operator_config_validation_diagnostics, - operator_tool_permission_context, tool_config_entry, tool_config_entry_with_context, + TOOL_CONFIG_PREFIX, auto_approve_config_entry, caller_resource_scope, find_operator_tool, + operator_config_not_wired_response, operator_config_unknown_key_error, + operator_config_validation_diagnostics, operator_tool_permission_context, tool_config_entry, + tool_config_entry_with_context, }; pub const OPERATOR_CONFIG_LIST_VIEW: RebornViewDescriptor = RebornViewDescriptor { diff --git a/crates/ironclaw_product/src/reborn_services/outbound_delivery_capability_surface.rs b/crates/ironclaw_product/src/reborn_services/outbound_delivery_capability_surface.rs index ccd36a084a4..7a8a3da6797 100644 --- a/crates/ironclaw_product/src/reborn_services/outbound_delivery_capability_surface.rs +++ b/crates/ironclaw_product/src/reborn_services/outbound_delivery_capability_surface.rs @@ -1,10 +1,10 @@ +use ironclaw_product_contracts::operator_tools::RebornOperatorToolInfo; use std::sync::Arc; use super::{ OutboundPreferencesProductService, ProductSurfaceCaller, ProductSurfaceError, - RebornOperatorToolInfo, RebornOutboundDeliveryTargetId, - RebornOutboundDeliveryTargetListResponse, RebornOutboundPreferencesResponse, - RebornSetOutboundPreferencesRequest, + RebornOutboundDeliveryTargetId, RebornOutboundDeliveryTargetListResponse, + RebornOutboundPreferencesResponse, RebornSetOutboundPreferencesRequest, }; use ironclaw_host_api::{ capability::{EffectKind, PermissionMode}, diff --git a/crates/ironclaw_product/src/reborn_services/outbound_views.rs b/crates/ironclaw_product/src/reborn_services/outbound_views.rs index 0e597dead39..6a64f6db6ae 100644 --- a/crates/ironclaw_product/src/reborn_services/outbound_views.rs +++ b/crates/ironclaw_product/src/reborn_services/outbound_views.rs @@ -1,9 +1,10 @@ //! Descriptor-backed outbound delivery read projections. +use ironclaw_product_contracts::views::RebornViewProvider; + use super::{ ProductCapabilityInvoker, ProductSurfaceCaller, ProductSurfaceError, ProductView, RebornOutboundDeliveryTargetListResponse, RebornOutboundPreferencesResponse, RebornServices, - RebornViewProvider, }; pub const OUTBOUND_PREFERENCES_VIEW: ProductView< diff --git a/crates/ironclaw_product/src/reborn_services/run_artifact.rs b/crates/ironclaw_product/src/reborn_services/run_artifact.rs index 11f3cdb0447..e6ddac554f0 100644 --- a/crates/ironclaw_product/src/reborn_services/run_artifact.rs +++ b/crates/ironclaw_product/src/reborn_services/run_artifact.rs @@ -5,6 +5,8 @@ //! selection, replay metadata reconstruction, and redaction rules live here so //! other callers cannot grow parallel definitions of a trajectory. +use ironclaw_product_contracts::views::{RebornViewDescriptor, RebornViewProvider}; + use std::collections::HashMap; use chrono::{DateTime, Utc}; @@ -21,8 +23,7 @@ use serde::{Deserialize, Serialize}; use super::{ OPERATOR_LOGS_MAX_LIMIT, ProductCapabilityInvoker, RebornGetRunStateRequest, RebornGetRunStateResponse, RebornLogEntry, RebornLogQueryRequest, RebornServices, - RebornViewDescriptor, RebornViewProvider, bounded_log_query, map_thread_error, - parse_run_id_field, parse_thread_id_field, + bounded_log_query, map_thread_error, parse_run_id_field, parse_thread_id_field, }; use ironclaw_product_contracts::surface::{ ProductSurfaceCaller, ProductSurfaceError, ProductSurfaceErrorCode, diff --git a/crates/ironclaw_product/src/reborn_services/thread_artifact.rs b/crates/ironclaw_product/src/reborn_services/thread_artifact.rs index 40c893a56fb..ba70e6cefaa 100644 --- a/crates/ironclaw_product/src/reborn_services/thread_artifact.rs +++ b/crates/ironclaw_product/src/reborn_services/thread_artifact.rs @@ -9,10 +9,11 @@ use ironclaw_reborn_traces::contribution::DeterministicTraceRedactor; use ironclaw_threads::{BoundedThreadMessages, BoundedThreadMessagesRequest}; use serde::{Deserialize, Serialize}; +use ironclaw_product_contracts::views::{RebornViewDescriptor, RebornViewProvider}; + use super::{ - ProductCapabilityInvoker, RebornServices, RebornViewDescriptor, RebornViewProvider, - RunArtifactLogs, RunArtifactMessage, RunArtifactRedaction, map_timeline_probe_error, - parse_thread_id_field, + ProductCapabilityInvoker, RebornServices, RunArtifactLogs, RunArtifactMessage, + RunArtifactRedaction, map_timeline_probe_error, parse_thread_id_field, run_artifact::{ARTIFACT_REDACTION_PIPELINE, artifact_messages, context_messages_by_id}, thread_scope_from_turn_scope, }; diff --git a/crates/ironclaw_product/src/reborn_services/trace_credits.rs b/crates/ironclaw_product/src/reborn_services/trace_credits.rs index 18804b4e1c8..acce7c9571e 100644 --- a/crates/ironclaw_product/src/reborn_services/trace_credits.rs +++ b/crates/ironclaw_product/src/reborn_services/trace_credits.rs @@ -7,6 +7,8 @@ //! always derived from the authenticated caller's tenant + user id (see //! [`ironclaw_reborn_traces::contribution::trace_scope_key`]). +use ironclaw_product_contracts::views::RebornViewProvider; + use chrono::{DateTime, Utc}; use ironclaw_host_api::ids::{TenantId, UserId}; use ironclaw_product_contracts::surface::{ProductSurfaceCaller, ProductSurfaceError}; @@ -17,7 +19,7 @@ use ironclaw_reborn_traces::contribution::{ }; use serde::{Deserialize, Serialize}; -use super::{ProductCapabilityInvoker, ProductView, RebornServices, RebornViewProvider}; +use super::{ProductCapabilityInvoker, ProductView, RebornServices}; pub const TRACE_CREDITS_VIEW: ProductView = ProductView::unpaginated("trace_credits"); diff --git a/crates/ironclaw_product/src/reborn_services/views.rs b/crates/ironclaw_product/src/reborn_services/views.rs index 9c6cf3228a6..85d2f9b2d0e 100644 --- a/crates/ironclaw_product/src/reborn_services/views.rs +++ b/crates/ironclaw_product/src/reborn_services/views.rs @@ -1,8 +1,12 @@ //! Generic read conduit for descriptor-declared product views. use async_trait::async_trait; +use ironclaw_product_contracts::views::{ + RebornViewDescriptor, RebornViewPage, RebornViewProvider, RebornViewQuery, +}; +use serde::Deserialize; +use serde::Serialize; use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; use std::marker::PhantomData; use super::{ProductSurfaceCaller, ProductSurfaceError}; @@ -11,13 +15,6 @@ use super::{ProductSurfaceCaller, ProductSurfaceError}; #[serde(deny_unknown_fields)] struct EmptyViewParams {} -/// Stable metadata for one read-only product view. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RebornViewDescriptor { - pub id: &'static str, - pub paginated: bool, -} - /// Typed declaration for one ProductSurface read view. /// /// The wire conduit remains [`RebornViewQuery`] / [`RebornViewPage`]. This @@ -124,23 +121,6 @@ where } } -/// One registered, read-only product view invocation. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RebornViewQuery { - pub view_id: String, - pub params: serde_json::Value, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cursor: Option, -} - -/// One page returned by the generic product view conduit. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RebornViewPage { - pub payload: serde_json::Value, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub next_cursor: Option, -} - pub(super) fn parse_empty_view_params( params: serde_json::Value, ) -> Result<(), ProductSurfaceError> { @@ -182,22 +162,6 @@ pub(super) fn view_page_with_cursor( }) } -/// One composition-supplied implementation behind the generic view conduit. -/// -/// Product features register descriptors and providers instead of growing -/// `ProductSurface` with feature-specific read methods. -#[async_trait] -pub trait RebornViewProvider: Send + Sync { - fn descriptor(&self) -> RebornViewDescriptor; - - async fn query( - &self, - caller: ProductSurfaceCaller, - params: serde_json::Value, - cursor: Option, - ) -> Result; -} - /// Fail-closed static default for compositions without an additional view. #[derive(Debug, Clone, Copy, Default)] pub struct UnavailableRebornViewProvider; diff --git a/crates/ironclaw_product/src/run_delivery.rs b/crates/ironclaw_product/src/run_delivery.rs index 97605b51616..2f6ce94a0aa 100644 --- a/crates/ironclaw_product/src/run_delivery.rs +++ b/crates/ironclaw_product/src/run_delivery.rs @@ -27,20 +27,18 @@ use std::num::NonZeroUsize; use std::sync::{Arc, Mutex}; use std::time::Duration; -use crate::{ - ApprovalPromptContextView, AuthPromptView, ExternalConversationRef, ExternalEventId, - OutboundPart, ProductAdapterError, -}; -use async_trait::async_trait; -use ironclaw_host_api::ids::UserId; -use ironclaw_host_api::turn::{TurnGateRef, TurnRunId, TurnScope, TurnStatus}; +use crate::{ExternalConversationRef, ExternalEventId, OutboundPart, ProductAdapterError}; +use ironclaw_host_api::turn::{TurnRunId, TurnScope, TurnStatus}; use ironclaw_outbound::{ CommunicationPreferenceRepository, DeliveredGateRouteStore, OutboundError, OutboundStateStorePort, }; use ironclaw_turns::{GetRunStateRequest, TurnCoordinator, TurnRunState}; -use crate::auth_prompt::{BlockedAuthFlowCanceller, BlockedAuthPromptRequest}; +use crate::auth_prompt::BlockedAuthFlowCanceller; +use ironclaw_product_contracts::prompt_source::{ + ApprovalPromptContextSource, BlockedAuthPromptSource, +}; use crate::ProjectFilesystemReader; use crate::delivery_coordinator::{ @@ -105,31 +103,6 @@ pub fn triggered_run_delivery_settings() -> RunDeliverySettings { RunDeliverySettings::default() } -/// Approval-gate context enrichment: resolves WHAT is being approved -/// (tool/action/reason) for a gate ref — the same source the WebUI gate -/// projection reads. Implemented by the composition over its approval -/// request store; `None` results degrade prompts to generic wording. -#[async_trait] -pub trait ApprovalPromptContextSource: Send + Sync { - async fn approval_prompt_context( - &self, - gate_ref: &TurnGateRef, - owner_user_id: &UserId, - scope: &TurnScope, - ) -> Option; -} - -/// Auth-prompt enrichment: resolves the challenge (OAuth authorization URL -/// vs manual credential entry) for a run blocked on auth. Implemented by the -/// composition over the auth engine. -#[async_trait] -pub trait BlockedAuthPromptSource: Send + Sync { - async fn auth_prompt_for_blocked_run( - &self, - request: BlockedAuthPromptRequest<'_>, - ) -> Result; -} - /// Everything the generic run-delivery components need. All handles are /// `Arc`s; cloning shares them. #[derive(Clone)] diff --git a/crates/ironclaw_product/src/run_delivery/observer.rs b/crates/ironclaw_product/src/run_delivery/observer.rs index 669342b9c66..9653a1665ad 100644 --- a/crates/ironclaw_product/src/run_delivery/observer.rs +++ b/crates/ironclaw_product/src/run_delivery/observer.rs @@ -2,6 +2,9 @@ //! channel message submitted and deliver its outputs back to the //! originating conversation, entirely through the [`DeliveryCoordinator`]. +use ironclaw_product_contracts::account_setup::ChannelConnectionNoticePolicy; +use ironclaw_product_contracts::prompt_source::BlockedAuthPromptRequest; + use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use tokio::time::Instant; @@ -37,18 +40,15 @@ use tokio::sync::Semaphore; use super::prompts; use super::{ - BlockedActionableMarker, BlockedAuthPromptRequest, DeliveredChannelMessage, HINT_SEEN_CAP, - HintSeenSet, RunDeliveryError, RunDeliveryServices, RunDeliverySettings, - blocked_actionable_marker, cancel_auth_blocked_run, delivered_messages_from_outcome, - gate_routes::record_gate_route_if_needed, thread_scope_from_binding, - turn_scope_from_thread_scope, + BlockedActionableMarker, DeliveredChannelMessage, HINT_SEEN_CAP, HintSeenSet, RunDeliveryError, + RunDeliveryServices, RunDeliverySettings, blocked_actionable_marker, cancel_auth_blocked_run, + delivered_messages_from_outcome, gate_routes::record_gate_route_if_needed, + thread_scope_from_binding, turn_scope_from_thread_scope, }; use crate::delivery_coordinator::{ CoordinatedDeliveryOutcome, CoordinatedDeliveryRequest, DeliveryIntent, }; -use crate::{ - ChannelConnectionNoticePolicy, ProductSurfaceFailure, ResolveBindingRequest, ResolvedBinding, -}; +use crate::{ProductSurfaceFailure, ResolveBindingRequest, ResolvedBinding}; const CONNECT_NOTICE_THROTTLE_WINDOW: std::time::Duration = std::time::Duration::from_secs(30); diff --git a/crates/ironclaw_product/src/run_delivery/triggered.rs b/crates/ironclaw_product/src/run_delivery/triggered.rs index a4aa33fe508..a251205bfcc 100644 --- a/crates/ironclaw_product/src/run_delivery/triggered.rs +++ b/crates/ironclaw_product/src/run_delivery/triggered.rs @@ -2,6 +2,8 @@ //! deliver its outputs to the creator's personal preference target, through //! the [`DeliveryCoordinator`]. +use ironclaw_product_contracts::prompt_source::BlockedAuthPromptRequest; + use std::sync::Arc; use crate::OutboundPart; @@ -23,8 +25,8 @@ use tokio::sync::Semaphore; use super::observer::AllowNoProjectionAccess; use super::prompts; use super::{ - BlockedActionableMarker, BlockedAuthPromptRequest, DeliveredChannelMessage, RunDeliveryError, - RunDeliveryServices, RunDeliverySettings, blocked_actionable_marker, cancel_auth_blocked_run, + BlockedActionableMarker, DeliveredChannelMessage, RunDeliveryError, RunDeliveryServices, + RunDeliverySettings, blocked_actionable_marker, cancel_auth_blocked_run, delivered_messages_from_outcome, gate_routes::record_gate_route_if_needed, triggered_run_delivery_settings, wait_for_actionable_state, }; diff --git a/crates/ironclaw_product/src/workflow.rs b/crates/ironclaw_product/src/workflow.rs index a4731f8e7c5..51eb4bc9dc0 100644 --- a/crates/ironclaw_product/src/workflow.rs +++ b/crates/ironclaw_product/src/workflow.rs @@ -6,6 +6,9 @@ use std::sync::Arc; +use ironclaw_product_contracts::action::{ActionFingerprintKey, ProductActionId, SourceBindingKey}; +use ironclaw_product_contracts::command::ProductCommandContext; + use crate::{ ApprovalDecision, ExternalConversationRef, ParsedProductInbound, ProductAdapterError, ProductCommandResultPayload, ProductInboundAck, ProductInboundEnvelope, ProductInboundPayload, @@ -33,7 +36,7 @@ use ironclaw_turns::{AdmissionRejectionReason, TurnError, TurnErrorCategory}; use sha2::{Digest, Sha256}; use tracing::debug; -use crate::action::{ActionDispatchKind, ActionFingerprintKey, SourceBindingKey}; +use crate::action::ActionDispatchKind; use crate::approval_interaction::{ ApprovalInteractionDecision, ApprovalInteractionRejectionKind, ApprovalInteractionService, ListPendingApprovalsRequest, RejectingApprovalInteractionService, @@ -52,7 +55,7 @@ use crate::binding_ref::{ DEFAULT_BINDING_REF_RAW_MAX_BYTES, binding_ref_segment, bounded_idempotency_key, }; use crate::command_dispatch::{ - ProductCommandAdmission, ProductCommandAdmissionService, ProductCommandContext, + ProductCommandAdmission, ProductCommandAdmissionService, RejectingProductCommandAdmissionService, }; use crate::commands::{ @@ -1139,7 +1142,7 @@ fn validate_projection_thread_hint( async fn dispatch_payload( envelope: &ProductInboundEnvelope, - action_id: crate::ProductActionId, + action_id: ProductActionId, action_fingerprint: ActionFingerprintKey, ports: DispatchPorts<'_>, attachment_admission: InboundAttachmentAdmission, @@ -1721,7 +1724,7 @@ fn product_surface_failure(error: ProductSurfaceError) -> ProductSurfaceFailure async fn dispatch_product_command( envelope: &ProductInboundEnvelope, - action_id: crate::ProductActionId, + action_id: ProductActionId, binding_service: &dyn ConversationBindingService, command_surface: Option<&dyn ProductSurface>, command: ProductCommand, diff --git a/crates/ironclaw_product/tests/durable_ledger_support/mod.rs b/crates/ironclaw_product/tests/durable_ledger_support/mod.rs index 882e99b060f..043707dc592 100644 --- a/crates/ironclaw_product/tests/durable_ledger_support/mod.rs +++ b/crates/ironclaw_product/tests/durable_ledger_support/mod.rs @@ -3,12 +3,12 @@ use chrono::{Duration, Utc}; use ironclaw_host_api::path::VirtualPath; use ironclaw_product::{ - ActionFingerprintKey, IdempotencyDecision, IdempotencyLedger, ProductInboundAction, - ProductSurfaceFailure, SourceBindingKey, + AdapterInstallationId, ExternalActorRef, ExternalEventId, ProductAdapterId, ProductInboundAck, }; use ironclaw_product::{ - AdapterInstallationId, ExternalActorRef, ExternalEventId, ProductAdapterId, ProductInboundAck, + IdempotencyDecision, IdempotencyLedger, ProductInboundAction, ProductSurfaceFailure, }; +use ironclaw_product_contracts::action::{ActionFingerprintKey, SourceBindingKey}; pub fn fingerprint(suffix: &str) -> ActionFingerprintKey { fingerprint_for_actor(suffix, "user1") diff --git a/crates/ironclaw_product/tests/durable_scoped_ledger_contract.rs b/crates/ironclaw_product/tests/durable_scoped_ledger_contract.rs index 088c04ab99d..398aaddc27e 100644 --- a/crates/ironclaw_product/tests/durable_scoped_ledger_contract.rs +++ b/crates/ironclaw_product/tests/durable_scoped_ledger_contract.rs @@ -13,9 +13,8 @@ use ironclaw_host_api::{ }; use ironclaw_product::ProductInboundAck; use ironclaw_product::RebornFilesystemIdempotencyLedger; -use ironclaw_product::{ - ActionFingerprintKey, IdempotencyDecision, IdempotencyLedger, ProductSurfaceFailure, -}; +use ironclaw_product::{IdempotencyDecision, IdempotencyLedger, ProductSurfaceFailure}; +use ironclaw_product_contracts::action::ActionFingerprintKey; #[path = "durable_ledger_support/mod.rs"] mod support; diff --git a/crates/ironclaw_product/tests/extension_account_setup_contract.rs b/crates/ironclaw_product/tests/extension_account_setup_contract.rs index 13971e88b39..198b2d6d85a 100644 --- a/crates/ironclaw_product/tests/extension_account_setup_contract.rs +++ b/crates/ironclaw_product/tests/extension_account_setup_contract.rs @@ -8,9 +8,11 @@ use ironclaw_host_api::{ ids::{ExtensionId, UserId, VendorId}, }; use ironclaw_product::{ + ChannelConnectionRequirement, ExtensionAccountSetupRegistry, RebornChannelConnectStrategy, +}; +use ironclaw_product_contracts::account_setup::{ AccountConnectionStatusError, AccountConnectionStatusSource, ChannelConnectionNoticePolicy, - ChannelConnectionRequirement, ExtensionAccountSetupDescriptor, ExtensionAccountSetupError, - ExtensionAccountSetupRegistry, RebornChannelConnectStrategy, + ExtensionAccountSetupDescriptor, ExtensionAccountSetupError, }; fn extension_id(value: &str) -> ExtensionId { diff --git a/crates/ironclaw_product/tests/outbound_delivery_contract.rs b/crates/ironclaw_product/tests/outbound_delivery_contract.rs index c52dfce43da..74dd47fc1da 100644 --- a/crates/ironclaw_product/tests/outbound_delivery_contract.rs +++ b/crates/ironclaw_product/tests/outbound_delivery_contract.rs @@ -252,15 +252,17 @@ use std::collections::VecDeque; use std::sync::Arc; use ironclaw_extension_contracts::channel_adapter::ChannelAdapter; -use ironclaw_product::{ - ChannelDeliveryResolver, CoordinatedDeliveryError, CoordinatedDeliveryOutcome, - CoordinatedDeliveryRequest, DeliveryCoordinator, DeliveryIntent, DeliveryReplyContextSource, - DeliveryRetryPolicy, NoticeDeliveryRequest, ResolvedChannelDelivery, -}; use ironclaw_product::{ ChannelError, DeliveryReport, InboundOutcome, OutboundEnvelope, PartDeliveryOutcome, VerifiedInbound, }; +use ironclaw_product::{ + CoordinatedDeliveryError, CoordinatedDeliveryOutcome, CoordinatedDeliveryRequest, + DeliveryCoordinator, DeliveryIntent, DeliveryRetryPolicy, NoticeDeliveryRequest, +}; +use ironclaw_product_contracts::delivery::{ + ChannelDeliveryResolver, DeliveryReplyContextSource, ResolvedChannelDelivery, +}; struct CoordinatorDenyAllEgress; diff --git a/crates/ironclaw_product/tests/product_command_surface_contract.rs b/crates/ironclaw_product/tests/product_command_surface_contract.rs index 4b7c98b240d..08114ebe9e4 100644 --- a/crates/ironclaw_product/tests/product_command_surface_contract.rs +++ b/crates/ironclaw_product/tests/product_command_surface_contract.rs @@ -6,12 +6,11 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use chrono::Utc; use ironclaw_product::{ - ActionDispatchKind, AdminUserRole, CommandActorRoleResolver, DefaultProductSurface, - DirectConversationCommandAdmission, FakeConversationBindingService, FakeIdempotencyLedger, - FakeInboundTurnService, PRODUCT_LIFECYCLE_COMMAND_OPERATION_ID, - PRODUCT_MODEL_COMMAND_OPERATION_ID, PRODUCT_STATUS_COMMAND_OPERATION_ID, ProductCommand, - ProductCommandAdmission, ProductCommandAdmissionService, ProductCommandContext, - ProductInboundAck, ProductRejectionKind, ProductSurfaceFailure, + ActionDispatchKind, DefaultProductSurface, DirectConversationCommandAdmission, + FakeConversationBindingService, FakeIdempotencyLedger, FakeInboundTurnService, + PRODUCT_LIFECYCLE_COMMAND_OPERATION_ID, PRODUCT_MODEL_COMMAND_OPERATION_ID, + PRODUCT_STATUS_COMMAND_OPERATION_ID, ProductCommand, ProductCommandAdmission, + ProductCommandAdmissionService, ProductInboundAck, ProductRejectionKind, ProductSurfaceFailure, }; use ironclaw_product::{ AdapterInstallationId, AuthRequirement, ConversationBindingService, ExternalActorRef, @@ -19,6 +18,8 @@ use ironclaw_product::{ ProductInboundEnvelope, ProductInboundPayload, ProductTriggerReason, ProtocolAuthEvidence, ResolveBindingRequest, ResolvedBinding, TrustedInboundContext, }; +use ironclaw_product_contracts::admin_users::AdminUserRole; +use ironclaw_product_contracts::command::{CommandActorRoleResolver, ProductCommandContext}; use ironclaw_product_contracts::surface::{ ProductSurface, ProductSurfaceCaller, ProductSurfaceError, ProductSurfaceErrorCode, ProductSurfaceInvokeRequest, ProductSurfaceInvokeResponse, diff --git a/crates/ironclaw_product/tests/product_surface_contract.rs b/crates/ironclaw_product/tests/product_surface_contract.rs index 3c7c3747afe..a55959c8a7f 100644 --- a/crates/ironclaw_product/tests/product_surface_contract.rs +++ b/crates/ironclaw_product/tests/product_surface_contract.rs @@ -26,25 +26,23 @@ use ironclaw_host_api::{ resource::ResourceScope, }; use ironclaw_product::{ - ActionDispatchKind, ActionFingerprintKey, ApprovalInteractionDecision, - ApprovalInteractionScope, ApprovalInteractionService, AuthInteractionDecision, - AuthInteractionScope, AuthInteractionService, AuthInteractionStatus, AuthRequestRef, - BeforeInboundPolicy, BeforeInboundPolicyOutcome, BeforeInboundPolicyRequest, - ConversationBindingService, DefaultInboundTurnService, DefaultProductSurface, - FakeBeforeInboundPolicy, FakeConversationBindingService, FakeIdempotencyLedger, - FakeInboundTurnService, IdempotencyDecision, IdempotencyLedger, InMemoryIdempotencyLedger, - InboundTurnOutcome, InboundTurnService, InboundUserMessageDispatch, LinkedThreadActionId, + ActionDispatchKind, ApprovalInteractionDecision, ApprovalInteractionScope, + ApprovalInteractionService, AuthInteractionDecision, AuthInteractionScope, + AuthInteractionService, AuthInteractionStatus, BeforeInboundPolicy, BeforeInboundPolicyOutcome, + BeforeInboundPolicyRequest, ConversationBindingService, DefaultInboundTurnService, + DefaultProductSurface, FakeBeforeInboundPolicy, FakeConversationBindingService, + FakeIdempotencyLedger, FakeInboundTurnService, IdempotencyDecision, IdempotencyLedger, + InMemoryIdempotencyLedger, InboundTurnOutcome, InboundTurnService, InboundUserMessageDispatch, ListPendingApprovalsRequest, ListPendingApprovalsResponse, ListPendingAuthInteractionsRequest, ListPendingAuthInteractionsResponse, PendingApprovalInteractionView, PendingAuthInteractionView, ProductActorUserResolutionRequest, ProductActorUserResolver, - ProductCommandName, ProductConversationBindingService, ProductConversationRouteKey, + ProductConversationBindingService, ProductConversationRouteKey, ProductConversationSubjectRouteResolutionRequest, ProductConversationSubjectRouteResolver, ProductInstallationKey, ProductInstallationScope, ProductSurfaceFailure, RebornFilesystemIdempotencyLedger, ResolveApprovalInteractionRequest, ResolveApprovalInteractionResponse, ResolveAuthInteractionRequest, ResolveAuthInteractionResponse, ResolveBindingRequest, ResolvedBinding, - ResolvedProductActorUser, SourceBindingKey, StaticProductInstallationResolver, - approval_gate_ref, + ResolvedProductActorUser, StaticProductInstallationResolver, approval_gate_ref, }; use ironclaw_product::{ AdapterInstallationId, ApprovalDecision, ApprovalResolutionPayload, AuthRequirement, @@ -58,6 +56,10 @@ use ironclaw_product::{ ProtocolAuthEvidence, ScopedApprovalResolutionPayload, TrustedInboundContext, UserMessagePayload, }; +use ironclaw_product_contracts::action::{ + ActionFingerprintKey, AuthRequestRef, LinkedThreadActionId, ProductCommandName, + SourceBindingKey, +}; use ironclaw_threads::InMemorySessionThreadService; use ironclaw_turns::{ CancelRunRequest, CancelRunResponse, GetRunStateRequest, ResumeTurnRequest, ResumeTurnResponse, diff --git a/crates/ironclaw_product/tests/prompt_projection_contract.rs b/crates/ironclaw_product/tests/prompt_projection_contract.rs index d09948e6b47..8d9f0f469eb 100644 --- a/crates/ironclaw_product/tests/prompt_projection_contract.rs +++ b/crates/ironclaw_product/tests/prompt_projection_contract.rs @@ -10,9 +10,10 @@ use ironclaw_host_api::{ }; use ironclaw_product::AuthPromptChallengeKind; use ironclaw_product::{ - AuthChallengeProvider, AuthChallengeView, BlockedAuthPromptRequest, approval_prompt_lookup, + AuthChallengeProvider, AuthChallengeView, approval_prompt_lookup, auth_prompt_view_for_blocked_auth, }; +use ironclaw_product_contracts::prompt_source::BlockedAuthPromptRequest; #[derive(Debug)] struct OAuthChallenge { diff --git a/crates/ironclaw_product/tests/reborn_services_contract.rs b/crates/ironclaw_product/tests/reborn_services_contract.rs index e8452cc2171..c59b95695ae 100644 --- a/crates/ironclaw_product/tests/reborn_services_contract.rs +++ b/crates/ironclaw_product/tests/reborn_services_contract.rs @@ -60,8 +60,8 @@ use ironclaw_product::{ ActiveModelReader, ApprovalInteractionActionView, ApprovalInteractionDecision, ApprovalInteractionScope, ApprovalInteractionService, AuthInteractionDecision, AuthInteractionService, AutomationListRequest, AutomationProductService, - ChannelAuthAccountState, ChannelConfigProductService, ChannelConnectionRequirement, - ChannelConnectionService, CodexLoginStart, CommandResultView, EXTENSION_IMPORT_CAPABILITY_ID, + ChannelAuthAccountState, ChannelConnectionRequirement, ChannelConnectionService, + CodexLoginStart, CommandResultView, EXTENSION_IMPORT_CAPABILITY_ID, EXTENSION_SETUP_SUBMIT_CAPABILITY_ID, EXTENSION_SETUP_VIEW, EXTENSIONS_VIEW, EmptyProductCommandInput, ExtensionCredentialSetupService, ExtensionCredentialStatusRequest, ExtensionCredentialSubmitRequest, FS_LIST_VIEW, FS_MOUNTS_VIEW, FS_STAT_VIEW, @@ -71,14 +71,14 @@ use ironclaw_product::{ LifecycleChannelDirections, LifecycleExtensionCredentialRequirement, LifecycleExtensionCredentialSetup, LifecycleExtensionOnboarding, LifecycleExtensionRuntimeKind, LifecycleExtensionSource, LifecycleExtensionSummary, LifecycleInstalledExtensionSummary, - LifecyclePackageKind, LifecyclePackageRef, LifecycleProductAction, LifecycleProductContext, - LifecycleProductPayload, LifecycleProductResponse, LifecycleProductService, - LifecycleReadinessBlocker, ListPendingApprovalsRequest, ListPendingApprovalsResponse, - ListPendingAuthInteractionsRequest, ListPendingAuthInteractionsResponse, LlmActiveSelection, - LlmConfigService, LlmConfigServiceError, LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest, - LlmProbeResult, LlmProviderView, NearAiLoginRequest, NearAiLoginStart, - NearAiWalletLoginRequest, NearAiWalletLoginResult, OPERATOR_CONFIG_KEY_VIEW, - OPERATOR_CONFIG_LIST_VIEW, OPERATOR_CONFIG_SET_AUTO_APPROVE_CAPABILITY_ID, + LifecyclePackageKind, LifecyclePackageRef, LifecycleProductAction, LifecycleProductPayload, + LifecycleProductResponse, LifecycleReadinessBlocker, ListPendingApprovalsRequest, + ListPendingApprovalsResponse, ListPendingAuthInteractionsRequest, + ListPendingAuthInteractionsResponse, LlmActiveSelection, LlmConfigService, + LlmConfigServiceError, LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest, LlmProbeResult, + LlmProviderView, NearAiLoginRequest, NearAiLoginStart, NearAiWalletLoginRequest, + NearAiWalletLoginResult, OPERATOR_CONFIG_KEY_VIEW, OPERATOR_CONFIG_LIST_VIEW, + OPERATOR_CONFIG_SET_AUTO_APPROVE_CAPABILITY_ID, OPERATOR_CONFIG_SET_TOOL_PERMISSION_CAPABILITY_ID, OPERATOR_CONFIG_VALIDATE_VIEW, OPERATOR_DIAGNOSTICS_VIEW, OPERATOR_LOGS_VIEW, OPERATOR_SETUP_RUN_CAPABILITY_ID, OPERATOR_SETUP_VIEW, OPERATOR_STATUS_VIEW, OUTBOUND_DELIVERY_TARGETS_VIEW, @@ -100,21 +100,20 @@ use ironclaw_product::{ RebornAttachmentRequest, RebornAutomationInfo, RebornAutomationMutationResponse, RebornAutomationRecentRunInfo, RebornAutomationRecentRunStatus, RebornAutomationRequest, RebornAutomationRunStatus, RebornAutomationSource, RebornAutomationState, - RebornChannelConfigField, RebornChannelConnectAction, RebornChannelConnectStrategy, - RebornCreateProjectRequest, RebornDeleteProjectRequest, RebornDeleteThreadRequest, - RebornExecuteProductCommandRequest, RebornExecuteProductCommandResponse, - RebornExtensionListResponse, RebornExtensionSurface, RebornFsListRequest, RebornFsListResponse, - RebornFsMountsRequest, RebornFsMountsResponse, RebornFsStatRequest, RebornFsStatResponse, - RebornGetProjectRequest, RebornGetRunStateRequest, RebornGlobalAutoApproveRequest, - RebornGlobalAutoApproveResponse, RebornListAutomationsResponse, RebornListMembersRequest, - RebornListMembersResponse, RebornListProjectsRequest, RebornListProjectsResponse, - RebornListThreadsResponse, RebornLogLevel, RebornLogQueryRequest, RebornLogQueryResponse, - RebornOperatorCommandPlaneResponse, RebornOperatorConfigDiagnosticSeverity, - RebornOperatorConfigGetResponse, RebornOperatorConfigListResponse, - RebornOperatorConfigSetRequest, RebornOperatorConfigValidateResponse, RebornOperatorLogsQuery, - RebornOperatorSetupRequest, RebornOperatorSetupStatus, RebornOperatorStatusCheck, - RebornOperatorStatusResponse, RebornOperatorStatusSeverity, RebornOperatorStatusState, - RebornOperatorSurfaceStatus, RebornOperatorToolCatalog, RebornOperatorToolInfo, + RebornChannelConnectAction, RebornChannelConnectStrategy, RebornCreateProjectRequest, + RebornDeleteProjectRequest, RebornDeleteThreadRequest, RebornExecuteProductCommandRequest, + RebornExecuteProductCommandResponse, RebornExtensionListResponse, RebornExtensionSurface, + RebornFsListRequest, RebornFsListResponse, RebornFsMountsRequest, RebornFsMountsResponse, + RebornFsStatRequest, RebornFsStatResponse, RebornGetProjectRequest, RebornGetRunStateRequest, + RebornGlobalAutoApproveRequest, RebornGlobalAutoApproveResponse, RebornListAutomationsResponse, + RebornListMembersRequest, RebornListMembersResponse, RebornListProjectsRequest, + RebornListProjectsResponse, RebornListThreadsResponse, RebornLogLevel, RebornLogQueryRequest, + RebornLogQueryResponse, RebornOperatorCommandPlaneResponse, + RebornOperatorConfigDiagnosticSeverity, RebornOperatorConfigGetResponse, + RebornOperatorConfigListResponse, RebornOperatorConfigSetRequest, + RebornOperatorConfigValidateResponse, RebornOperatorLogsQuery, RebornOperatorSetupRequest, + RebornOperatorSetupStatus, RebornOperatorStatusCheck, RebornOperatorStatusResponse, + RebornOperatorStatusSeverity, RebornOperatorStatusState, RebornOperatorSurfaceStatus, RebornOutboundDeliveryModality, RebornOutboundDeliveryTargetCapabilities, RebornOutboundDeliveryTargetDescription, RebornOutboundDeliveryTargetId, RebornOutboundDeliveryTargetListResponse, RebornOutboundDeliveryTargetOption, @@ -133,14 +132,13 @@ use ironclaw_product::{ RebornThreadArtifactRequest, RebornTimelineRequest, RebornTimelineResponse, RebornTraceCreditsResponse, RebornTraceHoldAuthorizeProductRequest, RebornTraceHoldAuthorizeResponse, RebornUpdateMemberRoleRequest, RebornUpdateProjectRequest, - RebornViewPage, RebornViewQuery, ResolveApprovalInteractionRequest, - ResolveApprovalInteractionResponse, ResolveAuthInteractionRequest, - ResolveAuthInteractionResponse, SKILL_CONTENT_VIEW, SKILL_SEARCH_VIEW, SKILLS_VIEW, - SetActiveLlmRequest, SkillsProductService, StaticOperatorStatusService, - THREAD_ARTIFACT_MAX_MESSAGES, THREAD_ARTIFACT_VIEW, THREAD_DELETE_CAPABILITY_ID, THREADS_VIEW, - TIMELINE_VIEW, TRACE_ACCOUNT_TRACES_VIEW, TRACE_CREDITS_VIEW, TRACE_HOLD_AUTHORIZE_COMMAND, - TriggerRunThreadScope, UpsertLlmProviderRequest, approval_gate_ref, - automation_trigger_thread_metadata_json, + ResolveApprovalInteractionRequest, ResolveApprovalInteractionResponse, + ResolveAuthInteractionRequest, ResolveAuthInteractionResponse, SKILL_CONTENT_VIEW, + SKILL_SEARCH_VIEW, SKILLS_VIEW, SetActiveLlmRequest, SkillsProductService, + StaticOperatorStatusService, THREAD_ARTIFACT_MAX_MESSAGES, THREAD_ARTIFACT_VIEW, + THREAD_DELETE_CAPABILITY_ID, THREADS_VIEW, TIMELINE_VIEW, TRACE_ACCOUNT_TRACES_VIEW, + TRACE_CREDITS_VIEW, TRACE_HOLD_AUTHORIZE_COMMAND, TriggerRunThreadScope, + UpsertLlmProviderRequest, approval_gate_ref, automation_trigger_thread_metadata_json, }; use ironclaw_product::{ AdapterInstallationId, ExternalConversationRef, ProductAdapterError, ProductAdapterId, @@ -149,21 +147,33 @@ use ironclaw_product::{ ProjectionSubscriptionRequest, ProtocolAuthFailure, RedactedString, }; use ironclaw_product::{ - AdminCreateUserFields, AdminCreatedUser, AdminUserError, AdminUserRecord, AdminUserRole, - AdminUserSecretMeta, AdminUserService, AdminUserStatus, RebornAdminCreateUserRequest, - RebornAdminDeleteSecretProductRequest, RebornAdminPutSecretProductRequest, - RebornAdminPutSecretRequest, RebornAdminSetRoleProductRequest, RebornAdminSetRoleRequest, + RebornAdminCreateUserRequest, RebornAdminDeleteSecretProductRequest, + RebornAdminPutSecretProductRequest, RebornAdminPutSecretRequest, + RebornAdminSetRoleProductRequest, RebornAdminSetRoleRequest, RebornAdminSetStatusProductRequest, RebornAdminSetStatusRequest, RebornAdminUpdateUserProductRequest, RebornAdminUpdateUserRequest, RebornAdminUserListQuery, RebornAdminUserListResponse, RebornAdminUserRequest, RebornAdminUserResponse, RebornAdminUserSecretsListResponse, }; +use ironclaw_product_contracts::admin_users::{ + AdminCreateUserFields, AdminCreatedUser, AdminUserError, AdminUserRecord, AdminUserRole, + AdminUserSecretMeta, AdminUserService, AdminUserStatus, +}; +use ironclaw_product_contracts::channel_config::ChannelConfigProductService; +use ironclaw_product_contracts::lifecycle_service::{ + LifecycleProductContext, LifecycleProductService, +}; +use ironclaw_product_contracts::operator_tools::{ + RebornOperatorToolCatalog, RebornOperatorToolInfo, +}; +use ironclaw_product_contracts::package_lifecycle::ChannelConfigField as RebornChannelConfigField; use ironclaw_product_contracts::projection::ProjectionStream; use ironclaw_product_contracts::surface::{ ProductSurface, ProductSurfaceCaller, ProductSurfaceError, ProductSurfaceErrorCode, ProductSurfaceErrorKind, ProductSurfaceInvokeRequest, ProductSurfaceStreamRequest, ProductSurfaceValidationCode, }; +use ironclaw_product_contracts::views::{RebornViewPage, RebornViewQuery}; use ironclaw_threads::{ AcceptInboundMessageRequest, AcceptedInboundMessage, AcceptedInboundMessageReplay, AppendAssistantDraftRequest, AppendCapabilityDisplayPreviewRequest, diff --git a/crates/ironclaw_product/tests/run_delivery_contract.rs b/crates/ironclaw_product/tests/run_delivery_contract.rs index bc4ceae2363..60633552e99 100644 --- a/crates/ironclaw_product/tests/run_delivery_contract.rs +++ b/crates/ironclaw_product/tests/run_delivery_contract.rs @@ -42,14 +42,19 @@ use ironclaw_product::{ VerifiedInbound, }; use ironclaw_product::{ - BlockedAuthPromptRequest, BlockedAuthPromptSource, ChannelConnectionNoticePolicy, - ChannelDeliveryResolver, DeliveryCoordinator, DeliveryReplyContextSource, DeliveryRetryPolicy, - ResolvedChannelDelivery, RunDeliveryObserver, RunDeliveryServices, RunDeliverySettings, - TriggeredRunDeliveryDriver, TriggeredRunDeliveryRequest, + DeliveryCoordinator, DeliveryRetryPolicy, RunDeliveryObserver, RunDeliveryServices, + RunDeliverySettings, TriggeredRunDeliveryDriver, TriggeredRunDeliveryRequest, }; use ironclaw_product::{ ProjectFilesystemReader, ProjectFsEntry, ProjectFsEntryKind, ProjectFsError, ProjectFsStat, }; +use ironclaw_product_contracts::account_setup::ChannelConnectionNoticePolicy; +use ironclaw_product_contracts::delivery::{ + ChannelDeliveryResolver, DeliveryReplyContextSource, ResolvedChannelDelivery, +}; +use ironclaw_product_contracts::prompt_source::{ + BlockedAuthPromptRequest, BlockedAuthPromptSource, +}; use ironclaw_threads::{ AppendFinalizedAssistantMessageRequest, AttachmentKind, AttachmentRef, EnsureThreadRequest, InMemorySessionThreadService, MessageContent, SessionThreadService, ThreadScope, diff --git a/crates/ironclaw_product_contracts/CLAUDE.md b/crates/ironclaw_product_contracts/CLAUDE.md index 052c80f77ed..6ee466b501b 100644 --- a/crates/ironclaw_product_contracts/CLAUDE.md +++ b/crates/ironclaw_product_contracts/CLAUDE.md @@ -16,7 +16,7 @@ A type is admitted iff all four hold (the contracts-family test, §6.1): 3. two or more consumers need it without importing an owner; 4. it carries no execution, persistence, policy engine, or workflow. -Today that is seven modules: +Today that is seventeen shipped modules (plus the dev-only `test_support`, gated behind `#[cfg(any(test, feature = "test-support"))]`; `src/lib.rs` is the source of truth for the list): | Module | Owns | | --- | --- | @@ -27,6 +27,16 @@ Today that is seven modules: | `interaction_commands` | The channel-neutral interaction-reply grammar (`parse_interaction_resolution_text`). | | `operator_llm` | The operator LLM menu vocabulary. | | `package_lifecycle` | Package/extension lifecycle projection vocabulary (`Lifecycle*`, `ChannelConnectStrategy`, `ChannelConfigField`) — see the ruling below. | +| `lifecycle_service` | The lifecycle product service port (`LifecycleProductService`) and its caller contexts. Implemented by `ironclaw_extension_host` — the only crate that may write lifecycle state. | +| `delivery` | The delivery-resolution ports: `ChannelDeliveryResolver`, `ResolvedChannelDelivery`, `DeliveryReplyContextSource`. The coordinator itself is product's. | +| `account_setup` | `AccountConnectionStatusSource` + the extension account-setup descriptor/notice/error vocabulary. The declaration registry is product's (it holds mutable state). | +| `channel_config` | `ChannelConfigProductService` — per-extension `[channel.config]` operator config, implemented over the installation store. | +| `prompt_source` | Gate-prompt enrichment ports: `ApprovalPromptContextSource`, `BlockedAuthPromptSource`, `BlockedAuthPromptRequest`. Rendering stays in product. | +| `command` | `ProductCommandContext` (the authority-bearing dispatch context) and the `CommandActorRoleResolver` admission port. | +| `action` | Inbound-action identity (`ProductActionId`), the bounded product tokens, and `ActionFingerprintKey`. The ledger record and saga are product's. | +| `admin_users` | The `AdminUserService` port, its records, and its error taxonomy. The `Reborn*` HTTP wire DTOs stay with product's frozen surface. | +| `operator_tools` | `RebornOperatorToolCatalog` + `RebornOperatorToolInfo`. | +| `views` | The generic product-view conduit's `RebornViewDescriptor`/`Query`/`Page` and the `RebornViewProvider` port. `ProductView` (the typed declaration wrapper) stays with product's frozen inventory. | ## What must never be here @@ -107,16 +117,50 @@ packages call `render_channel_auth_prompt` from `deliver`. It lives in (`ApprovalPrompt*View`), which only product and WebUI reach, stayed in `outbound` here. +**The eleven ports WS2's first row relocated, and the six it could not.** The +`extension_host` port-inversion row moved every product-declared port the +extension host reaches whose signature this crate may legally name. **Nine of +them `extension_host` itself implements** — those are the ones +`reborn_extension_host_port_inversion.rs::INVERTED_PORTS` enumerates and pins: +`AccountConnectionStatusSource`, `ApprovalPromptContextSource`, +`BlockedAuthPromptSource`, `ChannelConfigProductService`, +`ChannelDeliveryResolver`, `CommandActorRoleResolver`, +`DeliveryReplyContextSource`, `LifecycleProductService`, `RebornViewProvider`. +**Two more it only consumes**, implemented in `ironclaw_reborn_composition`, and +they moved for the same reason — a port whose implementation sits outside +product does not belong inside it: `AdminUserService`, +`RebornOperatorToolCatalog`. Quote that test rather than this list when the +count matters; the list here is prose and the test is the enforced inventory. +Six stayed, and each for +the same mechanical reason rather than a judgement call — **this crate's +dependency allowlist is `ironclaw_host_api` + `ironclaw_extension_contracts` +and nothing else internal**, so a port whose signature names a type from +`ironclaw_auth`, `ironclaw_threads`, `ironclaw_turns`, or +`ironclaw_conversations` cannot be declared here until that type is narrowed +out of it: `AuthChallengeProvider` and `ChannelConnectionService` and +`ExtensionCredentialSetupService` (auth credential vocabulary), +`ConversationBindingService` and `ProductActorUserResolver` and +`ProductConversationSubjectRouteResolver` (they error with +`ironclaw_product::ProductSurfaceFailure`, which carries `ironclaw_turns::TurnError` +on two variants). The residue is enumerated with its reasons and held +shrink-only by +`crates/ironclaw_architecture/tests/reborn_extension_host_port_inversion.rs`; +**do not add a row there** — narrow the signature or move the type instead. + ## Deferred by design (not missing) -§6.1.3's Owns list also names the product-side **ports** whose implementations -live beside product — delivery resolution, command admission, the operator -service set, `LifecycleProductService`, `AccountConnectionStatusSource`, -`ChannelConfigProductService` — and the command/view/capability descriptor -types. Those are sourced from `ironclaw_product`, not from `ironclaw_host_api`, -and CHECKLIST WS2 ("flip `extension_host`'s implemented ports to -`product_contracts`/`extension_contracts` definitions") and WS6 ("`operator` -implements `product_contracts` ports") own them by name. They land in this -crate; they land with the PRs that repoint their implementors, because moving a -port definition without its implementation buys no dependency-edge removal. The -WS1.4 PR body carries the per-port movability analysis those rows need. +§6.1.3's Owns list also names the **operator service ports** (`LlmConfigService`, +`ActiveModelReader`, `OperatorLogsService`, `OperatorServiceLifecycleService`, +`OperatorStatusService` + their DTOs) and the command/view/capability +**descriptor types** (`ProductSurfaceCommandDescriptor`, +`ProductCapabilityDescriptor`, `ProductView`). Those are sourced from +`ironclaw_product`, and CHECKLIST WS2's `operator`/`webui`/`openai_compat` flips +own them by name. They land in this crate; they land with the PRs that repoint +their implementors, because moving a port definition without its implementation +buys no dependency-edge removal. + +`ProductCommandAdmissionService` is a special case worth recording rather than +deciding: §6.1.3 names its "shape" for this crate, but `admit` takes a +`&ProductCommand`, and §6.9.1 keeps the command grammar with product's frozen +inventory. One of the two sections has to give; neither WS1.4 nor WS2's first +row had standing to choose, so the port stayed in `ironclaw_product`. diff --git a/crates/ironclaw_product_contracts/Cargo.toml b/crates/ironclaw_product_contracts/Cargo.toml index 19ad06e8b2f..c83316ac83b 100644 --- a/crates/ironclaw_product_contracts/Cargo.toml +++ b/crates/ironclaw_product_contracts/Cargo.toml @@ -25,6 +25,13 @@ async-trait = "0.1" chrono = { version = "0.4", features = ["serde"] } ironclaw_extension_contracts = { path = "../ironclaw_extension_contracts", version = "0.1.0" } ironclaw_host_api = { path = "../ironclaw_host_api", version = "0.1.0" } +# `AdminUserService::put_secret` takes the secret material and +# `AdminCreatedUser` carries the one-time API token; both are `SecretString` +# so the port cannot be implemented (or called) with a plain `String` that +# `Debug`-prints itself. A value wrapper, not a framework or runtime client — +# the contracts-purity denylist in `reborn_dependency_boundaries.rs` governs +# the latter and this is deliberately not one. +secrecy = "0.10" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" diff --git a/crates/ironclaw_product_contracts/src/account_setup.rs b/crates/ironclaw_product_contracts/src/account_setup.rs new file mode 100644 index 00000000000..67d1ed3ac97 --- /dev/null +++ b/crates/ironclaw_product_contracts/src/account_setup.rs @@ -0,0 +1,166 @@ +//! Extension account-setup vocabulary and the account-status port +//! (PROPOSAL §6.1.3). +//! +//! An extension whose activation depends on a user-scoped external account +//! declares immutable setup metadata once, and the crate that hosts that +//! account answers "is this user connected?" through +//! [`AccountConnectionStatusSource`]. The declaration registry itself is +//! product-owned mutable state and stays in `ironclaw_product`; what lives +//! here is the descriptor it stores, the sanitized error classes it reports, +//! and the probe port `ironclaw_extension_host` implements over its pairing +//! service. +//! +//! Never here: the registry, activation preflight policy, or any +//! implementation of the port. + +use async_trait::async_trait; +use ironclaw_host_api::{ + decision::RuntimeCredentialAuthRequirement, + ids::{ExtensionId, UserId}, +}; +use thiserror::Error; + +use crate::package_lifecycle::ChannelConnectionRequirement; + +/// A connection-status read failed inside the extension-owned host service. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("account connection status read failed: {reason}")] +pub struct AccountConnectionStatusError { + reason: String, +} + +impl AccountConnectionStatusError { + pub fn new(reason: impl Into) -> Self { + Self { + reason: reason.into(), + } + } +} + +/// Narrow per-user account-connection probe used during activation preflight. +#[async_trait] +pub trait AccountConnectionStatusSource: Send + Sync + std::fmt::Debug { + async fn connected(&self, user_id: &UserId) -> Result; +} + +/// Product-owned copy for a channel account's pairing lifecycle. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelConnectionNoticePolicy { + pub connect_required: String, + pub paired: String, + pub already_paired_same_user: String, + pub already_bound_to_other_user: String, + pub expired_or_unknown: String, +} + +impl ChannelConnectionNoticePolicy { + pub fn generic(display_name: &str) -> Self { + Self { + connect_required: format!( + "👋 To use {display_name}, connect it in the Ironclaw web app, then message me here again." + ), + paired: format!("✅ {display_name} is paired. You can talk to Ironclaw here."), + already_paired_same_user: format!( + "✅ This {display_name} account is already paired to you." + ), + already_bound_to_other_user: format!( + "This {display_name} account is already paired to another Ironclaw user." + ), + expired_or_unknown: format!( + "That {display_name} pairing code is invalid or expired. Get a fresh code from Ironclaw and try again." + ), + } + } +} + +/// Immutable product metadata for an extension whose activation depends on a +/// user-scoped external-account connection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionAccountSetupDescriptor { + pub extension_id: ExtensionId, + pub auth_requirement: RuntimeCredentialAuthRequirement, + pub connection_requirement: ChannelConnectionRequirement, + pub connection_notices: ChannelConnectionNoticePolicy, + pub activation_success_message: String, + /// `WebGeneratedCode` presentation: an optional deep-link template with + /// `{code}` plus non-secret `[channel.config]` field-handle placeholders + /// (e.g. `https://vendor.example/{bot_username}?start={code}`). `None` + /// presents the minted code alone. + pub pairing_deep_link_template: Option, + /// Exact message prefixes the channel's pairing parser may strip before + /// validating a host-issued proof code. + pub inbound_code_prefixes: Vec, +} + +/// Sanitized lifecycle classification for an unavailable setup host or status +/// backend. The concrete backend error never crosses this boundary. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ExtensionAccountSetupError { + #[error("account setup host is unavailable for extension {extension_id}")] + HostUnavailable { extension_id: ExtensionId }, + #[error("account connection status is unavailable for extension {extension_id}")] + StatusUnavailable { + extension_id: ExtensionId, + #[source] + source: AccountConnectionStatusError, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_error_carries_its_sanitized_reason() { + let error = AccountConnectionStatusError::new("backend timed out"); + assert_eq!( + error.to_string(), + "account connection status read failed: backend timed out" + ); + } + + #[test] + fn generic_notice_policy_interpolates_the_display_name_into_every_notice() { + let policy = ChannelConnectionNoticePolicy::generic("Slack"); + for notice in [ + &policy.connect_required, + &policy.paired, + &policy.already_paired_same_user, + &policy.already_bound_to_other_user, + &policy.expired_or_unknown, + ] { + assert!( + notice.contains("Slack"), + "notice must name the channel: {notice}" + ); + } + // Each notice is distinct copy, not one string reused for five states. + let all = [ + policy.connect_required.clone(), + policy.paired.clone(), + policy.already_paired_same_user.clone(), + policy.already_bound_to_other_user.clone(), + policy.expired_or_unknown.clone(), + ]; + let mut unique = all.to_vec(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), all.len(), "notices must not collapse"); + } + + #[test] + fn setup_error_sources_the_status_error_it_wraps() { + let extension_id = ExtensionId::new("slack").expect("valid extension id"); + let unavailable = ExtensionAccountSetupError::HostUnavailable { + extension_id: extension_id.clone(), + }; + assert!(unavailable.to_string().contains("slack")); + + let status = ExtensionAccountSetupError::StatusUnavailable { + extension_id, + source: AccountConnectionStatusError::new("no backend"), + }; + assert!(status.to_string().contains("slack")); + assert_ne!(unavailable, status); + } +} diff --git a/crates/ironclaw_product_contracts/src/action.rs b/crates/ironclaw_product_contracts/src/action.rs new file mode 100644 index 00000000000..f4c24b89ce8 --- /dev/null +++ b/crates/ironclaw_product_contracts/src/action.rs @@ -0,0 +1,239 @@ +//! Inbound-action identity and the bounded tokens product DTOs key on +//! (PROPOSAL §6.1.3). +//! +//! A single mutating action accepted at the product boundary is identified by +//! a [`ProductActionId`] and deduplicated by an [`ActionFingerprintKey`]: +//! tenant-scoped installation + external actor + source binding + external +//! event id. The durable ledger record and its saga phases are product +//! workflow state and stay in `ironclaw_product`; what crosses the boundary is +//! the identity and the fingerprint, because every caller that builds a +//! command context — including `ironclaw_extension_host`'s channel hosts — +//! must speak them. +//! +//! Never here: the ledger, its store, or the action saga. + +use ironclaw_extension_contracts::external::{ExternalActorRef, ExternalEventId}; +use ironclaw_host_api::product_adapter::{AdapterInstallationId, ProductAdapterId}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Unique identifier for a product inbound action ledger entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ProductActionId(Uuid); + +impl ProductActionId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + pub fn as_uuid(&self) -> Uuid { + self.0 + } +} + +impl Default for ProductActionId { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Display for ProductActionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +const SOURCE_BINDING_KEY_MAX_BYTES: usize = 2_048; +const PRODUCT_COMMAND_NAME_MAX_BYTES: usize = 256; +const INTERACTION_REF_MAX_BYTES: usize = 512; + +fn validate_typed_token(kind: &'static str, value: &str, max_bytes: usize) -> Result<(), String> { + if value.is_empty() { + return Err(format!("{kind} must not be empty")); + } + if value.len() > max_bytes { + return Err(format!("{kind} exceeds {max_bytes}-byte limit")); + } + if value.chars().any(|c| c == '\0' || c.is_control()) { + return Err(format!("{kind} contains unsupported control characters")); + } + Ok(()) +} + +macro_rules! typed_token { + ($name:ident, $kind:literal, $max_bytes:expr) => { + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[serde(try_from = "String")] + pub struct $name(String); + + impl $name { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_typed_token($kind, &value, $max_bytes)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } + } + + impl TryFrom for $name { + type Error = String; + + fn try_from(value: String) -> Result { + Self::new(value) + } + } + + impl AsRef for $name { + fn as_ref(&self) -> &str { + self.as_str() + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } + } + + impl From<$name> for String { + fn from(value: $name) -> Self { + value.0 + } + } + }; +} + +typed_token!( + SourceBindingKey, + "source binding key", + SOURCE_BINDING_KEY_MAX_BYTES +); +typed_token!( + ProductCommandName, + "product command name", + PRODUCT_COMMAND_NAME_MAX_BYTES +); +typed_token!( + AuthRequestRef, + "auth request ref", + INTERACTION_REF_MAX_BYTES +); +typed_token!( + LinkedThreadActionId, + "linked thread action id", + INTERACTION_REF_MAX_BYTES +); + +/// Composite deduplication key for inbound actions. Two envelopes with the same +/// fingerprint are considered duplicates and the second will replay the first +/// outcome. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ActionFingerprintKey { + pub adapter_id: ProductAdapterId, + pub installation_id: AdapterInstallationId, + pub external_actor_ref: ExternalActorRef, + pub source_binding_key: SourceBindingKey, + pub external_event_id: ExternalEventId, +} + +impl ActionFingerprintKey { + pub fn new( + adapter_id: ProductAdapterId, + installation_id: AdapterInstallationId, + external_actor_ref: ExternalActorRef, + source_binding_key: SourceBindingKey, + external_event_id: ExternalEventId, + ) -> Self { + Self { + adapter_id, + installation_id, + external_actor_ref, + source_binding_key, + external_event_id, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ironclaw_extension_contracts::external::{ExternalActorRef, ExternalEventId}; + use ironclaw_host_api::product_adapter::{AdapterInstallationId, ProductAdapterId}; + + #[test] + fn typed_tokens_reject_empty_oversized_and_control_values() { + assert!(SourceBindingKey::new("").is_err()); + assert!(ProductCommandName::new("x".repeat(PRODUCT_COMMAND_NAME_MAX_BYTES + 1)).is_err()); + assert!(AuthRequestRef::new("auth\nrequest").is_err()); + // NUL is rejected by its own arm of the guard, not by `is_control()`: + // a token that reaches a path or a header with an embedded NUL + // truncates at the C boundary, so both arms must be live. + assert!(SourceBindingKey::new("space:0:;conv\0ersation").is_err()); + + let linked = LinkedThreadActionId::new("open-thread").expect("valid action id"); + assert_eq!(linked.as_str(), "open-thread"); + assert_eq!(linked.clone().into_inner(), "open-thread"); + assert_eq!(String::from(linked), "open-thread"); + } + + #[test] + fn typed_tokens_round_trip_through_serde_as_ref_and_display() { + // The bounded-token template generates `TryFrom` (the serde + // entry point), `AsRef`, and `Display` for every token; the + // validating constructor alone leaves all three unexercised, and a + // token that deserializes without validating is the defect the + // `try_from` attribute exists to prevent. + let key: SourceBindingKey = + serde_json::from_value(serde_json::json!("space:0:;conversation:2:C1;topic:0:;")) + .expect("valid token deserializes"); + assert_eq!(key.as_ref(), "space:0:;conversation:2:C1;topic:0:;"); + assert_eq!(key.to_string(), "space:0:;conversation:2:C1;topic:0:;"); + assert_eq!( + serde_json::to_value(&key).expect("serialize"), + serde_json::json!("space:0:;conversation:2:C1;topic:0:;") + ); + + let rejected = serde_json::from_value::(serde_json::json!("")); + assert!( + rejected.is_err(), + "deserialization must run the same validation as the constructor" + ); + assert!( + serde_json::from_value::(serde_json::json!("auth\u{7}ref")).is_err(), + "control characters are rejected through serde too" + ); + } + + #[test] + fn product_action_id_round_trips_display_and_uuid() { + let action_id = ProductActionId::new(); + assert_eq!(action_id.to_string(), action_id.as_uuid().to_string()); + assert_ne!(ProductActionId::default().as_uuid(), action_id.as_uuid()); + } + + #[test] + fn action_fingerprint_key_carries_every_dedup_component() { + let key = ActionFingerprintKey::new( + ProductAdapterId::new("test_adapter").expect("valid adapter"), + AdapterInstallationId::new("install_alpha").expect("valid installation"), + ExternalActorRef::new("test", "user1", Option::::None).expect("valid actor"), + SourceBindingKey::new("space:0:;conversation:5:conv1;topic:0:;") + .expect("valid source binding"), + ExternalEventId::new("evt:action").expect("valid event"), + ); + assert_eq!(key.adapter_id.as_str(), "test_adapter"); + assert_eq!( + key.source_binding_key.as_str(), + "space:0:;conversation:5:conv1;topic:0:;" + ); + assert_eq!(key, key.clone()); + } +} diff --git a/crates/ironclaw_product_contracts/src/admin_users.rs b/crates/ironclaw_product_contracts/src/admin_users.rs new file mode 100644 index 00000000000..6ee8b0c699f --- /dev/null +++ b/crates/ironclaw_product_contracts/src/admin_users.rs @@ -0,0 +1,229 @@ +//! The admin user-directory port and its record vocabulary +//! (PROPOSAL §6.1.3). +//! +//! [`AdminUserService`] is a dependency-inversion port: its only production +//! implementation lives in `ironclaw_reborn_composition`, over the identity +//! user-directory and the per-user secret store. It was declared inside +//! `ironclaw_product` so product and WebUI would not have to depend on +//! `ironclaw_reborn_identity` — the right inversion in the wrong crate, since +//! `ironclaw_extension_host` reads the same directory to resolve a channel +//! actor's admin role and had to depend on product to do it. +//! +//! The `Reborn*` HTTP wire DTOs that wrap these records stay with product's +//! frozen surface inventory; only the port, its records, and its error taxonomy +//! are here. +//! +//! Never here: the composition adapter, the fail-closed default, or the +//! authorization/last-admin policy (enforced by the product service). + +use std::collections::BTreeMap; + +use async_trait::async_trait; +use ironclaw_host_api::ids::{SecretHandle, TenantId, UserId}; +use secrecy::SecretString; +use serde::{Deserialize, Serialize}; + +/// Account status. Wire-stable snake_case. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AdminUserStatus { + Active, + Suspended, +} + +/// Account role. Wire-stable snake_case. `Owner` and `Admin` clear the admin +/// authorization boundary; `Member` does not. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AdminUserRole { + Owner, + Admin, + Member, +} + +impl AdminUserRole { + /// Whether this role clears the admin authorization boundary. + pub fn is_admin(self) -> bool { + matches!(self, AdminUserRole::Owner | AdminUserRole::Admin) + } +} + +/// One user as seen by the admin surface — doubles as the domain record the +/// port returns and the JSON body the WebUI renders. Never carries an API +/// token: a freshly minted token is exposed exactly once via product's +/// `RebornAdminUserCreatedResponse`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AdminUserRecord { + pub user_id: UserId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + pub status: AdminUserStatus, + pub role: AdminUserRole, + pub created_at: String, + pub updated_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_login_at: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub metadata: BTreeMap, +} + +/// Metadata for one provisioned per-user secret. Never carries the material. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AdminUserSecretMeta { + pub handle: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated_at: Option, +} + +/// Fields for admin-minting a new user. +#[derive(Debug, Clone)] +pub struct AdminCreateUserFields { + pub email: Option, + pub display_name: Option, + pub role: AdminUserRole, +} + +/// A newly created user plus its one-time API token. The token is a session +/// bearer minted by the composition adapter; it is returned exactly once and +/// never persisted in plaintext. +pub struct AdminCreatedUser { + pub record: AdminUserRecord, + pub api_token: SecretString, +} + +/// Failure modes of the admin user port. Deliberately coarse and free of +/// backend detail — the composition adapter maps identity/secret errors into +/// these, and the service maps these into the sanitized `ProductSurfaceError` +/// wire taxonomy. Authorization and last-admin protection are enforced in the +/// service, not here, so they are not modeled as port errors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AdminUserError { + /// The targeted user id has no record. + NotFound, + /// A caller-supplied value is malformed (e.g. an invalid secret handle). + /// Maps to a 400, not a 500 — it is the client's input at fault, not the + /// backend. + InvalidInput, + /// A transient backend failure; the caller may retry. + Unavailable, + /// A backend inconsistency or unexpected failure; not retryable. + Internal, +} + +/// Default page size for `list_users` when the caller omits `limit`. +pub const ADMIN_USER_LIST_DEFAULT_LIMIT: usize = 100; +/// Hard ceiling on the `list_users` page size, so a caller cannot widen the +/// response (and the backing directory scan) by passing a huge `limit`. +pub const ADMIN_USER_LIST_MAX_LIMIT: usize = 200; + +/// Admin user-management operations. Implemented by the composition adapter +/// over the identity user-directory + per-user secret store. +/// +/// Every method is tenant-scoped from the trusted caller (never a request +/// body). `get_user` must return `Ok(None)` — not `Err(NotFound)` — for a user +/// that does not exist in the tenant, so the service can distinguish "no such +/// user" (404) from "exists but you may not" (403) at the authorization seam. +#[async_trait] +pub trait AdminUserService: Send + Sync { + /// One bounded page of users in `tenant`, optionally filtered by `status`, + /// ordered by `user_id` ascending and starting strictly after the `after` + /// cursor. At most `limit` records are returned; the service derives the + /// next cursor from the last record when a full page comes back. + async fn list_users( + &self, + tenant: &TenantId, + status: Option, + after: Option<&UserId>, + limit: usize, + ) -> Result, AdminUserError>; + + async fn get_user( + &self, + tenant: &TenantId, + user_id: &UserId, + ) -> Result, AdminUserError>; + + async fn create_user( + &self, + tenant: &TenantId, + actor: &UserId, + fields: AdminCreateUserFields, + ) -> Result; + + async fn update_profile( + &self, + tenant: &TenantId, + user_id: &UserId, + display_name: Option, + metadata: Option>, + ) -> Result; + + async fn set_status( + &self, + tenant: &TenantId, + user_id: &UserId, + status: AdminUserStatus, + ) -> Result; + + async fn set_role( + &self, + tenant: &TenantId, + user_id: &UserId, + role: AdminUserRole, + ) -> Result; + + async fn delete_user(&self, tenant: &TenantId, user_id: &UserId) -> Result<(), AdminUserError>; + + async fn count_active_admins(&self, tenant: &TenantId) -> Result; + + async fn list_secrets( + &self, + tenant: &TenantId, + user_id: &UserId, + ) -> Result, AdminUserError>; + + async fn put_secret( + &self, + tenant: &TenantId, + user_id: &UserId, + handle: SecretHandle, + material: SecretString, + ) -> Result; + + async fn delete_secret( + &self, + tenant: &TenantId, + user_id: &UserId, + handle: SecretHandle, + ) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_owner_and_admin_clear_the_admin_boundary() { + assert!(AdminUserRole::Owner.is_admin()); + assert!(AdminUserRole::Admin.is_admin()); + assert!(!AdminUserRole::Member.is_admin()); + } + + #[test] + fn role_and_status_wire_forms_stay_snake_case() { + assert_eq!( + serde_json::to_value(AdminUserRole::Owner).expect("serialize"), + serde_json::json!("owner") + ); + assert_eq!( + serde_json::to_value(AdminUserStatus::Suspended).expect("serialize"), + serde_json::json!("suspended") + ); + } +} diff --git a/crates/ironclaw_product_contracts/src/channel_config.rs b/crates/ironclaw_product_contracts/src/channel_config.rs new file mode 100644 index 00000000000..d5983d96809 --- /dev/null +++ b/crates/ironclaw_product_contracts/src/channel_config.rs @@ -0,0 +1,91 @@ +//! The per-extension channel-config port (PROPOSAL §6.1.3). +//! +//! `[channel.config]` declares operator-supplied configuration for a channel +//! extension. The product setup service routes submitted values through this +//! port and derives config completeness from the field status it returns; the +//! implementation is the extension host's, because it owns the durable +//! installation store and the scoped secret store the values land in. +//! +//! The field DTO itself is manifest vocabulary and already lives in +//! [`crate::package_lifecycle::ChannelConfigField`]; this module adds only the +//! port. + +use async_trait::async_trait; +use ironclaw_host_api::ids::ExtensionId; + +use crate::package_lifecycle::ChannelConfigField; +use crate::surface::ProductSurfaceError; + +/// The generic channel-config configure port: per-extension operator config +/// declared by the extension manifest's channel-config fields. The extension +/// host implements it over the durable installation store and the scoped +/// secret store; the setup service routes submitted values through it and +/// derives config completeness from the field status. +#[async_trait] +pub trait ChannelConfigProductService: Send + Sync { + /// Per-field presence for the extension's declared channel config. + /// Empty when the extension declares none (or is not installed yet). + async fn field_status( + &self, + extension_id: &ExtensionId, + ) -> Result, ProductSurfaceError>; + + /// Validate submitted `(handle, value)` pairs against the installed + /// manifest's declared fields and persist them (non-secret values + /// durably per installation, secret values into the scoped secret + /// store). Saving while the extension is active re-runs its activation + /// with the new values. + async fn save_values( + &self, + extension_id: &ExtensionId, + values: Vec<(String, String)>, + ) -> Result<(), ProductSurfaceError>; +} + +#[cfg(test)] +mod tests { + use super::*; + + static_assertions::assert_obj_safe!(ChannelConfigProductService); + + struct DeclaresNothing; + + #[async_trait] + impl ChannelConfigProductService for DeclaresNothing { + async fn field_status( + &self, + _extension_id: &ExtensionId, + ) -> Result, ProductSurfaceError> { + Ok(Vec::new()) + } + + async fn save_values( + &self, + _extension_id: &ExtensionId, + _values: Vec<(String, String)>, + ) -> Result<(), ProductSurfaceError> { + Ok(()) + } + } + + #[tokio::test] + async fn an_extension_declaring_no_config_reports_an_empty_field_set() { + // Empty is the "nothing to configure" answer the setup view renders + // from; it must be reachable without an error, or a channel with no + // `[channel.config]` cannot complete setup. + let service: std::sync::Arc = + std::sync::Arc::new(DeclaresNothing); + let extension_id = ExtensionId::new("slack").expect("valid extension id"); + assert!( + service + .field_status(&extension_id) + .await + .expect("empty status is not an error") + .is_empty() + ); + service + .save_values(&extension_id, Vec::new()) + .await + .expect("saving nothing is not an error"); + } +} diff --git a/crates/ironclaw_product_contracts/src/command.rs b/crates/ironclaw_product_contracts/src/command.rs new file mode 100644 index 00000000000..3a500a66f11 --- /dev/null +++ b/crates/ironclaw_product_contracts/src/command.rs @@ -0,0 +1,164 @@ +//! The authority-bearing command context and the actor-role admission port +//! (PROPOSAL §6.1.3). +//! +//! [`ProductCommandContext`] is what a channel host hands the product surface +//! when an inbound message turns out to be a command: the verified claim, the +//! external refs it arrived on, and the action identity it is deduplicated by. +//! It crosses the boundary in both directions — product builds it from an +//! envelope, and `ironclaw_extension_host` reads it to resolve the bound +//! user's admin role through [`CommandActorRoleResolver`]. +//! +//! Never here: the command grammar (`ProductCommand` and the declared command +//! inventory stay with product's frozen surface), admission policy, or any +//! resolver implementation. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use ironclaw_extension_contracts::channel_adapter::ProductTriggerReason; +use ironclaw_extension_contracts::external::{ExternalActorRef, ExternalConversationRef}; +use ironclaw_host_api::product_adapter::{ + AdapterInstallationId, ProductAdapterId, VerifiedAuthClaim, +}; +use serde::Serialize; + +use crate::action::{ActionFingerprintKey, ProductActionId}; +use crate::admin_users::AdminUserRole; +use crate::inbound::{ProductInboundEnvelope, ProductInboundPayload}; +use crate::surface::{ProductSurfaceError, ProductSurfaceErrorCode}; + +/// Authority-bearing command dispatch context built by the workflow. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProductCommandContext { + pub action_id: ProductActionId, + pub fingerprint: ActionFingerprintKey, + /// Exact raw inbound command token, verbatim from the payload. + pub requested_command: String, + pub adapter_id: ProductAdapterId, + pub installation_id: AdapterInstallationId, + pub external_actor_ref: ExternalActorRef, + pub external_conversation_ref: ExternalConversationRef, + pub auth_claim: VerifiedAuthClaim, + pub trigger: ProductTriggerReason, + pub received_at: DateTime, +} + +impl ProductCommandContext { + pub fn from_envelope( + envelope: &ProductInboundEnvelope, + action_id: ProductActionId, + fingerprint: ActionFingerprintKey, + ) -> Result { + let ProductInboundPayload::Command(command) = envelope.payload() else { + return Err(ProductSurfaceError::from_status( + ProductSurfaceErrorCode::InvalidRequest, + 400, + false, + )); + }; + Ok(Self { + action_id, + fingerprint, + requested_command: command.command.clone(), + adapter_id: envelope.adapter_id().clone(), + installation_id: envelope.installation_id().clone(), + external_actor_ref: envelope.external_actor_ref().clone(), + external_conversation_ref: envelope.external_conversation_ref().clone(), + auth_claim: envelope.auth_claim().clone(), + trigger: command.trigger, + received_at: envelope.received_at(), + }) + } +} + +/// Resolves the admin-boundary role of the ACTIVE bound user behind an +/// inbound channel actor. `Ok(None)` means unbound actor, missing record, or +/// suspended account — all treated as not-admin (fail closed). `Err` means +/// transient resolution failure; the command fails retryable rather than +/// silently degrading to member or admin treatment. +#[async_trait] +pub trait CommandActorRoleResolver: Send + Sync { + async fn actor_role( + &self, + context: &ProductCommandContext, + ) -> Result, ProductSurfaceError>; +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use ironclaw_extension_contracts::external::{ + ExternalActorRef, ExternalConversationRef, ExternalEventId, + }; + use ironclaw_host_api::product_adapter::ProtocolAuthEvidence; + use ironclaw_host_api::product_adapter::auth::AuthRequirement; + + use crate::action::{ActionFingerprintKey, ProductActionId, SourceBindingKey}; + use crate::inbound::{ + InboundCommandPayload, ParsedProductInbound, TrustedInboundContext, UserMessagePayload, + }; + + fn envelope(payload: ProductInboundPayload) -> ProductInboundEnvelope { + let evidence = ProtocolAuthEvidence::test_verified( + AuthRequirement::SharedSecretHeader { + header_name: "X-Slack-Signature".into(), + }, + "install_alpha", + ); + let context = TrustedInboundContext::from_verified_evidence( + ProductAdapterId::new("slack").expect("valid adapter"), + AdapterInstallationId::new("install_alpha").expect("valid installation"), + Utc::now(), + &evidence, + ) + .expect("verified evidence"); + let parsed = ParsedProductInbound::new( + ExternalEventId::new("evt:1").expect("valid event"), + ExternalActorRef::new("slack_user", "U1", Option::::None).expect("valid actor"), + ExternalConversationRef::new(None, "C1", None, None).expect("valid conversation"), + payload, + ) + .expect("parsed"); + ProductInboundEnvelope::from_trusted_parse(context, parsed).expect("envelope") + } + + fn fingerprint() -> ActionFingerprintKey { + ActionFingerprintKey::new( + ProductAdapterId::new("slack").expect("valid adapter"), + AdapterInstallationId::new("install_alpha").expect("valid installation"), + ExternalActorRef::new("slack_user", "U1", Option::::None).expect("valid actor"), + SourceBindingKey::new("space:0:;conversation:2:C1;topic:0:;").expect("valid binding"), + ExternalEventId::new("evt:1").expect("valid event"), + ) + } + + #[test] + fn command_context_is_built_from_a_command_envelope_verbatim() { + let envelope = envelope(ProductInboundPayload::Command( + InboundCommandPayload::new("status", "--json", ProductTriggerReason::DirectChat) + .expect("payload"), + )); + let action_id = ProductActionId::new(); + let context = ProductCommandContext::from_envelope(&envelope, action_id, fingerprint()) + .expect("built"); + + assert_eq!(context.requested_command, "status"); + assert_eq!(context.action_id, action_id); + assert_eq!(context.adapter_id.as_str(), "slack"); + assert_eq!(context.installation_id.as_str(), "install_alpha"); + assert_eq!(context.trigger, ProductTriggerReason::DirectChat); + assert_eq!(context.received_at, envelope.received_at()); + } + + #[test] + fn non_command_envelope_is_rejected_as_an_invalid_request_not_an_internal_error() { + let envelope = envelope(ProductInboundPayload::UserMessage( + UserMessagePayload::new("hello", vec![], ProductTriggerReason::DirectChat) + .expect("payload"), + )); + let error = + ProductCommandContext::from_envelope(&envelope, ProductActionId::new(), fingerprint()) + .expect_err("a user message is not a command"); + assert_eq!(error.code, ProductSurfaceErrorCode::InvalidRequest); + } +} diff --git a/crates/ironclaw_product_contracts/src/delivery.rs b/crates/ironclaw_product_contracts/src/delivery.rs new file mode 100644 index 00000000000..f45c4019b3d --- /dev/null +++ b/crates/ironclaw_product_contracts/src/delivery.rs @@ -0,0 +1,140 @@ +//! Delivery-resolution ports (PROPOSAL §6.1.3). +//! +//! The outbound delivery coordinator is product-tier *semantics* and stays in +//! `ironclaw_product`. What crosses the product boundary is the pair of ports +//! it reads through: "which channel extension is active right now" and "what +//! opaque vendor reply context did that extension attach to the originating +//! inbound message". Both are implemented **below** product by +//! `ironclaw_extension_host`, which owns the active snapshot and the +//! reply-context store — so defining them here is what lets the extension host +//! satisfy the coordinator without depending on it. +//! +//! Never here: the coordinator, delivery attempt persistence, retry policy, or +//! any implementation of these ports. + +use std::sync::Arc; + +use async_trait::async_trait; +use ironclaw_extension_contracts::channel_adapter::ChannelAdapter; +use ironclaw_extension_contracts::tool_adapter::RestrictedEgress; + +/// One channel's delivery half, resolved from a single active-snapshot read +/// (generation-pinned: an in-flight delivery keeps these `Arc`s across an +/// upgrade). +#[derive(Clone)] +pub struct ResolvedChannelDelivery { + pub extension_id: String, + pub installation_id: String, + pub adapter: Arc, + /// Policy-enforced egress built from the same snapshot read. + pub egress: Arc, +} + +/// Resolver port: the coordinator's view of the active extension set. +/// Defined here (the coordinator is the consumer); implemented over the +/// extension host's snapshot. +pub trait ChannelDeliveryResolver: Send + Sync { + fn resolve_channel_delivery(&self, extension_id: &str) -> Option; +} + +/// Read half of the host-side `reply_context` storage (ING-11): the opaque +/// vendor context an adapter attached to the originating inbound message, +/// handed back at delivery time. +#[async_trait] +pub trait DeliveryReplyContextSource: Send + Sync { + async fn reply_context( + &self, + extension_id: &str, + installation_id: &str, + conversation_fingerprint: &str, + ) -> Option>; +} + +#[cfg(test)] +mod tests { + use super::*; + + use async_trait::async_trait; + use std::sync::Mutex; + + // Every consumer holds these as `Arc`, so dyn-safety is part of the + // contract, not an implementation detail: a signature change that breaks it + // fails here rather than at the far-away wiring site. + static_assertions::assert_obj_safe!(ChannelDeliveryResolver, DeliveryReplyContextSource); + + /// Records the coordinates each port is asked about. The point under test + /// is the *seam*, not the lookup: both ports key on identifiers the + /// coordinator passes through, and both currently carry them as bare + /// strings, so a transposed argument is a silent mis-delivery rather than + /// a compile error. These pin the order and the pass-through. + #[derive(Default)] + struct RecordingResolver { + resolved: Mutex>, + contexts: Mutex>, + } + + impl ChannelDeliveryResolver for RecordingResolver { + fn resolve_channel_delivery(&self, extension_id: &str) -> Option { + self.resolved + .lock() + .expect("lock") + .push(extension_id.to_string()); + // Absence is expressible without an error on purpose: a channel + // that is not in the active snapshot is a normal outcome (it was + // just deactivated, or never installed), not a delivery failure. + None + } + } + + #[async_trait] + impl DeliveryReplyContextSource for RecordingResolver { + async fn reply_context( + &self, + extension_id: &str, + installation_id: &str, + conversation_fingerprint: &str, + ) -> Option> { + self.contexts.lock().expect("lock").push(( + extension_id.to_string(), + installation_id.to_string(), + conversation_fingerprint.to_string(), + )); + None + } + } + + #[test] + fn the_resolver_receives_the_extension_id_verbatim_and_may_answer_none() { + let recorder = Arc::new(RecordingResolver::default()); + let resolver: Arc = recorder.clone(); + + assert!(resolver.resolve_channel_delivery("slack").is_none()); + assert!(resolver.resolve_channel_delivery("telegram").is_none()); + + assert_eq!( + *recorder.resolved.lock().expect("lock"), + vec!["slack".to_string(), "telegram".to_string()], + "the port must hand the implementation the id it was asked about" + ); + } + + #[tokio::test] + async fn reply_context_keeps_extension_installation_and_fingerprint_in_order() { + // All three are bare strings today, so nothing but this test stops a + // transposition. `None` (no stored anchor) is also deliberately + // distinct from `Some(vec![])` (a stored but empty anchor). + let recorder = Arc::new(RecordingResolver::default()); + let source: Arc = recorder.clone(); + + assert_eq!(source.reply_context("slack", "inst-1", "fp-9").await, None); + + assert_eq!( + *recorder.contexts.lock().expect("lock"), + vec![( + "slack".to_string(), + "inst-1".to_string(), + "fp-9".to_string() + )], + ); + } +} diff --git a/crates/ironclaw_product_contracts/src/lib.rs b/crates/ironclaw_product_contracts/src/lib.rs index f323f989733..2df0eb3d7b4 100644 --- a/crates/ironclaw_product_contracts/src/lib.rs +++ b/crates/ironclaw_product_contracts/src/lib.rs @@ -32,15 +32,25 @@ //! in this crate. #![warn(unreachable_pub)] +pub mod account_setup; +pub mod action; +pub mod admin_users; +pub mod channel_config; +pub mod command; +pub mod delivery; pub mod inbound; pub mod interaction_commands; +pub mod lifecycle_service; pub mod operator_llm; +pub mod operator_tools; pub mod outbound; pub mod package_lifecycle; pub mod projection; +pub mod prompt_source; pub mod surface; #[cfg(any(test, feature = "test-support"))] pub mod test_support; +pub mod views; // There is deliberately no flat prelude and no cross-module re-export here. // Every contract is reached through the module that owns it — diff --git a/crates/ironclaw_product_contracts/src/lifecycle_service.rs b/crates/ironclaw_product_contracts/src/lifecycle_service.rs new file mode 100644 index 00000000000..9c664019abb --- /dev/null +++ b/crates/ironclaw_product_contracts/src/lifecycle_service.rs @@ -0,0 +1,203 @@ +//! The package-lifecycle product service port (PROPOSAL §6.1.3). +//! +//! [`crate::package_lifecycle`] owns the lifecycle *values*; this module owns +//! the service that answers in them. The split matters because the only +//! production implementation lives **below** product, in +//! `ironclaw_extension_host` — it is the crate that may write lifecycle state — +//! while product and every transport call it through this port. +//! +//! Never here: any lifecycle authority, install policy, or service +//! implementation (including the unsupported-runtime fallback, which is +//! product's). + +use async_trait::async_trait; +use ironclaw_host_api::ids::{AgentId, ProjectId, TenantId, UserId}; +use serde::Serialize; + +use crate::command::ProductCommandContext; +use crate::package_lifecycle::{ + LifecyclePackageRef, LifecycleProductAction, LifecycleProductResponse, +}; +use crate::surface::{ProductSurfaceError, ProductSurfaceErrorCode}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LifecycleProductSurfaceContext { + pub tenant_id: TenantId, + pub user_id: UserId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "source", rename_all = "snake_case")] +pub enum LifecycleProductContext { + Command(Box), + Surface(LifecycleProductSurfaceContext), +} + +#[async_trait] +pub trait LifecycleProductService: Send + Sync { + async fn execute( + &self, + context: LifecycleProductContext, + action: LifecycleProductAction, + ) -> Result; + + async fn project_package( + &self, + context: LifecycleProductContext, + package_ref: LifecyclePackageRef, + ) -> Result; + + /// Import a standalone extension from an uploaded bundle (zip bytes) — the + /// WebUI "Install Tool" path. Only the local runtime service implements it. + /// + /// The default refuses with `InvalidRequest`/400. ✎ **Known mismatch, + /// carried verbatim by the WS2.1 move and deliberately not changed in a + /// move-shaped PR**: the wording that used to sit here said "unavailable", + /// which is a different code (503) and a different meaning — 400 says the + /// caller's request was malformed, and an unwired capability is not the + /// caller's fault. Changing it changes an HTTP status on a live route, so + /// it belongs in its own change with the WebUI path re-checked; + /// `bundle_import_defaults_to_an_invalid_request_rather_than_silently_succeeding` + /// pins today's behavior so the flip cannot happen silently. The flip — + /// and this test's assertion with it — is owned by the CHECKLIST WS2 row + /// "The four WS2.1 follow-ups", which sizes it as the behavior change it + /// is rather than as a signature correction. + async fn import_extension_bundle( + &self, + _context: LifecycleProductContext, + _bundle: Vec, + ) -> Result { + Err(ProductSurfaceError::from_status( + ProductSurfaceErrorCode::InvalidRequest, + 400, + false, + )) + } + + /// Redacted activation error for each installed extension whose activation + /// failed, keyed by extension id — sourced from the durable installation + /// record's typed `last_error`. The extensions-list service threads this + /// into `RebornExtensionInfo::activation_error` so a failed extension shows + /// *why* it failed instead of collapsing to a bare `installed`/`failed` + /// state with no reason. + /// + /// Default: none. A service that does not surface durable installation + /// errors reports no reason and the wire's `activation_error` stays absent; + /// the production extension-host service overrides this to read the + /// installation records' `last_error`. + /// + /// ✎ **Known stringly-typed signature, carried verbatim by the WS2.1 + /// move.** The key should be `ExtensionId`, not `String` — the host + /// implementation already holds a typed `ExtensionId` and stringifies only + /// to satisfy this signature. Retyping it changes the contract for every + /// implementor, which PLAN operating principle 2 keeps out of a move-shaped + /// PR; it is owned by the CHECKLIST WS2 row "The four WS2.1 follow-ups". + /// It is independent of the `ProductSurfaceFailure` slice — `ExtensionId` + /// is `host_api` vocabulary this crate may already name — so it need not + /// wait on it. + async fn installed_activation_errors( + &self, + _context: LifecycleProductContext, + ) -> Result, ProductSurfaceError> { + Ok(std::collections::HashMap::new()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ironclaw_extension_contracts::state::InstallationState; + + use crate::package_lifecycle::LifecyclePackageKind; + + /// A service that implements only the two required methods, so the two + /// defaults below are exercised as written rather than through an + /// override. Fail-closed defaults are the reason they exist. + struct MinimalLifecycleService; + + fn surface_context() -> LifecycleProductContext { + LifecycleProductContext::Surface(LifecycleProductSurfaceContext { + tenant_id: TenantId::new("tenant-1").expect("valid tenant"), + user_id: UserId::new("user-1").expect("valid user"), + agent_id: None, + project_id: None, + }) + } + + fn package_ref() -> LifecyclePackageRef { + LifecyclePackageRef::new(LifecyclePackageKind::Extension, "slack").expect("valid ref") + } + + #[async_trait] + impl LifecycleProductService for MinimalLifecycleService { + async fn execute( + &self, + _context: LifecycleProductContext, + _action: LifecycleProductAction, + ) -> Result { + Ok(LifecycleProductResponse::projection( + Some(package_ref()), + InstallationState::Active, + Vec::new(), + )) + } + + async fn project_package( + &self, + _context: LifecycleProductContext, + package_ref: LifecyclePackageRef, + ) -> Result { + Ok(LifecycleProductResponse::projection( + Some(package_ref), + InstallationState::Active, + Vec::new(), + )) + } + } + + #[tokio::test] + async fn the_two_required_methods_have_no_default_and_answer_in_lifecycle_values() { + let service: &dyn LifecycleProductService = &MinimalLifecycleService; + let executed = service + .execute( + surface_context(), + LifecycleProductAction::ExtensionActivate { + package_ref: package_ref(), + }, + ) + .await + .expect("execute is required, not defaulted"); + assert_eq!(executed.phase, InstallationState::Active); + + let projected = service + .project_package(surface_context(), package_ref()) + .await + .expect("project_package is required, not defaulted"); + assert_eq!(projected.package_ref, Some(package_ref())); + } + + /// Pins today's behavior, not the desired one — see the mismatch note on + /// `import_extension_bundle`. The point that matters either way: a service + /// without bundle support must *refuse*, never return success. + #[tokio::test] + async fn bundle_import_defaults_to_an_invalid_request_rather_than_silently_succeeding() { + let error = MinimalLifecycleService + .import_extension_bundle(surface_context(), vec![0x50, 0x4b]) + .await + .expect_err("a service that does not implement bundle import must refuse"); + assert_eq!(error.code, ProductSurfaceErrorCode::InvalidRequest); + } + + #[tokio::test] + async fn activation_errors_default_to_none_so_the_wire_field_stays_absent() { + let errors = MinimalLifecycleService + .installed_activation_errors(surface_context()) + .await + .expect("default reports no durable errors"); + assert!(errors.is_empty()); + } +} diff --git a/crates/ironclaw_product_contracts/src/operator_tools.rs b/crates/ironclaw_product_contracts/src/operator_tools.rs new file mode 100644 index 00000000000..bc74317e7e3 --- /dev/null +++ b/crates/ironclaw_product_contracts/src/operator_tools.rs @@ -0,0 +1,134 @@ +//! The operator tool-catalog port (PROPOSAL §6.1.3). +//! +//! The operator/settings surface lists the capabilities a caller may see and +//! set per-tool permissions on them. What tools exist is an extension-host +//! question (it owns the active snapshot), so the catalog is a port defined at +//! the product boundary and implemented below it — the same inversion as +//! [`crate::delivery`]. +//! +//! Never here: permission policy, override storage, or any catalog +//! implementation. + +use std::sync::Arc; + +use async_trait::async_trait; +use ironclaw_host_api::{ + capability::{EffectKind, PermissionMode}, + ids::{CapabilityId, ExtensionId, UserId}, +}; + +/// One tool as the operator surface sees it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RebornOperatorToolInfo { + pub capability_id: CapabilityId, + pub provider: ExtensionId, + pub description: Arc, + pub default_permission: PermissionMode, + pub effects: Arc<[EffectKind]>, +} + +#[async_trait] +pub trait RebornOperatorToolCatalog: Send + Sync { + /// Tools visible to `caller` in the operator/settings surface (#5459 P1). + /// + /// The settings/tools routes are authenticated-caller routes (not + /// operator-gated), so a member reads this catalog. It MUST therefore be + /// filtered by installation owner exactly like the model capability + /// surface: tenant-shared tools for everyone, user-private tools only for + /// their owner. An unfiltered catalog would disclose another user's + /// private install (its capability id, description, effects) — the leak + /// this parameter closes. + async fn list_operator_tools(&self, caller: &UserId) -> Vec; +} + +#[cfg(test)] +mod tests { + use super::*; + + use ironclaw_host_api::ids::CapabilityId; + + static_assertions::assert_obj_safe!(RebornOperatorToolCatalog); + + /// A catalog that applies the ownership filter the port's doc comment + /// requires. It is a *double*, so it cannot prove the production catalog + /// filters — that lives in composition and is tested there. What it does + /// prove is the property this crate owns: the port hands the + /// implementation the caller, and its shape admits a per-caller answer. + /// A port that dropped `caller` (or returned one global list) could not + /// satisfy this test at all. + struct OwnershipFilteredCatalog; + + const SHARED: &str = "builtin.http_fetch"; + const ALICE_PRIVATE: &str = "alice.private_tool"; + const BOB_PRIVATE: &str = "bob.private_tool"; + + fn tool(capability_id: &str) -> RebornOperatorToolInfo { + RebornOperatorToolInfo { + capability_id: CapabilityId::new(capability_id).expect("valid capability id"), + provider: ExtensionId::new("web_access").expect("valid extension id"), + description: Arc::from("a tool"), + default_permission: PermissionMode::Ask, + effects: Arc::from(Vec::new()), + } + } + + #[async_trait] + impl RebornOperatorToolCatalog for OwnershipFilteredCatalog { + async fn list_operator_tools(&self, caller: &UserId) -> Vec { + let mut tools = vec![tool(SHARED)]; + match caller.as_str() { + "alice" => tools.push(tool(ALICE_PRIVATE)), + "bob" => tools.push(tool(BOB_PRIVATE)), + _ => {} + } + tools + } + } + + async fn ids_for(catalog: &dyn RebornOperatorToolCatalog, user: &str) -> Vec { + catalog + .list_operator_tools(&UserId::new(user).expect("valid user id")) + .await + .into_iter() + .map(|info| info.capability_id.as_str().to_string()) + .collect() + } + + #[tokio::test] + async fn the_catalog_is_caller_scoped_so_a_private_install_cannot_cross_callers() { + // #5459 P1: the settings/tools routes are authenticated-caller routes, + // not operator-gated, so a member reads this catalog. The `caller` + // parameter is the disclosure control; a catalog that ignored it would + // hand every member every other member's private installs. + let catalog: Arc = Arc::new(OwnershipFilteredCatalog); + + let alice = ids_for(catalog.as_ref(), "alice").await; + let bob = ids_for(catalog.as_ref(), "bob").await; + + assert!(alice.contains(&SHARED.to_string())); + assert!(bob.contains(&SHARED.to_string())); + + assert!(alice.contains(&ALICE_PRIVATE.to_string())); + assert!( + !alice.contains(&BOB_PRIVATE.to_string()), + "alice must not see bob's private install: {alice:?}" + ); + assert!(bob.contains(&BOB_PRIVATE.to_string())); + assert!( + !bob.contains(&ALICE_PRIVATE.to_string()), + "bob must not see alice's private install: {bob:?}" + ); + assert_ne!(alice, bob, "the answer must be able to differ by caller"); + } + + #[tokio::test] + async fn a_caller_with_no_private_installs_still_gets_the_shared_set() { + // An empty-for-this-caller answer must be reachable and must not be an + // error, or a fresh tenant cannot render the settings surface at all. + let catalog: Arc = Arc::new(OwnershipFilteredCatalog); + assert_eq!( + ids_for(catalog.as_ref(), "carol").await, + vec![SHARED.to_string()] + ); + } +} diff --git a/crates/ironclaw_product_contracts/src/prompt_source.rs b/crates/ironclaw_product_contracts/src/prompt_source.rs new file mode 100644 index 00000000000..48d44f44c10 --- /dev/null +++ b/crates/ironclaw_product_contracts/src/prompt_source.rs @@ -0,0 +1,132 @@ +//! The gate-prompt enrichment ports (PROPOSAL §6.1.3). +//! +//! When a run parks on an approval or auth gate, the delivery path and the +//! projection layer both render a prompt. *What* is being approved, and *which +//! challenge* an auth gate is waiting on, are read models owned outside +//! product — the approval-request store and the pairing/auth engines — so both +//! arrive through ports. `ironclaw_extension_host` implements both over its +//! pairing registry and the approvals store. +//! +//! Never here: prompt *rendering* (product owns the view constructor), the +//! challenge engine, or any implementation of these ports. + +use async_trait::async_trait; +use ironclaw_extension_contracts::auth_prompt::AuthPromptView; +use ironclaw_host_api::decision::RuntimeCredentialAuthRequirement; +use ironclaw_host_api::ids::{InvocationId, UserId}; +use ironclaw_host_api::product_adapter_error::ProductAdapterError; +use ironclaw_host_api::turn::{TurnGateRef, TurnRunId, TurnScope}; + +use crate::outbound::ApprovalPromptContextView; + +/// Inputs for resolving a blocked-auth run's prompt view. One request shape +/// for every renderer (delivery path, projection layer); the challenge +/// provider is a separate argument, not request data. +pub struct BlockedAuthPromptRequest<'a> { + pub fallback_owner_user_id: &'a UserId, + pub scope: &'a TurnScope, + pub run_id: TurnRunId, + pub gate_ref: &'a str, + /// Invocation the blocked capability ran under, when the renderer has it + /// (the projection layer does; the delivery path renders without one). + pub invocation_id: Option, + pub body: String, + pub credential_requirements: &'a [RuntimeCredentialAuthRequirement], +} + +/// Approval-gate context enrichment: resolves WHAT is being approved +/// (tool/action/reason) for a gate ref — the same source the WebUI gate +/// projection reads. Implemented over the approval request store; `None` +/// results degrade prompts to generic wording. +#[async_trait] +pub trait ApprovalPromptContextSource: Send + Sync { + async fn approval_prompt_context( + &self, + gate_ref: &TurnGateRef, + owner_user_id: &UserId, + scope: &TurnScope, + ) -> Option; +} + +/// Auth-prompt enrichment: resolves the challenge (OAuth authorization URL +/// vs manual credential entry) for a run blocked on auth. Implemented over the +/// auth engine and the host-issued pairing registry. +#[async_trait] +pub trait BlockedAuthPromptSource: Send + Sync { + async fn auth_prompt_for_blocked_run( + &self, + request: BlockedAuthPromptRequest<'_>, + ) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + use ironclaw_host_api::ids::{TenantId, ThreadId}; + + static_assertions::assert_obj_safe!(ApprovalPromptContextSource, BlockedAuthPromptSource); + + struct NoEnrichment; + + #[async_trait] + impl ApprovalPromptContextSource for NoEnrichment { + async fn approval_prompt_context( + &self, + _gate_ref: &TurnGateRef, + _owner_user_id: &UserId, + _scope: &TurnScope, + ) -> Option { + None + } + } + + fn scope() -> TurnScope { + TurnScope::new( + TenantId::new("tenant-1").expect("valid tenant"), + None, + None, + ThreadId::new("thread-1").expect("valid thread"), + ) + } + + #[tokio::test] + async fn absent_approval_context_degrades_to_none_instead_of_erroring() { + // The port returns `Option`, not `Result`, on purpose: a gate whose + // context cannot be resolved still has to render a prompt, just a + // generic one. An error type here would let an enrichment miss take + // down the whole delivery. + let source: std::sync::Arc = + std::sync::Arc::new(NoEnrichment); + let gate_ref = TurnGateRef::new("gate-1").expect("valid gate ref"); + let owner = UserId::new("user-1").expect("valid user"); + assert!( + source + .approval_prompt_context(&gate_ref, &owner, &scope()) + .await + .is_none() + ); + } + + #[test] + fn a_blocked_auth_request_borrows_its_scope_and_requirements() { + // The request is a borrow-only view by design — it is built per render + // and must not force the caller to clone the scope or the requirement + // slice onto the heap for a prompt that may never be sent. + let owner = UserId::new("user-1").expect("valid user"); + let scope = scope(); + let request = BlockedAuthPromptRequest { + fallback_owner_user_id: &owner, + scope: &scope, + run_id: TurnRunId::new(), + gate_ref: "gate-1", + invocation_id: None, + body: "needs auth".to_string(), + credential_requirements: &[], + }; + assert_eq!(request.fallback_owner_user_id, &owner); + assert_eq!(request.gate_ref, "gate-1"); + assert!(request.credential_requirements.is_empty()); + assert!(request.invocation_id.is_none()); + } +} diff --git a/crates/ironclaw_product_contracts/src/views.rs b/crates/ironclaw_product_contracts/src/views.rs new file mode 100644 index 00000000000..a92efbdf4d4 --- /dev/null +++ b/crates/ironclaw_product_contracts/src/views.rs @@ -0,0 +1,168 @@ +//! The generic product-view conduit's descriptor types and provider port +//! (PROPOSAL §6.1.3). +//! +//! Product features register a read-only view instead of growing +//! `ProductSurface` with a feature-specific query method. The *inventory* of +//! concrete views is product's frozen surface and stays there; the descriptor, +//! the page envelope, and the port a view provider implements are boundary +//! vocabulary, because providers legitimately sit outside product — the +//! admin-configuration view is implemented by `ironclaw_extension_host`. +//! +//! Never here: any concrete view id, any provider implementation, or the +//! typed `ProductView` declaration wrapper (that carries product's frozen +//! request/response DTOs and stays with them). + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::surface::{ProductSurfaceCaller, ProductSurfaceError}; + +/// Stable metadata for one read-only product view. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RebornViewDescriptor { + pub id: &'static str, + pub paginated: bool, +} + +/// One registered, read-only product view invocation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RebornViewQuery { + pub view_id: String, + pub params: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +/// One page returned by the generic product view conduit. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RebornViewPage { + pub payload: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +/// One composition-supplied implementation behind the generic view conduit. +/// +/// Product features register descriptors and providers instead of growing +/// `ProductSurface` with feature-specific read methods. +#[async_trait] +pub trait RebornViewProvider: Send + Sync { + fn descriptor(&self) -> RebornViewDescriptor; + + async fn query( + &self, + caller: ProductSurfaceCaller, + params: serde_json::Value, + cursor: Option, + ) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + static_assertions::assert_obj_safe!(RebornViewProvider); + + /// Echoes all three conduit arguments back into the page so the test can + /// assert each one arrived. A provider that silently dropped the caller + /// could not scope its read, and one that dropped the params could not + /// answer the question that was asked — both are invisible to a double + /// that only echoes the cursor. + struct EchoingView; + + #[async_trait] + impl RebornViewProvider for EchoingView { + fn descriptor(&self) -> RebornViewDescriptor { + RebornViewDescriptor { + id: "test_view", + paginated: false, + } + } + + async fn query( + &self, + caller: ProductSurfaceCaller, + params: serde_json::Value, + cursor: Option, + ) -> Result { + Ok(RebornViewPage { + payload: serde_json::json!({ + "tenant": caller.tenant_id.as_str(), + "user": caller.user_id.as_str(), + "params": params, + "echoed_cursor": cursor, + }), + next_cursor: None, + }) + } + } + + #[test] + fn a_provider_declares_its_own_id_and_pagination_shape() { + let provider: std::sync::Arc = std::sync::Arc::new(EchoingView); + let descriptor = provider.descriptor(); + assert_eq!(descriptor.id, "test_view"); + assert!(!descriptor.paginated); + } + + #[test] + fn an_unpaginated_page_omits_next_cursor_on_the_wire() { + // `next_cursor: None` must not serialize: the browser treats a present + // cursor as "there is more", so emitting `null` would make every + // unpaginated view look paginated. + let page = RebornViewPage { + payload: serde_json::json!({"rows": []}), + next_cursor: None, + }; + assert_eq!( + serde_json::to_value(&page).expect("serialize"), + serde_json::json!({"payload": {"rows": []}}) + ); + } + + #[tokio::test] + async fn the_conduit_hands_the_provider_its_caller_params_and_cursor() { + // The three arguments are the whole conduit: the caller is the + // authorization subject a provider scopes its read by, the params are + // the view's typed request, and the cursor is the pagination position. + // Asserting all three is what makes this test fail if a future + // signature change drops one on the floor. + let provider: std::sync::Arc = std::sync::Arc::new(EchoingView); + let caller = ProductSurfaceCaller::new( + ironclaw_host_api::ids::TenantId::new("tenant-alpha").expect("tenant"), + ironclaw_host_api::ids::UserId::new("user-alpha").expect("user"), + None, + None, + ); + let page = provider + .query( + caller, + serde_json::json!({"limit": 10}), + Some("c1".to_string()), + ) + .await + .expect("provider answers"); + assert_eq!( + page.payload, + serde_json::json!({ + "tenant": "tenant-alpha", + "user": "user-alpha", + "params": {"limit": 10}, + "echoed_cursor": "c1", + }) + ); + assert!(page.next_cursor.is_none()); + } + + #[test] + fn a_view_query_round_trips_its_params_and_optional_cursor() { + let query = RebornViewQuery { + view_id: "test_view".to_string(), + params: serde_json::json!({"limit": 10}), + cursor: Some("c1".to_string()), + }; + let encoded = serde_json::to_value(&query).expect("serialize"); + let decoded: RebornViewQuery = serde_json::from_value(encoded).expect("round trip"); + assert_eq!(decoded, query); + } +} diff --git a/crates/ironclaw_reborn_composition/src/admin_token.rs b/crates/ironclaw_reborn_composition/src/admin_token.rs index 6d98f5b9c46..be3f4be860d 100644 --- a/crates/ironclaw_reborn_composition/src/admin_token.rs +++ b/crates/ironclaw_reborn_composition/src/admin_token.rs @@ -20,7 +20,7 @@ pub trait AdminApiTokenMinter: Send + Sync { } /// Fail-closed placeholder for composition paths that need an -/// [`AdminUserService`](ironclaw_product::AdminUserService) handle purely for +/// [`AdminUserService`](ironclaw_product_contracts::admin_users::AdminUserService) handle purely for /// tenant-scoped role reads (channel-command admission's `get_user` calls, /// which never mint tokens) rather than the WebUI admin `create_user` route. /// `RebornAdminUserDirectory::create_user` is the sole caller of the minter; diff --git a/crates/ironclaw_reborn_composition/src/admin_user_directory.rs b/crates/ironclaw_reborn_composition/src/admin_user_directory.rs index 0da61589873..614df1b291e 100644 --- a/crates/ironclaw_reborn_composition/src/admin_user_directory.rs +++ b/crates/ironclaw_reborn_composition/src/admin_user_directory.rs @@ -1,5 +1,5 @@ //! Composition adapter implementing the product-workflow -//! [`AdminUserService`](ironclaw_product::AdminUserService) port over +//! [`AdminUserService`](ironclaw_product_contracts::admin_users::AdminUserService) port over //! the Reborn identity user-directory + admin secret provisioner + a token //! minter. //! @@ -13,7 +13,7 @@ use std::sync::Arc; use async_trait::async_trait; use ironclaw_host_api::ids::{SecretHandle, TenantId, UserId}; -use ironclaw_product::{ +use ironclaw_product_contracts::admin_users::{ AdminCreateUserFields, AdminCreatedUser, AdminUserError, AdminUserRecord, AdminUserRole, AdminUserSecretMeta, AdminUserService, AdminUserStatus, }; diff --git a/crates/ironclaw_reborn_composition/src/deployment.rs b/crates/ironclaw_reborn_composition/src/deployment.rs index 90804a3d761..9c257ca3b43 100644 --- a/crates/ironclaw_reborn_composition/src/deployment.rs +++ b/crates/ironclaw_reborn_composition/src/deployment.rs @@ -32,6 +32,7 @@ use crate::readiness::{ RebornReadinessDiagnostic, RebornReadinessDiagnosticReason, RebornReadinessDiagnosticStatus, RebornReadinessState, }; +use ironclaw_product_contracts::account_setup::ExtensionAccountSetupDescriptor; impl RebornReadinessDiagnostic { pub fn disabled() -> Self { @@ -262,7 +263,7 @@ pub struct DeploymentConfig { pub(crate) oauth_dcr_callback: Option, pub(crate) nearai_mcp_bootstrap_config: Option, - pub(crate) account_setup_descriptors: Vec, + pub(crate) account_setup_descriptors: Vec, pub(crate) first_party_bundles: Vec, } diff --git a/crates/ironclaw_reborn_composition/src/extension_host_assembly.rs b/crates/ironclaw_reborn_composition/src/extension_host_assembly.rs index 22385725ae9..9c373b56302 100644 --- a/crates/ironclaw_reborn_composition/src/extension_host_assembly.rs +++ b/crates/ironclaw_reborn_composition/src/extension_host_assembly.rs @@ -10,11 +10,14 @@ use ironclaw_host_api::{ }; use ironclaw_host_runtime::{ExtensionLaneToolBinder, HostRuntimeHttpEgressPort}; use ironclaw_product::{ - ApprovalInteractionService, ApprovalPromptContextSource, AuthChallengeProvider, - AuthInteractionService, BlockedAuthFlowCanceller, BlockedAuthPromptSource, - ExtensionAccountSetupDescriptor, ExtensionAccountSetupRegistry, InboundAttachmentLander, + ApprovalInteractionService, AuthChallengeProvider, AuthInteractionService, + BlockedAuthFlowCanceller, ExtensionAccountSetupRegistry, InboundAttachmentLander, ProjectFilesystemReader, RunDeliverySettings, }; +use ironclaw_product_contracts::account_setup::ExtensionAccountSetupDescriptor; +use ironclaw_product_contracts::prompt_source::{ + ApprovalPromptContextSource, BlockedAuthPromptSource, +}; use ironclaw_resources::ResourceGovernor; use ironclaw_threads::{SessionThreadService, ThreadScope}; use ironclaw_turns::TurnCoordinator; @@ -23,6 +26,9 @@ use crate::RebornBuildError; use crate::factory::RebornRuntimeStores; use crate::input::ChannelExtensionBinding; use crate::outbound::MutableOutboundDeliveryTargetRegistry; +use ironclaw_product_contracts::account_setup::AccountConnectionStatusSource; +use ironclaw_product_contracts::admin_users::AdminUserService; +use ironclaw_product_contracts::delivery::ChannelDeliveryResolver; pub(crate) struct BackendExtensionHostAssemblyInput { pub(crate) binder: ExtensionLaneToolBinder, @@ -45,8 +51,7 @@ pub(crate) struct BackendExtensionHostAssembly { pub(crate) ingress: ironclaw_extension_host::extension_ingress::ExtensionIngressParts, pub(crate) installation_store: Arc, pub(crate) delivery_coordinator: Option>, - pub(crate) channel_delivery_resolver: - Option>, + pub(crate) channel_delivery_resolver: Option>, #[cfg(feature = "test-support")] pub(crate) channel_egress_credential_bridges: Arc, @@ -144,7 +149,7 @@ pub(crate) async fn build_backend_extension_host( ); let (delivery_coordinator, channel_delivery_resolver) = match channel_egress_transport { Some(transport) => { - let resolver: Arc = Arc::new( + let resolver: Arc = Arc::new( ironclaw_extension_host::SnapshotChannelDeliveryResolver::new( generic.host.snapshot_watch(), transport, @@ -302,7 +307,7 @@ pub(crate) async fn build_backend_channel_pairing( })); if !account_setups.connect( &descriptor.extension_id, - Arc::clone(&service) as Arc, + Arc::clone(&service) as Arc, ) { return Err(RebornBuildError::InvalidConfig { reason: format!( @@ -343,7 +348,7 @@ pub(crate) struct ChannelHostAssemblyWiring { pub(crate) blocked_auth_prompts: Option>, pub(crate) auth_flow_cancel: Option>, pub(crate) run_delivery_settings: RunDeliverySettings, - pub(crate) admin_users: Arc, + pub(crate) admin_users: Arc, } pub(crate) struct RuntimeExtensionHostAssemblyWiring<'a> { @@ -416,7 +421,7 @@ fn channel_host_source(services: &RebornRuntimeStores) -> Option Arc { +) -> Arc { let directory: Arc = crate::factory::filesystem_reborn_identity_store( Arc::clone(&services.scoped_filesystem), diff --git a/crates/ironclaw_reborn_composition/src/factory.rs b/crates/ironclaw_reborn_composition/src/factory.rs index 6278c42f5bd..6a15af844b8 100644 --- a/crates/ironclaw_reborn_composition/src/factory.rs +++ b/crates/ironclaw_reborn_composition/src/factory.rs @@ -174,10 +174,13 @@ use ironclaw_outbound::{ use ironclaw_processes::{ProcessConcurrencyLimits, ProcessJournalStore, ProcessServices}; use ironclaw_product::RebornProjectService; use ironclaw_product::{ - ChannelConnectionNoticePolicy, ChannelConnectionRequirement, ExtensionAccountSetupDescriptor, - ExtensionAccountSetupRegistry, LifecycleProductSurfaceContext, - OutboundPreferencesProductService, ProductAuthTurnGateResumeDispatcher, ProjectService, + ChannelConnectionRequirement, ExtensionAccountSetupRegistry, OutboundPreferencesProductService, + ProductAuthTurnGateResumeDispatcher, ProjectService, }; +use ironclaw_product_contracts::account_setup::{ + ChannelConnectionNoticePolicy, ExtensionAccountSetupDescriptor, +}; +use ironclaw_product_contracts::lifecycle_service::LifecycleProductSurfaceContext; use ironclaw_projects::ProjectRepository; use ironclaw_resources::InMemoryResourceGovernor; use ironclaw_resources::{ @@ -218,6 +221,7 @@ use trigger_creation_assembly::{ mod production_backend_assembly; mod production_build_assembly; mod runtime_lane_assembly; +use ironclaw_product_contracts::delivery::ChannelDeliveryResolver; #[cfg(any(test, feature = "test-support"))] use production_backend_assembly::build_libsql_production; #[cfg(test)] @@ -408,8 +412,7 @@ pub(crate) struct RebornRuntimeStores { /// The deployment-first channel delivery resolver behind the coordinator, /// exposed separately for host flows (e.g. DM target provisioning) that /// need one stable adapter + egress read outside a delivery. - pub(crate) channel_delivery_resolver: - Option>, + pub(crate) channel_delivery_resolver: Option>, /// Registry of beta-era channel credential bridges (§11 compatibility): /// channel hosts whose secrets predate the extension-config store /// register resolution ports here. @@ -421,7 +424,7 @@ pub(crate) struct RebornRuntimeStores { struct ChannelHostWiring { extension_ingress: Option, delivery_coordinator: Option>, - channel_delivery_resolver: Option>, + channel_delivery_resolver: Option>, #[cfg(feature = "test-support")] channel_egress_credential_bridges: Option>, diff --git a/crates/ironclaw_reborn_composition/src/factory/production_backend_assembly.rs b/crates/ironclaw_reborn_composition/src/factory/production_backend_assembly.rs index 846f1c6a545..029ac79670a 100644 --- a/crates/ironclaw_reborn_composition/src/factory/production_backend_assembly.rs +++ b/crates/ironclaw_reborn_composition/src/factory/production_backend_assembly.rs @@ -1,5 +1,7 @@ use super::with_shared_host_runtime_wiring; use super::*; +use ironclaw_product_contracts::lifecycle_service::LifecycleProductService; +use ironclaw_product_contracts::operator_tools::RebornOperatorToolCatalog; pub(crate) async fn build_libsql_production_host_runtime_services( config: crate::LibSqlProductionSubstrateConfig, @@ -978,17 +980,16 @@ pub(super) async fn build_backend_production( ); extension_management.attach_channel_config(&admin_configuration_resolver); admin_configuration_credential_slot.fill(Arc::clone(&admin_configuration_resolver)); - let lifecycle_continuation_facade: Arc = - Arc::new( - ironclaw_extension_host::ExtensionHostLifecycleProductService::new(Arc::clone( - &skill_management, - )) - .with_extension_management(Arc::clone(&extension_management)) - .with_channel_config(Arc::clone(&admin_configuration_resolver)) - .with_runtime_credential_accounts( - product_auth_dependencies.runtime_credential_account_selection_service(), - ), - ); + let lifecycle_continuation_facade: Arc = Arc::new( + ironclaw_extension_host::ExtensionHostLifecycleProductService::new(Arc::clone( + &skill_management, + )) + .with_extension_management(Arc::clone(&extension_management)) + .with_channel_config(Arc::clone(&admin_configuration_resolver)) + .with_runtime_credential_accounts( + product_auth_dependencies.runtime_credential_account_selection_service(), + ), + ); let lifecycle_wrapped_product_continuation = ironclaw_product::lifecycle_auth_continuation_dispatcher( lifecycle_continuation_facade, @@ -1077,7 +1078,7 @@ pub(super) async fn build_backend_production( })?, ] }; - let operator_tool_catalog: Arc = + let operator_tool_catalog: Arc = Arc::new(ActiveRegistryOperatorToolCatalog::new( services.shared_extension_registry(), operator_synthetic_tools, diff --git a/crates/ironclaw_reborn_composition/src/factory/production_build_assembly.rs b/crates/ironclaw_reborn_composition/src/factory/production_build_assembly.rs index aa7454f71d5..2c5c16716f0 100644 --- a/crates/ironclaw_reborn_composition/src/factory/production_build_assembly.rs +++ b/crates/ironclaw_reborn_composition/src/factory/production_build_assembly.rs @@ -1,4 +1,5 @@ use super::*; +use ironclaw_product_contracts::account_setup::ExtensionAccountSetupDescriptor; pub(super) async fn build_production_shaped( input: RebornHostBindings, @@ -336,7 +337,7 @@ pub(super) struct RebornProductionBuildContext { pub(super) process_concurrency_limits: ProcessConcurrencyLimits, pub(super) resolved_memory: crate::ResolvedMemoryProvider, pub(super) scheduler_wake_wiring: ironclaw_runner::runtime::SchedulerWakeWiring, - pub(super) account_setup_descriptors: Vec, + pub(super) account_setup_descriptors: Vec, pub(super) nearai_mcp_bootstrap_config: Option, pub(super) native_extension_factories: diff --git a/crates/ironclaw_reborn_composition/src/factory/test_support.rs b/crates/ironclaw_reborn_composition/src/factory/test_support.rs index 8ebc965de81..450e4522239 100644 --- a/crates/ironclaw_reborn_composition/src/factory/test_support.rs +++ b/crates/ironclaw_reborn_composition/src/factory/test_support.rs @@ -7,6 +7,7 @@ use ironclaw_host_api::{ ids::{CapabilityGrantId, ExtensionId}, scope::Principal, }; +use ironclaw_product_contracts::channel_config::ChannelConfigProductService; #[cfg(feature = "test-support")] use ironclaw_trust::{AuthorityCeiling, EffectiveTrustClass, TrustDecision, TrustProvenance}; @@ -326,9 +327,7 @@ impl RebornRuntimeStores { /// §6.4): the production surface the WebUI setup service and the /// lifecycle configure action route operator channel config through. /// `None` without a standalone runtime. - pub(crate) fn channel_config_service( - &self, - ) -> Option> { + pub(crate) fn channel_config_service(&self) -> Option> { let service = self.channel_config_service.clone(); Some(Arc::new( ironclaw_extension_host::RebornChannelConfigProductService::new(service), diff --git a/crates/ironclaw_reborn_composition/src/factory/tests.rs b/crates/ironclaw_reborn_composition/src/factory/tests.rs index c32364bf9ac..1ab96130ebf 100644 --- a/crates/ironclaw_reborn_composition/src/factory/tests.rs +++ b/crates/ironclaw_reborn_composition/src/factory/tests.rs @@ -2726,6 +2726,9 @@ async fn enable_global_auto_approve_for_context( } use crate::approval_test_support::disable_global_auto_approve; +use ironclaw_product_contracts::account_setup::{ + ChannelConnectionNoticePolicy, ExtensionAccountSetupDescriptor, +}; fn web_access_context(capability_id: &str) -> ExecutionContext { let extension_id = ExtensionId::new("caller").expect("valid extension id"); @@ -3279,10 +3282,8 @@ async fn channel_pairing_completions_run_the_lifecycle_wrapped_continuation_disp ); } -fn pairing_account_setup_descriptor( - extension_id: &str, -) -> ironclaw_product::ExtensionAccountSetupDescriptor { - ironclaw_product::ExtensionAccountSetupDescriptor { +fn pairing_account_setup_descriptor(extension_id: &str) -> ExtensionAccountSetupDescriptor { + ExtensionAccountSetupDescriptor { extension_id: ExtensionId::new(extension_id).expect("extension id"), auth_requirement: ironclaw_host_api::decision::RuntimeCredentialAuthRequirement { provider: VendorId::new(extension_id).expect("provider id"), @@ -3299,9 +3300,7 @@ fn pairing_account_setup_descriptor( submit_label: "Pair".to_string(), error_message: "Pairing failed.".to_string(), }, - connection_notices: ironclaw_product::ChannelConnectionNoticePolicy::generic( - "Pairing Fixture", - ), + connection_notices: ChannelConnectionNoticePolicy::generic("Pairing Fixture"), activation_success_message: "Pairing fixture connected.".to_string(), pairing_deep_link_template: None, inbound_code_prefixes: Vec::new(), diff --git a/crates/ironclaw_reborn_composition/src/input.rs b/crates/ironclaw_reborn_composition/src/input.rs index 7421a347e1a..366c923c879 100644 --- a/crates/ironclaw_reborn_composition/src/input.rs +++ b/crates/ironclaw_reborn_composition/src/input.rs @@ -27,6 +27,7 @@ use crate::Mem0ConnectionConfig; use crate::RebornBuildError; use crate::RebornCompositionProfile; use crate::deployment::DeploymentConfig; +use ironclaw_product_contracts::account_setup::ExtensionAccountSetupDescriptor; const DEFAULT_REBORN_POSTGRES_URL_ENV: &str = "IRONCLAW_REBORN_POSTGRES_URL"; const DEFAULT_REBORN_SECRET_MASTER_KEY_ENV: &str = "IRONCLAW_REBORN_SECRET_MASTER_KEY"; @@ -773,7 +774,7 @@ impl RebornHostBindings { /// Binary-assembled account-setup descriptors (see the field doc). pub fn with_account_setup_descriptors( mut self, - descriptors: Vec, + descriptors: Vec, ) -> Self { self.deployment.account_setup_descriptors = descriptors; self diff --git a/crates/ironclaw_reborn_composition/src/lib.rs b/crates/ironclaw_reborn_composition/src/lib.rs index e006e48a585..19bfc1637bd 100644 --- a/crates/ironclaw_reborn_composition/src/lib.rs +++ b/crates/ironclaw_reborn_composition/src/lib.rs @@ -120,14 +120,15 @@ pub use ironclaw_host_runtime::{ /// The channel-adapter contract the assembling binary implements is reached at /// its owner, `ironclaw_extension_contracts::channel_adapter` — WS1.4 deleted /// the re-export chain that gave it a second import path through here. -pub use ironclaw_product::{ - ChannelConnectionNoticePolicy, ChannelConnectionRequirement, ExtensionAccountSetupDescriptor, - RebornChannelConnectStrategy, -}; +pub use ironclaw_product::RebornChannelConnectStrategy; pub use ironclaw_product::{ LifecycleExtensionSource, LifecycleExtensionSummary, LifecycleProductPayload, LifecycleProductResponse, LifecycleSearchExtensionSummary, }; +pub use ironclaw_product_contracts::account_setup::{ + ChannelConnectionNoticePolicy, ExtensionAccountSetupDescriptor, +}; +pub use ironclaw_product_contracts::package_lifecycle::ChannelConnectionRequirement; pub use ironclaw_runner::failure_lane::{ALL_RUN_FAILURE_CATEGORIES, FailureLane, failure_lane}; pub use ironclaw_runner::runtime::DEFAULT_TURN_RUNNER_WORKER_COUNT; pub use ironclaw_runtime_policy::{ diff --git a/crates/ironclaw_reborn_composition/src/operator_tool_catalog.rs b/crates/ironclaw_reborn_composition/src/operator_tool_catalog.rs index 227f8166eba..7a24659f2d7 100644 --- a/crates/ironclaw_reborn_composition/src/operator_tool_catalog.rs +++ b/crates/ironclaw_reborn_composition/src/operator_tool_catalog.rs @@ -7,7 +7,9 @@ use ironclaw_host_api::{ ids::{ExtensionId, UserId}, runtime::RuntimeKind, }; -use ironclaw_product::{RebornOperatorToolCatalog, RebornOperatorToolInfo}; +use ironclaw_product_contracts::operator_tools::{ + RebornOperatorToolCatalog, RebornOperatorToolInfo, +}; use ironclaw_extension_host::extension_lifecycle::RebornLocalExtensionManagementPort; diff --git a/crates/ironclaw_reborn_composition/src/product_surface/tests.rs b/crates/ironclaw_reborn_composition/src/product_surface/tests.rs index 755603b8e10..c89bc373857 100644 --- a/crates/ironclaw_reborn_composition/src/product_surface/tests.rs +++ b/crates/ironclaw_reborn_composition/src/product_surface/tests.rs @@ -28,7 +28,10 @@ use ironclaw_host_api::{ }; use ironclaw_product::{ EXTENSION_INSTALL_CAPABILITY, EXTENSION_REMOVE_CAPABILITY, OPERATOR_SERVICE_LIFECYCLE_COMMAND, - ProductCapabilityDescriptor, RebornOperatorToolCatalog, RebornOperatorToolInfo, + ProductCapabilityDescriptor, +}; +use ironclaw_product_contracts::operator_tools::{ + RebornOperatorToolCatalog, RebornOperatorToolInfo, }; use ironclaw_product_contracts::surface::{ ProductSurface, ProductSurfaceCaller, ProductSurfaceError, diff --git a/crates/ironclaw_reborn_composition/src/runtime.rs b/crates/ironclaw_reborn_composition/src/runtime.rs index 8846988a276..29307c73ab7 100644 --- a/crates/ironclaw_reborn_composition/src/runtime.rs +++ b/crates/ironclaw_reborn_composition/src/runtime.rs @@ -71,9 +71,10 @@ use ironclaw_product::{ ApprovalBlockedTurnRun, ApprovalInteractionScope, ApprovalInteractionService, ApprovalResolverPort, ApprovalTurnRunLocator, AuthInteractionService, DefaultApprovalInteractionService, DefaultAuthInteractionService, - LifecycleProductSurfaceContext, OutboundPreferencesProductService, - PersistentApprovalGranteeResolver, RunStateApprovalInteractionReadModel, + OutboundPreferencesProductService, PersistentApprovalGranteeResolver, + RunStateApprovalInteractionReadModel, }; +use ironclaw_product_contracts::lifecycle_service::LifecycleProductSurfaceContext; use ironclaw_product_contracts::projection::ProjectionStream; use ironclaw_product_contracts::surface::ProductSurface; use ironclaw_runner::loop_exit_applier::{ @@ -403,6 +404,10 @@ pub use skills::{ use skills::skill_asset_error; use ironclaw_operator::ResolvedRebornLlm; +use ironclaw_product_contracts::account_setup::ChannelConnectionNoticePolicy; +use ironclaw_product_contracts::admin_users::AdminUserService; +use ironclaw_product_contracts::channel_config::ChannelConfigProductService; +use ironclaw_product_contracts::delivery::ChannelDeliveryResolver; /// Stable identifier for a Reborn CLI conversation. Wraps a `ThreadId`. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -589,8 +594,7 @@ pub struct RebornRuntime { #[cfg(any(test, feature = "test-support"))] pub(crate) deployment_channels: Arc, pub(crate) channel_pairing: Option>, - pub(crate) channel_delivery_resolver: - Option>, + pub(crate) channel_delivery_resolver: Option>, #[cfg(feature = "test-support")] pub(crate) channel_egress_credential_bridges: Option>, @@ -1048,7 +1052,7 @@ impl RebornRuntime { channel_config: Arc::clone(&self.channel_config_service), channel_pairing: self.channel_pairing.clone(), }; - let admin_users: Arc = + let admin_users: Arc = Arc::new(crate::admin_user_directory::RebornAdminUserDirectory::new( self.reborn_user_directory(), self.reborn_admin_secret_provisioner(), @@ -1175,7 +1179,7 @@ impl RebornRuntime { pub fn pairing_connection_notices_for_test( &self, extension_id: &str, - ) -> Option { + ) -> Option { let service = self.channel_pairing.as_ref()?.get(extension_id)?; Some(service.connection_notices().clone()) } @@ -1194,9 +1198,7 @@ impl RebornRuntime { } #[cfg(any(test, feature = "test-support"))] - pub fn channel_config_service( - &self, - ) -> Option> { + pub fn channel_config_service(&self) -> Option> { Some(Arc::new( ironclaw_extension_host::RebornChannelConfigProductService::new(Arc::clone( &self.channel_config_service, diff --git a/crates/ironclaw_reborn_composition/src/runtime/tests/core.rs b/crates/ironclaw_reborn_composition/src/runtime/tests/core.rs index 54742c33a95..b639992f73e 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/tests/core.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/tests/core.rs @@ -550,12 +550,13 @@ use ironclaw_product::{ ProductSurfaceCommandDescriptor, RESOLVE_GATE_COMMAND, RebornExtensionCredentialSetup, RebornOutboundPreferencesResponse, RebornSetupExtensionResponse, RebornSkillListResponse, RebornStreamEventsRequest, RebornStreamEventsResponse, RebornSubmitTurnResponse, - RebornViewPage, RebornViewQuery, SUBMIT_TURN_COMMAND, approval_gate_ref, + SUBMIT_TURN_COMMAND, approval_gate_ref, }; use ironclaw_product::{ProductOutboundPayload, ProductProjectionItem}; use ironclaw_product_contracts::surface::{ ProductSurfaceCaller, ProductSurfaceError, ProductSurfaceErrorCode, ProductSurfaceErrorKind, }; +use ironclaw_product_contracts::views::{RebornViewPage, RebornViewQuery}; use ironclaw_skills::SkillTrust; use ironclaw_threads::{ AppendToolResultReferenceRequest, EnsureThreadRequest, LoadContextMessagesRequest, MessageKind, @@ -5251,7 +5252,7 @@ async fn query_webui_extension_setup( let page = query_product_surface_page( api, caller, - ironclaw_product::RebornViewQuery { + ironclaw_product_contracts::views::RebornViewQuery { view_id: ironclaw_product::EXTENSION_SETUP_VIEW.id.to_string(), params: serde_json::json!({ "package_id": package_id }), cursor: None, @@ -5568,7 +5569,7 @@ async fn standalone_webui_bundle_exposes_outbound_preferences_service() { let cleared_page = query_product_surface_page( bundle.as_ref(), caller.clone(), - ironclaw_product::RebornViewQuery { + ironclaw_product_contracts::views::RebornViewQuery { view_id: ironclaw_product::OUTBOUND_PREFERENCES_VIEW.id.to_string(), params: serde_json::json!({}), cursor: None, @@ -5583,7 +5584,7 @@ async fn standalone_webui_bundle_exposes_outbound_preferences_service() { let targets_page = query_product_surface_page( bundle.as_ref(), caller, - ironclaw_product::RebornViewQuery { + ironclaw_product_contracts::views::RebornViewQuery { view_id: ironclaw_product::OUTBOUND_DELIVERY_TARGETS_VIEW .id .to_string(), @@ -5656,7 +5657,7 @@ async fn standalone_webui_bundle_invokes_skill_install_with_scoped_mounts() { let skills_page = query_product_surface_page( bundle.as_ref(), caller, - ironclaw_product::RebornViewQuery { + ironclaw_product_contracts::views::RebornViewQuery { view_id: ironclaw_product::SKILLS_VIEW.id.to_string(), params: serde_json::json!({}), cursor: None, @@ -5869,7 +5870,7 @@ async fn runtime_product_surface_without_local_runtime_still_lists_automations_f let response = query_product_surface_page( bundle.as_ref(), caller, - ironclaw_product::RebornViewQuery { + ironclaw_product_contracts::views::RebornViewQuery { view_id: ironclaw_product::AUTOMATIONS_VIEW.id.to_string(), params: serde_json::to_value(ProductListAutomationsRequest::default()) .expect("automation list params"), @@ -6256,9 +6257,9 @@ async fn standalone_webui_bundle_records_selectable_filesystem_skill_context() { /// candidates carry the same (prompt-stage) surface version and the run completes. #[tokio::test] async fn multi_tool_call_response_survives_surface_change_mid_register() { - use ironclaw_product::{ - LifecycleProductAction, LifecycleProductContext, LifecycleProductService, - LifecycleProductSurfaceContext, + use ironclaw_product::LifecycleProductAction; + use ironclaw_product_contracts::lifecycle_service::{ + LifecycleProductContext, LifecycleProductService, LifecycleProductSurfaceContext, }; use std::sync::OnceLock; diff --git a/crates/ironclaw_reborn_composition/tests/production_runtime_automations.rs b/crates/ironclaw_reborn_composition/tests/production_runtime_automations.rs index 618ac8e262c..c66b386d692 100644 --- a/crates/ironclaw_reborn_composition/tests/production_runtime_automations.rs +++ b/crates/ironclaw_reborn_composition/tests/production_runtime_automations.rs @@ -30,6 +30,7 @@ use ironclaw_product::{ AUTOMATIONS_VIEW, ProductListAutomationsRequest, RebornListAutomationsResponse, }; use ironclaw_product_contracts::surface::ProductSurfaceCaller; +use ironclaw_product_contracts::views::RebornViewPage; use ironclaw_reborn_composition::{ RebornCompositionProfile, RebornRuntimeIdentity, RebornRuntimeInput, RebornRuntimeProcessBinding, build_reborn_runtime, @@ -138,7 +139,7 @@ async fn production_runtime_webui_serves_automations_without_local_runtime() { ) .await .expect("production automation service must be reachable (not 503)"); - let result = ironclaw_product::RebornViewPage { + let result = RebornViewPage { payload: result .items .into_iter() diff --git a/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs b/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs index ab043555faf..c267c3b6fe3 100644 --- a/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs +++ b/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs @@ -33,12 +33,13 @@ use ironclaw_product::{ ProductResolveGateRequest, ProductSubmitTurnRequest, RebornCancelRunResponse, RebornCreateThreadResponse, RebornDeleteThreadRequest, RebornListThreadsResponse, RebornSetupExtensionResponse, RebornSubmitTurnResponse, RebornTimelineResponse, - RebornTraceCreditsResponse, RebornViewQuery, THREAD_DELETE_CAPABILITY_ID, THREADS_VIEW, - TIMELINE_VIEW, TRACE_CREDITS_VIEW, + RebornTraceCreditsResponse, THREAD_DELETE_CAPABILITY_ID, THREADS_VIEW, TIMELINE_VIEW, + TRACE_CREDITS_VIEW, }; use ironclaw_product_contracts::surface::{ ProductSurfaceCaller, ProductSurfaceError, ProductSurfaceErrorCode, ProductSurfaceErrorKind, }; +use ironclaw_product_contracts::views::RebornViewQuery; use ironclaw_threads::{SessionThreadRecord, ThreadScope}; use ironclaw_turns::{EventCursor, RunProfileId, RunProfileVersion, TurnRunId, TurnStatus}; use ironclaw_webui::{ diff --git a/crates/ironclaw_webui/src/webui_v2/handlers.rs b/crates/ironclaw_webui/src/webui_v2/handlers.rs index 69603fa34f7..a4d124ca8ee 100644 --- a/crates/ironclaw_webui/src/webui_v2/handlers.rs +++ b/crates/ironclaw_webui/src/webui_v2/handlers.rs @@ -92,15 +92,16 @@ use ironclaw_product::{ RebornSkillListResponse, RebornSkillSearchResponse, RebornSubmitTurnResponse, RebornTimelineRequest, RebornTimelineResponse, RebornTraceCreditsResponse, RebornTraceHoldAuthorizeProductRequest, RebornTraceHoldAuthorizeResponse, - RebornUpdateMemberRoleRequest, RebornUpdateProjectRequest, RebornViewDescriptor, - RebornViewPage, RebornViewQuery, SKILL_AUTO_ACTIVATE_LEARNED_SET_CAPABILITY, - SKILL_AUTO_ACTIVATE_SET_CAPABILITY, SKILL_CONTENT_VIEW, SKILL_INSTALL_CAPABILITY, - SKILL_REMOVE_CAPABILITY, SKILL_SEARCH_VIEW, SKILL_UPDATE_CAPABILITY, SKILLS_VIEW, - SUBMIT_TURN_COMMAND, SetActiveLlmRequest, SettingsToolPermissionState, - THREAD_DELETE_CAPABILITY, THREADS_VIEW, TIMELINE_VIEW, TRACE_ACCOUNT_LOGIN_LINK_COMMAND, - TRACE_ACCOUNT_TRACES_VIEW, TRACE_CREDITS_VIEW, TRACE_HOLD_AUTHORIZE_COMMAND, - UpsertLlmProviderRequest, product_attachment_capabilities, project_public_lifecycle_states, + RebornUpdateMemberRoleRequest, RebornUpdateProjectRequest, + SKILL_AUTO_ACTIVATE_LEARNED_SET_CAPABILITY, SKILL_AUTO_ACTIVATE_SET_CAPABILITY, + SKILL_CONTENT_VIEW, SKILL_INSTALL_CAPABILITY, SKILL_REMOVE_CAPABILITY, SKILL_SEARCH_VIEW, + SKILL_UPDATE_CAPABILITY, SKILLS_VIEW, SUBMIT_TURN_COMMAND, SetActiveLlmRequest, + SettingsToolPermissionState, THREAD_DELETE_CAPABILITY, THREADS_VIEW, TIMELINE_VIEW, + TRACE_ACCOUNT_LOGIN_LINK_COMMAND, TRACE_ACCOUNT_TRACES_VIEW, TRACE_CREDITS_VIEW, + TRACE_HOLD_AUTHORIZE_COMMAND, UpsertLlmProviderRequest, product_attachment_capabilities, + project_public_lifecycle_states, }; +use ironclaw_product_contracts::views::{RebornViewDescriptor, RebornViewPage, RebornViewQuery}; use secrecy::ExposeSecret; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -126,6 +127,7 @@ use crate::webui_v2::error::WebUiV2HttpError; use crate::webui_v2::router::{WebUiV2Capabilities, WebUiV2State}; use crate::webui_v2::schema::{WebChatV2Event, WebChatV2EventFrame}; use crate::webui_v2::sse_capacity::{SSE_MAX_LIFETIME, SseSlot}; +use ironclaw_product_contracts::admin_users::AdminUserSecretMeta; // Session bootstrap must stay cheap and non-blocking: this flag only tunes // initial approval UI state. It is mutable through `/settings/tools`, so do @@ -307,7 +309,7 @@ async fn read_admin_user_secret( caller: ProductSurfaceCaller, user_id: UserId, handle: String, -) -> Result { +) -> Result { let surface = ironclaw_product_contracts::surface::BoundProductSurface::new( std::sync::Arc::clone(services), caller, diff --git a/crates/ironclaw_webui/tests/support/product_surface.rs b/crates/ironclaw_webui/tests/support/product_surface.rs index d6191e44a3f..2e986094c01 100644 --- a/crates/ironclaw_webui/tests/support/product_surface.rs +++ b/crates/ironclaw_webui/tests/support/product_surface.rs @@ -10,11 +10,11 @@ use ironclaw_host_api::{ }; use ironclaw_product::{ RebornGetRunStateRequest, RebornStreamEventsRequest, RebornStreamEventsResponse, - RebornViewPage, RebornViewQuery, }; use ironclaw_product_contracts::surface::{ ProductSurface, ProductSurfaceCaller, ProductSurfaceError, }; +use ironclaw_product_contracts::views::{RebornViewPage, RebornViewQuery}; type InvokeHandler = dyn Fn( ProductSurfaceCaller, diff --git a/crates/ironclaw_webui/tests/webui_v2_handlers_contract.rs b/crates/ironclaw_webui/tests/webui_v2_handlers_contract.rs index 293c7d2dd2b..87246b5edad 100644 --- a/crates/ironclaw_webui/tests/webui_v2_handlers_contract.rs +++ b/crates/ironclaw_webui/tests/webui_v2_handlers_contract.rs @@ -44,8 +44,7 @@ use ironclaw_product::{ ADMIN_USER_DELETE_CAPABILITY_ID, ADMIN_USER_PUT_SECRET_CAPABILITY_ID, ADMIN_USER_SECRETS_VIEW, ADMIN_USER_SET_ROLE_CAPABILITY_ID, ADMIN_USER_SET_STATUS_CAPABILITY_ID, ADMIN_USER_UPDATE_CAPABILITY_ID, ADMIN_USER_VIEW, ADMIN_USERS_VIEW, AUTOMATIONS_VIEW, - AdminUserRecord, AdminUserRole, AdminUserSecretMeta, AdminUserStatus, CodexLoginStart, - EXTENSION_IMPORT_CAPABILITY_ID, EXTENSION_INSTALL_CAPABILITY_ID, + CodexLoginStart, EXTENSION_IMPORT_CAPABILITY_ID, EXTENSION_INSTALL_CAPABILITY_ID, EXTENSION_REGISTER_HOSTED_MCP_CAPABILITY_ID, EXTENSION_REGISTRY_VIEW, EXTENSION_REMOVE_CAPABILITY_ID, EXTENSION_SETUP_SUBMIT_CAPABILITY_ID, EXTENSION_SETUP_VIEW, EXTENSIONS_VIEW, FS_LIST_VIEW, FS_MOUNTS_VIEW, FS_STAT_VIEW, FsMount, GLOBAL_AUTO_APPROVE_VIEW, @@ -100,13 +99,12 @@ use ironclaw_product::{ RebornSkillSearchResponse, RebornStreamEventsRequest, RebornStreamEventsResponse, RebornSubmitTurnResponse, RebornThreadArtifact, RebornThreadArtifactRequest, RebornTimelineRequest, RebornTimelineResponse, RebornTraceCreditsResponse, - RebornTraceHoldAuthorizeProductRequest, RebornTraceHoldAuthorizeResponse, RebornViewPage, - RebornViewQuery, RunArtifactLogs, RunArtifactRedaction, - SKILL_AUTO_ACTIVATE_LEARNED_SET_CAPABILITY_ID, SKILL_AUTO_ACTIVATE_SET_CAPABILITY_ID, - SKILL_CONTENT_VIEW, SKILL_INSTALL_CAPABILITY_ID, SKILL_REMOVE_CAPABILITY_ID, SKILL_SEARCH_VIEW, - SKILL_UPDATE_CAPABILITY_ID, SKILLS_VIEW, THREAD_ARTIFACT_SCHEMA, THREAD_ARTIFACT_VIEW, - THREAD_DELETE_CAPABILITY_ID, THREADS_VIEW, TIMELINE_VIEW, TRACE_ACCOUNT_TRACES_VIEW, - TRACE_CREDITS_VIEW, rejecting_product_surface_error, + RebornTraceHoldAuthorizeProductRequest, RebornTraceHoldAuthorizeResponse, RunArtifactLogs, + RunArtifactRedaction, SKILL_AUTO_ACTIVATE_LEARNED_SET_CAPABILITY_ID, + SKILL_AUTO_ACTIVATE_SET_CAPABILITY_ID, SKILL_CONTENT_VIEW, SKILL_INSTALL_CAPABILITY_ID, + SKILL_REMOVE_CAPABILITY_ID, SKILL_SEARCH_VIEW, SKILL_UPDATE_CAPABILITY_ID, SKILLS_VIEW, + THREAD_ARTIFACT_SCHEMA, THREAD_ARTIFACT_VIEW, THREAD_DELETE_CAPABILITY_ID, THREADS_VIEW, + TIMELINE_VIEW, TRACE_ACCOUNT_TRACES_VIEW, TRACE_CREDITS_VIEW, rejecting_product_surface_error, }; use ironclaw_product::{ AdapterInstallationId, CapabilityActivityStatusView, CapabilityActivityView, @@ -114,11 +112,15 @@ use ironclaw_product::{ ProductOutboundPayload, ProductOutboundTarget, ProductProjectionItem, ProductProjectionState, ProgressKind, ProgressUpdateView, ProjectionCursor, }; +use ironclaw_product_contracts::admin_users::{ + AdminUserRecord, AdminUserRole, AdminUserSecretMeta, AdminUserStatus, +}; use ironclaw_product_contracts::surface::{ ProductSurface, ProductSurfaceCaller, ProductSurfaceError, ProductSurfaceErrorCode, ProductSurfaceErrorKind, ProductSurfaceEventSubscription, ProductSurfaceStreamResponse, ProductSurfaceValidationCode, }; +use ironclaw_product_contracts::views::{RebornViewPage, RebornViewQuery}; use ironclaw_threads::SessionThreadRecord; use ironclaw_turns::{ AcceptedMessageRef, EventCursor, ReplyTargetBindingRef, RunProfileId, RunProfileVersion, diff --git a/docs/plans/composition-pubuse.snapshot b/docs/plans/composition-pubuse.snapshot index 370c5d47290..3e0f7e7f1aa 100644 --- a/docs/plans/composition-pubuse.snapshot +++ b/docs/plans/composition-pubuse.snapshot @@ -40,14 +40,15 @@ pub use ironclaw_host_runtime::{ FirstPartyCapabilityError, FirstPartyCapabilityHandler, FirstPartyCapabilityRegistry, FirstPartyCapabilityRequest, FirstPartyCapabilityResult, ProductAuthProviderRuntimePorts, }; -pub use ironclaw_product::{ - ChannelConnectionNoticePolicy, ChannelConnectionRequirement, ExtensionAccountSetupDescriptor, - RebornChannelConnectStrategy, -}; +pub use ironclaw_product::RebornChannelConnectStrategy; pub use ironclaw_product::{ LifecycleExtensionSource, LifecycleExtensionSummary, LifecycleProductPayload, LifecycleProductResponse, LifecycleSearchExtensionSummary, }; +pub use ironclaw_product_contracts::account_setup::{ + ChannelConnectionNoticePolicy, ExtensionAccountSetupDescriptor, +}; +pub use ironclaw_product_contracts::package_lifecycle::ChannelConnectionRequirement; pub use ironclaw_runner::failure_lane::{ALL_RUN_FAILURE_CATEGORIES, FailureLane, failure_lane}; pub use ironclaw_runner::runtime::DEFAULT_TURN_RUNNER_WORKER_COUNT; pub use ironclaw_runtime_policy::{ diff --git a/docs/reborn/target-architecture/CHECKLIST.md b/docs/reborn/target-architecture/CHECKLIST.md index ab694cff6f1..b2a767012c0 100644 --- a/docs/reborn/target-architecture/CHECKLIST.md +++ b/docs/reborn/target-architecture/CHECKLIST.md @@ -70,7 +70,18 @@ Conventions: every code item lands with its tests and its guidance updates in th ## WS2 — Extensions family (kills `extension_host→product`; packages colocated) -- [ ] Flip `extension_host`'s implemented ports to `product_contracts`/`extension_contracts` definitions (delivery resolver, reply-context source, admission, pairing sources, account-status) — **must land before the layer flip** (ordering constraint, PROPOSAL §12.1c). +- [x] Flip `extension_host`'s implemented ports to `product_contracts`/`extension_contracts` definitions (delivery resolver, reply-context source, admission, pairing sources, account-status) — **must land before the layer flip** (ordering constraint, PROPOSAL §12.1c). **Landed with the WS2.1 PR.** All five named families moved, plus six more §6.1.3 names the row did not list — **eleven port declarations relocated, nine of them implemented by `extension_host`** (the nine are pinned as `INVERTED_PORTS` in the new `reborn_extension_host_port_inversion.rs`; `AdminUserService` and `RebornOperatorToolCatalog` are the two `extension_host` only *consumes*, implemented in composition): `ChannelDeliveryResolver`+`ResolvedChannelDelivery`, `DeliveryReplyContextSource`, `CommandActorRoleResolver` (+`ProductCommandContext`, `ProductActionId`, `ActionFingerprintKey`, the bounded product tokens), `ApprovalPromptContextSource`+`BlockedAuthPromptSource`(+`BlockedAuthPromptRequest`), `AccountConnectionStatusSource` (+the account-setup descriptor/notice/error DTOs), `ChannelConfigProductService`, `RebornViewProvider`(+descriptor/query/page), `LifecycleProductService`(+its two contexts), `AdminUserService`(+records), `RebornOperatorToolCatalog`(+info). Four dispositions the lead sheet did not predict: + 1. **The row's headline effect is not reachable in this slot, and the reason is structural.** The wave milestone says the `extension_host → product` edge dies here. It does not: **62 product symbols across 35 production files survive** (down from 146 across 46). Every survivor is owned by a *later* WS2 row, and the mapping is exact — `channel_host.rs` constructs product's concrete assembly (`DefaultProductSurface`, `DefaultInboundTurnService`, `RebornFilesystemIdempotencyLedger`, `StaticProductInstallationResolver`, `DirectConversationCommandAdmission::new`, `ProductConversationBindingService::new`); `admin_configuration*.rs`/`operator_config_capability.rs`/`skill_auto_activate_capability.rs`/`available_extensions.rs`/`product_lifecycle.rs`/`webui_extension_credentials.rs` are the `extension_manager` split inventory; `channel_lifecycle.rs`/`available_extensions.rs`/`host_api_contracts.rs` reach `product::adapter_registry`, which §6.9.1 sheds to `extension_contracts`/`extension_registry`; `skill_learning.rs` and `channel_triggered_delivery.rs` are named strays. **The edge dies at the end of WS2, not at this row** — which is exactly what §12.1c's ordering says ("port inversions land *before* the layer flip"), and PLAN's per-row milestone overstated it. + 2. **Six ports could not move, all for one mechanical reason.** `product_contracts`' allowlist is `host_api` + `extension_contracts` and nothing else internal, so a port whose signature names `ironclaw_auth`, `ironclaw_threads`, `ironclaw_turns`, or `ironclaw_conversations` cannot be declared there: `AuthChallengeProvider`, `ChannelConnectionService`, `ExtensionCredentialSetupService` (auth credential vocabulary), and `ConversationBindingService`, `ProductActorUserResolver`, `ProductConversationSubjectRouteResolver` (they error with `ProductSurfaceFailure`, which carries `ironclaw_turns::TurnError`). The residue is enumerated with per-entry reasons and held **shrink-only** by the new `reborn_extension_host_port_inversion.rs`; the durable finding is that **`ProductSurfaceFailure` is the linchpin** — `extension_host` uses product's *internal workflow error type* as its own lifecycle error vocabulary in 19 production files, and no port that errors with it can be inverted while the type lives in `ironclaw_product`. ✎ **Corrected 2026-08-01 (was: "until it is either narrowed off `ironclaw_turns` or replaced by an extension-host-owned error"):** both of those are insufficient — the enforced allowlist admits only `host_api` and `extension_contracts`, never product and never the extension host — so the fix is a port-facing error defined in an allowed crate and mapped to `ProductSurfaceFailure` inside product. See the dedicated row below. + 3. **`ProductCommandAdmissionService` is a §6.1.3-vs-§6.9.1 conflict, recorded not decided.** §6.1.3 assigns "the `ProductCommandAdmissionService` shape" to contracts; its `admit` takes `&ProductCommand`, and §6.9.1 keeps the command grammar with product's frozen inventory. Left in `ironclaw_product`. + 4. **`extension_host` carries no `LAYER_MATRIX_EXCEPTIONS` entry, and never did.** The wave's "exception entries it carries fall" expectation is wrong at the mechanism level: `extension_host` and `product` are both `products`-layer, so the edge is *legal* by the matrix and invisible to it. Re-verified against this PR's base: the register holds 13 and none of the 13 names `extension_host`, `product`, or `product_contracts`. **The count stays 13.** What this PR removes instead is 84 facade symbols and nine mis-placed port declarations, pinned by a new architecture test rather than by the exception register. + ✎ **Coverage-gate note for every later Wave 2/3 slot (added 2026-08-01 by WS2.1).** A move-shaped PR reliably fails `reborn_changed_coverage.py` on its first CI run, and the failure has three distinct shapes that need three different answers. Budget one extra CI cycle for it. (1) **A relocated declaration-only module reports "absent from coverage or contain no DA records"** — rustc emits no LCOV source record for a file of pure traits and DTOs. The answer is a contract test in the module (object safety, argument pass-through, the absence-without-error shape), not a waiver; WS2.1 closed five modules this way. (2) **A file reports "contributed no instrumented lines"** when the only lines the diff added are *type positions* — a struct field, a function parameter, or a return type whose path was repointed. Those carry no LLVM coverage region and can never be covered; they are the exemption class WS1.1/WS1.4 established, filed under #6963. (3) **Genuine holes hide behind the noise.** Relocating a symbol re-writes the line that names it, which drags previously-uncovered *error* paths into the diff: WS2.1 found two fail-closed seams that way (`AccountConnectionStatusSource::connected`'s sanitization, and the lifecycle output-decode mapping) and one dead branch arm. Those are the real find, so read the uncovered-lines list before reaching for the manifest. **Two mechanical tips that shrink all three:** prefer a `use` import to an inline fully-qualified path — a `use` line is uninstrumentable by construction, and it also keeps the rest of the statement textually unchanged, so it stays diff context; and extract an inline `map_err` closure into a named function when the repoint re-wraps it, which makes the mapping testable instead of exemptible. +- [ ] **Give the product-side ports a contract-nameable error — the linchpin slice, added 2026-08-01 by WS2.1 review triage, corrected the same day.** Row 1's disposition 2 named this as the durable finding but left it without a row of its own. ✎ **The resolution it stated — "narrowed off `ironclaw_turns` or replaced by an extension-host-owned error" — is wrong, and review caught it: neither is sufficient.** `product_contracts`' allowlist is enforced as a *whitelist* of exactly `{ironclaw_product_contracts, ironclaw_extension_contracts, ironclaw_host_api}` (`reborn_dependency_boundaries.rs:362-374`, whose own comment reads "**Never product, never operator, never the extension host**"). `ProductSurfaceFailure` is defined in `ironclaw_product` (`crates/ironclaw_product/src/error.rs:46`), so a port erroring with it is undeclarable in contracts **whatever it carries** — narrowing it off `ironclaw_turns::TurnError` (`error.rs:10,90,112`) removes one obstacle but not the one that binds, and an `extension_host`-owned error is barred by the same list. **The actual work:** define the port-facing error in an allowed crate — `host_api` (the neutral choice) or `extension_contracts` — and map it to `ProductSurfaceFailure` inside `ironclaw_product`, where the workflow type stays. Narrowing off `ironclaw_turns` is then a *sub-goal*, not the goal: it is what lets the neutral error be defined without dragging turn vocabulary into contracts. Payload: unblock the six ports row 1 could not move — `AuthChallengeProvider`, `ChannelConnectionService`, `ExtensionCredentialSetupService` (blocked instead by `ironclaw_auth` credential vocabulary, which needs the same treatment) and `ConversationBindingService`, `ProductActorUserResolver`, `ProductConversationSubjectRouteResolver` (blocked by the error) — and shrink the `reborn_extension_host_port_inversion.rs` residue baseline accordingly. It also has to reckon with the same type being `extension_host`'s own lifecycle error vocabulary across 19 production files, which the mapping layer has to absorb rather than duplicate. +- [ ] **The four WS2.1 follow-ups — three typed-signature corrections and one status-code flip — independent of the linchpin row above, and of each other.** ✎ *Split out of that row 2026-08-01 after review pointed out that bundling them behind the blocker would hold independent work hostage; the original grouping was scheduling convenience ("the slice is already in these files"), not a dependency. Re-titled the same day: the first three are type changes, the fourth is a behavior change, and "signatures" covered only the first three.* All four were **relocated verbatim** by WS2.1 under PLAN operating principle 2 (no semantic change in a move PR) — that is their shared provenance, not a shared fix. The first three need a typed replacement plus a caller-level test at the seam named; the fourth changes a live HTTP status and is sized separately below. None of the four depends on the linchpin row: every replacement type already sits in a crate `product_contracts` may name. + - `product_contracts::prompt_source::BlockedAuthPromptRequest.gate_ref: &str` → `&TurnGateRef`. `TurnGateRef` is `host_api::turn` vocabulary the module already imports, and the sibling `ApprovalPromptContextSource::approval_prompt_context` already takes `&TurnGateRef` — the inconsistency is visible in one file. + - `product_contracts::lifecycle_service::LifecycleProductService::installed_activation_errors` → `HashMap`. The doc says "keyed by extension id"; `ExtensionHostLifecycleProductService` already holds typed `ExtensionId` and stringifies only to satisfy the signature. + - `product_contracts::delivery::ResolvedChannelDelivery.{extension_id, installation_id}: String` → distinct newtypes, so an identity mixup fails to compile. + - **Behavior change, not a signature correction — size it as one.** `LifecycleProductService::import_extension_bundle`'s default returns `ProductSurfaceErrorCode::InvalidRequest`/400 while its prose said "unavailable": different code, different meaning, and 400 blames the caller for a capability the runtime never wired. `UnsupportedLifecycleProductService` does **not** override it (`crates/ironclaw_product/src/lifecycle.rs:75`) and composition installs that service (`reborn_services.rs:2470`), so the default is the live status on the WebUI "Install Tool" route. Flipping it to `unavailable(false)` needs the WebUI path re-checked, and the assertion in `bundle_import_defaults_to_an_invalid_request_rather_than_silently_succeeding` — which pins *today's* behavior so the flip cannot happen silently — updated in the same change. - [ ] Split `ironclaw_extension_manager` out of `extension_host` (the #6616/#6669 arrival inventory: `product_lifecycle`, `available_extensions`+import, `extension_lifecycle_capabilities`+command+product service, `channel_config` product service, pairing workflow orchestration, `webui_extension_credentials`, admin/operator/skill capability handlers, `SharedCommandSurface`). - [ ] Relocate extension_host strays: `channel_pairing_serve.rs` Axum routes → `webui`; `skill_learning.rs` seam → composition/skills; `bundled_skills.rs`+`build.rs` → CLI/composition asset step; delete the `RETIRED_SLACK_USER_EXTENSION_ID` live branch and `nearai_mcp` module in favor of package-owned data/migration steps. - [ ] Kill the cross-crate `include_str!` reach-ins (gmail/github/nearai-mcp manifests): catalog/manifest data flows from package inventory via the binary; verify with the new §11.2.7 scan. diff --git a/docs/reborn/target-architecture/families/contracts.md b/docs/reborn/target-architecture/families/contracts.md index 51a78ebe492..a28244f93cf 100644 --- a/docs/reborn/target-architecture/families/contracts.md +++ b/docs/reborn/target-architecture/families/contracts.md @@ -45,7 +45,7 @@ Contracts is the vocabulary tier: the one family every other family depends on, ## Dependency direction -- **Depends on:** nothing, for the three foundational crates (`host_api`, `common`, `prompt_envelope`) — each is a leaf. The three port crates depend only within the family: `loop_contracts` on `host_api`, `common`, and `prompt_envelope`; `extension_contracts` on `host_api` and `common`; `product_contracts` on `host_api`, `common`, and `extension_contracts` for channel-facing DTO reuse. +- **Depends on:** nothing, for the three foundational crates (`host_api`, `common`, `prompt_envelope`) — each is a leaf. The three port crates depend only within the family: `loop_contracts` on `host_api`, `common`, and `prompt_envelope`; `extension_contracts` on `host_api` and `common`; `product_contracts` on `host_api` and `extension_contracts` for channel-facing DTO reuse (✎ *corrected 2026-08-01 (WS2.1): `common` was listed here and in §6.1.3 but the crate has never held that edge; the enforced allowlist is the two named*). - **Never depends on:** any crate outside this family; no HTTP framework, no database client, no WASM runtime. - **Depended on by:** every other family — substrate, events, domains, kernel, lanes, loop, extensions, product, and app all resolve to contracts somewhere in their dependency graph. No other family has that property; it is the definition of "leaf tier." - **Inversions:** every privileged port in this family is defined low and implemented high. `CapabilityDispatcher` is satisfied by the kernel's dispatch authority; `ChannelAdapter` and `ToolAdapter` are satisfied by extension packages; `ProductSurface` and its companion ports are satisfied by product, operator, the extension host, the extension manager, and composition; the `Loop*Port` set is satisfied by the loop-hosting tier. A port belongs here exactly when the lower layer must invoke behavior whose implementation cannot live below the caller — anything that fails that test is not a port, it is an unnecessary indirection, and has no place in this family. @@ -145,7 +145,7 @@ Contracts holds the sealed constructors that make forged authority a compile-tim - product-side ports whose implementations live beside product: channel delivery resolution and reply-context sourcing, command admission, the operator's LLM-config, active-model, logs, service-lifecycle, and status services, lifecycle product service vocabulary, account-connection status sourcing, and channel-config product service. - **Never contains:** the `ProductSurface` implementation itself; any handler, admission, or delivery logic; HTTP of any kind; projection reducers. - **Public surface:** the `ProductSurface` membrane and the ports above. Every port here is defined once and implemented by exactly the crate that owns the behavior — product, operator, the extension host, the extension manager, or composition — never by more than one, and never by this crate. -- **Depends on:** `ironclaw_host_api`, `ironclaw_common`, `ironclaw_extension_contracts` for channel-facing DTO reuse. +- **Depends on:** `ironclaw_host_api` and `ironclaw_extension_contracts` for channel-facing DTO reuse. ✎ *Corrected 2026-08-01 (WS2.1): this entry and §6.1.3 both list `ironclaw_common`, but the crate has never held that edge and the enforced allowlist (`reborn_dependency_boundaries.rs`, `product_contracts_allowed`) is the two crates named here. WS1.6's `common` narrowing makes the omission deliberate rather than accidental.* - **Never depends on:** product, operator, the extension host, or any transport crate. - **Security & authority role:** the compile-time enforcement of "a transport consumes DTOs and descriptors, never an implementation" — the discipline that keeps webui, the OpenAI-compatible adapter, and every channel package from reaching into product's internals. - **Why a separate crate:** operator's ports and DTOs belong beside operator, not inside product; a channel package's delivery-resolution needs belong beside the channel, not inside product. Declaring all of it here, once, removes every reason for a transport or an operator surface to depend on product's full implementation just to see its own port. diff --git a/tests/e2e/scenarios/test_admin_api.py b/tests/e2e/scenarios/test_admin_api.py index a619884ee70..cec1c7a415e 100644 --- a/tests/e2e/scenarios/test_admin_api.py +++ b/tests/e2e/scenarios/test_admin_api.py @@ -1,7 +1,7 @@ """Admin user-management E2E against the real ``ironclaw-reborn serve`` binary. Drives the WebChat v2 admin surface (`/api/webchat/v2/admin/*`, backed by -`ironclaw_product::AdminUserService`) over HTTP against the standalone +`ironclaw_product_contracts::admin_users::AdminUserService`) over HTTP against the standalone Reborn binary — so unlike the crate-tier `admin_api_e2e.rs` (which composes the router in-process), this exercises serve.rs's real wiring: the operator env-bearer authenticator, and the signed-session-store token minter that must diff --git a/tests/integration/changed-coverage-exemptions.toml b/tests/integration/changed-coverage-exemptions.toml index 5f6e9a12021..c8da69f6376 100644 --- a/tests/integration/changed-coverage-exemptions.toml +++ b/tests/integration/changed-coverage-exemptions.toml @@ -639,3 +639,163 @@ owner = "@nearai/reborn" reason = "Type position only: a function parameter's `&ironclaw_extension_contracts::channel_adapter::ChannelAttachmentRef` type, repointed when the channel-adapter DTO family left `ironclaw_host_api`. A parameter type carries no LLVM coverage region." issue = "https://github.com/nearai/ironclaw/issues/6963" review_after = "2026-10-31" + +# --------------------------------------------------------------------------- +# WS2.1 extension_host port inversion (PR #6998). One entry, for one line. +# +# The port move deleted `ironclaw_product`'s re-export of +# `ChannelConfigProductService`, so every signature naming it had to be +# repointed. In this file the repoint let the signature fit on one line, which +# makes the function's coverage region a *changed* line even though its body, +# its callers, and its behavior are untouched. +# +# It is not an uncovered path. The accessor has two live callers, both of which +# exercise it through the composed runtime: +# tests/integration/group_extensions/scenario_slack_channel_lifecycle_state_machine.rs:112 +# crates/ironclaw_reborn_composition/tests/trigger_poller_e2e.rs:630 +# The service it hands back is itself covered: `RebornChannelConfigProductService` +# is exercised by the extension_host channel-config suite, and the port contract +# by `ironclaw_product_contracts::channel_config`'s own tests, both added in this +# PR. What the merged lcov shows is the lane-attribution artifact #6963 tracks: +# the caller lives in a lane whose lcov does not attribute hits back to this +# `#[cfg(any(test, feature = "test-support"))]` accessor in the composition +# bucket build. +# +# Every other changed line in this PR is covered by a test, not exempted -- +# including the five relocated port modules, which each gained a contract test +# rather than a waiver. + +[[exemption]] +path = "crates/ironclaw_reborn_composition/src/factory/test_support.rs" +lines = [330] +owner = "@nearai/reborn" +reason = "Signature repoint only: the `channel_config_service` dev-seam accessor's return type moved from `ironclaw_product`'s deleted re-export to `ironclaw_product_contracts::channel_config`, collapsing the signature onto one line. Body, callers, and behavior are unchanged; two integration callers exercise it (see the block comment above)." +issue = "https://github.com/nearai/ironclaw/issues/6963" +review_after = "2026-10-31" + +# --------------------------------------------------------------------------- +# WS2.1, second tranche: the type-position residue. +# +# Deleting `ironclaw_product`'s re-exports forced every signature that named a +# moved symbol to be rewritten. Where the name appears in a *type position* -- +# a struct field, a function parameter, a struct-literal field's enum path -- +# the rewrite changes the line but LLVM emits no coverage region for it, so the +# line can never be covered and the file reports "contributed no instrumented +# lines". This is the same class as the four WS1 entries above, from the same +# cause, and each entry below names the exact construct. +# +# Everything in this PR that *can* be covered is covered by a test, not a +# waiver: the ten relocated port modules, the two fail-closed error paths in +# `extension_host`, and the `\0` arm of the bounded-token guard. The one +# `tracing::debug!` body that read as uncovered is now exercised through a +# real DEBUG subscriber rather than exempted. + +[[exemption]] +path = "crates/ironclaw_extension_host/src/channel_host.rs" +lines = [370] +owner = "@nearai/reborn" +reason = "Type position only: `GenericChannelHostAssembly::admin_users`'s `Arc` field type. A type position carries no LLVM coverage region, so the line can never be covered; the surrounding code is unchanged." +issue = "https://github.com/nearai/ironclaw/issues/6963" +review_after = "2026-10-31" + +[[exemption]] +path = "crates/ironclaw_extension_host/src/channel_host.rs" +lines = [1276] +owner = "@nearai/reborn" +reason = "Type position only: a function parameter's `ProductAdapterError` type, repointed to `ironclaw_host_api::product_adapter_error`. A type position carries no LLVM coverage region, so the line can never be covered; the surrounding code is unchanged." +issue = "https://github.com/nearai/ironclaw/issues/6963" +review_after = "2026-10-31" + +[[exemption]] +path = "crates/ironclaw_extension_host/src/extension_ingress.rs" +lines = [70] +owner = "@nearai/reborn" +reason = "Type position only: a function parameter's `ProductAdapterError` type, repointed to `ironclaw_host_api::product_adapter_error`. A type position carries no LLVM coverage region, so the line can never be covered; the surrounding code is unchanged." +issue = "https://github.com/nearai/ironclaw/issues/6963" +review_after = "2026-10-31" + +[[exemption]] +path = "crates/ironclaw_extension_host/src/run_delivery_ports.rs" +lines = [85] +owner = "@nearai/reborn" +reason = "Type position only: an `AuthChallengeView` struct-literal field's `AuthPromptChallengeKind::Pairing` path, repointed to `ironclaw_extension_contracts::auth_prompt`. A type position carries no LLVM coverage region, so the line can never be covered; the surrounding code is unchanged." +issue = "https://github.com/nearai/ironclaw/issues/6963" +review_after = "2026-10-31" + +[[exemption]] +path = "crates/ironclaw_extension_host/src/run_delivery_ports.rs" +lines = [173] +owner = "@nearai/reborn" +reason = "Type position only: a function parameter's `BlockedAuthPromptRequest<'_>` type, repointed to `ironclaw_product_contracts::prompt_source`. A type position carries no LLVM coverage region, so the line can never be covered; the surrounding code is unchanged." +issue = "https://github.com/nearai/ironclaw/issues/6963" +review_after = "2026-10-31" + +[[exemption]] +path = "crates/ironclaw_extension_host/src/test_support.rs" +lines = [472] +owner = "@nearai/reborn" +reason = "Type position only: a function parameter's `ExternalConversationRef` type, repointed to `ironclaw_extension_contracts::external`. A type position carries no LLVM coverage region, so the line can never be covered; the surrounding code is unchanged." +issue = "https://github.com/nearai/ironclaw/issues/6963" +review_after = "2026-10-31" + +[[exemption]] +path = "crates/ironclaw_extension_host/src/test_support.rs" +lines = [473] +owner = "@nearai/reborn" +reason = "Type position only: a function parameter's `ExternalEventId` type, repointed to `ironclaw_extension_contracts::external`. A type position carries no LLVM coverage region, so the line can never be covered; the surrounding code is unchanged." +issue = "https://github.com/nearai/ironclaw/issues/6963" +review_after = "2026-10-31" + +[[exemption]] +path = "crates/ironclaw_reborn_composition/src/deployment.rs" +lines = [266] +owner = "@nearai/reborn" +reason = "Type position only: `account_setup_descriptors`'s `Vec` field type. A type position carries no LLVM coverage region, so the line can never be covered; the surrounding code is unchanged." +issue = "https://github.com/nearai/ironclaw/issues/6963" +review_after = "2026-10-31" + +[[exemption]] +path = "crates/ironclaw_reborn_composition/src/factory/production_build_assembly.rs" +lines = [340] +owner = "@nearai/reborn" +reason = "Type position only: `account_setup_descriptors`'s `Vec` field type. A type position carries no LLVM coverage region, so the line can never be covered; the surrounding code is unchanged." +issue = "https://github.com/nearai/ironclaw/issues/6963" +review_after = "2026-10-31" + +# --------------------------------------------------------------------------- +# WS2.1, third tranche: one `tracing` message literal. +# +# This one is worth reading, because the obvious conclusion is wrong. The line +# is the message string inside a `tracing::debug!` invocation. It reads as +# uncovered, but the event body *does* execute: the test +# `output_serialization_failure_maps_to_output_decode_and_logs_the_detail` +# installs a DEBUG subscriber over a shared writer and asserts the rendered +# output contains that exact message, and it passes -- including in the +# `extension-operator` bucket, which is green. +# +# Proof it is an attribution artifact rather than a dead path, from that same +# bucket's own tracefile (`bucket-extension-operator.lcov`, run 30689416105): +# +# line 213 (fn signature) hits 1 +# line 214 (macro invocation) hits 1 +# line 217 (message literal) hits 0 <- this entry +# line 219 (error construction) hits 1 +# line 220 (closing brace) hits 1 +# +# The function ran, the macro ran, and the error was built. What LLVM does not +# count is the message literal: `tracing` bakes it into the callsite's `static` +# `Metadata`, so the region on that line belongs to a static initializer and is +# never attributed to an executed path. No subscriber, test, or restructuring +# short of changing the log target (a behavior change this move-shaped PR will +# not make) can move that counter. +# +# Every `tracing::debug!` in this workspace has the same shape; they simply do +# not enter this gate's denominator because their lines are not in the diff. + +[[exemption]] +path = "crates/ironclaw_extension_host/src/extension_lifecycle_capabilities.rs" +lines = [217] +owner = "@nearai/reborn" +reason = "A `tracing::debug!` message literal. `tracing` bakes the message into the callsite's `static` Metadata, so LLVM attributes this line's region to a static initializer and never counts it as executed -- while the surrounding lines 213/214/219/220 each score 1 hit in the same tracefile and the event body is asserted by a DEBUG-subscriber test. Attribution artifact, not a dead path; see the block comment above for the per-line evidence." +issue = "https://github.com/nearai/ironclaw/issues/6963" +review_after = "2026-10-31" diff --git a/tests/integration/extension_delivery.rs b/tests/integration/extension_delivery.rs index 7dcea63c238..f15176b0bb5 100644 --- a/tests/integration/extension_delivery.rs +++ b/tests/integration/extension_delivery.rs @@ -91,9 +91,10 @@ use ironclaw_product::{ UserMessagePayload, VerifiedInbound, }; use ironclaw_product::{ - ChannelConnectionNoticePolicy, ConversationBindingService, ResolveBindingRequest, - RunDeliveryObserver, RunDeliveryServices, RunDeliverySettings, + ConversationBindingService, ResolveBindingRequest, RunDeliveryObserver, RunDeliveryServices, + RunDeliverySettings, }; +use ironclaw_product_contracts::account_setup::ChannelConnectionNoticePolicy; use ironclaw_product_contracts::surface::ChannelInboundProductSurface; use ironclaw_product_contracts::surface::ProductSurfaceCaller; use ironclaw_reborn_composition::{ChannelHostAssemblyTestWiring, RebornRuntime}; diff --git a/tests/integration/hosted_mcp_registration.rs b/tests/integration/hosted_mcp_registration.rs index 18661e98698..e4d4e1e3d8d 100644 --- a/tests/integration/hosted_mcp_registration.rs +++ b/tests/integration/hosted_mcp_registration.rs @@ -43,8 +43,8 @@ use ironclaw_host_api::{ }; use ironclaw_product::{ LifecyclePackageKind, LifecyclePackageRef, LifecycleProductAction, LifecycleProductPayload, - LifecycleProductService, }; +use ironclaw_product_contracts::lifecycle_service::LifecycleProductService; use ironclaw_product_contracts::surface::{ProductSurfaceErrorCode, ProductSurfaceErrorKind}; use ironclaw_secrets::SecretStorePort; use secrecy::SecretString; diff --git a/tests/integration/support/product_surface.rs b/tests/integration/support/product_surface.rs index eab86524edb..c3eb38188c4 100644 --- a/tests/integration/support/product_surface.rs +++ b/tests/integration/support/product_surface.rs @@ -15,10 +15,11 @@ use ironclaw_host_api::{ resource::ResourceScope, }; use ironclaw_product::{ - ActionFingerprintKey, ActionPhase, ConversationBindingService, IdempotencyDecision, - IdempotencyLedger, ProductConversationRouteKind, ProductInboundAction, ProductSurfaceFailure, + ActionPhase, ConversationBindingService, IdempotencyDecision, IdempotencyLedger, + ProductConversationRouteKind, ProductInboundAction, ProductSurfaceFailure, ResolveBindingRequest, ResolvedBinding, }; +use ironclaw_product_contracts::action::ActionFingerprintKey; use serde::{Serialize, de::DeserializeOwned}; use sha2::{Digest, Sha256}; use thiserror::Error; diff --git a/tests/integration/webui_v2_product_api.rs b/tests/integration/webui_v2_product_api.rs index b61464db2b0..dd20ee17f33 100644 --- a/tests/integration/webui_v2_product_api.rs +++ b/tests/integration/webui_v2_product_api.rs @@ -31,12 +31,15 @@ use ironclaw_host_api::{ capability::{EffectKind, PermissionMode}, ids::{AgentId, CapabilityId, ExtensionId, SecretHandle, TenantId, UserId}, }; -use ironclaw_product::{ +use ironclaw_product::{ProductOutboundEnvelope, ProductOutboundPayload}; +use ironclaw_product::{RebornServices, RebornStreamEventsRequest}; +use ironclaw_product_contracts::admin_users::{ AdminCreateUserFields, AdminCreatedUser, AdminUserError, AdminUserRecord, AdminUserRole, - AdminUserSecretMeta, AdminUserService, AdminUserStatus, RebornOperatorToolCatalog, - RebornOperatorToolInfo, RebornServices, RebornStreamEventsRequest, + AdminUserSecretMeta, AdminUserService, AdminUserStatus, +}; +use ironclaw_product_contracts::operator_tools::{ + RebornOperatorToolCatalog, RebornOperatorToolInfo, }; -use ironclaw_product::{ProductOutboundEnvelope, ProductOutboundPayload}; use ironclaw_product_contracts::surface::{ ProductSurface, ProductSurfaceCaller, ProductSurfaceStreamRequest, };