feat(reborn): add ProductAdapter contracts + Telegram v2 tracer-bulle… - #3316
nickpismenkov wants to merge 2 commits into
Conversation
#3285) First-slice landing for #3285 (Migrate external channel adapters onto ProductAdapter contract). The PR ships three new crates that prove the ProductAdapter boundary end-to-end against fake Reborn services and recorded Telegram payloads, while keeping the legacy v1 Telegram WASM channel default-on and unchanged. * `ironclaw_product_adapters` (#3269 first slice) — ProductAdapter trait, inbound/outbound DTOs (`ProductInboundEnvelope`, `ProductInboundAck`, `ProductOutboundEnvelope`), sealed `ProtocolAuthEvidence::Verified`, `ProtocolHttpEgress` with declared hosts + opaque credential handles, `OutboundDeliverySink`, projection cursor stub, and in-memory fakes. Boundary tests forbid dependencies on `ironclaw_dispatcher`, `_capabilities`, `_host_runtime`, `_network`, `_secrets`, `_filesystem`, raw runtime lanes, and `ironclaw_turns::runner`. Redaction tests prove DTOs/errors do not leak secrets, host paths, or backend internals. * `ironclaw_wasm_product_adapters` — host runtime glue: HMAC-SHA256 + shared-secret-header webhook auth verifiers (constant-time compare), `EgressPolicy` (declared host + credential-handle allowlist), and `NativeProductAdapterRunner` that wires authentication + `ProductAdapter::parse_inbound` + `ProductWorkflow::accept_inbound` into one webhook flow. WIT contract for the eventual wasmtime component-model build is documented at `wit/product_adapter.wit`; the binary build lands in a follow-up. * `ironclaw_telegram_v2_adapter` — Telegram WASM v2 ProductAdapter, greenfield against the v2 contract (no `IncomingMessage`/v1 channel types). Normalizes `update_id` to `ExternalEventId`, `chat_id + message_thread_id` to `ExternalConversationRef`, and `message_id` as reply-target idempotency data (NOT part of the conversation key). Group/supergroup gating: only `bot_mention`/`reply_to_bot`/recognized bot commands trigger envelopes; ambient group messages produce a 200 no-op ack. Attachments expose bounded `ProductAttachmentDescriptor` values only — no raw bytes, source URLs, or host paths. Outbound renders `FinalReply` to `sendMessage` and (capability-gated) `Progress` to `sendChatAction`, both via constrained egress to the declared `api.telegram.org` host with an opaque credential handle. Acceptance-criteria test matrix: 32 tests in `crates/ironclaw_telegram_v2_adapter/tests/product_adapter_telegram_contract.rs` cover every AC bullet from #3285 (ac1..ac16 plus deterministic protocol smoke + redaction sentinels). Each test is named after the bullet it proves and drives the adapter through `NativeProductAdapterRunner` + `FakeProductWorkflow` + `FakeProtocolHttpEgress` + `FakeOutboundDeliverySink`. Default-off wiring: * New env var `REBORN_TELEGRAM_V2_ENABLED` (default false). Plumbed into `ChannelsConfig::reborn_telegram_v2_enabled`. * `ironclaw::config::validate_telegram_v1_v2_exclusivity` fails closed at startup when both v1 and v2 are configured for the same telegram installation. * `tests/telegram_v2_default_off_integration.rs` is a caller-tier integration test that drives the validator and confirms `Config::for_testing(...).channels.reborn_telegram_v2_enabled == false`. Docs: * `docs/reborn/contracts/product-adapters.md` — contract summary, layering, frozen invariants, capability grid, default-off rules. * `docs/reborn/contracts/telegram-v2.md` — Telegram-specific normalization, group gating, attachment policy, idempotency, and AC test pointer. * `_contract-freeze-index.md` updated with the new contracts. * `.env.example` documents `REBORN_TELEGRAM_V2_ENABLED`. Test coverage: 116 new tests pass. `cargo fmt --check` clean. `cargo clippy --all --tests --all-features` zero warnings. Legacy v1 Telegram path (`channels-src/telegram`, `src/channels/wasm/telegram_host_config.rs`) is unmodified. Refs: #3269 #3266 #3193 #3094 #3032 #3020 #3279 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces the Reborn ProductAdapter contract and its initial implementation for Telegram v2, establishing a clean boundary between transport-specific logic and the core pipeline. The changes include core DTO definitions, host runtime glue for authentication and egress enforcement, and comprehensive documentation. Key feedback identifies critical security concerns regarding potential forgery of authentication evidence via deserialization defaults and a replay attack vulnerability in HMAC verification. Additionally, improvements are needed for UTF-16 string slicing logic and error mapping to ensure transient egress failures are correctly handled as retryable.
Four security/correctness fixes from Gemini code review on PR #3316 plus the No-panics CI check. * **HIGH security — Auth-evidence serde forgery (Gemini #1).** The `ProtocolAuthEvidence::Verified` variant previously round-tripped through serde because its `seal` field used `#[serde(skip, default = "HostAuthSeal::host_only")]`. An attacker could mint a `Verified` value just by sending the matching JSON. Fix: custom `Deserialize` that REJECTS any wire payload claiming `kind == "verified"` (and any unknown field). Only `Failed` outcomes may cross trust boundaries. The inbound envelope no longer carries the full evidence enum — it carries only the sanitized `VerifiedAuthClaim`, which round-trips safely without re-opening the loophole. Tests pin: forged `Verified` payloads error, serialized `Verified` does not round-trip back, `Failed` round-trips, unknown fields fail. * **HIGH security — HMAC replay attack window (Gemini #2).** `HmacWebhookAuth` previously verified only the signature, not the timestamp. An attacker could replay a captured request indefinitely. Fix: enforce a symmetric `|now - ts| <= max_age_secs` window (default 300s = 5 min) BEFORE computing the HMAC; injectable `Clock` seam (`SystemClock` for prod, `FixedClock` for tests). New tests: in-window accept, stale reject, far-future reject, malformed timestamp reject, exact-boundary accept, just-outside reject. * **MEDIUM — Zero-length slice at offset 0 (Gemini #3).** `slice_text_by_offset(_, 0, 0)` previously returned `None` for empty strings (and at the start of any string), so a zero-length entity at position 0 was lost. Fix: initialize `byte_start = Some(0)` when `start == 0`, and treat `end == 0` symmetrically; same fix shape on `slice_text_to_end`. Nine new tests cover empty strings, slice-at-end, multibyte UTF-16 boundaries (🦀), and past-end / boundary cases. * **MEDIUM — Egress error retryability (Gemini #4).** `render_outbound` previously folded every egress failure into the non-retryable `EgressDenied`, so the host glue could not re-deliver on transient errors. Fix: map `Timeout` / `Network` / `LeakDetected` egress errors AND HTTP 5xx / 429 status responses to the retryable `WorkflowTransient`; keep `UndeclaredHost` / `Unknown/UnauthorizedCredentialHandle` / `PolicyDenied` / 4xx-non-429 as `EgressDenied`. New `ac14_egress_retryable_classification_matrix` drives 8 cases and pins each classification. * **CI No-panics check.** The check fails on `.unwrap()`/`.expect()` in newly-added production code. Two-pronged fix: - Feature-gate the test-fakes module (`fakes`) behind a new `test-support` Cargo feature so production binaries don't carry fake state machinery; downstream crates enable it from `[dev-dependencies]`. - Annotate the remaining intentional `.expect()` sites (static-host construction, JSON serialization of owned scalars) with inline `// safety: <reason>` comments per the script's documented suppression mechanism. Test totals across the new crates: 130 passing (35 product-adapters unit + 18 product-adapters contract + 31 telegram-v2 unit + 33 telegram-v2 contract + 13 wasm-host unit). Plus 5 default-off integration tests on the main crate. `cargo fmt --check` clean. `cargo clippy --all --tests --all-features` zero warnings. `python3 scripts/check_no_panics.py` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code ReviewOverview7,360-line first-slice landing of the ProductAdapter contract for #3285. Three new crates: Strengths
SuggestionsMinor:
Non-issues (intentional, called out in PR body)
Risk
RecommendationApprove. The four minor suggestions above are non-blocking polish — drop the home-rolled hex module and tighten |
|
This is like 3 PRs in one. chop it up, agents wont be able to review this correctly. |
| return Err(RunnerError::AuthenticationFailed { failure }); | ||
| } | ||
| }; | ||
| let Some(envelope) = self.adapter.parse_inbound(body, evidence)? else { |
There was a problem hiding this comment.
High Severity
The runner verifies the webhook, but it then trusts authority fields supplied by the adapter-produced envelope.
process_webhook mints trusted evidence, passes it into parse_inbound, and forwards the returned ProductInboundEnvelope directly to accept_inbound. Since the envelope carries public adapter_id, installation_id, and auth_claim fields (and Rust adapters can also call the public mark_*_verified helpers), a buggy or malicious adapter can return an envelope for a different installation/subject than the one the host just verified. That breaks the documented “adapters cannot fabricate verification” boundary and can confuse downstream binding/dedupe/auth decisions.
Please have trusted host/runner code inject or overwrite the authority fields before workflow submission, or change adapters to return only payload/protocol refs and let the runner build the final envelope. Add a malicious fake-adapter test that returns mismatched install/claim data and assert the runner rejects it before accept_inbound.
| /// `wasm_channels_enabled` is true). | ||
| /// | ||
| /// `v2_active`: value of `reborn_telegram_v2_enabled`. | ||
| pub fn validate_telegram_v1_v2_exclusivity( |
There was a problem hiding this comment.
Medium Severity
The Telegram v1/v2 exclusivity guard is defined but never invoked by production startup/config resolution.
The docs/tests say startup fails closed when legacy Telegram v1 and Telegram v2 are both active, but the real config/startup path only parses REBORN_TELEGRAM_V2_ENABLED; validate_telegram_v1_v2_exclusivity is exercised directly in tests and is not called before setup_wasm_channels/route registration. A deployment with WASM_CHANNELS_ENABLED=true, a configured legacy telegram channel, and REBORN_TELEGRAM_V2_ENABLED=true therefore proceeds instead of failing at startup.
Please call this validator from the real config or startup path after computing whether v1 and v2 are active, before registering Telegram/WASM webhook handling. Add a caller-level test that drives actual config/startup resolution with both paths enabled and expects ConfigError.
| }; | ||
| let now_secs = self.clock.now_unix_seconds() as i64; | ||
| let max_age = self.max_age_secs as i64; | ||
| let drift = (now_secs - timestamp_secs).abs(); |
There was a problem hiding this comment.
Medium Severity
The HMAC replay-window calculation can overflow before returning an auth failure.
timestamp_str is parsed as i64, then the verifier computes (now_secs - timestamp_secs).abs(). A hostile timestamp such as -9223372036854775808 overflows the subtraction in overflow-check builds, so an unauthenticated request can panic the verifier instead of receiving a fail-closed auth result.
Please reject negative timestamps (or parse as u64) and use checked arithmetic / abs_diff for the drift calculation. Add tests for i64::MIN, negative timestamps, and very large future timestamps.
| failure: ProtocolAuthFailure::Missing, | ||
| }; | ||
| }; | ||
| if !bool::from(received.as_bytes().ct_eq(self.expected_secret.as_bytes())) { |
There was a problem hiding this comment.
Medium Severity
Webhook verifiers accept empty secrets, which makes a missing secret fail open.
SharedSecretHeaderAuth has public fields and no empty-secret guard, so expected_secret == "" verifies when the request supplies an empty X-Telegram-Bot-Api-Secret-Token value. HmacWebhookAuth::new likewise accepts an empty signing key; HMAC with an empty key is computable by anyone. If host wiring builds these verifiers from a missing/empty env/config value, authentication becomes trivially forgeable.
Please hide fields behind validated constructors (or defensively fail in verify) and reject empty header names, subjects, shared secrets, and HMAC keys at startup. Add tests proving empty shared-secret and empty HMAC-key configurations reject even when the request matches the empty value.
| from.is_bot && from.id == bot_user_id | ||
| } | ||
|
|
||
| fn recognized_bot_command(message: &TelegramMessage, policy: &GroupTriggerPolicy) -> bool { |
There was a problem hiding this comment.
Medium Severity
Group command detection accepts recognized commands anywhere in the message, not only at the start.
The policy comment says recognized commands trigger when a group message starts with /foo or /foo@botusername, but this loop accepts every bot_command entity regardless of its offset. In a supergroup, text such as I ran /help yesterday can include a bot_command entity at offset 6; if help is recognized, the adapter forwards ambient chatter as a bot invocation.
Please require entity.offset == 0 for command triggers, or update the contract to explicitly allow mid-message commands. Add a fixture with a non-leading /help entity and assert it returns NoOp.
| "chat_id".into(), | ||
| serde_json::Value::Number(reply.chat_id.into()), | ||
| ); | ||
| body.insert("text".into(), serde_json::Value::String(view.text.clone())); |
There was a problem hiding this comment.
Medium Severity
Final replies are sent as one unbounded Telegram sendMessage body.
Telegram rejects message text over its 4096-character limit. As written, a long model reply is rendered into a single request; Telegram returns a 400, and render_outbound maps that 4xx to non-retryable EgressDenied, so the user receives no reply instead of chunked delivery.
Please split final replies into Telegram-sized chunks (preserving topic/reply target, and code-block safety if parse mode is added) or enforce a documented truncation policy. Add render/adapter tests that an over-limit final reply produces multiple valid sendMessage requests or a deliberate truncation outcome.
| chat_kind: TelegramChatKind, | ||
| policy: &GroupTriggerPolicy, | ||
| ) -> Option<ProductTriggerReason> { | ||
| if chat_kind == TelegramChatKind::Private { |
There was a problem hiding this comment.
Medium Severity
Private-chat bot commands are downgraded to ordinary user messages.
classify_trigger returns DirectChat immediately for private chats, and build_payload only emits ProductInboundPayload::Command when the trigger is BotCommand. That means a normal private /start or /help message with a Telegram bot_command entity is delivered as UserMessage { text: "/start", trigger: DirectChat } even though the adapter advertises InboundCommands. The common onboarding/help/control path will not reach command handling.
Please detect/extract recognized bot commands before the private-chat early return, or have build_payload emit Command whenever command extraction matches regardless of trigger. Add a private /start or /help fixture through parse_inbound/the runner and assert it produces ProductInboundPayload::Command.
| } | ||
| } | ||
|
|
||
| async fn render_outbound( |
There was a problem hiding this comment.
Medium Severity
Outbound rendering does not fail closed when an envelope is routed to the wrong adapter installation.
render_outbound never checks that envelope.adapter_id and envelope.installation_id match this TelegramV2Adapter instance. If a projection-router bug, stale queue entry, or corrupted outbound envelope for installation B reaches adapter instance A, the adapter will parse the target and send the reply using A's credential handle. This is the outbound confused-deputy analogue: a misrouted envelope can be delivered with the wrong bot credentials and incorrect delivery accounting.
Please validate envelope.adapter_id == self.config.adapter_id and envelope.installation_id == self.config.installation_id at the top of render_outbound, and fail closed before egress on mismatch. Add a test with mismatched IDs and assert FakeProtocolHttpEgress records no send calls.
| } | ||
| }; | ||
|
|
||
| let response = egress.send(request).await.map_err(map_egress_error)?; |
There was a problem hiding this comment.
Medium Severity
DeliveryStatusReporting is advertised, but the real outbound path never records a DeliveryStatus.
The contract/docs say outbound delivery reports statuses to OutboundDeliverySink, and Telegram's default capabilities include DeliveryStatusReporting. In the actual path here, however, render_outbound only calls egress.send and returns Ok/Err; there is no sink parameter or host wrapper recording Delivered, FailedRetryable, or FailedUnauthorized for the envelope's delivery_attempt_id. The AC14 test records sink.record(...) manually after observing the error, so it does not prove production/caller behavior.
Please add a host outbound runner/wrapper that owns the original ProductOutboundEnvelope, invokes render_outbound, classifies the result, and records DeliveryStatus to a sink (or pass a sink through the render API). Replace the manual sink-record test with caller-level tests for 2xx, 5xx/429, and 4xx outcomes.
| None | ||
| } | ||
|
|
||
| fn has_bot_mention(message: &TelegramMessage, policy: &GroupTriggerPolicy) -> bool { |
There was a problem hiding this comment.
Medium Severity
Group attachment captions cannot trigger the bot by mention/command.
The group trigger checks only message.text plus entities, while Telegram sends formatting/entities for captions in caption_entities and this DTO does not deserialize that field. As a result, a supergroup photo/document captioned @ironclaw_bot analyze this is treated as ambient NoOp unless it is also a reply to the bot, even though the adapter advertises inbound attachments and explicit group triggers.
Please deserialize caption_entities and run mention/command extraction over (text, entities) or (caption, caption_entities) as appropriate, then strip leading caption mentions when building UserMessagePayload. Add a recorded group photo/document caption-mention fixture and assert the adapter returns an envelope with BotMention plus an attachment descriptor.
|
Split this XL PR into a 7-PR stack so each slice is reviewable while preserving the original implementation and retaining Stack order:
I did not close this original PR; it can stay as the umbrella/reference until the split stack is reviewed/merged. |
|
Closing these as i already chopped them up in other 7 PRs. |
Split from PR #3316. Preserves the core ProductAdapter boundary before host runtime glue or Telegram-specific implementation. Co-authored-by: Nikolay Pismenkov <nickpismenkov@gmail.com>
Split from PR #3316. Lands constant-time webhook auth verification, declared-host/credential-handle egress policy, and the WIT contract before adding runner glue or Telegram behavior. Co-authored-by: Nikolay Pismenkov <nickpismenkov@gmail.com>
Split from PR #3316. Lands constant-time webhook auth verification, declared-host/credential-handle egress policy, and the WIT contract before adding runner glue or Telegram behavior. Co-authored-by: Nikolay Pismenkov <nickpismenkov@gmail.com>
Split from PR #3316. Lands constant-time webhook auth verification, declared-host/credential-handle egress policy, and the WIT contract before adding runner glue or Telegram behavior. Co-authored-by: Nikolay Pismenkov <nickpismenkov@gmail.com>
Split from PR nearai#3316. Adds the Telegram ProductAdapter implementation, outbound rendering through constrained egress, recorded payload fixtures, delivery-status behavior, and end-to-end adapter contract tests. Co-authored-by: Nikolay Pismenkov <nickpismenkov@gmail.com>
Split from PR nearai#3316. Adds the default-off REBORN_TELEGRAM_V2_ENABLED marker, config plumbing, and fail-closed v1/v2 Telegram exclusivity validator without registering production v2 routes. Co-authored-by: Nikolay Pismenkov <nickpismenkov@gmail.com>
Split from PR nearai#3316. Freezes the ProductAdapter and Telegram v2 contract docs after the code/test slices are present. Co-authored-by: Nikolay Pismenkov <nickpismenkov@gmail.com>
Split from PR nearai#3316. Preserves the core ProductAdapter boundary before host runtime glue or Telegram-specific implementation. Co-authored-by: Nikolay Pismenkov <nickpismenkov@gmail.com>
Split from PR nearai#3316. Lands constant-time webhook auth verification, declared-host/credential-handle egress policy, and the WIT contract before adding runner glue or Telegram behavior. Co-authored-by: Nikolay Pismenkov <nickpismenkov@gmail.com>
Split from PR nearai#3316. Adds the trusted native runner that verifies protocol auth, mints sealed evidence, invokes ProductAdapter parsing, and forwards envelopes to the ProductWorkflow facade. Co-authored-by: Nikolay Pismenkov <nickpismenkov@gmail.com>
Split from PR nearai#3316. Lands Telegram update parsing, explicit group trigger gating, normalized Reborn external refs, and bounded attachment descriptors before ProductAdapter outbound delivery. Co-authored-by: Nikolay Pismenkov <nickpismenkov@gmail.com>
Split from PR nearai#3316. Adds the Telegram ProductAdapter implementation, outbound rendering through constrained egress, recorded payload fixtures, delivery-status behavior, and end-to-end adapter contract tests. Co-authored-by: Nikolay Pismenkov <nickpismenkov@gmail.com>
Split from PR nearai#3316. Adds the default-off REBORN_TELEGRAM_V2_ENABLED marker, config plumbing, and fail-closed v1/v2 Telegram exclusivity validator without registering production v2 routes. Co-authored-by: Nikolay Pismenkov <nickpismenkov@gmail.com>
Split from PR nearai#3316. Freezes the ProductAdapter and Telegram v2 contract docs after the code/test slices are present. Co-authored-by: Nikolay Pismenkov <nickpismenkov@gmail.com>
Split from PR nearai#3316. Preserves the core ProductAdapter boundary before host runtime glue or Telegram-specific implementation. Co-authored-by: Nikolay Pismenkov <nickpismenkov@gmail.com>
Split from PR nearai#3316. Lands constant-time webhook auth verification, declared-host/credential-handle egress policy, and the WIT contract before adding runner glue or Telegram behavior. Co-authored-by: Nikolay Pismenkov <nickpismenkov@gmail.com>
First-slice landing for #3285 (Migrate external channel adapters onto ProductAdapter contract). Three new crates prove the ProductAdapter boundary end-to-end against fake Reborn services and recorded Telegram payloads, while keeping the legacy v1 Telegram WASM channel default-on and unchanged.
Summary
ironclaw_product_adapterscrate defines the ProductAdapter contract ([Reborn] Define ProductAdapter replacement for stale transport PR #3269 first slice): typed inbound/outbound DTOs, sealedProtocolAuthEvidence::Verified,ProtocolHttpEgresswith declared hosts + opaque credential handles,OutboundDeliverySink, projection cursor stub, in-memory fakes, and architecture/redaction boundary tests.ironclaw_wasm_product_adaptershost crate provides constant-time webhook auth verifiers (HMAC-SHA256, shared-secret-header),EgressPolicyenforcement,NativeProductAdapterRunnerglue, and thewit/product_adapter.witcontract for the eventual wasmtime component-model build.ironclaw_telegram_v2_adapteris the Telegram v2 tracer-bullet ProductAdapter: greenfield against the v2 contract, normalizes external refs, gates group/supergroup messages on explicit triggers (mention/reply-to-bot/recognized command), exposes bounded attachment descriptors, and renders projection-derived final replies through constrained egress toapi.telegram.org.REBORN_TELEGRAM_V2_ENABLED=false(env +ChannelsConfig) plusvalidate_telegram_v1_v2_exclusivitystartup fail-closed when v1 and v2 both target the same installation. Legacy v1 Telegram path is unmodified.Change Type
Linked Issue
Implements #3285. Coordinates with #3269 (ProductAdapter contract — first slice landed inline), #3266 (outbound egress policy), #3193 (ConversationBindingService), #3094 (gate UX, deferred), #3032 (no-exposure), #3020 (compatibility-gate), #3279 (TurnCoordinator product-flow tests).
What's in this PR
crates/ironclaw_product_adapters/ProductAdapter contract types and traits:
ProductInboundEnvelope,ProductInboundPayload(UserMessage/Command/ApprovalResolution/AuthResolution/SubscriptionRequest/NoOp),ProductInboundAck(Accepted/DeferredBusy/Rejected/Duplicate/NoOp),ProductOutboundEnvelope,ProductOutboundPayload(FinalReply/Progress/GatePrompt/AuthPrompt/ProjectionSnapshot/ProjectionUpdate).ExternalActorRef,ExternalConversationRef(keyed by space + conversation + optional topic; reply target message id is not part of the conversation key),ExternalEventId,ProductAttachmentDescriptor(no raw bytes, no source URLs, no host paths).ProtocolAuthEvidence::Verifiedis sealed via a crate-privateHostAuthSeal; only the publicmark_*_verifiedhelpers inironclaw_product_adapters::authcan mint aVerifiedvalue. WASM components and downstream adapters cannot fabricate verification.ProtocolHttpEgresstrait,DeclaredEgressHost,EgressCredentialHandle(opaque),OutboundDeliverySink,DeliveryStatus(Delivered/FailedRetryable/FailedUnauthorized/Deferred).ProductAdapterCapabilities(inbound messages/commands/attachments, final-reply push, opt-in progress push, opt-in gate push, projection subscription, sync wait, delivery status reporting).ProductAdapter,ProductWorkflow,ProjectionStream.FakeProductWorkflow(programmable outcomes + dedupe by external_event_id),FakeProtocolHttpEgress(records calls, validates declared host + credential handle),FakeOutboundDeliverySink,FakeProjectionStream.ironclaw_dispatcher,_capabilities,_host_runtime,_network,_secrets,_filesystem,_wasm,_processes,_mcp,_scripts,_runtime_policy,_authorization,_run_state,_approvals,_resources,_trust,_extensions,_safety,_skills,_engine,_gateway,_tui,_memory,_events,_reborn_event_store,_architecture, andironclaw_turns::runner.RedactedStringwraps any value originating from a protocol payload and renders as<redacted>inDebug/Display/Serialize.crates/ironclaw_wasm_product_adapters/Host runtime glue (currently runtime-free; the wasmtime component-model binary build lands alongside this crate in a follow-up):
WebhookAuthVerifiertrait +HmacWebhookAuth(Slack-style v0 HMAC-SHA256 with timestamp prefix) andSharedSecretHeaderAuth(Telegram-style). Both usesubtle::ConstantTimeEqto avoid timing oracles.EgressPolicy— declared-host + credential-handle allowlist enforcement.NativeProductAdapterRunner— receives webhook headers + body, verifies auth, mints a sealedVerifiedevidence, callsProductAdapter::parse_inbound, forwards the envelope toProductWorkflow::accept_inbound, and returns eitherAcknowledged { ack }orNoOp.RunnerError::is_auth_failure()andis_retryable()map the protocol-status response.wit/product_adapter.wit— agreed shape of the eventual WASM component contract: exports formanifest,parse-inbound,render-outbound; constrained host imports forlog,now-millis,http-egress. No raw filesystem, env, random, or arbitrary HTTP capability is exposed.crates/ironclaw_telegram_v2_adapter/Telegram WASM v2 ProductAdapter (greenfield, no v1 channel-type imports):
update_idExternalEventId(tg-<installation>-<update_id>)message.from.idExternalActorRef(kindtelegram_user)message.chat.idExternalConversationRef.conversation_idmessage.message_thread_idExternalConversationRef.topic_idmessage.message_idExternalConversationRef.reply_target_message_id(NOT part of conversation key)NoOp(200 ack, workflow never sees them). Envelopes only when (a)mentionentity matches configuredbot_username(case-insensitive, UTF-16-aware offset slicing), or (b)reply_to_message.from.is_bot && from.id == bot_user_id, or (c)bot_commandentity for a recognized command (with/foo@botnamesuffix matching).ProductAttachmentDescriptorwithexternal_file_id,mime_type, optionalfilename, optionalsize_bytes, andkind. No raw bytes, no source URLs, no host paths.FinalReply→POST api.telegram.org/sendMessagewithchat_id+ optionalmessage_thread_id+ optionalreply_to_message_id.Progress { Typing }→sendChatAction(only whenExternalProgressPushis opted-in per [Reborn] Define outbound egress and subscription policy #3266).GatePrompt/AuthPromptare deferred to [Reborn] Add approval/auth interaction services #3094 (no side effects in the first slice).ProjectionSnapshot/Updateare dropped (Telegram doesn't subscribe).api.telegram.orghost. Bot token travels as an opaqueEgressCredentialHandle = "telegram_bot_token"; the host resolves the underlying secret at request time.Default-off wiring
REBORN_TELEGRAM_V2_ENABLED=false(default) keeps legacy v1 Telegram running unchanged through the v1 channel manager.ironclaw::config::validate_telegram_v1_v2_exclusivity(v1_active, v2_active) -> Result<(), ConfigError>fails closed at startup when both paths are configured for the same telegram installation.tests/telegram_v2_default_off_integration.rsis a caller-tier integration test (not just a helper test) that drives the validator + assertsConfig::for_testing(...).channels.reborn_telegram_v2_enabled == false.Docs
docs/reborn/contracts/product-adapters.md— frozen contract: layering, invariants, capability grid, ack-to-status mapping.docs/reborn/contracts/telegram-v2.md— Telegram-specific normalization, group gating, attachment policy, idempotency, and AC test pointer._contract-freeze-index.mdupdated..env.exampledocumentsREBORN_TELEGRAM_V2_ENABLED.Acceptance criteria coverage (#3285)
crates/ironclaw_telegram_v2_adapter/tests/product_adapter_telegram_contract.rshas one named test per AC bullet:ac1_telegram_v2_does_not_import_v1_channel_typessrc/channelsac2_product_adapter_contracts_live_outside_src_channelsac3_runner_blocks_envelope_construction_on_bad_secret,_on_missing_secret,ac3_adapter_refuses_unverified_evidence_directlyac4_parse_normalizes_all_refsac5_adapter_does_not_import_turn_coordinator,ac5_adapter_path_only_invokes_workflow_facadeac6_workflow_returns_each_durable_outcome_kindupdate_iddedupeac7_duplicate_update_id_returns_prior_outcome_no_double_submitac8_attachments_have_no_raw_bytes_or_source_urlsac9_private_chat_creates_inbound,_group_ambient_is_noop_ack,_group_explicit_mention_creates_inbound,_group_command_creates_inboundac10_conversation_key_uses_chat_and_topic_not_message_idac11_durable_outcome_classification_for_each_path,ac11_duplicate_returns_200_no_op_ackac12_final_reply_renders_to_reply_target_bindingac13_egress_to_undeclared_host_is_blocked,_telegram_only_declares_telegram_api,_egress_request_carries_credential_handle_not_tokenac14_delivery_failure_records_status_separately,_does_not_mutate_canonical_workflow_stateac15_gate_prompt_envelope_is_no_op_egress_in_first_sliceac16_default_off_marker_present_in_workspace_root_config+tests/telegram_v2_default_off_integration.rssmoke_recorded_payloads_match_expected_outcomes,redaction_sentinels_in_envelope_debug,_in_error_display,telegram_default_capabilities_pin_first_slice_behavior,telegram_does_not_consume_projection_subscriptions,projection_stream_can_drive_telegram_via_render_outbound_chainValidation
cargo fmt --all -- --check— cleancargo clippy --all --benches --tests --examples --all-features— zero warningscargo build— passescargo test -p ironclaw_product_adapters -p ironclaw_wasm_product_adapters -p ironclaw_telegram_v2_adapter— 112 tests passcargo test --test telegram_v2_default_off_integration— 5 tests passcargo test -p ironclaw config::channels::telegram_v2_tests --lib— 4 tests passcargo test --features integrationnot run for this PR — no DB-backed code paths addedSecurity Impact
subtle::ConstantTimeEq).ProtocolAuthEvidence::Verifiedis sealed: only crate-internal helpers can construct it. WASM components (when wired up) cannot mint verification.ProtocolHttpEgressenforces declared hosts + credential-handle allowlist. Bot tokens never reach adapter code or WASM linear memory; the host resolves them at request time.RedactedStringfor any value originating from a protocol payload, secret store, or backend error text. Redaction is asserted by tests acrossDebug,Display, andSerialize.Database Impact
None. No migrations, no schema changes, no database access in any of the three new crates.
Blast Radius
reborn_telegram_v2_enabledfield onChannelsConfig(defaultfalseeverywhere it is constructed) plus the new publicvalidate_telegram_v1_v2_exclusivitysymbol.src/channels/wasm/telegram_host_config.rsandchannels-src/telegram/are not modified — the v1 path is byte-for-byte unchanged.Cargo.tomllines added (members list).Rollback Plan
Revert the single commit. The feature flag is default-off, so even with the code present, no production behavior changes until an operator explicitly sets
REBORN_TELEGRAM_V2_ENABLED=trueand wires the v2 webhook route.Review Follow-Through
channels-src-v2/telegram/to a.wasm, instantiate via wasmtime inironclaw_wasm_product_adapters) is intentionally deferred — the WIT contract is inwit/product_adapter.witand the Rust-native adapter implementation inironclaw_telegram_v2_adapteris the same logic that will move into the component.NativeProductAdapterRunner) is a follow-up. The default-off flag is the cutover seam.ProductWorkflow/ConversationBindingService/SessionThreadServiceimplementations land with [Reborn] Define conversation binding and session thread contracts #3193/[Reborn] Add ProductWorkflow and InboundTurnService facade #3280 and replace the in-memory fakes used here.Review track: B (new feature, default-off, contract-freeze landing for #3269/#3285)