diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index d5436deb..a5a6a137 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -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, - #[serde(default, skip_serializing_if = "Option::is_none")] - details: Option>, - }, + 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 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 { - // 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()]), diff --git a/packages/cultnet-rs/src/selection.rs b/packages/cultnet-rs/src/selection.rs index a17663a0..8c681e79 100644 --- a/packages/cultnet-rs/src/selection.rs +++ b/packages/cultnet-rs/src/selection.rs @@ -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; @@ -930,69 +929,11 @@ impl From 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 { - 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 diff --git a/packages/cultnet-rs/tests/selection.rs b/packages/cultnet-rs/tests/selection.rs index c5d6cc61..5060a596 100644 --- a/packages/cultnet-rs/tests/selection.rs +++ b/packages/cultnet-rs/tests/selection.rs @@ -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