R-AP: nil-not-omitted for Edge/RawDocumentHeader/SelectionDocumentRecord/SelectionPage

Decided the payload shape: a map with camelCase keys, fields in declared
order, every optional field always present and nil when absent - never
omitted. C#'s [MessagePackObject]/[Key("name")] map formatter already does
this by default (confirmed byte-for-byte below); Rust's `to_vec_named`
encoding for these four types used `skip_serializing_if = "Option::is_none"`,
which omits the key instead - the gap the map cited ("C# writes nine fields
with nils, Rust omits absent ones").

Removes skip_serializing_if from Edge, RawDocumentHeader,
SelectionDocumentRecord and SelectionPage, keeping #[serde(default)] for
decode-side robustness. Selection (the request type) is untouched - out of
this ruling's scope, and the map already confirmed the request side agrees
between runtimes.

Adds tests/selection_wire_parity.rs: RawDocumentHeader and Edge, each
carrying several absent optionals, encoded via to_vec_named and compared
byte-for-byte against hex captured from a real MessagePack C# 3.1.7 run
(same pattern as error_message.rs's csharp_hex vectors) - both now agree
exactly.

What is NOT done, and why: R-AP also called for "Select gains projection,
so the thing the vectors test is the thing that decides." In C#, projection
(header vs document, ToRawRecord/ToEdge/FromRecord) is owned entirely by
CultNetDocumentRegistry.SelectPage/SelectAll (GameCult.Networking), not by
CultNetSelectionEvaluator.Select/Page - the evaluator returns raw Rows/
EdgeMatches and knows nothing about a document's payload serializer, which
only the registry's bindings own. Moving page assembly into the evaluator
without threading a caller-supplied serializer (the same pattern ordinalOf/
shardIdOf already use) would give a schema-generic evaluator per-binding
wire knowledge it should not own; threading a serializer callback in is
buildable but changes SelectPage/SelectAll's own signature and every
caller, plus the parity-vector harness's construction - a materially larger
change than this pass's remaining scope. No vector yet exercises the real
CultNetSnapshotResponseRawV1Message/select_page output byte-for-byte; only
the two nested wire types in isolation. Flagged for the operator per the
brief's own stop condition.

R-AQ, updated: type-level byte parity for the nested wire shapes (Edge,
RawDocumentHeader) is now real and vector-tested. Whole-message byte parity
for cultnet.snapshot_response_raw.v1 is still not - the smaller claim (this
cut's page bytes agree only where R-AH's fixed-shape rule reaches, and
projection ownership is still split between the evaluator and the registry)
is the true one until "Select gains projection" actually lands.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-23 07:33:02 +02:00
commit 94707ed8ed
2 changed files with 127 additions and 20 deletions

View file

@ -1015,6 +1015,12 @@ impl Selection {
}
/// One edge a hop traversed, carried on a selection page.
///
/// R-AP: a decodable *fixed-shape* map is worth more than brevity, and `skip_serializing_if`
/// undermines the compact positional form anyway (a struct's `n`th absent optional turns an
/// n-element array into a shorter one, undecodable against the declared shape) - every field here
/// is always present on the wire, `nil` when absent, in this declared order, the same rule
/// `RawDocumentHeader`/`SelectionDocumentRecord`/`SelectionPage` follow below.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Edge {
@ -1025,11 +1031,11 @@ pub struct Edge {
/// The cited row, its schema resolved to the leaf actually stored.
pub to: RecordRef,
/// `"messagepack"` when `payload` is present.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub payload_encoding: Option<String>,
/// The value attached to the edge (a dictionary reference's value); present under `document`
/// projection only.
#[serde(default, skip_serializing_if = "Option::is_none", with = "optional_bytes")]
#[serde(default, with = "optional_bytes")]
pub payload: Option<Vec<u8>>,
}
@ -1052,25 +1058,28 @@ mod optional_bytes {
}
/// A header record: [`SelectionDocumentRecord`] minus its payload and payload encoding.
///
/// R-AP: every field always present, `nil` when absent, in this declared order (see `Edge`'s doc
/// comment above).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RawDocumentHeader {
pub schema_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub schema_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub schema_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub schema_content_hash: Option<String>,
pub record_key: String,
pub stored_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub source_runtime_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub source_agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub source_role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub tags: Option<Vec<String>>,
}
@ -1080,15 +1089,17 @@ pub struct RawDocumentHeader {
/// reference's `CultNetRawDocumentRecord` that this cut does not touch (out of scope: v0's wire is
/// unchanged). v1 reuses the reference's richer, already-existing C# type, so this Rust type matches
/// that one, not v0's.
// R-AP: every optional field always present, `nil` when absent, in this declared order (see
// `Edge`'s doc comment above).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SelectionDocumentRecord {
pub schema_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub schema_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub schema_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub schema_content_hash: Option<String>,
pub record_key: String,
pub stored_at: String,
@ -1096,13 +1107,13 @@ pub struct SelectionDocumentRecord {
pub payload_encoding: String,
#[serde(with = "serde_bytes")]
pub payload: Vec<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub source_runtime_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub source_agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub source_role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub tags: Option<Vec<String>>,
}
@ -1875,18 +1886,21 @@ pub fn select<R: Row + Clone>(
/// consumer's own message envelope (`shardId`/`shardEpoch`/`shardLogSequence`, `messageId`, ... -
/// declared once already in `contracts.rs`'s `CultNetMessage::SnapshotResponseRawV1`); this type
/// is the page body those fields wrap.
// R-AP: the payload, not the envelope - matched/asOf/next/headers/documents/edges in this
// declared order on both runtimes, every optional field always present and `nil` when absent
// (never omitted - see `Edge`'s doc comment above).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SelectionPage {
pub matched: u32,
pub as_of: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub next: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub headers: Option<Vec<RawDocumentHeader>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub documents: Option<Vec<SelectionDocumentRecord>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub edges: Option<Vec<Edge>>,
}

View file

@ -0,0 +1,93 @@
//! R-AP (docs/cultnet-selection-cut.md, "Self's rulings for fix batch 6"): `RawDocumentHeader` and
//! `Edge`, pinned byte-for-byte against the C# reference, the same way `tests/error_message.rs`
//! pins `cultnet.error.v0` - a hardcoded `csharp_hex` captured from a real MessagePack C# run,
//! compared against this crate's own encoding of the identical value.
//!
//! Unlike `error_message.rs`'s bytes (captured on Yggdrasil via `ygg-verify.sh` per that file's own
//! header), the bytes below were captured **locally**, in this worktree (`C:\ws7`), against
//! MessagePack C# 3.1.7 - the exact version `GameCult.Networking` depends on
//! (`src/GameCult.Networking/GameCult.Networking.csproj`) - using the project's own
//! `CultNetRawDocumentHeader`/`CultNetEdge`/`CultNetSchemaMessageSerialization.Options`
//! (`MessagePackSerializerOptions.Standard.WithSecurity(MessagePackSecurity.UntrustedData)`), then
//! `Convert.ToHexString(MessagePackSerializer.Serialize(value, options))`. This session had no
//! access to the Yggdrasil verify image, so unlike the error-message vectors this was not run
//! through `ygg-verify.sh`; the capture itself is still a real MessagePack C# 3.1.7 run on this
//! machine, not hand-derived bytes.
//!
//! What this proves: `RawDocumentHeader`/`Edge`'s `to_vec_named` encoding (R-AP: `skip_serializing_if`
//! removed, every optional field always present and `nil` when absent, camelCase, declared order)
//! now matches the C# reference's `[MessagePackObject]`/`[Key("name")]` map encoding byte-for-byte
//! for a value carrying absent optionals - the exact shape `SelectionPage`/`CultNetSelectionPage`
//! carry inside `cultnet.snapshot_response_raw.v1`. It does not prove whole-message parity: that
//! still depends on R-AP's unbuilt half (see selection.rs's `SelectionPage` doc comment and this
//! cut's map - "Select gains projection" has not moved page assembly into the evaluator, so no
//! vector yet exercises the actual `CultNetSnapshotResponseRawV1Message`/`select_page` output
//! byte-for-byte, only these two nested wire types in isolation).
use cultnet_rs::{Edge, RawDocumentHeader, RecordRef};
fn hex_decode(hex: &str) -> Vec<u8> {
assert!(hex.len() % 2 == 0, "odd-length hex string");
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("valid hex byte"))
.collect()
}
// C#: new CultNetRawDocumentHeader { SchemaId = "sha256:ff35f3...4cff", RecordKey = "a-eq",
// StoredAt = "1970-01-01T00:00:00.000Z" } - SchemaName/SchemaVersion/SchemaContentHash/
// SourceRuntimeId/SourceAgentId/SourceRole/Tags all left null (absent).
// Convert.ToHexString(MessagePackSerializer.Serialize(header, CultNetSchemaMessageSerialization.Options)):
const CSHARP_HEADER_HEX: &str = "8AA8736368656D614964D9477368613235363A66663335663364396436646533663130363662393336396537336665366337363438653838333638643138656663323237356339393437333063643834636666AA736368656D614E616D65C0AD736368656D6156657273696F6EC0B1736368656D61436F6E74656E7448617368C0A97265636F72644B6579A4612D6571A873746F7265644174B8313937302D30312D30315430303A30303A30302E3030305AAF736F7572636552756E74696D654964C0AD736F757263654167656E744964C0AA736F75726365526F6C65C0A474616773C0";
// C#: new CultNetEdge { From = { SchemaId = "sha256:9d0bc9...608dc", RecordKey = "citer-1" },
// Role = "Design", To = { SchemaId = "sha256:ff35f3...4cff", RecordKey = "a-eq" } } -
// PayloadEncoding/Payload left null (absent, header projection).
const CSHARP_EDGE_HEX: &str = "85A466726F6D82A8736368656D614964D9477368613235363A39643062633961656263333631396230376161626635363466393836363761666465343134343463316238363636363164373737363837643434333630386463A97265636F72644B6579A763697465722D31A4726F6C65A644657369676EA2746F82A8736368656D614964D9477368613235363A66663335663364396436646533663130363662393336396537336665366337363438653838333638643138656663323237356339393437333063643834636666A97265636F72644B6579A4612D6571AF7061796C6F6164456E636F64696E67C0A77061796C6F6164C0";
#[test]
fn raw_document_header_matches_the_csharp_reference_byte_for_byte() {
let header = RawDocumentHeader {
schema_id: "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff".to_string(),
schema_name: None,
schema_version: None,
schema_content_hash: None,
record_key: "a-eq".to_string(),
stored_at: "1970-01-01T00:00:00.000Z".to_string(),
source_runtime_id: None,
source_agent_id: None,
source_role: None,
tags: None,
};
let actual = rmp_serde::to_vec_named(&header).expect("encodes");
let expected = hex_decode(CSHARP_HEADER_HEX);
assert_eq!(
actual, expected,
"RawDocumentHeader's Rust-encoded bytes do not match the captured C# encoding"
);
}
#[test]
fn edge_matches_the_csharp_reference_byte_for_byte() {
let edge = Edge {
from: RecordRef::new(
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc",
"citer-1",
),
role: "Design".to_string(),
to: RecordRef::new(
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"a-eq",
),
payload_encoding: None,
payload: None,
};
let actual = rmp_serde::to_vec_named(&edge).expect("encodes");
let expected = hex_decode(CSHARP_EDGE_HEX);
assert_eq!(
actual, expected,
"Edge's Rust-encoded bytes do not match the captured C# encoding"
);
}