Skip to content

feat(product-workflow): prepare WebUI binding slice - #3727

Merged
serrrfirat merged 6 commits into
reborn-integrationfrom
codex/product-workflow-webui-readiness
May 19, 2026
Merged

serrrfirat merged 6 commits into
reborn-integrationfrom
codex/product-workflow-webui-readiness

Conversation

@serrrfirat

Copy link
Copy Markdown
Collaborator

Summary

Builds out the initial ProductWorkflow services needed for the WebUI rollout slice on top of the composition-root work in #3725.

  • wires ProductWorkflow binding resolution through ironclaw_conversations via a trusted adapter installation registry
  • keeps tenant/default agent/project scope under host-owned installation config, not inbound adapter payloads
  • implements projection subscription resolution with resolved-binding thread validation
  • adds a local-dev/test in-memory idempotency ledger with in-flight lease recovery and stale reservation protection
  • settles permanent binding/install rejections as terminal idempotent outcomes
  • documents the ProductWorkflow tenancy boundary and new services

Security / correctness notes

  • Unknown adapter installations are rejected before turn submission.
  • Unpaired external actors are rejected before message persistence or turn submission.
  • Installation mappings isolate tenants even when external actor/conversation refs collide.
  • thread_id_hint is only accepted when it exactly matches the resolved conversation binding; it cannot switch thread/tenant authority.
  • Stale release/settle calls from expired idempotency reservations cannot remove or overwrite a reclaimed reservation.

Validation

  • cargo test -p ironclaw_product_workflow
  • cargo test -p ironclaw_product_workflow in_memory_idempotency_ledger_ignores_stale_releases_after_reclaim
  • cargo test -p ironclaw_conversations
  • cargo test -p ironclaw_product_adapters --features test-support,host-auth-mint
  • cargo test -p ironclaw_architecture reborn_crate_dependency_boundaries_hold
  • cargo clippy -p ironclaw_product_workflow --all-targets -- -D warnings
  • cargo fmt --all -- --check
  • git diff --check

FEATURE_PARITY.md was checked; it does not currently track ProductWorkflow explicitly.

@github-actions github-actions Bot added scope: docs Documentation scope: dependencies Dependency updates size: XL 500+ changed lines risk: low Changes to docs, tests, or low-risk modules contributor: core 20+ merged PRs labels May 17, 2026

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request implements a concrete conversation binding layer that bridges product adapter requests to the canonical ironclaw_conversations service, utilizing a trusted installation resolver to derive TenantId and default scopes from host configuration. It also introduces an InMemoryIdempotencyLedger for local development and testing, and completes the implementation of projection subscription resolution within the DefaultProductWorkflow. Feedback was provided regarding the use of a wildcard arm in an enum mapping function, recommending an exhaustive match statement to improve compile-time safety.

Comment on lines +191 to +207
match payload {
ProductInboundPayload::UserMessage(message) => match message.trigger {
ProductTriggerReason::DirectChat => ProductConversationRouteKind::Direct,
ProductTriggerReason::BotMention
| ProductTriggerReason::ReplyToBot
| ProductTriggerReason::BotCommand
| ProductTriggerReason::LinkedThreadAction => ProductConversationRouteKind::Shared,
},
ProductInboundPayload::Command(command) => match command.trigger {
ProductTriggerReason::DirectChat => ProductConversationRouteKind::Direct,
ProductTriggerReason::BotMention
| ProductTriggerReason::ReplyToBot
| ProductTriggerReason::BotCommand
| ProductTriggerReason::LinkedThreadAction => ProductConversationRouteKind::Shared,
},
_ => ProductConversationRouteKind::Direct,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The match statement in route_kind_for_payload uses a wildcard _ arm. Per the general rules, mapping between enums should be exhaustive to ensure all cases are explicitly handled and to catch new variants at compile time.

    match payload {
        ProductInboundPayload::UserMessage(message) => match message.trigger {
            ProductTriggerReason::DirectChat => ProductConversationRouteKind::Direct,
            ProductTriggerReason::BotMention
            | ProductTriggerReason::ReplyToBot
            | ProductTriggerReason::BotCommand
            | ProductTriggerReason::LinkedThreadAction => ProductConversationRouteKind::Shared,
        },
        ProductInboundPayload::Command(command) => match command.trigger {
            ProductTriggerReason::DirectChat => ProductConversationRouteKind::Direct,
            ProductTriggerReason::BotMention
            | ProductTriggerReason::ReplyToBot
            | ProductTriggerReason::BotCommand
            | ProductTriggerReason::LinkedThreadAction => ProductConversationRouteKind::Shared,
        },
        ProductInboundPayload::ApprovalResolution(_)
        | ProductInboundPayload::AuthResolution(_)
        | ProductInboundPayload::SubscriptionRequest(_)
        | ProductInboundPayload::LinkedThreadAction(_)
        | ProductInboundPayload::NoOp => ProductConversationRouteKind::Direct,
    }
References
  1. In functions that map one enum to another, use an exhaustive match statement instead of a wildcard _ arm. This forces a compile-time error when new variants are added, ensuring all cases are explicitly handled.

@serrrfirat

Copy link
Copy Markdown
Collaborator Author

Security/correctness review findings have been addressed in a4a62f95.

What changed:

  • Projection subscriptions now use lookup-only binding resolution (lookup_binding) instead of the create-capable binding path, so subscription/read paths cannot create conversation bindings, threads, route-access widening, or external-event route reservations.
  • Trusted installation default agent_id / project_id is now passed into the canonical ironclaw_conversations binding service via resolve_or_create_binding_with_trusted_scope and persisted on first bind. ProductWorkflow no longer overlays current installation defaults on every resolve.
  • InMemoryIdempotencyLedger::settle now fails loudly if the caller no longer owns the in-flight reservation or the reservation expired before terminal settlement, instead of reporting success without recording the terminal outcome.

Regression coverage added:

  • lookup_binding_does_not_create_missing_conversation_binding
  • trusted_scope_is_persisted_on_first_bind
  • projection_subscription_requires_existing_conversation_binding
  • concrete_product_workflow_persists_first_bind_default_scope
  • in_memory_idempotency_ledger_rejects_settle_after_expiry_without_reclaim

Validation run:

  • cargo test -p ironclaw_product_workflow
  • cargo test -p ironclaw_conversations
  • cargo test -p ironclaw_conversations --features libsql,postgres
  • cargo clippy -p ironclaw_product_workflow --all-targets -- -D warnings
  • cargo clippy -p ironclaw_conversations --all-targets -- -D warnings
  • cargo clippy -p ironclaw_conversations --features libsql,postgres --all-targets -- -D warnings
  • cargo test -p ironclaw_architecture reborn_crate_dependency_boundaries_hold
  • cargo fmt --all -- --check
  • git diff --check

@serrrfirat serrrfirat left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Multi-agent code review

Reviewed with Security, Bugs, Performance/Concurrency, Tests, and Conventions lenses.

Findings: 10 total

  • Critical/High: 0
  • Medium: 10
  • Reviewers failed: 0

Main risk areas: trusted ProductWorkflow scope persistence, stale idempotency settlement, durable-state refresh concurrency, and missing regression coverage for the new projection/idempotency branches.

};

