fix(simulation): PR #176 review round — all 12 findings + 2 recommendations addressed

H1 demux: ShapeProbe defensive multi-shape rejection (union frames now Err, not first-match; +2 tests) and doc claim made honest. H2/T1 SystemIndex.reset_test_state() folded into SimBridge.reset_test_state() (load() inline per autoload rule) + has_pending_request() accessor. H3 no-op tests now assert the replay flag both directions. H4 retry test actually ingests a failure and asserts the retry semantic. H5 error fixture uses the normalized status string. H6 bridge_tcp e2e sends all five frame shapes over real TCP (star-map + city-names buffers asserted). H7 positive replay-on-CONNECTED test via the test_local_bridge test-mode-flip precedent (stub bridge captures + decodes the request bytes). H8/T2 stale PLACEHOLDER doc replaced with the confirmed contract. H9 is_capital doc matches the COALESCE reality. T-r1 demux ceiling written down (next shape = tagged envelope). T-r2 AtlasLayerResponse governance ceiling comment. Lead item: the four cargo-fmt-formatted files from the gate round are now committed (layer_proxy/plugin/bridge-mod/main). H10 note for the record: the 13 snapshot_*.msgpack fixtures in commit 845737617 were regenerated because they were stale against their own generator (pre-existing version-key removal) — verified harmless, no client reads that key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 17:31:15 +02:00
co-authored by Claude Fable 5
parent 9c990a0733
commit 3304ee30da
12 changed files with 249 additions and 34 deletions
+3 -2
View File
@@ -40,8 +40,9 @@ pub struct CityRecord {
/// `atlas_city_names.kind == 'capital'` (authored, not derived from
/// population). Threaded onto [`CityPlacement`] for the Atlas
/// [`SettlementLayer`](crate::atlas::layer_proxy::SettlementLayer) (T-960 §2).
/// Defaults to `false` when the source doesn't track `kind` (e.g. the
/// believability harness's own settlement read).
/// Every current reader (`city_context_reader` and the believability
/// harness's own settlement read) selects `COALESCE(kind,'city')`, so an
/// un-authored `kind` yields `false` — there is no kind-less source left.
pub is_capital: bool,
}
+12 -2
View File
@@ -72,6 +72,11 @@ pub struct DistrictGridLayer {
/// A layer response: the computed `Layer1Output` + the coarse district grid
/// (D-225, T-1046) + the road-graph and settlement overlays (T-960 §1/§2), or
/// a non-ready status.
///
/// Growth ceiling (governance-bounded): the one-`Option`-field-per-layer
/// pattern tops out around six fields — D-226's 2026-07-13 amendment (d)
/// rules out any L5/tile Atlas layer ever, leaving T-1112 (quarter
/// footprints) and T-1113 (region climate) as the only remaining candidates.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AtlasLayerResponse {
pub body_id: String,
@@ -726,8 +731,13 @@ mod tests {
assert_eq!(decoded.body_id, "GJ1c");
let rg = decoded.road_graph.expect("road_graph survives round trip");
assert_eq!(rg.nodes[0].position, (12, 58));
assert_eq!(rg.edges[0].maintenance, MaintenanceAuthority::Administrative);
let settlements = decoded.settlements.expect("settlements survives round trip");
assert_eq!(
rg.edges[0].maintenance,
MaintenanceAuthority::Administrative
);
let settlements = decoded
.settlements
.expect("settlements survives round trip");
assert_eq!(settlements.settlements[0].name, "Port Aldren");
assert_eq!(
settlements.settlements[0].size_class,
+2 -4
View File
@@ -863,10 +863,8 @@ mod tests {
use std::sync::atomic::{AtomicU32, Ordering};
static SEQ: AtomicU32 = AtomicU32::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"sr_plugin_starmap_{}_{n}.json",
std::process::id()
));
let path =
std::env::temp_dir().join(format!("sr_plugin_starmap_{}_{n}.json", std::process::id()));
std::fs::write(&path, r#"{"_meta": {}, "nodes": [], "edges": []}"#).unwrap();
let mut world = World::new();
+100 -7
View File
@@ -57,8 +57,14 @@ pub enum BridgeError {
/// fields), the two *new* map shapes each carry a mandatory boolean
/// discriminator field the others don't have at all (`star_map` /
/// `city_names`): a missing required field is a hard deserialize failure, not
/// a silent ignore, so every shape's required-field set is mutually
/// exclusive. `AtlasLayerRequest` itself is untouched byte-for-byte.
/// a silent ignore, so no *minimal well-formed* instance of one shape
/// satisfies another — and [`decode_inbound`] additionally REJECTS union
/// frames that carry more than one shape's discriminators outright (PR #176
/// review H1). `AtlasLayerRequest` itself is untouched byte-for-byte.
///
/// **Ceiling (D-225 trajectory):** four shapes is the practical limit of this
/// hand-rolled sniffing. The next new inbound shape must migrate the channel
/// to the tagged-envelope framing D-225 deferred — do not add a fifth probe.
#[derive(Debug)]
pub enum Inbound {
/// A batch of player inputs (the gameplay path).
@@ -71,17 +77,61 @@ pub enum Inbound {
CityNamesRequest(CityNamesRequest),
}
/// Key-presence probe for the defensive multi-shape check in
/// [`decode_inbound`]: `Option<IgnoredAny>` records whether a key exists
/// without caring about its value or type, so a union frame is detected even
/// when the individual values wouldn't parse as their target types.
#[derive(serde::Deserialize)]
struct ShapeProbe {
body_id: Option<serde::de::IgnoredAny>,
up_to: Option<serde::de::IgnoredAny>,
star_map: Option<serde::de::IgnoredAny>,
city_names: Option<serde::de::IgnoredAny>,
}
/// Demux a received frame payload into an [`Inbound`] (D-225, T-949). Tries,
/// in order: `Vec<PlayerInput>` (array) → `AtlasLayerRequest` (map,
/// `body_id`+`up_to`) → `StarMapRequest` (map, `star_map` discriminator) →
/// `CityNamesRequest` (map, `city_names` discriminator + `body_id`). Every map
/// shape's required fields are mutually exclusive (see the [`Inbound`] doc),
/// so this order is for stability, not correctness — a frame that satisfies
/// none of the four shapes is a genuinely malformed input frame.
/// `CityNamesRequest` (map, `city_names` discriminator + `body_id`).
///
/// Mutual exclusivity is enforced, not assumed: no minimal well-formed
/// instance of one shape satisfies another (see the [`Inbound`] doc), and a
/// defensive pre-check rejects any map frame carrying the discriminators of
/// more than one shape — e.g. a buggy encoder emitting
/// `{"star_map": true, "city_names": true, ...}` — instead of silently
/// routing it to whichever shape is tried first (PR #176 review H1). A frame
/// satisfying none of the four shapes is a genuinely malformed input frame.
pub fn decode_inbound(payload: &[u8]) -> Result<Inbound, BridgeError> {
if let Ok(inputs) = rmp_serde::from_slice::<Vec<PlayerInput>>(payload) {
return Ok(Inbound::Inputs(inputs));
}
// Defensive multi-shape rejection: serde ignores unknown fields, so a
// union frame would otherwise route silently by try-order. Unreachable
// from the shipped client encoders (each sends one minimal shape) — this
// guards buggy or adversarial frames.
if let Ok(probe) = rmp_serde::from_slice::<ShapeProbe>(payload) {
let atlas = probe.body_id.is_some() && probe.up_to.is_some();
let star_map = probe.star_map.is_some();
let city_names = probe.city_names.is_some();
let shapes = usize::from(atlas) + usize::from(star_map) + usize::from(city_names);
if shapes > 1 {
let dump_len = payload.len().min(256);
tracing::error!(
"inbound frame matches {} request shapes at once (atlas={}, star_map={}, city_names={}) — rejecting ambiguous frame. Raw ({} of {} bytes): {:02x?}",
shapes,
atlas,
star_map,
city_names,
dump_len,
payload.len(),
&payload[..dump_len]
);
return Err(BridgeError::DeserializationWithDump(format!(
"ambiguous inbound frame matches {shapes} request shapes (payload {} bytes)",
payload.len()
)));
}
}
if let Ok(req) = rmp_serde::from_slice::<AtlasLayerRequest>(payload) {
return Ok(Inbound::AtlasRequest(req));
}
@@ -442,7 +492,10 @@ impl Plugin for BridgePlugin {
.add_systems(Update, receive_bridge_inputs.in_set(TickPhase::PreInput))
.add_systems(Update, send_bridge_snapshot.in_set(TickPhase::PostSnapshot))
.add_systems(Update, send_atlas_responses.in_set(TickPhase::PostSnapshot))
.add_systems(Update, send_star_map_responses.in_set(TickPhase::PostSnapshot))
.add_systems(
Update,
send_star_map_responses.in_set(TickPhase::PostSnapshot),
)
.add_systems(
Update,
send_city_names_responses.in_set(TickPhase::PostSnapshot),
@@ -605,4 +658,44 @@ mod inbound_tests {
// it's missing the required `up_to` field.
assert!(rmp_serde::from_slice::<AtlasLayerRequest>(&city_names_frame).is_err());
}
/// PR #176 review H1: a union frame carrying more than one shape's
/// discriminators must be REJECTED, not silently routed to whichever
/// shape `decode_inbound` happens to try first.
#[test]
fn ambiguous_union_frame_is_rejected() {
#[derive(serde::Serialize)]
struct StarAndCity {
star_map: bool,
city_names: bool,
body_id: String,
}
let frame = rmp_serde::to_vec_named(&StarAndCity {
star_map: true,
city_names: true,
body_id: "GJ1c".into(),
})
.unwrap();
assert!(
decode_inbound(&frame).is_err(),
"star_map+city_names union frame must be rejected"
);
#[derive(serde::Serialize)]
struct AtlasAndStar {
body_id: String,
up_to: CascadeLayer,
star_map: bool,
}
let frame = rmp_serde::to_vec_named(&AtlasAndStar {
body_id: "GJ1c".into(),
up_to: CascadeLayer::Topography,
star_map: true,
})
.unwrap();
assert!(
decode_inbound(&frame).is_err(),
"atlas+star_map union frame must be rejected"
);
}
}
+3 -3
View File
@@ -211,9 +211,9 @@ fn main() {
star_map_data_path
);
}
app.insert_resource(settled_reach_server::atlas::atlas_data_proxy::StarMapDataPath(
star_map_data_path,
));
app.insert_resource(
settled_reach_server::atlas::atlas_data_proxy::StarMapDataPath(star_map_data_path),
);
// Settlement reader for Layer-3 placement (#955): reads a body's settlements
// from systems.db on a cache miss so the cascade work item stays DB-free.