Merge CultNet selection Cut 1

Four Soul gates. The first three refused it, each on something real: the row
identity ruling implemented as two different rules that answered opposite ways
on the shape it was written for; both runtimes faulting on an attacker-supplied
cursor through mirror-image overflows; a parity promise neither built nor
listed as outstanding; and a guard deleted on my ruling that took a legal
document shape into an untyped exception.

What lands is one rule for reference resolution in both runtimes, derived from
the wire rather than from preference, with the shared-key vector that never
existed. Malformed cursors reach the wire typed. Every comparator component
that decides output dies under mutation in both runtimes, and the one that
could not be distinguished by any input was deleted rather than recorded as
equivalent. The snapshot_response_raw.v1 envelope and its nested record types
are byte-identical across runtimes at populated and absent optionals, asserted
independently on each side.

The claim is exactly that, and no larger: byte parity for those types and
semantic parity over the named field subset. Whole-message assembly through a
single rows-to-bytes path is its own cut.

Follow-ups stay open and recorded: the C# Stryker survivor list untriaged,
contracts.rs and schema_discovery.rs unmeasured, the --since crash unrooted,
and three Caching tests that need a non-root container to prove and must not
be made to pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-23 09:22:25 +02:00
commit dbee044294
78 changed files with 17074 additions and 853 deletions

View file

@ -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 }
}
}
}

View file

@ -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 }
}
}
}
}

View file

@ -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 }
}
}
}

View file

@ -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 }
}
}

View file

@ -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" }
}
}
}
}

View file

@ -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" }
}
}

View file

@ -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"] }
}
}

View file

@ -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": []
}
]
}

View file

@ -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 <SchemaName>.v9 and resolves through both runtimes under that one rule. hashAlias is <schemaId>.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<T> serializes a CultRecordRef<T> 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" } ]
}
]
}

View file

@ -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": []
}
]
}

View file

@ -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

View file

@ -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(

View file

@ -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}");
}

View file

@ -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:

View file

@ -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

View file

@ -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 }
}
}

View file

@ -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 }
}
}
}

View file

@ -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"
}
}
}

View file

@ -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"
}
}
}

View file

@ -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 }
}
}
}
}

View file

@ -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" }
}
}

View file

@ -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 }
}
}

View file

@ -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 }
}
}

View file

@ -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 }
}
}
}

View file

@ -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 }
}
}

View file

@ -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 }
}
}

View file

@ -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 }
}
}

View file

@ -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"
]
}
}
}
}

View file

@ -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"
}
}
}
}

View file

@ -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 }
}
}
}

View file

@ -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 }
}
}
}
}

View file

@ -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"
}
}
}

View file

@ -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"
}
}
}
}

View file

@ -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"
}
}
}
}

View file

@ -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 }
}
}
}
}
}
}
}
}

View file

@ -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 }
}
}

View file

@ -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
}
}
}
}
}

View file

@ -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 <SchemaName>.v9 and resolves through both runtimes under that one rule. hashAlias is <schemaId>.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<T> serializes a CultRecordRef<T> 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" } ]
}
]
}

View file

@ -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<Self> {
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<String>,
/// The offending value, when there is one string worth naming (`selection_invalid`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
/// The cursor's own minted asOf (`cursor_stale`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub as_of: Option<u64>,
/// The answering server's current asOf (`cursor_stale`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current: Option<u64>,
}
#[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<CultNetErrorCode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
details: Option<CultNetErrorDetails>,
},
#[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<CultNetRawDocumentRecord>,
},
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
shard_epoch: Option<i64>,
},
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body_ids: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
supported_body_transports: Option<Vec<String>>,
},
/// `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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
headers: Option<Vec<RawDocumentHeader>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
documents: Option<Vec<SelectionDocumentRecord>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
edges: Option<Vec<Edge>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
shard_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
shard_epoch: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
shard_log_sequence: Option<i64>,
},
#[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<CultNetMessag
documents,
})
}
"cultnet.snapshot_response_raw.v1" => {
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::<RawDocumentHeader>(Some(value), &format!("headers[{index}]"))
})
.collect::<Result<Vec<_>>>()
})
.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::<SelectionDocumentRecord>(
Some(value),
&format!("documents[{index}]"),
)
})
.collect::<Result<Vec<_>>>()
})
.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::<Edge>(Some(value), &format!("edges[{index}]"))
})
.collect::<Result<Vec<_>>>()
})
.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<Option<CultNetErrorDetails>> {
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<Option<u64>> {
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<rmpv::Value> {
Ok(rmpv::Value::Map(match message {
CultNetMessage::DocumentPutRaw {
@ -845,6 +1147,99 @@ fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result<rmpv::V
rmpv::Value::Array(documents.iter().map(encode_raw_document_record).collect()),
),
],
CultNetMessage::SnapshotResponseRawV1 {
message_id,
matched,
as_of,
next,
headers,
documents,
edges,
shard_id,
shard_epoch,
shard_log_sequence,
} => 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::V
}))
}
fn encode_error_details(details: &CultNetErrorDetails) -> 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<T: serde::de::DeserializeOwned>(
value: Option<&rmpv::Value>,
field_name: &str,
) -> Result<T> {
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<T: Serialize>(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<u32> {
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<u64> {
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<Option<i64>> {
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<CultNetMessage> {
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<CultNetMessa
}),
4 => 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<rmpv::
legacy_bytes(session, "LoginSuccessMessage.Session")?,
]),
],
CultNetMessage::Error { error } => {
CultNetMessage::Error { error, .. } => {
vec![
rmpv::Value::from(4),
rmpv::Value::Array(vec![error.as_str().into()]),

View file

@ -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::*;

View file

@ -194,28 +194,28 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
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<CultNetSchemaRegistry> {
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<CultNetSchemaRegistry> {
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<CultNetSchemaRegistry> {
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<CultNetSchemaRegistry> {
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<CultNetSchemaRegistry> {
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<CultNetSchemaRegistry> {
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<CultNetSchemaRegistry> {
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<CultNetSchemaRegistry> {
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<CultNetSchemaRegistry> {
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"),

View file

@ -259,7 +259,7 @@ fn hmac_sha256(key: &[u8], input: &[u8]) -> Result<Vec<u8>> {
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;
}

File diff suppressed because it is too large Load diff

View file

@ -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<S: CultNetRawSnapshotSource>(
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::<BTreeSet<_>>());
let requested_keys = record_keys
.as_ref()
.map(|values| values.iter().map(String::as_str).collect::<BTreeSet<_>>());
// 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<S: CultNetRawSnapshotSource>(
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<string>` 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<String> {
unreachable!("v0's lowered selection carries no fields; values is not read")
}
fn number(&self, _index: &str) -> Option<String> {
unreachable!("v0's lowered selection carries no fields; number is not read")
}
fn references(&self) -> Vec<(String, String, Option<Vec<u8>>)> {
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<String>,
@ -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<Vec<String>>) -> Option<Vec<String>> {
// 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<String> = values
.iter()
.filter(|value| !value.trim().is_empty())
.filter(|value| seen.insert(value.as_str()))
.cloned()
.collect();
Some(filtered)
}

View file

@ -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(&registry, &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(&registry, &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(&registry, &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<string>` 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(&registry, &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()?;

View file

@ -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<u8> {
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<Case> {
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);
}
}

View file

@ -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"
);
}

File diff suppressed because it is too large Load diff

View file

@ -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<String>` 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<u8> {
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<String>`) 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<String> 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"
);
}

View file

@ -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<T>'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<object, string?>? nameAccessor,
IReadOnlyDictionary<string, Func<object, string>> indexAccessors,
IReadOnlyList<CultDocumentMemberDescriptor> members)
IReadOnlyList<CultDocumentMemberDescriptor> members,
IReadOnlyList<CultDocumentRegistry.PersistedMember> 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<object, string?>? NameAccessor { get; }
internal IReadOnlyDictionary<string, Func<object, string>> IndexAccessors { get; }
internal IReadOnlyList<CultDocumentMemberDescriptor> Members { get; }
internal IReadOnlyList<CultDocumentRegistry.PersistedMember> 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<CultDocumentMemberView> 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<T>, one per element of
// an IEnumerable<CultRecordRef<T>>, one per key of an IDictionary<CultRecordRef<T>, 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<CultDocumentDescriptor> 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<CultDocumentDescriptor> 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<T> type)
// whose CLR shape the cache cannot walk. many:false needs CultRecordRef<T> itself; many:true
// needs IEnumerable<CultRecordRef<T>> or IDictionary<CultRecordRef<T>, 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<CultReferenceAttribute>();
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<CultIndexAttribute>();
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<Type> 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<T> with a value produces a boxed T (never a boxed Nullable<T>), 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<T>, many:true needs IEnumerable<CultRecordRef<T>> or IDictionary<CultRecordRef<T>, 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<object, string> Getter { get; set; } = default!;
public Func<object, string?> GetterNullable { get; set; } = default!;
// D8/Q-J (revised 2026-09-22): non-null exactly for the closed CLR numeric set (Nullable<T>
// 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<object, string?> 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<object, IEnumerable<(CultRecordKey Target, object? Payload)>>? ReferenceEnumerator { get; set; }
public static PersistedMember FromMember(
MemberInfo member,
Type memberType,
@ -1069,6 +1488,8 @@ namespace GameCult.Caching
var referenceAttribute = member.GetCustomAttribute<CultReferenceAttribute>();
var targetType = ResolveReferenceTarget(memberType, referenceAttribute?.TargetType);
var targetSchemaName = targetType?.GetCustomAttribute<CultDocumentAttribute>()?.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<CultNameAttribute>() != 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<object, IEnumerable<(CultRecordKey Target, object? Payload)>> BuildReferenceEnumerator(
Type memberType, bool many, Func<object, object?> getValue)
{
var indexAttribute = member.GetCustomAttribute<CultIndexAttribute>();
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<CultReferenceAttribute>();
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<T> element's T - the
// same narrowing a scalar CultRecordRef<T> 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;
}
}

View file

@ -112,7 +112,7 @@ namespace GameCult.Mesh
/// <summary>
/// Options for opening one typed document from a remote CultNet snapshot endpoint.
/// </summary>
public sealed class CultMeshPeerSnapshotDocumentOptions
public sealed record CultMeshPeerSnapshotDocumentOptions
{
/// <summary>Gets or sets the semantic document id. Defaults to the record key.</summary>
public string? DocumentId { get; set; }
@ -135,8 +135,8 @@ namespace GameCult.Mesh
/// <summary>Gets or sets the message id prefix used for snapshot requests.</summary>
public string? MessageIdPrefix { get; set; }
/// <summary>Gets or sets schema ids to request. Empty means the document type schema is requested.</summary>
public IReadOnlyList<string>? SchemaIds { get; set; }
/// <summary>Gets or sets the selection to request. Absent means the document type's own schema.</summary>
public CultNetSelection? Selection { get; set; }
/// <summary>Gets or sets the target shard id, when the endpoint is shard-aware.</summary>
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;
}
/// <summary>
@ -2707,46 +2691,48 @@ namespace GameCult.Mesh
where TDocument : class
{
var descriptor = CultDocumentRegistry.Shared.GetRequired<TDocument>();
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<string>? schemaIds,
IReadOnlyList<string>? 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<TDocument>(
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<TDocument>();
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<TDocument>(record);
}
private static bool TryDecodeSnapshotDocument<TDocument>(
CultNetRawDocumentRecord record,
out TDocument? document)
where TDocument : class
{
document = null;
if (!string.Equals(record.PayloadEncoding, "messagepack", StringComparison.Ordinal))
return false;
try
{
document = DecodeSnapshotDocument<TDocument>(record);
return true;
}
catch
{
return false;
}
}
private static TDocument DecodeSnapshotDocument<TDocument>(CultNetRawDocumentRecord record)
where TDocument : class
{
return (TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(
typeof(TDocument),
record.Payload);
return (TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TDocument), record.Payload);
}
/// <summary>
@ -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<TDocument>());
}
// 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<TDocument>(object document)

View file

@ -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<string>())
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.ToArray();
SchemaIds = new[]
{
Require(viewSchemaId, nameof(viewSchemaId)),
CultMeshBodyPublicationSchemaVersions.Publication
}
.Concat(additionalSchemaIds ?? Array.Empty<string>())
.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<string>())
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.ToArray(),
Schemas = new[]
{
Require(viewSchemaId, nameof(viewSchemaId)),
CultMeshBodyPublicationSchemaVersions.Publication
}
.Concat(additionalSchemaIds ?? Array.Empty<string>())
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.ToArray()
};
}
public string SubscriptionId { get; }
public string ConsumerRuntimeId { get; }
public string BodyId { get; }
public IReadOnlyList<string> RecordKeys { get; }
public IReadOnlyList<string> SchemaIds { get; }
private readonly CultNetSelection _selection;
/// <summary>
/// The view record, the body's latest-publication record, and any additional keys/schemas, as
/// one selection. Returns a fresh copy on every read: <see cref="CultNetSelection"/>'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).
/// </summary>
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)

View file

@ -13,13 +13,10 @@ namespace GameCult.Mesh
/// <summary>
/// Options for one scoped CultNet snapshot request.
/// </summary>
public sealed class CultMeshSnapshotRequestOptions
public sealed record CultMeshSnapshotRequestOptions
{
/// <summary>Gets or sets schema ids to request. Empty means no schema filter.</summary>
public IReadOnlyList<string>? SchemaIds { get; set; }
/// <summary>Gets or sets record keys to request. Empty means no record-key filter.</summary>
public IReadOnlyList<string>? RecordKeys { get; set; }
/// <summary>Gets or sets the selection to request. Absent selects every schema and key.</summary>
public CultNetSelection? Selection { get; set; }
/// <summary>Gets or sets the target shard id, when the endpoint is shard-aware.</summary>
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<CultNetSnapshotResponseRawMessage>(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<CultNetSnapshotResponseRawMessage>(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<CultNetSnapshotResponseRawMessage>(
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<TDocument>();
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<TDocument>(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<TDocument>();
var selection = CultMesh.ResolveDefaultSelection(schemaIds, recordKeys, descriptor);
return CultMesh.FetchSnapshotDocumentsAsync<TDocument>(
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<TDocument>();
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<TDocument>(result.Snapshot, DocumentRegistry);
@ -612,53 +611,13 @@ namespace GameCult.Mesh
IReadOnlyList<string>? schemaIds,
IReadOnlyList<string>? 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<string>? ResolveDefaultSchemaFilter(
IReadOnlyList<string>? schemaIds,
IReadOnlyList<string>? 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<TDocument>(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
};
}
/// <summary>
/// Fetches one scoped raw CultNet snapshot from an endpoint.
/// </summary>
@ -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<CultNetSnapshotResponseRawMessage>(
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)
/// <summary>
/// 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
/// <c>schemas</c> and <c>keys</c> reach the wire message, so <c>fields</c>, <c>cites</c>,
/// <c>cited</c>, <c>limit</c>, <c>cursor</c>, <c>descending</c>, and any non-default
/// <c>projection</c> are refused here instead of being silently dropped. A v1-carrying Mesh
/// transport is FU-Mesh-v1, not this cut.
/// </summary>
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).");
}
/// <summary>
/// Resolves one selection for a typed convenience call: explicit <paramref name="schemaIds"/>
/// win; otherwise an explicit <paramref name="recordKeys"/> - 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 <paramref name="recordKeys"/> 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).
/// </summary>
internal static CultNetSelection ResolveDefaultSelection(
IReadOnlyList<string>? schemaIds,
IReadOnlyList<string>? 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 };
}
/// <summary>
/// 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.
/// </summary>
internal static CultNetSelection OverlaySelection(
CultNetSelection? defaults,
IReadOnlyList<string>? schemaIds,
IReadOnlyList<string>? 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<string>? 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<string>())}] " +
$"and records [{string.Join(", ", CleanSnapshotFilter(options.RecordKeys) ?? Array.Empty<string>())}].");
$"for schemas [{string.Join(", ", options.Selection?.Schemas ?? Array.Empty<string>())}] " +
$"and records [{string.Join(", ", options.Selection?.Keys ?? Array.Empty<string>())}].");
}
return await responseTask.ConfigureAwait(false);
}
internal static string[]? CleanSnapshotFilter(IReadOnlyList<string>? 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<TDocument> DecodeSnapshotDocuments<TDocument>(
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<object[]>(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<IReadOnlyDictionary<string, object?>>(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;
}
}
}

View file

@ -44,7 +44,10 @@ namespace GameCult.Networking
Reconciled
}
internal static class CultNetSchemaAliasMatching
/// <summary>
/// The one schema-alias matcher for every runtime-side schema match (docs/cultnet-selection-cut.md, D4).
/// </summary>
public static class CultNetSchemaAliasMatching
{
public static bool MatchesAny(IReadOnlyList<string> 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.
}
/// <summary>
@ -298,6 +305,12 @@ namespace GameCult.Networking
/// Gets or sets the durable store for accepted shard mutation logs.
/// </summary>
public ICultNetShardMutationLogStore? MutationLogStore { get; set; }
/// <summary>
/// Gets or sets the process cursor key (R-O). Omitted in production, where a fresh random key
/// per <see cref="CultNetDatabase"/> 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.
/// </summary>
public CultNetSelectionCursorKey? CursorKey { get; set; }
}
/// <summary>
@ -464,6 +477,11 @@ namespace GameCult.Networking
new(StringComparer.Ordinal);
private readonly Dictionary<string, long> _nextLogSequences = new(StringComparer.Ordinal);
private readonly Dictionary<string, long> _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<object> _changes = new();
private bool _disposed;
@ -482,10 +500,19 @@ namespace GameCult.Networking
: options.Shards)
.ToList();
_clientAuthorityScopes = (options.ClientAuthorityScopes ?? Array.Empty<CultNetClientAuthorityScope>()).ToList();
CursorKey = options.CursorKey ?? CultNetSelectionCursorKey.Random();
InitializeLogSequencesFromStore();
_cache.OnUpdate += PublishCacheUpdate;
}
/// <summary>
/// This process's cursor key (R-O): random per instance unless <see cref="CultNetDatabaseOptions.CursorKey"/>
/// supplies one, so a page minted by this database cannot be answered by a differently-keyed one,
/// and a fresh <see cref="CultNetDatabase"/> - 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.
/// </summary>
public CultNetSelectionCursorKey CursorKey { get; }
/// <summary>
/// Gets the local cache backing this database surface.
/// </summary>
@ -589,7 +616,10 @@ namespace GameCult.Networking
}
/// <summary>
/// Creates a raw document snapshot bounded to one shard.
/// Creates a raw document snapshot bounded to one shard. Answers through the one evaluator
/// (<see cref="CultNetSelectionEvaluator"/>, via <see cref="CultNetDocumentRegistry.SelectPage"/>),
/// 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).
/// </summary>
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<string>(filter.SchemaIds, StringComparer.Ordinal)
: null;
var requestedRecordKeys = filter?.RecordKeys != null
? new HashSet<string>(filter.RecordKeys, StringComparer.Ordinal)
: null;
var documents = new List<CultNetRawDocumentRecord>();
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<CultNetRawDocumentRecord>(),
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<CultNetRawDocumentRecord>(),
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;
}
/// <summary>
/// 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).
/// </summary>
public long? LastWriteSequence(string schemaId, CultRecordKey key) =>
_lastWriteSequence.TryGetValue((schemaId, key.Value), out var sequence) ? sequence : null;
/// <summary>
/// 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 <c>asOf</c> must use <see cref="CurrentAsOf(string)"/> instead
/// (S-9): the shard-log sequence is a per-shard counter (<see cref="NextMutationLogSequence"/>),
/// 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.
/// </summary>
public ulong CurrentAsOf() => _lastWriteSequence.Count == 0 ? 0UL : (ulong)_lastWriteSequence.Values.Max();
/// <summary>
/// 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
/// <c>asOf</c> for a v1 snapshot or subscription page whose matched rows all come from one shard
/// (<see cref="CultNetDocumentRegistry"/>'s single-shard check) - it advances only when that
/// shard's own log advances, so a cursor minted against it refuses <c>cursor_stale</c> exactly
/// when that shard's log has moved, never because an unrelated shard elsewhere took a write the
/// selection never touched.
/// </summary>
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<string> 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

View file

@ -18,11 +18,13 @@ namespace GameCult.Networking
private readonly CultNetDatabase _database;
private readonly CultNetDatabaseServerOptions _options;
private readonly Func<CultNetSnapshotRequestMessage, CultNetServerPeer, Task> _snapshotHandler;
private readonly Func<CultNetSnapshotRequestV1Message, CultNetServerPeer, Task> _snapshotHandlerV1;
private readonly Func<CultNetDocumentPutRawMessage, CultNetServerPeer, Task> _putHandler;
private readonly Func<CultNetDocumentDeleteMessage, CultNetServerPeer, Task> _deleteHandler;
private readonly Func<CultNetShardCatalogRequestMessage, CultNetServerPeer, Task> _shardCatalogHandler;
private readonly Func<CultNetShardLogRequestMessage, CultNetServerPeer, Task> _shardLogHandler;
private readonly Func<CultNetDatabaseSubscribeMessage, CultNetServerPeer, Task> _subscribeHandler;
private readonly Func<CultNetDatabaseSubscribeV1Message, CultNetServerPeer, Task> _subscribeHandlerV1;
private readonly Func<CultNetDatabaseUnsubscribeMessage, CultNetServerPeer, Task> _unsubscribeHandler;
private readonly ConcurrentDictionary<string, IDisposable> _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);
}
/// <summary>
/// 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
/// <see cref="CultNetDocumentRegistry.CreateSelectionResponse"/>.
/// </summary>
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);
}
/// <summary>
/// Creates a shard catalog response from the database shard map.
/// </summary>
@ -193,11 +223,13 @@ namespace GameCult.Networking
_disposed = true;
_server.RemoveCultNetMessageListener<CultNetSnapshotRequestMessage>(_snapshotHandler);
_server.RemoveCultNetMessageListener<CultNetSnapshotRequestV1Message>(_snapshotHandlerV1);
_server.RemoveCultNetMessageListener<CultNetDocumentPutRawMessage>(_putHandler);
_server.RemoveCultNetMessageListener<CultNetDocumentDeleteMessage>(_deleteHandler);
_server.RemoveCultNetMessageListener<CultNetShardCatalogRequestMessage>(_shardCatalogHandler);
_server.RemoveCultNetMessageListener<CultNetShardLogRequestMessage>(_shardLogHandler);
_server.RemoveCultNetMessageListener<CultNetDatabaseSubscribeMessage>(_subscribeHandler);
_server.RemoveCultNetMessageListener<CultNetDatabaseSubscribeV1Message>(_subscribeHandlerV1);
_server.RemoveCultNetMessageListener<CultNetDatabaseUnsubscribeMessage>(_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}";

View file

@ -13,23 +13,24 @@ namespace GameCult.Networking
{
/// <summary>
/// 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.
/// </summary>
public sealed class CultNetDatabaseSubscriptionServer : IDisposable
{
private readonly ICultNetSchemaServer _server;
private readonly CultNetDatabase _database;
private readonly Func<CultNetDatabaseSubscribeMessage, ICultNetSchemaServerPeer, Task> _subscribe;
private readonly Func<CultNetDatabaseSubscribeV1Message, ICultNetSchemaServerPeer, Task> _subscribeV1;
private readonly Func<CultNetDatabaseUnsubscribeMessage, ICultNetSchemaServerPeer, Task> _unsubscribe;
private readonly Func<CultNetDatabaseSubscribeMessage, ICultNetSchemaServerPeer, bool>? _authorizeRequest;
private readonly Func<CultNetDatabaseSubscribeMessage, ICultNetSchemaServerPeer, string, string, bool>? _authorizeRecord;
private readonly Func<CultNetDatabaseSubscribeMessage, ICultNetSchemaServerPeer, CultNetRawDocumentRecord, CultNetRawDocumentRecord?>? _projectRecord;
private readonly ICultNetSchemaServerPeerLifecycle? _peerLifecycle;
private readonly ConcurrentDictionary<SubscriptionKey, IDisposable> _subscriptions =
new ConcurrentDictionary<SubscriptionKey, IDisposable>(SubscriptionKeyComparer.Instance);
private readonly ConcurrentDictionary<SubscriptionKey, CultNetDatabaseSubscribeMessage> _requests =
new ConcurrentDictionary<SubscriptionKey, CultNetDatabaseSubscribeMessage>(SubscriptionKeyComparer.Instance);
private readonly ConcurrentDictionary<SubscriptionKey, SubscriptionRequest> _requests =
new ConcurrentDictionary<SubscriptionKey, SubscriptionRequest>(SubscriptionKeyComparer.Instance);
private readonly ConcurrentDictionary<SubscriptionKey, SubscriptionProjectionState> _projections =
new ConcurrentDictionary<SubscriptionKey, SubscriptionProjectionState>(SubscriptionKeyComparer.Instance);
private readonly object _lifecycleGate = new();
@ -40,17 +41,17 @@ namespace GameCult.Networking
ICultNetSchemaServer server,
CultNetDatabase database,
Func<CultNetDatabaseSubscribeMessage, ICultNetSchemaServerPeer, bool>? authorizeRequest = null,
Func<CultNetDatabaseSubscribeMessage, ICultNetSchemaServerPeer, string, string, bool>? authorizeRecord = null,
Func<CultNetDatabaseSubscribeMessage, ICultNetSchemaServerPeer, CultNetRawDocumentRecord, CultNetRawDocumentRecord?>? projectRecord = null)
Func<CultNetDatabaseSubscribeMessage, ICultNetSchemaServerPeer, string, string, bool>? 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<CultNetDatabaseSubscribeMessage>(_subscribe);
_server.RemoveCultNetMessageListener<CultNetDatabaseSubscribeV1Message>(_subscribeV1);
_server.RemoveCultNetMessageListener<CultNetDatabaseUnsubscribeMessage>(_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<CultNetRawDocumentRecord>()
});
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<string>())
(request.Selection.Keys ?? Array.Empty<string>())
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.ToArray(),
(request.SchemaIds ?? Array.Empty<string>())
(request.Selection.Schemas ?? Array.Empty<string>())
.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<string, CultNetRawDocumentRecord>(StringComparer.Ordinal);
var projectedRecordKeys = new HashSet<string>(StringComparer.Ordinal);
foreach (var source in snapshot.Documents)
foreach (var source in page.Documents ?? Array.Empty<CultNetRawDocumentRecord>())
{
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<CultNetEdge>());
}
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<string, string, bool> 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<string, CultNetRawDocumentRecord> DeliveredBySourceRecordKey { get; } =
@ -523,15 +603,17 @@ namespace GameCult.Networking
private sealed class ProjectedSnapshot
{
public ProjectedSnapshot(Dictionary<string, CultNetRawDocumentRecord> bySourceRecordKey)
public ProjectedSnapshot(Dictionary<string, CultNetRawDocumentRecord> bySourceRecordKey, CultNetEdge[]? edges = null)
{
BySourceRecordKey = bySourceRecordKey;
Documents = bySourceRecordKey.Values.ToArray();
Edges = edges ?? Array.Empty<CultNetEdge>();
}
public static ProjectedSnapshot Empty { get; } =
new(new Dictionary<string, CultNetRawDocumentRecord>(StringComparer.Ordinal));
public Dictionary<string, CultNetRawDocumentRecord> BySourceRecordKey { get; }
public CultNetEdge[] Edges { get; }
public CultNetRawDocumentRecord[] Documents { get; }
}

View file

@ -258,7 +258,23 @@ namespace GameCult.Networking
}
/// <summary>
/// Creates a raw snapshot response from the cache.
/// Creates a typed-selection snapshot request message (docs/cultnet-selection-cut.md).
/// </summary>
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
};
}
/// <summary>
/// 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 <see cref="CultNetSelection"/>
/// and is answered by <see cref="CultNetSelectionEvaluator"/> in one evaluation (R-G), so a v0
/// caller sees its whole matching set in one message without re-filtering the cache per page.
/// </summary>
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<string>(filter.SchemaIds, StringComparer.Ordinal)
: null;
var requestedRecordKeys = filter?.RecordKeys != null
? new HashSet<string>(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<CultNetRawDocumentRecord>();
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<CultNetRawDocumentRecord>()
};
}
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<string> 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<string, CultRecordKey, long> ordinalOf;
if (lowKeys is { Length: > 0 })
{
var order = new Dictionary<string, long>(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<CultNetRawDocumentRecord>()
};
}
/// <summary>
/// Evaluates a typed selection against the cache and returns its page
/// (docs/cultnet-selection-cut.md, section 2/6). <paramref name="ordinalOf"/> supplies each
/// row's ordinal (the reference: <see cref="CultNetDatabase.LastWriteSequence"/>); <paramref name="asOf"/>
/// is the snapshot the page is exact for when the selection is not shard-scoped, or the fallback
/// used only when <paramref name="asOfForShard"/> is null or the selection matches no rows (S-9).
/// </summary>
public CultNetSnapshotResponseRawV1Message CreateSelectionResponse(
CultCache cache,
string messageId,
CultNetSelection selection,
Func<string, CultRecordKey, long> ordinalOf,
ulong asOf,
CultNetDocumentMessageOptions? options = null,
Func<string, CultRecordKey, string>? shardIdOf = null,
CultNetSelectionCursorKey? cursorKey = null,
Func<string, ulong>? 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
};
}
/// <summary>
/// Evaluates a selection over every row the cache holds and projects the matched page to wire
/// records. The one evaluator (<see cref="CultNetSelectionEvaluator"/>) owns matching, order,
/// the hop and the cursor; this owns turning its result into <see cref="CultNetRawDocumentRecord"/>s.
/// </summary>
internal CultNetSelectionPage SelectPage(
CultCache cache,
CultNetSelection selection,
Func<string, CultRecordKey, long> ordinalOf,
ulong asOf,
CultNetDocumentMessageOptions? options,
Func<CultDocumentDescriptor, CultRecordKey, bool>? rowFilter = null,
Func<string, CultRecordKey, string>? shardIdOf = null,
CultNetSelectionCursorKey? cursorKey = null,
Func<string, ulong>? 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
};
}
/// <summary>
/// Refuses a selection whose matched rows come from more than one shard's mutation log (R-Q):
/// <c>asOf</c> is one shard-log watermark (<see cref="CultNetDatabase.CurrentAsOf"/> 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
/// <c>keys</c> list (R-F), rather than answered with an <c>asOf</c> that is not really defined -
/// reusing <see cref="CultNetSelectionInvalidException"/>/<c>selection_invalid</c> (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.
/// </summary>
/// <summary>Returns the one shard every matched row belongs to, or null when the selection matched no rows.</summary>
private static string? EnsureSingleShard(
IReadOnlyList<CultNetSelectionEvaluator.Row> rows,
Func<string, CultRecordKey, string> 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;
}
/// <summary>
/// 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
/// <see cref="SelectPage"/> 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").
/// </summary>
internal CultNetSelectionPage SelectAll(
CultCache cache,
CultNetSelection selection,
Func<string, CultRecordKey, long> ordinalOf,
ulong asOf,
CultNetDocumentMessageOptions? options,
Func<CultDocumentDescriptor, CultRecordKey, bool>? 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<string>? 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<string>(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
};
}
/// <summary>
/// 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").
/// </summary>
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;
}
/// <summary>
/// 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
/// <see cref="ToRawRecord(CultNetSelectionEvaluator.Row, CultNetDocumentMessageOptions?)"/> 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.
/// </summary>
internal string WireSchemaId(CultDocumentDescriptor descriptor) =>
GetByDocumentType(descriptor.DocumentType)?.SchemaId ?? descriptor.SchemaId;
/// <summary>
/// Applies a raw document put message to a cache.
/// </summary>
@ -471,7 +743,19 @@ namespace GameCult.Networking
string.Equals(candidate.SchemaName, schemaName, StringComparison.Ordinal));
}
private CultDocumentDescriptor? TryResolveDescriptorByPayloadSchema(byte[] payload)
/// <summary>
/// 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 <see cref="ResolveDescriptorForRawDocument"/>
/// 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 <see cref="TryReadSchemaVersion"/>'s
/// result against that descriptor with <see cref="CultNetSchemaAliasMatching"/> 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.
/// </summary>
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<string> requestedSchemaIds)
{
if (requestedSchemaIds.Count == 0)
return true;
/// <summary>
/// True when a raw payload's embedded <c>schemaVersion</c> 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). <see cref="TryReadSchemaVersion"/> stays internal: a caller with an already-known
/// descriptor never needs the raw schema-version string itself, only this answer.
/// </summary>
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)
/// <summary>
/// Reads a raw payload's embedded <c>schemaVersion</c> stamp, when it carries one - the parse
/// step of the registry's payload-schema decode rule, shared with
/// <see cref="TryResolveDescriptorByPayloadSchema"/> and <see cref="PayloadMatchesSchema"/>
/// (docs/cultnet-selection-cut.md, section 4/S2-5, R-L). Internal: a caller outside this
/// assembly matching against one already-known descriptor uses <see cref="PayloadMatchesSchema"/>;
/// resolving "whichever registered type owns this schema string" (ambiguous when two document
/// types alias the same schema id) is <see cref="TryResolveDescriptorByPayloadSchema"/>'s job.
/// </summary>
internal static string? TryReadSchemaVersion(byte[] payload)
{
try
{

View file

@ -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<Type, Delegate> _handlers = new();
private readonly ConcurrentDictionary<Delegate, Delegate> _schemaPeerAdapters = new();
private bool _disposed;
private ILogger _logger = new NullLogger();
/// <summary>
/// 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.
/// </summary>
public ILogger Logger
{
get => _logger;
set => _logger = value ?? new NullLogger();
}
/// <summary>
/// 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;
}

View file

@ -61,6 +61,9 @@ namespace GameCult.Networking
CultNetSchemaVersions.DocumentPutRaw => Deserialize<CultNetDocumentPutRawMessage>(payload),
CultNetSchemaVersions.SnapshotRequest => Deserialize<CultNetSnapshotRequestMessage>(payload),
CultNetSchemaVersions.SnapshotResponseRaw => Deserialize<CultNetSnapshotResponseRawMessage>(payload),
CultNetSchemaVersions.SnapshotRequestV1 => Deserialize<CultNetSnapshotRequestV1Message>(payload),
CultNetSchemaVersions.DatabaseSubscribeV1 => Deserialize<CultNetDatabaseSubscribeV1Message>(payload),
CultNetSchemaVersions.SnapshotResponseRawV1 => Deserialize<CultNetSnapshotResponseRawV1Message>(payload),
CultNetSchemaVersions.DatabaseSubscribe => Deserialize<CultNetDatabaseSubscribeMessage>(payload),
CultNetSchemaVersions.DatabaseUnsubscribe => Deserialize<CultNetDatabaseUnsubscribeMessage>(payload),
CultNetSchemaVersions.DatabaseChangeRaw => Deserialize<CultNetDatabaseChangeRawMessage>(payload),

View file

@ -80,6 +80,18 @@ namespace GameCult.Networking
/// </summary>
public const string SnapshotResponseRaw = "cultnet.snapshot_response_raw.v0";
/// <summary>
/// typed-selection snapshot request contract identifier (docs/cultnet-selection-cut.md).
/// </summary>
public const string SnapshotRequestV1 = "cultnet.snapshot_request.v1";
/// <summary>
/// typed-selection database subscription request contract identifier (docs/cultnet-selection-cut.md).
/// </summary>
public const string DatabaseSubscribeV1 = "cultnet.database_subscribe.v1";
/// <summary>
/// typed-selection snapshot response contract identifier (docs/cultnet-selection-cut.md).
/// </summary>
public const string SnapshotResponseRawV1 = "cultnet.snapshot_response_raw.v1";
/// <summary>
/// schema catalog request contract identifier.
/// </summary>
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.
/// </summary>
[Key("routingHint")] public CultNetShardRoutingHint? RoutingHint { get; set; }
/// <summary>
/// Gets or sets the machine-readable refusal code (R-N), e.g. <c>selection_invalid</c>,
/// <c>cursor_stale</c>, <c>cursor_invalid</c>, <c>reference_outside_target</c>. Additive to
/// <c>cultnet.error.v0</c>; a v0 peer that does not know this field ignores it.
/// </summary>
[Key("code")] public string? Code { get; set; }
/// <summary>
/// Gets or sets the code-specific structured detail (R-N). Additive to <c>cultnet.error.v0</c>.
/// </summary>
[Key("details")] public CultNetErrorDetails? Details { get; set; }
/// <summary>
/// Builds the wire error for a door refusal (R-N): code <c>selection_invalid</c>, details
/// <c>{ field, value }</c>.
/// </summary>
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 }
};
/// <summary>
/// Builds the wire error for a cursor refusal (R-N): code <c>cursor_stale</c> with details
/// <c>{ asOf, current }</c>, or code <c>cursor_invalid</c> with no details.
/// </summary>
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
};
/// <summary>
/// Builds the wire error for an out-of-target reference refusal (R-N): code
/// <c>reference_outside_target</c>, no details.
/// </summary>
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"
};
}
/// <summary>
/// Code-specific structured detail carried on <see cref="CultNetErrorMessage"/> (R-N). Fields are
/// populated only for the code that defines them; every field is otherwise omitted (nil on the wire).
/// </summary>
[MessagePackObject]
public class CultNetErrorDetails
{
/// <summary>The selection field that failed (<c>selection_invalid</c>).</summary>
[Key("field")] public string? Field { get; set; }
/// <summary>The offending value, when there is one string worth naming (<c>selection_invalid</c>).</summary>
[Key("value")] public string? Value { get; set; }
/// <summary>The cursor's own minted asOf (<c>cursor_stale</c>).</summary>
[Key("asOf")] public ulong? AsOf { get; set; }
/// <summary>The answering server's current asOf (<c>cursor_stale</c>).</summary>
[Key("current")] public ulong? Current { get; set; }
}
/// <summary>
@ -825,6 +905,78 @@ namespace GameCult.Networking
[Key("supportedBodyTransports")] public string[]? SupportedBodyTransports { get; set; }
}
/// <summary>
/// CultNet message requesting a document snapshot through a typed selection (docs/cultnet-selection-cut.md).
/// </summary>
[MessagePackObject]
public class CultNetSnapshotRequestV1Message : ICultNetSchemaMessage
{
/// <summary>Gets or sets the schema version.</summary>
[Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.SnapshotRequestV1;
/// <summary>Gets or sets the message id.</summary>
[Key("messageId")] public string MessageId { get; set; } = string.Empty;
/// <summary>Gets or sets the selection.</summary>
[Key("selection")] public CultNetSelection Selection { get; set; } = new CultNetSelection();
/// <summary>Gets or sets the shard id requested for a shard-bounded snapshot.</summary>
[Key("shardId")] public string? ShardId { get; set; }
/// <summary>Gets or sets the expected shard epoch.</summary>
[Key("shardEpoch")] public long? ShardEpoch { get; set; }
}
/// <summary>
/// Requests a live database subscription through a typed selection (docs/cultnet-selection-cut.md).
/// </summary>
[MessagePackObject]
public class CultNetDatabaseSubscribeV1Message : ICultNetSchemaMessage
{
/// <summary>Gets or sets the schema version.</summary>
[Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.DatabaseSubscribeV1;
/// <summary>Gets or sets the request id.</summary>
[Key("messageId")] public string MessageId { get; set; } = string.Empty;
/// <summary>Gets or sets the live subscription id.</summary>
[Key("subscriptionId")] public string SubscriptionId { get; set; } = string.Empty;
/// <summary>Gets or sets the selection.</summary>
[Key("selection")] public CultNetSelection Selection { get; set; } = new CultNetSelection();
/// <summary>Gets or sets whether the server should send a matching snapshot before live changes.</summary>
[Key("includeSnapshot")] public bool IncludeSnapshot { get; set; } = true;
/// <summary>Gets or sets the stable consumer runtime identity used for subscription diagnostics and body-plane demand.</summary>
[Key("consumerRuntimeId")] public string? ConsumerRuntimeId { get; set; }
/// <summary>Gets or sets logical hot-body identities needed by this exact state subscription.</summary>
[Key("bodyIds")] public string[]? BodyIds { get; set; }
/// <summary>Gets or sets body transports the consumer can open, ordered by consumer preference.</summary>
[Key("supportedBodyTransports")] public string[]? SupportedBodyTransports { get; set; }
}
/// <summary>
/// CultNet message returning a typed selection's page (docs/cultnet-selection-cut.md).
/// </summary>
[MessagePackObject]
public class CultNetSnapshotResponseRawV1Message : ICultNetSchemaMessage
{
/// <summary>Gets or sets the schema version.</summary>
[Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.SnapshotResponseRawV1;
/// <summary>Gets or sets the message id.</summary>
[Key("messageId")] public string MessageId { get; set; } = string.Empty;
/// <summary>Gets or sets the number of rows on this page.</summary>
[Key("matched")] public uint Matched { get; set; }
/// <summary>Gets or sets the snapshot this page is exact as of.</summary>
[Key("asOf")] public ulong AsOf { get; set; }
/// <summary>Gets or sets the cursor for the next page, absent on the last page.</summary>
[Key("next")] public string? Next { get; set; }
/// <summary>Gets or sets the headers, present under header projection.</summary>
[Key("headers")] public CultNetRawDocumentHeader[]? Headers { get; set; }
/// <summary>Gets or sets the documents, present under document projection.</summary>
[Key("documents")] public CultNetRawDocumentRecord[]? Documents { get; set; }
/// <summary>Gets or sets the edges a hop-bearing selection traversed.</summary>
[Key("edges")] public CultNetEdge[]? Edges { get; set; }
/// <summary>Gets or sets the shard id represented by this snapshot.</summary>
[Key("shardId")] public string? ShardId { get; set; }
/// <summary>Gets or sets the shard epoch represented by this snapshot.</summary>
[Key("shardEpoch")] public long? ShardEpoch { get; set; }
/// <summary>Gets or sets the shard-log sequence represented by this snapshot.</summary>
[Key("shardLogSequence")] public long? ShardLogSequence { get; set; }
}
/// <summary>
/// Cancels a live database subscription.
/// </summary>

View file

@ -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",

View file

@ -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
{
/// <summary>
/// The four comparison and one any-of operator a field predicate may carry. Serialised on the wire
/// as the <c>op</c> string (<see cref="CultNetSelectionOperators"/>), never as a union: the C#
/// enum exists for evaluator and validation ergonomics only.
/// </summary>
public enum CultNetSelectionOperator
{
/// <summary>Matches when the row's declared value is one of <c>values</c>.</summary>
AnyOf,
/// <summary>Matches when the row's declared numeric value is less than <c>number</c>.</summary>
Lt,
/// <summary>Matches when the row's declared numeric value is less than or equal to <c>number</c>.</summary>
Le,
/// <summary>Matches when the row's declared numeric value is greater than or equal to <c>number</c>.</summary>
Ge,
/// <summary>Matches when the row's declared numeric value is greater than <c>number</c>.</summary>
Gt
}
/// <summary>Converts <see cref="CultNetSelectionOperator"/> to and from its wire spelling.</summary>
public static class CultNetSelectionOperators
{
/// <summary>Returns the wire spelling of an operator.</summary>
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))
};
/// <summary>Parses a wire operator string; returns false for anything else.</summary>
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;
}
}
/// <summary>True for the four comparison operators (everything but any_of).</summary>
public static bool IsComparison(this CultNetSelectionOperator op) => op != CultNetSelectionOperator.AnyOf;
}
/// <summary>
/// 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 <see cref="Pattern"/>, 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.
/// </summary>
public static class CultNetCanonicalNumber
{
/// <summary>
/// Forbids leading zeros, a trailing fractional zero or bare point, an explicit '+', exponent
/// notation, and whitespace. Does not by itself forbid "-0"; <see cref="IsCanonical"/> does.
/// Anchored with <c>\z</c>, not <c>$</c>: .NET's <c>$</c> matches immediately before a single
/// trailing newline even without <see cref="System.Text.RegularExpressions.RegexOptions.Multiline"/>,
/// so a naive <c>$</c> here would accept "5\n" as canonical (R-D/Q-J; the JSON schema's ECMA-262
/// <c>$</c> has no such exception and already refused it).
/// </summary>
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);
/// <summary>True for a canonical decimal string; false for null, "-0", or any other spelling.</summary>
public static bool IsCanonical(string? value) =>
value != null && value != "-0" && CanonicalRegex.IsMatch(value);
/// <summary>
/// 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 <see cref="IsCanonical"/> first; this does not
/// re-validate.
/// </summary>
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));
}
}
/// <summary>One predicate over a single declared index alias (docs/cultnet-selection-cut.md, section 2).</summary>
[MessagePackObject]
public sealed class CultNetFieldPredicate
{
/// <summary>A declared index alias, reachable on some schema the selection can reach.</summary>
[Key("index")] public string Index { get; set; } = string.Empty;
/// <summary>"any_of", "lt", "le", "ge", or "gt".</summary>
[Key("op")] public string Op { get; set; } = string.Empty;
/// <summary>Present iff op = any_of; non-empty.</summary>
[Key("values")] public string[]? Values { get; set; }
/// <summary>Present iff op is a comparison; a canonical decimal string (Q-J, <see cref="CultNetCanonicalNumber"/>).</summary>
[Key("number")] public string? Number { get; set; }
}
/// <summary>Identifies one row on the wire by schema and record key.</summary>
[MessagePackObject]
public sealed class CultNetRecordRef
{
/// <summary>Gets or sets the schema id.</summary>
[Key("schemaId")] public string SchemaId { get; set; } = string.Empty;
/// <summary>Gets or sets the record key.</summary>
[Key("recordKey")] public string RecordKey { get; set; } = string.Empty;
}
/// <summary>Selects rows whose declared reference names a target row (docs/cultnet-selection-cut.md, section 2).</summary>
[MessagePackObject]
public sealed class CultNetCitation
{
/// <summary>The row the citer must name.</summary>
[Key("target")] public CultNetRecordRef Target { get; set; } = new();
/// <summary>The reference's declared alias; absent matches any declared reference.</summary>
[Key("role")] public string? Role { get; set; }
}
/// <summary>Selects rows some other row does or does not name in <c>role</c> - the one negation.</summary>
[MessagePackObject]
public sealed class CultNetIncoming
{
/// <summary>The declared reference alias to check for an incoming edge.</summary>
[Key("role")] public string Role { get; set; } = string.Empty;
/// <summary>True to select rows with an incoming edge, false to select rows without one.</summary>
[Key("exists")] public bool Exists { get; set; }
}
/// <summary>
/// One typed selection, carried by <c>cultnet.snapshot_request.v1</c> and
/// <c>cultnet.database_subscribe.v1</c>. docs/cultnet-selection-cut.md, section 2.
/// </summary>
[MessagePackObject]
public sealed class CultNetSelection
{
/// <summary>Kinds: schema ids or aliases, matched by <see cref="CultNetSchemaAliasMatching"/>. Absent = every schema.</summary>
[Key("schemas")] public string[]? Schemas { get; set; }
/// <summary>Record-key allowlist. Absent = every key.</summary>
[Key("keys")] public string[]? Keys { get; set; }
/// <summary>Conjunction; each is any-of over one declared index alias.</summary>
[Key("fields")] public CultNetFieldPredicate[]? Fields { get; set; }
/// <summary>Rows whose declared reference names <c>target</c>.</summary>
[Key("cites")] public CultNetCitation? Cites { get; set; }
/// <summary>Rows some row does or does not name in <c>role</c>.</summary>
[Key("cited")] public CultNetIncoming? Cited { get; set; }
/// <summary>"header" or "document"; default "header".</summary>
[Key("projection")] public string Projection { get; set; } = CultNetSelectionProjections.Header;
/// <summary>Reverses the one order.</summary>
[Key("descending")] public bool Descending { get; set; }
/// <summary>Clamped 1..=200.</summary>
[Key("limit")] public uint? Limit { get; set; }
/// <summary>Opaque; minted by the answering server.</summary>
[Key("cursor")] public string? Cursor { get; set; }
/// <summary>True when this selection follows an edge (section 2: "Edges in the answer").</summary>
[IgnoreMember]
public bool HasHop => Cites != null || Cited != null;
}
/// <summary>
/// Lowers a v0 <c>schemaIds</c>/<c>recordKeys</c> 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 <see cref="CultNetSelectionEvaluator"/> or
/// <see cref="CultNetSelectionValidation"/> carries - v1 selections are refused at the door
/// instead (S1).
/// </summary>
public static class CultNetV0SelectionLowering
{
/// <summary>
/// Lowers one v0 filter list; see the type summary. R-R (2026-09-22): a <c>null</c> list means
/// "no filter" and stays <c>null</c>; 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 (<c>CultNetDatabase.CreateShardSnapshotResponse</c> at
/// <c>b3d9cf7</c>) tests <c>filter?.RecordKeys != null</c>, not a length, so
/// <c>recordKeys: []</c> always answered empty. The evaluator already honours that distinction
/// (<c>CultNetSelectionEvaluator</c>: a non-null <c>Keys</c> array excludes every row when it
/// is empty); only this lowering used to erase it before the selection ever reached the
/// evaluator.
/// </summary>
public static string[]? Lower(IReadOnlyList<string>? values)
{
if (values == null)
return null;
return values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.ToArray();
}
}
/// <summary>The two wire values <see cref="CultNetSelection.Projection"/> may carry.</summary>
public static class CultNetSelectionProjections
{
/// <summary>The record without its payload.</summary>
public const string Header = "header";
/// <summary>The full record.</summary>
public const string Document = "document";
}
/// <summary>One edge a hop traversed, carried on <see cref="CultNetSelectionPage"/>.</summary>
[MessagePackObject]
public sealed class CultNetEdge
{
/// <summary>The citing row.</summary>
[Key("from")] public CultNetRecordRef From { get; set; } = new();
/// <summary>The declared reference alias the citer used.</summary>
[Key("role")] public string Role { get; set; } = string.Empty;
/// <summary>The cited row, its schema resolved to the leaf actually stored.</summary>
[Key("to")] public CultNetRecordRef To { get; set; } = new();
/// <summary>"messagepack" when <see cref="Payload"/> is present.</summary>
[Key("payloadEncoding")] public string? PayloadEncoding { get; set; }
/// <summary>The value attached to the edge (a dictionary reference's value); present under document projection only.</summary>
[Key("payload")] public byte[]? Payload { get; set; }
}
/// <summary>
/// A header record: <see cref="CultNetRawDocumentRecord"/> minus its payload and payloadEncoding.
/// </summary>
[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; }
/// <summary>Builds a header by dropping a raw record's payload.</summary>
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
};
}
/// <summary>
/// The result of evaluating a selection, before it is carried by a wire message. Exactly one of
/// <see cref="Headers"/> / <see cref="Documents"/> is populated, by projection.
/// </summary>
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; }
}
/// <summary>
/// Refused typed at the door (section 2/3): a name, list, or comparison the declared shape cannot
/// support. Carried as <c>cultnet.error.v0 { code: "selection_invalid", details: { field, value } }</c>.
/// </summary>
public sealed class CultNetSelectionInvalidException : Exception
{
public CultNetSelectionInvalidException(string field, string? value, string message)
: base(message)
{
Field = field;
Value = value;
}
/// <summary>The selection field that failed ("schemas", "keys", "fields[i].index", "cites.role", "cited.role", ...).</summary>
public string Field { get; }
/// <summary>The offending value, when there is one string worth naming.</summary>
public string? Value { get; }
}
/// <summary>
/// Validates a <see cref="CultNetSelection"/> against the declared shape of the schemas it can
/// reach - the door refusals of docs/cultnet-selection-cut.md section 2/3 (S1).
/// </summary>
public static class CultNetSelectionValidation
{
/// <summary>
/// The declaration-independent half of the door: empty/blank <c>schemas</c>/<c>keys</c> and an
/// unrecognised <c>projection</c>. Runs first inside <see cref="Validate"/>, and is also the
/// whole door for a caller with no descriptor list in hand - CultMesh's
/// <c>EnsureV0Compatible</c> (docs/cultnet-selection-cut.md, R-F) runs this before its own v0
/// transport refusals, so <c>Keys=[]</c>/<c>[""]</c> is refused there too, not only in the
/// reference runtime's full evaluator path.
/// </summary>
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<CultDocumentDescriptor> 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<CultDocumentDescriptor> 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<CultDocumentDescriptor> allDescriptors, string role) =>
allDescriptors.Any(descriptor => descriptor.DeclaredMembers.Any(member =>
member.IsReference && (member.IndexAlias ?? member.MemberName) == role));
/// <summary>The schemas <see cref="CultNetSelection.Schemas"/> reaches - every schema when absent.</summary>
public static IReadOnlyList<CultDocumentDescriptor> ReachableSchemas(
CultNetSelection selection, IReadOnlyList<CultDocumentDescriptor> allDescriptors)
{
if (selection.Schemas == null || selection.Schemas.Length == 0)
return allDescriptors;
return allDescriptors.Where(descriptor => CultNetSchemaAliasMatching.MatchesAny(selection.Schemas, descriptor)).ToArray();
}
}
}

View file

@ -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
{
/// <summary>
/// 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 <see cref="CultDocumentDescriptor"/>'s public read surface (section 1's cache
/// commit). Replaces the three selector engines this cut deletes from
/// <c>CultNetDocumentRegistry</c>/<c>CultNetDatabaseServer</c>/<c>CultNetDatabaseSubscriptionServer</c>.
/// </summary>
public static class CultNetSelectionEvaluator
{
/// <summary>The lowest and highest limit a page may carry (section 2).</summary>
public const uint LimitMin = 1;
public const uint LimitMax = 200;
/// <summary>One row available to the evaluator: what the cache already knows about it.</summary>
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; }
}
/// <summary>One edge a hop traversed, resolved against the row set in hand.</summary>
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; }
}
/// <summary>Evaluated, ordered, paged rows plus the edges a hop traversed.</summary>
public sealed class Evaluation
{
public IReadOnlyList<Row> Rows { get; set; } = Array.Empty<Row>();
public IReadOnlyList<EdgeMatch> Edges { get; set; } = Array.Empty<EdgeMatch>();
public string? NextCursor { get; set; }
/// <summary>The selection's whole matching count, not this page's row count (R-G/C5).</summary>
public int TotalMatched { get; set; }
}
/// <summary>
/// The single-row fast path (D6): schemas, keys and fields only. A hop-bearing selection
/// (<see cref="CultNetSelection.HasHop"/>) is set-dependent and must not use this path - the
/// caller reconciles the full set instead.
/// </summary>
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))
};
}
/// <summary>One evaluation's full matching, ordered row set, with the edges its hop traversed anchored to the row that carries each one.</summary>
public sealed class FullEvaluation
{
/// <summary>Every matching row, in order, with no cursor or limit applied.</summary>
public IReadOnlyList<Row> Ordered { get; set; } = Array.Empty<Row>();
/// <summary>Every edge the hop traversed, each paired with the page-eligible row it is reported against: the citer under <c>cites</c>, the cited row under <c>cited</c> (R-B).</summary>
public IReadOnlyList<(EdgeMatch Edge, Row Anchor)> Edges { get; set; } = Array.Empty<(EdgeMatch, Row)>();
}
/// <summary>
/// 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 (<see cref="Select"/>) and a v0/shard full walk page over,
/// instead of each page re-filtering and re-sorting the whole row set (R-G).
/// </summary>
public static FullEvaluation EvaluateAll(
CultDocumentRegistry registry,
IReadOnlyList<Row> 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<string, List<Row>>(StringComparer.Ordinal);
foreach (var row in allRows)
{
if (!byKey.TryGetValue(row.Key.Value, out var rowsAtKey))
byKey[row.Key.Value] = rowsAtKey = new List<Row>();
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<EdgeMatch>();
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<EdgeMatch>();
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 };
}
/// <summary>
/// Evaluates a selection over the full row set and returns one cursor/limit page of it
/// (docs/cultnet-selection-cut.md, section 2/6). <see cref="Evaluation.TotalMatched"/> is the
/// selection's whole matching count, not this page's length (R-G/C5). The caller projects the
/// result to wire records (<see cref="CultNetDocumentRegistry"/> owns that).
/// </summary>
public static Evaluation Select(
CultDocumentRegistry registry,
IReadOnlyList<Row> allRows,
CultNetSelection selection,
ulong asOf,
CultNetSelectionCursorKey? cursorKey = null)
{
var full = EvaluateAll(registry, allRows, selection);
return Page(full, selection, asOf, cursorKey);
}
/// <summary>
/// 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 <see cref="EvaluateAll"/>
/// result instead of re-evaluating per page.
/// </summary>
/// <param name="cursorKey">
/// 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 <see cref="CultNetDatabase"/>
/// still gets a keyed digest; <see cref="CultNetDatabase"/> 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.
/// </param>
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<EdgeMatch>();
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<Row> 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<Row> 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<string, List<Row>> 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<string, List<Row>> byKey,
Row citer,
CultNetCitation citation,
List<EdgeMatch> 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<Row> allRows,
IReadOnlyDictionary<string, List<Row>> byKey,
string role,
List<EdgeMatch> 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<CultDocumentMemberView> 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;
}
}
}
/// <summary>The typed refusal for an edge naming a row outside its reference's declared target (S18).</summary>
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; }
}
/// <summary>The typed refusal for a cursor the server cannot answer (<c>cursor_stale</c>/<c>cursor_invalid</c>).</summary>
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; }
/// <summary>The cursor's own minted <c>asOf</c>. Carried on the wire only for <c>cursor_stale</c> (R-N).</summary>
public ulong? AsOf { get; }
/// <summary>The answering server's current <c>asOf</c>. Carried on the wire only for <c>cursor_stale</c> (R-N).</summary>
public ulong? Current { get; }
}
/// <summary>
/// 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. <see cref="Random"/> is never persisted or serialized; the answering server
/// holds one instance for its process lifetime (<see cref="CultNetDatabase.CursorKey"/>), 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 <c>cursor_invalid</c> - acceptable, because cursors are
/// short-lived and section 2 already calls them "minted by the answering server".
/// </summary>
public sealed class CultNetSelectionCursorKey
{
/// <summary>
/// The key <see cref="CultNetSelectionEvaluator.Page"/>/<see cref="CultNetSelectionEvaluator.Select"/>
/// 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
/// <see cref="CultNetDatabase"/> (most unit tests) still gets a keyed, per-process digest instead
/// of an unkeyed one.
/// </summary>
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;
}
/// <summary>A fresh 32-byte key from the OS RNG.</summary>
public static CultNetSelectionCursorKey Random()
{
var bytes = new byte[32];
RandomNumberGenerator.Fill(bytes);
return new CultNetSelectionCursorKey(bytes);
}
internal byte[] KeyBytes => _key;
}
/// <summary>
/// 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 <see cref="CultNetSelectionCursorKey"/> (R-O/R-Y). Minted only by the
/// answering server.
/// </summary>
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]);
}
/// <summary>
/// Verifies this cursor's digest against <paramref name="selection"/> under <paramref name="key"/>
/// 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
/// (<see cref="AsOf"/>, <see cref="Ordinal"/>, <see cref="SchemaId"/>, <see cref="RecordKey"/>)
/// 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").
/// </summary>
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));
}
/// <summary>
/// An HMAC-SHA256 digest, keyed under <paramref name="key"/>, of the cursor's own body
/// (<paramref name="asOf"/>, <paramref name="ordinal"/>, <paramref name="schemaId"/>,
/// <paramref name="recordKey"/>) 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"].
/// </summary>
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<CultNetFieldPredicate>();
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();
}
/// <summary>Appends one length-prefixed string: unambiguous regardless of what the string itself contains.</summary>
// 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);
}
/// <summary>Appends a count-prefixed list of length-prefixed strings, sorted by code point so the digest does not depend on wire order.</summary>
private static void AppendList(StringBuilder sb, IReadOnlyList<string>? values)
{
var ordered = (values ?? Array.Empty<string>()).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);
}
/// <summary>
/// Reads exactly <paramref name="count"/> length-prefixed fields written by
/// <see cref="AppendString"/> back out of <paramref name="bytes"/>, 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.
/// </summary>
private static List<string>? ReadLengthPrefixedFields(byte[] bytes, int count)
{
var fields = new List<string>(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;
}
}
/// <summary>
/// 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.
/// </summary>
public sealed class CultNetCodePointComparer : IComparer<string>
{
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;
}
}
}

View file

@ -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;
}
/// <inheritdoc />

View file

@ -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<FixtureCiter>();
var a1 = new CultRecordRef<FixtureLeafA>(new CultRecordKey("leaf-a-1"));
var a2 = new CultRecordRef<FixtureLeafA>(new CultRecordKey("leaf-a-2"));
var document = new FixtureCiter
{
Components = new Dictionary<CultRecordRef<FixtureLeafA>, 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<FixtureCiter>();
var document = new FixtureCiter
{
Related = new List<CultRecordRef<FixtureLeafA>>
{
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<FixtureCiter>();
var document = new FixtureCiter { Design = new CultRecordRef<FixtureMiddle>(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<FixtureCiter>();
var designMember = citer.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureCiter.Design));
Assert.That(designMember.TargetType, Is.EqualTo(typeof(FixtureMiddle)));
Assert.That(typeof(FixtureMiddle).GetCustomAttribute<CultDocumentAttribute>(), 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<FixtureCiter>();
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<FixtureLeafB>();
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<FixtureLeafA>();
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<FixtureLeafA>();
Assert.That(leafA.TryGetIndexValue(new FixtureLeafA(), "not_declared", out _), Is.False);
}
// S22: IsNumeric is derived from the CLR set (sbyte..decimal, Nullable<T> 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<FixtureLeafB>();
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<FixtureLeafA>();
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<FixtureLeafNumbers>();
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<FixtureLeafNumbers>();
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<FixtureLeafNumbers>();
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<FixtureLeafNumbers>();
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<InvalidOperationException>(() => 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<FixtureLeafA>), Key(1), IndexAttr("Owner"));
var message = Assert.Throws<InvalidOperationException>(() => 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<int>), Key(0), ReferenceAttr(null, many: true));
var message = Assert.Throws<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<FixtureMiddle> Design;
[Key(2)]
[CultReference(typeof(FixtureLeafA), many: true)]
public Dictionary<CultRecordRef<FixtureLeafA>, float> Components = new();
[Key(3)]
[CultReference(typeof(FixtureLeafA), many: true)]
public List<CultRecordRef<FixtureLeafA>> Related = new();
}
}
}

View file

@ -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");

View file

@ -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<string>() }
};
var ex = Assert.ThrowsAsync<CultNetSelectionInvalidException>(() =>
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<CultNetSelectionInvalidException>(() =>
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"));
}
}

View file

@ -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<MeshNoteAliasDocument>(
_ => Task.FromResult(new CultNetSnapshotResponseRawMessage
{
MessageId = "case-variant-key",
Documents = new[]
{
new CultNetRawDocumentRecord
{
SchemaId = CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().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<MeshNoteDocument>().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<MeshNoteDocument>(CultDocumentRegistry.Shared));
var requests = new List<CultNetSnapshotRequestMessage>();
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<CultNetSelectionInvalidException>(
() => session.FetchDocumentsAsync<MeshNoteDocument>(recordKeys: Array.Empty<string>()));
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))
},

View file

@ -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));
}
}
}

View file

@ -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>(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<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(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");
}
}
}

View file

@ -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<string>() }, descriptors, "schemas");
AssertInvalid(new CultNetSelection { Keys = Array.Empty<string>() }, 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<string>() } } },
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<CultDocumentDescriptor> descriptors, string expectedField)
{
var ex = Assert.Throws<CultNetSelectionInvalidException>(() => 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>();
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<CultNetSelectionCursorException>()
.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<CultNetSelectionCursorException>()
.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<CultNetSelectionInvalidException>(
() => new CultNetSelection { Schemas = Array.Empty<string>() }.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<CultNetSelectionCursorException>(
() => 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<CultNetSelectionCursorException>(
() => 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<SelLeafA>(new CultRecordKey("b")) };
var narrowRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(SelLeafA), typeof(SelLeafB), typeof(SelCiterNarrow) });
var rows = new[]
{
new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired<SelLeafB>(), new CultRecordKey("b"), leafB, 1),
new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired<SelCiterNarrow>(), new CultRecordKey("bad"), offTargetCiter, 2)
};
var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = "NarrowRef", Exists = true } };
var ex = Assert.Throws<CultNetSelectionReferenceOutsideTargetException>(
() => 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<SelFixtureMiddle>(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<SelLeafA>().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<SelLeafA>().SchemaId, RecordKey = "target" }, Role = "NotARole" }
};
// Validate refuses an undeclared role before evaluation ever runs (S1).
Assert.Throws<CultNetSelectionInvalidException>(() => 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<SelFixtureMiddle>(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<SelLeafA>().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<SelLeafA>().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<SelLeafA>().SchemaId, registry.GetRequired<SelLeafB>().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<SelFixtureMiddle>(new CultRecordKey("b")) };
var refA = registry.GetRequired<SelLeafA>();
var offTargetCiter = new SelCiterNarrow { Name = "bad", NarrowRef = new CultRecordRef<SelLeafA>(new CultRecordKey("b")) };
var narrowRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(SelLeafA), typeof(SelLeafB), typeof(SelCiterNarrow) });
var rows = new[]
{
new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired<SelLeafB>(), new CultRecordKey("b"), leafB, 1),
new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired<SelCiterNarrow>(), new CultRecordKey("bad"), offTargetCiter, 2)
};
var selection = new CultNetSelection
{
Cited = new CultNetIncoming { Role = "NarrowRef", Exists = true }
};
Assert.Throws<CultNetSelectionReferenceOutsideTargetException>(
() => 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<T> 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<SelLeafA> 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<SelLeafA>(new CultRecordKey("a")) }
};
var offTargetCiter = new SelCiterManyUntyped
{
Name = "bad",
ManyRefs = new[] { new CultRecordRef<SelLeafA>(new CultRecordKey("b")) }
};
var okRows = new[]
{
new CultNetSelectionEvaluator.Row(registry.GetRequired<SelLeafA>(), new CultRecordKey("a"), leafA, 1),
new CultNetSelectionEvaluator.Row(registry.GetRequired<SelCiterManyUntyped>(), 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<SelLeafB>(), new CultRecordKey("b"), leafB, 1),
new CultNetSelectionEvaluator.Row(registry.GetRequired<SelCiterManyUntyped>(), new CultRecordKey("bad"), offTargetCiter, 2)
};
Assert.Throws<CultNetSelectionReferenceOutsideTargetException>(
() => 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<SelCiter>().SchemaId },
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "x" } } }
};
var ex = Assert.Throws<CultNetSelectionInvalidException>(() => 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<SelFixtureMiddle> 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<SelLeafA> 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<SelLeafA> element alone (R-T(b)).
[Key(1)]
[CultReference(many: true)]
public CultRecordRef<SelLeafA>[] ManyRefs = Array.Empty<CultRecordRef<SelLeafA>>();
}
}
}

View file

@ -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<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(new CultRecordKey("target")) };
var citerA = new CultNetSelectionEvaluatorTests.SelCiter { Name = "a", Design = new CultRecordRef<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(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<CultNetSelectionEvaluatorTests.SelLeafA>().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<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(new CultRecordKey("target")) };
var rows = new[] { Row(registry, target, "target", 1), Row(registry, citer, "citer", 2) };
var alias = registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafA>().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<CultNetSelectionInvalidException>(() => 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<CultNetSelectionEvaluatorTests.SelCiter>().SchemaId;
var leafAlias = registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafA>().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<string>() };
Assert.Throws<CultNetSelectionInvalidException>(() => 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<CultNetSelectionCursorException>(
() => 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<CultNetSelectionCursorException>(
() => 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<CultNetSelectionCursorException>(
() => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: key));
Assert.That(ex!.Code, Is.EqualTo("cursor_invalid"));
}
/// <summary>
/// Builds a cursor string carrying an arbitrary body under a caller-supplied digest, the same
/// wire shape <see cref="CultNetSelectionCursor.Mint"/> produces - used to simulate a forged
/// cursor that reuses a legitimately minted digest under a rewritten body (R-Y).
/// </summary>
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);
}
/// <summary>
/// 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 <c>ReadLengthPrefixedFields</c> reaches the hostile one mid-loop rather
/// than failing earlier for an unrelated reason.
/// </summary>
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<CultNetSelectionCursorException>(() => 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<CultNetSelectionCursorException>(() => 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<CultNetSelectionCursorException>(() => 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<CultNetSelectionCursorException>(() => 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<CultNetSelectionEvaluatorTests.SelLeafA>();
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<CultNetSelectionEvaluatorTests.SelLeafA>(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<CultNetSelectionEvaluatorTests.SelLeafA>().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<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(new CultRecordKey("dup"))
}, "citer", 2);
var rows = reverseRowOrder
? new[] { citer, leafB, leafA }
: new[] { citer, leafA, leafB };
var expectedFirstSchemaId = reverseRowOrder
? registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafB>().SchemaId
: registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafA>().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<CultNetSelectionEvaluatorTests.SelLeafA>(new CultRecordKey("a")),
new CultRecordRef<CultNetSelectionEvaluatorTests.SelLeafA>(new CultRecordKey("b"))
}
}, "citer", 2);
var rows = new[] { leafA, leafB, citer };
var selection = new CultNetSelection
{
Cites = new CultNetCitation
{
Target = new CultNetRecordRef { SchemaId = registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafA>().SchemaId, RecordKey = "does-not-exist" },
Role = "ManyRefs"
}
};
var ex = Assert.Throws<CultNetSelectionReferenceOutsideTargetException>(
() => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1));
Assert.That(ex!.ToSchemaId, Is.EqualTo(registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafB>().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<CultNetSelectionEvaluatorTests.SelLeafA>), 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<string, Func<object, string>>(),
members,
richMembers
});
var referenceMembersMethod = typeof(CultNetSelectionEvaluator)
.GetMethod("ReferenceMembers", BindingFlags.NonPublic | BindingFlags.Static)!;
var yielded = ((IEnumerable<CultDocumentMemberView>)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<CultNetSelectionEvaluatorTests.SelLeafA> 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<SoulSelfCiter>[] Peers = Array.Empty<CultRecordRef<SoulSelfCiter>>();
}
// 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<SoulSelfCiter>();
var alpha = new SoulSelfCiter
{
Name = "alpha",
Peers = new[]
{
new CultRecordRef<SoulSelfCiter>(new CultRecordKey("alpha")),
new CultRecordRef<SoulSelfCiter>(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<CultNetSnapshotRequestV1Message, CultNetServerPeer, Task>)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<ArgumentNullException>(),
"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<CultNetShardCatalogRequestMessage>((_, _) =>
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<string> 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) { }
}
}
}

View file

@ -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<PageFixtureLeaf>(cache.Registry))
.Register(CultNetDocumentBinding.ForDocument<PageFixtureCiter>(cache.Registry));
return (cache, documents);
}
private static async Task<CultRecordKey> AddLeafAsync(CultCache cache, string key, string kind)
{
var recordKey = new CultRecordKey(key);
await cache.AddAsync(
new PageFixtureLeaf { Name = key, Kind = kind },
new CultRecordHandle<PageFixtureLeaf>(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<CultRecordRef<PageFixtureLeaf>, float> { [new CultRecordRef<PageFixtureLeaf>(target)] = 2.5f } },
new CultRecordHandle<PageFixtureCiter>(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<CultRecordRef<PageFixtureLeaf>, float> { [new CultRecordRef<PageFixtureLeaf>(target)] = 2.5f } },
new CultRecordHandle<PageFixtureCiter>(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<CultRecordRef<PageFixtureLeaf>, float> Components = new();
}
}
}

View file

@ -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<CultNetSelectionInvalidException>(() => 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<CultNetSelectionInvalidException>(() => 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<CultNetSelectionInvalidException>(() => 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<SurvivorAliasTarget>(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<SurvivorAliasTarget>().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<SurvivorAliasTarget>(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<SurvivorAliasTarget> 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<CultNetSelectionEvaluatorTests.SelLeafA>().SchemaId;
var schemaB = registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafB>().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<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(new CultRecordKey("target"))
};
var citerAlt = new SurvivorAltCiter { Name = "alt", Design = new CultRecordRef<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(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<CultNetSelectionEvaluatorTests.SelCiter>().SchemaId;
var schemaAlt = registry.GetRequired<SurvivorAltCiter>().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<CultNetSelectionEvaluatorTests.SelLeafA>().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<SurvivorAliasTarget>(new CultRecordKey("target")),
RefA = new CultRecordRef<SurvivorAliasTarget>(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<SurvivorAliasTarget>().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<SurvivorAliasTarget>(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<SurvivorAliasTarget>(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<SurvivorAliasTarget> 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<CultNetSelectionEvaluatorTests.SelFixtureMiddle> 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<SurvivorAliasTarget> RefZ;
[Key(2)]
[CultIndex("aFirstRole")]
[CultReference(typeof(SurvivorAliasTarget))]
public CultRecordRef<SurvivorAliasTarget> 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<string>? values)
{
var ordered = (values ?? Array.Empty<string>()).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<CultNetFieldPredicate>();
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<CultNetSelectionCursorException>(() => 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<CultNetSelectionCursorException>(() => 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>();
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<CultNetSelectionEvaluatorTests.SelLeafA>().SchemaId;
var schemaB = registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafB>().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));
}
}
}

View file

@ -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
{
/// <summary>
/// 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 <see cref="ImplementedKeywords"/>/<see cref="MetadataKeywords"/> and
/// <see cref="CheckSupportedKeywords"/>. 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.
/// </summary>
internal sealed class MiniJsonSchemaValidator
{
// Keywords this validator gives real constraint semantics to.
private static readonly HashSet<string> 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<string> MetadataKeywords = new(StringComparer.Ordinal)
{
"$schema", "$id", "$defs", "title", "description", "default",
"contentEncoding", "x-cult-runtime-type"
};
private readonly string _schemaDir;
private readonly Dictionary<string, JsonElement> _docCache = new(StringComparer.Ordinal);
public MiniJsonSchemaValidator(string schemaDir)
{
_schemaDir = schemaDir;
}
/// <summary>Validates <paramref name="instance"/> against the named schema file's root schema. Throws with every collected error when invalid.</summary>
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)}");
}
}
/// <summary>Validates against a $defs entry of the named schema file, e.g. "cultnet.selection.schema.json#/$defs/fieldPredicate".</summary>
public List<string> Validate(JsonElement instance, string refText)
{
var errors = new List<string>();
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<string> 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<string>();
foreach (var branch in oneOf.EnumerateArray())
{
var local = new List<string>();
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<string>();
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<string>(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);
}
}
}
}
/// <summary>
/// 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.
/// </summary>
private static void CheckSupportedKeywords(JsonElement schema, string path, List<string> 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()
};
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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<T> 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<string, string> Fields,
IReadOnlyList<FixtureReference> References);
private static string FixturePath() =>
Path.Combine(RepoRoot(), "contracts", "cultnet", "interop", "selection-vectors.fixture.json");
private static (IReadOnlyDictionary<string, FixtureSchema> Schemas, IReadOnlyList<FixtureRow> Rows) LoadFixture()
{
using var document = JsonDocument.Parse(File.ReadAllText(FixturePath()));
var root = document.RootElement;
var schemas = new Dictionary<string, FixtureSchema>(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<FixtureRow>();
foreach (var row in root.GetProperty("rows").EnumerateArray())
{
var fields = new Dictionary<string, string>(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<FixtureReference>();
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<ParityMiddle>(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<ParityLeafA>(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<string, FixtureSchema> 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<string, FixtureSchema> 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<string, FixtureSchema> 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<string, FixtureSchema> schemas)
{
yield return ("refuses_empty_schemas_list", new CultNetSelection { Schemas = Array.Empty<string>() }, "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<string>();
foreach (var vector in vectors.EnumerateArray())
{
var name = vector.GetProperty("name").GetString();
var selectionBytes = Convert.FromBase64String(vector.GetProperty("selectionMessagePackBase64").GetString()!);
var selection = MessagePackSerializer.Deserialize<CultNetSelection>(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<object>();
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<CultNetSelectionInvalidException>(
() => 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<string>(),
matched = 0U,
hasNext = false,
expectedEdges = Array.Empty<object>()
});
}
// 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<CultNetSelectionReferenceOutsideTargetException>(
() => 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<string>(),
matched = 0U,
hasNext = false,
expectedEdges = Array.Empty<object>()
});
}
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<ParityMiddle> 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<ParityLeafA> NarrowRef;
}
}
}

View file

@ -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<String>` 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<String> 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");
}
}
}