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": {
"schemas": {
"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": {
"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": {
"type": "array",
@ -25,7 +29,12 @@
"default": "header"
},
"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" }
},
"$defs": {

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,14 +385,27 @@ def handle_server_message(state: PeerState, message: dict[str, Any], subscriptio
return [state.verse_catalog.create_response(message)]
if schema_version == PEER_EXCHANGE_REQUEST:
return [state.peer_catalog.create_response(message)]
if isinstance(schema_version, str) and schema_version.startswith("cultnet.") and schema_version.endswith(".v1"):
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) not at
# all - it is v0-only. A v1 message is refused loudly, not answered with a silent empty list.
# 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 {

View file

@ -1954,11 +1954,19 @@ class CultNetTests(unittest.TestCase):
self.assertEqual([record["recordKey"] for record in response["documents"]], ["note:logged"])
def test_python_interop_peer_refuses_v1_selection_messages(self) -> None:
# R-2, owed by Cut 1 (docs/cultnet-selection-cut.md, section 9): a v0-only peer refuses a v1
# message loudly - cultnet.error.v0 { code: "unsupported_schema_version" } - rather than
# 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.
for schema_version in ("cultnet.snapshot_request.v1", "cultnet.database_subscribe.v1"):
# 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]

View file

@ -42,6 +42,15 @@ param(
$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 }
$testProjectPath = Join-Path $repo $TestProject
$utf8 = [System.Text.UTF8Encoding]::new($false)
@ -165,11 +174,11 @@ foreach ($mutation in $mutations) {
Write-Host "--- $($mutation.Id) ($($mutation.Mutant)): $($mutation.Rule)"
$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 ($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]
$eol = if ($text.Contains("`r`n")) { "`r`n" } else { "`n" }
$old = ($edit.Old -replace "`r`n", "`n")