Back out the guessed R-N wire shape; R-N is pending on this branch

Self's sequencing note (2026-09-22): the C# R-N pass (code/details on
cultnet.error.v0) is running separately in C:\ws3 and will report the
exact field names and code strings once it lands. Do not guess them.

Reverts the earlier Error{code,details} fields on CultNetMessage, the
SelectionRefusal::wire_code/wire_details/to_wire_message mapping, and
their five tests. R-O, R-T, R-U's hand-probes and R-R are unaffected -
none of them depend on the wire error shape. When the C# R-N commit
lands, pull it, read its exact field names and codes, and implement the
Rust mapping against those instead of a guess.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 19:35:57 +02:00
commit c606eb38f2
3 changed files with 15 additions and 190 deletions

View file

@ -1,5 +1,3 @@
use std::collections::BTreeMap;
use anyhow::Result;
use anyhow::anyhow;
use base64::Engine;
@ -293,18 +291,11 @@ pub enum CultNetMessage {
Verify { nonce: String, session: String },
#[serde(rename = "cultnet.login_success.v0", rename_all = "camelCase")]
LoginSuccess { nonce: String, session: String },
/// R-N (docs/cultnet-selection-cut.md, fix batch 3): `code` and `details` are additive over
/// the pre-cut `error` string, so a v0 peer that has never heard of them keeps working
/// unchanged. Every selection and cursor refusal, and `reference_outside_target`, is carried
/// this way in both servers and in Rust - see `SelectionRefusal::to_wire_message`.
// R-N (docs/cultnet-selection-cut.md, fix batch 3) is pending: `code`/`details` land here once
// the C# reference's exact field names and code strings are known (Self, 2026-09-22 - do not
// guess them). `Error` stays its pre-cut shape until then.
#[serde(rename = "cultnet.error.v0", rename_all = "camelCase")]
Error {
error: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
details: Option<BTreeMap<String, String>>,
},
Error { error: String },
#[serde(rename = "cultnet.sample.change_name.v0", rename_all = "camelCase")]
SampleChangeName { name: String },
#[serde(rename = "cultnet.sample.chat.v0", rename_all = "camelCase")]
@ -585,7 +576,7 @@ fn validate_message(message: &CultNetMessage) -> Result<()> {
require_non_empty(nonce, "nonce")?;
require_non_empty(session, "session")?;
}
CultNetMessage::Error { error, .. } => require_non_empty(error, "error")?,
CultNetMessage::Error { error } => require_non_empty(error, "error")?,
CultNetMessage::SampleChangeName { name } => require_non_empty(name, "name")?,
CultNetMessage::SampleChat { text } => require_non_empty(text, "text")?,
CultNetMessage::DocumentPut {
@ -1305,10 +1296,6 @@ fn parse_gamecult_networking_message(input: &rmpv::Value) -> Result<CultNetMessa
}),
4 => Ok(CultNetMessage::Error {
error: require_legacy_string(payload.first(), "ErrorMessage.Error")?,
// The legacy array wire has no slot for `code`/`details` (R-N); a v0 peer never sees
// them, on the wire or off it.
code: None,
details: None,
}),
5 => Ok(CultNetMessage::SampleChangeName {
name: require_legacy_string(payload.first(), "ChangeNameMessage.Name")?,
@ -1392,9 +1379,7 @@ fn encode_gamecult_networking_message(message: &CultNetMessage) -> Result<rmpv::
legacy_bytes(session, "LoginSuccessMessage.Session")?,
]),
],
CultNetMessage::Error { error, .. } => {
// R-N: `code`/`details` are additive on the named wire only; the legacy array wire
// has no slot for them and never carried them.
CultNetMessage::Error { error } => {
vec![
rmpv::Value::from(4),
rmpv::Value::Array(vec![error.as_str().into()]),

View file

@ -15,7 +15,7 @@
//! the comparator; it is a pure string algorithm with no `f64`/`f32` anywhere on the comparison path.
use std::cmp::Ordering;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::collections::{HashMap, HashSet};
use std::fmt;
use base64::Engine;
@ -26,7 +26,6 @@ use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use crate::CultNetMessage;
use crate::security::constant_time_eq;
type HmacSha256 = Hmac<Sha256>;
@ -930,69 +929,11 @@ impl From<SelectionInvalid> for SelectionRefusal {
}
}
// ---------------------------------------------------------------------------------------------
// R-N (docs/cultnet-selection-cut.md, fix batch 3): refusals are wire messages
// ---------------------------------------------------------------------------------------------
impl SelectionRefusal {
/// The wire `code` this refusal carries on `cultnet.error.v0` (R-N). Mirrors the C#
/// reference's mapping from `CultNetSelectionInvalidException` and the cursor/reference
/// refusals to the same string.
pub fn wire_code(&self) -> &'static str {
match self {
Self::Invalid(_) => "selection_invalid",
Self::CursorStale { .. } => "cursor_stale",
Self::CursorInvalid { .. } => "cursor_invalid",
Self::ReferenceOutsideTarget { .. } => "reference_outside_target",
}
}
/// The wire `details` this refusal carries alongside `code` (R-N): a flat string map, so the
/// shape stays additive over the pre-cut `error` string on every transport this crate speaks.
pub fn wire_details(&self) -> BTreeMap<String, String> {
let mut details = BTreeMap::new();
match self {
Self::Invalid(inner) => {
details.insert("field".to_string(), inner.field.clone());
if let Some(value) = &inner.value {
details.insert("value".to_string(), value.clone());
}
}
Self::CursorStale { as_of, current } => {
details.insert("asOf".to_string(), as_of.to_string());
details.insert("current".to_string(), current.to_string());
}
Self::CursorInvalid { message } => {
details.insert("message".to_string(), message.clone());
}
Self::ReferenceOutsideTarget {
from_schema_id,
from_key,
role,
to_schema_id,
to_key,
} => {
details.insert("fromSchemaId".to_string(), from_schema_id.clone());
details.insert("fromKey".to_string(), from_key.clone());
details.insert("role".to_string(), role.clone());
details.insert("toSchemaId".to_string(), to_schema_id.clone());
details.insert("toKey".to_string(), to_key.clone());
}
}
details
}
/// This refusal as the `cultnet.error.v0` message a peer actually receives (R-N): `error`
/// stays the human string every peer already reads, `code`/`details` are additive.
pub fn to_wire_message(&self) -> CultNetMessage {
let details = self.wire_details();
CultNetMessage::Error {
error: self.to_string(),
code: Some(self.wire_code().to_string()),
details: if details.is_empty() { None } else { Some(details) },
}
}
}
// R-N (docs/cultnet-selection-cut.md, fix batch 3) is deliberately not implemented here: the C#
// reference's exact `code` string per refusal and `details` field names are not landed on this
// branch yet, and Self's instruction (2026-09-22) is not to guess them. When they land, this is
// where a `SelectionRefusal -> CultNetMessage::Error` mapping belongs, matched byte-for-byte
// against the reference.
// ---------------------------------------------------------------------------------------------
// Row / RowSet (D7): the consumer's declarations and values, reflected over nothing

View file

@ -1458,110 +1458,9 @@ fn cursor_round_trips_a_record_key_carrying_the_pre_fix_delimiter_character() {
assert_eq!(cursor.record_key, "row-\u{241F}-with-the-old-delimiter");
}
// ------------------------------------------------------------------------------------------
// R-N: refusals are wire messages. `error` stays the human string every peer already reads;
// `code`/`details` are additive, decoded from the bytes a peer actually receives.
// ------------------------------------------------------------------------------------------
#[test]
fn selection_invalid_refusal_carries_its_code_and_field_on_the_wire() {
let refusal = SelectionRefusal::from(
validate(&Selection { schemas: Some(vec![]), ..Selection::default() }, &FixtureRowSet)
.expect_err("empty schemas is refused"),
);
let message = refusal.to_wire_message();
let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0)
.expect("encodes");
let decoded = decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0)
.expect("decodes");
match decoded {
CultNetMessage::Error { error, code, details } => {
assert!(!error.is_empty());
assert_eq!(code.as_deref(), Some("selection_invalid"));
assert_eq!(details.as_ref().and_then(|d| d.get("field")).map(String::as_str), Some("schemas"));
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn cursor_stale_refusal_carries_its_code_and_as_of_on_the_wire() {
let refusal = SelectionRefusal::CursorStale { as_of: 1, current: 2 };
let message = refusal.to_wire_message();
let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0)
.expect("encodes");
let decoded = decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0)
.expect("decodes");
match decoded {
CultNetMessage::Error { code, details, .. } => {
assert_eq!(code.as_deref(), Some("cursor_stale"));
let details = details.expect("cursor_stale carries details");
assert_eq!(details.get("asOf").map(String::as_str), Some("1"));
assert_eq!(details.get("current").map(String::as_str), Some("2"));
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn cursor_invalid_refusal_carries_its_code_on_the_wire() {
let refusal = SelectionRefusal::CursorInvalid { message: "The cursor does not decode.".into() };
let message = refusal.to_wire_message();
let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0)
.expect("encodes");
let decoded = decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0)
.expect("decodes");
match decoded {
CultNetMessage::Error { code, .. } => assert_eq!(code.as_deref(), Some("cursor_invalid")),
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn reference_outside_target_refusal_carries_its_code_and_edge_on_the_wire() {
let refusal = SelectionRefusal::ReferenceOutsideTarget {
from_schema_id: citer().to_string(),
from_key: "citer-1".to_string(),
role: "narrow_ref".to_string(),
to_schema_id: leaf_b().to_string(),
to_key: "b-hi".to_string(),
};
let message = refusal.to_wire_message();
let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0)
.expect("encodes");
let decoded = decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0)
.expect("decodes");
match decoded {
CultNetMessage::Error { code, details, .. } => {
assert_eq!(code.as_deref(), Some("reference_outside_target"));
let details = details.expect("reference_outside_target carries details");
assert_eq!(details.get("role").map(String::as_str), Some("narrow_ref"));
assert_eq!(details.get("toSchemaId").map(String::as_str), Some(leaf_b()));
assert_eq!(details.get("toKey").map(String::as_str), Some("b-hi"));
}
other => panic!("expected Error, got {other:?}"),
}
}
// A v0 peer that has never heard of `code`/`details` must still decode `error` unchanged - the
// legacy array wire has no slot for the new fields at all (R-N).
#[test]
fn error_message_stays_decodable_on_the_legacy_array_wire_without_code_or_details() {
let refusal = SelectionRefusal::CursorInvalid { message: "The cursor does not decode.".into() };
let message = refusal.to_wire_message();
let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::GameCultNetworkingV0)
.expect("encodes");
let decoded = decode_cultnet_message_from_slice(&bytes, CultNetWireContract::GameCultNetworkingV0)
.expect("decodes");
match decoded {
CultNetMessage::Error { error, code, details } => {
assert!(!error.is_empty());
assert_eq!(code, None);
assert_eq!(details, None);
}
other => panic!("expected Error, got {other:?}"),
}
}
// R-N (docs/cultnet-selection-cut.md, fix batch 3) is pending on this branch: the C# reference's
// exact `code`/`details` shape has not landed, and Self's instruction (2026-09-22) is not to guess
// it. No test here exercises it yet; add the wire-decode tests alongside the mapping once it lands.
// ------------------------------------------------------------------------------------------
// R-E: the alias matcher, exercised at the `select`/`validate` level (the module's own unit