From b1313d2477bd28f858c4d0bca56968e34695d3a9 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 28 Aug 2026 14:00:46 -0500 Subject: [PATCH] opp: erase an outbound envelope only once the outpost has consumed it CertiK WNS-18 (WIRE-348) reports that buildenv's cleanup drops every prior outenvelopes row for the destination unconditionally, so a batch operator delayed past the next buildenv would see its envelope deleted before delivery, permanently breaking the outbound chain. The deletion is unconditional as described, but the scenario is unreachable. buildenv runs only as an inline from sysio.epoch::advance, which post-genesis accepts no caller but sysio.msgch; msgch sends advance only from chkcons, and only once every active outpost has outpcons.epoch_index at the current epoch; that row is written only when an inbound envelope from the outpost reaches consensus, and deliver requires the envelope's epoch to equal the current one; and an outpost can emit its epoch-N envelope only after accepting the depot's epoch-N envelope -- Solana asserts it (emit.rs, EmitBeforeEpochAccepted, binding the admin recovery instruction too) and Ethereum reaches emitOutboundEnvelope only from the OPPInbound consensus tip under OPP_FINALIZER_ROLE. So the previous emit is provably consumed before the next buildenv can run, and a stalled relay stalls the epoch rather than losing an envelope. What the code did not do was say any of that. The sweep referenced none of the state it depended on, so the invariant lived entirely in the call path and a later change there could have invalidated it silently. It is now local: the sweep erases a row only when the outpost's consensus record covers that row's epoch_index, and prints a diagnostic when it retains one instead. Absence of an outpcons row means nothing has been acknowledged rather than epoch 0 has been, so an outpost that has never delivered inbound retains every emit. Healthy-path behaviour is unchanged -- acked always covers the prior emit there, the same row is erased, and the table stays one-deep -- so this costs no storage against the pruning added in 0f543179fb. A retained row is the only surviving copy of an envelope the outpost still needs: envlog keeps a checksum, not a payload, and the source attestations are drained by the same call. Retention makes the tip read ambiguous, so it moves too. It walked byoutpost with lower_bound, which returns the OLDEST row for the outpost because KV secondary entries sort by ascending primary key within one secondary key; that was the tip only because exactly one row survived. It now walks byoutepoch, whose composite packs (chain_code, epoch_index), and takes the last match -- forward-only, the same shape as the sweep below it, one row in the healthy path. Tests cover both halves. buildenv_retains_unacknowledged_outenvelopes drives buildenv without consensus and asserts successive emits accumulate and the tip is still the newest row; buildenv_erases_acknowledged_outenvelope drives real inbound consensus and asserts one acknowledgement drains every emit at or below it, leaving the table one-deep. Three existing readers that selected "the single row" by lowest id were switched to newest-row selection. No ABI change. Change-Id: I6e37aa22ac4d99d7379ffe2de34d1f87f95c7c31 --- .../include/sysio.msgch/sysio.msgch.hpp | 13 ++- contracts/sysio.msgch/src/sysio.msgch.cpp | 76 ++++++++++++--- contracts/sysio.msgch/sysio.msgch.wasm | Bin 157530 -> 162518 bytes contracts/tests/sysio.msgch_chain_tests.cpp | 91 +++++++++++++++++- contracts/tests/sysio.msgch_tests.cpp | 81 +++++++++------- 5 files changed, 206 insertions(+), 55 deletions(-) diff --git a/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp b/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp index 7a3e8b9aab..0784913cd7 100644 --- a/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp +++ b/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp @@ -198,9 +198,16 @@ namespace sysio { sysio::const_mem_fun> >; - /// Outbound envelope table. One-deep per outpost: `buildenv` erases every older row for the - /// destination `chain_code` after inserting the new emit, so the surviving row doubles as the - /// per-outpost chain tip. + /// Outbound envelope table. After inserting a new emit, `buildenv` erases the older rows for + /// the destination `chain_code` that the outpost has CONSUMED -- those whose `epoch_index` is + /// covered by `outpcons.epoch_index` -- and retains any it has not. The epoch-advance + /// interlock (`chkcons` releases `advance`, and therefore `buildenv`, only once every active + /// outpost has reached consensus at the current epoch) makes that the whole table in the + /// healthy path, so it is normally one-deep; a retained row means the interlock did not hold + /// and its `raw_envelope` is the only remaining copy of an envelope the outpost still needs. + /// + /// The per-outpost chain tip is therefore the NEWEST row, selected through `byoutepoch` -- + /// NOT `byoutpost`, whose entries sort by ascending primary key and so yield the oldest. /// /// `envelope_hash` is the canonical epoch digest: keccak256 over the canonical /// field-complete encoding with the in-envelope `envelope_hash` field blanked (equal to diff --git a/contracts/sysio.msgch/src/sysio.msgch.cpp b/contracts/sysio.msgch/src/sysio.msgch.cpp index 38ef1d82e4..91a12d202b 100644 --- a/contracts/sysio.msgch/src/sysio.msgch.cpp +++ b/contracts/sysio.msgch/src/sysio.msgch.cpp @@ -1870,21 +1870,31 @@ void msgch::buildenv(uint64_t chain_code) { candidate_ids.begin(), candidate_ids.begin() + included_count); - // Chain links: the previous envelope emitted for this outpost. `outenvelopes` is one-deep per - // outpost (see the cleanup below), so the single surviving row is the previous emit; its - // `envelope_hash` is that envelope's epoch digest and its `last_message_id` is this outpost's - // message-stream tip. The first emit for an outpost has no row and chains both links from - // empty (genesis), matching the outpost contracts' zero genesis tip. + // Chain links: the previous envelope emitted for this outpost -- the NEWEST row for + // `chain_code`, its `envelope_hash` that envelope's epoch digest and its `last_message_id` + // this outpost's message-stream tip. The first emit for an outpost has no row and chains both + // links from empty (genesis), matching the outpost contracts' zero genesis tip. + // + // Walked over `byoutepoch` rather than `byoutpost`: KV secondary entries sort by ascending + // PRIMARY key within one secondary key, so a `byoutpost` lower_bound yields the OLDEST row for + // the outpost. That was the tip only while exactly one row survived; the ack-gated cleanup + // below legitimately retains an emit the outpost has not consumed, so the tip must be selected + // by epoch instead of by "the only row left". + // + // `byoutepoch` packs `(chain_code, epoch_index)`, so this outpost's rows are contiguous and + // ascending by epoch -- the LAST match in the walk is the tip. Forward-only (`lower_bound` + + // `++` + the chain_code guard), the same shape as the sweep below; the walk is one row in the + // healthy path and bounded by the retained emits otherwise. outenvelopes_t envelopes(get_self()); std::vector prev_envelope_digest; std::vector prev_message_id; { - auto by_outpost = envelopes.get_index<"byoutpost"_n>(); - auto prev_it = by_outpost.lower_bound(chain_code); - if (prev_it != by_outpost.end() && prev_it->chain_code == chain_code) { - const auto digest_bytes = prev_it->envelope_hash.extract_as_byte_array(); + auto by_epoch = envelopes.get_index<"byoutepoch"_n>(); + for (auto it = by_epoch.lower_bound(opp::outpost_epoch_key(chain_code, 0)); + it != by_epoch.end() && it->chain_code == chain_code; ++it) { + const auto digest_bytes = it->envelope_hash.extract_as_byte_array(); prev_envelope_digest.assign(digest_bytes.begin(), digest_bytes.end()); - const auto tip_bytes = prev_it->last_message_id.extract_as_byte_array(); + const auto tip_bytes = it->last_message_id.extract_as_byte_array(); prev_message_id.assign(tip_bytes.begin(), tip_bytes.end()); } } @@ -2003,20 +2013,56 @@ void msgch::buildenv(uint64_t chain_code) { // === AUDIT LOG + INLINE CLEANUP OF WORKING STATE === // // Audit-log row mirrors the outbound emit (WIRE → outpost). Followed - // by inline drains of the previous-epoch outenvelopes row (one-deep - // retention; the batch op only ever reads the most-recent emit) and - // the just-PROCESSED attestations for this outpost (their bytes are - // now baked into `packed` above). + // by inline drains of the outenvelopes rows this outpost has already + // CONSUMED (see the ack gate below) and the just-PROCESSED + // attestations for this outpost (their bytes are now baked into + // `packed` above). { // Same endpoints the wire envelope carries (derived above from the // destination's `sysio.chains` row). write_envelope_log(get_self(), route_endpoints, epoch, envelope_digest); - // Drop previous outpost emits — keep only the row we just inserted. + // Drop previous outpost emits the destination has CONSUMED, and only those. + // + // `outpcons.epoch_index` is the last epoch whose INBOUND envelope from this outpost + // reached consensus, and an outpost can only emit its epoch-N envelope after accepting + // the depot's epoch-N emit -- Solana asserts it directly + // (`emit.rs`: `require!(wire_epoch_index < config.next_epoch_index, EmitBeforeEpochAccepted)`, + // which binds the admin recovery instruction too), and Ethereum reaches + // `OPP::emitOutboundEnvelope` only from the `OPPInbound` consensus tip under + // `OPP_FINALIZER_ROLE`. So `epoch_index <= acked` is PROOF the outpost consumed that emit. + // + // In the healthy path this erases exactly what the old unconditional sweep did and leaves + // the table one-deep: `chkcons` only released the `advance` that reached this `buildenv` + // because every active outpost had already reached consensus at the previous epoch, so + // `acked` covers the prior emit. A row survives only when that interlock did not hold, in + // which case its bytes are the ONLY copy of an envelope the outpost still needs -- the + // audit log keeps a checksum, not a payload, and the source attestations are drained just + // below. Retaining is what stops a chain break from becoming unrecoverable data loss. + // + // `has_ack` is load-bearing, not defensive: NO row means the outpost has acknowledged + // NOTHING, which a bare `epoch_index` default of 0 would make indistinguishable from + // having acknowledged epoch 0 -- and would erase an epoch-0 emit on that reading. + outpost_consensus_t opcons(get_self()); + const auto opc_pk = outpost_consensus_key{chain_code}; + const bool has_ack = opcons.contains(opc_pk); + const uint32_t acked = has_ack ? opcons.get(opc_pk).epoch_index : 0; + auto by_outpost = envelopes.get_index<"byoutpost"_n>(); for (auto it = by_outpost.lower_bound(chain_code); it != by_outpost.end() && it->chain_code == chain_code; ) { if (it->id == out_id) { ++it; continue; } + if (!has_ack || it->epoch_index > acked) { + // Never expected: reaching this `buildenv` required consensus past this emit. + // Logged (visible under --contracts-console) so a broken interlock is greppable + // rather than a silently growing table. + sysio::print_f("msgch::buildenv: retaining unacknowledged emit chain_code=%llu " + "epoch=%u has_ack=%d acked=%u\n", + static_cast(chain_code), it->epoch_index, + static_cast(has_ack), acked); + ++it; + continue; + } it = by_outpost.erase(std::move(it)); } diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index b581f28a4f6c2d039c3f9a103e3d3b01ddea705b..0ba30f006155eca61ad5bf7734a706293a9497f7 100755 GIT binary patch delta 39104 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 delta 35776 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ⅇ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 L$s0Vz() == epoch_index) return row; } return fc::variant{}; } + /// Live outbound envelope rows for `chain_code`. + uint32_t outbound_envelope_count(uint64_t chain_code, uint64_t scan_until = 32) { + uint32_t n = 0; + for (uint64_t id = 0; id < scan_until; ++id) { + auto data = get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "outenvelopes"_n, id); + if (data.empty()) continue; + auto row = msgch_abi.binary_to_variant( + "outbound_envelope", data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + if (row["chain_code"].as_uint64() == chain_code) ++n; + } + return n; + } + /// Per-outpost consensus row (`outpcons`, primary key = chain_code); null variant when absent. fc::variant get_outpcons(uint64_t chain_code) { auto data = get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "outpcons"_n, chain_code); @@ -1063,6 +1096,56 @@ BOOST_FIXTURE_TEST_CASE(buildenv_first_emit_chains_from_empty, sysio_msgch_chain fc::to_hex(header.message_id().data(), header.message_id().size())); } FC_LOG_AND_RETHROW() } +// --------------------------------------------------------------------------- +// Outbound retention: an emit is erased only once the outpost acknowledges it. +// --------------------------------------------------------------------------- + +/// WNS-18 / WIRE-348, the acknowledged half. `buildenv` erases prior emits for an outpost +/// only up to `outpcons.epoch_index` -- the last epoch whose INBOUND envelope from that +/// outpost reached consensus, which the outpost can only have produced after consuming the +/// depot's emit for that epoch. So: +/// +/// * while nothing is acknowledged, successive emits ACCUMULATE (their `raw_envelope` is +/// the only surviving copy of an envelope the outpost still needs), and +/// * one acknowledgement drains every emit at or below it, leaving the table one-deep -- +/// which is what the healthy path always looks like, because `chkcons` releases the +/// `advance` that reaches `buildenv` only once every active outpost has reached +/// consensus at the current epoch. +BOOST_FIXTURE_TEST_CASE(buildenv_erases_acknowledged_outenvelope, sysio_msgch_chain_tester) { try { + bootstrap(); + + // bootstrap()'s genesis `advance` fanned a buildenv to every registered outpost. + const uint32_t epoch_a = current_epoch(); + BOOST_REQUIRE(!outbound_envelope_for_epoch(ETH_OUTPOST_ID, epoch_a).is_null()); + BOOST_REQUIRE(get_outpcons(ETH_OUTPOST_ID).is_null()); // nothing acknowledged yet + + // Unacknowledged: the next epoch's emit does NOT displace epoch A's. + const uint32_t epoch_b = advance_one_epoch(); + BOOST_REQUIRE_NE(epoch_a, epoch_b); + BOOST_REQUIRE(!outbound_envelope_for_epoch(ETH_OUTPOST_ID, epoch_a).is_null()); + BOOST_REQUIRE(!outbound_envelope_for_epoch(ETH_OUTPOST_ID, epoch_b).is_null()); + BOOST_REQUIRE_EQUAL(2u, outbound_envelope_count(ETH_OUTPOST_ID)); + + // The tip is the NEWEST row, not the retained predecessor. + BOOST_REQUIRE_EQUAL(find_outbound_envelope(ETH_OUTPOST_ID)["epoch_index"].as(), + epoch_b); + + // An inbound envelope accepted for epoch B is the acknowledgement: the outpost could not + // have emitted it without first consuming the depot's epoch-B envelope. + BOOST_REQUIRE_EQUAL(success(), deliver(ETH_OUTPOST_ID, encode_delivery(epoch_b, "alpha"))); + produce_blocks(); + auto opc = get_outpcons(ETH_OUTPOST_ID); + BOOST_REQUIRE(!opc.is_null()); + BOOST_REQUIRE_EQUAL(opc["epoch_index"].as(), epoch_b); + + // The next emit now drains everything at or below the acknowledged epoch -- both A and B. + const uint32_t epoch_c = advance_one_epoch(); + BOOST_REQUIRE(outbound_envelope_for_epoch(ETH_OUTPOST_ID, epoch_a).is_null()); + BOOST_REQUIRE(outbound_envelope_for_epoch(ETH_OUTPOST_ID, epoch_b).is_null()); + BOOST_REQUIRE(!outbound_envelope_for_epoch(ETH_OUTPOST_ID, epoch_c).is_null()); + BOOST_REQUIRE_EQUAL(1u, outbound_envelope_count(ETH_OUTPOST_ID)); +} FC_LOG_AND_RETHROW() } + // --------------------------------------------------------------------------- // Inbound: apply_consensus records and verifies the per-outpost chain tip. // --------------------------------------------------------------------------- @@ -1306,7 +1389,7 @@ BOOST_FIXTURE_TEST_CASE(inbound_rejects_cross_stream_link_for_svm_outposts, // Next epoch: emit a depot outbound envelope, then deliver an inbound envelope whose prev is // that emit's digest (NOT the inbound tip). Building the outbound envelope in the delivery - // epoch pins the one-deep outenvelopes row so no epoch-advance emission replaces it. + // epoch pins the outenvelopes tip so no epoch-advance emission supersedes it. epoch = advance_one_epoch(); BOOST_REQUIRE_EQUAL(success(), queueout(SOL_OUTPOST_ID, sysio::opp::types::ATTESTATION_TYPE_OPERATORS, std::vector{0x0a})); diff --git a/contracts/tests/sysio.msgch_tests.cpp b/contracts/tests/sysio.msgch_tests.cpp index e237de42bd..00e4d84af6 100644 --- a/contracts/tests/sysio.msgch_tests.cpp +++ b/contracts/tests/sysio.msgch_tests.cpp @@ -323,16 +323,29 @@ class sysio_msgch_envlog_tester : public tester { ); } - /// Return the first outbound envelope row found in a small id scan. + /// Return the NEWEST outbound envelope row in a small id scan -- the per-outpost + /// chain tip, which is what `buildenv` chains from. Deliberately not "the first + /// row found": `buildenv` retains emits the outpost has not acknowledged, so the + /// lowest id can be a retained predecessor rather than the tip. fc::variant find_outbound_envelope(uint64_t scan_until = 16) { + fc::variant newest; for (uint64_t id = 0; id < scan_until; ++id) { auto data = get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "outenvelopes"_n, id); if (data.empty()) continue; - return msgch_abi.binary_to_variant( + newest = msgch_abi.binary_to_variant( "outbound_envelope", data, abi_serializer::create_yield_function(abi_serializer_max_time)); } - return fc::variant{}; + return newest; + } + + /// Number of live `outenvelopes` rows in the id range `[0, scan_until)`. + uint32_t outbound_envelope_count(uint64_t scan_until = 16) { + uint32_t n = 0; + for (uint64_t id = 0; id < scan_until; ++id) { + if (!get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "outenvelopes"_n, id).empty()) ++n; + } + return n; } /// Count populated `envlog` rows in the id range `[0, max_id_exclusive)`. @@ -475,10 +488,18 @@ BOOST_FIXTURE_TEST_CASE(envlog_cap_tracks_outpost_count, sysio_msgch_envlog_test BOOST_REQUIRE_EQUAL(7u, alive); } FC_LOG_AND_RETHROW() } -/// Existing `outenvelopes` row gets dropped on the next `buildenv` for -/// the same outpost — one-deep retention (the batch op only ever reads -/// the most-recent emit). -BOOST_FIXTURE_TEST_CASE(buildenv_drops_previous_outenvelopes, sysio_msgch_envlog_tester) { try { +/// WNS-18 / WIRE-348. `buildenv` erases a previous emit only once the destination +/// outpost has CONSUMED it -- i.e. `outpcons.epoch_index` reaches that row's epoch. +/// This fixture pushes `buildenv` directly and never drives inbound consensus, so no +/// `outpcons` row exists (acked = 0) and the earlier emit MUST survive: its +/// `raw_envelope` is the only remaining copy of an envelope the outpost still needs +/// (the audit log keeps a checksum, not a payload, and the source attestations are +/// drained by the same `buildenv`). +/// +/// The acknowledged half of the contract -- the erase actually firing, and the table +/// staying one-deep in the healthy path -- needs real consensus and lives in +/// `sysio.msgch_chain_tests.cpp::buildenv_erases_acknowledged_outenvelope`. +BOOST_FIXTURE_TEST_CASE(buildenv_retains_unacknowledged_outenvelopes, sysio_msgch_envlog_tester) { try { bootstrap_epoch_config(/*retention=*/200); register_outpost(opp::types::CHAIN_KIND_EVM, 31337); produce_blocks(); @@ -490,16 +511,25 @@ BOOST_FIXTURE_TEST_CASE(buildenv_drops_previous_outenvelopes, sysio_msgch_envlog produce_blocks(); auto first = get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "outenvelopes"_n, 1); BOOST_REQUIRE(!first.empty()); + BOOST_REQUIRE_EQUAL(1u, outbound_envelope_count()); BOOST_REQUIRE_EQUAL(success(), queueout(ETH_OUTPOST_ID, EVM_TEST_ATTESTATION_TYPE)); BOOST_REQUIRE_EQUAL(success(), buildenv(ETH_OUTPOST_ID)); produce_blocks(); - // First row is now gone (replaced by the second emit). + // Both rows live: the outpost acknowledged neither. first = get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "outenvelopes"_n, 1); - BOOST_REQUIRE(first.empty()); + BOOST_REQUIRE(!first.empty()); auto second = get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "outenvelopes"_n, 2); BOOST_REQUIRE(!second.empty()); + BOOST_REQUIRE_EQUAL(2u, outbound_envelope_count()); + + // With a predecessor retained, the tip must still be the NEWEST row -- the + // `byoutepoch` read in `buildenv`, not a `byoutpost` lower_bound (which orders by + // ascending primary key and would hand back the retained row 1). + auto tip = find_outbound_envelope(); + BOOST_REQUIRE(!tip.is_null()); + BOOST_REQUIRE_EQUAL(2u, tip["id"].as_uint64()); } FC_LOG_AND_RETHROW() } /// `attestations` PROCESSED rows for a given outpost are dropped at the @@ -575,20 +605,12 @@ BOOST_FIXTURE_TEST_CASE(buildenv_packs_until_cap_then_leaves_remainder, BOOST_REQUIRE_EQUAL(success(), buildenv(/*chain_code=*/ETH_OUTPOST_ID)); produce_blocks(); - // The most recent emit lives at one of the early ids; the one-deep - // retention sweep means at most one row exists per outpost. Find it. - fc::variant emitted_row; - uint64_t emitted_id = std::numeric_limits::max(); - for (uint64_t id = 0; id < 16; ++id) { - auto data = get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "outenvelopes"_n, id); - if (data.empty()) continue; - emitted_row = msgch_abi.binary_to_variant( - "outbound_envelope", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - emitted_id = id; - break; - } + // Take the NEWEST emit. This fixture never drives inbound consensus, so nothing is + // acknowledged and `buildenv` retains each predecessor -- the lowest id is not the + // row under test. + fc::variant emitted_row = find_outbound_envelope(); BOOST_REQUIRE(!emitted_row.is_null()); + const uint64_t emitted_id = emitted_row["id"].as_uint64(); // ── Invariant 1: emitted envelope is at or under the cross-chain cap. const auto& raw = emitted_row["raw_envelope"].as>(); @@ -609,18 +631,11 @@ BOOST_FIXTURE_TEST_CASE(buildenv_packs_until_cap_then_leaves_remainder, BOOST_REQUIRE_EQUAL(success(), buildenv(/*chain_code=*/ETH_OUTPOST_ID)); produce_blocks(); - // Find the new emitted row (one-deep retention dropped the prior one). - fc::variant emitted_row_2; - for (uint64_t id = 0; id < 16; ++id) { - if (id == emitted_id) continue; // prior row was evicted - auto data = get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "outenvelopes"_n, id); - if (data.empty()) continue; - emitted_row_2 = msgch_abi.binary_to_variant( - "outbound_envelope", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - break; - } + // The new tip. emit#1's row is still present (unacknowledged), so select by + // recency rather than by "the other row". + fc::variant emitted_row_2 = find_outbound_envelope(); BOOST_REQUIRE(!emitted_row_2.is_null()); + BOOST_REQUIRE_NE(emitted_id, emitted_row_2["id"].as_uint64()); const auto& raw2 = emitted_row_2["raw_envelope"].as>(); BOOST_TEST_MESSAGE("emit#2 envelope size = " << raw2.size() << " bytes"); BOOST_REQUIRE_LE(raw2.size(), MAX_ENV_BYTES);