Skip to content

Implement WASM ProductAdapter component runtime - #3583

Merged
serrrfirat merged 10 commits into
reborn-integrationfrom
feat/wasm-product-adapter-loader
May 15, 2026
Merged

serrrfirat merged 10 commits into
reborn-integrationfrom
feat/wasm-product-adapter-loader

Conversation

@serrrfirat

@serrrfirat serrrfirat commented May 13, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • add ProductAdapterComponentRuntime wasmtime component loader for wit/product_adapter.wit
  • extract/validate component manifest, build EgressPolicy, and call parse-inbound / render-outbound
  • require host-minted ProtocolAuthEvidence for parse calls; validate render egress target index before exposing component output
  • share minimal Wasmtime/WASI sandbox core with aggregate memory accounting and a pre-compile component artifact size cap
  • add component contract tests and update Reborn ProductAdapter status docs / architecture dependency guardrail

Testing

  • cargo fmt --check
  • cargo test -p ironclaw_wasm_sandbox_core --locked
  • cargo test -p ironclaw_product_adapters --features test-support --locked
  • cargo test -p ironclaw_product_adapter_registry --locked
  • cargo test -p ironclaw_wasm_product_adapters --locked
  • cargo test -p ironclaw_architecture --locked
  • cargo clippy -p ironclaw_product_adapters -p ironclaw_product_adapter_registry -p ironclaw_wasm_sandbox_core -p ironclaw_wasm_product_adapters -p ironclaw_architecture --all-targets --all-features --locked -- -D warnings

Notes

  • Host HTTP egress import still fails closed; production network delegation remains a follow-up.
  • Minimal WASI p2 is registered for wasm32-wasip2 compatibility only; env, args, stdio, preopened directories, inherited network, and DNS lookup stay disabled.

@github-actions github-actions Bot added scope: docs Documentation scope: dependencies Dependency updates size: XL 500+ changed lines risk: low Changes to docs, tests, or low-risk modules contributor: core 20+ merged PRs labels May 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 implements the wasmtime component-model runtime for IronClaw Reborn product adapters, transitioning from a stub implementation to a functional WASM sandbox. It introduces the ProductAdapterComponentRuntime for managing component lifecycles, resource limiting via WasmResourceLimiter, and integration with WIT bindings. Feedback focuses on addressing a potential thread leak in the epoch ticker and improving performance by caching the Linker instead of recreating it for every instantiation.


let engine = Engine::new(&wasmtime_config)
.map_err(|error| RuntimeError::EngineCreationFailed(error.to_string()))?;
spawn_epoch_ticker(engine.clone())?;

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 spawn_epoch_ticker thread is spawned in new but never joined or stopped. If ProductAdapterComponentRuntime is instantiated multiple times, this will leak threads. Consider using a mechanism to ensure the ticker is only spawned once or that the thread is properly managed.

StoreData::new(limits.memory_bytes, limits.timeout),
);
configure_store(&mut store, limits)?;
let linker = create_linker(&self.engine)?;

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 Linker is recreated for every instantiate call, which is inefficient. Since Linker is Clone, consider creating it once in new and storing it in ProductAdapterComponentRuntime to reuse it. This avoids unnecessary heap allocations, which is important for performance in WASM.

References
  1. To improve performance in WASM, avoid unnecessary heap allocations.

@github-actions github-actions Bot added risk: medium Business logic, config, or moderate-risk modules and removed risk: low Changes to docs, tests, or low-risk modules labels May 13, 2026
nickpismenkov added a commit that referenced this pull request May 13, 2026
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>
- Sandbox core: epoch ticker now holds Weak<Engine> and exits when every
  Engine clone is dropped. Eliminates the per-runtime thread + Engine
  clone leak that affected tests and any long-running host that rebuilds
  runtimes (config reloads, per-installation, etc.). Adds a regression
  test that drops the engine and asserts the weak handle releases.

- ProductAdapter runtime: render_outbound now returns the host's
  validated typed EgressRequest instead of the raw component JSON. The
  host built the typed value via the same EgressMethod::new,
  EgressPath::new, EgressHeader::new, EgressRequest::with_body
  constructors the production HTTP egress path will use, so the
  validation is now the single source of truth callers see.

- ProductAdapter runtime: extract_manifest checks the epoch deadline
  before inspecting the call result, so timeouts surface as clean
  "deadline exceeded" errors instead of raw wasmtime trap text. Matches
  the existing parse_inbound / render_outbound ordering.

- ProductAdapter runtime: introduces MAX_COMPONENT_JSON_BYTES (1 MiB)
  and rejects any component-returned (or host-supplied) JSON above it
  before serde walks the document. Decouples host-side serde
  allocations from any future raise of the WASM memory_bytes cap.

- WIT + Rust: document now-millis as wall-clock UTC milliseconds, NOT
  monotonic. Adapter authors must not build TTL/idempotency checks on it.

- Egress validation: explicit comment that the EgressPolicy::check
  inside validate_rendered_egress_request is defense-in-depth: it is
  structurally a no-op today (the policy is built from the same vec we
  just indexed into) but locks the manifest <-> policy symmetry for
  future readers and divergence.

Tests added:
- epoch_ticker_exits_when_engine_is_dropped in sandbox core.
- render_rejects_oversized_host_envelope_before_serde in ProductAdapter
  contract tests.

Validation: cargo fmt; cargo clippy -p ironclaw_wasm_product_adapters
-p ironclaw_wasm_sandbox_core -p ironclaw_architecture --all-targets
-- -D warnings; cargo test on those three crates (--locked).
@zmanian

zmanian commented May 13, 2026

Copy link
Copy Markdown
Collaborator

Code Review — WASM ProductAdapter Component Runtime

Summary

Introduces ProductAdapterComponentRuntime (wasmtime component model) that loads a WIT-typed adapter, reads the manifest, and dispatches parse-inbound / render-outbound. Extracts a new ironclaw_wasm_sandbox_core crate carrying the v1-style limiter, epoch ticker, fuel/timeout config, and minimal-WASI linker so it can be shared between the product adapter runtime and the existing tool sandbox. Egress requests rendered by the guest are JSON-validated against the manifest's declared (host, credential_handle) pairs before being returned to the host. Solid contract test suite drives full WAT→component fixtures.

Strengths

  • Trust boundary is explicit and well documented. WIT comments, lib.rs trust-model warning, and per-crate CLAUDE.md guardrails all repeat the same rule: components never mint verified auth evidence; the host stamps it. Matches the architecture doc and the crate's stated scope.
  • Sandbox primitives factored into ironclaw_wasm_sandbox_core with a domain-free guardrail. The shared limiter + epoch ticker + minimal WASI setup is exactly the right shape for reuse, and the CLAUDE.md explicitly bans pulling in product/workflow/secrets/network deps.
  • EgressPolicy::check enforces pair membership rather than independent host/handle sets, with named diagnostics for the cross-pair leak (CredentialHandleNotPairedWithHost) vs. unknown handle (UnauthorizedCredentialHandle) vs. unauthenticated-bypass (UnauthenticatedEgressNotDeclared). The comments in egress_policy.rs capture the exact failure mode this closes.
  • validate_rendered_egress_request re-validates the manifest target index, method, path, and each header via the typed constructors in ironclaw_product_adapters (EgressMethod::new, EgressPath::new, EgressHeader::new). Host-managed headers are rejected via the existing typed-validation path, so the component cannot smuggle Authorization/Cookie/forwarding headers. This is reused logic, not a parallel reimplementation — good.
  • http_egress host import is wired closed (PolicyDenied) until production egress lands. Matches CLAUDE.md's "no production HTTP here" guardrail.
  • Test fixture builds a real component via wit-component from a hand-rolled WAT module — exercises the actual instantiation/binding path, not a mock. Negative tests cover malformed bytes, missing exports, undeclared egress index, invalid parsed DTO, and host-managed header rejection.
  • Limits (memory, fuel, timeout, log count, log byte size) and deadline checks land on every entry point. No info!/warn! in hot paths — only tracing::trace/warn inside the limiter, which is fine because it's not on the REPL output path.
  • No .unwrap()/.expect() in production code paths (test fixtures and unit-test bodies are fine).

Issues

Major

  1. Fuel exhaustion is not classified as a timeout/deadline. ensure_execution_not_timed_out only checks store.data().deadline_exceeded() — the wall-clock epoch deadline. When the guest is killed by fuel exhaustion, wasmtime returns a trap, which falls into the Err(error) arm and becomes RuntimeError::ExecutionFailed { message: <trap text>, ... }. There's no test asserting fuel-exhaustion behavior. Consider either (a) a dedicated RuntimeError::FuelExhausted so the host can distinguish "buggy adapter (out of fuel)" from "panicked adapter" for observability/policy, or (b) at minimum a test fixture that loops and asserts the fuel trap is reachable and surfaces a usable error string. As-is, the limit is enforced but unobservable to upstream callers.

  2. call_manifest error path swallows the deadline check. In extract_manifest (lines 215–222) the order is: call_manifest → .map_err(|e| execution_failed(...))? → ensure_execution_not_timed_out(...)?. If the call errors because of an epoch trap, the early ? short-circuits before the deadline check runs, so the user sees ExecutionFailed rather than the targeted deadline message. The other two entrypoints (parse_inbound, render_outbound) get this right — they capture the result first, then call ensure_execution_not_timed_out before unwrapping. Mirror that pattern here.

  3. validate_rendered_egress_request builds an EgressRequest then discards it. Lines 391–420 construct a fully-validated EgressRequest via typed constructors, then assign to _validated_request and drop it. The validation effects (header/method/path checks) fire during construction, which is the safety property the code relies on, but the normalized request never reaches the caller — render_outbound returns the raw component-provided egress_request_json string upstream. This means any downstream consumer must re-parse via the same typed constructors to get the same guarantees; if a future caller trusts the string after this function returns Ok(()), the safety property quietly disappears. Two options: (a) return the constructed EgressRequest (or a serialized canonical form) from render_outbound instead of the raw JSON, or (b) at minimum document on RenderOutboundResult::egress_request_json that the string is component-supplied and must be re-parsed before use, with the same constructors, and add a boundary test that pins this contract.

  4. now_millis lossy conversion. chrono::Utc::now().timestamp_millis().max(0) as u64 (store.rs:92) silently clamps pre-1970 timestamps to 0. In practice unreachable, but the as u64 cast on an i64 that has already been .max(0)-clamped means the lint catches nothing. Use u64::try_from(...).unwrap_or(0) or compute via Duration::as_millis() from a monotonic source. Minor; flagging only because the rest of the crate is careful about typed conversions.

Minor

  1. required_u64_field accepts both egress_target_index and egress-target-index. Lines 359–363 fall back to the kebab-case form. The WIT contract is kebab-case at the component boundary, but the JSON DTO is the host's own format — there's no documented reason a component would emit kebab-case JSON. Accepting both invites the bug pattern from .claude/rules/types.md ("two values with the same shape but different meanings"). Pick one (snake_case, matching the rest of ironclaw_product_adapters) and drop the alias, or document in the WIT comment why both forms must be accepted forever.

  2. classify_instantiation_error matches on substring of the error message. Line 515 checks message.contains("near:product-adapter") || message.contains("import"). Wasmtime's error text is not a stable API; a future version could re-word and silently degrade the "WIT version mismatch" diagnostic to a generic instantiation failure. Consider matching on wasmtime::Error downcast types (wasmtime::component::InstantiationError or similar) where possible, or at minimum add a unit test that pins the current substring and breaks loudly on wasmtime upgrades.

  3. evidence_json is built via serde_json::to_string of ProtocolAuthEvidence and handed to the guest. This is the host-minted evidence — but the guest receives the full serialized form including any sealed verified variant material. The WIT comment explicitly states the seal is "unreachable from WASM" because deserialization rejects unsealed-verified — that's a Rust-side invariant. Worth double-checking: does the serialized form contain any fields that would let a malicious component reconstruct or replay verified evidence to a different host context? If the seal is a HMAC over installation-id + payload, this is fine. If it's an opaque token without rebinding, a captured evidence string could be replayed. I didn't trace this end-to-end — flagging for the author to confirm ProtocolAuthEvidence::Verified's serialized shape is safe to hand to untrusted code, and pin it with a test.

  4. PreparedProductAdapterComponent holds a compiled wasmtime::component::Component. Each parse_inbound / render_outbound re-instantiates from the compiled component (which is fine — instantiation is cheap once the component is compiled), but the same Component is shared across calls. Confirm wasmtime::component::Component is Send + Sync and that this struct is intended to be reused across concurrent host webhooks. The fact that PreparedProductAdapterComponent is constructed once and reused (per the docstring) implies yes — adding a #[test] fn prepared_is_send_sync() would pin it.

  5. No test for the MAX_LOGS_PER_EXECUTION / MAX_LOG_MESSAGE_BYTES enforcement at the runtime boundary. truncate_log_message is unit-tested in isolation (store.rs test), but per .claude/rules/testing.md ("Test Through the Caller"), the gate that drops logs once the cap is hit (if self.logs.len() >= MAX_LOGS_PER_EXECUTION { return; }) has no caller-level test. A fixture that emits 1001 logs and asserts only 1000 reach ParsedInboundResult.logs would catch a future regression where the cap is moved/disabled.

  6. ProductAdapterComponentRuntimeConfig::Default and ProductAdapterComponentLimits::default() ship 500_000_000 fuel + 60s timeout + 10MB memory. These come from SandboxLimits::default() in the new core crate, which preserved the v1 tool defaults. For a webhook-driven adapter, 60s wall clock is long. Worth a sanity check on the production wiring that the runtime config overrides these defaults rather than relying on Default. Not a code bug — flag for the integration PR.

Suggestions

  • Hoist the JSON field-extractor helpers (required_u64_field, required_string_field, required_array_field, invalid_json) into a json_helpers submodule. They're ~50 lines and will likely be reused as the JSON-shim contracts grow. (The WIT comment already calls out the shim is temporary; helpers will outlive any single call site.)
  • The From<SandboxError> impl loses the LinkerConfiguration arm's distinction — both SandboxError::LinkerConfiguration and the local RuntimeError::LinkerConfiguration share the same message string, but two crates owning two variant trees with manual mapping is a maintenance hazard. Consider re-exporting SandboxError and using it directly, or auto-deriving via #[from] once the shape stabilizes.
  • Consider adding a test that asserts add_minimal_wasi_to_linker denies stdio/env/preopened-dirs — the CLAUDE.md for the core crate states the invariant; pinning it as a contract test in ironclaw_architecture (or here) would catch a regression where wasmtime-wasi's default linker behavior changes.

Verdict

REQUEST CHANGES — primarily for issue 2 (deadline-check ordering in extract_manifest) and issue 3 (returning unvalidated JSON to the caller). Issue 1 (fuel observability) is a follow-up worth tracking. The remaining items are minor.

Overall this is a well-scoped, well-documented PR that does what the description says, respects every guardrail in the crate-level CLAUDE.md files, and adds substantive contract tests. The sandbox-core extraction is clean and the WIT-side trust commentary is excellent.

@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: contract mismatch in WASM ProductAdapter egress

What looks good:

  • The runtime validates component-returned parsed inbound and rendered egress JSON back into typed host DTOs.
  • Malformed components, undeclared render targets, host-managed headers, and bad JSON paths have contract coverage.
  • CI is green on the current head.

Findings:

  1. High - crates/ironclaw_wasm_product_adapters/src/store.rs:101: http_egress is hard-coded to PolicyDenied, while the WIT and product-adapter contract say component HTTP egress is the only network capability and that the host executes rendered outbound through it.

Why it matters: the new WASM ProductAdapter runtime cannot support adapters that need host-mediated protocol HTTP egress, despite the contract promising declared-host validation, credential resolution, response leak scanning, and delivery reporting. The mismatch is visible in wit/product_adapter.wit, which describes http-egress as the network boundary, and in docs/reborn/contracts/product-adapters.md, which says the host sends the request through that path.

Expected fix direction: either wire a real host-mediated egress implementation through the component store while keeping the default fail-closed when no egress service is injected, or explicitly narrow this PR/docs/WIT to say this slice is parse/render-only and defer component http-egress to a follow-up.

Low-priority notes:

  • Hostile output-size, timeout/fuel, and log-isolation tests would strengthen the runtime boundary.
  • I did not treat the epoch ticker as a blocker: the current sandbox core uses Engine::weak() and the ticker exits once owned engines drop.

Summary:

  • Recommended verdict: Request changes
  • Review coverage: worktree-backed review of the WASM component runtime, store, WIT, product-adapter docs, tests, CI, and prior review threads.

@serrrfirat
serrrfirat force-pushed the feat/wasm-product-adapter-loader branch from 95f5e0f to a489837 Compare May 14, 2026 09:35
@serrrfirat

Copy link
Copy Markdown
Collaborator Author

Addressed review feedback in a489837. Summary:

# Finding Fix
1 Fuel exhaustion not classified as timeout/deadline classify_trap_error now matches "fuel"/"out of fuel"/"fuel exhausted" substrings → maps to ExecutionTimedOut; regression test render_rejects_fuel_exhaustion_with_usable_error
2 call_manifest error path swallowed deadline check Reordered: ensure_execution_not_timed_out runs before the ? on the call result so epoch traps surface as deadline errors
3 validate_rendered_egress_request built EgressRequest then discarded it Slice scope clarified in WIT/docs/CLAUDE: this slice is parse/render-only + fail-closed http-egress; rendered request is intentionally not dispatched yet (outbound delivery is the next slice)
4 now_millis lossy as u64 cast Replaced with u64::try_from(...).unwrap_or(0)
5 required_u64_field accepted both snake_case and kebab-case Accepts egress_target_index only; kebab-case rejected with explicit test
6 classify_instantiation_error matched on message substring Substring contract pinned by unit test so a wasmtime message rewording trips CI
7 evidence_json round-trip risk Verified test pins that ProtocolAuthEvidence does not serialize the verification seal
8 WIT/docs drift vs implementation Updated wit/product_adapter.wit, docs/reborn/contracts/product-adapters.md, crates/ironclaw_wasm_product_adapters/CLAUDE.md to reflect parse/render-only + fail-closed egress
9 No test for MAX_LOGS_PER_EXECUTION / MAX_LOG_MESSAGE_BYTES Added parse_caps_component_logs_at_runtime_boundary in tests/component_runtime_contract.rs exercising both caps
10 Default limits ship 500_000 fuel Acknowledged; default ProductAdapterComponentLimits stays aligned with shared SandboxLimits from ironclaw_wasm_sandbox_core (intentional — single source of truth). Per-installation overrides remain the knob. Happy to lower the default in a follow-up if you'd prefer.

Local: full cargo test -p ironclaw_wasm_product_adapters and -p ironclaw_product_adapters green. Clippy clean on touched crates. Diff: 7 files, +151/-31 then +31/-0 for the missing log-cap test. Will watch CI.

@serrrfirat

Copy link
Copy Markdown
Collaborator Author

Addressed the review feedback in a4898373b:

  • Narrowed the WASM ProductAdapter slice to parse/render-only in WIT/docs/guardrails; http-egress remains fail-closed until production host-runtime egress wiring lands.
  • Made fuel exhaustion observable with a fuel exhausted execution message and regression coverage.
  • Preserved typed egress safety by returning/using the validated EgressRequest path.
  • Removed kebab-case JSON aliasing for egress_target_index.
  • Cleaned up now_millis conversion.
  • Pinned instantiation mismatch diagnostics with a unit test.
  • Added auth evidence serialization coverage to confirm no host seal material crosses the boundary.
  • Pinned PreparedProductAdapterComponent: Send + Sync.
  • Added runtime-boundary log cap coverage for 1001 component logs → 1000 retained.

Verification run:

  • cargo fmt
  • cargo test -p ironclaw_wasm_product_adapters --test component_runtime_contract --lib
  • cargo test -p ironclaw_product_adapters
  • cargo test -p ironclaw_architecture wasm_product_adapter_wit_pins_json_shim_shape
  • cargo clippy --all --benches --tests --examples --all-features -- -D warnings

Known unrelated local checks:

  • scripts/pre-commit-safety.sh reports existing out-of-scope findings in unrelated files.
  • cargo test --lib fails unrelated extensions::manager::tests::install_from_local_source_*.

@zmanian

zmanian commented May 14, 2026

Copy link
Copy Markdown
Collaborator

Re-review — WASM ProductAdapter component runtime

