diff --git a/contracts/cultnet/cultnet.database-subscribe.v1.schema.json b/contracts/cultnet/cultnet.database-subscribe.v1.schema.json new file mode 100644 index 00000000..094e354e --- /dev/null +++ b/contracts/cultnet/cultnet.database-subscribe.v1.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.database-subscribe.v1.schema.json", + "title": "CultNet Database Subscribe Message v1", + "type": "object", + "required": ["schemaVersion", "messageId", "subscriptionId", "selection"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.database_subscribe.v1" }, + "messageId": { "type": "string", "minLength": 1 }, + "subscriptionId": { "type": "string", "minLength": 1 }, + "selection": { "$ref": "cultnet.selection.schema.json" }, + "includeSnapshot": { "type": "boolean", "default": true }, + "consumerRuntimeId": { "type": "string", "minLength": 1 }, + "bodyIds": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "supportedBodyTransports": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } +} diff --git a/contracts/cultnet/cultnet.error.schema.json b/contracts/cultnet/cultnet.error.schema.json index 08dc1e45..8653cbd5 100644 --- a/contracts/cultnet/cultnet.error.schema.json +++ b/contracts/cultnet/cultnet.error.schema.json @@ -2,14 +2,45 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.error.schema.json", "title": "CultNet Error Message", + "description": "R-X (docs/cultnet-selection-cut.md, fix batch 4): CultNetErrorMessage is a MessagePack-CSharp map-mode object, so every declared key is written on the wire, nil included - routingHint, code and details are `required` here the same as cultnet.raw-document-header.schema.json's nullable fields, not optional just because a given refusal leaves them null.", "type": "object", "required": [ "schemaVersion", - "error" + "error", + "routingHint", + "code", + "details" ], "additionalProperties": false, "properties": { "schemaVersion": { "const": "cultnet.error.v0" }, - "error": { "type": "string", "minLength": 1 } + "error": { "type": "string", "minLength": 1 }, + "routingHint": { + "description": "Optional shard routing detail for an authority failure. Null for every selection/cursor refusal code (R-N).", + "type": ["object", "null"], + "additionalProperties": false, + "required": ["shard", "reason"], + "properties": { + "shard": { "type": ["object", "null"] }, + "reason": { "type": "string" } + } + }, + "code": { + "description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it.", + "type": ["string", "null"], + "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target"] + }, + "details": { + "description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.", + "type": ["object", "null"], + "additionalProperties": false, + "required": ["field", "value", "asOf", "current"], + "properties": { + "field": { "description": "selection_invalid: the selection field that failed.", "type": ["string", "null"] }, + "value": { "description": "selection_invalid: the offending value, when there is one string worth naming.", "type": ["string", "null"] }, + "asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 }, + "current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 } + } + } } } diff --git a/contracts/cultnet/cultnet.raw-document-header.schema.json b/contracts/cultnet/cultnet.raw-document-header.schema.json new file mode 100644 index 00000000..c85023c9 --- /dev/null +++ b/contracts/cultnet/cultnet.raw-document-header.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.raw-document-header.schema.json", + "title": "CultNet Raw Document Header", + "description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): CultNetRawDocumentRecord minus payload/payloadEncoding - CultNetRawDocumentHeader has no such members at all, so a header must not $ref the record schema (which requires them). Matches MessagePack-CSharp's map-mode output: every declared key is written, nil included, so every property here is `required` even where its value may be null.", + "type": "object", + "required": [ + "schemaId", + "schemaName", + "schemaVersion", + "schemaContentHash", + "recordKey", + "storedAt", + "sourceRuntimeId", + "sourceAgentId", + "sourceRole", + "tags" + ], + "additionalProperties": false, + "properties": { + "schemaId": { "type": "string", "minLength": 1 }, + "schemaName": { "type": ["string", "null"], "minLength": 1 }, + "schemaVersion": { "type": ["string", "null"], "minLength": 1 }, + "schemaContentHash": { "type": ["string", "null"], "minLength": 1 }, + "recordKey": { "type": "string", "minLength": 1 }, + "storedAt": { "type": "string", "minLength": 1 }, + "sourceRuntimeId": { "type": ["string", "null"], "minLength": 1 }, + "sourceAgentId": { "type": ["string", "null"], "minLength": 1 }, + "sourceRole": { "type": ["string", "null"], "minLength": 1 }, + "tags": { + "type": ["array", "null"], + "items": { "type": "string", "minLength": 1 } + } + } +} diff --git a/contracts/cultnet/cultnet.raw-document-record.schema.json b/contracts/cultnet/cultnet.raw-document-record.schema.json index 2892a717..445cf8f1 100644 --- a/contracts/cultnet/cultnet.raw-document-record.schema.json +++ b/contracts/cultnet/cultnet.raw-document-record.schema.json @@ -2,17 +2,28 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.raw-document-record.schema.json", "title": "CultNet Raw Document Record", + "description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): matches MessagePack-CSharp's map-mode output for CultNetRawDocumentRecord - every declared key is written, nil included, so every property here is `required` (present on the wire) even where its value may be null.", "type": "object", "required": [ "schemaId", + "schemaName", + "schemaVersion", + "schemaContentHash", "recordKey", "storedAt", "payloadEncoding", - "payload" + "payload", + "sourceRuntimeId", + "sourceAgentId", + "sourceRole", + "tags" ], "additionalProperties": false, "properties": { "schemaId": { "type": "string", "minLength": 1 }, + "schemaName": { "type": ["string", "null"], "minLength": 1 }, + "schemaVersion": { "type": ["string", "null"], "minLength": 1 }, + "schemaContentHash": { "type": ["string", "null"], "minLength": 1 }, "recordKey": { "type": "string", "minLength": 1 }, "storedAt": { "type": "string", "minLength": 1 }, "payloadEncoding": { "const": "messagepack" }, @@ -21,11 +32,11 @@ "contentEncoding": "base64", "x-cult-runtime-type": "bytes" }, - "sourceRuntimeId": { "type": "string", "minLength": 1 }, - "sourceAgentId": { "type": "string", "minLength": 1 }, - "sourceRole": { "type": "string", "minLength": 1 }, + "sourceRuntimeId": { "type": ["string", "null"], "minLength": 1 }, + "sourceAgentId": { "type": ["string", "null"], "minLength": 1 }, + "sourceRole": { "type": ["string", "null"], "minLength": 1 }, "tags": { - "type": "array", + "type": ["array", "null"], "items": { "type": "string", "minLength": 1 } } } diff --git a/contracts/cultnet/cultnet.selection.schema.json b/contracts/cultnet/cultnet.selection.schema.json new file mode 100644 index 00000000..f082e891 --- /dev/null +++ b/contracts/cultnet/cultnet.selection.schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.selection.schema.json", + "title": "CultNet Typed Selection", + "type": "object", + "additionalProperties": false, + "properties": { + "schemas": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "description": "Absent means every schema. Present and empty is refused at the door (selection_invalid), never treated as \"every schema\": omit the field instead." + }, + "keys": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "description": "Absent means every key. Present and empty is refused at the door (selection_invalid), never treated as \"every key\": omit the field instead." + }, + "fields": { + "type": "array", + "items": { "$ref": "#/$defs/fieldPredicate" } + }, + "cites": { "$ref": "#/$defs/citation" }, + "cited": { "$ref": "#/$defs/incoming" }, + "projection": { + "type": "string", + "enum": ["header", "document"], + "default": "header" + }, + "descending": { "type": "boolean", "default": false }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "description": "Clamped to 1..=200 by the answering server, not refused outside that range: an absent value or one outside the bound is treated as the nearer bound (absent = 200)." + }, + "cursor": { "type": "string" } + }, + "$defs": { + "recordRef": { + "type": "object", + "required": ["schemaId", "recordKey"], + "additionalProperties": false, + "properties": { + "schemaId": { "type": "string", "minLength": 1 }, + "recordKey": { "type": "string", "minLength": 1 } + } + }, + "fieldPredicate": { + "type": "object", + "required": ["index", "op"], + "additionalProperties": false, + "properties": { + "index": { "type": "string", "minLength": 1 }, + "op": { "type": "string", "enum": ["any_of", "lt", "le", "ge", "gt"] }, + "values": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "number": { + "type": "string", + "description": "Q-J: a canonical decimal string, never a JSON number. Matches ^-?(0|[1-9][0-9]*)(\\.[0-9]*[1-9])?$ and is not \"-0\"; any other spelling is refused typed at the door (selection_invalid), never normalised.", + "pattern": "^(?!-0$)-?(0|[1-9][0-9]*)(\\.[0-9]*[1-9])?$" + } + }, + "oneOf": [ + { + "required": ["values"], + "not": { "required": ["number"] }, + "properties": { "op": { "const": "any_of" } } + }, + { + "required": ["number"], + "not": { "required": ["values"] }, + "properties": { "op": { "enum": ["lt", "le", "ge", "gt"] } } + } + ] + }, + "citation": { + "type": "object", + "required": ["target"], + "additionalProperties": false, + "properties": { + "target": { "$ref": "#/$defs/recordRef" }, + "role": { "type": "string", "minLength": 1 } + } + }, + "incoming": { + "type": "object", + "required": ["role", "exists"], + "additionalProperties": false, + "properties": { + "role": { "type": "string", "minLength": 1 }, + "exists": { "type": "boolean" } + } + } + } +} diff --git a/contracts/cultnet/cultnet.snapshot-request.v1.schema.json b/contracts/cultnet/cultnet.snapshot-request.v1.schema.json new file mode 100644 index 00000000..8617b2df --- /dev/null +++ b/contracts/cultnet/cultnet.snapshot-request.v1.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-request.v1.schema.json", + "title": "CultNet Snapshot Request Message v1", + "type": "object", + "required": ["schemaVersion", "messageId", "selection"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.snapshot_request.v1" }, + "messageId": { "type": "string", "minLength": 1 }, + "selection": { "$ref": "cultnet.selection.schema.json" }, + "shardId": { "type": "string", "minLength": 1 }, + "shardEpoch": { "type": "integer" } + } +} diff --git a/contracts/cultnet/cultnet.snapshot-response-raw.v1.schema.json b/contracts/cultnet/cultnet.snapshot-response-raw.v1.schema.json new file mode 100644 index 00000000..63b54405 --- /dev/null +++ b/contracts/cultnet/cultnet.snapshot-response-raw.v1.schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-response-raw.v1.schema.json", + "title": "CultNet Snapshot Response Raw Message v1", + "description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): matches MessagePack-CSharp's map-mode output for CultNetSnapshotResponseRawV1Message - every declared key is written, nil included, so every property is `required` (present on the wire) even where its value may be null. `headers`/`documents` are still exactly one populated by projection (C5), but the OTHER key is present too, as an explicit null, not absent - the prior `oneOf: [{required:[headers]},{required:[documents]}]` matched both branches on every real page (both keys always present) and so never validated at all.", + "type": "object", + "required": [ + "schemaVersion", "messageId", "matched", "asOf", "next", + "headers", "documents", "edges", "shardId", "shardEpoch", "shardLogSequence" + ], + "additionalProperties": false, + "oneOf": [ + { + "required": ["headers", "documents"], + "properties": { "headers": { "type": "array" }, "documents": { "type": "null" } } + }, + { + "required": ["headers", "documents"], + "properties": { "headers": { "type": "null" }, "documents": { "type": "array" } } + } + ], + "properties": { + "schemaVersion": { "const": "cultnet.snapshot_response_raw.v1" }, + "messageId": { "type": "string", "minLength": 1 }, + "matched": { "type": "integer", "minimum": 0 }, + "asOf": { "type": "integer", "minimum": 0 }, + "next": { "type": ["string", "null"] }, + "headers": { + "type": ["array", "null"], + "items": { "$ref": "cultnet.raw-document-header.schema.json" } + }, + "documents": { + "type": ["array", "null"], + "items": { "$ref": "cultnet.raw-document-record.schema.json" } + }, + "edges": { + "type": ["array", "null"], + "items": { + "type": "object", + "required": ["from", "role", "to", "payloadEncoding", "payload"], + "additionalProperties": false, + "properties": { + "from": { "$ref": "cultnet.selection.schema.json#/$defs/recordRef" }, + "role": { "type": "string", "minLength": 1 }, + "to": { "$ref": "cultnet.selection.schema.json#/$defs/recordRef" }, + "payloadEncoding": { "type": ["string", "null"] }, + "payload": { + "type": ["string", "null"], + "contentEncoding": "base64", + "x-cult-runtime-type": "bytes" + } + } + } + }, + "shardId": { "type": ["string", "null"], "minLength": 1 }, + "shardEpoch": { "type": ["integer", "null"] }, + "shardLogSequence": { "type": ["integer", "null"] } + } +} diff --git a/contracts/cultnet/interop/selection-vectors.cs-written.json b/contracts/cultnet/interop/selection-vectors.cs-written.json new file mode 100644 index 00000000..8c58a174 --- /dev/null +++ b/contracts/cultnet/interop/selection-vectors.cs-written.json @@ -0,0 +1,334 @@ +{ + "vectors": [ + { + "name": "any_of_plus_ge_conjunction", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHOShKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpndlYXBvbqZudW1iZXLAhKVpbmRleKRtYXNzom9womdlpnZhbHVlc8CmbnVtYmVyoTWlY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==", + "asOf": 1, + "expectedRefusalField": null, + "expectedEvaluationRefusal": null, + "expectedIds": [ + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq" + ], + "matched": 1, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "descending_with_limit", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHPApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8OlbGltaXQDpmN1cnNvcsA=", + "asOf": 1, + "expectedRefusalField": null, + "expectedEvaluationRefusal": null, + "expectedIds": [ + "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema", + "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/zz-tie-schema" + ], + "matched": 18, + "hasNext": true, + "expectedEdges": [] + }, + { + "name": "gt_numeric_boundary", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRtYXNzom9womd0pnZhbHVlc8CmbnVtYmVyoTGlY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==", + "asOf": 1, + "expectedRefusalField": null, + "expectedEvaluationRefusal": null, + "expectedIds": [ + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq", + "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/b-hi", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass" + ], + "matched": 4, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "incoming_exists_true", + "selectionMessagePackBase64": "iadzY2hlbWFzkdlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaka2V5c8CmZmllbGRzwKVjaXRlc8ClY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8OqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=", + "asOf": 1, + "expectedRefusalField": null, + "expectedEvaluationRefusal": null, + "expectedIds": [ + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key" + ], + "matched": 2, + "hasNext": false, + "expectedEdges": [ + { + "fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1", + "role": "Design", + "toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq", + "payloadBase64": null + }, + { + "fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target", + "role": "Design", + "toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key", + "payloadBase64": null + } + ] + }, + { + "name": "incoming_negation", + "selectionMessagePackBase64": "iadzY2hlbWFzkdlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaka2V5c8CmZmllbGRzwKVjaXRlc8ClY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=", + "asOf": 1, + "expectedRefusalField": null, + "expectedEvaluationRefusal": null, + "expectedIds": [ + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema" + ], + "matched": 7, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "shared_index_value_three_rows", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpnNoYXJlZKZudW1iZXLApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=", + "asOf": 1, + "expectedRefusalField": null, + "expectedEvaluationRefusal": null, + "expectedIds": [ + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1", + "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/shared-2", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3" + ], + "matched": 3, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "float_tie_exact_ge", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRtYXNzom9womdlpnZhbHVlc8CmbnVtYmVyqjIyMzM3NTkuMjWlY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==", + "asOf": 1, + "expectedRefusalField": null, + "expectedEvaluationRefusal": null, + "expectedIds": [ + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass" + ], + "matched": 1, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "hop_by_role_cites_exact_id", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkadjaXRlci0xpmZpZWxkc8ClY2l0ZXOCpnRhcmdldIKoc2NoZW1hSWTZR3NoYTI1NjpmZjM1ZjNkOWQ2ZGUzZjEwNjZiOTM2OWU3M2ZlNmM3NjQ4ZTg4MzY4ZDE4ZWZjMjI3NWM5OTQ3MzBjZDg0Y2ZmqXJlY29yZEtleaRhLWVxpHJvbGWmRGVzaWdupWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==", + "asOf": 1, + "expectedRefusalField": null, + "expectedEvaluationRefusal": null, + "expectedIds": [ + "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1" + ], + "matched": 1, + "hasNext": false, + "expectedEdges": [ + { + "fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1", + "role": "Design", + "toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq", + "payloadBase64": null + } + ] + }, + { + "name": "cites_target_by_name_alias", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkadjaXRlci0xpmZpZWxkc8ClY2l0ZXOCpnRhcmdldIKoc2NoZW1hSWSpbGVhZl9hLnY5qXJlY29yZEtleaRhLWVxpHJvbGWmRGVzaWdupWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==", + "asOf": 1, + "expectedRefusalField": null, + "expectedEvaluationRefusal": null, + "expectedIds": [ + "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1" + ], + "matched": 1, + "hasNext": false, + "expectedEdges": [ + { + "fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1", + "role": "Design", + "toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq", + "payloadBase64": null + } + ] + }, + { + "name": "schemas_by_name_alias", + "selectionMessagePackBase64": "iadzY2hlbWFzkalsZWFmX2Eudjmka2V5c8CmZmllbGRzwKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA", + "asOf": 1, + "expectedRefusalField": null, + "expectedEvaluationRefusal": null, + "expectedIds": [ + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema" + ], + "matched": 9, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "astral_key_code_point_order", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORp3VuaWNvZGWmbnVtYmVywKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA", + "asOf": 1, + "expectedRefusalField": null, + "expectedEvaluationRefusal": null, + "expectedIds": [ + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row" + ], + "matched": 2, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "header_projection", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORp3VuaWNvZGWmbnVtYmVywKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA", + "asOf": 1, + "expectedRefusalField": null, + "expectedEvaluationRefusal": null, + "expectedIds": [ + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row" + ], + "matched": 2, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "document_projection", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORp3VuaWNvZGWmbnVtYmVywKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26oZG9jdW1lbnSqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=", + "asOf": 1, + "expectedRefusalField": null, + "expectedEvaluationRefusal": null, + "expectedIds": [ + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row" + ], + "matched": 2, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "row_tiebreak_schema_id_before_record_key_at_tied_ordinal", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORqnRpZS1zY2hlbWGmbnVtYmVywKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA", + "asOf": 1, + "expectedRefusalField": null, + "expectedEvaluationRefusal": null, + "expectedIds": [ + "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/zz-tie-schema", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema" + ], + "matched": 2, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "shared_key_narrow_ref_resolves_leaf_within_target", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkbVuYXJyb3ctY2l0ZXItYWNjZXB0ZWSmZmllbGRzwKVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5p2R1cC1rZXmkcm9sZapuYXJyb3dfcmVmpWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==", + "asOf": 1, + "expectedRefusalField": null, + "expectedEvaluationRefusal": null, + "expectedIds": [ + "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d/narrow-citer-accepted" + ], + "matched": 1, + "hasNext": false, + "expectedEdges": [ + { + "fromId": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d/narrow-citer-accepted", + "role": "narrow_ref", + "toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key", + "payloadBase64": null + } + ] + }, + { + "name": "shared_key_design_resolves_the_first_in_target_candidate_in_row_order", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkbRjaXRlci1ib3RoLWluLXRhcmdldKZmaWVsZHPApWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmnZHVwLWtleaRyb2xlpkRlc2lnbqVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=", + "asOf": 1, + "expectedRefusalField": null, + "expectedEvaluationRefusal": null, + "expectedIds": [ + "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target" + ], + "matched": 1, + "hasNext": false, + "expectedEdges": [ + { + "fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target", + "role": "Design", + "toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key", + "payloadBase64": null + } + ] + }, + { + "name": "refuses_empty_schemas_list", + "selectionMessagePackBase64": "iadzY2hlbWFzkKRrZXlzwKZmaWVsZHPApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=", + "asOf": 1, + "expectedRefusalField": "schemas", + "expectedEvaluationRefusal": null, + "expectedIds": [], + "matched": 0, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "refuses_blank_key_entry", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkqRhLWVxoyAgIKZmaWVsZHPApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=", + "asOf": 1, + "expectedRefusalField": "keys", + "expectedEvaluationRefusal": null, + "expectedIds": [], + "matched": 0, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "refuses_unmatched_cites_target_schema", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHPApWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlkvHNoYTI1Njpub3QtYS1kZWNsYXJlZC1zY2hlbWGpcmVjb3JkS2V5pGEtZXGkcm9sZcClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA", + "asOf": 1, + "expectedRefusalField": "cites.target.schemaId", + "expectedEvaluationRefusal": null, + "expectedIds": [], + "matched": 0, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "refuses_cites_target_by_hash_shaped_alias", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHPApWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UpzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZi52MalyZWNvcmRLZXmkYS1lcaRyb2xlwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=", + "asOf": 1, + "expectedRefusalField": "cites.target.schemaId", + "expectedEvaluationRefusal": null, + "expectedIds": [], + "matched": 0, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "shared_key_narrow_ref_refuses_when_only_candidate_is_outside_target", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkbRuYXJyb3ctY2l0ZXItcmVmdXNlZKZmaWVsZHPApWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmrbmFycm93LW9ubHmkcm9sZapuYXJyb3dfcmVmpWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==", + "asOf": 1, + "expectedRefusalField": null, + "expectedEvaluationRefusal": "reference_outside_target", + "expectedIds": [], + "matched": 0, + "hasNext": false, + "expectedEdges": [] + } + ] +} \ No newline at end of file diff --git a/contracts/cultnet/interop/selection-vectors.fixture.json b/contracts/cultnet/interop/selection-vectors.fixture.json new file mode 100644 index 00000000..7e94fd7b --- /dev/null +++ b/contracts/cultnet/interop/selection-vectors.fixture.json @@ -0,0 +1,105 @@ +{ + "//": "CultNet typed selection, Cut 1 fix batch (docs/cultnet-selection-cut.md, Self's rulings for the Cut 1 fix batch, R-E and the shared-fixture ruling, 2026-09-22). The one row set both the C# reference and cultnet-rs build their parity vectors from. schemaId is each schema's real SHA-256 content-hash id, exactly as GameCult.Caching.CultDocumentRegistry computes it for the fixture's C# document types (leaf_a=ParityLeafA, leaf_b=ParityLeafB, citer=ParityCiter, citer_narrow=ParityCiterNarrow, tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs) - CultCache.cs's Sha256(semanticFingerprint), which carries no '.vN' suffix of its own; the version is folded into the hash, never appended as text. Both writers read this file instead of building their own fixture rows.", + "//aliases": "Two DIFFERENT alias forms, now deliberate rather than a cross-runtime defect (S-12, 2026-09-22: the Rust alias port made this so - see docs/cultnet-selection-cut.md, 'Self's ruling, 2026-09-22: the C# reference's alias rule is the rule'). Both runtimes resolve a schema alias the same one way: strip a trailing .vN and compare the remainder against the descriptor's declared SchemaName (CultNetSchemaAliasMatching.Matches(candidate, CultDocumentDescriptor), src/GameCult.Networking/CultNetDatabase.cs:74-87; cultnet_rs::selection::schema_alias::matches ports it name-for-name and deletes the hash-stripping overload it used to carry). nameAlias is .v9 and resolves through both runtimes under that one rule. hashAlias is .v1 and is refused as unmatched by both - it is not a wire form either runtime's production call sites ever produce or accept, and it is kept in the fixture specifically to pin that neither runtime resolves it.", + "//references": "R-AF, 2026-09-23: a reference's `references[].targetKey` is the only thing a stored edge actually carries on the wire - GameCult.Caching.MessagePack.CultRecordRefFormatter serializes a CultRecordRef as a bare key string (probed: MessagePackSerializer.Serialize on a real Design field produced the single msgpack string 'a-eq', nothing else). This fixture used to also carry a `targetSchema` per reference; it was never anything but documentation - SelectionParityVectorTests.BuildDocument and cultnet-rs's build_row both read only targetKey to construct the edge - and it is removed rather than kept as a field the wire does not have. Resolving which row an edge names is by targetKey alone, filtered to the declared role's target leaves (D9); see citer_narrow below for the shared-key shape that rule is pinned against.", + "schemas": { + "leaf_a": { + "schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff", + "nameAlias": "leaf_a.v9", + "hashAlias": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff.v1", + "indexes": [ + { "name": "kind", "numeric": false }, + { "name": "mass", "numeric": true } + ] + }, + "leaf_b": { + "schemaId": "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42", + "nameAlias": "leaf_b.v9", + "hashAlias": "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42.v1", + "indexes": [ + { "name": "kind", "numeric": false }, + { "name": "mass", "numeric": true } + ] + }, + "citer": { + "schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc", + "nameAlias": "citer.v9", + "hashAlias": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc.v1", + "roles": [ + { "name": "Design", "targets": ["leaf_a", "leaf_b"] } + ] + }, + "citer_narrow": { + "//": "R-AF: a citer whose declared reference target is one leaf only (leaf_a), so a record key shared by a leaf_a row and a leaf_b row has exactly one candidate inside the declared target and exactly one outside it - the shape neither runtime's parity vectors covered before this cut (zero duplicate record keys across the original ten rows).", + "schemaId": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d", + "nameAlias": "citer_narrow.v9", + "hashAlias": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d.v1", + "roles": [ + { "name": "narrow_ref", "targets": ["leaf_a"] } + ] + } + }, + "rows": [ + { "schema": "leaf_a", "key": "a-lo", "ordinal": 1, "fields": { "kind": "weapon", "mass": "4" } }, + { "schema": "leaf_a", "key": "a-eq", "ordinal": 2, "fields": { "kind": "weapon", "mass": "5" } }, + { "schema": "leaf_b", "key": "b-hi", "ordinal": 3, "fields": { "kind": "armor", "mass": "6" } }, + { "schema": "leaf_a", "key": "shared-1", "ordinal": 4, "fields": { "kind": "shared", "mass": "1" } }, + { "schema": "leaf_b", "key": "shared-2", "ordinal": 5, "fields": { "kind": "shared", "mass": "1" } }, + { "schema": "leaf_a", "key": "shared-3", "ordinal": 6, "fields": { "kind": "shared", "mass": "1" } }, + { + "schema": "citer", + "key": "citer-1", + "ordinal": 7, + "references": [ { "role": "Design", "targetKey": "a-eq" } ] + }, + { + "//": "R-D: 2233759.25 is exactly representable as an f32 and sits on a round-to-even tie between two shortest-form spellings; its exact canonical decimal is 2233759.25 either way. kind=tie so no weapon/armor/shared case can sweep it up.", + "schema": "leaf_a", "key": "tie-mass", "ordinal": 8, "fields": { "kind": "tie", "mass": "2233759.25" } + }, + { + "//": "R-C: BMP private-use key (U+E000), same ordinal as the astral row below so the tiebreak - not the ordinal - decides order. kind=unicode, mass=0 so no unfiltered or mass>1 case reaches these.", + "schema": "leaf_a", "key": "-row", "ordinal": 9, "fields": { "kind": "unicode", "mass": "0" } + }, + { + "//": "R-C: astral key (U+1F602, encoded as a surrogate pair whose high half 0xD83D is less than 0xE000 - a UTF-16 code-unit compare sorts this first; code-point order sorts it after the BMP row above).", + "schema": "leaf_a", "key": "😂-row", "ordinal": 9, "fields": { "kind": "unicode", "mass": "0" } + }, + { + "//": "R-AF shape 1: the only row at this key is outside citer_narrow's declared target (leaf_a only) - resolving the edge below must refuse reference_outside_target in both runtimes, not silently accept an empty page. kind=narrow-fixture (not \"shared\") so this row never sweeps into shared_index_value_three_rows, which pins an exact count of 3.", + "schema": "leaf_b", "key": "narrow-only", "ordinal": 10, "fields": { "kind": "narrow-fixture", "mass": "1" } + }, + { + "//": "R-AF shape 2: two rows share this key, one inside citer_narrow's declared target and one outside it - resolving the edge below must accept, deterministically picking the leaf_a row, in both runtimes.", + "schema": "leaf_a", "key": "dup-key", "ordinal": 11, "fields": { "kind": "narrow-fixture", "mass": "1" } + }, + { + "schema": "leaf_b", "key": "dup-key", "ordinal": 12, "fields": { "kind": "narrow-fixture", "mass": "1" } + }, + { + "schema": "citer_narrow", + "key": "narrow-citer-refused", + "ordinal": 13, + "references": [ { "role": "narrow_ref", "targetKey": "narrow-only" } ] + }, + { + "schema": "citer_narrow", + "key": "narrow-citer-accepted", + "ordinal": 14, + "references": [ { "role": "narrow_ref", "targetKey": "dup-key" } ] + }, + { + "//": "R-AJ: a tied ordinal across two DIFFERENT schemas (leaf_a and leaf_b), not two rows of the same leaf like the astral/BMP pair above - the shape the row order's schemaId tiebreak needs and the original ten rows never had. Keys are anti-correlated with the real schema ids on purpose: leaf_b's real id sorts before leaf_a's (sha256:b1f2... < sha256:ff35...), so leaf_b gets the alphabetically-*later* key (\"zz-tie-schema\") and leaf_a gets the *earlier* one (\"aa-tie-schema\") - a schemaId-first order and a recordKey-first order disagree here on purpose, so only the correct composition produces leaf_b's row first.", + "schema": "leaf_b", "key": "zz-tie-schema", "ordinal": 15, "fields": { "kind": "tie-schema", "mass": "1" } + }, + { + "schema": "leaf_a", "key": "aa-tie-schema", "ordinal": 15, "fields": { "kind": "tie-schema", "mass": "1" } + }, + { + "//": "R-AO, 2026-09-23: 'dup-key' already carries two rows (leaf_a ordinal 11, leaf_b ordinal 12) - both are in citer's Design role's declared target ([leaf_a, leaf_b], unlike citer_narrow's narrow_ref above, which only ever had one in-target candidate at a shared key). This citer is the first row in this file (hence in both runtimes' row order) to name 'dup-key' through Design, so resolving it must deterministically pick the leaf_a row - the first in-target candidate in the caller's order - never the leaf_b row, in both runtimes.", + "schema": "citer", + "key": "citer-both-in-target", + "ordinal": 16, + "references": [ { "role": "Design", "targetKey": "dup-key" } ] + } + ] +} diff --git a/contracts/cultnet/interop/selection-vectors.rs-written.json b/contracts/cultnet/interop/selection-vectors.rs-written.json new file mode 100644 index 00000000..a9b635c4 --- /dev/null +++ b/contracts/cultnet/interop/selection-vectors.rs-written.json @@ -0,0 +1,282 @@ +{ + "vectors": [ + { + "name": "any_of_plus_ge_conjunction", + "selectionMessagePackBase64": "g6ZmaWVsZHOSg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpndlYXBvboOlaW5kZXikbWFzc6JvcKJnZaZudW1iZXKhNapwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==", + "asOf": 1, + "expectedIds": [ + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq" + ], + "matched": 1, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "descending_with_limit", + "selectionMessagePackBase64": "g6pwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nw6VsaW1pdAM=", + "asOf": 1, + "expectedIds": [ + "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema", + "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/zz-tie-schema" + ], + "matched": 18, + "hasNext": true, + "expectedEdges": [] + }, + { + "name": "gt_numeric_boundary", + "selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRtYXNzom9womd0pm51bWJlcqExqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfC", + "asOf": 1, + "expectedIds": [ + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq", + "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/b-hi", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass" + ], + "matched": 4, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "incoming_exists_true", + "selectionMessagePackBase64": "hKdzY2hlbWFzkdlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmalY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8OqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=", + "asOf": 1, + "expectedIds": [ + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key" + ], + "matched": 2, + "hasNext": false, + "expectedEdges": [ + { + "fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1", + "role": "Design", + "toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq", + "payloadBase64": null + }, + { + "fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target", + "role": "Design", + "toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key", + "payloadBase64": null + } + ] + }, + { + "name": "incoming_negation", + "selectionMessagePackBase64": "hKdzY2hlbWFzkdlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmalY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=", + "asOf": 1, + "expectedIds": [ + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/😂-row", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema" + ], + "matched": 7, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "shared_index_value_three_rows", + "selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpnNoYXJlZKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==", + "asOf": 1, + "expectedIds": [ + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1", + "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/shared-2", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3" + ], + "matched": 3, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "hop_by_role_cites_exact_id", + "selectionMessagePackBase64": "hKRrZXlzkadjaXRlci0xpWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmkYS1lcaRyb2xlpkRlc2lnbqpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==", + "asOf": 1, + "expectedIds": [ + "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1" + ], + "matched": 1, + "hasNext": false, + "expectedEdges": [ + { + "fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1", + "role": "Design", + "toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq", + "payloadBase64": null + } + ] + }, + { + "name": "hop_by_role_cites_name_alias", + "selectionMessagePackBase64": "hKRrZXlzkadjaXRlci0xpWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlkqWxlYWZfYS52OalyZWNvcmRLZXmkYS1lcaRyb2xlpkRlc2lnbqpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==", + "asOf": 1, + "expectedIds": [ + "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1" + ], + "matched": 1, + "hasNext": false, + "expectedEdges": [ + { + "fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1", + "role": "Design", + "toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq", + "payloadBase64": null + } + ] + }, + { + "name": "schemas_by_hash_alias", + "selectionMessagePackBase64": "g6dzY2hlbWFzkdlKc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmYudjGqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=", + "asOf": 1, + "expectedIds": [], + "matched": 0, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "schemas_by_name_alias", + "selectionMessagePackBase64": "g6dzY2hlbWFzkalsZWFmX2EudjmqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=", + "asOf": 1, + "expectedIds": [ + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/😂-row", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema" + ], + "matched": 9, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "float_tie_exact_ge", + "selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRtYXNzom9womdlpm51bWJlcqoyMjMzNzU5LjI1qnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfC", + "asOf": 1, + "expectedIds": [ + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass" + ], + "matched": 1, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "astral_key_code_point_order", + "selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORp3VuaWNvZGWqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=", + "asOf": 1, + "expectedIds": [ + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/😂-row" + ], + "matched": 2, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "row_tiebreak_schema_id_before_record_key_at_tied_ordinal", + "selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORqnRpZS1zY2hlbWGqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=", + "asOf": 1, + "expectedIds": [ + "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/zz-tie-schema", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema" + ], + "matched": 2, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "shared_key_narrow_ref_resolves_leaf_within_target", + "selectionMessagePackBase64": "hKRrZXlzkbVuYXJyb3ctY2l0ZXItYWNjZXB0ZWSlY2l0ZXOCpnRhcmdldIKoc2NoZW1hSWTZR3NoYTI1NjpmZjM1ZjNkOWQ2ZGUzZjEwNjZiOTM2OWU3M2ZlNmM3NjQ4ZTg4MzY4ZDE4ZWZjMjI3NWM5OTQ3MzBjZDg0Y2ZmqXJlY29yZEtleadkdXAta2V5pHJvbGWqbmFycm93X3JlZqpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==", + "asOf": 1, + "expectedIds": [ + "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d/narrow-citer-accepted" + ], + "matched": 1, + "hasNext": false, + "expectedEdges": [ + { + "fromId": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d/narrow-citer-accepted", + "role": "narrow_ref", + "toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key", + "payloadBase64": null + } + ] + }, + { + "name": "shared_key_design_resolves_the_first_in_target_candidate_in_row_order", + "selectionMessagePackBase64": "hKRrZXlzkbRjaXRlci1ib3RoLWluLXRhcmdldKVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5p2R1cC1rZXmkcm9sZaZEZXNpZ26qcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=", + "asOf": 1, + "expectedIds": [ + "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target" + ], + "matched": 1, + "hasNext": false, + "expectedEdges": [ + { + "fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target", + "role": "Design", + "toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key", + "payloadBase64": null + } + ] + }, + { + "name": "refuses_empty_schemas_list", + "selectionMessagePackBase64": "g6dzY2hlbWFzkKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==", + "asOf": 1, + "expectedRefusalField": "schemas", + "expectedIds": [], + "matched": 0, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "refuses_blank_key_entry", + "selectionMessagePackBase64": "g6RrZXlzkqRhLWVxoyAgIKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==", + "asOf": 1, + "expectedRefusalField": "keys", + "expectedIds": [], + "matched": 0, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "refuses_unmatched_cites_target_schema", + "selectionMessagePackBase64": "g6VjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZLlzaGEyNTY6bm90LWEta25vd24tc2NoZW1hqXJlY29yZEtleaRhLWVxpHJvbGWmRGVzaWduqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfC", + "asOf": 1, + "expectedRefusalField": "cites.target.schemaId", + "expectedIds": [], + "matched": 0, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "refuses_cites_target_by_hash_shaped_alias", + "selectionMessagePackBase64": "g6VjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlKc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmYudjGpcmVjb3JkS2V5pGEtZXGkcm9sZaZEZXNpZ26qcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=", + "asOf": 1, + "expectedRefusalField": "cites.target.schemaId", + "expectedIds": [], + "matched": 0, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "shared_key_narrow_ref_refuses_when_only_candidate_is_outside_target", + "selectionMessagePackBase64": "hKRrZXlzkbRuYXJyb3ctY2l0ZXItcmVmdXNlZKVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5q25hcnJvdy1vbmx5pHJvbGWqbmFycm93X3JlZqpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==", + "asOf": 1, + "expectedEvaluationRefusal": "reference_outside_target", + "expectedIds": [], + "matched": 0, + "hasNext": false, + "expectedEdges": [] + } + ] +} \ No newline at end of file diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md index 44715c7c..72768673 100644 --- a/docs/runtime-parity-scope.md +++ b/docs/runtime-parity-scope.md @@ -62,6 +62,17 @@ to speak CultNet or participate in CultMesh. shared transport port and expand RUDP adoption beyond the core proof. - `packages/cultmesh-kotlin/README.md` records Kotlin's runtime role and ergonomic surface, including the explicit non-claim around C# SoA storage. +- CultNet typed selection (`cultnet.snapshot_response_raw.v1`, C#/Rust only; + docs/cultnet-selection-cut.md, R-AY): byte parity for the + `snapshot_response_raw.v1` envelope and its nested record types, verified at + populated and absent optional values, plus semantic parity over the named + field subset. `packages/cultnet-rs/tests/selection_wire_parity.rs` and + `tests/GameCult.Networking.Tests/SelectionWireParityTests.cs` carry the byte + vectors; `packages/cultnet-rs/tests/selection.rs`'s + `write_selection_vectors_for_the_reference` and + `tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs` carry the + semantic ones. A single rows-to-bytes path driving both halves in one call + remains its own cut (R-AR) and is not part of this claim. ## Expansion Work Outside The Current Parity Claim 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/cultmesh-rs/src/lib.rs b/packages/cultmesh-rs/src/lib.rs index 8cfe8cec..d850f3b9 100644 --- a/packages/cultmesh-rs/src/lib.rs +++ b/packages/cultmesh-rs/src/lib.rs @@ -820,7 +820,7 @@ pub fn request_raw_snapshot_from_rudp_catalog( } if received == &message_id => { snapshot_response = Some(message); } - cultnet_rs::CultNetMessage::Error { error } => { + cultnet_rs::CultNetMessage::Error { error, .. } => { let _ = client.disconnect(b"snapshot-error".to_vec()); anyhow::bail!("CultMesh RUDP snapshot failed: {error}"); } diff --git a/packages/cultnet-py/src/cultnet_py/interop_peer.py b/packages/cultnet-py/src/cultnet_py/interop_peer.py index 2eaaf0ac..ba236686 100644 --- a/packages/cultnet-py/src/cultnet_py/interop_peer.py +++ b/packages/cultnet-py/src/cultnet_py/interop_peer.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse from collections import defaultdict import json +import re import selectors import signal import socket @@ -384,9 +385,38 @@ 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 is_unsupported_cultnet_version(schema_version): + # 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, and + # any later vN) not at all - it is v0-only. A non-v0 message is refused loudly, not answered + # with a silent empty list. The prior check only matched ".v1" exactly, so v2+ fell through + # to the silent empty return below instead of being refused (R-L). + return [unsupported_schema_version_error(message)] return [] +_CULTNET_SCHEMA_VERSION_SUFFIX = re.compile(r"^cultnet\..*\.v(\d+)$") + + +def is_unsupported_cultnet_version(schema_version: Any) -> bool: + """True for any cultnet.*.vN with N > 0 - every version this v0-only peer does not speak (R-2/R-L).""" + if not isinstance(schema_version, str): + return False + match = _CULTNET_SCHEMA_VERSION_SUFFIX.match(schema_version) + return match is not None and int(match.group(1)) > 0 + + +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..475305d3 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,31 @@ 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 any + # non-v0 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. R-L: the original check matched + # only ".v1" exactly, so v2+ silently fell through to an empty list instead of being refused - + # this covers v2 and v10 (double-digit) too, not only the exact v1 spelling. + for schema_version in ( + "cultnet.snapshot_request.v1", + "cultnet.database_subscribe.v1", + "cultnet.snapshot_request.v2", + "cultnet.database_subscribe.v2", + "cultnet.snapshot_request.v10", + ): + 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 diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.document-delete.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.document-delete.schema.json new file mode 100644 index 00000000..3ba5b3fc --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.document-delete.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-delete.schema.json", + "title": "CultNet Document Delete Message", + "type": "object", + "required": [ + "schemaVersion", + "messageId", + "schemaId", + "recordKey" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.document_delete.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "schemaId": { "type": "string", "minLength": 1 }, + "recordKey": { "type": "string", "minLength": 1 } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.document-mutation-contract.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.document-mutation-contract.schema.json new file mode 100644 index 00000000..4f8c9b78 --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.document-mutation-contract.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-mutation-contract.schema.json", + "title": "CultNet Document Mutation Contract", + "type": "object", + "required": [ + "documentType", + "operations", + "authority" + ], + "additionalProperties": false, + "properties": { + "documentType": { "type": "string", "minLength": 1 }, + "payloadSchemaVersion": { "type": "string", "minLength": 1 }, + "operations": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "enum": ["snapshot", "documentPut", "documentDelete", "intentSubmit", "receiptWatch"] + } + }, + "authority": { + "type": "string", + "enum": ["readOnly", "localUser", "coordinator", "runtime", "denied"] + }, + "intentDocumentTypes": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "receiptDocumentTypes": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "notes": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.document-put-raw.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.document-put-raw.schema.json new file mode 100644 index 00000000..c2c98030 --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.document-put-raw.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-put-raw.schema.json", + "title": "CultNet Raw Document Put Message", + "type": "object", + "required": [ + "schemaVersion", + "messageId", + "document" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.document_put_raw.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "document": { + "$ref": "./cultnet.raw-document-record.schema.json" + } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.document-put.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.document-put.schema.json new file mode 100644 index 00000000..2b92051a --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.document-put.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-put.schema.json", + "title": "CultNet Document Put Message", + "type": "object", + "required": [ + "schemaVersion", + "messageId", + "document" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.document_put.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "document": { + "$ref": "./cultnet.document-record.schema.json" + } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json new file mode 100644 index 00000000..8653cbd5 --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.error.schema.json", + "title": "CultNet Error Message", + "description": "R-X (docs/cultnet-selection-cut.md, fix batch 4): CultNetErrorMessage is a MessagePack-CSharp map-mode object, so every declared key is written on the wire, nil included - routingHint, code and details are `required` here the same as cultnet.raw-document-header.schema.json's nullable fields, not optional just because a given refusal leaves them null.", + "type": "object", + "required": [ + "schemaVersion", + "error", + "routingHint", + "code", + "details" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.error.v0" }, + "error": { "type": "string", "minLength": 1 }, + "routingHint": { + "description": "Optional shard routing detail for an authority failure. Null for every selection/cursor refusal code (R-N).", + "type": ["object", "null"], + "additionalProperties": false, + "required": ["shard", "reason"], + "properties": { + "shard": { "type": ["object", "null"] }, + "reason": { "type": "string" } + } + }, + "code": { + "description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it.", + "type": ["string", "null"], + "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target"] + }, + "details": { + "description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.", + "type": ["object", "null"], + "additionalProperties": false, + "required": ["field", "value", "asOf", "current"], + "properties": { + "field": { "description": "selection_invalid: the selection field that failed.", "type": ["string", "null"] }, + "value": { "description": "selection_invalid: the offending value, when there is one string worth naming.", "type": ["string", "null"] }, + "asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 }, + "current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 } + } + } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.hello.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.hello.schema.json new file mode 100644 index 00000000..36f9e8cf --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.hello.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.hello.schema.json", + "title": "CultNet Hello Message", + "type": "object", + "required": [ + "schemaVersion", + "runtimeId", + "runtimeKind" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.hello.v0" }, + "runtimeId": { "type": "string", "minLength": 1 }, + "runtimeKind": { "type": "string", "minLength": 1 }, + "agentId": { "type": "string", "minLength": 1 }, + "role": { "type": "string", "minLength": 1 }, + "displayName": { "type": "string", "minLength": 1 }, + "supportedDocumentTypes": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "supportedMutationContracts": { + "type": "array", + "items": { + "$ref": "./cultnet.document-mutation-contract.schema.json" + } + }, + "supportedMessageVersions": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "transportProfiles": { + "type": "array", + "items": { + "$ref": "./cultnet.transport-profile.schema.json" + } + }, + "supportsSchemaCatalog": { "type": "boolean" } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.login-success.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.login-success.schema.json new file mode 100644 index 00000000..fbe41690 --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.login-success.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.login-success.schema.json", + "title": "CultNet Login Success Message", + "type": "object", + "required": [ + "schemaVersion", + "nonce", + "session" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.login_success.v0" }, + "nonce": { "type": "string", "minLength": 1 }, + "session": { "type": "string", "minLength": 1 } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.login.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.login.schema.json new file mode 100644 index 00000000..86154717 --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.login.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.login.schema.json", + "title": "CultNet Login Message", + "type": "object", + "required": [ + "schemaVersion", + "nonce", + "auth", + "password" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.login.v0" }, + "nonce": { "type": "string", "minLength": 1 }, + "auth": { "type": "string", "minLength": 1 }, + "password": { "type": "string", "minLength": 1 } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.raw-document-record.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.raw-document-record.schema.json new file mode 100644 index 00000000..445cf8f1 --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.raw-document-record.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.raw-document-record.schema.json", + "title": "CultNet Raw Document Record", + "description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): matches MessagePack-CSharp's map-mode output for CultNetRawDocumentRecord - every declared key is written, nil included, so every property here is `required` (present on the wire) even where its value may be null.", + "type": "object", + "required": [ + "schemaId", + "schemaName", + "schemaVersion", + "schemaContentHash", + "recordKey", + "storedAt", + "payloadEncoding", + "payload", + "sourceRuntimeId", + "sourceAgentId", + "sourceRole", + "tags" + ], + "additionalProperties": false, + "properties": { + "schemaId": { "type": "string", "minLength": 1 }, + "schemaName": { "type": ["string", "null"], "minLength": 1 }, + "schemaVersion": { "type": ["string", "null"], "minLength": 1 }, + "schemaContentHash": { "type": ["string", "null"], "minLength": 1 }, + "recordKey": { "type": "string", "minLength": 1 }, + "storedAt": { "type": "string", "minLength": 1 }, + "payloadEncoding": { "const": "messagepack" }, + "payload": { + "type": "string", + "contentEncoding": "base64", + "x-cult-runtime-type": "bytes" + }, + "sourceRuntimeId": { "type": ["string", "null"], "minLength": 1 }, + "sourceAgentId": { "type": ["string", "null"], "minLength": 1 }, + "sourceRole": { "type": ["string", "null"], "minLength": 1 }, + "tags": { + "type": ["array", "null"], + "items": { "type": "string", "minLength": 1 } + } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.register.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.register.schema.json new file mode 100644 index 00000000..c5f5ce42 --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.register.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.register.schema.json", + "title": "CultNet Register Message", + "type": "object", + "required": [ + "schemaVersion", + "nonce", + "email", + "password", + "name" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.register.v0" }, + "nonce": { "type": "string", "minLength": 1 }, + "email": { "type": "string", "minLength": 1 }, + "password": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.sample-change-name.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.sample-change-name.schema.json new file mode 100644 index 00000000..2408a449 --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.sample-change-name.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.sample-change-name.schema.json", + "title": "CultNet Sample Change Name Message", + "type": "object", + "required": [ + "schemaVersion", + "name" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.sample.change_name.v0" }, + "name": { "type": "string", "minLength": 1 } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.sample-chat.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.sample-chat.schema.json new file mode 100644 index 00000000..e9bf544c --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.sample-chat.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.sample-chat.schema.json", + "title": "CultNet Sample Chat Message", + "type": "object", + "required": [ + "schemaVersion", + "text" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.sample.chat.v0" }, + "text": { "type": "string", "minLength": 1 } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.schema-catalog-request.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.schema-catalog-request.schema.json new file mode 100644 index 00000000..5459129b --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.schema-catalog-request.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.schema-catalog-request.schema.json", + "title": "CultNet Schema Catalog Request Message", + "type": "object", + "required": [ + "schemaVersion", + "messageId" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.schema_catalog_request.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "includeSchemaJson": { "type": "boolean" }, + "schemaIds": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "kinds": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "wire_message", + "document_payload", + "shared_contract" + ] + } + } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.schema-catalog-response.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.schema-catalog-response.schema.json new file mode 100644 index 00000000..dbda847e --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.schema-catalog-response.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.schema-catalog-response.schema.json", + "title": "CultNet Schema Catalog Response Message", + "type": "object", + "required": [ + "schemaVersion", + "messageId", + "schemas" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.schema_catalog_response.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "schemas": { + "type": "array", + "items": { + "$ref": "./cultnet.schema-descriptor.schema.json" + } + } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.shard-catalog-request.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.shard-catalog-request.schema.json new file mode 100644 index 00000000..7e443e60 --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.shard-catalog-request.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.shard-catalog-request.schema.json", + "type": "object", + "required": ["schemaVersion", "messageId"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.shard_catalog_request.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "schemaIds": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "recordKeys": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.shard-catalog-response.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.shard-catalog-response.schema.json new file mode 100644 index 00000000..f0b846ac --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.shard-catalog-response.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.shard-catalog-response.schema.json", + "type": "object", + "required": ["schemaVersion", "messageId", "shards"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.shard_catalog_response.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "shards": { + "type": "array", + "items": { "$ref": "#/$defs/shardDescriptor" } + } + }, + "$defs": { + "shardDescriptor": { + "type": "object", + "required": ["shardId", "ownerRuntimeId", "epoch"], + "additionalProperties": false, + "properties": { + "shardId": { "type": "string", "minLength": 1 }, + "ownerRuntimeId": { "type": "string", "minLength": 1 }, + "epoch": { "type": "integer", "minimum": 0 }, + "isPrimary": { "type": "boolean" }, + "schemaIds": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "keyPrefix": { "type": "string", "minLength": 1 }, + "primaryEndpoints": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "replicaEndpoints": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "readReplicaEndpoints": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "region": { "type": "string", "minLength": 1 }, + "authorityLeaseId": { "type": "string", "minLength": 1 } + } + } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.snapshot-request.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.snapshot-request.schema.json new file mode 100644 index 00000000..9571669c --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.snapshot-request.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-request.schema.json", + "title": "CultNet Snapshot Request Message", + "type": "object", + "required": [ + "schemaVersion", + "messageId" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.snapshot_request.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "schemaIds": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "recordKeys": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "shardId": { + "type": "string", + "minLength": 1 + }, + "shardEpoch": { + "type": "integer" + } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.snapshot-response-raw.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.snapshot-response-raw.schema.json new file mode 100644 index 00000000..538f48de --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.snapshot-response-raw.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-response-raw.schema.json", + "title": "CultNet Raw Snapshot Response Message", + "type": "object", + "required": [ + "schemaVersion", + "messageId", + "documents" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.snapshot_response_raw.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "documents": { + "type": "array", + "items": { + "$ref": "./cultnet.raw-document-record.schema.json" + } + } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.snapshot-response.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.snapshot-response.schema.json new file mode 100644 index 00000000..87daf147 --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.snapshot-response.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-response.schema.json", + "title": "CultNet Snapshot Response Message", + "type": "object", + "required": [ + "schemaVersion", + "messageId", + "documents" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.snapshot_response.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "documents": { + "type": "array", + "items": { + "$ref": "./cultnet.document-record.schema.json" + } + } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.transport-profile.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.transport-profile.schema.json new file mode 100644 index 00000000..dff458f1 --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.transport-profile.schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.transport-profile.schema.json", + "title": "CultNet Transport Profile", + "type": "object", + "required": [ + "schemaVersion", + "runtimeId", + "transports" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.transport_profile.v0" }, + "runtimeId": { "type": "string", "minLength": 1 }, + "transports": { + "type": "array", + "items": { + "type": "object", + "required": [ + "transportId", + "protocol", + "channels" + ], + "additionalProperties": false, + "properties": { + "transportId": { "type": "string", "minLength": 1 }, + "protocol": { + "type": "string", + "enum": [ + "tcp_framed", + "litenetlib", + "websocket", + "rudp" + ] + }, + "host": { "type": "string", "minLength": 1 }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "path": { "type": "string", "minLength": 1 }, + "discoveryGroup": { "type": "string", "minLength": 1 }, + "wireContracts": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "reconnectPolicy": { + "type": "object", + "required": [ + "schemaVersion", + "policyId", + "baseDelayMs", + "maxDelayMs", + "maxJitterMs" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.reconnect_policy.v0" }, + "policyId": { "type": "string", "minLength": 1 }, + "baseDelayMs": { "type": "integer", "minimum": 0 }, + "maxDelayMs": { "type": "integer", "minimum": 0 }, + "maxJitterMs": { "type": "integer", "minimum": 0 }, + "maxAttempts": { "type": "integer", "minimum": 1 } + } + }, + "channels": { + "type": "array", + "items": { + "type": "object", + "required": [ + "channelId", + "delivery", + "ordering" + ], + "additionalProperties": false, + "properties": { + "channelId": { "type": "string", "minLength": 1 }, + "delivery": { + "type": "string", + "enum": [ + "reliable", + "unreliable" + ] + }, + "ordering": { + "type": "string", + "enum": [ + "ordered", + "unordered", + "sequenced" + ] + }, + "maxPayloadBytes": { "type": "integer", "minimum": 1 }, + "maxFragmentBytes": { "type": "integer", "minimum": 1 }, + "maxPendingReliablePackets": { "type": "integer", "minimum": 1 } + } + } + } + } + } + } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.verify.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.verify.schema.json new file mode 100644 index 00000000..301ae338 --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.verify.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.verify.schema.json", + "title": "CultNet Verify Message", + "type": "object", + "required": [ + "schemaVersion", + "nonce", + "session" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.verify.v0" }, + "nonce": { "type": "string", "minLength": 1 }, + "session": { "type": "string", "minLength": 1 } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/ghostlight.agent-state.schema.json b/packages/cultnet-rs/contracts/cultnet/ghostlight.agent-state.schema.json new file mode 100644 index 00000000..6437cd01 --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/ghostlight.agent-state.schema.json @@ -0,0 +1,845 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/Ghostlight/schemas/agent-state.schema.json", + "title": "Ghostlight Agent State Schema", + "description": "First canonical contract for Ghostlight agent, perception, relationship, event, scene, and dialogue-context state.", + "type": "object", + "required": [ + "schema_version", + "world", + "agents", + "relationships", + "events", + "scenes" + ], + "additionalProperties": false, + "properties": { + "schema_version": { + "const": "ghostlight.agent_state.v0" + }, + "world": { + "$ref": "#/$defs/world_state" + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/$defs/agent" + } + }, + "relationships": { + "type": "array", + "items": { + "$ref": "#/$defs/relationship" + } + }, + "events": { + "type": "array", + "items": { + "$ref": "#/$defs/event" + } + }, + "scenes": { + "type": "array", + "items": { + "$ref": "#/$defs/scene" + } + } + }, + "$defs": { + "world_state": { + "type": "object", + "required": [ + "world_id", + "setting", + "time", + "canon_context" + ], + "additionalProperties": false, + "properties": { + "world_id": { + "type": "string" + }, + "setting": { + "type": "string" + }, + "time": { + "type": "object", + "required": [ + "label" + ], + "additionalProperties": true, + "properties": { + "label": { + "type": "string" + } + } + }, + "canon_context": { + "type": "array", + "items": { + "type": "string" + } + }, + "ambient_pressures": { + "type": "array", + "items": { + "$ref": "#/$defs/pressure" + } + } + } + }, + "agent": { + "type": "object", + "required": [ + "agent_id", + "identity", + "canonical_state", + "goals", + "memories", + "perceived_state_overlays" + ], + "additionalProperties": false, + "properties": { + "agent_id": { + "type": "string" + }, + "identity": { + "$ref": "#/$defs/identity" + }, + "canonical_state": { + "$ref": "#/$defs/canonical_state" + }, + "goals": { + "type": "array", + "items": { + "$ref": "#/$defs/goal" + } + }, + "memories": { + "$ref": "#/$defs/memory_bundle" + }, + "perceived_state_overlays": { + "type": "array", + "items": { + "$ref": "#/$defs/perceived_state_overlay" + } + } + } + }, + "identity": { + "type": "object", + "required": [ + "name", + "roles", + "origin", + "public_description" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + }, + "origin": { + "type": "string" + }, + "public_description": { + "type": "string" + }, + "private_notes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "canonical_state": { + "type": "object", + "required": [ + "underlying_organization", + "stable_dispositions", + "behavioral_dimensions", + "presentation_strategy", + "voice_style", + "situational_state", + "values" + ], + "additionalProperties": false, + "properties": { + "underlying_organization": { + "$ref": "#/$defs/variable_map" + }, + "stable_dispositions": { + "$ref": "#/$defs/variable_map" + }, + "behavioral_dimensions": { + "$ref": "#/$defs/variable_map" + }, + "presentation_strategy": { + "$ref": "#/$defs/variable_map" + }, + "situational_state": { + "$ref": "#/$defs/variable_map" + }, + "values": { + "type": "array", + "items": { + "$ref": "#/$defs/value_commitment" + } + }, + "voice_style": { + "$ref": "#/$defs/variable_map" + } + } + }, + "variable_map": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/state_variable" + } + }, + "state_variable": { + "type": "object", + "required": [ + "mean", + "plasticity", + "current_activation" + ], + "additionalProperties": false, + "properties": { + "mean": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "plasticity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "current_activation": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "evidence": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "value_commitment": { + "type": "object", + "required": [ + "value_id", + "label", + "priority", + "unforgivable_if_betrayed" + ], + "additionalProperties": false, + "properties": { + "value_id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "priority": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "unforgivable_if_betrayed": { + "type": "boolean" + } + } + }, + "goal": { + "type": "object", + "required": [ + "goal_id", + "description", + "scope", + "priority", + "emotional_stake", + "status" + ], + "additionalProperties": false, + "properties": { + "goal_id": { + "type": "string" + }, + "description": { + "type": "string" + }, + "scope": { + "enum": [ + "immediate", + "scene", + "case", + "arc", + "life" + ] + }, + "priority": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "emotional_stake": { + "type": "string" + }, + "blockers": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "enum": [ + "active", + "blocked", + "dormant", + "resolved", + "abandoned" + ] + } + } + }, + "memory_bundle": { + "type": "object", + "required": [ + "episodic", + "semantic", + "relationship_summaries" + ], + "additionalProperties": false, + "properties": { + "episodic": { + "type": "array", + "items": { + "$ref": "#/$defs/memory" + } + }, + "semantic": { + "type": "array", + "items": { + "$ref": "#/$defs/memory" + } + }, + "relationship_summaries": { + "type": "array", + "items": { + "$ref": "#/$defs/memory" + } + } + } + }, + "memory": { + "type": "object", + "required": [ + "memory_id", + "summary", + "salience", + "confidence" + ], + "additionalProperties": false, + "properties": { + "memory_id": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "salience": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "linked_event_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "linked_relationship_id": { + "type": "string" + } + } + }, + "perceived_state_overlay": { + "type": "object", + "required": [ + "observer_agent_id", + "target_agent_id", + "perceived_dimensions", + "beliefs", + "distortions" + ], + "additionalProperties": false, + "properties": { + "observer_agent_id": { + "type": "string" + }, + "target_agent_id": { + "type": "string" + }, + "perceived_dimensions": { + "$ref": "#/$defs/inferred_variable_map" + }, + "beliefs": { + "type": "array", + "items": { + "$ref": "#/$defs/belief" + } + }, + "distortions": { + "type": "array", + "items": { + "enum": [ + "hostile_attribution", + "false_reassurance", + "overread_warmth", + "underread_threat", + "false_mask_detection", + "missed_mask_detection", + "status_injury", + "threat_amplification", + "institutional_suspicion_transfer", + "care_underread", + "mask_overfocus" + ] + } + } + } + }, + "belief": { + "type": "object", + "required": [ + "belief_id", + "claim", + "confidence" + ], + "additionalProperties": false, + "properties": { + "belief_id": { + "type": "string" + }, + "claim": { + "type": "string" + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "subject_id": { + "type": "string" + }, + "claim_type": { + "enum": [ + "fact", + "identity_inference", + "motive_inference", + "prediction", + "norm", + "value_read", + "relationship_read" + ] + }, + "evidence_event_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "visibility": { + "enum": [ + "private", + "shared", + "public", + "rumor" + ] + }, + "emotional_charge": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + }, + "relationship": { + "type": "object", + "required": [ + "relationship_id", + "source_id", + "target_id", + "stance", + "summary" + ], + "additionalProperties": false, + "properties": { + "relationship_id": { + "type": "string" + }, + "source_id": { + "type": "string" + }, + "target_id": { + "type": "string" + }, + "stance": { + "$ref": "#/$defs/variable_map" + }, + "summary": { + "type": "string" + }, + "unresolved_incident_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "event": { + "type": "object", + "required": [ + "event_id", + "kind", + "summary", + "participants", + "pressure_tags" + ], + "additionalProperties": false, + "properties": { + "event_id": { + "type": "string" + }, + "kind": { + "enum": [ + "dialogue", + "action", + "memory", + "relationship_shift", + "scene_outcome", + "author_note" + ] + }, + "summary": { + "type": "string" + }, + "participants": { + "type": "array", + "items": { + "type": "string" + } + }, + "pressure_tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "observed_exchange": { + "type": "array", + "items": { + "$ref": "#/$defs/event_exchange_turn" + } + }, + "private_interpretations": { + "type": "array", + "items": { + "$ref": "#/$defs/event_private_interpretation" + } + }, + "event_effects": { + "type": "array", + "items": { + "$ref": "#/$defs/event_effect" + } + } + } + }, + "scene": { + "type": "object", + "required": [ + "scene_id", + "location", + "participants", + "public_stakes", + "hidden_stakes", + "dialogue_context_packs" + ], + "additionalProperties": false, + "properties": { + "scene_id": { + "type": "string" + }, + "location": { + "type": "string" + }, + "participants": { + "type": "array", + "items": { + "type": "string" + } + }, + "public_stakes": { + "type": "array", + "items": { + "type": "string" + } + }, + "hidden_stakes": { + "type": "array", + "items": { + "type": "string" + } + }, + "dialogue_context_packs": { + "type": "array", + "items": { + "$ref": "#/$defs/dialogue_context_pack" + } + } + } + }, + "dialogue_context_pack": { + "type": "object", + "required": [ + "speaker_agent_id", + "listener_ids", + "speaker_local_truth", + "speaker_beliefs", + "active_memories", + "active_goals", + "presentation_constraints" + ], + "additionalProperties": false, + "properties": { + "speaker_agent_id": { + "type": "string" + }, + "listener_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "speaker_local_truth": { + "type": "array", + "items": { + "type": "string" + } + }, + "speaker_beliefs": { + "type": "array", + "items": { + "type": "string" + } + }, + "active_memories": { + "type": "array", + "items": { + "type": "string" + } + }, + "active_goals": { + "type": "array", + "items": { + "type": "string" + } + }, + "presentation_constraints": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "pressure": { + "type": "object", + "required": [ + "pressure_id", + "label", + "intensity" + ], + "additionalProperties": false, + "properties": { + "pressure_id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "intensity": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + }, + "inferred_variable_map": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/inferred_state_variable" + } + }, + "inferred_state_variable": { + "type": "object", + "required": [ + "observed_activation", + "attributed_disposition", + "confidence" + ], + "additionalProperties": false, + "properties": { + "observed_activation": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "attributed_disposition": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "evidence": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "event_exchange_turn": { + "type": "object", + "required": [ + "speaker_id", + "utterance_summary", + "dialogue_function" + ], + "additionalProperties": false, + "properties": { + "speaker_id": { + "type": "string" + }, + "utterance_summary": { + "type": "string" + }, + "dialogue_function": { + "type": "string" + }, + "observable_cues": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "event_private_interpretation": { + "type": "object", + "required": [ + "agent_id", + "interpretation", + "confidence" + ], + "additionalProperties": false, + "properties": { + "agent_id": { + "type": "string" + }, + "interpretation": { + "type": "string" + }, + "attributed_motive": { + "type": "string" + }, + "appraisal_tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + }, + "event_effect": { + "type": "object", + "required": [ + "agent_id", + "summary" + ], + "additionalProperties": false, + "properties": { + "agent_id": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "state_deltas": { + "type": "array", + "items": { + "$ref": "#/$defs/state_delta" + } + }, + "new_belief_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "memory_update_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "state_delta": { + "type": "object", + "required": [ + "path", + "delta" + ], + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + }, + "delta": { + "type": "number", + "minimum": -1, + "maximum": 1 + } + } + } + } +} diff --git a/packages/cultnet-rs/contracts/cultnet/interop/selection-vectors.fixture.json b/packages/cultnet-rs/contracts/cultnet/interop/selection-vectors.fixture.json new file mode 100644 index 00000000..7e94fd7b --- /dev/null +++ b/packages/cultnet-rs/contracts/cultnet/interop/selection-vectors.fixture.json @@ -0,0 +1,105 @@ +{ + "//": "CultNet typed selection, Cut 1 fix batch (docs/cultnet-selection-cut.md, Self's rulings for the Cut 1 fix batch, R-E and the shared-fixture ruling, 2026-09-22). The one row set both the C# reference and cultnet-rs build their parity vectors from. schemaId is each schema's real SHA-256 content-hash id, exactly as GameCult.Caching.CultDocumentRegistry computes it for the fixture's C# document types (leaf_a=ParityLeafA, leaf_b=ParityLeafB, citer=ParityCiter, citer_narrow=ParityCiterNarrow, tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs) - CultCache.cs's Sha256(semanticFingerprint), which carries no '.vN' suffix of its own; the version is folded into the hash, never appended as text. Both writers read this file instead of building their own fixture rows.", + "//aliases": "Two DIFFERENT alias forms, now deliberate rather than a cross-runtime defect (S-12, 2026-09-22: the Rust alias port made this so - see docs/cultnet-selection-cut.md, 'Self's ruling, 2026-09-22: the C# reference's alias rule is the rule'). Both runtimes resolve a schema alias the same one way: strip a trailing .vN and compare the remainder against the descriptor's declared SchemaName (CultNetSchemaAliasMatching.Matches(candidate, CultDocumentDescriptor), src/GameCult.Networking/CultNetDatabase.cs:74-87; cultnet_rs::selection::schema_alias::matches ports it name-for-name and deletes the hash-stripping overload it used to carry). nameAlias is .v9 and resolves through both runtimes under that one rule. hashAlias is .v1 and is refused as unmatched by both - it is not a wire form either runtime's production call sites ever produce or accept, and it is kept in the fixture specifically to pin that neither runtime resolves it.", + "//references": "R-AF, 2026-09-23: a reference's `references[].targetKey` is the only thing a stored edge actually carries on the wire - GameCult.Caching.MessagePack.CultRecordRefFormatter serializes a CultRecordRef as a bare key string (probed: MessagePackSerializer.Serialize on a real Design field produced the single msgpack string 'a-eq', nothing else). This fixture used to also carry a `targetSchema` per reference; it was never anything but documentation - SelectionParityVectorTests.BuildDocument and cultnet-rs's build_row both read only targetKey to construct the edge - and it is removed rather than kept as a field the wire does not have. Resolving which row an edge names is by targetKey alone, filtered to the declared role's target leaves (D9); see citer_narrow below for the shared-key shape that rule is pinned against.", + "schemas": { + "leaf_a": { + "schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff", + "nameAlias": "leaf_a.v9", + "hashAlias": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff.v1", + "indexes": [ + { "name": "kind", "numeric": false }, + { "name": "mass", "numeric": true } + ] + }, + "leaf_b": { + "schemaId": "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42", + "nameAlias": "leaf_b.v9", + "hashAlias": "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42.v1", + "indexes": [ + { "name": "kind", "numeric": false }, + { "name": "mass", "numeric": true } + ] + }, + "citer": { + "schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc", + "nameAlias": "citer.v9", + "hashAlias": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc.v1", + "roles": [ + { "name": "Design", "targets": ["leaf_a", "leaf_b"] } + ] + }, + "citer_narrow": { + "//": "R-AF: a citer whose declared reference target is one leaf only (leaf_a), so a record key shared by a leaf_a row and a leaf_b row has exactly one candidate inside the declared target and exactly one outside it - the shape neither runtime's parity vectors covered before this cut (zero duplicate record keys across the original ten rows).", + "schemaId": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d", + "nameAlias": "citer_narrow.v9", + "hashAlias": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d.v1", + "roles": [ + { "name": "narrow_ref", "targets": ["leaf_a"] } + ] + } + }, + "rows": [ + { "schema": "leaf_a", "key": "a-lo", "ordinal": 1, "fields": { "kind": "weapon", "mass": "4" } }, + { "schema": "leaf_a", "key": "a-eq", "ordinal": 2, "fields": { "kind": "weapon", "mass": "5" } }, + { "schema": "leaf_b", "key": "b-hi", "ordinal": 3, "fields": { "kind": "armor", "mass": "6" } }, + { "schema": "leaf_a", "key": "shared-1", "ordinal": 4, "fields": { "kind": "shared", "mass": "1" } }, + { "schema": "leaf_b", "key": "shared-2", "ordinal": 5, "fields": { "kind": "shared", "mass": "1" } }, + { "schema": "leaf_a", "key": "shared-3", "ordinal": 6, "fields": { "kind": "shared", "mass": "1" } }, + { + "schema": "citer", + "key": "citer-1", + "ordinal": 7, + "references": [ { "role": "Design", "targetKey": "a-eq" } ] + }, + { + "//": "R-D: 2233759.25 is exactly representable as an f32 and sits on a round-to-even tie between two shortest-form spellings; its exact canonical decimal is 2233759.25 either way. kind=tie so no weapon/armor/shared case can sweep it up.", + "schema": "leaf_a", "key": "tie-mass", "ordinal": 8, "fields": { "kind": "tie", "mass": "2233759.25" } + }, + { + "//": "R-C: BMP private-use key (U+E000), same ordinal as the astral row below so the tiebreak - not the ordinal - decides order. kind=unicode, mass=0 so no unfiltered or mass>1 case reaches these.", + "schema": "leaf_a", "key": "-row", "ordinal": 9, "fields": { "kind": "unicode", "mass": "0" } + }, + { + "//": "R-C: astral key (U+1F602, encoded as a surrogate pair whose high half 0xD83D is less than 0xE000 - a UTF-16 code-unit compare sorts this first; code-point order sorts it after the BMP row above).", + "schema": "leaf_a", "key": "😂-row", "ordinal": 9, "fields": { "kind": "unicode", "mass": "0" } + }, + { + "//": "R-AF shape 1: the only row at this key is outside citer_narrow's declared target (leaf_a only) - resolving the edge below must refuse reference_outside_target in both runtimes, not silently accept an empty page. kind=narrow-fixture (not \"shared\") so this row never sweeps into shared_index_value_three_rows, which pins an exact count of 3.", + "schema": "leaf_b", "key": "narrow-only", "ordinal": 10, "fields": { "kind": "narrow-fixture", "mass": "1" } + }, + { + "//": "R-AF shape 2: two rows share this key, one inside citer_narrow's declared target and one outside it - resolving the edge below must accept, deterministically picking the leaf_a row, in both runtimes.", + "schema": "leaf_a", "key": "dup-key", "ordinal": 11, "fields": { "kind": "narrow-fixture", "mass": "1" } + }, + { + "schema": "leaf_b", "key": "dup-key", "ordinal": 12, "fields": { "kind": "narrow-fixture", "mass": "1" } + }, + { + "schema": "citer_narrow", + "key": "narrow-citer-refused", + "ordinal": 13, + "references": [ { "role": "narrow_ref", "targetKey": "narrow-only" } ] + }, + { + "schema": "citer_narrow", + "key": "narrow-citer-accepted", + "ordinal": 14, + "references": [ { "role": "narrow_ref", "targetKey": "dup-key" } ] + }, + { + "//": "R-AJ: a tied ordinal across two DIFFERENT schemas (leaf_a and leaf_b), not two rows of the same leaf like the astral/BMP pair above - the shape the row order's schemaId tiebreak needs and the original ten rows never had. Keys are anti-correlated with the real schema ids on purpose: leaf_b's real id sorts before leaf_a's (sha256:b1f2... < sha256:ff35...), so leaf_b gets the alphabetically-*later* key (\"zz-tie-schema\") and leaf_a gets the *earlier* one (\"aa-tie-schema\") - a schemaId-first order and a recordKey-first order disagree here on purpose, so only the correct composition produces leaf_b's row first.", + "schema": "leaf_b", "key": "zz-tie-schema", "ordinal": 15, "fields": { "kind": "tie-schema", "mass": "1" } + }, + { + "schema": "leaf_a", "key": "aa-tie-schema", "ordinal": 15, "fields": { "kind": "tie-schema", "mass": "1" } + }, + { + "//": "R-AO, 2026-09-23: 'dup-key' already carries two rows (leaf_a ordinal 11, leaf_b ordinal 12) - both are in citer's Design role's declared target ([leaf_a, leaf_b], unlike citer_narrow's narrow_ref above, which only ever had one in-target candidate at a shared key). This citer is the first row in this file (hence in both runtimes' row order) to name 'dup-key' through Design, so resolving it must deterministically pick the leaf_a row - the first in-target candidate in the caller's order - never the leaf_b row, in both runtimes.", + "schema": "citer", + "key": "citer-both-in-target", + "ordinal": 16, + "references": [ { "role": "Design", "targetKey": "dup-key" } ] + } + ] +} diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index b5c89ff7..68edf97a 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -6,6 +6,8 @@ use serde::Deserialize; use serde::Serialize; use serde_json::Value; +use crate::{Edge, RawDocumentHeader, Selection, SelectionDocumentRecord}; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum CultNetWireContract { #[serde(rename = "cultnet.schema.v0")] @@ -248,6 +250,59 @@ impl CultNetShardDescriptor { } } +/// Machine-readable `cultnet.error.v0` refusal code (R-N). Mirrors the four strings +/// `contracts/cultnet/cultnet.error.schema.json` enumerates for `code`; a peer that receives a fifth +/// string it does not know refuses to decode rather than guess a meaning for it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CultNetErrorCode { + SelectionInvalid, + CursorStale, + CursorInvalid, + ReferenceOutsideTarget, +} + +impl CultNetErrorCode { + fn as_wire_str(self) -> &'static str { + match self { + Self::SelectionInvalid => "selection_invalid", + Self::CursorStale => "cursor_stale", + Self::CursorInvalid => "cursor_invalid", + Self::ReferenceOutsideTarget => "reference_outside_target", + } + } + + fn from_wire_str(value: &str) -> Result { + match value { + "selection_invalid" => Ok(Self::SelectionInvalid), + "cursor_stale" => Ok(Self::CursorStale), + "cursor_invalid" => Ok(Self::CursorInvalid), + "reference_outside_target" => Ok(Self::ReferenceOutsideTarget), + other => Err(anyhow!("ErrorMessage.Code {other:?} is not a recognized cultnet.error.v0 code")), + } + } +} + +/// Code-specific structured detail carried on `cultnet.error.v0`'s `code`/`details` (R-N). Mirrors +/// `CultNetErrorDetails`: every field is populated only for the code that defines it and is nil +/// (present, not omitted) on the wire otherwise, matching the C# reference's always-five-key map. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CultNetErrorDetails { + /// The selection field that failed (`selection_invalid`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub field: Option, + /// The offending value, when there is one string worth naming (`selection_invalid`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, + /// The cursor's own minted asOf (`cursor_stale`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub as_of: Option, + /// The answering server's current asOf (`cursor_stale`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current: Option, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "schemaVersion")] pub enum CultNetMessage { @@ -289,8 +344,22 @@ 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`/`details` mirror the landed C# + // reference (CultNetSchemaMessages.cs) exactly, including its always-five-key map shape + // (`schemaVersion`, `error`, `routingHint`, `code`, `details`, every optional key nil rather + // than omitted). `Error` is therefore encoded/decoded through the raw + // `cultnet.schema.v0` path (parse_raw_cultnet_schema_message/encode_raw_cultnet_schema_message) + // like the other messages that need exact wire control, not through this derive - the derive + // attributes below exist only so the enum as a whole still compiles and round-trips through the + // generic `serde_json::Value` path other tooling may still reach for. #[serde(rename = "cultnet.error.v0", rename_all = "camelCase")] - Error { error: String }, + Error { + error: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + details: Option, + }, #[serde(rename = "cultnet.sample.change_name.v0", rename_all = "camelCase")] SampleChangeName { name: String }, #[serde(rename = "cultnet.sample.chat.v0", rename_all = "camelCase")] @@ -329,6 +398,56 @@ pub enum CultNetMessage { message_id: String, documents: Vec, }, + /// CultNet typed selection, Cut 1 (docs/cultnet-selection-cut.md section 2/7). Mirrors + /// `CultNetSnapshotRequestV1Message`. + #[serde(rename = "cultnet.snapshot_request.v1", rename_all = "camelCase")] + SnapshotRequestV1 { + message_id: String, + selection: Selection, + #[serde(default, skip_serializing_if = "Option::is_none")] + shard_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + shard_epoch: Option, + }, + /// Mirrors `CultNetDatabaseSubscribeV1Message`. + #[serde(rename = "cultnet.database_subscribe.v1", rename_all = "camelCase")] + DatabaseSubscribeV1 { + message_id: String, + subscription_id: String, + selection: Selection, + #[serde(default = "true_default")] + include_snapshot: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + consumer_runtime_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + body_ids: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + supported_body_transports: Option>, + }, + /// `cultnet.snapshot_response_raw.v1` (section 2: `SelectionPage`). Binary payload, so this is + /// carried through `encode_raw_cultnet_schema_message`/`parse_raw_cultnet_schema_message` like + /// `SnapshotResponseRaw` above - a JSON intermediate cannot losslessly carry `documents[].payload` + /// or `edges[].payload`. Mirrors `CultNetSnapshotResponseRawV1Message`. + #[serde(rename = "cultnet.snapshot_response_raw.v1", rename_all = "camelCase")] + SnapshotResponseRawV1 { + message_id: String, + matched: u32, + as_of: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + next: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + headers: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + documents: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + edges: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + shard_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + shard_epoch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + shard_log_sequence: Option, + }, #[serde(rename = "cultnet.schema_catalog_request.v0", rename_all = "camelCase")] SchemaCatalogRequest { message_id: String, @@ -395,6 +514,10 @@ fn messagepack_base64_encoding() -> String { "messagepack-base64".to_string() } +fn true_default() -> bool { + true +} + pub fn parse_cultnet_message( wire_value: &rmpv::Value, contract: CultNetWireContract, @@ -402,9 +525,12 @@ pub fn parse_cultnet_message( match contract { CultNetWireContract::CultNetSchemaV0 => { let message = match schema_version_from_wire_value(wire_value)? { - Some("cultnet.document_put_raw.v0" | "cultnet.snapshot_response_raw.v0") => { - parse_raw_cultnet_schema_message(wire_value)? - } + Some( + "cultnet.document_put_raw.v0" + | "cultnet.snapshot_response_raw.v0" + | "cultnet.snapshot_response_raw.v1" + | "cultnet.error.v0", + ) => parse_raw_cultnet_schema_message(wire_value)?, _ => { let json_value: Value = rmp_serde::from_slice(&rmp_serde::to_vec(wire_value)?)?; serde_json::from_value(json_value)? @@ -424,9 +550,10 @@ pub fn encode_cultnet_message_for_wire( validate_message(message)?; match contract { CultNetWireContract::CultNetSchemaV0 => match message { - CultNetMessage::DocumentPutRaw { .. } | CultNetMessage::SnapshotResponseRaw { .. } => { - encode_raw_cultnet_schema_message(message) - } + CultNetMessage::DocumentPutRaw { .. } + | CultNetMessage::SnapshotResponseRaw { .. } + | CultNetMessage::SnapshotResponseRawV1 { .. } + | CultNetMessage::Error { .. } => encode_raw_cultnet_schema_message(message), _ => Ok(rmp_serde::from_slice(&rmp_serde::to_vec( &serde_json::to_value(message)?, )?)?), @@ -513,7 +640,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, code: _, details: _ } => require_non_empty(error, "error")?, CultNetMessage::SampleChangeName { name } => require_non_empty(name, "name")?, CultNetMessage::SampleChat { text } => require_non_empty(text, "text")?, CultNetMessage::DocumentPut { @@ -566,6 +693,57 @@ fn validate_message(message: &CultNetMessage) -> Result<()> { validate_raw_document_record(document)?; } } + CultNetMessage::SnapshotRequestV1 { + message_id, + // The selection's own declared-alias semantics need a schema registry this wire-level + // deserialization does not have; the C# reference does not validate CultNetSelection at + // this layer either (CultNetSchemaMessageSerialization.cs just deserializes it). A + // server-side caller runs `selection::validate` against its RowSet before evaluating. + selection: _, + shard_id, + shard_epoch: _, + } => { + require_non_empty(message_id, "messageId")?; + require_optional_non_empty(shard_id.as_deref(), "shardId")?; + } + CultNetMessage::DatabaseSubscribeV1 { + message_id, + subscription_id, + selection: _, + include_snapshot: _, + consumer_runtime_id, + body_ids, + supported_body_transports, + } => { + require_non_empty(message_id, "messageId")?; + require_non_empty(subscription_id, "subscriptionId")?; + require_optional_non_empty(consumer_runtime_id.as_deref(), "consumerRuntimeId")?; + require_optional_string_vec(body_ids.as_deref(), "bodyIds")?; + require_optional_string_vec( + supported_body_transports.as_deref(), + "supportedBodyTransports", + )?; + } + CultNetMessage::SnapshotResponseRawV1 { + message_id, + matched: _, + as_of: _, + next: _, + headers, + documents, + edges: _, + shard_id, + shard_epoch: _, + shard_log_sequence: _, + } => { + require_non_empty(message_id, "messageId")?; + if headers.is_some() == documents.is_some() { + return Err(anyhow!( + "CultNet field exactly one of headers/documents must be present" + )); + } + require_optional_non_empty(shard_id.as_deref(), "shardId")?; + } CultNetMessage::SchemaCatalogRequest { message_id, include_schema_json: _, @@ -803,12 +981,136 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result { + let headers = get("headers") + .filter(|value| !value.is_nil()) + .map(|value| { + value + .as_array() + .ok_or_else(|| anyhow!("headers must be an array"))? + .iter() + .enumerate() + .map(|(index, value)| { + require_typed::(Some(value), &format!("headers[{index}]")) + }) + .collect::>>() + }) + .transpose()?; + let documents = get("documents") + .filter(|value| !value.is_nil()) + .map(|value| { + value + .as_array() + .ok_or_else(|| anyhow!("documents must be an array"))? + .iter() + .enumerate() + .map(|(index, value)| { + require_typed::( + Some(value), + &format!("documents[{index}]"), + ) + }) + .collect::>>() + }) + .transpose()?; + let edges = get("edges") + .filter(|value| !value.is_nil()) + .map(|value| { + value + .as_array() + .ok_or_else(|| anyhow!("edges must be an array"))? + .iter() + .enumerate() + .map(|(index, value)| { + require_typed::(Some(value), &format!("edges[{index}]")) + }) + .collect::>>() + }) + .transpose()?; + + Ok(CultNetMessage::SnapshotResponseRawV1 { + message_id: require_legacy_string(get("messageId"), "messageId")?, + matched: require_legacy_u32(get("matched"), "matched")?, + as_of: require_legacy_u64(get("asOf"), "asOf")?, + next: require_legacy_optional_string(get("next"), "next")?, + headers, + documents, + edges, + shard_id: require_legacy_optional_string(get("shardId"), "shardId")?, + shard_epoch: require_legacy_optional_i64(get("shardEpoch"), "shardEpoch")?, + shard_log_sequence: require_legacy_optional_i64( + get("shardLogSequence"), + "shardLogSequence", + )?, + }) + } + "cultnet.error.v0" => { + let code = get("code") + .filter(|value| !value.is_nil()) + .map(|value| { + value + .as_str() + .ok_or_else(|| anyhow!("ErrorMessage.Code must be a string")) + .and_then(CultNetErrorCode::from_wire_str) + }) + .transpose()?; + let details = require_error_details(get("details"), "ErrorMessage.Details")?; + Ok(CultNetMessage::Error { + error: require_legacy_string(get("error"), "ErrorMessage.Error")?, + code, + details, + }) + } _ => Err(anyhow!( "Unsupported raw cultnet.schema.v0 schemaVersion {schema_version}" )), } } +fn require_error_details( + value: Option<&rmpv::Value>, + field_name: &str, +) -> Result> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_nil() { + return Ok(None); + } + let object = value + .as_map() + .ok_or_else(|| anyhow!("{field_name} must be an object"))?; + let get = |name: &str| -> Option<&rmpv::Value> { + object.iter().find_map(|(key, value)| { + key.as_str() + .filter(|candidate| *candidate == name) + .map(|_| value) + }) + }; + Ok(Some(CultNetErrorDetails { + field: require_legacy_optional_string(get("field"), "ErrorMessage.Details.Field")?, + value: require_legacy_optional_string(get("value"), "ErrorMessage.Details.Value")?, + as_of: require_legacy_optional_u64(get("asOf"), "ErrorMessage.Details.AsOf")?, + current: require_legacy_optional_u64(get("current"), "ErrorMessage.Details.Current")?, + })) +} + +fn require_legacy_optional_u64( + value: Option<&rmpv::Value>, + field_name: &str, +) -> Result> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_nil() { + return Ok(None); + } + value + .as_u64() + .map(Some) + .ok_or_else(|| anyhow!("{field_name} must be a non-negative integer")) +} + fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result { Ok(rmpv::Value::Map(match message { CultNetMessage::DocumentPutRaw { @@ -845,6 +1147,99 @@ fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result vec![ + ( + rmpv::Value::from("schemaVersion"), + rmpv::Value::from("cultnet.snapshot_response_raw.v1"), + ), + ( + rmpv::Value::from("messageId"), + rmpv::Value::from(message_id.as_str()), + ), + (rmpv::Value::from("matched"), rmpv::Value::from(*matched)), + (rmpv::Value::from("asOf"), rmpv::Value::from(*as_of)), + ( + rmpv::Value::from("next"), + next.as_deref().map(rmpv::Value::from).unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("headers"), + headers + .as_deref() + .map(|headers| rmpv::Value::Array(headers.iter().map(encode_typed).collect())) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("documents"), + documents + .as_deref() + .map(|documents| rmpv::Value::Array(documents.iter().map(encode_typed).collect())) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("edges"), + edges + .as_deref() + .map(|edges| rmpv::Value::Array(edges.iter().map(encode_typed).collect())) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("shardId"), + shard_id.as_deref().map(rmpv::Value::from).unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("shardEpoch"), + shard_epoch.map(rmpv::Value::from).unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("shardLogSequence"), + shard_log_sequence + .map(rmpv::Value::from) + .unwrap_or(rmpv::Value::Nil), + ), + ], + // R-N: matches the landed C# `CultNetErrorMessage` + // (src/GameCult.Networking/CultNetSchemaMessages.cs) key for key and in its declared + // order - schemaVersion, error, routingHint, code, details - with every optional key + // present as nil rather than omitted. Rust never mints a routingHint today, so that key is + // always nil on encode; a peer that sent one is ignored on decode, same as any other v0 + // peer that does not know a field it did not ask for. + CultNetMessage::Error { + error, + code, + details, + } => vec![ + ( + rmpv::Value::from("schemaVersion"), + rmpv::Value::from("cultnet.error.v0"), + ), + (rmpv::Value::from("error"), rmpv::Value::from(error.as_str())), + (rmpv::Value::from("routingHint"), rmpv::Value::Nil), + ( + rmpv::Value::from("code"), + code.as_ref() + .map(|code| rmpv::Value::from(code.as_wire_str())) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("details"), + details + .as_ref() + .map(encode_error_details) + .unwrap_or(rmpv::Value::Nil), + ), + ], _ => { return Err(anyhow!( "Message is not a raw cultnet.schema.v0 binary replication message" @@ -853,6 +1248,35 @@ fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result rmpv::Value { + rmpv::Value::Map(vec![ + ( + rmpv::Value::from("field"), + details + .field + .as_deref() + .map(rmpv::Value::from) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("value"), + details + .value + .as_deref() + .map(rmpv::Value::from) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("asOf"), + details.as_of.map(rmpv::Value::from).unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("current"), + details.current.map(rmpv::Value::from).unwrap_or(rmpv::Value::Nil), + ), + ]) +} + fn require_raw_document_record( value: Option<&rmpv::Value>, field_name: &str, @@ -961,6 +1385,65 @@ fn encode_raw_document_record(document: &CultNetRawDocumentRecord) -> rmpv::Valu ]) } +// R-M: `SelectionDocumentRecord`, `RawDocumentHeader`, `Edge` and `RecordRef` already derive +// `Serialize`/`Deserialize` (selection.rs) with the same camelCase field names this hand-written +// codec used to spell out by hand - about 335 lines duplicating what those derives already do. +// +// `rmpv::ext::to_value`/`from_value` looked like the natural generic bridge, but they serialize a +// struct positionally (`serialize_struct` as a MessagePack array, one slot per field in +// declaration order, breaking as soon as a `skip_serializing_if` field is actually skipped) - +// wrong for these types twice over: it is not the named-map encoding this wire always uses +// (section 10's rule; `rmp_serde::to_vec_named` elsewhere in this crate), and it does not +// round-trip at all once a field is omitted. These two helpers instead go through +// `rmp_serde::to_vec_named`/`from_slice` (named-map bytes, proven both ways by +// `tests/selection.rs`'s `snapshot_response_raw_v1_round_trips_*` tests) and hand the bytes to +// `rmpv::Value`'s own `Serialize`/`Deserialize` (already used throughout this file, e.g. to decode +// the outer message envelope) to shuttle to/from the `rmpv::Value` tree the rest of this codec +// builds by hand. + +fn require_typed( + value: Option<&rmpv::Value>, + field_name: &str, +) -> Result { + let value = value.ok_or_else(|| anyhow!("{field_name} is required"))?; + let bytes = rmp_serde::to_vec(value).map_err(|err| anyhow!("{field_name}: {err}"))?; + rmp_serde::from_slice(&bytes).map_err(|err| anyhow!("{field_name}: {err}")) +} + +fn encode_typed(value: &T) -> rmpv::Value { + let bytes = rmp_serde::to_vec_named(value).expect("selection wire types always encode to MessagePack"); + rmp_serde::from_slice(&bytes).expect("named-map MessagePack bytes always decode into rmpv::Value") +} + +fn require_legacy_u32(value: Option<&rmpv::Value>, field_name: &str) -> Result { + let number = value + .and_then(rmpv::Value::as_u64) + .ok_or_else(|| anyhow!("{field_name} must be a non-negative integer"))?; + u32::try_from(number).map_err(|_| anyhow!("{field_name} does not fit in u32")) +} + +fn require_legacy_u64(value: Option<&rmpv::Value>, field_name: &str) -> Result { + value + .and_then(rmpv::Value::as_u64) + .ok_or_else(|| anyhow!("{field_name} must be a non-negative integer")) +} + +fn require_legacy_optional_i64( + value: Option<&rmpv::Value>, + field_name: &str, +) -> Result> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_nil() { + return Ok(None); + } + value + .as_i64() + .map(Some) + .ok_or_else(|| anyhow!("{field_name} must be an integer")) +} + fn parse_gamecult_networking_message(input: &rmpv::Value) -> Result { let items = input.as_array().ok_or_else(|| { anyhow!("gamecult.networking.v0 messages must be a 2-element union array") @@ -998,6 +1481,11 @@ fn parse_gamecult_networking_message(input: &rmpv::Value) -> Result Ok(CultNetMessage::Error { error: require_legacy_string(payload.first(), "ErrorMessage.Error")?, + // gamecult.networking.v0 is the legacy union tag/array contract, unrelated to R-N's + // cultnet.error.v0 map shape; it never carried code/details before and does not gain + // them now. + code: None, + details: None, }), 5 => Ok(CultNetMessage::SampleChangeName { name: require_legacy_string(payload.first(), "ChangeNameMessage.Name")?, @@ -1081,7 +1569,7 @@ fn encode_gamecult_networking_message(message: &CultNetMessage) -> Result { + CultNetMessage::Error { error, .. } => { vec![ rmpv::Value::from(4), rmpv::Value::Array(vec![error.as_str().into()]), diff --git a/packages/cultnet-rs/src/lib.rs b/packages/cultnet-rs/src/lib.rs index 074c2a29..cf858647 100644 --- a/packages/cultnet-rs/src/lib.rs +++ b/packages/cultnet-rs/src/lib.rs @@ -15,6 +15,7 @@ mod replication; mod rudp; mod schema_discovery; mod security; +mod selection; mod shard_catalog; mod transport; @@ -35,5 +36,6 @@ pub use replication::*; pub use rudp::*; pub use schema_discovery::*; pub use security::*; +pub use selection::*; pub use shard_catalog::*; pub use transport::*; diff --git a/packages/cultnet-rs/src/schema_discovery.rs b/packages/cultnet-rs/src/schema_discovery.rs index a32bc7cb..43211c3c 100644 --- a/packages/cultnet-rs/src/schema_discovery.rs +++ b/packages/cultnet-rs/src/schema_discovery.rs @@ -194,28 +194,28 @@ pub fn builtin_schema_registry() -> Result { for registration in [ schema_registration( - include_str!("../../../contracts/cultnet/cultnet.hello.schema.json"), + include_str!("../contracts/cultnet/cultnet.hello.schema.json"), CultNetSchemaKind::WireMessage, vec![CultNetWireContract::CultNetSchemaV0], Some("cultnet.hello.v0"), None, )?, schema_registration( - include_str!("../../../contracts/cultnet/cultnet.document-mutation-contract.schema.json"), + include_str!("../contracts/cultnet/cultnet.document-mutation-contract.schema.json"), CultNetSchemaKind::SharedContract, vec![CultNetWireContract::CultNetSchemaV0], None, None, )?, schema_registration( - include_str!("../../../contracts/cultnet/cultnet.transport-profile.schema.json"), + include_str!("../contracts/cultnet/cultnet.transport-profile.schema.json"), CultNetSchemaKind::SharedContract, vec![CultNetWireContract::CultNetSchemaV0], Some("cultnet.transport_profile.v0"), None, )?, schema_registration( - include_str!("../../../contracts/cultnet/cultnet.login.schema.json"), + include_str!("../contracts/cultnet/cultnet.login.schema.json"), CultNetSchemaKind::WireMessage, vec![ CultNetWireContract::CultNetSchemaV0, @@ -225,7 +225,7 @@ pub fn builtin_schema_registry() -> Result { None, )?, schema_registration( - include_str!("../../../contracts/cultnet/cultnet.register.schema.json"), + include_str!("../contracts/cultnet/cultnet.register.schema.json"), CultNetSchemaKind::WireMessage, vec![ CultNetWireContract::CultNetSchemaV0, @@ -235,7 +235,7 @@ pub fn builtin_schema_registry() -> Result { None, )?, schema_registration( - include_str!("../../../contracts/cultnet/cultnet.verify.schema.json"), + include_str!("../contracts/cultnet/cultnet.verify.schema.json"), CultNetSchemaKind::WireMessage, vec![ CultNetWireContract::CultNetSchemaV0, @@ -245,7 +245,7 @@ pub fn builtin_schema_registry() -> Result { None, )?, schema_registration( - include_str!("../../../contracts/cultnet/cultnet.login-success.schema.json"), + include_str!("../contracts/cultnet/cultnet.login-success.schema.json"), CultNetSchemaKind::WireMessage, vec![ CultNetWireContract::CultNetSchemaV0, @@ -255,7 +255,7 @@ pub fn builtin_schema_registry() -> Result { None, )?, schema_registration( - include_str!("../../../contracts/cultnet/cultnet.error.schema.json"), + include_str!("../contracts/cultnet/cultnet.error.schema.json"), CultNetSchemaKind::WireMessage, vec![ CultNetWireContract::CultNetSchemaV0, @@ -265,7 +265,7 @@ pub fn builtin_schema_registry() -> Result { None, )?, schema_registration( - include_str!("../../../contracts/cultnet/cultnet.sample-change-name.schema.json"), + include_str!("../contracts/cultnet/cultnet.sample-change-name.schema.json"), CultNetSchemaKind::WireMessage, vec![ CultNetWireContract::CultNetSchemaV0, @@ -275,7 +275,7 @@ pub fn builtin_schema_registry() -> Result { None, )?, schema_registration( - include_str!("../../../contracts/cultnet/cultnet.sample-chat.schema.json"), + include_str!("../contracts/cultnet/cultnet.sample-chat.schema.json"), CultNetSchemaKind::WireMessage, vec![ CultNetWireContract::CultNetSchemaV0, @@ -285,56 +285,56 @@ pub fn builtin_schema_registry() -> Result { None, )?, schema_registration( - include_str!("../../../contracts/cultnet/cultnet.document-put.schema.json"), + include_str!("../contracts/cultnet/cultnet.document-put.schema.json"), CultNetSchemaKind::WireMessage, vec![CultNetWireContract::CultNetSchemaV0], Some("cultnet.document_put.v0"), None, )?, schema_registration( - include_str!("../../../contracts/cultnet/cultnet.document-delete.schema.json"), + include_str!("../contracts/cultnet/cultnet.document-delete.schema.json"), CultNetSchemaKind::WireMessage, vec![CultNetWireContract::CultNetSchemaV0], Some("cultnet.document_delete.v0"), None, )?, schema_registration( - include_str!("../../../contracts/cultnet/cultnet.raw-document-record.schema.json"), + include_str!("../contracts/cultnet/cultnet.raw-document-record.schema.json"), CultNetSchemaKind::SharedContract, vec![CultNetWireContract::CultNetSchemaV0], None, None, )?, schema_registration( - include_str!("../../../contracts/cultnet/cultnet.document-put-raw.schema.json"), + include_str!("../contracts/cultnet/cultnet.document-put-raw.schema.json"), CultNetSchemaKind::WireMessage, vec![CultNetWireContract::CultNetSchemaV0], Some("cultnet.document_put_raw.v0"), None, )?, schema_registration( - include_str!("../../../contracts/cultnet/cultnet.snapshot-request.schema.json"), + include_str!("../contracts/cultnet/cultnet.snapshot-request.schema.json"), CultNetSchemaKind::WireMessage, vec![CultNetWireContract::CultNetSchemaV0], Some("cultnet.snapshot_request.v0"), None, )?, schema_registration( - include_str!("../../../contracts/cultnet/cultnet.snapshot-response.schema.json"), + include_str!("../contracts/cultnet/cultnet.snapshot-response.schema.json"), CultNetSchemaKind::WireMessage, vec![CultNetWireContract::CultNetSchemaV0], Some("cultnet.snapshot_response.v0"), None, )?, schema_registration( - include_str!("../../../contracts/cultnet/cultnet.snapshot-response-raw.schema.json"), + include_str!("../contracts/cultnet/cultnet.snapshot-response-raw.schema.json"), CultNetSchemaKind::WireMessage, vec![CultNetWireContract::CultNetSchemaV0], Some("cultnet.snapshot_response_raw.v0"), None, )?, schema_registration( - include_str!("../../../contracts/cultnet/cultnet.schema-catalog-request.schema.json"), + include_str!("../contracts/cultnet/cultnet.schema-catalog-request.schema.json"), CultNetSchemaKind::WireMessage, vec![ CultNetWireContract::CultNetSchemaV0, @@ -344,7 +344,7 @@ pub fn builtin_schema_registry() -> Result { None, )?, schema_registration( - include_str!("../../../contracts/cultnet/cultnet.schema-catalog-response.schema.json"), + include_str!("../contracts/cultnet/cultnet.schema-catalog-response.schema.json"), CultNetSchemaKind::WireMessage, vec![ CultNetWireContract::CultNetSchemaV0, @@ -354,21 +354,21 @@ pub fn builtin_schema_registry() -> Result { None, )?, schema_registration( - include_str!("../../../contracts/cultnet/cultnet.shard-catalog-request.schema.json"), + include_str!("../contracts/cultnet/cultnet.shard-catalog-request.schema.json"), CultNetSchemaKind::WireMessage, vec![CultNetWireContract::CultNetSchemaV0], Some("cultnet.shard_catalog_request.v0"), None, )?, schema_registration( - include_str!("../../../contracts/cultnet/cultnet.shard-catalog-response.schema.json"), + include_str!("../contracts/cultnet/cultnet.shard-catalog-response.schema.json"), CultNetSchemaKind::WireMessage, vec![CultNetWireContract::CultNetSchemaV0], Some("cultnet.shard_catalog_response.v0"), None, )?, schema_registration( - include_str!("../../../contracts/cultnet/ghostlight.agent-state.schema.json"), + include_str!("../contracts/cultnet/ghostlight.agent-state.schema.json"), CultNetSchemaKind::DocumentPayload, vec![CultNetWireContract::CultNetSchemaV0], Some("ghostlight.agent_state.v0"), diff --git a/packages/cultnet-rs/src/security.rs b/packages/cultnet-rs/src/security.rs index 30aa841b..76f23b23 100644 --- a/packages/cultnet-rs/src/security.rs +++ b/packages/cultnet-rs/src/security.rs @@ -259,7 +259,7 @@ fn hmac_sha256(key: &[u8], input: &[u8]) -> Result> { Ok(mac.finalize().into_bytes().to_vec()) } -fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { +pub(crate) fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { if left.len() != right.len() { return false; } diff --git a/packages/cultnet-rs/src/selection.rs b/packages/cultnet-rs/src/selection.rs new file mode 100644 index 00000000..9e146321 --- /dev/null +++ b/packages/cultnet-rs/src/selection.rs @@ -0,0 +1,2507 @@ +//! CultNet typed selection (docs/cultnet-selection-cut.md, sections 2/6/7): the Rust spelling of +//! the same vocabulary the C# reference (`GameCult.Networking/CultNetSelection.cs`, +//! `CultNetSelectionEvaluator.cs`) owns, at wire parity, pinned by the S12 parity vectors rather than +//! by matching shapes 1:1. +//! +//! D7: this evaluator carries no `cultcache-rs` knowledge. A row's declared values and edges come +//! entirely through the [`Row`] and [`RowSet`] traits; the consumer (Huginn, or a test fixture) is the +//! row owner and supplies them the way `CultCache` supplies the C# evaluator. Nothing here reflects +//! over a document, and no type here is a serde union that skips its own tag (section 10's +//! negative grep checks for the literal attribute spelling, which this file deliberately does not +//! quote even in prose). +//! +//! Q-J (section 2 "Numbers"): every comparison number on the wire and every row's declared numeric +//! value is a canonical decimal string, never a float. [`canonical_number`] is the door's grammar and +//! the comparator; it is a pure string algorithm with no `f64`/`f32` anywhere on the comparison path. + +use std::cmp::Ordering; +use std::collections::{HashMap, HashSet}; +use std::fmt; + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use hmac::Hmac; +use hmac::Mac; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; + +use crate::contracts::{CultNetErrorCode, CultNetErrorDetails, CultNetMessage}; +use crate::security::constant_time_eq; + +type HmacSha256 = Hmac; + +// --------------------------------------------------------------------------------------------- +// Canonical decimal numbers (Q-J) +// --------------------------------------------------------------------------------------------- + +/// The wire's canonical decimal grammar and comparator (docs/cultnet-selection-cut.md section 2 +/// "Numbers", operator ruling Q-J). A pure string algorithm - no numeric parse, no precision limit, +/// no `f64`/`f32` on the comparison path. Ported from `CultNetCanonicalNumber` +/// (`GameCult.Networking/CultNetSelection.cs`); the digit grammar is hand-rolled rather than pulled +/// in via a `regex` dependency this crate does not otherwise need. +pub mod canonical_number { + use super::Ordering; + + /// `true` for a canonical decimal string: matches `^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$` and is + /// not `"-0"` (excluded explicitly; the grammar alone matches it). Forbids leading zeros, a + /// trailing fractional zero or bare point, an explicit `+`, exponent notation, and whitespace. + pub fn is_canonical(value: &str) -> bool { + if value == "-0" { + return false; + } + let bytes = value.as_bytes(); + if bytes.is_empty() { + return false; + } + let mut i = 0usize; + if bytes[0] == b'-' { + i += 1; + } + if i >= bytes.len() || !bytes[i].is_ascii_digit() { + return false; + } + if bytes[i] == b'0' { + i += 1; + // A leading zero is canonical only as the whole integer part: "0" or "0.x", never "01". + if i < bytes.len() && bytes[i].is_ascii_digit() { + return false; + } + } else { + i += 1; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + } + if i == bytes.len() { + return true; + } + if bytes[i] != b'.' { + return false; + } + i += 1; + let fraction_start = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + if i != bytes.len() { + return false; + } + let fraction_len = i - fraction_start; + // (\.[0-9]*[1-9])?: at least one fraction digit, and the last one is never zero. + fraction_len > 0 && bytes[i - 1] != b'0' + } + + /// Compares two canonical decimal strings by sign, then the integer part's length, then its + /// digits, then the fraction digits padded on the right to equal length. Callers validate both + /// operands with [`is_canonical`] first; this does not re-validate. Mirrors + /// `CultNetCanonicalNumber.Compare` exactly (docs/cultnet-selection-cut.md section 2 "Numbers"). + pub fn compare(left: &str, right: &str) -> Ordering { + let (negative_left, integer_left, fraction_left) = decompose(left); + let (negative_right, integer_right, fraction_right) = decompose(right); + + if negative_left != negative_right { + return if negative_left { + Ordering::Less + } else { + Ordering::Greater + }; + } + + let magnitude = if integer_left.len() != integer_right.len() { + integer_left.len().cmp(&integer_right.len()) + } else { + let integer_cmp = integer_left.cmp(integer_right); + if integer_cmp != Ordering::Equal { + integer_cmp + } else { + let width = fraction_left.len().max(fraction_right.len()); + pad_right(fraction_left, width).cmp(&pad_right(fraction_right, width)) + } + }; + + if negative_left { + magnitude.reverse() + } else { + magnitude + } + } + + fn decompose(value: &str) -> (bool, &str, &str) { + let negative = value.starts_with('-'); + let unsigned = if negative { &value[1..] } else { value }; + match unsigned.find('.') { + Some(dot) => (negative, &unsigned[..dot], &unsigned[dot + 1..]), + None => (negative, unsigned, ""), + } + } + + fn pad_right(value: &str, width: usize) -> String { + let mut owned = value.to_string(); + while owned.len() < width { + owned.push('0'); + } + owned + } + + /// The one canonicalizer every rendered number passes through: strips leading integer zeros and + /// trailing fractional zeros, and collapses a negative value that reduces to zero into `"0"`. + /// Mirrors `CultCache.cs`'s `CanonicalizeDecimalDigits`. Exposed so a `Row` implementation can + /// render its own numeric values into this grammar without re-deriving the algorithm. + pub fn canonicalize_decimal_digits(signed_positional: &str) -> String { + let negative = signed_positional.starts_with('-'); + let unsigned = if negative { + &signed_positional[1..] + } else { + signed_positional + }; + let (mut integer_part, fraction_part) = match unsigned.find('.') { + Some(dot) => (&unsigned[..dot], &unsigned[dot + 1..]), + None => (unsigned, ""), + }; + integer_part = integer_part.trim_start_matches('0'); + if integer_part.is_empty() { + integer_part = "0"; + } + let fraction_part = fraction_part.trim_end_matches('0'); + + let is_zero = integer_part == "0" && fraction_part.is_empty(); + let result = if fraction_part.is_empty() { + integer_part.to_string() + } else { + format!("{integer_part}.{fraction_part}") + }; + if negative && !is_zero { + format!("-{result}") + } else { + result + } + } + + /// Renders a signed 64-bit integer as its exact canonical decimal - no 2^53 limit, because this + /// never passes through a float. + pub fn render_i64(value: i64) -> String { + canonicalize_decimal_digits(&value.to_string()) + } + + /// Renders an unsigned 64-bit integer as its exact canonical decimal. + pub fn render_u64(value: u64) -> String { + canonicalize_decimal_digits(&value.to_string()) + } + + // R-D (docs/cultnet-selection-cut.md, "Self's rulings for the Cut 1 fix batch"): a float + // renders as its exact decimal expansion, computed from mantissa and exponent, not as a + // shortest round-trip form (the previous `f64::to_string()` path rounded ties the wrong way + // relative to .NET - Soul's parity vectors). Every finite f32/f64 is an exact dyadic rational + // `mantissa * 2^exponent`; this is a small fixed-point bignum (decimal digits, little-endian) + // that computes that expansion exactly, with no precision loss and no new dependency. + + /// One decimal digit per element, least-significant first. + type BigDigits = Vec; + + fn big_from_u64(mut value: u64) -> BigDigits { + if value == 0 { + return vec![0]; + } + let mut digits = Vec::new(); + while value > 0 { + digits.push((value % 10) as u8); + value /= 10; + } + digits + } + + /// Multiplies `digits` in place by a one-digit factor (2 or 5, here - the two primes 2^k and + /// 5^k need to turn a binary mantissa into an exact decimal one). + /// Multiplies `digits` in place by a single-digit `factor` (only ever called with 2 or 5, the + /// two primes that turn a binary mantissa's magnitude into a decimal one). A single-digit + /// factor against a single digit plus a carry that is itself always a single digit + /// (inductively: it starts at 0) never produces a carry of 10 or more, so the leftover carry + /// needs at most one new leading digit - never a loop (the mutation harness's own + /// RS-D-BigMulCarry-Loosening entry proved a `while` here is unreachable generality: nothing + /// distinguishes it from a plain `if`). + fn big_mul_small(digits: &mut BigDigits, factor: u8) { + debug_assert!(factor <= 9, "big_mul_small takes a single decimal digit as its factor"); + let mut carry: u32 = 0; + for digit in digits.iter_mut() { + let product = (*digit as u32) * (factor as u32) + carry; + *digit = (product % 10) as u8; + carry = product / 10; + } + if carry > 0 { + digits.push(carry as u8); + } + } + + fn big_to_most_significant_first(digits: &BigDigits) -> String { + digits.iter().rev().map(|d| (b'0' + d) as char).collect() + } + + /// The exact decimal expansion of `(-1)^negative * mantissa * 2^exponent`. `mantissa` carries + /// no implicit bit; the caller has already folded it in (or not, for a subnormal). + fn exact_decimal_from_mantissa(mantissa: u64, exponent: i32, negative: bool) -> String { + // No mantissa == 0 short-circuit: the general path already collapses to "0" for a zero + // mantissa (multiplying zero stays zero at every step, and `canonicalize_decimal_digits` + // collapses the signed "-0" case too) - a dedicated early return here duplicated that + // without changing the answer, and the mutation harness's own RS-D-ZeroMantissa-Revert + // entry proved it: removing the guard entirely left the test green. + let mut digits = big_from_u64(mantissa); + let unsigned = if exponent >= 0 { + for _ in 0..exponent { + big_mul_small(&mut digits, 2); + } + big_to_most_significant_first(&digits) + } else { + // mantissa / 2^k == (mantissa * 5^k) / 10^k: multiplying by 5^k turns the binary + // fraction into a decimal one with exactly k fraction digits. + let k = (-exponent) as usize; + for _ in 0..k { + big_mul_small(&mut digits, 5); + } + let mut rendered = big_to_most_significant_first(&digits); + if rendered.len() <= k { + rendered = format!("{}{rendered}", "0".repeat(k + 1 - rendered.len())); + } + let split = rendered.len() - k; + format!("{}.{}", &rendered[..split], &rendered[split..]) + }; + canonicalize_decimal_digits(&if negative { + format!("-{unsigned}") + } else { + unsigned + }) + } + + /// Renders a `f64` as its canonical decimal, or `None` for NaN/infinity ("a NaN or infinite + /// member matches no comparison"). Built from IEEE-754's own mantissa and exponent (R-D) - + /// never from `Display`'s shortest round-trip form, which rounds ties differently than .NET's + /// `ToString()` (Soul's parity vectors, 394/200k f32 and 48/200k f64 values at ties). + pub fn render_f64(value: f64) -> Option { + if value.is_nan() || value.is_infinite() { + return None; + } + let bits = value.to_bits(); + let negative = (bits >> 63) & 1 == 1; + let biased_exponent = ((bits >> 52) & 0x7FF) as i32; + let fraction = bits & ((1u64 << 52) - 1); + let (mantissa, exponent) = if biased_exponent == 0 { + (fraction, -1074) // subnormal: 1 - 1023 (bias) - 52 (fraction bits) + } else { + (fraction | (1u64 << 52), biased_exponent - 1023 - 52) + }; + Some(exact_decimal_from_mantissa(mantissa, exponent, negative)) + } + + /// Renders a `f32` as its canonical decimal, or `None` for NaN/infinity. + pub fn render_f32(value: f32) -> Option { + if value.is_nan() || value.is_infinite() { + return None; + } + let bits = value.to_bits(); + let negative = (bits >> 31) & 1 == 1; + let biased_exponent = ((bits >> 23) & 0xFF) as i32; + let fraction = (bits & ((1u32 << 23) - 1)) as u64; + let (mantissa, exponent) = if biased_exponent == 0 { + (fraction, -149) // subnormal: 1 - 127 (bias) - 23 (fraction bits) + } else { + (fraction | (1u64 << 23), biased_exponent - 127 - 23) + }; + Some(exact_decimal_from_mantissa(mantissa, exponent, negative)) + } + + #[cfg(test)] + mod tests { + // R-D no longer renders through `Display` at all (render_f64/render_f32 build the exact + // decimal straight from mantissa and exponent), so this probe no longer guards a + // production path - it is kept anyway, on the map's instruction, as a standing record of + // what Rust's `Display` does for a value this module used to hand it. + #[test] + fn probe_f64_display_never_uses_exponent_notation() { + let huge = 1e21_f64; + let tiny = 1e-7_f64; + let huge_text = huge.to_string(); + let tiny_text = tiny.to_string(); + assert!( + !huge_text.contains(['e', 'E']), + "expected no exponent notation, got {huge_text}" + ); + assert!( + !tiny_text.contains(['e', 'E']), + "expected no exponent notation, got {tiny_text}" + ); + assert_eq!(huge_text, "1000000000000000000000"); + assert_eq!(tiny_text, "0.0000001"); + } + + // Q-J: an i64 past 2^53 (where f64 loses integer precision) renders exactly - this is the + // row-rendering mutant docs/cultnet-selection-cut.md section 2 "Numbers" names: a value + // rendered through float64 would silently become "9007199254740992" here. + #[test] + fn render_i64_past_2_pow_53_is_exact() { + assert_eq!(super::render_i64(9_007_199_254_740_993), "9007199254740993"); + assert_eq!(super::render_i64(-9_007_199_254_740_993), "-9007199254740993"); + assert_eq!(super::render_i64(0), "0"); + } + + #[test] + fn render_u64_is_exact() { + assert_eq!(super::render_u64(18_446_744_073_709_551_615), "18446744073709551615"); + } + + #[test] + fn canonicalize_decimal_digits_strips_leading_and_trailing_zeros() { + assert_eq!(super::canonicalize_decimal_digits("13.500"), "13.5"); + assert_eq!(super::canonicalize_decimal_digits("007"), "7"); + assert_eq!(super::canonicalize_decimal_digits("0.000"), "0"); + assert_eq!(super::canonicalize_decimal_digits("-0.000"), "0"); + } + + #[test] + fn render_f64_rewrites_exponent_notation_to_positional_digits() { + // 1e21 and 3.5 are both exact dyadic rationals, so their exact expansion is also + // their shortest form. + assert_eq!(super::render_f64(1e21).unwrap(), "1000000000000000000000"); + assert_eq!(super::render_f64(3.5).unwrap(), "3.5"); + // 1e-7 is not exactly representable in binary - R-D renders the value actually + // stored, not the shortest decimal that would round-trip back to it (verified + // independently outside this crate: 1e-7_f64.to_bits() decomposes to mantissa + // 5764607523034235 * 2^-106, whose exact decimal is this 79-digit expansion). + assert_eq!( + super::render_f64(1e-7).unwrap(), + "0.0000000999999999999999954748111825886258685613938723690807819366455078125" + ); + } + + #[test] + fn render_f64_and_f32_answer_none_for_nan_and_infinity() { + assert_eq!(super::render_f64(f64::NAN), None); + assert_eq!(super::render_f64(f64::INFINITY), None); + assert_eq!(super::render_f32(f32::NAN), None); + assert_eq!(super::render_f32(f32::NEG_INFINITY), None); + } + + // R-D: an f32 of 3e20 is not "3e20" or "300000000000000000000", it is the exact IEEE-754 + // value nearest 3e20. The map's own worked example (and Soul's notes) give this as + // "300000002010536247296" - that literal is wrong: 3e20_f32.to_bits() is + // 0b01100001100000100001101010110001 (sign 0, exponent 195, fraction 137905), so mantissa + // 8526513 * 2^45 - verified independently in Node with BigInt bit-shifting outside this + // crate - is 300000006012263202816, which is what this algorithm (and a plain `mantissa + // << exponent`) computes. Flagged as a discrepancy in the fix-batch report; this test + // pins the value this algorithm can prove, not the map's transcription. + #[test] + fn render_f32_3e20_is_the_exact_ieee754_value_not_the_shortest_form() { + assert_eq!(super::render_f32(3e20_f32).unwrap(), "300000006012263202816"); + } + + // R-D: 0.1 has no exact binary representation, so its f32/f64 renderings are long and + // differ from each other - a shortest-form renderer would print "0.1" for both. + #[test] + fn render_f32_and_f64_of_0_1_render_the_distinct_exact_binary_values() { + let as_f32 = super::render_f32(0.1_f32).unwrap(); + let as_f64 = super::render_f64(0.1_f64).unwrap(); + assert_ne!(as_f32, as_f64, "f32 and f64 round 0.1 to different exact values"); + // Round-trip through Rust's own correctly-rounded parser: the exact decimal expansion + // must parse back to the identical bit pattern it was rendered from. + assert_eq!(as_f32.parse::().unwrap().to_bits(), 0.1_f32.to_bits()); + assert_eq!(as_f64.parse::().unwrap().to_bits(), 0.1_f64.to_bits()); + assert_ne!(as_f32, "0.1", "the exact expansion is far longer than the shortest form"); + } + + // R-D: subnormals (the smallest representable magnitudes, below the normal range) still + // render exactly, and round-trip through a correctly-rounded parser. + #[test] + fn render_f32_and_f64_subnormals_round_trip() { + let smallest_f32 = f32::from_bits(1); // smallest positive subnormal f32 + let smallest_f64 = f64::from_bits(1); // smallest positive subnormal f64 + let rendered_f32 = super::render_f32(smallest_f32).unwrap(); + let rendered_f64 = super::render_f64(smallest_f64).unwrap(); + assert_eq!(rendered_f32.parse::().unwrap().to_bits(), smallest_f32.to_bits()); + assert_eq!(rendered_f64.parse::().unwrap().to_bits(), smallest_f64.to_bits()); + assert!(!rendered_f32.contains(['e', 'E'])); + assert!(!rendered_f64.contains(['e', 'E'])); + } + + // R-D: the largest finite magnitudes round-trip too - these are the widest bignums this + // algorithm produces (f64::MAX has 309 integer digits). + #[test] + fn render_f32_and_f64_max_round_trip() { + let rendered_f32 = super::render_f32(f32::MAX).unwrap(); + let rendered_f64 = super::render_f64(f64::MAX).unwrap(); + assert_eq!(rendered_f32.parse::().unwrap().to_bits(), f32::MAX.to_bits()); + assert_eq!(rendered_f64.parse::().unwrap().to_bits(), f64::MAX.to_bits()); + assert!(!rendered_f32.starts_with('-')); + assert!(!rendered_f64.starts_with('-')); + } + + // R-D: positive and negative zero both render as the canonical "0", never "-0". + #[test] + fn render_f32_and_f64_positive_and_negative_zero_render_as_canonical_zero() { + assert_eq!(super::render_f32(0.0_f32).unwrap(), "0"); + assert_eq!(super::render_f32(-0.0_f32).unwrap(), "0"); + assert_eq!(super::render_f64(0.0_f64).unwrap(), "0"); + assert_eq!(super::render_f64(-0.0_f64).unwrap(), "0"); + } + + // R-AI: cargo-mutants found `render_f32`'s subnormal-vs-normal branch survivable + // (`biased_exponent == 0` flipped to `!=`, and the subnormal exponent's `-149` losing its + // sign) even with the round-trip tests above - a round trip through a correctly-rounded + // parser can still agree with a wrong bit-extraction if the wrong value happens to parse + // back to the same bits it was carelessly derived from, and the MAX/subnormal round-trip + // tests above never assert on digit count or magnitude, only on parsing back correctly. + // This pins the *shape* of the subnormal case directly: 2^-149 is a fraction with 148 + // leading zeros after the point, not an astronomical integer (what a stray `+149` would + // render) and not a value one exponent off (what taking the normal branch for a + // biased_exponent of 0 would render). + #[test] + fn render_f32_subnormal_uses_the_subnormal_branch_and_its_negative_exponent() { + let rendered = super::render_f32(f32::from_bits(1)).unwrap(); + assert!(rendered.starts_with("0.00000"), "got {rendered:?}"); + assert_eq!(rendered.len(), 151, "2^-149 is \"0.\" plus 149 fraction digits, got {rendered:?}"); + } + + // R-AI: the normal-path implicit leading bit (`fraction | (1 << 23)`) is what distinguishes + // 1.0 from 0.0 in the mantissa - their fraction bits are identical (all zero); only the + // implicit bit the `|` folds in carries the value. Drop it (mutate `|` away, or take the + // subnormal branch instead) and both render as "0". + #[test] + fn render_f32_normal_path_folds_in_the_implicit_leading_bit() { + assert_eq!(super::render_f32(1.0_f32).unwrap(), "1"); + assert_eq!(super::render_f32(2.0_f32).unwrap(), "2"); + } + + // R-AI: `(bits >> 31) & 1 == 1` reads the sign bit - but 1.0_f32 and 2.0_f32 (bit pattern + // 0x3F800000 / 0x40000000) both happen to have their *low* bit (bit 0) equal to 0, same as + // their real sign bit (bit 31, also 0 for a positive value) - so a mutant that shifts the + // wrong direction (`bits << 31` instead of `bits >> 31`, reading bit 0 instead of bit 31) + // computes the same "not negative" answer for both by coincidence, and every render test + // above only used positive values. A negative value whose bit 0 differs from its sign bit + // (any of them - the mantissa/exponent bits are unrelated to bit 0) exposes it. + #[test] + fn render_f32_negative_values_carry_the_sign() { + assert_eq!(super::render_f32(-1.0_f32).unwrap(), "-1"); + assert_eq!(super::render_f32(-2.0_f32).unwrap(), "-2"); + } + + // R-AI: render_f64's sign extraction has the identical shape (`(bits >> 63) & 1 == 1`) and + // the identical gap - -1.0_f64 and -2.0_f64's bit 0 is 0, same coincidence as f32's. + #[test] + fn render_f64_negative_values_carry_the_sign() { + assert_eq!(super::render_f64(-1.0_f64).unwrap(), "-1"); + assert_eq!(super::render_f64(-2.0_f64).unwrap(), "-2"); + } + + // R-AI/R-D: `is_canonical` is the Q-J door's grammar + // (`^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$`, minus the literal "-0") and was, until this test, + // never called directly by any test in this crate - only reachable through + // `validate_field`, and only for the handful of spellings + // `validation_refuses_non_canonical_number_spellings` (tests/selection.rs) happens to + // cover. cargo-mutants found every branch mutation in this function surviving as a result. + // This test walks the grammar clause by clause so each comparison, each increment and each + // branch has a true case and a false case pinned against the exact byte shape that flips + // it. + #[test] + fn is_canonical_accepts_the_canonical_grammar() { + // "0", or "0." - a leading zero is canonical only as the whole integer part. + assert!(super::is_canonical("0")); + assert!(super::is_canonical("0.5")); + assert!(super::is_canonical("0.05")); + // "-0" is excluded by name, even though the grammar alone would match it; "-0.5" is a + // different literal and stays canonical. + assert!(!super::is_canonical("-0")); + assert!(super::is_canonical("-0.5")); + // A nonzero leading digit, single- and multi-digit. + assert!(super::is_canonical("5")); + assert!(super::is_canonical("123456789")); + assert!(super::is_canonical("-5")); + assert!(super::is_canonical("-123")); + // A fraction whose last digit is nonzero, one digit and several. + assert!(super::is_canonical("5.1")); + assert!(super::is_canonical("5.01")); + assert!(super::is_canonical("5.100001")); + } + + #[test] + fn is_canonical_rejects_the_empty_string() { + assert!(!super::is_canonical("")); + } + + #[test] + fn is_canonical_rejects_a_lone_minus_sign() { + // After consuming '-', i == bytes.len(): the `i >= bytes.len()` half of the guard. + assert!(!super::is_canonical("-")); + } + + #[test] + fn is_canonical_rejects_a_first_character_that_is_not_a_digit() { + // The `!bytes[i].is_ascii_digit()` half of the same guard, unsigned and signed. + assert!(!super::is_canonical(".")); + assert!(!super::is_canonical(".5")); + assert!(!super::is_canonical("+5")); + assert!(!super::is_canonical("a")); + assert!(!super::is_canonical("-a")); + } + + #[test] + fn is_canonical_rejects_a_leading_zero_followed_by_another_digit() { + assert!(!super::is_canonical("01")); + assert!(!super::is_canonical("00")); + assert!(!super::is_canonical("-01")); + } + + #[test] + fn is_canonical_rejects_a_non_digit_non_dot_after_the_integer_part() { + // Exponent notation, stray whitespace, and any other trailing junk right after the + // integer digits - the `bytes[i] != b'.'` check. + assert!(!super::is_canonical("5e3")); + assert!(!super::is_canonical("123e10")); + assert!(!super::is_canonical("5 ")); + assert!(!super::is_canonical("5,0")); + } + + #[test] + fn is_canonical_rejects_a_bare_trailing_dot() { + // Zero fraction digits: `fraction_len > 0` is false. + assert!(!super::is_canonical("5.")); + assert!(!super::is_canonical("0.")); + } + + #[test] + fn is_canonical_rejects_trailing_bytes_after_the_fraction_digits() { + // The `i != bytes.len()` check inside the fraction loop's tail. + assert!(!super::is_canonical("5.1x")); + assert!(!super::is_canonical("5.12 ")); + } + + #[test] + fn is_canonical_rejects_a_trailing_fractional_zero() { + // `bytes[i - 1] != b'0'`: the last fraction digit must not be zero. + assert!(!super::is_canonical("5.0")); + assert!(!super::is_canonical("5.10")); + assert!(!super::is_canonical("0.10")); + } + + // R-AI: `decompose` and `pad_right` back `compare`'s magnitude comparison and were pinned + // only indirectly, through whichever field-comparison tests happened to exercise `compare` + // - never with inputs chosen to force unequal-length fraction padding, which is the one + // place `pad_right`'s `while owned.len() < width` loop actually runs more than zero times + // on both sides. These call the private helpers directly (this test module is nested + // inside `canonical_number`, so `super::` reaches them) and pin `compare`'s ordering at the + // shapes that exercise sign, integer length, integer digits and padded fraction digits + // separately. + #[test] + fn decompose_splits_sign_integer_and_fraction() { + assert_eq!(super::decompose("123.45"), (false, "123", "45")); + assert_eq!(super::decompose("-123.45"), (true, "123", "45")); + assert_eq!(super::decompose("7"), (false, "7", "")); + assert_eq!(super::decompose("-7"), (true, "7", "")); + } + + #[test] + fn pad_right_pads_to_width_and_leaves_a_wide_enough_value_alone() { + assert_eq!(super::pad_right("5", 3), "500"); + assert_eq!(super::pad_right("", 2), "00"); + assert_eq!(super::pad_right("50", 2), "50"); + assert_eq!(super::pad_right("500", 2), "500"); + } + + #[test] + fn compare_orders_by_sign_then_integer_length_then_digits_then_padded_fraction() { + use super::Ordering; + // Opposite signs: negative is always less, regardless of magnitude. + assert_eq!(super::compare("-1", "1"), Ordering::Less); + assert_eq!(super::compare("1", "-1"), Ordering::Greater); + // Same sign, different integer-part length. + assert_eq!(super::compare("9", "10"), Ordering::Less); + assert_eq!(super::compare("-9", "-10"), Ordering::Greater); + // Same integer length, different digits. + assert_eq!(super::compare("19", "21"), Ordering::Less); + // Equal integer part, fractions of unequal length: pad_right must align them before + // comparing, not compare "5" against "45" lexicographically (which would say "5" > + // "45" as strings - the padded comparison must say "1.5" > "1.45"). + assert_eq!(super::compare("1.5", "1.45"), Ordering::Greater); + assert_eq!(super::compare("1.45", "1.5"), Ordering::Less); + assert_eq!(super::compare("1.50", "1.5"), Ordering::Equal); + // Negative magnitude comparison reverses. + assert_eq!(super::compare("-1.5", "-1.45"), Ordering::Less); + assert_eq!(super::compare("3", "3"), Ordering::Equal); + } + + // R-AI: `canonicalize_decimal_digits` already had one exact-value test; this adds the + // negative-collapses-to-zero branch (`negative && !is_zero`) on both its true and false + // side, which the existing test never reached (it only fed unsigned and "-0.000"). + #[test] + fn canonicalize_decimal_digits_keeps_a_genuine_negative_value_signed() { + assert_eq!(super::canonicalize_decimal_digits("-13.500"), "-13.5"); + assert_eq!(super::canonicalize_decimal_digits("-007"), "-7"); + } + + // R-AI: `big_mul_small` and `big_to_most_significant_first` are private helpers behind + // `exact_decimal_from_mantissa`, reachable indirectly through render_f32/f64 - but every + // render test above happens to pin only the *final* string, so a mutant that corrupts an + // intermediate digit or carry and then has the corruption happen to cancel out (or that + // only misbehaves on inputs those particular mantissas never exercise) can still leave + // every render assertion green. These call both helpers directly with digit sequences + // chosen to force a multi-step carry chain (mirroring the "RS-D-BigMulCarry-Loosening" + // finding's own reasoning: single-digit-times-single-digit-plus-carry never exceeds one + // new leading digit, so a carry chain needs several calls in a row to build multiple + // digits deep). + #[test] + fn big_mul_small_multiplies_in_place_with_carry() { + // 9 * 5 = 45: exercises the carry path (`carry > 0`) pushing one new leading digit. + let mut digits: Vec = vec![9]; // "9", little-endian + super::big_mul_small(&mut digits, 5); + assert_eq!(digits, vec![5, 4]); // "45" + + // Repeated multiplication by 2 on "999" builds a carry chain across every digit: + // 999 * 2 = 1998. + let mut digits: Vec = vec![9, 9, 9]; // "999" + super::big_mul_small(&mut digits, 2); + assert_eq!(digits, vec![8, 9, 9, 1]); // "1998" + + // No carry at all: 10 * 2 = 20, digit count unchanged. + let mut digits: Vec = vec![0, 1]; // "10" + super::big_mul_small(&mut digits, 2); + assert_eq!(digits, vec![0, 2]); // "20" + } + + #[test] + fn big_to_most_significant_first_reverses_into_a_decimal_string() { + assert_eq!(super::big_to_most_significant_first(&vec![9, 9, 1]), "199"); + assert_eq!(super::big_to_most_significant_first(&vec![0]), "0"); + assert_eq!(super::big_to_most_significant_first(&vec![7]), "7"); + } + + // R-AI: `big_from_u64` (the seed of every bignum expansion) was only reachable indirectly + // through render_f32/f64, never pinned on its own digit sequence. + #[test] + fn big_from_u64_produces_little_endian_digits() { + assert_eq!(super::big_from_u64(0), vec![0]); + assert_eq!(super::big_from_u64(9), vec![9]); + assert_eq!(super::big_from_u64(123), vec![3, 2, 1]); + } + + // R-AI: `exact_decimal_from_mantissa` backs both render_f32 and render_f64, but every + // existing render test only pins the *final* string for a real IEEE-754 bit pattern - + // never the exponent sign branch directly, and never a case where the multiply-by-5 + // fraction expansion is *shorter* than the number of fraction digits it needs (`rendered.len() + // <= k`), which only happens for a small mantissa at a deeply negative exponent and is the + // one place the leading-zero padding actually runs. + #[test] + fn exact_decimal_from_mantissa_takes_the_exponent_sign_branch_it_is_given() { + // exponent >= 0: repeated multiply-by-2, no fraction. + assert_eq!(super::exact_decimal_from_mantissa(3, 4, false), "48"); // 3 * 2^4 + // exponent < 0: repeated multiply-by-5, k fraction digits, no padding needed (the + // rendered digit count already exceeds k). + assert_eq!(super::exact_decimal_from_mantissa(5, -1, false), "2.5"); // 5 / 2^1 + } + + #[test] + fn exact_decimal_from_mantissa_pads_leading_fraction_zeros_when_the_digits_run_short() { + // mantissa=1, exponent=-5: 5^5 = 3125 is only 4 digits, but 5 fraction positions are + // needed - `rendered.len() (4) <= k (5)` must trigger the leading-zero pad, or the + // decimal point lands in the wrong place (and, without the pad, `split` underflows). + assert_eq!(super::exact_decimal_from_mantissa(1, -5, false), "0.03125"); // 1 / 2^5 + assert_eq!(super::exact_decimal_from_mantissa(1, -5, true), "-0.03125"); + } + } +} + +// --------------------------------------------------------------------------------------------- +// Schema-alias matching (R-E) +// --------------------------------------------------------------------------------------------- + +/// The one schema-identity rule (R-E), ported from the C# reference's +/// `CultNetSchemaAliasMatching.Matches(string, CultDocumentDescriptor)` / `MatchesAny` - +/// **Self's ruling, 2026-09-22 ("the C# reference's alias rule is the rule")**: an alias is a +/// schema *name* with a trailing `.v` suffix, resolved against the schema's declared name, +/// never against its hash id. C#'s production call sites (`CultNetSelection.cs:438`, +/// `CultNetSelectionEvaluator.cs:88,340`) all use the descriptor overload, never the bare +/// `Matches(string, string)` id-vs-id overload, so that is the behaviour this module ports. D7 +/// still holds: Rust carries no `CultDocumentDescriptor` type, so `RowSet::schema_name`/ +/// `Row::schema_name` stand in for it - the consumer supplies a schema's declared name the same way +/// it supplies everything else `Row`/`RowSet` expose. +pub mod schema_alias { + /// `true` when `candidate` names `schema_id` exactly, names `schema_name` exactly, or + /// `candidate`'s inferred name (the part before a trailing `.v` marker, or the whole of + /// `candidate` when it carries no such marker) equals `schema_name` exactly. Ordinal + /// (case-sensitive) comparison throughout, matching C#'s `StringComparison.Ordinal`. Mirrors + /// `CultNetSchemaAliasMatching.Matches(string candidate, CultDocumentDescriptor descriptor)` + /// (`CultNetDatabase.cs:71-84`) with its `SchemaVersion`/`CompatibleSchemaIds` checks folded + /// away: this crate's `Row`/`RowSet` expose no equivalent of either, and no fixture or call site + /// needs them (`CultNetSelection.cs:438` and `CultNetSelectionEvaluator.cs:88,340` are this + /// port's only production callers, and neither reaches for a schema's version string or its + /// compatibility list). + pub fn matches(candidate: &str, schema_id: &str, schema_name: &str) -> bool { + if candidate == schema_id || candidate == schema_name { + return true; + } + let candidate_name = infer_schema_name(candidate).unwrap_or(candidate); + candidate_name == schema_name + } + + /// `true` when `candidates` is empty (no filter) or any candidate matches `schema_id`/ + /// `schema_name`. Mirrors `CultNetSchemaAliasMatching.MatchesAny(IReadOnlyList, + /// CultDocumentDescriptor)`. + pub fn matches_any(candidates: &[String], schema_id: &str, schema_name: &str) -> bool { + candidates.is_empty() + || candidates + .iter() + .any(|candidate| matches(candidate, schema_id, schema_name)) + } + + /// The part of `schema_id` before a trailing `.v` marker, or `None` when `schema_id` + /// carries no such marker: the marker is absent, sits at position 0 (`".v5"`, C#'s `marker <= 0` + /// refusing a zero index too), has nothing after it (`"a.v"`), or what follows `.v` is not every + /// byte a digit (`"a.vx"`, `"a.v5x"`). A `schema_id` with more than one `.v` marker + /// resolves at the *last* one (`"a.v5.v2"` -> `Some("a.v5")`), matching C#'s + /// `LastIndexOf(".v", Ordinal)`. Byte-for-byte port of ASCII digit classification: C#'s + /// `char.IsDigit` is Unicode-aware but every marker byte here is ASCII, so `is_ascii_digit` + /// agrees on every input this rule ever sees. Mirrors + /// `CultNetSchemaAliasMatching.InferSchemaName` (`CultNetDatabase.cs:86-96`). + fn infer_schema_name(schema_id: &str) -> Option<&str> { + let marker = schema_id.rfind(".v")?; + if marker == 0 { + return None; + } + let version = &schema_id[marker + 2..]; + if version.is_empty() || !version.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + Some(&schema_id[..marker]) + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn matches_exact_ids() { + assert!(matches("sha256:abc", "sha256:abc", "leaf_a")); + assert!(!matches("sha256:abc", "sha256:def", "leaf_a")); + } + + #[test] + fn matches_exact_name() { + assert!(matches("leaf_a", "sha256:abc", "leaf_a")); + assert!(!matches("leaf_b", "sha256:abc", "leaf_a")); + } + + #[test] + fn matches_by_inferred_name_alias_against_the_declared_name() { + assert!(matches("leaf_a.v9", "sha256:abc", "leaf_a")); + assert!(matches("leaf_a.v0", "sha256:abc", "leaf_a")); + assert!(!matches("leaf_b.v9", "sha256:abc", "leaf_a")); + } + + // R-E, the confirmed cross-runtime defect this cut fixes: a hash with a synthetic ".v1" + // appended is not a wire form C#'s production rule ever resolves (it strips the ".v1" and + // compares the bare hash text against the declared *name*, "leaf_a" - never equal to a + // hash). Sections of docs/cultnet-selection-cut.md call this "hashAlias"; it is a negative + // check in both runtimes now, not an alias either one resolves. + #[test] + fn does_not_match_a_hash_shaped_alias() { + assert!(!matches("sha256:abc.v1", "sha256:abc", "leaf_a")); + } + + #[test] + fn does_not_match_a_real_hashed_id_with_no_shared_name() { + assert!(!matches("sha256:abc", "sha256:xyz", "leaf_a")); + } + + #[test] + fn matches_any_empty_candidates_matches_everything() { + assert!(matches_any(&[], "sha256:anything", "leaf_a")); + } + + // Case sensitivity: C#'s StringComparison.Ordinal is case-sensitive throughout, on the + // exact-id check, the exact-name check and the inferred-name comparison alike. + #[test] + fn every_comparison_is_case_sensitive() { + assert!(!matches("SHA256:ABC", "sha256:abc", "leaf_a")); + assert!(!matches("LEAF_A", "sha256:abc", "leaf_a")); + assert!(!matches("LEAF_A.v9", "sha256:abc", "leaf_a")); + assert!(!matches("leaf_a.V9", "sha256:abc", "leaf_a")); // ".V9", not ".v9" - no marker + } + + // Edge handling of ".v" with no digits after it: InferSchemaName returns None, so the + // candidate falls back to itself, verbatim - it only matches a schema whose *name* equals + // that literal string. + #[test] + fn a_trailing_dot_v_with_no_digits_falls_back_to_the_literal_candidate() { + assert!(!matches("leaf_a.v", "sha256:abc", "leaf_a")); + assert!(matches("leaf_a.v", "sha256:abc", "leaf_a.v")); + } + + // Edge handling of multiple dots: the *last* ".v" marker wins, matching C#'s + // LastIndexOf(".v", Ordinal) - not the first. + #[test] + fn multiple_dot_v_markers_resolve_at_the_last_one() { + assert!(matches("a.v5.v2", "sha256:abc", "a.v5")); + assert!(!matches("a.v5.v2", "sha256:abc", "a")); + } + + #[test] + fn infer_schema_name_refuses_a_leading_marker_and_a_non_numeric_or_empty_version() { + assert_eq!(infer_schema_name(".v1"), None); + assert_eq!(infer_schema_name("name.vX"), None); + assert_eq!(infer_schema_name("name.v"), None); + assert_eq!(infer_schema_name("name.v1"), Some("name")); + } + } +} + +// --------------------------------------------------------------------------------------------- +// Wire types (section 2) +// --------------------------------------------------------------------------------------------- + +/// The four comparison and one any-of operator a field predicate may carry. `FieldPredicate::op` is +/// the wire's raw string (never a serde-tagged union); this enum exists for evaluator ergonomics +/// only, mirroring `CultNetSelectionOperator`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SelectionOperator { + AnyOf, + Lt, + Le, + Ge, + Gt, +} + +impl SelectionOperator { + /// Parses a wire operator string; `None` for anything else. + pub fn parse(wire: &str) -> Option { + match wire { + "any_of" => Some(Self::AnyOf), + "lt" => Some(Self::Lt), + "le" => Some(Self::Le), + "ge" => Some(Self::Ge), + "gt" => Some(Self::Gt), + _ => None, + } + } + + /// The wire spelling of this operator. + pub fn as_wire_str(self) -> &'static str { + match self { + Self::AnyOf => "any_of", + Self::Lt => "lt", + Self::Le => "le", + Self::Ge => "ge", + Self::Gt => "gt", + } + } + + /// `true` for the four comparison operators (everything but `any_of`). + pub fn is_comparison(self) -> bool { + !matches!(self, Self::AnyOf) + } +} + +/// `"header"` under [`Selection::projection`]. +pub const PROJECTION_HEADER: &str = "header"; +/// `"document"` under [`Selection::projection`]. +pub const PROJECTION_DOCUMENT: &str = "document"; + +fn default_projection() -> String { + PROJECTION_HEADER.to_string() +} + +/// Identifies one row on the wire by schema and record key. +#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecordRef { + pub schema_id: String, + pub record_key: String, +} + +impl RecordRef { + pub fn new(schema_id: impl Into, record_key: impl Into) -> Self { + Self { + schema_id: schema_id.into(), + record_key: record_key.into(), + } + } +} + +/// One predicate over a single declared index alias (section 2). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FieldPredicate { + /// A declared index alias, reachable on some schema the selection can reach. + pub index: String, + /// `"any_of"`, `"lt"`, `"le"`, `"ge"`, or `"gt"`. + pub op: String, + /// Present iff `op = any_of`; non-empty. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub values: Option>, + /// Present iff `op` is a comparison; a canonical decimal string (Q-J). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub number: Option, +} + +/// Selects rows whose declared reference names a target row. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Citation { + pub target: RecordRef, + /// The reference's declared alias; absent matches any declared reference. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, +} + +/// Selects rows some other row does or does not name in `role` - the one negation. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Incoming { + pub role: String, + pub exists: bool, +} + +/// One typed selection, carried by `cultnet.snapshot_request.v1` and +/// `cultnet.database_subscribe.v1` (section 2). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Selection { + /// Kinds: schema ids. Absent = every schema. (No alias matching in this runtime - D7: Rust's + /// row owner has no schema-alias registry; a caller wanting an alias expands it before calling.) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schemas: Option>, + /// Record-key allowlist. Absent = every key. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keys: Option>, + /// Conjunction; each is any-of over one declared index alias. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fields: Option>, + /// Rows whose declared reference names `target`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cites: Option, + /// Rows some row does or does not name in `role`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cited: Option, + /// `"header"` or `"document"`; default `"header"`. + #[serde(default = "default_projection")] + pub projection: String, + /// Reverses the one order. + #[serde(default)] + pub descending: bool, + /// Clamped 1..=200. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Opaque; minted by the answering server. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +impl Default for Selection { + fn default() -> Self { + Self { + schemas: None, + keys: None, + fields: None, + cites: None, + cited: None, + projection: default_projection(), + descending: false, + limit: None, + cursor: None, + } + } +} + +impl Selection { + /// `true` when this selection follows an edge (section 2: "Edges in the answer"). + pub fn has_hop(&self) -> bool { + self.cites.is_some() || self.cited.is_some() + } +} + +/// One edge a hop traversed, carried on a selection page. +/// +/// R-AP: a decodable *fixed-shape* map is worth more than brevity, and `skip_serializing_if` +/// undermines the compact positional form anyway (a struct's `n`th absent optional turns an +/// n-element array into a shorter one, undecodable against the declared shape) - every field here +/// is always present on the wire, `nil` when absent, in this declared order, the same rule +/// `RawDocumentHeader`/`SelectionDocumentRecord`/`SelectionPage` follow below. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Edge { + /// The citing row. + pub from: RecordRef, + /// The declared reference alias the citer used. + pub role: String, + /// The cited row, its schema resolved to the leaf actually stored. + pub to: RecordRef, + /// `"messagepack"` when `payload` is present. + #[serde(default)] + pub payload_encoding: Option, + /// The value attached to the edge (a dictionary reference's value); present under `document` + /// projection only. + #[serde(default, with = "optional_bytes")] + pub payload: Option>, +} + +/// `serde_bytes` has no `Option>` submodule; this is the same MessagePack `bin`/`nil` +/// encoding, spelled for the optional case. +mod optional_bytes { + use serde::{Deserialize, Deserializer, Serializer}; + use serde_bytes::{ByteBuf, Bytes}; + + pub fn serialize(value: &Option>, serializer: S) -> Result { + match value { + Some(bytes) => serializer.serialize_some(Bytes::new(bytes)), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result>, D::Error> { + Ok(Option::::deserialize(deserializer)?.map(ByteBuf::into_vec)) + } +} + +/// A header record: [`SelectionDocumentRecord`] minus its payload and payload encoding. +/// +/// R-AP: every field always present, `nil` when absent, in this declared order (see `Edge`'s doc +/// comment above). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RawDocumentHeader { + pub schema_id: String, + #[serde(default)] + pub schema_name: Option, + #[serde(default)] + pub schema_version: Option, + #[serde(default)] + pub schema_content_hash: Option, + pub record_key: String, + pub stored_at: String, + #[serde(default)] + pub source_runtime_id: Option, + #[serde(default)] + pub source_agent_id: Option, + #[serde(default)] + pub source_role: Option, + #[serde(default)] + pub tags: Option>, +} + +/// The full record `cultnet.snapshot_response_raw.v1` carries under `document` projection. Distinct +/// from `contracts::CultNetRawDocumentRecord` (the v0 raw record), which lacks `schemaName`/ +/// `schemaVersion`/`schemaContentHash` - a pre-existing gap in the v0 Rust struct relative to the C# +/// reference's `CultNetRawDocumentRecord` that this cut does not touch (out of scope: v0's wire is +/// unchanged). v1 reuses the reference's richer, already-existing C# type, so this Rust type matches +/// that one, not v0's. +// R-AP: every optional field always present, `nil` when absent, in this declared order (see +// `Edge`'s doc comment above). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SelectionDocumentRecord { + pub schema_id: String, + #[serde(default)] + pub schema_name: Option, + #[serde(default)] + pub schema_version: Option, + #[serde(default)] + pub schema_content_hash: Option, + pub record_key: String, + pub stored_at: String, + #[serde(default = "messagepack_encoding")] + pub payload_encoding: String, + #[serde(with = "serde_bytes")] + pub payload: Vec, + #[serde(default)] + pub source_runtime_id: Option, + #[serde(default)] + pub source_agent_id: Option, + #[serde(default)] + pub source_role: Option, + #[serde(default)] + pub tags: Option>, +} + +fn messagepack_encoding() -> String { + "messagepack".to_string() +} + +// --------------------------------------------------------------------------------------------- +// Refusals (section 2/3, typed at the door) +// --------------------------------------------------------------------------------------------- + +/// Refused typed at the door: a name, list, or comparison the declared shape cannot support. +/// Mirrors `CultNetSelectionInvalidException`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SelectionInvalid { + /// The selection field that failed (`"schemas"`, `"keys"`, `"fields[i].index"`, ...). + pub field: String, + /// The offending value, when there is one string worth naming. + pub value: Option, + pub message: String, +} + +impl SelectionInvalid { + fn new(field: impl Into, value: Option, message: impl Into) -> Self { + Self { + field: field.into(), + value, + message: message.into(), + } + } +} + +impl fmt::Display for SelectionInvalid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "selection_invalid[{}]: {}", self.field, self.message) + } +} + +impl std::error::Error for SelectionInvalid {} + +/// Every way `select`/`matches` can refuse rather than answer (section 2/3/8). +#[derive(Clone, Debug, PartialEq)] +pub enum SelectionRefusal { + Invalid(SelectionInvalid), + /// The cursor's `asOf` does not match the snapshot being answered. + CursorStale { as_of: u64, current: u64 }, + /// The cursor does not decode, or its digest is not this selection's. + CursorInvalid { message: String }, + /// A stored edge names a row whose schema is outside the reference's declared target (D9/S18). + ReferenceOutsideTarget { + from_schema_id: String, + from_key: String, + role: String, + to_schema_id: String, + to_key: String, + }, +} + +impl fmt::Display for SelectionRefusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(inner) => write!(f, "{inner}"), + Self::CursorStale { as_of, current } => write!( + f, + "cursor_stale: the cursor was minted at asOf {as_of}; this server answers as of {current}." + ), + Self::CursorInvalid { message } => write!(f, "cursor_invalid: {message}"), + Self::ReferenceOutsideTarget { + from_schema_id, + from_key, + role, + to_schema_id, + to_key, + } => write!( + f, + "reference_outside_target: {from_schema_id}/{from_key} names {to_schema_id}/{to_key} through role \"{role}\", which is outside the reference's declared target." + ), + } + } +} + +impl std::error::Error for SelectionRefusal {} + +impl From for SelectionRefusal { + fn from(value: SelectionInvalid) -> Self { + Self::Invalid(value) + } +} + +// R-N (docs/cultnet-selection-cut.md, fix batch 3): the wire shape below is matched byte-for-byte +// against the landed C# reference (CultNetErrorMessage.ForSelectionInvalid/ForCursor/ +// ForReferenceOutsideTarget in src/GameCult.Networking/CultNetSchemaMessages.cs) - see +// packages/cultnet-rs/tests/error_message.rs for the comparison against captured C# bytes. +impl From<&SelectionRefusal> for CultNetMessage { + fn from(refusal: &SelectionRefusal) -> Self { + match refusal { + SelectionRefusal::Invalid(inner) => CultNetMessage::Error { + error: format!("selection_invalid: {}", inner.message), + code: Some(CultNetErrorCode::SelectionInvalid), + details: Some(CultNetErrorDetails { + field: Some(inner.field.clone()), + value: inner.value.clone(), + as_of: None, + current: None, + }), + }, + SelectionRefusal::CursorStale { as_of, current } => CultNetMessage::Error { + error: format!( + "cursor_stale: The cursor was minted at asOf {as_of}; this server answers as of {current}." + ), + code: Some(CultNetErrorCode::CursorStale), + details: Some(CultNetErrorDetails { + field: None, + value: None, + as_of: Some(*as_of), + current: Some(*current), + }), + }, + SelectionRefusal::CursorInvalid { message } => CultNetMessage::Error { + error: format!("cursor_invalid: {message}"), + code: Some(CultNetErrorCode::CursorInvalid), + details: None, + }, + SelectionRefusal::ReferenceOutsideTarget { + from_schema_id, + from_key, + role, + to_schema_id, + to_key, + } => CultNetMessage::Error { + error: format!( + "reference_outside_target: {from_schema_id}/{from_key} names {to_schema_id}/{to_key} through role \"{role}\", which is outside the reference's declared target." + ), + code: Some(CultNetErrorCode::ReferenceOutsideTarget), + details: None, + }, + } + } +} + +impl From for CultNetMessage { + fn from(refusal: SelectionRefusal) -> Self { + CultNetMessage::from(&refusal) + } +} + +// --------------------------------------------------------------------------------------------- +// Row / RowSet (D7): the consumer's declarations and values, reflected over nothing +// --------------------------------------------------------------------------------------------- + +/// One row available to the evaluator: what the row owner already knows about it. Implemented by +/// the consumer (Huginn, or a test fixture) - the Rust spelling of what `CultCache`'s public read +/// surface supplies the C# evaluator. +pub trait Row { + fn schema_id(&self) -> &str; + /// This row's declared schema *name* (C#'s `CultDocumentDescriptor.SchemaName`) - the alias + /// matcher's name-comparison target (R-E, `schema_alias::matches`). A row whose owner supplies + /// no separate name (e.g. a raw wire record with only a hash id) falls back to `schema_id()`, + /// which is the same "no name known" behaviour the C# reference has none of: every C# document + /// descriptor always carries a real `SchemaName`, so this default exists only for Rust rows + /// that genuinely have nothing better, and it costs them nothing they had before (the fallback + /// still matches an exact id, just never a name alias). + fn schema_name(&self) -> &str { + self.schema_id() + } + fn record_key(&self) -> &str; + /// The sequence of the commit that last wrote this row. The row owner supplies it; the + /// evaluator never reads a clock. + fn ordinal(&self) -> i64; + /// Every declared value at `index` on this row (usually zero or one; `any_of` matches + /// membership, matching the cache's own string-valued indexes). + fn values(&self, index: &str) -> Vec; + /// This row's declared numeric value at `index`, as its exact canonical decimal (Q-J) - never + /// `f64`. `None` when the row has no value, or the value is NaN/infinite. + fn number(&self, index: &str) -> Option; + /// Every declared reference edge this row carries: `(role, target_record_key, payload)`. A + /// one-reference yields one entry, a many-reference one per element, a dictionary reference one + /// per key with its value as the payload (D11). + /// + /// R-AF: the target is a bare record key, not a `RecordRef` - a stored reference carries no + /// schema id on the wire (`GameCult.Caching.MessagePack.CultRecordRefFormatter` serializes a + /// `CultRecordRef` as only its key string; probed against real serialized bytes, + /// docs/cultnet-selection-cut.md, "R-AF"). `resolve_reference_target` turns this bare key into a + /// resolved row, by record key plus the declared leaf set (D9) - never by an exact (schema, key) + /// pair, which would assume a schema the edge never actually carries. + fn references(&self) -> Vec<(String, String, Option>)>; +} + +impl Row for &T { + fn schema_id(&self) -> &str { + (**self).schema_id() + } + fn schema_name(&self) -> &str { + (**self).schema_name() + } + fn record_key(&self) -> &str { + (**self).record_key() + } + fn ordinal(&self) -> i64 { + (**self).ordinal() + } + fn values(&self, index: &str) -> Vec { + (**self).values(index) + } + fn number(&self, index: &str) -> Option { + (**self).number(index) + } + fn references(&self) -> Vec<(String, String, Option>)> { + (**self).references() + } +} + +/// The consumer's schema-level declarations: what `select`/`validate` need to know about the shape +/// of the rows, independent of any one row's values. +pub trait RowSet { + /// Every schema id this row set knows about - the universe `selection.schemas` narrows. + fn all_schema_ids(&self) -> Vec; + /// `schema_id`'s declared name (C#'s `CultDocumentDescriptor.SchemaName`) - the alias matcher's + /// name-comparison target (R-E). `None` when `schema_id` is not one of `all_schema_ids()`. + fn schema_name(&self, schema_id: &str) -> Option; + /// The declared index aliases a schema carries (inherited or own). + fn declared_indexes(&self, schema_id: &str) -> Vec; + /// The declared reference roles a schema carries. + fn declared_roles(&self, schema_id: &str) -> Vec; + /// `true` when `index` is declared numeric on `schema_id`. + fn is_numeric(&self, schema_id: &str, index: &str) -> bool; + /// The schema ids a reference named `role` may target. An empty result means the role carries + /// no restriction the hop must enforce (mirrors C#'s `TargetType == null` skipping the check) - + /// a `RowSet` with a real, closed target set returns it non-empty. + fn target_leaves(&self, role: &str) -> Vec; +} + +// --------------------------------------------------------------------------------------------- +// Validation (S1, the door) +// --------------------------------------------------------------------------------------------- + +/// Validates a [`Selection`] against the declared shape of the schemas it can reach - the door +/// refusals of section 2/3 (S1). Mirrors `CultNetSelectionValidation.Validate`. +pub fn validate(selection: &Selection, row_set: &impl RowSet) -> Result<(), SelectionInvalid> { + if matches!(&selection.schemas, Some(schemas) if schemas.is_empty()) { + return Err(SelectionInvalid::new( + "schemas", + None, + "selection.schemas is present and empty; omit it to reach every schema.", + )); + } + if matches!(&selection.keys, Some(keys) if keys.is_empty()) { + return Err(SelectionInvalid::new( + "keys", + None, + "selection.keys is present and empty; omit it to reach every key.", + )); + } + // Self's ruling, 2026-09-22 (docs/cultnet-selection-cut.md, commit 2 fix batch): section 2 + // already said an empty list is refused at the door; the door must refuse a blank entry + // inside a present list too, so the evaluator never has to decide what "" or " " means. + // `null` (Rust: `None`) means no filter, everywhere - an empty-or-blank list is never + // silently treated as "no filter" by the evaluator (that was CultNetSelectionEvaluator.cs's + // pre-fix bug, section 2's S2-3 finding, and this Rust evaluator never had it). + // Field name matches the C# reference exactly (568e8e3): plain "schemas"/"keys", not an + // indexed "schemas[i]" - CultNetSelectionInvalidException("schemas", schema, ...) names the + // list, not the position, and the parity vectors compare this string. + if let Some(schemas) = &selection.schemas { + for schema in schemas { + if schema.trim().is_empty() { + return Err(SelectionInvalid::new( + "schemas", + Some(schema.clone()), + "selection.schemas carries an empty or whitespace entry.", + )); + } + } + } + if let Some(keys) = &selection.keys { + for key in keys { + if key.trim().is_empty() { + return Err(SelectionInvalid::new( + "keys", + Some(key.clone()), + "selection.keys carries an empty or whitespace entry.", + )); + } + } + } + if selection.projection != PROJECTION_HEADER && selection.projection != PROJECTION_DOCUMENT { + return Err(SelectionInvalid::new( + "projection", + Some(selection.projection.clone()), + format!( + "selection.projection {:?} is neither \"header\" nor \"document\".", + selection.projection + ), + )); + } + + let reachable = reachable_schemas(selection, row_set); + + if let Some(fields) = &selection.fields { + for (index, field) in fields.iter().enumerate() { + validate_field(field, index, &reachable, row_set)?; + } + } + + if let Some(cites) = &selection.cites { + if cites.target.schema_id.is_empty() || cites.target.record_key.is_empty() { + return Err(SelectionInvalid::new( + "cites.target", + None, + "selection.cites.target requires schemaId and recordKey.", + )); + } + // R-E: cites.target.schemaId goes through the one schema-identity rule too. An unmatched + // target is refused at the door, never silently answered with an empty page. + if !row_set.all_schema_ids().iter().any(|schema_id| { + let name = row_set.schema_name(schema_id).unwrap_or_else(|| schema_id.clone()); + schema_alias::matches(&cites.target.schema_id, schema_id, &name) + }) { + return Err(SelectionInvalid::new( + "cites.target.schemaId", + Some(cites.target.schema_id.clone()), + format!( + "selection.cites.target.schemaId {:?} matches no known schema.", + cites.target.schema_id + ), + )); + } + if let Some(role) = &cites.role + && !any_schema_declares_role(row_set, role) + { + return Err(SelectionInvalid::new( + "cites.role", + Some(role.clone()), + format!("selection.cites.role {role:?} is not declared by any schema."), + )); + } + } + + if let Some(cited) = &selection.cited { + if cited.role.is_empty() { + return Err(SelectionInvalid::new( + "cited.role", + None, + "selection.cited.role must be non-empty.", + )); + } + if !any_schema_declares_role(row_set, &cited.role) { + return Err(SelectionInvalid::new( + "cited.role", + Some(cited.role.clone()), + format!( + "selection.cited.role {:?} is not declared by any schema.", + cited.role + ), + )); + } + } + + Ok(()) +} + +/// The schemas [`Selection::schemas`] reaches - every schema when absent. +pub fn reachable_schemas(selection: &Selection, row_set: &impl RowSet) -> Vec { + let all = row_set.all_schema_ids(); + match &selection.schemas { + None => all, + // R-E: reachability goes through the alias matcher, not exact equality. + Some(schemas) => all + .into_iter() + .filter(|id| { + let name = row_set.schema_name(id).unwrap_or_else(|| id.clone()); + schema_alias::matches_any(schemas, id, &name) + }) + .collect(), + } +} + +fn any_schema_declares_role(row_set: &impl RowSet, role: &str) -> bool { + row_set + .all_schema_ids() + .iter() + .any(|schema_id| row_set.declared_roles(schema_id).iter().any(|r| r == role)) +} + +fn validate_field( + field: &FieldPredicate, + index: usize, + reachable: &[String], + row_set: &impl RowSet, +) -> Result<(), SelectionInvalid> { + let prefix = format!("fields[{index}]"); + if field.index.is_empty() { + return Err(SelectionInvalid::new( + format!("{prefix}.index"), + None, + format!("{prefix}.index must be non-empty."), + )); + } + let Some(op) = SelectionOperator::parse(&field.op) else { + return Err(SelectionInvalid::new( + format!("{prefix}.op"), + Some(field.op.clone()), + format!("{prefix}.op {:?} is not any_of/lt/le/ge/gt.", field.op), + )); + }; + + if op == SelectionOperator::AnyOf { + match &field.values { + None => { + return Err(SelectionInvalid::new( + format!("{prefix}.values"), + None, + format!("{prefix}.values must be non-empty for op any_of."), + )); + } + Some(values) if values.is_empty() => { + return Err(SelectionInvalid::new( + format!("{prefix}.values"), + None, + format!("{prefix}.values must be non-empty for op any_of."), + )); + } + _ => {} + } + if field.number.is_some() { + return Err(SelectionInvalid::new( + format!("{prefix}.number"), + None, + format!("{prefix} carries both values and number; any_of takes only values."), + )); + } + } else { + match &field.number { + None => { + return Err(SelectionInvalid::new( + format!("{prefix}.number"), + None, + format!("{prefix}.number is required for op {}.", field.op), + )); + } + Some(number) if !canonical_number::is_canonical(number) => { + return Err(SelectionInvalid::new( + format!("{prefix}.number"), + Some(number.clone()), + format!( + "{prefix}.number {number:?} is not a canonical decimal string (Q-J): it must match {} and not be \"-0\".", + r"^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$" + ), + )); + } + _ => {} + } + if field.values.is_some() { + return Err(SelectionInvalid::new( + format!("{prefix}.values"), + None, + format!("{prefix} carries both values and number; a comparison takes only number."), + )); + } + } + + let declaring: Vec<&String> = reachable + .iter() + .filter(|schema_id| { + row_set + .declared_indexes(schema_id) + .iter() + .any(|alias| alias == &field.index) + }) + .collect(); + if declaring.is_empty() { + return Err(SelectionInvalid::new( + format!("{prefix}.index"), + Some(field.index.clone()), + format!( + "{prefix}.index {:?} is not declared by any schema this selection can reach.", + field.index + ), + )); + } + if op.is_comparison() { + for schema_id in &declaring { + if !row_set.is_numeric(schema_id, &field.index) { + return Err(SelectionInvalid::new( + format!("{prefix}.index"), + Some(field.index.clone()), + format!( + "{prefix}.index {:?} is declared non-numeric on a schema this selection can reach; comparisons need a numeric declaration everywhere the alias is reachable.", + field.index + ), + )); + } + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------------------------- +// Evaluation (D6/D7: the single-row fast path and the full Select) +// --------------------------------------------------------------------------------------------- + +/// The lowest and highest limit a page may carry (section 2). +pub const LIMIT_MIN: u32 = 1; +pub const LIMIT_MAX: u32 = 200; + +/// The single-row fast path (D6): schemas, keys and fields only. A hop-bearing selection +/// ([`Selection::has_hop`]) is set-dependent and must not use this path - the caller reconciles the +/// full row set with [`select`] instead. +/// +/// R-F: this is a real, always-on `assert!`, not a `debug_assert!` - a release build must not +/// silently ignore `cites`/`cited` by skipping straight to the schema/keys/fields comparison; a +/// caller that reaches this with a hop-bearing selection has a programming error to fix, not a +/// page to answer wrong. +pub fn matches(row: &R, selection: &Selection) -> bool { + assert!( + !selection.has_hop(), + "a hop-bearing selection (cites/cited) is set-dependent and cannot use the single-row \ + matches fast path; reconcile the full row set with select() instead" + ); + matches_schema_keys_fields(row, selection) +} + +fn matches_schema_keys_fields(row: &R, selection: &Selection) -> bool { + // R-E, but deliberately not `schema_alias::matches_any`: that helper's `candidates.is_empty()` + // shortcut mirrors the C# reference's own `MatchesAny(schemaId)` on purpose (R-E), but this is + // the evaluator's fast path, reached without going through `validate` on the v0 lowering path + // (`serve_read_only_raw_snapshot`, which never calls `select`/`validate` by design - v0 has its + // own null-collapsing). An empty-but-present `schemas` must never be silently read as "every + // schema" here - only `None` means no filter. A plain `.any()` over an empty slice is already + // `false` (matches nothing), which is what a present-but-empty list must do until something + // upstream (the v1 door, or v0's own lowering) turns it into `None` or refuses it outright. + if let Some(schemas) = &selection.schemas + && !schemas + .iter() + .any(|candidate| schema_alias::matches(candidate, row.schema_id(), row.schema_name())) + { + return false; + } + if let Some(keys) = &selection.keys + && !keys.iter().any(|k| k == row.record_key()) + { + return false; + } + let Some(fields) = &selection.fields else { + return true; + }; + + for field in fields { + let Some(op) = SelectionOperator::parse(&field.op) else { + return false; + }; + if op == SelectionOperator::AnyOf { + let values = row.values(&field.index); + let Some(wanted) = &field.values else { + return false; + }; + if !values.iter().any(|v| wanted.iter().any(|w| w == v)) { + return false; + } + } else { + let Some(number) = row.number(&field.index) else { + return false; + }; + let Some(compared) = &field.number else { + return false; + }; + if !compare_number(&number, op, compared) { + return false; + } + } + } + true +} + +fn compare_number(left: &str, op: SelectionOperator, right: &str) -> bool { + let ordering = canonical_number::compare(left, right); + match op { + SelectionOperator::Lt => ordering == Ordering::Less, + SelectionOperator::Le => ordering != Ordering::Greater, + SelectionOperator::Ge => ordering != Ordering::Less, + SelectionOperator::Gt => ordering == Ordering::Greater, + SelectionOperator::AnyOf => unreachable!("any_of never reaches compare_number"), + } +} + +/// One edge a hop traversed, resolved against the row set in hand. +#[derive(Clone, Debug)] +pub struct EdgeMatch { + pub from: R, + pub role: String, + pub to: R, + pub payload: Option>, + /// R-W: which end of this edge is the page row that produced it - `cites` anchors on the + /// citer (`from`), `cited` anchors on the citee (`to`). A selection carrying both hops mixes + /// edges from both sources in one `Vec`, so each edge must carry its own anchor rather than + /// the whole batch being filtered by one shared direction. + pub anchor: EdgeAnchor, +} + +/// Which end of an [`EdgeMatch`] is the page row it was found through (R-W). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EdgeAnchor { + /// Produced by `cites`: the page row is the citer, `from`. + Citer, + /// Produced by `cited`: the page row is the citee, `to`. + Citee, +} + +/// Evaluated, ordered, paged rows plus the edges a hop traversed. +#[derive(Clone, Debug)] +pub struct Evaluation { + pub rows: Vec, + /// The total number of matches (C5, R-G/P-1) - not the page count. One evaluation answers + /// both v0 and shard paging as a single snapshot. + pub matched: u32, + pub edges: Vec>, + pub next_cursor: Option, +} + +/// Evaluates a selection over the full row set: schemas/keys/fields, the hop (`cites`/`cited`, with +/// its edges and the `reference_outside_target` refusal), order, cursor and paging. Mirrors +/// `CultNetSelectionEvaluator.Select`. +/// +/// R-F: the door is inside `select` - this validates first and returns the typed refusal; there is +/// no public entry point that evaluates an unvalidated selection. +pub fn select( + row_set: &impl RowSet, + all_rows: &[R], + selection: &Selection, + as_of: u64, + cursor_key: &CursorKey, +) -> Result, SelectionRefusal> { + validate(selection, row_set)?; + + // R-AF: keyed by record key alone, not `(schemaId, recordKey)` - a stored reference edge + // carries no schema id on the wire (see `Row::references`'s doc comment), so resolving "the + // row this edge names" must search every row sharing a key, not look one up by a pair the edge + // never actually supplies. Two schemas legitimately sharing one record key both live under + // that key's entry; `resolve_reference_target` is what picks the one inside the declared + // target. + let mut by_key: HashMap<&str, Vec<&R>> = HashMap::new(); + for row in all_rows { + by_key.entry(row.record_key()).or_default().push(row); + } + + let mut candidates: Vec<&R> = all_rows + .iter() + .filter(|row| matches_schema_keys_fields(*row, selection)) + .collect(); + + let mut edges: Vec> = Vec::new(); + + if let Some(cited) = &selection.cited { + let incoming = build_incoming_index(row_set, all_rows, &by_key, &cited.role, &mut edges)?; + let exists = cited.exists; + // R-V: membership is by (schemaId, recordKey) - a row of one schema is not "cited" + // merely because some other row cites a different schema sharing its record key. + candidates.retain(|row| { + incoming.contains(&(row.schema_id().to_string(), row.record_key().to_string())) == exists + }); + } + + if let Some(cites) = &selection.cites { + let mut kept = Vec::with_capacity(candidates.len()); + for row in candidates { + if matches_citation(row_set, &by_key, row, cites, &mut edges)? { + kept.push(row); + } + } + candidates = kept; + } + + let mut matched = candidates; + matched.sort_by(|a, b| order_rows(*a, *b, selection.descending)); + let matched_total = matched.len() as u32; + + let mut start_index = 0usize; + if let Some(cursor_text) = selection.cursor.as_deref() + && !cursor_text.is_empty() + { + let cursor = Cursor::parse(cursor_text)?; + // R-O: a forged cursor, or one minted under a different process's key, fails this check - + // digest equality alone used to be enough to mint one; now the key is required too. + if !cursor.verify_digest(selection, cursor_key) { + return Err(SelectionRefusal::CursorInvalid { + message: "The cursor's selection digest does not match this selection.".into(), + }); + } + if cursor.as_of != as_of { + return Err(SelectionRefusal::CursorStale { + as_of: cursor.as_of, + current: as_of, + }); + } + start_index = find_cursor_position(&matched, &cursor, selection.descending); + } + + let limit = selection + .limit + .unwrap_or(LIMIT_MAX) + .clamp(LIMIT_MIN, LIMIT_MAX) as usize; + let page: Vec = matched + .iter() + .skip(start_index) + .take(limit) + .map(|row| (*row).clone()) + .collect(); + let has_next = start_index + page.len() < matched.len(); + let next_cursor = if has_next && !page.is_empty() { + Some(Cursor::mint( + as_of, + page.last().expect("checked non-empty"), + selection, + cursor_key, + )) + } else { + None + }; + + // R-B/R-W: hop edges follow the hop's own direction, per edge - not one direction for the + // whole batch. A selection carrying both `cites` and `cited` puts edges from both functions + // into one `Vec`, and each edge already knows which end (R-V: (schemaId, recordKey)) is the + // page row that produced it (`EdgeAnchor`): `cites` anchors on the citer (`from`), `cited` + // anchors on the citee (`to`). Order is deterministic: page-row order, then (from, role, + // to.record_key) in code-point order - never a `HashMap`'s iteration order. + // + // R-BA (docs/cultnet-selection-cut.md, "Soul, the merge gate, fourth pass"): `to.schema_id` + // used to sit last in this tiebreak and has been deleted. `resolve_reference_target` (this + // file) resolves purely from `(role, record_key)` against the evaluation's own fixed `by_key` + // and `target_leaves(role)` - neither depends on which edge or which `from` is asking - so + // within one evaluation the same `(role, record_key)` pair always resolves to the same row, + // schema included. That means once `from`, `role` and `to.record_key` already tie, `to.schema_id` + // is forced equal; no input can make it decide an order the earlier four keys did not already + // decide. Soul verified no construction breaks this (a component that cannot be distinguished + // by any input is a line every future reader must reason about for no behavioural gain). + let page_edges = if selection.has_hop() { + let page_position: HashMap<(&str, &str), usize> = page + .iter() + .enumerate() + .map(|(index, row)| ((row.schema_id(), row.record_key()), index)) + .collect(); + fn owner_ref<'a, R: Row + Clone>(edge: &'a EdgeMatch) -> (&'a str, &'a str) { + match edge.anchor { + EdgeAnchor::Citee => (edge.to.schema_id(), edge.to.record_key()), + EdgeAnchor::Citer => (edge.from.schema_id(), edge.from.record_key()), + } + } + let mut kept: Vec> = edges + .into_iter() + .filter(|edge| page_position.contains_key(&owner_ref(edge))) + .collect(); + kept.sort_by(|a, b| { + page_position[&owner_ref(a)] + .cmp(&page_position[&owner_ref(b)]) + .then_with(|| a.from.schema_id().cmp(b.from.schema_id())) + .then_with(|| a.from.record_key().cmp(b.from.record_key())) + .then_with(|| a.role.cmp(&b.role)) + .then_with(|| a.to.record_key().cmp(b.to.record_key())) + }); + kept + } else { + Vec::new() + }; + + Ok(Evaluation { + rows: page, + matched: matched_total, + edges: page_edges, + next_cursor, + }) +} + +// --------------------------------------------------------------------------------------------- +// Page projection (R-I): `cultnet.snapshot_response_raw.v1`'s wire shape +// --------------------------------------------------------------------------------------------- + +/// `cultnet.snapshot_response_raw.v1`'s page (section 2 `SelectionPage`): `matched`, the page +/// itself under [`Selection::projection`], and the hop's edges. Carried on the wire by the +/// consumer's own message envelope (`shardId`/`shardEpoch`/`shardLogSequence`, `messageId`, ... - +/// declared once already in `contracts.rs`'s `CultNetMessage::SnapshotResponseRawV1`); this type +/// is the page body those fields wrap. +// R-AP: the payload, not the envelope - matched/asOf/next/headers/documents/edges in this +// declared order on both runtimes, every optional field always present and `nil` when absent +// (never omitted - see `Edge`'s doc comment above). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SelectionPage { + pub matched: u32, + pub as_of: u64, + #[serde(default)] + pub next: Option, + #[serde(default)] + pub headers: Option>, + #[serde(default)] + pub documents: Option>, + #[serde(default)] + pub edges: Option>, +} + +/// Evaluates a selection and projects it into the wire's `SelectionPage` shape (R-I): `select`'s +/// generic rows become `headers` or `documents` under [`Selection::projection`], never both, and a +/// header projection carries no payload - in the page's rows and in its edges alike (S20). The +/// caller supplies `document_record`, the one place a full wire record is built from an `R` (D7: +/// the evaluator never reflects over a row; the row owner renders it). +pub fn select_page( + row_set: &impl RowSet, + all_rows: &[R], + selection: &Selection, + as_of: u64, + cursor_key: &CursorKey, + document_record: impl Fn(&R) -> SelectionDocumentRecord, +) -> Result { + let evaluation = select(row_set, all_rows, selection, as_of, cursor_key)?; + let want_document = selection.projection == PROJECTION_DOCUMENT; + + let (headers, documents) = if want_document { + (None, Some(evaluation.rows.iter().map(&document_record).collect())) + } else { + ( + Some( + evaluation + .rows + .iter() + .map(|row| header_from_record(&document_record(row))) + .collect(), + ), + None, + ) + }; + + let edges = if selection.has_hop() { + Some( + evaluation + .edges + .iter() + .map(|edge| Edge { + from: RecordRef::new(edge.from.schema_id(), edge.from.record_key()), + role: edge.role.clone(), + to: RecordRef::new(edge.to.schema_id(), edge.to.record_key()), + // S20: a header projection carries no payload, in edges too. + payload_encoding: if want_document && edge.payload.is_some() { + Some(messagepack_encoding()) + } else { + None + }, + payload: if want_document { edge.payload.clone() } else { None }, + }) + .collect(), + ) + } else { + None + }; + + Ok(SelectionPage { + matched: evaluation.matched, + as_of, + next: evaluation.next_cursor, + headers, + documents, + edges, + }) +} + +/// A header carries every field a document record does, minus its payload and payload encoding +/// (private: R-M deleted the public, zero-caller `RawDocumentHeader::from_document`; this is the +/// one real caller, folded in rather than resurrecting that associated function under a new name). +fn header_from_record(document: &SelectionDocumentRecord) -> RawDocumentHeader { + RawDocumentHeader { + schema_id: document.schema_id.clone(), + schema_name: document.schema_name.clone(), + schema_version: document.schema_version.clone(), + schema_content_hash: document.schema_content_hash.clone(), + record_key: document.record_key.clone(), + stored_at: document.stored_at.clone(), + source_runtime_id: document.source_runtime_id.clone(), + source_agent_id: document.source_agent_id.clone(), + source_role: document.source_role.clone(), + tags: document.tags.clone(), + } +} + +fn order_rows(a: &R, b: &R, descending: bool) -> Ordering { + let ordering = a + .ordinal() + .cmp(&b.ordinal()) + .then_with(|| a.schema_id().cmp(b.schema_id())) + .then_with(|| a.record_key().cmp(b.record_key())); + if descending { ordering.reverse() } else { ordering } +} + +fn find_cursor_position(ordered: &[R], cursor: &Cursor, descending: bool) -> usize { + for (index, row) in ordered.iter().enumerate() { + let position = compare_position(row, cursor); + let compares_after = if descending { + position == Ordering::Less + } else { + position == Ordering::Greater + }; + if compares_after { + return index; + } + } + ordered.len() +} + +fn compare_position(row: &R, cursor: &Cursor) -> Ordering { + row.ordinal() + .cmp(&cursor.ordinal) + .then_with(|| row.schema_id().cmp(cursor.schema_id.as_str())) + .then_with(|| row.record_key().cmp(cursor.record_key.as_str())) +} + +// D9/R-AF: a reference's target set is every schema `RowSet::target_leaves` names for that role. +// A stored edge names only a bare record key (see `Row::references`'s doc comment) - resolving +// "the row this edge names" means searching every row sharing that key for the one whose schema +// falls inside the declared set, not an exact (schema, key) lookup that assumes a schema the edge +// never carries. Two schemas legitimately sharing one record key resolve to two different rows +// depending on which reference declared which target. Folds D9's leaf-set check into resolution +// itself (mirroring C#'s `TryResolveReferenceTarget`) rather than a second pass after the fact - +// there is exactly one row-owner decision here, not two. +// +// R-T: every resolvable edge is checked against its declared target, not only the ones a +// citation's own role/key filter happens to ask about - a corrupt edge must not hide behind an +// unrelated filter (F15: the pre-fix evaluator skipped this check for every edge that did not +// point at the requested key). `matches_citation`/`build_incoming_index` call this +// unconditionally, before any role/key/schema filtering, for exactly that reason. +fn resolve_reference_target<'a, R: Row>( + row_set: &impl RowSet, + by_key: &HashMap<&str, Vec<&'a R>>, + role: &str, + from: &R, + record_key: &str, +) -> Result, SelectionRefusal> { + let Some(candidates) = by_key.get(record_key) else { + return Ok(None); // a dangling reference - no row at all exists at this key (not an error). + }; + let leaves = row_set.target_leaves(role); + for &candidate in candidates { + if leaves.iter().any(|leaf| leaf == candidate.schema_id()) { + return Ok(Some(candidate)); + } + } + // Every row sharing this record key falls outside the reference's declared target: refuse + // deterministically (by schema id, code-point order - Rust's byte-wise `str` ordering already + // matches `CultNetCodePointComparer` for well-formed UTF-8, R-C) rather than let `by_key`'s + // insertion order decide which of them the refusal names (both row orders give the same + // answer). + let outside = candidates + .iter() + .min_by(|a, b| a.schema_id().cmp(b.schema_id())) + .expect("by_key never stores an empty Vec - entry()/push() always leaves at least one"); + Err(SelectionRefusal::ReferenceOutsideTarget { + from_schema_id: from.schema_id().to_string(), + from_key: from.record_key().to_string(), + role: role.to_string(), + to_schema_id: outside.schema_id().to_string(), + to_key: outside.record_key().to_string(), + }) +} + +fn matches_citation( + row_set: &impl RowSet, + by_key: &HashMap<&str, Vec<&R>>, + citer: &R, + citation: &Citation, + edge_sink: &mut Vec>, +) -> Result { + let mut found = false; + for (role, target_key, payload) in citer.references() { + // R-T/R-W parity fix (found landing R-AF): the citation's own role filter is checked + // *before* resolving the edge, mirroring C#'s ReferenceMembers(descriptor, citation.Role) - + // which narrows to that role's declared members before any of R-W's per-edge target check + // ever runs (CultNetSelectionEvaluator.cs:451-458, MatchesCitation's own foreach). This + // module used to resolve every edge unconditionally regardless of the requested role, which + // is a stronger and different guarantee than the reference ever gives: C#'s own R-T(F15) + // test (Evaluator_CitesRefusesAnOutOfTargetEdgeEvenWhenTheQueriedKeyMatchesNeitherEdge) + // queries the *same* role/member that carries the corrupt edge, never an unrelated one - R-W + // still checks every edge of the *requested* role unconditionally (untouched below), just + // not every edge the row happens to carry under other roles when a role was asked for. + if let Some(wanted_role) = &citation.role + && *wanted_role != role + { + continue; + } + let Some(resolved) = resolve_reference_target(row_set, by_key, &role, citer, &target_key)? + else { + continue; + }; + if target_key != citation.target.record_key { + continue; + } + // R-E: the target's schema id is matched through the one alias rule, not exact equality. + let resolved_name = row_set + .schema_name(resolved.schema_id()) + .unwrap_or_else(|| resolved.schema_id().to_string()); + if !schema_alias::matches(&citation.target.schema_id, resolved.schema_id(), &resolved_name) { + continue; + } + edge_sink.push(EdgeMatch { + from: citer.clone(), + role, + to: resolved.clone(), + payload, + anchor: EdgeAnchor::Citer, + }); + found = true; + } + Ok(found) +} + +// R-B: iterates `all_rows` (the caller's own `Vec` order), never `by_key.values()` - a `HashMap`'s +// iteration order is not something this function's output may depend on, even transiently, given +// `select`'s own final sort relies on nothing here having silently picked up map order. +fn build_incoming_index( + row_set: &impl RowSet, + all_rows: &[R], + by_key: &HashMap<&str, Vec<&R>>, + role: &str, + edge_sink: &mut Vec>, +) -> Result, SelectionRefusal> { + let mut incoming = HashSet::new(); + for citer in all_rows { + for (edge_role, target_key, payload) in citer.references() { + if edge_role != role { + continue; + } + let Some(resolved) = resolve_reference_target(row_set, by_key, &edge_role, citer, &target_key)? + else { + continue; + }; + incoming.insert((resolved.schema_id().to_string(), resolved.record_key().to_string())); + edge_sink.push(EdgeMatch { + from: citer.clone(), + role: edge_role, + to: resolved.clone(), + payload, + anchor: EdgeAnchor::Citee, + }); + } + } + Ok(incoming) +} + +// --------------------------------------------------------------------------------------------- +// Cursor (section 2: "Snapshot") +// --------------------------------------------------------------------------------------------- + +/// Random per-process key the answering server holds for minting and verifying its own cursors +/// (R-O). A cursor minted under one process's key is refused `cursor_invalid` by any other key, +/// including the same server's own key after a restart - cursors are short-lived and minted only +/// by the answering server (section 2, "Snapshot"), so not surviving a restart is the accepted +/// cost, not a bug. Never serialized and never persisted; hold one instance for the process's +/// lifetime and share it across every `select`/`select_page` call that process answers. +#[derive(Clone)] +pub struct CursorKey(Vec); + +impl CursorKey { + /// A fresh 32-byte key from the OS RNG. + pub fn random() -> Self { + let mut bytes = vec![0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + Self(bytes) + } +} + +impl fmt::Debug for CursorKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("CursorKey(..)") + } +} + +/// The opaque cursor: `asOf`, the last `(ordinal, schemaId, recordKey)`, and an HMAC digest of the +/// selection with `cursor`/`limit` cleared, keyed under the answering process's [`CursorKey`] +/// (R-O). Minted only by the answering server. This runtime's own mint/parse/digest algorithm is +/// internal to it - a cursor is never handed across runtimes (each server answers its own pages), +/// so it need not (and does not) byte-match the C# reference's; the *rule* it enforces (section 2 +/// "Snapshot") does. +/// +/// `parse` decodes the cursor's body and exposes `as_of` without the answering server's key - +/// Huginn depends on reading `as_of` this way, and a cursor's shape does not need the key to +/// decode, only its digest needs the key to *verify* (done by [`select`], which holds the key). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Cursor { + pub as_of: u64, + pub ordinal: i64, + pub schema_id: String, + pub record_key: String, + digest: String, +} + +impl Cursor { + pub fn mint(as_of: u64, last_row: &R, selection: &Selection, key: &CursorKey) -> String { + let digest = Self::compute_digest( + as_of, + last_row.ordinal(), + last_row.schema_id(), + last_row.record_key(), + selection, + key, + ); + // R-O: the cursor body uses length-prefixed fields, not a delimiter - a record key + // carrying any character, including the pre-fix delimiter itself (F6), round-trips. + let mut body = String::new(); + write_length_prefixed(&mut body, &as_of.to_string()); + write_length_prefixed(&mut body, &last_row.ordinal().to_string()); + write_length_prefixed(&mut body, last_row.schema_id()); + write_length_prefixed(&mut body, last_row.record_key()); + write_length_prefixed(&mut body, &digest); + URL_SAFE_NO_PAD.encode(body.as_bytes()) + } + + pub fn parse(cursor: &str) -> Result { + let invalid = || SelectionRefusal::CursorInvalid { + message: "The cursor does not decode.".into(), + }; + let bytes = URL_SAFE_NO_PAD.decode(cursor).map_err(|_| invalid())?; + let mut fields = read_length_prefixed_fields(&bytes, 5).ok_or_else(invalid)?; + let digest = fields.pop().unwrap(); + let record_key = fields.pop().unwrap(); + let schema_id = fields.pop().unwrap(); + let ordinal: i64 = fields.pop().unwrap().parse().map_err(|_| invalid())?; + let as_of: u64 = fields.pop().unwrap().parse().map_err(|_| invalid())?; + Ok(Self { + as_of, + ordinal, + schema_id, + record_key, + digest, + }) + } + + /// Verifies this cursor's digest against `selection` under `key` in constant time (R-O/R-Y): a + /// cursor forged without the key, minted by a different process's key, or **rewritten in its + /// own position** (`asOf`, `ordinal`, `schemaId` or `recordKey` edited after minting, with the + /// original digest reused) is refused - the digest covers the body, not only the selection. + fn verify_digest(&self, selection: &Selection, key: &CursorKey) -> bool { + let expected = Self::compute_digest( + self.as_of, + self.ordinal, + &self.schema_id, + &self.record_key, + selection, + key, + ); + constant_time_eq(self.digest.as_bytes(), expected.as_bytes()) + } + + /// An HMAC-SHA256 digest, keyed under `key`, of the cursor's own position (`asOf`, `ordinal`, + /// `schemaId`, `recordKey`) as well as the selection with `cursor` and `limit` cleared (R-Y), + /// so a cursor is bound both to the selection that minted it, the process that minted it, and + /// the exact position it names - a caller cannot hold one valid cursor, rewrite `ordinal` or + /// `recordKey` to skip or replay rows, and reuse the original digest. + /// + /// R-H: every string and list inside the selection is length-prefixed, so no separator + /// character can ever make two distinct selections collide (Soul found `["a|b"]` and + /// `["a","b"]` digesting the same under a separator-joined scheme). This need not (and does + /// not) byte-match the C# reference's own digest - each server answers its own pages (see this + /// type's doc comment) - only the collision-freedom and the keying are load-bearing. + pub fn compute_digest( + as_of: u64, + ordinal: i64, + schema_id: &str, + record_key: &str, + selection: &Selection, + key: &CursorKey, + ) -> String { + let mut canonical = String::new(); + write_length_prefixed(&mut canonical, &as_of.to_string()); + write_length_prefixed(&mut canonical, &ordinal.to_string()); + write_length_prefixed(&mut canonical, schema_id); + write_length_prefixed(&mut canonical, record_key); + + let mut sorted_schemas = selection.schemas.clone().unwrap_or_default(); + sorted_schemas.sort(); + let mut sorted_keys = selection.keys.clone().unwrap_or_default(); + sorted_keys.sort(); + + write_length_prefixed_list(&mut canonical, &sorted_schemas); + write_length_prefixed_list(&mut canonical, &sorted_keys); + + let fields = selection.fields.as_deref().unwrap_or_default(); + canonical.push_str(&fields.len().to_string()); + canonical.push('{'); + for field in fields { + write_length_prefixed(&mut canonical, &field.index); + write_length_prefixed(&mut canonical, &field.op); + write_length_prefixed_list(&mut canonical, field.values.as_deref().unwrap_or_default()); + write_length_prefixed(&mut canonical, field.number.as_deref().unwrap_or_default()); + } + canonical.push('}'); + + match &selection.cites { + Some(cites) => { + canonical.push('1'); + write_length_prefixed(&mut canonical, &cites.target.schema_id); + write_length_prefixed(&mut canonical, &cites.target.record_key); + write_length_prefixed(&mut canonical, cites.role.as_deref().unwrap_or_default()); + } + None => canonical.push('0'), + } + + match &selection.cited { + Some(cited) => { + canonical.push('1'); + write_length_prefixed(&mut canonical, &cited.role); + canonical.push(if cited.exists { 'T' } else { 'F' }); + } + None => canonical.push('0'), + } + + write_length_prefixed(&mut canonical, &selection.projection); + canonical.push(if selection.descending { 'D' } else { 'A' }); + + let mut mac = ::new_from_slice(&key.0) + .expect("HMAC-SHA256 accepts a key of any length"); + mac.update(canonical.as_bytes()); + mac.finalize() + .into_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } +} + +/// Writes `value` as `:` - unambiguous regardless of what characters `value` +/// itself carries, including the separator this module would otherwise use. +fn write_length_prefixed(buffer: &mut String, value: &str) { + buffer.push_str(&value.len().to_string()); + buffer.push(':'); + buffer.push_str(value); +} + +/// Writes a list as `[...]`. +fn write_length_prefixed_list(buffer: &mut String, values: &[String]) { + buffer.push_str(&values.len().to_string()); + buffer.push('['); + for value in values { + write_length_prefixed(buffer, value); + } + buffer.push(']'); +} + +/// Reads exactly `count` `:` fields written by [`write_length_prefixed`] back +/// out of `bytes`, in order. `None` on any malformed length, a truncated value, a non-UTF-8 value, +/// or leftover bytes after the last field - the cursor body has no field this reader may skip. +fn read_length_prefixed_fields(bytes: &[u8], count: usize) -> Option> { + let mut fields = Vec::with_capacity(count); + let mut pos = 0usize; + for _ in 0..count { + let colon = bytes[pos..].iter().position(|&b| b == b':')?; + let len: usize = std::str::from_utf8(&bytes[pos..pos + colon]).ok()?.parse().ok()?; + pos += colon + 1; + // R-AG: checked arithmetic - an attacker-supplied prefix near usize::MAX makes `pos + len` + // overflow. In a release build that wraps silently instead of panicking on the add itself, + // but the wrapped (small) sum then passes this guard and `&bytes[pos..pos + len]` below + // panics on the out-of-bounds slice anyway; `checked_add` catches both paths the same way + // every other malformed shape here already returns `None` instead of unwinding. + let end = pos.checked_add(len)?; + if end > bytes.len() { + return None; + } + fields.push(std::str::from_utf8(&bytes[pos..end]).ok()?.to_string()); + pos = end; + } + if pos != bytes.len() { + return None; + } + Some(fields) +} + +/// R-Y: the cursor's digest covers its own body, not only the selection, so a cursor's position +/// cannot be rewritten while reusing a genuine digest. Lives here, rather than in the integration +/// tests, because forging that specific attack - splicing a genuine digest onto a rewritten body - +/// needs the private `digest` field and the length-prefix wire helpers above. +#[cfg(test)] +mod cursor_tests { + use super::*; + + fn encode(as_of: u64, ordinal: i64, schema_id: &str, record_key: &str, digest: &str) -> String { + let mut body = String::new(); + write_length_prefixed(&mut body, &as_of.to_string()); + write_length_prefixed(&mut body, &ordinal.to_string()); + write_length_prefixed(&mut body, schema_id); + write_length_prefixed(&mut body, record_key); + write_length_prefixed(&mut body, digest); + URL_SAFE_NO_PAD.encode(body.as_bytes()) + } + + #[test] + fn a_rewritten_ordinal_under_the_original_digest_fails_verification() { + let key = CursorKey::random(); + let selection = Selection::default(); + let genuine_digest = Cursor::compute_digest(1, 5, "schema-a", "k1", &selection, &key); + + let forged = encode(1, 6, "schema-a", "k1", &genuine_digest); + let parsed = Cursor::parse(&forged).expect("well-formed body, forged digest"); + assert!( + !parsed.verify_digest(&selection, &key), + "reusing the digest under a rewritten ordinal must fail verification" + ); + } + + #[test] + fn a_rewritten_record_key_under_the_original_digest_fails_verification() { + let key = CursorKey::random(); + let selection = Selection::default(); + let genuine_digest = Cursor::compute_digest(1, 5, "schema-a", "k1", &selection, &key); + + let forged = encode(1, 5, "schema-a", "k2", &genuine_digest); + let parsed = Cursor::parse(&forged).expect("well-formed body, forged digest"); + assert!( + !parsed.verify_digest(&selection, &key), + "reusing the digest under a rewritten record key must fail verification" + ); + } + + #[test] + fn the_genuine_digest_still_verifies() { + let key = CursorKey::random(); + let selection = Selection::default(); + let genuine_digest = Cursor::compute_digest(1, 5, "schema-a", "k1", &selection, &key); + let genuine = encode(1, 5, "schema-a", "k1", &genuine_digest); + let parsed = Cursor::parse(&genuine).expect("well-formed body"); + assert!(parsed.verify_digest(&selection, &key)); + } + + // R-AG: a length-prefixed field with a hostile prefix, first 4 fields well-formed so + // `read_length_prefixed_fields` reaches the hostile one mid-loop rather than failing earlier + // for an unrelated reason. + fn cursor_with_hostile_last_field(hostile_field: &str) -> String { + let mut body = String::new(); + write_length_prefixed(&mut body, "1"); + write_length_prefixed(&mut body, "5"); + write_length_prefixed(&mut body, "schema-a"); + write_length_prefixed(&mut body, "k1"); + body.push_str(hostile_field); + URL_SAFE_NO_PAD.encode(body.as_bytes()) + } + + // R-AG: Rust's own proven overflow (`selection.rs`, before this cut) - a length prefix of + // `usize::MAX` makes `pos + len` overflow; `checked_add` must refuse typed, never panic. + #[test] + fn hostile_length_prefix_near_usize_max_refuses_typed_not_panic() { + let cursor = cursor_with_hostile_last_field("18446744073709551615:x"); + let result = std::panic::catch_unwind(|| Cursor::parse(&cursor)); + match result { + Ok(Err(SelectionRefusal::CursorInvalid { .. })) => {} + other => panic!("expected a typed CursorInvalid refusal, not a panic or acceptance: {other:?}"), + } + } + + // R-AG: the mirror-image proven overflow, C#'s (`CultNetSelectionEvaluator.cs`, + // `ReadLengthPrefixedFields`, before this cut) - a length prefix of `int.MaxValue` + // (2147483647) overflowed C#'s `int` guard. Rust's `usize` is wider so this magnitude was + // never Rust's own hole, but "each runtime holding exactly the hole the other closed" means + // neither side's tests are evidence about the other - fed here too. + #[test] + fn hostile_length_prefix_near_i32_max_the_csharp_overflow_refuses_typed_here_too() { + let cursor = cursor_with_hostile_last_field("2147483647:x"); + let result = std::panic::catch_unwind(|| Cursor::parse(&cursor)); + match result { + Ok(Err(SelectionRefusal::CursorInvalid { .. })) => {} + other => panic!("expected a typed CursorInvalid refusal, not a panic or acceptance: {other:?}"), + } + } + + // R-AG: a length prefix with more digits than any integer type holds - `.parse::()` + // itself fails, a different failure path than the checked-add overflow above, and must also + // refuse typed rather than panic. + #[test] + fn hostile_length_prefix_wider_than_any_integer_type_refuses_typed() { + let cursor = cursor_with_hostile_last_field("999999999999999999999999999999:x"); + let result = std::panic::catch_unwind(|| Cursor::parse(&cursor)); + match result { + Ok(Err(SelectionRefusal::CursorInvalid { .. })) => {} + other => panic!("expected a typed CursorInvalid refusal, not a panic or acceptance: {other:?}"), + } + } + + // R-AG: a negative-looking length prefix - `usize::parse` refuses the leading `-` outright, + // a third failure path (neither overflow nor digit-count) that must also refuse typed. + #[test] + fn hostile_negative_length_prefix_refuses_typed() { + let cursor = cursor_with_hostile_last_field("-1:x"); + let result = std::panic::catch_unwind(|| Cursor::parse(&cursor)); + match result { + Ok(Err(SelectionRefusal::CursorInvalid { .. })) => {} + other => panic!("expected a typed CursorInvalid refusal, not a panic or acceptance: {other:?}"), + } + } +} diff --git a/packages/cultnet-rs/src/snapshot_query.rs b/packages/cultnet-rs/src/snapshot_query.rs index f6150d1d..92e9c12a 100644 --- a/packages/cultnet-rs/src/snapshot_query.rs +++ b/packages/cultnet-rs/src/snapshot_query.rs @@ -1,7 +1,7 @@ use anyhow::{Result, anyhow}; use std::collections::{BTreeMap, BTreeSet}; -use crate::{CultNetDocumentRegistry, CultNetMessage, CultNetRawDocumentRecord}; +use crate::{CultNetDocumentRegistry, CultNetMessage, CultNetRawDocumentRecord, Row, Selection}; /// Read-only backing surface for a CultNet snapshot server. /// @@ -76,15 +76,31 @@ pub fn serve_read_only_raw_snapshot( return Err(anyhow!("expected cultnet.snapshot_request.v0")); }; require_non_empty(message_id, "message_id")?; - reject_duplicates(schema_ids.as_deref(), "requested schema id")?; - reject_duplicates(record_keys.as_deref(), "requested record key")?; + // R-Q3/R-R (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3, 2026-09-22 - + // corrects this comment's own earlier ruling from the commit 2 fix batch, which lowered an + // empty v0 list to "no filter"): `None` (the field omitted) still means "no filter", but a + // *present* v0 list - even one that empties out after trimming/deduping blanks - must lower to + // `Some(possibly-empty)`, never collapse to `None`. `matches_schema_keys_fields` already + // treats a present-but-empty selection list as "match nothing" (R-E), which is v0's own + // pre-cut behaviour (`recordKeys: []` answers empty, `CultNetDocumentRegistry. + // CreateRawSnapshotResponse` tests `filter?.RecordKeys != null`, not a length); collapsing to + // `None` here used to turn that into "match everything" instead. Unlike the C# reference, this + // function never calls `validate`, so there is no v1 door for a present-but-empty list to hit + // (R-R's fixup on the C# side does not apply here). + // R-M: `lower_v0_list` already dedups (Soul: it dedups before `reject_duplicates` ever ran, + // making that function's own duplicate check dead code - deleted below along with its calls). + let schema_ids = lower_v0_list(schema_ids); + let record_keys = lower_v0_list(record_keys); - let requested_schemas = schema_ids - .as_ref() - .map(|values| values.iter().map(String::as_str).collect::>()); - let requested_keys = record_keys - .as_ref() - .map(|values| values.iter().map(String::as_str).collect::>()); + // D3/D4's v0 lowering (docs/cultnet-selection-cut.md section 4): the two allowlists are not a + // selector of their own, they are cultnet.snapshot_request.v0 lowered into a Selection with no + // fields/cites/cited, matched through the one evaluator (selection::matches) instead of a fifth + // hand-rolled copy of "is this schema/key requested". + let selection = Selection { + schemas: schema_ids.clone(), + keys: record_keys.clone(), + ..Selection::default() + }; let mut selected = Vec::new(); let mut seen = BTreeSet::new(); @@ -110,27 +126,60 @@ pub fn serve_read_only_raw_snapshot( if !policy.allows(&document.schema_id, &document.record_key) { continue; } - if requested_schemas - .as_ref() - .is_some_and(|schemas| !schemas.contains(document.schema_id.as_str())) - { - continue; - } - if requested_keys - .as_ref() - .is_some_and(|keys| !keys.contains(document.record_key.as_str())) - { + if !crate::matches(&RawSnapshotRow(&document), &selection) { continue; } selected.push(document); } + // R-R: pre-cut v0 iterated the requested record keys in the caller's own order (a + // `HashSet` built from the array, which enumerates in insertion order); the shared + // evaluator carries no such tiebreak of its own, so this v0 lowering restores the requested + // order here rather than leaking plain snapshot-source order when the caller asked for + // particular keys. A stable sort keeps the relative order of documents that share a requested + // key (e.g. across schemas) as `raw_snapshot()` produced them. + if let Some(keys) = &record_keys { + selected.sort_by_key(|document| { + keys.iter() + .position(|key| key == &document.record_key) + .unwrap_or(usize::MAX) + }); + } + Ok(CultNetMessage::SnapshotResponseRaw { message_id: message_id.clone(), documents: selected, }) } +/// A `CultNetRawDocumentRecord` carries only identity (schema id, record key) at this layer - no +/// declared index values, no ordinal, no reference edges, because it is already-serialized bytes +/// from a read-only source, not a live document. The v0 lowering above never sets `fields`, `cites` +/// or `cited`, so `matches_schema_keys_fields` never calls `ordinal`/`values`/`number`/`references` +/// on this row; the unreachable stubs exist only to satisfy the `Row` trait's shape. +struct RawSnapshotRow<'a>(&'a CultNetRawDocumentRecord); + +impl Row for RawSnapshotRow<'_> { + fn schema_id(&self) -> &str { + &self.0.schema_id + } + fn record_key(&self) -> &str { + &self.0.record_key + } + fn ordinal(&self) -> i64 { + unreachable!("v0's lowered selection carries no fields/cites/cited; ordinal is not read") + } + fn values(&self, _index: &str) -> Vec { + unreachable!("v0's lowered selection carries no fields; values is not read") + } + fn number(&self, _index: &str) -> Option { + unreachable!("v0's lowered selection carries no fields; number is not read") + } + fn references(&self) -> Vec<(String, String, Option>)> { + unreachable!("v0's lowered selection carries no cites/cited; references is not read") + } +} + #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct CultNetSnapshotSourceExpectation { pub runtime_id: Option, @@ -317,23 +366,30 @@ fn require_expected_metadata( Ok(()) } -fn reject_duplicates(values: Option<&[String]>, label: &str) -> Result<()> { - let Some(values) = values else { - return Ok(()); - }; - let mut seen = BTreeSet::new(); - for value in values { - require_non_empty(value, label)?; - if !seen.insert(value) { - return Err(anyhow!("duplicate {label} {value:?}")); - } - } - Ok(()) -} - fn require_non_empty(value: &str, field: &str) -> Result<()> { if value.trim().is_empty() { return Err(anyhow!("{field} must be non-empty")); } Ok(()) } + +/// v0's own cleaning, matching the reference's `CultNetV0SelectionLowering.Lower` exactly +/// (568e8e3): an absent list, an empty list, or a list made only of blank entries all lower to +/// `None` (no filter); a mixed list keeps its non-blank entries, deduplicated (order-preserving, +/// first occurrence wins - `Distinct(StringComparer.Ordinal)`'s behaviour). A v0 compatibility +/// rule the lowering owns, not a meaning the door or the evaluator carries for v1's own lists +/// (those refuse `[]` and any blank entry outright, in `selection::validate`). +fn lower_v0_list(list: &Option>) -> Option> { + // R-Q3: `None` (the field omitted) still means "no filter"; a present list is always lowered + // to `Some`, even when trimming/deduping empties it out (R-R) - see this function's caller for + // why collapsing that case to `None` was wrong. + let values = list.as_ref()?; + let mut seen = std::collections::HashSet::new(); + let filtered: Vec = values + .iter() + .filter(|value| !value.trim().is_empty()) + .filter(|value| seen.insert(value.as_str())) + .cloned() + .collect(); + Some(filtered) +} diff --git a/packages/cultnet-rs/tests/cultnet.rs b/packages/cultnet-rs/tests/cultnet.rs index c63faa07..426e2ed9 100644 --- a/packages/cultnet-rs/tests/cultnet.rs +++ b/packages/cultnet-rs/tests/cultnet.rs @@ -18,6 +18,9 @@ use cultnet_rs::CultNetDocumentPutOptions; use cultnet_rs::CultNetDocumentRegistry; use cultnet_rs::CultNetMessage; use cultnet_rs::CultNetMutationAuthority; +use cultnet_rs::CultNetRawDocumentRecord; +use cultnet_rs::CultNetRawPayloadEncoding; +use cultnet_rs::CultNetReadOnlySnapshotPolicy; use cultnet_rs::CultNetReactiveDocumentOptions; use cultnet_rs::CultNetReconnectController; use cultnet_rs::CultNetReconnectPolicyOptions; @@ -43,6 +46,7 @@ use cultnet_rs::CultNetTransportDescriptor; use cultnet_rs::CultNetTransportFrame; use cultnet_rs::CultNetTransportOrdering; use cultnet_rs::CultNetTransportProfile; +use cultnet_rs::serve_read_only_raw_snapshot; use cultnet_rs::CultNetTransportProtocol; use cultnet_rs::CultNetWireContract; use cultnet_rs::LengthPrefixedMessageFramer; @@ -2034,6 +2038,174 @@ fn raw_snapshot_replication_preserves_messagepack_payload_bytes() -> Result<()> Ok(()) } +// R-Q3/R-R (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3, 2026-09-22): corrects +// this test's own earlier ruling from the commit 2 fix batch, which lowered an *explicit* empty +// or all-blank v0 list to "no filter". Only an *omitted* list (`None`) means "no filter" now - a +// present list, even one that empties out after trimming/deduping blanks, answers empty +// (`recordKeys: []` matches v0's own pre-cut server, which tested `filter?.RecordKeys != null`, +// not a length). A v1 Selection with the same shape is refused outright +// (validation_refuses_a_blank_entry_in_schemas_or_keys, +// validation_refuses_undeclared_index_role_and_empty_lists in tests/selection.rs) - that is a v1 +// door rule this v0 lowering never reaches (`serve_read_only_raw_snapshot` never calls `validate`). +#[test] +fn serve_read_only_raw_snapshot_omitted_v0_schema_list_is_no_filter_present_empty_or_blank_answers_empty() +-> Result<()> { + let mut registry = CultNetDocumentRegistry::new(); + registry.register(CultNetDocumentBinding::for_entry_with_schema_id::< + GhostlightAgentStateFixture, + >("ghostlight.agent-state".to_string(), None)); + + let mut policy = CultNetReadOnlySnapshotPolicy::new(); + policy.allow("ghostlight.agent-state", "row-1")?; + policy.allow("ghostlight.agent-state", "row-2")?; + + let source = vec![ + CultNetRawDocumentRecord { + schema_id: "ghostlight.agent-state".to_string(), + record_key: "row-1".to_string(), + stored_at: "now".to_string(), + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload: vec![1, 2, 3], + source_runtime_id: None, + source_agent_id: None, + source_role: None, + tags: None, + }, + CultNetRawDocumentRecord { + schema_id: "ghostlight.agent-state".to_string(), + record_key: "row-2".to_string(), + stored_at: "now".to_string(), + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload: vec![4, 5, 6], + source_runtime_id: None, + source_agent_id: None, + source_role: None, + tags: None, + }, + ]; + + // The field omitted entirely: no filter, both rows. + let omitted_request = CultNetMessage::SnapshotRequest { + message_id: "req-1".to_string(), + schema_ids: None, + record_keys: None, + }; + let omitted_response = + serve_read_only_raw_snapshot(®istry, &policy, &source, &omitted_request)?; + let CultNetMessage::SnapshotResponseRaw { documents, .. } = omitted_response else { + panic!("expected a raw snapshot response"); + }; + assert_eq!(documents.len(), 2, "schema_ids omitted must lower to no filter, not zero rows"); + + // A present list - empty, or made only of blanks - answers empty (R-R), not "no filter". + for schema_ids in [Some(Vec::new()), Some(vec![" ".to_string()])] { + let request = CultNetMessage::SnapshotRequest { + message_id: "req-1".to_string(), + schema_ids: schema_ids.clone(), + record_keys: None, + }; + let response = serve_read_only_raw_snapshot(®istry, &policy, &source, &request)?; + let CultNetMessage::SnapshotResponseRaw { documents, .. } = response else { + panic!("expected a raw snapshot response"); + }; + assert_eq!( + documents.len(), + 0, + "schema_ids = {schema_ids:?} is present and empty after trimming, so it must answer \ + empty, not fall back to every row" + ); + } + + // A mixed list keeps its non-blank entries, deduplicated (matches + // CultNetV0SelectionLowering.Lower exactly, 568e8e3: Distinct(StringComparer.Ordinal)). + let mixed_request = CultNetMessage::SnapshotRequest { + message_id: "req-2".to_string(), + schema_ids: Some(vec![ + "ghostlight.agent-state".to_string(), + " ".to_string(), + "ghostlight.agent-state".to_string(), + ]), + record_keys: None, + }; + let mixed_response = serve_read_only_raw_snapshot(®istry, &policy, &source, &mixed_request)?; + let CultNetMessage::SnapshotResponseRaw { documents, .. } = mixed_response else { + panic!("expected a raw snapshot response"); + }; + assert_eq!( + documents.len(), + 2, + "a mixed list keeps its one distinct non-blank schema id, still selecting both rows of that schema" + ); + + Ok(()) +} + +// R-R: v0's pre-cut server preserved the caller's requested key order (a `HashSet` built +// from the array, which enumerates in insertion order), not plain snapshot-source order. +#[test] +fn serve_read_only_raw_snapshot_keeps_the_requested_record_key_order() -> Result<()> { + let mut registry = CultNetDocumentRegistry::new(); + registry.register(CultNetDocumentBinding::for_entry_with_schema_id::< + GhostlightAgentStateFixture, + >("ghostlight.agent-state".to_string(), None)); + + let mut policy = CultNetReadOnlySnapshotPolicy::new(); + policy.allow("ghostlight.agent-state", "row-1")?; + policy.allow("ghostlight.agent-state", "row-2")?; + policy.allow("ghostlight.agent-state", "row-3")?; + + // Source order is row-1, row-2, row-3; the request asks for them in the opposite order. + let source = vec![ + CultNetRawDocumentRecord { + schema_id: "ghostlight.agent-state".to_string(), + record_key: "row-1".to_string(), + stored_at: "now".to_string(), + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload: vec![1], + source_runtime_id: None, + source_agent_id: None, + source_role: None, + tags: None, + }, + CultNetRawDocumentRecord { + schema_id: "ghostlight.agent-state".to_string(), + record_key: "row-2".to_string(), + stored_at: "now".to_string(), + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload: vec![2], + source_runtime_id: None, + source_agent_id: None, + source_role: None, + tags: None, + }, + CultNetRawDocumentRecord { + schema_id: "ghostlight.agent-state".to_string(), + record_key: "row-3".to_string(), + stored_at: "now".to_string(), + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload: vec![3], + source_runtime_id: None, + source_agent_id: None, + source_role: None, + tags: None, + }, + ]; + + let request = CultNetMessage::SnapshotRequest { + message_id: "req-1".to_string(), + schema_ids: None, + record_keys: Some(vec!["row-3".to_string(), "row-1".to_string(), "row-2".to_string()]), + }; + let response = serve_read_only_raw_snapshot(®istry, &policy, &source, &request)?; + let CultNetMessage::SnapshotResponseRaw { documents, .. } = response else { + panic!("expected a raw snapshot response"); + }; + let keys: Vec<&str> = documents.iter().map(|d| d.record_key.as_str()).collect(); + assert_eq!(keys, vec!["row-3", "row-1", "row-2"], "the requested key order must survive, not the source's"); + + Ok(()) +} + #[test] fn raw_snapshot_replication_hydrates_same_schema_rust_aliases() -> Result<()> { let temp = tempfile::tempdir()?; diff --git a/packages/cultnet-rs/tests/error_message.rs b/packages/cultnet-rs/tests/error_message.rs new file mode 100644 index 00000000..011ff147 --- /dev/null +++ b/packages/cultnet-rs/tests/error_message.rs @@ -0,0 +1,158 @@ +//! R-N (docs/cultnet-selection-cut.md, fix batch 3): `cultnet.error.v0`'s `code`/`details` fields, +//! pinned byte-for-byte against the landed C# reference +//! (`CultNetErrorMessage.ForSelectionInvalid`/`ForCursor`/`ForReferenceOutsideTarget`, +//! `src/GameCult.Networking/CultNetSchemaMessages.cs`, commit `e8a3dec`). +//! +//! The expected bytes below are not hand-derived: they were captured by running MessagePack C# +//! 3.1.7 (the exact package/version `GameCult.Networking` depends on) against a standalone +//! `CultNetErrorMessage`/`CultNetErrorDetails` pair carrying the landed `[Key(...)]` shape, using +//! `MessagePackSerializerOptions.Standard.WithSecurity(MessagePackSecurity.UntrustedData)` - the +//! same options `CultNetSchemaMessageSerialization.Options` uses - then +//! `Convert.ToHexString(MessagePackSerializer.Serialize(message, options))`. The probe program lived +//! at `%TEMP%\claude\probe-msgpack` (outside this repo) and was run in the `dotnet` Yggdrasil verify +//! image via `ygg-verify.sh`; it is not part of this crate. Each case below documents the C# call +//! that produced its bytes. +//! +//! This confirms the one thing that matters for byte parity here: MessagePack C#'s +//! `[Key("name")]`-attributed map formatter always writes every declared key, in declaration order, +//! nil for a null value - it never omits a key. `CultNetMessage::Error` is therefore encoded through +//! the same `rmpv::Value::Map`-literal path as the crate's other raw `cultnet.schema.v0` messages +//! (`encode_raw_cultnet_schema_message`/`parse_raw_cultnet_schema_message` in `src/contracts.rs`) +//! rather than through the generic `serde_json::Value` round trip every other `CultNetMessage` +//! variant uses - that path's `serde_json::Value::Object` is a `BTreeMap` (no `preserve_order` +//! feature enabled in this crate's `serde_json` dependency), so it would emit keys sorted +//! alphabetically and silently break this exact byte match. + +use cultnet_rs::{ + CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetWireContract, + decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, +}; + +fn hex_decode(hex: &str) -> Vec { + assert!(hex.len() % 2 == 0, "odd-length hex string"); + (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("valid hex byte")) + .collect() +} + +struct Case { + name: &'static str, + /// Captured via `Convert.ToHexString(MessagePackSerializer.Serialize(msg, options))` in the + /// probe program described in this file's header comment. + csharp_hex: &'static str, + message: fn() -> CultNetMessage, +} + +fn cases() -> Vec { + vec![ + Case { + name: "selection_invalid_schemas_null", + // C#: ForSelectionInvalid(new CultNetSelectionInvalidException("schemas", null, + // "must not be empty")) + csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92473656C656374696F6E5F696E76616C69643A206D757374206E6F7420626520656D707479AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A7736368656D6173A576616C7565C0A461734F66C0A763757272656E74C0", + message: || CultNetMessage::Error { + error: "selection_invalid: must not be empty".to_string(), + code: Some(CultNetErrorCode::SelectionInvalid), + details: Some(CultNetErrorDetails { + field: Some("schemas".to_string()), + value: None, + as_of: None, + current: None, + }), + }, + }, + Case { + name: "selection_invalid_field_value", + // C#: ForSelectionInvalid(new CultNetSelectionInvalidException("keys", " ", + // "blank key entry")) + csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92273656C656374696F6E5F696E76616C69643A20626C616E6B206B657920656E747279AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A46B657973A576616C7565A3202020A461734F66C0A763757272656E74C0", + message: || CultNetMessage::Error { + error: "selection_invalid: blank key entry".to_string(), + code: Some(CultNetErrorCode::SelectionInvalid), + details: Some(CultNetErrorDetails { + field: Some("keys".to_string()), + value: Some(" ".to_string()), + as_of: None, + current: None, + }), + }, + }, + Case { + name: "cursor_stale", + // C#: ForCursor(new CultNetSelectionCursorException("cursor_stale", + // "cursor is stale", asOf: 1, current: 2)) + csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72BD637572736F725F7374616C653A20637572736F72206973207374616C65AB726F7574696E6748696E74C0A4636F6465AC637572736F725F7374616C65A764657461696C7384A56669656C64C0A576616C7565C0A461734F6601A763757272656E7402", + message: || CultNetMessage::Error { + error: "cursor_stale: cursor is stale".to_string(), + code: Some(CultNetErrorCode::CursorStale), + details: Some(CultNetErrorDetails { + field: None, + value: None, + as_of: Some(1), + current: Some(2), + }), + }, + }, + Case { + name: "cursor_invalid", + // C#: ForCursor(new CultNetSelectionCursorException("cursor_invalid", + // "cannot parse cursor")) - no asOf/current, so Details stays null. + csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D923637572736F725F696E76616C69643A2063616E6E6F7420706172736520637572736F72AB726F7574696E6748696E74C0A4636F6465AE637572736F725F696E76616C6964A764657461696C73C0", + message: || CultNetMessage::Error { + error: "cursor_invalid: cannot parse cursor".to_string(), + code: Some(CultNetErrorCode::CursorInvalid), + details: None, + }, + }, + Case { + name: "reference_outside_target", + // C#: ForReferenceOutsideTarget(...) - built directly with the final Error text here + // (the constructor path itself double-prefixes "reference_outside_target: ", see this + // crate's SelectionRefusal -> CultNetMessage mapping doc comment in src/selection.rs + // for the discrepancy); the wire shape under test does not depend on the text's content. + csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D9317265666572656E63655F6F7574736964655F7461726765743A206F757473696465206465636C6172656420746172676574AB726F7574696E6748696E74C0A4636F6465B87265666572656E63655F6F7574736964655F746172676574A764657461696C73C0", + message: || CultNetMessage::Error { + error: "reference_outside_target: outside declared target".to_string(), + code: Some(CultNetErrorCode::ReferenceOutsideTarget), + details: None, + }, + }, + ] +} + +#[test] +fn rust_encode_matches_csharp_bytes_for_every_r_n_code() { + for case in cases() { + let expected = hex_decode(case.csharp_hex); + let actual = encode_cultnet_message_to_vec(&(case.message)(), CultNetWireContract::CultNetSchemaV0) + .unwrap_or_else(|err| panic!("{}: encode failed: {err}", case.name)); + assert_eq!( + actual, expected, + "{}: Rust-encoded bytes do not match the captured C# encoding", + case.name + ); + } +} + +#[test] +fn rust_decodes_the_csharp_written_bytes_back_into_the_same_typed_message() { + for case in cases() { + let bytes = hex_decode(case.csharp_hex); + let decoded = decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0) + .unwrap_or_else(|err| panic!("{}: decode failed: {err}", case.name)); + assert_eq!(decoded, (case.message)(), "{}: decoded value mismatch", case.name); + } +} + +#[test] +fn round_trip_is_stable() { + for case in cases() { + let message = (case.message)(); + let encoded = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0) + .unwrap_or_else(|err| panic!("{}: encode failed: {err}", case.name)); + let decoded = decode_cultnet_message_from_slice(&encoded, CultNetWireContract::CultNetSchemaV0) + .unwrap_or_else(|err| panic!("{}: decode failed: {err}", case.name)); + assert_eq!(decoded, message, "{}: round trip mismatch", case.name); + } +} diff --git a/packages/cultnet-rs/tests/schema_discovery_contracts.rs b/packages/cultnet-rs/tests/schema_discovery_contracts.rs new file mode 100644 index 00000000..7c62b80f --- /dev/null +++ b/packages/cultnet-rs/tests/schema_discovery_contracts.rs @@ -0,0 +1,113 @@ +//! R-AB: `schema_discovery.rs` used to `include_str!("../../../contracts/cultnet/…")`, reaching +//! three levels above `src/` and out of `packages/cultnet-rs` entirely. cargo-mutants builds this +//! crate alone (there is no workspace `Cargo.toml` tying it to its siblings, so its own manifest +//! directory is the whole tree cargo-mutants copies) - a path that escapes the package is a path +//! that does not exist in that copy, and the unmutated baseline build failed with "No such file or +//! directory" on every one of the 22 schemas (`rust-mut3.log`, captured on Soul's whole-cut pass). +//! +//! The fix vendors byte-identical copies of the 22 schemas this crate embeds under +//! `packages/cultnet-rs/contracts/cultnet/`, git-tracked so any tool that copies just this +//! package's directory carries them along, and repoints every `include_str!` at that vendored copy +//! instead of the repo-root `contracts/cultnet/`. A build script staging files into `OUT_DIR` was +//! the other option R-AB named, but it does not actually solve this: the script itself would still +//! need to read from outside the package to find its source, and a build running inside +//! cargo-mutants' copy would fail exactly as `include_str!` did. Vendoring - "another route that +//! keeps every source path inside the package" - is what R-AB's ruling names as the alternative, +//! and it is what actually keeps the crate self-contained. +//! +//! Vendoring is a second copy of files this repo already treats as canonical +//! (`contracts/cultnet/*.schema.json`, the shared source multiple runtimes compile against per +//! `docs/cultnet-selection-cut.md` section 1). A second copy can drift silently, so this test is +//! the thing that makes that impossible without a loud, immediate failure: every vendored file is +//! compared byte-for-byte against its repo-root original. It skips (not fails) when the canonical +//! `contracts/` tree is not present beside the package - i.e. exactly the isolated-copy situation +//! R-AB exists to survive - so this check does not reintroduce the same escape it is guarding +//! against. + +use std::fs; +use std::path::Path; + +/// Every schema this crate's `schema_discovery::builtin_schema_registry` embeds via +/// `include_str!("../contracts/cultnet/…")` - kept in sync with `schema_discovery.rs` by hand; +/// a schema added there and not here is still caught, because the crate would then fail to +/// compile against a file this list never copied to `contracts/cultnet/`. +const VENDORED_SCHEMAS: &[&str] = &[ + "cultnet.hello.schema.json", + "cultnet.document-mutation-contract.schema.json", + "cultnet.transport-profile.schema.json", + "cultnet.login.schema.json", + "cultnet.register.schema.json", + "cultnet.verify.schema.json", + "cultnet.login-success.schema.json", + "cultnet.error.schema.json", + "cultnet.sample-change-name.schema.json", + "cultnet.sample-chat.schema.json", + "cultnet.document-put.schema.json", + "cultnet.document-delete.schema.json", + "cultnet.raw-document-record.schema.json", + "cultnet.document-put-raw.schema.json", + "cultnet.snapshot-request.schema.json", + "cultnet.snapshot-response.schema.json", + "cultnet.snapshot-response-raw.schema.json", + "cultnet.schema-catalog-request.schema.json", + "cultnet.schema-catalog-response.schema.json", + "cultnet.shard-catalog-request.schema.json", + "cultnet.shard-catalog-response.schema.json", + "ghostlight.agent-state.schema.json", +]; + +#[test] +fn vendored_contract_schemas_are_present_and_match_every_include_str_call() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let vendored_root = manifest_dir.join("contracts/cultnet"); + for name in VENDORED_SCHEMAS { + let path = vendored_root.join(name); + assert!( + path.is_file(), + "{name} is embedded by schema_discovery.rs but missing from the vendored \ + contracts/cultnet/ copy at {path:?}" + ); + } +} + +#[test] +fn vendored_contract_schemas_match_the_canonical_repo_root_source() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let vendored_root = manifest_dir.join("contracts/cultnet"); + // packages/cultnet-rs -> packages -> repo root -> contracts/cultnet + let canonical_root = manifest_dir.join("../../contracts/cultnet"); + + if !canonical_root.is_dir() { + // R-AB: exactly the isolated-copy case this crate's own vendored files exist to survive + // (cargo-mutants, or any tool that copies only this package's directory). Nothing to + // compare against, and that is expected, not a failure. + eprintln!( + "skipping: {canonical_root:?} not present beside the package (isolated build, \ + the case R-AB's vendored copies are for)" + ); + return; + } + + for name in VENDORED_SCHEMAS { + let canonical = fs::read_to_string(canonical_root.join(name)) + .unwrap_or_else(|error| panic!("reading canonical contracts/cultnet/{name}: {error}")); + let vendored = fs::read_to_string(vendored_root.join(name)) + .unwrap_or_else(|error| panic!("reading vendored contracts/cultnet/{name}: {error}")); + assert_eq!( + canonical, vendored, + "packages/cultnet-rs/contracts/cultnet/{name} has drifted from the canonical \ + contracts/cultnet/{name} - re-copy it" + ); + } +} + +#[test] +fn builtin_schema_registry_loads_every_vendored_schema() { + let registry = cultnet_rs::builtin_schema_registry().expect("registry builds from the vendored schemas"); + let listed = registry.list(&cultnet_rs::CultNetSchemaCatalogOptions::default()); + assert_eq!( + listed.len(), + VENDORED_SCHEMAS.len(), + "one registration per vendored schema file" + ); +} diff --git a/packages/cultnet-rs/tests/selection.rs b/packages/cultnet-rs/tests/selection.rs new file mode 100644 index 00000000..fd0fef74 --- /dev/null +++ b/packages/cultnet-rs/tests/selection.rs @@ -0,0 +1,2406 @@ +//! CultNet typed selection, Cut 1 commit 3 (docs/cultnet-selection-cut.md section 7/10). A toy row +//! set implementing `Row`/`RowSet` directly - D7: no `cultcache-rs` knowledge, matching S14's brief +//! ("select_over_a_toy_row_set_matches_orders_hops_pages_and_refuses") rather than reproducing every +//! one of C#'s S1-S23 fixtures 1:1. Section 10's negative greps and the parity vectors (S12, S13) are +//! here too. + +use std::fs; +use std::path::Path; + +use cultnet_rs::{ + Citation, Cursor, CursorKey, CultNetMessage, CultNetWireContract, Edge, FieldPredicate, Incoming, + RawDocumentHeader, RecordRef, Row, RowSet, Selection, SelectionDocumentRecord, SelectionOperator, + SelectionPage, SelectionRefusal, canonical_number, decode_cultnet_message_from_slice, + encode_cultnet_message_to_vec, schema_alias, select, select_page, validate, +}; + +/// R-O: one process-lifetime key shared by every test in this binary, so a cursor minted in one +/// `select`/`select_page` call verifies in a later call of the same test. +fn test_cursor_key() -> &'static CursorKey { + static KEY: std::sync::OnceLock = std::sync::OnceLock::new(); + KEY.get_or_init(CursorKey::random) +} + +// ------------------------------------------------------------------------------------------ +// The fixture: two leaves under a conceptual abstract middle (mass declared on both, +// independently - Rust has no inheritance to share it through), a citer with four reference +// shapes (a single "Design" reference into the abstract middle's leaf set, a "related" many +// reference, a "components" many-dictionary reference with float payloads, and a "parent" +// single reference), and a narrow citer whose "narrow_ref" targets only leaf_a - the edge S18 +// needs to reach outside its declared target. +// +// Soul's finding (soul-ss4-notes.md, F3): the earlier fixture used the schema *names* ("leaf_a", +// "leaf_b", ...) as their own schema ids, so every `schemas`/`cites.target` selection matched by +// plain string equality and never exercised the alias matcher at all - the real gap (C# resolves +// `["leaf_a"]` against a schema's declared name; Rust's exact-id compare gave 0 rows) was +// invisible. leaf_a()/leaf_b()/citer() below are real SHA-256-shaped ids +// (`sha256:`, no version suffix of their own - see the fixture-loading block's comment) - the +// same ids `CultCache.cs`'s `Sha256(semanticFingerprint)` produces for the shared fixture's C# +// document types. NARROW_CITER (just below) is the one id this file still invents locally; it is +// never compared across runtimes. +// ------------------------------------------------------------------------------------------ + +// R-E/shared-fixture ruling (docs/cultnet-selection-cut.md, Self's rulings for the Cut 1 fix batch, +// 2026-09-22): leaf_a/leaf_b/citer's real ids come from the one shared fixture, +// contracts/cultnet/interop/selection-vectors.fixture.json, loaded lazily below - real SHA-256 +// content-hash ids, exactly as GameCult.Caching.CultDocumentRegistry computes them for the +// fixture's C# document types (tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs). They +// carry no ".vN" suffix of their own - CultCache.cs's Sha256(semanticFingerprint) folds the version +// into the hash rather than appending it as text. leaf_a_hash_alias() is the same hash with a +// synthetic ".v1" appended, the one alias form this crate's own schema_alias module can resolve +// (see its module doc); leaf_a_name_alias() is the one alias form the C# reference can resolve +// instead ("leaf_a.v9") - the two runtimes' alias matchers key off different attributes of a +// descriptor and neither candidate resolves through both. NARROW_CITER stays a local, unshared id: +// it only ever appears in this file's own S18 test, never in a cross-runtime vector. +const NARROW_CITER: &str = "sha256:356840f80f14bda0186a33eb7aed41c1e644fd4ffc3b925ad0d7f10fe77b52cb.v1"; + +#[derive(serde::Deserialize)] +struct FixtureSchemaDef { + #[serde(rename = "schemaId")] + schema_id: String, + #[serde(rename = "nameAlias")] + name_alias: String, + #[serde(rename = "hashAlias")] + hash_alias: String, +} + +// R-AF: no target_schema - the wire a stored reference actually travels on carries a bare record +// key (GameCult.Caching.MessagePack.CultRecordRefFormatter writes only value.Key.Value), so the +// fixture stopped pretending otherwise. See the fixture file's "//references" note. +#[derive(serde::Deserialize)] +struct FixtureReferenceDef { + role: String, + #[serde(rename = "targetKey")] + target_key: String, +} + +#[derive(serde::Deserialize)] +struct FixtureRowDef { + schema: String, + key: String, + ordinal: i64, + #[serde(default)] + fields: std::collections::BTreeMap, + #[serde(default)] + references: Vec, +} + +#[derive(serde::Deserialize)] +struct FixtureFile { + schemas: std::collections::BTreeMap, + rows: Vec, +} + +static FIXTURE: std::sync::OnceLock = std::sync::OnceLock::new(); + +// R-AB: `fixture()` used to read only `contracts_dir()` (the canonical, repo-root +// `contracts/cultnet/interop/`), which is outside `packages/cultnet-rs` and escapes any tool that +// copies just this package's directory - the same failure shape `schema_discovery.rs`'s +// `include_str!`s had. `selection-vectors.fixture.json` is a checked-in input, not a generated +// artifact (unlike `*-written.json` below, which each runtime produces from the *other* runtime's +// output and cannot be vendored the same way), so it gets the same vendored-copy treatment: a +// byte-identical copy lives under `packages/cultnet-rs/contracts/cultnet/interop/`, checked by the +// drift test right below, and the loader reads the vendored copy unconditionally so it works the +// same way whether or not the wider repo is checked out beside the package. +fn vendored_fixture_dir() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("contracts").join("cultnet").join("interop") +} + +fn fixture() -> &'static FixtureFile { + FIXTURE.get_or_init(|| { + let path = vendored_fixture_dir().join("selection-vectors.fixture.json"); + let text = fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("{} is missing or unreadable: {error}", path.display())); + serde_json::from_str(&text) + .unwrap_or_else(|error| panic!("{} is not valid fixture JSON: {error}", path.display())) + }) +} + +// The drift guard for the vendored fixture, mirroring +// `schema_discovery_contracts.rs`'s vendored-schema check: skips (not fails) when the canonical +// repo-root tree is not present beside the package, which is exactly the isolated-copy case the +// vendored copy exists to survive. +#[test] +fn fixture_contract_matches_the_canonical_source() { + let canonical = contracts_dir().join("selection-vectors.fixture.json"); + if !canonical.is_file() { + eprintln!( + "skipping: {canonical:?} not present beside the package (isolated build, the case \ + the vendored fixture copy is for)" + ); + return; + } + let canonical_text = fs::read_to_string(&canonical).expect("reading canonical fixture"); + let vendored_text = fs::read_to_string(vendored_fixture_dir().join("selection-vectors.fixture.json")) + .expect("reading vendored fixture"); + assert_eq!( + canonical_text, vendored_text, + "packages/cultnet-rs/contracts/cultnet/interop/selection-vectors.fixture.json has \ + drifted from the canonical contracts/cultnet/interop/selection-vectors.fixture.json - \ + re-copy it" + ); +} + +fn fixture_schema(name: &str) -> &'static FixtureSchemaDef { + fixture() + .schemas + .get(name) + .unwrap_or_else(|| panic!("selection-vectors.fixture.json: no schema named '{name}'")) +} + +fn leaf_a() -> &'static str { + &fixture_schema("leaf_a").schema_id +} +fn leaf_b() -> &'static str { + &fixture_schema("leaf_b").schema_id +} +fn citer() -> &'static str { + &fixture_schema("citer").schema_id +} +// R-AF: the shared fixture's narrow-target citer - real SHA-256 id, same treatment as +// leaf_a()/leaf_b()/citer() above, so the shared-key resolution rule is pinned in the actual +// cross-runtime parity vectors rather than only in this file's own local NARROW_CITER tests. +fn citer_narrow() -> &'static str { + &fixture_schema("citer_narrow").schema_id +} +// R-E/shared-fixture ruling: the fixture's map key ("leaf_a", "leaf_b", "citer") *is* each +// schema's declared name (C#'s CultDocumentDescriptor.SchemaName) - what RowSet::schema_name/ +// Row::schema_name now expose, and what nameAlias's ".v9" suffix resolves against. +fn leaf_a_name() -> &'static str { + "leaf_a" +} +fn leaf_b_name() -> &'static str { + "leaf_b" +} +fn citer_name() -> &'static str { + "citer" +} +fn citer_narrow_name() -> &'static str { + "citer_narrow" +} +const NARROW_CITER_NAME: &str = "narrow_citer"; +// R-E, Self's ruling 2026-09-22 ("the C# reference's alias rule is the rule"): a hash id with a +// synthetic ".v1" appended is not a wire form either runtime's production rule resolves - +// CultNetSchemaAliasMatching's descriptor overload strips the ".v1" and compares the bare hash +// text against the declared *name* ("leaf_a"), never equal. This is now a shared negative check +// (see does_not_match_a_hash_shaped_alias below and the parity vectors this file writes). +fn leaf_a_hash_alias() -> &'static str { + &fixture_schema("leaf_a").hash_alias +} +// The alias form the C# reference's descriptor overload resolves ("leaf_a.v9") - and, as of this +// cut's fix, the alias form this crate's schema_alias resolves too, through Row::schema_name/ +// RowSet::schema_name now standing in for CultDocumentDescriptor.SchemaName. +fn leaf_a_name_alias() -> &'static str { + &fixture_schema("leaf_a").name_alias +} + +fn leak(value: String) -> &'static str { + Box::leak(value.into_boxed_str()) +} + +fn build_row(row: &FixtureRowDef) -> FixtureRow { + match row.schema.as_str() { + "leaf_a" | "leaf_b" => { + let schema_id = if row.schema == "leaf_a" { leaf_a() } else { leaf_b() }; + FixtureRow::leaf( + schema_id, + leak(row.key.clone()), + row.ordinal, + leak(row.fields["kind"].clone()), + leak(row.fields["mass"].clone()), + ) + } + "citer" => { + // R-AF: the stored edge is a bare target key - the wire carries no schema for it. + let references = row + .references + .iter() + .map(|reference| (reference.role.clone(), reference.target_key.clone(), None)) + .collect(); + FixtureRow::citer(leak(row.key.clone()), row.ordinal, references) + } + "citer_narrow" => { + let references = row + .references + .iter() + .map(|reference| (reference.role.clone(), reference.target_key.clone(), None)) + .collect(); + FixtureRow::citer_narrow(leak(row.key.clone()), row.ordinal, references) + } + other => panic!("selection-vectors.fixture.json: unknown schema '{other}'"), + } +} + +#[derive(Clone, Debug)] +struct FixtureRow { + schema_id: &'static str, + record_key: &'static str, + ordinal: i64, + kind: Option<&'static str>, + mass: Option<&'static str>, + // R-AF: the stored target is a bare record key, not a RecordRef - see Row::references's doc + // comment in src/selection.rs. + references: Vec<(String, String, Option>)>, +} + +impl FixtureRow { + fn leaf(schema_id: &'static str, record_key: &'static str, ordinal: i64, kind: &'static str, mass: &'static str) -> Self { + Self { + schema_id, + record_key, + ordinal, + kind: Some(kind), + mass: Some(mass), + references: Vec::new(), + } + } + + fn citer(record_key: &'static str, ordinal: i64, references: Vec<(String, String, Option>)>) -> Self { + Self { + schema_id: citer(), + record_key, + ordinal, + kind: None, + mass: None, + references, + } + } + + // R-AF: the shared fixture's narrow-target citer (real schema id, from + // selection-vectors.fixture.json) - distinct from this file's own local NARROW_CITER, which + // stays a Rust-only id never compared across runtimes. + fn citer_narrow(record_key: &'static str, ordinal: i64, references: Vec<(String, String, Option>)>) -> Self { + Self { + schema_id: citer_narrow(), + record_key, + ordinal, + kind: None, + mass: None, + references, + } + } +} + +// Shared by Row::schema_name and RowSet::schema_name below - one place that knows which fixture +// schema id carries which declared name. +fn schema_name_for(schema_id: &str) -> Option<&'static str> { + if schema_id == leaf_a() { + Some(leaf_a_name()) + } else if schema_id == leaf_b() { + Some(leaf_b_name()) + } else if schema_id == citer() { + Some(citer_name()) + } else if schema_id == citer_narrow() { + Some(citer_narrow_name()) + } else if schema_id == NARROW_CITER { + Some(NARROW_CITER_NAME) + } else { + None + } +} + +impl Row for FixtureRow { + fn schema_id(&self) -> &str { + self.schema_id + } + fn schema_name(&self) -> &str { + schema_name_for(self.schema_id).unwrap_or(self.schema_id) + } + fn record_key(&self) -> &str { + self.record_key + } + fn ordinal(&self) -> i64 { + self.ordinal + } + fn values(&self, index: &str) -> Vec { + match index { + "kind" => self.kind.map(|v| vec![v.to_string()]).unwrap_or_default(), + _ => Vec::new(), + } + } + fn number(&self, index: &str) -> Option { + if index == "mass" { + self.mass.map(str::to_string) + } else { + None + } + } + fn references(&self) -> Vec<(String, String, Option>)> { + self.references.clone() + } +} + +struct FixtureRowSet; + +impl RowSet for FixtureRowSet { + fn all_schema_ids(&self) -> Vec { + vec![ + leaf_a().into(), + leaf_b().into(), + citer().into(), + citer_narrow().into(), + NARROW_CITER.into(), + ] + } + fn schema_name(&self, schema_id: &str) -> Option { + schema_name_for(schema_id).map(str::to_string) + } + fn declared_indexes(&self, schema_id: &str) -> Vec { + if schema_id == leaf_a() || schema_id == leaf_b() { + vec!["kind".into(), "mass".into()] + } else { + Vec::new() + } + } + fn declared_roles(&self, schema_id: &str) -> Vec { + if schema_id == citer() { + vec![ + "parent".into(), + "related".into(), + "components".into(), + "Design".into(), + // R-W: a citer-to-citer role, so a both-hops selection can page a citer row that + // is itself cited by another citer (rather than only ever by a leaf). + "peer".into(), + ] + } else if schema_id == NARROW_CITER || schema_id == citer_narrow() { + vec!["narrow_ref".into()] + } else { + Vec::new() + } + } + fn is_numeric(&self, schema_id: &str, index: &str) -> bool { + (schema_id == leaf_a() || schema_id == leaf_b()) && index == "mass" + } + fn target_leaves(&self, role: &str) -> Vec { + match role { + "parent" | "Design" | "related" | "components" => { + vec![leaf_a().into(), leaf_b().into()] + } + "narrow_ref" => vec![leaf_a().into()], + "peer" => vec![citer().into()], + _ => Vec::new(), + } + } +} + +fn rr(schema_id: &str, record_key: &str) -> RecordRef { + RecordRef::new(schema_id, record_key) +} + +// S2-S23's original 7-row set: the shared fixture's first 7 rows (a-lo..citer-1), unchanged in +// value or order from before the shared fixture - every test below that enumerates base_rows()'s +// exact key order or count (S3, S4/S5, matched_is_the_total_count_not_the_page_page, ...) still +// holds. The parity-vector tests use all_fixture_rows() instead, which adds the fix-batch rows +// (float tie, astral/BMP keys) the shared fixture also carries. +fn base_rows() -> Vec { + fixture().rows.iter().take(7).map(build_row).collect() +} + +fn all_fixture_rows() -> Vec { + fixture().rows.iter().map(build_row).collect() +} + +// S2: fields conjoin over declared indexes. +#[test] +fn conjoins_any_of_and_comparison_predicates() { + let rows = base_rows(); + let selection = Selection { + fields: Some(vec![ + FieldPredicate { + index: "kind".into(), + op: "any_of".into(), + values: Some(vec!["weapon".into()]), + number: None, + }, + FieldPredicate { + index: "mass".into(), + op: "ge".into(), + values: None, + number: Some("5".into()), + }, + ]), + ..Selection::default() + }; + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).expect("selection is valid"); + let ids: Vec<&str> = evaluation.rows.iter().map(Row::record_key).collect(); + assert_eq!(ids, vec!["a-eq"]); +} + +// S3: order is (ordinal, schemaId, recordKey) ascending, reversed under descending. +#[test] +fn orders_by_ordinal_then_identity_and_reverses() { + let rows = base_rows(); + let ascending = select(&FixtureRowSet, &rows, &Selection::default(), 1, test_cursor_key()).unwrap(); + assert_eq!( + ascending.rows.iter().map(Row::record_key).collect::>(), + vec!["a-lo", "a-eq", "b-hi", "shared-1", "shared-2", "shared-3", "citer-1"] + ); + + let descending = select( + &FixtureRowSet, + &rows, + &Selection { + descending: true, + ..Selection::default() + }, + 1, + test_cursor_key(), + ) + .unwrap(); + assert_eq!( + descending.rows.iter().map(Row::record_key).collect::>(), + vec!["citer-1", "shared-3", "shared-2", "shared-1", "b-hi", "a-eq", "a-lo"] + ); +} + +// R-AJ (Soul's SM-4): `base_rows()`'s one tied ordinal (a-lo/a-eq/b-hi... no - actually every +// base row has a distinct ordinal) never exercises the schemaId component of `order_rows`'s +// `(ordinal, schemaId, recordKey)` tiebreak at all. This test builds two rows, one on each +// schema, sharing one ordinal, with keys chosen *relative to the schema ids discovered at +// runtime* - the low-sorting schema gets the high-sorting key ("z") and the high-sorting schema +// gets the low-sorting key ("a") - so a schemaId-first order and a key-first order are the exact +// reverse of each other no matter which real schema id happens to be smaller (a fixture that +// merely ties one ordinal across two same-key-ordered rows can pass under either rule by +// coincidence - see the C# mirror's fix, CultNetSelectionSurvivorTests.cs, for the probe that +// found this). +#[test] +fn row_tiebreak_orders_by_schema_id_before_record_key_when_both_vary_at_a_tied_ordinal() { + let (low_schema, high_schema) = if leaf_a() < leaf_b() { (leaf_a(), leaf_b()) } else { (leaf_b(), leaf_a()) }; + let low_schema_high_key = FixtureRow::leaf(low_schema, "z", 1, "k", "1"); + let high_schema_low_key = FixtureRow::leaf(high_schema, "a", 1, "k", "1"); + let rows = vec![low_schema_high_key, high_schema_low_key]; + + let evaluation = select(&FixtureRowSet, &rows, &Selection::default(), 1, test_cursor_key()).unwrap(); + + // schemaId-first (the rule): low_schema < high_schema regardless of key, so the "z"-keyed row + // (on low_schema) comes first. record_key-first (the mutant) would instead put the "a"-keyed + // row first - the exact reverse. + let order: Vec<(&str, &str)> = evaluation.rows.iter().map(|r| (r.schema_id(), r.record_key())).collect(); + assert_eq!(order, vec![(low_schema, "z"), (high_schema, "a")]); +} + +// R-AT (Soul's SM-7 table, row comparator): `order_rows`'s tiebreak is +// (ordinal, schema_id, record_key) - the test above pins schema_id ahead of record_key, but every +// row in it has a distinct record_key too, so record_key never has to be the sole discriminator. +// This test ties both ordinal and schema_id across two rows on the same schema and varies only +// record_key, inserted out of order, so dropping record_key from the comparator entirely (SM-7's +// named Rust row-comparator survivor - killed in C# a pass ago, never pinned in Rust) leaves them +// in insertion order instead of "a" before "z". +#[test] +fn row_tiebreak_orders_by_record_key_when_ordinal_and_schema_id_are_both_tied() { + let high_key = FixtureRow::leaf(leaf_a(), "z", 1, "k", "1"); + let low_key = FixtureRow::leaf(leaf_a(), "a", 1, "k", "1"); + // Inserted high-key-first, the reverse of the required output. + let rows = vec![high_key, low_key]; + + let evaluation = select(&FixtureRowSet, &rows, &Selection::default(), 1, test_cursor_key()).unwrap(); + + let order: Vec<&str> = evaluation.rows.iter().map(Row::record_key).collect(); + assert_eq!(order, vec!["a", "z"]); +} + +// S4/S5: a page walk visits every row exactly once, the last page carries no cursor, and a +// cursor is refused when it does not decode, its digest does not match, or asOf has moved. +#[test] +fn pages_exactly_once_and_refuses_a_stale_or_mismatched_cursor() { + let rows = base_rows(); + let mut selection = Selection { + limit: Some(2), + ..Selection::default() + }; + let mut seen = Vec::new(); + for _ in 0..10 { + let page = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + seen.extend(page.rows.iter().map(|r| r.record_key.to_string())); + match page.next_cursor { + Some(cursor) => selection.cursor = Some(cursor), + None => break, + } + } + assert_eq!( + seen, + vec!["a-lo", "a-eq", "b-hi", "shared-1", "shared-2", "shared-3", "citer-1"] + ); + + let first = select(&FixtureRowSet, &rows, &Selection { limit: Some(1), ..Selection::default() }, 1, test_cursor_key()).unwrap(); + let cursor = first.next_cursor.expect("more than one row"); + + let stale = select( + &FixtureRowSet, + &rows, + &Selection { limit: Some(1), cursor: Some(cursor.clone()), ..Selection::default() }, + 2, + test_cursor_key(), + ); + assert!(matches!(stale, Err(SelectionRefusal::CursorStale { .. }))); + + let mismatched = select( + &FixtureRowSet, + &rows, + &Selection { limit: Some(1), cursor: Some(cursor), descending: true, ..Selection::default() }, + 1, + test_cursor_key(), + ); + assert!(matches!(mismatched, Err(SelectionRefusal::CursorInvalid { .. }))); + + let garbage = select( + &FixtureRowSet, + &rows, + &Selection { cursor: Some("not-base64!!".into()), ..Selection::default() }, + 1, + test_cursor_key(), + ); + assert!(matches!(garbage, Err(SelectionRefusal::CursorInvalid { .. }))); +} + +// S6: the hop follows one declared reference by role. +#[test] +fn hops_one_edge_by_declared_role() { + let rows = base_rows(); + let selection = Selection { + cites: Some(Citation { + target: rr(leaf_a(), "a-eq"), + role: Some("Design".into()), + }), + ..Selection::default() + }; + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + assert_eq!(evaluation.rows.iter().map(Row::record_key).collect::>(), vec!["citer-1"]); + assert_eq!(evaluation.edges.len(), 1); + assert_eq!(evaluation.edges[0].role, "Design"); +} + +// S6, isolated: a citer with two references at the *same* target under different roles - the +// role filter must exclude the one the caller did not ask for. base_rows()'s citer-1 only ever +// carries one reference, so a mutant that drops the role filter entirely is invisible there (it +// has nothing else to wrongly match); this fixture gives it something to wrongly match. +#[test] +fn cites_role_excludes_a_second_reference_at_the_same_target() { + let rows = vec![ + FixtureRow::leaf(leaf_a(), "a-eq", 1, "weapon", "5"), + FixtureRow::citer( + "double-citer", + 2, + vec![ + ("Design".to_string(), "a-eq".to_string(), None), + ("OtherRole".to_string(), "a-eq".to_string(), None), + ], + ), + ]; + let selection = Selection { + cites: Some(Citation { + target: rr(leaf_a(), "a-eq"), + role: Some("Design".into()), + }), + ..Selection::default() + }; + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + assert_eq!(evaluation.edges.len(), 1, "OtherRole's reference must not also match"); + assert_eq!(evaluation.edges[0].role, "Design"); +} + +// S7: cited { exists } is the one negation, and the two directions answer opposite sets. +#[test] +fn cited_exists_is_the_one_negation() { + let rows = base_rows(); + let cited = select( + &FixtureRowSet, + &rows, + &Selection { + schemas: Some(vec![leaf_a().into()]), + cited: Some(Incoming { role: "Design".into(), exists: true }), + ..Selection::default() + }, + 1, + test_cursor_key(), + ) + .unwrap(); + assert_eq!(cited.rows.iter().map(Row::record_key).collect::>(), vec!["a-eq"]); + + let uncited = select( + &FixtureRowSet, + &rows, + &Selection { + schemas: Some(vec![leaf_a().into()]), + cited: Some(Incoming { role: "Design".into(), exists: false }), + ..Selection::default() + }, + 1, + test_cursor_key(), + ) + .unwrap(); + let mut ids: Vec<&str> = uncited.rows.iter().map(Row::record_key).collect(); + ids.sort_unstable(); + assert_eq!(ids, vec!["a-lo", "shared-1", "shared-3"]); +} + +// R-V (S-1): a row's identity is `(schemaId, recordKey)`, never the key alone. CultCache keys are +// unique per schema, so two schemas can share one record key. Before the fix, `byKey` kept only +// the last row seen at a key, so which schema's row a `cites` edge resolved to - and whether a +// `cited` selection counted a row as cited at all - depended on row order. +// +// R-AF: uses `narrow_ref` (declared target leaf_a only), not `Design` (both leaves) - a stored +// edge carries no schema on the wire (`Row::references`'s doc comment), so a *broad*-target role +// has nothing of its own to break a shared-key tie with and genuinely does depend on row order +// (Soul's map, SM-1/R-AF: "both row orders give the same answer" is R-V's narrower promise, true +// only when the declared target excludes all-but-one of the duplicate-key candidates). A +// narrow-target role still resolves the one candidate inside its target regardless of order, +// because the other schema's row at the same key is never a valid candidate to begin with - that +// is the guarantee this test pins. +#[test] +fn row_identity_is_schema_and_key_not_key_alone_in_either_row_order() { + let leaf_a_row = FixtureRow::leaf(leaf_a(), "shared-key", 1, "weapon", "1"); + let leaf_b_row = FixtureRow::leaf(leaf_b(), "shared-key", 2, "shield", "2"); + let citer_row = FixtureRow { + schema_id: NARROW_CITER, + record_key: "citer-1", + ordinal: 3, + kind: None, + mass: None, + references: vec![("narrow_ref".to_string(), "shared-key".to_string(), None)], + }; + + let cites_selection = Selection { + cites: Some(Citation { target: rr(leaf_a(), "shared-key"), role: Some("narrow_ref".into()) }), + ..Selection::default() + }; + let cited_selection = Selection { + cited: Some(Incoming { role: "narrow_ref".into(), exists: true }), + ..Selection::default() + }; + + for rows in [ + vec![leaf_a_row.clone(), leaf_b_row.clone(), citer_row.clone()], + vec![leaf_b_row.clone(), leaf_a_row.clone(), citer_row.clone()], + ] { + let cites_eval = select(&FixtureRowSet, &rows, &cites_selection, 1, test_cursor_key()).unwrap(); + assert_eq!( + cites_eval.rows.iter().map(Row::record_key).collect::>(), + vec!["citer-1"], + "row order must not change whether the citation resolves" + ); + assert_eq!(cites_eval.edges.len(), 1); + assert_eq!( + cites_eval.edges[0].to.schema_id(), + leaf_a(), + "the edge must resolve to leaf_a's row, never leaf_b's, regardless of row order" + ); + + let cited_eval = select(&FixtureRowSet, &rows, &cited_selection, 1, test_cursor_key()).unwrap(); + let cited_pairs: Vec<(&str, &str)> = + cited_eval.rows.iter().map(|r| (r.schema_id(), r.record_key())).collect(); + assert_eq!( + cited_pairs, + vec![(leaf_a(), "shared-key")], + "cited membership is (schemaId, recordKey) - leaf_b's row at the same key must not \ + count as cited just because leaf_a's row at that key is" + ); + } +} + +// R-W (S-3), Rust half: a selection carrying both `cites` and `cited` anchors each edge by its +// own hop, so it returns both sets of edges rather than one flag deciding the direction for the +// whole batch. Shape from Soul's probe (soul-sel-final-notes.md): a page row "ca" that cites a +// leaf via "Design" and is itself cited by another citer "cb" via "peer" - the answer must carry +// both `cb --peer--> ca` (anchored on the citee) and `ca --Design--> leaf` (anchored on the +// citer). +#[test] +fn a_selection_with_both_hops_returns_both_directions_edges() { + let leaf = FixtureRow::leaf(leaf_a(), "leaf-row", 1, "weapon", "5"); + let ca = FixtureRow::citer("ca", 2, vec![("Design".to_string(), "leaf-row".to_string(), None)]); + let cb = FixtureRow::citer("cb", 3, vec![("peer".to_string(), "ca".to_string(), None)]); + let rows = vec![leaf, ca, cb]; + + let selection = Selection { + cites: Some(Citation { target: rr(leaf_a(), "leaf-row"), role: Some("Design".into()) }), + cited: Some(Incoming { role: "peer".into(), exists: true }), + ..Selection::default() + }; + + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + assert_eq!(evaluation.rows.iter().map(Row::record_key).collect::>(), vec!["ca"]); + assert_eq!( + evaluation.edges.len(), + 2, + "both the cites edge (ca->leaf) and the cited edge (cb->ca) must survive - not just one" + ); + + let mut seen: Vec<(&str, &str, &str)> = evaluation + .edges + .iter() + .map(|edge| (edge.from.record_key(), edge.role.as_str(), edge.to.record_key())) + .collect(); + seen.sort_unstable(); + assert_eq!(seen, vec![("ca", "Design", "leaf-row"), ("cb", "peer", "ca")]); +} + +// R-AJ (Soul's SM-4): the test above (and its C# mirror) never actually checks the evaluator's +// edge *order* - it sorts the observed edges before comparing, so it pins which edges appear, not +// where. And even a version that did compare order would be blind here: "ca" < "cb" and +// "Design" < "peer" agree, so a From-key-first rule and a Role-first rule produce the same +// sequence by coincidence. This test picks a hub citer key and role name that *disagree*: the hub +// ("z_hub") sorts after the other citer ("a_other") by key, but the hub's own cites-edge role +// ("Design") sorts before the other's cited-edge role ("peer") - so a From-key-first order and a +// Role-first order are provably the reverse of each other, and only one of them is the rule (R-B: +// "page-row order, then (from, role, to)"). +#[test] +fn edge_order_is_from_key_before_role_when_both_vary_at_the_same_anchor() { + let leaf = FixtureRow::leaf(leaf_a(), "target", 1, "weapon", "5"); + let hub = FixtureRow::citer("z_hub", 2, vec![("Design".to_string(), "target".to_string(), None)]); + let other = FixtureRow::citer("a_other", 3, vec![("peer".to_string(), "z_hub".to_string(), None)]); + let rows = vec![leaf, hub, other]; + + let selection = Selection { + cites: Some(Citation { target: rr(leaf_a(), "target"), role: Some("Design".into()) }), + cited: Some(Incoming { role: "peer".into(), exists: true }), + ..Selection::default() + }; + + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + assert_eq!(evaluation.rows.iter().map(Row::record_key).collect::>(), vec!["z_hub"]); + assert_eq!(evaluation.edges.len(), 2); + + // From-key-first (the rule): "a_other" < "z_hub", so the cited edge (From=a_other) comes + // first. Role-first (the mutant) would instead put the cites edge (Role="Design") first, + // since "Design" < "peer" - the exact reverse. + let order: Vec<(&str, &str)> = + evaluation.edges.iter().map(|edge| (edge.from.record_key(), edge.role.as_str())).collect(); + assert_eq!(order, vec![("a_other", "peer"), ("z_hub", "Design")]); +} + +// R-AN: the test above pins from.record_key before role, but ties From and To across the two +// edges - dropping role from the comparator entirely still leaves from.record_key as the sole, +// sufficient discriminator, so that mutation survives (Soul's SM-4). This test ties From (one +// citer) and To (one target) identically across both edges - only role can differ - by giving the +// same citer two references through different roles at the same target key. Citation.role: None +// matches any declared reference (matches_citation), so both edges surface from one `cites` query. +// Declared out of code-point order ("parent" before "Design") on purpose, so a stable sort with +// role dropped from the comparator would leave them in that wrong order. +#[test] +fn edge_order_pins_role_when_from_and_to_are_both_tied() { + let leaf = FixtureRow::leaf(leaf_a(), "target", 1, "weapon", "5"); + let hub = FixtureRow::citer( + "hub", + 2, + vec![ + ("parent".to_string(), "target".to_string(), None), + ("Design".to_string(), "target".to_string(), None), + ], + ); + let rows = vec![leaf, hub]; + + let selection = + Selection { cites: Some(Citation { target: rr(leaf_a(), "target"), role: None }), ..Selection::default() }; + + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + assert_eq!(evaluation.edges.len(), 2); + let order: Vec<&str> = evaluation.edges.iter().map(|edge| edge.role.as_str()).collect(); + // "Design" < "parent" in code-point order (uppercase 'D' 0x44 sorts before lowercase 'p' 0x70). + assert_eq!(order, vec!["Design", "parent"]); +} + +// R-AN: ties From.Key and Role across the two edges (both citers share one record key and cite +// the same target through the same role, "peer" - already declared by the `citer()` schema and +// resolvable regardless of which schema's row carries it, since target_leaves takes no schema - +// Soul's separate PLAUSIBLE finding) - only From.SchemaId can differ. Two different-schema citer +// rows (`citer()` and `citer_narrow()`), same record key, inserted in the opposite of the correct +// order so a stable sort with from.schema_id dropped from the comparator would leave them wrong. +#[test] +fn edge_order_pins_from_schema_id_when_from_key_and_role_are_both_tied() { + let target = FixtureRow::citer("hub-target", 1, Vec::new()); + let from_wide = FixtureRow::citer("same-key", 2, vec![("peer".to_string(), "hub-target".to_string(), None)]); + let from_narrow = + FixtureRow::citer_narrow("same-key", 3, vec![("peer".to_string(), "hub-target".to_string(), None)]); + // Insertion order is whichever schema id sorts *second* first, then the one that sorts first - + // deliberately the reverse of the comparator's required output. + let (first_inserted, second_inserted) = if citer() < citer_narrow() { + (from_narrow, from_wide) + } else { + (from_wide, from_narrow) + }; + let expected_first_schema = if citer() < citer_narrow() { citer() } else { citer_narrow() }; + let rows = vec![target, first_inserted, second_inserted]; + + let selection = + Selection { cited: Some(Incoming { role: "peer".into(), exists: true }), ..Selection::default() }; + + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + assert_eq!(evaluation.rows.iter().map(Row::record_key).collect::>(), vec!["hub-target"]); + assert_eq!(evaluation.edges.len(), 2); + assert_eq!(evaluation.edges[0].from.record_key(), "same-key"); + assert_eq!(evaluation.edges[1].from.record_key(), "same-key"); + assert_eq!(evaluation.edges[0].role, "peer"); + assert_eq!(evaluation.edges[1].role, "peer"); + assert_eq!(evaluation.edges[0].from.schema_id(), expected_first_schema); +} + +// R-AT (Soul's SM-7, merge gate third pass): the two tests above tie From+To or From+Role but +// never both edges' `to` while `from` and `role` are also tied - so dropping `to.schema_id` and +// `to.record_key` from the edge comparator entirely (or reversing `to.record_key`) still sorted +// correctly by luck and survived under mutation. Hands' argument that no construction produces +// this was false: a selection carrying BOTH hops (`cites` and `cited`) mixes their edges into one +// `Vec`, and a row that self-references through the same role it also uses to cite a peer yields +// a `cites` edge (`to` = the queried peer) and a `cited` edge (`to` = the anchor row itself) tied +// on `from` and `role`, differing only on `to`. Soul built this counterexample and it is taken +// from the rig, not rebuilt - but the rig's own "hub"/"other" naming leaves `to.record_key` +// dropped-entirely still SURVIVING: `select`'s `cited` phase always pushes the self-edge before +// the `cites` phase pushes the peer edge, and "hub" < "other" happens to match that push order, so +// a stable sort with the tiebreak gone reproduces the right answer by coincidence. Naming the +// self-citing row so its key sorts AFTER the cited target's key ("zebra" self-cites and cites +// "alpha") makes the push order and the correct sorted order disagree, so only the comparator's +// own `to` component - not accidental insertion order - can produce the right answer. +#[test] +fn edge_order_pins_to_when_from_and_role_are_both_tied_by_a_self_citing_peer() { + let zebra = FixtureRow::citer( + "zebra", + 1, + vec![ + ("peer".to_string(), "zebra".to_string(), None), + ("peer".to_string(), "alpha".to_string(), None), + ], + ); + let alpha = FixtureRow::citer("alpha", 2, Vec::new()); + let rows = vec![zebra, alpha]; + + let selection = Selection { + cites: Some(Citation { target: rr(citer(), "alpha"), role: Some("peer".into()) }), + cited: Some(Incoming { role: "peer".into(), exists: true }), + ..Selection::default() + }; + + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + assert_eq!(evaluation.edges.len(), 2, "expected two edges in one page-position bucket"); + assert_eq!(evaluation.edges[0].from.record_key(), evaluation.edges[1].from.record_key()); + assert_eq!(evaluation.edges[0].role, evaluation.edges[1].role); + assert_ne!( + evaluation.edges[0].to.record_key(), + evaluation.edges[1].to.record_key(), + "To must differ - if it does not, Hands' equivalence claim survives this construction" + ); + // The comparator's to component is what decides the order: "alpha" < "zebra" in code-point + // order, opposite of push order (the self edge is always pushed before the cites edge), so + // dropping or reversing to.record_key flips this rather than merely agreeing by luck. + assert_eq!(evaluation.edges[0].to.record_key(), "alpha"); + assert_eq!(evaluation.edges[1].to.record_key(), "zebra"); +} + +// S16: the four comparisons at the boundary, including the row whose value equals the compared +// number exactly. +#[test] +fn compares_numbers_at_the_boundary_for_all_four_operators() { + let rows = vec![ + FixtureRow::leaf(leaf_a(), "lo", 1, "k", "4"), + FixtureRow::leaf(leaf_a(), "eq", 2, "k", "5"), + FixtureRow::leaf(leaf_a(), "hi", 3, "k", "6"), + ]; + let matches_for = |op: &str| { + let selection = Selection { + fields: Some(vec![FieldPredicate { + index: "mass".into(), + op: op.into(), + values: None, + number: Some("5".into()), + }]), + ..Selection::default() + }; + let mut ids: Vec = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()) + .unwrap() + .rows + .iter() + .map(|r| r.record_key.to_string()) + .collect(); + ids.sort(); + ids + }; + assert_eq!(matches_for("lt"), vec!["lo"]); + assert_eq!(matches_for("le"), vec!["eq", "lo"]); + assert_eq!(matches_for("ge"), vec!["eq", "hi"]); + assert_eq!(matches_for("gt"), vec!["hi"]); +} + +// S18: an edge naming a row outside the reference's declared target refuses the selection. +#[test] +fn refuses_an_edge_outside_its_declared_target() { + let rows = vec![ + FixtureRow::leaf(leaf_b(), "b", 1, "k", "1"), + FixtureRow { + schema_id: NARROW_CITER, + record_key: "bad", + ordinal: 2, + kind: None, + mass: None, + references: vec![("narrow_ref".to_string(), "b".to_string(), None)], + }, + ]; + let selection = Selection { + cited: Some(Incoming { role: "narrow_ref".into(), exists: true }), + ..Selection::default() + }; + let result = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()); + assert!(matches!(result, Err(SelectionRefusal::ReferenceOutsideTarget { .. }))); +} + +// R-AF, Soul's SM-1 shape table (docs/cultnet-selection-cut.md): a record key shared by two rows +// of different schemas, one inside the declared target and one outside it. The in-target row must +// resolve regardless of which schema's row happens to enumerate first - the edge itself carries no +// schema to break the tie (`Row::references`'s doc comment), so only the declared leaf set +// decides. This is the shape neither runtime's parity vectors covered before this cut (zero +// duplicate record keys across the original fixture's ten rows). +#[test] +fn shared_key_resolves_to_the_row_inside_the_declared_target_in_either_row_order() { + let leaf_a_row = FixtureRow::leaf(leaf_a(), "dup-key", 1, "weapon", "1"); + let leaf_b_row = FixtureRow::leaf(leaf_b(), "dup-key", 2, "shield", "2"); + let citer_row = FixtureRow { + schema_id: NARROW_CITER, + record_key: "narrow-citer", + ordinal: 3, + kind: None, + mass: None, + references: vec![("narrow_ref".to_string(), "dup-key".to_string(), None)], + }; + let selection = Selection { + cited: Some(Incoming { role: "narrow_ref".into(), exists: true }), + ..Selection::default() + }; + + for rows in [ + vec![leaf_a_row.clone(), leaf_b_row.clone(), citer_row.clone()], + vec![leaf_b_row.clone(), leaf_a_row.clone(), citer_row.clone()], + ] { + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + assert_eq!(evaluation.edges.len(), 1); + assert_eq!( + evaluation.edges[0].to.schema_id(), + leaf_a(), + "must resolve to leaf_a, the only in-target row, never leaf_b, in either row order" + ); + assert_eq!(evaluation.edges[0].to.record_key(), "dup-key"); + } +} + +// R-AO: the test above covers one in-target candidate and one out - resolve_reference_target's +// `for &candidate in candidates { ... return Ok(Some(candidate)) }` picks the first match, but +// nothing pinned that rule specifically when *two* in-target rows share a key (Soul's SM-4: "the +// first row in the caller's order wins" was a coincidence of source, not a rule). "Design"'s +// declared target is [leaf_a, leaf_b] (FixtureRowSet::target_leaves) - both schemas are in-target, +// so which one resolves depends only on which the caller listed first in `rows`/`by_key`'s +// insertion order. Both orders are exercised, each expecting whichever row was listed first in +// *that* run - proving the rule is "first in the caller's order", not merely "leaf_a happens to +// win" or some other fixed tiebreak. +#[test] +fn shared_key_with_two_in_target_candidates_resolves_to_whichever_the_caller_listed_first() { + let leaf_a_row = FixtureRow::leaf(leaf_a(), "dup-key-both-in", 1, "weapon", "1"); + let leaf_b_row = FixtureRow::leaf(leaf_b(), "dup-key-both-in", 2, "shield", "2"); + let citer_row = FixtureRow::citer( + "both-in-citer", + 3, + vec![("Design".to_string(), "dup-key-both-in".to_string(), None)], + ); + let selection = + Selection { cited: Some(Incoming { role: "Design".into(), exists: true }), ..Selection::default() }; + + for (rows, expected_first_schema) in [ + (vec![leaf_a_row.clone(), leaf_b_row.clone(), citer_row.clone()], leaf_a()), + (vec![leaf_b_row.clone(), leaf_a_row.clone(), citer_row.clone()], leaf_b()), + ] { + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + assert_eq!(evaluation.edges.len(), 1); + assert_eq!( + evaluation.edges[0].to.schema_id(), + expected_first_schema, + "must resolve to whichever in-target row the caller listed first, not a fixed schema" + ); + assert_eq!(evaluation.edges[0].to.record_key(), "dup-key-both-in"); + } +} + +// R-AF, the mirror shape: the only row at the key is outside the declared target - the edge must +// refuse the selection (S18), not silently produce an empty page. Opposite of +// `refuses_an_edge_outside_its_declared_target` above only in that this key is one a same-role, +// in-target row could also have shared (see the test above) - it just doesn't here. +#[test] +fn shared_key_refuses_when_the_only_candidate_is_outside_the_declared_target() { + let leaf_b_row = FixtureRow::leaf(leaf_b(), "narrow-only", 1, "shield", "2"); + let citer_row = FixtureRow { + schema_id: NARROW_CITER, + record_key: "narrow-citer", + ordinal: 2, + kind: None, + mass: None, + references: vec![("narrow_ref".to_string(), "narrow-only".to_string(), None)], + }; + let rows = vec![leaf_b_row, citer_row]; + let selection = Selection { + cited: Some(Incoming { role: "narrow_ref".into(), exists: true }), + ..Selection::default() + }; + let result = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()); + assert!(matches!(result, Err(SelectionRefusal::ReferenceOutsideTarget { .. }))); +} + +// R-T/F15: `cites` checks every edge of the *queried role* a citer carries against its declared +// target, not only the one edge whose key matches the citation - a many-reference member can carry +// several edges under the one role/member name, and a corrupt one among them must not hide behind +// the key filter. Two edges under "Design" here (this crate's `FixtureRow` has no distinct +// many-reference shape - two tuples sharing one role name is the same thing D11 gives one edge per +// element of), one in-target and one out, both under the role the citation asks about - mirrors +// C#'s own R-T(F15) test exactly (Evaluator_CitesRefusesAnOutOfTargetEdgeEvenWhenTheQueriedKey- +// MatchesNeitherEdge, CultNetSelectionFixBatchTests.cs), which queries the *same* role/member that +// carries both elements. +// +// R-AF (found landing it, matches_citation's own comment): an *unrelated* role's out-of-target edge +// no longer poisons a citation for a *different*, explicitly-requested role - that was this test's +// original shape, and it tested a stronger guarantee than the C# reference actually gives +// (ReferenceMembers(descriptor, citation.Role) filters by role before R-W's per-edge check ever +// runs, CultNetSelectionEvaluator.cs:451-458). +#[test] +fn cites_refuses_an_out_of_target_edge_under_the_same_queried_role() { + let rows = vec![ + FixtureRow::leaf(leaf_a(), "a-eq", 1, "weapon", "5"), + // Design's declared target is [leaf_a, leaf_b] (FixtureRowSet::target_leaves) - a citer + // schema row is outside it, so a "Design" edge pointed here is the out-of-target one. + FixtureRow::citer("other-citer", 2, Vec::new()), + FixtureRow::citer( + "bad-citer", + 3, + vec![ + ("Design".to_string(), "a-eq".to_string(), None), + ("Design".to_string(), "other-citer".to_string(), None), + ], + ), + ]; + // Queries a key neither Design edge carries - the door for the pre-fix bug this pins: skipping + // straight to "does any edge's key match" would find nothing and answer an empty page, never + // looking at the out-of-target second edge at all. + let selection = Selection { + cites: Some(Citation { target: rr(leaf_a(), "does-not-exist"), role: Some("Design".into()) }), + ..Selection::default() + }; + let result = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()); + assert!(matches!(result, Err(SelectionRefusal::ReferenceOutsideTarget { .. }))); +} + +// S19-equivalent (D11): a dictionary reference's two entries keep distinct payload bytes. +// Row::references() is where Rust exposes this - there is no separate cache layer to test it +// against, unlike C#'s ReferencesOf/Cache_EnumeratesADictionaryReferenceAsEdgesCarryingItsValues. +#[test] +fn dictionary_reference_entries_keep_distinct_payload_bytes() { + let citer = FixtureRow::citer( + "assembly", + 3, + vec![ + ("components".to_string(), "part-1".to_string(), Some(vec![1, 5])), + ("components".to_string(), "part-2".to_string(), Some(vec![2, 5])), + ], + ); + let payloads: std::collections::HashSet> = + citer.references().into_iter().filter_map(|(_, _, payload)| payload).collect(); + assert_eq!(payloads.len(), 2, "the two dictionary entries must keep distinct payloads"); +} + +// R-B: hop edges follow the hop's direction. A `cites` selection's page holds the citer, so its +// edges (From = citer) survive the page filter, mirroring +// `EvaluatorHopsOneEdgeByDeclaredReferenceAndRole`. A `cited` selection's page holds the *citee* +// instead, so - as of R-B - its edges survive when their *To* is on the page, not their *From*: +// the citer ("assembly") need not itself be on the page at all. (Before R-B, Rust filtered both +// hop kinds by `edge.From` on the page, so a `cited` selection's edges were silently empty +// whenever the citer itself did not separately match - Soul's F2 finding.) +#[test] +fn cited_selections_page_the_citee_and_their_edges_survive_the_page_filter() { + let rows = vec![ + FixtureRow::leaf(leaf_a(), "part-1", 1, "k", "1"), + FixtureRow::citer( + "assembly", + 2, + vec![("components".to_string(), "part-1".to_string(), Some(vec![9]))], + ), + ]; + let selection = Selection { + cited: Some(Incoming { role: "components".into(), exists: true }), + ..Selection::default() + }; + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + assert_eq!(evaluation.rows.iter().map(Row::record_key).collect::>(), vec!["part-1"]); + assert_eq!(evaluation.edges.len(), 1, "the citee (part-1) is on the page, so its incoming edge survives"); + assert_eq!(evaluation.edges[0].from.record_key, "assembly"); + assert_eq!(evaluation.edges[0].to.record_key, "part-1"); + assert_eq!(evaluation.edges[0].payload, Some(vec![9])); +} + +// R-B: the converse of the test above - a `cites` selection's edges still key off the *citer* +// being on the page (unchanged by R-B), so an edge whose citee is on the page but whose citer is +// not filtered out does not leak in under `cites`. +#[test] +fn cites_selections_still_page_the_citer_and_key_edges_off_it() { + let rows = vec![ + FixtureRow::leaf(leaf_a(), "a-eq", 1, "weapon", "5"), + FixtureRow::citer("citer-1", 2, vec![("Design".to_string(), "a-eq".to_string(), None)]), + ]; + let selection = Selection { + cites: Some(Citation { target: rr(leaf_a(), "a-eq"), role: Some("Design".into()) }), + ..Selection::default() + }; + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + assert_eq!(evaluation.rows.iter().map(Row::record_key).collect::>(), vec!["citer-1"]); + assert_eq!(evaluation.edges.len(), 1); + assert_eq!(evaluation.edges[0].from.record_key, "citer-1"); +} + +// S23: the evaluator matches every row sharing an index value - not a cache's last-writer-wins +// unique-index lookup (Rust's toy Row set never had that bug, but the rule is pinned here too: +// nothing in select() short-circuits after the first match). +#[test] +fn matches_every_row_sharing_an_index_value() { + let rows = base_rows(); + let selection = Selection { + fields: Some(vec![FieldPredicate { + index: "kind".into(), + op: "any_of".into(), + values: Some(vec!["shared".into()]), + number: None, + }]), + ..Selection::default() + }; + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + let mut ids: Vec<&str> = evaluation.rows.iter().map(Row::record_key).collect(); + ids.sort_unstable(); + assert_eq!(ids, vec!["shared-1", "shared-2", "shared-3"]); +} + +// S1: the door refuses typed rather than answering an empty page. +#[test] +fn validation_refuses_undeclared_index_role_and_empty_lists() { + let empty_schemas = Selection { + schemas: Some(Vec::new()), + ..Selection::default() + }; + assert_eq!(validate(&empty_schemas, &FixtureRowSet).unwrap_err().field, "schemas"); + + let empty_keys = Selection { + keys: Some(Vec::new()), + ..Selection::default() + }; + assert_eq!(validate(&empty_keys, &FixtureRowSet).unwrap_err().field, "keys"); + + let undeclared_index = Selection { + fields: Some(vec![FieldPredicate { + index: "not_declared".into(), + op: "any_of".into(), + values: Some(vec!["x".into()]), + number: None, + }]), + ..Selection::default() + }; + assert_eq!( + validate(&undeclared_index, &FixtureRowSet).unwrap_err().field, + "fields[0].index" + ); + + let undeclared_role = Selection { + cited: Some(Incoming { role: "no_such_role".into(), exists: true }), + ..Selection::default() + }; + assert_eq!(validate(&undeclared_role, &FixtureRowSet).unwrap_err().field, "cited.role"); + + let non_numeric_compare = Selection { + fields: Some(vec![FieldPredicate { + index: "kind".into(), + op: "gt".into(), + values: None, + number: Some("1".into()), + }]), + ..Selection::default() + }; + assert_eq!( + validate(&non_numeric_compare, &FixtureRowSet).unwrap_err().field, + "fields[0].index" + ); +} + +// Self's ruling, 2026-09-22 (docs/cultnet-selection-cut.md, commit 2 fix batch): the door refuses +// a blank entry inside a present schemas/keys list, not only an empty list - the evaluator must +// never have to decide what "" or " " means. +#[test] +fn validation_refuses_a_blank_entry_in_schemas_or_keys() { + let blank_schema = Selection { + schemas: Some(vec![leaf_a().into(), " ".into()]), + ..Selection::default() + }; + assert_eq!(validate(&blank_schema, &FixtureRowSet).unwrap_err().field, "schemas"); + + let blank_key = Selection { + keys: Some(vec!["".into()]), + ..Selection::default() + }; + assert_eq!(validate(&blank_key, &FixtureRowSet).unwrap_err().field, "keys"); +} + +// Q-J: the door refuses every named non-canonical spelling of a comparison number. +#[test] +fn validation_refuses_non_canonical_number_spellings() { + for spelling in ["+1", "1.0", "01", "1e3", "-0"] { + let selection = Selection { + fields: Some(vec![FieldPredicate { + index: "mass".into(), + op: "gt".into(), + values: None, + number: Some(spelling.into()), + }]), + ..Selection::default() + }; + let error = validate(&selection, &FixtureRowSet) + .expect_err(&format!("{spelling:?} must be refused")); + assert_eq!(error.field, "fields[0].number", "spelling {spelling:?}"); + } +} + +// S13a: nothing on the wire is serde-untagged (docs/cultnet-selection-cut.md section 2). +#[test] +fn selection_is_never_untagged() { + let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + for entry in fs::read_dir(&src_dir).expect("src directory exists") { + let entry = entry.expect("readable directory entry"); + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("rs") { + continue; + } + let text = fs::read_to_string(&path).unwrap_or_default(); + assert!( + !text.contains("untagged"), + "{} must not contain #[serde(untagged)] (section 2's rule)", + path.display() + ); + } +} + +// S13b: the selection types round-trip through the named-map MessagePack encoding every runtime +// on this wire uses (rmp_serde::to_vec_named / from_slice), across every shape this cut adds. +#[test] +fn selection_round_trips_through_named_messagepack() { + let selection = Selection { + schemas: Some(vec![leaf_a().into(), leaf_b().into()]), + keys: Some(vec!["k1".into()]), + fields: Some(vec![ + FieldPredicate { + index: "kind".into(), + op: "any_of".into(), + values: Some(vec!["weapon".into()]), + number: None, + }, + FieldPredicate { + index: "mass".into(), + op: "ge".into(), + values: None, + number: Some("5".into()), + }, + ]), + cites: Some(Citation { target: rr(leaf_a(), "a-eq"), role: Some("Design".into()) }), + cited: Some(Incoming { role: "Design".into(), exists: true }), + projection: "document".into(), + descending: true, + limit: Some(50), + cursor: Some("opaque".into()), + }; + let bytes = rmp_serde::to_vec_named(&selection).expect("encodes"); + let decoded: Selection = rmp_serde::from_slice(&bytes).expect("decodes"); + assert_eq!(decoded, selection); +} + +// ------------------------------------------------------------------------------------------ +// S12: parity vectors. contracts/cultnet/interop/selection-vectors.cs-written.json is written by +// the C# reference (SelectionParityVectorTests.WriteVectors, gated on CULTNET_WRITE_VECTORS=1) +// and judged here; selection-vectors.rs-written.json is the mirror this binary writes (gated the +// same way) for the C# side to judge. A within-runtime round trip pins nothing - both directions +// run against the fixture's *own* rows, decoding only the Selection bytes across the wire. +// ------------------------------------------------------------------------------------------ + +#[derive(serde::Serialize, serde::Deserialize)] +struct VectorFile { + vectors: Vec, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct Vector { + name: String, + #[serde(rename = "selectionMessagePackBase64")] + selection_message_pack_base64: String, + #[serde(rename = "asOf")] + as_of: u64, + // Present iff this vector is a door refusal (Self's ruling, 2026-09-22): the selection must + // be refused by `validate`/`CultNetSelection.Validate` with exactly this field name, and no + // evaluation runs. Absent for an ordinary evaluated vector. + #[serde(rename = "expectedRefusalField", default, skip_serializing_if = "Option::is_none")] + expected_refusal_field: Option, + // R-AF: a small, separate signal from expected_refusal_field above - that one is the door + // (validate, never touches select); this one names an evaluation-time typed refusal select + // itself must return (currently only "reference_outside_target", S18). Deliberately narrower + // than R-Z/R-AH's page-bytes-and-code parity harness (not this cut's job): it checks which + // typed refusal fires, nothing about wire bytes. + #[serde(rename = "expectedEvaluationRefusal", default, skip_serializing_if = "Option::is_none")] + expected_evaluation_refusal: Option, + #[serde(rename = "expectedIds", default)] + expected_ids: Vec, + #[serde(default)] + matched: u32, + #[serde(rename = "hasNext", default)] + has_next: bool, + #[serde(rename = "expectedEdges", default)] + expected_edges: Vec, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct VectorEdge { + #[serde(rename = "fromId")] + from_id: String, + role: String, + #[serde(rename = "toId")] + to_id: String, + #[serde(rename = "payloadBase64")] + payload_base64: Option, +} + +fn row_id(schema_id: &str, record_key: &str) -> String { + format!("{schema_id}/{record_key}") +} + +fn contracts_dir() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("contracts") + .join("cultnet") + .join("interop") +} + +// Self's ruling, 2026-09-22 (shared-fixture ruling): a real parity defect must be reported, not +// papered over by aborting the run at the first one. Returns Err(description) instead of +// panicking when `select` refuses a vector this file did not mark as a refusal vector - R-F wires +// the door inside `select`, and the C# reference's Validate has no equivalent check for an +// unresolved `cites.target.schemaId` (CultNetSelection.cs:361-391), so a vector the reference +// evaluates for real can still be one this crate's door refuses. The caller collects every such +// mismatch instead of stopping at the first one. +fn evaluate_vector(vector: &Vector) -> Result<(Vec, u32, bool, Vec), String> { + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(&vector.selection_message_pack_base64) + .expect("vector selection bytes are valid base64"); + let selection: Selection = + rmp_serde::from_slice(&bytes).expect("vector selection bytes decode as MessagePack"); + let rows = all_fixture_rows(); + let evaluation = select(&FixtureRowSet, &rows, &selection, vector.as_of, test_cursor_key()) + .map_err(|refusal| format!("select() refused: {refusal:?}"))?; + let ids: Vec = evaluation + .rows + .iter() + .map(|row| row_id(row.schema_id(), row.record_key())) + .collect(); + // R-G/P-1: matched is the total match count, not the page count - the two differ whenever a + // vector's limit clips the page. + let matched = evaluation.matched; + let has_next = evaluation.next_cursor.is_some(); + let edges = evaluation + .edges + .iter() + .map(|edge| VectorEdge { + from_id: row_id(edge.from.schema_id(), edge.from.record_key()), + role: edge.role.clone(), + to_id: row_id(edge.to.schema_id(), edge.to.record_key()), + payload_base64: edge + .payload + .as_ref() + .map(|bytes| base64::engine::general_purpose::STANDARD.encode(bytes)), + }) + .collect(); + Ok((ids, matched, has_next, edges)) +} + +// Self's ruling, 2026-09-22: a door-refusal vector decodes its (necessarily malformed, by the +// v1 rule) selection bytes and asserts `validate` refuses it at exactly the named field, rather +// than evaluating it - "the evaluator never sees []" means these selections are never run +// through `select` at all, on either side of the vector. +fn assert_refusal_vector(vector: &Vector, expected_field: &str) { + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(&vector.selection_message_pack_base64) + .expect("vector selection bytes are valid base64"); + let selection: Selection = + rmp_serde::from_slice(&bytes).expect("vector selection bytes decode as MessagePack"); + let error = validate(&selection, &FixtureRowSet) + .expect_err(&format!("vector {:?} must be refused", vector.name)); + assert_eq!(error.field, expected_field, "vector {:?}: refusal field", vector.name); +} + +/// Vectors written by the reference (C#) decode and evaluate identically in Rust. +#[test] +fn selection_vectors_written_by_the_reference_decode_and_evaluate_identically_in_rust() { + if !contracts_dir().is_dir() { + // R-AB: cs-written.json is the C# reference's own generated output - there is nothing to + // vendor (it is not a checked-in input like the fixture; it exists only once the C# side + // has actually run its writer against a real checkout of both runtimes). An isolated + // Rust-only copy, such as cargo-mutants' baseline build, can never satisfy this test's + // premise no matter what this crate does with its own files, so it skips rather than + // failing the whole suite over a cross-runtime artifact the isolated copy cannot produce. + eprintln!( + "skipping: {:?} not present beside the package (isolated build, no C# checkout to \ + have written cs-written.json against)", + contracts_dir() + ); + return; + } + let path = contracts_dir().join("selection-vectors.cs-written.json"); + let Ok(text) = fs::read_to_string(&path) else { + panic!( + "{} is missing - run the C# writer (SelectionParityVectorTests, CULTNET_WRITE_VECTORS=1) first", + path.display() + ); + }; + let file: VectorFile = serde_json::from_str(&text).expect("vector file is valid JSON"); + assert!(!file.vectors.is_empty(), "the vector file must carry at least one vector"); + + // Collect every mismatch instead of stopping at the first one (see evaluate_vector's comment) - + // a real parity defect is reported in full, not truncated by whichever vector happened to come + // first in the file. + let mut failures: Vec = Vec::new(); + for vector in &file.vectors { + if let Some(expected_field) = &vector.expected_refusal_field { + let bytes_ok = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + assert_refusal_vector(vector, expected_field) + })); + if let Err(panic) = bytes_ok { + let message = panic + .downcast_ref::() + .cloned() + .or_else(|| panic.downcast_ref::<&str>().map(|s| s.to_string())) + .unwrap_or_else(|| "".into()); + failures.push(format!("{}: {}", vector.name, message)); + } + continue; + } + + if let Some(expected_kind) = &vector.expected_evaluation_refusal { + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(&vector.selection_message_pack_base64) + .expect("vector selection bytes are valid base64"); + let selection: Selection = + rmp_serde::from_slice(&bytes).expect("vector selection bytes decode as MessagePack"); + let rows = all_fixture_rows(); + match select(&FixtureRowSet, &rows, &selection, vector.as_of, test_cursor_key()) { + Ok(_) => failures.push(format!( + "{}: expected select() to refuse ({expected_kind}) but it accepted the selection", + vector.name + )), + Err(SelectionRefusal::ReferenceOutsideTarget { .. }) if expected_kind == "reference_outside_target" => { + // expected + } + Err(other) => failures.push(format!( + "{}: expected select() to refuse with {expected_kind}, got {other:?}", + vector.name + )), + } + continue; + } + + let evaluated = match evaluate_vector(vector) { + Ok(evaluated) => evaluated, + Err(reason) => { + failures.push(format!( + "{}: expected real evaluation (expectedIds={:?}) but {reason}", + vector.name, vector.expected_ids + )); + continue; + } + }; + let (ids, matched, has_next, edges) = evaluated; + + if ids != vector.expected_ids { + failures.push(format!("{}: row ids - expected {:?}, got {:?}", vector.name, vector.expected_ids, ids)); + } + if matched != vector.matched { + failures.push(format!("{}: matched - expected {}, got {}", vector.name, vector.matched, matched)); + } + if has_next != vector.has_next { + failures.push(format!("{}: hasNext - expected {}, got {}", vector.name, vector.has_next, has_next)); + } + if edges.len() != vector.expected_edges.len() { + failures.push(format!( + "{}: edge count - expected {}, got {}", + vector.name, + vector.expected_edges.len(), + edges.len() + )); + continue; + } + for (i, (actual, expected)) in edges.iter().zip(vector.expected_edges.iter()).enumerate() { + if actual.from_id != expected.from_id + || actual.role != expected.role + || actual.to_id != expected.to_id + || actual.payload_base64 != expected.payload_base64 + { + failures.push(format!("{}: edge[{i}] mismatch", vector.name)); + } + } + } + + assert!( + failures.is_empty(), + "{} of {} vectors disagreed with the reference:\n{}", + failures.len(), + file.vectors.len(), + failures.join("\n") + ); +} + +/// The Rust->C# mirror: builds `selection-vectors.rs-written.json` from this binary's own +/// selections and its own evaluation of the same fixture, for the C# test +/// `SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference` to judge. Committed, +/// not regenerated on every run - rerun with `CULTNET_WRITE_VECTORS=1` after a real change to the +/// evaluator or the vocabulary. +#[test] +fn write_selection_vectors_for_the_reference() { + if std::env::var("CULTNET_WRITE_VECTORS").as_deref() != Ok("1") { + return; + } + use base64::Engine; + + let cases: Vec<(&str, Selection)> = vec![ + ( + "any_of_plus_ge_conjunction", + Selection { + fields: Some(vec![ + FieldPredicate { + index: "kind".into(), + op: "any_of".into(), + values: Some(vec!["weapon".into()]), + number: None, + }, + FieldPredicate { + index: "mass".into(), + op: "ge".into(), + values: None, + number: Some("5".into()), + }, + ]), + ..Selection::default() + }, + ), + ( + "descending_with_limit", + Selection { + descending: true, + limit: Some(3), + ..Selection::default() + }, + ), + ( + "gt_numeric_boundary", + Selection { + fields: Some(vec![FieldPredicate { + index: "mass".into(), + op: "gt".into(), + values: None, + number: Some("1".into()), + }]), + ..Selection::default() + }, + ), + ( + "incoming_exists_true", + Selection { + schemas: Some(vec![leaf_a().into()]), + cited: Some(Incoming { role: "Design".into(), exists: true }), + ..Selection::default() + }, + ), + ( + "incoming_negation", + Selection { + schemas: Some(vec![leaf_a().into()]), + cited: Some(Incoming { role: "Design".into(), exists: false }), + ..Selection::default() + }, + ), + ( + "shared_index_value_three_rows", + Selection { + fields: Some(vec![FieldPredicate { + index: "kind".into(), + op: "any_of".into(), + values: Some(vec!["shared".into()]), + number: None, + }]), + ..Selection::default() + }, + ), + // R-E/Soul "Settled": with real SHA-256 schema ids in the fixture, `cites.target.schemaId` + // agrees byte for byte across runtimes - the earlier name-as-id fixture masked this. This + // is the exact-id form. + // + // R-AF: keys=["citer-1"] (the fixture's one row that actually cites a-eq via Design) scopes + // this away from citer_narrow's rows, as hygiene rather than necessity - landing R-AF's + // vectors surfaced a real R-T/R-W parity bug this exposed: matches_citation used to resolve + // and R-W-check every declared reference a row carries before filtering by the citation's + // own role, where C#'s ReferenceMembers(descriptor, citation.Role) filters by role first. + // Fixed above (see matches_citation's own comment) so narrow-citer-refused's out-of-target + // narrow_ref edge no longer reaches a role="Design" citation in either runtime - this vector + // keeps the keys scope anyway so it never depends on that. + ( + "hop_by_role_cites_exact_id", + Selection { + keys: Some(vec!["citer-1".into()]), + cites: Some(Citation { target: rr(leaf_a(), "a-eq"), role: Some("Design".into()) }), + ..Selection::default() + }, + ), + // R-E: the same citation, but the target is named by the alias form C#'s production rule + // resolves ("leaf_a.v9", stripped to "leaf_a" and compared against the schema's declared + // name) - as of this cut's fix, Row::schema_name/RowSet::schema_name give this crate's own + // schema_alias the same name to resolve against, so this now agrees with the reference too. + // R-AF: keys scoping for the same reason as hop_by_role_cites_exact_id above. + ( + "hop_by_role_cites_name_alias", + Selection { + keys: Some(vec!["citer-1".into()]), + cites: Some(Citation { target: rr(leaf_a_name_alias(), "a-eq"), role: Some("Design".into()) }), + ..Selection::default() + }, + ), + // R-E, Self's ruling 2026-09-22 ("the C# reference's alias rule is the rule"): a hash id + // with a synthetic ".v1" appended is not a wire form either runtime's production rule + // resolves - it strips the ".v1" and compares the bare hash text against the schema's + // declared *name* ("leaf_a"), never equal. This is a shared negative check now: both + // runtimes give an empty reachable set for `schemas`, hence empty expected_ids here. + ( + "schemas_by_hash_alias", + Selection { + schemas: Some(vec![leaf_a_hash_alias().into()]), + ..Selection::default() + }, + ), + // R-E: `schemas` reaches a schema by its name alias - as of this cut's fix, this crate's + // schema_alias resolves it the same way CultNetSchemaAliasMatching's descriptor overload + // does (the only overload any C# call site uses): strip the trailing ".v9" and compare + // "leaf_a" to the schema's declared name. Previously this crate's alias matcher had no + // concept of a schema's name at all and gave an empty result here, disagreeing with the + // reference - that cross-runtime defect is what this cut fixes. + ( + "schemas_by_name_alias", + Selection { + schemas: Some(vec![leaf_a_name_alias().into()]), + ..Selection::default() + }, + ), + // R-D: the row's canonical rendering of 2233759.25f32 is its exact decimal expansion, not a + // shortest round-trip form that could round the tie the other way - ge against the exact + // value must match. Mirrors SelectionParityVectorTests.Cases's float_tie_exact_ge. + ( + "float_tie_exact_ge", + Selection { + fields: Some(vec![FieldPredicate { + index: "mass".into(), + op: "ge".into(), + values: None, + number: Some("2233759.25".into()), + }]), + ..Selection::default() + }, + ), + // R-C: the astral/BMP-private-use key pair sorts by code point, not UTF-16 code unit. + // Mirrors SelectionParityVectorTests.Cases's astral_key_code_point_order. + ( + "astral_key_code_point_order", + Selection { + fields: Some(vec![FieldPredicate { + index: "kind".into(), + op: "any_of".into(), + values: Some(vec!["unicode".into()]), + number: None, + }]), + ..Selection::default() + }, + ), + // R-AJ: a tied ordinal across two DIFFERENT schemas (kind=tie-schema is leaf_b/"zz-tie- + // schema" and leaf_a/"aa-tie-schema") - the shape the row order's schemaId tiebreak needs + // and the astral/BMP pair above never covered (both those rows are leaf_a). The fixture's + // keys are anti-correlated with the real schema ids on purpose, so a schemaId-first order + // and a recordKey-first order disagree: only the correct composition puts leaf_b's row + // ("zz-tie-schema") first. + ( + "row_tiebreak_schema_id_before_record_key_at_tied_ordinal", + Selection { + fields: Some(vec![FieldPredicate { + index: "kind".into(), + op: "any_of".into(), + values: Some(vec!["tie-schema".into()]), + number: None, + }]), + ..Selection::default() + }, + ), + // R-AF: "dup-key" carries two rows (leaf_a and leaf_b); citer_narrow's narrow_ref targets + // leaf_a only. Keys scopes the citer candidate set to narrow-citer-accepted alone, so this + // vector never touches narrow-citer-refused's bad edge (R-W resolves every reference + // member unconditionally, so leaving both citer_narrow rows in one candidate set would + // always refuse regardless of which edge the query named). One rule, derived from the wire + // (docs/cultnet-selection-cut.md, R-AF): resolving "the row this edge names" is by record + // key plus the declared leaf set, not an exact (schema, key) pair, so this must accept in + // both runtimes and resolve to the leaf_a row even though a leaf_b row shares the same key. + ( + "shared_key_narrow_ref_resolves_leaf_within_target", + Selection { + keys: Some(vec!["narrow-citer-accepted".into()]), + cites: Some(Citation { target: rr(leaf_a(), "dup-key"), role: Some("narrow_ref".into()) }), + ..Selection::default() + }, + ), + // R-AO: "dup-key" has two in-target candidates for citer's Design role (unlike + // citer_narrow's narrow_ref above, which only ever had one). citer-both-in-target is the + // first row in the fixture file (and so in both runtimes' row order) to name it through + // Design - resolution must deterministically pick the first in-target candidate in that + // order, the leaf_a row (ordinal 11), never the leaf_b row (ordinal 12) that shares the key. + ( + "shared_key_design_resolves_the_first_in_target_candidate_in_row_order", + Selection { + keys: Some(vec!["citer-both-in-target".into()]), + cites: Some(Citation { target: rr(leaf_a(), "dup-key"), role: Some("Design".into()) }), + ..Selection::default() + }, + ), + ]; + + let mut vectors = Vec::new(); + for (name, selection) in cases { + let bytes = rmp_serde::to_vec_named(&selection).expect("encodes"); + let rows = all_fixture_rows(); + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()) + .unwrap_or_else(|e| panic!("case {name:?} failed: {e:?}")); + let ids: Vec = evaluation + .rows + .iter() + .map(|row| row_id(row.schema_id(), row.record_key())) + .collect(); + let edges = evaluation + .edges + .iter() + .map(|edge| VectorEdge { + from_id: row_id(edge.from.schema_id(), edge.from.record_key()), + role: edge.role.clone(), + to_id: row_id(edge.to.schema_id(), edge.to.record_key()), + payload_base64: edge + .payload + .as_ref() + .map(|bytes| base64::engine::general_purpose::STANDARD.encode(bytes)), + }) + .collect(); + vectors.push(Vector { + name: name.to_string(), + selection_message_pack_base64: base64::engine::general_purpose::STANDARD.encode(&bytes), + as_of: 1, + expected_refusal_field: None, + expected_evaluation_refusal: None, + matched: evaluation.matched, + has_next: evaluation.next_cursor.is_some(), + expected_ids: ids, + expected_edges: edges, + }); + } + + // Self's ruling, 2026-09-22 (commit 2 fix batch): door-refusal vectors. These selections are + // never evaluated on either side - only decoded and handed to validate/Validate, which must + // refuse at exactly the named field. + let refusal_cases: Vec<(&str, Selection, &str)> = vec![ + ( + "refuses_empty_schemas_list", + Selection { + schemas: Some(Vec::new()), + ..Selection::default() + }, + "schemas", + ), + ( + "refuses_blank_key_entry", + Selection { + keys: Some(vec!["a-eq".into(), " ".into()]), + ..Selection::default() + }, + "keys", + ), + // R-E: an unmatched cites.target.schemaId is refused at the door, never silently answered + // with an empty page. + ( + "refuses_unmatched_cites_target_schema", + Selection { + cites: Some(Citation { + target: rr("sha256:not-a-known-schema", "a-eq"), + role: Some("Design".into()), + }), + ..Selection::default() + }, + "cites.target.schemaId", + ), + // R-E, Self's ruling 2026-09-22: a hash-shaped alias resolves to no known schema in either + // runtime now (see schemas_by_hash_alias above), so naming one as a cites.target is the + // same "unmatched target" refusal as any other unresolvable schema id. + ( + "refuses_cites_target_by_hash_shaped_alias", + Selection { + cites: Some(Citation { + target: rr(leaf_a_hash_alias(), "a-eq"), + role: Some("Design".into()), + }), + ..Selection::default() + }, + "cites.target.schemaId", + ), + ]; + for (name, selection, expected_field) in refusal_cases { + let bytes = rmp_serde::to_vec_named(&selection).expect("encodes"); + vectors.push(Vector { + name: name.to_string(), + selection_message_pack_base64: base64::engine::general_purpose::STANDARD.encode(&bytes), + as_of: 1, + expected_refusal_field: Some(expected_field.to_string()), + expected_evaluation_refusal: None, + matched: 0, + has_next: false, + expected_ids: Vec::new(), + expected_edges: Vec::new(), + }); + } + + // R-AF: evaluation-time refusal vectors. Never handed to validate - the selection decodes and + // passes the door; only select itself refuses, and only once the shared-key resolution rule + // actually runs. Keys scopes the candidate set to narrow-citer-refused alone (see the comment + // on shared_key_narrow_ref_resolves_leaf_within_target above). + let evaluation_refusal_cases: Vec<(&str, Selection)> = vec![( + "shared_key_narrow_ref_refuses_when_only_candidate_is_outside_target", + Selection { + keys: Some(vec!["narrow-citer-refused".into()]), + cites: Some(Citation { target: rr(leaf_a(), "narrow-only"), role: Some("narrow_ref".into()) }), + ..Selection::default() + }, + )]; + for (name, selection) in evaluation_refusal_cases { + let rows = all_fixture_rows(); + let result = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()); + assert!( + matches!(result, Err(SelectionRefusal::ReferenceOutsideTarget { .. })), + "{name} must be refused by select() before it can be committed as an evaluation-refusal vector, got {result:?}" + ); + let bytes = rmp_serde::to_vec_named(&selection).expect("encodes"); + vectors.push(Vector { + name: name.to_string(), + selection_message_pack_base64: base64::engine::general_purpose::STANDARD.encode(&bytes), + as_of: 1, + expected_refusal_field: None, + expected_evaluation_refusal: Some("reference_outside_target".to_string()), + matched: 0, + has_next: false, + expected_ids: Vec::new(), + expected_edges: Vec::new(), + }); + } + + let file = VectorFile { vectors }; + let json = serde_json::to_string_pretty(&file).expect("serializes"); + let dir = contracts_dir(); + fs::create_dir_all(&dir).expect("contracts/cultnet/interop exists or is created"); + fs::write(dir.join("selection-vectors.rs-written.json"), json).expect("writes the vector file"); +} + +// A canary that the canonical_number module used above stays reachable from an integration test +// (it is exercised indirectly through Selection.fields[].number already, but this names the rule +// directly): a lexicographic compare would put "10" below "9". +#[test] +fn canonical_number_compares_by_magnitude() { + assert_eq!( + canonical_number::compare("10", "9"), + std::cmp::Ordering::Greater + ); + assert!(canonical_number::is_canonical("10")); + assert!(!canonical_number::is_canonical("+10")); +} + +// Q-J: a negative value orders below a positive one of the same magnitude - a comparator that +// ignores sign would treat "-1" and "1" as equal (same integer/fraction digits). +#[test] +fn canonical_number_compares_by_sign() { + assert_eq!(canonical_number::compare("-1", "1"), std::cmp::Ordering::Less); + assert_eq!(canonical_number::compare("1", "-1"), std::cmp::Ordering::Greater); + assert_eq!(canonical_number::compare("-10", "-2"), std::cmp::Ordering::Less); +} + +#[allow(dead_code)] +fn unused_operator_reference(op: SelectionOperator) -> &'static str { + op.as_wire_str() +} + +// ------------------------------------------------------------------------------------------ +// R-F: the door is inside `select` - it validates first and returns the typed refusal, and the +// single-row fast path refuses (loudly, not just in debug builds) to answer a hop-bearing +// selection at all. +// ------------------------------------------------------------------------------------------ + +#[test] +fn select_refuses_an_invalid_selection_instead_of_evaluating_it() { + let rows = base_rows(); + let selection = Selection { schemas: Some(Vec::new()), ..Selection::default() }; + let result = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()); + assert!(matches!(result, Err(SelectionRefusal::Invalid(ref invalid)) if invalid.field == "schemas")); +} + +// A plain assert (not #[should_panic]) so a mutant that removes the panic reads as an ordinary +// FAILED test rather than the harness having to special-case should_panic's own output shape. +#[test] +fn matches_refuses_a_hop_bearing_selection_even_in_a_release_style_assert() { + let row = FixtureRow::leaf(leaf_a(), "a-eq", 1, "weapon", "5"); + let selection = Selection { + cited: Some(Incoming { role: "Design".into(), exists: true }), + ..Selection::default() + }; + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + cultnet_rs::matches(&row, &selection); + })); + let panicked = outcome.is_err(); + assert!(panicked, "matches() must panic on a hop-bearing selection, not answer wrong"); +} + +// ------------------------------------------------------------------------------------------ +// R-H: the cursor digest is length-prefixed, so no separator character can make two distinct +// selections collide (Soul: `["a|b"]` and `["a","b"]` used to digest the same). +// ------------------------------------------------------------------------------------------ + +#[test] +fn cursor_digest_does_not_collide_on_differently_split_lists() { + let one_joined_value = Selection { + fields: Some(vec![FieldPredicate { + index: "kind".into(), + op: "any_of".into(), + values: Some(vec!["a|b".into()]), + number: None, + }]), + ..Selection::default() + }; + let two_split_values = Selection { + fields: Some(vec![FieldPredicate { + index: "kind".into(), + op: "any_of".into(), + values: Some(vec!["a".into(), "b".into()]), + number: None, + }]), + ..Selection::default() + }; + assert_ne!( + Cursor::compute_digest(1, 1, "s", "k", &one_joined_value, test_cursor_key()), + Cursor::compute_digest(1, 1, "s", "k", &two_split_values, test_cursor_key()), + "a length-prefixed digest must not let list-splitting collide" + ); + + let one_key = Selection { keys: Some(vec!["a,b".into()]), ..Selection::default() }; + let two_keys = Selection { keys: Some(vec!["a".into(), "b".into()]), ..Selection::default() }; + assert_ne!( + Cursor::compute_digest(1, 1, "s", "k", &one_key, test_cursor_key()), + Cursor::compute_digest(1, 1, "s", "k", &two_keys, test_cursor_key()) + ); +} + +// ------------------------------------------------------------------------------------------ +// R-Y: the digest covers the cursor's own body (`asOf`, `ordinal`, `schemaId`, `recordKey`), not +// only the selection - a caller holding one valid cursor cannot rewrite its position and reuse +// the digest. +// ------------------------------------------------------------------------------------------ + +#[test] +fn cursor_digest_does_not_collide_across_rewritten_positions() { + let selection = Selection::default(); + let key = test_cursor_key(); + let base = Cursor::compute_digest(1, 5, "schema-a", "k1", &selection, key); + let rewritten_ordinal = Cursor::compute_digest(1, 6, "schema-a", "k1", &selection, key); + let rewritten_key = Cursor::compute_digest(1, 5, "schema-a", "k2", &selection, key); + let rewritten_as_of = Cursor::compute_digest(2, 5, "schema-a", "k1", &selection, key); + let rewritten_schema = Cursor::compute_digest(1, 5, "schema-b", "k1", &selection, key); + assert_ne!(base, rewritten_ordinal, "a rewritten ordinal must not reuse the digest"); + assert_ne!(base, rewritten_key, "a rewritten record key must not reuse the digest"); + assert_ne!(base, rewritten_as_of, "a rewritten asOf must not reuse the digest"); + assert_ne!(base, rewritten_schema, "a rewritten schemaId must not reuse the digest"); +} + +// A cursor whose position was rewritten but whose digest bytes were reused (a forgery only +// possible with access to the encoded body, not the process key) is covered by the in-module +// unit test next to `Cursor` itself (`selection.rs`'s `cursor_tests`), where the private `digest` +// field and the length-prefix helpers used to splice a forged body are directly reachable. This +// integration test only exercises the public surface: two cursors minted for different positions +// under the same selection and key never share a digest, which is what makes that forgery +// impossible without the raw bytes. +#[test] +fn cursor_minted_for_a_different_position_never_shares_a_digest() { + let rows = base_rows(); + let selection = Selection { limit: Some(1), ..Selection::default() }; + let page = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + let genuine = page.next_cursor.expect("more than one row"); + let parsed = Cursor::parse(&genuine).expect("mints decode"); + + let d1 = Cursor::compute_digest(1, parsed.ordinal, &parsed.schema_id, &parsed.record_key, &selection, test_cursor_key()); + let d2 = Cursor::compute_digest(1, parsed.ordinal + 1, &parsed.schema_id, &parsed.record_key, &selection, test_cursor_key()); + let d3 = Cursor::compute_digest(1, parsed.ordinal, &parsed.schema_id, "some-other-key", &selection, test_cursor_key()); + assert_ne!(d1, d2, "a rewritten ordinal must not reuse the digest"); + assert_ne!(d1, d3, "a rewritten record key must not reuse the digest"); +} + +// ------------------------------------------------------------------------------------------ +// R-O: the cursor is keyed. A forged cursor, a cursor minted by another process, and a record +// key carrying the pre-fix delimiter character are all refused or round-trip correctly under the +// keyed, length-prefixed cursor. +// ------------------------------------------------------------------------------------------ + +#[test] +fn cursor_forged_without_the_key_is_refused() { + let rows = base_rows(); + let selection = Selection { limit: Some(1), ..Selection::default() }; + let page = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + let genuine = page.next_cursor.expect("more than one row"); + + // A forgery attempt: decode the genuine cursor, flip one byte inside its digest (the tail of + // the length-prefixed body), and re-encode - the shape (five length-prefixed fields) stays + // valid, only the digest content is wrong, exactly what an attacker guessing the selection's + // shape without the key would produce. + let mut bytes = base64::Engine::decode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + &genuine, + ) + .expect("genuine cursor decodes"); + let last = bytes.len() - 1; + bytes[last] ^= 0xFF; + let forged = base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, &bytes); + + let result = select( + &FixtureRowSet, + &rows, + &Selection { limit: Some(1), cursor: Some(forged), ..Selection::default() }, + 1, + test_cursor_key(), + ); + assert!(matches!(result, Err(SelectionRefusal::CursorInvalid { .. }))); +} + +#[test] +fn cursor_minted_by_another_process_is_refused() { + let rows = base_rows(); + let selection = Selection { limit: Some(1), ..Selection::default() }; + let matched = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + let last_row = matched.rows.last().expect("more than one row"); + + // A different process holds a different random key (R-O) - this cursor is genuine, just not + // minted under the key the answering server in this test holds. + let other_process_key = CursorKey::random(); + let cursor = Cursor::mint(1, last_row, &selection, &other_process_key); + + let result = select( + &FixtureRowSet, + &rows, + &Selection { limit: Some(1), cursor: Some(cursor), ..Selection::default() }, + 1, + test_cursor_key(), + ); + assert!(matches!(result, Err(SelectionRefusal::CursorInvalid { .. }))); +} + +// F6: the pre-fix C# cursor used U+241F as its field delimiter, so a record key carrying that +// exact character broke the server's own cursor. R-O's length-prefixed body has no delimiter +// character at all, so this (and any other character a record key might carry) round-trips. +#[test] +fn cursor_round_trips_a_record_key_carrying_the_pre_fix_delimiter_character() { + let row = FixtureRow::leaf(leaf_a(), "row-\u{241F}-with-the-old-delimiter", 7, "weapon", "5"); + let selection = Selection::default(); + let cursor_text = Cursor::mint(9, &row, &selection, test_cursor_key()); + let cursor = Cursor::parse(&cursor_text).expect("length-prefixed body decodes"); + assert_eq!(cursor.as_of, 9); + assert_eq!(cursor.ordinal, 7); + assert_eq!(cursor.schema_id, leaf_a()); + assert_eq!(cursor.record_key, "row-\u{241F}-with-the-old-delimiter"); +} + +// 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 +// tests in cultnet_rs::selection::schema_alias::tests cover the string algorithm directly). +// ------------------------------------------------------------------------------------------ + +#[test] +fn schemas_reaches_a_schema_by_its_unversioned_name_alias() { + let rows = base_rows(); + let selection = Selection { + schemas: Some(vec![leaf_a_name_alias().into()]), + ..Selection::default() + }; + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + let mut ids: Vec<&str> = evaluation.rows.iter().map(Row::record_key).collect(); + ids.sort_unstable(); + assert_eq!(ids, vec!["a-eq", "a-lo", "shared-1", "shared-3"], "every leaf_a row, by name alias"); +} + +// R-E, Self's ruling 2026-09-22: a hash-shaped alias (".v1") is not a wire form the C# +// reference's production rule ever resolves - it strips the ".v1" and compares the bare hash text +// against the schema's declared *name*, never equal. `schemas` never refuses (unlike cites.target +// below), so an unresolved alias just narrows reachability to nothing. +#[test] +fn schemas_does_not_reach_a_schema_by_a_hash_shaped_alias() { + let rows = base_rows(); + let selection = Selection { + schemas: Some(vec![leaf_a_hash_alias().into()]), + ..Selection::default() + }; + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + assert!(evaluation.rows.is_empty(), "a hash-shaped alias must not resolve to leaf_a"); +} + +// R-E, at the fast path `matches()` uses directly (the v0 snapshot server's own caller, +// serve_read_only_raw_snapshot, never calls validate/select - v0 has its own lowering instead). +// An empty-but-present schemas list must match nothing there too, the same as the door's own +// rule for select() - schema_alias::matches_any's own "empty candidates matches everything" +// convention (ported faithfully from the C# reference's MatchesAny, for the door/reachability +// call sites that already refuse or normalise an empty list before this ever runs) must not leak +// into this call site, which has no such guarantee upstream of it. +#[test] +fn matches_treats_an_empty_but_present_schemas_list_as_matching_nothing() { + let row = FixtureRow::leaf(leaf_a(), "a-eq", 1, "weapon", "5"); + let selection = Selection { schemas: Some(Vec::new()), ..Selection::default() }; + assert!( + !cultnet_rs::matches(&row, &selection), + "an empty-but-present schemas list must never be read as \"no filter\" by the evaluator" + ); +} + +#[test] +fn cites_target_resolves_through_the_name_alias_matcher() { + let rows = base_rows(); + let selection = Selection { + cites: Some(Citation { target: rr(leaf_a_name_alias(), "a-eq"), role: Some("Design".into()) }), + ..Selection::default() + }; + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + assert_eq!(evaluation.rows.iter().map(Row::record_key).collect::>(), vec!["citer-1"]); +} + +// R-E, Self's ruling 2026-09-22: a hash-shaped alias resolves to no known schema (see +// schemas_does_not_reach_a_schema_by_a_hash_shaped_alias above), so a cites.target naming one is +// refused at the door (R-E's unmatched-target rule), not silently answered with an empty page. +#[test] +fn cites_target_by_a_hash_shaped_alias_is_refused_as_unmatched() { + let selection = Selection { + cites: Some(Citation { target: rr(leaf_a_hash_alias(), "a-eq"), role: Some("Design".into()) }), + ..Selection::default() + }; + let error = validate(&selection, &FixtureRowSet).expect_err("hash-shaped alias must be refused"); + assert_eq!(error.field, "cites.target.schemaId"); +} + +#[test] +fn validate_refuses_a_cites_target_schema_that_matches_no_known_schema() { + let selection = Selection { + cites: Some(Citation { + target: rr("sha256:not-a-known-schema", "a-eq"), + role: Some("Design".into()), + }), + ..Selection::default() + }; + let error = validate(&selection, &FixtureRowSet).expect_err("unmatched target must be refused"); + assert_eq!(error.field, "cites.target.schemaId"); +} + +#[test] +fn schema_alias_module_is_reachable_from_the_crate_root() { + assert!(schema_alias::matches(leaf_a_name_alias(), leaf_a(), leaf_a_name())); + assert!(!schema_alias::matches(leaf_a_name_alias(), leaf_a(), leaf_b_name())); + assert!(!schema_alias::matches(leaf_a_hash_alias(), leaf_a(), leaf_a_name())); +} + +// ------------------------------------------------------------------------------------------ +// R-G/P-1: `matched` is the total match count, not the page count. +// ------------------------------------------------------------------------------------------ + +#[test] +fn matched_is_the_total_count_not_the_page_count() { + let rows = base_rows(); + let selection = Selection { limit: Some(2), ..Selection::default() }; + let evaluation = select(&FixtureRowSet, &rows, &selection, 1, test_cursor_key()).unwrap(); + assert_eq!(evaluation.rows.len(), 2, "the page is clipped to the limit"); + assert_eq!(evaluation.matched, 7, "matched counts every row base_rows() carries"); +} + +// ------------------------------------------------------------------------------------------ +// R-I: `select_page` projects `select`'s generic rows into the wire's `SelectionPage` - a header +// projection carries no payload, in the page's rows and in its edges alike (S20). +// ------------------------------------------------------------------------------------------ + +fn document_record_for(row: &FixtureRow) -> SelectionDocumentRecord { + SelectionDocumentRecord { + schema_id: row.schema_id.to_string(), + schema_name: None, + schema_version: None, + schema_content_hash: None, + record_key: row.record_key.to_string(), + stored_at: "2026-09-22T00:00:00Z".to_string(), + payload_encoding: "messagepack".to_string(), + payload: vec![0xC0], // a nonempty payload every header-projection assertion must not see + source_runtime_id: None, + source_agent_id: None, + source_role: None, + tags: None, + } +} + +#[test] +fn select_page_header_projection_carries_no_payload_in_rows_or_edges() { + let rows = vec![ + FixtureRow::leaf(leaf_a(), "a-eq", 1, "weapon", "5"), + FixtureRow::citer("citer-1", 2, vec![("Design".to_string(), "a-eq".to_string(), Some(vec![7]))]), + ]; + let selection = Selection { + cites: Some(Citation { target: rr(leaf_a(), "a-eq"), role: Some("Design".into()) }), + projection: "header".into(), + ..Selection::default() + }; + let page: SelectionPage = + select_page(&FixtureRowSet, &rows, &selection, 1, test_cursor_key(), document_record_for).unwrap(); + assert!(page.documents.is_none()); + let headers = page.headers.expect("header projection carries headers"); + assert_eq!(headers.len(), 1); + assert_eq!(headers[0].record_key, "citer-1"); + let edges = page.edges.expect("a hop-bearing selection carries edges"); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].payload, None, "S20: a header projection's edges carry no payload"); + assert_eq!(edges[0].payload_encoding, None); +} + +#[test] +fn select_page_document_projection_carries_payload_in_rows_and_edges() { + let rows = vec![ + FixtureRow::leaf(leaf_a(), "a-eq", 1, "weapon", "5"), + FixtureRow::citer("citer-1", 2, vec![("Design".to_string(), "a-eq".to_string(), Some(vec![7]))]), + ]; + let selection = Selection { + cites: Some(Citation { target: rr(leaf_a(), "a-eq"), role: Some("Design".into()) }), + projection: "document".into(), + ..Selection::default() + }; + let page: SelectionPage = + select_page(&FixtureRowSet, &rows, &selection, 1, test_cursor_key(), document_record_for).unwrap(); + assert!(page.headers.is_none()); + let documents = page.documents.expect("document projection carries documents"); + assert_eq!(documents.len(), 1); + assert_eq!(documents[0].payload, vec![0xC0]); + let edges = page.edges.expect("a hop-bearing selection carries edges"); + assert_eq!(edges[0].payload, Some(vec![7])); + assert_eq!(edges[0].payload_encoding.as_deref(), Some("messagepack")); +} + +#[test] +fn select_page_matched_is_the_total_count() { + let rows = base_rows(); + let selection = Selection { limit: Some(2), ..Selection::default() }; + let page: SelectionPage = select_page(&FixtureRowSet, &rows, &selection, 1, test_cursor_key(), document_record_for).unwrap(); + assert_eq!(page.matched, 7); + assert_eq!(page.headers.unwrap().len(), 2); +} + +// ------------------------------------------------------------------------------------------ +// R-M: `SelectionDocumentRecord`/`RawDocumentHeader`/`Edge`/`RecordRef` used to be encoded and +// decoded by ~335 lines of hand-written `rmpv::Value` construction (contracts.rs). R-M replaced +// that with the generic `rmpv::ext::from_value`/`to_value` bridge over their own serde derives. +// This proves the replacement round-trips through the real message envelope +// (`cultnet.snapshot_response_raw.v1`, MessagePack, named maps) both ways - headers, documents +// with a real payload, and edges with and without a payload. +// ------------------------------------------------------------------------------------------ + +#[test] +fn snapshot_response_raw_v1_round_trips_headers_documents_and_edges_through_the_generic_codec() { + let message = CultNetMessage::SnapshotResponseRawV1 { + message_id: "msg-1".to_string(), + matched: 3, + as_of: 42, + next: Some("cursor-1".to_string()), + // A message carries exactly one of headers/documents (validate_message's own invariant, + // matching Selection::projection); this vector exercises the document side, the richer of + // the two. + headers: None, + documents: Some(vec![SelectionDocumentRecord { + schema_id: leaf_a().to_string(), + schema_name: None, + schema_version: None, + schema_content_hash: None, + record_key: "a-eq".to_string(), + stored_at: "2026-09-22T00:00:00Z".to_string(), + payload_encoding: "messagepack".to_string(), + payload: vec![1, 2, 3, 4, 5], + source_runtime_id: None, + source_agent_id: None, + source_role: None, + tags: None, + }]), + edges: Some(vec![ + Edge { + from: rr(citer(), "citer-1"), + role: "Design".to_string(), + to: rr(leaf_a(), "a-eq"), + payload_encoding: Some("messagepack".to_string()), + payload: Some(vec![9, 9]), + }, + Edge { + from: rr(citer(), "citer-1"), + role: "parent".to_string(), + to: rr(leaf_b(), "b-hi"), + payload_encoding: None, + payload: None, + }, + ]), + shard_id: Some("shard-1".to_string()), + shard_epoch: Some(5), + shard_log_sequence: Some(100), + }; + + let bytes = + encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).expect("encodes"); + let decoded = decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0) + .expect("decodes"); + assert_eq!(decoded, message); +} + +// The header side of the same message shape (S20: no payload field at all, since +// `RawDocumentHeader` has none to carry). +#[test] +fn snapshot_response_raw_v1_round_trips_headers_through_the_generic_codec() { + let message = CultNetMessage::SnapshotResponseRawV1 { + message_id: "msg-2".to_string(), + matched: 1, + as_of: 7, + next: None, + headers: Some(vec![RawDocumentHeader { + schema_id: leaf_a().to_string(), + schema_name: Some("leaf_a".to_string()), + schema_version: Some("v1".to_string()), + schema_content_hash: Some("hash".to_string()), + record_key: "a-eq".to_string(), + stored_at: "2026-09-22T00:00:00Z".to_string(), + source_runtime_id: Some("runtime-1".to_string()), + source_agent_id: None, + source_role: None, + tags: Some(vec!["tag-a".to_string()]), + }]), + documents: None, + edges: None, + shard_id: None, + shard_epoch: None, + shard_log_sequence: None, + }; + + let bytes = + encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).expect("encodes"); + let decoded = decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0) + .expect("decodes"); + assert_eq!(decoded, message); +} diff --git a/packages/cultnet-rs/tests/selection_wire_parity.rs b/packages/cultnet-rs/tests/selection_wire_parity.rs new file mode 100644 index 00000000..4ee5a1ec --- /dev/null +++ b/packages/cultnet-rs/tests/selection_wire_parity.rs @@ -0,0 +1,209 @@ +//! R-AP (docs/cultnet-selection-cut.md, "Self's rulings for fix batch 6"): `RawDocumentHeader` and +//! `Edge`, pinned byte-for-byte against the C# reference, the same way `tests/error_message.rs` +//! pins `cultnet.error.v0` - a hardcoded `csharp_hex` captured from a real MessagePack C# run, +//! compared against this crate's own encoding of the identical value. +//! +//! Unlike `error_message.rs`'s bytes (captured on Yggdrasil via `ygg-verify.sh` per that file's own +//! header), the bytes below were captured **locally**, in this worktree (`C:\ws7`), against +//! MessagePack C# 3.1.7 - the exact version `GameCult.Networking` depends on +//! (`src/GameCult.Networking/GameCult.Networking.csproj`) - using the project's own +//! `CultNetRawDocumentHeader`/`CultNetEdge`/`CultNetSchemaMessageSerialization.Options` +//! (`MessagePackSerializerOptions.Standard.WithSecurity(MessagePackSecurity.UntrustedData)`), then +//! `Convert.ToHexString(MessagePackSerializer.Serialize(value, options))`. This session had no +//! access to the Yggdrasil verify image, so unlike the error-message vectors this was not run +//! through `ygg-verify.sh`; the capture itself is still a real MessagePack C# 3.1.7 run on this +//! machine, not hand-derived bytes. +//! +//! What this proves: `RawDocumentHeader`/`Edge`'s `to_vec_named` encoding (R-AP: `skip_serializing_if` +//! removed, every optional field always present and `nil` when absent, camelCase, declared order) +//! matches the C# reference's `[MessagePackObject]`/`[Key("name")]` map encoding byte-for-byte, both +//! at a value carrying every optional absent and - R-AX (docs/cultnet-selection-cut.md, "Self's +//! rulings for fix batch 7"), because an all-absent value proves nothing about `string[]` vs +//! `Vec` or `byte[]` vs `serde_bytes` - at a value carrying `tags`/`payload` populated. R-AX +//! also lands the whole `cultnet.snapshot_response_raw.v1` envelope byte-identical against the C# +//! reference (`snapshot_response_raw_v1_envelope_matches_the_csharp_reference_byte_for_byte`, +//! below): Soul's earlier finding that this was "blocked on unbuilt page-assembly ownership" named +//! the wrong thing - the *encoders* producing identical bytes for the same page value never needed +//! that move, only the *evaluators* producing the same page value from the same rows does (that +//! half is `select_page`'s own vectors in `selection.rs`, a separate claim R-AR still owns). + +use cultnet_rs::{ + CultNetMessage, CultNetWireContract, Edge, RawDocumentHeader, RecordRef, + encode_cultnet_message_to_vec, +}; + +fn hex_decode(hex: &str) -> Vec { + assert!(hex.len() % 2 == 0, "odd-length hex string"); + (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("valid hex byte")) + .collect() +} + +// C#: new CultNetRawDocumentHeader { SchemaId = "sha256:ff35f3...4cff", RecordKey = "a-eq", +// StoredAt = "1970-01-01T00:00:00.000Z" } - SchemaName/SchemaVersion/SchemaContentHash/ +// SourceRuntimeId/SourceAgentId/SourceRole/Tags all left null (absent). +// Convert.ToHexString(MessagePackSerializer.Serialize(header, CultNetSchemaMessageSerialization.Options)): +const CSHARP_HEADER_HEX: &str = "8AA8736368656D614964D9477368613235363A66663335663364396436646533663130363662393336396537336665366337363438653838333638643138656663323237356339393437333063643834636666AA736368656D614E616D65C0AD736368656D6156657273696F6EC0B1736368656D61436F6E74656E7448617368C0A97265636F72644B6579A4612D6571A873746F7265644174B8313937302D30312D30315430303A30303A30302E3030305AAF736F7572636552756E74696D654964C0AD736F757263654167656E744964C0AA736F75726365526F6C65C0A474616773C0"; + +// C#: new CultNetEdge { From = { SchemaId = "sha256:9d0bc9...608dc", RecordKey = "citer-1" }, +// Role = "Design", To = { SchemaId = "sha256:ff35f3...4cff", RecordKey = "a-eq" } } - +// PayloadEncoding/Payload left null (absent, header projection). +const CSHARP_EDGE_HEX: &str = "85A466726F6D82A8736368656D614964D9477368613235363A39643062633961656263333631396230376161626635363466393836363761666465343134343463316238363636363164373737363837643434333630386463A97265636F72644B6579A763697465722D31A4726F6C65A644657369676EA2746F82A8736368656D614964D9477368613235363A66663335663364396436646533663130363662393336396537336665366337363438653838333638643138656663323237356339393437333063643834636666A97265636F72644B6579A4612D6571AF7061796C6F6164456E636F64696E67C0A77061796C6F6164C0"; + +#[test] +fn raw_document_header_matches_the_csharp_reference_byte_for_byte() { + let header = RawDocumentHeader { + schema_id: "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff".to_string(), + schema_name: None, + schema_version: None, + schema_content_hash: None, + record_key: "a-eq".to_string(), + stored_at: "1970-01-01T00:00:00.000Z".to_string(), + source_runtime_id: None, + source_agent_id: None, + source_role: None, + tags: None, + }; + + let actual = rmp_serde::to_vec_named(&header).expect("encodes"); + let expected = hex_decode(CSHARP_HEADER_HEX); + assert_eq!( + actual, expected, + "RawDocumentHeader's Rust-encoded bytes do not match the captured C# encoding" + ); +} + +#[test] +fn edge_matches_the_csharp_reference_byte_for_byte() { + let edge = Edge { + from: RecordRef::new( + "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc", + "citer-1", + ), + role: "Design".to_string(), + to: RecordRef::new( + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff", + "a-eq", + ), + payload_encoding: None, + payload: None, + }; + + let actual = rmp_serde::to_vec_named(&edge).expect("encodes"); + let expected = hex_decode(CSHARP_EDGE_HEX); + assert_eq!( + actual, expected, + "Edge's Rust-encoded bytes do not match the captured C# encoding" + ); +} + +// R-AX (docs/cultnet-selection-cut.md, "Self's rulings for fix batch 7"): the two vectors above +// pin `RawDocumentHeader`/`Edge` at a single value with every optional absent - exactly the shape +// that proves nothing about `tags` (`string[]` vs `Vec`) or `payload` (`byte[]` vs +// `serde_bytes`), since an absent optional never exercises either mapping. These two extend the +// same byte-for-byte proof to a header with `tags` populated and an edge with a `payload` +// populated, captured the same way (`Convert.ToHexString(MessagePackSerializer.Serialize(...))` +// against the real MessagePack C# 3.1.7 GameCult.Networking depends on, via ygg-verify.sh on +// Yggdrasil). Both matched byte-for-byte on capture - no divergence found. + +// C#: new CultNetRawDocumentHeader { SchemaId = LEAF_A, RecordKey = "a-eq", +// StoredAt = "1970-01-01T00:00:00.000Z", Tags = ["alpha", "beta"] } - every other optional absent. +const CSHARP_HEADER_WITH_TAGS_HEX: &str = "8AA8736368656D614964D9477368613235363A66663335663364396436646533663130363662393336396537336665366337363438653838333638643138656663323237356339393437333063643834636666AA736368656D614E616D65C0AD736368656D6156657273696F6EC0B1736368656D61436F6E74656E7448617368C0A97265636F72644B6579A4612D6571A873746F7265644174B8313937302D30312D30315430303A30303A30302E3030305AAF736F7572636552756E74696D654964C0AD736F757263654167656E744964C0AA736F75726365526F6C65C0A47461677392A5616C706861A462657461"; + +// C#: new CultNetEdge { From = { SchemaId = CITER_ID, RecordKey = "citer-1" }, Role = "Design", +// To = { SchemaId = LEAF_A, RecordKey = "a-eq" }, PayloadEncoding = "messagepack", +// Payload = [1, 2, 3] }. +const CSHARP_EDGE_WITH_PAYLOAD_HEX: &str = "85A466726F6D82A8736368656D614964D9477368613235363A39643062633961656263333631396230376161626635363466393836363761666465343134343463316238363636363164373737363837643434333630386463A97265636F72644B6579A763697465722D31A4726F6C65A644657369676EA2746F82A8736368656D614964D9477368613235363A66663335663364396436646533663130363662393336396537336665366337363438653838333638643138656663323237356339393437333063643834636666A97265636F72644B6579A4612D6571AF7061796C6F6164456E636F64696E67AB6D6573736167657061636BA77061796C6F6164C403010203"; + +#[test] +fn raw_document_header_with_tags_matches_the_csharp_reference_byte_for_byte() { + let header = RawDocumentHeader { + schema_id: "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff".to_string(), + schema_name: None, + schema_version: None, + schema_content_hash: None, + record_key: "a-eq".to_string(), + stored_at: "1970-01-01T00:00:00.000Z".to_string(), + source_runtime_id: None, + source_agent_id: None, + source_role: None, + tags: Some(vec!["alpha".to_string(), "beta".to_string()]), + }; + + let actual = rmp_serde::to_vec_named(&header).expect("encodes"); + let expected = hex_decode(CSHARP_HEADER_WITH_TAGS_HEX); + assert_eq!( + actual, expected, + "RawDocumentHeader with tags populated differs from the C# reference - string[] vs Vec diverges" + ); +} + +#[test] +fn edge_with_payload_matches_the_csharp_reference_byte_for_byte() { + let edge = Edge { + from: RecordRef::new( + "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc", + "citer-1", + ), + role: "Design".to_string(), + to: RecordRef::new( + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff", + "a-eq", + ), + payload_encoding: Some("messagepack".to_string()), + payload: Some(vec![1, 2, 3]), + }; + + let actual = rmp_serde::to_vec_named(&edge).expect("encodes"); + let expected = hex_decode(CSHARP_EDGE_WITH_PAYLOAD_HEX); + assert_eq!( + actual, expected, + "Edge with payload populated differs from the C# reference - byte[] vs serde_bytes diverges" + ); +} + +// R-AX: the whole `cultnet.snapshot_response_raw.v1` envelope, byte-identical against the same C# +// reference message (`CultNetSnapshotResponseRawV1Message { MessageId = "m", Matched = 1, AsOf = 1, +// Headers = [ { SchemaId = LEAF_A, RecordKey = "a-eq", StoredAt = "1970-01-01T00:00:00.000Z" } ] }`, +// every other field absent). This is R-AX landed, not blocked: `select_page`'s output type +// (`SelectionPage`) is carried inside the caller's own `CultNetMessage::SnapshotResponseRawV1` +// envelope, and this proves the two runtimes' *encoders* agree on that whole shape, byte for byte +// - the rows-to-bytes single-call path R-AR would add is a separate, smaller claim (Self's +// ruling: "R-AR remains its own cut"). +const CSHARP_ENVELOPE_ALL_ABSENT_HEX: &str = "8BAD736368656D6156657273696F6ED92063756C746E65742E736E617073686F745F726573706F6E73655F7261772E7631A96D6573736167654964A16DA76D61746368656401A461734F6601A46E657874C0A768656164657273918AA8736368656D614964D9477368613235363A66663335663364396436646533663130363662393336396537336665366337363438653838333638643138656663323237356339393437333063643834636666AA736368656D614E616D65C0AD736368656D6156657273696F6EC0B1736368656D61436F6E74656E7448617368C0A97265636F72644B6579A4612D6571A873746F7265644174B8313937302D30312D30315430303A30303A30302E3030305AAF736F7572636552756E74696D654964C0AD736F757263654167656E744964C0AA736F75726365526F6C65C0A474616773C0A9646F63756D656E7473C0A56564676573C0A773686172644964C0AA736861726445706F6368C0B073686172644C6F6753657175656E6365C0"; + +#[test] +fn snapshot_response_raw_v1_envelope_matches_the_csharp_reference_byte_for_byte() { + let message = CultNetMessage::SnapshotResponseRawV1 { + message_id: "m".to_string(), + matched: 1, + as_of: 1, + next: None, + headers: Some(vec![RawDocumentHeader { + schema_id: "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff".to_string(), + schema_name: None, + schema_version: None, + schema_content_hash: None, + record_key: "a-eq".to_string(), + stored_at: "1970-01-01T00:00:00.000Z".to_string(), + source_runtime_id: None, + source_agent_id: None, + source_role: None, + tags: None, + }]), + documents: None, + edges: None, + shard_id: None, + shard_epoch: None, + shard_log_sequence: None, + }; + + let actual = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).expect("encodes"); + let expected = hex_decode(CSHARP_ENVELOPE_ALL_ABSENT_HEX); + assert_eq!(actual.len(), 376); + assert_eq!( + actual, expected, + "the whole cultnet.snapshot_response_raw.v1 envelope differs from the C# reference" + ); +} diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 5e8c8f6d..2f8e316a 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; +using System.Numerics; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.ExceptionServices; @@ -109,6 +110,46 @@ namespace GameCult.Caching public CultSchemaMigrationReport Report { get; } } + // A declared member's addressing shape, read-only: what a caller outside GameCult.Caching + // (the CultNet selection evaluator, chiefly) is allowed to know about a member without + // reflecting over the document itself. Built from the same declarations CultIndexAttribute + // and CultReferenceAttribute already recorded; carries no value. + public readonly struct CultDocumentMemberView + { + internal CultDocumentMemberView( + string memberName, + int slot, + string? indexAlias, + bool isName, + bool isReference, + bool isMany, + bool isNumeric, + Type? targetType) + { + MemberName = memberName; + Slot = slot; + IndexAlias = indexAlias; + IsName = isName; + IsReference = isReference; + IsMany = isMany; + IsNumeric = isNumeric; + TargetType = targetType; + } + + public string MemberName { get; } + public int Slot { get; } + public string? IndexAlias { get; } + public bool IsName { get; } + public bool IsReference { get; } + public bool IsMany { get; } + public bool IsNumeric { get; } + + // The reference's declared target: the type CultReferenceAttribute names, or CultRecordRef's T. + // Null when the member is not a reference. May be abstract (D9): the leaf set is resolved at + // evaluation by CultDocumentRegistry.ResolveTargetLeaves, never persisted here or in the catalog. + public Type? TargetType { get; } + } + public sealed class CultDocumentDescriptor { internal CultDocumentDescriptor( @@ -122,7 +163,8 @@ namespace GameCult.Caching string? nameMember, Func? nameAccessor, IReadOnlyDictionary> indexAccessors, - IReadOnlyList members) + IReadOnlyList members, + IReadOnlyList richMembers) { DocumentType = documentType; SchemaName = schemaName; @@ -135,6 +177,18 @@ namespace GameCult.Caching NameAccessor = nameAccessor; IndexAccessors = indexAccessors; Members = members; + RichMembers = richMembers; + DeclaredMembers = richMembers + .Select(member => new CultDocumentMemberView( + member.Member.Name, + member.Slot, + member.IndexAlias, + member.IsName, + member.IsReference, + member.IsMany, + member.IsNumeric, + member.TargetType)) + .ToArray(); } public Type DocumentType { get; } @@ -148,6 +202,80 @@ namespace GameCult.Caching internal Func? NameAccessor { get; } internal IReadOnlyDictionary> IndexAccessors { get; } internal IReadOnlyList Members { get; } + internal IReadOnlyList RichMembers { get; } + + // The public read surface (CultNet selection cut 1, section 6): declared addressing, and the + // three ways to read a value through it. GameCult.Networking has no InternalsVisibleTo here on + // purpose - this is the only door, so nothing outside the cache decides what a member means. + public IReadOnlyList DeclaredMembers { get; } + + public bool TryGetIndexValue(object document, string alias, out string? value) + { + if (document == null) throw new ArgumentNullException(nameof(document)); + if (string.IsNullOrEmpty(alias)) + { + value = null; + return false; + } + + foreach (var member in RichMembers) + { + if (string.Equals(member.IndexAlias, alias, StringComparison.Ordinal)) + { + value = member.GetterNullable(document); + return true; + } + } + + value = null; + return false; + } + + public bool TryGetIndexNumber(object document, string alias, out string? value) + { + if (document == null) throw new ArgumentNullException(nameof(document)); + if (!string.IsNullOrEmpty(alias)) + { + foreach (var member in RichMembers) + { + if (string.Equals(member.IndexAlias, alias, StringComparison.Ordinal) && member.IsNumeric) + { + var number = member.NumberOf(document); + if (number != null) + { + value = number; + return true; + } + + break; + } + } + } + + value = null; + return false; + } + + // The edges a declared reference member carries: one per CultRecordRef, one per element of + // an IEnumerable>, one per key of an IDictionary, V> with the + // value as the payload (D11). role is the member's index alias, or its name when it has none. + public IEnumerable<(CultRecordKey Target, object? Payload)> ReferencesOf(object document, string role) + { + if (document == null) throw new ArgumentNullException(nameof(document)); + if (string.IsNullOrEmpty(role)) return Array.Empty<(CultRecordKey, object?)>(); + + foreach (var member in RichMembers) + { + if (!member.IsReference) continue; + var memberRole = member.IndexAlias ?? member.Member.Name; + if (string.Equals(memberRole, role, StringComparison.Ordinal)) + { + return member.ReferenceEnumerator!(document); + } + } + + return Array.Empty<(CultRecordKey, object?)>(); + } public CultSchemaCatalogEntry ToCatalogEntry() { @@ -254,6 +382,15 @@ namespace GameCult.Caching public IEnumerable AllDescriptors => _indexes.ByType.Values.OrderBy(d => d.SchemaName, StringComparer.Ordinal); + // D9: a reference's target set is every registered leaf assignable to its declared target type, + // computed against the registry as it stands right now - never cached, never persisted. A leaf + // registered after some other descriptor was built must still show up here. + public IReadOnlyList ResolveTargetLeaves(Type targetType) + { + if (targetType == null) throw new ArgumentNullException(nameof(targetType)); + return AllDescriptors.Where(d => targetType.IsAssignableFrom(d.DocumentType)).ToArray(); + } + public void Refresh() { lock (_registrationGate) @@ -495,7 +632,8 @@ namespace GameCult.Caching nameMember?.Member.Name, nameMember?.GetterNullable, indexAccessors, - descriptorMembers); + descriptorMembers, + members); } private static string BuildSemanticFingerprint( @@ -944,6 +1082,57 @@ namespace GameCult.Caching rejections.Add(HiddenMemberMessage(type.Name, Qualified(pair[0].Member), Qualified(pair[1].Member))); } + // D10: two differently-named members that independently declare the same index alias. A + // hiding member sharing one slot is already caught above; this is two distinct members whose + // alias map entry (BuildDescriptor's indexAccessors) would otherwise collide silently. + foreach (var group in keyed + .Select(entry => (Entry: entry, Alias: ResolveIndexAlias(entry.Member))) + .Where(pair => pair.Alias != null) + .GroupBy(pair => pair.Alias, StringComparer.Ordinal) + .Where(group => group.Select(pair => pair.Entry.Member.Name).Distinct().Count() > 1) + .OrderBy(group => group.Key, StringComparer.Ordinal)) + { + var pair = group.Take(2).ToArray(); + rejections.Add(DuplicateIndexAliasMessage(type.Name, Qualified(pair[0].Entry.Member), Qualified(pair[1].Entry.Member), group.Key!)); + } + + // D10b: the reference-role namespace is (IndexAlias ?? MemberName) for every member, not + // only the aliased ones D10 groups above. A member with no [CultIndex] still contributes + // its bare name to that namespace, and a reference member declaring the same string as its + // alias collides with it - the collision D10 cannot see because an unaliased member's Alias + // is null. + foreach (var group in keyed + .Select(entry => ( + Entry: entry, + Role: ResolveIndexAlias(entry.Member) ?? entry.Member.Name, + IsReference: PersistedMember.IsReferenceCandidate(entry.Member, entry.MemberType))) + .GroupBy(pair => pair.Role, StringComparer.Ordinal) + .Where(group => group.Select(pair => pair.Entry.Member.Name).Distinct().Count() > 1 + && group.Any(pair => pair.IsReference)) + .OrderBy(group => group.Key, StringComparer.Ordinal)) + { + var pair = group.Take(2).ToArray(); + rejections.Add(DuplicateReferenceRoleMessage(type.Name, Qualified(pair[0].Entry.Member), Qualified(pair[1].Entry.Member), group.Key)); + } + + // D11: a member declared as a reference (by [CultReference] or a bare CultRecordRef type) + // whose CLR shape the cache cannot walk. many:false needs CultRecordRef itself; many:true + // needs IEnumerable> or IDictionary, V>. A declaration the + // cache cannot read is refused here, not answered as an unreadable no-op reference later. + foreach (var candidate in keyed) + { + var referenceAttribute = candidate.Member.GetCustomAttribute(); + var isBareRecordRef = IsCultRecordRefType(candidate.MemberType); + if (referenceAttribute == null && !isBareRecordRef) + continue; + var many = referenceAttribute?.Many ?? false; + var supported = many + ? IsEnumerableOfRecordRef(candidate.MemberType) || TryGetDictionaryRefTypes(candidate.MemberType, out _, out _) + : isBareRecordRef; + if (!supported) + rejections.Add(UnsupportedReferenceShapeMessage(type.Name, Qualified(candidate.Member))); + } + if (rejections.Count > 0) throw new InvalidOperationException(rejections[0]); @@ -992,6 +1181,216 @@ namespace GameCult.Caching private static string Qualified(MemberInfo member) => member.DeclaringType!.Name.Split('`')[0] + "." + member.Name; + // Shared by DiscoverMembers' D10/D11 rejections and PersistedMember.FromMember, so registration + // refuses exactly the shapes the accessor it builds afterward could not have walked anyway. + private static string? ResolveIndexAlias(MemberInfo member) + { + var indexAttribute = member.GetCustomAttribute(); + if (indexAttribute == null) + return null; + return string.IsNullOrWhiteSpace(indexAttribute.Alias) ? member.Name : indexAttribute.Alias; + } + + private static bool IsCultRecordRefType(Type type) => + type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>); + + private static bool IsEnumerableOfRecordRef(Type type) + { + foreach (var candidate in type.GetInterfaces().Append(type)) + { + if (candidate.IsGenericType + && candidate.GetGenericTypeDefinition() == typeof(IEnumerable<>) + && IsCultRecordRefType(candidate.GetGenericArguments()[0])) + return true; + } + + return false; + } + + private static bool TryGetDictionaryRefTypes(Type type, out PropertyInfo keyProperty, out PropertyInfo valueProperty) + { + foreach (var candidate in type.GetInterfaces().Append(type)) + { + if (!candidate.IsGenericType || candidate.GetGenericTypeDefinition() != typeof(IDictionary<,>)) + continue; + var arguments = candidate.GetGenericArguments(); + if (!IsCultRecordRefType(arguments[0])) + continue; + var pairType = typeof(KeyValuePair<,>).MakeGenericType(arguments[0], arguments[1]); + keyProperty = pairType.GetProperty("Key")!; + valueProperty = pairType.GetProperty("Value")!; + return true; + } + + keyProperty = null!; + valueProperty = null!; + return false; + } + + private static readonly HashSet NumericClrTypes = new() + { + typeof(sbyte), typeof(byte), typeof(short), typeof(ushort), + typeof(int), typeof(uint), typeof(long), typeof(ulong), + typeof(float), typeof(double), typeof(decimal) + }; + + // D8's closed CLR numeric set: the sole authority for whether a comparison predicate may reach + // a member. The TypeName string persisted in the catalog is generated from this set (via + // CultSchemaTypeNames.FromType) and must never be hand-checked against separately - S22 pins it. + private static bool IsNumericClrType(Type type) + { + var underlying = Nullable.GetUnderlyingType(type) ?? type; + return NumericClrTypes.Contains(underlying); + } + + // Q-J (docs/cultnet-selection-cut.md, section 2 "Numbers"): a row's exact canonical decimal, + // never a double - a long past 2^53 must not lose precision passing through this path. Boxing a + // Nullable with a value produces a boxed T (never a boxed Nullable), so raw is always one + // of the closed CLR numeric set's non-nullable types here. NaN and infinity render as null: + // "a NaN or infinite member matches no comparison." + private static string? RenderCanonicalNumber(object raw) => raw switch + { + float f => float.IsNaN(f) || float.IsInfinity(f) ? null : RenderExactFloat(f), + double d => double.IsNaN(d) || double.IsInfinity(d) ? null : RenderExactDouble(d), + decimal m => CanonicalizeDecimalDigits(m.ToString(CultureInfo.InvariantCulture)), + _ => CanonicalizeDecimalDigits(Convert.ToString(raw, CultureInfo.InvariantCulture)!) + }; + + // R-D (docs/cultnet-selection-cut.md): the wire's canonical decimal is the value's exact decimal + // expansion, not a shortest round-trip form. .NET's default float/double ToString has used the + // shortest round-trip form since .NET Core 3.0, which rounds a tie between two decimals to + // whichever is "shorter" - Rust's shortest form breaks the same tie the other way at some + // values, so the two runtimes rendered different strings for the same bit pattern (394/200k f32, + // 48/200k f64 in the parity vectors). Every finite float and double is an exact dyadic rational + // (significand * 2^exponent for an integer significand and exponent), so it has a finite exact + // decimal expansion computed with BigInteger: multiply the significand by 5^-exponent when the + // exponent is negative (1/2^k == 5^k/10^k) to get the exact digits, and place the decimal point. + // No CLR floating-point arithmetic and no string round-tripping is on this path. + private static string RenderExactFloat(float value) + { + var bits = BitConverter.SingleToInt32Bits(value); + var negative = bits < 0; + var biasedExponent = (bits >> 23) & 0xFF; + var mantissaBits = bits & 0x7FFFFF; + if (biasedExponent == 0 && mantissaBits == 0) + return "0"; // canonical zero: a single "0", never "-0" (Q-J). + + BigInteger significand; + int exponent; + if (biasedExponent == 0) + { + significand = mantissaBits; + exponent = -126 - 23; // subnormal: no implicit leading 1, minimum exponent. + } + else + { + significand = mantissaBits | (1 << 23); + exponent = biasedExponent - 127 - 23; + } + + return RenderExactDecimal(negative, significand, exponent); + } + + private static string RenderExactDouble(double value) + { + var bits = BitConverter.DoubleToInt64Bits(value); + var negative = bits < 0; + var biasedExponent = (int)((bits >> 52) & 0x7FF); + var mantissaBits = bits & 0xFFFFFFFFFFFFFL; + if (biasedExponent == 0 && mantissaBits == 0) + return "0"; // canonical zero: a single "0", never "-0" (Q-J). + + BigInteger significand; + int exponent; + if (biasedExponent == 0) + { + significand = mantissaBits; + exponent = -1022 - 52; // subnormal: no implicit leading 1, minimum exponent. + } + else + { + significand = mantissaBits | (1L << 52); + exponent = biasedExponent - 1023 - 52; + } + + return RenderExactDecimal(negative, significand, exponent); + } + + // value = significand * 2^exponent, significand > 0. exponent >= 0 is an exact integer shift; + // exponent < 0 rewrites 2^exponent as 5^-exponent / 10^-exponent, so the digits are exact and + // the decimal point lands -exponent places from the right - no precision limit, no rounding. + private static string RenderExactDecimal(bool negative, BigInteger significand, int exponent) + { + string digits; + int fractionDigits; + if (exponent >= 0) + { + digits = (significand << exponent).ToString(CultureInfo.InvariantCulture); + fractionDigits = 0; + } + else + { + fractionDigits = -exponent; + digits = (significand * BigInteger.Pow(5, fractionDigits)).ToString(CultureInfo.InvariantCulture); + } + + string positional; + if (fractionDigits == 0) + { + positional = digits; + } + else + { + if (digits.Length <= fractionDigits) + digits = digits.PadLeft(fractionDigits + 1, '0'); + var splitAt = digits.Length - fractionDigits; + positional = digits.Substring(0, splitAt) + "." + digits.Substring(splitAt); + } + + return CanonicalizeDecimalDigits(negative ? "-" + positional : positional); + } + + // The one canonicalizer every rendered number passes through: strips leading integer zeros and + // trailing fractional zeros, and collapses a negative value that reduces to zero into "0" - the + // door's regex forbids all three spellings on the way in (Q-J), and this is why the row side + // never produces one on the way out. + private static string CanonicalizeDecimalDigits(string signedPositional) + { + var negative = signedPositional.Length > 0 && signedPositional[0] == '-'; + var unsigned = negative ? signedPositional.Substring(1) : signedPositional; + var dot = unsigned.IndexOf('.'); + var integerPart = dot < 0 ? unsigned : unsigned.Substring(0, dot); + var fractionPart = dot < 0 ? string.Empty : unsigned.Substring(dot + 1); + + integerPart = integerPart.TrimStart('0'); + if (integerPart.Length == 0) integerPart = "0"; + fractionPart = fractionPart.TrimEnd('0'); + + var isZero = integerPart == "0" && fractionPart.Length == 0; + var result = fractionPart.Length == 0 ? integerPart : integerPart + "." + fractionPart; + return negative && !isZero ? "-" + result : result; + } + + private static string DuplicateIndexAliasMessage(string documentTypeName, string first, string second, string alias) => + $"Cult document {documentTypeName} members {first} and {second} both declare index alias \"{alias}\"; an index alias must name one member."; + + // D10b (R-AL): a plain member's bare name colliding with a reference member's index alias. + // CultDocumentRegistry.ReferencesOf and CultNetSelectionEvaluator.ReferenceMembers both key + // reference lookups off (IndexAlias ?? MemberName); D10 above only groups members that + // declare an alias, so an unaliased member's bare name sharing that string with a reference + // member's alias registered silently and faulted every hop through it at resolution time. + // + // Breaking registration change, noted per the fix batch 7 ruling: a shape that used to + // register and work - a data member aliased "Owner" beside a reference member named + // "Owner" - is now refused. There is no compatibility path; the collision was always a + // silent resolution fault waiting to happen, so the fix is a door, not a warning. + private static string DuplicateReferenceRoleMessage(string documentTypeName, string first, string second, string role) => + $"Cult document {documentTypeName} members {first} and {second} both resolve to reference role \"{role}\" (index alias, or bare member name when unaliased); a reference role must name one member. " + + $"This is a breaking registration change from before D10b existed: a shape where a plain member's bare name collided with a reference member's index alias used to register silently and fault at resolution time instead."; + + private static string UnsupportedReferenceShapeMessage(string documentTypeName, string member) => + $"Cult document {documentTypeName} member {member} declares a reference the cache cannot walk; many:false needs CultRecordRef, many:true needs IEnumerable> or IDictionary, V>."; + private static string NotMessagePackObjectMessage(string documentTypeName) => $"Cult document {documentTypeName} is not a MessagePack object; add [MessagePackObject] so MessagePack serializes its [Key(n)] members."; @@ -1047,7 +1446,9 @@ namespace GameCult.Caching return "sha256:" + BitConverter.ToString(bytes).Replace("-", string.Empty).ToLowerInvariant(); } - private sealed class PersistedMember + // internal, not private: CultDocumentDescriptor (a separate top-level class) reads RichMembers + // through this type to build the public DeclaredMembers view and the three value accessors. + internal sealed class PersistedMember { public MemberInfo Member { get; set; } = default!; public Type MemberType { get; set; } = default!; @@ -1057,9 +1458,27 @@ namespace GameCult.Caching public bool IsReference { get; set; } public bool IsMany { get; set; } public string? TargetSchemaName { get; set; } + + // The reference's declared target Type, held alongside TargetSchemaName (which is null for + // an abstract target - D9). CultDocumentRegistry.ResolveTargetLeaves needs the Type, not the + // name, to compute the leaf set by assignability. + public Type? TargetType { get; set; } + public bool IsNumeric { get; set; } public Func Getter { get; set; } = default!; public Func GetterNullable { get; set; } = default!; + // D8/Q-J (revised 2026-09-22): non-null exactly for the closed CLR numeric set (Nullable + // unwrapped), rendered as the member's exact canonical decimal string - never a double, so + // no CLR numeric type's range or precision is lost rendering it. docs/cultnet-selection-cut.md + // section 2 "Numbers". + public Func NumberOf { get; set; } = default!; + + // D11: null for a non-reference member; otherwise yields (target key, payload) per declared + // shape - one entry for a bare reference, one per element for a many-enumerable, one per key + // with its value as payload for a many-dictionary. Registration already refused every other + // shape (DiscoverMembers), so this is never called against something it cannot walk. + public Func>? ReferenceEnumerator { get; set; } + public static PersistedMember FromMember( MemberInfo member, Type memberType, @@ -1069,6 +1488,8 @@ namespace GameCult.Caching var referenceAttribute = member.GetCustomAttribute(); var targetType = ResolveReferenceTarget(memberType, referenceAttribute?.TargetType); var targetSchemaName = targetType?.GetCustomAttribute()?.SchemaName; + var isReference = targetType != null || referenceAttribute != null; + var many = referenceAttribute?.Many ?? false; return new PersistedMember { Member = member, @@ -1076,25 +1497,74 @@ namespace GameCult.Caching Slot = slot, IsName = member.GetCustomAttribute() != null, IndexAlias = ResolveIndexAlias(member), - IsReference = targetType != null || referenceAttribute != null, - IsMany = referenceAttribute?.Many ?? false, + IsReference = isReference, + IsMany = many, TargetSchemaName = targetSchemaName, + TargetType = targetType, + IsNumeric = IsNumericClrType(memberType), Getter = document => getValue(document)?.ToString() ?? string.Empty, - GetterNullable = document => getValue(document)?.ToString() + GetterNullable = document => getValue(document)?.ToString(), + NumberOf = document => + { + var raw = getValue(document); + return raw == null ? null : RenderCanonicalNumber(raw); + }, + ReferenceEnumerator = isReference ? BuildReferenceEnumerator(memberType, many, getValue) : null }; } - private static string? ResolveIndexAlias(MemberInfo member) + // Registration (DiscoverMembers' D11 check) already refused every shape but the three this + // builds an enumerator for; this does not re-refuse, it only classifies which of the three. + private static Func> BuildReferenceEnumerator( + Type memberType, bool many, Func getValue) { - var indexAttribute = member.GetCustomAttribute(); - if (indexAttribute == null) + if (!many) { - return null; + return document => + getValue(document) is ICultRecordRef reference + ? new[] { (reference.Key, (object?)null) } + : Array.Empty<(CultRecordKey, object?)>(); } - return string.IsNullOrWhiteSpace(indexAttribute.Alias) - ? member.Name - : indexAttribute.Alias; + if (TryGetDictionaryRefTypes(memberType, out var keyProperty, out var valueProperty)) + { + return document => + { + if (getValue(document) is not System.Collections.IEnumerable sequence) + return Array.Empty<(CultRecordKey, object?)>(); + var results = new List<(CultRecordKey, object?)>(); + foreach (var entry in sequence) + { + if (keyProperty.GetValue(entry) is ICultRecordRef reference) + results.Add((reference.Key, valueProperty.GetValue(entry))); + } + + return results; + }; + } + + return document => + { + if (getValue(document) is not System.Collections.IEnumerable sequence) + return Array.Empty<(CultRecordKey, object?)>(); + var results = new List<(CultRecordKey, object?)>(); + foreach (var entry in sequence) + { + if (entry is ICultRecordRef reference) + results.Add((reference.Key, (object?)null)); + } + + return results; + }; + } + + // Mirrors the isReference computation FromMember uses below, callable from DiscoverMembers + // (D10b) before a slot is turned into a full PersistedMember. + internal static bool IsReferenceCandidate(MemberInfo member, Type memberType) + { + var referenceAttribute = member.GetCustomAttribute(); + var targetType = ResolveReferenceTarget(memberType, referenceAttribute?.TargetType); + return targetType != null || referenceAttribute != null; } private static Type? ResolveReferenceTarget(Type memberType, Type? explicitTarget) @@ -1110,6 +1580,30 @@ namespace GameCult.Caching return memberType.GetGenericArguments()[0]; } + // R-T(b): a many reference's implicit target is its CultRecordRef element's T - the + // same narrowing a scalar CultRecordRef gets above - not left null just because the + // member's own declared type is List<> or IDictionary<,>, not CultRecordRef<> itself. + // Before this, an implicitly-typed many reference reached CultNetSelectionEvaluator's + // EnsureWithinDeclaredTarget with a null TargetType and its out-of-target check was + // silently skipped for every edge through it (R-T(b)/S18). + if (IsEnumerableOfRecordRef(memberType)) + { + foreach (var candidate in memberType.GetInterfaces().Append(memberType)) + { + if (candidate.IsGenericType && + candidate.GetGenericTypeDefinition() == typeof(IEnumerable<>) && + IsCultRecordRefType(candidate.GetGenericArguments()[0])) + { + return candidate.GetGenericArguments()[0].GetGenericArguments()[0]; + } + } + } + + if (TryGetDictionaryRefTypes(memberType, out var keyProperty, out _)) + { + return keyProperty.PropertyType.GetGenericArguments()[0]; + } + return null; } } diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index e7436db2..52c67482 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -112,7 +112,7 @@ namespace GameCult.Mesh /// /// Options for opening one typed document from a remote CultNet snapshot endpoint. /// - public sealed class CultMeshPeerSnapshotDocumentOptions + public sealed record CultMeshPeerSnapshotDocumentOptions { /// Gets or sets the semantic document id. Defaults to the record key. public string? DocumentId { get; set; } @@ -135,8 +135,8 @@ namespace GameCult.Mesh /// Gets or sets the message id prefix used for snapshot requests. public string? MessageIdPrefix { get; set; } - /// Gets or sets schema ids to request. Empty means the document type schema is requested. - public IReadOnlyList? SchemaIds { get; set; } + /// Gets or sets the selection to request. Absent means the document type's own schema. + public CultNetSelection? Selection { get; set; } /// Gets or sets the target shard id, when the endpoint is shard-aware. public string? ShardId { get; set; } @@ -1944,28 +1944,12 @@ namespace GameCult.Mesh string? documentId, string? sourceId) { - return new CultMeshPeerSnapshotDocumentOptions - { - DocumentId = string.IsNullOrWhiteSpace(documentId) ? key.Value : documentId, - SourceId = string.IsNullOrWhiteSpace(sourceId) - ? (string.IsNullOrWhiteSpace(documentId) ? key.Value : documentId) - : sourceId, - RouteHint = options?.RouteHint, - ResponseTimeout = options?.ResponseTimeout ?? TimeSpan.FromSeconds(5), - ConnectTimeout = options?.ConnectTimeout ?? TimeSpan.FromSeconds(5), - PollInterval = options?.PollInterval ?? TimeSpan.FromMilliseconds(250), - MessageIdPrefix = options?.MessageIdPrefix, - SchemaIds = options?.SchemaIds, - ShardId = options?.ShardId, - ShardEpoch = options?.ShardEpoch, - Security = options?.Security, - ConfigureClient = options?.ConfigureClient, - RudpRuntimeId = options?.RudpRuntimeId, - RudpConnectionId = options?.RudpConnectionId ?? 0x43554c54, - RudpConnectPayload = options?.RudpConnectPayload ?? "cultnet-schema-rudp", - RudpMaxFragmentBytes = options?.RudpMaxFragmentBytes ?? 1024, - RudpResendDelayMs = options?.RudpResendDelayMs ?? 25 - }; + var resolved = options is null ? new CultMeshPeerSnapshotDocumentOptions() : options with { }; + resolved.DocumentId = string.IsNullOrWhiteSpace(documentId) ? key.Value : documentId; + resolved.SourceId = string.IsNullOrWhiteSpace(sourceId) + ? (string.IsNullOrWhiteSpace(documentId) ? key.Value : documentId) + : sourceId; + return resolved; } /// @@ -2707,46 +2691,48 @@ namespace GameCult.Mesh where TDocument : class { var descriptor = CultDocumentRegistry.Shared.GetRequired(); + var selection = new CultNetSelection + { + Schemas = options.Selection?.Schemas ?? new[] { descriptor.SchemaId }, + Keys = new[] { recordKey } + }; return await FetchSnapshotAsync( createClient, endpoint, - ToSnapshotRequestOptions( - options, - context, - options.SchemaIds ?? new[] { descriptor.SchemaId }, - new[] { recordKey })) + new CultMeshSnapshotRequestOptions + { + Selection = selection, + ShardId = options.ShardId, + ShardEpoch = options.ShardEpoch, + ResponseTimeout = options.ResponseTimeout, + ConnectTimeout = options.ConnectTimeout, + MessageIdPrefix = string.IsNullOrWhiteSpace(options.MessageIdPrefix) + ? $"cultmesh:{context.RuntimeId}:snapshot" + : options.MessageIdPrefix, + Security = options.Security, + ConfigureClient = options.ConfigureClient, + RudpRuntimeId = string.IsNullOrWhiteSpace(options.RudpRuntimeId) + ? context.RuntimeId + : options.RudpRuntimeId, + RudpConnectionId = options.RudpConnectionId, + RudpConnectPayload = options.RudpConnectPayload, + RudpMaxFragmentBytes = options.RudpMaxFragmentBytes, + RudpResendDelayMs = options.RudpResendDelayMs + }) .ConfigureAwait(false); } - private static CultMeshSnapshotRequestOptions ToSnapshotRequestOptions( - CultMeshPeerSnapshotDocumentOptions options, - CultMeshQueryContext context, - IReadOnlyList? schemaIds, - IReadOnlyList? recordKeys) - { - return new CultMeshSnapshotRequestOptions - { - SchemaIds = schemaIds, - RecordKeys = recordKeys, - ShardId = options.ShardId, - ShardEpoch = options.ShardEpoch, - ResponseTimeout = options.ResponseTimeout, - ConnectTimeout = options.ConnectTimeout, - MessageIdPrefix = string.IsNullOrWhiteSpace(options.MessageIdPrefix) - ? $"cultmesh:{context.RuntimeId}:snapshot" - : options.MessageIdPrefix, - Security = options.Security, - ConfigureClient = options.ConfigureClient, - RudpRuntimeId = string.IsNullOrWhiteSpace(options.RudpRuntimeId) - ? context.RuntimeId - : options.RudpRuntimeId, - RudpConnectionId = options.RudpConnectionId, - RudpConnectPayload = options.RudpConnectPayload, - RudpMaxFragmentBytes = options.RudpMaxFragmentBytes, - RudpResendDelayMs = options.RudpResendDelayMs - }; - } - + // One exact read (docs/cultnet-selection-cut.md, section 4/7): the record this recordKey + // names, decoded as TDocument. Candidates at that key are searched in one order - exact + // schema, then a declared alias of it, then (a foreign/runtime-generated schema id this + // runtime does not recognize by any alias) its payload's own embedded schema stamp, + // alias-matched against TDocument's descriptor. The parse step + // (CultNetDocumentRegistry.TryReadSchemaVersion) is the one decode rule shared with + // DecodeSnapshotDocuments; the match itself stays a direct alias-match against the caller's + // own descriptor rather than a global "which registered type owns this schema string" lookup, + // because that lookup is ambiguous when two document types alias the same schema id (as + // MeshNoteDocument/MeshNoteAliasDocument do in tests/GameCult.Mesh.Tests). No re-filter of the + // server's answer by schema alone, and no fallback that returns a record under the wrong key. private static TDocument ReadDocumentFromSnapshotResponse( CultNetSnapshotResponseRawMessage response, string schemaId, @@ -2754,16 +2740,26 @@ namespace GameCult.Mesh where TDocument : class { if (response == null) throw new ArgumentNullException(nameof(response)); - var record = response.Documents.FirstOrDefault(candidate => - string.Equals(candidate.SchemaId, schemaId, StringComparison.Ordinal) && - string.Equals(candidate.RecordKey, recordKey, StringComparison.Ordinal)) - ?? response.Documents.FirstOrDefault(candidate => - string.Equals(candidate.RecordKey, recordKey, StringComparison.Ordinal) && - TryDecodeSnapshotDocument(candidate, out TDocument? _)) - ?? response.Documents.FirstOrDefault(candidate => - string.Equals(candidate.SchemaId, schemaId, StringComparison.Ordinal)) - ?? response.Documents.FirstOrDefault(candidate => - string.Equals(candidate.RecordKey, recordKey, StringComparison.Ordinal)); + var descriptor = CultDocumentRegistry.Shared.GetRequired(); + + CultNetRawDocumentRecord? exact = null; + CultNetRawDocumentRecord? aliased = null; + CultNetRawDocumentRecord? byPayload = null; + + foreach (var candidate in response.Documents) + { + if (!string.Equals(candidate.RecordKey, recordKey, StringComparison.Ordinal)) + continue; + + if (exact == null && string.Equals(candidate.SchemaId, schemaId, StringComparison.Ordinal)) + exact = candidate; + else if (aliased == null && CultNetSchemaAliasMatching.Matches(candidate.SchemaId, descriptor)) + aliased = candidate; + else if (byPayload == null && CultNetDocumentRegistry.PayloadMatchesSchema(candidate.Payload, descriptor)) + byPayload = candidate; + } + + var record = exact ?? aliased ?? byPayload; if (record == null) { throw new InvalidOperationException( @@ -2776,35 +2772,7 @@ namespace GameCult.Mesh $"CultNet raw document payloadEncoding must be \"messagepack\", not \"{record.PayloadEncoding}\"."); } - return DecodeSnapshotDocument(record); - } - - private static bool TryDecodeSnapshotDocument( - CultNetRawDocumentRecord record, - out TDocument? document) - where TDocument : class - { - document = null; - if (!string.Equals(record.PayloadEncoding, "messagepack", StringComparison.Ordinal)) - return false; - - try - { - document = DecodeSnapshotDocument(record); - return true; - } - catch - { - return false; - } - } - - private static TDocument DecodeSnapshotDocument(CultNetRawDocumentRecord record) - where TDocument : class - { - return (TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped( - typeof(TDocument), - record.Payload); + return (TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TDocument), record.Payload); } /// @@ -2849,8 +2817,8 @@ namespace GameCult.Mesh throw new ArgumentException("At least one readable body transport is required.", nameof(supportedBodyTransports)); return subscriptions.SubscribeAsync( subscription.SubscriptionId, - recordKeys: subscription.RecordKeys, - schemaIds: subscription.SchemaIds, + recordKeys: subscription.Selection.Keys, + schemaIds: subscription.Selection.Schemas, consumerRuntimeId: subscription.ConsumerRuntimeId, bodyIds: new[] { subscription.BodyId }, supportedBodyTransports: transports.Select(value => value.ToString()), @@ -3471,13 +3439,15 @@ namespace GameCult.Mesh CultDocumentRegistry.Shared.GetRequired()); } + // The one schema-alias matcher (docs/cultnet-selection-cut.md, D4): every runtime-side + // schema identity decision, wire or local, goes through CultNetSchemaAliasMatching. private static bool IsSameCultDocumentSchema( Type documentType, CultDocumentDescriptor descriptor, string? schemaId = null) { if (!string.IsNullOrWhiteSpace(schemaId) && - string.Equals(schemaId, descriptor.SchemaId, StringComparison.Ordinal)) + CultNetSchemaAliasMatching.Matches(schemaId, descriptor)) { return true; } @@ -3492,8 +3462,7 @@ namespace GameCult.Mesh return false; } - return string.Equals(storedDescriptor.SchemaName, descriptor.SchemaName, StringComparison.Ordinal) && - string.Equals(storedDescriptor.SchemaVersion, descriptor.SchemaVersion, StringComparison.Ordinal); + return CultNetSchemaAliasMatching.Matches(storedDescriptor.SchemaVersion, descriptor); } private static TDocument ConvertUntypedDocument(object document) diff --git a/src/GameCult.Mesh/CultMeshBodyDemand.cs b/src/GameCult.Mesh/CultMeshBodyDemand.cs index 2ab90617..4293dba4 100644 --- a/src/GameCult.Mesh/CultMeshBodyDemand.cs +++ b/src/GameCult.Mesh/CultMeshBodyDemand.cs @@ -188,31 +188,46 @@ namespace GameCult.Mesh SubscriptionId = Require(subscriptionId, nameof(subscriptionId)); ConsumerRuntimeId = Require(consumerRuntimeId, nameof(consumerRuntimeId)); BodyId = Require(bodyId, nameof(bodyId)); - RecordKeys = new[] - { - Require(viewRecordKey, nameof(viewRecordKey)), - CultMeshBodyPublicationDocument.CreateLatestRecordKey(bodyId).Value - } - .Concat(additionalRecordKeys ?? Array.Empty()) - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Distinct(StringComparer.Ordinal) - .ToArray(); - SchemaIds = new[] - { - Require(viewSchemaId, nameof(viewSchemaId)), - CultMeshBodyPublicationSchemaVersions.Publication - } - .Concat(additionalSchemaIds ?? Array.Empty()) - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Distinct(StringComparer.Ordinal) - .ToArray(); + _selection = new CultNetSelection + { + Keys = new[] + { + Require(viewRecordKey, nameof(viewRecordKey)), + CultMeshBodyPublicationDocument.CreateLatestRecordKey(bodyId).Value + } + .Concat(additionalRecordKeys ?? Array.Empty()) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .ToArray(), + Schemas = new[] + { + Require(viewSchemaId, nameof(viewSchemaId)), + CultMeshBodyPublicationSchemaVersions.Publication + } + .Concat(additionalSchemaIds ?? Array.Empty()) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .ToArray() + }; } public string SubscriptionId { get; } public string ConsumerRuntimeId { get; } public string BodyId { get; } - public IReadOnlyList RecordKeys { get; } - public IReadOnlyList SchemaIds { get; } + + private readonly CultNetSelection _selection; + + /// + /// The view record, the body's latest-publication record, and any additional keys/schemas, as + /// one selection. Returns a fresh copy on every read: 's arrays + /// are wire-mutable by design, and this contract does not let a caller mutate them out from + /// under it (docs/cultnet-selection-cut.md, S2-8). + /// + public CultNetSelection Selection => new() + { + Schemas = (string[]?)_selection.Schemas?.Clone(), + Keys = (string[]?)_selection.Keys?.Clone() + }; private static string Require(string value, string parameterName) => string.IsNullOrWhiteSpace(value) diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs index d40eef03..58aede9d 100644 --- a/src/GameCult.Mesh/CultMeshSnapshots.cs +++ b/src/GameCult.Mesh/CultMeshSnapshots.cs @@ -13,13 +13,10 @@ namespace GameCult.Mesh /// /// Options for one scoped CultNet snapshot request. /// - public sealed class CultMeshSnapshotRequestOptions + public sealed record CultMeshSnapshotRequestOptions { - /// Gets or sets schema ids to request. Empty means no schema filter. - public IReadOnlyList? SchemaIds { get; set; } - - /// Gets or sets record keys to request. Empty means no record-key filter. - public IReadOnlyList? RecordKeys { get; set; } + /// Gets or sets the selection to request. Absent selects every schema and key. + public CultNetSelection? Selection { get; set; } /// Gets or sets the target shard id, when the endpoint is shard-aware. public string? ShardId { get; set; } @@ -87,7 +84,7 @@ namespace GameCult.Mesh _endpoint = string.IsNullOrWhiteSpace(endpoint) ? throw new ArgumentException("Value must be non-empty.", nameof(endpoint)) : endpoint; - _defaults = CultMesh.CloneSnapshotRequestOptions(options); + _defaults = options with { }; _registry = registry ?? new CultNetDocumentRegistry(); _client = CultMesh.CreateSnapshotClient(endpoint, _defaults)(); _client.OnCultNet(OnResponse); @@ -103,7 +100,7 @@ namespace GameCult.Mesh { _sharedSession = session ?? throw new ArgumentNullException(nameof(session)); _endpoint = session.State.Path?.Endpoint ?? session.Target.AuthorityRuntimeId; - _defaults = CultMesh.CloneSnapshotRequestOptions(options); + _defaults = options with { }; _registry = registry ?? new CultNetDocumentRegistry(); _client = session.Channel; _responseSubscription = session.OnCultNet(OnResponse); @@ -150,9 +147,9 @@ namespace GameCult.Mesh _defaults.ConnectTimeout, (_client as ICultNetSchemaClientHealth)?.BackgroundFailure) .ConfigureAwait(false); - var options = CultMesh.CloneSnapshotRequestOptions(_defaults); - options.SchemaIds = schemaIds ?? options.SchemaIds; - options.RecordKeys = recordKeys ?? options.RecordKeys; + var selection = CultMesh.OverlaySelection(_defaults.Selection, schemaIds, recordKeys); + CultMesh.EnsureV0Compatible(selection); + var options = _defaults with { Selection = selection }; var messageId = CultMesh.CreateSnapshotMessageId(options); var completion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); @@ -164,8 +161,8 @@ namespace GameCult.Mesh _client.SendCultNet(new CultNetSnapshotRequestMessage { MessageId = messageId, - SchemaIds = CultMesh.CleanSnapshotFilter(options.SchemaIds), - RecordKeys = CultMesh.CleanSnapshotFilter(options.RecordKeys), + SchemaIds = selection.Schemas, + RecordKeys = selection.Keys, ShardId = string.IsNullOrWhiteSpace(options.ShardId) ? null : options.ShardId, ShardEpoch = options.ShardEpoch }); @@ -195,8 +192,8 @@ namespace GameCult.Mesh where TDocument : class { var descriptor = CultDocumentRegistry.Shared.GetRequired(); - var resolvedSchemas = schemaIds ?? (recordKeys is { Count: > 0 } ? null : new[] { descriptor.SchemaId }); - var snapshot = await FetchSnapshotAsync(resolvedSchemas, recordKeys).ConfigureAwait(false); + var selection = CultMesh.ResolveDefaultSelection(schemaIds, recordKeys, descriptor); + var snapshot = await FetchSnapshotAsync(selection.Schemas, selection.Keys).ConfigureAwait(false); return CultMesh.DecodeSnapshotDocuments(snapshot, _registry); } @@ -397,7 +394,7 @@ namespace GameCult.Mesh var resolvedOptions = options ?? new CultMeshSnapshotEndpointOptions(); Context = resolvedOptions.Context ?? CultMesh.Verse("remote", "cultmesh-snapshot-client").Context; DocumentRegistry = resolvedOptions.DocumentRegistry ?? new CultNetDocumentRegistry(); - Request = CloneSnapshotRequestOptions(resolvedOptions.Request); + Request = resolvedOptions.Request is null ? new CultMeshSnapshotRequestOptions() : resolvedOptions.Request with { }; RouteHint = resolvedOptions.RouteHint ?? new CultMeshRouteHint(CultMeshLocalityKind.Network, Endpoint); SourceId = string.IsNullOrWhiteSpace(resolvedOptions.SourceId) ? Endpoint : resolvedOptions.SourceId!; PollInterval = resolvedOptions.PollInterval; @@ -463,9 +460,10 @@ namespace GameCult.Mesh where TDocument : class { var descriptor = CultDocumentRegistry.Shared.GetRequired(); + var selection = CultMesh.ResolveDefaultSelection(schemaIds, recordKeys, descriptor); return CultMesh.FetchSnapshotDocumentsAsync( Endpoint, - CreateRequest(ResolveDefaultSchemaFilter(schemaIds, recordKeys, descriptor), recordKeys), + CreateRequest(selection.Schemas, selection.Keys), DocumentRegistry); } @@ -480,10 +478,11 @@ namespace GameCult.Mesh if (node == null) throw new ArgumentNullException(nameof(node)); var descriptor = CultDocumentRegistry.Shared.GetRequired(); + var selection = CultMesh.ResolveDefaultSelection(schemaIds, recordKeys, descriptor); var result = await SyncSnapshotAsync( node, - ResolveDefaultSchemaFilter(schemaIds, recordKeys, descriptor), - recordKeys, + selection.Schemas, + selection.Keys, flush) .ConfigureAwait(false); return CultMesh.DecodeSnapshotDocuments(result.Snapshot, DocumentRegistry); @@ -612,53 +611,13 @@ namespace GameCult.Mesh IReadOnlyList? schemaIds, IReadOnlyList? recordKeys) { - var request = CloneSnapshotRequestOptions(Request); - request.SchemaIds = schemaIds ?? request.SchemaIds; - request.RecordKeys = recordKeys ?? request.RecordKeys; + var request = Request with { Selection = CultMesh.OverlaySelection(Request.Selection, schemaIds, recordKeys) }; if (string.IsNullOrWhiteSpace(request.RudpRuntimeId)) request.RudpRuntimeId = Context.RuntimeId; if (string.IsNullOrWhiteSpace(request.MessageIdPrefix)) request.MessageIdPrefix = $"cultmesh:{Context.RuntimeId}:snapshot"; return request; } - - private static IReadOnlyList? ResolveDefaultSchemaFilter( - IReadOnlyList? schemaIds, - IReadOnlyList? recordKeys, - CultDocumentDescriptor descriptor) - { - if (schemaIds != null) - return schemaIds; - - return recordKeys is { Count: > 0 } - ? null - : new[] { descriptor.SchemaId }; - } - - private static CultMeshSnapshotRequestOptions CloneSnapshotRequestOptions(CultMeshSnapshotRequestOptions? source) - { - if (source == null) - return new CultMeshSnapshotRequestOptions(); - - return new CultMeshSnapshotRequestOptions - { - SchemaIds = source.SchemaIds, - RecordKeys = source.RecordKeys, - ShardId = source.ShardId, - ShardEpoch = source.ShardEpoch, - ResponseTimeout = source.ResponseTimeout, - ConnectTimeout = source.ConnectTimeout, - MessageIdPrefix = source.MessageIdPrefix, - Security = source.Security, - ConfigureClient = source.ConfigureClient, - CreateClient = source.CreateClient, - RudpRuntimeId = source.RudpRuntimeId, - RudpConnectionId = source.RudpConnectionId, - RudpConnectPayload = source.RudpConnectPayload, - RudpMaxFragmentBytes = source.RudpMaxFragmentBytes, - RudpResendDelayMs = source.RudpResendDelayMs - }; - } } public static partial class CultMesh @@ -733,7 +692,7 @@ namespace GameCult.Mesh if (string.IsNullOrWhiteSpace(endpoint)) throw new ArgumentException("Value must be non-empty.", nameof(endpoint)); if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Value must be non-empty.", nameof(recordKey)); - var request = CloneSnapshotFacadeRequestOptions(options?.Request); + var request = options?.Request is null ? new CultMeshSnapshotRequestOptions() : options.Request with { }; request.CreateClient = createClient; var resolvedOptions = new CultMeshSnapshotEndpointOptions { @@ -747,31 +706,6 @@ namespace GameCult.Mesh return SnapshotEndpoint(endpoint, resolvedOptions).SyncDocumentAsync(node, recordKey, flush); } - private static CultMeshSnapshotRequestOptions CloneSnapshotFacadeRequestOptions(CultMeshSnapshotRequestOptions? source) - { - if (source == null) - return new CultMeshSnapshotRequestOptions(); - - return new CultMeshSnapshotRequestOptions - { - SchemaIds = source.SchemaIds, - RecordKeys = source.RecordKeys, - ShardId = source.ShardId, - ShardEpoch = source.ShardEpoch, - ResponseTimeout = source.ResponseTimeout, - ConnectTimeout = source.ConnectTimeout, - MessageIdPrefix = source.MessageIdPrefix, - Security = source.Security, - ConfigureClient = source.ConfigureClient, - CreateClient = source.CreateClient, - RudpRuntimeId = source.RudpRuntimeId, - RudpConnectionId = source.RudpConnectionId, - RudpConnectPayload = source.RudpConnectPayload, - RudpMaxFragmentBytes = source.RudpMaxFragmentBytes, - RudpResendDelayMs = source.RudpResendDelayMs - }; - } - /// /// Fetches one scoped raw CultNet snapshot from an endpoint. /// @@ -796,6 +730,7 @@ namespace GameCult.Mesh if (string.IsNullOrWhiteSpace(endpoint)) throw new ArgumentException("Value must be non-empty.", nameof(endpoint)); var resolvedOptions = options ?? new CultMeshSnapshotRequestOptions(); + EnsureV0Compatible(resolvedOptions.Selection); var messageId = CreateSnapshotMessageId(resolvedOptions); var completion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); @@ -819,8 +754,8 @@ namespace GameCult.Mesh client.SendCultNet(new CultNetSnapshotRequestMessage { MessageId = messageId, - SchemaIds = CleanSnapshotFilter(resolvedOptions.SchemaIds), - RecordKeys = CleanSnapshotFilter(resolvedOptions.RecordKeys), + SchemaIds = resolvedOptions.Selection?.Schemas, + RecordKeys = resolvedOptions.Selection?.Keys, ShardId = string.IsNullOrWhiteSpace(resolvedOptions.ShardId) ? null : resolvedOptions.ShardId, ShardEpoch = resolvedOptions.ShardEpoch }); @@ -886,30 +821,85 @@ namespace GameCult.Mesh }; } - internal static CultMeshSnapshotRequestOptions CloneSnapshotRequestOptions( - CultMeshSnapshotRequestOptions? source) + /// + /// Refuses, loudly and typed, any selection term the v0 CultNet wire cannot carry. Mesh keeps + /// sending v0 in this cut (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22): only + /// schemas and keys reach the wire message, so fields, cites, + /// cited, limit, cursor, descending, and any non-default + /// projection are refused here instead of being silently dropped. A v1-carrying Mesh + /// transport is FU-Mesh-v1, not this cut. + /// + internal static void EnsureV0Compatible(CultNetSelection? selection) { - if (source == null) return new CultMeshSnapshotRequestOptions(); - return new CultMeshSnapshotRequestOptions + if (selection == null) + return; + // The door runs first (docs/cultnet-selection-cut.md, R-F): Mesh has no descriptor list to + // check field/role reachability against, but the declaration-independent half of the door - + // an empty or blank-only schemas/keys list, an unrecognised projection - applies here just + // as it does inside CultNetSelectionEvaluator.Select, so Keys=[]/[""] is refused rather than + // silently lowered to "every key". + CultNetSelectionValidation.ValidateShape(selection); + if (selection.Fields != null) + throw new CultNetSelectionInvalidException("fields", null, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.fields cannot reach the wire (FU-Mesh-v1)."); + if (selection.Cites != null) + throw new CultNetSelectionInvalidException("cites", null, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.cites cannot reach the wire (FU-Mesh-v1)."); + if (selection.Cited != null) + throw new CultNetSelectionInvalidException("cited", null, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.cited cannot reach the wire (FU-Mesh-v1)."); + if (selection.Limit != null) + throw new CultNetSelectionInvalidException("limit", null, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.limit cannot reach the wire (FU-Mesh-v1)."); + if (selection.Cursor != null) + throw new CultNetSelectionInvalidException("cursor", null, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.cursor cannot reach the wire (FU-Mesh-v1)."); + if (selection.Descending) + throw new CultNetSelectionInvalidException("descending", null, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.descending cannot reach the wire (FU-Mesh-v1)."); + if (selection.Projection != CultNetSelectionProjections.Header) + throw new CultNetSelectionInvalidException("projection", selection.Projection, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.projection cannot reach the wire (FU-Mesh-v1)."); + } + + /// + /// Resolves one selection for a typed convenience call: explicit + /// win; otherwise an explicit - including one that lowers to an + /// empty array - means no schema filter; otherwise the selection defaults to the document + /// type's own schema. R-R (2026-09-22): an empty is a real filter + /// (answers nothing), not "no filter", so it must not fall through to the schema default + /// either. The one owner of that rule (docs/cultnet-selection-cut.md, section 4). + /// + internal static CultNetSelection ResolveDefaultSelection( + IReadOnlyList? schemaIds, + IReadOnlyList? recordKeys, + CultDocumentDescriptor descriptor) + { + var cleanedKeys = Clean(recordKeys); + var schemas = schemaIds != null + ? Clean(schemaIds) + : (cleanedKeys != null ? null : new[] { descriptor.SchemaId }); + return new CultNetSelection { Schemas = schemas, Keys = cleanedKeys }; + } + + /// + /// Overlays explicit schema/record-key overrides onto a default selection: the caller's + /// selection replaces the default wholesale rather than merging field by field, so neither + /// side decides what a missing override means (docs/cultnet-selection-cut.md, Self's rulings + /// 2026-09-22, S2-7). No override at all keeps the default selection untouched. + /// + internal static CultNetSelection OverlaySelection( + CultNetSelection? defaults, + IReadOnlyList? schemaIds, + IReadOnlyList? recordKeys) + { + if (schemaIds == null && recordKeys == null) + return defaults ?? new CultNetSelection(); + + return new CultNetSelection { - SchemaIds = source.SchemaIds, - RecordKeys = source.RecordKeys, - ShardId = source.ShardId, - ShardEpoch = source.ShardEpoch, - ResponseTimeout = source.ResponseTimeout, - ConnectTimeout = source.ConnectTimeout, - MessageIdPrefix = source.MessageIdPrefix, - Security = source.Security, - ConfigureClient = source.ConfigureClient, - CreateClient = source.CreateClient, - RudpRuntimeId = source.RudpRuntimeId, - RudpConnectionId = source.RudpConnectionId, - RudpConnectPayload = source.RudpConnectPayload, - RudpMaxFragmentBytes = source.RudpMaxFragmentBytes, - RudpResendDelayMs = source.RudpResendDelayMs + Schemas = Clean(schemaIds), + Keys = Clean(recordKeys) }; } + // The v0-compatibility list cleaning is CultNetV0SelectionLowering's, not a second copy here + // (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22). + private static string[]? Clean(IReadOnlyList? values) => CultNetV0SelectionLowering.Lower(values); + internal static string CreateSnapshotMessageId(CultMeshSnapshotRequestOptions options) { var prefix = string.IsNullOrWhiteSpace(options.MessageIdPrefix) @@ -963,25 +953,13 @@ namespace GameCult.Mesh { throw new TimeoutException( $"Timed out waiting for CultNet snapshot response '{messageId}' from {endpoint} " + - $"for schemas [{string.Join(", ", CleanSnapshotFilter(options.SchemaIds) ?? Array.Empty())}] " + - $"and records [{string.Join(", ", CleanSnapshotFilter(options.RecordKeys) ?? Array.Empty())}]."); + $"for schemas [{string.Join(", ", options.Selection?.Schemas ?? Array.Empty())}] " + + $"and records [{string.Join(", ", options.Selection?.Keys ?? Array.Empty())}]."); } return await responseTask.ConfigureAwait(false); } - internal static string[]? CleanSnapshotFilter(IReadOnlyList? values) - { - if (values is not { Count: > 0 }) - return null; - - var filtered = values - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Distinct(StringComparer.Ordinal) - .ToArray(); - return filtered.Length == 0 ? null : filtered; - } - internal static IReadOnlyList DecodeSnapshotDocuments( CultNetSnapshotResponseRawMessage snapshot, CultNetDocumentRegistry registry) @@ -1002,9 +980,9 @@ namespace GameCult.Mesh binding != null && typeof(TDocument).IsAssignableFrom(binding.DocumentType); var canDeserializeAsSchemaAlias = - string.Equals(record.SchemaId, descriptor.SchemaId, StringComparison.Ordinal) || - (binding != null && IsSameCultDocumentSchema(binding.DocumentType, descriptor)) || - RawSnapshotPayloadMatchesSchema(record.Payload, descriptor); + CultNetSchemaAliasMatching.Matches(record.SchemaId, descriptor) || + (binding != null && CultNetSchemaAliasMatching.Matches(binding.SchemaId, descriptor)) || + CultNetDocumentRegistry.PayloadMatchesSchema(record.Payload, descriptor); if (!canDeserializeWithBinding && !canDeserializeAsSchemaAlias) continue; @@ -1029,62 +1007,5 @@ namespace GameCult.Mesh return documents; } - - private static bool RawSnapshotPayloadMatchesSchema( - byte[] payload, - CultDocumentDescriptor descriptor) - { - var schemaVersion = TryReadSchemaVersion(payload); - if (string.IsNullOrWhiteSpace(schemaVersion)) - return false; - - if (string.Equals(schemaVersion, descriptor.SchemaVersion, StringComparison.Ordinal)) - return true; - - var schemaName = InferSchemaName(schemaVersion!); - return !string.IsNullOrWhiteSpace(schemaName) && - string.Equals(schemaName, descriptor.SchemaName, StringComparison.Ordinal); - } - - private static string? TryReadSchemaVersion(byte[] payload) - { - try - { - var array = MessagePackSerializer.Deserialize(payload, CultNetSchemaMessageSerialization.Options); - if (array.Length > 0 && array[0] is string schemaVersion) - return schemaVersion; - } - catch (Exception) - { - // Fall through to map decoding; different runtimes may encode object-like payloads. - } - - try - { - var map = MessagePackSerializer.Deserialize>(payload, CultNetSchemaMessageSerialization.Options); - if (map.TryGetValue("schemaVersion", out var schemaVersion) && schemaVersion is string schemaVersionText) - return schemaVersionText; - if (map.TryGetValue("schema_version", out var snakeSchemaVersion) && snakeSchemaVersion is string snakeSchemaVersionText) - return snakeSchemaVersionText; - } - catch (Exception) - { - return null; - } - - return null; - } - - private static string? InferSchemaName(string schemaVersion) - { - var marker = schemaVersion.LastIndexOf(".v", StringComparison.Ordinal); - if (marker <= 0 || marker + 2 >= schemaVersion.Length) - return null; - - var version = schemaVersion.Substring(marker + 2); - return version.All(char.IsDigit) - ? schemaVersion.Substring(0, marker) - : null; - } } } diff --git a/src/GameCult.Networking/CultNetDatabase.cs b/src/GameCult.Networking/CultNetDatabase.cs index 996e85f5..d59cd6c7 100644 --- a/src/GameCult.Networking/CultNetDatabase.cs +++ b/src/GameCult.Networking/CultNetDatabase.cs @@ -44,7 +44,10 @@ namespace GameCult.Networking Reconciled } - internal static class CultNetSchemaAliasMatching + /// + /// The one schema-alias matcher for every runtime-side schema match (docs/cultnet-selection-cut.md, D4). + /// + public static class CultNetSchemaAliasMatching { public static bool MatchesAny(IReadOnlyList candidates, string schemaId) { @@ -94,6 +97,10 @@ namespace GameCult.Networking ? schemaId.Substring(0, marker) : null; } + + // R-M: WithBindingSchemaAlias (a second binding-alias reconciler, scoped to one descriptor) is + // deleted - CultNetDocumentRegistry.ExpandSchemaBindingAliases is the one copy every caller + // uses, CultNetDatabaseServer and CultNetDatabaseSubscriptionServer included. } /// @@ -298,6 +305,12 @@ namespace GameCult.Networking /// Gets or sets the durable store for accepted shard mutation logs. /// public ICultNetShardMutationLogStore? MutationLogStore { get; set; } + /// + /// Gets or sets the process cursor key (R-O). Omitted in production, where a fresh random key + /// per is exactly the point; a test that must mint a cursor under a + /// known key, or reuse the pre-restart key deliberately, sets this explicitly. + /// + public CultNetSelectionCursorKey? CursorKey { get; set; } } /// @@ -464,6 +477,11 @@ namespace GameCult.Networking new(StringComparer.Ordinal); private readonly Dictionary _nextLogSequences = new(StringComparer.Ordinal); private readonly Dictionary _appliedShardSequences = new(StringComparer.Ordinal); + // CultNet typed selection, section 2: "ordinal is the sequence of the commit that last wrote + // the row". Kept on every append (AppendMutationLogEntry is the one site), keyed by (schemaId, + // recordKey) so the evaluator's order and cursor never read a clock. + private readonly Dictionary<(string SchemaId, string RecordKey), long> _lastWriteSequence = + new(); private readonly Subject _changes = new(); private bool _disposed; @@ -482,10 +500,19 @@ namespace GameCult.Networking : options.Shards) .ToList(); _clientAuthorityScopes = (options.ClientAuthorityScopes ?? Array.Empty()).ToList(); + CursorKey = options.CursorKey ?? CultNetSelectionCursorKey.Random(); InitializeLogSequencesFromStore(); _cache.OnUpdate += PublishCacheUpdate; } + /// + /// This process's cursor key (R-O): random per instance unless + /// supplies one, so a page minted by this database cannot be answered by a differently-keyed one, + /// and a fresh - a real process restart, or a simulated one in tests - + /// mints under a fresh key that a prior cursor's digest does not verify against. + /// + public CultNetSelectionCursorKey CursorKey { get; } + /// /// Gets the local cache backing this database surface. /// @@ -589,7 +616,10 @@ namespace GameCult.Networking } /// - /// Creates a raw document snapshot bounded to one shard. + /// Creates a raw document snapshot bounded to one shard. Answers through the one evaluator + /// (, via ), + /// binding-alias expansion included, with the shard membership check as the row filter - + /// no loop of its own (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22, S2-3). /// public CultNetSnapshotResponseRawMessage CreateShardSnapshotResponse( CultNetShardDescriptor shard, @@ -599,33 +629,40 @@ namespace GameCult.Networking ThrowIfDisposed(); if (shard == null) throw new ArgumentNullException(nameof(shard)); - var requestedSchemaIds = filter?.SchemaIds != null - ? new HashSet(filter.SchemaIds, StringComparer.Ordinal) - : null; - var requestedRecordKeys = filter?.RecordKeys != null - ? new HashSet(filter.RecordKeys, StringComparer.Ordinal) - : null; - var documents = new List(); - foreach (var document in _cache.AllEntries) - { - var documentType = document.GetType(); - var descriptor = _cache.Registry.GetRequired(documentType); - var key = GetTrackedKey(document, documentType); - if (string.IsNullOrWhiteSpace(key.Value) || - !shard.Matches(descriptor.SchemaId, key) || - (requestedSchemaIds != null && !MatchesRequestedSchema(descriptor, requestedSchemaIds)) || - (requestedRecordKeys != null && !requestedRecordKeys.Contains(key.Value))) - { - continue; - } + var lowSchemas = CultNetV0SelectionLowering.Lower(filter?.SchemaIds); + var lowKeys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys); - documents.Add(CreateRawDocumentRecord(key, document)); + // R-R: an explicit empty schemas or keys list is v0's own "answer nothing" - it must not + // reach the v1 door, which refuses an empty selection.schemas/keys outright. + if (lowSchemas is { Length: 0 } || lowKeys is { Length: 0 }) + { + return new CultNetSnapshotResponseRawMessage + { + MessageId = string.IsNullOrWhiteSpace(messageId) ? Guid.NewGuid().ToString("N") : messageId, + Documents = Array.Empty(), + ShardId = shard.ShardId, + ShardEpoch = shard.Epoch, + ShardLogSequence = GetLatestMutationLogSequence(shard.ShardId) + }; } + var selection = new CultNetSelection + { + Schemas = lowSchemas, + Keys = lowKeys, + Projection = CultNetSelectionProjections.Document + }; + bool RowFilter(CultDocumentDescriptor descriptor, CultRecordKey key) => + !string.IsNullOrWhiteSpace(key.Value) && shard.Matches(descriptor.SchemaId, key); + + // R-G: one evaluation answers the whole shard snapshot, not a loop that re-filters and + // re-sorts the shard's row set once per 200-row page. + var page = _documents.SelectAll(_cache, selection, ordinalOf: static (_, _) => 0, asOf: 0, options: null, rowFilter: RowFilter); + return new CultNetSnapshotResponseRawMessage { MessageId = string.IsNullOrWhiteSpace(messageId) ? Guid.NewGuid().ToString("N") : messageId, - Documents = documents.ToArray(), + Documents = page.Documents ?? Array.Empty(), ShardId = shard.ShardId, ShardEpoch = shard.Epoch, ShardLogSequence = GetLatestMutationLogSequence(shard.ShardId) @@ -1254,6 +1291,41 @@ namespace GameCult.Networking ? null : wireEntry ?? ToLogEntryMessage(entry); RecordMutationLogEntry(entry, storedWireEntry); + _lastWriteSequence[(schemaId, key.Value)] = sequence; + } + + /// + /// The shard-log sequence of the commit that last wrote this row, or null when the row has + /// never been committed through this database. The order the CultNet selection evaluator + /// pages by (docs/cultnet-selection-cut.md, section 2). + /// + public long? LastWriteSequence(string schemaId, CultRecordKey key) => + _lastWriteSequence.TryGetValue((schemaId, key.Value), out var sequence) ? sequence : null; + + /// + /// The current write-sequence watermark across every shard this database holds, 0 when nothing + /// has been committed yet. Only meaningful for an answer that is not scoped to any particular + /// shard's log - a selection's own asOf must use instead + /// (S-9): the shard-log sequence is a per-shard counter (), + /// so taking the maximum across every shard's rows mixes counters that do not compare to one + /// another and can report a page as exact "as of" a sequence another, unrelated shard advanced to + /// while the page's own shard sat still. + /// + public ulong CurrentAsOf() => _lastWriteSequence.Count == 0 ? 0UL : (ulong)_lastWriteSequence.Values.Max(); + + /// + /// The write-sequence watermark of one shard's own log (S-9): the sequence of the last commit + /// that shard's log recorded, or 0 when the shard has never taken a write. This is the evaluator's + /// asOf for a v1 snapshot or subscription page whose matched rows all come from one shard + /// ('s single-shard check) - it advances only when that + /// shard's own log advances, so a cursor minted against it refuses cursor_stale exactly + /// when that shard's log has moved, never because an unrelated shard elsewhere took a write the + /// selection never touched. + /// + public ulong CurrentAsOf(string shardId) + { + if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId)); + return _nextLogSequences.TryGetValue(shardId, out var next) ? (ulong)(next - 1) : 0UL; } private void RecordMutationLogEntry( @@ -1297,10 +1369,26 @@ namespace GameCult.Networking foreach (var shard in _shards) { - var highest = _mutationLogStore.Read(shard.ShardId) - .Select(entry => entry.Sequence) - .DefaultIfEmpty(0) - .Max(); + // R-Q: the log is the durable record of every commit, replica-applied ones included, so + // the last-write sequence a restart resumes with is rebuilt from it exactly as + // AppendMutationLogEntry/the replica apply paths maintain it live - a row's ordinal for + // the selection evaluator's order and cursor must not reset to "never written" on restart. + var entries = _mutationLogStore.Read(shard.ShardId); + var highest = 0L; + foreach (var entry in entries) + { + if (entry.Sequence > highest) + { + highest = entry.Sequence; + } + + var (schemaId, recordKey) = KeyOf(entry); + if (schemaId != null && recordKey != null) + { + _lastWriteSequence[(schemaId, recordKey)] = entry.Sequence; + } + } + if (highest > 0) { _nextLogSequences[shard.ShardId] = highest + 1; @@ -1308,6 +1396,21 @@ namespace GameCult.Networking } } + private static (string? SchemaId, string? RecordKey) KeyOf(CultNetShardLogEntryMessage entry) + { + if (entry.Put?.Document != null) + { + return (entry.Put.Document.SchemaId, entry.Put.Document.RecordKey); + } + + if (entry.Delete != null) + { + return (entry.Delete.SchemaId, entry.Delete.RecordKey); + } + + return (null, null); + } + private async Task ApplyCommittedShardLogEntryAsync( CultNetShardDescriptor shard, CultNetShardLogEntryMessage entry) @@ -1376,6 +1479,11 @@ namespace GameCult.Networking document, previous), entry); + // R-Q: a replica apply is a commit path too - the last-write sequence this row now carries + // is the entry's own sequence, exactly like AppendMutationLogEntry's local-commit path sets + // it, so the selection evaluator's order and cursor agree whether a row's most recent write + // landed locally or arrived through replication. + _lastWriteSequence[(descriptor.SchemaId, key.Value)] = entry.Sequence; PublishUntyped( descriptor.DocumentType, kind, @@ -1419,6 +1527,8 @@ namespace GameCult.Networking document: null, previousDocument: previous), entry); + // R-Q: see ApplyCommittedPutAsync - a replica-applied delete is a commit too. + _lastWriteSequence[(descriptor.SchemaId, key.Value)] = entry.Sequence; PublishUntyped( descriptor.DocumentType, CultNetDatabaseChangeKind.Removed, @@ -1644,7 +1754,7 @@ namespace GameCult.Networking { try { - if (MatchesRequestedSchema(_cache.Registry.GetRequiredBySchemaId(shardSchemaId), requestedSchemaIds)) + if (CultNetSchemaAliasMatching.MatchesAny(schemaIds, _cache.Registry.GetRequiredBySchemaId(shardSchemaId))) return true; } catch (InvalidOperationException) @@ -1656,37 +1766,6 @@ namespace GameCult.Networking return false; } - private static bool MatchesRequestedSchema( - CultDocumentDescriptor descriptor, - ISet requestedSchemaIds) - { - if (requestedSchemaIds.Count == 0) - return true; - - if (requestedSchemaIds.Contains(descriptor.SchemaId)) - return true; - - if (descriptor.ToCatalogEntry().CompatibleSchemaIds.Any(requestedSchemaIds.Contains)) - return true; - - return requestedSchemaIds - .Select(InferSchemaName) - .Where(schemaName => !string.IsNullOrWhiteSpace(schemaName)) - .Any(schemaName => string.Equals(schemaName, descriptor.SchemaName, StringComparison.Ordinal)); - } - - private static string? InferSchemaName(string schemaVersion) - { - var marker = schemaVersion.LastIndexOf(".v", StringComparison.Ordinal); - if (marker <= 0 || marker + 2 >= schemaVersion.Length) - return null; - - var version = schemaVersion.Substring(marker + 2); - return version.All(char.IsDigit) - ? schemaVersion.Substring(0, marker) - : null; - } - internal static CultNetShardDescriptorMessage ToMessage(CultNetShardDescriptor shard) { return new CultNetShardDescriptorMessage diff --git a/src/GameCult.Networking/CultNetDatabaseServer.cs b/src/GameCult.Networking/CultNetDatabaseServer.cs index c2926da3..1a66dc2a 100644 --- a/src/GameCult.Networking/CultNetDatabaseServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseServer.cs @@ -18,11 +18,13 @@ namespace GameCult.Networking private readonly CultNetDatabase _database; private readonly CultNetDatabaseServerOptions _options; private readonly Func _snapshotHandler; + private readonly Func _snapshotHandlerV1; private readonly Func _putHandler; private readonly Func _deleteHandler; private readonly Func _shardCatalogHandler; private readonly Func _shardLogHandler; private readonly Func _subscribeHandler; + private readonly Func _subscribeHandlerV1; private readonly Func _unsubscribeHandler; private readonly ConcurrentDictionary _subscriptions = new(StringComparer.Ordinal); private bool _disposed; @@ -39,19 +41,23 @@ namespace GameCult.Networking _database = database ?? throw new ArgumentNullException(nameof(database)); _options = options ?? new CultNetDatabaseServerOptions(); _snapshotHandler = HandleSnapshotRequestAsync; + _snapshotHandlerV1 = HandleSnapshotRequestV1Async; _putHandler = HandlePutAsync; _deleteHandler = HandleDeleteAsync; _shardCatalogHandler = HandleShardCatalogRequestAsync; _shardLogHandler = HandleShardLogRequestAsync; _subscribeHandler = HandleSubscribeAsync; + _subscribeHandlerV1 = HandleSubscribeV1Async; _unsubscribeHandler = HandleUnsubscribeAsync; _server.OnCultNet(_snapshotHandler); + _server.OnCultNet(_snapshotHandlerV1); _server.OnCultNet(_putHandler); _server.OnCultNet(_deleteHandler); _server.OnCultNet(_shardCatalogHandler); _server.OnCultNet(_shardLogHandler); _server.OnCultNet(_subscribeHandler); + _server.OnCultNet(_subscribeHandlerV1); _server.OnCultNet(_unsubscribeHandler); } @@ -111,6 +117,30 @@ namespace GameCult.Networking request); } + /// + /// Answers a typed-selection snapshot request (R-A, docs/cultnet-selection-cut.md): the door, + /// then one evaluation over the database's rows in last-write order, through + /// . + /// + public CultNetSnapshotResponseRawV1Message CreateSelectionResponse(CultNetSnapshotRequestV1Message request) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + return _database.Documents.CreateSelectionResponse( + _database.Cache, + string.IsNullOrWhiteSpace(request.MessageId) ? Guid.NewGuid().ToString("N") : request.MessageId, + request.Selection, + ordinalOf: (schemaId, key) => _database.LastWriteSequence(schemaId, key) ?? 0, + asOf: _database.CurrentAsOf(), + // R-Q: asOf is one shard-log watermark, so a selection whose matched rows span more than + // one shard's log is refused rather than answered against a watermark that is not + // exact for all of them. + shardIdOf: (schemaId, key) => _database.ResolveShard(schemaId, key).ShardId, + cursorKey: _database.CursorKey, + // S-9: once the matched rows resolve to one shard, asOf is that shard's own watermark, + // not CurrentAsOf()'s database-wide maximum across every shard. + asOfForShard: _database.CurrentAsOf); + } + /// /// Creates a shard catalog response from the database shard map. /// @@ -193,11 +223,13 @@ namespace GameCult.Networking _disposed = true; _server.RemoveCultNetMessageListener(_snapshotHandler); + _server.RemoveCultNetMessageListener(_snapshotHandlerV1); _server.RemoveCultNetMessageListener(_putHandler); _server.RemoveCultNetMessageListener(_deleteHandler); _server.RemoveCultNetMessageListener(_shardCatalogHandler); _server.RemoveCultNetMessageListener(_shardLogHandler); _server.RemoveCultNetMessageListener(_subscribeHandler); + _server.RemoveCultNetMessageListener(_subscribeHandlerV1); _server.RemoveCultNetMessageListener(_unsubscribeHandler); foreach (var subscription in _subscriptions.Values) { @@ -213,6 +245,38 @@ namespace GameCult.Networking return Task.CompletedTask; } + private Task HandleSnapshotRequestV1Async(CultNetSnapshotRequestV1Message request, CultNetServerPeer peer) + { + try + { + peer.SendCultNet(CreateSelectionResponse(request)); + } + catch (CultNetSelectionInvalidException ex) + { + peer.SendCultNet(CultNetErrorMessage.ForSelectionInvalid(ex)); + } + catch (CultNetSelectionCursorException ex) + { + peer.SendCultNet(CultNetErrorMessage.ForCursor(ex)); + } + catch (CultNetSelectionReferenceOutsideTargetException ex) + { + peer.SendCultNet(CultNetErrorMessage.ForReferenceOutsideTarget(ex)); + } + // R-AM: an untyped fault answers like HandleShardLogRequestAsync/HandlePutAsync's own + // catch (Exception) - a CultNetErrorMessage on the wire, not silence. Before this, only + // the three typed selection exceptions were caught here; anything else propagated up into + // the dispatch backstop (CultNetRudpSchemaServer.cs), which swallowed it with no log and + // hid SM-6's ArgumentNullException regression for a whole batch. + catch (Exception ex) + { + _server.Logger.LogError($"CultNet snapshot v1 request failed: {ex.Message}"); + peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message }); + } + + return Task.CompletedTask; + } + private Task HandleShardCatalogRequestAsync(CultNetShardCatalogRequestMessage request, CultNetServerPeer peer) { peer.SendCultNet(CreateShardCatalogResponse(request)); @@ -315,6 +379,94 @@ namespace GameCult.Networking return Task.CompletedTask; } + private Task HandleSubscribeV1Async(CultNetDatabaseSubscribeV1Message message, CultNetServerPeer peer) + { + var subscriptionId = string.IsNullOrWhiteSpace(message.SubscriptionId) + ? message.MessageId + : message.SubscriptionId; + if (string.IsNullOrWhiteSpace(subscriptionId)) + { + peer.SendCultNet(new CultNetErrorMessage { Error = "Database subscription requires a subscriptionId or messageId." }); + return Task.CompletedTask; + } + + // S-11: the door runs first, unconditionally (R-F) - a selection that is invalid for reasons + // unrelated to its hop (an empty keys list, an undeclared index, ...) reports that refusal, + // not a hop refusal that has nothing to do with why it was actually rejected. + try + { + message.Selection.Validate(_database.Cache.Registry.AllDescriptors.ToArray()); + } + catch (CultNetSelectionInvalidException ex) + { + peer.SendCultNet(CultNetErrorMessage.ForSelectionInvalid(ex)); + return Task.CompletedTask; + } + + // This server delivers live changes through the single-row fast path (CreateChangeMessage / + // CultNetSelectionEvaluator.Matches), which is set-independent by construction - the same + // ceiling v0 subscriptions on this server already have. A hop-bearing selection is + // set-dependent (D6) and needs the subscription server's reconcile loop instead, so it is + // refused here, after the door, rather than reaching Matches' own InvalidOperationException + // on the first change. The refused field names whichever hop is actually set - cited, not + // always cites - so the refusal describes the selection that was actually sent. + if (message.Selection.HasHop) + { + var hopField = message.Selection.Cites != null ? "cites" : "cited"; + peer.SendCultNet(new CultNetErrorMessage + { + Error = $"selection_invalid: selection.{hopField} needs the subscription server's reconcile loop (D6); this server only fast-matches a single row.", + Code = "selection_invalid", + Details = new CultNetErrorDetails { Field = hopField } + }); + return Task.CompletedTask; + } + + var key = SubscriptionKey(peer.Peer, subscriptionId); + _subscriptions.AddOrUpdate( + key, + _ => CreateSubscription(message.Selection, subscriptionId, peer), + (_, existing) => + { + existing.Dispose(); + return CreateSubscription(message.Selection, subscriptionId, peer); + }); + + if (message.IncludeSnapshot) + { + try + { + peer.SendCultNet(CreateSelectionResponse(new CultNetSnapshotRequestV1Message + { + MessageId = message.MessageId, + Selection = message.Selection + })); + } + catch (CultNetSelectionInvalidException ex) + { + peer.SendCultNet(CultNetErrorMessage.ForSelectionInvalid(ex)); + } + catch (CultNetSelectionCursorException ex) + { + peer.SendCultNet(CultNetErrorMessage.ForCursor(ex)); + } + catch (CultNetSelectionReferenceOutsideTargetException ex) + { + peer.SendCultNet(CultNetErrorMessage.ForReferenceOutsideTarget(ex)); + } + } + else + { + peer.SendCultNet(new CultNetSnapshotResponseRawV1Message + { + MessageId = message.MessageId, + AsOf = _database.CurrentAsOf() + }); + } + + return Task.CompletedTask; + } + private Task HandleUnsubscribeAsync(CultNetDatabaseUnsubscribeMessage message, CultNetServerPeer peer) { var subscriptionId = string.IsNullOrWhiteSpace(message.SubscriptionId) @@ -333,10 +485,26 @@ namespace GameCult.Networking CultNetDatabaseSubscribeMessage request, string subscriptionId, CultNetServerPeer peer) + { + // v0 has no engine of its own (docs/cultnet-selection-cut.md, D3/D4): it lowers into a + // Selection and is matched by the one evaluator, same as a v1 subscribe. + var selection = new CultNetSelection + { + Schemas = request.SchemaIds, + Keys = request.RecordKeys, + Projection = CultNetSelectionProjections.Document + }; + return CreateSubscription(selection, subscriptionId, peer); + } + + private IDisposable CreateSubscription( + CultNetSelection selection, + string subscriptionId, + CultNetServerPeer peer) { return _database.WatchAllChanges().Subscribe(change => { - var outbound = CreateChangeMessage(change, subscriptionId, request); + var outbound = CreateChangeMessage(change, subscriptionId, selection); if (outbound != null) { peer.SendCultNet(outbound); @@ -347,21 +515,28 @@ namespace GameCult.Networking internal CultNetDatabaseChangeRawMessage? CreateChangeMessage( object change, string subscriptionId, - CultNetDatabaseSubscribeMessage request) + CultNetSelection selection) { var changeType = change.GetType(); var key = (CultRecordKey)(changeType.GetProperty("Key")?.GetValue(change) ?? new CultRecordKey(string.Empty)); var schemaId = (string?)changeType.GetProperty("SchemaId")?.GetValue(change) ?? string.Empty; var documentType = changeType.IsGenericType ? changeType.GetGenericArguments()[0] : null; var descriptor = documentType == null ? null : _database.Cache.Registry.GetRequired(documentType); - var binding = documentType == null ? null : _database.Documents.GetByDocumentType(documentType); - if (!Matches(request, schemaId, key, descriptor, binding)) + var kind = (CultNetDatabaseChangeKind)(changeType.GetProperty("Kind")?.GetValue(change) ?? CultNetDatabaseChangeKind.Updated); + var document = changeType.GetProperty("Document")?.GetValue(change); + + if (descriptor == null) + { + return null; + } + + // R-M: the one binding-alias reconciler (CultNetDocumentRegistry.ExpandSchemaBindingAliases). + var effectiveSelection = _database.Documents.ExpandSchemaBindingAliases(selection); + if (!CultNetSelectionEvaluator.Matches(descriptor, key, document, effectiveSelection)) { return null; } - var kind = (CultNetDatabaseChangeKind)(changeType.GetProperty("Kind")?.GetValue(change) ?? CultNetDatabaseChangeKind.Updated); - var document = changeType.GetProperty("Document")?.GetValue(change); if (kind == CultNetDatabaseChangeKind.Removed || document == null) { return new CultNetDatabaseChangeRawMessage @@ -379,94 +554,10 @@ namespace GameCult.Networking MessageId = Guid.NewGuid().ToString("N"), SubscriptionId = subscriptionId, ChangeKind = kind == CultNetDatabaseChangeKind.Added ? "added" : "updated", - Document = CreateRawDocumentRecord(key, document) + Document = _database.Documents.ToRawRecord(descriptor, key, document, DateTimeOffset.UtcNow.ToString("O")) }; } - private CultNetRawDocumentRecord CreateRawDocumentRecord(CultRecordKey key, object document) - { - var method = typeof(CultNetDocumentRegistry) - .GetMethods(BindingFlags.Public | BindingFlags.Instance) - .Single(candidate => candidate.Name == nameof(CultNetDocumentRegistry.CreateRawDocumentPutMessage) && - candidate.IsGenericMethodDefinition); - var documentType = document.GetType(); - var handleType = typeof(CultRecordHandle<>).MakeGenericType(documentType); - var handle = Activator.CreateInstance(handleType, new object[] { key }); - var message = method - .MakeGenericMethod(documentType) - .Invoke(_database.Documents, new[] { Guid.NewGuid().ToString("N"), handle, document, null }); - return ((CultNetDocumentPutRawMessage)message!).Document; - } - - private static bool Matches( - CultNetDatabaseSubscribeMessage request, - string schemaId, - CultRecordKey key, - CultDocumentDescriptor? descriptor, - CultNetDocumentBinding? binding) - { - var schemaMatches = MatchesSchema(request.SchemaIds, schemaId, descriptor, binding); - var keyMatches = request.RecordKeys == null || - request.RecordKeys.Length == 0 || - request.RecordKeys.Contains(key.Value, StringComparer.Ordinal); - return schemaMatches && keyMatches; - } - - private static bool MatchesSchema( - string[]? requestedSchemaIds, - string schemaId, - CultDocumentDescriptor? descriptor, - CultNetDocumentBinding? binding) - { - if (requestedSchemaIds == null || requestedSchemaIds.Length == 0) - { - return true; - } - - var requested = requestedSchemaIds.ToHashSet(StringComparer.Ordinal); - if (requested.Contains(schemaId)) - { - return true; - } - - if (descriptor == null) - { - return false; - } - - if (requested.Contains(descriptor.SchemaId) || - requested.Contains(descriptor.SchemaName) || - requested.Contains(descriptor.SchemaVersion) || - (binding != null && requested.Contains(binding.SchemaId))) - { - return true; - } - - if (descriptor.ToCatalogEntry().CompatibleSchemaIds.Any(requested.Contains)) - { - return true; - } - - return requested - .Select(InferSchemaName) - .Where(schemaName => !string.IsNullOrWhiteSpace(schemaName)) - .Any(schemaName => string.Equals(schemaName, descriptor.SchemaName, StringComparison.Ordinal)); - } - - private static string? InferSchemaName(string schemaVersion) - { - var marker = schemaVersion.LastIndexOf(".v", StringComparison.Ordinal); - if (marker <= 0 || marker + 2 >= schemaVersion.Length) - { - return null; - } - - var version = schemaVersion.Substring(marker + 2); - return version.All(char.IsDigit) - ? schemaVersion.Substring(0, marker) - : null; - } - private static string SubscriptionKey(NetPeer peer, string subscriptionId) { return $"{peer.Id}:{subscriptionId}"; diff --git a/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs b/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs index 3d284556..2ccb031c 100644 --- a/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs @@ -13,23 +13,24 @@ namespace GameCult.Networking { /// /// Publishes live database changes through any schema-v0 server transport. - /// Optional record projection runs after authorization and owns both initial - /// snapshot and live-update delivery for a peer. + /// Selection match runs after authorization and owns both initial + /// snapshot and live-update delivery for a peer. Projection (header vs. + /// document) is a value on the request's selection, not a delegate. /// public sealed class CultNetDatabaseSubscriptionServer : IDisposable { private readonly ICultNetSchemaServer _server; private readonly CultNetDatabase _database; private readonly Func _subscribe; + private readonly Func _subscribeV1; private readonly Func _unsubscribe; private readonly Func? _authorizeRequest; private readonly Func? _authorizeRecord; - private readonly Func? _projectRecord; private readonly ICultNetSchemaServerPeerLifecycle? _peerLifecycle; private readonly ConcurrentDictionary _subscriptions = new ConcurrentDictionary(SubscriptionKeyComparer.Instance); - private readonly ConcurrentDictionary _requests = - new ConcurrentDictionary(SubscriptionKeyComparer.Instance); + private readonly ConcurrentDictionary _requests = + new ConcurrentDictionary(SubscriptionKeyComparer.Instance); private readonly ConcurrentDictionary _projections = new ConcurrentDictionary(SubscriptionKeyComparer.Instance); private readonly object _lifecycleGate = new(); @@ -40,17 +41,17 @@ namespace GameCult.Networking ICultNetSchemaServer server, CultNetDatabase database, Func? authorizeRequest = null, - Func? authorizeRecord = null, - Func? projectRecord = null) + Func? authorizeRecord = null) { _server = server ?? throw new ArgumentNullException(nameof(server)); _database = database ?? throw new ArgumentNullException(nameof(database)); _authorizeRequest = authorizeRequest; _authorizeRecord = authorizeRecord; - _projectRecord = projectRecord; _subscribe = HandleSubscribeAsync; + _subscribeV1 = HandleSubscribeV1Async; _unsubscribe = HandleUnsubscribeAsync; _server.OnCultNet(_subscribe); + _server.OnCultNet(_subscribeV1); _server.OnCultNet(_unsubscribe); _peerLifecycle = server as ICultNetSchemaServerPeerLifecycle; if (_peerLifecycle != null) @@ -88,6 +89,7 @@ namespace GameCult.Networking if (_disposed) return; _disposed = true; _server.RemoveCultNetMessageListener(_subscribe); + _server.RemoveCultNetMessageListener(_subscribeV1); _server.RemoveCultNetMessageListener(_unsubscribe); if (_peerLifecycle != null) _peerLifecycle.PeerDisconnected -= HandlePeerDisconnected; @@ -110,6 +112,33 @@ namespace GameCult.Networking } private Task HandleSubscribeAsync(CultNetDatabaseSubscribeMessage request, ICultNetSchemaServerPeer peer) + { + // v0 has no engine of its own (docs/cultnet-selection-cut.md, D3/D4): it lowers into a + // Selection and is matched by the one evaluator, same as a v1 subscribe. + var selection = new CultNetSelection + { + Schemas = request.SchemaIds, + Keys = request.RecordKeys, + Projection = CultNetSelectionProjections.Document + }; + var subscriptionRequest = new SubscriptionRequest( + selection, request.MessageId, request.SubscriptionId, request.IncludeSnapshot, + request.ConsumerRuntimeId, request.BodyIds, request.SupportedBodyTransports, isV1: false); + return Subscribe(subscriptionRequest, peer); + } + + private Task HandleSubscribeV1Async(CultNetDatabaseSubscribeV1Message request, ICultNetSchemaServerPeer peer) + { + var subscriptionRequest = new SubscriptionRequest( + request.Selection, request.MessageId, request.SubscriptionId, request.IncludeSnapshot, + request.ConsumerRuntimeId, request.BodyIds, request.SupportedBodyTransports, isV1: true); + return Subscribe(subscriptionRequest, peer); + } + + // R-A: the one subscribe core both v0 (lowered) and v1 selections run through - the door + // (validate), the watch (D6: reconcile on every change for a hop-bearing selection, the fast + // single-row path otherwise), the initial snapshot in each version's own wire shape, and demand. + private Task Subscribe(SubscriptionRequest request, ICultNetSchemaServerPeer peer) { var subscriptionId = string.IsNullOrWhiteSpace(request.SubscriptionId) ? request.MessageId @@ -119,7 +148,19 @@ namespace GameCult.Networking peer.SendCultNet(new CultNetErrorMessage { Error = "Database subscription requires a subscriptionId or messageId." }); return Task.CompletedTask; } - if (_authorizeRequest?.Invoke(request, peer) == false) + + try + { + request.Selection.Validate(_database.Cache.Registry.AllDescriptors.ToArray()); + } + catch (CultNetSelectionInvalidException ex) + { + peer.SendCultNet(CultNetErrorMessage.ForSelectionInvalid(ex)); + return Task.CompletedTask; + } + + var legacyRequest = request.ToLegacyShape(subscriptionId); + if (_authorizeRequest?.Invoke(legacyRequest, peer) == false) { peer.SendCultNet(new CultNetErrorMessage { Error = "Database subscription is not authorized for this peer." }); return Task.CompletedTask; @@ -140,23 +181,29 @@ namespace GameCult.Networking var snapshot = CreateProjectedSnapshot(request, peer); foreach (var entry in snapshot.BySourceRecordKey) projection.DeliveredBySourceRecordKey[entry.Key] = entry.Value; - peer.SendCultNet(new CultNetSnapshotResponseRawMessage - { - MessageId = request.MessageId, - Documents = snapshot.Documents - }); + SendSnapshot(peer, request, snapshot); } else { - peer.SendCultNet(new CultNetSnapshotResponseRawMessage - { - MessageId = request.MessageId, - Documents = Array.Empty() - }); + SendSnapshot(peer, request, ProjectedSnapshot.Empty); } PublishDemand(request, key, active: true); projection.DemandActive = true; } + catch (CultNetSelectionInvalidException ex) + { + // R-N: a hop-bearing selection re-validates inside CreateProjectedSnapshot's + // EvaluateAll, so a selection that passed the door check above can still refuse here. + Withdraw(key, sendRemovals: false, forgetRequest: true); + peer.SendCultNet(CultNetErrorMessage.ForSelectionInvalid(ex)); + return Task.CompletedTask; + } + catch (CultNetSelectionReferenceOutsideTargetException ex) + { + Withdraw(key, sendRemovals: false, forgetRequest: true); + peer.SendCultNet(CultNetErrorMessage.ForReferenceOutsideTarget(ex)); + return Task.CompletedTask; + } catch { Withdraw(key, sendRemovals: false, forgetRequest: true); @@ -166,6 +213,30 @@ namespace GameCult.Networking return Task.CompletedTask; } + private static void SendSnapshot(ICultNetSchemaServerPeer peer, SubscriptionRequest request, ProjectedSnapshot snapshot) + { + if (request.IsV1) + { + var wantDocument = request.Selection.Projection == CultNetSelectionProjections.Document; + peer.SendCultNet(new CultNetSnapshotResponseRawV1Message + { + MessageId = request.MessageId, + Matched = (uint)snapshot.Documents.Length, + Documents = wantDocument ? snapshot.Documents : null, + Headers = wantDocument ? null : snapshot.Documents.Select(CultNetRawDocumentHeader.FromRecord).ToArray(), + Edges = request.Selection.HasHop ? snapshot.Edges : null + }); + } + else + { + peer.SendCultNet(new CultNetSnapshotResponseRawMessage + { + MessageId = request.MessageId, + Documents = snapshot.Documents + }); + } + } + private Task HandleUnsubscribeAsync(CultNetDatabaseUnsubscribeMessage request, ICultNetSchemaServerPeer peer) { var subscriptionId = string.IsNullOrWhiteSpace(request.SubscriptionId) @@ -181,18 +252,18 @@ namespace GameCult.Networking } private void PublishDemand( - CultNetDatabaseSubscribeMessage request, + SubscriptionRequest request, SubscriptionKey key, bool active) { DemandChanged?.Invoke(new CultNetDatabaseSubscriptionDemand( string.IsNullOrWhiteSpace(request.ConsumerRuntimeId) ? key.Id : request.ConsumerRuntimeId!, key.Id, - (request.RecordKeys ?? Array.Empty()) + (request.Selection.Keys ?? Array.Empty()) .Where(value => !string.IsNullOrWhiteSpace(value)) .Distinct(StringComparer.Ordinal) .ToArray(), - (request.SchemaIds ?? Array.Empty()) + (request.Selection.Schemas ?? Array.Empty()) .Where(value => !string.IsNullOrWhiteSpace(value)) .Distinct(StringComparer.Ordinal) .ToArray(), @@ -216,46 +287,57 @@ namespace GameCult.Networking } private IDisposable Watch( - CultNetDatabaseSubscribeMessage request, + SubscriptionRequest request, string subscriptionId, ICultNetSchemaServerPeer peer, SubscriptionKey key) { + var legacyRequest = request.ToLegacyShape(subscriptionId); return _database.WatchAllChanges().Subscribe(change => { lock (_lifecycleGate) { if (_disposed || !_requests.ContainsKey(key) || !_projections.TryGetValue(key, out var projection)) return; - if (_authorizeRequest?.Invoke(request, peer) == false) + if (_authorizeRequest?.Invoke(legacyRequest, peer) == false) + { + Reconcile(key, request); + return; + } + + // D6: cites/cited are set-dependent - a change to row B can change whether row A + // matches - so a hop-bearing selection runs the full diff-against-delivered + // reconcile on every change instead of the single-row fast path. + if (request.Selection.HasHop) { Reconcile(key, request); return; } var sourceRecordKey = ResolveChangeRecordKey(change); - var message = CreateAuthorizedChange(change, request, subscriptionId, peer); - var projected = message?.Document; - if (projected != null && _projectRecord != null) - projected = _projectRecord(request, peer, projected); + var matched = CreateMatchedRecord(change, request, peer); ApplyProjectedChange( projection, sourceRecordKey, - projected, + matched, peer, subscriptionId); } }); } - private void Reconcile(SubscriptionKey key, CultNetDatabaseSubscribeMessage request) + private void Reconcile(SubscriptionKey key, SubscriptionRequest request) { if (!_projections.TryGetValue(key, out var projection)) return; - var authorized = _authorizeRequest?.Invoke(request, key.Peer) != false; + var legacyRequest = request.ToLegacyShape(key.Id); + var authorized = _authorizeRequest?.Invoke(legacyRequest, key.Peer) != false; var next = authorized ? CreateProjectedSnapshot(request, key.Peer) : ProjectedSnapshot.Empty; + // With _projectRecord gone (docs/cultnet-selection-cut.md, S2-2), a projection's dictionary + // key is always the record's own RecordKey/SchemaId, so the two can never disagree here; + // the identity branch that used to catch a re-projected key is dead and deleted. foreach (var previous in projection.DeliveredBySourceRecordKey.ToArray()) { if (!next.BySourceRecordKey.TryGetValue(previous.Key, out var current)) @@ -263,13 +345,7 @@ namespace GameCult.Networking SendRemoval(key.Peer, key.Id, previous.Value); continue; } - if (!string.Equals(previous.Value.RecordKey, current.RecordKey, StringComparison.Ordinal) || - !string.Equals(previous.Value.SchemaId, current.SchemaId, StringComparison.Ordinal)) - { - SendRemoval(key.Peer, key.Id, previous.Value); - SendUpsert(key.Peer, key.Id, current, added: true); - } - else if (!Equivalent(previous.Value, current)) + if (!Equivalent(previous.Value, current)) { SendUpsert(key.Peer, key.Id, current, added: false); } @@ -292,33 +368,40 @@ namespace GameCult.Networking } private ProjectedSnapshot CreateProjectedSnapshot( - CultNetDatabaseSubscribeMessage request, + SubscriptionRequest request, ICultNetSchemaServerPeer peer) { - var snapshot = _database.Documents.CreateRawSnapshotResponse( + var legacyRequest = request.ToLegacyShape(request.SubscriptionId); + // Both v0 (lowered) and v1 selections evaluate through SelectAll: one evaluation over the + // whole matching set (R-G), documents always (this projects the wire record regardless of + // the selection's own header/document projection - SendSnapshot applies that afterward). + var documentSelection = new CultNetSelection + { + Schemas = request.Selection.Schemas, + Keys = request.Selection.Keys, + Fields = request.Selection.Fields, + Cites = request.Selection.Cites, + Cited = request.Selection.Cited, + Projection = CultNetSelectionProjections.Document + }; + var page = _database.Documents.SelectAll( _database.Cache, - request.MessageId, - new CultNetSnapshotRequestMessage - { - MessageId = request.MessageId, - SchemaIds = request.SchemaIds, - RecordKeys = request.RecordKeys - }); + documentSelection, + ordinalOf: (schemaId, key) => _database.LastWriteSequence(schemaId, key) ?? 0, + asOf: _database.CurrentAsOf(), + options: null); + // The snapshot is one row per record key by construction, so the duplicate-key throw this + // used to guard can no longer fire (docs/cultnet-selection-cut.md, S2-2); deleted rather + // than tested around. var bySourceRecordKey = new Dictionary(StringComparer.Ordinal); - var projectedRecordKeys = new HashSet(StringComparer.Ordinal); - foreach (var source in snapshot.Documents) + foreach (var source in page.Documents ?? Array.Empty()) { var sourceRecordKey = source.RecordKey; - if (_authorizeRecord?.Invoke(request, peer, sourceRecordKey, source.SchemaId) == false) + if (_authorizeRecord?.Invoke(legacyRequest, peer, sourceRecordKey, source.SchemaId) == false) continue; - var projected = _projectRecord == null ? source : _projectRecord(request, peer, source); - if (projected == null) continue; - if (!projectedRecordKeys.Add(projected.RecordKey)) - throw new InvalidOperationException( - $"Database subscription projection produced duplicate record key '{projected.RecordKey}'."); - bySourceRecordKey[sourceRecordKey] = projected; + bySourceRecordKey[sourceRecordKey] = source; } - return new ProjectedSnapshot(bySourceRecordKey); + return new ProjectedSnapshot(bySourceRecordKey, page.Edges ?? Array.Empty()); } private void ApplyProjectedChange( @@ -344,14 +427,8 @@ namespace GameCult.Networking SendUpsert(peer, subscriptionId, current, added: true); return; } - if (!string.Equals(previous.RecordKey, current.RecordKey, StringComparison.Ordinal) || - !string.Equals(previous.SchemaId, current.SchemaId, StringComparison.Ordinal)) - { - SendRemoval(peer, subscriptionId, previous); - projection.DeliveredBySourceRecordKey[sourceRecordKey] = current; - SendUpsert(peer, subscriptionId, current, added: true); - return; - } + // Same S2-2 fact as Reconcile: previous/current are looked up by the record's own key, so + // they can never disagree in RecordKey/SchemaId here either. if (!Equivalent(previous, current)) { projection.DeliveredBySourceRecordKey[sourceRecordKey] = current; @@ -421,99 +498,102 @@ namespace GameCult.Networking _requests.TryRemove(key, out _); } - private CultNetDatabaseChangeRawMessage? CreateAuthorizedChange( + // One evaluator (docs/cultnet-selection-cut.md, D3/D4): a v0 subscribe request lowers into a + // Selection with no engine of its own, matched and projected exactly as a v1 request would be. + // Only called for a non-hop selection (Watch routes a hop-bearing one to Reconcile instead, per + // D6) - CultNetSelectionEvaluator.Matches refuses a hop-bearing selection on this fast path. + // Record-level authorization (_authorizeRecord) is not selection and stays layered on top. + private CultNetRawDocumentRecord? CreateMatchedRecord( object change, - CultNetDatabaseSubscribeMessage request, - string subscriptionId, + SubscriptionRequest request, ICultNetSchemaServerPeer peer) - { - return CreateChangeCore(change, request, subscriptionId, (recordKey, schemaId) => - _authorizeRecord?.Invoke(request, peer, recordKey, schemaId) != false); - } - - private CultNetDatabaseChangeRawMessage? CreateChange( - object change, - CultNetDatabaseSubscribeMessage request, - string subscriptionId) - { - return CreateChangeCore(change, request, subscriptionId, (_, _) => true); - } - - private CultNetDatabaseChangeRawMessage? CreateChangeCore( - object change, - CultNetDatabaseSubscribeMessage request, - string subscriptionId, - Func authorizeRecord) { var changeType = change.GetType(); var key = (CultRecordKey)(changeType.GetProperty("Key")?.GetValue(change) ?? new CultRecordKey("")); - if (request.RecordKeys is { Length: > 0 } && !request.RecordKeys.Contains(key.Value, StringComparer.Ordinal)) - return null; - var kind = (CultNetDatabaseChangeKind)(changeType.GetProperty("Kind")?.GetValue(change) ?? CultNetDatabaseChangeKind.Updated); var document = changeType.GetProperty("Document")?.GetValue(change); - if (kind == CultNetDatabaseChangeKind.Removed || document == null) + var forDescriptor = document ?? changeType.GetProperty("PreviousDocument")?.GetValue(change); + if (forDescriptor == null) + return null; + + var descriptor = _database.Cache.Registry.GetRequired(forDescriptor.GetType()); + var selection = new CultNetSelection { - var previous = changeType.GetProperty("PreviousDocument")?.GetValue(change); - var schemaId = ResolveWireSchemaId(previous, (string?)changeType.GetProperty("SchemaId")?.GetValue(change) ?? ""); - if (!MatchesRequestedSchema(request.SchemaIds, previous, schemaId)) - return null; - if (!authorizeRecord(key.Value, schemaId)) - return null; - return new CultNetDatabaseChangeRawMessage - { - MessageId = Guid.NewGuid().ToString("N"), - SubscriptionId = subscriptionId, - ChangeKind = "removed", - RecordKey = key.Value, - SchemaId = schemaId - }; + Schemas = request.Selection.Schemas, + Keys = request.Selection.Keys, + Fields = request.Selection.Fields, + Projection = CultNetSelectionProjections.Document + }; + // R-M: the one binding-alias reconciler (CultNetDocumentRegistry.ExpandSchemaBindingAliases). + var effectiveSelection = _database.Documents.ExpandSchemaBindingAliases(selection); + if (!CultNetSelectionEvaluator.Matches(descriptor, key, document, effectiveSelection)) + return null; + var legacyRequest = request.ToLegacyShape(request.SubscriptionId); + // R-P: one id reaches the authorizer, and it is the wire id, on every path. The snapshot + // path (CreateProjectedSnapshot, below) authorizes against the raw record's own SchemaId, + // which is the wire id ToRawRecord/binding emit; this live fast path used to pass + // descriptor.SchemaId instead - the CLR type's own registered id, not the id a binding + // overrides to - so a row delivered by the snapshot could be refused on its first live + // change, or leak through a denylist keyed on the wire id. + if (_authorizeRecord?.Invoke(legacyRequest, peer, key.Value, _database.Documents.WireSchemaId(descriptor)) == false) + return null; + + if (kind == CultNetDatabaseChangeKind.Removed || document == null) + return null; + + return _database.Documents.ToRawRecord(descriptor, key, document, DateTimeOffset.UtcNow.ToString("O")); + } + + // R-A: the one internal shape both a lowered v0 subscribe and a v1 subscribe carry through + // Subscribe/Watch/Reconcile/CreateProjectedSnapshot/CreateMatchedRecord/PublishDemand. The + // authorize/authorizeRecord delegates keep their existing public constructor contract (typed + // against the v0 message) rather than becoming a breaking API change in this fix batch; + // ToLegacyShape carries every field those delegates read (schemas/keys/consumer/body demand) - + // fields/cites/cited reach neither delegate under v0 today either, so nothing regresses. + private sealed class SubscriptionRequest + { + public SubscriptionRequest( + CultNetSelection selection, + string messageId, + string subscriptionId, + bool includeSnapshot, + string? consumerRuntimeId, + string[]? bodyIds, + string[]? supportedBodyTransports, + bool isV1) + { + Selection = selection; + MessageId = messageId; + SubscriptionId = subscriptionId; + IncludeSnapshot = includeSnapshot; + ConsumerRuntimeId = consumerRuntimeId; + BodyIds = bodyIds; + SupportedBodyTransports = supportedBodyTransports; + IsV1 = isV1; } - var raw = CreateRawRecord(key, document); - if (!MatchesRequestedSchema(request.SchemaIds, document, raw.SchemaId)) - return null; - if (!authorizeRecord(key.Value, raw.SchemaId)) - return null; - return new CultNetDatabaseChangeRawMessage + public CultNetSelection Selection { get; } + public string MessageId { get; } + public string SubscriptionId { get; } + public bool IncludeSnapshot { get; } + public string? ConsumerRuntimeId { get; } + public string[]? BodyIds { get; } + public string[]? SupportedBodyTransports { get; } + public bool IsV1 { get; } + + public CultNetDatabaseSubscribeMessage ToLegacyShape(string subscriptionId) => new() { - MessageId = Guid.NewGuid().ToString("N"), + MessageId = MessageId, SubscriptionId = subscriptionId, - ChangeKind = kind == CultNetDatabaseChangeKind.Added ? "added" : "updated", - Document = raw + SchemaIds = Selection.Schemas, + RecordKeys = Selection.Keys, + IncludeSnapshot = IncludeSnapshot, + ConsumerRuntimeId = ConsumerRuntimeId, + BodyIds = BodyIds, + SupportedBodyTransports = SupportedBodyTransports }; } - private string ResolveWireSchemaId(object? document, string fallback) - { - if (document == null) return fallback; - return _database.Documents.GetByDocumentType(document.GetType())?.SchemaId ?? fallback; - } - - private bool MatchesRequestedSchema(string[]? requestedSchemaIds, object? document, string wireSchemaId) - { - if (requestedSchemaIds is not { Length: > 0 }) return true; - if (requestedSchemaIds.Contains(wireSchemaId, StringComparer.Ordinal)) return true; - return document != null && CultNetSchemaAliasMatching.MatchesAny( - requestedSchemaIds, - _database.Cache.Registry.GetRequired(document.GetType())); - } - - private CultNetRawDocumentRecord CreateRawRecord(CultRecordKey key, object document) - { - var method = typeof(CultNetDocumentRegistry) - .GetMethods(BindingFlags.Public | BindingFlags.Instance) - .Single(candidate => candidate.Name == nameof(CultNetDocumentRegistry.CreateRawDocumentPutMessage) && - candidate.IsGenericMethodDefinition); - var documentType = document.GetType(); - var handle = Activator.CreateInstance( - typeof(CultRecordHandle<>).MakeGenericType(documentType), - new object[] { key }); - var put = method.MakeGenericMethod(documentType) - .Invoke(_database.Documents, new[] { Guid.NewGuid().ToString("N"), handle, document, null }); - return ((CultNetDocumentPutRawMessage)put!).Document; - } - private sealed class SubscriptionProjectionState { public Dictionary DeliveredBySourceRecordKey { get; } = @@ -523,15 +603,17 @@ namespace GameCult.Networking private sealed class ProjectedSnapshot { - public ProjectedSnapshot(Dictionary bySourceRecordKey) + public ProjectedSnapshot(Dictionary bySourceRecordKey, CultNetEdge[]? edges = null) { BySourceRecordKey = bySourceRecordKey; Documents = bySourceRecordKey.Values.ToArray(); + Edges = edges ?? Array.Empty(); } public static ProjectedSnapshot Empty { get; } = new(new Dictionary(StringComparer.Ordinal)); public Dictionary BySourceRecordKey { get; } + public CultNetEdge[] Edges { get; } public CultNetRawDocumentRecord[] Documents { get; } } diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs index 4a5d314e..be114002 100644 --- a/src/GameCult.Networking/CultNetDocumentRegistry.cs +++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs @@ -258,7 +258,23 @@ namespace GameCult.Networking } /// - /// Creates a raw snapshot response from the cache. + /// Creates a typed-selection snapshot request message (docs/cultnet-selection-cut.md). + /// + public CultNetSnapshotRequestV1Message CreateSnapshotRequestV1(string messageId, CultNetSelection selection) + { + if (selection == null) throw new ArgumentNullException(nameof(selection)); + return new CultNetSnapshotRequestV1Message + { + MessageId = RequireNonEmpty(messageId, nameof(messageId)), + Selection = selection + }; + } + + /// + /// Creates a raw snapshot response from the cache. v0 has no selector engine of its own + /// (docs/cultnet-selection-cut.md, D3/D4): the filter lowers into a + /// and is answered by in one evaluation (R-G), so a v0 + /// caller sees its whole matching set in one message without re-filtering the cache per page. /// public CultNetSnapshotResponseRawMessage CreateRawSnapshotResponse( CultCache cache, @@ -266,88 +282,344 @@ namespace GameCult.Networking CultNetSnapshotRequestMessage? filter = null, CultNetDocumentMessageOptions? options = null) { - var requestedSchemaIds = filter?.SchemaIds != null - ? new HashSet(filter.SchemaIds, StringComparer.Ordinal) - : null; - var requestedRecordKeys = filter?.RecordKeys != null - ? new HashSet(filter.RecordKeys, StringComparer.Ordinal) - : null; - var storedAt = ResolveStoredAt(options); + if (cache == null) throw new ArgumentNullException(nameof(cache)); + var lowSchemas = CultNetV0SelectionLowering.Lower(filter?.SchemaIds); + var lowKeys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys); - var documents = new List(); - void AddDocument(object document, string key) + // R-R: an explicit empty schemas or keys list is v0's own "answer nothing" - it must not + // reach the v1 door, which refuses an empty selection.schemas/keys outright + // (CultNetSelectionValidation.ValidateShape). v0 and v1 disagree about what an empty list + // means, so v0 decides this before the v1 evaluator ever sees the selection. + if (lowSchemas is { Length: 0 } || lowKeys is { Length: 0 }) { - var descriptor = _documents.GetRequired(document.GetType()); - var binding = GetByDocumentType(document.GetType()) ?? - new CultNetDocumentBinding( - document.GetType(), - descriptor.SchemaId, - value => CultDocumentMessagePackSerialization.SerializeUntyped(value, value.GetType()), - payload => CultDocumentMessagePackSerialization.DeserializeUntyped(document.GetType(), payload)); - - if (requestedSchemaIds != null && !MatchesRequestedSchema(descriptor, binding, requestedSchemaIds)) + return new CultNetSnapshotResponseRawMessage { - return; - } - if (requestedRecordKeys != null && !requestedRecordKeys.Contains(key)) - { - return; - } - - documents.Add(new CultNetRawDocumentRecord - { - SchemaId = binding.SchemaId, - SchemaName = descriptor.SchemaName, - SchemaVersion = descriptor.SchemaVersion, - SchemaContentHash = descriptor.ContentHash, - RecordKey = key, - StoredAt = storedAt, - PayloadEncoding = "messagepack", - Payload = binding.PayloadSerializer(document), - SourceRuntimeId = options?.SourceRuntimeId, - SourceAgentId = options?.SourceAgentId, - SourceRole = options?.SourceRole, - Tags = options?.Tags - }); + MessageId = RequireNonEmpty(messageId, nameof(messageId)), + Documents = Array.Empty() + }; } - if (requestedRecordKeys != null) + var selection = new CultNetSelection { - foreach (var key in requestedRecordKeys) - { - var document = cache.Get(new CultRecordKey(key)); - if (document != null) - AddDocument(document, key); - } + Schemas = lowSchemas, + Keys = lowKeys, + Projection = CultNetSelectionProjections.Document + }; + + // R-R: v0's pre-cut CreateRawSnapshotResponse (b3d9cf7) iterated the requested record keys + // in the order the caller sent them (a HashSet built from the array, enumerated in + // insertion order). The evaluator's own tiebreak (schema then key) only applies when the + // caller did not name keys. + Func ordinalOf; + if (lowKeys is { Length: > 0 }) + { + var order = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < lowKeys.Length; i++) + order.TryAdd(lowKeys[i], i); + ordinalOf = (_, key) => order.TryGetValue(key.Value, out var index) ? index : lowKeys.Length; } else { - foreach (var document in cache.AllEntries) - { - var handleMethod = typeof(CultCache) - .GetMethod(nameof(CultCache.TryGetHandle))! - .MakeGenericMethod(document.GetType()); - var handleObject = handleMethod.Invoke(cache, new[] { document }); - if (handleObject == null) - continue; - - var keyProperty = handleObject.GetType().GetProperty("Value"); - var handleValue = keyProperty?.GetValue(handleObject) ?? handleObject; - var recordKeyProperty = handleValue.GetType().GetProperty("Key"); - var recordKey = recordKeyProperty?.GetValue(handleValue); - var valueProperty = recordKey?.GetType().GetProperty("Value"); - var key = (string?)(valueProperty?.GetValue(recordKey)) ?? string.Empty; - AddDocument(document, key); - } + ordinalOf = static (_, _) => 0; } + var page = SelectAll(cache, selection, ordinalOf, asOf: 0, options); + return new CultNetSnapshotResponseRawMessage { MessageId = RequireNonEmpty(messageId, nameof(messageId)), - Documents = documents.ToArray() + Documents = page.Documents ?? Array.Empty() }; } + /// + /// Evaluates a typed selection against the cache and returns its page + /// (docs/cultnet-selection-cut.md, section 2/6). supplies each + /// row's ordinal (the reference: ); + /// is the snapshot the page is exact for when the selection is not shard-scoped, or the fallback + /// used only when is null or the selection matches no rows (S-9). + /// + public CultNetSnapshotResponseRawV1Message CreateSelectionResponse( + CultCache cache, + string messageId, + CultNetSelection selection, + Func ordinalOf, + ulong asOf, + CultNetDocumentMessageOptions? options = null, + Func? shardIdOf = null, + CultNetSelectionCursorKey? cursorKey = null, + Func? asOfForShard = null) + { + if (cache == null) throw new ArgumentNullException(nameof(cache)); + if (selection == null) throw new ArgumentNullException(nameof(selection)); + if (ordinalOf == null) throw new ArgumentNullException(nameof(ordinalOf)); + + var page = SelectPage(cache, selection, ordinalOf, asOf, options, shardIdOf: shardIdOf, cursorKey: cursorKey, asOfForShard: asOfForShard); + return new CultNetSnapshotResponseRawV1Message + { + MessageId = RequireNonEmpty(messageId, nameof(messageId)), + Matched = page.Matched, + AsOf = page.AsOf, + Next = page.Next, + Headers = page.Headers, + Documents = page.Documents, + Edges = page.Edges + }; + } + + /// + /// Evaluates a selection over every row the cache holds and projects the matched page to wire + /// records. The one evaluator () owns matching, order, + /// the hop and the cursor; this owns turning its result into s. + /// + internal CultNetSelectionPage SelectPage( + CultCache cache, + CultNetSelection selection, + Func ordinalOf, + ulong asOf, + CultNetDocumentMessageOptions? options, + Func? rowFilter = null, + Func? shardIdOf = null, + CultNetSelectionCursorKey? cursorKey = null, + Func? asOfForShard = null) + { + selection = ExpandSchemaBindingAliases(selection); + // R-F: CultNetSelectionEvaluator.Select validates first, every time - there is no separate + // validate flag here any more, because a public evaluation path that can skip the door is + // exactly what R-F closes. + + var stored = cache.AllStoredDocuments; + if (rowFilter != null) + stored = stored.Where(entry => rowFilter(entry.Descriptor, entry.Key)); + var rows = stored + .Select(entry => new CultNetSelectionEvaluator.Row( + entry.Descriptor, entry.Key, entry.Document, ordinalOf(entry.Descriptor.SchemaId, entry.Key), entry.StoredAt)) + .ToArray(); + var full = CultNetSelectionEvaluator.EvaluateAll(_documents, rows, selection); + // R-Q: a page's asOf is exact only when every matched row is committed through one shard's + // log - a caller that knows about shards (CultNetDatabaseServer) supplies shardIdOf; one + // that does not (the raw v0/shard-scoped rowFilter paths, which are already bounded to a + // single shard by construction) passes null and skips the check rather than pay for it. + if (shardIdOf != null) + { + var shardId = EnsureSingleShard(full.Ordered, shardIdOf); + // S-9: asOf is the watermark of the shard the selection's matched rows actually come + // from, not the database-wide maximum across every shard - a selection matching only one + // shard's rows must not claim exactness as of a sequence a different, untouched shard + // happened to advance to. A selection matching no rows has no shard to scope to, so it + // keeps the caller-supplied fallback. + if (shardId != null && asOfForShard != null) + { + asOf = asOfForShard(shardId); + } + } + + var evaluation = CultNetSelectionEvaluator.Page(full, selection, asOf, cursorKey); + var records = evaluation.Rows.Select(row => ToRawRecord(row, options)).ToArray(); + var wantDocument = selection.Projection == CultNetSelectionProjections.Document; + + return new CultNetSelectionPage + { + // R-G/C5: the selection's whole matching count, not this page's row count. + Matched = (uint)evaluation.TotalMatched, + AsOf = asOf, + Next = evaluation.NextCursor, + Documents = wantDocument ? records : null, + Headers = wantDocument ? null : records.Select(CultNetRawDocumentHeader.FromRecord).ToArray(), + Edges = selection.HasHop ? evaluation.Edges.Select(edge => ToEdge(edge, wantDocument)).ToArray() : null + }; + } + + /// + /// Refuses a selection whose matched rows come from more than one shard's mutation log (R-Q): + /// asOf is one shard-log watermark ( is a single + /// number today, not one per shard), so a page spanning two shards' logs cannot honestly claim + /// to be exact as of it. Refused door-side, the same as an unreachable index or an empty + /// keys list (R-F), rather than answered with an asOf that is not really defined - + /// reusing /selection_invalid (R-N) rather + /// than adding a second refusal shape for what is, from the wire's point of view, the same + /// "this selection cannot be answered as asked" refusal a bad field or an empty list gets. + /// + /// Returns the one shard every matched row belongs to, or null when the selection matched no rows. + private static string? EnsureSingleShard( + IReadOnlyList rows, + Func shardIdOf) + { + string? shardId = null; + foreach (var row in rows) + { + var rowShardId = shardIdOf(row.Descriptor.SchemaId, row.Key); + if (shardId == null) + { + shardId = rowShardId; + } + else if (!string.Equals(shardId, rowShardId, StringComparison.Ordinal)) + { + throw new CultNetSelectionInvalidException( + "asOf", + null, + $"The selection matches rows from more than one shard's log (at least '{shardId}' and '{rowShardId}'); asOf is not defined for a shard-spanning page."); + } + } + + return shardId; + } + + /// + /// Evaluates a selection over every row the cache holds and projects every matched row - no + /// cursor, no 200-row cap. The v0 lowering and the shard-bounded snapshot both answer their + /// whole matching set in one message, so they call this once instead of paging through + /// in a loop that re-filters and re-sorts the same row set per page + /// (R-G: "answer v0 and shard paging from one evaluation"). + /// + internal CultNetSelectionPage SelectAll( + CultCache cache, + CultNetSelection selection, + Func ordinalOf, + ulong asOf, + CultNetDocumentMessageOptions? options, + Func? rowFilter = null) + { + selection = ExpandSchemaBindingAliases(selection); + + var stored = cache.AllStoredDocuments; + if (rowFilter != null) + stored = stored.Where(entry => rowFilter(entry.Descriptor, entry.Key)); + var rows = stored + .Select(entry => new CultNetSelectionEvaluator.Row( + entry.Descriptor, entry.Key, entry.Document, ordinalOf(entry.Descriptor.SchemaId, entry.Key), entry.StoredAt)) + .ToArray(); + + var full = CultNetSelectionEvaluator.EvaluateAll(_documents, rows, selection); + var records = full.Ordered.Select(row => ToRawRecord(row, options)).ToArray(); + var wantDocument = selection.Projection == CultNetSelectionProjections.Document; + var edges = selection.HasHop + ? CultNetSelectionEvaluator.EdgesFor(full.Ordered, full).Select(edge => ToEdge(edge, wantDocument)).ToArray() + : null; + + return new CultNetSelectionPage + { + Matched = (uint)full.Ordered.Count, + AsOf = asOf, + Next = null, + Documents = wantDocument ? records : null, + Headers = wantDocument ? null : records.Select(CultNetRawDocumentHeader.FromRecord).ToArray(), + Edges = edges + }; + } + + // A CultDocumentDescriptor knows nothing of CultNetDocumentBinding's optional schema-id + // override; a caller filtering by that wire id (rather than the descriptor's own) needs the + // descriptor's own id present in the selection too, since CultNetSelectionEvaluator only ever + // reads descriptors. R-M: this is the one copy - both CultNetDatabaseServer's and + // CultNetDatabaseSubscriptionServer's single-change fast path call this too instead of carrying + // their own CultNetSchemaAliasMatching.WithBindingSchemaAlias (deleted). + internal CultNetSelection ExpandSchemaBindingAliases(CultNetSelection selection) + { + if (selection.Schemas is not { Length: > 0 } || _bindingsByType.Count == 0) + return selection; + + List? expanded = null; + foreach (var binding in _bindingsByType.Values) + { + if (!selection.Schemas.Contains(binding.SchemaId, StringComparer.Ordinal)) + continue; + var descriptor = _documents.GetRequired(binding.DocumentType); + if (selection.Schemas.Contains(descriptor.SchemaId, StringComparer.Ordinal)) + continue; + expanded ??= new List(selection.Schemas); + if (!expanded.Contains(descriptor.SchemaId, StringComparer.Ordinal)) + expanded.Add(descriptor.SchemaId); + } + + if (expanded == null) + return selection; + + return new CultNetSelection + { + Schemas = expanded.ToArray(), + Keys = selection.Keys, + Fields = selection.Fields, + Cites = selection.Cites, + Cited = selection.Cited, + Projection = selection.Projection, + Descending = selection.Descending, + Limit = selection.Limit, + Cursor = selection.Cursor + }; + } + + /// + /// Builds the wire record for one evaluated row. The shared path under snapshot and change + /// (docs/cultnet-selection-cut.md, section 8's "shared paths"). + /// + internal CultNetRawDocumentRecord ToRawRecord(CultNetSelectionEvaluator.Row row, CultNetDocumentMessageOptions? options = null) => + ToRawRecord(row.Descriptor, row.Key, row.Document, string.IsNullOrEmpty(row.StoredAt) ? ResolveStoredAt(options) : row.StoredAt, options); + + internal CultNetRawDocumentRecord ToRawRecord( + CultDocumentDescriptor descriptor, + CultRecordKey key, + object document, + string storedAt, + CultNetDocumentMessageOptions? options = null) + { + var binding = GetByDocumentType(descriptor.DocumentType) ?? + new CultNetDocumentBinding( + descriptor.DocumentType, + descriptor.SchemaId, + value => CultDocumentMessagePackSerialization.SerializeUntyped(value, value.GetType(), _documents), + payload => CultDocumentMessagePackSerialization.DeserializeUntyped(descriptor.DocumentType, payload, _documents)); + + return new CultNetRawDocumentRecord + { + SchemaId = binding.SchemaId, + SchemaName = descriptor.SchemaName, + SchemaVersion = descriptor.SchemaVersion, + SchemaContentHash = descriptor.ContentHash, + RecordKey = key.Value, + StoredAt = storedAt, + PayloadEncoding = "messagepack", + Payload = binding.PayloadSerializer(document), + SourceRuntimeId = options?.SourceRuntimeId, + SourceAgentId = options?.SourceAgentId, + SourceRole = options?.SourceRole, + Tags = options?.Tags + }; + } + + private CultNetEdge ToEdge(CultNetSelectionEvaluator.EdgeMatch edge, bool wantDocument) + { + // R-E: ToRawRecord and ToEdge emit the same id for the same row - the wire schema id a + // binding overrides to (WireSchemaId), never the descriptor's own id when the two differ. + var wire = new CultNetEdge + { + From = new CultNetRecordRef { SchemaId = WireSchemaId(edge.From.Descriptor), RecordKey = edge.From.Key.Value }, + Role = edge.Role, + To = new CultNetRecordRef { SchemaId = WireSchemaId(edge.To.Descriptor), RecordKey = edge.To.Key.Value } + }; + if (wantDocument && edge.Payload != null) + { + wire.PayloadEncoding = "messagepack"; + // A dictionary reference's payload is typed by the member's declared value type (D11) - + // routinely a plain value like float, not a registered CultDocument - so this serializes + // it directly rather than through CultDocumentMessagePackSerialization.SerializeUntyped, + // which requires the type to be a registered document and throws otherwise. + wire.Payload = MessagePackSerializer.Serialize(edge.Payload.GetType(), edge.Payload, CultNetSchemaMessageSerialization.Options); + } + + return wire; + } + + /// + /// The wire schema id this registry answers with for a descriptor - its binding's override when + /// one is registered, else the descriptor's own id (R-E: the same id + /// emits). + /// Internal rather than private (R-P): one id reaches the authorizer, and it is this one, on + /// every path - CultNetDatabaseSubscriptionServer's live path needs it too. + /// + internal string WireSchemaId(CultDocumentDescriptor descriptor) => + GetByDocumentType(descriptor.DocumentType)?.SchemaId ?? descriptor.SchemaId; + /// /// Applies a raw document put message to a cache. /// @@ -471,7 +743,19 @@ namespace GameCult.Networking string.Equals(candidate.SchemaName, schemaName, StringComparison.Ordinal)); } - private CultDocumentDescriptor? TryResolveDescriptorByPayloadSchema(byte[] payload) + /// + /// Resolves the descriptor this registry's own bindings (or every registered descriptor, when + /// none are bound) associate with a raw payload's embedded schema version, when exactly one + /// registered type owns that schema string. Used by + /// to recover a document's type from a foreign/runtime-generated schema id. Ambiguous - and + /// so not the right tool - when the caller already knows the target descriptor and two + /// registered types alias the same schema id: match 's + /// result against that descriptor with directly + /// instead (docs/cultnet-selection-cut.md, section 4/S2-5; this is what CultMesh's + /// foreign-schema decode does). Trusts the payload's own schema stamp; it does not re-run a + /// selection or re-check authorization. + /// + public CultDocumentDescriptor? TryResolveDescriptorByPayloadSchema(byte[] payload) { var schemaVersion = TryReadSchemaVersion(payload); if (string.IsNullOrWhiteSpace(schemaVersion)) @@ -502,28 +786,28 @@ namespace GameCult.Networking .Select(group => group.First()); } - private static bool MatchesRequestedSchema( - CultDocumentDescriptor descriptor, - CultNetDocumentBinding binding, - ISet requestedSchemaIds) - { - if (requestedSchemaIds.Count == 0) - return true; + /// + /// True when a raw payload's embedded schemaVersion stamp (when it carries one) aliases + /// the given descriptor - the "foreign schema id" fallback shared by CultMesh's peer-snapshot + /// read and its typed-document decode (docs/cultnet-selection-cut.md, R-L: one registry method + /// taking the descriptor, replacing two call sites that each read the payload and alias-matched + /// it by hand). stays internal: a caller with an already-known + /// descriptor never needs the raw schema-version string itself, only this answer. + /// + public static bool PayloadMatchesSchema(byte[] payload, CultDocumentDescriptor descriptor) => + TryReadSchemaVersion(payload) is { } schemaVersion && + CultNetSchemaAliasMatching.Matches(schemaVersion, descriptor); - if (requestedSchemaIds.Contains(descriptor.SchemaId) || - requestedSchemaIds.Contains(binding.SchemaId)) - return true; - - if (descriptor.ToCatalogEntry().CompatibleSchemaIds.Any(requestedSchemaIds.Contains)) - return true; - - return requestedSchemaIds - .Select(InferSchemaName) - .Where(schemaName => !string.IsNullOrWhiteSpace(schemaName)) - .Any(schemaName => string.Equals(schemaName, descriptor.SchemaName, StringComparison.Ordinal)); - } - - private static string? TryReadSchemaVersion(byte[] payload) + /// + /// Reads a raw payload's embedded schemaVersion stamp, when it carries one - the parse + /// step of the registry's payload-schema decode rule, shared with + /// and + /// (docs/cultnet-selection-cut.md, section 4/S2-5, R-L). Internal: a caller outside this + /// assembly matching against one already-known descriptor uses ; + /// resolving "whichever registered type owns this schema string" (ambiguous when two document + /// types alias the same schema id) is 's job. + /// + internal static string? TryReadSchemaVersion(byte[] payload) { try { diff --git a/src/GameCult.Networking/CultNetRudpSchemaServer.cs b/src/GameCult.Networking/CultNetRudpSchemaServer.cs index 1abce308..41a2017d 100644 --- a/src/GameCult.Networking/CultNetRudpSchemaServer.cs +++ b/src/GameCult.Networking/CultNetRudpSchemaServer.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Threading.Tasks; +using GameCult.Logging; namespace GameCult.Networking { @@ -111,6 +112,17 @@ namespace GameCult.Networking private readonly ConcurrentDictionary _handlers = new(); private readonly ConcurrentDictionary _schemaPeerAdapters = new(); private bool _disposed; + private ILogger _logger = new NullLogger(); + + /// + /// Gets or sets the logger used by the server. R-AM: the dispatch backstop in DispatchAsync + /// logs every untyped fault through this instead of swallowing it silently. + /// + public ILogger Logger + { + get => _logger; + set => _logger = value ?? new NullLogger(); + } /// /// Creates a schema-v0 RUDP server. @@ -281,10 +293,31 @@ namespace GameCult.Networking return false; } - var result = handler.DynamicInvoke(message, new RudpCultNetSchemaServerPeer(this, delivered.Peer)); - if (result is Task task) + // R-AG: a handler is expected to catch its own typed refusals (HandleSnapshotRequestV1Async + // catches the three selection exceptions and answers cursor_invalid/selection_invalid/ + // reference_outside_target on the wire, R-AM) - this catch is the backstop for whatever a + // handler does not, so an untyped exception from a malformed/hostile message can never + // reach the poll loop and stall the server for every other peer. It still answers nothing + // on the wire (an exception this generic carries no typed refusal to report) and drops the + // one malformed dispatch, exactly as an unregistered handler already does just above - + // R-AM: but it always logs. A backstop that swallows silently is a compensator, and an + // untested, unlogged one hid the SM-6 ArgumentNullException regression for a whole batch. + try { - await task.ConfigureAwait(false); + var result = handler.DynamicInvoke(message, new RudpCultNetSchemaServerPeer(this, delivered.Peer)); + if (result is Task task) + { + await task.ConfigureAwait(false); + } + } + catch (Exception ex) + { + // DynamicInvoke wraps a synchronously-thrown handler fault in TargetInvocationException, + // whose own .Message is the generic "Exception has been thrown by the target of an + // invocation." - log the real fault, not that wrapper. + var reported = ex is System.Reflection.TargetInvocationException { InnerException: { } inner } ? inner : ex; + Logger.LogError($"CultNet RUDP schema dispatch failed for {message.GetType().Name}: {reported.Message}"); + return false; } return true; } diff --git a/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs b/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs index e712beef..d098a923 100644 --- a/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs +++ b/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs @@ -61,6 +61,9 @@ namespace GameCult.Networking CultNetSchemaVersions.DocumentPutRaw => Deserialize(payload), CultNetSchemaVersions.SnapshotRequest => Deserialize(payload), CultNetSchemaVersions.SnapshotResponseRaw => Deserialize(payload), + CultNetSchemaVersions.SnapshotRequestV1 => Deserialize(payload), + CultNetSchemaVersions.DatabaseSubscribeV1 => Deserialize(payload), + CultNetSchemaVersions.SnapshotResponseRawV1 => Deserialize(payload), CultNetSchemaVersions.DatabaseSubscribe => Deserialize(payload), CultNetSchemaVersions.DatabaseUnsubscribe => Deserialize(payload), CultNetSchemaVersions.DatabaseChangeRaw => Deserialize(payload), diff --git a/src/GameCult.Networking/CultNetSchemaMessages.cs b/src/GameCult.Networking/CultNetSchemaMessages.cs index 56370207..f343fad7 100644 --- a/src/GameCult.Networking/CultNetSchemaMessages.cs +++ b/src/GameCult.Networking/CultNetSchemaMessages.cs @@ -80,6 +80,18 @@ namespace GameCult.Networking /// public const string SnapshotResponseRaw = "cultnet.snapshot_response_raw.v0"; /// + /// typed-selection snapshot request contract identifier (docs/cultnet-selection-cut.md). + /// + public const string SnapshotRequestV1 = "cultnet.snapshot_request.v1"; + /// + /// typed-selection database subscription request contract identifier (docs/cultnet-selection-cut.md). + /// + public const string DatabaseSubscribeV1 = "cultnet.database_subscribe.v1"; + /// + /// typed-selection snapshot response contract identifier (docs/cultnet-selection-cut.md). + /// + public const string SnapshotResponseRawV1 = "cultnet.snapshot_response_raw.v1"; + /// /// schema catalog request contract identifier. /// public const string SchemaCatalogRequest = "cultnet.schema_catalog_request.v0"; @@ -497,6 +509,74 @@ namespace GameCult.Networking /// Gets or sets optional shard routing details for authority failures. /// [Key("routingHint")] public CultNetShardRoutingHint? RoutingHint { get; set; } + /// + /// Gets or sets the machine-readable refusal code (R-N), e.g. selection_invalid, + /// cursor_stale, cursor_invalid, reference_outside_target. Additive to + /// cultnet.error.v0; a v0 peer that does not know this field ignores it. + /// + [Key("code")] public string? Code { get; set; } + /// + /// Gets or sets the code-specific structured detail (R-N). Additive to cultnet.error.v0. + /// + [Key("details")] public CultNetErrorDetails? Details { get; set; } + + /// + /// Builds the wire error for a door refusal (R-N): code selection_invalid, details + /// { field, value }. + /// + public static CultNetErrorMessage ForSelectionInvalid(CultNetSelectionInvalidException ex) => + new CultNetErrorMessage + { + Error = $"selection_invalid: {ex.Message}", + Code = "selection_invalid", + Details = new CultNetErrorDetails { Field = ex.Field, Value = ex.Value } + }; + + /// + /// Builds the wire error for a cursor refusal (R-N): code cursor_stale with details + /// { asOf, current }, or code cursor_invalid with no details. + /// + public static CultNetErrorMessage ForCursor(CultNetSelectionCursorException ex) => + new CultNetErrorMessage + { + Error = $"{ex.Code}: {ex.Message}", + Code = ex.Code, + Details = ex.Code == "cursor_stale" + ? new CultNetErrorDetails { AsOf = ex.AsOf, Current = ex.Current } + : null + }; + + /// + /// Builds the wire error for an out-of-target reference refusal (R-N): code + /// reference_outside_target, no details. + /// + public static CultNetErrorMessage ForReferenceOutsideTarget(CultNetSelectionReferenceOutsideTargetException ex) => + new CultNetErrorMessage + { + // ex.Message already begins "reference_outside_target: " (the exception's own + // constructor prefixes it) - this used to prefix it a second time, so a peer received + // "reference_outside_target: reference_outside_target: ...". Rust's ForReferenceOutsideTarget + // (packages/cultnet-rs) emits the single-prefixed form; this now matches it. + Error = ex.Message, + Code = "reference_outside_target" + }; + } + + /// + /// Code-specific structured detail carried on (R-N). Fields are + /// populated only for the code that defines them; every field is otherwise omitted (nil on the wire). + /// + [MessagePackObject] + public class CultNetErrorDetails + { + /// The selection field that failed (selection_invalid). + [Key("field")] public string? Field { get; set; } + /// The offending value, when there is one string worth naming (selection_invalid). + [Key("value")] public string? Value { get; set; } + /// The cursor's own minted asOf (cursor_stale). + [Key("asOf")] public ulong? AsOf { get; set; } + /// The answering server's current asOf (cursor_stale). + [Key("current")] public ulong? Current { get; set; } } /// @@ -825,6 +905,78 @@ namespace GameCult.Networking [Key("supportedBodyTransports")] public string[]? SupportedBodyTransports { get; set; } } + /// + /// CultNet message requesting a document snapshot through a typed selection (docs/cultnet-selection-cut.md). + /// + [MessagePackObject] + public class CultNetSnapshotRequestV1Message : ICultNetSchemaMessage + { + /// Gets or sets the schema version. + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.SnapshotRequestV1; + /// Gets or sets the message id. + [Key("messageId")] public string MessageId { get; set; } = string.Empty; + /// Gets or sets the selection. + [Key("selection")] public CultNetSelection Selection { get; set; } = new CultNetSelection(); + /// Gets or sets the shard id requested for a shard-bounded snapshot. + [Key("shardId")] public string? ShardId { get; set; } + /// Gets or sets the expected shard epoch. + [Key("shardEpoch")] public long? ShardEpoch { get; set; } + } + + /// + /// Requests a live database subscription through a typed selection (docs/cultnet-selection-cut.md). + /// + [MessagePackObject] + public class CultNetDatabaseSubscribeV1Message : ICultNetSchemaMessage + { + /// Gets or sets the schema version. + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.DatabaseSubscribeV1; + /// Gets or sets the request id. + [Key("messageId")] public string MessageId { get; set; } = string.Empty; + /// Gets or sets the live subscription id. + [Key("subscriptionId")] public string SubscriptionId { get; set; } = string.Empty; + /// Gets or sets the selection. + [Key("selection")] public CultNetSelection Selection { get; set; } = new CultNetSelection(); + /// Gets or sets whether the server should send a matching snapshot before live changes. + [Key("includeSnapshot")] public bool IncludeSnapshot { get; set; } = true; + /// Gets or sets the stable consumer runtime identity used for subscription diagnostics and body-plane demand. + [Key("consumerRuntimeId")] public string? ConsumerRuntimeId { get; set; } + /// Gets or sets logical hot-body identities needed by this exact state subscription. + [Key("bodyIds")] public string[]? BodyIds { get; set; } + /// Gets or sets body transports the consumer can open, ordered by consumer preference. + [Key("supportedBodyTransports")] public string[]? SupportedBodyTransports { get; set; } + } + + /// + /// CultNet message returning a typed selection's page (docs/cultnet-selection-cut.md). + /// + [MessagePackObject] + public class CultNetSnapshotResponseRawV1Message : ICultNetSchemaMessage + { + /// Gets or sets the schema version. + [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.SnapshotResponseRawV1; + /// Gets or sets the message id. + [Key("messageId")] public string MessageId { get; set; } = string.Empty; + /// Gets or sets the number of rows on this page. + [Key("matched")] public uint Matched { get; set; } + /// Gets or sets the snapshot this page is exact as of. + [Key("asOf")] public ulong AsOf { get; set; } + /// Gets or sets the cursor for the next page, absent on the last page. + [Key("next")] public string? Next { get; set; } + /// Gets or sets the headers, present under header projection. + [Key("headers")] public CultNetRawDocumentHeader[]? Headers { get; set; } + /// Gets or sets the documents, present under document projection. + [Key("documents")] public CultNetRawDocumentRecord[]? Documents { get; set; } + /// Gets or sets the edges a hop-bearing selection traversed. + [Key("edges")] public CultNetEdge[]? Edges { get; set; } + /// Gets or sets the shard id represented by this snapshot. + [Key("shardId")] public string? ShardId { get; set; } + /// Gets or sets the shard epoch represented by this snapshot. + [Key("shardEpoch")] public long? ShardEpoch { get; set; } + /// Gets or sets the shard-log sequence represented by this snapshot. + [Key("shardLogSequence")] public long? ShardLogSequence { get; set; } + } + /// /// Cancels a live database subscription. /// diff --git a/src/GameCult.Networking/CultNetSchemaRegistry.cs b/src/GameCult.Networking/CultNetSchemaRegistry.cs index 4fc68a13..1407cbec 100644 --- a/src/GameCult.Networking/CultNetSchemaRegistry.cs +++ b/src/GameCult.Networking/CultNetSchemaRegistry.cs @@ -377,6 +377,33 @@ namespace GameCult.Networking WireContracts = new[] { CultNetWireContracts.SchemaV0 } }, new SchemaResourceSpec + { + FileName = "cultnet.selection.schema.json", + Kind = "shared_contract", + WireContracts = new[] { CultNetWireContracts.SchemaV0 } + }, + new SchemaResourceSpec + { + FileName = "cultnet.snapshot-request.v1.schema.json", + Kind = "wire_message", + SchemaVersion = CultNetSchemaVersions.SnapshotRequestV1, + WireContracts = new[] { CultNetWireContracts.SchemaV0 } + }, + new SchemaResourceSpec + { + FileName = "cultnet.database-subscribe.v1.schema.json", + Kind = "wire_message", + SchemaVersion = CultNetSchemaVersions.DatabaseSubscribeV1, + WireContracts = new[] { CultNetWireContracts.SchemaV0 } + }, + new SchemaResourceSpec + { + FileName = "cultnet.snapshot-response-raw.v1.schema.json", + Kind = "wire_message", + SchemaVersion = CultNetSchemaVersions.SnapshotResponseRawV1, + WireContracts = new[] { CultNetWireContracts.SchemaV0 } + }, + new SchemaResourceSpec { FileName = "cultnet.database-unsubscribe.schema.json", Kind = "wire_message", diff --git a/src/GameCult.Networking/CultNetSelection.cs b/src/GameCult.Networking/CultNetSelection.cs new file mode 100644 index 00000000..ed4ad9b0 --- /dev/null +++ b/src/GameCult.Networking/CultNetSelection.cs @@ -0,0 +1,456 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using GameCult.Caching; +using MessagePack; + +namespace GameCult.Networking +{ + /// + /// The four comparison and one any-of operator a field predicate may carry. Serialised on the wire + /// as the op string (), never as a union: the C# + /// enum exists for evaluator and validation ergonomics only. + /// + public enum CultNetSelectionOperator + { + /// Matches when the row's declared value is one of values. + AnyOf, + /// Matches when the row's declared numeric value is less than number. + Lt, + /// Matches when the row's declared numeric value is less than or equal to number. + Le, + /// Matches when the row's declared numeric value is greater than or equal to number. + Ge, + /// Matches when the row's declared numeric value is greater than number. + Gt + } + + /// Converts to and from its wire spelling. + public static class CultNetSelectionOperators + { + /// Returns the wire spelling of an operator. + public static string ToWireString(this CultNetSelectionOperator op) => op switch + { + CultNetSelectionOperator.AnyOf => "any_of", + CultNetSelectionOperator.Lt => "lt", + CultNetSelectionOperator.Le => "le", + CultNetSelectionOperator.Ge => "ge", + CultNetSelectionOperator.Gt => "gt", + _ => throw new ArgumentOutOfRangeException(nameof(op)) + }; + + /// Parses a wire operator string; returns false for anything else. + public static bool TryParse(string? wire, out CultNetSelectionOperator op) + { + switch (wire) + { + case "any_of": op = CultNetSelectionOperator.AnyOf; return true; + case "lt": op = CultNetSelectionOperator.Lt; return true; + case "le": op = CultNetSelectionOperator.Le; return true; + case "ge": op = CultNetSelectionOperator.Ge; return true; + case "gt": op = CultNetSelectionOperator.Gt; return true; + default: op = default; return false; + } + } + + /// True for the four comparison operators (everything but any_of). + public static bool IsComparison(this CultNetSelectionOperator op) => op != CultNetSelectionOperator.AnyOf; + } + + /// + /// The wire's canonical decimal form (Q-J, docs/cultnet-selection-cut.md section 2 "Numbers"): one + /// spelling per value, so the cursor digest and the cross-runtime parity vectors need no numeric + /// parser and no precision limit. A comparison number that does not match , or + /// that spells zero with a leading minus, is refused at the door rather than normalised - a + /// non-canonical spelling of an equal value never reaches the evaluator. + /// + public static class CultNetCanonicalNumber + { + /// + /// Forbids leading zeros, a trailing fractional zero or bare point, an explicit '+', exponent + /// notation, and whitespace. Does not by itself forbid "-0"; does. + /// Anchored with \z, not $: .NET's $ matches immediately before a single + /// trailing newline even without , + /// so a naive $ here would accept "5\n" as canonical (R-D/Q-J; the JSON schema's ECMA-262 + /// $ has no such exception and already refused it). + /// + public const string Pattern = @"\A-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?\z"; + + private static readonly Regex CanonicalRegex = new(Pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant); + + /// True for a canonical decimal string; false for null, "-0", or any other spelling. + public static bool IsCanonical(string? value) => + value != null && value != "-0" && CanonicalRegex.IsMatch(value); + + /// + /// Compares two canonical decimal strings by sign, then the integer part's length, then its + /// digits, then the fraction digits padded on the right to equal length - a pure string + /// comparison, never a numeric parse, so no CLR type's range limits what a comparison can hold. + /// Callers must validate both operands with first; this does not + /// re-validate. + /// + public static int Compare(string left, string right) + { + var (negativeLeft, integerLeft, fractionLeft) = Decompose(left); + var (negativeRight, integerRight, fractionRight) = Decompose(right); + + if (negativeLeft != negativeRight) + return negativeLeft ? -1 : 1; + var sign = negativeLeft ? -1 : 1; + + if (integerLeft.Length != integerRight.Length) + return sign * (integerLeft.Length < integerRight.Length ? -1 : 1); + + var integerCompare = string.CompareOrdinal(integerLeft, integerRight); + if (integerCompare != 0) + return sign * Math.Sign(integerCompare); + + var fractionLength = Math.Max(fractionLeft.Length, fractionRight.Length); + var fractionCompare = string.CompareOrdinal( + fractionLeft.PadRight(fractionLength, '0'), + fractionRight.PadRight(fractionLength, '0')); + return sign * Math.Sign(fractionCompare); + } + + private static (bool Negative, string Integer, string Fraction) Decompose(string canonical) + { + var negative = canonical.Length > 0 && canonical[0] == '-'; + var unsigned = negative ? canonical.Substring(1) : canonical; + var dot = unsigned.IndexOf('.'); + return dot < 0 + ? (negative, unsigned, string.Empty) + : (negative, unsigned.Substring(0, dot), unsigned.Substring(dot + 1)); + } + } + + /// One predicate over a single declared index alias (docs/cultnet-selection-cut.md, section 2). + [MessagePackObject] + public sealed class CultNetFieldPredicate + { + /// A declared index alias, reachable on some schema the selection can reach. + [Key("index")] public string Index { get; set; } = string.Empty; + /// "any_of", "lt", "le", "ge", or "gt". + [Key("op")] public string Op { get; set; } = string.Empty; + /// Present iff op = any_of; non-empty. + [Key("values")] public string[]? Values { get; set; } + /// Present iff op is a comparison; a canonical decimal string (Q-J, ). + [Key("number")] public string? Number { get; set; } + } + + /// Identifies one row on the wire by schema and record key. + [MessagePackObject] + public sealed class CultNetRecordRef + { + /// Gets or sets the schema id. + [Key("schemaId")] public string SchemaId { get; set; } = string.Empty; + /// Gets or sets the record key. + [Key("recordKey")] public string RecordKey { get; set; } = string.Empty; + } + + /// Selects rows whose declared reference names a target row (docs/cultnet-selection-cut.md, section 2). + [MessagePackObject] + public sealed class CultNetCitation + { + /// The row the citer must name. + [Key("target")] public CultNetRecordRef Target { get; set; } = new(); + /// The reference's declared alias; absent matches any declared reference. + [Key("role")] public string? Role { get; set; } + } + + /// Selects rows some other row does or does not name in role - the one negation. + [MessagePackObject] + public sealed class CultNetIncoming + { + /// The declared reference alias to check for an incoming edge. + [Key("role")] public string Role { get; set; } = string.Empty; + /// True to select rows with an incoming edge, false to select rows without one. + [Key("exists")] public bool Exists { get; set; } + } + + /// + /// One typed selection, carried by cultnet.snapshot_request.v1 and + /// cultnet.database_subscribe.v1. docs/cultnet-selection-cut.md, section 2. + /// + [MessagePackObject] + public sealed class CultNetSelection + { + /// Kinds: schema ids or aliases, matched by . Absent = every schema. + [Key("schemas")] public string[]? Schemas { get; set; } + /// Record-key allowlist. Absent = every key. + [Key("keys")] public string[]? Keys { get; set; } + /// Conjunction; each is any-of over one declared index alias. + [Key("fields")] public CultNetFieldPredicate[]? Fields { get; set; } + /// Rows whose declared reference names target. + [Key("cites")] public CultNetCitation? Cites { get; set; } + /// Rows some row does or does not name in role. + [Key("cited")] public CultNetIncoming? Cited { get; set; } + /// "header" or "document"; default "header". + [Key("projection")] public string Projection { get; set; } = CultNetSelectionProjections.Header; + /// Reverses the one order. + [Key("descending")] public bool Descending { get; set; } + /// Clamped 1..=200. + [Key("limit")] public uint? Limit { get; set; } + /// Opaque; minted by the answering server. + [Key("cursor")] public string? Cursor { get; set; } + + /// True when this selection follows an edge (section 2: "Edges in the answer"). + [IgnoreMember] + public bool HasHop => Cites != null || Cited != null; + } + + /// + /// Lowers a v0 schemaIds/recordKeys filter into cultnet.selection.v1's null-means- + /// every vocabulary (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22): v0's old cleaning + /// carried over as the one place that decides it. An absent list, an empty one, or one made only + /// of blank entries all lower to null; a mixed list keeps its non-blank entries, deduplicated. + /// This is a v0-compatibility rule, not a meaning or + /// carries - v1 selections are refused at the door + /// instead (S1). + /// + public static class CultNetV0SelectionLowering + { + /// + /// Lowers one v0 filter list; see the type summary. R-R (2026-09-22): a null list means + /// "no filter" and stays null; a non-null list, even one that filters down to nothing, + /// stays a real (possibly empty) array. Collapsing an explicit empty list to "no filter" was + /// wrong - the pre-cut reference (CultNetDatabase.CreateShardSnapshotResponse at + /// b3d9cf7) tests filter?.RecordKeys != null, not a length, so + /// recordKeys: [] always answered empty. The evaluator already honours that distinction + /// (CultNetSelectionEvaluator: a non-null Keys array excludes every row when it + /// is empty); only this lowering used to erase it before the selection ever reached the + /// evaluator. + /// + public static string[]? Lower(IReadOnlyList? values) + { + if (values == null) + return null; + + return values + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + } + + /// The two wire values may carry. + public static class CultNetSelectionProjections + { + /// The record without its payload. + public const string Header = "header"; + /// The full record. + public const string Document = "document"; + } + + /// One edge a hop traversed, carried on . + [MessagePackObject] + public sealed class CultNetEdge + { + /// The citing row. + [Key("from")] public CultNetRecordRef From { get; set; } = new(); + /// The declared reference alias the citer used. + [Key("role")] public string Role { get; set; } = string.Empty; + /// The cited row, its schema resolved to the leaf actually stored. + [Key("to")] public CultNetRecordRef To { get; set; } = new(); + /// "messagepack" when is present. + [Key("payloadEncoding")] public string? PayloadEncoding { get; set; } + /// The value attached to the edge (a dictionary reference's value); present under document projection only. + [Key("payload")] public byte[]? Payload { get; set; } + } + + /// + /// A header record: minus its payload and payloadEncoding. + /// + [MessagePackObject] + public sealed class CultNetRawDocumentHeader + { + [Key("schemaId")] public string SchemaId { get; set; } = string.Empty; + [Key("schemaName")] public string? SchemaName { get; set; } + [Key("schemaVersion")] public string? SchemaVersion { get; set; } + [Key("schemaContentHash")] public string? SchemaContentHash { get; set; } + [Key("recordKey")] public string RecordKey { get; set; } = string.Empty; + [Key("storedAt")] public string StoredAt { get; set; } = string.Empty; + [Key("sourceRuntimeId")] public string? SourceRuntimeId { get; set; } + [Key("sourceAgentId")] public string? SourceAgentId { get; set; } + [Key("sourceRole")] public string? SourceRole { get; set; } + [Key("tags")] public string[]? Tags { get; set; } + + /// Builds a header by dropping a raw record's payload. + public static CultNetRawDocumentHeader FromRecord(CultNetRawDocumentRecord record) => new() + { + SchemaId = record.SchemaId, + SchemaName = record.SchemaName, + SchemaVersion = record.SchemaVersion, + SchemaContentHash = record.SchemaContentHash, + RecordKey = record.RecordKey, + StoredAt = record.StoredAt, + SourceRuntimeId = record.SourceRuntimeId, + SourceAgentId = record.SourceAgentId, + SourceRole = record.SourceRole, + Tags = record.Tags + }; + } + + /// + /// The result of evaluating a selection, before it is carried by a wire message. Exactly one of + /// / is populated, by projection. + /// + public sealed class CultNetSelectionPage + { + public uint Matched { get; set; } + public ulong AsOf { get; set; } + public string? Next { get; set; } + public CultNetRawDocumentHeader[]? Headers { get; set; } + public CultNetRawDocumentRecord[]? Documents { get; set; } + public CultNetEdge[]? Edges { get; set; } + } + + /// + /// Refused typed at the door (section 2/3): a name, list, or comparison the declared shape cannot + /// support. Carried as cultnet.error.v0 { code: "selection_invalid", details: { field, value } }. + /// + public sealed class CultNetSelectionInvalidException : Exception + { + public CultNetSelectionInvalidException(string field, string? value, string message) + : base(message) + { + Field = field; + Value = value; + } + + /// The selection field that failed ("schemas", "keys", "fields[i].index", "cites.role", "cited.role", ...). + public string Field { get; } + + /// The offending value, when there is one string worth naming. + public string? Value { get; } + } + + /// + /// Validates a against the declared shape of the schemas it can + /// reach - the door refusals of docs/cultnet-selection-cut.md section 2/3 (S1). + /// + public static class CultNetSelectionValidation + { + /// + /// The declaration-independent half of the door: empty/blank schemas/keys and an + /// unrecognised projection. Runs first inside , and is also the + /// whole door for a caller with no descriptor list in hand - CultMesh's + /// EnsureV0Compatible (docs/cultnet-selection-cut.md, R-F) runs this before its own v0 + /// transport refusals, so Keys=[]/[""] is refused there too, not only in the + /// reference runtime's full evaluator path. + /// + public static void ValidateShape(CultNetSelection selection) + { + if (selection == null) throw new ArgumentNullException(nameof(selection)); + + if (selection.Schemas != null && selection.Schemas.Length == 0) + throw new CultNetSelectionInvalidException("schemas", null, "selection.schemas is present and empty; omit it to reach every schema."); + if (selection.Keys != null && selection.Keys.Length == 0) + throw new CultNetSelectionInvalidException("keys", null, "selection.keys is present and empty; omit it to reach every key."); + if (selection.Schemas != null) + { + foreach (var schema in selection.Schemas) + { + if (string.IsNullOrWhiteSpace(schema)) + throw new CultNetSelectionInvalidException("schemas", schema, "selection.schemas carries an empty or whitespace entry."); + } + } + if (selection.Keys != null) + { + foreach (var key in selection.Keys) + { + if (string.IsNullOrWhiteSpace(key)) + throw new CultNetSelectionInvalidException("keys", key, "selection.keys carries an empty or whitespace entry."); + } + } + if (selection.Projection is not (CultNetSelectionProjections.Header or CultNetSelectionProjections.Document)) + throw new CultNetSelectionInvalidException("projection", selection.Projection, $"selection.projection \"{selection.Projection}\" is neither \"header\" nor \"document\"."); + } + + public static void Validate(this CultNetSelection selection, IReadOnlyList allDescriptors) + { + if (allDescriptors == null) throw new ArgumentNullException(nameof(allDescriptors)); + + ValidateShape(selection); + + var reachable = ReachableSchemas(selection, allDescriptors); + if (selection.Fields != null) + { + for (var i = 0; i < selection.Fields.Length; i++) + { + ValidateField(selection.Fields[i], i, reachable); + } + } + + if (selection.Cites != null) + { + if (string.IsNullOrEmpty(selection.Cites.Target?.SchemaId) || string.IsNullOrEmpty(selection.Cites.Target?.RecordKey)) + throw new CultNetSelectionInvalidException("cites.target", null, "selection.cites.target requires schemaId and recordKey."); + // R-E (docs/cultnet-selection-cut.md): a cites target names a schema through the same + // alias matcher `schemas` uses. One that matches no declared schema is refused here, + // never answered with an empty page by MatchesCitation silently treating an unresolved + // target as "not found". + if (!allDescriptors.Any(descriptor => CultNetSchemaAliasMatching.Matches(selection.Cites.Target!.SchemaId, descriptor))) + throw new CultNetSelectionInvalidException("cites.target.schemaId", selection.Cites.Target!.SchemaId, $"selection.cites.target.schemaId \"{selection.Cites.Target!.SchemaId}\" does not match any declared schema."); + if (selection.Cites.Role != null && !AnySchemaDeclaresRole(allDescriptors, selection.Cites.Role)) + throw new CultNetSelectionInvalidException("cites.role", selection.Cites.Role, $"selection.cites.role \"{selection.Cites.Role}\" is not declared by any schema."); + } + + if (selection.Cited != null) + { + if (string.IsNullOrEmpty(selection.Cited.Role)) + throw new CultNetSelectionInvalidException("cited.role", null, "selection.cited.role must be non-empty."); + if (!AnySchemaDeclaresRole(allDescriptors, selection.Cited.Role)) + throw new CultNetSelectionInvalidException("cited.role", selection.Cited.Role, $"selection.cited.role \"{selection.Cited.Role}\" is not declared by any schema."); + } + } + + private static void ValidateField(CultNetFieldPredicate field, int index, IReadOnlyList reachable) + { + var prefix = $"fields[{index}]"; + if (string.IsNullOrEmpty(field.Index)) + throw new CultNetSelectionInvalidException($"{prefix}.index", null, $"{prefix}.index must be non-empty."); + if (!CultNetSelectionOperators.TryParse(field.Op, out var op)) + throw new CultNetSelectionInvalidException($"{prefix}.op", field.Op, $"{prefix}.op \"{field.Op}\" is not any_of/lt/le/ge/gt."); + + if (op == CultNetSelectionOperator.AnyOf) + { + if (field.Values == null || field.Values.Length == 0) + throw new CultNetSelectionInvalidException($"{prefix}.values", null, $"{prefix}.values must be non-empty for op any_of."); + if (field.Number != null) + throw new CultNetSelectionInvalidException($"{prefix}.number", null, $"{prefix} carries both values and number; any_of takes only values."); + } + else + { + if (field.Number == null) + throw new CultNetSelectionInvalidException($"{prefix}.number", null, $"{prefix}.number is required for op {field.Op}."); + if (!CultNetCanonicalNumber.IsCanonical(field.Number)) + throw new CultNetSelectionInvalidException($"{prefix}.number", field.Number, $"{prefix}.number \"{field.Number}\" is not a canonical decimal string (Q-J): it must match {CultNetCanonicalNumber.Pattern} and not be \"-0\"."); + if (field.Values != null) + throw new CultNetSelectionInvalidException($"{prefix}.values", null, $"{prefix} carries both values and number; a comparison takes only number."); + } + + var declaring = reachable + .Select(descriptor => descriptor.DeclaredMembers.FirstOrDefault(member => member.IndexAlias == field.Index)) + .Where(member => member.MemberName != null) + .ToArray(); + if (declaring.Length == 0) + throw new CultNetSelectionInvalidException($"{prefix}.index", field.Index, $"{prefix}.index \"{field.Index}\" is not declared by any schema this selection can reach."); + if (op.IsComparison() && declaring.Any(member => !member.IsNumeric)) + throw new CultNetSelectionInvalidException($"{prefix}.index", field.Index, $"{prefix}.index \"{field.Index}\" is declared non-numeric on a schema this selection can reach; comparisons need a numeric declaration everywhere the alias is reachable."); + } + + private static bool AnySchemaDeclaresRole(IReadOnlyList allDescriptors, string role) => + allDescriptors.Any(descriptor => descriptor.DeclaredMembers.Any(member => + member.IsReference && (member.IndexAlias ?? member.MemberName) == role)); + + /// The schemas reaches - every schema when absent. + public static IReadOnlyList ReachableSchemas( + CultNetSelection selection, IReadOnlyList allDescriptors) + { + if (selection.Schemas == null || selection.Schemas.Length == 0) + return allDescriptors; + return allDescriptors.Where(descriptor => CultNetSchemaAliasMatching.MatchesAny(selection.Schemas, descriptor)).ToArray(); + } + } +} diff --git a/src/GameCult.Networking/CultNetSelectionEvaluator.cs b/src/GameCult.Networking/CultNetSelectionEvaluator.cs new file mode 100644 index 00000000..d81c7b71 --- /dev/null +++ b/src/GameCult.Networking/CultNetSelectionEvaluator.cs @@ -0,0 +1,836 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using GameCult.Caching; + +namespace GameCult.Networking +{ + /// + /// The one evaluator for CultNet typed selections (docs/cultnet-selection-cut.md, section 6/8). + /// Owns matching, order, snapshot, hop and cursor. Reflects over nothing: every declared value it + /// reads comes through 's public read surface (section 1's cache + /// commit). Replaces the three selector engines this cut deletes from + /// CultNetDocumentRegistry/CultNetDatabaseServer/CultNetDatabaseSubscriptionServer. + /// + public static class CultNetSelectionEvaluator + { + /// The lowest and highest limit a page may carry (section 2). + public const uint LimitMin = 1; + public const uint LimitMax = 200; + + /// One row available to the evaluator: what the cache already knows about it. + public readonly struct Row + { + public Row(CultDocumentDescriptor descriptor, CultRecordKey key, object document, long ordinal, string storedAt = "") + { + Descriptor = descriptor; + Key = key; + Document = document; + Ordinal = ordinal; + StoredAt = storedAt; + } + + public CultDocumentDescriptor Descriptor { get; } + public CultRecordKey Key { get; } + public object Document { get; } + public long Ordinal { get; } + public string StoredAt { get; } + } + + /// One edge a hop traversed, resolved against the row set in hand. + public readonly struct EdgeMatch + { + public EdgeMatch(Row from, string role, Row to, object? payload) + { + From = from; + Role = role; + To = to; + Payload = payload; + } + + public Row From { get; } + public string Role { get; } + public Row To { get; } + public object? Payload { get; } + } + + /// Evaluated, ordered, paged rows plus the edges a hop traversed. + public sealed class Evaluation + { + public IReadOnlyList Rows { get; set; } = Array.Empty(); + public IReadOnlyList Edges { get; set; } = Array.Empty(); + public string? NextCursor { get; set; } + /// The selection's whole matching count, not this page's row count (R-G/C5). + public int TotalMatched { get; set; } + } + + /// + /// The single-row fast path (D6): schemas, keys and fields only. A hop-bearing selection + /// () is set-dependent and must not use this path - the + /// caller reconciles the full set instead. + /// + public static bool Matches(CultDocumentDescriptor descriptor, CultRecordKey key, object? document, CultNetSelection selection) + { + if (selection.HasHop) + throw new InvalidOperationException("A hop-bearing selection (cites/cited) is set-dependent and cannot use the single-row Matches fast path; reconcile the full row set instead."); + return MatchesSchemaKeysFields(descriptor, key, document, selection); + } + + private static bool MatchesSchemaKeysFields(CultDocumentDescriptor descriptor, CultRecordKey key, object? document, CultNetSelection selection) + { + // The door (CultNetSelectionValidation.Validate) refuses an empty schemas/keys list before + // a selection ever reaches evaluation, so a non-null array here is always non-empty; the + // evaluator does not special-case [] as "no filter" (docs/cultnet-selection-cut.md, + // Self's rulings 2026-09-22, S2-3). + if (selection.Schemas != null && !CultNetSchemaAliasMatching.MatchesAny(selection.Schemas, descriptor)) + return false; + if (selection.Keys != null && !selection.Keys.Contains(key.Value, StringComparer.Ordinal)) + return false; + if (selection.Fields == null) + return true; + // R-T(a): a removed change is evaluated without reading a document (there is none to read - + // the row is gone). It matches on the selection's keys and schemas alone; a field predicate + // cannot be tested against a value that no longer exists, so it counts as unknown rather + // than excluding the row (or throwing TryGetIndexValue(null)). + if (document == null) + return true; + + foreach (var field in selection.Fields) + { + if (!CultNetSelectionOperators.TryParse(field.Op, out var op)) + return false; + if (op == CultNetSelectionOperator.AnyOf) + { + // R-J: on a numeric alias, any_of compares the canonical rendering + // (TryGetIndexNumber), not the string getter's culture-dependent ToString() - the + // same rendering a comparison operator uses, so a numeric member's any_of and its + // comparisons agree on what the row's value spells. + var isNumeric = descriptor.DeclaredMembers.Any(member => member.IndexAlias == field.Index && member.IsNumeric); + var value = isNumeric + ? (descriptor.TryGetIndexNumber(document, field.Index, out var numeric) ? numeric : null) + : (descriptor.TryGetIndexValue(document, field.Index, out var raw) ? raw : null); + if (value == null || field.Values == null || !field.Values.Contains(value, StringComparer.Ordinal)) + return false; + } + else + { + if (!descriptor.TryGetIndexNumber(document, field.Index, out var number) || field.Number == null) + return false; + if (!CompareNumber(number, op, field.Number)) + return false; + } + } + + return true; + } + + // Q-J: both operands are canonical decimal strings by the time they reach here (the row side + // renders through RenderCanonicalNumber, the wire side is validated at the door), so the + // comparison is CultNetCanonicalNumber.Compare's pure string function - no double anywhere on + // this path (docs/cultnet-selection-cut.md section 2 "Numbers"). + private static bool CompareNumber(string left, CultNetSelectionOperator op, string right) + { + var cmp = CultNetCanonicalNumber.Compare(left, right); + return op switch + { + CultNetSelectionOperator.Lt => cmp < 0, + CultNetSelectionOperator.Le => cmp <= 0, + CultNetSelectionOperator.Ge => cmp >= 0, + CultNetSelectionOperator.Gt => cmp > 0, + _ => throw new ArgumentOutOfRangeException(nameof(op)) + }; + } + + /// One evaluation's full matching, ordered row set, with the edges its hop traversed anchored to the row that carries each one. + public sealed class FullEvaluation + { + /// Every matching row, in order, with no cursor or limit applied. + public IReadOnlyList Ordered { get; set; } = Array.Empty(); + /// Every edge the hop traversed, each paired with the page-eligible row it is reported against: the citer under cites, the cited row under cited (R-B). + public IReadOnlyList<(EdgeMatch Edge, Row Anchor)> Edges { get; set; } = Array.Empty<(EdgeMatch, Row)>(); + } + + /// + /// Evaluates a selection's full matching, ordered row set and the edges its hop traversed, with + /// no cursor or limit applied - the door (R-F), schemas/keys/fields, the hop (cites/cited, with + /// the reference_outside_target refusal) and order all run exactly once here. This is the one + /// evaluation both a single v1 page () and a v0/shard full walk page over, + /// instead of each page re-filtering and re-sorting the whole row set (R-G). + /// + public static FullEvaluation EvaluateAll( + CultDocumentRegistry registry, + IReadOnlyList allRows, + CultNetSelection selection) + { + if (registry == null) throw new ArgumentNullException(nameof(registry)); + if (allRows == null) throw new ArgumentNullException(nameof(allRows)); + if (selection == null) throw new ArgumentNullException(nameof(selection)); + + // R-F: the door is inside select. This is the one public entry point that evaluates a + // selection over a full row set, so validation cannot be skipped by a caller that forgets + // to gate it - a public entry point that answers an unvalidated selection does not exist. + selection.Validate(registry.AllDescriptors.ToArray()); + + // R-V: a row's identity is (schemaId, recordKey), never the record key alone. A bare-key + // dictionary here silently let one schema's row displace another's at the same key (last + // writer wins), so a hop resolving "the row at this key" picked whichever schema happened + // to insert last instead of the schema the reference/citation actually declares. Every key + // that appears on more than one schema's row is kept, not collapsed, so hop resolution can + // still tell them apart. + var byKey = new Dictionary>(StringComparer.Ordinal); + foreach (var row in allRows) + { + if (!byKey.TryGetValue(row.Key.Value, out var rowsAtKey)) + byKey[row.Key.Value] = rowsAtKey = new List(); + rowsAtKey.Add(row); + } + + var candidates = allRows.Where(row => MatchesSchemaKeysFields(row.Descriptor, row.Key, row.Document, selection)); + + // R-B: cites and cited are opposite hop directions, and each keeps its own edge sink so an + // edge is always anchored to the row it will be reported against - the citer under cites, + // the cited row under cited - never filtered by "From is on the page" regardless of which + // direction the hop ran. + var citedEdges = new List(); + if (selection.Cited != null) + { + var incoming = BuildIncomingIndex(registry, allRows, byKey, selection.Cited.Role, citedEdges); + var exists = selection.Cited.Exists; + candidates = candidates.Where(row => incoming.Contains((row.Descriptor.SchemaId, row.Key.Value)) == exists); + } + + var citesEdges = new List(); + if (selection.Cites != null) + { + candidates = candidates.Where(row => MatchesCitation(registry, byKey, row, selection.Cites, citesEdges)); + } + + var matched = candidates.ToArray(); + var ordered = (selection.Descending + ? matched.OrderByDescending(row => row.Ordinal).ThenByDescending(row => row.Descriptor.SchemaId, CultNetCodePointComparer.Instance).ThenByDescending(row => row.Key.Value, CultNetCodePointComparer.Instance) + : matched.OrderBy(row => row.Ordinal).ThenBy(row => row.Descriptor.SchemaId, CultNetCodePointComparer.Instance).ThenBy(row => row.Key.Value, CultNetCodePointComparer.Instance)) + .ToArray(); + + var edges = new List<(EdgeMatch, Row)>(citesEdges.Count + citedEdges.Count); + foreach (var edge in citesEdges) edges.Add((edge, edge.From)); + foreach (var edge in citedEdges) edges.Add((edge, edge.To)); + + return new FullEvaluation { Ordered = ordered, Edges = edges }; + } + + /// + /// Evaluates a selection over the full row set and returns one cursor/limit page of it + /// (docs/cultnet-selection-cut.md, section 2/6). is the + /// selection's whole matching count, not this page's length (R-G/C5). The caller projects the + /// result to wire records ( owns that). + /// + public static Evaluation Select( + CultDocumentRegistry registry, + IReadOnlyList allRows, + CultNetSelection selection, + ulong asOf, + CultNetSelectionCursorKey? cursorKey = null) + { + var full = EvaluateAll(registry, allRows, selection); + return Page(full, selection, asOf, cursorKey); + } + + /// + /// Slices one cursor/limit page out of an already-evaluated, already-ordered row set (R-G): the + /// v0/shard full walk calls this once per 200-row chunk of a single + /// result instead of re-evaluating per page. + /// + /// + /// The answering process's cursor key (R-O). Defaults to a key generated once per process the + /// first time this method runs, so a caller that never sets up its own + /// still gets a keyed digest; holds and passes its own instance so a + /// fresh database (a simulated restart in tests, a real process restart in production) mints + /// under a fresh key and a prior cursor's digest stops verifying. + /// + public static Evaluation Page(FullEvaluation full, CultNetSelection selection, ulong asOf, CultNetSelectionCursorKey? cursorKey = null) + { + if (full == null) throw new ArgumentNullException(nameof(full)); + if (selection == null) throw new ArgumentNullException(nameof(selection)); + + var key = cursorKey ?? CultNetSelectionCursorKey.ProcessDefault; + var ordered = full.Ordered; + var startIndex = 0; + if (!string.IsNullOrEmpty(selection.Cursor)) + { + var cursor = CultNetSelectionCursor.Parse(selection.Cursor!); + if (!cursor.VerifyDigest(selection, key)) + throw new CultNetSelectionCursorException("cursor_invalid", "The cursor's selection digest does not match this selection."); + if (cursor.AsOf != asOf) + throw new CultNetSelectionCursorException( + "cursor_stale", + $"The cursor was minted at asOf {cursor.AsOf}; this server answers as of {asOf}.", + asOf: cursor.AsOf, + current: asOf); + startIndex = FindCursorPosition(ordered, cursor, selection.Descending); + } + + var limit = (int)Math.Clamp(selection.Limit ?? LimitMax, LimitMin, LimitMax); + var page = ordered.Skip(startIndex).Take(limit).ToArray(); + var hasNext = startIndex + page.Length < ordered.Count; + var nextCursor = hasNext && page.Length > 0 + ? CultNetSelectionCursor.Mint(asOf, page[^1], selection, key) + : null; + + var pageEdges = selection.HasHop + ? EdgesFor(page, full) + : Array.Empty(); + + return new Evaluation { Rows = page, Edges = pageEdges, NextCursor = nextCursor, TotalMatched = ordered.Count }; + } + + // R-B: the order is deterministic - the page-row order, then (from, role, to.recordKey) in + // code-point order. Grouping by the row an edge is anchored to (LINQ's OrderBy is stable, so + // the given rows' own order survives as the primary key) and breaking ties by the edge's own + // identity gives both without a second pass. Public because a v0/shard full walk (R-G, one + // evaluation answering every page) reports edges against its whole matched set, not one + // 200-row page. + // + // R-BA (docs/cultnet-selection-cut.md, "Soul, the merge gate, fourth pass"): a + // `To.Descriptor.SchemaId` tiebreak used to sit last here and has been deleted. + // TryResolveReferenceTarget (this file) resolves purely from a fixed target type - itself + // determined only by the citer's schema and the role, both already pinned once `From` and + // `Role` tie - plus the record key, against the evaluation's own fixed `byKey`; no edge or + // caller-specific input steers it. So within one evaluation the same (citer schema, role, + // recordKey) always resolves to the same row, schema included: once `From`, `Role` and + // `To.RecordKey` already tie, `To.SchemaId` is forced equal and cannot decide an order the + // earlier four keys did not already decide. Soul verified no construction breaks this. + public static EdgeMatch[] EdgesFor(IReadOnlyList rows, FullEvaluation full) + { + // R-V: the anchor lookup is keyed by (schemaId, recordKey), not the bare key - two schemas' + // rows sharing one record key can both legitimately be on the same page now that the hop + // resolves rows by identity, and a bare-key index would let one silently steal the other's + // edges. + var pageRowIndex = new Dictionary<(string SchemaId, string RecordKey), int>(); + for (var i = 0; i < rows.Count; i++) + pageRowIndex[(rows[i].Descriptor.SchemaId, rows[i].Key.Value)] = i; + + return full.Edges + .Where(pair => pageRowIndex.ContainsKey((pair.Anchor.Descriptor.SchemaId, pair.Anchor.Key.Value))) + .OrderBy(pair => pageRowIndex[(pair.Anchor.Descriptor.SchemaId, pair.Anchor.Key.Value)]) + .ThenBy(pair => pair.Edge.From.Descriptor.SchemaId, CultNetCodePointComparer.Instance) + .ThenBy(pair => pair.Edge.From.Key.Value, CultNetCodePointComparer.Instance) + .ThenBy(pair => pair.Edge.Role, CultNetCodePointComparer.Instance) + .ThenBy(pair => pair.Edge.To.Key.Value, CultNetCodePointComparer.Instance) + .Select(pair => pair.Edge) + .ToArray(); + } + + private static int FindCursorPosition(IReadOnlyList ordered, CultNetSelectionCursor cursor, bool descending) + { + for (var i = 0; i < ordered.Count; i++) + { + var row = ordered[i]; + var comparesAfter = descending + ? ComparePosition(row, cursor) < 0 + : ComparePosition(row, cursor) > 0; + if (comparesAfter) + return i; + } + + return ordered.Count; + } + + private static int ComparePosition(Row row, CultNetSelectionCursor cursor) + { + var ordinal = row.Ordinal.CompareTo(cursor.Ordinal); + if (ordinal != 0) return ordinal; + var schema = CultNetCodePointComparer.Instance.Compare(row.Descriptor.SchemaId, cursor.SchemaId); + return schema != 0 ? schema : CultNetCodePointComparer.Instance.Compare(row.Key.Value, cursor.RecordKey); + } + + // D9: a reference's target set is every registered leaf assignable to its declared target type. + // A stored edge naming a row whose schema is outside that set refuses the selection (S18). + // S-8: targetType is never null here - D11 (CultCache's DiscoverMembers) refuses every reference + // shape whose target the cache could not infer (a bare ICultRecordRef, scalar or many) at + // registration, before a document carrying one can ever reach the evaluator. The old + // `targetType ?? typeof(object)` fallback had no fixture that could reach it and is deleted + // rather than kept as an unreachable branch. + // + // R-V: a reference's stored target is a bare record key - the wire carries no schema for it - + // so resolving "the row this edge names" means searching every row sharing that key (byKey) for + // the one whose schema falls inside the reference's declared leaf set, not grabbing whichever + // row a bare-key dictionary happened to keep. Two schemas legitimately sharing one record key + // resolve to two different rows depending on which reference declared which target (Soul's P2). + // + // R-W: this is called for every stored edge a reference member carries, unconditionally, before + // any citation- or role-specific filtering decides whether that edge is the one the caller + // asked about - a many-reference edge into a schema outside its declared target refuses the + // selection even when the caller's own key/role filter would otherwise have skipped past it + // without ever looking at it (Soul's P3). + private static bool TryResolveReferenceTarget( + CultDocumentRegistry registry, + IReadOnlyDictionary> byKey, + Type targetType, + Row from, + string role, + string recordKey, + out Row resolved) + { + resolved = default; + if (!byKey.TryGetValue(recordKey, out var candidates) || candidates.Count == 0) + return false; // a dangling reference - no row at all exists at this key (R-T(b) style: not an error). + + var leaves = registry.ResolveTargetLeaves(targetType); + foreach (var candidate in candidates) + { + if (leaves.Any(leaf => leaf.SchemaId == candidate.Descriptor.SchemaId)) + { + resolved = candidate; + return true; + } + } + + // Every row sharing this record key falls outside the reference's declared target: refuse + // deterministically (by schema id, code-point order) rather than let insertion order decide + // which of them the refusal names (R-V: "both row orders give the same answer"). + var outside = candidates.OrderBy(c => c.Descriptor.SchemaId, CultNetCodePointComparer.Instance).First(); + throw new CultNetSelectionReferenceOutsideTargetException(from.Descriptor.SchemaId, from.Key.Value, role, outside.Descriptor.SchemaId, outside.Key.Value); + } + + private static bool MatchesCitation( + CultDocumentRegistry registry, + IReadOnlyDictionary> byKey, + Row citer, + CultNetCitation citation, + List edgeSink) + { + var found = false; + foreach (var member in ReferenceMembers(citer.Descriptor, citation.Role)) + { + var role = member.IndexAlias ?? member.MemberName; + foreach (var (targetKey, payload) in citer.Descriptor.ReferencesOf(citer.Document, role)) + { + // R-W: resolve and check the edge's own declared target first, for every edge this + // reference carries - before the citation's key/schema decides whether this is the + // edge the caller asked about. + if (!TryResolveReferenceTarget(registry, byKey, member.TargetType!, citer, role, targetKey.Value, out var resolved)) + continue; + + if (!string.Equals(targetKey.Value, citation.Target.RecordKey, StringComparison.Ordinal)) + continue; + // R-E: one schema-identity rule everywhere - a cites target goes through the alias + // matcher, the same as `schemas`, instead of an exact CultDocumentDescriptor.SchemaId + // compare that a schema alias or version string could never satisfy. + if (!CultNetSchemaAliasMatching.Matches(citation.Target.SchemaId, resolved.Descriptor)) + continue; + edgeSink.Add(new EdgeMatch(citer, role, resolved, payload)); + found = true; + } + } + + return found; + } + + private static HashSet<(string SchemaId, string RecordKey)> BuildIncomingIndex( + CultDocumentRegistry registry, + IReadOnlyList allRows, + IReadOnlyDictionary> byKey, + string role, + List edgeSink) + { + // R-V: iterate every row directly, not byKey.Values - a bare-key index collapses two + // schemas' rows sharing a key into one slot, which used to silently drop one of them from + // consideration as a citer entirely. + var incoming = new HashSet<(string, string)>(); + foreach (var citer in allRows) + { + foreach (var member in ReferenceMembers(citer.Descriptor, role)) + { + var memberRole = member.IndexAlias ?? member.MemberName; + foreach (var (targetKey, payload) in citer.Descriptor.ReferencesOf(citer.Document, memberRole)) + { + if (!TryResolveReferenceTarget(registry, byKey, member.TargetType!, citer, memberRole, targetKey.Value, out var resolved)) + continue; + incoming.Add((resolved.Descriptor.SchemaId, resolved.Key.Value)); + edgeSink.Add(new EdgeMatch(citer, memberRole, resolved, payload)); + } + } + } + + return incoming; + } + + // R-AL: the `!member.IsReference` guard is restored. R-AK's removal argued a data member's + // role string could never coincide with a reference member's because index aliases are + // unique per descriptor - false. CultCache's D10 only rejects a collision between two + // *aliased* members (CultCache.cs:1085-1097); a plain data member with no [CultIndex] + // contributes its bare MemberName to the same role namespace unchecked (fixed for new + // registrations by D10b, CultCache.cs ~1099, but this guard is the caller-side backstop for + // whatever is already registered). Without it, a data member sharing a reference member's + // role is yielded here, ReferencesOf then matches it to the *reference* member's edges by + // role string, and `member.TargetType!` is null - ArgumentNullException on every hop. + private static IEnumerable ReferenceMembers(CultDocumentDescriptor descriptor, string? role) + { + foreach (var member in descriptor.DeclaredMembers) + { + if (!member.IsReference) continue; + if (role != null && (member.IndexAlias ?? member.MemberName) != role) continue; + yield return member; + } + } + } + + /// The typed refusal for an edge naming a row outside its reference's declared target (S18). + public sealed class CultNetSelectionReferenceOutsideTargetException : Exception + { + public CultNetSelectionReferenceOutsideTargetException(string fromSchemaId, string fromKey, string role, string toSchemaId, string toKey) + : base($"reference_outside_target: {fromSchemaId}/{fromKey} names {toSchemaId}/{toKey} through role \"{role}\", which is outside the reference's declared target.") + { + FromSchemaId = fromSchemaId; + FromKey = fromKey; + Role = role; + ToSchemaId = toSchemaId; + ToKey = toKey; + } + + public string FromSchemaId { get; } + public string FromKey { get; } + public string Role { get; } + public string ToSchemaId { get; } + public string ToKey { get; } + } + + /// The typed refusal for a cursor the server cannot answer (cursor_stale/cursor_invalid). + public sealed class CultNetSelectionCursorException : Exception + { + public CultNetSelectionCursorException(string code, string message, ulong? asOf = null, ulong? current = null) + : base(message) + { + Code = code; + AsOf = asOf; + Current = current; + } + + public string Code { get; } + + /// The cursor's own minted asOf. Carried on the wire only for cursor_stale (R-N). + public ulong? AsOf { get; } + + /// The answering server's current asOf. Carried on the wire only for cursor_stale (R-N). + public ulong? Current { get; } + } + + /// + /// The answering process's key for cursor digests (R-O, F6: "cursors are keyed"). A digest is an + /// HMAC-SHA256 under this key, so a cursor cannot be forged without it and cannot be verified by + /// a different key. is never persisted or serialized; the answering server + /// holds one instance for its process lifetime (), so a + /// cursor minted before a restart carries a digest the new process's key does not produce, and + /// the restart's first page for it answers cursor_invalid - acceptable, because cursors are + /// short-lived and section 2 already calls them "minted by the answering server". + /// + public sealed class CultNetSelectionCursorKey + { + /// + /// The key / + /// fall back to when no caller-owned key is supplied - generated once, the first time this type + /// is touched in the process, so a caller that evaluates selections without wiring up its own + /// (most unit tests) still gets a keyed, per-process digest instead + /// of an unkeyed one. + /// + internal static readonly CultNetSelectionCursorKey ProcessDefault = Random(); + + private readonly byte[] _key; + + public CultNetSelectionCursorKey(byte[] key) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + if (key.Length == 0) throw new ArgumentException("Value must be non-empty.", nameof(key)); + _key = key; + } + + /// A fresh 32-byte key from the OS RNG. + public static CultNetSelectionCursorKey Random() + { + var bytes = new byte[32]; + RandomNumberGenerator.Fill(bytes); + return new CultNetSelectionCursorKey(bytes); + } + + internal byte[] KeyBytes => _key; + } + + /// + /// The opaque cursor: asOf, the last (ordinal, schemaId, recordKey), and an HMAC digest that covers + /// that same body as well as the selection (with cursor and limit cleared), keyed under the + /// answering process's (R-O/R-Y). Minted only by the + /// answering server. + /// + public readonly struct CultNetSelectionCursor + { + private CultNetSelectionCursor(ulong asOf, long ordinal, string schemaId, string recordKey, string digest) + { + AsOf = asOf; + Ordinal = ordinal; + SchemaId = schemaId; + RecordKey = recordKey; + Digest = digest; + } + + public ulong AsOf { get; } + public long Ordinal { get; } + public string SchemaId { get; } + public string RecordKey { get; } + public string Digest { get; } + + public static string Mint(ulong asOf, CultNetSelectionEvaluator.Row lastRow, CultNetSelection selection, CultNetSelectionCursorKey key) + { + var digest = ComputeDigest(asOf, lastRow.Ordinal, lastRow.Descriptor.SchemaId, lastRow.Key.Value, selection, key); + // R-O: the cursor body is length-prefixed fields, not a delimiter-joined string - a record + // key carrying any character at all, including whatever delimiter an earlier scheme would + // have chosen (Soul found a key containing U+241F broke a delimiter split), round-trips. + var body = new StringBuilder(); + AppendString(body, asOf.ToString(CultureInfo.InvariantCulture)); + AppendString(body, lastRow.Ordinal.ToString(CultureInfo.InvariantCulture)); + AppendString(body, lastRow.Descriptor.SchemaId); + AppendString(body, lastRow.Key.Value); + AppendString(body, digest); + return Base64UrlEncode(Encoding.UTF8.GetBytes(body.ToString())); + } + + public static CultNetSelectionCursor Parse(string cursor) + { + byte[] bytes; + try + { + bytes = Base64UrlDecode(cursor); + } + catch (FormatException) + { + throw new CultNetSelectionCursorException("cursor_invalid", "The cursor does not decode."); + } + + var fields = ReadLengthPrefixedFields(bytes, 5); + if (fields == null || + !ulong.TryParse(fields[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var asOf) || + !long.TryParse(fields[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ordinal)) + { + throw new CultNetSelectionCursorException("cursor_invalid", "The cursor does not decode."); + } + + return new CultNetSelectionCursor(asOf, ordinal, fields[2], fields[3], fields[4]); + } + + /// + /// Verifies this cursor's digest against under + /// in fixed time (R-O): a cursor forged without the key, or minted under a different process's + /// key (a restart), does not verify. R-Y: the digest is recomputed over this cursor's own body + /// (, , , ) + /// as well as the selection, so a cursor whose body was rewritten after minting - the ordinal or + /// record key edited underneath a digest that was still legitimately minted for this selection - + /// no longer verifies either (Soul's P4: "forged cursor position, reused digest"). + /// + public bool VerifyDigest(CultNetSelection selection, CultNetSelectionCursorKey key) + { + var expected = ComputeDigest(AsOf, Ordinal, SchemaId, RecordKey, selection, key); + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(Digest), + Encoding.UTF8.GetBytes(expected)); + } + + /// + /// An HMAC-SHA256 digest, keyed under , of the cursor's own body + /// (, , , + /// ) plus the selection with cursor and limit cleared, so a cursor is + /// bound to the exact position it was minted at as well as to the selection and the process that + /// minted it (R-O/R-Y). R-H: every string and list is length-prefixed, so no delimiter choice + /// can make two different selections collide - a delimiter-joined form used to digest + /// values ["a|b"] the same as ["a","b"], and keys ["a,b"] the same as ["a","b"]. + /// + public static string ComputeDigest( + ulong asOf, + long ordinal, + string schemaId, + string recordKey, + CultNetSelection selection, + CultNetSelectionCursorKey? key = null) + { + key ??= CultNetSelectionCursorKey.ProcessDefault; + var sb = new StringBuilder(); + // R-Y: the body is part of what the HMAC signs - without this, a forged cursor could keep a + // legitimately minted digest and rewrite the ordinal or record key underneath it, since + // nothing about the body itself was ever authenticated. + AppendString(sb, asOf.ToString(CultureInfo.InvariantCulture)); + AppendString(sb, ordinal.ToString(CultureInfo.InvariantCulture)); + AppendString(sb, schemaId); + AppendString(sb, recordKey); + AppendList(sb, selection.Schemas); + AppendList(sb, selection.Keys); + + var fields = selection.Fields ?? Array.Empty(); + sb.Append(fields.Length).Append(':'); + foreach (var field in fields) + { + AppendString(sb, field.Index); + AppendString(sb, field.Op); + AppendList(sb, field.Values); + AppendString(sb, field.Number ?? string.Empty); + sb.Append(field.Number == null ? '0' : '1'); + } + + if (selection.Cites != null) + { + sb.Append('1'); + AppendString(sb, selection.Cites.Target.SchemaId); + AppendString(sb, selection.Cites.Target.RecordKey); + AppendString(sb, selection.Cites.Role ?? string.Empty); + sb.Append(selection.Cites.Role == null ? '0' : '1'); + } + else + { + sb.Append('0'); + } + + if (selection.Cited != null) + { + sb.Append('1'); + AppendString(sb, selection.Cited.Role); + sb.Append(selection.Cited.Exists ? '1' : '0'); + } + else + { + sb.Append('0'); + } + + AppendString(sb, selection.Projection); + sb.Append(selection.Descending ? '1' : '0'); + + using var hmac = new HMACSHA256(key.KeyBytes); + var bytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString())); + var hex = new StringBuilder(bytes.Length * 2); + foreach (var b in bytes) hex.Append(b.ToString("x2", CultureInfo.InvariantCulture)); + return hex.ToString(); + } + + /// Appends one length-prefixed string: unambiguous regardless of what the string itself contains. + // The length prefix is a UTF-8 BYTE count, not string.Length's UTF-16 code-unit count - the + // cursor body's prefix is read back out of UTF-8 bytes (ReadLengthPrefixedFields), and an + // astral character (a surrogate pair: 2 UTF-16 code units, 4 UTF-8 bytes) would otherwise + // slice the wrong number of bytes on the way back. The digest's canonical string never + // round-trips through this byte-sliced reader - it is hashed whole - but keeping one counting + // rule for both uses is what makes AppendString unambiguous rather than merely usually right. + private static void AppendString(StringBuilder sb, string value) + { + sb.Append(Encoding.UTF8.GetByteCount(value)).Append(':').Append(value); + } + + /// Appends a count-prefixed list of length-prefixed strings, sorted by code point so the digest does not depend on wire order. + private static void AppendList(StringBuilder sb, IReadOnlyList? values) + { + var ordered = (values ?? Array.Empty()).OrderBy(v => v, CultNetCodePointComparer.Instance).ToArray(); + sb.Append(ordered.Length).Append(':'); + foreach (var value in ordered) + AppendString(sb, value); + } + + private static string Base64UrlEncode(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static byte[] Base64UrlDecode(string value) + { + var padded = value.Replace('-', '+').Replace('_', '/'); + switch (padded.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + case 0: break; + default: throw new FormatException("Invalid base64url string length."); + } + + return Convert.FromBase64String(padded); + } + + /// + /// Reads exactly length-prefixed fields written by + /// back out of , in order. Null on any + /// malformed length, a truncated value, a non-UTF-8 value, or leftover bytes after the last + /// field - the cursor body has no field this reader may skip. + /// + private static List? ReadLengthPrefixedFields(byte[] bytes, int count) + { + var fields = new List(count); + var pos = 0; + for (var i = 0; i < count; i++) + { + var colon = Array.IndexOf(bytes, (byte)':', pos); + if (colon < 0) return null; + var lengthText = Encoding.UTF8.GetString(bytes, pos, colon - pos); + if (!int.TryParse(lengthText, NumberStyles.None, CultureInfo.InvariantCulture, out var length)) + return null; + pos = colon + 1; + // R-AG: checked arithmetic - an attacker-supplied prefix near int.MaxValue makes + // `pos + length` overflow in unchecked int math (C#'s default), wrapping negative and + // slipping past this guard; GetString then throws ArgumentOutOfRangeException instead + // of this method returning null the way every other malformed shape does. `(long)` + // promotes before the add so the sum can never wrap - both operands are bounded well + // under long's range, so this can never itself overflow. + if (length < 0 || (long)pos + length > bytes.Length) return null; + fields.Add(Encoding.UTF8.GetString(bytes, pos, length)); + pos += length; + } + + return pos == bytes.Length ? fields : null; + } + } + + /// + /// Compares two strings by Unicode code point, not UTF-16 code unit (R-C, docs/cultnet-selection-cut.md). + /// .NET's ordinal comparers (StringComparer.Ordinal, string.CompareOrdinal) compare UTF-16 code + /// units, which sorts every astral character (encoded as a surrogate pair whose high half is in + /// U+D800-U+DBFF) before every BMP character at or above U+E000 - the opposite of code-point order, + /// and the opposite of what Rust gets by comparing UTF-8 bytes. Every ordered comparison the + /// selection vocabulary makes - the row tiebreak, the cursor position, and edge order - uses this + /// instead, so an astral key sorts the same way in both runtimes. + /// + public sealed class CultNetCodePointComparer : IComparer + { + public static readonly CultNetCodePointComparer Instance = new(); + + private CultNetCodePointComparer() { } + + public int Compare(string? x, string? y) + { + if (ReferenceEquals(x, y)) return 0; + if (x == null) return -1; + if (y == null) return 1; + + var xi = 0; + var yi = 0; + while (xi < x.Length && yi < y.Length) + { + var xCodePoint = CodePointAt(x, xi, out var xSize); + var yCodePoint = CodePointAt(y, yi, out var ySize); + if (xCodePoint != yCodePoint) + return xCodePoint < yCodePoint ? -1 : 1; + xi += xSize; + yi += ySize; + } + + return (x.Length - xi).CompareTo(y.Length - yi); + } + + // A lone (unpaired) surrogate is not valid UTF-16, but a comparer must not throw on it - it is + // treated as its own code unit value. It never round-trips to the wire as anything but + // well-formed text, so this only has to not crash. + private static int CodePointAt(string value, int index, out int size) + { + var unit = value[index]; + if (char.IsHighSurrogate(unit) && index + 1 < value.Length && char.IsLowSurrogate(value[index + 1])) + { + size = 2; + return char.ConvertToUtf32(unit, value[index + 1]); + } + + size = 1; + return unit; + } + } +} diff --git a/src/GameCult.Networking/Server.cs b/src/GameCult.Networking/Server.cs index 616f9ccd..128eb47d 100644 --- a/src/GameCult.Networking/Server.cs +++ b/src/GameCult.Networking/Server.cs @@ -567,7 +567,12 @@ namespace GameCult.Networking or CultNetDocumentDeleteMessage or CultNetDatabaseSubscribeMessage or CultNetDatabaseUnsubscribeMessage - or CultNetSnapshotRequestMessage; + or CultNetSnapshotRequestMessage + // R-A: v1 requests reach the same pre-verification handling as their v0 counterparts - + // otherwise a v1 snapshot/subscribe from an unverified peer is silently dropped before + // any listener runs, unlike v0 (docs/cultnet-selection-cut.md, Soul's whole-cut pass F1). + or CultNetSnapshotRequestV1Message + or CultNetDatabaseSubscribeV1Message; } /// diff --git a/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs b/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs new file mode 100644 index 00000000..daa71c81 --- /dev/null +++ b/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs @@ -0,0 +1,381 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; +using GameCult.Caching; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // CultNet typed selection, Cut 1, commit 0: the cache's public read surface over declared members + // (docs/cultnet-selection-cut.md, section 6). These tests are independent of GameCult.Networking - + // the evaluator that will consume this surface does not exist yet in this commit. + public sealed class CultDocumentSelectionSurfaceTests + { + // S19: a dictionary reference is enumerated as edges carrying its values, not merely its keys. + [Test] + public void ReferencesOfEnumeratesADictionaryReferenceAsEdgesCarryingItsValues() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB), typeof(FixtureCiter) }); + var citer = registry.GetRequired(); + var a1 = new CultRecordRef(new CultRecordKey("leaf-a-1")); + var a2 = new CultRecordRef(new CultRecordKey("leaf-a-2")); + var document = new FixtureCiter + { + Components = new Dictionary, float> { [a1] = 1.5f, [a2] = 2.5f } + }; + + var edges = citer.ReferencesOf(document, "Components").ToArray(); + + Assert.That(edges.Select(edge => edge.Target.Value).OrderBy(v => v, StringComparer.Ordinal), + Is.EqualTo(new[] { "leaf-a-1", "leaf-a-2" })); + Assert.That(edges.Single(edge => edge.Target.Value == "leaf-a-1").Payload, Is.EqualTo(1.5f)); + Assert.That(edges.Single(edge => edge.Target.Value == "leaf-a-2").Payload, Is.EqualTo(2.5f)); + // Two entries with different payload bytes: a shape-only assertion (keys enumerated, values + // dropped) would pass without this distinguishing the payloads (spec section 10, S19). + Assert.That(edges.Select(edge => edge.Payload).Distinct().Count(), Is.EqualTo(2)); + } + + [Test] + public void ReferencesOfEnumeratesAManyListReferenceWithNoPayload() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB), typeof(FixtureCiter) }); + var citer = registry.GetRequired(); + var document = new FixtureCiter + { + Related = new List> + { + new(new CultRecordKey("leaf-a-3")), + new(new CultRecordKey("leaf-a-4")) + } + }; + + var edges = citer.ReferencesOf(document, "Related").ToArray(); + + Assert.That(edges.Select(edge => edge.Target.Value), Is.EqualTo(new[] { "leaf-a-3", "leaf-a-4" })); + Assert.That(edges.All(edge => edge.Payload == null), Is.True); + } + + [Test] + public void ReferencesOfEnumeratesASingleReferenceToAnAbstractTarget() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB), typeof(FixtureCiter) }); + var citer = registry.GetRequired(); + var document = new FixtureCiter { Design = new CultRecordRef(new CultRecordKey("leaf-b-1")) }; + + var edges = citer.ReferencesOf(document, "Design").ToArray(); + + Assert.That(edges.Single().Target.Value, Is.EqualTo("leaf-b-1")); + Assert.That(edges.Single().Payload, Is.Null); + } + + // D9: the abstract middle carries no [CultDocument] and no catalog entry (TargetSchemaName is + // null for exactly this reference), so the leaf set must be resolved by assignability against + // the live registry, not read off a persisted name. + [Test] + public void ResolveTargetLeavesFindsBothLeavesUnderAnAbstractMiddleWithNoSchemaOfItsOwn() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB), typeof(FixtureCiter) }); + var citer = registry.GetRequired(); + var designMember = citer.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureCiter.Design)); + + Assert.That(designMember.TargetType, Is.EqualTo(typeof(FixtureMiddle))); + Assert.That(typeof(FixtureMiddle).GetCustomAttribute(), Is.Null); + + var leaves = registry.ResolveTargetLeaves(designMember.TargetType!); + + Assert.That(leaves.Select(d => d.DocumentType).OrderBy(t => t.Name), + Is.EqualTo(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB) }.OrderBy(t => t.Name))); + } + + [Test] + public void ResolveTargetLeavesSeesALeafRegisteredAfterAnEarlierDescriptorWasBuilt() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureCiter), typeof(FixtureLeafA) }); + var citer = registry.GetRequired(); + var designMember = citer.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureCiter.Design)); + + var before = registry.ResolveTargetLeaves(designMember.TargetType!); + Assert.That(before.Select(d => d.DocumentType), Does.Not.Contain(typeof(FixtureLeafB))); + + registry.GetRequired(); + var after = registry.ResolveTargetLeaves(designMember.TargetType!); + Assert.That(after.Select(d => d.DocumentType), Does.Contain(typeof(FixtureLeafB))); + } + + // D8's inherited-alias case, read through the public surface: mass is declared once on the + // abstract middle and reachable on both leaves. + [Test] + public void TryGetIndexValueAndNumberReadAnInheritedAliasOnEachLeaf() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB) }); + var leafA = registry.GetRequired(); + var documentA = new FixtureLeafA { Name = "sword", Mass = 3.5f }; + + Assert.That(leafA.TryGetIndexValue(documentA, "mass", out var text), Is.True); + Assert.That(text, Is.EqualTo("3.5")); + Assert.That(leafA.TryGetIndexNumber(documentA, "mass", out var number), Is.True); + Assert.That(number, Is.EqualTo("3.5")); + } + + [Test] + public void TryGetIndexValueRefusesAnUndeclaredAlias() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA) }); + var leafA = registry.GetRequired(); + + Assert.That(leafA.TryGetIndexValue(new FixtureLeafA(), "not_declared", out _), Is.False); + } + + // S22: IsNumeric is derived from the CLR set (sbyte..decimal, Nullable unwrapped) and must + // agree for a nullable numeric member and a decimal member - the two authorities D8 names. + [Test] + public void IsNumericAgreesForANullableNumericMemberAndADecimalMember() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafB) }); + var leafB = registry.GetRequired(); + var weight = leafB.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureLeafB.Weight)); + + Assert.That(weight.IsNumeric, Is.True); + var document = new FixtureLeafB { Weight = 4.25m }; + Assert.That(leafB.TryGetIndexNumber(document, "weight", out var number), Is.True); + Assert.That(number, Is.EqualTo("4.25")); + } + + [Test] + public void IsNumericIsFalseForAStringAlias() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA) }); + var leafA = registry.GetRequired(); + var name = leafA.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureLeafA.Name)); + + Assert.That(name.IsNumeric, Is.False); + } + + // Q-J (docs/cultnet-selection-cut.md, section 2 "Numbers", 2026-09-22): the row side renders a + // member's exact canonical decimal - never a double. A long past 2^53 (where double loses + // integer precision) is the named boundary: rendering it through float64 would silently produce + // "9007199254740992" for 9007199254740993, and the two would then compare equal on the wire. + [Test] + public void TryGetIndexNumberRendersALongPast2Pow53ExactlyNotThroughFloat64() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafNumbers) }); + var leaf = registry.GetRequired(); + var document = new FixtureLeafNumbers { BigInteger = 9007199254740993L }; + + Assert.That(leaf.TryGetIndexNumber(document, "big_integer", out var number), Is.True); + Assert.That(number, Is.EqualTo("9007199254740993")); + } + + [Test] + public void TryGetIndexNumberStripsTrailingZerosFromADecimal() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafNumbers) }); + var leaf = registry.GetRequired(); + var document = new FixtureLeafNumbers { TrimmedDecimal = 13.500m }; + + Assert.That(leaf.TryGetIndexNumber(document, "trimmed_decimal", out var number), Is.True); + Assert.That(number, Is.EqualTo("13.5")); + } + + // The shortest round-trip form .NET renders for a double/float outside a middle magnitude range + // uses exponent notation ("1E+21", "1E-07"); the canonical wire form never does (Q-J). + [Test] + public void TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafNumbers) }); + var leaf = registry.GetRequired(); + var wide = new FixtureLeafNumbers { WideDouble = 1e21 }; + var tiny = new FixtureLeafNumbers { TinyDouble = 1e-7 }; + + Assert.That(leaf.TryGetIndexNumber(wide, "wide_double", out var wideNumber), Is.True); + Assert.That(wideNumber, Does.Not.Contain("E")); + Assert.That(wideNumber, Is.EqualTo("1000000000000000000000")); + + Assert.That(leaf.TryGetIndexNumber(tiny, "tiny_double", out var tinyNumber), Is.True); + Assert.That(tinyNumber, Does.Not.Contain("E")); + // R-D (docs/cultnet-selection-cut.md): the exact decimal expansion, not a shortest + // round-trip form. 1e-7 is not exactly representable as a double; its nearest double's + // exact value is this - the old assertion pinned "0.0000001", the shortest form a + // pre-R-D render would have produced instead. + Assert.That(tinyNumber, Is.EqualTo("0.0000000999999999999999954748111825886258685613938723690807819366455078125")); + } + + [Test] + public void TryGetIndexNumberReturnsFalseForNaNAndInfinity() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafNumbers) }); + var leaf = registry.GetRequired(); + var nan = new FixtureLeafNumbers { WideDouble = double.NaN }; + var infinite = new FixtureLeafNumbers { WideDouble = double.PositiveInfinity }; + + Assert.That(leaf.TryGetIndexNumber(nan, "wide_double", out _), Is.False); + Assert.That(leaf.TryGetIndexNumber(infinite, "wide_double", out _), Is.False); + } + + // D10 and D11 registration refusals: emitted into a dynamic assembly, invisible to registry + // discovery by attribute scan, exactly as DocumentShapeTests does for every other shape refusal. + private static readonly ModuleBuilder Rejected = AssemblyBuilder + .DefineDynamicAssembly(new AssemblyName("RejectedSelectionShapes"), AssemblyBuilderAccess.Run) + .DefineDynamicModule("RejectedSelectionShapes"); + + [Test] + public void DuplicateIndexAliasOnTwoDifferentlyNamedMembersIsRefusedByName() + { + var type = Document("DupAlias"); + Field(type, "First", typeof(string), Key(0), IndexAttr("shared")); + Field(type, "Second", typeof(string), Key(1), IndexAttr("shared")); + var message = Assert.Throws(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message; + + Assert.That(message, Does.Contain("DupAlias")); + Assert.That(message, Does.Contain("First")); + Assert.That(message, Does.Contain("Second")); + Assert.That(message, Does.Contain("shared")); + } + + // R-AL/SM-6: a plain data member's bare name (it declares no [CultIndex], so its role in + // ReferencesOf/ReferenceMembers falls back to its own MemberName) colliding with a reference + // member's explicit alias. D10 above only groups members that declare an alias and misses + // this; before the fix this registered without error and then every hop through the + // reference member threw ArgumentNullException at resolution time (probed by Soul). + [Test] + public void DataMemberNameCollidingWithAReferenceMembersAliasIsRefusedAtRegistration() + { + var type = Document("OwnerCollision"); + Field(type, "Owner", typeof(string), Key(0)); + Field(type, "Ref", typeof(CultRecordRef), Key(1), IndexAttr("Owner")); + var message = Assert.Throws(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message; + + Assert.That(message, Does.Contain("OwnerCollision")); + Assert.That(message, Does.Contain("Owner")); + Assert.That(message, Does.Contain("Ref")); + } + + [Test] + public void ManyReferenceOfAnElementTypeThatIsNotACultRecordRefIsRefused() + { + var type = Document("BadMany"); + Field(type, "Values", typeof(List), Key(0), ReferenceAttr(null, many: true)); + var message = Assert.Throws(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message; + + Assert.That(message, Does.Contain("BadMany")); + Assert.That(message, Does.Contain("Values")); + } + + [Test] + public void SingleReferenceWhoseMemberTypeIsNotACultRecordRefIsRefused() + { + var type = Document("BadSingle"); + Field(type, "Target", typeof(int), Key(0), ReferenceAttr(typeof(FixtureLeafA), many: false)); + var message = Assert.Throws(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message; + + Assert.That(message, Does.Contain("BadSingle")); + Assert.That(message, Does.Contain("Target")); + } + + private static TypeBuilder Document(string name) + { + var type = Rejected.DefineType(name, TypeAttributes.Public); + type.SetCustomAttribute(new CustomAttributeBuilder( + typeof(CultDocumentAttribute).GetConstructor(new[] { typeof(string), typeof(string) })!, + new object[] { "selection.reject." + name, "selection.reject." + name + ".v1" })); + type.SetCustomAttribute(new CustomAttributeBuilder(typeof(MessagePackObjectAttribute).GetConstructor(new[] { typeof(bool) })!, new object[] { false })); + return type; + } + + private static void Field(TypeBuilder type, string name, Type fieldType, params CustomAttributeBuilder[] attributes) + { + var field = type.DefineField(name, fieldType, FieldAttributes.Public); + foreach (var attribute in attributes) field.SetCustomAttribute(attribute); + } + + private static CustomAttributeBuilder Key(int slot) => + new(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { slot }); + + private static CustomAttributeBuilder IndexAttr(string alias) => + new(typeof(CultIndexAttribute).GetConstructor(new[] { typeof(string) })!, new object?[] { alias }); + + private static CustomAttributeBuilder ReferenceAttr(Type? targetType, bool many) => + new(typeof(CultReferenceAttribute).GetConstructor(new[] { typeof(Type), typeof(bool) })!, new object?[] { targetType, many }); + + public abstract class FixtureMiddle + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + [CultIndex("mass")] + public float Mass; + } + + [CultDocument("selection.fixture.leaf_a", "selection.fixture.leaf_a.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class FixtureLeafA : FixtureMiddle + { + [Key(2)] + public int Extra; + } + + [CultDocument("selection.fixture.leaf_b", "selection.fixture.leaf_b.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class FixtureLeafB : FixtureMiddle + { + [Key(2)] + [CultIndex("weight")] + public decimal? Weight; + } + + // Q-J: a fixture dedicated to the row-side canonical rendering rules, independent of the + // abstract-middle/inheritance fixtures above so it can carry a long, a decimal and two doubles + // without disturbing the schemas other tests in this file pin. + [CultDocument("selection.fixture.leaf_numbers", "selection.fixture.leaf_numbers.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class FixtureLeafNumbers + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + [CultIndex("big_integer")] + public long BigInteger; + + [Key(2)] + [CultIndex("trimmed_decimal")] + public decimal TrimmedDecimal; + + [Key(3)] + [CultIndex("wide_double")] + public double WideDouble; + + [Key(4)] + [CultIndex("tiny_double")] + public double TinyDouble; + } + + [CultDocument("selection.fixture.citer", "selection.fixture.citer.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class FixtureCiter + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + [CultReference(typeof(FixtureMiddle))] + public CultRecordRef Design; + + [Key(2)] + [CultReference(typeof(FixtureLeafA), many: true)] + public Dictionary, float> Components = new(); + + [Key(3)] + [CultReference(typeof(FixtureLeafA), many: true)] + public List> Related = new(); + } + } +} diff --git a/tests/GameCult.Mesh.Tests/CultMeshBodyTransportTests.cs b/tests/GameCult.Mesh.Tests/CultMeshBodyTransportTests.cs index 131bcf24..3ce7dc88 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshBodyTransportTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshBodyTransportTests.cs @@ -463,11 +463,11 @@ public sealed class CultMeshBodyTransportTests new[] { "eve:field:gravity" }, new[] { "gamecult.eve.fields_splats.v1" }); - subscription.RecordKeys.Should().Equal( + subscription.Selection.Keys.Should().Equal( "eve:view:pilot", CultMeshBodyPublicationDocument.CreateLatestRecordKey("eve:body:pilot").Value, "eve:field:gravity"); - subscription.SchemaIds.Should().Equal( + subscription.Selection.Schemas.Should().Equal( "gamecult.eve.entity_soa_view.v2", CultMeshBodyPublicationSchemaVersions.Publication, "gamecult.eve.fields_splats.v1"); diff --git a/tests/GameCult.Mesh.Tests/CultMeshSnapshotOptionsTests.cs b/tests/GameCult.Mesh.Tests/CultMeshSnapshotOptionsTests.cs new file mode 100644 index 00000000..549f4a1b --- /dev/null +++ b/tests/GameCult.Mesh.Tests/CultMeshSnapshotOptionsTests.cs @@ -0,0 +1,50 @@ +using System; +using GameCult.Networking; +using NUnit.Framework; + +namespace GameCult.Mesh.Tests; + +// R-F (docs/cultnet-selection-cut.md, Self's rulings for the Cut 1 fix batch): the door is inside +// select in both runtimes, and CultMesh - which has no descriptor list to check field/role +// reachability against - still runs the declaration-independent half of it +// (CultNetSelectionValidation.ValidateShape) before its own v0 transport refusals, through +// CultMeshSnapshots.EnsureV0Compatible. Keys=[]/[""] is refused there, not silently lowered to +// "every key" the way v0's own cleaning would. +public sealed class CultMeshSnapshotOptionsTests +{ + [Test] + public void FetchSnapshotAsync_RefusesAnEmptyKeysListBeforeTouchingTheClient() + { + var options = new CultMeshSnapshotRequestOptions + { + Selection = new CultNetSelection { Keys = Array.Empty() } + }; + + var ex = Assert.ThrowsAsync(() => + CultMesh.FetchSnapshotAsync( + createClient: () => throw new InvalidOperationException( + "EnsureV0Compatible must refuse before a client is ever created."), + endpoint: "mesh-door-test-endpoint", + options: options)); + + Assert.That(ex!.Field, Is.EqualTo("keys")); + } + + [Test] + public void FetchSnapshotAsync_RefusesABlankOnlyKeysEntryBeforeTouchingTheClient() + { + var options = new CultMeshSnapshotRequestOptions + { + Selection = new CultNetSelection { Keys = new[] { " " } } + }; + + var ex = Assert.ThrowsAsync(() => + CultMesh.FetchSnapshotAsync( + createClient: () => throw new InvalidOperationException( + "EnsureV0Compatible must refuse before a client is ever created."), + endpoint: "mesh-door-test-endpoint", + options: options)); + + Assert.That(ex!.Field, Is.EqualTo("keys")); + } +} diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 584569e6..acf5eb6c 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -2745,6 +2745,87 @@ public sealed class CultMeshStreamingTests CultMesh.Verse("starbridge", "unity-pilot")); (await mixedAliasSchemaHandle.LatestAsync()).Text.Should().Be("compatible-record-key"); + + // The recordKey match is exact (ordinal), not case-insensitive. A case-variant key must not + // win even when it appears first in the response. + var caseVariantKey = key.Value.ToUpperInvariant(); + var caseVariantKeyHandle = CultMesh.DocumentFromPeerSnapshot( + _ => Task.FromResult(new CultNetSnapshotResponseRawMessage + { + MessageId = "case-variant-key", + Documents = new[] + { + new CultNetRawDocumentRecord + { + SchemaId = CultDocumentRegistry.Shared.GetRequired().SchemaId, + RecordKey = caseVariantKey, + StoredAt = DateTimeOffset.UtcNow.ToString("O"), + PayloadEncoding = "messagepack", + Payload = CultDocumentMessagePackSerialization.SerializeUntyped( + new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "wrong-case-key", + Revision = 13 + }, + typeof(MeshNoteDocument)) + }, + new CultNetRawDocumentRecord + { + SchemaId = CultDocumentRegistry.Shared.GetRequired().SchemaId, + RecordKey = key.Value, + StoredAt = DateTimeOffset.UtcNow.ToString("O"), + PayloadEncoding = "messagepack", + Payload = CultDocumentMessagePackSerialization.SerializeUntyped( + new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "right-case-key", + Revision = 14 + }, + typeof(MeshNoteDocument)) + } + } + }), + key.Value, + CultMesh.Verse("starbridge", "unity-pilot")); + + (await caseVariantKeyHandle.LatestAsync()).Text.Should().Be("right-case-key"); + } + + // R-R (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22) corrects the earlier commit-2 + // ruling this test used to pin: an explicit empty recordKeys array is a real filter, not "no + // filter" that falls back to the type's own schema default. Discrepancy against R-R's prose, + // reported rather than silently resolved: at CultMesh's typed convenience layer this selection + // never reaches the wire at all. ResolveDefaultSelection now produces Keys=[] instead of Keys=null, + // and EnsureV0Compatible - CultMesh's own client-side door, landed under the earlier ruling R-F - + // already refuses an empty selection.keys before any request is sent ("Keys=[]/[\"\"] is refused + // rather than silently lowered to 'every key'", CultMeshSnapshots.cs:836-841). R-R's "recordKeys: + // [] answers empty" therefore only holds for the raw v0 wire protocol server, which any v0 peer + // can call directly without CultMesh's typed wrapper - see + // CultNetDocumentRegistry_RawSnapshot_ExplicitEmptyRecordKeys_AnswersEmpty in NetworkingTests.cs. + [Test] + public void FetchDocumentsAsync_WithExplicitEmptyRecordKeys_IsRefusedClientSide() + { + var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared) + .Register(CultNetDocumentBinding.ForDocument(CultDocumentRegistry.Shared)); + var requests = new List(); + + using var session = CultMesh.SnapshotSession( + "cultnet://empty-keys-default.test:3075", + new CultMeshSnapshotRequestOptions + { + CreateClient = () => new MeshSnapshotSchemaClient(request => + { + requests.Add(request); + return registry.CreateRawSnapshotResponse(new CultCache(), request.MessageId, request); + }) + }, + registry); + + Assert.ThrowsAsync( + () => session.FetchDocumentsAsync(recordKeys: Array.Empty())); + requests.Should().BeEmpty(); } [Test] @@ -2936,7 +3017,7 @@ public sealed class CultMeshStreamingTests var endpoint = "cultnet://snapshot-helper.test:3075"; var options = new CultMeshSnapshotRequestOptions { - RecordKeys = new[] { key.Value }, + Selection = new CultNetSelection { Keys = new[] { key.Value } }, ShardId = "primary", ShardEpoch = 7, MessageIdPrefix = "mesh-test-scoped-snapshot", @@ -2986,7 +3067,7 @@ public sealed class CultMeshStreamingTests var options = new CultMeshSnapshotRequestOptions { ResponseTimeout = TimeSpan.FromSeconds(30), - RecordKeys = new[] { "eve:surface:test" }, + Selection = new CultNetSelection { Keys = new[] { "eve:surface:test" } }, CreateClient = () => new FailedBackgroundSchemaClient( new FormatException("malformed fragment")) }; @@ -3051,7 +3132,7 @@ public sealed class CultMeshStreamingTests "cultnet://foreign-schema.test:3075", new CultMeshSnapshotRequestOptions { - RecordKeys = new[] { key.Value }, + Selection = new CultNetSelection { Keys = new[] { key.Value } }, CreateClient = () => new MeshSnapshotSchemaClient(request => { snapshot.MessageId = request.MessageId; @@ -3236,7 +3317,7 @@ public sealed class CultMeshStreamingTests endpoint, new CultMeshSnapshotRequestOptions { - RecordKeys = new[] { key.Value }, + Selection = new CultNetSelection { Keys = new[] { key.Value } }, CreateClient = () => new MeshSnapshotSchemaClient(request => networkRegistry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request)) }, diff --git a/tests/GameCult.Networking.Tests/CultNetCanonicalNumberTests.cs b/tests/GameCult.Networking.Tests/CultNetCanonicalNumberTests.cs new file mode 100644 index 00000000..76f2c488 --- /dev/null +++ b/tests/GameCult.Networking.Tests/CultNetCanonicalNumberTests.cs @@ -0,0 +1,101 @@ +#nullable enable +using NUnit.Framework; + +namespace GameCult.Networking.Tests +{ + // Q-J (docs/cultnet-selection-cut.md, section 2 "Numbers", 2026-09-22): the wire's canonical + // decimal form - the door's grammar and the comparator that reads it. Pure string-in functions, + // independent of the cache and the evaluator; CultDocumentSelectionSurfaceTests (GameCult.Caching.Tests) + // pins the row side's rendering of the same form. + public sealed class CultNetCanonicalNumberTests + { + [TestCase("0")] + [TestCase("1")] + [TestCase("-1")] + [TestCase("10")] + [TestCase("0.5")] + [TestCase("-0.5")] + [TestCase("9007199254740993")] + [TestCase("123.456")] + public void IsCanonicalAcceptsCanonicalSpellings(string value) => + Assert.That(CultNetCanonicalNumber.IsCanonical(value), Is.True); + + // The door's named mutants: a leading '+', a trailing fractional zero, a leading integer zero, + // exponent notation, and negative zero are each refused, never normalised. + [TestCase("+1")] + [TestCase("1.0")] + [TestCase("01")] + [TestCase("1e3")] + [TestCase("1E3")] + [TestCase("-0")] + [TestCase("")] + [TestCase(" 1")] + [TestCase("1.")] + [TestCase("1..5")] + // R-D/Q-J (Soul's whole-cut pass, confirmed): .NET's $ matches immediately before a single + // trailing newline even without RegexOptions.Multiline, so a naive `$`-anchored pattern accepts + // "5\n" as canonical - the door must refuse it exactly as it refuses any other non-canonical + // spelling, byte for byte the same as Rust and the JSON schema's ECMA-262 $. + [TestCase("5\n")] + [TestCase("5\r\n")] + public void IsCanonicalRefusesNonCanonicalSpellings(string value) => + Assert.That(CultNetCanonicalNumber.IsCanonical(value), Is.False); + + [Test] + public void IsCanonicalRefusesNull() => + Assert.That(CultNetCanonicalNumber.IsCanonical(null), Is.False); + + // The comparator: sign first, then the integer part's length, then its digits, then the + // fraction padded on the right - never a lexicographic compare of the whole string, which + // would rank "10" below "9". + [Test] + public void CompareOrdersByMagnitudeNotLexicographically() + { + Assert.That(CultNetCanonicalNumber.Compare("10", "9"), Is.GreaterThan(0)); + Assert.That(CultNetCanonicalNumber.Compare("9", "10"), Is.LessThan(0)); + } + + [Test] + public void CompareOrdersNegativeBelowPositive() + { + Assert.That(CultNetCanonicalNumber.Compare("-1", "1"), Is.LessThan(0)); + Assert.That(CultNetCanonicalNumber.Compare("1", "-1"), Is.GreaterThan(0)); + Assert.That(CultNetCanonicalNumber.Compare("-10", "-2"), Is.LessThan(0)); + } + + // Same leading digit, different length: "100" > "99" even though '1' < '9' as a bare character + // comparison - this is what a comparator that skips the integer part's length gets wrong. + [Test] + public void CompareOrdersByIntegerPartLengthBeforeDigits() + { + Assert.That(CultNetCanonicalNumber.Compare("100", "99"), Is.GreaterThan(0)); + Assert.That(CultNetCanonicalNumber.Compare("99", "100"), Is.LessThan(0)); + } + + // "1.5" vs "1.45": padded to "50" vs "45" so 1.5 > 1.45. Comparing the raw, unpadded fraction + // strings would get this wrong ("5" > "45" as characters is true by luck, "45" > "5" as strings + // sorted by length first is not the rule either) - padding to equal length is the rule. + [Test] + public void CompareOrdersFractionsByPaddedDigitsNotRawLength() + { + Assert.That(CultNetCanonicalNumber.Compare("1.5", "1.45"), Is.GreaterThan(0)); + Assert.That(CultNetCanonicalNumber.Compare("1.45", "1.5"), Is.LessThan(0)); + } + + [Test] + public void CompareTreatsEqualCanonicalStringsAsEqual() + { + Assert.That(CultNetCanonicalNumber.Compare("5", "5"), Is.EqualTo(0)); + Assert.That(CultNetCanonicalNumber.Compare("-0.5", "-0.5"), Is.EqualTo(0)); + } + + // The row-rendering mutant named in section 2 "Numbers": a long past 2^53 (where double loses + // integer precision) must still compare correctly once rendered. This pins the comparator's + // side of that guarantee; CultDocumentSelectionSurfaceTests pins the cache's rendering side. + [Test] + public void CompareDistinguishesALongPast2Pow53FromItsFloat64Rounding() + { + Assert.That(CultNetCanonicalNumber.Compare("9007199254740993", "9007199254740992"), Is.GreaterThan(0)); + } + } +} diff --git a/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs b/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs new file mode 100644 index 00000000..b5f4f08f --- /dev/null +++ b/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs @@ -0,0 +1,204 @@ +#nullable enable +using System; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using GameCult.Caching; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Networking.Tests +{ + // R-S (docs/cultnet-selection-cut.md, "Self's rulings for fix batch 3", F12): the committed + // contracts/cultnet/*.schema.json files describe the bytes MessagePack-CSharp actually writes - + // headers get their own schema (no payload/payloadEncoding, which CultNetRawDocumentHeader has + // no members for at all), the snapshot page's shape matches map-mode output (every declared key + // present, nil included), and fieldPredicate refuses values/number together. This decodes real + // wire bytes from a live CultNetDatabaseServer and checks them against the committed schema + // files with MiniJsonSchemaValidator, rather than asserting on the C# objects alone - a schema + // file can drift from the bytes even while the C# types stay internally consistent, which is + // exactly what F12 was. + public sealed class CultNetSchemaContractTests + { + private static readonly ServerSecurityOptions DevelopmentServerSecurity = ServerSecurityOptions.Development(); + + private static string SchemaDir() => Path.Combine(RepoRoot(), "contracts", "cultnet"); + + private static string RepoRoot() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln"))) + directory = directory.Parent; + return directory?.FullName ?? throw new InvalidOperationException("CultLib.sln was not found above the test directory."); + } + + private static JsonElement ToJson(T message) where T : ICultNetSchemaMessage + { + var bytes = CultNetSchemaMessageSerialization.Serialize(message); + var json = MessagePackSerializer.ConvertToJson(bytes); + return JsonDocument.Parse(json).RootElement; + } + + [Test] + public async Task SnapshotResponseRawV1_HeaderProjectionRealBytes_MatchesItsSchema() + { + var cache = new CultCache(); + var database = new CultNetDatabase(cache); + await database.PutAsync(new CultRecordKey("v1:weapon"), new CultNetSelectionEvaluatorTests.SelLeafA + { + Name = "sword", + Kind = "weapon", + Mass = 3 + }); + using var server = new Server(cache, DevelopmentServerSecurity); + using var databaseServer = new CultNetDatabaseServer(server, database); + + var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message + { + MessageId = "header-projection", + Selection = new CultNetSelection() // default projection is "header" + }); + Assert.That(response.Headers, Is.Not.Null); + Assert.That(response.Documents, Is.Null); + + var validator = new MiniJsonSchemaValidator(SchemaDir()); + validator.AssertValid(ToJson(response), "cultnet.snapshot-response-raw.v1.schema.json"); + } + + [Test] + public async Task SnapshotResponseRawV1_DocumentProjectionRealBytes_MatchesItsSchema() + { + var cache = new CultCache(); + var database = new CultNetDatabase(cache); + await database.PutAsync(new CultRecordKey("v1:weapon"), new CultNetSelectionEvaluatorTests.SelLeafA + { + Name = "sword", + Kind = "weapon", + Mass = 3 + }); + using var server = new Server(cache, DevelopmentServerSecurity); + using var databaseServer = new CultNetDatabaseServer(server, database); + + var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message + { + MessageId = "document-projection", + Selection = new CultNetSelection { Projection = CultNetSelectionProjections.Document } + }); + Assert.That(response.Documents, Is.Not.Null); + Assert.That(response.Headers, Is.Null); + + var validator = new MiniJsonSchemaValidator(SchemaDir()); + validator.AssertValid(ToJson(response), "cultnet.snapshot-response-raw.v1.schema.json"); + } + + [Test] + public async Task SnapshotResponseRawV1_WithEdgesRealBytes_MatchesItsSchema() + { + var cache = new CultCache(); + var database = new CultNetDatabase(cache); + var target = new CultNetSelectionEvaluatorTests.SelLeafA { Name = "target", Kind = "k", Mass = 1 }; + await database.PutAsync(new CultRecordKey("target"), target); + await database.PutAsync(new CultRecordKey("citer"), new CultNetSelectionEvaluatorTests.SelCiter + { + Name = "citer", + Design = new CultRecordRef(new CultRecordKey("target")) + }); + using var server = new Server(cache, DevelopmentServerSecurity); + using var databaseServer = new CultNetDatabaseServer(server, database); + + var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message + { + MessageId = "with-edges", + Selection = new CultNetSelection + { + Cited = new CultNetIncoming { Role = "Design", Exists = true }, + Projection = CultNetSelectionProjections.Document + } + }); + Assert.That(response.Edges, Is.Not.Null.And.Length.GreaterThan(0)); + + var validator = new MiniJsonSchemaValidator(SchemaDir()); + validator.AssertValid(ToJson(response), "cultnet.snapshot-response-raw.v1.schema.json"); + } + + // R-S: fieldPredicate is now exclusive - values and number may not both be present, even + // though each alone satisfies one oneOf branch's `required`. Hand-built JSON, not a C# object, + // because CultNetFieldPredicate itself has no door stopping this shape from being constructed + // in memory - the door is CultNetSelectionValidation, a different layer; this test is about the + // wire schema's own oneOf, independent of that door. + [Test] + public void FieldPredicate_ValuesAndNumberTogether_FailsItsSchema() + { + var instance = JsonDocument.Parse( + """{"index":"mass","op":"any_of","values":["1"],"number":"1"}""").RootElement; + + var validator = new MiniJsonSchemaValidator(SchemaDir()); + var errors = validator.Validate(instance, "cultnet.selection.schema.json#/$defs/fieldPredicate"); + + Assert.That(errors, Is.Not.Empty); + } + + [Test] + public void FieldPredicate_ValuesOnly_MatchesItsSchema() + { + var instance = JsonDocument.Parse( + """{"index":"kind","op":"any_of","values":["weapon"]}""").RootElement; + + var validator = new MiniJsonSchemaValidator(SchemaDir()); + var errors = validator.Validate(instance, "cultnet.selection.schema.json#/$defs/fieldPredicate"); + + Assert.That(errors, Is.Empty, string.Join("; ", errors)); + } + + [Test] + public void FieldPredicate_NumberOnly_MatchesItsSchema() + { + var instance = JsonDocument.Parse( + """{"index":"mass","op":"gt","number":"1"}""").RootElement; + + var validator = new MiniJsonSchemaValidator(SchemaDir()); + var errors = validator.Validate(instance, "cultnet.selection.schema.json#/$defs/fieldPredicate"); + + Assert.That(errors, Is.Empty, string.Join("; ", errors)); + } + + // R-X (docs/cultnet-selection-cut.md, fix batch 4): CultNetErrorMessage is a MessagePack-CSharp + // map-mode object - routingHint/code/details are always written, nil included, the same as any + // other declared key. Soul's P7 found the schema disagreed with the real bytes on exactly this: + // routingHint undeclared, additionalProperties:false, and details.asOf/current typed as a plain + // integer instead of ["integer","null"]. This decodes real bytes for all four refusal codes. + [Test] + public void ErrorMessage_SelectionInvalidRealBytes_MatchesItsSchema() + { + var ex = new CultNetSelectionInvalidException("schemas", null, "selection.schemas must be non-empty."); + AssertErrorMatchesSchema(CultNetErrorMessage.ForSelectionInvalid(ex)); + } + + [Test] + public void ErrorMessage_CursorStaleRealBytes_MatchesItsSchema() + { + var ex = new CultNetSelectionCursorException("cursor_stale", "stale", asOf: 3, current: 7); + AssertErrorMatchesSchema(CultNetErrorMessage.ForCursor(ex)); + } + + [Test] + public void ErrorMessage_CursorInvalidRealBytes_MatchesItsSchema() + { + var ex = new CultNetSelectionCursorException("cursor_invalid", "The cursor does not decode."); + AssertErrorMatchesSchema(CultNetErrorMessage.ForCursor(ex)); + } + + [Test] + public void ErrorMessage_ReferenceOutsideTargetRealBytes_MatchesItsSchema() + { + var ex = new CultNetSelectionReferenceOutsideTargetException("citer-schema", "citer-key", "Design", "outside-schema", "outside-key"); + AssertErrorMatchesSchema(CultNetErrorMessage.ForReferenceOutsideTarget(ex)); + } + + private static void AssertErrorMatchesSchema(CultNetErrorMessage message) + { + var validator = new MiniJsonSchemaValidator(SchemaDir()); + validator.AssertValid(ToJson(message), "cultnet.error.schema.json"); + } + } +} diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs new file mode 100644 index 00000000..b2dfcf3c --- /dev/null +++ b/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs @@ -0,0 +1,559 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Caching; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Networking.Tests +{ + // CultNet typed selection, Cut 1, commit 1 (docs/cultnet-selection-cut.md): the shape, its + // validation door and the one evaluator. Independent of any live server/client transport - these + // exercise CultNetSelection/CultNetSelectionEvaluator directly against a registry and a hand-built + // row set, matching section 10's S1-S23 naming. + public sealed class CultNetSelectionEvaluatorTests + { + private static CultDocumentRegistry Registry() => CultDocumentRegistry.ForTypes(new[] + { + typeof(SelLeafA), typeof(SelLeafB), typeof(SelCiter) + }); + + private static CultNetSelectionEvaluator.Row Row(CultDocumentRegistry registry, object document, string key, long ordinal) => + new(registry.GetRequired(document.GetType()), new CultRecordKey(key), document, ordinal); + + // S1: an index no reachable schema declares, an empty values list, and an empty schemas/keys + // list are refused typed at the door, never answered as an empty page. + [Test] + public void ValidationRefusesUndeclaredIndexRoleAndEmptyLists() + { + var descriptors = Registry().AllDescriptors.ToArray(); + + AssertInvalid(new CultNetSelection { Schemas = Array.Empty() }, descriptors, "schemas"); + AssertInvalid(new CultNetSelection { Keys = Array.Empty() }, descriptors, "keys"); + AssertInvalid( + new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "not_declared", Op = "any_of", Values = new[] { "x" } } } }, + descriptors, + "fields[0].index"); + AssertInvalid( + new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "any_of", Values = Array.Empty() } } }, + descriptors, + "fields[0].values"); + AssertInvalid( + new CultNetSelection { Cited = new CultNetIncoming { Role = "no_such_role", Exists = true } }, + descriptors, + "cited.role"); + } + + private static void AssertInvalid(CultNetSelection selection, IReadOnlyList descriptors, string expectedField) + { + var ex = Assert.Throws(() => selection.Validate(descriptors)); + Assert.That(ex!.Field, Is.EqualTo(expectedField)); + } + + // Q-J (docs/cultnet-selection-cut.md, section 2 "Numbers", 2026-09-22): the door refuses every + // non-canonical spelling of a comparison number, rather than normalising it - a leading '+', a + // trailing fractional zero, a leading integer zero, exponent notation, and negative zero. + [TestCase("+1")] + [TestCase("1.0")] + [TestCase("01")] + [TestCase("1e3")] + [TestCase("-0")] + public void ValidationRefusesNonCanonicalNumberSpellings(string spelling) + { + var descriptors = Registry().AllDescriptors.ToArray(); + AssertInvalid( + new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "gt", Number = spelling } } }, + descriptors, + "fields[0].number"); + } + + // S2: fields conjoin; any_of matches on membership. + [Test] + public void EvaluatorConjoinsAnyOfPredicatesOverDeclaredIndexes() + { + var registry = Registry(); + var a = new SelLeafA { Name = "sword", Kind = "weapon", Mass = 3 }; + var b = new SelLeafA { Name = "shield", Kind = "armor", Mass = 3 }; + var rows = new[] { Row(registry, a, "a", 1), Row(registry, b, "b", 2) }; + var selection = new CultNetSelection + { + Fields = new[] + { + new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon" } }, + new CultNetFieldPredicate { Index = "mass", Op = "ge", Number = "3" } + } + }; + + var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + + Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a" })); + } + + // S3: order is (ordinal, schemaId, recordKey) ascending, reversed under descending. + [Test] + public void EvaluatorOrdersByOrdinalThenIdentityAndReverses() + { + var registry = Registry(); + var rows = new[] + { + Row(registry, new SelLeafA { Name = "c", Kind = "k", Mass = 1 }, "c", 3), + Row(registry, new SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1), + Row(registry, new SelLeafA { Name = "b", Kind = "k", Mass = 1 }, "b", 2) + }; + + var ascending = CultNetSelectionEvaluator.Select(registry, rows, new CultNetSelection(), asOf: 1); + Assert.That(ascending.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a", "b", "c" })); + + var descending = CultNetSelectionEvaluator.Select(registry, rows, new CultNetSelection { Descending = true }, asOf: 1); + Assert.That(descending.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "c", "b", "a" })); + } + + // S4: a page walk visits every row exactly once and the last page carries no cursor. + [Test] + public void EvaluatorPagesExactlyOnceAndTheLastPageSaysSo() + { + var registry = Registry(); + var rows = Enumerable.Range(0, 5) + .Select(i => Row(registry, new SelLeafA { Name = $"n{i}", Kind = "k", Mass = i }, $"k{i}", i)) + .ToArray(); + + var selection = new CultNetSelection { Limit = 2 }; + var seen = new List(); + string? cursor = null; + for (var guard = 0; guard < 10; guard++) + { + selection.Cursor = cursor; + var page = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + seen.AddRange(page.Rows.Select(r => r.Key.Value)); + if (page.NextCursor == null) break; + cursor = page.NextCursor; + } + + Assert.That(seen, Is.EqualTo(new[] { "k0", "k1", "k2", "k3", "k4" })); + } + + // S5: a cursor answers only the asOf and the selection it was minted for. + [Test] + public void EvaluatorRefusesAStaleCursorAndAMismatchedSelection() + { + var registry = Registry(); + var rows = new[] + { + Row(registry, new SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1), + Row(registry, new SelLeafA { Name = "b", Kind = "k", Mass = 1 }, "b", 2) + }; + var selection = new CultNetSelection { Limit = 1 }; + var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + Assert.That(first.NextCursor, Is.Not.Null); + + selection.Cursor = first.NextCursor; + Assert.That( + () => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 2), + Throws.TypeOf() + .With.Property(nameof(CultNetSelectionCursorException.Code)).EqualTo("cursor_stale")); + + var otherSelection = new CultNetSelection { Limit = 1, Cursor = first.NextCursor, Descending = true }; + Assert.That( + () => CultNetSelectionEvaluator.Select(registry, rows, otherSelection, asOf: 1), + Throws.TypeOf() + .With.Property(nameof(CultNetSelectionCursorException.Code)).EqualTo("cursor_invalid")); + } + + // R-N (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3): every refusal maps to a + // wire code, and selection_invalid/cursor_stale carry structured details, on cultnet.error.v0. + // Each test here decodes the bytes a peer actually receives (CultNetSchemaMessageSerialization + // round trip), not the in-process exception or message object. + [Test] + public void ErrorMessage_WireDecodesSelectionInvalidCodeAndDetails() + { + var registry = Registry(); + var ex = Assert.Throws( + () => new CultNetSelection { Schemas = Array.Empty() }.Validate(registry.AllDescriptors.ToArray()))!; + + var payload = CultNetSchemaMessageSerialization.Serialize(CultNetErrorMessage.ForSelectionInvalid(ex)); + var decoded = (CultNetErrorMessage)CultNetSchemaMessageSerialization.Deserialize(payload); + + Assert.That(decoded.Code, Is.EqualTo("selection_invalid")); + Assert.That(decoded.Details, Is.Not.Null); + Assert.That(decoded.Details!.Field, Is.EqualTo("schemas")); + Assert.That(decoded.Details!.Value, Is.Null); + } + + [Test] + public void ErrorMessage_WireDecodesCursorStaleCodeAndDetails() + { + var registry = Registry(); + var rows = new[] + { + Row(registry, new SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1), + Row(registry, new SelLeafA { Name = "b", Kind = "k", Mass = 1 }, "b", 2) + }; + var selection = new CultNetSelection { Limit = 1 }; + var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + selection.Cursor = first.NextCursor; + var ex = Assert.Throws( + () => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 2))!; + + var payload = CultNetSchemaMessageSerialization.Serialize(CultNetErrorMessage.ForCursor(ex)); + var decoded = (CultNetErrorMessage)CultNetSchemaMessageSerialization.Deserialize(payload); + + Assert.That(decoded.Code, Is.EqualTo("cursor_stale")); + Assert.That(decoded.Details, Is.Not.Null); + Assert.That(decoded.Details!.AsOf, Is.EqualTo(1ul)); + Assert.That(decoded.Details!.Current, Is.EqualTo(2ul)); + } + + [Test] + public void ErrorMessage_WireDecodesCursorInvalidCodeWithNoDetails() + { + var registry = Registry(); + var rows = new[] + { + Row(registry, new SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1), + Row(registry, new SelLeafA { Name = "b", Kind = "k", Mass = 1 }, "b", 2) + }; + var selection = new CultNetSelection { Limit = 1 }; + var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + var mismatched = new CultNetSelection { Limit = 1, Cursor = first.NextCursor, Descending = true }; + var ex = Assert.Throws( + () => CultNetSelectionEvaluator.Select(registry, rows, mismatched, asOf: 1))!; + + var payload = CultNetSchemaMessageSerialization.Serialize(CultNetErrorMessage.ForCursor(ex)); + var decoded = (CultNetErrorMessage)CultNetSchemaMessageSerialization.Deserialize(payload); + + Assert.That(decoded.Code, Is.EqualTo("cursor_invalid")); + Assert.That(decoded.Details, Is.Null); + } + + [Test] + public void ErrorMessage_WireDecodesReferenceOutsideTargetCodeWithNoDetails() + { + var leafB = new SelLeafB { Name = "b", Kind = "k", Mass = 1 }; + var offTargetCiter = new SelCiterNarrow { Name = "bad", NarrowRef = new CultRecordRef(new CultRecordKey("b")) }; + var narrowRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(SelLeafA), typeof(SelLeafB), typeof(SelCiterNarrow) }); + var rows = new[] + { + new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired(), new CultRecordKey("b"), leafB, 1), + new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired(), new CultRecordKey("bad"), offTargetCiter, 2) + }; + var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = "NarrowRef", Exists = true } }; + var ex = Assert.Throws( + () => CultNetSelectionEvaluator.Select(narrowRegistry, rows, selection, asOf: 1))!; + + var payload = CultNetSchemaMessageSerialization.Serialize(CultNetErrorMessage.ForReferenceOutsideTarget(ex)); + var decoded = (CultNetErrorMessage)CultNetSchemaMessageSerialization.Deserialize(payload); + + Assert.That(decoded.Code, Is.EqualTo("reference_outside_target")); + Assert.That(decoded.Details, Is.Null); + // ForReferenceOutsideTarget used to prefix "reference_outside_target: " a second time on + // top of the exception's own message, which already carries it - a peer received the code + // name twice. One prefix only, on the wire bytes a peer actually decodes. + Assert.That(decoded.Error, Does.StartWith("reference_outside_target: ")); + Assert.That(decoded.Error, Does.Not.Contain("reference_outside_target: reference_outside_target:")); + } + + // S6: the hop follows one declared reference by role; a second role is not followed. + [Test] + public void EvaluatorHopsOneEdgeByDeclaredReferenceAndRole() + { + var registry = Registry(); + var target = new SelLeafA { Name = "target", Kind = "k", Mass = 1 }; + var citer = new SelCiter { Name = "citer", Design = new CultRecordRef(new CultRecordKey("target")) }; + var rows = new[] { Row(registry, target, "target", 1), Row(registry, citer, "citer", 2) }; + + var selection = new CultNetSelection + { + Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = registry.GetRequired().SchemaId, RecordKey = "target" }, Role = "Design" } + }; + var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "citer" })); + Assert.That(evaluation.Edges.Single().Role, Is.EqualTo("Design")); + + var wrongRole = new CultNetSelection + { + Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = registry.GetRequired().SchemaId, RecordKey = "target" }, Role = "NotARole" } + }; + // Validate refuses an undeclared role before evaluation ever runs (S1). + Assert.Throws(() => wrongRole.Validate(registry.AllDescriptors.ToArray())); + } + + // S7: cited { exists: false } is the one negation. + [Test] + public void EvaluatorNegatedIncomingEdgeIsTheOnlyNegation() + { + var registry = Registry(); + var cited = new SelLeafA { Name = "cited", Kind = "k", Mass = 1 }; + var uncited = new SelLeafA { Name = "uncited", Kind = "k", Mass = 1 }; + var citer = new SelCiter { Name = "citer", Design = new CultRecordRef(new CultRecordKey("cited")) }; + var rows = new[] { Row(registry, cited, "cited", 1), Row(registry, uncited, "uncited", 2), Row(registry, citer, "citer", 3) }; + + var exists = CultNetSelectionEvaluator.Select(registry, rows, + new CultNetSelection { Schemas = new[] { registry.GetRequired().SchemaId }, Cited = new CultNetIncoming { Role = "Design", Exists = true } }, + asOf: 1); + Assert.That(exists.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "cited" })); + + var absent = CultNetSelectionEvaluator.Select(registry, rows, + new CultNetSelection { Schemas = new[] { registry.GetRequired().SchemaId }, Cited = new CultNetIncoming { Role = "Design", Exists = false } }, + asOf: 1); + Assert.That(absent.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "uncited" })); + } + + // S8: header projection carries no payload; edges under header carry no payload either (S20). + [Test] + public void HeaderProjectionCarriesNoPayload() + { + var record = new CultNetRawDocumentRecord + { + SchemaId = "s", RecordKey = "k", StoredAt = "now", PayloadEncoding = "messagepack", Payload = new byte[] { 1, 2, 3 } + }; + var header = CultNetRawDocumentHeader.FromRecord(record); + Assert.That(header.SchemaId, Is.EqualTo("s")); + // CultNetRawDocumentHeader has no Payload/PayloadEncoding members at all - a compile-time + // guarantee, not merely a null check; this test exists to name the rule. + } + + // S16: the four comparisons at the boundary, including equal-to-the-compared-number rows. + [Test] + public void EvaluatorComparesNumbersAtTheBoundaryForEachOfTheFourOperators() + { + var registry = Registry(); + var rows = new[] + { + Row(registry, new SelLeafA { Name = "lo", Kind = "k", Mass = 4 }, "lo", 1), + Row(registry, new SelLeafA { Name = "eq", Kind = "k", Mass = 5 }, "eq", 2), + Row(registry, new SelLeafA { Name = "hi", Kind = "k", Mass = 6 }, "hi", 3) + }; + + string[] Matches(string op) => CultNetSelectionEvaluator.Select( + registry, rows, + new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = op, Number = "5" } } }, + asOf: 1).Rows.Select(r => r.Key.Value).OrderBy(k => k, StringComparer.Ordinal).ToArray(); + + Assert.That(Matches("lt"), Is.EqualTo(new[] { "lo" })); + Assert.That(Matches("le"), Is.EqualTo(new[] { "eq", "lo" })); + Assert.That(Matches("ge"), Is.EqualTo(new[] { "eq", "hi" })); + Assert.That(Matches("gt"), Is.EqualTo(new[] { "hi" })); + } + + // S17: an alias declared once on the abstract middle reaches every leaf that inherits it. + [Test] + public void EvaluatorAppliesAnInheritedAliasToEveryReachableLeaf() + { + var registry = Registry(); + var a = new SelLeafA { Name = "a", Kind = "k", Mass = 20 }; + var b = new SelLeafB { Name = "b", Kind = "k", Mass = 20 }; + var rows = new[] { Row(registry, a, "a", 1), Row(registry, b, "b", 2) }; + + var evaluation = CultNetSelectionEvaluator.Select( + registry, rows, + new CultNetSelection + { + Schemas = new[] { registry.GetRequired().SchemaId, registry.GetRequired().SchemaId }, + Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "gt", Number = "10" } } + }, + asOf: 1); + + Assert.That(evaluation.Rows.Select(r => r.Key.Value).OrderBy(k => k, StringComparer.Ordinal), Is.EqualTo(new[] { "a", "b" })); + } + + // S18: an edge naming a row outside the reference's declared target refuses the selection. + [Test] + public void EvaluatorRefusesAnEdgeOutsideItsDeclaredTarget() + { + var registry = Registry(); + var leafB = new SelLeafB { Name = "b", Kind = "k", Mass = 1 }; + // Design's declared target is SelFixtureMiddle (an abstract type with no schema of its own), + // and both leaves are within that target, so this citer is a legitimate in-target edge. + var okCiter = new SelCiter { Name = "ok", Design = new CultRecordRef(new CultRecordKey("b")) }; + var refA = registry.GetRequired(); + var offTargetCiter = new SelCiterNarrow { Name = "bad", NarrowRef = new CultRecordRef(new CultRecordKey("b")) }; + var narrowRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(SelLeafA), typeof(SelLeafB), typeof(SelCiterNarrow) }); + var rows = new[] + { + new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired(), new CultRecordKey("b"), leafB, 1), + new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired(), new CultRecordKey("bad"), offTargetCiter, 2) + }; + + var selection = new CultNetSelection + { + Cited = new CultNetIncoming { Role = "NarrowRef", Exists = true } + }; + Assert.Throws( + () => CultNetSelectionEvaluator.Select(narrowRegistry, rows, selection, asOf: 1)); + } + + // R-T(b) (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3): EnsureWithinDeclaredTarget + // checks every edge against its declared target, including a many reference declared with no + // explicit [CultReference(typeof(...))] - CultDocumentRegistry.PersistedMember used to leave + // TargetType null for that shape (only the explicit-target and scalar CultRecordRef cases were + // inferred), which made EnsureWithinDeclaredTarget skip the check outright for every edge through + // it. SelCiterManyUntyped's ManyRefs declares many:true with no explicit target, so its target is + // inferred from the CultRecordRef element alone; a SelLeafB target must still refuse. + [Test] + public void EvaluatorRefusesAnOutOfTargetEdgeThroughAnImplicitlyTypedManyReference() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SelLeafA), typeof(SelLeafB), typeof(SelCiterManyUntyped) }); + var leafA = new SelLeafA { Name = "a", Kind = "k", Mass = 1 }; + var leafB = new SelLeafB { Name = "b", Kind = "k", Mass = 1 }; + var okCiter = new SelCiterManyUntyped + { + Name = "ok", + ManyRefs = new[] { new CultRecordRef(new CultRecordKey("a")) } + }; + var offTargetCiter = new SelCiterManyUntyped + { + Name = "bad", + ManyRefs = new[] { new CultRecordRef(new CultRecordKey("b")) } + }; + + var okRows = new[] + { + new CultNetSelectionEvaluator.Row(registry.GetRequired(), new CultRecordKey("a"), leafA, 1), + new CultNetSelectionEvaluator.Row(registry.GetRequired(), new CultRecordKey("ok"), okCiter, 2) + }; + var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = "ManyRefs", Exists = true } }; + var okEvaluation = CultNetSelectionEvaluator.Select(registry, okRows, selection, asOf: 1); + Assert.That(okEvaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a" })); + + var badRows = new[] + { + new CultNetSelectionEvaluator.Row(registry.GetRequired(), new CultRecordKey("b"), leafB, 1), + new CultNetSelectionEvaluator.Row(registry.GetRequired(), new CultRecordKey("bad"), offTargetCiter, 2) + }; + Assert.Throws( + () => CultNetSelectionEvaluator.Select(registry, badRows, selection, asOf: 1)); + } + + // S23: the evaluator matches every row sharing an index value, never the cache's own + // last-writer-wins unique-index map. Three rows share "shared" here. + [Test] + public void EvaluatorMatchesEveryRowSharingAnIndexValueNotAWinner() + { + var registry = Registry(); + var rows = new[] + { + Row(registry, new SelLeafA { Name = "1", Kind = "shared", Mass = 1 }, "1", 1), + Row(registry, new SelLeafA { Name = "2", Kind = "shared", Mass = 1 }, "2", 2), + Row(registry, new SelLeafA { Name = "3", Kind = "shared", Mass = 1 }, "3", 3) + }; + + var evaluation = CultNetSelectionEvaluator.Select( + registry, rows, + new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "shared" } } } }, + asOf: 1); + + Assert.That(evaluation.Rows.Select(r => r.Key.Value).OrderBy(k => k, StringComparer.Ordinal), Is.EqualTo(new[] { "1", "2", "3" })); + } + + // S1-Loose (docs/cultnet-selection-cut.md, "Self's rulings for fix batch 3" -> "Surviving + // mutants"): ReachableSchemas narrows to the schemas selection.schemas actually names; a mutant + // that widens it back to every registered descriptor lets a predicate on an index only an + // EXCLUDED schema declares reach the door silently (matching nothing, never refusing), instead + // of the typed "not declared by any schema this selection can reach" refusal. "kind" is declared + // on SelLeafA/SelLeafB, not on SelCiter; Schemas here names only SelCiter. + [Test] + public void ValidationRefusesAnIndexDeclaredOnlyOnASchemaTheSelectionDoesNotReach() + { + var registry = Registry(); + var descriptors = registry.AllDescriptors.ToArray(); + var selection = new CultNetSelection + { + Schemas = new[] { registry.GetRequired().SchemaId }, + Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "x" } } } + }; + + var ex = Assert.Throws(() => selection.Validate(descriptors)); + Assert.That(ex!.Field, Is.EqualTo("fields[0].index")); + } + + // S2-AllOf (fix batch 3 "Surviving mutants"): any_of matches when the row's declared value + // equals ANY one of the predicate's values, not when it equals every one of them - impossible + // for a single-valued index whenever Values carries more than one entry, so a mutant that + // requires all of them makes a real multi-value any_of predicate match nothing. + [Test] + public void EvaluatorAnyOfMatchesWhenTheRowsValueIsAnyOneOfSeveralPredicateValues() + { + var registry = Registry(); + var row = Row(registry, new SelLeafA { Name = "a", Kind = "weapon", Mass = 1 }, "a", 1); + var selection = new CultNetSelection + { + Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "armor", "weapon" } } } + }; + + var evaluation = CultNetSelectionEvaluator.Select(registry, new[] { row }, selection, asOf: 1); + + Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a" })); + } + + public abstract class SelFixtureMiddle + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + [CultIndex("mass")] + public float Mass; + + [Key(2)] + [CultIndex("kind")] + public string Kind = string.Empty; + } + + [CultDocument("cultnet.selection-tests.leaf_a", "cultnet.selection-tests.leaf_a.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class SelLeafA : SelFixtureMiddle + { + } + + [CultDocument("cultnet.selection-tests.leaf_b", "cultnet.selection-tests.leaf_b.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class SelLeafB : SelFixtureMiddle + { + } + + [CultDocument("cultnet.selection-tests.citer", "cultnet.selection-tests.citer.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class SelCiter + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + [CultReference(typeof(SelFixtureMiddle))] + public CultRecordRef Design; + } + + [CultDocument("cultnet.selection-tests.citer_narrow", "cultnet.selection-tests.citer_narrow.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class SelCiterNarrow + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + // Declared target is the single leaf SelLeafA, not the abstract middle - a value pointing at + // a SelLeafB row is outside this reference's declared target set (S18). + [Key(1)] + [CultReference(typeof(SelLeafA))] + public CultRecordRef NarrowRef; + } + + [CultDocument("cultnet.selection-tests.citer_many_untyped", "cultnet.selection-tests.citer_many_untyped.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class SelCiterManyUntyped + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + // No explicit [CultReference(typeof(...))] target - the target is inferred from the + // CultRecordRef element alone (R-T(b)). + [Key(1)] + [CultReference(many: true)] + public CultRecordRef[] ManyRefs = Array.Empty>(); + } + } +} diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs new file mode 100644 index 00000000..9b6959c0 --- /dev/null +++ b/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs @@ -0,0 +1,812 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Logging; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Networking.Tests +{ + // CultNet typed selection, Cut 1, second Hands pass on the fix batch (docs/cultnet-selection-cut.md, + // "Self's rulings for the Cut 1 fix batch" and "The C# half of the fix batch landed"): regression + // tests for the rules the first Hands pass fixed but left proven only by 7903853..3d32c67's own + // (unnamed, un-mutated) coverage or, for R-E's cites.target.schemaId door refusal, left entirely + // unimplemented. Independent of any live server/client transport, same style as + // CultNetSelectionEvaluatorTests - a hand-built row set against CultNetSelectionEvaluator directly. + // Reuses CultNetSelectionEvaluatorTests' public fixture types (SelLeafA/SelLeafB/SelFixtureMiddle/ + // SelCiter/SelCiterNarrow) rather than declaring a fourth near-identical set. + public sealed class CultNetSelectionFixBatchTests + { + private static CultDocumentRegistry Registry() => CultDocumentRegistry.ForTypes(new[] + { + typeof(CultNetSelectionEvaluatorTests.SelLeafA), + typeof(CultNetSelectionEvaluatorTests.SelLeafB), + typeof(CultNetSelectionEvaluatorTests.SelCiter) + }); + + private static CultNetSelectionEvaluator.Row Row(CultDocumentRegistry registry, object document, string key, long ordinal) => + new(registry.GetRequired(document.GetType()), new CultRecordKey(key), document, ordinal); + + // R-B: edges are anchored to the row the hop direction actually reports against - the cited row + // under `cited`, not the citer - and edge order is deterministic: page-row order, then + // (from, role, to) in code-point order. Two citers citing the same target through the same role + // put two edges on one page row (`target`), so only a real (from, role, to) tie-break - not + // insertion order - can produce "citer-a" before "citer-b". + [Test] + public void Evaluator_AnchorsCitedEdgesToTheCitedRowAndOrdersThemByFromThenRole() + { + var registry = Registry(); + var target = new CultNetSelectionEvaluatorTests.SelLeafA { Name = "target", Kind = "k", Mass = 1 }; + var citerB = new CultNetSelectionEvaluatorTests.SelCiter { Name = "b", Design = new CultRecordRef(new CultRecordKey("target")) }; + var citerA = new CultNetSelectionEvaluatorTests.SelCiter { Name = "a", Design = new CultRecordRef(new CultRecordKey("target")) }; + // Supplied out of code-point order (b before a) on purpose - the assertion only passes if + // EdgesFor's own tie-break sorts them, not if it merely preserves row-supply order. + var rows = new[] + { + Row(registry, target, "target", 1), + Row(registry, citerB, "citer-b", 2), + Row(registry, citerA, "citer-a", 3) + }; + + var selection = new CultNetSelection + { + Schemas = new[] { registry.GetRequired().SchemaId }, + Cited = new CultNetIncoming { Role = "Design", Exists = true } + }; + var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + + Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "target" })); + // Anchor: both edges must be reported (dropped entirely if wrongly anchored to the citer, + // which is not on this page under `cited`). + Assert.That(evaluation.Edges.Select(e => e.From.Key.Value), Is.EqualTo(new[] { "citer-a", "citer-b" })); + Assert.That(evaluation.Edges.All(e => e.To.Key.Value == "target"), Is.True); + } + + // R-C: every ordered comparison the vocabulary makes uses Unicode code-point order, not UTF-16 + // code-unit order. U+E000 (BMP private use) sorts before U+1F602 (astral, "😂") in code-point + // order despite the astral character's leading UTF-16 surrogate (0xD83D) being numerically + // smaller than 0xE000 - a code-unit ("ordinal") compare sorts it the other way. + private const string BmpKey = "-row"; + private const string AstralKey = "😂-row"; + + [Test] + public void Evaluator_OrdersAstralKeysByCodePointNotUtf16CodeUnit() + { + var registry = Registry(); + var rows = new[] + { + Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "astral", Kind = "k", Mass = 1 }, AstralKey, 1), + Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "bmp", Kind = "k", Mass = 1 }, BmpKey, 1) + }; + + var evaluation = CultNetSelectionEvaluator.Select(registry, rows, new CultNetSelection(), asOf: 1); + + Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { BmpKey, AstralKey })); + } + + // R-C, the cursor half: the cursor's position search must compare by the same code-point order + // the row tiebreak uses, or a page walk over astral/BMP-boundary keys can skip or repeat rows. + [Test] + public void Evaluator_CursorPositionUsesCodePointOrderMatchingTheRowTiebreak() + { + var registry = Registry(); + var rows = new[] + { + Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "astral", Kind = "k", Mass = 1 }, AstralKey, 1), + Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "bmp", Kind = "k", Mass = 1 }, BmpKey, 1) + }; + var selection = new CultNetSelection { Limit = 1 }; + + var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + Assert.That(first.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { BmpKey })); + Assert.That(first.NextCursor, Is.Not.Null); + + selection.Cursor = first.NextCursor; + var second = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + Assert.That(second.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { AstralKey })); + } + + // R-E: cites.target.schemaId is matched through the same alias matcher `schemas` uses (a + // SchemaName, not only the real content-hash SchemaId), and a target whose schema matches + // nothing declared is refused at the door - never answered with a quietly empty page. + [Test] + public void Evaluator_MatchesCitesTargetThroughTheAliasMatcher() + { + var registry = Registry(); + var target = new CultNetSelectionEvaluatorTests.SelLeafA { Name = "target", Kind = "k", Mass = 1 }; + var citer = new CultNetSelectionEvaluatorTests.SelCiter { Name = "citer", Design = new CultRecordRef(new CultRecordKey("target")) }; + var rows = new[] { Row(registry, target, "target", 1), Row(registry, citer, "citer", 2) }; + + var alias = registry.GetRequired().SchemaName; + var selection = new CultNetSelection + { + Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = alias, RecordKey = "target" } } + }; + var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + + Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "citer" })); + } + + [Test] + public void Validation_RefusesACitesTargetSchemaThatMatchesNoDeclaredSchema() + { + var descriptors = Registry().AllDescriptors.ToArray(); + var selection = new CultNetSelection + { + Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = "no-such-schema", RecordKey = "target" } } + }; + + var ex = Assert.Throws(() => selection.Validate(descriptors)); + Assert.That(ex!.Field, Is.EqualTo("cites.target.schemaId")); + } + + // R-E: cites.target.schemaId is resolved against every declared schema, not only the schemas + // `selection.Schemas` itself reaches - a cites target is a separate structural reference, not + // filtered by the selection's own schema allowlist. A plausible weaker implementation reuses the + // `reachable` set already computed for Fields validation and wrongly refuses a valid target whose + // schema sits outside it. + [Test] + public void Validation_ResolvesACitesTargetSchemaEvenWhenNotInTheSelectionsOwnSchemasFilter() + { + var registry = Registry(); + var descriptors = registry.AllDescriptors.ToArray(); + var citerSchemaId = registry.GetRequired().SchemaId; + var leafAlias = registry.GetRequired().SchemaName; + + var selection = new CultNetSelection + { + Schemas = new[] { citerSchemaId }, + Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = leafAlias, RecordKey = "target" } } + }; + + Assert.DoesNotThrow(() => selection.Validate(descriptors)); + } + + // R-F: the door is inside Select/EvaluateAll unconditionally - there is no public entry point + // that evaluates a selection without validating it first. + [Test] + public void Evaluator_SelectValidatesFirstAndNeverAnswersAnInvalidSelection() + { + var registry = Registry(); + var rows = new[] { Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1) }; + var invalid = new CultNetSelection { Schemas = Array.Empty() }; + + Assert.Throws(() => CultNetSelectionEvaluator.Select(registry, rows, invalid, asOf: 1)); + } + + // R-H: the cursor digest length-prefixes every string and list, so no delimiter choice can make + // two different selections collide. The pre-fix scheme joined keys with "," and a field + // predicate's values with "|" with no length prefix - Keys=["a,b"] (one string containing a + // literal comma) digested the same as Keys=["a","b"] (two strings), and a field's + // Values=["a|b"] digested the same as Values=["a","b"]. + [Test] + public void ComputeDigest_DoesNotCollideOnDelimiterAmbiguousKeysOrFieldValues() + { + var keysOne = new CultNetSelection { Keys = new[] { "a,b" } }; + var keysTwo = new CultNetSelection { Keys = new[] { "a", "b" } }; + Assert.That( + CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", keysOne), + Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", keysTwo))); + + var valuesOne = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "a|b" } } } }; + var valuesTwo = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "a", "b" } } } }; + Assert.That( + CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", valuesOne), + Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", valuesTwo))); + } + + // S5-DigestNoFields (fix batch 3 "Surviving mutants"): the digest reads selection.Fields - a + // mutant that drops the fields loop would digest two selections identically as long as their + // schemas/keys/hop/projection agree, letting a cursor minted for one selection page a + // differently-fielded one. + [Test] + public void ComputeDigest_DiffersWhenOnlyFieldsDiffer() + { + var withFieldA = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "a" } } } }; + var withFieldB = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "b" } } } }; + var noFields = new CultNetSelection(); + + Assert.That( + CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", withFieldA), + Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", withFieldB))); + Assert.That( + CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", withFieldA), + Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", noFields))); + } + + // R-O (fix batch 3, F6 "cursors are keyed"): a cursor's digest is an HMAC under the answering + // process's key - a page request carrying a cursor minted under a different key (forged, or + // minted by a different process) is refused cursor_invalid, never answered. + [Test] + public void Page_RefusesACursorMintedUnderADifferentKey() + { + var registry = Registry(); + var rows = Enumerable.Range(0, 3) + .Select(i => Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = $"n{i}", Kind = "k", Mass = i }, $"k{i}", i)) + .ToArray(); + var selection = new CultNetSelection { Limit = 1 }; + var mintingKey = CultNetSelectionCursorKey.Random(); + var otherKey = CultNetSelectionCursorKey.Random(); + + var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: mintingKey); + Assert.That(first.NextCursor, Is.Not.Null); + + selection.Cursor = first.NextCursor; + var ex = Assert.Throws( + () => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: otherKey)); + Assert.That(ex!.Code, Is.EqualTo("cursor_invalid")); + } + + // R-O: a cursor minted before a (simulated) restart does not verify against the restarted + // process's fresh key - the same refusal as a forged cursor, because from the answering + // process's point of view they are indistinguishable (F6: "not surviving a restart is the + // accepted cost, not a bug"). + [Test] + public void Page_RefusesACursorMintedBeforeARestart() + { + var registry = Registry(); + var rows = Enumerable.Range(0, 3) + .Select(i => Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = $"n{i}", Kind = "k", Mass = i }, $"k{i}", i)) + .ToArray(); + var selection = new CultNetSelection { Limit = 1 }; + var preRestartKey = CultNetSelectionCursorKey.Random(); + + var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: preRestartKey); + Assert.That(first.NextCursor, Is.Not.Null); + + // A restart is a fresh process key, exactly as CultNetDatabase's constructor mints a fresh + // CursorKey per instance unless one is supplied. + var postRestartKey = CultNetSelectionCursorKey.Random(); + selection.Cursor = first.NextCursor; + var ex = Assert.Throws( + () => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: postRestartKey)); + Assert.That(ex!.Code, Is.EqualTo("cursor_invalid")); + } + + // R-Y (fix batch 4, Soul's P4 "forged cursor position, reused digest"): before this, the HMAC + // covered only the selection, not the cursor's own body - so a forged cursor could keep a + // legitimately minted digest and rewrite the ordinal (or schemaId/recordKey) underneath it, and + // the server would page from wherever the forged position named instead of refusing it. + [Test] + public void Page_RefusesAForgedCursorThatReusesALegitimateDigestUnderARewrittenOrdinal() + { + var registry = Registry(); + var rows = Enumerable.Range(0, 3) + .Select(i => Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = $"n{i}", Kind = "k", Mass = i }, $"k{i}", i)) + .ToArray(); + var selection = new CultNetSelection { Limit = 1 }; + var key = CultNetSelectionCursorKey.Random(); + + var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: key); + Assert.That(first.NextCursor, Is.Not.Null); + var legitimate = CultNetSelectionCursor.Parse(first.NextCursor!); + + var forged = ForgeCursorBody(legitimate.AsOf, ordinal: 999, legitimate.SchemaId, legitimate.RecordKey, legitimate.Digest); + Assert.That(forged, Is.Not.EqualTo(first.NextCursor), "the forged cursor must actually differ from the legitimate one"); + + selection.Cursor = forged; + var ex = Assert.Throws( + () => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: key)); + Assert.That(ex!.Code, Is.EqualTo("cursor_invalid")); + } + + /// + /// Builds a cursor string carrying an arbitrary body under a caller-supplied digest, the same + /// wire shape produces - used to simulate a forged + /// cursor that reuses a legitimately minted digest under a rewritten body (R-Y). + /// + private static string ForgeCursorBody(ulong asOf, long ordinal, string schemaId, string recordKey, string digest) + { + var body = new System.Text.StringBuilder(); + void AppendString(string value) => + body.Append(System.Text.Encoding.UTF8.GetByteCount(value)).Append(':').Append(value); + AppendString(asOf.ToString(System.Globalization.CultureInfo.InvariantCulture)); + AppendString(ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture)); + AppendString(schemaId); + AppendString(recordKey); + AppendString(digest); + var bytes = System.Text.Encoding.UTF8.GetBytes(body.ToString()); + return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + // R-O: the cursor body is length-prefixed, not delimiter-joined, so a record key carrying any + // character at all - including U+241F, which broke the pre-fix delimiter-split parse - round-trips. + [Test] + public void Cursor_RoundTripsARecordKeyContainingU241F() + { + var registry = Registry(); + var weirdKey = "row-␟-two"; + var rows = new[] + { + Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a", Kind = "k", Mass = 0 }, "a-first", 0), + Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "b", Kind = "k", Mass = 1 }, weirdKey, 1) + }; + var selection = new CultNetSelection { Limit = 1 }; + var key = CultNetSelectionCursorKey.Random(); + + var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: key); + Assert.That(first.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a-first" })); + Assert.That(first.NextCursor, Is.Not.Null); + + selection.Cursor = first.NextCursor; + var second = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: key); + Assert.That(second.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { weirdKey })); + Assert.That(second.NextCursor, Is.Null); + } + + /// + /// Builds a cursor body whose 5th (final) field carries a raw, hostile length-prefix + /// spelling instead of a well-formed `AppendString` field - R-AG. The first 4 fields are + /// well-formed so ReadLengthPrefixedFields reaches the hostile one mid-loop rather + /// than failing earlier for an unrelated reason. + /// + private static string CursorWithHostileLastField(string hostileField) + { + var body = new System.Text.StringBuilder(); + void AppendString(string value) => + body.Append(System.Text.Encoding.UTF8.GetByteCount(value)).Append(':').Append(value); + AppendString("1"); + AppendString("5"); + AppendString("schema-a"); + AppendString("k1"); + body.Append(hostileField); + var bytes = System.Text.Encoding.UTF8.GetBytes(body.ToString()); + return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + // R-AG: C#'s own proven overflow (CultNetSelectionEvaluator.cs, ReadLengthPrefixedFields, + // before this cut) - a length prefix of int.MaxValue (2147483647) made `pos + length` + // overflow int math and slip past the guard, so Encoding.UTF8.GetString threw + // ArgumentOutOfRangeException instead of Parse returning the typed cursor_invalid refusal. + [Test] + public void HostileCursor_LengthPrefixNearInt32Max_RefusesTypedNotUntyped() + { + var cursor = CursorWithHostileLastField("2147483647:x"); + Assert.Throws(() => CultNetSelectionCursor.Parse(cursor)); + } + + // R-AG: the mirror-image proven overflow, Rust's (packages/cultnet-rs/src/selection.rs, + // read_length_prefixed_fields, before this cut) - a length prefix of usize::MAX + // (18446744073709551615) overflowed Rust's usize guard and panicked. "Each runtime holding + // exactly the hole the other closed" means neither side's tests are evidence about the + // other - fed here too. int.TryParse refuses a value this wide outright (a different + // failure path than C#'s own overflow above), which must also refuse typed. + [Test] + public void HostileCursor_LengthPrefixNearUSizeMax_RefusesTypedNotUntyped() + { + var cursor = CursorWithHostileLastField("18446744073709551615:x"); + Assert.Throws(() => CultNetSelectionCursor.Parse(cursor)); + } + + // R-AG: a length prefix with more digits than any integer type holds. + [Test] + public void HostileCursor_LengthPrefixWiderThanAnyIntegerType_RefusesTypedNotUntyped() + { + var cursor = CursorWithHostileLastField("999999999999999999999999999999:x"); + Assert.Throws(() => CultNetSelectionCursor.Parse(cursor)); + } + + // R-AG: a negative-looking length prefix - int.TryParse(NumberStyles.None, ...) refuses the + // leading '-' outright (NumberStyles.None permits no sign), a third failure path (neither + // overflow nor digit-count) that must also refuse typed. + [Test] + public void HostileCursor_NegativeLengthPrefix_RefusesTypedNotUntyped() + { + var cursor = CursorWithHostileLastField("-1:x"); + Assert.Throws(() => CultNetSelectionCursor.Parse(cursor)); + } + + // R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical rendering, not the + // string getter's culture-dependent ToString() - a float large enough that .NET's default + // ToString() renders exponent notation ("1E+21") must still match its canonical decimal form. + [Test] + public void Evaluator_AnyOfOnANumericAliasComparesTheCanonicalRenderingNotToString() + { + var registry = Registry(); + var row = new CultNetSelectionEvaluatorTests.SelLeafA { Name = "big", Kind = "k", Mass = 1e21f }; + var rows = new[] { Row(registry, row, "big", 1) }; + var descriptor = registry.GetRequired(); + Assert.That(descriptor.TryGetIndexNumber(row, "mass", out var canonical), Is.True); + // .NET's default ToString() on this magnitude renders exponent notation ("1E+21"); the + // canonical decimal never does - if the two happened to be textually equal this assertion, + // not the mutant, would be the problem, so pin that precondition rather than assume it. + Assert.That(canonical, Does.Not.Contain("E")); + Assert.That(row.Mass.ToString(System.Globalization.CultureInfo.InvariantCulture), Is.Not.EqualTo(canonical)); + + var selection = new CultNetSelection + { + Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "any_of", Values = new[] { canonical! } } } + }; + var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + + Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "big" })); + } + + private static CultDocumentRegistry NarrowAndManyRegistry() => CultDocumentRegistry.ForTypes(new[] + { + typeof(CultNetSelectionEvaluatorTests.SelLeafA), + typeof(CultNetSelectionEvaluatorTests.SelLeafB), + typeof(CultNetSelectionEvaluatorTests.SelCiterNarrow), + typeof(CultNetSelectionEvaluatorTests.SelCiterManyUntyped) + }); + + // R-V (S-1, Soul's P2 "two schemas at one key"): a row's identity is (schemaId, recordKey), not + // the record key alone. SelLeafA and SelLeafB rows both live at key "k"; SelCiterNarrow's + // NarrowRef is declared to SelLeafA only, so it must always resolve to the SelLeafA row at "k", + // never the SelLeafB one - regardless of which of the two same-keyed rows a bare-key dictionary + // would have kept. Before this, byKey was a bare-key dictionary that kept only one of them (last + // writer wins), so which row NarrowRef resolved to - and therefore whether the selection matched + // or refused reference_outside_target - depended on row insertion order. + [TestCase(false)] + [TestCase(true)] + public void Evaluator_CitedResolvesARowSharingItsKeyWithAnotherSchema_ByDeclaredTargetNotInsertionOrder(bool reverseRowOrder) + { + var registry = NarrowAndManyRegistry(); + var leafA = Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "k", 0); + var leafB = Row(registry, new CultNetSelectionEvaluatorTests.SelLeafB { Name = "b", Kind = "k", Mass = 1 }, "k", 1); + var citer = Row(registry, new CultNetSelectionEvaluatorTests.SelCiterNarrow + { + Name = "citer", + NarrowRef = new CultRecordRef(new CultRecordKey("k")) + }, "citer", 2); + + var rows = reverseRowOrder + ? new[] { citer, leafB, leafA } + : new[] { citer, leafA, leafB }; + + var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = "NarrowRef", Exists = true } }; + var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + + // Only the SelLeafA row at "k" is cited - SelLeafB's row at the same key is a different + // identity and must not match, in either row order. + Assert.That(evaluation.Rows.Select(r => (r.Descriptor.SchemaId, r.Key.Value)), + Is.EqualTo(new[] { (registry.GetRequired().SchemaId, "k") })); + } + + // R-AO: the test above covers one in-target candidate and one out - TryResolveReferenceTarget's + // `foreach (var candidate in candidates) { ... return true; }` (CultNetSelectionEvaluator.cs) + // picks the first match, but nothing pinned that rule specifically when *two* in-target rows + // share a key (Soul's SM-4: "the first row in the caller's order wins" was a coincidence of + // source, not a rule). SelCiter.Design's declared target is the abstract SelFixtureMiddle, so + // both SelLeafA and SelLeafB are in-target - which one resolves depends only on which the + // caller listed first in `rows` (byKey's insertion order follows `allRows`). Both orders are + // exercised, each expecting whichever row was listed first in *that* run - proving the rule is + // "first in the caller's order", not a fixed schema preference. + [TestCase(false)] + [TestCase(true)] + public void Evaluator_CitedWithTwoInTargetCandidates_ResolvesToWhicheverTheCallerListedFirst(bool reverseRowOrder) + { + var registry = Registry(); + var leafA = Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "dup", 0); + var leafB = Row(registry, new CultNetSelectionEvaluatorTests.SelLeafB { Name = "b", Kind = "k", Mass = 1 }, "dup", 1); + var citer = Row(registry, new CultNetSelectionEvaluatorTests.SelCiter + { + Name = "citer", + Design = new CultRecordRef(new CultRecordKey("dup")) + }, "citer", 2); + + var rows = reverseRowOrder + ? new[] { citer, leafB, leafA } + : new[] { citer, leafA, leafB }; + var expectedFirstSchemaId = reverseRowOrder + ? registry.GetRequired().SchemaId + : registry.GetRequired().SchemaId; + + var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = "Design", Exists = true } }; + var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + + Assert.That(evaluation.Rows.Select(r => (r.Descriptor.SchemaId, r.Key.Value)), + Is.EqualTo(new[] { (expectedFirstSchemaId, "dup") }), + "must resolve to whichever in-target row the caller listed first, not a fixed schema"); + } + + // R-W (S-2, Soul's P3): EnsureWithinDeclaredTarget's equivalent check must run for every edge a + // many-reference carries, before the citation's own key filters that edge out - not only for the + // edge the citation happens to be asking about. SelCiterManyUntyped.ManyRefs is (by inference, + // R-T(b)) declared to SelLeafA alone; one element points at a SelLeafA row (in-target) and a + // second at a SelLeafB row (out-of-target). Querying `cites` for a record key neither element + // carries must still refuse: the out-of-target edge is wrong regardless of what the caller asked. + [Test] + public void Evaluator_CitesRefusesAnOutOfTargetEdgeEvenWhenTheQueriedKeyMatchesNeitherEdge() + { + var registry = NarrowAndManyRegistry(); + var leafA = Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 0); + var leafB = Row(registry, new CultNetSelectionEvaluatorTests.SelLeafB { Name = "b", Kind = "k", Mass = 1 }, "b", 1); + var citer = Row(registry, new CultNetSelectionEvaluatorTests.SelCiterManyUntyped + { + Name = "citer", + ManyRefs = new[] + { + new CultRecordRef(new CultRecordKey("a")), + new CultRecordRef(new CultRecordKey("b")) + } + }, "citer", 2); + var rows = new[] { leafA, leafB, citer }; + + var selection = new CultNetSelection + { + Cites = new CultNetCitation + { + Target = new CultNetRecordRef { SchemaId = registry.GetRequired().SchemaId, RecordKey = "does-not-exist" }, + Role = "ManyRefs" + } + }; + + var ex = Assert.Throws( + () => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1)); + Assert.That(ex!.ToSchemaId, Is.EqualTo(registry.GetRequired().SchemaId)); + Assert.That(ex.ToKey, Is.EqualTo("b")); + } + + // R-AL/SM-6: the evaluator's own `!member.IsReference` guard in ReferenceMembers, pinned + // directly. CultCache's D10b (CultCache.cs DiscoverMembers, ~1099) now refuses the colliding + // shape at registration, so CultDocumentRegistry.ForTypes can no longer produce a descriptor + // this bug reaches - which is D10b's own test's job, not this one's. This test builds the + // colliding descriptor by hand, bypassing the registry entirely (reflection over the internal + // CultDocumentDescriptor/PersistedMember constructors - the "only door" CultCache.cs:208 + // describes is a door for production code, not for pinning a caller-side guard in isolation), + // so the guard is proven on its own: even a descriptor D10b never had a chance to validate + // must not let a data member answer for a reference member's role. + [Test] + public void ReferenceMembersExcludesADataMemberSharingAReferenceMembersRoleEvenOffARegistryBypassingDescriptor() + { + var cachingAssembly = typeof(CultDocumentRegistry).Assembly; + var persistedMemberType = cachingAssembly.GetType("GameCult.Caching.CultDocumentRegistry+PersistedMember", throwOnError: true)!; + var memberDescriptorType = cachingAssembly.GetType("GameCult.Caching.CultDocumentMemberDescriptor", throwOnError: true)!; + var descriptorCtor = typeof(CultDocumentDescriptor) + .GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance).Single(); + + object MakeMember(MemberInfo member, Type memberType, int slot, string? indexAlias, bool isReference, Type? targetType) + { + var instance = Activator.CreateInstance(persistedMemberType, nonPublic: true)!; + void Set(string property, object? value) => persistedMemberType.GetProperty(property)!.SetValue(instance, value); + Set("Member", member); + Set("MemberType", memberType); + Set("Slot", slot); + Set("IsName", false); + Set("IndexAlias", indexAlias); + Set("IsReference", isReference); + Set("IsMany", false); + Set("TargetSchemaName", null); + Set("TargetType", targetType); + Set("IsNumeric", false); + return instance; + } + + // A plain data member "Owner" with no [CultIndex] (its role falls back to its bare name) + // and a reference member whose alias is explicitly "Owner" - the exact shape Soul probed. + var ownerMember = MakeMember(typeof(GuardProbeShape).GetField(nameof(GuardProbeShape.Owner))!, + typeof(string), 0, indexAlias: null, isReference: false, targetType: null); + var refMember = MakeMember(typeof(GuardProbeShape).GetField(nameof(GuardProbeShape.Ref))!, + typeof(CultRecordRef), 1, indexAlias: "Owner", isReference: true, + targetType: typeof(CultNetSelectionEvaluatorTests.SelLeafA)); + + var richMembers = Array.CreateInstance(persistedMemberType, 2); + richMembers.SetValue(ownerMember, 0); + richMembers.SetValue(refMember, 1); + var members = Array.CreateInstance(memberDescriptorType, 0); + + var descriptor = (CultDocumentDescriptor)descriptorCtor.Invoke(new object?[] + { + typeof(GuardProbeShape), + "selection.fixture.guard_probe", + "selection.fixture.guard_probe.v1", + "selection.fixture.guard_probe.v1", + "sha256:guard-probe", + "{}", + false, + null, + null, + new Dictionary>(), + members, + richMembers + }); + + var referenceMembersMethod = typeof(CultNetSelectionEvaluator) + .GetMethod("ReferenceMembers", BindingFlags.NonPublic | BindingFlags.Static)!; + var yielded = ((IEnumerable)referenceMembersMethod + .Invoke(null, new object?[] { descriptor, "Owner" })!).ToArray(); + + // Only the reference member answers for role "Owner" - the data member sharing that bare + // name must not be yielded, or ReferencesOf would be asked to walk it as a reference and + // ResolveTargetLeaves would throw on its null TargetType (the fault Soul probed). + Assert.That(yielded.Select(m => m.MemberName), Is.EqualTo(new[] { "Ref" })); + } + + private sealed class GuardProbeShape + { + public string Owner = string.Empty; + public CultRecordRef Ref = default; + } + + // R-AT (Soul's SM-7, merge gate third pass): the edge comparator's `to.schemaId`/ + // `to.recordKey` components survived dropping entirely, and reversing `to.recordKey` + // survived too, because every existing fixture ties `from`+`to` or `from`+`role` but never + // ties `from`+`role` while leaving `to` free to vary. Hands had argued no real selection + // could do that; Soul falsified the argument by building one: a selection carrying BOTH + // `cites` and `cited` mixes their edges into one Vec, and a row that self-references + // through the same role it also uses to cite a peer produces a `cites` edge (To = the + // queried peer) and a `cited` edge (To = the anchor row itself), tied on From and Role, + // differing only on To, in the same page-position bucket. Taken from the rig rather than + // rebuilt. + [CultDocument("cultnet.selection-tests.soul_self_citer", "cultnet.selection-tests.soul_self_citer.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class SoulSelfCiter + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + [CultReference(typeof(SoulSelfCiter), many: true)] + public CultRecordRef[] Peers = Array.Empty>(); + } + + // Named "alpha" (self-citer) / "zebra" (cites target) - the OPPOSITE pairing from the Rust + // mirror of this test. CultNetSelectionEvaluator.EvaluateAll builds `edges` as + // `citesEdges` THEN `citedEdges` (line ~215-216), unlike cultnet_rs::select's single Vec + // pushed cited-then-cites - so in C# the cites edge (anchored on From) is pushed before the + // self/cited edge (anchored on To). With the rig's "hub" self-citing and "other" as the + // cites target, push order is [other, hub] and "hub" < "other" is the wrong order anyway + // (masked by the assertion using different values) - the actual coincidence this runtime + // needs broken is push order agreeing with sort order when the *target* name sorts before + // the *self* name. Naming the self-citer "alpha" and the target "zebra" makes C#'s push + // order [zebra, alpha] disagree with the required sorted order [alpha, zebra], so only the + // comparator's own To component - not insertion order - can produce it. (The Rust test uses + // the reverse pairing because cultnet_rs's push order is cited-then-cites, the opposite of + // this runtime's citesEdges-then-citedEdges - each runtime needs its own naming to defeat + // its own coincidence.) + [Test] + public void Evaluator_EdgeOrderPinsToWhenFromAndRoleAreBothTiedByASelfCitingPeer() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SoulSelfCiter) }); + var descriptor = registry.GetRequired(); + + var alpha = new SoulSelfCiter + { + Name = "alpha", + Peers = new[] + { + new CultRecordRef(new CultRecordKey("alpha")), + new CultRecordRef(new CultRecordKey("zebra")) + } + }; + var zebra = new SoulSelfCiter { Name = "zebra" }; + + var rows = new[] + { + new CultNetSelectionEvaluator.Row(descriptor, new CultRecordKey("alpha"), alpha, 1), + new CultNetSelectionEvaluator.Row(descriptor, new CultRecordKey("zebra"), zebra, 2) + }; + + var selection = new CultNetSelection + { + Cites = new CultNetCitation + { + Target = new CultNetRecordRef { SchemaId = descriptor.SchemaId, RecordKey = "zebra" }, + Role = "Peers" + }, + Cited = new CultNetIncoming { Role = "Peers", Exists = true } + }; + + var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + + Assert.That(evaluation.Edges.Count, Is.EqualTo(2), "expected two edges in one page-position bucket"); + Assert.That(evaluation.Edges[0].From.Key.Value, Is.EqualTo(evaluation.Edges[1].From.Key.Value)); + Assert.That(evaluation.Edges[0].Role, Is.EqualTo(evaluation.Edges[1].Role)); + Assert.That(evaluation.Edges[0].To.Key.Value, Is.Not.EqualTo(evaluation.Edges[1].To.Key.Value), + "To must differ - if it does not, Hands' equivalence claim survives in C# too"); + Assert.That(evaluation.Edges[0].To.Key.Value, Is.EqualTo("alpha")); + Assert.That(evaluation.Edges[1].To.Key.Value, Is.EqualTo("zebra")); + } + + // R-AM: HandleSnapshotRequestV1Async (CultNetDatabaseServer.cs) now answers an untyped fault + // like its siblings HandleShardLogRequestAsync/HandlePutAsync - catch (Exception), log, try to + // answer a CultNetErrorMessage - instead of only catching the three typed selection exceptions + // and leaving anything else to escape into the RUDP dispatch backstop's silent swallow. The + // handler is a private delegate keyed to the concrete sealed CultNetServerPeer (no + // ICultNetSchemaServerPeer seam here, unlike CultNetOperationServer's handlers, so a FakePeer + // cannot substitute), and CultNetServerPeer's own constructor needs a live LiteNetLib NetPeer + // this test does not stand up. Reached the only way available instead: the private delegate + // field directly, with Selection: null (a shape CultNetSnapshotRequestV1Message's own type + // does not forbid, so a client could still send it) and an uninitialized CultNetServerPeer + // (RuntimeHelpers.GetUninitializedObject bypasses its internal constructor). SendCultNet then + // NREs on the peer's null Transport - a different, later exception than the + // ArgumentNullException CreateSelectionResponse actually threw for a null selection, which is + // exactly the proof the catch (Exception) block ran: logged the original fault, then tried and + // failed to reply on a peer this test never gave working transport plumbing. + [Test] + public void CultNetDatabaseServer_SnapshotV1Handler_CatchesAnUntypedFaultAndLogsBeforeAnswering() + { + var cache = new CultCache(); + var database = new CultNetDatabase(cache); + using var server = new Server(cache, ServerSecurityOptions.Development()); + var logger = new CapturingLogger(); + server.Logger = logger; + using var databaseServer = new CultNetDatabaseServer(server, database); + + var handlerField = typeof(CultNetDatabaseServer) + .GetField("_snapshotHandlerV1", BindingFlags.NonPublic | BindingFlags.Instance)!; + var handler = (Func)handlerField.GetValue(databaseServer)!; + var peer = (CultNetServerPeer)RuntimeHelpers.GetUninitializedObject(typeof(CultNetServerPeer)); + + var thrown = Assert.Catch(() => handler(new CultNetSnapshotRequestV1Message { Selection = null! }, peer)); + + Assert.That(thrown, Is.Not.InstanceOf(), + "the null-Selection ArgumentNullException must be caught inside the handler, not escape raw"); + Assert.That(logger.Errors, Has.Some.Contains("CultNet snapshot v1 request failed")); + } + + // R-AM: the RUDP dispatch backstop (CultNetRudpSchemaServer.cs, DispatchAsync) logs every + // untyped handler fault it swallows, always - it used to answer nothing and log nothing, which + // is exactly what hid SM-6's ArgumentNullException regression for a whole batch. Real + // client/server round trip over loopback UDP, the same pattern this file's other RUDP tests + // use (CultNetSchemaClients.CreateRudp against a RudpCultNetSchemaServer with an OS-assigned + // port), with a handler that deliberately throws an untyped exception to reach the backstop. + [Test] + public async Task RudpCultNetSchemaServer_DispatchBackstop_LogsEveryUntypedHandlerFault() + { + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + socket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions + { + RuntimeId = "dispatch-backstop-logs-server", + Socket = socket + }); + var logger = new CapturingLogger(); + server.Logger = logger; + server.OnCultNet((_, _) => + throw new InvalidOperationException("dispatch-backstop-probe")); + + using var cancellation = new CancellationTokenSource(); + var serverThread = new Thread(() => + { + while (!cancellation.IsCancellationRequested) + { + _ = server.PollOnceAsync().GetAwaiter().GetResult(); + Thread.Sleep(1); + } + }) + { IsBackground = true }; + serverThread.Start(); + try + { + using var client = CultNetSchemaClients.CreateRudp("dispatch-backstop-probe-client"); + client.Connect("127.0.0.1", server.LocalEndPoint.Port); + for (var attempt = 0; attempt < 200 && !client.Connected; attempt++) + await Task.Delay(10); + Assert.That(client.Connected, Is.True, "client failed to connect to the RUDP server"); + + client.SendCultNet(new CultNetShardCatalogRequestMessage { MessageId = "dispatch-backstop-probe" }); + + for (var attempt = 0; attempt < 200 && logger.Errors.Count == 0; attempt++) + await Task.Delay(10); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } + + Assert.That(logger.Errors, Has.Some.Contains("dispatch-backstop-probe"), + "the backstop must log the handler's own exception message, not swallow it silently"); + } + + private sealed class CapturingLogger : ILogger + { + public List Errors { get; } = new(); + public void LogInfo(string message) { } + public void LogWarning(string message) { } + public void LogError(string message) => Errors.Add(message); + public void LogDebug(string message) { } + } + } +} diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionPageTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionPageTests.cs new file mode 100644 index 00000000..0f1f20a9 --- /dev/null +++ b/tests/GameCult.Networking.Tests/CultNetSelectionPageTests.cs @@ -0,0 +1,150 @@ +#nullable enable +using System.Collections.Generic; +using System.Threading.Tasks; +using GameCult.Caching; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Networking.Tests +{ + // R-I (docs/cultnet-selection-cut.md): SelectPage/CreateSelectionResponse had no test at all before + // this cut's fix batch, and Soul's whole-cut pass found two untested mutants survive 196/196: + // dropping `wantDocument &&` in ToEdge (a header-projection edge would then carry the document's + // payload bytes anyway), and always emitting Edges instead of `selection.HasHop ? ... : null` (a + // hop-less selection's page would then carry an empty Edges array instead of no Edges member). + public sealed class CultNetSelectionPageTests + { + private static (CultCache cache, CultNetDocumentRegistry documents) BuildFixture() + { + var cache = new CultCache(); + var documents = new CultNetDocumentRegistry(cache.Registry) + .Register(CultNetDocumentBinding.ForDocument(cache.Registry)) + .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); + return (cache, documents); + } + + private static async Task AddLeafAsync(CultCache cache, string key, string kind) + { + var recordKey = new CultRecordKey(key); + await cache.AddAsync( + new PageFixtureLeaf { Name = key, Kind = kind }, + new CultRecordHandle(recordKey)); + return recordKey; + } + + // Kills Soul's ToEdge mutant (CultNetDocumentRegistry.cs, ~:468): dropping `wantDocument &&` + // from `if (wantDocument && edge.Payload != null)` would leak the dictionary edge's payload + // bytes into a header-projection page, which carries no payloads by contract. + [Test] + public async Task CreateSelectionResponse_UnderHeaderProjection_CarriesNoEdgePayload() + { + var (cache, documents) = BuildFixture(); + var target = await AddLeafAsync(cache, "leaf-target", "weapon"); + await cache.AddAsync( + new PageFixtureCiter { Name = "citer-1", Components = new Dictionary, float> { [new CultRecordRef(target)] = 2.5f } }, + new CultRecordHandle(new CultRecordKey("citer-1"))); + + var selection = new CultNetSelection + { + Cited = new CultNetIncoming { Role = "Components", Exists = true }, + Projection = CultNetSelectionProjections.Header + }; + var response = documents.CreateSelectionResponse(cache, "m1", selection, ordinalOf: (_, _) => 1, asOf: 1); + + Assert.That(response.Edges, Is.Not.Null.And.Not.Empty, "a hop-bearing selection with a match must carry the edge it traversed"); + foreach (var edge in response.Edges!) + { + Assert.That(edge.PayloadEncoding, Is.Null, "header projection must not carry an edge's payload encoding"); + Assert.That(edge.Payload, Is.Null, "header projection must not carry an edge's payload bytes"); + } + } + + // The positive case for the same rule: document projection does carry the payload. + [Test] + public async Task CreateSelectionResponse_UnderDocumentProjection_CarriesEdgePayload() + { + var (cache, documents) = BuildFixture(); + var target = await AddLeafAsync(cache, "leaf-target", "weapon"); + await cache.AddAsync( + new PageFixtureCiter { Name = "citer-1", Components = new Dictionary, float> { [new CultRecordRef(target)] = 2.5f } }, + new CultRecordHandle(new CultRecordKey("citer-1"))); + + var selection = new CultNetSelection + { + Cited = new CultNetIncoming { Role = "Components", Exists = true }, + Projection = CultNetSelectionProjections.Document + }; + var response = documents.CreateSelectionResponse(cache, "m1", selection, ordinalOf: (_, _) => 1, asOf: 1); + + Assert.That(response.Edges, Is.Not.Null.And.Not.Empty); + Assert.That(response.Edges![0].PayloadEncoding, Is.EqualTo("messagepack")); + Assert.That(response.Edges![0].Payload, Is.Not.Null.And.Not.Empty); + } + + // Kills Soul's SelectPage/CreateSelectionResponse mutant (CultNetDocumentRegistry.cs, ~:378): + // always emitting `Edges = ...` instead of `selection.HasHop ? ... : null` would give a + // hop-less selection's page an empty Edges array (Is.Empty) instead of no Edges member at all + // (Is.Null) - a mutant a shape-only "no edges reported" assertion cannot tell apart from the rule. + [Test] + public async Task CreateSelectionResponse_WithoutAHop_CarriesNoEdgesMember() + { + var (cache, documents) = BuildFixture(); + await AddLeafAsync(cache, "leaf-only", "weapon"); + + var selection = new CultNetSelection { Projection = CultNetSelectionProjections.Document }; + var response = documents.CreateSelectionResponse(cache, "m1", selection, ordinalOf: (_, _) => 1, asOf: 1); + + Assert.That(response.Documents, Has.Length.EqualTo(1)); + Assert.That(response.Edges, Is.Null); + } + + // SelectPage/CreateSelectionResponse's own paging and matched-total contract (R-G/C5): a + // limited page reports the whole matching count, not the page's row count, and carries a + // cursor while more rows remain. + [Test] + public async Task CreateSelectionResponse_ReportsTotalMatchedNotPageLength() + { + var (cache, documents) = BuildFixture(); + await AddLeafAsync(cache, "leaf-1", "weapon"); + await AddLeafAsync(cache, "leaf-2", "weapon"); + await AddLeafAsync(cache, "leaf-3", "weapon"); + + var selection = new CultNetSelection + { + Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon" } } }, + Limit = 2 + }; + var response = documents.CreateSelectionResponse(cache, "m1", selection, ordinalOf: static (_, _) => 1, asOf: 1); + + Assert.That(response.Headers, Has.Length.EqualTo(2), "the page itself is capped at the limit"); + Assert.That(response.Matched, Is.EqualTo(3), "matched is the selection's whole matching count, not this page's length"); + Assert.That(response.Next, Is.Not.Null.And.Not.Empty, "a cursor is minted while more rows remain"); + } + + [CultDocument("selection.page-fixture.leaf", "selection.page-fixture.leaf.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class PageFixtureLeaf + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + [CultIndex("kind")] + public string Kind = string.Empty; + } + + [CultDocument("selection.page-fixture.citer", "selection.page-fixture.citer.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class PageFixtureCiter + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + [CultReference(typeof(PageFixtureLeaf), many: true)] + public Dictionary, float> Components = new(); + } + } +} diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionSurvivorTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionSurvivorTests.cs new file mode 100644 index 00000000..a03b62d2 --- /dev/null +++ b/tests/GameCult.Networking.Tests/CultNetSelectionSurvivorTests.cs @@ -0,0 +1,769 @@ +#nullable enable +using System; +using System.Linq; +using GameCult.Caching; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Networking.Tests +{ + // CultNet typed selection, Cut 1: behavioural tests for the Stryker survivors Soul triaged as real + // against CultNetSelection.cs/CultNetSelectionEvaluator.cs (docs/cultnet-selection-cut.md, S-5/R-AA, + // "Self's rulings for fix batch 4"). Each test targets one named survivor by the rule it pins, not + // the mutated line's spelling - see the method-level comment for the rule and where the pre-fix + // mutant differs in the answer, not in the code's shape. Fixtures are new where the survivor's own + // absence-of-fixture is the reason it survived (a mixed-numeric alias, an index alias that differs + // from its member name, two reference members that resolve to one target). + public sealed class CultNetSelectionSurvivorTests + { + private static CultNetSelectionEvaluator.Row Row(CultDocumentRegistry registry, object document, string key, long ordinal) => + new(registry.GetRequired(document.GetType()), new CultRecordKey(key), document, ordinal); + + // --------------------------------------------------------------------------------------------- + // Survivor 1: CultNetSelection.cs, ValidateField - `declaring.Any(member => !member.IsNumeric)` + // mutated to `.All(...)`. Rule (section 2, "Inheritance"): "a numeric comparison on an alias that + // is numeric on one reachable leaf and a string on another is refused at the door, because the + // row owner cannot compare a string." The mutant only refuses when EVERY reachable leaf declares + // the alias non-numeric - the exact mixed case the rule exists for was untested. + // --------------------------------------------------------------------------------------------- + [Test] + public void Validation_RefusesAComparisonOnAnAliasNumericOnOneReachableLeafAndStringOnAnother() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorNumericLeaf), typeof(SurvivorStringLeaf) }); + var descriptors = registry.AllDescriptors.ToArray(); + + var selection = new CultNetSelection + { + Fields = new[] { new CultNetFieldPredicate { Index = "level", Op = "gt", Number = "5" } } + }; + + var ex = Assert.Throws(() => selection.Validate(descriptors)); + Assert.That(ex!.Field, Is.EqualTo("fields[0].index")); + } + + // The any_of counterpart must still pass on the same mixed pair - only comparisons need the + // alias numeric everywhere reachable. + [Test] + public void Validation_AllowsAnyOfOnAMixedNumericStringAliasAcrossReachableLeaves() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorNumericLeaf), typeof(SurvivorStringLeaf) }); + var descriptors = registry.AllDescriptors.ToArray(); + + var selection = new CultNetSelection + { + Fields = new[] { new CultNetFieldPredicate { Index = "level", Op = "any_of", Values = new[] { "5" } } } + }; + + Assert.DoesNotThrow(() => selection.Validate(descriptors)); + } + + [CultDocument("cultnet.selection-tests.survivor_numeric_leaf", "cultnet.selection-tests.survivor_numeric_leaf.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class SurvivorNumericLeaf + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] [CultIndex("level")] public int Level; + } + + [CultDocument("cultnet.selection-tests.survivor_string_leaf", "cultnet.selection-tests.survivor_string_leaf.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class SurvivorStringLeaf + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] [CultIndex("level")] public string Level = string.Empty; + } + + // --------------------------------------------------------------------------------------------- + // Survivor 2: CultNetSelection.cs, Validate - the `cites.target` blank-field door mutated `||` + // to `&&`. A half-blank target (schemaId present, recordKey blank, or the reverse) must be + // refused; the mutant only refuses when BOTH are blank. + // --------------------------------------------------------------------------------------------- + [TestCase("some-schema", "")] + [TestCase("", "some-key")] + public void Validation_RefusesACitesTargetWithOnlyOneOfSchemaIdOrRecordKeyBlank(string schemaId, string recordKey) + { + var descriptors = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorNumericLeaf) }).AllDescriptors.ToArray(); + var selection = new CultNetSelection + { + Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemaId, RecordKey = recordKey } } + }; + + var ex = Assert.Throws(() => selection.Validate(descriptors)); + Assert.That(ex!.Field, Is.EqualTo("cites.target")); + } + + // --------------------------------------------------------------------------------------------- + // Survivor 3: `member.IndexAlias ?? member.MemberName` mutated to `member.MemberName` at all + // four sites (CultNetSelectionEvaluator MatchesCitation, BuildIncomingIndex, ReferenceMembers; + // CultNetSelection.AnySchemaDeclaresRole). Rule (section 2, "The hop"): "role = the member's + // index alias or name". SurvivorAliasedCiter.Link declares alias "linkRole", which differs from + // its member name "Link" - the exact case with no prior fixture. + // --------------------------------------------------------------------------------------------- + + // Door: CultNetSelection.AnySchemaDeclaresRole (line 445). Only the declared alias satisfies + // cited.role/cites.role - the bare member name does not. + [TestCase("linkRole", true)] + [TestCase("Link", false)] + public void Validation_RoleDoorAcceptsTheDeclaredIndexAliasNotTheMemberName(string role, bool shouldValidate) + { + var descriptors = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorAliasTarget), typeof(SurvivorAliasedCiter) }).AllDescriptors.ToArray(); + var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = role, Exists = true } }; + + if (shouldValidate) + Assert.DoesNotThrow(() => selection.Validate(descriptors)); + else + Assert.Throws(() => selection.Validate(descriptors)); + } + + // Evaluator, cites direction: MatchesCitation (role resolution) and ReferenceMembers (the role + // filter) both read the alias. The citation names the role by its alias; the edge reported back + // must also carry the alias, not the member name. + [Test] + public void Evaluator_CitesMatchesAndReportsTheDeclaredIndexAliasAsTheEdgeRole() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorAliasTarget), typeof(SurvivorAliasedCiter) }); + var target = new SurvivorAliasTarget { Name = "target" }; + var citer = new SurvivorAliasedCiter { Name = "citer", Link = new CultRecordRef(new CultRecordKey("target")) }; + var rows = new[] { Row(registry, target, "target", 1), Row(registry, citer, "citer", 2) }; + + var selection = new CultNetSelection + { + Cites = new CultNetCitation + { + Target = new CultNetRecordRef { SchemaId = registry.GetRequired().SchemaId, RecordKey = "target" }, + Role = "linkRole" + } + }; + var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + + Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "citer" })); + Assert.That(evaluation.Edges.Select(e => e.Role), Is.EqualTo(new[] { "linkRole" })); + } + + // Evaluator, cited direction: BuildIncomingIndex (role resolution) and ReferenceMembers again - + // the same alias, read on the incoming-edge path instead of the outgoing one. + [Test] + public void Evaluator_CitedMatchesAndReportsTheDeclaredIndexAliasAsTheEdgeRole() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorAliasTarget), typeof(SurvivorAliasedCiter) }); + var target = new SurvivorAliasTarget { Name = "target" }; + var citer = new SurvivorAliasedCiter { Name = "citer", Link = new CultRecordRef(new CultRecordKey("target")) }; + var rows = new[] { Row(registry, target, "target", 1), Row(registry, citer, "citer", 2) }; + + var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = "linkRole", Exists = true } }; + var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + + Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "target" })); + Assert.That(evaluation.Edges.Select(e => e.Role), Is.EqualTo(new[] { "linkRole" })); + } + + [CultDocument("cultnet.selection-tests.survivor_alias_target", "cultnet.selection-tests.survivor_alias_target.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class SurvivorAliasTarget + { + [Key(0)] [CultName] public string Name = string.Empty; + } + + [CultDocument("cultnet.selection-tests.survivor_aliased_citer", "cultnet.selection-tests.survivor_aliased_citer.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class SurvivorAliasedCiter + { + [Key(0)] [CultName] public string Name = string.Empty; + + // The declared index alias ("linkRole") differs from the member name ("Link") - the exact + // shape section 2's role-naming rule exists for, and no prior fixture had one. + [Key(1)] + [CultIndex("linkRole")] + [CultReference(typeof(SurvivorAliasTarget))] + public CultRecordRef Link; + } + + // --------------------------------------------------------------------------------------------- + // Survivor 4: CultNetSelectionEvaluator.EvaluateAll - `ThenBy`/`ThenByDescending` on schemaId + // and recordKey mutated, on both the ascending and descending sort expressions. Rule (section 2, + // "Order"): "(ordinal, schemaId, recordKey) ascending, or the reverse under descending." Every + // prior test used distinct ordinals, so the schemaId/recordKey tiebreak comparators were never + // actually invoked by the sort (LINQ's composed comparer short-circuits once a key differs). + // Three rows share one ordinal so the sort must fall through to schemaId, then recordKey. + // + // R-AJ (Soul's SM-4): this test's earlier fixture (two SelLeafA rows "a"/"b", one SelLeafB row + // "a") passed 269/269 even under a SchemaId<->Key swap mutant - not because the test is wrong + // in principle, but because whether it discriminates the swap depends on which of SelLeafA's/ + // SelLeafB's real SHA-256 schema ids happens to sort first, and on this repo's actual ids it + // didn't (probed: reverting the fix and applying the swap by hand, this test still passed). + // A loosening that cannot fail is a finding about the fixture, not license to keep it. The fix + // below assigns keys *relative to the schema ids discovered at runtime* - the low-sorting + // schema gets the high-sorting key and vice versa - so schema-first and key-first orderings + // are forced to disagree regardless of which real schema id happens to be smaller. + // --------------------------------------------------------------------------------------------- + [Test] + public void Evaluator_BreaksAnOrdinalTieBySchemaIdThenRecordKeyInBothDirections() + { + var registry = CultDocumentRegistry.ForTypes(new[] + { + typeof(CultNetSelectionEvaluatorTests.SelLeafA), typeof(CultNetSelectionEvaluatorTests.SelLeafB) + }); + var schemaA = registry.GetRequired().SchemaId; + var schemaB = registry.GetRequired().SchemaId; + // sanity: schemaA/schemaB really are distinct, or this test would not exercise the tiebreak. + Assert.That(schemaA, Is.Not.EqualTo(schemaB)); + + var schemaALowersFirst = CultNetCodePointComparer.Instance.Compare(schemaA, schemaB) < 0; + var lowSchema = schemaALowersFirst ? typeof(CultNetSelectionEvaluatorTests.SelLeafA) : typeof(CultNetSelectionEvaluatorTests.SelLeafB); + + // The low-sorting schema's row gets the *high*-sorting key ("z"), and the high-sorting + // schema's row gets the *low*-sorting key ("a") - anti-correlated on purpose, so + // schema-first order ([low-schema, high-schema], independent of key) and key-first order + // ([low-key, high-key] = [high-schema, low-schema]) are the exact reverse of each other no + // matter which real id sorts lower. Two rows, two different schemas, two different keys: + // the simplest shape that cannot degenerate into agreement between the two candidate rules. + var rows = lowSchema == typeof(CultNetSelectionEvaluatorTests.SelLeafA) + ? new[] + { + Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "low-schema-high-key", Kind = "k", Mass = 1 }, "z", 1), + Row(registry, new CultNetSelectionEvaluatorTests.SelLeafB { Name = "high-schema-low-key", Kind = "k", Mass = 1 }, "a", 1) + } + : new[] + { + Row(registry, new CultNetSelectionEvaluatorTests.SelLeafB { Name = "low-schema-high-key", Kind = "k", Mass = 1 }, "z", 1), + Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "high-schema-low-key", Kind = "k", Mass = 1 }, "a", 1) + }; + + var expectedAscending = rows + .OrderBy(r => r.Descriptor.SchemaId, CultNetCodePointComparer.Instance) + .ThenBy(r => r.Key.Value, CultNetCodePointComparer.Instance) + .Select(r => (r.Descriptor.SchemaId, r.Key.Value)) + .ToArray(); + // The anti-correlated construction above means the schema-first answer must put the + // high-key row first (its schema sorts low) - if a key-first mutant instead put the + // low-key row first, this sanity check catches the fixture degenerating, not just the + // main assertions below. + Assert.That(expectedAscending[0].Value, Is.EqualTo("z"), "fixture sanity: schema-first must not agree with key-first here"); + + var ascending = CultNetSelectionEvaluator.Select(registry, rows, new CultNetSelection(), asOf: 1); + Assert.That(ascending.Rows.Select(r => (r.Descriptor.SchemaId, r.Key.Value)), Is.EqualTo(expectedAscending)); + + var descending = CultNetSelectionEvaluator.Select(registry, rows, new CultNetSelection { Descending = true }, asOf: 1); + Assert.That(descending.Rows.Select(r => (r.Descriptor.SchemaId, r.Key.Value)), Is.EqualTo(expectedAscending.Reverse())); + } + + // --------------------------------------------------------------------------------------------- + // Survivor 5: CultNetSelectionEvaluator.EdgesFor - the ordering keys after page-row position + // (From.SchemaId, From.Key, Role, To.Key) each mutated ThenBy<->ThenByDescending. Rule (R-B): + // "page-row order, then (from, role, to.recordKey) in code-point order." Two behavioural + // angles: the From.SchemaId tiebreak (two differently-schema'd citers, same key, citing one + // target under the same role), and the Role tiebreak (one citer with two reference members + // that both resolve to the same target, so From and To tie and only Role differs). + // + // A fifth component, To.SchemaId, used to sit last in this chain. R-BA (docs/cultnet-selection + // -cut.md, "Soul, the merge gate, fourth pass") deleted it from both runtimes' + // comparators: TryResolveReferenceTarget/resolve_reference_target resolve purely from + // (the citer's schema, role, recordKey) against the evaluation's own fixed candidate index, so + // once From and Role already tie (pinning the citer's schema and the target type/leaves) and + // To.RecordKey also ties, To.SchemaId is forced equal - no input can make it decide an order + // the earlier keys did not already decide, so a Stryker mutant on it cannot be killed and the + // component bought nothing. + // --------------------------------------------------------------------------------------------- + [Test] + public void EdgesFor_BreaksATiedAnchorByFromSchemaIdWhenFromKeysAreEqual() + { + var registry = CultDocumentRegistry.ForTypes(new[] + { + typeof(CultNetSelectionEvaluatorTests.SelLeafA), + typeof(CultNetSelectionEvaluatorTests.SelCiter), + typeof(SurvivorAltCiter) + }); + var target = new CultNetSelectionEvaluatorTests.SelLeafA { Name = "target", Kind = "k", Mass = 1 }; + // Both citers use record key "same" - only their schemaId differs, isolating that tiebreak + // from the already-covered From.Key case (CultNetSelectionFixBatchTests, citer-a/citer-b). + var citerOriginal = new CultNetSelectionEvaluatorTests.SelCiter + { + Name = "orig", + Design = new CultRecordRef(new CultRecordKey("target")) + }; + var citerAlt = new SurvivorAltCiter { Name = "alt", Design = new CultRecordRef(new CultRecordKey("target")) }; + var rows = new[] + { + Row(registry, target, "target", 1), + Row(registry, citerOriginal, "same", 2), + Row(registry, citerAlt, "same", 3) + }; + + var schemaOriginal = registry.GetRequired().SchemaId; + var schemaAlt = registry.GetRequired().SchemaId; + var expectedFirst = CultNetCodePointComparer.Instance.Compare(schemaOriginal, schemaAlt) <= 0 ? schemaOriginal : schemaAlt; + var expectedSecond = expectedFirst == schemaOriginal ? schemaAlt : schemaOriginal; + + var selection = new CultNetSelection + { + Schemas = new[] { registry.GetRequired().SchemaId }, + Cited = new CultNetIncoming { Role = "Design", Exists = true } + }; + var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + + Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "target" })); + Assert.That(evaluation.Edges.Select(e => e.From.Descriptor.SchemaId), Is.EqualTo(new[] { expectedFirst, expectedSecond })); + } + + [Test] + public void EdgesFor_BreaksATiedAnchorByRoleWhenFromAndToAreEqual() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorAliasTarget), typeof(SurvivorDualRoleCiter) }); + var target = new SurvivorAliasTarget { Name = "target" }; + // Both reference members on the one citer point at the same target row, so From and To tie + // for both edges and only Role can order them. + var citer = new SurvivorDualRoleCiter + { + Name = "citer", + RefZ = new CultRecordRef(new CultRecordKey("target")), + RefA = new CultRecordRef(new CultRecordKey("target")) + }; + var rows = new[] { Row(registry, target, "target", 1), Row(registry, citer, "citer", 2) }; + + var selection = new CultNetSelection + { + Cites = new CultNetCitation + { + Target = new CultNetRecordRef { SchemaId = registry.GetRequired().SchemaId, RecordKey = "target" } + // Role left null: matches any declared reference, so both RefZ (alias "zLastRole") + // and RefA (alias "aFirstRole") contribute an edge. + } + }; + var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + + Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "citer" })); + // "aFirstRole" sorts before "zLastRole" in code-point order - the opposite of RefZ/RefA's + // declaration order (Key(1)/Key(2)), so a correct answer cannot come from preserving + // declaration order by accident. + Assert.That(evaluation.Edges.Select(e => e.Role), Is.EqualTo(new[] { "aFirstRole", "zLastRole" })); + } + + // A sixth EdgesFor mutant Stryker reported alongside the five R-B names: OrderBy(pageRowIndex) + // itself (the primary key, page-row position) mutated to OrderByDescending. Two page rows, each + // the anchor for exactly one edge from a differently-keyed citer, where the citers' own + // (from, role, to) order is the *opposite* of the pages' row order - so only the primary + // page-position key, not the secondary from/role/to tiebreak, can produce the right answer. + [Test] + public void EdgesFor_OrdersByPageRowPositionBeforeFromRoleTo() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorAliasTarget), typeof(SurvivorPageOrderCiter) }); + var targetZ = new SurvivorAliasTarget { Name = "z" }; + var targetA = new SurvivorAliasTarget { Name = "a" }; + var citerForZ = new SurvivorPageOrderCiter { Name = "cz", Link = new CultRecordRef(new CultRecordKey("z-target")) }; + // "citer-a" sorts before "citer-z" in code-point order - the opposite of the page order below + // (targetZ's ordinal puts it first), so a from/role/to-only sort would put this edge first. + var citerForA = new SurvivorPageOrderCiter { Name = "ca", Link = new CultRecordRef(new CultRecordKey("a-target")) }; + var rows = new[] + { + Row(registry, targetZ, "z-target", 1), + Row(registry, targetA, "a-target", 2), + Row(registry, citerForZ, "citer-z", 3), + Row(registry, citerForA, "citer-a", 4) + }; + + var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = "hopRole", Exists = true } }; + var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + + // Page order is by ordinal: z-target (1) then a-target (2). + Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "z-target", "a-target" })); + // Edges must follow that same page order, not "citer-a" < "citer-z". + Assert.That(evaluation.Edges.Select(e => e.From.Key.Value), Is.EqualTo(new[] { "citer-z", "citer-a" })); + } + + // R-AJ (Soul's SM-4): the two tests above each isolate one component of the (From.SchemaId, + // From.Key, Role, To.Key) tiebreak by tying every other one - so neither can + // distinguish the *composition* "From.Key before Role" from "Role before From.Key", which is + // exactly the mutant that survived (permuting Role ahead of From.Key, keeping every + // component). This calls EdgesFor directly with two hand-built edges sharing one anchor, so + // both From.Key and Role vary at once and the two orderings disagree: "aaa" < "zzz" as a key, + // but the row keyed "zzz" carries the role "aaa" - a Role-first mutant would put that edge + // first; the rule (R-B: "page-row order, then (from, role, to)") puts it second. + [Test] + public void EdgesFor_OrdersByFromKeyBeforeRoleWhenBothVaryAtTheSameAnchor() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorAliasTarget) }); + var anchor = Row(registry, new SurvivorAliasTarget { Name = "anchor" }, "anchor-key", 1); + var to = Row(registry, new SurvivorAliasTarget { Name = "to" }, "to-key", 2); + var fromZ = Row(registry, new SurvivorAliasTarget { Name = "z" }, "zzz", 3); + var fromA = Row(registry, new SurvivorAliasTarget { Name = "a" }, "aaa", 4); + + var edgeFromZRoleA = new CultNetSelectionEvaluator.EdgeMatch(fromZ, "aaa", to, null); + var edgeFromARoleZ = new CultNetSelectionEvaluator.EdgeMatch(fromA, "zzz", to, null); + var full = new CultNetSelectionEvaluator.FullEvaluation + { + Edges = new[] { (edgeFromZRoleA, anchor), (edgeFromARoleZ, anchor) } + }; + + var edges = CultNetSelectionEvaluator.EdgesFor(new[] { anchor }, full); + + Assert.That(edges.Select(e => e.From.Key.Value), Is.EqualTo(new[] { "aaa", "zzz" }), + "From.Key must decide before Role - a Role-first composition would put the \"zzz\"-keyed edge first, since its role \"aaa\" sorts first"); + Assert.That(edges.Select(e => e.Role), Is.EqualTo(new[] { "zzz", "aaa" })); + } + + [CultDocument("cultnet.selection-tests.survivor_page_order_citer", "cultnet.selection-tests.survivor_page_order_citer.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class SurvivorPageOrderCiter + { + [Key(0)] [CultName] public string Name = string.Empty; + + [Key(1)] + [CultIndex("hopRole")] + [CultReference(typeof(SurvivorAliasTarget))] + public CultRecordRef Link; + } + + [CultDocument("cultnet.selection-tests.survivor_alt_citer", "cultnet.selection-tests.survivor_alt_citer.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class SurvivorAltCiter + { + [Key(0)] [CultName] public string Name = string.Empty; + + [Key(1)] + [CultReference(typeof(CultNetSelectionEvaluatorTests.SelFixtureMiddle))] + public CultRecordRef Design; + } + + [CultDocument("cultnet.selection-tests.survivor_dual_role_citer", "cultnet.selection-tests.survivor_dual_role_citer.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class SurvivorDualRoleCiter + { + [Key(0)] [CultName] public string Name = string.Empty; + + [Key(1)] + [CultIndex("zLastRole")] + [CultReference(typeof(SurvivorAliasTarget))] + public CultRecordRef RefZ; + + [Key(2)] + [CultIndex("aFirstRole")] + [CultReference(typeof(SurvivorAliasTarget))] + public CultRecordRef RefA; + } + + // --------------------------------------------------------------------------------------------- + // Survivor 6: CultNetSelectionCursor.ComputeDigest - the `cites`/`cited` branches (and the + // field-number presence flag) had no coverage at all. Rule (R-H): "every string and list is + // length-prefixed, so no delimiter choice can make two different selections collide" - extended + // here to the branch flags themselves: two selections differing only in whether they carry a + // hop, or only in a field's number-vs-no-number flag, must not collide. + // --------------------------------------------------------------------------------------------- + [Test] + public void ComputeDigest_DiffersWhenOnlyCitesPresenceOrItsRoleDiffers() + { + var withoutCites = new CultNetSelection(); + var withCitesNoRole = new CultNetSelection + { + Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = "s", RecordKey = "k" } } + }; + var withCitesRole = new CultNetSelection + { + Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = "s", RecordKey = "k" }, Role = "r" } + }; + + Assert.That( + CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", withoutCites), + Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", withCitesNoRole))); + Assert.That( + CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", withCitesNoRole), + Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", withCitesRole))); + } + + [Test] + public void ComputeDigest_DiffersWhenOnlyCitedPresenceOrItsExistsFlagDiffers() + { + var withoutCited = new CultNetSelection(); + var citedExistsTrue = new CultNetSelection { Cited = new CultNetIncoming { Role = "r", Exists = true } }; + var citedExistsFalse = new CultNetSelection { Cited = new CultNetIncoming { Role = "r", Exists = false } }; + + Assert.That( + CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", withoutCited), + Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", citedExistsTrue))); + Assert.That( + CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", citedExistsTrue), + Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", citedExistsFalse))); + } + + // The number-presence flag: Number = null (an any_of predicate never carries one) must not + // digest the same as Number = "" (an empty-but-present string) - AppendString alone would render + // both as a zero-length field, so the trailing '0'/'1' flag is the only thing that tells them + // apart. + [Test] + public void ComputeDigest_DiffersBetweenAnAbsentFieldNumberAndAnEmptyOne() + { + var absentNumber = new CultNetSelection + { + Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "any_of", Values = new[] { "x" } } } + }; + var emptyNumber = new CultNetSelection + { + Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "any_of", Values = new[] { "x" }, Number = "" } } + }; + + Assert.That( + CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", absentNumber), + Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", emptyNumber))); + } + + // Every "differs" assertion above is symmetric to a mutant that swaps which of two cases gets + // which flag value (e.g. field.Number == null ? '0' : '1' mutated to != null) - both cases still + // produce two different digests, just with the labels swapped, so inequality alone cannot catch + // it. Nor can it catch deleting one AppendString/sb.Append call whose content happens not to be + // the only thing distinguishing a particular pair. An independently reconstructed golden value - + // built from ComputeDigest's own doc comments, not by calling its private helpers - pins the + // exact byte-for-byte wire shape: every append call and every flag's polarity must be exactly + // right for this to still match, so deleting or mislabelling any of them fails it. + [Test] + public void ComputeDigest_MatchesAnIndependentlyReconstructedGoldenValue() + { + var keyBytes = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray(); + var key = new CultNetSelectionCursorKey(keyBytes); + var selection = new CultNetSelection + { + Schemas = new[] { "schema-b", "schema-a" }, + Keys = new[] { "key-a" }, + Fields = new[] + { + new CultNetFieldPredicate { Index = "idx-a", Op = "any_of", Values = new[] { "v2", "v1" } }, + new CultNetFieldPredicate { Index = "idx-b", Op = "gt", Number = "5" } + }, + Cites = new CultNetCitation + { + Target = new CultNetRecordRef { SchemaId = "cite-schema", RecordKey = "cite-key" }, + Role = "cite-role" + }, + Cited = new CultNetIncoming { Role = "cited-role", Exists = true }, + Projection = CultNetSelectionProjections.Document, + Descending = true + }; + + var expected = IndependentDigest(asOf: 42, ordinal: 7, schemaId: "row-schema", recordKey: "row-key", selection, keyBytes); + var actual = CultNetSelectionCursor.ComputeDigest(42, 7, "row-schema", "row-key", selection, key); + + Assert.That(actual, Is.EqualTo(expected)); + } + + // Independent re-implementation of ComputeDigest's wire shape (R-H/R-Y), written from the + // method's doc comments rather than by delegating to it or its private helpers. + private static string IndependentDigest(ulong asOf, long ordinal, string schemaId, string recordKey, CultNetSelection selection, byte[] keyBytes) + { + var sb = new System.Text.StringBuilder(); + void AppendString(string value) => sb.Append(System.Text.Encoding.UTF8.GetByteCount(value)).Append(':').Append(value); + void AppendList(System.Collections.Generic.IReadOnlyList? values) + { + var ordered = (values ?? Array.Empty()).OrderBy(v => v, CultNetCodePointComparer.Instance).ToArray(); + sb.Append(ordered.Length).Append(':'); + foreach (var v in ordered) AppendString(v); + } + + AppendString(asOf.ToString(System.Globalization.CultureInfo.InvariantCulture)); + AppendString(ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture)); + AppendString(schemaId); + AppendString(recordKey); + AppendList(selection.Schemas); + AppendList(selection.Keys); + + var fields = selection.Fields ?? Array.Empty(); + sb.Append(fields.Length).Append(':'); + foreach (var field in fields) + { + AppendString(field.Index); + AppendString(field.Op); + AppendList(field.Values); + AppendString(field.Number ?? string.Empty); + sb.Append(field.Number == null ? '0' : '1'); + } + + if (selection.Cites != null) + { + sb.Append('1'); + AppendString(selection.Cites.Target.SchemaId); + AppendString(selection.Cites.Target.RecordKey); + AppendString(selection.Cites.Role ?? string.Empty); + sb.Append(selection.Cites.Role == null ? '0' : '1'); + } + else + { + sb.Append('0'); + } + + if (selection.Cited != null) + { + sb.Append('1'); + AppendString(selection.Cited.Role); + sb.Append(selection.Cited.Exists ? '1' : '0'); + } + else + { + sb.Append('0'); + } + + AppendString(selection.Projection); + sb.Append(selection.Descending ? '1' : '0'); + + using var hmac = new System.Security.Cryptography.HMACSHA256(keyBytes); + var bytes = hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(sb.ToString())); + var hex = new System.Text.StringBuilder(bytes.Length * 2); + foreach (var b in bytes) hex.Append(b.ToString("x2", System.Globalization.CultureInfo.InvariantCulture)); + return hex.ToString(); + } + + // --------------------------------------------------------------------------------------------- + // Survivor 7: CultNetSelectionCursor.Parse / ReadLengthPrefixedFields had no coverage at all on + // attacker-supplied base64. A length prefix claiming more bytes than the payload actually holds, + // and trailing bytes left over after the five fields, must both refuse cursor_invalid rather + // than throw an unhandled exception or silently accept the extra bytes. + // --------------------------------------------------------------------------------------------- + [Test] + public void CursorParse_RefusesALengthPrefixClaimingMoreBytesThanArePresent() + { + // "999:" claims a 999-byte field but supplies only "x" - the door must refuse, not read + // past the end of the buffer. + var hostile = Base64UrlEncode(System.Text.Encoding.UTF8.GetBytes("999:x")); + + var ex = Assert.Throws(() => CultNetSelectionCursor.Parse(hostile)); + Assert.That(ex!.Code, Is.EqualTo("cursor_invalid")); + } + + [Test] + public void CursorParse_RefusesTrailingBytesAfterTheFiveFields() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(CultNetSelectionEvaluatorTests.SelLeafA) }); + var rows = new[] { Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1) }; + var selection = new CultNetSelection { Limit = 1 }; + var key = CultNetSelectionCursorKey.Random(); + var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: key); + Assert.That(evaluation.NextCursor, Is.Null, "a single-row set with limit 1 has no next page to mint a cursor from"); + + // Mint a real cursor over a two-row set instead, so there is one to tamper with. + var rows2 = new[] + { + Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1), + Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "b", Kind = "k", Mass = 1 }, "b", 2) + }; + var page = CultNetSelectionEvaluator.Select(registry, rows2, selection, asOf: 1, cursorKey: key); + Assert.That(page.NextCursor, Is.Not.Null); + + var raw = Base64UrlDecode(page.NextCursor!); + var withTrailingGarbage = new byte[raw.Length + 3]; + Array.Copy(raw, withTrailingGarbage, raw.Length); + withTrailingGarbage[raw.Length] = (byte)'x'; + withTrailingGarbage[raw.Length + 1] = (byte)'y'; + withTrailingGarbage[raw.Length + 2] = (byte)'z'; + var tampered = Base64UrlEncode(withTrailingGarbage); + + var ex = Assert.Throws(() => CultNetSelectionCursor.Parse(tampered)); + Assert.That(ex!.Code, Is.EqualTo("cursor_invalid")); + } + + private static string Base64UrlEncode(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static byte[] Base64UrlDecode(string value) + { + var padded = value.Replace('-', '+').Replace('_', '/'); + switch (padded.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + case 0: break; + default: throw new FormatException("Invalid base64url string length."); + } + + return Convert.FromBase64String(padded); + } + + // --------------------------------------------------------------------------------------------- + // Survivor 8: FindCursorPosition (descending walk) and ComparePosition (the schemaId-vs-key + // tiebreak) had no test exercising an equal-ordinal boundary. Two angles: a full descending page + // walk (mirroring the existing ascending-only walk test), and a cursor boundary where two rows + // share both ordinal and record key but differ only by schemaId. + // --------------------------------------------------------------------------------------------- + [Test] + public void Evaluator_PagesDescendingExactlyOnceEachRow() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(CultNetSelectionEvaluatorTests.SelLeafA) }); + var rows = Enumerable.Range(0, 5) + .Select(i => Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = $"n{i}", Kind = "k", Mass = i }, $"k{i}", i)) + .ToArray(); + + var selection = new CultNetSelection { Limit = 2, Descending = true }; + var seen = new System.Collections.Generic.List(); + string? cursor = null; + for (var guard = 0; guard < 10; guard++) + { + selection.Cursor = cursor; + var page = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + seen.AddRange(page.Rows.Select(r => r.Key.Value)); + if (page.NextCursor == null) break; + cursor = page.NextCursor; + } + + Assert.That(seen, Is.EqualTo(new[] { "k4", "k3", "k2", "k1", "k0" })); + } + + // ComparePosition's ordinal.CompareTo ties (both rows ordinal 1, both keyed "m") - only the + // schemaId comparison can tell the cursor which row it already returned. + [Test] + public void Page_ResolvesAnEqualOrdinalEqualKeyCursorBoundaryBySchemaId() + { + var registry = CultDocumentRegistry.ForTypes(new[] + { + typeof(CultNetSelectionEvaluatorTests.SelLeafA), typeof(CultNetSelectionEvaluatorTests.SelLeafB) + }); + var rows = new[] + { + Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "m", 1), + Row(registry, new CultNetSelectionEvaluatorTests.SelLeafB { Name = "b", Kind = "k", Mass = 1 }, "m", 1) + }; + var schemaA = registry.GetRequired().SchemaId; + var schemaB = registry.GetRequired().SchemaId; + var expectedOrder = CultNetCodePointComparer.Instance.Compare(schemaA, schemaB) <= 0 + ? new[] { schemaA, schemaB } + : new[] { schemaB, schemaA }; + + var selection = new CultNetSelection { Limit = 1 }; + var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + Assert.That(first.Rows.Select(r => r.Descriptor.SchemaId), Is.EqualTo(new[] { expectedOrder[0] })); + Assert.That(first.NextCursor, Is.Not.Null); + + selection.Cursor = first.NextCursor; + var second = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + Assert.That(second.Rows.Select(r => r.Descriptor.SchemaId), Is.EqualTo(new[] { expectedOrder[1] })); + Assert.That(second.NextCursor, Is.Null); + } + + // --------------------------------------------------------------------------------------------- + // Survivor 9: CultNetCodePointComparer's private CodePointAt - the unpaired (lone) surrogate + // branch. R-C's existing astral-key test always supplies a complete, valid surrogate pair, so + // the "not actually a pair" branch (documented on the type: "a comparer must not throw on it") + // was never reached: a high surrogate at the very end of the string (no following char at all), + // and a high surrogate followed by a non-low-surrogate character. + // --------------------------------------------------------------------------------------------- + [Test] + public void CodePointComparer_OrdersALoneHighSurrogateAtStringEndAsItsOwnCodeUnit() + { + // U+D800 (lone high surrogate) as its own value (55296) sorts before U+E000 (57344, BMP + // private-use) - the same relative order code points would give, confirming the lone + // surrogate is compared by its raw unit value rather than causing a crash or being skipped. + var withLoneSurrogateAtEnd = "x\uD800"; + var bmpPrivateUse = "x"; + + Assert.That(CultNetCodePointComparer.Instance.Compare(withLoneSurrogateAtEnd, bmpPrivateUse), Is.LessThan(0)); + Assert.That(CultNetCodePointComparer.Instance.Compare(bmpPrivateUse, withLoneSurrogateAtEnd), Is.GreaterThan(0)); + } + + [Test] + public void CodePointComparer_TreatsAHighSurrogateNotFollowedByALowSurrogateAsOneCodeUnit() + { + // The high surrogate is immediately followed by an ordinary BMP character, not a low + // surrogate - IsLowSurrogate must fail and the comparer must advance by one unit, not two, + // continuing on to compare "A" vs "B" rather than treating \uD800 + 'A' as a surrogate pair. + var first = "x\uD800A"; + var second = "x\uD800B"; + + Assert.That(CultNetCodePointComparer.Instance.Compare(first, second), Is.LessThan(0)); + Assert.That(CultNetCodePointComparer.Instance.Compare(second, first), Is.GreaterThan(0)); + } + } +} diff --git a/tests/GameCult.Networking.Tests/MiniJsonSchemaValidator.cs b/tests/GameCult.Networking.Tests/MiniJsonSchemaValidator.cs new file mode 100644 index 00000000..74a50465 --- /dev/null +++ b/tests/GameCult.Networking.Tests/MiniJsonSchemaValidator.cs @@ -0,0 +1,299 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace GameCult.Networking.Tests +{ + /// + /// A deliberately small JSON Schema (draft 2020-12) validator covering only the constructs + /// contracts/cultnet/*.schema.json actually use: object/array/string/integer/boolean/null types + /// (plain or as a ["type","null"] union), required, additionalProperties: false, properties, + /// items, const, enum, pattern, minLength, maxLength, minItems, maxItems, minimum, maximum, oneOf, + /// not, and $ref - same-document ("#/$defs/name"), cross-document ("other.schema.json"), and both + /// ("other.schema.json#/$defs/name"). Not a general validator: it exists for R-S + /// (docs/cultnet-selection-cut.md, fix batch 3) to decode real C# wire bytes and check them against + /// the committed schema files, which no dependency in this repo already does - see + /// CultNetSchemaContractTests. + /// R-X (fix batch 4): a schema keyword this validator does not implement is refused, not silently + /// ignored - see / and + /// . Before this, a schema using e.g. allOf/anyOf/if-then-else/ + /// uniqueItems validated an instance as if that keyword were not there at all, which is silent + /// under-validation, not the "no such constraint" the schema author intended. + /// + internal sealed class MiniJsonSchemaValidator + { + // Keywords this validator gives real constraint semantics to. + private static readonly HashSet ImplementedKeywords = new(StringComparer.Ordinal) + { + "$ref", "oneOf", "not", "const", "enum", "type", + "minLength", "maxLength", "pattern", "minItems", "maxItems", "minimum", "maximum", + "items", "required", "properties", "additionalProperties" + }; + + // Keywords that are pure annotation/metadata - present on many schema nodes, never checked + // against an instance by any JSON Schema vocabulary this validator implements, so there is + // nothing for CheckSupportedKeywords to refuse. contentEncoding/contentMediaType/contentSchema + // are "media" annotations that draft 2020-12 itself does not assign validation behavior to + // without an explicit vocabulary; x-cult-runtime-type is this repo's own vendor extension. + private static readonly HashSet MetadataKeywords = new(StringComparer.Ordinal) + { + "$schema", "$id", "$defs", "title", "description", "default", + "contentEncoding", "x-cult-runtime-type" + }; + + private readonly string _schemaDir; + private readonly Dictionary _docCache = new(StringComparer.Ordinal); + + public MiniJsonSchemaValidator(string schemaDir) + { + _schemaDir = schemaDir; + } + + /// Validates against the named schema file's root schema. Throws with every collected error when invalid. + public void AssertValid(JsonElement instance, string schemaFileName) + { + var errors = Validate(instance, schemaFileName); + if (errors.Count > 0) + { + throw new InvalidOperationException( + $"{instance} does not satisfy {schemaFileName}:{Environment.NewLine}{string.Join(Environment.NewLine, errors)}"); + } + } + + /// Validates against a $defs entry of the named schema file, e.g. "cultnet.selection.schema.json#/$defs/fieldPredicate". + public List Validate(JsonElement instance, string refText) + { + var errors = new List(); + var (schema, file) = Resolve(refText, currentFile: ""); + ValidateAgainst(instance, schema, file, "$", errors); + return errors; + } + + private JsonElement LoadDoc(string fileName) + { + if (_docCache.TryGetValue(fileName, out var cached)) return cached; + var path = Path.Combine(_schemaDir, fileName); + var doc = JsonDocument.Parse(File.ReadAllText(path)).RootElement; + _docCache[fileName] = doc; + return doc; + } + + private (JsonElement Schema, string File) Resolve(string refText, string currentFile) + { + var hash = refText.IndexOf('#'); + string file; + string pointer; + if (hash < 0) + { + file = refText; + pointer = ""; + } + else + { + var filePart = refText[..hash]; + file = filePart.Length == 0 ? currentFile : filePart; + pointer = refText[(hash + 1)..]; + } + + var doc = LoadDoc(file); + if (string.IsNullOrEmpty(pointer) || pointer == "/") return (doc, file); + + var current = doc; + foreach (var segment in pointer.TrimStart('/').Split('/')) + { + var key = segment.Replace("~1", "/").Replace("~0", "~"); + current = current.GetProperty(key); + } + + return (current, file); + } + + private void ValidateAgainst(JsonElement instance, JsonElement schema, string file, string path, List errors) + { + CheckSupportedKeywords(schema, path, errors); + + if (schema.TryGetProperty("$ref", out var refProp)) + { + var (resolved, resolvedFile) = Resolve(refProp.GetString()!, file); + ValidateAgainst(instance, resolved, resolvedFile, path, errors); + return; + } + + if (schema.TryGetProperty("oneOf", out var oneOf)) + { + var matches = 0; + var branchErrors = new List(); + foreach (var branch in oneOf.EnumerateArray()) + { + var local = new List(); + ValidateAgainst(instance, branch, file, path, local); + if (local.Count == 0) matches++; + else branchErrors.AddRange(local); + } + + if (matches != 1) + { + errors.Add($"{path}: oneOf matched {matches} branches (want exactly 1); branch errors: {string.Join(" | ", branchErrors)}"); + } + } + + if (schema.TryGetProperty("not", out var not)) + { + var local = new List(); + ValidateAgainst(instance, not, file, path, local); + if (local.Count == 0) + errors.Add($"{path}: matched the \"not\" subschema, which it must not"); + } + + if (schema.TryGetProperty("const", out var constEl)) + { + if (!JsonElementDeepEquals(instance, constEl)) + errors.Add($"{path}: expected const {constEl}, got {instance}"); + } + + if (schema.TryGetProperty("enum", out var enumEl)) + { + if (!enumEl.EnumerateArray().Any(candidate => JsonElementDeepEquals(instance, candidate))) + errors.Add($"{path}: value {instance} is not one of the declared enum values"); + } + + if (schema.TryGetProperty("type", out var typeEl)) + { + var allowed = typeEl.ValueKind == JsonValueKind.Array + ? typeEl.EnumerateArray().Select(t => t.GetString()!).ToArray() + : new[] { typeEl.GetString()! }; + if (!allowed.Any(t => MatchesType(instance, t))) + errors.Add($"{path}: kind {instance.ValueKind} does not satisfy type [{string.Join(",", allowed)}]"); + } + + // Null short-circuits the value-shaped checks below (a nilled key still satisfies a + // ["...","null"] type, but has no length/properties/items of its own to check). + if (instance.ValueKind == JsonValueKind.Null) + { + return; + } + + if (instance.ValueKind == JsonValueKind.String) + { + var value = instance.GetString()!; + if (schema.TryGetProperty("minLength", out var minLength) && value.Length < minLength.GetInt32()) + errors.Add($"{path}: string shorter than minLength {minLength.GetInt32()}"); + if (schema.TryGetProperty("maxLength", out var maxLength) && value.Length > maxLength.GetInt32()) + errors.Add($"{path}: string longer than maxLength {maxLength.GetInt32()}"); + if (schema.TryGetProperty("pattern", out var pattern)) + { + // R-X: a full match, not Regex.IsMatch's "search anywhere" - and not .NET's own `$`, + // which (unlike ECMA-262's) matches immediately before a single trailing newline even + // without RegexOptions.Multiline, so an author-anchored "^...$" pattern here would + // wrongly accept e.g. "5\n" for a pattern that means "exactly one digit". + var match = Regex.Match(value, pattern.GetString()!); + if (!match.Success || match.Index != 0 || match.Length != value.Length) + errors.Add($"{path}: \"{value}\" does not match pattern {pattern.GetString()}"); + } + } + + if (instance.ValueKind == JsonValueKind.Number) + { + if (schema.TryGetProperty("minimum", out var minimum) && instance.GetDouble() < minimum.GetDouble()) + errors.Add($"{path}: {instance} is less than minimum {minimum}"); + if (schema.TryGetProperty("maximum", out var maximum) && instance.GetDouble() > maximum.GetDouble()) + errors.Add($"{path}: {instance} is greater than maximum {maximum}"); + } + + if (instance.ValueKind == JsonValueKind.Array) + { + var items = instance.EnumerateArray().ToArray(); + if (schema.TryGetProperty("minItems", out var minItems) && items.Length < minItems.GetInt32()) + errors.Add($"{path}: array shorter than minItems {minItems.GetInt32()}"); + if (schema.TryGetProperty("maxItems", out var maxItems) && items.Length > maxItems.GetInt32()) + errors.Add($"{path}: array longer than maxItems {maxItems.GetInt32()}"); + if (schema.TryGetProperty("items", out var itemSchema)) + { + for (var i = 0; i < items.Length; i++) + ValidateAgainst(items[i], itemSchema, file, $"{path}[{i}]", errors); + } + } + + if (instance.ValueKind == JsonValueKind.Object) + { + var propertyNames = instance.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal); + if (schema.TryGetProperty("required", out var required)) + { + foreach (var name in required.EnumerateArray().Select(r => r.GetString()!)) + { + if (!propertyNames.Contains(name)) + errors.Add($"{path}: missing required property \"{name}\""); + } + } + + var declared = schema.TryGetProperty("properties", out var properties) + ? properties.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal) + : new HashSet(StringComparer.Ordinal); + if (schema.TryGetProperty("additionalProperties", out var additional) && + additional.ValueKind == JsonValueKind.False) + { + foreach (var name in propertyNames) + { + if (!declared.Contains(name)) + errors.Add($"{path}: property \"{name}\" is not declared and additionalProperties is false"); + } + } + + if (properties.ValueKind == JsonValueKind.Object) + { + foreach (var propertySchema in properties.EnumerateObject()) + { + if (instance.TryGetProperty(propertySchema.Name, out var propertyValue)) + ValidateAgainst(propertyValue, propertySchema.Value, file, $"{path}.{propertySchema.Name}", errors); + } + } + } + } + + /// + /// R-X: refuses a schema node that uses a keyword this validator gives no behaviour to - allOf, + /// anyOf, if/then/else and uniqueItems are the ones found in the committed contracts at the time + /// this check was added. Before this, such a keyword was silently invisible: an instance that + /// violated it still validated clean, because nothing ever looked at the keyword to begin with. + /// Only checks the schema node actually reached during this validation (the same nodes + /// ValidateAgainst recurses into) - it is not a whole-document schema linter. + /// + private static void CheckSupportedKeywords(JsonElement schema, string path, List errors) + { + if (schema.ValueKind != JsonValueKind.Object) return; + foreach (var property in schema.EnumerateObject()) + { + if (!ImplementedKeywords.Contains(property.Name) && !MetadataKeywords.Contains(property.Name)) + errors.Add($"{path}: schema uses keyword \"{property.Name}\", which this validator does not implement"); + } + } + + private static bool MatchesType(JsonElement instance, string type) => type switch + { + "object" => instance.ValueKind == JsonValueKind.Object, + "array" => instance.ValueKind == JsonValueKind.Array, + "string" => instance.ValueKind == JsonValueKind.String, + "boolean" => instance.ValueKind is JsonValueKind.True or JsonValueKind.False, + "null" => instance.ValueKind == JsonValueKind.Null, + "integer" => instance.ValueKind == JsonValueKind.Number && instance.TryGetInt64(out _), + "number" => instance.ValueKind == JsonValueKind.Number, + _ => throw new NotSupportedException($"Unsupported schema type \"{type}\".") + }; + + private static bool JsonElementDeepEquals(JsonElement a, JsonElement b) + { + if (a.ValueKind != b.ValueKind) return false; + return a.ValueKind switch + { + JsonValueKind.String => a.GetString() == b.GetString(), + JsonValueKind.Number => a.GetRawText() == b.GetRawText(), + JsonValueKind.True or JsonValueKind.False or JsonValueKind.Null => true, + _ => a.GetRawText() == b.GetRawText() + }; + } + } +} diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 756e55a3..40bf43eb 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -1425,6 +1425,7 @@ namespace GameCult.Networking.Tests var response = await AwaitWithTimeout(responseCompletion.Task, TimeSpan.FromSeconds(2)); await AwaitWithTimeout(serverDone.Task, TimeSpan.FromSeconds(2)); + serverThread.Join(); Assert.That(response.MessageId, Is.EqualTo("rudp-schema-host-test")); Assert.That(response.Schemas.Single().SchemaId, Is.EqualTo("rudp.schema.host")); @@ -1470,6 +1471,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { async Task FetchOnce(string messageId) { @@ -1487,7 +1493,12 @@ namespace GameCult.Networking.Tests await FetchOnce("first"); await WaitUntilAsync(() => server.Peers.Count == 0, TimeSpan.FromSeconds(2)); await FetchOnce("second"); - cancellation.Cancel(); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } // A batch is a value: nothing staged in it reaches CultNet observers before the store commits it. A put made @@ -1549,6 +1560,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { using var client = CultNetSchemaClients.CreateRudp("database-subscription-client"); var subscribed = new TaskCompletionSource( @@ -1575,12 +1591,16 @@ namespace GameCult.Networking.Tests Text = "live" }); var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); - - cancellation.Cancel(); Assert.That(update.SubscriptionId, Is.EqualTo("note")); Assert.That(update.ChangeKind, Is.EqualTo("added")); Assert.That(update.Document, Is.Not.Null); Assert.That(update.Document!.RecordKey, Is.EqualTo(recordKey)); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] @@ -1597,12 +1617,7 @@ namespace GameCult.Networking.Tests server, database, authorizeRequest: (request, _) => request.ConsumerRuntimeId == "allowed-runtime", - authorizeRecord: (_, _, recordKey, _) => recordKey.StartsWith("tests:public:", StringComparison.Ordinal), - projectRecord: (request, _, record) => - { - record.SourceRuntimeId = "projection:" + request.ConsumerRuntimeId; - return record; - }); + authorizeRecord: (_, _, recordKey, _) => recordKey.StartsWith("tests:public:", StringComparison.Ordinal)); using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => { @@ -1613,6 +1628,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { await database.PutAsync(new CultRecordKey("tests:public:initial"), new NetworkSchemaNote { @@ -1645,7 +1665,6 @@ namespace GameCult.Networking.Tests var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2)); Assert.That(snapshot.Documents.Select(document => document.RecordKey), Is.EqualTo(new[] { "tests:public:initial" })); - Assert.That(snapshot.Documents.Single().SourceRuntimeId, Is.EqualTo("projection:allowed-runtime")); await database.PutAsync(new CultRecordKey("tests:private:live"), new NetworkSchemaNote { @@ -1658,23 +1677,27 @@ namespace GameCult.Networking.Tests Text = "public-live" }); var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); - - cancellation.Cancel(); Assert.That(update.Document, Is.Not.Null); Assert.That(update.Document!.RecordKey, Is.EqualTo("tests:public:live")); - Assert.That(update.Document.SourceRuntimeId, Is.EqualTo("projection:allowed-runtime")); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] public async Task DatabaseSubscriptionServer_ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges() { - const string sourceRecordKey = "tests:authority:source"; + const string schemaId = "tests.networking_note"; + const string recordKeyOne = "tests:authority:one"; + const string recordKeyTwo = "tests:authority:two"; const string bodyId = "tests:authority:body"; var authorized = true; - var projectionName = "one"; var sourceCache = new CultCache(); var sourceDatabase = new CultNetDatabase(sourceCache); - await sourceDatabase.PutAsync(new CultRecordKey(sourceRecordKey), new NetworkSchemaNote + await sourceDatabase.PutAsync(new CultRecordKey(recordKeyOne), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "initial" @@ -1688,12 +1711,7 @@ namespace GameCult.Networking.Tests server, sourceDatabase, authorizeRequest: (_, _) => authorized, - authorizeRecord: (_, _, _, _) => authorized, - projectRecord: (_, _, record) => - { - record.RecordKey = $"tests:authority:projected:{projectionName}"; - return record; - }); + authorizeRecord: (_, _, _, _) => authorized); using var bodyDemand = new CultMeshBodyDemandTracker(subscriptions); using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => @@ -1705,6 +1723,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { var targetCache = new CultCache(); var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry) @@ -1718,42 +1741,427 @@ namespace GameCult.Networking.Tests await AwaitWithTimeout( client.SubscribeAsync( "authority", - recordKeys: [sourceRecordKey], + schemaIds: [schemaId], consumerRuntimeId: "authority-client", bodyIds: [bodyId], supportedBodyTransports: [CultMeshBodyTransportKind.SharedMemory.ToString()]), TimeSpan.FromSeconds(2)); - Assert.That(targetCache.Get(new CultRecordKey("tests:authority:projected:one")), Is.Not.Null); + Assert.That(targetCache.Get(new CultRecordKey(recordKeyOne)), Is.Not.Null); + // R-K: PublishDemand runs after the snapshot is sent, on the server thread, while + // SubscribeAsync's completion is driven by the client's receipt of that same snapshot on a + // different thread - asserting immediately races the two. Wait for the deterministic + // condition (the demand actually being published) instead of assuming order-of-completion + // across a network round trip. + await WaitUntilAsync(() => bodyDemand.Plan(bodyId).HasConsumers, TimeSpan.FromSeconds(2)); Assert.That(bodyDemand.Plan(bodyId).HasConsumers, Is.True); - projectionName = "two"; + // Drive the record's identity change through the selection itself + // (a real rename: the old key's row is deleted, a new key's row is + // put), not through a projection delegate, then force the full + // reconcile diff that D6 gives the subscription server. + await sourceDatabase.PutAsync(new CultRecordKey(recordKeyTwo), new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "renamed" + }); + await sourceDatabase.DeleteAsync(new CultRecordKey(recordKeyOne)); subscriptions.Reconcile(); await WaitUntilAsync( - () => targetCache.Get(new CultRecordKey("tests:authority:projected:one")) == null && - targetCache.Get(new CultRecordKey("tests:authority:projected:two")) != null, + () => targetCache.Get(new CultRecordKey(recordKeyOne)) == null && + targetCache.Get(new CultRecordKey(recordKeyTwo)) != null, TimeSpan.FromSeconds(2)); Assert.That(changes.Any(change => change.ChangeKind == "removed" && - change.RecordKey == "tests:authority:projected:one"), Is.True); + change.RecordKey == recordKeyOne), Is.True); Assert.That(changes.Any(change => change.ChangeKind == "added" && - change.RecordKey == "tests:authority:projected:two"), Is.True); + change.RecordKey == recordKeyTwo), Is.True); authorized = false; subscriptions.Reconcile(); await WaitUntilAsync( - () => targetCache.Get(new CultRecordKey("tests:authority:projected:two")) == null, + () => targetCache.Get(new CultRecordKey(recordKeyTwo)) == null, TimeSpan.FromSeconds(2)); + // R-K: same race as the HasConsumers=true assertion above, the other direction. + await WaitUntilAsync(() => !bodyDemand.Plan(bodyId).HasConsumers, TimeSpan.FromSeconds(2)); Assert.That(bodyDemand.Plan(bodyId).HasConsumers, Is.False); var changeCountAfterRevocation = changes.Count; - await sourceDatabase.PutAsync(new CultRecordKey(sourceRecordKey), new NetworkSchemaNote + await sourceDatabase.PutAsync(new CultRecordKey(recordKeyTwo), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "must-not-escape" }); await Task.Delay(100); - cancellation.Cancel(); - Assert.That(changes.Count, Is.EqualTo(changeCountAfterRevocation)); - Assert.That(targetCache.Get(new CultRecordKey("tests:authority:projected:two")), Is.Null); + Assert.That(targetCache.Get(new CultRecordKey(recordKeyTwo)), Is.Null); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } + } + + // Reconcile's remaining responsibility after the S2-2 deletion (docs/cultnet-selection-cut.md, + // Self's rulings 2026-09-22): flip authorizeRecord's answer per key while the whole request + // stays authorized (so DemandActive never toggles), and prove both halves of the diff - + // removing a now-unauthorized key and the add loop picking up a newly-authorized one. + [Test] + public async Task DatabaseSubscriptionServer_Reconcile_FlipsPerRecordAuthorizationWithoutChangingRequestAuthority() + { + const string schemaId = "tests.networking_note"; + const string recordKeyA = "tests:record-authority:a"; + const string recordKeyB = "tests:record-authority:b"; + var authorizedKeys = new HashSet(StringComparer.Ordinal) { recordKeyA }; + var cache = new CultCache(); + var database = new CultNetDatabase(cache); + await database.PutAsync(new CultRecordKey(recordKeyA), new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "a" + }); + await database.PutAsync(new CultRecordKey(recordKeyB), new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "b" + }); + + using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions + { + RuntimeId = "database-record-authority-subscription-server", + Socket = BindUdpSocket() + }); + using var subscriptions = new CultNetDatabaseSubscriptionServer( + server, + database, + authorizeRequest: (_, _) => true, + authorizeRecord: (_, _, recordKey, _) => authorizedKeys.Contains(recordKey)); + using var cancellation = new CancellationTokenSource(); + var serverThread = new Thread(() => + { + while (!cancellation.IsCancellationRequested) + { + _ = server.PollOnceAsync().GetAwaiter().GetResult(); + Thread.Sleep(1); + } + }) { IsBackground = true }; + serverThread.Start(); + try + { + using var client = CultNetSchemaClients.CreateRudp("database-record-authority-subscription-client"); + var changes = new ConcurrentQueue(); + var subscribed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + client.OnCultNet(message => subscribed.TrySetResult(message)); + client.OnCultNet(message => changes.Enqueue(message)); + client.Connect("127.0.0.1", server.LocalEndPoint.Port); + await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); + client.SendCultNet(new CultNetDatabaseSubscribeMessage + { + MessageId = "subscribe-record-authority", + SubscriptionId = "record-authority", + ConsumerRuntimeId = "record-authority-client", + SchemaIds = new[] { schemaId }, + IncludeSnapshot = true + }); + var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2)); + Assert.That(snapshot.Documents.Select(document => document.RecordKey), Is.EqualTo(new[] { recordKeyA })); + + authorizedKeys.Clear(); + authorizedKeys.Add(recordKeyB); + subscriptions.Reconcile(); + + // SendRemoval carries the removed key on the message's own RecordKey field (no + // Document); SendUpsert carries the full record under Document. + await WaitUntilAsync( + () => changes.Any(change => change.ChangeKind == "removed" && change.RecordKey == recordKeyA) && + changes.Any(change => change.ChangeKind == "added" && change.Document?.RecordKey == recordKeyB), + TimeSpan.FromSeconds(2)); + + Assert.That(changes.Any(change => change.ChangeKind == "removed" && change.RecordKey == recordKeyA), Is.True); + Assert.That(changes.Any(change => change.ChangeKind == "added" && change.Document?.RecordKey == recordKeyB), Is.True); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } + } + + // R-P (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3): one id reaches the + // authorizer, and it is the wire id, on both the snapshot and the live path. The binding below + // overrides NetworkSchemaNote's wire schema id away from its descriptor's own id + // ("tests.networking_note"); the authorizer allowlists only the wire id. Before the fix, the + // live fast path (CreateMatchedRecord) passed descriptor.SchemaId instead of the wire id, so a + // row delivered by the snapshot was refused on its first live change. + [Test] + public async Task DatabaseSubscriptionServer_AuthorizesSnapshotAndFirstLiveChangeByTheSameWireId() + { + const string wireSchemaId = "tests.networking_note.wire-alias"; + const string recordKey = "tests:wire-id:note"; + var cache = new CultCache(); + var registry = new CultNetDocumentRegistry(cache.Registry) + .Register(CultNetDocumentBinding.ForDocument(cache.Registry, schemaId: wireSchemaId)); + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry }); + await database.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "initial" + }); + + using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions + { + RuntimeId = "database-wire-id-subscription-server", + Socket = BindUdpSocket() + }); + using var subscriptions = new CultNetDatabaseSubscriptionServer( + server, + database, + authorizeRequest: (_, _) => true, + authorizeRecord: (_, _, _, schemaId) => schemaId == wireSchemaId); + using var cancellation = new CancellationTokenSource(); + var serverThread = new Thread(() => + { + while (!cancellation.IsCancellationRequested) + { + _ = server.PollOnceAsync().GetAwaiter().GetResult(); + Thread.Sleep(1); + } + }) { IsBackground = true }; + serverThread.Start(); + try + { + using var client = CultNetSchemaClients.CreateRudp("database-wire-id-subscription-client"); + var subscribed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var changed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + client.OnCultNet(message => subscribed.TrySetResult(message)); + client.OnCultNet(message => changed.TrySetResult(message)); + client.Connect("127.0.0.1", server.LocalEndPoint.Port); + await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); + client.SendCultNet(new CultNetDatabaseSubscribeMessage + { + MessageId = "subscribe-wire-id", + SubscriptionId = "wire-id", + RecordKeys = new[] { recordKey }, + IncludeSnapshot = true + }); + + var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2)); + Assert.That(snapshot.Documents.Select(document => document.RecordKey), Is.EqualTo(new[] { recordKey })); + + await database.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "updated" + }); + var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); + Assert.That(update.Document, Is.Not.Null); + Assert.That(update.Document!.RecordKey, Is.EqualTo(recordKey)); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } + } + + // R-T(a) (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3): a removed change is + // evaluated without reading a document - it matches on the selection's keys and schemas alone, + // and a field predicate counts as unknown rather than excluding the row or throwing + // TryGetIndexValue(null). A subscriber holding the row under a selection with a field predicate + // must still receive the removal. + [Test] + public async Task DatabaseSubscriptionServer_DeliversARemovalUnderAFieldPredicateSelectionWithNoException() + { + const string recordKey = "v1:field-predicate-removal:sword"; + var cache = new CultCache(); + var database = new CultNetDatabase(cache); + await database.PutAsync(new CultRecordKey(recordKey), new CultNetSelectionEvaluatorTests.SelLeafA + { + Name = "sword", + Kind = "weapon", + Mass = 3 + }); + var schemaId = cache.Registry.GetRequired().SchemaId; + + using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions + { + RuntimeId = "database-field-predicate-removal-server", + Socket = BindUdpSocket() + }); + using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database); + using var cancellation = new CancellationTokenSource(); + var serverThread = new Thread(() => + { + while (!cancellation.IsCancellationRequested) + { + _ = server.PollOnceAsync().GetAwaiter().GetResult(); + Thread.Sleep(1); + } + }) { IsBackground = true }; + serverThread.Start(); + try + { + using var client = CultNetSchemaClients.CreateRudp("database-field-predicate-removal-client"); + var subscribed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var changes = new ConcurrentQueue(); + client.OnCultNet(message => subscribed.TrySetResult(message)); + client.OnCultNet(message => changes.Enqueue(message)); + client.Connect("127.0.0.1", server.LocalEndPoint.Port); + await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); + + client.SendCultNet(new CultNetDatabaseSubscribeV1Message + { + MessageId = "subscribe-field-predicate-removal", + SubscriptionId = "field-predicate-removal", + IncludeSnapshot = true, + Selection = new CultNetSelection + { + Schemas = new[] { schemaId }, + Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon" } } } + } + }); + var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2)); + Assert.That(snapshot.Matched, Is.EqualTo(1u)); + + // Before R-T(a), evaluating this delete against the field predicate threw + // TryGetIndexValue(null) on the server's poll thread instead of delivering a removal. + await database.DeleteAsync(new CultRecordKey(recordKey)); + + await WaitUntilAsync( + () => changes.Any(change => change.ChangeKind == "removed" && change.RecordKey == recordKey), + TimeSpan.FromSeconds(2)); + Assert.That(changes.Any(change => change.ChangeKind == "removed" && change.RecordKey == recordKey), Is.True); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } + } + + // R-A (docs/cultnet-selection-cut.md, Self's rulings for the Cut 1 fix batch): CultNetDatabaseServer + // answers a v1 snapshot request against the selection's own Fields predicate, not a selection + // quietly lowered to v0's two allowlists. CultNetDatabaseServer pairs with Server (the + // LiteNetLib/TCP-secured production construction site, per section 1), not RudpCultNetSchemaServer + // - matching CultNetDatabaseServer_Creates_Filtered_SnapshotResponse above, this drives + // CreateSelectionResponse directly rather than a real client/server round trip. + [Test] + public async Task CultNetDatabaseServer_AnswersV1SnapshotRequestRespectingItsFields() + { + var cache = new CultCache(); + var database = new CultNetDatabase(cache); + await database.PutAsync(new CultRecordKey("v1:weapon"), new CultNetSelectionEvaluatorTests.SelLeafA + { + Name = "sword", + Kind = "weapon", + Mass = 3 + }); + await database.PutAsync(new CultRecordKey("v1:armor"), new CultNetSelectionEvaluatorTests.SelLeafA + { + Name = "shield", + Kind = "armor", + Mass = 3 + }); + + using var server = new Server(cache, DevelopmentServerSecurity); + using var databaseServer = new CultNetDatabaseServer(server, database); + + var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message + { + MessageId = "v1-snapshot", + Selection = new CultNetSelection + { + Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon" } } }, + Projection = CultNetSelectionProjections.Document + } + }); + + Assert.That(response.Matched, Is.EqualTo(1u)); + Assert.That(response.Documents!.Select(document => document.RecordKey), Is.EqualTo(new[] { "v1:weapon" })); + } + + // S9 (docs/cultnet-selection-cut.md section 10) / D6: a hop-bearing subscription reconciles the + // whole matched set on every change, not only the changed row. "target" does not itself change + // here - a new citer is put that cites it - so only the reconcile path (not the single-row fast + // path, which would evaluate the citer's own row against a Selection carrying no hop and answer + // null, since the citer's schema is excluded by this selection's Schemas filter) can produce the + // add this test waits for. + [Test] + public async Task DatabaseSubscriptionServer_SubscribesV1AndReconcilesAHopBearingSelectionWhenTheCiterChanges() + { + var cache = new CultCache(); + var database = new CultNetDatabase(cache); + await database.PutAsync(new CultRecordKey("target"), new CultNetSelectionEvaluatorTests.SelLeafA + { + Name = "target", + Kind = "k", + Mass = 1 + }); + var targetSchemaId = cache.Registry.GetRequired().SchemaId; + + using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions + { + RuntimeId = "database-v1-hop-subscription-server", + Socket = BindUdpSocket() + }); + using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database); + using var cancellation = new CancellationTokenSource(); + var serverThread = new Thread(() => + { + while (!cancellation.IsCancellationRequested) + { + _ = server.PollOnceAsync().GetAwaiter().GetResult(); + Thread.Sleep(1); + } + }) { IsBackground = true }; + serverThread.Start(); + try + { + using var client = CultNetSchemaClients.CreateRudp("database-v1-hop-subscription-client"); + var subscribed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var changes = new ConcurrentQueue(); + client.OnCultNet(message => subscribed.TrySetResult(message)); + client.OnCultNet(message => changes.Enqueue(message)); + client.Connect("127.0.0.1", server.LocalEndPoint.Port); + await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); + + client.SendCultNet(new CultNetDatabaseSubscribeV1Message + { + MessageId = "subscribe-v1-hop", + SubscriptionId = "hop", + IncludeSnapshot = true, + Selection = new CultNetSelection + { + Schemas = new[] { targetSchemaId }, + Cited = new CultNetIncoming { Role = "Design", Exists = true } + } + }); + + var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2)); + // "target" is not yet cited by anything - the hop-bearing selection matches nothing yet. + Assert.That(snapshot.Matched, Is.EqualTo(0u)); + + // The citer is a different schema entirely, excluded by this selection's own Schemas + // filter - the change that must cause "target" to be reported is not to "target" itself. + await database.PutAsync(new CultRecordKey("citer"), new CultNetSelectionEvaluatorTests.SelCiter + { + Name = "citer", + Design = new CultRecordRef(new CultRecordKey("target")) + }); + + await WaitUntilAsync( + () => changes.Any(change => change.ChangeKind == "added" && change.Document?.RecordKey == "target"), + TimeSpan.FromSeconds(2)); + Assert.That(changes.Any(change => change.ChangeKind == "added" && change.Document?.RecordKey == "target"), Is.True); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] @@ -1785,6 +2193,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { using var client = CultNetSchemaClients.CreateRudp("database-body-demand-client"); client.Connect("127.0.0.1", server.LocalEndPoint.Port); @@ -1815,9 +2228,14 @@ namespace GameCult.Networking.Tests SubscriptionId = "world-body" }); var removed = await AwaitWithTimeout(withdrawn.Task, TimeSpan.FromSeconds(2)); - cancellation.Cancel(); Assert.That(removed.ConsumerRuntimeId, Is.EqualTo("eve-unity")); Assert.That(removed.Active, Is.False); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } // A load's delivery reaches a subscription observer that needs the lifecycle gate while a subscribe's demand @@ -1868,6 +2286,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { using var client = CultNetSchemaClients.CreateRudp("demand-write-client"); client.Connect("127.0.0.1", server.LocalEndPoint.Port); @@ -1884,7 +2307,14 @@ namespace GameCult.Networking.Tests cancellation.Cancel(); Assert.That(finished, Is.True, "the demand handler's cache write deadlocked with the load's delivery"); await written.Task; - serverThread.Join(); + } + finally + { + // Cancel/Join before disposing anything the poll thread touches (server), so a failed + // assertion above cannot race the thread against a disposed socket (R-K). + cancellation.Cancel(); + serverThread.Join(); + } subscriptions.Dispose(); server.Dispose(); database.Dispose(); @@ -1948,6 +2378,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { using var client = CultNetSchemaClients.CreateRudp("database-reactive-demand-client"); client.Connect("127.0.0.1", server.LocalEndPoint.Port); @@ -1963,10 +2398,15 @@ namespace GameCult.Networking.Tests }); var observed = await AwaitWithTimeout(active.Task, TimeSpan.FromSeconds(2)); - cancellation.Cancel(); Assert.That(observed.RecordKeys, Is.EqualTo(new[] { "world:field:fog" })); Assert.That(observed.SchemaIds, Is.EqualTo(new[] { "gamecult.fields.splats.v1" })); Assert.That(observed.BodyIds, Is.Empty); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] @@ -1998,6 +2438,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { using var client = CultNetSchemaClients.CreateRudp("database-disconnect-demand-client"); client.Connect("127.0.0.1", server.LocalEndPoint.Port); @@ -2016,10 +2461,14 @@ namespace GameCult.Networking.Tests client.Dispose(); var removed = await AwaitWithTimeout(withdrawn.Task, TimeSpan.FromSeconds(2)); await WaitUntilAsync(() => server.Peers.Count == 0, TimeSpan.FromSeconds(2)); - cancellation.Cancel(); - Assert.That(removed.SubscriptionId, Is.EqualTo("disconnect-world-body")); Assert.That(removed.Active, Is.False); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] @@ -2049,6 +2498,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { var targetCache = new CultCache(); var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry) @@ -2072,11 +2526,15 @@ namespace GameCult.Networking.Tests Text = "live" }); var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); - - cancellation.Cancel(); Assert.That(update.SubscriptionId, Is.EqualTo("notes")); Assert.That(update.Document, Is.TypeOf()); Assert.That(((NetworkSchemaNote)targetCache.Get(new CultRecordKey(recordKey))!).Text, Is.EqualTo("live")); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] @@ -2106,6 +2564,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { var targetCache = new CultCache(); var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry) @@ -2132,10 +2595,14 @@ namespace GameCult.Networking.Tests Text = "live" }); var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); - - cancellation.Cancel(); Assert.That(((NetworkSchemaNote)update.Document!).Text, Is.EqualTo("live")); Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] @@ -2160,6 +2627,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { var targetCache = new CultCache(); var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry) @@ -2196,10 +2668,14 @@ namespace GameCult.Networking.Tests Text = "updated" }); update = await AwaitWithTimeout(updated.Task, TimeSpan.FromSeconds(2)); - - cancellation.Cancel(); Assert.That(update.Text, Is.EqualTo("updated")); Assert.That(value.Current.Text, Is.EqualTo("updated")); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] @@ -2233,6 +2709,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { var targetCache = new CultCache(); var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry) @@ -2246,10 +2727,14 @@ namespace GameCult.Networking.Tests client.SubscribeLiveValueAsync("demanded-note", recordKey), TimeSpan.FromSeconds(2)); await WaitUntilAsync(() => value.HasValue, TimeSpan.FromSeconds(2)); - - cancellation.Cancel(); Assert.That(value.Current.Text, Is.EqualTo("materialized-on-demand")); Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] @@ -2280,6 +2765,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { var targetCache = new CultCache(); var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry) @@ -2300,10 +2790,14 @@ namespace GameCult.Networking.Tests Text = "wire-live" }); var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); - - cancellation.Cancel(); Assert.That(update.SchemaId, Is.EqualTo(wireSchema)); Assert.That(((NetworkSchemaNote)update.Document!).Text, Is.EqualTo("wire-live")); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] @@ -2331,26 +2825,39 @@ namespace GameCult.Networking.Tests new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "alias-live" }, previousDocument: null); var method = typeof(CultNetDatabaseSubscriptionServer).GetMethod( - "CreateChange", + "CreateMatchedRecord", BindingFlags.Instance | BindingFlags.NonPublic)!; - - var message = (CultNetDatabaseChangeRawMessage?)method.Invoke(subscriptions, new object[] + var requestType = typeof(CultNetDatabaseSubscriptionServer).GetNestedType( + "SubscriptionRequest", BindingFlags.NonPublic)!; + var requestCtor = requestType.GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public).Single(); + var request = requestCtor.Invoke(new object?[] { - change, - new CultNetDatabaseSubscribeMessage + new CultNetSelection { - SubscriptionId = "alias-notes", - SchemaIds = ["tests.networking_note.v1"], - RecordKeys = ["tests:subscription-client:alias-note"] + Schemas = ["tests.networking_note.v1"], + Keys = ["tests:subscription-client:alias-note"] }, - "alias-notes" + "alias-notes", + "alias-notes", + true, + null, + null, + null, + false }); - Assert.That(message, Is.Not.Null); - Assert.That(message!.Document!.SchemaId, Is.EqualTo(descriptor.SchemaId)); - Assert.That(message.Document.SchemaName, Is.EqualTo(descriptor.SchemaName)); - Assert.That(message.Document.SchemaVersion, Is.EqualTo(descriptor.SchemaVersion)); - Assert.That(message.Document.SchemaContentHash, Is.EqualTo(descriptor.ContentHash)); + var record = (CultNetRawDocumentRecord?)method.Invoke(subscriptions, new object?[] + { + change, + request, + null + }); + + Assert.That(record, Is.Not.Null); + Assert.That(record!.SchemaId, Is.EqualTo(descriptor.SchemaId)); + Assert.That(record.SchemaName, Is.EqualTo(descriptor.SchemaName)); + Assert.That(record.SchemaVersion, Is.EqualTo(descriptor.SchemaVersion)); + Assert.That(record.SchemaContentHash, Is.EqualTo(descriptor.ContentHash)); } [Test] @@ -2397,6 +2904,8 @@ namespace GameCult.Networking.Tests catch (Exception error) { serverFailure.TrySetResult(error); } }) { IsBackground = true }; serverThread.Start(); + try + { using var client = CultNetSchemaClients.CreateRudp( runtimeId: "csharp-rudp-large-snapshot-client", @@ -2419,11 +2928,16 @@ namespace GameCult.Networking.Tests await WaitUntilAsync( () => server.Peers.Count == 1 && server.Peers.Single().PendingReliablePacketCount == 0, TimeSpan.FromSeconds(2)); - cancellation.Cancel(); Assert.That(response.Documents, Has.Length.EqualTo(1)); Assert.That(response.Documents[0].Payload, Has.Length.EqualTo(128 * 1024)); Assert.That(server.Stats.BytesSent, Is.LessThan(512 * 1024), "A lossless large response must not remain trapped in the reliable resend queue."); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] @@ -2457,6 +2971,8 @@ namespace GameCult.Networking.Tests catch (Exception error) { serverFailure.TrySetResult(error); } }) { IsBackground = true }; serverThread.Start(); + try + { using var client = CultNetSchemaClients.CreateRudp( runtimeId: "csharp-rudp-concurrent-publish-client", @@ -2499,7 +3015,12 @@ namespace GameCult.Networking.Tests if (completed == serverFailure.Task) throw await serverFailure.Task; if (completed != receivedAll.Task) Assert.Fail($"Concurrent publish timed out after receiving {receivedIds.Count} of {expectedMessages} logical messages."); - cancellation.Cancel(); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } Assert.That(receivedIds.Count, Is.EqualTo(expectedMessages)); } @@ -2918,6 +3439,63 @@ namespace GameCult.Networking.Tests Assert.That(SerializePlayerDataPayload(replicated!), Is.EqualTo(expectedPayload)); } + // R-R (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22): v0 lowering preserves v0's + // pre-cut answers exactly. recordKeys: [] answers empty - it does not lower to "no filter". + [Test] + public async Task CultNetDocumentRegistry_RawSnapshot_ExplicitEmptyRecordKeys_AnswersEmpty() + { + var cache = new CultCache(); + var registry = new CultNetDocumentRegistry() + .Register(CultNetDocumentBinding.ForDocument( + payloadSerializer: SerializePlayerDataPayload, + payloadDeserializer: DeserializePlayerDataPayload)); + await cache.AddAsync(new PlayerData { PlayerId = Guid.NewGuid(), Email = "a@test", PasswordHash = "x", Username = "A" }); + + var request = registry.CreateSnapshotRequest("request-empty", recordKeys: Array.Empty()); + var response = registry.CreateRawSnapshotResponse(cache, "snapshot-empty", request); + + Assert.That(response.Documents, Is.Empty); + } + + // R-R: v0's pre-cut CreateRawSnapshotResponse iterated the requested record keys in the order + // the caller sent them, not in schema-and-key sort order. This pins that order survives the + // move to the shared evaluator. + [Test] + public async Task CultNetDocumentRegistry_RawSnapshot_KeepsRequestedRecordKeyOrder() + { + var cache = new CultCache(); + var registry = new CultNetDocumentRegistry() + .Register(CultNetDocumentBinding.ForDocument( + payloadSerializer: SerializePlayerDataPayload, + payloadDeserializer: DeserializePlayerDataPayload)); + + var players = new[] { "zeta", "alpha", "mid" } + .Select(name => new PlayerData { PlayerId = Guid.NewGuid(), Email = $"{name}@test", PasswordHash = "x", Username = name }) + .ToArray(); + var handles = new List>(); + foreach (var player in players) + handles.Add(await cache.AddAsync(player)); + + // Request in an order that matches neither the insertion order nor the ordinal-sorted + // record-key order, so a schema-and-key sort would visibly reorder the response. + var requestedOrder = new[] { handles[2].Key.Value, handles[0].Key.Value, handles[1].Key.Value }; + var request = registry.CreateSnapshotRequest("request-order", recordKeys: requestedOrder); + var response = registry.CreateRawSnapshotResponse(cache, "snapshot-order", request); + + Assert.That(response.Documents.Select(d => d.RecordKey), Is.EqualTo(requestedOrder)); + } + + [Test] + public void CultNetV0SelectionLowering_Lower_DistinguishesNullFromExplicitEmpty() + { + Assert.That(CultNetV0SelectionLowering.Lower(null), Is.Null); + Assert.That(CultNetV0SelectionLowering.Lower(Array.Empty()), Is.Not.Null); + Assert.That(CultNetV0SelectionLowering.Lower(Array.Empty()), Is.Empty); + Assert.That(CultNetV0SelectionLowering.Lower(new[] { "", " " }), Is.Not.Null); + Assert.That(CultNetV0SelectionLowering.Lower(new[] { "", " " }), Is.Empty); + Assert.That(CultNetV0SelectionLowering.Lower(new[] { "b", "a", "b" }), Is.EqualTo(new[] { "b", "a" })); + } + [Test] public async Task CultNetDatabase_PutGet_AndWatchByIndex_Uses_PrimaryShard() { @@ -3227,6 +3805,100 @@ namespace GameCult.Networking.Tests Assert.That(response.Documents[0].Payload, Is.EqualTo(CultDocumentMessagePackSerialization.SerializeUntyped(note, note.GetType()))); } + // S2-3 (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22): the shard snapshot answers + // through the same evaluator as the non-shard path, no loop of its own. An unfiltered request + // ({schemas:null, keys:null}) must return the same rows from both. + [Test] + public async Task CultNetDatabase_ShardAndNonShardSnapshot_AgreeOnUnfilteredRequest() + { + var cache = new CultCache(); + var registry = new CultNetDocumentRegistry(cache.Registry) + .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); + var descriptor = cache.Registry.GetRequired(); + var shard = new CultNetShardDescriptor( + "unfiltered-notes", + "runtime-a", + epoch: 1, + isPrimary: true, + schemaIds: [descriptor.SchemaId], + keyPrefix: "unfiltered-note:", + primaryEndpoints: ["cultnet://runtime-a:3080"]); + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions + { + DocumentRegistry = registry, + Shards = [shard] + }); + await database.PutAsync(new CultRecordKey("unfiltered-note:one"), new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "one" + }); + await database.PutAsync(new CultRecordKey("unfiltered-note:two"), new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "two" + }); + // Outside the shard's key prefix: must reach the non-shard snapshot but never the + // shard-scoped one, so an unfiltered request still proves shard membership is enforced. + await database.PutAsync(new CultRecordKey("other-note:outside-shard"), new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "outside" + }); + + var nonShard = registry.CreateRawSnapshotResponse(cache, "snapshot-nonshard-unfiltered", filter: null); + var shardScoped = database.CreateShardSnapshotResponse(shard, "snapshot-shard-unfiltered", filter: null); + + Assert.That( + shardScoped.Documents.Select(document => document.RecordKey).OrderBy(value => value, StringComparer.Ordinal), + Is.EqualTo(new[] { "unfiltered-note:one", "unfiltered-note:two" })); + Assert.That( + nonShard.Documents.Select(document => document.RecordKey).OrderBy(value => value, StringComparer.Ordinal), + Is.EqualTo(new[] { "other-note:outside-shard", "unfiltered-note:one", "unfiltered-note:two" })); + Assert.That(shardScoped.Documents, Has.Length.EqualTo(2)); + } + + // S2-3, second probe: an override of the binding schema id (rather than the descriptor's own) + // must match on both the shard and non-shard paths - both run binding-alias expansion. + [Test] + public async Task CultNetDatabase_ShardAndNonShardSnapshot_AgreeOnBindingSchemaIdOverride() + { + var cache = new CultCache(); + var registry = new CultNetDocumentRegistry(cache.Registry) + .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); + var descriptor = cache.Registry.GetRequired(); + var shard = new CultNetShardDescriptor( + "override-notes", + "runtime-a", + epoch: 1, + isPrimary: true, + schemaIds: [descriptor.SchemaId], + keyPrefix: "override-note:", + primaryEndpoints: ["cultnet://runtime-a:3081"]); + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions + { + DocumentRegistry = registry, + Shards = [shard] + }); + var key = new CultRecordKey("override-note:one"); + await database.PutAsync(key, new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "override" + }); + + var request = registry.CreateSnapshotRequest( + "snapshot-override-request", + schemaIds: ["tests.networking_note.v1"], + recordKeys: [key.Value]); + var nonShard = registry.CreateRawSnapshotResponse(cache, "snapshot-nonshard-override", request); + var shardScoped = database.CreateShardSnapshotResponse(shard, "snapshot-shard-override", request); + + Assert.That(nonShard.Documents, Has.Length.EqualTo(1)); + Assert.That(shardScoped.Documents, Has.Length.EqualTo(1)); + Assert.That(shardScoped.Documents[0].RecordKey, Is.EqualTo(nonShard.Documents[0].RecordKey)); + } + [Test] public async Task CultNetDatabase_Routes_TypedWrite_ToShardSchemaAlias() { @@ -3598,6 +4270,7 @@ namespace GameCult.Networking.Tests var response = await AwaitWithTimeout(responseCompletion.Task, TimeSpan.FromSeconds(2)); await AwaitWithTimeout(serverDone.Task, TimeSpan.FromSeconds(2)); + serverThread.Join(); Assert.That(response.MessageId, Is.EqualTo("rudp-schema-client-test")); Assert.That(response.Schemas.Single().SchemaId, Is.EqualTo("rudp.schema.test")); @@ -3667,6 +4340,7 @@ namespace GameCult.Networking.Tests }); var response = await client.FetchAsync($"rudp://127.0.0.1:{((IPEndPoint)serverSocket.LocalEndPoint!).Port}"); await AwaitWithTimeout(serverDone.Task, TimeSpan.FromSeconds(2)); + serverThread.Join(); Assert.That(response.Verses.Single().VerseId, Is.EqualTo("rudp-verse")); } @@ -3736,6 +4410,7 @@ namespace GameCult.Networking.Tests $"rudp://127.0.0.1:{((IPEndPoint)serverSocket.LocalEndPoint!).Port}", new CultMeshPeerExchangeRequestMessage { VerseId = "local" }); await AwaitWithTimeout(serverDone.Task, TimeSpan.FromSeconds(2)); + serverThread.Join(); Assert.That(response.Peers.Single().PeerId, Is.EqualTo("rudp-peer")); } @@ -4818,6 +5493,197 @@ namespace GameCult.Networking.Tests Assert.That(replicated, Is.Not.Null); Assert.That(replicated!.Username, Is.EqualTo("Replica")); Assert.That(targetDatabase.GetMutationLog("players-replica"), Has.Count.EqualTo(1)); + // R-Q (docs/cultnet-selection-cut.md, fix batch 3): a replica apply is a commit path too - + // it must set LastWriteSequence exactly like a local commit does (CultNetDatabase.cs, the + // ApplyCommittedPutAsync/ApplyCommittedDelete gap), or the selection evaluator's order and + // cursor treat a replicated row as never written. + Assert.That(targetDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(1)); + } + + [Test] + public async Task CultNetDatabase_ReplicaApply_DeleteSetsLastWriteSequence() + { + var sourceCache = new CultCache(); + var targetCache = new CultCache(); + var sourceRegistry = new CultNetDocumentRegistry(sourceCache.Registry) + .Register(CultNetDocumentBinding.ForDocument( + sourceCache.Registry, + payloadSerializer: SerializePlayerDataPayload, + payloadDeserializer: DeserializePlayerDataPayload)); + var targetRegistry = new CultNetDocumentRegistry(targetCache.Registry) + .Register(CultNetDocumentBinding.ForDocument( + targetCache.Registry, + payloadSerializer: SerializePlayerDataPayload, + payloadDeserializer: DeserializePlayerDataPayload)); + var schemaId = sourceCache.Registry.GetRequired().SchemaId; + CultNetShardDescriptor Shard(bool isPrimary) => new( + "players-replica-delete", + "runtime-a", + epoch: 1, + isPrimary: isPrimary, + schemaIds: [schemaId], + keyPrefix: "player:"); + var sourceDatabase = new CultNetDatabase(sourceCache, new CultNetDatabaseOptions + { + DocumentRegistry = sourceRegistry, + Shards = [Shard(true)] + }); + var targetDatabase = new CultNetDatabase(targetCache, new CultNetDatabaseOptions + { + DocumentRegistry = targetRegistry, + Shards = [Shard(false)] + }); + using var server = new Server(sourceCache, DevelopmentServerSecurity); + using var databaseServer = new CultNetDatabaseServer(server, sourceDatabase); + var key = new CultRecordKey("player:replica-delete"); + var player = new PlayerData { PlayerId = Guid.NewGuid(), Email = "d@example.test", PasswordHash = "hash", Username = "D" }; + + await sourceDatabase.PutAsync(key, player); + await sourceDatabase.DeleteAsync(key); + var response = databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage + { + ShardId = "players-replica-delete", + ShardEpoch = 1 + }); + + await targetDatabase.ApplyShardLogResponseAsync(response); + + Assert.That(targetCache.Get(key), Is.Null); + Assert.That(targetDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(2)); + } + + [Test] + public async Task CultNetDatabase_RebuildsLastWriteSequence_FromTheDurableLogAfterRestart() + { + var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "shard-logs", Guid.NewGuid().ToString("N")); + var schemaId = new CultCache().Registry.GetRequired().SchemaId; + var shard = new CultNetShardDescriptor( + "players-durable-sequence", + "runtime-a", + epoch: 1, + isPrimary: true, + schemaIds: [schemaId], + keyPrefix: "player:"); + + var sourceCache = new CultCache(); + var sourceRegistry = new CultNetDocumentRegistry(sourceCache.Registry) + .Register(CultNetDocumentBinding.ForDocument( + sourceCache.Registry, + payloadSerializer: SerializePlayerDataPayload, + payloadDeserializer: DeserializePlayerDataPayload)); + var sourceDatabase = new CultNetDatabase(sourceCache, new CultNetDatabaseOptions + { + DocumentRegistry = sourceRegistry, + MutationLogStore = new CultNetFileShardMutationLogStore(rootPath), + Shards = [shard] + }); + var key = new CultRecordKey("player:durable-sequence"); + var player = new PlayerData { PlayerId = Guid.NewGuid(), Email = "r@example.test", PasswordHash = "hash", Username = "R" }; + await sourceDatabase.PutAsync(key, player); + Assert.That(sourceDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(1)); + sourceDatabase.Dispose(); + + // R-Q: a restart is a fresh CultNetDatabase instance over the same durable log - the + // sequence InitializeLogSequencesFromStore used to rebuild only _nextLogSequences + // (what the next local commit gets), never _lastWriteSequence (what a row's own last + // write was), so a selection evaluated right after restart would order/page the row as + // "never written" until it was written again. + var restartedCache = new CultCache(); + var restartedDatabase = new CultNetDatabase(restartedCache, new CultNetDatabaseOptions + { + MutationLogStore = new CultNetFileShardMutationLogStore(rootPath), + Shards = [shard] + }); + + Assert.That(restartedDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(1)); + } + + [Test] + public async Task CultNetDatabaseServer_RefusesASelectionSpanningMoreThanOneShardsLog() + { + var cache = new CultCache(); + var registry = new CultNetDocumentRegistry(cache.Registry) + .Register(CultNetDocumentBinding.ForDocument( + cache.Registry, + payloadSerializer: SerializePlayerDataPayload, + payloadDeserializer: DeserializePlayerDataPayload)); + var schemaId = cache.Registry.GetRequired().SchemaId; + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions + { + DocumentRegistry = registry, + Shards = + [ + new CultNetShardDescriptor("shard-a", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "a:"), + new CultNetShardDescriptor("shard-b", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "b:") + ] + }); + using var server = new Server(cache, DevelopmentServerSecurity); + using var databaseServer = new CultNetDatabaseServer(server, database); + + await database.PutAsync( + new CultRecordKey("a:one"), + new PlayerData { PlayerId = Guid.NewGuid(), Email = "a@example.test", PasswordHash = "hash", Username = "A" }); + await database.PutAsync( + new CultRecordKey("b:one"), + new PlayerData { PlayerId = Guid.NewGuid(), Email = "b@example.test", PasswordHash = "hash", Username = "B" }); + + // R-Q: asOf is one shard-log watermark - a selection whose matched rows span shard-a's log + // and shard-b's log cannot honestly claim to be exact as of it, so it is refused rather than + // answered, reusing the selection_invalid door refusal (R-N) rather than a second shape. + var ex = Assert.Throws(() => databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message + { + Selection = new CultNetSelection { Schemas = [schemaId] } + })); + Assert.That(ex!.Field, Is.EqualTo("asOf")); + } + + // S-9 (docs/cultnet-selection-cut.md, fix batch 4): asOf is the watermark of the shard(s) the + // selection's matched rows actually come from, not CurrentAsOf()'s database-wide maximum across + // every shard - a shard-log sequence is a per-shard counter, so a selection matching only + // shard-a's row must not claim exactness as of a sequence shard-b's unrelated writes advanced to. + [Test] + public async Task CultNetDatabaseServer_SelectionResponseAsOf_IsTheMatchedShardsOwnWatermarkNotTheDatabaseWideMaximum() + { + var cache = new CultCache(); + var registry = new CultNetDocumentRegistry(cache.Registry) + .Register(CultNetDocumentBinding.ForDocument( + cache.Registry, + payloadSerializer: SerializePlayerDataPayload, + payloadDeserializer: DeserializePlayerDataPayload)); + var schemaId = cache.Registry.GetRequired().SchemaId; + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions + { + DocumentRegistry = registry, + Shards = + [ + new CultNetShardDescriptor("shard-a", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "a:"), + new CultNetShardDescriptor("shard-b", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "b:") + ] + }); + using var server = new Server(cache, DevelopmentServerSecurity); + using var databaseServer = new CultNetDatabaseServer(server, database); + + await database.PutAsync( + new CultRecordKey("a:one"), + new PlayerData { PlayerId = Guid.NewGuid(), Email = "a@example.test", PasswordHash = "hash", Username = "A" }); + // shard-b takes several more writes than shard-a, so the database-wide maximum runs well + // ahead of shard-a's own watermark. + for (var i = 0; i < 4; i++) + { + await database.PutAsync( + new CultRecordKey("b:one"), + new PlayerData { PlayerId = Guid.NewGuid(), Email = $"b{i}@example.test", PasswordHash = "hash", Username = "B" }); + } + + Assert.That(database.CurrentAsOf(), Is.EqualTo(4UL), "sanity: the database-wide maximum is shard-b's watermark"); + Assert.That(database.CurrentAsOf("shard-a"), Is.EqualTo(1UL), "sanity: shard-a's own watermark is unaffected by shard-b's writes"); + + var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message + { + Selection = new CultNetSelection { Keys = ["a:one"] } + }); + + Assert.That(response.AsOf, Is.EqualTo(1UL)); } [Test] @@ -5582,11 +6448,11 @@ namespace GameCult.Networking.Tests { change, "sub-1", - new CultNetDatabaseSubscribeMessage + new CultNetSelection { - SubscriptionId = "sub-1", - SchemaIds = [schemaId], - RecordKeys = [key.Value] + Schemas = [schemaId], + Keys = [key.Value], + Projection = CultNetSelectionProjections.Document } }); @@ -5632,11 +6498,11 @@ namespace GameCult.Networking.Tests { change, "sub-alias", - new CultNetDatabaseSubscribeMessage + new CultNetSelection { - SubscriptionId = "sub-alias", - SchemaIds = ["tests.networking_note.v1"], - RecordKeys = [key.Value] + Schemas = ["tests.networking_note.v1"], + Keys = [key.Value], + Projection = CultNetSelectionProjections.Document } }); diff --git a/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs b/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs new file mode 100644 index 00000000..e9a070fc --- /dev/null +++ b/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs @@ -0,0 +1,639 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using GameCult.Caching; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Networking.Tests +{ + // CultNet typed selection, Cut 1 commit 3/4 (docs/cultnet-selection-cut.md section 6/10, S12): + // the C#<->Rust parity vectors. New file; no existing C# source is modified. + // + // Self's ruling, 2026-09-22 (shared-fixture ruling, on top of the Cut 1 fix batch): both this + // writer and packages/cultnet-rs/tests/selection.rs's write_selection_vectors_for_the_reference + // build their rows from the one shared + // contracts/cultnet/interop/selection-vectors.fixture.json, keyed by each schema's real + // SHA-256 content-hash id, exactly as CultDocumentRegistry computes it for the fixture's own + // document types below - never by schema *name* as a stand-in id, which let every vector that + // carried an id decode on the wrong runtime's terms (Soul's whole-cut pass, F3: a name-as-id + // fixture masked the real alias-matching gap between CultNetSchemaAliasMatching and + // cultnet_rs::selection::schema_alias). AssertSchemaIdMatches below fails loudly, not silently, + // if this file's reflected shape ever drifts from the committed fixture. + public sealed class SelectionParityVectorTests + { + private static CultDocumentRegistry Registry() => CultDocumentRegistry.ForTypes(new[] + { + typeof(ParityLeafA), typeof(ParityLeafB), typeof(ParityCiter), typeof(ParityCiterNarrow) + }); + + private static CultNetSelectionEvaluator.Row Row(CultDocumentRegistry registry, object document, string key, long ordinal) => + new(registry.GetRequired(document.GetType()), new CultRecordKey(key), document, ordinal); + + // ---------------------------------------------------------------------------------------- + // The shared fixture: contracts/cultnet/interop/selection-vectors.fixture.json. Holds the + // real schema ids (and their unversioned aliases) and every row either runtime needs: + // astral/BMP-private-use keys (R-C), a float tie (R-D), a cites/cited pair, and a cites + // target reached by alias (R-E). Both writers build from it; neither hand-rolls its own row + // set any more. + // ---------------------------------------------------------------------------------------- + + // R-E: two alias forms, because the two runtimes' schema-alias matchers key off different + // attributes and no candidate resolves through both (see the fixture file's "//aliases" + // note and this file's report of the confirmed defect). NameAlias is what + // CultNetSchemaAliasMatching's descriptor overload (the only one any C# call site uses) + // can resolve; HashAlias is what cultnet_rs::selection::schema_alias can resolve. + private sealed record FixtureSchema(string SchemaId, string NameAlias, string HashAlias); + + // R-AF: no TargetSchema - the wire a stored reference actually travels on carries a bare + // record key (GameCult.Caching.MessagePack.CultRecordRefFormatter writes only + // value.Key.Value), so the fixture stopped pretending otherwise. See the fixture file's + // "//references" note. + private sealed record FixtureReference(string Role, string TargetKey); + + private sealed record FixtureRow( + string Schema, string Key, long Ordinal, + IReadOnlyDictionary Fields, + IReadOnlyList References); + + private static string FixturePath() => + Path.Combine(RepoRoot(), "contracts", "cultnet", "interop", "selection-vectors.fixture.json"); + + private static (IReadOnlyDictionary Schemas, IReadOnlyList Rows) LoadFixture() + { + using var document = JsonDocument.Parse(File.ReadAllText(FixturePath())); + var root = document.RootElement; + + var schemas = new Dictionary(StringComparer.Ordinal); + foreach (var entry in root.GetProperty("schemas").EnumerateObject()) + { + schemas[entry.Name] = new FixtureSchema( + entry.Value.GetProperty("schemaId").GetString()!, + entry.Value.GetProperty("nameAlias").GetString()!, + entry.Value.GetProperty("hashAlias").GetString()!); + } + + var rows = new List(); + foreach (var row in root.GetProperty("rows").EnumerateArray()) + { + var fields = new Dictionary(StringComparer.Ordinal); + if (row.TryGetProperty("fields", out var fieldsElement)) + { + foreach (var field in fieldsElement.EnumerateObject()) + fields[field.Name] = field.Value.GetString()!; + } + + var references = new List(); + if (row.TryGetProperty("references", out var referencesElement)) + { + foreach (var reference in referencesElement.EnumerateArray()) + { + references.Add(new FixtureReference( + reference.GetProperty("role").GetString()!, + reference.GetProperty("targetKey").GetString()!)); + } + } + + rows.Add(new FixtureRow( + row.GetProperty("schema").GetString()!, + row.GetProperty("key").GetString()!, + row.GetProperty("ordinal").GetInt64(), + fields, + references)); + } + + return (schemas, rows); + } + + private static void AssertSchemaIdMatches(CultDocumentRegistry registry, Type type, string expected) + { + var actual = registry.GetRequired(type).SchemaId; + Assert.That(actual, Is.EqualTo(expected), + $"{type.Name}'s real schema id drifted from contracts/cultnet/interop/selection-vectors.fixture.json - " + + "regenerate the fixture (its schemas.*.schemaId/alias fields) from CultDocumentRegistry before trusting these vectors."); + } + + private static object BuildDocument(FixtureRow row) => row.Schema switch + { + "leaf_a" => new ParityLeafA + { + Name = row.Key, Kind = row.Fields["kind"], Mass = float.Parse(row.Fields["mass"], CultureInfo.InvariantCulture) + }, + "leaf_b" => new ParityLeafB + { + Name = row.Key, Kind = row.Fields["kind"], Mass = float.Parse(row.Fields["mass"], CultureInfo.InvariantCulture) + }, + "citer" => new ParityCiter + { + Name = row.Key, + Design = new CultRecordRef(new CultRecordKey( + row.References.Single(reference => reference.Role == "Design").TargetKey)) + }, + // R-AF: citer_narrow's reference is declared to ParityLeafA only (one leaf, not + // ParityMiddle) - the shared-key shape needs a role whose declared target excludes at + // least one of the schemas that can share a record key. + "citer_narrow" => new ParityCiterNarrow + { + Name = row.Key, + NarrowRef = new CultRecordRef(new CultRecordKey( + row.References.Single(reference => reference.Role == "narrow_ref").TargetKey)) + }, + _ => throw new InvalidOperationException($"selection-vectors.fixture.json: unknown schema '{row.Schema}'") + }; + + // The one row set: both SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference + // (reading rs-written) and WriteSelectionVectors (writing cs-written) build from it, so both + // committed vector files are regenerated together whenever the fixture changes. + private static (CultDocumentRegistry registry, CultNetSelectionEvaluator.Row[] rows, IReadOnlyDictionary schemas) BuildFixture() + { + var registry = Registry(); + var (schemas, fixtureRows) = LoadFixture(); + + AssertSchemaIdMatches(registry, typeof(ParityLeafA), schemas["leaf_a"].SchemaId); + AssertSchemaIdMatches(registry, typeof(ParityLeafB), schemas["leaf_b"].SchemaId); + AssertSchemaIdMatches(registry, typeof(ParityCiter), schemas["citer"].SchemaId); + AssertSchemaIdMatches(registry, typeof(ParityCiterNarrow), schemas["citer_narrow"].SchemaId); + + var rows = fixtureRows.Select(row => Row(registry, BuildDocument(row), row.Key, row.Ordinal)).ToArray(); + return (registry, rows, schemas); + } + + private static IEnumerable<(string Name, CultNetSelection Selection)> Cases(IReadOnlyDictionary schemas) + { + yield return ("any_of_plus_ge_conjunction", new CultNetSelection + { + Fields = new[] + { + new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon" } }, + new CultNetFieldPredicate { Index = "mass", Op = "ge", Number = "5" } + } + }); + yield return ("descending_with_limit", new CultNetSelection { Descending = true, Limit = 3 }); + yield return ("gt_numeric_boundary", new CultNetSelection + { + Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "gt", Number = "1" } } + }); + // R-E: the exact real schema id - matches on both runtimes' first (exact string) + // comparison branch, independent of the alias-matching gap below. + yield return ("incoming_exists_true", new CultNetSelection + { + Schemas = new[] { schemas["leaf_a"].SchemaId }, + Cited = new CultNetIncoming { Role = "Design", Exists = true } + }); + yield return ("incoming_negation", new CultNetSelection + { + Schemas = new[] { schemas["leaf_a"].SchemaId }, + Cited = new CultNetIncoming { Role = "Design", Exists = false } + }); + yield return ("shared_index_value_three_rows", new CultNetSelection + { + Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "shared" } } } + }); + // R-D: the row's canonical rendering of 2233759.25f is its exact decimal expansion, not a + // shortest round-trip form that could round the tie the other way - ge against the exact + // value must match. + yield return ("float_tie_exact_ge", new CultNetSelection + { + Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "ge", Number = "2233759.25" } } + }); + // R-E: the exact real schema id, mirroring cultnet-rs's hop_by_role_cites_exact_id. + // R-AF: Keys=["citer-1"] (the fixture's one row that actually cites a-eq via Design) + // scopes this away from citer_narrow's rows, as hygiene rather than necessity - + // landing R-AF's vectors surfaced a real R-T/R-W parity bug this exposed: Rust's + // matches_citation used to resolve and R-W-check every declared reference a row + // carries before filtering by the citation's own role, where C#'s + // ReferenceMembers(descriptor, citation.Role) filters by role first + // (CultNetSelectionEvaluator.cs:451-458). Fixed in cultnet_rs::selection::matches_citation + // to filter first too, so narrow-citer-refused's out-of-target narrow_ref edge no longer + // reaches a Role="Design" citation in either runtime - this vector keeps the Keys scope + // anyway so it never depends on that. + yield return ("hop_by_role_cites_exact_id", new CultNetSelection + { + Keys = new[] { "citer-1" }, + Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].SchemaId, RecordKey = "a-eq" }, Role = "Design" } + }); + // R-E: the same citation, but the target is named by its C#-resolvable alias + // ("leaf_a.v9") rather than the exact id. CultNetSchemaAliasMatching's descriptor overload + // (CultNetDatabase.cs:74-87, the only overload any `schemas`/`cites.target.schemaId` call + // site in src/ uses) strips the trailing ".v9" and compares "leaf_a" to + // ParityLeafA's descriptor.SchemaName - a real match here, in the reference. S-12 + // (2026-09-22) is not a leftover defect: cultnet_rs::selection::schema_alias now resolves + // the same way, through Row::schema_name/RowSet::schema_name standing in for + // CultDocumentDescriptor.SchemaName (packages/cultnet-rs/src/selection.rs:452-475), so + // this vector's expectedIds are real and non-empty in both runtimes - the alias port made + // the two DIFFERENT alias forms deliberate (see the fixture file's "//aliases" note) + // rather than leaving this one unresolved on the Rust side. + // R-AF: Role="Design" plus Keys=["citer-1"] for the same reason as + // hop_by_role_cites_exact_id above. + yield return ("cites_target_by_name_alias", new CultNetSelection + { + Keys = new[] { "citer-1" }, + Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].NameAlias, RecordKey = "a-eq" }, Role = "Design" } + }); + // R-E: `schemas` reached by the same name alias. Same one-sided-match caveat as above. + yield return ("schemas_by_name_alias", new CultNetSelection { Schemas = new[] { schemas["leaf_a"].NameAlias } }); + // R-C: BmpPrivateUseKey (U+E000) sorts before AstralKey (U+1F602) in code-point order, + // despite AstralKey's leading UTF-16 code unit (0xD83D) being numerically smaller. + yield return ("astral_key_code_point_order", new CultNetSelection + { + Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "unicode" } } } + }); + // R-I: page bytes under each projection, over the same match set. + yield return ("header_projection", new CultNetSelection + { + Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "unicode" } } }, + Projection = CultNetSelectionProjections.Header + }); + yield return ("document_projection", new CultNetSelection + { + Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "unicode" } } }, + Projection = CultNetSelectionProjections.Document + }); + // R-AJ: a tied ordinal across two DIFFERENT schemas (kind=tie-schema is leaf_b/"zz-tie- + // schema" and leaf_a/"aa-tie-schema") - the shape the row order's schemaId tiebreak + // needs and the astral/BMP pair above never covered (both those rows are leaf_a). The + // fixture's keys are anti-correlated with the real schema ids on purpose, so a + // schemaId-first order and a recordKey-first order disagree: only the correct + // composition puts leaf_b's row ("zz-tie-schema") first. + yield return ("row_tiebreak_schema_id_before_record_key_at_tied_ordinal", new CultNetSelection + { + Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "tie-schema" } } } + }); + // R-AF: "dup-key" carries two rows (leaf_a and leaf_b); citer_narrow's narrow_ref + // targets leaf_a only. Keys scopes the citer candidate set to narrow-citer-accepted + // alone, so this vector never touches narrow-citer-refused's bad edge (R-W resolves + // every reference member unconditionally, so leaving both citer_narrow rows in one + // candidate set would always refuse regardless of which edge the query named). One + // rule, derived from the wire (docs/cultnet-selection-cut.md, R-AF): resolving "the + // row this edge names" is by record key plus the declared leaf set, not an exact + // (schema, key) pair, so this must accept in both runtimes and resolve to the leaf_a + // row even though a leaf_b row shares the same key. + yield return ("shared_key_narrow_ref_resolves_leaf_within_target", new CultNetSelection + { + Keys = new[] { "narrow-citer-accepted" }, + Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].SchemaId, RecordKey = "dup-key" }, Role = "narrow_ref" } + }); + // R-AO: "dup-key" has two in-target candidates for citer's Design role (unlike + // citer_narrow's narrow_ref above, which only ever had one). citer-both-in-target is the + // first row in the fixture file (and so in both runtimes' row order) to name it through + // Design - resolution must deterministically pick the first in-target candidate in that + // order, the leaf_a row (ordinal 11), never the leaf_b row (ordinal 12) sharing the key. + yield return ("shared_key_design_resolves_the_first_in_target_candidate_in_row_order", new CultNetSelection + { + Keys = new[] { "citer-both-in-target" }, + Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].SchemaId, RecordKey = "dup-key" }, Role = "Design" } + }); + } + + // R-AF: the shared-key shape's other direction - the only row at the declared edge's key is + // outside the reference's declared target, so evaluating the selection itself must refuse + // (CultNetSelectionReferenceOutsideTargetException/SelectionRefusal::ReferenceOutsideTarget), + // not merely return an empty page. Keys scopes the candidate set to narrow-citer-refused + // alone (see the comment on shared_key_narrow_ref_resolves_leaf_within_target above). + private static IEnumerable<(string Name, CultNetSelection Selection)> EvaluationRefusalCases( + IReadOnlyDictionary schemas) + { + yield return ("shared_key_narrow_ref_refuses_when_only_candidate_is_outside_target", new CultNetSelection + { + Keys = new[] { "narrow-citer-refused" }, + Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].SchemaId, RecordKey = "narrow-only" }, Role = "narrow_ref" } + }); + } + + // Self's ruling, 2026-09-22 (commit 2 fix batch): door-refusal vectors. Never evaluated on + // either side - only decoded and handed to Validate/validate, which must refuse at exactly + // the named field. Mirrors packages/cultnet-rs/tests/selection.rs's refusal_cases. + // + // refuses_unmatched_cites_target_schema and refuses_cites_target_by_hash_shaped_alias now + // land here too: the C# matrix Hands pass added the door check + // (CultNetSelection.cs:389-394) that CultNetSelection.Validate refuses a cites.target.schemaId + // that CultNetSchemaAliasMatching cannot resolve to any declared schema. Under Self's ruling + // 2026-09-22 ("the C# reference's alias rule is the rule"), C# only ever resolves a name-form + // alias, never a hash-shaped one, so a hash-shaped cites.target.schemaId is exactly such an + // unresolved target and is refused at the same field. + private static IEnumerable<(string Name, CultNetSelection Selection, string ExpectedField)> RefusalCases( + IReadOnlyDictionary schemas) + { + yield return ("refuses_empty_schemas_list", new CultNetSelection { Schemas = Array.Empty() }, "schemas"); + yield return ("refuses_blank_key_entry", new CultNetSelection { Keys = new[] { "a-eq", " " } }, "keys"); + yield return ("refuses_unmatched_cites_target_schema", new CultNetSelection + { + Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = "sha256:not-a-declared-schema", RecordKey = "a-eq" } } + }, "cites.target.schemaId"); + yield return ("refuses_cites_target_by_hash_shaped_alias", new CultNetSelection + { + Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].HashAlias, RecordKey = "a-eq" } } + }, "cites.target.schemaId"); + } + + // R-E: id, not name - a row's cross-runtime identity is its real schema id plus its key, the + // same shape cultnet-rs's row_id() produces. + private static string RowId(CultNetSelectionEvaluator.Row row) => $"{row.Descriptor.SchemaId}/{row.Key.Value}"; + + // Vectors written by Rust (packages/cultnet-rs/tests/selection.rs, + // write_selection_vectors_for_the_reference) decode and evaluate identically here. + [Test] + public void SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference() + { + var path = Path.Combine(RepoRoot(), "contracts", "cultnet", "interop", "selection-vectors.rs-written.json"); + if (!File.Exists(path)) + Assert.Inconclusive($"{path} is missing - run cargo test -p cultnet-rs --test selection with CULTNET_WRITE_VECTORS=1 first."); + + var (registry, rows, _) = BuildFixture(); + using var document = JsonDocument.Parse(File.ReadAllText(path)); + var vectors = document.RootElement.GetProperty("vectors"); + Assert.That(vectors.GetArrayLength(), Is.GreaterThan(0), "the vector file must carry at least one vector"); + + // Self's ruling, 2026-09-22 (shared-fixture ruling): a real parity defect is reported in + // full, not truncated by whichever vector happened to come first in the file - every + // vector is judged and every mismatch collected before the test fails once, mirroring + // packages/cultnet-rs/tests/selection.rs's own reader. + var failures = new List(); + foreach (var vector in vectors.EnumerateArray()) + { + var name = vector.GetProperty("name").GetString(); + var selectionBytes = Convert.FromBase64String(vector.GetProperty("selectionMessagePackBase64").GetString()!); + var selection = MessagePackSerializer.Deserialize(selectionBytes, CultNetSchemaMessageSerialization.Options); + var asOf = vector.GetProperty("asOf").GetUInt64(); + + if (vector.TryGetProperty("expectedRefusalField", out var refusalField) && refusalField.ValueKind != JsonValueKind.Null) + { + try + { + selection.Validate(registry.AllDescriptors.ToArray()); + failures.Add($"{name}: expected refusal at '{refusalField.GetString()}' but the reference accepted it"); + } + catch (CultNetSelectionInvalidException error) + { + if (error.Field != refusalField.GetString()) + failures.Add($"{name}: refusal field - expected '{refusalField.GetString()}', got '{error.Field}'"); + } + continue; + } + + // R-AF: a small, separate signal from expectedRefusalField above - that one is the + // door (Validate, never touches Select); this one names an evaluation-time typed + // refusal Select itself must throw (currently only reference_outside_target, S18). + // Deliberately narrower than R-Z/R-AH's page-bytes-and-code parity harness (not + // this cut's job): it checks which typed refusal fires, nothing about wire bytes. + if (vector.TryGetProperty("expectedEvaluationRefusal", out var evalRefusal) && evalRefusal.ValueKind != JsonValueKind.Null) + { + var expectedKind = evalRefusal.GetString(); + try + { + CultNetSelectionEvaluator.Select(registry, rows, selection, asOf); + failures.Add($"{name}: expected Select to refuse ({expectedKind}) but it accepted the selection"); + } + catch (CultNetSelectionReferenceOutsideTargetException) when (expectedKind == "reference_outside_target") + { + // expected + } + catch (Exception error) + { + failures.Add($"{name}: expected Select to refuse with {expectedKind}, got {error.GetType().Name}: {error.Message}"); + } + continue; + } + + CultNetSelectionEvaluator.Evaluation evaluation; + try + { + evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf); + } + catch (Exception error) + { + var expectedIds = vector.GetProperty("expectedIds").EnumerateArray().Select(e => e.GetString()).ToArray(); + failures.Add($"{name}: expected real evaluation (expectedIds=[{string.Join(", ", expectedIds)}]) but Select threw {error.GetType().Name}: {error.Message}"); + continue; + } + + var expectedIdsList = vector.GetProperty("expectedIds").EnumerateArray().Select(e => e.GetString()).ToArray(); + var actualIds = evaluation.Rows.Select(RowId).ToArray(); + if (!actualIds.SequenceEqual(expectedIdsList)) + failures.Add($"{name}: row ids - expected [{string.Join(", ", expectedIdsList)}], got [{string.Join(", ", actualIds)}]"); + + var expectedMatched = vector.GetProperty("matched").GetUInt32(); + if ((uint)evaluation.TotalMatched != expectedMatched) + failures.Add($"{name}: matched - expected {expectedMatched}, got {evaluation.TotalMatched}"); + + var expectedHasNext = vector.GetProperty("hasNext").GetBoolean(); + if ((evaluation.NextCursor != null) != expectedHasNext) + failures.Add($"{name}: hasNext - expected {expectedHasNext}, got {evaluation.NextCursor != null}"); + + var expectedEdges = vector.GetProperty("expectedEdges").EnumerateArray().ToArray(); + if (evaluation.Edges.Count != expectedEdges.Length) + { + failures.Add($"{name}: edge count - expected {expectedEdges.Length}, got {evaluation.Edges.Count}"); + continue; + } + for (var i = 0; i < expectedEdges.Length; i++) + { + var edge = evaluation.Edges[i]; + var expectedEdge = expectedEdges[i]; + var expectedPayload = expectedEdge.GetProperty("payloadBase64"); + var actualPayload = edge.Payload == null ? null : Convert.ToBase64String((byte[])edge.Payload); + var payloadMatches = expectedPayload.ValueKind == JsonValueKind.Null + ? actualPayload == null + : actualPayload == expectedPayload.GetString(); + if (RowId(edge.From) != expectedEdge.GetProperty("fromId").GetString() || + edge.Role != expectedEdge.GetProperty("role").GetString() || + RowId(edge.To) != expectedEdge.GetProperty("toId").GetString() || + !payloadMatches) + { + failures.Add($"{name}: edge[{i}] mismatch"); + } + } + } + + Assert.That(failures, Is.Empty, + $"{failures.Count} of {vectors.GetArrayLength()} vectors disagreed with the reference:\n" + string.Join("\n", failures)); + } + + // Writes contracts/cultnet/interop/selection-vectors.cs-written.json for Rust's + // selection_vectors_written_by_the_reference_decode_and_evaluate_identically_in_rust to + // judge. Gated the same way the QUIC campaign's C#-written vectors are + // (docs/typescript-quic-realtime-cut.md:15-21): committed, not regenerated on every run. + [Test] + public void WriteSelectionVectors() + { + if (Environment.GetEnvironmentVariable("CULTNET_WRITE_VECTORS") != "1") + Assert.Ignore("Set CULTNET_WRITE_VECTORS=1 to (re)write the committed vector file."); + + var (registry, rows, schemas) = BuildFixture(); + var vectors = new List(); + foreach (var (name, selection) in Cases(schemas)) + { + var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + var selectionBytes = MessagePackSerializer.Serialize(selection, CultNetSchemaMessageSerialization.Options); + + vectors.Add(new + { + name, + selectionMessagePackBase64 = Convert.ToBase64String(selectionBytes), + asOf = 1UL, + expectedRefusalField = (string?)null, + expectedEvaluationRefusal = (string?)null, + expectedIds = evaluation.Rows.Select(RowId).ToArray(), + matched = (uint)evaluation.TotalMatched, + hasNext = evaluation.NextCursor != null, + expectedEdges = evaluation.Edges.Select(edge => new + { + fromId = RowId(edge.From), + role = edge.Role, + toId = RowId(edge.To), + // None of this file's cases hop through a many-dictionary reference (the only + // shape D11 gives a non-null payload), so this is always null here; + // packages/cultnet-rs/tests/selection.rs's + // dictionary_reference_entries_keep_distinct_payload_bytes pins the + // payload-carrying case directly against Row::references() instead. + payloadBase64 = edge.Payload == null ? null : Convert.ToBase64String((byte[])edge.Payload) + }).ToArray() + }); + } + + // Self's ruling, 2026-09-22 (commit 2 fix batch): door-refusal vectors. Never + // evaluated on either side - only decoded and handed to Validate, which must refuse + // at exactly the named field. + foreach (var (name, selection, expectedField) in RefusalCases(schemas)) + { + var error = Assert.Throws( + () => selection.Validate(registry.AllDescriptors.ToArray()), + $"{name} must be refused by the reference before it can be committed as a refusal vector"); + Assert.That(error!.Field, Is.EqualTo(expectedField), $"{name}: refusal field"); + + var selectionBytes = MessagePackSerializer.Serialize(selection, CultNetSchemaMessageSerialization.Options); + vectors.Add(new + { + name, + selectionMessagePackBase64 = Convert.ToBase64String(selectionBytes), + asOf = 1UL, + expectedRefusalField = (string?)expectedField, + expectedEvaluationRefusal = (string?)null, + expectedIds = Array.Empty(), + matched = 0U, + hasNext = false, + expectedEdges = Array.Empty() + }); + } + + // R-AF: evaluation-time refusal vectors. Never handed to Validate - the selection + // decodes and passes the door; only Select itself refuses, and only once the shared-key + // resolution rule actually runs. Asserted here before being committed, mirroring how + // RefusalCases above asserts Validate refuses before its vectors are written. + foreach (var (name, selection) in EvaluationRefusalCases(schemas)) + { + Assert.Throws( + () => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1), + $"{name} must be refused by Select before it can be committed as an evaluation-refusal vector"); + + var selectionBytes = MessagePackSerializer.Serialize(selection, CultNetSchemaMessageSerialization.Options); + vectors.Add(new + { + name, + selectionMessagePackBase64 = Convert.ToBase64String(selectionBytes), + asOf = 1UL, + expectedRefusalField = (string?)null, + expectedEvaluationRefusal = (string?)"reference_outside_target", + expectedIds = Array.Empty(), + matched = 0U, + hasNext = false, + expectedEdges = Array.Empty() + }); + } + + var directory = Path.Combine(RepoRoot(), "contracts", "cultnet", "interop"); + Directory.CreateDirectory(directory); + var json = JsonSerializer.Serialize(new { vectors }, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(Path.Combine(directory, "selection-vectors.cs-written.json"), json); + } + + // Dumps each fixture schema's real, reference-computed id (and lets a maintainer derive the + // unversioned alias by stripping the trailing ".v1") so + // contracts/cultnet/interop/selection-vectors.fixture.json can be regenerated after a shape + // change. Not part of the parity contract itself. + [Test] + public void DumpFixtureSchemaIds() + { + if (Environment.GetEnvironmentVariable("CULTNET_DUMP_SCHEMA_IDS") != "1") + Assert.Ignore("Set CULTNET_DUMP_SCHEMA_IDS=1 to print the fixture's real schema ids."); + + var registry = Registry(); + Console.WriteLine("SCHEMA_ID leaf_a=" + registry.GetRequired(typeof(ParityLeafA)).SchemaId); + Console.WriteLine("SCHEMA_ID leaf_b=" + registry.GetRequired(typeof(ParityLeafB)).SchemaId); + Console.WriteLine("SCHEMA_ID citer=" + registry.GetRequired(typeof(ParityCiter)).SchemaId); + } + + private static string RepoRoot() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln"))) + directory = directory.Parent; + return directory?.FullName ?? throw new InvalidOperationException("CultLib.sln was not found above the test directory."); + } + + // A conceptual abstract middle (no [CultDocument]), matching Design's declared target so + // D9's leaf-set resolution reaches both ParityLeafA and ParityLeafB. SchemaName is the + // literal fixture-identity string ("leaf_a", "leaf_b", "citer") the vectors compare by - + // see this file's header comment. + public abstract class ParityMiddle + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + [CultIndex("kind")] + public string Kind = string.Empty; + + [Key(2)] + [CultIndex("mass")] + public float Mass; + } + + [CultDocument("leaf_a", "cultnet.selection-parity.leaf_a.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class ParityLeafA : ParityMiddle + { + } + + [CultDocument("leaf_b", "cultnet.selection-parity.leaf_b.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class ParityLeafB : ParityMiddle + { + } + + [CultDocument("citer", "cultnet.selection-parity.citer.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class ParityCiter + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + [CultReference(typeof(ParityMiddle))] + public CultRecordRef Design; + } + + // R-AF: a citer whose declared reference target is one leaf only, so a record key shared + // by a ParityLeafA row and a ParityLeafB row has exactly one row inside the declared + // target and exactly one outside it - see the fixture file's "citer_narrow" schema. + [CultDocument("citer_narrow", "cultnet.selection-parity.citer_narrow.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class ParityCiterNarrow + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + [CultIndex("narrow_ref")] + [CultReference(typeof(ParityLeafA))] + public CultRecordRef NarrowRef; + } + } +} diff --git a/tests/GameCult.Networking.Tests/SelectionWireParityTests.cs b/tests/GameCult.Networking.Tests/SelectionWireParityTests.cs new file mode 100644 index 00000000..cc79fd87 --- /dev/null +++ b/tests/GameCult.Networking.Tests/SelectionWireParityTests.cs @@ -0,0 +1,100 @@ +#nullable enable +using System; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Networking.Tests +{ + // CultNet typed selection, Cut 1 (docs/cultnet-selection-cut.md, "Self's rulings for fix batch + // 7", R-AX). The C# half of the byte-parity vectors committed into + // packages/cultnet-rs/tests/selection_wire_parity.rs: each constant below is the exact hex that + // file's own Rust encoder is asserted against. Landing both sides against the same literal + // string means either runtime's serializer drifting from the other fails here, not only in the + // Rust suite. + // + // The all-absent header/edge vectors were already landed on the Rust side by R-AP; the ones + // below are the R-AX extension - a header with `tags` populated and an edge with a `payload` + // populated, the exact shapes where `string[]`/`Vec` and `byte[]`/`serde_bytes` could + // diverge and an all-absent value proves nothing about - plus the whole + // `cultnet.snapshot_response_raw.v1` envelope, byte-identical between the two encoders. None + // diverged on capture. + public sealed class SelectionWireParityTests + { + private const string LeafA = "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff"; + private const string CiterId = "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc"; + + private const string RustHeaderWithTagsHex = "8AA8736368656D614964D9477368613235363A66663335663364396436646533663130363662393336396537336665366337363438653838333638643138656663323237356339393437333063643834636666AA736368656D614E616D65C0AD736368656D6156657273696F6EC0B1736368656D61436F6E74656E7448617368C0A97265636F72644B6579A4612D6571A873746F7265644174B8313937302D30312D30315430303A30303A30302E3030305AAF736F7572636552756E74696D654964C0AD736F757263654167656E744964C0AA736F75726365526F6C65C0A47461677392A5616C706861A462657461"; + private const string RustEdgeAllAbsentHex = "85A466726F6D82A8736368656D614964D9477368613235363A39643062633961656263333631396230376161626635363466393836363761666465343134343463316238363636363164373737363837643434333630386463A97265636F72644B6579A763697465722D31A4726F6C65A644657369676EA2746F82A8736368656D614964D9477368613235363A66663335663364396436646533663130363662393336396537336665366337363438653838333638643138656663323237356339393437333063643834636666A97265636F72644B6579A4612D6571AF7061796C6F6164456E636F64696E67C0A77061796C6F6164C0"; + private const string RustEdgeWithPayloadHex = "85A466726F6D82A8736368656D614964D9477368613235363A39643062633961656263333631396230376161626635363466393836363761666465343134343463316238363636363164373737363837643434333630386463A97265636F72644B6579A763697465722D31A4726F6C65A644657369676EA2746F82A8736368656D614964D9477368613235363A66663335663364396436646533663130363662393336396537336665366337363438653838333638643138656663323237356339393437333063643834636666A97265636F72644B6579A4612D6571AF7061796C6F6164456E636F64696E67AB6D6573736167657061636BA77061796C6F6164C403010203"; + private const string RustEnvelopeAllAbsentHex = "8BAD736368656D6156657273696F6ED92063756C746E65742E736E617073686F745F726573706F6E73655F7261772E7631A96D6573736167654964A16DA76D61746368656401A461734F6601A46E657874C0A768656164657273918AA8736368656D614964D9477368613235363A66663335663364396436646533663130363662393336396537336665366337363438653838333638643138656663323237356339393437333063643834636666AA736368656D614E616D65C0AD736368656D6156657273696F6EC0B1736368656D61436F6E74656E7448617368C0A97265636F72644B6579A4612D6571A873746F7265644174B8313937302D30312D30315430303A30303A30302E3030305AAF736F7572636552756E74696D654964C0AD736F757263654167656E744964C0AA736F75726365526F6C65C0A474616773C0A9646F63756D656E7473C0A56564676573C0A773686172644964C0AA736861726445706F6368C0B073686172644C6F6753657175656E6365C0"; + + [Test] + public void HeaderWithTagsMatchesTheConstantCommittedIntoTheRustTest() + { + var header = new CultNetRawDocumentHeader + { + SchemaId = LeafA, + RecordKey = "a-eq", + StoredAt = "1970-01-01T00:00:00.000Z", + Tags = new[] { "alpha", "beta" } + }; + var hex = Convert.ToHexString(MessagePackSerializer.Serialize(header, CultNetSchemaMessageSerialization.Options)); + Assert.That(hex, Is.EqualTo(RustHeaderWithTagsHex), + "string[] Tags must encode to the same bytes cultnet-rs's Vec tags does"); + } + + [Test] + public void EdgeAllAbsentMatchesTheConstantCommittedIntoTheRustTest() + { + // R-AZ (docs/cultnet-selection-cut.md, "Soul, the merge gate, fourth pass"): the + // 246-byte all-absent Edge vector existed only on the Rust side (SM-10). The all-absent + // header is covered incidentally because it is nested inside the 376-byte envelope test + // above; the edge has no such cover, so a C# edge encoder that resumed omitting a null + // payload/payloadEncoding would pass every other test here and still diverge from Rust. + var edge = new CultNetEdge + { + From = new CultNetRecordRef { SchemaId = CiterId, RecordKey = "citer-1" }, + Role = "Design", + To = new CultNetRecordRef { SchemaId = LeafA, RecordKey = "a-eq" } + }; + var hex = Convert.ToHexString(MessagePackSerializer.Serialize(edge, CultNetSchemaMessageSerialization.Options)); + Assert.That(hex, Is.EqualTo(RustEdgeAllAbsentHex), + "an all-absent Edge (PayloadEncoding/Payload both null) must encode to the same bytes cultnet-rs's all-absent Edge does"); + } + + [Test] + public void EdgeWithPayloadMatchesTheConstantCommittedIntoTheRustTest() + { + var edge = new CultNetEdge + { + From = new CultNetRecordRef { SchemaId = CiterId, RecordKey = "citer-1" }, + Role = "Design", + To = new CultNetRecordRef { SchemaId = LeafA, RecordKey = "a-eq" }, + PayloadEncoding = "messagepack", + Payload = new byte[] { 1, 2, 3 } + }; + var hex = Convert.ToHexString(MessagePackSerializer.Serialize(edge, CultNetSchemaMessageSerialization.Options)); + Assert.That(hex, Is.EqualTo(RustEdgeWithPayloadHex), + "byte[] Payload must encode to the same bytes cultnet-rs's serde_bytes payload does"); + } + + [Test] + public void SnapshotResponseRawV1EnvelopeMatchesTheConstantCommittedIntoTheRustTest() + { + var message = new CultNetSnapshotResponseRawV1Message + { + MessageId = "m", + Matched = 1, + AsOf = 1, + Headers = new[] + { + new CultNetRawDocumentHeader { SchemaId = LeafA, RecordKey = "a-eq", StoredAt = "1970-01-01T00:00:00.000Z" } + } + }; + var hex = Convert.ToHexString(MessagePackSerializer.Serialize(message, CultNetSchemaMessageSerialization.Options)); + Assert.That(hex.Length / 2, Is.EqualTo(376)); + Assert.That(hex, Is.EqualTo(RustEnvelopeAllAbsentHex), + "the whole cultnet.snapshot_response_raw.v1 envelope must encode identically on both runtimes"); + } + } +}