Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/ironclaw_product_workflow/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- Read `Cargo.toml` for actual dependencies and feature shape.
- Use these local contracts as the source of truth before changing behavior:
- `tests/product_workflow_contract.rs`
- `tests/approval_interaction_contract.rs`
- `tests/inbound_turn_contract.rs`
- `tests/webui_inbound_contract.rs`
- `tests/reborn_services_contract.rs`
Expand Down
13 changes: 13 additions & 0 deletions crates/ironclaw_product_workflow/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,17 @@ handling, gate routing, mission routing, and redacted acknowledgements.
| `ProductInboundAction` | Durable ledger record for inbound actions |
| `ProductCommandAdmissionService` | Source/auth-aware admission port that decides whether a typed product command may execute |
| `ProductCommandService` | Reborn-native product command execution port for already-admitted typed commands |
| `ApprovalInteractionService` / `DefaultApprovalInteractionService` | Approval-only product/WebUI boundary for listing redacted pending approval gates and resolving click approve/deny through canonical approval resolver + turn coordinator ports |
| `RunStateApprovalInteractionReadModel` | Canonical read model that returns status-bearing approval gates from scoped approval-request records plus the parked turn-run locator; `ApprovalInteractionService::list_pending` filters those records to pending UI DTOs |
| `RebornServicesApi` / `RebornServices` | Native WebChat v2 facade — 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; rejects stale or attacker-supplied `gate_ref` on denied/cancelled gate resolutions; refuses persistent (`always: true`) approvals until an approval-policy port lands |

## Dependencies

- `ironclaw_product_adapters` — trait definitions, envelope/ack types, `ProjectionStream` for SSE
- `ironclaw_approvals` / `ironclaw_authorization` — canonical approval resolution and scoped lease issue ports used by approval interactions
- `ironclaw_auth` — typed product-auth continuation events consumed by the workflow auth bridge
- `ironclaw_conversations` — canonical actor/conversation binding and thread route ownership
- `ironclaw_run_state` — approval request store contract surfaced through approval resolution/read-model ports
- `ironclaw_turns` — turn coordinator, scope, IDs
- `ironclaw_threads` — session thread service contract
- `ironclaw_host_api` — canonical identifiers (TenantId, UserId, etc.)
Expand All @@ -53,6 +57,15 @@ the command. Admitted commands dispatch through `ProductCommandService`, not
`InboundTurnService`, v1 `SubmissionParser`, v1 command routers, or agent-loop
command handlers.

Approval interactions are click-approval only. Pending approval DTOs must be
redacted, scoped, and derived from canonical run-state/approval records or a
projection read model built from them. Approve/deny decisions must go through
`ApprovalResolutionPort` and `TurnCoordinator`; product/WebUI code must not
directly execute tools, mutate approval stores ad hoc, or implement
`AlwaysAllow` before a durable approval-policy port exists. High-value signing
and attested approvals require a separate service shape with canonical payload
attestation and must not be folded into this redacted click-approval DTO.

WebUI-facing facade methods must bind browser thread ids through
`SessionThreadService` using a `ThreadScope` derived from the authenticated
caller before accepting messages, streaming events, canceling runs, or resolving
Expand Down
4 changes: 4 additions & 0 deletions crates/ironclaw_product_workflow/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,14 @@ test-support = ["ironclaw_product_adapters/test-support"]
async-trait = "0.1"
chrono = { version = "0.4", features = ["serde"] }
ironclaw_common = { path = "../ironclaw_common", version = "0.4.1" }
ironclaw_approvals = { path = "../ironclaw_approvals", version = "0.1.0" }
ironclaw_authorization = { path = "../ironclaw_authorization", version = "0.1.0" }
ironclaw_auth = { path = "../ironclaw_auth", version = "0.1.0" }
ironclaw_events = { path = "../ironclaw_events", version = "0.1.0" }
ironclaw_host_api = { path = "../ironclaw_host_api", version = "0.1.0" }
ironclaw_conversations = { path = "../ironclaw_conversations", version = "0.1.0" }
ironclaw_product_adapters = { path = "../ironclaw_product_adapters", version = "0.1.0" }
ironclaw_run_state = { path = "../ironclaw_run_state", version = "0.1.0" }
ironclaw_threads = { path = "../ironclaw_threads", version = "0.1.0" }
ironclaw_turns = { path = "../ironclaw_turns", version = "0.1.0" }
serde = { version = "1", features = ["derive"] }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
use ironclaw_host_api::ApprovalRequestId;
use ironclaw_turns::{GateRef, ReplyTargetBindingRef, SourceBindingRef};