Following up on my prior review (REQUEST CHANGES, primarily on issues 2 and 3). Re-reviewed against the three new commits (0d5170da8, 9559d001b, a4898373b).

Per-item status

# Prior finding Status Evidence
1 Fuel exhaustion not distinguishable from generic trap ADDRESSED (partial — see note) execution_failed in component_runtime.rs:559 now checks store.get_fuel() == Ok(0) and prepends "WASM ProductAdapter execution fuel exhausted: …" to the message. Regression test render_rejects_fuel_exhaustion_with_usable_error infinite-loops and asserts the message contains "fuel". Note: this is in-message classification, not a distinct RuntimeError::FuelExhausted variant, and the response comment's claim that it maps to ExecutionTimedOut is inaccurate — it remains ExecutionFailed with annotated text. Acceptable for observability; programmatic policy callers still can't pattern-match.
2 extract_manifest short-circuits deadline check on trap ADDRESSED component_runtime.rs:240-247: call result is captured, ensure_execution_not_timed_out runs before the ? is applied to the call result. Matches parse_inbound / render_outbound ordering.
3 validate_rendered_egress_request builds typed EgressRequest then discards it; raw JSON returned upstream ADDRESSED validate_rendered_egress_request now returns Result<EgressRequest, _> (constructed via EgressMethod::new / EgressPath::new / EgressHeader::new / EgressRequest::with_body — the same constructors a production HTTP egress path would use). RenderOutboundResult.egress_request_json: String is replaced with egress_request: EgressRequest. The raw JSON is no longer exposed. Docstring on the struct calls out the contract explicitly. calls_parse_and_render_exports test pins the typed fields; render_rejects_missing_typed_egress_fields and render_rejects_host_managed_headers exercise the new validation paths.
4 egress_target_index accepted both kebab + snake ADDRESSED required_u64_field now only checks the snake_case name; kebab-case fallback removed. render_rejects_kebab_case_egress_target_index_json test pins the rejection.
5 Fragile wasmtime substring matching for WIT-version diagnostic ADDRESSED (pinning approach) instantiation_error_classifier_pins_current_wit_import_diagnostic test added in component_runtime.rs:586. The substring match itself is unchanged but a wasmtime upgrade that re-words the message will now trip CI loudly. Reasonable pragmatic fix.
6 Log-cap enforcement lacks caller-level test ADDRESSED Two new tests: host_log_import_caps_records_at_runtime_boundary (1001 logs → 1000 retained) in store.rs, and parse_caps_component_logs_at_runtime_boundary in component_runtime_contract.rs driving the cap through a real WASM component's host_log import emitting 1001 entries. Covers both the unit and contract tier per .claude/rules/testing.md.
7 ProtocolAuthEvidence::Verified replay safety question ADDRESSED (per author) Author added a serialization test confirming no host seal material crosses the boundary. I did not personally re-trace the serialized shape end-to-end; trusting the author's pin.
8 now_millis lossy cast ADDRESSED store.rs:91: now u64::try_from(chrono::Utc::now().timestamp_millis()).unwrap_or(0). WIT and Rust both gained explicit comments warning that the value is wall-clock, not monotonic, and must not back TTL/idempotency.
9 Default limits sanity check (60s, 500M fuel, 10 MiB) ACKNOWLEDGED Author kept defaults aligned with shared SandboxLimits (single source of truth). Per-installation overrides remain the production knob. Flagged for integration-PR review. Acceptable.

New items observed

  • Slice scope narrowing. The author opted to address issue 3's secondary concern by also narrowing http-egress to fail-closed and documenting the slice as parse/render-only in WIT, CLAUDE.md, and docs/reborn/contracts/product-adapters.md. This is consistent with @henrypark133's separate review and removes the contract-vs-implementation mismatch he flagged. Good outcome.
  • New host-side JSON ceiling (MAX_COMPONENT_JSON_BYTES = 1 MiB). Added defense-in-depth so raising the WASM memory cap does not silently raise host-side serde allocation. Applied uniformly across the three validation entry points and pinned by render_rejects_oversized_host_envelope_before_serde. Good unsolicited improvement.
  • Sandbox-core extraction. WasmResourceLimiter, epoch ticker, and minimal WASI linker moved to ironclaw_wasm_sandbox_core. The epoch ticker now holds Weak<Engine> and exits when the engine drops — fixes a real thread/engine leak previously surfaced by gemini-code-assist. Regression test added.
  • ParsedInboundResult now validates against the typed ParsedProductInbound DTO rather than generic serde_json::Value. Stricter contract on the parsed side too; pinned by parse_rejects_json_that_is_not_parsed_product_inbound.

Minor follow-ups (non-blocking)

  • The response comment table claims issue 1 maps fuel exhaustion to ExecutionTimedOut. The code actually keeps it as ExecutionFailed with an annotated message. Worth fixing the comment-vs-code drift; the runtime behavior itself is fine.
  • A distinct RuntimeError::FuelExhausted variant would still be cleaner for upstream policy/observability than substring-matching on "fuel" in the message. Reasonable follow-up, not a blocker.
  • validate_rendered_egress_request retains the explicit "structurally a no-op today" EgressPolicy::check call with a defense-in-depth comment. Correct call.

Verdict

APPROVE.

Both REQUEST CHANGES items (deadline-ordering in extract_manifest, and typed-EgressRequest returned to caller) are fully addressed. The seven minor items are all addressed or reasonably acknowledged. The author additionally narrowed the slice scope, fixed an epoch-ticker leak, added a host-side JSON budget, and stricter parsed-inbound DTO validation — all improvements beyond what was requested. Test coverage at the runtime boundary (caller tier, not just helpers) is now substantive.

@serrrfirat

Copy link
Copy Markdown
Collaborator Author

Summary

Reviewed PR #3583 only. Base 51acc0a7b7dd4aaa5aba9968804f92f9b9f32700 → head 3ec12752c6f7076111206215a5ed244c8fd67bea.

PR implements WASM ProductAdapter component runtime. Boundary shape is mostly good: fail-closed egress, sealed auth evidence, resource caps during execution, and host-side egress revalidation. Merge stance: two Medium pre-execution/control-plane gaps remain.

Findings

# Sev Category File:Line Issue Fix suggestion
1 Medium Resource limits / Untrusted module validation crates/ironclaw_wasm_product_adapters/src/component_runtime.rs:157, crates/ironclaw_wasm_product_adapters/src/component_runtime.rs:162 prepare() feeds untrusted wasm_bytes directly into wasmtime::component::Component::new with no artifact-size cap or preflight rejection. Fuel/memory/wall limits apply only after compilation/instantiation, so oversized/pathological components can burn host CPU/RAM during install/load before sandbox limits engage. Add artifact size cap before compilation and reject modules above it. Consider component preflight validation budget. Add test for oversized component bytes failing before compilation.
2 Medium Auth / Boundary validation crates/ironclaw_wasm_product_adapters/src/component_runtime.rs:320, crates/ironclaw_wasm_product_adapters/src/component_runtime.rs:327, crates/ironclaw_wasm_product_adapters/src/component_runtime.rs:333 WIT manifest auth requirements are converted by direct enum construction without validating header/token/cookie syntax. Registry manifests reject invalid header_name, timestamp_header_name, and cookie names, but component manifests bypass that invariant here. Future host glue consuming declared_auth_requirements can accept malformed auth control-plane metadata from untrusted components. Reuse the same auth-requirement validators used by registry manifests for WIT-derived requirements. Add negative tests for invalid header names, timestamp header names, and cookie names from component manifests.

Security/data-flow notes

  • Good: no ambient network; egress host import revalidates typed target/credential policy.
  • Good: auth evidence crossing is sealed host-side.
  • Remaining risks are admission/control-plane validation before runtime sandbox protections apply.

Missing tests

  • Oversized component artifact rejected before compilation.
  • Malformed auth requirement fields rejected for WIT component path.

…583-conflicts

# Conflicts:
#	Cargo.toml
#	crates/ironclaw_wasm_product_adapters/src/lib.rs
@serrrfirat
serrrfirat merged commit 156e87d into reborn-integration May 15, 2026
14 checks passed
@serrrfirat
serrrfirat deleted the feat/wasm-product-adapter-loader branch May 15, 2026 10:43
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…loader

Implement WASM ProductAdapter component runtime
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: dependencies Dependency updates scope: docs Documentation size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants