Files
ironclaw/scripts/workflow_canary
Benjamin Kurrek 25ef441e71 fix(reborn): make extension readiness and channel delivery generic (#6520)
* fix(reborn): unify extension lifecycle and channel delivery

* Finish generic extension lifecycle and delivery correctness

* fix(reborn): repair integration + triggers test compilation

The generic extension/channel refactor renamed/removed several APIs but did
not propagate the changes into the shared integration test-support harness or
two new journey tests, so `cargo check --workspace --all-targets` failed with
101 errors (the whole `ironclaw_reborn_integration_tests` suite plus the
`ironclaw_triggers` lib-test), meaning none of the P0 journey tests could
build.

- Drop the removed `inbound_payload_classifier` / `classifier` fields from the
  test-support `ChannelExtensionBinding` and two `ChannelInboundSinkConfig`
  literals — gate-command classification now runs generically in
  `GenericChannelInboundSink`, so the per-binding classifier is obsolete.
- Remove the now-unused `RunDeliverySettings` and `ProductWorkflow` imports and
  point the stale `RebornServicesApi` import at the current `ProductSurface`
  trait that exposes `query`.
- Widen the `InMemoryTriggerRepository` history/lock helpers to `pub(crate)` so
  the relocated `src/tests.rs` module can reach them.
- `cargo fmt` (also fixes a pre-existing import-order drift in
  composition `outbound/mod.rs`).

No production behavior changes; this restores the deleted-then-relocated test
coverage so the suite compiles and can run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EK1qoiKDmXh2KzrgeMfTPf

* fix(mcp): skip shape-invalid tools instead of bricking the catalog

Hosted-MCP discovery rejected the entire advertised catalog on the first
shape-nonconforming tool (e.g. a non-lowercase name), so 23 valid tools +
1 bad tool made the whole integration unusable on first install with no
prior generation to fall back to.

parse_tools_list_result now distinguishes shape-only, non-security defects
(invalid tool name / description / annotations) from security/bounds
violations (missing or unsafe input schema, over-cap catalog). Shape-only
defects skip just the offending tool and publish the rest, emitting a
bounded debug record (tool index + stable cause token only). Security and
bounds violations still fail the whole generation with the unchanged stable
subcause; per-tool checks evaluate the schema first so a co-occurring
cosmetic defect cannot downgrade a security failure to a skip. A catalog
where every tool is shape-invalid still fails non-retryably (nothing to
publish), while an empty provider list stays an empty result.

Test-first regressions in mcp_adapter_contract.rs (survivors publish
end-to-end through the real client) and lib unit tests (skip+record,
security-amid-valid fails whole catalog, all-invalid fails, empty preserved).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EK1qoiKDmXh2KzrgeMfTPf

* fix(outbound): guard crash-recovery Sending->Unknown against clobbering a delivered send

`recover_interrupted_deliveries` listed `Sending` attempts from a point-in-time
snapshot and then blindly called `update_delivery_status(Unknown)` for each,
which unconditionally set the status. A concurrent worker that completed egress
and wrote `Delivered` could then be overwritten back to `Unknown` by a stale
recovery, durably losing a successful delivery (latent duplicate-send risk).

Add a dedicated `recover_interrupted_delivery_attempt` store method that
re-reads the attempt inside the same CAS the write commits against and
transitions `Sending -> Unknown` only when it is still `Sending` (mirroring the
`Prepared` guard on `claim_delivery_attempt_for_send`), returning `false`
otherwise. Route the coordinator's recovery through it so the guard, not the
list snapshot, decides. `update_delivery_status` stays an unconditional setter
for legitimate forward egress-result writes.

Regression: `recovery_transition_never_clobbers_delivered` in
`outbound_state_store_contract` drives record->claim(Sending)->Delivered, then
recovery, and asserts the row stays `Delivered` (and that a still-`Sending`
attempt is still recovered to `Unknown`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EK1qoiKDmXh2KzrgeMfTPf

* fix(extension-host): fence hosted-MCP discovery on stable credential authority

The discovery authority fence compared the whole `Vec<CredentialAccount>`
via the fence's derived `PartialEq`, which includes each account's volatile
`created_at`/`updated_at`. A benign write to the credential row between the
pre-discovery capture and the post-discovery recheck therefore tripped the
fence and forced a spurious `discovery_recheck` transient — a hard retry
loop if anything keeps touching the row mid-discovery.

`still_authorizes` now compares a stable, timestamp-free projection of each
account (id, status, access/refresh secret handles, scopes) alongside the
existing package, manifest digest, and tool ceiling. The fence stays
fail-closed on any real authority change (scope, secret, or status). The
fence's derived `PartialEq` is removed so no caller can reintroduce the
timestamp-sensitive comparison by accident. `CredentialAccount`'s own
derived `PartialEq` is deliberately left unchanged.

Test-first regressions through the real caller (`run_extension_activation`):
a benign `updated_at` bump activates without a spurious recheck; a scope,
secret, or status change still fails the fence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EK1qoiKDmXh2KzrgeMfTPf

* fix(run-delivery): gate completed-run handoff materialization + freeze Final dedup cursor

Two durable-delivery correctness fixes in the lifecycle-event router.

Fix 1 (HIGH) — unsettled completed-run handoffs leak durably and are re-scanned
every drain. `materialize_completed_handoffs` wrote a `RunFinalReplyHandoffRecord`
for EVERY `Completed` event on the global bus, but a handoff is only settled by a
live SOURCE channel handler. Runs no such handler owns — pure WebUI→WebApp
completions (no adapter), ScheduledTrigger runs (served by the volatile triggered
driver), and context-less runs — never settled, so under normal WebUI chat usage
every completion leaked one permanent pending row, and every drain (fires on every
lifecycle publish, from cursor 0) re-read and re-fanned-out all N leaked rows.
Unbounded durable growth + O(N)-per-event work that can starve real inbound
delivery. Gate materialization: give the durable replay a `TurnStateStore` seam and
materialize a handoff only when the completed run actually needs channel delivery —
`Inbound` origin always (source route or external target; blocked-on-OAuth channel
runs still deliver), or `WebUi` origin only with a sealed `External` target. Skip
WebApp/None-destination WebUI answers, scheduled triggers, and context-less runs.
The classification is fail-open toward delivery: any run-state/target lookup error
materializes, so a real channel reply is never dropped. The cursor still advances
past skipped events, so they are never re-scanned.

Fix 2 (MED) — the Final delivery's durable at-most-once identity was derived from
the re-fetched live `state.event_cursor`, stable only because Completed is currently
terminal. A future post-Completed cursor bump would drift the `ProjectionUpdateRef`
→ a new `delivery_id` → the CAS dedup misses → double final send. Key the Final
projection epoch off the frozen `event.cursor` the drain already loaded and
validated, making at-most-once structural rather than incidental.

Tests (run_delivery_contract), red-then-green:
- completed_webui_webapp_run_does_not_leak_a_durable_handoff and
  completed_scheduled_trigger_run_does_not_leak_a_durable_handoff — assert
  list_pending_run_final_reply_handoffs stays empty and the cursor advances; both
  FAIL (leaked pending handoff) with the gate neutered.
- completed_final_delivery_dedups_across_a_post_completed_cursor_advance — replays a
  Completed event after a simulated later cursor advance and asserts one send; FAILS
  (double "exactly-once final") when the epoch keys off live state.
- Existing channel-delivery journeys (delayed-OAuth final, duplicate-events-deliver-
  once, cross-channel, crash-replay) unchanged; the paginated-drain test reworked to
  keep Inbound fillers (which still materialize) instead of context-less ones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EK1qoiKDmXh2KzrgeMfTPf

* style(product-workflow): clear pre-existing clippy 1.96 findings blocking -D warnings

The corrective PR's own commits left clippy 1.96 findings that fail the required
`cargo clippy -p ironclaw_product_workflow -p ironclaw_outbound --all-targets --
-D warnings` gate (unrelated to the delivery bug fixes; surfaced once the gate was
run). Cleared as drive-bys so the gate is green:

- lifecycle_auth_continuation.rs: collapse nested `if let { if }` into a let-chain
  (collapsible_if); let-chains are already used elsewhere in the workspace.
- reborn_services_contract.rs: drop `.clone()` on the `Copy` `ActivityId` (clone_on_copy).
- channel_pairing_contract.rs: allow type_complexity on a test-only field.

No behavior change; all touched test suites still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EK1qoiKDmXh2KzrgeMfTPf

* fix(reborn): route discovery-time provider 401/403 back to OAuth

Discovery credentials are checked present pre-discovery, but if the provider
rejects them mid-`tools/list` (token expired/revoked) the concrete MCP client
returns `McpClientError::AuthRequired`. The composition discovery classifier
folded every non-catalog error (via a catch-all) into `Transient`, so the
extension stayed `setup_needed` and was retried forever, re-hitting the same
401 and never escalating to re-OAuth.

`classify_discovery_error` now matches `AuthRequired` explicitly and maps it to
a new `HostedMcpDiscoveryError::ReAuthRequired`; genuinely transient failures
(timeouts, 5xx) stay `Transient` and an invalid catalog stays `Permanent`. The
activation transaction's discovery step returns a `HostedMcpDiscoveryOutcome`
so the composition impl can turn `ReAuthRequired` into the same
credentials-missing outcome the pre-discovery missing-credential path uses
(re-deriving the extension's declared requirements from the package). The user
is routed back through OAuth with credential blockers, nothing publishes
(no false activation), the staged discovery authority is still revoked, and
credentials are not discarded from the store.

Test-first regressions: classifier unit test (AuthRequired -> ReAuthRequired,
not Transient), transaction-level routing test (CredentialsRejected ->
CredentialsMissing, no false-active, authority revoked), and a composition
end-to-end test driving a real 401 mid-`tools/list` through the concrete
client (Ok re-auth response with credential blockers, capability unpublished)
-- both shown red against the old fold. The `..._when_credential_epoch_changes`
fixture now rotates a real authority input (access secret) rather than only a
timestamp, matching the corrected discovery-fence semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EK1qoiKDmXh2KzrgeMfTPf

* fix(webui): correct chat gate/card affordances, prune dead auth+i18n paths

Lane F of PR #6520 review polish (all under ironclaw_webui/frontend, test-first):

- gates.ts/chat.tsx: derive the channel-connection (pairing) gate through one
  shared `channelConnectionFromGate` predicate so the composer affordance and
  the pairing-card selector can never disagree. A `manual_token` gate carrying
  a stray `connection` now stays token-paste in both places instead of the
  composer promising pairing. Pinned by a caller-level chat.test.ts regression
  (mutation-verified) + gates.test.ts unit coverage; backend invariant
  (auth_prompt.rs: connection only on challenge_kind==pairing) documented.

- auth-generic-card.tsx: delete the dead `gate?.remediation ||` branch — neither
  AuthPromptView nor AuthPromptContextView carries a remediation field, so
  gates.ts never populates one. Test now pins the neutral fallback is always used.

- onboarding-pairing-card.tsx + i18n: rename the stale "paste"/"code" fallbacks
  (pairing.openAndPaste -> pairing.connectInstructions, pairing.checkCodeAndRetry
  -> pairing.connectFailedRetry) to match the QR/deep-link/web-code flow, and
  drop orphaned keys (pairing.title/instructions/placeholder/approve/success/
  error/none/resumeFailed, pairing.web.copyUsername) consistently across all 11
  locales. New i18n.test.ts case pins retired-absent / renamed-present parity.

- configuration-tab.test.ts: cover the post-save reseed branch where `save`
  resolves WITH the saved group — reseeds non-secret values from the SAVED
  group, secrets stay blank (mutation-verified; matches production save shape).

pnpm test (849 pass), pnpm lint, pnpm build all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EK1qoiKDmXh2KzrgeMfTPf

* test(reborn): cover OAuth install Blocked::Auth path; stop boot re-attempt of Failed extensions

Item 1 (coverage): install_extension_on_surface accepts a dispatch-time
Blocked::Auth as success only when the caller-scoped membership readback
proves the exact package is now visible — pinning the join-before-block
ordering OAuth installs depend on. Adds two contract cases through the
real RebornServices seam (BlockedAuthExtensionInstallInvoker):
Blocked::Auth WITH membership visible -> success; WITHOUT membership ->
retryable 503. Break-to-confirm: removing the Blocked::Auth arm turns
case (a) into a 503, proving the test pins that branch. Also drops two
pre-existing clone_on_copy on ActivityId (Copy) in this owned test file.

Item 2 (fix): generic extension-host boot restore re-published every
installed non-hosted-MCP extension regardless of durable state, which
re-ran activation for a terminally-failed (Unhealthy) installation on
every boot and — on success — masked the failure as active, breaking the
InstallationState::Failed "does not auto-retry" contract (overview
§6.1). build_generic_extension_host now skips boot re-publication of an
installation whose durable health is Unhealthy, leaving it
installed-but-not-served and remediable; healthy and hosted-MCP restore
paths are unchanged. Regression test asserts a durably-failed install is
not re-published while a healthy sibling still restores.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EK1qoiKDmXh2KzrgeMfTPf

* fix(reborn): trust-gate [admin_configuration] to first-party manifests

An untrusted extension could declare an `[admin_configuration]` group
colliding with a first-party group id (e.g. `extension.slack`). The
composition fold registered admin descriptors from every catalog package
— including filesystem-discovered, non-first-party ones — so a colliding
third-party manifest either aborted every boot with a DescriptorConflict
(byte-different) or was silently registered as a consumer of the
first-party group's non-secret routing (byte-identical): a boot-DoS and a
non-secret confused-deputy.

Fail closed toward the safer behavior:
- Trust-gate `[admin_configuration]` at parse (v3): only host-bundled
  (first-party) manifests may declare an admin group. A filesystem
  manifest that declares one now fails to parse and is skipped by the
  existing fail-open catalog loader instead of aborting boot.
- Filter the composition fold (`admin_configuration_uses`) to first-party
  sources as defense in depth: a non-first-party package can never be
  folded as a consumer or reach the descriptor service.

Coverage:
- The v3 trust gate rejects InstalledLocal/RegistryInstalled admin
  groups; a filesystem package is skipped without aborting boot while
  the first-party group still resolves; the fold excludes non-first-party
  sources.
- The four previously-uncovered validate_channel_admin_configuration
  branches (egress credential, egress body credential, connection
  deep-link placeholder, wrong secret flag) now fail closed under test.
- The ordinary-user (lifecycle/setup) projection carries no admin
  material; the admin-configuration routes 403 a non-operator caller.

test_support::resolve now stamps HostBundled: its channel fixtures
declare admin-config-backed signing secrets, which only first-party
manifests may do; resolved output is source-independent for their
third-party trust class.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EK1qoiKDmXh2KzrgeMfTPf

* fix(reborn): stop the shared ingress classifier from swallowing normal chat

The channel-neutral gate-command classifier runs generically on every
inbound message. Any message whose first token was a command verb
(`approve`/`deny`/`auth`) but which was NOT the reserved gate-command
shape classified to `NoOp` and settled silently — no turn, no
user-visible feedback. A normal chat message like "approve this design"
was lost, and a bare `auth deny` (missing ref) or `auth deny gate:x
extra` was pulled out of the conversation the same way.

Distinguish a *confident* gate command — the reserved shape the system
advertises: a bare `approve`/`deny`, or any verb carrying a `gate:<ref>`
(`approve gate:<ref>`, `auth deny gate:<ref>`) — from ambiguous natural
language that merely starts with a verb. Confident commands still
classify to their resolution payload, so the real approve/deny/auth gate
flow is unchanged. Everything else now returns `Ok(None)` and falls
through to normal turn handling instead of being silently classified out
of the conversation as a no-op. (`Err` is still returned only when a
confident command carries a hostile/invalid ref that fails payload
validation.)

Because the classifier is the single channel-neutral definition, the fix
applies uniformly across Slack, Telegram, and the generic sink — no
per-channel drift.

Tested at two seams:
- classifier grammar: ambiguous verb-first text ("approve this design",
  bare `auth deny`, `auth deny gate:x extra`, "deny that idea") routes as
  a user message; confident `approve gate:<ref>`, bare `deny`, and
  `auth deny gate:<ref>` still parse to their resolution payloads.
- GenericChannelInboundSink ingress: ambiguous verb-first chat reaches
  the workflow as a UserMessage (a turn IS submitted, not swallowed);
  confident `approve gate:<ref>` still reaches it as an ApprovalResolution
  and bare `deny` as a ScopedApprovalResolution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EK1qoiKDmXh2KzrgeMfTPf

* fix(6520): harden trigger self-mutation + OAuth reconcile + origin/delivery coverage (lane T)

Review-pass items 1-6 for PR #6520:

1 (serve.rs): document that poller-on wires the TenantMembership grant
  unconditionally by design — fire access is gated on ACTIVE canonical
  membership at fire time (IdentityMembershipTriggerFireChecker denies
  suspended/unknown/wrong-tenant), not the auth method. Enforcement is already
  tested at the checker seam and the policy-shape regression test already exists.

2 (trigger_management.rs): broaden the routine self-mutation backstop to deny
  the Automation origin as well as ScheduledLoopRun (matches the descriptors'
  automation=Forbidden gate matrix); document the defense layering (the runner's
  scheduled_trigger surface-deny and the subagent flavor tool allowlist are the
  structural guarantees; this origin check is the backstop, which cannot see a
  subagent's lost ScheduledTrigger lineage — the surface exclusion protects that
  path, and spawn_subagent is globally disabled pending #4147). +5 crate-tier
  tests through dispatch().

3 (lifecycle_auth_continuation.rs): do NOT durably fence a SetupIncomplete OAuth
  continuation — return a retryable error so the caller leaves the flow
  un-fenced and a later cross-replica reconcile completes the fan-out once
  readiness is Active. auth.rs documents the caller side. +unit re-drive test +
  composition route reconcile-recovery test.

4 (scope.rs): document resolved_origin's LoopRun-only fallback invariant — a
  scheduled run always stamps ScheduledLoopRun upstream, so it never reaches or
  is downgraded by the fallback. +tests. A debug_assert/fail-closed was rejected:
  it breaks the pinned transitional-compat contract in ironclaw_capabilities::host.

5 (trigger_management.rs): test that an explicit delivery_target_id wins over a
  source run context and the implicit resolver is never consulted.

6 (invocation.rs): correct the stale "nothing wired into the dispatch path yet"
  doc (Invocation/InvocationOrigin are consumed live). The group_triggers
  external-source scenario now asserts the persisted delivery_target_id EQUALS
  the registered source target, not just is_some().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EK1qoiKDmXh2KzrgeMfTPf

* chore(reborn): recapture composition pub-use snapshot + drop dead OperatorSetupCall alias

Two pre-existing merge blockers exposed by the strict gates (both on the base
PR head, not introduced by the correctness fixes):

- docs/plans/composition-pubuse.snapshot still listed `InboundPayloadClassifier`,
  which the generic refactor removed from the composition public surface;
  `ironclaw_architecture::composition_public_pub_use_surface_matches_snapshot`
  failed. Recaptured to match `lib.rs`.
- `type OperatorSetupCall` in webui_v2_handlers_contract.rs was unused, tripping
  `clippy -D warnings` (dead_code). Removed.

Result: `cargo clippy --workspace --all-targets --all-features -- -D warnings`
now exits clean across the workspace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EK1qoiKDmXh2KzrgeMfTPf

* fix(reborn): finish #6442 test-suite reconciliation to the #6520 contracts

Production wiring restored (dropped in the merge, surfaced as dead code):
boot-time hosted-MCP reconcile after restore, the composed
RecipeAuthChallengeProvider (pairing + product-auth challenges) feeding
projections and channel delivery, current-delivery-target resolver assembly
attach, and manifest-derived account-setup descriptors (catalog + injected
extras) so /start pairing and connect notices compose again.

Test reconciliation (no test deleted or weakened): #6442-side suites
rewritten to the no-Activate three-state lifecycle (install drives
readiness; installs are caller-private, so surface tests install as the
surface user), caller-scoped 3-arg project with the production credential
gate, LifecyclePublicState assertions, admin-configuration seeding via the
composed resolver, RunFinalReplyDestination::External, and the 1-arg
active-extension authority in the integration harness. Three #6520 test
accessors ported onto the flat RebornRuntime. Ratchet maintenance:
HostedMcpDiscoveryOutcome frozen-name entry, specificity allowlist
re-pathing for merge-moved files, stale entries dropped, and the
banned "mcp_server" literal swapped out of a lifecycle test fixture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WF1EiatUVLjKKs3eYGMbKV

* chore(reborn): relocate channel-host e2e tests under the test-only path convention

The no-panics checker exempts src/**/tests/* and cannot see cross-file
cfg(test) gating; a #[path] attribute keeps the module name and its
super:: references unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WF1EiatUVLjKKs3eYGMbKV

* fix(reborn): honor typed auth denial end-to-end + compose the lifecycle OAuth continuation

Executor/runner denial tests rewritten to the typed-denial contract (the
host terminalizes the exact durable invocation through the capability
port; unrelated batch calls dispatch alongside). Three harness
HostRuntime wrappers forward the new decline_auth_capability trait
method (its default fails closed and was killing every denied-auth-resume
run at the integration tier).

Production fixes surfaced by the suites: the admin-configuration
resolver treats an extension with no declared [admin_configuration] (or
one imported after boot) as empty config instead of failing activation
with UnknownExtension; the restored hosted-MCP boot reconcile skips
unresolvable orphan installation rows instead of failing boot (§6.5);
and the factory now composes lifecycle_auth_continuation_dispatcher over
the base product-auth dispatcher via a two-phase build (dependencies
clone first, dispatcher wrap after the lifecycle facade exists) — an
extension-card OAuth completion re-enters the canonical lifecycle
install/readiness command instead of being durably fenced un-activated.
Regression: completed_lifecycle_activation_continuation_installs_the_extension
drives flow create → claim → complete → reconcile through the composed
runtime and pins install + membership + published tools + the fence.

Golden payload snapshots regenerated for the intended
ResultReference success-observation shape, with minted result refs
normalized alongside the existing volatile fields. Isolation fixture
supplies the bundled first-party surface (test-support builds skip the
cfg(test)-only injection); restart test expects caller-private scope
per the membership model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WF1EiatUVLjKKs3eYGMbKV

* style(reborn): format merged sources; carry frontend test reconciliation

The merge commit missed the unstaged frontend test rewrites (client
action id wire, no-Activate) and post-merge rustfmt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WF1EiatUVLjKKs3eYGMbKV

* fix(reborn): burn down post-merge CI failures — restore dropped wirings, reconcile tests to #6520 contracts

Production fixes (dropped in the main merge, restored from the PR side):
- factory.rs: register the product-owned RunFinalReplyRoutingService over the
  fail-closed UnavailableRunFinalReplyRouter so the model-facing
  builtin.outbound_delivery_target_route_current capability can route a run's
  final reply again
- factory.rs: seed the host-owned WebApp final-reply target in the outbound
  delivery target registry (host_owned_outbound_delivery_target_registry)

Test/harness reconciliations to the merged #6520 + client_action_id contracts:
- webui handlers contract: prime membership read-back for second installs,
  add required client_action_id to install bodies, reconcile retired
  "unsupported" phase literal to setup_needed (118/118)
- webui lib: update SPA shape tests to the Lane F channelConnectionFromGate
  predicate + split configure-modal expression (187/187)
- frontend: drop dead ./pairing-api import (tsc/lint green)
- composition: auth_tests lifecycle-activation callback test installs github
  first and asserts Active projection; core.rs auth-gate test seeds the
  caller-phase notion account (secretless) so the surface is model-visible
- webui_v2_e2e: uninstalled-phase literals, no-Activate reconciliations
  (install owns fail-closed provider-instance 400; OAuth-complete asserts
  active projection instead of the deleted activate route) (14/14)
- webui_v2_serve: setup projection phase literal (62/62)
- CLI extension: search envelope neutral phase (6/6)
- integration: client_action_id on isolation/product_api lifecycle bodies;
  harness github fixture gains a /tenants mount + filesystem run-state store so
  typed auth-gate deny/resume terminalizes durably (auth_gate 17/17); fake
  outbound facade mirrors the production always-present web_app target;
  QA smoke counts distinct install gestures (hosted-MCP bounded retries
  re-dispatch the same activity id)
- live-QA canary harness: send required client_action_id on install/setup
  submit (4 sites) — root cause of the 12/12 canary wipeout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WF1EiatUVLjKKs3eYGMbKV

* fix(e2e): skip quarantined retired-activation traces in manifest loaders

The PR quarantined the retired-activation fixtures (files moved under
quarantined_retired_activation/, manifest gained quarantined_model_cases)
but the three python selected_cases consumers still read every case file at
collection time, so pytest died on FileNotFoundError before running a single
scenario (the WebUI v2 smoke lane wipeout).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WF1EiatUVLjKKs3eYGMbKV

* merge: fold main c6696baef (#6583 product-surface vocabulary, #6580, #6588) into the PR branch

Adopts the agent-resolved merge commit 461e348df (78 conflicted files:
ironclaw_product_workflow -> ironclaw_product crate rename, adapters folded
into ironclaw_product/host_api, ProductSurfaceCaller/ProductSurfaceError
vocabulary, generic ProductSurface trait in host_api) and repoints the
post-merge test fixes to the renamed crate. #6520 semantics preserved: no
Activate anywhere, required client_action_id install gesture, event-driven
delivery, channel-config deletion upheld.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WF1EiatUVLjKKs3eYGMbKV

* fix(reborn): route channel-pairing completions through the lifecycle continuation dispatcher

Pairing completions dispatch AuthContinuationRef::LifecycleActivation, but
composition handed every ChannelPairingService a freshly built base
turn-resume dispatcher instead of the lifecycle-wrapped one product-auth
uses, so completing a pairing never re-ran readiness reconciliation /
runtime publication. Live repro on the demo stack: telegram remove →
install (activation parks on the connection requirement, unpublished) →
pair (bot replies paired) → card stuck at setup_needed forever.

Both product-auth and the pairing registry now share one wrapped dispatcher.
Regression pinned by pointer identity at the composition seam
(channel_pairing_completions_run_the_lifecycle_wrapped_continuation_dispatcher;
verified red pre-fix), via new test-support dispatcher accessors.

Also: live-QA canary harness reconciled to the post-#6520 wire (slack OAuth
start sends the manifest requirement handle; retired readiness booleans
dropped in favor of installation_state; non-secret setup values route
through the operator extension-configuration surface).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WF1EiatUVLjKKs3eYGMbKV

* fix(ci): burn down the remaining red lanes at 6e948cb42

Production wiring restored (dropped in the merges):
- factory.rs: LocalRuntimeTriggerCreatorPairingHook regains the #6520
  resolve_implicit_delivery_target override through a restored
  TriggerFinalReplyTargetService, reading a new late-bound
  TurnStateStore slot (trigger_source_turn_state_store) that the
  test-support repoint seam swaps alongside the snapshot slot — a
  trigger created from an externally-sourced turn inherits that turn's
  reply destination again
- trigger_final_reply_target.rs: the implicit-inference arm seals no
  target when the source run's reply binding maps to no current
  outbound target (explicit targets still fail closed); previously it
  failed the whole trigger_create

Test reconciliations to the post-#6520 contracts:
- composition core.rs: webui bundle target listing pins the
  always-present host-owned web_app destination (was: empty)
- scenario_trigger_self_create_denied: typed Resolution::Denied
  (PolicyDenied) pin + denied-before-dispatch re-pinned on capability
  RESULT absence (the authorize consolidation records port invocations)
- outbound_target: inventory = two seeded targets + builtin:web_app
- mcp.rs (main #6580): nearai journey reconciled to no-Activate —
  account seeded before install, install owns readiness and publication
- qa smoke bundled-surface: hosted-MCP nearai.web_search excluded from
  the binary-tier static-surface assert (no discovery egress seam
  there); its contract is pinned end-to-end by reborn_integration_mcp
- event-driven delivery wire asserts poll with the files' bounded 30s
  deadline instead of one post-idle snapshot (4 sites: telegram pairing
  + coordinated reply, slack final reply, cross-channel immediate)
- model_replay assert_provider_tools re-checks the surface for up to
  10s (hosted-MCP discovery publishes asynchronously)

New executable evidence (inventory gate):
- ProviderOperationCase entries for github get_authenticated_user /
  get_repo / list_releases and google-calendar list_calendars, whose
  harvested journeys were quarantined with the retired activation flow

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WF1EiatUVLjKKs3eYGMbKV

* merge: fold CI burn-down batch — trigger delivery-target capture restored, red lanes reconciled

Folds the agent-resolved CI fixes (202629964): restores the dropped
trigger-create implicit delivery-target capture (TriggerFinalReplyTargetService
over a late-bound TurnStateStore slot — the live "joke trigger delivered to
the web app instead of telegram" defect), reconciles the retired-contract
asserts (typed Denied verdicts, web_app always-present inventory, no-Activate
nearai evidence), converts four post-idle wire snapshots to the established
bounded poll under event-driven delivery, and re-covers the four
quarantine-orphaned provider capabilities with typed cases. Post-merge fmt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WF1EiatUVLjKKs3eYGMbKV

* fix: drop concrete extension name from generic composition comment; withdraw red repro pending its fix

The specificity gate rejects generic composition code naming a concrete
extension even in comments. Also withdraws the deliberately-red
member-remove repro test that was pushed prematurely in the previous
commit — it returns together with the identity fix that turns it green
(in flight), keeping the branch honest about what is currently pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WF1EiatUVLjKKs3eYGMbKV

* fix(ci): cycle-2 burn-down at 7011672c2

- facade/tests.rs: restore the `Blocked` import lost in the fold — the
  test-support/all-features compile error behind BOTH the Code Style
  clippy lane and the composition-core bucket (exact lane re-run: zero
  findings; bucket flags compile clean; facade tests 11/11)
- first-party coverage ratchet: `builtin.outbound_delivery_target_route_current`
  is a real registered builtin again (#6520 router restore); its e2e
  coverage is named — reborn_integration_delivery_user_journeys
  ROUTE_CURRENT journeys (26/26 ratchet suite green)
- webui_v2_product_api legacy canonicalization test: install body gains
  the required #6520 client_action_id gesture (29/29)

Responses API `test_reborn_responses_rejects_wrong_external_tool_call_id`:
unreproducible at this head — green locally alone, full-file, and in the
CI two-file shared-session shape (28/28); the one CI occurrence failed
server-side fast with the response error field truncated from the log.
Left untouched; if it recurs, capture the response error detail first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WF1EiatUVLjKKs3eYGMbKV

* fix(webui): carry the client gesture id on extension remove — stop permanent input deduplication

Root cause of the live "remove says success but stays installed" defect: the
#6596 merge restored main's remove_extension, which derives its ActivityId
from (caller, capability, input) with no client gesture component. The
product-capability invoker replays the durably recorded resolution per
activity id, so after one successful remove of an extension, EVERY later
remove of the same extension by the same caller — including after a
reinstall — replayed the first remove's recorded success without
dispatching: HTTP 200, no new product-result row, durable membership
untouched. First-vs-repeat remove per (user, extension) exactly matched the
live timeline (notion's first remove worked and recorded; telegram/slack
replayed their 21:5xZ successes forever).

remove_extension now takes RemoveExtensionBody with a required
client_action_id and derives extension_lifecycle_activity_id, mirroring
install (#6520 gesture idempotency: distinct gestures dispatch, response-lost
retries replay).

Regression tests (all verified red before their fix):
- webui_v2_handlers_contract: remove gesture-idempotency test (distinct
  gestures -> distinct activity ids; retry -> same id)
- facade/tests: channel-extension remove through the real product dispatch
  asserts the DURABLE membership row is deleted (the prior test asserted only
  the resolution verdict), plus caller-scoped projection read-back (installer
  sees telegram, another member does not — acceptance-contract member
  binding)
- factory/tests: re-land the withdrawn member-remove repro, green over the
  factory-tier channel-disconnect slot fill

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WF1EiatUVLjKKs3eYGMbKV

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:11:24 -04:00
..
2026-04-27 23:46:34 -07:00
2026-04-27 23:46:34 -07:00
2026-04-27 23:46:34 -07:00
2026-04-27 23:46:34 -07:00
2026-04-27 23:46:34 -07:00
2026-04-27 23:46:34 -07:00
2026-04-27 23:46:34 -07:00
2026-04-27 23:46:34 -07:00
2026-04-27 23:46:34 -07:00
2026-04-27 23:46:34 -07:00

Workflow Canary

End-to-end canary lane for the multi-tool / multi-channel user workflows defined in issue #1044. Where auth-live-canary covers credential / OAuth flows, this lane covers what happens after the user is authenticated: cron-driven routines, chat-driven tool dispatch, Telegram round-trips, Sheets writes, etc.

Lane structure

scripts/workflow_canary/
├── run_workflow_canary.py     # entrypoint
├── telegram_mock.py            # fake Telegram Bot API
├── sheets_mock.py              # mock Google Sheets v4
├── calendar_mock.py            # mock Google Calendar v3
├── hn_mock.py                  # mock Hacker News /newest
├── gmail_mock.py               # mock Gmail v1
├── web_search_mock.py          # mock Brave Search v3
├── telegram_setup.py           # install + capability patch + pairing helpers
├── routines.py                 # libSQL helpers (insert + backdate + poll)
└── scenarios/
    ├── _common.py                              # shared run_routine_probe()
    ├── bug_logger.py                           # Script 1 — Sheet write
    ├── calendar_prep.py                        # Script 2 — Calendar → Telegram
    ├── hn_monitor.py                           # Script 3 — HN → Telegram
    ├── periodic_reminder.py                    # Script 4 — periodic reminder
    ├── crm_tracker.py                          # Script 5 — Gmail → Sheets CRM
    ├── manual_trigger.py                       # POST /api/routines/<id>/trigger
    ├── lifecycle.py                            # disable/enable/delete via API
    ├── dedup_cooldown.py                       # cooldown_secs back-to-back
    ├── nl_routine_create.py                    # NL → routine_create tool
    ├── nl_schedule_update.py                   # NL → routine_update tool
    ├── telegram_channel_install.py             # install + setup → Active
    ├── telegram_round_trip.py                  # webhook → agent → reply
    ├── routine_visibility_from_telegram.py     # paired user → list routines
    ├── manual_trigger_from_telegram.py         # paired user → trigger now
    ├── first_immediate_run.py                  # fire_immediately ≤ 10s
    ├── idempotent_disable_enable.py            # double-toggle is no-op
    ├── cron_timing_accuracy.py                 # next_fire_at ±10s
    └── log_assertions.py                       # gateway log scan (runs LAST)

scripts/live-canary/run.sh dispatches LANE=workflow-canary here, and .github/workflows/live-canary.yml has a matching workflow-canary job in the live-canary matrix.

Mock surfaces

Every external Google / external service is replaced by a single-port aiohttp mock in this directory. Mocks announce their port via MOCK_<NAME>_PORT=<n> on stdout (caught by wait_for_port_line), expose Bot-API-equivalent handlers under their canonical paths, and provide /__mock/... test hooks for seeding / draining / resetting.

run_workflow_canary.py builds a comma-joined IRONCLAW_TEST_HTTP_REMAP for the gateway env so outbound HTTP for api.telegram.org, sheets.googleapis.com, www.googleapis.com, news.ycombinator.com, gmail.googleapis.com, and api.search.brave.com lands at the corresponding mock loopback address.

What's covered today

20 probes across 7 phases:

Phase 1 (Sheets): bug_logger asserts the routine fires AND a row gets appended to mock_sheets with the correct timestamp / message / source shape. Catches the canonical "expected a sequence" regression.

Phase 2 (Calendar): calendar_prep seeds a deterministic event, asserts mock_calendar saw events.list AND mock_telegram received a prep briefing referencing the seeded event title.

Phase 3 (HN): hn_monitor seeds two posts, asserts mock_hn received the /newest fetch AND mock_telegram captured a summary mentioning both posts.

Phase 4 (CRM): crm_tracker seeds 1 lead + 1 newsletter + 1 receipt, asserts exactly ONE row gets appended to mock_sheets (the lead only) with all 6 expected columns.

Phase 5 (Telegram): telegram_channel_install runs the full install + capability patch + setup flow and asserts the channel reaches the installed/active state. telegram_round_trip posts an inbound webhook and asserts mock_telegram receives an outbound sendMessage with the actual chat_id (not 'default'). routine_visibility_from_telegram + manual_trigger_from_telegram exercise the post-pairing chat path.

Phase 6 (Stability): first_immediate_run asserts a backdated routine fires within 10s. log_assertions runs LAST and scans gateway.log for known fail-criterion regex patterns (chat_id 'default', parsed naive timestamp without timezone, retry after None, expected a sequence).

Phase 7 (Timing): cron_timing_accuracy sets next_fire_at to "now + 5s" and asserts the actual fire happens within ±10s. idempotent_disable_enable double-toggles enable/disable and asserts both halves are no-ops.

How to run locally

tests/e2e/.venv/bin/python scripts/workflow_canary/run_workflow_canary.py \
  --skip-build --skip-python-bootstrap

Run a single scenario:

tests/e2e/.venv/bin/python scripts/workflow_canary/run_workflow_canary.py \
  --skip-build --skip-python-bootstrap \
  --scenario telegram_round_trip

CLI matches run_live_canary.py so the same scripts/live-canary/run.sh dispatcher drives both.

Adding a new scenario

  1. Drop a scenarios/<name>.py exporting an async def run(*, stack, mock_telegram_url, mock_sheets_url=None, mock_calendar_url=None, mock_hn_url=None, mock_gmail_url=None, mock_web_search_url=None, output_dir, log_dir) -> list[ProbeResult].
  2. For routine-only coverage, delegate to scenarios._common.run_routine_probe() — pass the script-specific provider / mode / routine_name / prompt and you're done.
  3. For side-effect verification, drive the relevant API directly and read back from the appropriate mock's /__mock/ endpoint. The ProbeResult.details dict is the right place to capture observed side effects for the artifact.
  4. Register in SCENARIOS in run_workflow_canary.py. Order matters — log_assertions should always run last so it sees the full log surface.
  5. For new mocks, add a <name>_mock.py in this directory, a _spawn_mock_<name> helper in the runner, and an entry in the comma-joined IRONCLAW_TEST_HTTP_REMAP.

Deferred coverage

  • Real-provider variant (workflow-canary-live) — same probes with real Gmail / Calendar / Sheets credentials. Separate lane.
  • Auth recovery (token revocation → auth_required SSE event with valid auth_url) — requires real OAuth setup; covered by auth-live-canary.
  • UI flows (Approval modal, Reconfigure flow, "Run now" button, Routines tab interactions) — Playwright concerns; belong in auth-browser-consent or a new routine-ui-canary lane.
  • App-level dedup across cron fires — would need a feature on the agent side to track already-reported items; not implemented.