Phase 3: guest measure operation + protobuf result + call-table boundary¶
Master plan: PLAN-measure.md · Previous phase: PLAN-measure-phase-02-allocation-scanners.md
Status: Not started¶
Mission¶
Ship every cross-boundary piece needed to actually run the measure operation inside the KVM guest, except the host CLI surface (that is phase 4). After phase 3 the system can:
- accept a
MeasureConfigwritten toOPERATION_CONFIG_ADDR, - launch a fresh
measure.binguest binary at0x20000, - have the guest read the config, scan the source via the
phase 2 scanners (or skip the scan for
--sizemode), call the phase 1 calculators, and emit aMeasureResultMessageover the serial command channel, - have the host decode that protobuf message into a
MeasureResultMessagestruct ready for phase 4 to render.
Phase 3 deliberately does not add the Commands::Measure
clap variant or run_measure() function on the host —
that is phase 4. The end-to-end functional test of measure
therefore lives in phase 7. Phase 3's verification is
compile-and-link plus binary-size cap plus the existing fuzz
harness's mock-CallTable build.
Why this is its own phase¶
This is the single phase where the call-table ABI changes
(VERSION: u32 = 13 → 14), where a new protobuf payload is
added to the GuestMessage oneof, and where a new guest
operation binary appears in the build outputs. Each of those
is a small change in isolation but each ripples through
multiple files (shared, core, vmm, guest-protocol, fuzz mock,
build scripts, binary-size check, operation cargo manifest /
linker / main). Bundling them lets one phase's review check
the whole boundary at once; splitting them would mean three
half-broken intermediate states.
Architecture¶
Boundary changes (ABI)¶
CallTable extension¶
Add one new function pointer at the end of CallTable in
src/shared/src/lib.rs, mirroring send_check_result /
send_compare_result:
/// Send measure result message.
/// Args: measure_result pointer containing required +
/// fully_allocated bytes for the target format.
pub send_measure_result: unsafe extern "C" fn(*const MeasureResult),
Bump pub const VERSION: u32 = 14;. This is breaking — every
existing operation binary built against version 13 will be
rejected by the validate_call_table! macro until rebuilt.
Acceptable because the project ships all operation binaries
together with the VMM (see build.sh).
The fuzz harness's mock CallTable in src/fuzz/src/lib.rs
gains a matching no-op stub:
New shared types¶
Add to src/shared/src/lib.rs:
/// Configuration for the measure operation.
///
/// Written to OPERATION_CONFIG_ADDR by the VMM before launching
/// the measure guest binary. The guest reads this directly via
/// `&*(OPERATION_CONFIG_ADDR as *const MeasureConfig)`.
#[repr(C)]
#[derive(Clone, Copy)]
pub struct MeasureConfig {
/// Magic number (0x4D454153 = "MEAS")
pub magic: u32,
/// Target output format (`ImageFormat as u32`). Only RAW,
/// QCOW2, VMDK, VHD, and VHDX are valid for measure.
pub target_format: u32,
/// Configuration flags (see FLAG_* constants).
pub flags: u32,
/// Padding for alignment.
pub _pad: u32,
/// Non-zero virtual size in `--size` mode (skip source scan).
/// Zero means "use the source device".
pub virtual_size_override: u64,
// qcow2 options
/// Output cluster size in bytes (qcow2). 0 = default (65536).
pub qcow2_cluster_size: u32,
/// Refcount entry width in bits (qcow2). 0 = default (16).
pub qcow2_refcount_bits: u8,
/// VMDK subformat selector (0=MonolithicSparse, 1=StreamOptimized,
/// 2=MonolithicFlat).
pub vmdk_subformat: u8,
/// Reserved padding.
pub _pad2: u16,
/// VMDK grain size in bytes. 0 = default (65536).
pub vmdk_grain_size: u32,
/// VHD subformat selector (0=Dynamic, 1=Fixed).
pub vhd_subformat: u8,
/// Reserved padding.
pub _pad3: [u8; 3],
/// VHD/VHDX block size in bytes. 0 = use format default.
pub block_size: u32,
/// LUKS-in-qcow2 header overhead in bytes (0 = no LUKS).
pub luks_header_overhead: u64,
}
impl MeasureConfig {
pub const MAGIC: u32 = 0x4D454153; // "MEAS"
/// Flag: produce qcow2 extended L2 (16-byte entries).
pub const FLAG_EXTENDED_L2: u32 = 1 << 0;
/// Flag: enable qcow2 lazy refcounts. Accepted but ignored for size.
pub const FLAG_LAZY_REFCOUNTS: u32 = 1 << 1;
/// Flag: produce qcow2 v3 (compat 1.1). Default true; clear for v2.
pub const FLAG_COMPAT_V3: u32 = 1 << 2;
/// Flag: qcow2 compressed output. Does not change required.
pub const FLAG_COMPRESS: u32 = 1 << 3;
/// Flag: preallocation mode (low 2 bits in dedicated nibble — see
/// preallocation()/set_preallocation() helpers).
pub const PREALLOC_MASK: u32 = 0b11 << 4;
pub const PREALLOC_OFF: u32 = 0 << 4;
pub const PREALLOC_METADATA: u32 = 1 << 4;
pub const PREALLOC_FALLOC: u32 = 2 << 4;
pub const PREALLOC_FULL: u32 = 3 << 4;
pub fn is_valid(&self) -> bool { self.magic == Self::MAGIC }
pub fn preallocation(&self) -> u32 { self.flags & Self::PREALLOC_MASK }
}
/// Result structure for the measure operation.
///
/// Returned via send_measure_result call table function.
#[repr(C)]
#[derive(Clone, Copy)]
pub struct MeasureResult {
/// Magic (0x4D524553 = "MRES").
pub magic: u32,
/// Target format echoed back so the host can render the right output.
pub target_format: u32,
/// Bytes required when only allocated extents are written.
pub required: u64,
/// Bytes required when every cluster/grain/block is allocated.
pub fully_allocated: u64,
/// Cluster/grain/block size actually used after resolving defaults
/// (host renders this in JSON output where qemu-img varies).
pub resolved_unit_size: u32,
/// Error code: 0 = ok, non-zero = MeasureError variant from
/// crates/measure (1=Overflow, 2=InvalidOption, 3=InvalidSize).
pub error: u32,
}
impl MeasureResult {
pub const MAGIC: u32 = 0x4D524553;
pub const ERROR_OK: u32 = 0;
pub const ERROR_OVERFLOW: u32 = 1;
pub const ERROR_INVALID_OPTION: u32 = 2;
pub const ERROR_INVALID_SIZE: u32 = 3;
pub fn is_valid(&self) -> bool { self.magic == Self::MAGIC }
pub const fn ok(target: u32, m: crate::measure::MeasureOutput) -> Self { ... }
}
(The MeasureResult::ok constructor uses MeasureOutput from
crates/measure/. Since shared cannot depend on measure
without creating the cycle phase 2 fixed, the constructor lives
in the guest binary, not shared. The struct itself stays
plain #[repr(C)] data.)
Magic value choices (verified unique against existing struct
magics in shared/src/lib.rs):
- MeasureConfig::MAGIC = 0x4D454153 = "MEAS" — does not
collide with CHEC, CMPR, CONV, COPY, INFO.
- MeasureResult::MAGIC = 0x4D524553 = "MRES" — does not
collide with CHRS, CMRS, RESU.
New protobuf message¶
In crates/guest-protocol/proto/guest.proto:
message MeasureResultMessage {
// Target format echoed back. e.g. "raw", "qcow2", "vmdk", "vhd", "vhdx".
string target_format = 1;
// Bytes required when only allocated extents are written.
uint64 required = 2;
// Bytes required when every cluster/grain/block is allocated.
uint64 fully_allocated = 3;
// Cluster / grain / block size actually used (0 if not applicable).
uint32 resolved_unit_size = 4;
// Error code: 0 = ok, non-zero values mirror MeasureResult::ERROR_*.
uint32 error = 5;
}
Extend the GuestMessage oneof:
message GuestMessage {
Level level = 1;
oneof payload {
InitMessage init = 2;
CapacityMessage capacity = 3;
ProgressMessage progress = 4;
ErrorMessage error = 5;
CompleteMessage complete = 6;
InfoResultMessage info_result = 7;
CheckResultMessage check_result = 8;
CompareResultMessage compare_result = 9;
MeasureResultMessage measure_result = 10;
}
}
Field number 10 — confirmed by reading the current proto that 9 is the last assigned tag.
Add a helper in crates/guest-protocol/src/lib.rs:
pub fn measure_result_message(
target_format: &str,
required: u64,
fully_allocated: u64,
resolved_unit_size: u32,
error: u32,
) -> guest_::GuestMessage { ... }
The proto regen is automatic — the existing build.rs handles micropb code generation.
Guest binary layout¶
src/operations/measure/ follows the same layout as
src/operations/info/:
Cargo.toml — name = "measure", deps: shared, measure,
qcow2, vmdk, vhd, vhdx, raw
linker.ld — identical to other operations (load 0x20000)
src/main.rs — entry point per the algorithm below
The Cargo.toml mirrors convert/Cargo.toml for feature flags
on qcow2 (decompress / decompress-zstd for compressed source
support — measure walks metadata only, but the same parser
crate is depended on, and clipping features at the measure
op's boundary keeps the binary lean), plus shared and the new
measure crate.
Cargo features and binary size¶
The 384 KB cap (OPERATION_MAX_SIZE = 0x60000) is enforced by
scripts/check-binary-sizes.sh. Reference: info is the
smallest and lightest operation. The measure binary should
land well under the cap because it uses the parser crates'
metadata-walking paths only — no decompression, no
encryption, no chain composition. Bring in the minimum set of
features:
[dependencies]
shared = { path = "../../shared" }
measure = { path = "../../crates/measure" }
qcow2 = { path = "../../crates/qcow2" } # no decompress, no aes
raw = { path = "../../crates/raw" }
vmdk = { path = "../../crates/vmdk" }
vhd = { path = "../../crates/vhd" }
vhdx = { path = "../../crates/vhdx" }
If the binary exceeds the cap after first build, audit which feature-gated code is being pulled in (cargo bloat is the diagnostic). Likely culprits: aes / argon2 if they accidentally leak via the qcow2 crate's default features. Confirm during step 3f.
Guest entry-point algorithm¶
#[no_mangle]
pub unsafe extern "C" fn _start() -> u64 {
let call_table = get_call_table();
validate_call_table!(call_table, "measure");
// 1. Read config.
let config = &*(OPERATION_CONFIG_ADDR as *const MeasureConfig);
if !config.is_valid() {
send_measure_error(call_table, ImageFormat::Unknown,
MeasureResult::ERROR_INVALID_OPTION);
return 0;
}
// 2. Build AllocationSummary, either from --size override or by
// scanning device 0.
let summary = if config.virtual_size_override != 0 {
// --size mode: produce both required (empty) and
// fully_allocated (full) by calling measure twice with
// different allocated_bytes. The crates/measure functions
// already return both fields from one call when
// allocated_bytes == 0, so a single call suffices: required
// is what we want for the "empty" answer, fully_allocated
// for the "full" answer. We pass allocated_bytes = 0.
AllocationSummary {
virtual_size: config.virtual_size_override,
allocated_bytes: 0,
}
} else {
// Source-image mode: detect format on device 0, run the
// matching parser's scan_allocation.
match detect_and_scan(call_table) {
Some(s) => s,
None => {
send_measure_error(call_table, ImageFormat::Unknown,
MeasureResult::ERROR_INVALID_SIZE);
return 0;
}
}
};
// 3. Compute the measure output for the target format.
let target = ImageFormat::from_u32(config.target_format);
let out = match target {
ImageFormat::Raw => measure::measure_raw(summary.virtual_size),
ImageFormat::Qcow2 => measure::measure_qcow2(&summary, &qcow2_opts_from(config)),
ImageFormat::Vmdk => measure::measure_vmdk(&summary, &vmdk_opts_from(config)),
ImageFormat::Vhd => measure::measure_vhd(&summary, &vhd_opts_from(config)),
ImageFormat::Vhdx => measure::measure_vhdx(&summary, &vhdx_opts_from(config)),
_ => Err(measure::MeasureError::InvalidOption),
};
// 4. Build and send the result.
let result = match out {
Ok(o) => MeasureResult { magic: ..., target_format, required: o.required,
fully_allocated: o.fully_allocated,
resolved_unit_size: resolved_unit_size(config, target),
error: MeasureResult::ERROR_OK },
Err(e) => MeasureResult { magic: ..., target_format, required: 0,
fully_allocated: 0, resolved_unit_size: 0,
error: map_measure_error(e) },
};
(call_table.send_measure_result)(&result);
(call_table.send_complete)(b"measure\0".as_ptr(), 0, true);
0
}
unsafe fn detect_and_scan(ct: &CallTable) -> Option<AllocationSummary> {
// Read first sector, run shared::format_detection::detect_format_from_header
// (already used by info / check / convert). Dispatch to the matching
// parser's *State::scan_allocation. Format-specific initialisation
// mirrors what info does: allocate cache buffers in scratch memory
// (positions defined by the SCRATCH_MEM_BASE layout), call the
// parser's init function, then scan_allocation.
...
}
Cache-buffer allocation in scratch memory follows the info
operation pattern — measure only needs the L1/L2 (qcow2) or
GD/GT (vmdk) or BAT (vhd/vhdx) cache buffers, not the
decompression buffers. Define a small ScratchLayout struct
local to the measure binary.
What the guest does not do in phase 3¶
- No backing-chain composition. Single-device source only. (Chain support is a phase-3 follow-up — see Open questions #1 — or deferred to phase 4 when the host CLI is wired.)
- No LUKS-encrypted source decryption. Native LUKS measurement is master-plan future work.
- No
-o cluster_size=Nor-o block_size=Nparsing on the guest side — those land already-resolved inMeasureConfig. Parsing is phase 4's job. - No snapshot extraction (
-l SNAPSHOT). Master-plan future work.
Host wiring (minimal)¶
Phase 3's host changes are limited to what is needed to handle
the new protobuf message without crashing if a stray
MeasureResult arrives, plus building the new function
pointer in the VMM-side CallTable that the core guest sees:
src/core/src/serial.rs:pub fn send_measure_result(result: &shared::MeasureResult)builds the protobuf and callssend_message(&msg).src/core/src/main.rs:ct_send_measure_result(*const MeasureResult)wrapper, wired into theCallTable { ... }literal next toct_send_compare_result.src/vmm/src/main.rs:format_messageadds an arm forSome(guest_::GuestMessage_::Payload::MeasureResult(m))that produces a debug-log string mirroring the existinginfo_result/check_resultarms. No newrun_measurefunction; no newCommands::Measurevariant; no newMeasureArgsstruct.
Build infrastructure¶
src/build.sh: add a new section for the measure operation (cargo build, rust-objcopy ELF → bin) following the copy/check/compare/convert pattern.Makefile: addmeasuretomake clean-instartarget if it enumerates operation binaries explicitly; otherwise no change.scripts/check-binary-sizes.sh: addmeasureto the loop in line 65 (for op in info copy check compare convert; dobecomesfor op in info copy check compare convert measure; do).src/Cargo.toml: addoperations/measureto the workspacememberslist, right afteroperations/convert.
Open questions¶
-
Backing-chain composition: should phase 3 ship single-device measure (no chain) and defer chain support to a phase-3 follow-up step, or build it in? Recommendation: defer to a follow-up step or to phase 4. Chain support needs at minimum a
ChainConfigwrite path on the host, guest-side iteration across devices, and a union/shadow rule for overlapping allocations. Phase 3 has enough moving parts already. The single-device path covers--sizemode and the majority of the source-image case (top of chain only). The active layer is whatqemu-img measurereports when there's no chain, and it is a sound lower bound for the chained case — match qemu-img's no-chain behaviour first, then add chain awareness. -
MeasureResult::ok()constructor location: the natural home isshared/src/lib.rs, but the constructor wants acrates::measure::MeasureOutputvalue, andsharedcan't depend onmeasure. Recommendation: the guest binary builds the result manually, no helper constructor inshared. Add animpl MeasureResultwith theMAGICandERROR_*constants only; the four-field constructor pattern lives inoperations/measure/src/main.rs. -
resolved_unit_sizesemantics: forqcow2the unit iscluster_size; forvmdkit'sgrain_size; forvhdandvhdxit'sblock_size. Forrawthere is no unit — set to 0. Confirm phase 4's JSON output formatter matches qemu-img's "info" subset; if qemu-img doesn't emit a unit size for measure (it doesn't — only required and fully-allocated), we still report it for our own JSON audit trail. Mark as instar-specific JSON extra indocs/quirks.md. -
MeasureConfigsize budget: the struct is ~64 bytes excluding padding. The config region atOPERATION_CONFIG_ADDRis 4 KiB. Plenty of room. No issue, just noting for future extensions (e.g. snapshot ID, image-opts strings). -
Sector size: every other operation reads a
sector_sizefield from its config. Measure does too — add it beforeqcow2_cluster_size. Default 65536. Reused byinitfor each parser state. (Note: this field is present in the struct above implicitly via..reserved; make it explicit during step 3a.) -
What if the source is
unknownformat? The scanner returnsNone, and the result reportsERROR_INVALID_SIZE. Phase 4 renders this as a clear error message to the user. The--sizeoverride path bypasses detection entirely. Document in the operation's doc comment. -
Subcluster-aware extended-L2 measurement on the source side: the scanner in phase 2 already handles this. No additional logic in phase 3 — the guest just calls
Qcow2State::scan_allocationwhich returns the correctallocated_bytes.
Execution¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 3a | medium | sonnet | none | Add MeasureConfig and MeasureResult to src/shared/src/lib.rs per the schema above. Include sector_size: u32 as an explicit field. Magic constants 0x4D454153 ("MEAS") and 0x4D524553 ("MRES"). impl blocks expose MAGIC, is_valid, ERROR_* constants, FLAG_* constants, PREALLOC_* constants, and a preallocation() accessor. No MeasureResult::ok constructor (kept in the guest binary; shared cannot depend on measure). Add ~4 unit tests inside shared's existing #[cfg(test)] mod tests verifying magic uniqueness, is_valid, preallocation(), and the FLAG/PREALLOC bit layout. Run make lint, make test-rust, pre-commit run --all-files. Only src/shared/src/lib.rs changes. |
| 3b | medium | sonnet | none | Add MeasureResultMessage to crates/guest-protocol/proto/guest.proto as field 10 in the GuestMessage oneof (after compare_result = 9). Fields per the schema above. Then add a pub fn measure_result_message(...) helper in crates/guest-protocol/src/lib.rs mirroring check_result_message and compare_result_message. Run make lint (the proto regen runs as part of cargo build), make test-rust. Verify the generated rust module exposes MeasureResultMessage and the oneof variant Payload::MeasureResult. Touch only crates/guest-protocol/proto/guest.proto and crates/guest-protocol/src/lib.rs. |
| 3c | high | opus | none | Add the send_measure_result function pointer to CallTable in src/shared/src/lib.rs (immediately after send_compare_result). Bump pub const VERSION: u32 = 14;. Add the matching mock_send_measure_result no-op stub to src/fuzz/src/lib.rs and include it in build_call_table(). Run make lint, make test-rust, pre-commit run --all-files. The version bump is the breaking change; everything must rebuild cleanly. High effort because: the CallTable layout is consumed by the guest via raw memory casts. Any field ordering mistake silently miscompiles. The sub-agent must read the existing CallTable { ... } literal in src/core/src/main.rs:261 and confirm field-order parity. |
| 3d | medium | sonnet | none | Wire the new function pointer through core. Add pub fn send_measure_result(result: &shared::MeasureResult) to src/core/src/serial.rs (builds the protobuf via guest_protocol::measure_result_message(...), calls send_message(&msg)). Add ct_send_measure_result(result: *const MeasureResult) wrapper to src/core/src/main.rs mirroring ct_send_check_result at line 633. Add send_measure_result: ct_send_measure_result, to the CallTable { ... } literal at line 261. Run make lint, make test-rust. Touches src/core/src/main.rs and src/core/src/serial.rs. |
| 3e | medium | sonnet | none | Extend format_message in src/vmm/src/main.rs (around line 645–680) to handle Some(guest_::GuestMessage_::Payload::MeasureResult(m)), producing a debug-log string. No CLI or render — phase 4 owns that. The main event loop already routes anything in format_message through debug!; no changes needed there. Run make instar, make lint. Touches only src/vmm/src/main.rs. |
| 3f | high | opus | none | Create the new guest binary src/operations/measure/ (Cargo.toml, linker.ld, src/main.rs) following the layout of src/operations/info/. The src/main.rs implements the algorithm described in the "Guest entry-point algorithm" section above: read MeasureConfig, build AllocationSummary (either from virtual_size_override or by detecting source format + calling the matching scan_allocation), call the matching crates/measure/ function, send result via (call_table.send_measure_result)(&result). Use the existing format_detection helpers. Allocate scratch-memory cache buffers for the parsers (L1/L2 caches for qcow2, GD/GT for vmdk, BAT for vhd/vhdx) at fixed offsets within SCRATCH_MEM_BASE; follow operations/info/src/main.rs for the pattern. No backing chain support, no LUKS decryption, no snapshot extraction — all out of scope. Cargo.toml deps minimal (no decompress / aes / argon2 features) to keep binary under the 384 KiB cap. Add operations/measure to the workspace members list in src/Cargo.toml. Run make instar, make lint, make test-rust, make check-binary-sizes. High effort because: this binary ties together every previous step — config reading, format dispatch, scanner invocation, calculator invocation, result construction, error mapping. Subtle bugs here surface as silent wrong numbers in phase 7. |
| 3g | low | sonnet | none | Update src/build.sh to include a "measure" section following the copy/check/compare/convert pattern (cargo build → rust-objcopy → measure.bin). Update scripts/check-binary-sizes.sh line 65 to add measure to the for op in info copy check compare convert; do loop. If Makefile enumerates operation binaries (search for the list explicitly), update accordingly. Run make instar, make check-binary-sizes. Confirm measure.bin lands < 384 KiB. Reports the actual size to verify margin. |
| 3h | low | sonnet | none | Update ARCHITECTURE.md to mention the new operations/measure/ binary (one paragraph mirroring the other operation entries) and the new MeasureConfig / MeasureResult structs in shared. Update CHANGELOG.md Unreleased / Added with one line citing the new operation binary, new protobuf message, and CallTable version bump (13 → 14). Mention that the CLI surface ships in phase 4. Run pre-commit run --all-files. |
Total: 8 commits.
Out of scope for phase 3¶
Commands::Measureclap variant andMeasureArgsstruct (phase 4).run_measure()host orchestration function (phase 4).-o key=value,...option parsing (phase 5).--sizevalue parsing on the host (phase 4 — uses the existingparse_memory_sizehelper).--snapshot/-l SNAPSHOT(master-plan future work).- Backing-chain composition (Open question #1; deferred).
- Native LUKS source decryption (master-plan future work).
- Cross-version baseline generation (phase 6).
- Integration tests against real testdata images (phase 7) — the new boundary is exercised end-to-end only after phase 4 ships the CLI.
- Fuzz target updates (phase 8 — the new
measure_result_msghelper is reachable but no fuzz target consumes it yet).
Success criteria¶
src/shared/src/lib.rsdefinesMeasureConfigandMeasureResult(magic, fields,is_valid, FLAGs, errors).crates/guest-protocol/proto/guest.protocarriesMeasureResultMessageas field 10 in theGuestMessageoneof.CallTable::VERSION == 14.send_measure_resultfunction pointer is the last field. Mock CallTable insrc/fuzz/has a matching stub.src/operations/measure/measure.binbuilds and lands well under 384 KiB (target: < 200 KiB).make instarbuilds the full toolchain.make lintis clean.make test-rustpasses; new tests insharedincrease total by ~4.make check-binary-sizespasses withmeasure.binlisted.pre-commit run --all-filespasses.ARCHITECTURE.mdandCHANGELOG.mdare updated.- No
Commands::Measureclap variant exists yet (phase 4).
Risks and mitigations¶
- CallTable version bump breaks the build until every
operation is rebuilt. Mitigation:
build.shbuilds every operation in one pass; if a stale binary is loaded, thevalidate_call_table!macro returns 0 with a clear log message. The risk is failure-stop, not undefined behaviour. measure.binexceeds 384 KiB. Mitigation: step 3f's brief calls out the feature-gate audit. The measure binary's code is ~600 LoC of orchestration plus the parsers' scan_allocation path only — well under the cap. If it exceeds,cargo bloat -p measure --releaseidentifies the bloat source.- Magic value collisions. Mitigation: 3a's brief checks
uniqueness via grep across
shared/src/lib.rs. The chosen values ("MEAS", "MRES") are visibly disjoint from existing ones. - Proto regen failure on micropb. Mitigation: existing
build.rshandles regen; if a sub-agent runs into an issue it is almost certainly a syntax problem in the proto file. Step 3b's brief includes amake test-rustcheckpoint to surface regen errors immediately. - Silent ABI drift between guest and host CallTable. The
CallTable is consumed via raw memory cast, so a field-order
mismatch between core's literal at line 261 and shared's
struct definition is invisible to the compiler. Mitigation:
3c's brief explicitly tells the sub-agent to diff-check the
field order between
shared::CallTableand core's literal. MeasureResult::okconstructor cycle: avoided by building the result in the guest binary, not inshared(Open question #2). The struct insharedis plain#[repr(C)]data; the guest binary owns the construction logic.
Back brief¶
Before executing any step, the executing agent should
back-brief: which file is being edited, which existing
operation is the closest template, and which parts of the
new code involve raw memory casts (MeasureConfig read,
CallTable extension). The reviewer should verify no step
bleeds into phase 4 (clap), phase 5 (-o parsing), phase 6
(baselines), or phase 7 (integration tests).