let mut changed = false;
if binding.agent_id.is_none() && trusted_agent_id.is_some() {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

When this path resolves an existing binding that still has no persisted agent/project scope, it mutates the binding to whatever trusted defaults are configured today. That can silently re-scope an old external conversation route after installation config changes or after a legacy unscoped bind was created, which conflicts with the new guardrail that first-contact defaults are persisted rather than later overlaid. Please apply trusted defaults only when creating the binding, or reject/migrate existing unscoped bindings explicitly.


async fn settle(&self, action: ProductInboundAction) -> Result<(), ProductWorkflowError> {
let mut state = self.lock_state()?;
if state.settled.contains_key(&action.fingerprint) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This returns Ok(()) for any later settle once the fingerprint exists in settled, even if the caller is an older expired action whose reservation was reclaimed and settled by a different action_id. In that race the stale dispatch believes its terminal outcome was durably accepted, violating the stale-reservation protection invariant. Please only treat settle as idempotent when the settled action_id matches; otherwise return the superseded-reservation transient error.

request: ResolveConversationRequest,
) -> Result<ConversationBindingResolution, InboundTurnError> {
#[cfg(any(feature = "libsql", feature = "postgres"))]
self.refresh_state_from_repository().await?;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

With libsql/postgres enabled, lookup_binding refreshes the shared in-memory snapshot without taking mutation_lock, while all mutating paths hold that lock across refresh and persist. A lookup can load an older revision while a writer is saving, then install that stale snapshot after the writer commits, causing projection lookups to miss fresh bindings under concurrent traffic. Please take the same mutation lock around this refresh/read path, or make refresh revision-aware so it cannot move the cache backward.

request: ResolveConversationRequest,
trusted_agent_id: Option<ironclaw_host_api::AgentId>,
trusted_project_id: Option<ironclaw_host_api::ProjectId>,
) -> Result<ConversationBindingResolution, InboundTurnError> {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The default implementation accepts trusted host-owned scope and then discards it, falling back to the legacy unscoped resolver. Since this is a public trait method, any implementer that does not override it will compile while silently violating the ProductWorkflow boundary that default agent/project scope must be persisted on first bind. Please make this method required, or have the default return an explicit unsupported error instead of ignoring the scope.

Err(ProductAdapterError::Internal {
detail: ironclaw_product_adapters::RedactedString::new(
"projection subscription resolution not yet implemented",
let ProductInboundPayload::SubscriptionRequest(payload) = envelope.payload() else {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This new public projection resolver has a distinct MalformedInboundPayload branch for non-subscription envelopes, but the contract tests only exercise subscription payloads. Please add projection_subscription_rejects_non_subscription_payload or equivalent coverage so this caller-facing error contract does not regress.

thread_id_hint: Option<&str>,
) -> Result<ironclaw_host_api::ThreadId, ProductAdapterError> {
if let Some(thread_id_hint) = thread_id_hint {
let hinted = ironclaw_host_api::ThreadId::new(thread_id_hint).map_err(|_| {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Projection tests cover a well-formed but mismatched thread_id_hint, but not the malformed external-input path here. Please add coverage such as projection_subscription_rejects_malformed_thread_hint to assert invalid hints return MalformedInboundPayload rather than being treated as binding access failures.

fingerprint: ActionFingerprintKey,
received_at: DateTime<Utc>,
) -> Result<IdempotencyDecision, ProductWorkflowError> {
let mut state = self.lock_state()?;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The ledger uses a mutex to make same-fingerprint reservation atomic, but the current tests only exercise this sequentially. Please add a contention test, for example in_memory_idempotency_ledger_allows_only_one_concurrent_reservation, that races two begin_or_replay calls on the same fingerprint and asserts only one gets New.

"unknown adapter installation",
)))
}
ProductWorkflowError::BindingRequired { reason } => Some(ProductInboundAck::Rejected(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The unpaired-actor workflow test checks the first BindingRequired rejection and absence of turn submission, but not the new terminal idempotency behavior for this branch. Please add a retry assertion or a dedicated concrete_product_workflow_replays_unpaired_actor_terminal_rejection test so duplicate deliveries replay the settled permanent rejection.

ProductWorkflowError::BindingRequired { reason } => Some(ProductInboundAck::Rejected(
ProductRejection::permanent(ProductRejectionKind::BindingRequired, reason.clone()),
)),
ProductWorkflowError::BindingAccessDenied => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

BindingAccessDenied is now settled as a permanent terminal rejection, but the product workflow tests do not cover that branch or its duplicate replay behavior. Please add a workflow-level access-denied case, not only a conversation-layer test, so this facade mapping stays protected.

ironclaw_product_adapters::ProductTriggerReason::DirectChat => {
ProductConversationRouteKind::Direct
}
ironclaw_product_adapters::ProductTriggerReason::BotMention

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The concrete ProductWorkflow path only submits DirectChat messages in tests, but this new mapping gives BotMention, ReplyToBot, BotCommand, and LinkedThreadAction shared-route semantics. Please add a concrete workflow test, for example concrete_product_workflow_bot_mention_uses_shared_route, to prove shared triggers reach binding resolution with shared access.

@serrrfirat

Copy link
Copy Markdown
Collaborator Author

Addressed the code-review feedback in ab996fa.

✓ Fixed trusted-scope drift for existing unscoped conversation bindings: later trusted defaults now reject instead of silently re-scoping legacy bindings.
✓ Fixed stale idempotency settles after a newer reclaimed reservation has already settled: settle is idempotent only for the same action_id.
✓ Fixed durable lookup refresh race: lookup_binding now takes the mutation lock before refreshing repository-backed state.
✓ Fixed the public trusted-scope trait default: resolve_or_create_binding_with_trusted_scope is now required instead of silently dropping trusted scope.
✓ Added projection resolver coverage for non-subscription payloads.
✓ Added projection resolver coverage for malformed thread_id_hint.
✓ Added concurrent same-fingerprint reservation coverage for InMemoryIdempotencyLedger.
✓ Added terminal replay coverage for BindingRequired/unpaired actor rejection.
✓ Added terminal replay coverage for BindingAccessDenied rejection.
✓ Added coverage proving bot mentions resolve through the shared-route binding path.

Verification run:
✓ cargo fmt --all -- --check
✓ git diff --check
✓ cargo test -p ironclaw_conversations
✓ cargo test -p ironclaw_product_workflow
✓ cargo clippy -p ironclaw_conversations -p ironclaw_product_workflow --all-targets -- -D warnings

Note: scripts/pre-commit-safety.sh still reports existing PR-wide findings outside this fix set (UTF8/TMPDIR/PANIC/PROJECTION matches in files not touched by ab996fa), so I did not fold unrelated cleanup into this review-fix commit.

Base automatically changed from feat/reborn-webui-turn-scope to reborn-integration May 19, 2026 08:54

@serrrfirat serrrfirat left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Multi-agent review for PR #3727 at ab996fa812b0d26372cecd65bc76fb8b8d4b86fa.

Result: blocking findings found. GitHub does not allow this account to request changes on its own PR, so this is posted as a review comment. Findings kept to the high-confidence blockers plus one regression-test gap:

  • High: failed trusted-scope resolves can still mutate route access before returning an error.
  • High: terminal idempotency replay for binding/install rejections is keyed without actor validation.
  • High: accepted-message replay can run before validating the current envelope identity.
  • Medium: add regression coverage that lookup-only / rejected resolve paths do not reserve or widen route state.

thread_id: binding.thread_id.to_string(),
});
}
if request.route_kind == ConversationRouteKind::Shared {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[High] This mutates route_access before the trusted-scope compatibility check below can fail. Because the function returns immediately on ensure_trusted_scope_not_reinterpreted, the live in-memory state is not rolled back, so a rejected Shared resolve can leave a previously direct binding widened. Please run all fallible validation before widen_binding_route_access, or stage changes in a cloned state and commit only after validation succeeds.


fn terminal_ack_for_error(error: &ProductWorkflowError) -> Option<ProductInboundAck> {
match error {
ProductWorkflowError::UnknownInstallation => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[High] Settling binding/install rejections as terminal outcomes is useful, but the workflow replay key is still adapter + installation + source binding + external event, with no actor identity. A failed delivery from an unpaired actor can poison that idempotency record for a later valid actor using the same event, and replay returns before the binding service can validate the current envelope. Please either include actor identity in the replay key or validate the replaying envelope against the stored resolved/rejected binding before returning Duplicate.

installation_id: envelope.installation_id().clone(),
external_actor_ref: envelope.external_actor_ref().clone(),
external_conversation_ref: envelope.external_conversation_ref().clone(),
external_event_id: envelope.external_event_id().clone(),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[High] This adds event/route validation to the fresh binding path, but accepted-message replay is checked before this block and is still keyed only by source binding plus external event id. For non-terminal/released workflow actions, a matching retry can submit or replay the previously accepted message without validating the current actor, installation mapping, route access, or conversation identity. Please validate the binding before replay, or extend the replay request/store key to include the accepted actor and conversation identity.

)?;
let binding_key = BindingKey::from_request(&request);
let external_conversation_identity = request.external_conversation_ref.identity();
state.ensure_external_event_route(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Medium] Please add regression coverage proving lookup-only and failed lookup paths do not reserve or widen route state. A targeted case like lookup_binding_miss_does_not_reserve_external_event_route should do a lookup miss, then create with the same external_event_id on a different valid conversation so an accidental reservation would fail the test.

@serrrfirat

Copy link
Copy Markdown
Collaborator Author

Addressed the review findings in 2a04ee4. Summary:

  • moved trusted-scope validation before route widening so rejected resolves cannot mutate route access
  • included external actor identity in product workflow idempotency fingerprints
  • made accepted-message replay lookup require matching thread scope and actor, and validate binding before replay
  • added regression coverage for lookup-only route reservation/widening, rejected trusted-scope widening, terminal rejection actor isolation, and accepted-message replay validation

Local verification:

  • cargo fmt --all -- --check
  • cargo test -p ironclaw_threads
  • cargo test -p ironclaw_conversations
  • cargo test -p ironclaw_product_workflow
  • cargo clippy -p ironclaw_threads -p ironclaw_conversations -p ironclaw_product_workflow --all-targets -- -D warnings
  • git diff --check

Note: scripts/pre-commit-safety.sh still reports pre-existing warnings elsewhere in the PR diff outside the files changed for this review fix.

@serrrfirat
serrrfirat merged commit a49ba99 into reborn-integration May 19, 2026
13 of 14 checks passed
@serrrfirat
serrrfirat deleted the codex/product-workflow-webui-readiness branch May 19, 2026 11:40
nickpismenkov added a commit that referenced this pull request May 20, 2026
…versationBindingService

Drops the Telegram-specific product_bindings table and its libsql/postgres
implementations in favor of the shared ProductConversationBindingService
(PR #3727) backed by ironclaw_conversations' filesystem store (PR #3679).
The shared facade fails closed on unpaired actors, so the host now reads
REBORN_TELEGRAM_PAIRINGS at boot and installs the operator-trusted
external-user → Reborn-user pairings idempotently before serving traffic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…ui-readiness

feat(product-workflow): prepare WebUI binding slice
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: low Changes to docs, tests, or low-risk modules scope: dependencies Dependency updates scope: docs Documentation size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant