Skip to content

feat(reborn): Telegram v2 inbound tracer (webhook → ledger + binding, no reply) - #3590

Closed
nickpismenkov wants to merge 18 commits into
mainfrom
feat/reborn-telegram-v2-wiring
Closed

nickpismenkov wants to merge 18 commits into
mainfrom
feat/reborn-telegram-v2-wiring

Conversation

@nickpismenkov

@nickpismenkov nickpismenkov commented May 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Scope: Stands up the Reborn ProductAdapter / ProductWorkflow stack as an
inbound tracer for Telegram v2. Webhook delivery → shared-secret auth →
idempotency ledger → conversation binding → 200 ACK. The reply path is
intentionally stubbed
; this PR does not produce outbound sendMessage.

Why no reply yet: the Reborn agent loop (DefaultInboundTurnService +
a real TurnCoordinator) ships across the still-open PRs #3544, #3550,
#3586. Without those, there is no concrete turn executor to invoke;
render_outbound has nothing to render. Wiring a canned/echo reply path
just to fire sendMessage would bake throwaway code into a production-marked
binary and create a non-trivial revert when the loop lands. We chose to ship
the inbound contract durably and merge the reply path in a focused follow-up
once the loop is in src/.

Operator visibility: the stub status is documented prominently in
crates/ironclaw_reborn_telegram_v2_host/CLAUDE.md
("Why 'stubbed reply path'") and in the module docs of
src/inbound_turn.rs.
On every inbound the host logs "Reborn host: inbound resolved + bound; reply path stubbed (no Reborn agent loop yet)" so it cannot be missed in
production.

Renamed from "Telegram v2 tracer — end-to-end webhook → reply" to better
match what the diff actually ships (per @serrrfirat's review:
#issuecomment-4454525610,
finding #1).

What this lands

Piece File
libSQL V26 + Postgres V28 migrations (product_inbound_actions, product_bindings) src/db/libsql_migrations.rs, migrations/V28__product_inbound_actions_and_bindings.sql
Durable IdempotencyLedger impls (both backends) crates/ironclaw_product_workflow_storage/src/ledger_*.rs
Durable ConversationBindingService impls crates/ironclaw_product_workflow_storage/src/binding_*.rs
TelegramHttpEgress shim (reqwest, declared-host allowlist, credential-handle resolve) crates/ironclaw_product_workflow_storage/src/egress.rs
OutboundStateStoreDeliverySink (reuses existing ironclaw_outbound) crates/ironclaw_product_workflow_storage/src/outbound_sink.rs
Standalone host crate (composition + axum router + serve loop), wired into the ironclaw-reborn binary behind a telegram-v2 Cargo feature crates/ironclaw_reborn_telegram_v2_host/, crates/ironclaw_reborn_cli/src/commands/run.rs
Stub inbound turn service (resolves binding, returns synthetic Submitted, no reply) crates/ironclaw_reborn_telegram_v2_host/src/inbound_turn.rs
Fail-closed default-namespace guard (rejects default / tenant_default / agent_default without explicit opt-in) crates/ironclaw_reborn_telegram_v2_host/src/config.rs
Atomic create-or-bind in conversation binding service (no orphan threads on first-bind race) crates/ironclaw_product_workflow_storage/src/binding_{libsql,postgres}.rs

What this does NOT land

Design alignment

Follows the Reborn channel porting guide (docs/reborn/how-to-port-channel-to-reborn.md) Path C native-core shape and #3577 shared acceptance criteria for storage, host-mediated egress, credential handles, declared egress, redaction, default-off gates, and idempotency.

Two deliberate interim deviations, both anticipated by the design:

  1. Native runner, not WASM component. NativeProductAdapterRunner (PR feat(reborn): add native product adapter runner #3353) until Implement WASM ProductAdapter component runtime #3583's WASM runtime ships. The porting guide explicitly permits native for parse/render in the tracer phase. Migration: swap one constructor call once Implement WASM ProductAdapter component runtime #3583 lands.
  2. No reply path. Stub instead of DefaultInboundTurnService + real TurnCoordinator. See "Why no reply yet" above. Migration: drop StubInboundTurnService, wire the real services once docs(reborn): agent loop skeleton framework spec + 9 workstream briefs #3544 / arch(ws-0): state, checkpoints, BoundedRing, CapabilityCallSignature, NoProgressDetected #3550 / arch(ws-6): canonical executor — strategy dispatch + checkpoint contract #3586 land.

Test plan

  • cargo fmt --all -- --check — clean
  • cargo clippy --bin ironclaw --features libsql --tests -- -D warnings — clean
  • cargo clippy -p ironclaw_product_workflow_storage --features libsql,postgres --tests -- -D warnings — clean
  • cargo test -p ironclaw_product_workflow_storage --features libsql,postgres — passes
  • cargo test -p ironclaw_reborn_telegram_v2_host — full e2e webhook → ledger settle + binding row + auth fail paths
  • cargo test -p ironclaw_reborn_cli --all-features — CLI smoke (incl. regression that run enters the Telegram v2 path when TELEGRAM_BOT_TOKEN is set)
  • cargo test -p ironclaw_architecture --test reborn_dependency_boundaries — dependency boundary tests pass

Review feedback addressed

@serrrfirat's #issuecomment-4454525610:

  • Finding Move whatsapp channel source to channels-src/ for consistency #1 (High — reply not implemented): Resolved by narrowing PR scope and title. Stub is documented in code and CLAUDE.md; follow-up PR will wire the real DefaultInboundTurnService once the agent loop lands.
  • Finding feat: adding Web UI #2 (Medium — binding/thread race): Fixed. Binding insert is now attempted before thread creation; on lost race, no orphan thread is created. Regression test added under contention.
  • Finding Onboarding: show Telegram in channel selection and auto-install bundled channel #3 (Medium — namespace collision via defaults): Fixed. Startup now rejects literal default / tenant_default / agent_default unless IRONCLAW_REBORN_ALLOW_DEFAULT_NAMESPACE=1 is set (intended for dev/test only). Regression tests cover both the rejection path and the opt-in path.

Refs

🤖 Generated with Claude Code

Stand up the Reborn ProductAdapter / ProductWorkflow stack as a real,
working second Telegram channel that round-trips a message live. Coexists
with the v1 WASM Telegram channel; `REBORN_TELEGRAM_V2_ENABLED` gates
activation, and the existing exclusivity guard at
`src/config/channels.rs:517` prevents both paths from running for the
same install.

What this lands
---------------

* **`crates/ironclaw_product_workflow_storage/`** — new crate with
  durable `IdempotencyLedger` + `ConversationBindingService` for both
  libSQL and Postgres, a `TelegramHttpEgress` shim implementing
  `ProtocolHttpEgress` over reqwest, and `OutboundStateStoreDeliverySink`
  over the existing `ironclaw_outbound::OutboundStateStore`. 13 unit
  tests covering both backends.

* **Schema** — libSQL migration V26 + Postgres migration V28 add
  `product_inbound_actions` (idempotency ledger keyed by adapter +
  installation + source_binding_key + external_event_id) and
  `product_bindings` (external chat → Reborn user/thread mapping).

* **`src/channels/reborn/`**:
  * `composition.rs` — wires the storage + transport layer against
    `DatabaseHandles`; Postgres takes precedence when both backends are
    configured; falls back to libSQL.
  * `boot.rs` — orchestration helper: reads secrets, builds adapter,
    runner, workflow, channel; wires the axum route. Returns
    `Ok(None)` when secrets are missing so the binary boots cleanly.
  * `v2_inbound_turn.rs` — custom `InboundTurnService` that bypasses
    `TurnCoordinator` (no Reborn executor exists yet) and emits
    `IncomingMessage` onto the v1 `ChannelManager` stream.
  * `product_channel.rs` — synthetic `Channel` impl that receives v1's
    `OutgoingResponse`, builds a `ProductOutboundEnvelope`, calls
    `TelegramV2Adapter::render_outbound` via the egress shim, and
    records delivery status. Two-phase async: respond() returns
    immediately, render+POST runs in a spawned task.
  * `v2_router.rs` — axum handler at
    `POST /webhook/telegram-v2/{installation_id}` delegating to
    `NativeProductAdapterRunner::process_webhook`.

* **`src/main.rs`** — boot wiring under the v2 enable flag. Hooks into
  the existing `WebhookServer` + `ChannelManager` infrastructure.

* **`src/app.rs`** — expose `database_handles` on `AppComponents` so
  the v2 wiring can construct backend-specific stores.

* **`ironclaw_product_workflow`** — adds `ProductActionId::from_uuid`
  for durable rehydration on replay.

* **`ironclaw_telegram_v2_adapter`** — re-exports
  `build_reply_target_binding`.

Tests
-----

13 storage-crate tests + 6 e2e tests + the existing 46 telegram v2
adapter tests, all green. Coverage includes:

  * libSQL ledger: new/replay/in-flight/release semantics
  * libSQL binding: first-resolve / idempotent / actor-PK isolation
  * `TelegramHttpEgress`: declared-host allowlist, credential resolve,
    **real reqwest POST against a `tokio::net::TcpListener`** verifying
    URL path (`/bot{token}/sendMessage`), Content-Type header, body bytes
  * `OutboundStateStoreDeliverySink`: Delivered / DeadLettered status
    mapping
  * E2E webhook → bus, duplicate `update_id` replay through the ledger,
    invalid secret rejected as 401, `Channel::respond` → mock egress
  * **ChannelManager round-trip**: real `ChannelManager`, real
    `add(Box<dyn Channel>)`, real `respond()` → asserts mock egress
    receives correct `sendMessage` and `OutboundStateStore` records
    `Delivered`. Closes the test-coverage gap that masked a live bug.
  * ChannelManager rejects unknown channel as `Err` (no silent drop)

Verified live
-------------

Real Telegram bot, cloudflared tunnel, `setWebhook` registered against
`/webhook/telegram-v2/default`. User sends "hey" → ironclaw parses via
`TelegramV2Adapter` → ledger row inserted → binding row created →
`IncomingMessage` lands on the v2 bus → v1 agent runs LLM (NEAR AI
GLM-5-FP8) → `OutgoingResponse` flows back through
`ChannelManager::respond` → `ProductChannel::respond` → spawned task
calls `TelegramV2Adapter::render_outbound` → `TelegramHttpEgress` POSTs
to `api.telegram.org` → reply appears in Telegram.

Aligns with design
------------------

Follows the Reborn channel porting guide
(`docs/reborn/how-to-port-channel-to-reborn.md`) Path C native-core
shape. Aligned with #3577 acceptance criteria for storage, host-mediated
egress, credential handles, declared egress, redaction, default-off
gates, and idempotency.

Two deliberate deviations, both anticipated as interim by the design:

  1. **Native runner, not WASM component.** Uses
     `NativeProductAdapterRunner` (PR #3353) rather than the WASM
     component runtime in open PR #3583. The porting guide explicitly
     says "Native code is still useful for pure parse/render logic and
     tests, but not as the production boundary"; #3577 calls this out as
     "the native tracer bullet is not treated as final production
     boundary." Migration path: swap `TelegramV2Adapter::new(...)` for
     `ProductAdapterComponentRuntime::load(...)` in `boot.rs` once
     #3583 lands.

  2. **v1 `ChannelManager` bridge via `ProductChannel` +
     `V2InboundTurnService`.** Violates #3577's shared AC forbidding "v1
     `Channel` dependencies in adapter core." The Reborn agent loop
     doesn't exist in `src/` yet (PRs #3544/#3550/#3586 still open);
     without an executor on the Reborn side, this is the only way to
     produce a working live round-trip. The bridge is localized — the
     adapter itself is clean. Migration path: drop `V2InboundTurnService`
     + `ProductChannel`, use `DefaultInboundTurnService` +
     `TurnCoordinator` once the Reborn agent loop lands.

Verification
------------

  cargo fmt --all -- --check                                          # clean
  cargo clippy --bin ironclaw --features libsql --tests -- -D warnings  # clean
  cargo clippy -p ironclaw_product_workflow_storage --features libsql,postgres --tests -- -D warnings  # clean
  cargo test -p ironclaw_product_workflow_storage --features libsql,postgres  # 13/13
  cargo test --test reborn_telegram_v2_e2e --features libsql --no-default-features  # 6/6
  cargo test --lib --no-default-features --features libsql  # 4949/4949 (3 ignored)

Refs: #3285, #3577, #3578, #3582, #3484 (Child 8 + Child 10 deliverable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added size: XL 500+ changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs scope: channel Channel infrastructure scope: db/postgres PostgreSQL backend scope: db/libsql libSQL / Turso backend scope: dependencies Dependency updates DB MIGRATION PR adds or modifies PostgreSQL or libSQL migration definitions and removed contributor: core 20+ merged PRs labels May 13, 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 introduces the 'Reborn' product-layer infrastructure, specifically enabling a Telegram v2 channel. It adds a new storage crate with libSQL and Postgres implementations for idempotency ledgers and conversation bindings, alongside a synthetic channel that bridges the new v2 workflow with the existing v1 agent runtime. Feedback focuses on improving the robustness and efficiency of the storage layer, including centralizing duplicated ID derivation logic, refining enum parsing, and optimizing Postgres operations through native UUID types and single-trip 'RETURNING' clauses. A minor correction was also suggested to use validated identifiers for internal runner lookups.

Comment on lines +52 to +63
fn derive_user_id(request: &ResolveBindingRequest) -> Result<UserId, ProductWorkflowError> {
let raw = format!(
"{}_{}_{}_{}",
request.adapter_id.as_str(),
request.installation_id.as_str(),
request.external_actor_ref.kind(),
request.external_actor_ref.id(),
);
UserId::new(raw).map_err(|e| ProductWorkflowError::BindingResolutionFailed {
reason: e.to_string(),
})
}

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 logic for deriving a UserId is duplicated between the libSQL and Postgres implementations. Additionally, there is a discrepancy between the documented format (colon-separated with a user: prefix at line 50) and the actual implementation (underscore-separated, no prefix). This logic should be moved to a shared location within the crate to ensure consistency and avoid duplication, especially for security-sensitive identifiers.

References
  1. Use centralized helper functions for security-sensitive logic to ensure consistency and avoid duplication.

Comment on lines +51 to +54
fn parse_phase(value: &str) -> Result<ActionPhase, ProductWorkflowError> {
serde_json::from_str::<ActionPhase>(&format!("\"{value}\""))
.map_err(|e| transient(format!("invalid phase '{value}': {e}")))
}

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 parse_phase function uses a fragile approach by wrapping the string in quotes and using serde_json::from_str. To improve robustness, implement parsing logic that handles case-insensitivity and trims whitespace. Additionally, prefer an exhaustive match statement over automated solutions to ensure that any new variants added to the enum are consciously handled at compile-time.

References
  1. When parsing string inputs into enums, ensure the parsing logic is robust by handling case-insensitivity and trimming whitespace.
  2. When mapping enum variants to another representation, prefer an exhaustive match statement over automated solutions to force a compile-time failure when new variants are added.

Comment on lines +150 to +194
client
.execute(
"INSERT INTO product_bindings \
(adapter_id, installation_id, external_conversation_fingerprint, \
external_actor_kind, external_actor_id, \
tenant_id, user_id, thread_id, agent_id, project_id) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NULL) \
ON CONFLICT DO NOTHING",
&[
&request.adapter_id.as_str(),
&request.installation_id.as_str(),
&conversation_fingerprint.as_str(),
&actor_kind,
&actor_id,
&self.default_tenant_id.as_str(),
&user_id.as_str(),
&thread_id.as_str(),
&self.default_agent_id.as_str(),
],
)
.await
.map_err(postgres_error)?;

// Read-back to handle the concurrent-insert race: if another inbound
// beat us to the insert, ON CONFLICT DO NOTHING swallowed the violation
// and we need the canonical row.
let canonical = client
.query_opt(
"SELECT thread_id FROM product_bindings \
WHERE adapter_id = $1 \
AND installation_id = $2 \
AND external_conversation_fingerprint = $3 \
AND external_actor_kind = $4 \
AND external_actor_id = $5",
&[
&request.adapter_id.as_str(),
&request.installation_id.as_str(),
&conversation_fingerprint.as_str(),
&actor_kind,
&actor_id,
],
)
.await
.map_err(postgres_error)?
.ok_or_else(|| transient("binding row missing after insert"))?;

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 resolve_binding implementation performs an INSERT ... ON CONFLICT DO NOTHING followed by a separate SELECT to retrieve the thread_id. This can be optimized into a single database round-trip by using INSERT ... ON CONFLICT (adapter_id, installation_id, external_conversation_fingerprint, external_actor_kind, external_actor_id) DO UPDATE SET adapter_id = EXCLUDED.adapter_id RETURNING thread_id. This "fake update" pattern ensures the required data is always returned in one call, even when a conflict occurs.

-- received | dispatched | settled | deduplicated_replay.

CREATE TABLE IF NOT EXISTS product_inbound_actions (
action_id TEXT PRIMARY KEY,

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

In Postgres, it is more efficient to use the native UUID type for the action_id primary key instead of TEXT. This reduces storage overhead and improves indexing performance for these identifiers.

Comment thread src/channels/reborn/boot.rs Outdated

// 8. Router state — one runner per installation_id key.
let mut runners: HashMap<String, Arc<NativeProductAdapterRunner>> = HashMap::new();
runners.insert(installation_id_str.to_string(), Arc::new(runner));

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 runner map is currently keyed using the raw installation_id_str from the environment. It is safer to use the string representation of the validated installation_id (from line 93) to ensure that any normalization or validation performed by the AdapterInstallationId type is reflected in the lookup key.

Suggested change
runners.insert(installation_id_str.to_string(), Arc::new(runner));
runners.insert(installation_id.as_str().to_string(), Arc::new(runner));

Comment thread src/main.rs Outdated
// REBORN_TELEGRAM_V2_ENABLED. Coexists with the v1 WASM Telegram
// channel — the exclusivity guard at src/config/channels.rs:517
// prevents both paths from running for the same installation.
if enable_non_cli && config.channels.reborn_telegram_v2_enabled {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

reborn related crates should not mingle with the current agent binary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 5667f19 — pulled the v2 boot orchestration out of src/main.rs into a single call:

ironclaw::channels::reborn::register_reborn_channels(
    ironclaw::channels::reborn::RebornChannelWiringInputs { … },
    &channels,
    &mut webhook_routes,
    &mut channel_names,
).await;

All Reborn-aware logic (secrets reads, storage construction, adapter assembly, route mounting) now lives in src/channels/reborn/registry.rs. src/main.rs no longer mentions specific Reborn types — bootstrap_telegram_v2, TELEGRAM_V2_CHANNEL_NAME, env-var parsing, all gone from the call site.

One honest caveat I'd want your read on: I interpreted "should not mingle" at the orchestration layer (no Reborn boot logic spread across main.rs). The Reborn crates are still listed in the root Cargo.toml because src/channels/reborn/*.rs uses them. If your intent is dependency-layer separation — i.e. the src/channels/reborn/ directory itself shouldn't exist inside the v1 agent binary's source tree, and should be its own workspace crate (something like crates/ironclaw_reborn_channels/ with main.rs depending on it via an optional Cargo feature) — that's a structural refactor I'm happy to do as a follow-up. Just let me know which read you want.

This commit addresses every code review comment from PR #3590 and lands the
P1 follow-ups for the Telegram v2 tracer (durable storage backends fully
tested, migration smoke tests, boundary entry, real bot identity, ledger
settle verification, docs).

## Review comments addressed

| # | Reviewer | Change |
|---|---|---|
| 1 | gemini-code-assist | Centralize `derive_user_id` — new `identifiers.rs` module; libSQL and Postgres bindings share one implementation. Removes the documented/implementation drift the reviewer flagged. |
| 2 | gemini-code-assist | `parse_phase` exhaustive `match` in both ledger impls (replaces the fragile serde-quoted-string trick); paired comment on `phase_to_str` so any future `ActionPhase` variant fails closed. |
| 3 | gemini-code-assist | Postgres binding upsert is now a single-roundtrip `INSERT ... ON CONFLICT ... DO UPDATE SET adapter_id = EXCLUDED.adapter_id RETURNING thread_id` (was: separate `INSERT ... ON CONFLICT DO NOTHING` + `SELECT thread_id`). |
| 4 | gemini-code-assist | V28 `product_inbound_actions.action_id` is `UUID` (was `TEXT`); Postgres ledger binds `uuid::Uuid` directly via tokio-postgres `with-uuid-1` feature. libSQL keeps `TEXT` (no UUID type). |
| 5 | gemini-code-assist | Runner map keyed by the validated `installation_id.as_str()` (was: raw `installation_id_str` from env). Webhook lookup now matches what the adapter sees. |
| 6 | serrrfirat | Extracted Reborn boot orchestration out of `src/main.rs` into `src/channels/reborn/registry.rs::register_reborn_channels`. Main binary now has one function call + a `RebornChannelWiringInputs` struct; no direct references to Reborn crate types. This is interpretation (A) of the comment — orchestration-only extraction. If the reviewer's intent is interpretation (B), full crate-level separation of `src/channels/reborn/` is a follow-up. |

## P1 batch (per pre-agreed in-scope follow-ups)

* **#15** — Postgres contract tests: 4 tests for ledger (begin/settle/replay/release) and binding (idempotent + per-actor distinct) under `--features postgres`, gated on `IRONCLAW_PRODUCT_STORAGE_POSTGRES_URL` (or `DATABASE_URL`). Skip-clean when no Postgres available.
* **#16** — Migration smoke tests in `src/db/libsql_migrations.rs`: applies V26 against fresh in-memory libSQL, asserts both tables + all required columns + recording in `_migrations`.
* **#17** — `ironclaw_product_workflow_storage` added to architecture boundary rules forbidding `dispatcher` / `extensions` / `host_runtime` / `mcp` / `wasm` / `scripts` / `network` / `engine` / `gateway` / `secrets` / `authorization` / `capabilities` / `reborn` / `reborn_cli`.
* **#18** — `getMe` at boot resolves real `bot_user_id` + `bot_username` from `api.telegram.org` (was: hardcoded placeholders). Fail-soft on network/token errors: warn + use safe placeholders, so the binary still starts.
* **#19** — `accepted_inbound_settles_the_ledger_row` e2e test SELECTs the DB row after a webhook completes, asserts `phase='settled'` + `outcome_json` is non-null + `settled_at` is set. Proves `DefaultProductWorkflow::accept_inbound` actually invokes `ledger.settle` end-to-end.
* **#20** — Documentation: new `src/channels/reborn/CLAUDE.md` (file map, call paths, bridge seams, migration path post-#3583, operator setup, known gaps) + paragraph in top-level `CLAUDE.md` mentioning `REBORN_TELEGRAM_V2_ENABLED` and pointing at the module guide.

## Verification

  cargo fmt --all -- --check                                                                       # clean
  cargo clippy --bin ironclaw --features libsql --tests -- -D warnings                              # clean
  cargo clippy -p ironclaw_product_workflow_storage --features libsql,postgres --tests -- -D warnings  # clean
  cargo test -p ironclaw_product_workflow_storage --features libsql                                 # 13/13
  IRONCLAW_SKIP_POSTGRES_TESTS=1 cargo test --test postgres_contract -p ironclaw_product_workflow_storage --features libsql,postgres  # 4/4
  cargo test --test reborn_telegram_v2_e2e --features libsql --no-default-features                  # 7/7
  cargo test --lib --no-default-features --features libsql migration_smoke                          # 2/2
  cargo test -p ironclaw_architecture                                                               # 13/13

## P2 deferred to follow-up PRs

Per the in-scope vs follow-up split agreed during triage:

* #21 multi-installation support — own PR (env vs registry design)
* #22 setWebhook helper — own PR (CLI subcommand)
* #23 slash command routing — own PR (couples to Epic Child 9)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added scope: docs Documentation contributor: core 20+ merged PRs labels May 13, 2026
@nickpismenkov

Copy link
Copy Markdown
Contributor Author

Pushed 5667f197a addressing all open review comments + landing the P1 round.

Code review comments — addressed

# Reviewer Where in 5667f197a
1 gemini-code-assist (derive_user_id duplicated) New crates/ironclaw_product_workflow_storage/src/identifiers.rs; both binding_libsql.rs and binding_postgres.rs now call the shared helper
2 gemini-code-assist (parse_phase is fragile) Exhaustive match in both ledger_libsql.rs and ledger_postgres.rs; paired comment on phase_to_str
3 gemini-code-assist (Postgres binding two-trip) Single-roundtrip INSERT ... ON CONFLICT (...) DO UPDATE SET adapter_id = EXCLUDED.adapter_id RETURNING thread_id
4 gemini-code-assist (V28 action_id TEXT) V28 uses UUID; Postgres ledger binds uuid::Uuid directly via tokio-postgres/with-uuid-1; libSQL keeps TEXT (no UUID type)
5 gemini-code-assist (runner map keyed by raw env) runners.insert(installation_id.as_str().to_string(), ...) — uses the validated id
6 @serrrfirat (Reborn crates mingled with main binary) New src/channels/reborn/registry.rs::register_reborn_channels; src/main.rs now has one function call + a RebornChannelWiringInputs struct, no direct Reborn type refs. Inline reply with an open question on orchestration- vs. dependency-layer interpretation.

P1 follow-ups (in-scope per pre-agreed triage)

  • Postgres ledger + binding contract tests (4, gated on IRONCLAW_PRODUCT_STORAGE_POSTGRES_URL, skip-clean otherwise)
  • V26 / V28 migration smoke tests (libSQL applied, _migrations recorded)
  • Architecture boundary entry for ironclaw_product_workflow_storage
  • Real bot identity from Telegram getMe at boot (fail-soft on errors)
  • E2E accepted_inbound_settles_the_ledger_row proves ledger reaches phase='settled' after a webhook
  • src/channels/reborn/CLAUDE.md + top-level mention

P2 — explicitly deferred to follow-up PRs

Per the triage we did: multi-installation support, in-binary setWebhook helper, and slash-command routing each get their own PRs (design choices distinct from the tracer scope).

Verification

cargo fmt --all -- --check                                                                  # clean
cargo clippy --bin ironclaw --features libsql --tests -- -D warnings                         # clean
cargo clippy -p ironclaw_product_workflow_storage --features libsql,postgres --tests -- -D warnings  # clean
cargo test -p ironclaw_product_workflow_storage --features libsql                            # 13/13
IRONCLAW_SKIP_POSTGRES_TESTS=1 cargo test --test postgres_contract ... --features libsql,postgres  # 4/4
cargo test --test reborn_telegram_v2_e2e --features libsql --no-default-features             # 7/7
cargo test --lib --no-default-features --features libsql migration_smoke                     # 2/2
cargo test -p ironclaw_architecture                                                          # 13/13

Two narrow fixes for the failing `scripts/pre-commit-safety.sh` job
on PR #3590:

* `src/db/libsql_migrations.rs` — renamed the `migration_smoke_tests`
  module to `tests`. The pre-commit script's test-mod detection
  matches the literal `mod tests` only (line 157, 314, 322 of the
  script), so any other test-mod name leaks its `.expect()` calls
  through the filter. The module body is unchanged; only the name.

* `src/channels/reborn/boot.rs:205` — added the `// safety:` inline
  comment the script honors as a per-line exemption. The `.expect()`
  is on `NonZeroUsize::new(64)`, where 64 is a compile-time literal
  that is provably non-zero — the panic branch is unreachable.

Verified `bash scripts/pre-commit-safety.sh` exits 0. Both migration
tests still pass under the renamed module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #3590's cargo-deny CI check failed with:

    licenses FAILED — 5 workspace crates unlicensed
    bans FAILED    — 9 wildcard path deps in root Cargo.toml

Two narrow fixes:

1. **Licenses.** Added `license = "MIT OR Apache-2.0"` (the
   established workspace policy) to five `Cargo.toml`s. One is mine
   (`ironclaw_product_workflow_storage`); the other four
   (`ironclaw_outbound`, `ironclaw_event_projections`,
   `ironclaw_events`, `ironclaw_storage`) were merged into
   `reborn-integration` without a license field. They didn't trip
   cargo-deny before because they weren't in the main `ironclaw`
   binary's dependency closure; adding them via the v2 wiring in
   this PR exposes the gap. License pick matches every other
   workspace crate.

2. **Bans.** The nine path dependencies I added to the root
   `Cargo.toml` were missing `version = "0.1.0"` declarations.
   cargo-deny treats path-only deps on a published crate
   (`ironclaw` is not `publish = false`) as wildcard deps, which
   `allow-wildcard-paths` does not cover. Added the version field
   to each, matching the existing path-dep style.

Verified locally:

    cargo deny --all-features check licenses  # licenses ok
    cargo deny --all-features check bans      # bans ok

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nickpismenkov
nickpismenkov requested a review from zmanian May 13, 2026 22:22
Comment thread src/main.rs Outdated
// Reborn-related crate usage is contained in this single call into
// `ironclaw::channels::reborn`; the v1 agent binary doesn't otherwise
// mingle with Reborn crates (per @serrrfirat's review on PR #3590).
ironclaw::channels::reborn::register_reborn_channels(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why are we trying to wire the reborn v2 telegram channel into the current agent binary?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Just used it to manually verify that it actually works since reborn agent loop is not ready yet, will move to a separate workspace crate (crates/ironclaw_reborn_telegram_v2_host)

@zmanian

zmanian commented May 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Tracer-bullet wiring of the Reborn ProductAdapter/ProductWorkflow stack as a real second Telegram channel, gated by REBORN_TELEGRAM_V2_ENABLED. The boundary between the new storage crate (ironclaw_product_workflow_storage) and the in-binary wiring (src/channels/reborn/) is clean; the two interim bridge seams (V2InboundTurnService + synthetic ProductChannel) are well-isolated and explicitly documented. Live round-trip is verified and the test surface is strong for a tracer (router, idempotency replay, fail-closed auth, channel-manager round-trip, ledger settlement).

I have no blocking concerns. The points below are correctness/discipline items that should be addressed either in this PR or as named follow-ups before this becomes anything more than a default-off tracer.

Strengths

  • Clean separation: storage crate has no v1 dependencies; src/channels/reborn/registry.rs is the single entry point from main.rs, addressing serrrfirat's "Reborn crates mingled with main binary" concern.
  • Credential discipline: bot token never reaches the adapter; EgressCredentialHandle is resolved inside TelegramHttpEgress at transit time. The declared-host allowlist gates before any network call. unresolved_credential_is_rejected test locks this in.
  • Identifier discipline: typed newtypes throughout (AdapterInstallationId, ProductAdapterId, EgressCredentialHandle, TenantId, AgentId, UserId, ThreadId). The derive_user_id helper is centralized in identifiers.rs per gemini's prior comment, with an explicit security-relevance docstring.
  • Fail-closed webhook auth: invalid_secret_returns_401 test, AuthenticationFailed → 401, retryable vs non-retryable separation in v2_router::handle.
  • Idempotency contract is exercised end-to-end: duplicate_update_replays_through_ledger and accepted_inbound_settles_the_ledger_row cover the New/Replay/Settled state transitions.
  • Boot is robust: missing secrets warn and skip wiring (does not crash boot); getMe failure falls back to placeholder bot identity with a warn (does not crash boot); explicit comments document the consequence (group-chat triggers may misclassify until restart).
  • Dual-backend parity: V26 (libSQL) and V28 (Postgres) migrations are aligned; unique constraints match; parse_phase / phase_to_str exhaustive on ActionPhase in both backends.

Issues

Critical

None.

Major

  1. Ledger begin_or_replay is TOCTOU across both backends. Both LibSqlProductIdempotencyLedger::begin_or_replay (ledger_libsql.rs:167) and PostgresProductIdempotencyLedger::begin_or_replay (ledger_postgres.rs:94) do SELECT … WHERE fingerprint followed by an unconditional INSERT. Two concurrent webhooks with the same (adapter, installation, source_binding_key, external_event_id) will both miss the SELECT, both attempt INSERT, and one will fail on the UNIQUE constraint with a raw DB error rather than the expected Transient { "already in flight" } reply. Telegram retries reuse update_id, so this race is reachable in production under retry pressure. The binding layer's Postgres impl was already moved to single-roundtrip upsert in this PR (per the changelog) and the libSQL binding handles SqliteFailure(19, _) explicitly — apply the same discipline to the ledger:

    • libSQL: catch ::libsql::Error::SqliteFailure(19, _) from the INSERT and re-fetch the row, returning Replay or Transient based on the now-visible phase. (Mirrors binding_libsql.rs:173.)
    • Postgres: switch to INSERT … ON CONFLICT (adapter_id, installation_id, source_binding_key, external_event_id) DO NOTHING RETURNING action_id, and if no row is returned, SELECT the existing row and decide Replay vs Transient. (Mirrors the binding's new pattern.)

    This is the same anti-pattern flagged across the codebase in .claude/rules/review-discipline.md ("Transaction safety" / "Fix the pattern, not the instance"). The unit test second_begin_while_in_flight_is_transient passes only because it's serial; add a concurrent test driving two begin_or_replay calls in parallel.

  2. Outbound tokio::spawn in ProductChannel::respond is unbounded and untracked. product_channel.rs:150 spawns the render+POST in a detached task and returns Ok(()) immediately. There is no bound on in-flight tasks, no JoinHandle retained, no per-installation semaphore, and no observability that one has hung. A misbehaving Telegram API (timeouts at 30s × N concurrent agent replies) will accumulate tasks indefinitely. Recommend an Arc<Semaphore> with a configurable in-flight cap (default ~16) per ProductChannel, and either a tracked JoinSet or at minimum a counter exposed via the existing observability hooks. References: .claude/rules/safety-and-sandbox.md "Bounded Resources" — "Tokio task fan-out … bounded semaphore on spawns driven by user input."

  3. bot_token is held in memory as a plain String for the lifetime of the runner. boot.rs:67 calls .expose().to_string() and the clone flows into StaticCredentialResolver (which stores it as String). The host secrets store presumably has zeroize semantics; once exposed into a plain String, that's lost. The dedicated EgressCredentialResolver trait is the right seam to fix this — replace StaticCredentialResolver in the bootstrap path with one that re-reads the secret via the SecretsStore on every resolve() (or caches a secrecy::SecretString). At minimum, document the lifecycle explicitly. For a tracer this is acceptable; for production it shouldn't ship.

  4. V28 migration numbering jump from V9. migrations/V28__...sql jumps over V10–V27. Per .claude/rules/database.md, "always number after the highest version on staging/main." git ls-tree origin/staging migrations/ shows V9 as the highest. The jump may be intentional (reserving numbers for in-flight branches) but should be justified in the PR body — and if any V10–V27 lands on staging before this merges, you have a conflict. Confirm with the migration owner that V28 is allocated for this PR, and if not, renumber. The libSQL counterpart uses V26 which has the same risk in INCREMENTAL_MIGRATIONS.

Minor

  1. Phase parse/format pair is duplicated between ledger_libsql.rs and ledger_postgres.rs. Both files redefine parse_phase and phase_to_str with identical bodies. The shared identifiers.rs module is a natural home for these too — or a dedicated phase.rs. Same drift risk gemini flagged on derive_user_id. Lock-step comments help but don't enforce.

  2. ProductChannel::respond swallows errors via tracing::warn! only. product_channel.rs:168 logs and discards. There's no OutboundDeliveryAttempt recorded for synthesis/build errors (e.g. rebuild conversation_ref failure, thread id parse failure). A failure between the agent producing a reply and adapter.render_outbound being called leaves no audit row, so reconciliation jobs can't see it. Consider writing a synthetic DeliveryStatus::FailedPermanent to the sink before returning, so the audit trail is complete.

  3. enable_non_cli is checked at the top of register_reborn_channels (registry.rs:39) so the CLI runs do not wire Reborn. No issue, but flag: there is no doctor/status output today that reports "v2 enabled but skipped because non_cli=false." Consider adding a tracing::debug! so an operator running ironclaw --cli-only with the v2 flag set isn't silently surprised.

  4. OutboundStateStoreDeliverySink::record swallows store errors via warn. outbound_sink.rs:103 is documented ("Persist failure must not unwind a successful HTTP send") and the comment is reasonable — but no metric is emitted, only a log line. Reconciliation cannot drive off log lines. Track in a follow-up: emit a DeliverySink::record_failed counter or surface via Observer.

  5. fetch_bot_identity parses result.id as i64. The Telegram bot user id is well below 2^63, but the response field is per-spec a 64-bit signed integer — fine. The bot_user_id = 0 fallback is reasonable; just be aware that 0 is also a valid value range (it isn't actually used by Telegram, but worth a comment that 0 is reserved as the sentinel).

  6. product_channel.rs:122 silently drops malformed v2_topic_id / v2_reply_target_message_id. .and_then(|s| s.parse::<i64>().ok()) collapses a parse error to None. This is .ok()? pattern banned by .claude/rules/error-handling.md for DB/IO but is acceptable here since these are optional fields recovered from metadata — please add a // silent-ok: optional metadata, falls back to top-level chat reply comment per the convention.

  7. #[doc(hidden)] pub fn with_base_url_for_test on TelegramHttpEgress (egress.rs:69) is the right pattern, but consider gating behind #[cfg(any(test, feature = "test-support"))] — doc(hidden) is not a compile-time barrier.

Suggestions

  • Add a concurrent-begin regression test for the ledger once the TOCTOU fix lands. Pattern: spawn 4 tasks calling begin_or_replay on the same fingerprint; assert exactly one returns New and the rest return Replay or Transient. This is exactly the case .claude/rules/testing.md "Test through the caller" calls out — the unit tests pass while the runtime races.
  • Surface a metrics_descriptor for product_inbound_actions phase counts (and outbound_delivery_attempts status counts) so the existing observability subsystem can scrape them without bespoke wiring.
  • The CLAUDE.md for src/channels/reborn/ is excellent — recommend adding a one-line note about the TOCTOU/concurrent-retry semantics next to the "Known gaps" list once the ledger fix lands.
  • The "two deliberate deviations" framing in the PR body and CLAUDE.md is exactly right — keep the migration receipts in the body for Implement WASM ProductAdapter component runtime #3583 / Reborn loop landings.

Verdict

Approve with major-tier follow-ups. This is a strong tracer that lands the durable ledger/binding/egress contract, plumbs it end-to-end against both DB backends, and verifies live round-trip. The bridge seams are honest about their interim status. Major items (1) ledger TOCTOU, (2) unbounded spawn, (3) plaintext bot token cache should each get a dedicated follow-up issue tracked against this PR before any non-default-off rollout. Item (4) needs confirmation in this PR. Default-off gating and the existing v1/v2 exclusivity guard make merging now safe.

PR #3590 originally wired the Reborn ProductAdapter / Telegram v2 channel
into the v1 agent binary at src/channels/reborn/, gated only by a runtime
flag. Per @serrrfirat's review the v1 agent should not be the host for
Reborn-experimental code at all. This commit removes that coupling
entirely.

The Reborn host is now a separate workspace crate
(crates/ironclaw_reborn_telegram_v2_host/) with its own binary
(ironclaw-reborn-telegram-host). The v1 ironclaw binary has zero
awareness it exists: no Reborn crate dependencies in v1's Cargo.toml,
no wiring code, no shared in-process state, no runtime flag, no v1/v2
exclusivity guard.

Reply-path stub
---------------

The current PR's tracer bridged through v1's in-process ChannelManager
to produce an actual Telegram reply. That bridge cannot exist across
processes, and no Reborn agent loop ships in src/ yet (PRs #3544 /
#3550 / #3586 still open). The new host terminates inbound at the
durable ledger / binding write and acks 200 to Telegram; no reply is
produced until the Reborn loop lands, at which point swapping
StubInboundTurnService for DefaultInboundTurnService is the only
required change.

zmanian's review items
----------------------

Fixed in this commit alongside the extraction (verified by tests):

1. TOCTOU in IdempotencyLedger::begin_or_replay (Major) — both libSQL
   and Postgres ledgers used SELECT-then-INSERT, racing the UNIQUE
   constraint on concurrent webhook retries. Both switched to
   INSERT-first patterns (libSQL catches SqliteFailure(2067), Postgres
   uses ON CONFLICT DO NOTHING RETURNING). New concurrent regression
   test spawns 8 racing callers; exactly one wins New, rest surface as
   Transient. Bonus: fixed the same wrong-error-code bug in
   binding_libsql.rs which was matching code 19 (primary
   SQLITE_CONSTRAINT) when libsql 0.6 actually surfaces 2067
   (extended SQLITE_CONSTRAINT_UNIQUE); the existing concurrent
   handler was silently never firing.

3. bot_token / webhook_secret lifecycle (Major) — wrapped in
   secrecy::SecretString in HostConfig so they zeroize on drop and
   accidental Debug prints reveal [REDACTED]. Residual exposure
   inside StaticCredentialResolver / SharedSecretHeaderAuth
   documented inline; full fix requires re-reading through
   EgressCredentialResolver, flagged as follow-up.

5. parse_phase/phase_to_str duplicated between ledger files (Minor)
   — extracted into crates/ironclaw_product_workflow_storage/src/phase.rs
   with roundtrip + reject tests.

11. with_base_url_for_test was #[doc(hidden)] but not compile-gated
    (Minor) — added a `test-support` feature; the helper now
    physically does not exist in release builds without it.

Items 2, 6, 7, 10 (ProductChannel-related) made moot by removing the
in-process bridge entirely.

Diff shape
----------

V1 source tree: 22 files changed, 60 insertions, 2810 deletions —
net subtraction. Removed src/channels/reborn/ (7 files), the
register_reborn_channels call in main.rs, the reborn_telegram_v2_enabled
config field + parser, validate_telegram_v1_v2_exclusivity + all its
tests, the v1/v2 hot-activation guard in ExtensionManager + 3 tests,
the V28 Postgres migration, the V26 libSQL migration entry + 2 tests,
and 9 optional Reborn workspace deps.

New crate: 12 files. Owns its own migrations (no entry in v1's
migration set), boot path, config (env-driven, no shared Config type
with v1), webhook router, composition root, stubbed inbound turn
service, and e2e tests.

Verification
------------

  cargo check                                                # clean
  cargo check --no-default-features --features libsql        # clean
  cargo check --all-features                                 # clean
  cargo build -p ironclaw_reborn_telegram_v2_host --bin ironclaw-reborn-telegram-host  # clean
  cargo clippy --all --tests --benches --examples --all-features  # zero warnings
  cargo deny check                                           # advisories/bans/licenses/sources ok
  cargo fmt --all -- --check                                 # clean
  cargo test -p ironclaw_product_workflow_storage --features libsql --lib  # 16/16
  cargo test -p ironclaw_reborn_telegram_v2_host             # 5/5 e2e
  cargo test --lib                                           # 4951/4952 (1 pre-existing
                                                             #            Postgres-connection
                                                             #            failure, unrelated)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the scope: extensions Extension management label May 14, 2026
@nickpismenkov
nickpismenkov requested a review from serrrfirat May 14, 2026 00:36

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review: fix durable retry recovery before merging

The standalone-host extraction is the right direction: the v1 agent binary no longer owns this Reborn Telegram tracer, the storage/host split is clearer, and the concurrent-insert TOCTOU fix is a good improvement. I still think this needs changes before merge because the durable idempotency contract can permanently wedge Telegram retries after a timeout or crash.

What looks good:

  • The latest commit cleanly removes the Reborn Telegram host from the v1 agent binary and keeps the tracer in its own workspace crate.
  • The ledger insert path now handles concurrent duplicate deliveries via insert-first / ON CONFLICT patterns instead of surfacing raw unique-constraint errors.
  • Local targeted checks passed for me: cargo test -p ironclaw_product_workflow_storage --features libsql --lib, cargo test -p ironclaw_reborn_telegram_v2_host --features libsql, and cargo test -p ironclaw_architecture reborn_crate_dependency_boundaries_hold.

Critical: non-terminal idempotency rows can retry-block forever

IdempotencyLedger explicitly requires durable implementations to reclaim non-terminal Received / Dispatched reservations through a recovery lease, TTL, or equivalent sweeper so a process crash or transient dispatch failure cannot leave the fingerprint permanently blocked. The new libSQL and Postgres ledgers record received_at, but begin_or_replay never checks age and there is no sweeper or lease handoff. Once a row is stuck in received or dispatched, both backends just return Transient("idempotency fingerprint already in flight; retry after recovery lease") forever.

That is reachable in this host: NativeProductAdapterRunner::process_webhook reserves the fingerprint inside workflow handling, and on workflow timeout it aborts the spawned task. A timeout, panic boundary, process crash, or cancellation after reservation but before settle/release leaves the row non-terminal. Telegram will retry the same update_id, but every retry hits the same row and receives a retryable error forever, so the message is never accepted and never replayed.

Expected fix direction: implement the recovery contract in both durable backends. For example, add a configured in-flight lease TTL and have begin_or_replay atomically reclaim stale received/dispatched rows before returning New, or add a sweeper with caller-level tests that prove a stale non-terminal ledger row is eventually reprocessable. Please cover both libSQL and Postgres, and include a test that drives the actual begin_or_replay path with an expired non-terminal row rather than only testing the helper shape.

Concerning: getMe failures can leak the bot token through logged URLs

fetch_bot_identity builds https://api.telegram.org/bot{token}/getMe and then formats reqwest errors into strings that are logged by the caller. Reqwest errors commonly include the request URL, so a DNS/TLS/connect failure can put the bot token into logs despite the surrounding SecretString handling. This path should sanitize before logging, e.g. use err.without_url() / err.to_string() after URL removal, or avoid carrying token-bearing URLs in formatted error strings at all.

Concerning: missing LIBSQL_PATH silently uses in-memory durable storage

When Postgres is not configured, the host defaults libSQL to :memory: if LIBSQL_PATH is absent. For a binary whose purpose is durable idempotency and conversation binding, that is a dangerous production footgun: a restart drops the ledger and bindings, so duplicate webhook delivery and conversation continuity semantics silently change. Please require an explicit persistent storage path by default, or gate :memory: behind an unmistakable dev/test-only env var.

Low-priority notes:

  • The Reborn docs are stale after the standalone-host extraction. docs/reborn/contracts/telegram-v2.md and docs/reborn/contracts/product-adapters.md still describe REBORN_TELEGRAM_V2_ENABLED, v1/v2 exclusivity through ironclaw::config::validate_telegram_v1_v2_exclusivity, fake services below the workflow facade, and production wiring as not implemented. Those no longer match this PR and should be updated or clearly scoped as historical status.

Henry left 4 items on the Reborn extraction PR; all four are real and
all four are addressed in this commit.

#1 Critical — IdempotencyLedger recovery-lease contract
-------------------------------------------------------

The trait at crates/ironclaw_product_workflow/src/ledger.rs explicitly
requires durable implementations to reclaim non-terminal Received /
Dispatched reservations after a TTL. Our backends recorded received_at
but never consulted it: a workflow timeout, panic, cancelled spawn, or
process crash before settle/release left the row stuck in Received,
and every subsequent webhook for the same update_id returned
Transient forever. The mode is reachable under
NativeProductAdapterRunner's 15s workflow_timeout aborting a spawned
task. Single retried-but-stuck Telegram update would permanently wedge
that conversation's idempotency key.

Both LibSqlProductIdempotencyLedger and
PostgresProductIdempotencyLedger now carry a recovery_lease (default
300s; configurable via with_recovery_lease) sourced from a new shared
crates/ironclaw_product_workflow_storage/src/recovery.rs module. The
INSERT-first conflict path now branches:

  * Settled / DeduplicatedReplay → Replay (unchanged)
  * Received / Dispatched within lease → Transient (unchanged)
  * Received / Dispatched past lease → atomic UPDATE-with-WHERE reclaim
    (new action_id, fresh received_at, phase=Received) and return New

The reclaim UPDATE includes received_at = $prior in the WHERE so two
concurrent stale-reclaim attempts converge: only one wins, the loser
sees rows_affected = 0 and surfaces Transient on the now-fresh claim.
Caller-level regression tests in both backends drive begin_or_replay
against a hand-aged stale row and assert New with a fresh action_id;
a counter-test pins that a row within the lease still surfaces as
Transient. Mirrors the test-discipline rule in
.claude/rules/testing.md ("Test Through the Caller").

#2 Concerning — bot token leaked through reqwest URL strings
------------------------------------------------------------

Telegram's only auth method is the path-embedded bot token. reqwest's
Display for Error includes the URL by default; formatting an error
with {e} therefore writes the token into logs on any DNS/TLS/connect
failure during boot getMe. SecretString in HostConfig didn't help —
the token still has to reach the URL.

fetch_bot_identity now scrubs every reqwest error through a
local scrub() helper that calls Error::without_url() before
to_string(). Applies to client-build, send, and json failure paths.

#3 Concerning — :memory: fallback for durable storage
-----------------------------------------------------

resolve_storage() previously defaulted libSQL to :memory: when
LIBSQL_PATH was unset. For a binary whose entire purpose is durable
idempotency + binding state, that footgun silently breaks the
contract on every restart with no visible signal.

The host now fails closed at startup when neither DATABASE_URL nor
LIBSQL_PATH is set, with an error pointing at both. Operators who
genuinely want ephemeral storage for dev/tests opt in explicitly via
IRONCLAW_REBORN_ALLOW_EPHEMERAL=1, which logs a loud warning that the
storage will not survive a restart.

#4 Low — stale Reborn contract docs
-----------------------------------

docs/reborn/contracts/telegram-v2.md and product-adapters.md still
described the v1-coupled state: REBORN_TELEGRAM_V2_ENABLED flag,
validate_telegram_v1_v2_exclusivity, fake services below the
workflow facade, "production wiring not implemented". After the
extraction those are all wrong. Both files now describe:

  * the standalone-host binary as the production entry point
  * the storage crate as the production storage layer (durable
    ledger with TOCTOU-safe insert + recovery-lease reclaim,
    conversation binding, outbound delivery sink, Telegram HTTP
    egress shim)
  * the stubbed reply path until the Reborn agent loop ships
  * how the host fails closed without explicit storage config
  * URL-scrubbed getMe path

Verification
------------

  cargo fmt --all -- --check                                       # clean
  cargo clippy --all --tests --benches --examples --all-features   # zero warnings
  cargo deny check                                                 # ok
  cargo check                                                      # v1 default — clean
  cargo check --no-default-features --features libsql              # v1 libsql — clean
  cargo test -p ironclaw_product_workflow_storage --features libsql --lib  # 18/18
                                                                   #   (+2 new
                                                                   #    recovery-lease
                                                                   #    regression tests)
  cargo test -p ironclaw_reborn_telegram_v2_host --test webhook_e2e        # 5/5

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nickpismenkov

Copy link
Copy Markdown
Contributor Author

@henrypark133 could you please take a look again

nickpismenkov and others added 3 commits May 14, 2026 18:36
Addresses PR #3590 review finding #2 (@serrrfirat): both
ConversationBindingService backends called ensure_thread *before* the
binding insert, so two concurrent first-inbounds for the same external
conversation could both mint a durable thread, then one INSERT would
win the UNIQUE constraint while the loser's thread became orphaned —
never bound, never referenced. Telegram retries make this reachable.

Fix: reserve the binding row first with a pre-minted candidate
ThreadId, then create the durable thread only on insert success. The
loser of the race re-reads the canonical binding and returns it
immediately — no orphan thread, no transient bounce.

  * libSQL: INSERT, catch SqliteFailure(2067) (extended UNIQUE code),
    re-SELECT for canonical on loss.
  * Postgres: INSERT ... ON CONFLICT DO NOTHING RETURNING thread_id;
    Some(row) means we won, None means we lost.
  * Best-effort rollback_binding on the rare ensure_thread-after-insert
    failure path so we never leave a binding pointing to a missing
    thread.

Regression test (libSQL): 16 concurrent resolve_binding calls assert
all return the same canonical thread_id AND that ensure_thread is
called exactly once via a counting decorator on SessionThreadService.
Postgres equivalent stays at the integration tier (needs a real DB).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses PR #3590 review finding #3 (@serrrfirat): HostConfig defaulted
installation_id/tenant_id/agent_id to literal strings (`default`,
`tenant_default`, `agent_default`). Since installation_id keys binding
uniqueness and feeds derive_user_id, and tenant_id/agent_id scope every
downstream Reborn operation, two host processes started against the
same DB with these unset collapsed into one canonical user namespace —
distinct bots binding to the same canonical UserId.

Startup now requires REBORN_TELEGRAM_V2_INSTALLATION_ID, REBORN_TENANT_ID,
and REBORN_AGENT_ID to be explicitly set, and surfaces the names of the
missing vars in the HostError::Config message. Dev/test opt-in:
IRONCLAW_REBORN_ALLOW_DEFAULT_NAMESPACE=1 bypasses the guard and logs a
loud warning when used (mirrors the existing
IRONCLAW_REBORN_ALLOW_EPHEMERAL=1 pattern for in-memory storage).

Refactored from_env into a from_env_with(EnvLookup) variant so unit
tests inject a fake env map instead of touching process globals
(set_var is unsafe under multi-threaded test scheduling, and the
codebase already documents this concern via ironclaw_common's
env_helpers).

5 unit tests added: explicit namespace accepted, missing install_id
rejected, missing tenant+agent rejected together, opt-in flag accepted,
opt-in=0 correctly NOT treated as set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…born-telegram-v2-wiring

# Conflicts:
#	Cargo.toml
#	crates/ironclaw_architecture/tests/reborn_dependency_boundaries.rs
@zmanian

zmanian commented May 15, 2026

Copy link
Copy Markdown
Collaborator

Re-review (post 2b20637)

Following up on my prior re-review (#3590 (comment), verdict APPROVE with the plaintext-token residual tracked as #3606). Three new commits land on top of face166: af0ef69, 40a160f, 1f433f7, plus the merge 2b20637. None regress prior approvals; each closes a real defect that is in scope for this PR.

40a160f — atomic create-or-bind

The fix is correct on both backends and well-matched: reserve the binding row with a pre-minted ThreadId first, only call ensure_thread once you have won the INSERT. libSQL catches SqliteFailure(2067) (the extended SQLITE_CONSTRAINT_UNIQUE code — note this is the same extended-code bug pattern the earlier PR-3590 commit caught in the ledger; reusing the right code here is what makes this work). Postgres uses INSERT ... ON CONFLICT DO NOTHING RETURNING thread_id with query_opt as the win/loss signal. A rollback_binding cleans up the binding row if ensure_thread fails after the INSERT win, so we never leave a binding pointing at a missing thread. The lost-race lookup_existing re-SELECT returns canonical state; an ok_or_else makes the "INSERT lost but no canonical row" case fail loud rather than silently mis-bind.

The regression test (concurrent_first_bind_creates_exactly_one_thread) is exactly the right shape: 16 concurrent resolve_binding calls, asserts both that every caller sees the same canonical thread_id and that ensure_thread is invoked exactly once (via a counting decorator on SessionThreadService). This is the caller-level coverage the .claude/rules/testing.md "Test Through the Caller" rule asks for. Postgres equivalent reasonably deferred to integration tier.

One thin spot: the libSQL re-SELECT runs on the same Connection as the INSERT. In libSQL/SQLite, in autocommit mode without an explicit transaction, the winner's INSERT is durable before our re-SELECT sees the conflict, so the loser will read it. Worth a single-line comment confirming the assumption (no transaction wrapping these statements), since the symmetric Postgres path also runs on a single pooled client and is autocommit; not a blocker.

1f433f7 — fail closed on default namespace IDs

This is the right fix and a real escalation vector. Prior posture: installation_id, tenant_id, agent_id defaulted to literal strings (default / tenant_default / agent_default). Since installation_id is part of every binding-uniqueness key and feeds derive_user_id, two hosts started against the same DB with all three unset would collapse different bots into the same canonical UserId — distinct Telegram chats observing one user namespace, with all the cross-tenant data-bleed that implies. The new guard requires all three to be explicitly set, surfaces the missing names in HostError::Config, and supports an explicit IRONCLAW_REBORN_ALLOW_DEFAULT_NAMESPACE=1 opt-in that mirrors IRONCLAW_REBORN_ALLOW_EPHEMERAL. The env_flag_set helper correctly treats =0 as unset.

Refactoring from_env into from_env_with(EnvLookup) to inject a fake env map (rather than mutating process globals) is the right shape — std::env::set_var is unsafe under multi-threaded test scheduling, and your tests now exercise the exact branches without that risk. 5 unit tests cover the matrix cleanly (explicit accepted, single missing rejected, multiple missing rejected, opt-in accepted, opt-in=0 not bypassing).

af0ef69 — consolidate Telegram v2 host into ironclaw-reborn

Substrate independence is preserved. I verified:

  • Root Cargo.toml's [dependencies] block contains zero ironclaw_reborn*, ironclaw_product_*, or ironclaw_telegram_v2_* entries. The v1 agent binary still has no compile-time path to any Reborn crate.
  • The Reborn coupling lives entirely in ironclaw_reborn_cli, gated behind a telegram-v2 Cargo feature with proper optional = true + default-features = false and feature-forwarding via the ? syntax (ironclaw_reborn_telegram_v2_host?/libsql) so libSQL/Postgres aren't pulled in when telegram-v2 is off.
  • The architecture-boundary test (reborn_cli_binary_crate_stays_separate_from_v1_root) was updated to allow ironclaw_reborn_telegram_v2_host as an explicit, justified exception, with the inline rationale spelling out that future per-channel hosts (Slack/Discord/WeChat) join the same list — that is the right way to evolve the allow-list rather than to weaken it.
  • --no-default-features (or --no-default-features --features libsql) builds an ironclaw-reborn without any Telegram crate at all; the smoke test run_initializes_minimal_runtime_shell_without_touching_v1_state now explicitly env_remove("TELEGRAM_BOT_TOKEN") to keep the runtime-shell snapshot path under test.
  • The new caller-level test run_enters_telegram_v2_path_when_bot_token_is_set proves the wire-up: setting the token and withholding the webhook secret forces a HostConfig::from_env failure, which the test pattern-matches on stderr while asserting the runtime-shell banner did not print. This is exactly the regression coverage the "test through the caller" rule asks for.

One minor: telegram_v2_configured_in_env() keys solely on TELEGRAM_BOT_TOKEN. If an operator has that var set in their shell for unrelated reasons (e.g. CI seeding it for a different binary), ironclaw-reborn run silently changes behavior. The fail-closed namespace guard in 1f433f7 mitigates this — they get a loud startup error rather than a quiet boot — and the smoke test documents the contract. Worth a one-line note in the CLI help for run ("when TELEGRAM_BOT_TOKEN is set, run enters the Telegram v2 serve loop"), but non-blocking.

Open items, unchanged

Verdict

APPROVE. The three new commits are clean, narrowly scoped, well-tested, and each closes a real defect I or @serrrfirat flagged. Substrate independence is preserved (the v1 binary still has zero Reborn crate awareness). #3606 remains the right follow-up for the plaintext token residual.

@serrrfirat

Copy link
Copy Markdown
Collaborator

Architecture alignment request: this PR is the main place where channel-specific drift can appear, so please route Telegram v2 through the shared ProductWorkflow surface instead of growing a Telegram-specific ledger/binding/turn pipeline.

Desired shape:

  • Telegram host/adapter owns protocol parsing, auth verification, and outbound rendering/egress.
  • After inbound parse/auth, Telegram should call ProductWorkflow::accept_inbound.
  • ProductWorkflow should own product inbound idempotency, binding resolution, terminal rejection replay, and turn submission handoff.
  • Durable storage from this PR should back the shared ProductWorkflow ports where needed, rather than becoming a separate Telegram-only implementation path.

Follow-up PR #3727 prepares that shared ProductWorkflow route for the initial rollout slice: trusted installation-to-tenant/default-scope mapping, ironclaw_conversations binding, projection subscription binding validation, and in-flight idempotency semantics. Please align this PR with #3727 so Telegram and future channels do not each build their own facade at the composition root.

@zmanian zmanian left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review of 4 new commits since prior round

Range covered: db68eae8..2b206374 (4 substantive commits + merge, landed 2026-05-14 → 2026-05-15). I have not reviewed this PR before under this account, so prior-finding tracking below is against @henrypark133's CHANGES_REQUESTED review (2026-05-14 01:09Z) and @serrrfirat's inline comments, since the author's comments reference an anticipated zmanian review.

Status of prior findings

  1. Recovery lease for non-terminal idempotency rows — Resolved. ledger_libsql.rs and ledger_postgres.rs now carry recovery_lease: Duration (default constant in recovery.rs), and begin_or_replay reclaims stale received / dispatched rows via a guarded UPDATE on (action_id, received_at) so two racing reclaimers can't both win. Tests drive the actual begin_or_replay path with both fresh and expired rows, on both backends. Contract is now reachable on Telegram retries after a process crash.

  2. getMe URL leaking the bot token in reqwest error strings — Resolved. fetch_bot_identity in boot.rs routes every reqwest::Error through a scrub helper that calls .without_url() before to_string(). JSON/HTTP-status branches stringify only response.status() or body, neither of which carries the token.

  3. Silent :memory: libSQL fallback — Resolved. config.rs resolve_storage now requires either DATABASE_URL or LIBSQL_PATH; absence of both returns a Config error unless ALLOW_EPHEMERAL_LIBSQL=1 is set, in which case a warn! documents the choice. Acceptable.

  4. Default-namespace collision (installation_id / tenant_id / agent_id) — Resolved (1f433f73). Fail-closed unless every var is set or ALLOW_DEFAULT_NAMESPACE=1 opt-in. Good.

  5. Orphan thread when concurrent inbound loses the binding-insert race — Resolved (40a160f8). Both backends pre-mint the candidate ThreadId, INSERT the binding first, and only call ensure_thread after winning. Loser path goes through lookup_existing without ever creating a thread. Winner failure path calls rollback_binding. Verified for libSQL SqliteFailure(2067, _) (extended UNIQUE code, not bare 19) and the Postgres ON CONFLICT … DO NOTHING RETURNING pattern. Concurrency test (binding_libsql.rs:548) exists.

  6. v1/v2 wiring inside agent binary (@serrrfirat) — Resolved. af0ef699 consolidates the host into crates/ironclaw_reborn_telegram_v2_host and removes it from src/main.rs. The dependency-boundary test (reborn_dependency_boundaries.rs) enforces it.

  7. Stale docs after extraction — Partial. docs/reborn/contracts/telegram-v2.md was updated, but I'd want a spot-check that product-adapters.md no longer says production wiring is unimplemented. Not blocking.

Re-verifying the tracer-specific invariants

  • Atomic create-or-bind: confirmed above (finding #5).
  • No-reply mode: confirmed. boot.rs constructs the adapter with progress_push_enabled: false and never wires an OutboundDeliverySink. NativeProductAdapterRunner::process_webhook returns Acknowledged { ack } only; the workflow path for ProductInboundPayload::NoOp and user-message acks does not invoke any sink. There is no sendMessage call anywhere in crates/ironclaw_reborn_telegram_v2_host/**. The tracer cannot reply by construction.
  • Webhook signature validation: SharedSecretHeaderAuth checks X-Telegram-Bot-Api-Secret-Token before parse_inbound. Auth-strategy mismatch is rejected pre-parse. Good. Note that expected_secret is held as a plain String in the runner for process lifetime — author has annotated this as residual exposure; acceptable for tracer rollout, must be revisited before non-default-off.
  • Idempotency on duplicate Telegram updates: the same update_id produces the same fingerprint, which now goes through the durable ledger with recovery-lease semantics. Retries after crash are now replayable.

New findings (non-blocking)

  • boot.rs falls back to placeholder ("ironclaw_telegram_v2_unknown", 0) when getMe fails. The bot_user_id = 0 placeholder is used in GroupTriggerPolicy and will silently misclassify group-chat triggers until restart. Operationally surprising on transient startup network errors. Consider failing closed and exiting, or at least surfacing a metric/health-check bit so an operator notices.
  • expected_secret and telegram_bot_token both end up cloned as plain String inside long-lived runner / resolver state. Author has called this out in comments. Track as a follow-up before non-tracer rollout; not a blocker for a no-reply tracer.
  • docs/reborn/contracts/product-adapters.md not visibly touched in this round — please confirm it's not still describing fake services / v1 exclusivity.

Verdict

Approve. All four critical items from the prior CHANGES_REQUESTED round are now addressed with caller-level tests on the public APIs. The tracer's no-reply property is structurally enforced, not just documented. The two residual String exposures and the getMe-failure placeholder are tracked and acceptable for a default-off tracer; revisit before rollout.

— Zaki

…born-telegram-v2-wiring

Resolves textual conflicts and refits the Telegram v2 host onto the post-merge APIs.

Textual conflicts resolved:
- Cargo.toml workspace members — take base's list (with ironclaw_wasm_sandbox_core,
  without ironclaw_storage which base deleted) and re-add ironclaw_product_workflow_storage
  + ironclaw_reborn_telegram_v2_host.
- crates/ironclaw_storage/ — accept the base-side deletion (crate dissolved in #3679).
- crates/ironclaw_reborn_cli/Cargo.toml — merge feature blocks; telegram-v2 sits
  next to root-llm-provider; tokio is no longer optional (base needs it for the
  composition runtime), so drop the dep:tokio fork from telegram-v2.
- crates/ironclaw_reborn_cli/src/commands/run.rs — try_serve_telegram_v2 runs
  after init_tracing + dry-run handling but before the new runtime::execute REPL
  path; channel mode and REPL mode stay mutually exclusive.
- crates/ironclaw_architecture/tests/reborn_dependency_boundaries.rs — keep the
  new ironclaw_product_workflow_storage boundary rule, accept base's updated
  comment on the registry rule, and let ironclaw_reborn_cli depend on both
  ironclaw_reborn_composition (new) and ironclaw_reborn_telegram_v2_host (ours).
- docs/reborn/contracts/product-adapters.md — keep both new status rows.
- Cargo.lock — merge the ironclaw_reborn_cli dep list (telegram-v2 host + the
  composition stack).

API drift required to compile after merge:
- ironclaw_threads deleted LibSql/PostgresSessionThreadService (#3679). The host
  composition now constructs FilesystemSessionThreadService over Lib/Pg
  RootFilesystem + a fixed-view ScopedFilesystem (single-tenant for the
  standalone binary; per-invocation rewriting follows once the loop lands).
  Same migration for the outbound store (FilesystemOutboundStateStore replaces
  the deleted SQL impls). Drops ironclaw_threads/{libsql,postgres} +
  ironclaw_turns/{libsql,postgres} feature forwards on the host crate; adds
  ironclaw_filesystem as a direct dep with libsql/postgres feature forwarding.
- ConversationBindingService trait gained lookup_binding — implemented on both
  LibSql/Postgres binding services using the existing lookup_existing helper,
  returning BindingRequired on miss.
- DefaultProductWorkflow::new gained a binding-service arg — supplied at the
  boot site + webhook_e2e test.
- ResolveBindingRequest gained external_event_id + route_kind — populated in
  StubInboundTurnService and in both storage-crate binding test fixtures.
- ActionFingerprintKey::new gained an ExternalActorRef arg — fixed in both the
  libsql ledger test and postgres_contract test.
- SessionThreadService gained append_tool_result_reference + load_context_messages
  — implemented as unreachable! on the CountingThreadService binding-test mock.

Verified locally:
- cargo check --workspace --tests — clean
- cargo test -p ironclaw_reborn_telegram_v2_host --features libsql — 5/5 webhook E2E
- cargo test -p ironclaw_product_workflow_storage --features libsql — 19/19
- cargo test -p ironclaw_architecture --test reborn_dependency_boundaries — 19/19
- cargo test -p ironclaw_reborn_cli --features libsql — 62/62 (with DEEPSEEK_API_KEY unset; the smoke test that asserts the stub-gateway warning is sensitive to LLM env vars leaking in from the shell)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nickpismenkov
nickpismenkov requested a review from zmanian May 20, 2026 01:22
…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>

@serrrfirat serrrfirat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Multi-agent review for #3590 at 2278da82a9602a680a44ba3ea346ce27088fbd22.

Result: requesting changes. I found 2 High issues and 5 Medium issues worth fixing before merge. The High issues are the durable ledger ownership race after recovery-lease reclaim in both storage backends.

Additional non-inline follow-ups:

  • Add Postgres contract coverage for ON CONFLICT contention and stale lease recovery, preferably proving the action_id ownership fix in both backends.
  • Exercise run_postgres_migrations against a real Postgres pool instead of duplicating a schema constant in tests.
  • Remove the stale Reborn Telegram v2 comment left above gateway_base_url in src/extensions/manager.rs.

WHERE adapter_id = $5 \
AND installation_id = $6 \
AND source_binding_key = $7 \
AND external_event_id = $8",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

High: stale owners can overwrite reclaimed actions here. The recovery branch above mints a fresh action_id, but this UPDATE is scoped only by the fingerprint. If the old worker resumes after another request has reclaimed the row, it can settle the new reservation with the old outcome. Scope settle by action_id as well, and make stale settles report a superseded/transient result. The same ownership check is already enforced by the in-memory ledger before moving an in-flight action to settled.

WHERE adapter_id = ?5 \
AND installation_id = ?6 \
AND source_binding_key = ?7 \
AND external_event_id = ?8",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

High: stale owners can overwrite reclaimed actions here. The reclaim path replaces the row's action_id, but settle still matches only the fingerprint, so an old worker that resumes after the lease can write its stale outcome onto the new owner's reservation. Include action_id in this WHERE, and apply the same guard to release so stale owners cannot delete a fresh in-flight row.

const ALLOW_DEFAULT_NAMESPACE_ENV: &str = "IRONCLAW_REBORN_ALLOW_DEFAULT_NAMESPACE";

fn env_flag_set(env_lookup: EnvLookup, name: &str) -> bool {
env_lookup(name).is_some_and(|v| !v.is_empty() && v != "0")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium: false enables these unsafe opt-ins. This treats any non-empty value except 0 as true, so IRONCLAW_REBORN_ALLOW_EPHEMERAL=false enables in-memory storage and IRONCLAW_REBORN_ALLOW_DEFAULT_NAMESPACE=false enables default namespace IDs. Since these are fail-closed escape hatches, parse them strictly: accept only explicit true values such as 1/true, treat empty/0/false as false, and reject any other value.

if entry.is_empty() {
continue;
}
let mut parts = entry.splitn(2, ':');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium: the parser says entries with more or fewer than one colon fail closed, but splitn(2, ':') accepts 123:user:extra as a valid pair with user_id = "user:extra". Split on : and require exactly two non-empty parts before installing the pairing, with a regression test for the extra-colon case.

let mut cfg = PoolConfig::new();
cfg.url = Some(url.clone());
let pool = cfg
.create_pool(Some(Runtime::Tokio1), tokio_postgres::NoTls)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium: this always builds the Postgres pool with NoTls. Managed Postgres URLs commonly require TLS via sslmode=require; those deployments will fail during migration/pool connection even though the host advertises Postgres support. Please mirror the existing Postgres TLS connector selection or use a rustls connector for TLS-capable URLs.

}

pub(crate) fn from_env_with(env_lookup: EnvLookup) -> Result<Self, HostError> {
let listen_addr = env_lookup("IRONCLAW_REBORN_LISTEN_ADDR")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium: the new required/operational env vars are not reflected in .env.example. This config reads IRONCLAW_REBORN_LISTEN_ADDR, REBORN_TELEGRAM_V2_INSTALLATION_ID, REBORN_TENANT_ID, REBORN_AGENT_ID, TELEGRAM_WEBHOOK_SECRET, REBORN_TELEGRAM_PAIRINGS, IRONCLAW_REBORN_ALLOW_DEFAULT_NAMESPACE, and IRONCLAW_REBORN_ALLOW_EPHEMERAL, while .env.example still only has TELEGRAM_BOT_TOKEN plus the stale REBORN_TELEGRAM_V2_ENABLED block. The repo docs say .env.example lists all env vars, so operators will miss fail-closed settings.

Comment thread src/app.rs Outdated
/// Backend-specific handles (libsql::Database / Pg pool). Needed by
/// late-stage wiring like Reborn Telegram v2 that must instantiate
/// storage outside the `Database` trait surface.
pub database_handles: Option<crate::db::DatabaseHandles>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium: this expands the v1 AppComponents surface specifically for Reborn Telegram v2 storage, but this PR also documents that Reborn product-layer channels live in ironclaw-reborn and that the v1 agent has zero Reborn wiring or shared in-process state. I also only find this field assigned, not read. Please remove it unless there is a current non-Reborn v1 consumer; the Reborn storage handles should stay inside the standalone host composition.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why are we mingling with app.rs again...

persistence, ledger settlement — so that swapping in the real loop is a
one-line change in [`boot.rs`].

When the Reborn loop lands:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

loop landed

Resolved conflicts:
- Cargo.toml: kept HEAD's ironclaw_product_workflow_storage and
  ironclaw_reborn_telegram_v2_host workspace members; added
  origin's ironclaw_webui_v2 (union of both new-crate additions).
- crates/ironclaw_architecture/tests/reborn_dependency_boundaries.rs:
  kept both new BoundaryRule entries (product_workflow_storage from
  HEAD, webui_v2 from origin).
@serrrfirat

Copy link
Copy Markdown
Collaborator

Follow-up architecture summary after the inline review:

The line-level review already requested changes for the concrete blockers. Pulling the findings together, I think this PR currently violates several Reborn storage/host-boundary rules and should be reshaped before merge.

  1. Idempotency ledger bypasses the universal filesystem + CAS contract.
    Current reborn-integration has ironclaw_filesystem::RootFilesystem::put/get with CasExpectation::{Absent, Version}, and crates/ironclaw_filesystem/CLAUDE.md explicitly says CAS is the floor for claim/consume/transition operations. This PR instead adds backend-specific product_inbound_actions SQL implementations for libSQL and Postgres. The ledger claim/reclaim/settle/release state machine is exactly the kind of multi-step store operation that should be represented as filesystem records and transitioned with CAS + retry on VersionMismatch, unless there is a documented exemption.

  2. The direct SQL ledger already produced a correctness bug.
    The inline review flagged that stale workers can settle/delete a reclaimed row because settle and release match only the fingerprint, not the current action_id. A filesystem CAS design would naturally make the version/owner check explicit for every transition.

  3. Egress is adapter-mediated but not host-api mediated.
    The adapter itself correctly accepts &dyn ProtocolHttpEgress and a credential handle, but the host implements a bespoke TelegramHttpEgress around reqwest rather than using ironclaw_host_api::RuntimeHttpEgress / host network policy surfaces. Boot-time getMe is also a direct reqwest call.

  4. Secrets are handles at the adapter boundary, but not through the host secret surface.
    TELEGRAM_BOT_TOKEN starts as SecretString, then is exposed into a StaticCredentialResolver as a plain String. That means the adapter does not see the raw token, but the host composition is not using ironclaw_host_api::SecretHandle or a real brokered secret resolver.

  5. Filesystem usage is mixed.
    Conversation binding and outbound delivery state are routed through ScopedFilesystem, RebornFilesystemConversationServices, and FilesystemOutboundStateStore, which is the right shape. The exception is the idempotency ledger, which remains direct SQL despite the universal FS/CAS API existing on the base branch.

  6. Other concrete review findings still apply.
    The earlier review also flagged fail-open env flag parsing (false enabling unsafe opt-ins), malformed pairing parsing with extra colons, Postgres NoTls for TLS-required databases, missing env docs in .env.example, and unused v1 AppComponents.database_handles surface expansion.

Suggested direction: move the idempotency ledger to a filesystem-backed record store using RootFilesystem::put/get and CasExpectation; keep the existing conversation/outbound filesystem path; and either wire egress/secrets through the intended host-api surfaces now or explicitly document why this tracer slice is temporarily allowed to use a bespoke host shim.

…review fixes

Migrates Telegram v2 outbound through the host-api `RuntimeHttpEgress`
pipeline (URL-path credential injection via the new
`RuntimeCredentialTarget::UrlPath`) and replaces the per-backend SQL
idempotency ledgers with `FilesystemIdempotencyLedger` over the
universal `RootFilesystem` dispatch fabric. The v1 agent stays
unaware: composition lives in `crates/ironclaw_reborn_telegram_v2_host`.

Also addresses the high-severity findings from the multi-agent code
review:

- Batch the three per-egress secret-store ops (metadata + lease +
  consume) into a single `block_on_secret`, paying the
  std::thread + tokio-runtime build cost once per request instead of
  3× (`crates/ironclaw_host_runtime/src/lib.rs`).
- Add UrlPath credential-injection regression tests for the
  empty-placeholder, control-character, and invalid-substituted-URL
  fail-closed branches (`runtime_http_egress_contract.rs`).
- Add a `host_egress_contract.rs` suite for
  `HostMediatedTelegramEgress`: drives `send()` against a recording
  fake to lock in URL construction, undeclared-host rejection, missing
  credential handle, per-request credential override, unsupported HTTP
  method, and empty-declared-list fail-closed construction.
- Strengthen `webhook_e2e::duplicate_update_replays_through_ledger`
  with action_id-stability + fresh-fingerprint cross-checks; the
  authoritative "exactly one row per fingerprint" row-count
  assertion lives in the ledger crate's own
  `ledger_filesystem_contract.rs` (added) where it can list
  `/ledger/inbound` directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@nickpismenkov nickpismenkov left a comment

Copy link
Copy Markdown
Contributor 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 #3590 at 10c98777d4e56a6616e0479357d3ad76f172c3bd (post-fix commit). Re-run after the prior review's findings landed in commit 10c98777d.

Verdict: still some High issues to address. 4 High (1 critical-tier rule violation, 1 untested TLS branch, 2 retry-classification bugs that don't fire today but will when outbound lands), 11 Medium, 15 Low/Nit. Posting 15 inline plus a body-only digest for the rest.

What got fixed vs the previous review

  • The 3× per-egress block_on_secret is now batched into a single call (was: thread+runtime built 3× per request). ✅
  • UrlPath fail-closed branches now have empty-placeholder / control-char / invalid-URL regression tests. ✅
  • HostMediatedTelegramEgress now has a contract test suite (host_egress_contract.rs). ✅
  • duplicate_update_replays_through_ledger strengthened with action_id stability + fresh-fingerprint cross-checks, plus a directory-listing row-count test in ledger_filesystem_contract.rs. ✅

What's still flagged

High

# File:Line Title
1 boot.rs:139 NonZeroUsize::new(64).expect("64 > 0") violates CLAUDE.md's no-panic rule (Critical-tier convention) — use a const evaluator
2 lib.rs:131 Postgres sslmode branching has zero test coverage — a regression flipping the match would silently downgrade TLS on managed Postgres
3 host_egress.rs:252 Response → Network classifies leak-detector matches as retryable; permanent failures will retry forever when outbound lands
4 host_egress.rs:243 Credential → UnknownCredentialHandle puts free-text into a handle field and makes transient StoreUnavailable permanent

Medium (highlights)

File:Line Issue
config.rs:252 LIBSQL_PATH=:memory: set explicitly bypasses the ALLOW_EPHEMERAL opt-in — fail-closed guard is incomplete
host_runtime/lib.rs:1075 block_on_secret still spawns one OS thread + builds a fresh tokio runtime per request — cache via OnceLock
webhook_e2e.rs:413 Auth tests assert only status codes, never that subtle::ConstantTimeEq is actually wired
inbound_turn.rs:48 StubInboundTurnService has no direct test — UnsupportedActionKind reject branch and v2: accepted-ref prefix are unverified
webhook_e2e.rs:351 Atomic create-or-bind never exercised under concurrent webhooks for the same actor
host_egress_contract.rs:134 New happy-path test doesn't assert response_body_limit (4 MB) or timeout_ms (30 s) on the captured request — load-bearing limits unprotected
host_egress.rs:224 spawn_blocking + inner block_on_secret = two thread hops per outbound; will become a load-bearing cost when reply path wires
inbound_turn.rs:64 Stub hardcodes route_kind: Direct — Telegram group/channel inbound silently mis-binds to direct conversations
ledger_filesystem.rs:211 CAS retry loop matches via reason.contains("version") — fragile across backend driver text and a future error-message rename
composition.rs:61 Bot token downgraded SecretString → String between boot and composition — defeats zeroize-on-drop for ~50 lines
host_egress.rs:246 Request → PolicyDenied conflates composition config errors with network-policy denials (audit noise; not retry-affecting today)
ledger_filesystem.rs:158 begin_or_replay always does read+write — old SQL impl was INSERT-first (1 round-trip on happy path). 4 round-trips per inbound on Postgres-backed FS
webhook_e2e.rs:467 !status.is_success() is too loose — a 5xx regression on the unpaired-actor path would pass (causing Telegram retry storms)
ledger_filesystem_contract.rs:259 Retry-budget exhaustion path (MAX_CAS_RETRIES = 5) untested — dead-code-equivalent without a regression test

Low / Nit (body-only — not posted inline to save the 15-comment budget)

  • router.rs:37 — 404 vs 401 lets an unauthenticated attacker enumerate installation_ids
  • boot.rs:182 — fetch_bot_identity embeds raw token in a format! heap String (defeats SecretString for the getMe probe)
  • host_egress.rs:204 — request.body().to_vec() copies the entire body per send (loses Bytes ref-count; matters for media uploads)
  • host_egress.rs:197-215 — network_policy, scope, capability_id, placeholder all cloned per send() — wrap in Arc
  • lib.rs:121-147 — Postgres pool uses deadpool defaults (no explicit max_size / timeouts)
  • host_runtime/lib.rs:1148 — UrlPath substitution scans then re-parses the URL on every request
  • ledger_filesystem.rs:343 — ledger_path() recomputes SHA-256 + JSON serialize per CAS retry
  • runtime_http_egress_contract.rs:869+ — new UrlPath tests miss non-ASCII placeholder and non-control-but-URL-unsafe chars
  • host_egress_contract.rs:280-319 — unsupported-method test only covers PUT; PATCH/DELETE/lowercase post all share one wildcard arm
  • ledger_filesystem_contract.rs:514 — ledger_path collision-resistance / length-bound not asserted directly
  • webhook_e2e.rs:572 — duplicate_pairing_install_is_idempotent only proves build_router doesn't error, not that state is uncorrupted
  • boot.rs:83-105 — fetch_bot_identity failure-path fallback (bot_user_id=0 sentinel) untested + scrub()'s token redaction unverified
  • config.rs:245-276 — resolve_storage precedence not driven through the caller; only the env_flag_set helper is tested
  • ledger_filesystem_contract.rs — no crash / partial-write / corrupt-JSON recovery test (intent flagged this risk area explicitly)
  • inbound_turn.rs:50 — format!("{:?}", envelope.payload()) may leak user-controlled message content into typed error / logs

Themes

  1. map_runtime_error in host_egress.rs:241-256 is one function with three retry-classification bugs (Highs #3, #4 and Medium "Request→PolicyDenied"). The fix is the same: branch on concrete RuntimeHttpEgressError sub-variants and map each to the matching ProtocolHttpEgressError (retryable vs permanent vs timeout). All three resolve together. None fire today (no outbound), so this is "before outbound lands" not "stop ship."
  2. Test-through-the-caller gaps are the dominant Medium category — the new code paths (sslmode branching, stub turn service boundary, atomic create-or-bind race, response_body_limit forwarding, ledger retry-budget) all have unit tests on neighboring helpers but nothing that drives the call site. Each is a one-test fix.
  3. CLAUDE.md / types.md hygiene — the .expect("64 > 0") and SecretString → String downgrade are both rule-tier violations with one-line fixes (const evaluator, change field type).
  4. block_on_secret thread+runtime churn is now 1× per egress (was 3× pre-fix) but still 1×. Worth caching the runtime in a OnceLock before any throughput rollout.

The PR scope ("inbound tracer, no reply") makes most of these latent — the map_runtime_error retry bugs don't fire because outbound isn't wired, and the perf overhead is invisible at tracer volume. But all four Highs become live the moment the reply path lands; better to fix them in this PR than as a follow-up.

🤖 Generated with Claude Code

});
let runner_config = NativeProductAdapterRunnerConfig::new(
Duration::from_secs(15),
NonZeroUsize::new(64).expect("64 > 0"), // safety: literal 64 is provably non-zero

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🔴 High — Conventions / Critical-tier rule · .expect("64 > 0") violates CLAUDE.md's absolute "No .unwrap() or .expect() in production code" rule. The literal 64 is provably non-zero so the panic is unreachable, but the rule is enforced everywhere else in the codebase. Fix: make it a const:

const RUNNER_CAPACITY: NonZeroUsize = match NonZeroUsize::new(64) {
    Some(n) => n,
    None => unreachable!(),
};

Then pass RUNNER_CAPACITY to the runner config. Compile-time validation, no panic in the binary.

// though the rest of the crate is otherwise fully configured. Plain
// `postgres://` URLs with no `sslmode` default to `Prefer`, which we
// treat as TLS so the typical managed-Postgres URL works out of the box.
let parsed: tokio_postgres::Config = url

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🔴 High — Tests · Postgres TLS sslmode branching has zero test coverage. The match arm classifies SslMode::Disable → NoTls vs everything else → rustls. A regression flipping the arms (or dropping the _ catch-all) would silently downgrade TLS for managed Postgres (sslmode=require) — and managed Postgres is the production target.

Fix: extract fn ssl_mode_for_url(url: &str) -> Result<SslMode, HostError> and unit-test each branch: ?sslmode=disable, ?sslmode=require, no sslmode (→ Prefer default), and a malformed URL (→ HostError::Storage).

RuntimeHttpEgressError::Network { reason, .. } => {
ProtocolHttpEgressError::Network(RedactedString::new(reason))
}
RuntimeHttpEgressError::Response { reason, .. } => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🔴 High — Bugs · RuntimeHttpEgressError::Response → ProtocolHttpEgressError::Network makes leak-detector matches and response-body-limit-exceeded errors retryable. The host runtime emits Response for permanent response-side failures (response_leak_blocked, decode errors). The adapter retry classifier then treats Network as retryable → the workflow replays a request that will deterministically fail again with the same leak-detector hit.

Doesn't fire today (reply path is stubbed) but lights up the moment outbound lands. Fix:

RuntimeHttpEgressError::Response { reason, .. } => ProtocolHttpEgressError::PolicyDenied {
    reason: RedactedString::new(reason),
},

/// shape so the adapter can branch on retryable/permanent.
fn map_runtime_error(err: RuntimeHttpEgressError) -> ProtocolHttpEgressError {
match err {
RuntimeHttpEgressError::Credential { reason } => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🔴 High — Bugs · Credential { reason } → UnknownCredentialHandle { handle: reason } has two problems:

  1. reason is free-form sanitized text ("credential store unavailable", "credential lease was already used"); stuffing it into the handle field — which downstream code logs/compares as a credential identity — produces nonsense like egress credential handle 'credential store unavailable' is unknown.
  2. UnknownCredentialHandle maps to EgressDenied (not retryable). A transient StoreUnavailable thus becomes permanent and the inbound event is dropped instead of retried.

Fix: add a CredentialUnavailable { reason: RedactedString } variant to ProtocolHttpEgressError (or route through PolicyDenied with the redacted reason). Reserve UnknownCredentialHandle for the path where the runtime actually says the handle id is missing.

return Ok(StorageBackend::Postgres { url });
}
#[cfg(feature = "libsql")]
if let Some(path) = env_lookup("LIBSQL_PATH") {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟡 Medium — Security / fail-closed bypass · The IRONCLAW_REBORN_ALLOW_EPHEMERAL opt-in (lines 260-269) only gates the implicit fallback. An operator who sets LIBSQL_PATH=:memory: directly hits line 253 first and returns LibSql{path: ":memory:"} — connect_backend then routes that to libsql::Builder::new_local(":memory:") without requiring the opt-in.

The in-file comment specifically calls out that silent :memory: is dangerous ("would break the idempotency contract on every restart without anyone noticing") — but a typo or copy-from-test-config triggers it without the warning the ALLOW_EPHEMERAL path emits.

Fix: inspect the path value:

if let Some(path) = env_lookup("LIBSQL_PATH") {
    if path == ":memory:" || path.starts_with(":memory:?") {
        // require ALLOW_EPHEMERAL opt-in just like the implicit fallback
    }
    return Ok(StorageBackend::LibSql { path });
}

// keeps the async runtime unblocked while the host executes the
// call.
let egress = Arc::clone(&self.egress);
let response = tokio::task::spawn_blocking(move || egress.execute(runtime_request))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟡 Medium — Performance · tokio::task::spawn_blocking holds one blocking-pool thread for the entire round-trip (including remote Telegram latency), and the inner block_on_secret spawns another OS thread + runtime. Two thread hops + one runtime build per send. At tracer volume this is invisible, but the default blocking pool is 512 threads and burst webhooks for high-volume bots will starve other spawn_blocking consumers.

Fix path: make HostHttpEgressService::execute async fn and let NetworkHttpEgress/SecretStore stay async end-to-end; the spawn_blocking goes away entirely. Interim: pin secret/IO to a long-lived dedicated runtime so each request is one channel send.

// `DefaultInboundTurnService` derives `route_kind` from
// `payload.trigger` via `route_kind_for_user_message`; the
// outbound migration follow-up will replace this stub.
route_kind: ironclaw_product_workflow::ProductConversationRouteKind::Direct,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟡 Medium — Bugs · ResolveBindingRequest hardcodes route_kind: ProductConversationRouteKind::Direct regardless of envelope content. Telegram group/channel messages must bind under Shared (see DefaultInboundTurnService::route_kind_for_user_message). With Direct, the binding service will either (a) silently merge multi-party group inbound under one direct conversation or (b) reject it on the Direct invariant. The PR title says "Telegram v2 inbound tracer" — a single group message lands on the wrong canonical thread.

Fix: either compute route_kind from envelope.payload().trigger() now (mirroring DefaultInboundTurnService), or reject group-shaped inbound at this boundary with an UnsupportedActionKind that names the missing Shared support — don't silently downgrade.

return Ok(IdempotencyDecision::New(claimed));
}
Err(ProductWorkflowError::Transient { reason })
if reason.contains("version") =>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟡 Medium — Bugs / fragile error matching · The CAS retry loops at lines 211, 230, 289 all match reason.contains("version") to detect VersionMismatch. map_fs_error (line 369) renders that variant as "ledger row version mismatch" but renders every other FilesystemError as format!("ledger filesystem error: {other}"). Any future driver text mentioning "version" — "unsupported schema version", "server_version mismatch", even a serde error "missing field 'version'" on a stale row — will be silently treated as a CAS conflict and burn MAX_CAS_RETRIES iterations before surfacing as "contended past retry budget". Real backend failures look like contention.

Fix: match the concrete FilesystemError::VersionMismatch variant in map_fs_error and emit a tagged sentinel (e.g. Transient { reason: "ledger:cas_miss:..." }), then match that exact prefix in the retry loops. Or carry a LedgerError enum through with a typed VersionMismatch arm.

where
F: RootFilesystem,
{
async fn begin_or_replay(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟡 Medium — Performance · begin_or_replay always reads then writes. The old SQL impls (now deleted) used INSERT-first with conflict semantics — 1 round-trip in the common (no-prior-row) case. This impl always pays read+write: 2 round-trips per webhook on the happy path, up to 5 read+write iterations under contention. On a Postgres-backed filesystem each round-trip is a real network hop; begin_or_replay + settle ≈ 4 round-trips per inbound. At tracer volume invisible; at reply-path volume meaningful.

Fix: try CasExpectation::Absent put first; only read on VersionMismatch. Happy path drops to one round-trip and matches the old SQL behavior.

/// installation resolver key.
pub adapter_id: ProductAdapterId,
pub installation_id: AdapterInstallationId,
pub telegram_bot_token: String,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟡 Medium — Conventions / types.md · HostConfig::telegram_bot_token: SecretString (zeroize + redacted Debug) is downgraded to RebornProductRuntimeConfig::telegram_bot_token: String in boot.rs:75 via expose_secret().to_string(). The plain String then lives on the heap until line ~153 where it's consumed into SecretMaterial::from(...). That window spans secret_store.put(...).await plus storage layer builds — defeating zeroize-on-drop for the busiest part of startup.

Fix: change this field to SecretString (or ironclaw_secrets::SecretMaterial if appropriate) and call .expose_secret() only at the final SecretMaterial::from(...) site. Mirrors how HostConfig::telegram_webhook_secret already stays SecretString end-to-end.

Four targeted fixes for the High and load-bearing Medium findings
from the post-fix multi-agent review on PR #3590. Each one closes
the gap with the smallest local change; no architectural shifts.

1. `host_egress::map_runtime_error` retry classifications.
   Branch on `RuntimeHttpEgressError::reason_code()` instead of by
   variant + free-text. Now:
   - `Network` → retryable `Network` (transport — may recover).
   - `Credential` / `Request` / `Response` / `ResponseBodyLimitExceeded`
     → permanent `PolicyDenied` with a stable, short reason.
   Fixes two latent bugs from the previous review: (a) `Response →
   Network` was silently retryable, so leak-detector matches and
   response-decode failures would loop forever once the reply path
   lands; (b) `Credential` reasons were stuffed into the typed
   `UnknownCredentialHandle.handle` field, producing nonsense audit
   strings and making transient store-unavailable failures permanent.
   Adds 4 contract tests in `host_egress_contract.rs` driving each
   `RuntimeHttpEgressError` variant via a fake and asserting the
   adapter retry classifier (`ProductAdapterError::is_retryable`)
   sees the right verdict.

2. `boot::RUNNER_CAPACITY` const evaluator (replaces
   `.expect("64 > 0")` on the boot path).
   CLAUDE.md forbids `.unwrap()` / `.expect()` in production. `match`
   is `const`, so an unreachable `None` arm is a compile error rather
   than a runtime panic.

3. `RebornProductRuntimeConfig::telegram_bot_token: SecretString`.
   The boot path was downgrading the token from `SecretString` to
   `String` between `boot::boot` and `composition::build_…`, defeating
   `secrecy`'s zeroize-on-drop and redacted-Debug guarantees for the
   busiest part of startup. Threaded `SecretString` through; only
   call `.expose_secret()` at the boundary into the secret store
   (`secret_store.put`), where the value is consumed and dropped.
   `SecretMaterial` is a re-export of `secrecy::SecretString`, so this
   change is a typed-identity-only move with no extra clones.

4. `LIBSQL_PATH=:memory:` fail-closed bypass.
   The original PR's `IRONCLAW_REBORN_ALLOW_EPHEMERAL=1` opt-in only
   guarded the *implicit* in-memory fallback. An operator who set
   `LIBSQL_PATH=:memory:` (or `:memory:?cache=shared`) directly bypassed
   the guard and silently lost ledger durability. Now `resolve_storage`
   inspects the path: explicit `:memory:` variants require the same
   opt-in flag as the implicit fallback. Adds 5 regression tests
   covering both URI forms, the opt-in accept path, the file-path
   passthrough, and the `is_libsql_in_memory_path` classifier.

Verification: `cargo fmt`, `cargo clippy --tests --all-features`
(clean across the three touched crates), full test suite for
`ironclaw_reborn_telegram_v2_host` / `ironclaw_host_runtime` /
`ironclaw_product_workflow_storage` (all green; 10 new tests added).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nickpismenkov
nickpismenkov requested a review from serrrfirat May 21, 2026 01:10
Resolves four conflicts surfaced by reborn-integration moving forward
since the previous merge:

* `Cargo.toml`: keep this branch's workspace `members` list, which
  includes `crates/ironclaw_reborn_telegram_v2_host` (added by this
  PR). The only conflict was that membership line.

* `crates/ironclaw_product_workflow_storage/Cargo.toml` (add/add):
  keep this branch's dependency set (the storage crate also ships the
  outbound-state delivery sink, which needs `ironclaw_outbound`,
  `ironclaw_product_adapters`, `ironclaw_threads`, `ironclaw_turns`,
  plus `sha2`/`hex`/`uuid` for the filesystem-ledger path scheme).
  Picked up RI's package metadata polish (authors, homepage,
  repository, license fields).

* `crates/ironclaw_product_workflow_storage/src/lib.rs` (add/add):
  keep this branch's module-index form. RI shipped a competing
  inline `FilesystemIdempotencyLedger` implementation (PR #3759) plus
  backend-specific `RebornLibSqlIdempotencyLedger` /
  `RebornPostgresIdempotencyLedger` wrappers. Those wrappers have no
  downstream consumers yet, while this branch's
  `FilesystemIdempotencyLedger` over `ScopedFilesystem` is what the
  composition/runtime in this PR already consumes. The two
  implementations have the same intent (durable ledger over the
  universal FS dispatch fabric) but incompatible APIs
  (`ScopedFilesystem` vs raw `RootFilesystem`), so they can't
  coexist; RI's design can either rebase against this PR or land
  later as a follow-up that replaces this one cleanly.

* `Cargo.lock`: regenerated.

Also removes
`crates/ironclaw_product_workflow_storage/tests/durable_ledger_contract.rs`
(came in cleanly from RI but references the dropped
`RebornLibSqlIdempotencyLedger` / `RebornPostgresIdempotencyLedger`
types); the existing `ledger_filesystem_contract.rs` in this branch
provides equivalent CAS/concurrency coverage.

`InboundTurnService` trait gained two methods upstream
(`replay_accepted_user_message`,
`accept_user_message_with_before_policy`) — implemented on
`StubInboundTurnService` with the smallest honest behavior: replay
probe returns `Ok(None)` (no session-thread store is wired in the
tracer), and the before-policy variant applies the policy gate then
delegates to `accept_user_message`. The `#[non_exhaustive]`
`BeforeInboundPolicyOutcome` enum is handled with an explicit fallback
arm so a new variant upstream surfaces as `TurnSubmissionRejected`
rather than a silent allow/reject in this stub.

Verification: `cargo fmt`, `cargo clippy --tests --all-features`
(clean), full test suite for `ironclaw_reborn_telegram_v2_host` /
`ironclaw_host_runtime` / `ironclaw_product_workflow_storage` — all
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@zmanian

zmanian commented May 23, 2026

Copy link
Copy Markdown
Collaborator

Re-review (post 7e6ecb607, FS-CAS ledger landing)

Following my prior APPROVE at #3590 (issuecomment-4458499619). Two material changes since: 2278da82a and the 7e6ecb607 merge that pulls in the durable filesystem-CAS ledger (#3759). My four prior major-tier items remain resolved/tracked, and the latest CHANGES_REQUESTED findings (serrrfirat 2026-05-20) are addressed by the FS-CAS migration.

Substrate boundary — verified clean

This PR adds only ironclaw_reborn_telegram_v2_host as a workspace member; root Cargo.toml [dependencies] gains zero Reborn entries; no use ironclaw_reborn:: / use ironclaw_product_:: / use ironclaw_telegram_v2:: anywhere under src/. The v1 binary remains compile-time-unaware. reborn_dependency_boundaries.rs is correctly pinned with the per-channel-host allow-list pattern called out for future channels.

Webhook auth — correct

SharedSecretHeaderAuth (auth_verifier.rs:235) uses subtle::ConstantTimeEq and rejects empty expected_secret as malformed config. Auth runs before parse/workflow in NativeProductAdapterRunner::process_webhook. Router maps AuthenticationFailed to 401.

Ledger correctness — resolved

Migration to FilesystemIdempotencyLedger over RootFilesystem (8c91d75) replaces dual SQL backends with a single CAS-versioned store where the version token is the saga ownership token. settle/release (ledger_filesystem.rs:244,300) explicitly check existing.action_id != action.action_id and surface superseded — closing serrrfirat's CHANGES_REQUESTED High on stale-worker writes after recovery-lease reclaim.

Egress — host-mediated, resolved

HostMediatedTelegramEgress routes through RuntimeHttpEgress / HostHttpEgressService / PolicyNetworkHttpEgress. No bespoke reqwest client survives. RuntimeCredentialTarget::UrlPath is the right primitive for path-embedded bot tokens.

Residuals (non-blocking, all pre-rollout)

Medium — fetch_bot_identity token in URL string (boot.rs:192). scrub(err) strips URLs from reqwest::Error displays, but the URL with the token lives as plain String on the heap between client build and .send(). Defeats SecretString for the getMe path. Recommend merging into #3606 as one pre-rollout follow-up.

Medium — StubInboundTurnService hardcodes route_kind: Direct (inbound_turn.rs:127). Group/channel inbound silently mis-binds until the outbound migration. Acknowledged in code; non-blocking only because the stub produces no reply.

Medium — bot_user_id = 0 placeholder on getMe failure (boot.rs:115). Group-trigger classifier accepts 0; transient startup network errors silently degrade group routing. Consider fail-closed or a metric.

Low — host-owned ledger path layout is implicit between the host crate and ledger_filesystem.rs. A constant-string-comparison test would lock it.

Low — 404-before-401 on unknown installation (router.rs:37) lets an unauthenticated probe enumerate installation IDs.

Verdict

APPROVE. Substrate boundary holds, all CHANGES_REQUESTED resolved, residuals are correctly scoped to pre-rollout follow-ups.

@zmanian zmanian left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review: COMMENT (lean approve for the narrowed inbound-tracer scope).

The PR has been substantially reshaped since the open CHANGES_REQUESTED reviews. The current head no longer contains the dual SQL ledgers (ledger_libsql.rs/ledger_postgres.rs), the per-backend binding files, or the V26/V28 migrations earlier reviewers critiqued — those are replaced by a single FilesystemIdempotencyLedger over the universal RootFilesystem/CAS contract. The verifiable prior blockers appear addressed; final merge gate should be the original reviewers (serrrfirat/henrypark) dismissing their now-stale reviews.

Substrate boundary — clean (the priority concern): root Cargo.toml [dependencies] gains zero Reborn entries; ironclaw_reborn_telegram_v2_host is a workspace member wired into the separate ironclaw-reborn binary only. No inverted dependency. The v1/v2 exclusivity guard removal from main.rs/extensions/manager.rs is correct now that v2 is its own binary. reborn_dependency_boundaries.rs enforces the boundary with an allow-list + a forbidden rule on ironclaw_product_workflow_storage.

Should-fix

  • ledger_filesystem.rs (map_fs_error + CAS loops): version-conflict detection round-trips a typed FilesystemError::VersionMismatch into Transient { reason: "...version mismatch" } then re-detects via reason.contains("version"). Lossy string-matching for control flow — carry a typed sentinel/variant instead. In-crate, so not a correctness bug today.
  • boot.rs ~192 fetch_bot_identity: getMe builds the URL as a plain String, so the token lives on the heap outside SecretString for this one path, and it's a bespoke reqwest call outside the host egress surface. Already tracked in #3606 — fine to defer, but route it through the host egress like the send path.

Nits

  • inbound_turn.rs ~127: stub hardcodes route_kind: Direct; group/channel inbound mis-binds until the outbound migration (acknowledged in code).
  • info! logs on the inbound path are fine here — this is the ironclaw-reborn binary, not the v1 REPL/TUI, so the CLAUDE.md info!-corrupts-TUI rule doesn't apply.

Test coverage — strong for a tracer and drives through the caller (webhook POST → workflow → ledger): settle, duplicate-replay, 401 on wrong/missing secret, 404 on unknown installation, unpaired-actor-does-not-settle (BindingRequired regression), plus CAS tests (stale-settle-after-reclaim, fabricated-action-id rejection, concurrent-begin funneling) and host-egress contract tests.

Prior CHANGES_REQUESTED status (predates the FS-CAS reshape): recovery-lease wedge, binding/thread orphan race, default-namespace collision, idempotency-bypasses-CAS, stale-worker settle, egress-not-host-mediated, and the stale extensions/manager.rs comment all appear resolved in the current head. Remaining: getMe heap exposure (#3606). A re-review from serrrfirat/henrypark is what's needed to clear the gate.

# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	crates/ironclaw_architecture/tests/reborn_dependency_boundaries.rs
#	crates/ironclaw_host_runtime/src/lib.rs
#	crates/ironclaw_reborn_cli/Cargo.toml
@nickpismenkov
nickpismenkov requested a review from zmanian May 25, 2026 17:47

@zmanian zmanian left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review (head cbe23032f, prior review was 7e6ecb60, 2026-05-23)

What changed since my 2026-05-23 review

Almost nothing in this PR's own surface. The diff between my prior review commit and the current head is dominated entirely by the base branch (reborn-integration) advancing via two merge commits — subagent-spawn docs, trace-CLI refactors, secrets/keychain work, etc. None of that is authored by this PR.

The only PR-authored changes to its own crates since 7e6ecb60 are two small forward-compat adaptations to keep up with new upstream enum variants:

  1. crates/ironclaw_telegram_v2_adapter/src/adapter.rs — render_outbound now also matches the new ProductOutboundPayload::CapabilityActivity(_) and ProductOutboundPayload::KeepAlive variants, routing them to the existing "Telegram does not consume this → Deferred + record status" arm. The match remains exhaustive with no wildcard (all 8 variants enumerated), so a future variant fails compilation rather than silently mis-routing — good defensive shape. A focused test (render_outbound_capability_activity_deferred_without_egress) drives the new arm and asserts no egress call fires. .expect() usages are test-only (allowed).

  2. crates/ironclaw_reborn_telegram_v2_host/src/host_egress.rs — one line adds the new RuntimeHttpEgressReasonCode::PolicyDenied to the permanent-PolicyDenied arm of map_runtime_error. Correct classification (a policy denial is not retryable).

Status of prior findings

  • map_runtime_error retry-classification Highs (nickpismenkov #3 Response→Network-retryable-forever, #4 free-text into UnknownCredentialHandle.handle) — Resolved at this head. The function now switches on a typed reason_code() enum (no string matching), maps ResponseError/ResponseBodyLimitExceeded/CredentialUnavailable/RequestDenied/PolicyDenied all to permanent PolicyDenied, and surfaces only the allow-listed stable_runtime_reason() instead of raw reason text. The doc comment explicitly calls out that this keeps host-internal strings out of the handle field. Both latent retry bugs are closed before the reply path lands.
  • Substrate boundary / no inverted dependency — Still clean. Confirmed in my prior review; unchanged. Root Cargo.toml gains no Reborn deps; host is a workspace member wired only into the ironclaw-reborn binary; reborn_dependency_boundaries.rs enforces it.
  • No-reply tracer property — Still structurally enforced. No sendMessage; the two new payload arms both terminate in Deferred without touching egress (test-asserted).
  • getMe heap token exposure (#3606) — Still open / tracked, unchanged this round. Acceptable to defer for a default-off tracer.
  • ledger_filesystem.rs lossy reason.contains("version") CAS control flow — Still open, unchanged. In-crate, not a correctness bug today; carry a typed sentinel when convenient.
  • inbound_turn.rs stub hardcodes route_kind: Direct — Still open (acknowledged in code), fires only when outbound migration lands.

New findings

  • None at correctness/security severity. The only PR-authored delta is the two adaptations above, both correct.
  • Nit (adapter.rs): the shared arm's comment ("Telegram never consumes projection subscriptions") is now slightly inaccurate — CapabilityActivity and KeepAlive aren't projection subscriptions. The Deferred reason string "telegram surface does not consume projection envelopes" is similarly narrow for the two new variants. Cosmetic; the behavior is correct.

Test coverage

Unchanged-and-strong from the prior round (webhook → workflow → ledger: settle, duplicate-replay, 401/404, unpaired-actor, CAS reclaim, host-egress contract). The new variant gets a caller-level test that drives render_outbound and asserts no egress side effect — exactly the "test through the caller" expectation.

Recommendation

COMMENT / lean approve, unchanged from 2026-05-23. There is effectively no new PR-authored risk surface since my last review — just two correct forward-compat patches for new upstream enum variants, one of which (PolicyDenied) plus the surrounding map_runtime_error rework actually closes the prior retry-classification Highs. Residual items (getMe #3606, FS-CAS string match, stub route_kind: Direct) remain deferred and acceptable for a default-off, no-reply tracer. Merge gate remains a dismissal of the now-stale CHANGES_REQUESTED reviews by serrrfirat/henrypark.

Re-reviewed on behalf of @zmanian.

Base automatically changed from reborn-integration to main June 5, 2026 12:57
@serrrfirat serrrfirat closed this Jun 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs DB MIGRATION PR adds or modifies PostgreSQL or libSQL migration definitions risk: medium Business logic, config, or moderate-risk modules scope: channel Channel infrastructure scope: db/libsql libSQL / Turso backend scope: db/postgres PostgreSQL backend scope: dependencies Dependency updates scope: docs Documentation scope: extensions Extension management size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants