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:
parent
a312f6072c
commit
c606eb38f2
3 changed files with 15 additions and 190 deletions
|
|
@ -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()]),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue