diff --git a/packages/cultmesh-py/tests/test_cultmesh.py b/packages/cultmesh-py/tests/test_cultmesh.py index 8cb2801f..9a697370 100644 --- a/packages/cultmesh-py/tests/test_cultmesh.py +++ b/packages/cultmesh-py/tests/test_cultmesh.py @@ -4317,6 +4317,14 @@ class CultMeshTests(unittest.TestCase): {"schemaVersion": "cultnet.document_delete.v0", "messageId": "bad-delete", "schemaId": "mesh.error_note.v1"}, expected_schema_version="cultnet.error.v0", ) + # R-2, owed by Cut 1 (docs/cultnet-selection-cut.md, section 9): this v0-only Mesh server + # refuses a cultnet.selection.v1 carrier message through the same delegating path + # (_handle_connection_message -> handle_message) as any other unsupported schema, not a + # silent empty response. + unsupported_v1 = raw_client.request( + {"schemaVersion": "cultnet.snapshot_request.v1", "messageId": "bad-v1-selection"}, + expected_schema_version="cultnet.error.v0", + ) finally: server.stop() @@ -4328,6 +4336,9 @@ class CultMeshTests(unittest.TestCase): self.assertEqual(malformed_put["code"], "malformed_document_put") self.assertEqual(malformed_delete["messageId"], "bad-delete") self.assertEqual(malformed_delete["code"], "malformed_document_delete") + self.assertEqual(unsupported_v1["messageId"], "bad-v1-selection") + self.assertEqual(unsupported_v1["code"], "unsupported_schema_version") + self.assertEqual(unsupported_v1["details"]["schemaVersion"], "cultnet.snapshot_request.v1") def test_cultmesh_authority_lease_requires_live_matching_lease(self) -> None: peer = CultMeshPeerCard( diff --git a/packages/cultnet-py/src/cultnet_py/interop_peer.py b/packages/cultnet-py/src/cultnet_py/interop_peer.py index 2eaaf0ac..4ff89314 100644 --- a/packages/cultnet-py/src/cultnet_py/interop_peer.py +++ b/packages/cultnet-py/src/cultnet_py/interop_peer.py @@ -384,9 +384,25 @@ def handle_server_message(state: PeerState, message: dict[str, Any], subscriptio return [state.verse_catalog.create_response(message)] if schema_version == PEER_EXCHANGE_REQUEST: return [state.peer_catalog.create_response(message)] + if isinstance(schema_version, str) and schema_version.startswith("cultnet.") and schema_version.endswith(".v1"): + # R-2, owed by Cut 1 (docs/cultnet-selection-cut.md, section 9): this peer speaks + # cultnet.selection.v1's carrier messages (snapshot_request.v1, database_subscribe.v1) not at + # all - it is v0-only. A v1 message is refused loudly, not answered with a silent empty list. + return [unsupported_schema_version_error(message)] return [] +def unsupported_schema_version_error(message: dict[str, Any]) -> dict[str, Any]: + schema_version = message.get("schemaVersion") + return { + "schemaVersion": "cultnet.error.v0", + "messageId": str(message.get("messageId") or ""), + "error": f"Unsupported CultNet message schema: {schema_version!r}.", + "code": "unsupported_schema_version", + "details": {"schemaVersion": schema_version}, + } + + def handle_database_subscribe(state: PeerState, message: dict[str, Any], subscriptions: dict[str, DatabaseSubscription]) -> list[dict[str, Any]]: subscription_id = str(message.get("subscriptionId") or message.get("messageId") or "") if not subscription_id: diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index 4c57f42b..b95686be 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -22,7 +22,12 @@ from cultnet_py.compare_csharp import ( _median_result, _parity_status, ) -from cultnet_py.interop_peer import append_shard_log_put, build_state, raw_snapshot_response +from cultnet_py.interop_peer import ( + append_shard_log_put, + build_state, + handle_server_message, + raw_snapshot_response, +) from cultnet_py import ( compute_simulation_claim_hash, CultNetDatabaseChange, @@ -1948,6 +1953,23 @@ class CultNetTests(unittest.TestCase): self.assertEqual(response["shardLogSequence"], 1) self.assertEqual([record["recordKey"] for record in response["documents"]], ["note:logged"]) + def test_python_interop_peer_refuses_v1_selection_messages(self) -> None: + # R-2, owed by Cut 1 (docs/cultnet-selection-cut.md, section 9): a v0-only peer refuses a v1 + # message loudly - cultnet.error.v0 { code: "unsupported_schema_version" } - rather than + # silently answering with an empty list. handle_server_message never touches `state` before + # this refusal fires, so no PeerState fixture is needed here. + for schema_version in ("cultnet.snapshot_request.v1", "cultnet.database_subscribe.v1"): + with self.subTest(schema_version=schema_version): + responses = handle_server_message( + None, # type: ignore[arg-type] + {"schemaVersion": schema_version, "messageId": "v1-refused"}, + {}, + ) + self.assertEqual(len(responses), 1) + self.assertEqual(responses[0]["schemaVersion"], "cultnet.error.v0") + self.assertEqual(responses[0]["code"], "unsupported_schema_version") + self.assertEqual(responses[0]["messageId"], "v1-refused") + def test_cultnet_simulation_consensus_dedupes_witnesses_and_requires_quorum(self) -> None: from cultnet_py import CultNetSimulationConsensus