CultNet selection cut1 fix batch: R-L (contract, Python, mutate-dotnet.ps1)

- cultnet.selection.schema.json: schemas/keys gain minItems: 1 (present
  and empty is refused at the door, never "every schema/key" - omit the
  field instead); limit documents that it is clamped 1..=200 by the
  answering server rather than refused outside that range.
- cultnet_py/interop_peer.py: handle_server_message refused only an
  exact ".v1" suffix, so a v2+ selection message fell through to a
  silent empty-list answer instead of a loud refusal. is_unsupported_cultnet_version
  matches any cultnet.*.vN with N > 0 instead. test_cultnet.py's R-2
  test now covers v2 and v10, not only v1.
- mutate-dotnet.ps1: an entry's File (from -Entries, often written with
  "/") is looked up against the $targets dictionary keyed by -Target's
  own spelling; a "\\" vs "/" mismatch didn't throw at the lookup -
  $targets[$file] silently returned $null - so the crash surfaced one
  call later as a null-argument error inside a .NET path API instead of
  as "unknown target". Both -Target and every File read from an entry
  now normalise through Normalize-TargetPath before indexing $targets.

Discrepancy: the ruling names "both R-2 sites" for the Python fix; only
one site with this exact narrow-match bug exists in packages/cultnet-py
and packages/cultmesh-py (cultmesh_py/server.py's handle_message already
refuses every unrecognised schemaVersion, v1+ included, through its own
default branch). Fixed the one that exists; did not invent a second.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 09:39:53 +02:00
commit c01bc0a129
4 changed files with 52 additions and 12 deletions

View file

@ -7,11 +7,15 @@
"properties": { "properties": {
"schemas": { "schemas": {
"type": "array", "type": "array",
"items": { "type": "string", "minLength": 1 } "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": { "keys": {
"type": "array", "type": "array",
"items": { "type": "string", "minLength": 1 } "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": { "fields": {
"type": "array", "type": "array",
@ -25,7 +29,12 @@
"default": "header" "default": "header"
}, },
"descending": { "type": "boolean", "default": false }, "descending": { "type": "boolean", "default": false },
"limit": { "type": "integer", "minimum": 1, "maximum": 200 }, "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" } "cursor": { "type": "string" }
}, },
"$defs": { "$defs": {

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import argparse import argparse
from collections import defaultdict from collections import defaultdict
import json import json
import re
import selectors import selectors
import signal import signal
import socket import socket
@ -384,14 +385,27 @@ def handle_server_message(state: PeerState, message: dict[str, Any], subscriptio
return [state.verse_catalog.create_response(message)] return [state.verse_catalog.create_response(message)]
if schema_version == PEER_EXCHANGE_REQUEST: if schema_version == PEER_EXCHANGE_REQUEST:
return [state.peer_catalog.create_response(message)] return [state.peer_catalog.create_response(message)]
if isinstance(schema_version, str) and schema_version.startswith("cultnet.") and schema_version.endswith(".v1"): if is_unsupported_cultnet_version(schema_version):
# R-2, owed by Cut 1 (docs/cultnet-selection-cut.md, section 9): this peer speaks # R-2, owed by Cut 1 (docs/cultnet-selection-cut.md, section 9): this peer speaks
# cultnet.selection.v1's carrier messages (snapshot_request.v1, database_subscribe.v1) not at # cultnet.selection.v1's carrier messages (snapshot_request.v1, database_subscribe.v1, and
# all - it is v0-only. A v1 message is refused loudly, not answered with a silent empty list. # 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 [unsupported_schema_version_error(message)]
return [] 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]: def unsupported_schema_version_error(message: dict[str, Any]) -> dict[str, Any]:
schema_version = message.get("schemaVersion") schema_version = message.get("schemaVersion")
return { return {

View file

@ -1954,11 +1954,19 @@ class CultNetTests(unittest.TestCase):
self.assertEqual([record["recordKey"] for record in response["documents"]], ["note:logged"]) self.assertEqual([record["recordKey"] for record in response["documents"]], ["note:logged"])
def test_python_interop_peer_refuses_v1_selection_messages(self) -> None: def test_python_interop_peer_refuses_v1_selection_messages(self) -> None:
# R-2, owed by Cut 1 (docs/cultnet-selection-cut.md, section 9): a v0-only peer refuses a v1 # R-2, owed by Cut 1 (docs/cultnet-selection-cut.md, section 9): a v0-only peer refuses any
# message loudly - cultnet.error.v0 { code: "unsupported_schema_version" } - rather than # 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 # silently answering with an empty list. handle_server_message never touches `state` before
# this refusal fires, so no PeerState fixture is needed here. # this refusal fires, so no PeerState fixture is needed here. R-L: the original check matched
for schema_version in ("cultnet.snapshot_request.v1", "cultnet.database_subscribe.v1"): # 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): with self.subTest(schema_version=schema_version):
responses = handle_server_message( responses = handle_server_message(
None, # type: ignore[arg-type] None, # type: ignore[arg-type]

View file

@ -42,6 +42,15 @@ param(
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
# R-L: an entry's `File` (from -Entries, often written with forward slashes) is looked up against the
# $targets dictionary keyed by -Target's own spelling. A bare string-key mismatch ("a\b.cs" vs
# "a/b.cs") doesn't throw at the lookup - $targets[$file] just silently returns $null - so the crash
# happens one call later, deep inside a .NET path/string API, as a null-argument error that reads
# nothing like "unknown target". Normalizing both sides to '/' up front turns that into either a
# clean match or an honest "Target ... does not exist" at line 99, never a null-argument surprise.
function Normalize-TargetPath([string]$path) { $path -replace '\\', '/' }
$Target = @($Target | ForEach-Object { Normalize-TargetPath $_ })
$repo = if ($Repo) { (Resolve-Path -LiteralPath $Repo).Path } else { (Resolve-Path (Join-Path $PSScriptRoot '..')).Path } $repo = if ($Repo) { (Resolve-Path -LiteralPath $Repo).Path } else { (Resolve-Path (Join-Path $PSScriptRoot '..')).Path }
$testProjectPath = Join-Path $repo $TestProject $testProjectPath = Join-Path $repo $TestProject
$utf8 = [System.Text.UTF8Encoding]::new($false) $utf8 = [System.Text.UTF8Encoding]::new($false)
@ -165,11 +174,11 @@ foreach ($mutation in $mutations) {
Write-Host "--- $($mutation.Id) ($($mutation.Mutant)): $($mutation.Rule)" Write-Host "--- $($mutation.Id) ($($mutation.Mutant)): $($mutation.Rule)"
$texts = @{} $texts = @{}
$files = @($edits | ForEach-Object { if ($_.File) { $_.File } elseif ($Target.Count -eq 1) { $Target[0] } else { throw "$($mutation.Id): an edit names no File and there is more than one target." } } | Select-Object -Unique) $files = @($edits | ForEach-Object { if ($_.File) { Normalize-TargetPath $_.File } elseif ($Target.Count -eq 1) { $Target[0] } else { throw "$($mutation.Id): an edit names no File and there is more than one target." } } | Select-Object -Unique)
foreach ($file in $files) { $texts[$file] = $utf8.GetString($originalBytes[$file]) } foreach ($file in $files) { $texts[$file] = $utf8.GetString($originalBytes[$file]) }
foreach ($edit in $edits) { foreach ($edit in $edits) {
$file = if ($edit.File) { $edit.File } else { $Target[0] } $file = if ($edit.File) { Normalize-TargetPath $edit.File } else { $Target[0] }
$text = $texts[$file] $text = $texts[$file]
$eol = if ($text.Contains("`r`n")) { "`r`n" } else { "`n" } $eol = if ($text.Contains("`r`n")) { "`r`n" } else { "`n" }
$old = ($edit.Old -replace "`r`n", "`n") $old = ($edit.Old -replace "`r`n", "`n")