use super::{ApprovalInteractionRejectionKind, approval_rejected};
use crate::binding_ref::{
DEFAULT_BINDING_REF_RAW_MAX_BYTES, bounded_reply_target_binding_ref, bounded_source_binding_ref,
};
use crate::error::ProductWorkflowError;

const APPROVAL_GATE_PREFIX: &str = "gate:approval-";

pub fn is_approval_gate_ref(gate_ref: &GateRef) -> bool {
gate_ref.as_str().starts_with(APPROVAL_GATE_PREFIX)
}

pub fn approval_gate_ref(request_id: ApprovalRequestId) -> Result<GateRef, ProductWorkflowError> {
GateRef::new(format!("{APPROVAL_GATE_PREFIX}{request_id}"))
.map_err(|_| approval_rejected(ApprovalInteractionRejectionKind::InvalidGateRef))
}

pub(super) fn approval_request_id_from_gate_ref(
gate_ref: &GateRef,
) -> Result<ApprovalRequestId, ProductWorkflowError> {
let Some(value) = gate_ref.as_str().strip_prefix(APPROVAL_GATE_PREFIX) else {
return Err(approval_rejected(
ApprovalInteractionRejectionKind::InvalidGateRef,
));
};
ApprovalRequestId::parse(value)
.map_err(|_| approval_rejected(ApprovalInteractionRejectionKind::InvalidGateRef))
}

pub(super) fn approval_source_binding_ref(
gate_ref: &GateRef,
) -> Result<SourceBindingRef, ProductWorkflowError> {
bounded_source_binding_ref(
"approval-src",
gate_ref.as_str(),
DEFAULT_BINDING_REF_RAW_MAX_BYTES,
)
.map_err(|_| approval_rejected(ApprovalInteractionRejectionKind::InvalidBindingRef))
}

pub(super) fn approval_reply_binding_ref(
gate_ref: &GateRef,
) -> Result<ReplyTargetBindingRef, ProductWorkflowError> {
bounded_reply_target_binding_ref(
"approval-reply",
gate_ref.as_str(),
DEFAULT_BINDING_REF_RAW_MAX_BYTES,
)
.map_err(|_| approval_rejected(ApprovalInteractionRejectionKind::InvalidBindingRef))
}
33 changes: 33 additions & 0 deletions crates/ironclaw_product_workflow/src/approval_interaction/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//! Product-facing approval interaction boundary.
//!
//! This module owns the click-approval service shape used by WebUI/product
//! surfaces. It deliberately returns redacted DTOs and routes decisions through
//! injected canonical approval + turn coordination ports; it does not keep an
//! ad hoc approval queue or execute capabilities directly.

mod gate_ref;
mod read_model;
mod resolver;
mod service;
mod types;

pub use gate_ref::{approval_gate_ref, is_approval_gate_ref};
pub use read_model::{
ApprovalBlockedTurnRun, ApprovalInteractionReadModel, ApprovalTurnRunLocator,
RunStateApprovalInteractionReadModel,
};
pub use resolver::{ApprovalLeaseTermsProvider, ApprovalResolutionPort, ApprovalResolverPort};
pub(crate) use service::RejectingApprovalInteractionService;
pub use service::{ApprovalInteractionService, DefaultApprovalInteractionService};
pub use types::{
ApprovalGateRecord, ApprovalInteractionActionView, ApprovalInteractionDecision,
ApprovalInteractionRejectionKind, ApprovalInteractionScope, ListPendingApprovalsRequest,
ListPendingApprovalsResponse, PendingApprovalInteractionView,
ResolveApprovalInteractionRequest, ResolveApprovalInteractionResponse,
};

use crate::error::ProductWorkflowError;

fn approval_rejected(kind: ApprovalInteractionRejectionKind) -> ProductWorkflowError {
ProductWorkflowError::ApprovalInteractionRejected { kind }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
use std::sync::Arc;

use async_trait::async_trait;
use ironclaw_host_api::{InvocationId, ResourceScope};
use ironclaw_run_state::{ApprovalRequestStore, RunStateError};
use ironclaw_turns::{GateRef, TurnRunId};

use super::gate_ref::{approval_gate_ref, approval_request_id_from_gate_ref};
use super::{ApprovalGateRecord, ApprovalInteractionScope};
use crate::error::ProductWorkflowError;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApprovalBlockedTurnRun {
pub run_id: TurnRunId,
pub gate_ref: GateRef,
}

#[async_trait]
pub trait ApprovalTurnRunLocator: Send + Sync {
async fn blocked_approval_runs(
&self,
scope: &ApprovalInteractionScope,
) -> Result<Vec<ApprovalBlockedTurnRun>, ProductWorkflowError>;

async fn blocked_approval_run(
&self,
scope: &ApprovalInteractionScope,
gate_ref: &GateRef,
) -> Result<Option<TurnRunId>, ProductWorkflowError> {
Ok(self
.blocked_approval_runs(scope)
.await?
.into_iter()
.find(|run| &run.gate_ref == gate_ref)
.map(|run| run.run_id))
}

async fn approval_run_for_gate(
&self,
scope: &ApprovalInteractionScope,
gate_ref: &GateRef,
) -> Result<Option<TurnRunId>, ProductWorkflowError> {
self.blocked_approval_run(scope, gate_ref).await
}
}

#[async_trait]
pub trait ApprovalInteractionReadModel: Send + Sync {
async fn approval_gates(
&self,
scope: &ApprovalInteractionScope,
) -> Result<Vec<ApprovalGateRecord>, ProductWorkflowError>;

async fn approval_gate(
&self,
scope: &ApprovalInteractionScope,
run_id_hint: Option<TurnRunId>,
gate_ref: &GateRef,
) -> Result<Option<ApprovalGateRecord>, ProductWorkflowError>;
}

/// Read-model backed by canonical approval records and parked turn state.
pub struct RunStateApprovalInteractionReadModel {
approval_requests: Arc<dyn ApprovalRequestStore>,
turn_runs: Arc<dyn ApprovalTurnRunLocator>,
}

impl RunStateApprovalInteractionReadModel {
pub fn new(
approval_requests: Arc<dyn ApprovalRequestStore>,
turn_runs: Arc<dyn ApprovalTurnRunLocator>,
) -> Self {
Self {
approval_requests,
turn_runs,
}
}
}

#[async_trait]
impl ApprovalInteractionReadModel for RunStateApprovalInteractionReadModel {
async fn approval_gates(
&self,
scope: &ApprovalInteractionScope,
) -> Result<Vec<ApprovalGateRecord>, ProductWorkflowError> {
let owner_scope = resource_scope_for_interaction(scope);
let mut gates = Vec::new();
for run in self.turn_runs.blocked_approval_runs(scope).await? {
let request_id = approval_request_id_from_gate_ref(&run.gate_ref)?;
let Some(approval) = self
.approval_requests
.get(&owner_scope, request_id)
.await
.map_err(map_approval_read_error)?
else {
continue;
};
if !same_interaction_owner(&approval.scope, &owner_scope) {
continue;
}
gates.push(ApprovalGateRecord::with_status(
approval.scope,
run.run_id,
run.gate_ref,
approval.request,
approval.status,
)?);
}
Ok(gates)
}

async fn approval_gate(
&self,
scope: &ApprovalInteractionScope,
run_id_hint: Option<TurnRunId>,
gate_ref: &GateRef,
) -> Result<Option<ApprovalGateRecord>, ProductWorkflowError> {
let request_id = approval_request_id_from_gate_ref(gate_ref)?;
let owner_scope = resource_scope_for_interaction(scope);
let Some(approval) = self
.approval_requests
.get(&owner_scope, request_id)
.await
.map_err(map_approval_read_error)?
else {
return Ok(None);
};
if !same_interaction_owner(&approval.scope, &owner_scope) {
return Ok(None);
}
let run_id = match run_id_hint {
Some(run_id) => run_id,
None => {
let Some(run_id) = self
.turn_runs
.approval_run_for_gate(scope, gate_ref)
.await?
else {
return Ok(None);
};
run_id
}
};
Ok(Some(ApprovalGateRecord::with_status(
approval.scope,
run_id,
approval_gate_ref(request_id)?,
approval.request,
approval.status,
)?))
}
}

fn resource_scope_for_interaction(scope: &ApprovalInteractionScope) -> ResourceScope {
ResourceScope {
tenant_id: scope.tenant_id.clone(),
user_id: scope.user_id.clone(),
agent_id: scope.agent_id.clone(),
project_id: scope.project_id.clone(),
mission_id: None,
thread_id: Some(scope.thread_id.clone()),
invocation_id: InvocationId::new(),
}
}

fn same_interaction_owner(left: &ResourceScope, right: &ResourceScope) -> bool {
left.tenant_id == right.tenant_id
&& left.user_id == right.user_id
&& left.agent_id == right.agent_id
&& left.project_id == right.project_id
&& left.mission_id == right.mission_id
&& left.thread_id == right.thread_id
}

fn map_approval_read_error(_error: RunStateError) -> ProductWorkflowError {
ProductWorkflowError::Transient {
reason: "approval read model unavailable".to_string(),
}
}
Loading
Loading