feat(scheduler-core): define replay stream envelope schema (task 7.1)

Adds ReplayCluster/ReplayHeader/SnapshotEntry/ReplayEvent/
SessionSummary/ReplaySession to controller.capnp, additively, first
task of Phase 3 (Foundation: Replay Wire Format).

Requirements.md's AC 20 capnp sketch isn't self-contained -- ReplayEvent's
union references JobStateChangeEvent/JobUpdateEvent (task 7.2's job),
SnapshotEntry.data/ReplayDecision's union reference SnapshotData/
DispatchDecision/PendDecision/PreemptDecision (task 7.3's job), none
of which exist yet. Resolved by verifying capnp's real constraints
against the toolchain directly: SnapshotData is an empty {} placeholder
(task 7.3 replaces its mapname'd C struct wholesale when adding the
real ~11 fields), ReplayDecision holds only {ts} (task 7.3 appends a
body union at new ordinals), and ReplayEvent's union holds only the 9
members with pre-existing backing types (Conf4Sched/SchedJobData/
WorkerData/ResourceData/ResValList/QueueConf/SchedJobRusages/Void),
deferring jobStateChange/jobUpdate to task 7.2 as new ordinals --
capnp requires strictly sequential ordinals with no holes, so this
split is compiler-mandated, not stylistic.

Round-trip test added to test_e2e_capnp.c. Full project build (all
binaries sharing this schema) and 1075/1075 controller_unit_test
verified clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dw5WnN3DXStaEaPYfACU1
This commit is contained in:
Rongsong Shen 2026-08-04 09:38:13 +00:00
parent d39ffc1b4e
commit 68a6196517
Signed by: rshen
SSH key fingerprint: SHA256:8Vf2pZ3h6rJa7EhGK+WaAQ9Mta/apZvteW6g9U/WKKk
4 changed files with 407 additions and 1 deletions

View file

@ -158,10 +158,11 @@
- [ ] 7. Foundation: Replay Wire Format
- [ ] 7.1 Define the replay stream envelope schema
- [x] 7.1 Define the replay stream envelope schema
- Add the replay header, snapshot entry, replay event, replay session, and replay decision wrapper message types to the shared wire schema, following the single-stream-of-consecutive-messages framing
- Observable: the schema compiles and a minimal header-plus-snapshot-entry byte stream round-trips through encode/decode
- _Requirements: 5.20_
- _Verified: 2026-08-04 — ReplayCluster/ReplayHeader/SnapshotEntry/ReplayEvent/SessionSummary/ReplaySession added fully; SnapshotData (empty {}) and ReplayDecision ({ts only}) added as compiler-mandated placeholders task 7.3 fills in (real fields for SnapshotData wholesale-replaced, a body union appended to ReplayDecision at new ordinals) since capnp's $C.codecgen has no per-struct opt-out from requiring the mapname'd C type to exist. ReplayEvent's union deliberately holds only the 9 members with pre-existing backing types (configPush/jobsPush/workersPush/resourcesPush/resvalsFull/resvalsDelta/queueUpdate/jobRusage/endSession); jobStateChange/jobUpdate deferred to task 7.2 as new ordinals — proven compiler-mandatory (not stylistic) by review, which independently confirmed capnp rejects both the AC 20 pseudocode's literal union syntax and any ordinal gap. Round-trip test (test_e2e_capnp.c) proven real via full clean rebuild (deleted+regenerated controller.capnp.{c,h}) both by implementer and reviewer. Full project build clean (controller/apiserver-CGO/scheduler/worker/xceadmin), 1075/1075 controller_unit_test passing, review APPROVED first pass with reviewer going beyond implementer's own verification (real capn_resolve()-omission and placeholder-removal breakage reproduced firsthand). Zero existing structs/fields touched._
- _Depends: 6_
- [ ] 7.2 Define new event payload types

View file

@ -331,6 +331,220 @@ static void test_data_job_array_variant_round_trip(void)
printf("test_data_job_array_variant_round_trip: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 7: ReplayHeader + SnapshotEntry (replay stream envelope schema, */
/* scheduler-core task 7.1, Req 5 AC 20) round-trip through the wire. */
/* */
/* Built entirely through the low-level capnp accessor API (new_X / */
/* X_get_* / X_set_*, all generated by $C.fieldgetset) instead of the */
/* encode_X/decode_X "codec" bridge to a mapname'd C struct. The codec */
/* bridge is a documented no-op for Data fields in this codebase's */
/* capnpc-c setup (see decode_UpgradeConfigRequest's empty body above, */
/* and request.c's xce_replconfigpush_req_raw comment) -- encoding */
/* ReplayHeader.configHash (a Data field) through it would silently */
/* write whatever garbage sits in an uninitialized native `struct */
/* ReplayHeader` local. Going through the accessors avoids that pitfall */
/* entirely and, since no "real" application C struct exists yet for */
/* these frame-only replay-envelope types (task 9+ builds the actual */
/* reader/writer), is the correct level to prove the SCHEMA/wire format */
/* itself round-trips -- which is all task 7.1 owns. */
/* ------------------------------------------------------------------ */
static void test_replay_header_snapshot_entry_round_trip(void)
{
/* Arrange + Act (message 1: ReplayHeader) */
struct capn c1;
capn_init_malloc(&c1);
struct capn_segment *cs1 = capn_root(&c1).seg;
ReplayCluster_ptr cluster = new_ReplayCluster(cs1);
capn_text cname = { 7, "xce-lab", NULL };
ReplayCluster_set_name(cluster, cname);
capn_ptr ctrl_list = capn_new_ptr_list(cs1, 2);
capn_setp(ctrl_list, 0, capn_new_string(cs1, "host1:9991", 10));
capn_setp(ctrl_list, 1, capn_new_string(cs1, "host2:9992", 10));
ReplayCluster_set_controllers(cluster, ctrl_list);
capn_text rhost = { 5, "host1", NULL };
ReplayCluster_set_recordingHost(cluster, rhost);
uint8_t hash[32];
for (int i = 0; i < 32; i++)
hash[i] = (uint8_t)(i * 3 + 1);
capn_list8 hash_list = capn_new_list8(cs1, 32);
capn_setv8(hash_list, 0, hash, 32);
capn_data hash_data;
hash_data.p = hash_list.p;
ReplayHeader_ptr hdr = new_ReplayHeader(cs1);
capn_text rv = { 5, "1.0.0", NULL };
ReplayHeader_set_recorderVersion(hdr, rv);
ReplayHeader_set_configHash(hdr, hash_data);
ReplayHeader_set_cluster(hdr, cluster);
ReplayHeader_set_recordingStartTs(hdr, 123456789ULL);
capn_text wall = { 20, "2026-08-04T00:00:00Z", NULL };
ReplayHeader_set_recordingStartWall(hdr, wall);
capn_text policy = { 12, "builtin_fcfs", NULL };
ReplayHeader_set_schedPolicy(hdr, policy);
capn_setp(capn_root(&c1), 0, hdr.p);
int64_t sz1 = capn_size(&c1);
TEST_ASSERT_TRUE(sz1 > 0);
uint8_t *buf1 = (uint8_t *)malloc((size_t)sz1);
TEST_ASSERT_NOT_NULL(buf1);
capn_write_mem(&c1, buf1, (size_t)sz1, 0);
capn_free(&c1);
/* Arrange + Act (message 2: SnapshotEntry -- MUST be the first data
* message after ReplayHeader per Req 5 AC 20's stream framing rule;
* encoded here as an independent message buffer since the file-level
* multi-message concatenation is task 9+ reader/writer logic, not
* this schema-only task's concern). */
struct capn c2;
capn_init_malloc(&c2);
struct capn_segment *cs2 = capn_root(&c2).seg;
SnapshotData_ptr sdata = new_SnapshotData(cs2);
SnapshotEntry_ptr snap = new_SnapshotEntry(cs2);
SnapshotEntry_set_ts(snap, 99999ULL);
SnapshotEntry_set_data(snap, sdata);
capn_setp(capn_root(&c2), 0, snap.p);
int64_t sz2 = capn_size(&c2);
TEST_ASSERT_TRUE(sz2 > 0);
uint8_t *buf2 = (uint8_t *)malloc((size_t)sz2);
TEST_ASSERT_NOT_NULL(buf2);
capn_write_mem(&c2, buf2, (size_t)sz2, 0);
capn_free(&c2);
/* Act: decode both messages back */
struct capn d1;
TEST_ASSERT_EQUAL_INT(0, capn_init_mem(&d1, buf1, (size_t)sz1, 0));
ReplayHeader_ptr rhdr;
rhdr.p = capn_getp(capn_root(&d1), 0, 1);
struct capn d2;
TEST_ASSERT_EQUAL_INT(0, capn_init_mem(&d2, buf2, (size_t)sz2, 0));
SnapshotEntry_ptr rsnap;
rsnap.p = capn_getp(capn_root(&d2), 0, 1);
/* Assert: ReplayHeader fields survive field-for-field */
capn_text out_rv = ReplayHeader_get_recorderVersion(rhdr);
TEST_ASSERT_EQUAL_INT(5, out_rv.len);
TEST_ASSERT_EQUAL_STRING("1.0.0", out_rv.str);
capn_data out_hash = ReplayHeader_get_configHash(rhdr);
TEST_ASSERT_EQUAL_INT(32, capn_len((capn_list8){ out_hash.p }));
uint8_t out_hash_bytes[32];
TEST_ASSERT_EQUAL_INT(32, capn_getv8((capn_list8){ out_hash.p }, 0,
out_hash_bytes, 32));
TEST_ASSERT_EQUAL_MEMORY(hash, out_hash_bytes, 32);
TEST_ASSERT_EQUAL_UINT64(123456789ULL,
ReplayHeader_get_recordingStartTs(rhdr));
capn_text out_wall = ReplayHeader_get_recordingStartWall(rhdr);
TEST_ASSERT_EQUAL_STRING("2026-08-04T00:00:00Z", out_wall.str);
capn_text out_policy = ReplayHeader_get_schedPolicy(rhdr);
TEST_ASSERT_EQUAL_STRING("builtin_fcfs", out_policy.str);
ReplayCluster_ptr out_cluster = ReplayHeader_get_cluster(rhdr);
/* A nested-struct field getter (capn_getp(..., resolve=0)) can return an
* unresolved far pointer; per-field accessors that index raw struct
* offsets (capn_read*) do NOT auto-resolve it (unlike capn_get_text/
* capn_get_data, which resolve internally), so any nested struct
* pointer must be explicitly resolved before its own field accessors
* are used. */
capn_resolve(&out_cluster.p);
capn_text out_cname = ReplayCluster_get_name(out_cluster);
TEST_ASSERT_EQUAL_STRING("xce-lab", out_cname.str);
capn_ptr out_ctrl_list = ReplayCluster_get_controllers(out_cluster);
capn_resolve(&out_ctrl_list);
TEST_ASSERT_EQUAL_INT(2, out_ctrl_list.len);
capn_text text_default = { 0, "", NULL };
capn_text c0 = capn_get_text(out_ctrl_list, 0, text_default);
capn_text c1t = capn_get_text(out_ctrl_list, 1, text_default);
TEST_ASSERT_EQUAL_STRING("host1:9991", c0.str);
TEST_ASSERT_EQUAL_STRING("host2:9992", c1t.str);
capn_text out_rhost = ReplayCluster_get_recordingHost(out_cluster);
TEST_ASSERT_EQUAL_STRING("host1", out_rhost.str);
/* Assert: SnapshotEntry -- the required Observable of task 7.1
* (Req 5 AC 20): a minimal header-plus-snapshot-entry byte stream
* round-trips through encode/decode. */
TEST_ASSERT_EQUAL_UINT64(99999ULL, SnapshotEntry_get_ts(rsnap));
SnapshotData_ptr out_sdata = SnapshotEntry_get_data(rsnap);
TEST_ASSERT_FALSE(out_sdata.p.type == CAPN_NULL);
capn_free(&d1);
capn_free(&d2);
free(buf1);
free(buf2);
printf("test_replay_header_snapshot_entry_round_trip: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 8: SessionSummary (fully-real, all-scalar struct) round-trips. */
/* Bonus coverage beyond task 7.1's required Observable -- proves the */
/* rest of the new schema surface (ReplaySession/ReplayDecision/ */
/* ReplayEvent) actually compiles and links, not just SnapshotEntry. */
/* ------------------------------------------------------------------ */
static void test_session_summary_round_trip(void)
{
struct capn c;
capn_init_malloc(&c);
struct capn_segment *cs = capn_root(&c).seg;
SessionSummary_ptr summary = new_SessionSummary(cs);
SessionSummary_set_durationNs(summary, 5000000000ULL);
SessionSummary_set_nDecisions(summary, 42u);
SessionSummary_set_nDispatched(summary, 40u);
SessionSummary_set_nPended(summary, 2u);
ReplayDecision_list decisions = new_ReplayDecision_list(cs, 1);
set_ReplayDecision(&(struct ReplayDecision){ .ts = 111ULL }, decisions, 0);
ReplaySession_ptr sess = new_ReplaySession(cs);
ReplaySession_set_beginRound(sess, 7ULL);
ReplaySession_set_decisions(sess, decisions);
ReplaySession_set_summary(sess, summary);
capn_setp(capn_root(&c), 0, sess.p);
int64_t sz = capn_size(&c);
TEST_ASSERT_TRUE(sz > 0);
uint8_t *buf = (uint8_t *)malloc((size_t)sz);
TEST_ASSERT_NOT_NULL(buf);
capn_write_mem(&c, buf, (size_t)sz, 0);
capn_free(&c);
struct capn d;
TEST_ASSERT_EQUAL_INT(0, capn_init_mem(&d, buf, (size_t)sz, 0));
ReplaySession_ptr rsess;
rsess.p = capn_getp(capn_root(&d), 0, 1);
TEST_ASSERT_EQUAL_UINT64(7ULL, ReplaySession_get_beginRound(rsess));
SessionSummary_ptr out_summary = ReplaySession_get_summary(rsess);
/* See the matching comment in the ReplayHeader test above: a nested
* struct pointer returned from a generated X_get_Y() accessor may be an
* unresolved far pointer and must be resolved before its own per-field
* accessors (capn_read*-based) are used. */
capn_resolve(&out_summary.p);
TEST_ASSERT_EQUAL_UINT64(5000000000ULL,
SessionSummary_get_durationNs(out_summary));
TEST_ASSERT_EQUAL_UINT32(42u, SessionSummary_get_nDecisions(out_summary));
TEST_ASSERT_EQUAL_UINT32(40u, SessionSummary_get_nDispatched(out_summary));
TEST_ASSERT_EQUAL_UINT32(2u, SessionSummary_get_nPended(out_summary));
ReplayDecision_list out_decisions = ReplaySession_get_decisions(rsess);
TEST_ASSERT_EQUAL_INT(1, capn_len(out_decisions));
struct ReplayDecision out_dec;
get_ReplayDecision(&out_dec, out_decisions, 0);
TEST_ASSERT_EQUAL_UINT64(111ULL, out_dec.ts);
capn_free(&d);
free(buf);
printf("test_session_summary_round_trip: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Suite entry point */
/* ------------------------------------------------------------------ */
@ -346,4 +560,6 @@ void test_e2e_capnp(void)
RUN_TEST(test_request_vote_last_log_tuple_round_trip);
RUN_TEST(test_wal_catchup_append_term_fields_round_trip);
RUN_TEST(test_data_job_array_variant_round_trip);
RUN_TEST(test_replay_header_snapshot_entry_round_trip);
RUN_TEST(test_session_summary_round_trip);
}

View file

@ -541,4 +541,88 @@ struct ctrl_hello_reply_s {
uint64_t config_tag; /* primary's current config tag */
};
/* ── Replay stream envelope frames (scheduler-core Group 7 task 7.1,
* Req 5 AC 20) ──────────────────────────────────────────────────────────
* Frame-only wire types for the `.replay` stream envelope -- same "no live
* C struct" convention as capnp_snap_hdr_t/capnp_persist_cfg_t/
* capnp_host_admin_state_t above (see controller.capnp's comment at
* `struct ReplayCluster` for the full Group 7 task split). The C-side
* reader/writer that actually populates/consumes these (recording,
* `.replay` file I/O) is task 9+, out of this schema-only task's scope. */
typedef struct {
char *name;
char **controllers;
int n_controllers;
char *recording_host;
} replay_cluster_t;
typedef struct {
char *recorder_version;
/* configHash is a Data field -- the generated encode_/decode_ codec is
* a documented no-op for Data fields in this codebase's capnpc-c setup
* (same limitation as e.g. upgrade_config_req_s.sha256/bytes above);
* real byte extraction is task 9+ hand-written code, same raw_sha256-
* style pattern used throughout this file. This pointer/len pair exists
* only so the mapname target type is well-formed; nothing here reads it
* yet. */
uint8_t *config_hash;
size_t config_hash_len;
replay_cluster_t *cluster;
uint64_t recording_start_ts;
char *recording_start_wall;
char *sched_policy;
} replay_header_t;
/* Placeholder -- task 7.3 replaces this wholesale (not merely appends to)
* with the real 11-field snapshot struct (config/jobs/queues/workers/
* resources/resvals/users/projects/prngSeed/internalState/internalVersion,
* design.md's Wire Protocol > ReplayFormat section). The single reserved
* byte below has no capnp counterpart -- SnapshotData has zero capnp fields
* in this task, so the generated encode_SnapshotData/decode_SnapshotData
* bodies are no-ops regardless of what this C struct contains. */
typedef struct {
uint8_t _reserved;
} sched_snapshot_data_t;
typedef struct {
uint64_t ts;
sched_snapshot_data_t *data;
} replay_snapshot_entry_t;
typedef struct {
uint64_t ts;
int body_which;
union {
xce_conf4sched_t *config_push;
xce_jobs_data_t *jobs_push;
xce_workers_data_t *workers_push;
xce_resource_data_t *resources_push;
xce_resval_list_t *resvals_full;
xce_resval_list_t *resvals_delta;
xce_queue_t *queue_update;
xce_job_rusages_t *job_rusage;
/* endSession: Void, no storage */
} body;
} replay_event_t;
typedef struct {
uint64_t duration_ns;
uint32_t n_decisions;
uint32_t n_dispatched;
uint32_t n_pended;
} session_summary_t;
/* Placeholder -- task 7.3 adds the `body` union (dispatch/pend/preempt)
* here once DispatchDecision/PendDecision/PreemptDecision exist. */
typedef struct {
uint64_t ts;
} replay_decision_t;
typedef struct {
uint64_t begin_round;
replay_decision_t **decisions;
int n_decisions;
session_summary_t *summary;
} replay_session_t;
#endif

View file

@ -977,3 +977,108 @@ struct CtrlHelloReply $C.mapname("ctrl_hello_reply_t") {
needsSnapshot @1 :Bool $C.mapname("needs_snapshot");
configTag @2 :UInt64 $C.mapname("config_tag");
}
# ── Replay stream envelope schema (scheduler-core Group 7 task 7.1,
# Req 5 AC 20) ────────────────────────────────────────────────────────────
# `.replay` file format: a single stream of consecutive capnp messages --
# ReplayHeader (msg 1) -> SnapshotEntry (msg 2, MUST be the first data
# message) -> ReplayEvent/ReplaySession cycles -> periodic SnapshotEntry
# checkpoints -> final ReplayEvent{endSession}. See requirements.md Req 5
# AC 20 for the full framing prose; this schema captures only the message
# *shapes* -- the file-level reader/writer that produces/consumes the
# stream is task 9+, entirely out of Group 7's (schema-only) scope.
#
# Struct-ownership split across Group 7 tasks, so a fresh implementer on
# 7.2/7.3/7.4 knows exactly what to add and where. Every later addition is a
# NEW ordinal on a struct THIS task already created -- nothing below is ever
# renumbered or restructured by a later task:
# - task 7.1 (this task): ReplayCluster, ReplayHeader, SnapshotData
# (zero-field placeholder), SnapshotEntry, ReplayEvent (9 members, all
# reusing pre-existing structs), SessionSummary (fully real),
# ReplayDecision (ts-only stub), ReplaySession.
# - task 7.2: adds the new JobStateChangeEvent + JobUpdateEvent structs,
# then adds two new union members (jobStateChange, jobUpdate) to
# ReplayEvent's `body` union at new ordinals (10, 11) -- purely additive.
# - task 7.3: adds SnapshotData's real 11 fields (Req 5 AC 21) starting at
# ordinal @0 -- replacing this task's zero-field placeholder wholesale,
# both in this schema and in typemap.h's sched_snapshot_data_t (which
# has no capnp-visible fields yet, so nothing there is "renumbered"
# either). task 7.3 also adds DispatchDecision/PendDecision/
# PreemptVictim/PreemptDecision, then adds a `body :union { dispatch @1:
# DispatchDecision; pend @2: PendDecision; preempt @3: PreemptDecision;
# }` to ReplayDecision -- purely additive new ordinals on the ts@0
# struct this task creates (see the JobControl/JoinData `:union` group
# precedent above: a union group name never consumes its own ordinal,
# only its member variants do, so this is safe to bolt on later).
struct ReplayCluster $C.mapname("replay_cluster_t") {
name @0: Text;
controllers @1: List(Text) $C.maplistcount("n_controllers"); # ["host1:9991", "host2:9992", "host3:9993"]
recordingHost @2: Text $C.mapname("recording_host"); # controller hostname that made the recording
}
struct ReplayHeader $C.mapname("replay_header_t") {
recorderVersion @0: Text $C.mapname("recorder_version"); # scheduler binary version that wrote this file
configHash @1: Data $C.mapname("config_hash"); # sha256 of xce_conf4sched_t
cluster @2: ReplayCluster;
recordingStartTs @3: UInt64 $C.mapname("recording_start_ts"); # logical time recording started (ns)
recordingStartWall @4: Text $C.mapname("recording_start_wall"); # wall-clock time recording started (ISO 8601)
schedPolicy @5: Text $C.mapname("sched_policy"); # active picker plugin name (e.g. "builtin_fcfs")
}
# Placeholder -- task 7.3 replaces this with the full snapshot schema
# (Req 5 AC 21): config, jobs, queues, workers, resources, resvals, users,
# projects, prngSeed, internalState, internalVersion (see design.md's Wire
# Protocol > ReplayFormat section for the already-committed 11-field
# layout). Deliberately left with ZERO capnp fields here (instead of a
# placeholder scalar) so task 7.3 is free to start numbering its real
# fields at ordinal @0 exactly as design.md specifies, with no renumbering.
struct SnapshotData $C.mapname("sched_snapshot_data_t") {
}
struct SnapshotEntry $C.mapname("replay_snapshot_entry_t") {
ts @0: UInt64; # logical time of snapshot
data @1: SnapshotData; # full core state
}
# ReplayEvent.body reuses 7 pre-existing wire structs verbatim (Conf4Sched,
# SchedJobData, WorkerData, ResourceData, ResValList x2, QueueConf,
# SchedJobRusages -- verified present in controller.capnp, research.md) plus
# a Void end-of-recording marker. jobStateChange/jobUpdate (task 7.2) are
# added later as new union members at ordinals 10/11, once
# JobStateChangeEvent/JobUpdateEvent exist.
struct ReplayEvent $C.mapname("replay_event_t") {
ts @0: UInt64; # logical time
body :union $C.mapuniontag("body_which") {
configPush @1: Conf4Sched $C.mapname("config_push");
jobsPush @2: SchedJobData $C.mapname("jobs_push");
workersPush @3: WorkerData $C.mapname("workers_push");
resourcesPush @4: ResourceData $C.mapname("resources_push");
resvalsFull @5: ResValList $C.mapname("resvals_full");
resvalsDelta @6: ResValList $C.mapname("resvals_delta");
queueUpdate @7: QueueConf $C.mapname("queue_update");
jobRusage @8: SchedJobRusages $C.mapname("job_rusage");
endSession @9: Void; # recording stop marker
}
}
struct SessionSummary $C.mapname("session_summary_t") {
durationNs @0: UInt64 $C.mapname("duration_ns");
nDecisions @1: UInt32 $C.mapname("n_decisions");
nDispatched @2: UInt32 $C.mapname("n_dispatched");
nPended @3: UInt32 $C.mapname("n_pended");
}
# Placeholder -- task 7.3 adds `body :union { dispatch @1: DispatchDecision;
# pend @2: PendDecision; preempt @3: PreemptDecision; }` here once those
# payload types exist (purely additive new ordinals 1-3; ts@0 untouched --
# see the JobControl/JoinData precedent noted above).
struct ReplayDecision $C.mapname("replay_decision_t") {
ts @0: UInt64;
}
struct ReplaySession $C.mapname("replay_session_t") {
beginRound @0: UInt64 $C.mapname("begin_round"); # logical time (ts value)
decisions @1: List(ReplayDecision) $C.maplistcount("n_decisions");
summary @2: SessionSummary; # optional diagnostic metadata
}