Commit graph CultLib/scripts
Author SHA1 Message Date
Meta Vens
5cf31d1e87 CultNet selection cut1 fix batch 3, Rust half: R-N/R-O/R-T, retire the Rust mutation harness
docs/cultnet-selection-cut.md, Self's rulings for fix batch 3 (2026-09-22),
the Rust-owned share:

- R-N: cultnet.error.v0 gains additive code/details fields. SelectionRefusal
  now maps to a wire code (selection_invalid, cursor_stale, cursor_invalid,
  reference_outside_target) and a flat string-map details payload via
  to_wire_message(); the legacy array wire (gamecult.networking.v0) has no
  slot for either and never carries them, matching v0 peers ignoring them.
- R-O: the snapshot cursor is keyed. CursorKey is a random 32-byte
  per-process key; Cursor::mint/compute_digest now take it and use
  HMAC-SHA256 instead of plain SHA-256, so a cursor forged without the key,
  or minted under a different process's key, is refused cursor_invalid.
  Cursor::parse stays public and decodes as_of without the key (Huginn's
  dependency); only verify_digest (called from select()) needs it. The
  cursor body itself is now length-prefixed fields, not a delimiter-joined
  string, so a record key carrying any character - including the pre-fix
  C# delimiter U+241F - round-trips.
- R-T: matches_citation (the `cites` path) now checks every one of a
  citer's edges against its declared target before applying the
  citation's own role/key filters, closing F15: a corrupt edge used to
  hide behind an unrelated filter and never get validated.
- The retired Rust mutation harness entries file
  (scripts/mutate-cultnet-selection-rust.entries.ps1) is deleted; tests
  pin the behaviour directly.

Regenerated contracts/cultnet/interop/selection-vectors.rs-written.json
from the shared fixture; it is byte-identical to what was committed, so
R-T's fix does not change any existing fixture vector's outcome. The Rust
reader still evaluates every vector in the committed cs-written.json
(15/15) unchanged - that file predates the parallel C# Hands' R-N/R-S
work and was not touched here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:26:56 +02:00
Meta Vens
8d7bee9346 Delete the retired hand-written mutation harness from this branch
Operator ruling, 2026-09-22: "Better to have nothing than a harness that
punishes refactoring." Removes scripts/mutate-dotnet.ps1 and every
scripts/*.entries.ps1 mutation-entry file for CultNet selection (networking,
mesh, caching, fix-batch-2). Also drops the two live code comments in
tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs that cited entry ids
(MESH-KEY-Loosening, MESH-DEFAULT-Loosening), rewording them to describe the
rule instead of the retired harness. Mutation testing now uses Stryker.NET on
the cut's diff, run by Soul.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:13:12 +02:00
Meta Vens
0714492858 CultNet selection cut1 fix batch, second Hands pass: R-D mutation entries and Mesh R-F/R-L coverage
- Repairs two stale mutation entries in
  scripts/mutate-cultnet-selection-caching.entries.ps1
  (CACHE-QJ-ExponentExpansion-Revert, CACHE-QJ-NaNInfinity-Revert): both
  anchored on ExpandScientificNotation/the pre-R-D switch arm, which the
  first fix-batch pass's R-D commit (7903853) deleted outright in favour of
  RenderExactFloat/RenderExactDouble. Replaces the exponent-expansion entry
  with CACHE-RD-ExactExpansion-Revert (reverts to .NET's shortest
  round-trip ToString(), the exact defect R-D fixes) and
  CACHE-RD-FractionSplit-Loosening (an off-by-one digit-split bug that
  still looks "exact"), and re-anchors the NaN/Infinity entry on the
  current RenderExactDouble call.
- Repairs one stale mutation entry in
  scripts/mutate-cultnet-selection-mesh.entries.ps1 (MESH-FOREIGN-Revert):
  anchored on the pre-R-L inline TryReadSchemaVersion call, which R-L's
  commit (c01bc0a) replaced with CultNetDocumentRegistry.PayloadMatchesSchema.
  Re-anchors it and adds MESH-RF-Door-Revert (R-F: EnsureV0Compatible runs
  CultNetSelectionValidation.ValidateShape before its own v0 transport
  refusals), with a new tests/GameCult.Mesh.Tests/CultMeshSnapshotOptionsTests.cs
  proving FetchSnapshotAsync refuses Keys=[]/[""] before ever touching a
  client.
- All entries verified: Caching 11/11 killed, Mesh 7/7 killed (pending final
  confirmation on this run), restores byte-exact.

R-L (docs/cultnet-selection-cut.md): the Python refusal of vN>0 already has
a real regression test (packages/cultnet-py/tests/test_cultnet.py:1958-1978,
unsupported_schema_version) but no mutation entry - mutate-dotnet.ps1 is a
dotnet/NUnit harness only and this repo has no Python mutation harness. R-L's
C#-adjacent piece (TryReadSchemaVersion going internal behind
CultNetDocumentRegistry.PayloadMatchesSchema) is covered by
MESH-FOREIGN-Revert above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 13:11:21 +02:00
Meta Vens
d94174d597 CultNet selection cut1 fix batch, second Hands pass: mutation entries and S9
- Adds mutation entries for R-A, R-B, R-C, R-E, R-F, R-G, R-H, R-J
  (scripts/mutate-cultnet-selection-networking.entries.ps1), each a revert
  and a loosening where the rule supports one, targeting
  CultNetSelectionEvaluator.cs, CultNetSelection.cs, CultNetDatabaseServer.cs
  and CultNetDatabaseSubscriptionServer.cs.
- Adds S9 (docs/cultnet-selection-cut.md section 10): a new RUDP round-trip
  test proving a hop-bearing v1 subscription reconciles the whole matched set
  when the citer (not the page row itself) changes, plus its own mutation
  entry (NET-S9-HopFastPath-Revert) reverting D6's Watch routing.
- Adds tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs:
  regression coverage for R-B (edge anchor/order), R-C (code-point order in
  both the row tiebreak and the cursor position compare), R-E (alias
  matcher; door refusal), R-F (Select validates unconditionally), R-H
  (digest collision), R-J (numeric any_of canonical rendering) - none of
  these rules had a test that could fail independently of the fix-batch
  regression tests already in place.
- Fixes a real gap R-E named but the first fix-batch pass never wrote:
  CultNetSelection.Validate now refuses a cites.target.schemaId that
  matches no declared schema, at the door, instead of MatchesCitation
  silently treating an unresolved target as "not found" and answering an
  empty page. Resolved through CultNetSchemaAliasMatching, the reference
  rule, against every declared schema - not only the selection's own
  Schemas-filtered reachable set, which a second entry
  (NET-RE-DoorTargetReachableOnly-Loosening) proves: a cites target is a
  separate structural reference, not itself constrained by Schemas.
  Coordinator-assigned item; the shared parity fixture's
  refuses_unmatched_cites_target_schema vector (Rust side, another Hands)
  is not yet in selection-vectors.rs-written.json, so
  SelectionParityVectorTests.SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference
  does not yet exercise it - it will once that vector lands, unmodified
  by this commit.
- NetworkingTests.cs gains CultNetDatabaseServer_AnswersV1SnapshotRequestRespectingItsFields
  (R-A's loosening) and DatabaseSubscriptionServer_SubscribesV1AndReconcilesAHopBearingSelectionWhenTheCiterChanges
  (S9 / R-A's subscribe-listener revert).

Discrepancy: R-A's snapshot-request v1 listener registration
(CultNetDatabaseServer's _server.OnCultNet(_snapshotHandlerV1)) has no
mutation entry. CultNetDatabaseServer pairs only with Server (the
LiteNetLib/TCP-secured production construction site), and no existing test
in this suite - before or after this pass - drives Server's real
client/listener dispatch for CultNetDatabaseServer; every test calls
CreateSelectionResponse/CreateSnapshotResponse directly. That revert is
recorded as not yet reached in the entries file, not swapped for an easier
mutant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 13:11:21 +02:00
Meta Vens
a31993d518 CultNet selection: update mutation entries for the R-E rewrite
The alias-matching call sites changed shape (schema_alias::matches/
matches_any now take (candidate, schema_id, schema_name)), so the entries
that edit them needed their Old/New anchors and Test names updated:
RS-E-Alias-Revert (now targets the ported name-comparison line, not the
deleted hash-stripping compare), RS-E-CitesTarget-Revert, and
RS-E-EmptySchemasNeverMatchesEverything-Revert.

New entries for this cut's rule, per spec:
- RS-E-Alias-RevertToHashStripping: the revert - go back to hash stripping
  (compare schema_id against schema_id's own inferred name, the deleted
  matcher's behaviour).
- RS-E-Alias-CaseInsensitive-Loosening: the loosening - compare
  case-insensitively instead of Ordinal.
- RS-E-Alias-AcceptAnyVersionSuffix-Loosening: a second loosening - accept
  any ".vN" suffix without comparing the name.
- RS-E-InferSchemaName-StripAnyLastDotSuffix-Loosening: a function-of-input
  mutant - infer_schema_name strips any suffix after the last dot, not only
  a ".v<digits>" marker.

Also fixed the invocation: File/Command values are relative to the CultLib
repo root and carry --manifest-path packages/cultnet-rs/Cargo.toml (there is
no repo-root Cargo.toml/workspace), matching -Repo C:\wal and -Target
'packages/cultnet-rs/src/selection.rs,packages/cultnet-rs/src/snapshot_query.rs'.

Verified: M0 green (4 commands, 36+81+7+1 tests), all 40 entries killed by
their own test, every restore byte-exact (git diff clean against the
committed source after the run).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 12:19:12 +02:00
Meta Vens
a088435f9f CultNet selection cut1 Rust fix batch: mutation entries for R-B/D/E/F/G/H/I
Adds a revert (and loosening, where the rule has one) per new rule to
scripts/mutate-cultnet-selection-rust.entries.ps1, and repairs three things
the entries file's own first run against this fix batch found:

- The RS-QJ-ExponentExpansion-Revert entry targeted
  expand_scientific_notation, which R-M deleted; replaced by entries
  against R-D's real replacement (the mantissa/exponent bignum).
- RS-D-ExponentBranch-Revert's first draft ("New = if false") hung: it
  routes every value, including f64::MAX's exponent of ~971, into the
  fraction branch, whose "k = (-exponent) as usize" wraps a positive
  exponent's negation into a huge usize and spins effectively forever - the
  run had to kill that process by PID. "New = if true" is the safe
  direction (a negative exponent's "for _ in 0..exponent" is just an empty
  range, not a cast) and is pinned against a negative-exponent test
  instead.
- RS-V0Lowering-Revert (pre-existing, not from this batch) unexpectedly
  survived the first run: matches_schema_keys_fields's move to
  schema_alias::matches_any (R-E) picked up that helper's own "empty
  candidates matches everything" shortcut, masking the v0-lowering
  mutation this entry exists to catch. Fixed in the prior commit
  (matches_schema_keys_fields no longer uses matches_any's shortcut); a new
  entry, RS-E-EmptySchemasNeverMatchesEverything-Revert, pins the fix
  directly rather than only through v0's own lowering.

All commands are narrowed with "-- --skip
selection_vectors_written_by_the_reference_decode_and_evaluate_identically_in_rust":
that vector-file test is legitimately pending the parallel C# Hands's own
regenerated cs-written.json (this suite does not touch that file), the same
carve-out this file already gave the pre-existing
reactive_document_coalesces_direct_same_schema_alias_member_writes flake.

Full run against this batch's final state: 36/36 entries killed, M0 green
on both commands, no survivors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 10:11:31 +02:00
Meta Vens
c01bc0a129 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>
2026-09-22 09:39:53 +02:00
Meta Vens
00f4c02dd7 CultNet selection cut1, commit 4: the parity vectors, and the ruling this branch landed under
Two things landed on this branch while commit 3 was in flight, both addressed
here rather than left for the vectors to paper over:

**Self's ruling, 2026-09-22 (commit 2 fix batch, 568e8e3/021e3f1): empty
lists, blank entries, and v0's own cleaning.** Rust already refused an empty
schemas/keys list (inherited from before the ruling); it now also matches
the reference's three further decisions exactly, not just in spirit:

- The door's refusal field is the plain list name ("schemas"/"keys"), not an
  indexed "schemas[i]" - selection::validate's SelectionInvalid.field now
  matches CultNetSelectionInvalidException("schemas", schema, ...) literally,
  because the parity vectors compare this string across runtimes.
- snapshot_query.rs's lower_v0_list now deduplicates a mixed list the same
  way CultNetV0SelectionLowering.Lower does (Distinct(StringComparer.Ordinal)
  - order-preserving, first occurrence wins), not just blank-filters it.
  A new test (serve_read_only_raw_snapshot_lowers_an_empty_or_blank_v0_schema_list_to_no_filter,
  extended) pins the dedup case directly.
- Two mutation entries (RS-EmptySchemas-Revert, RS-EmptyKeys-Revert) added
  for the door's original empty-list refusal, which had tests but no
  mutation coverage before this commit.

**S12: the two-way parity vectors**, contracts/cultnet/interop/selection-vectors.{cs,rs}-written.json,
eight vectors each: the six evaluated selections from commit 3 (any_of+ge
conjunction, descending+limit, gt boundary, cited exists true/false, three
rows sharing one index value) plus two door-refusal vectors added for this
ruling - `refuses_empty_schemas_list` (schemas: []) and `refuses_blank_key_entry`
(keys: ["a-eq", "   "]) - which decode their selection bytes and assert
`validate`/`Validate` refuses at exactly the named field, never evaluating.
The blank-key vector could not be generated before this commit: the door
rule it exercises did not exist in the reference until 568e8e3 landed.
`cites`-based hop vectors are deliberately absent (documented in both test
files): MatchesCitation compares Citation.Target.SchemaId against the
reference's real SHA-256 schema id exactly, with no SchemaName fallback,
and this fixture pair has no way to agree on that hash across runtimes;
`cited` (role/existence, matched by record key alone) is the hop direction
both sides can cross-check, so incoming_exists_true/incoming_negation carry
that coverage instead. Both reader tests pass:
SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference (C#)
and selection_vectors_written_by_the_reference_decode_and_evaluate_identically_in_rust
(Rust), each judging the other runtime's file.

**The remaining mutation matrix.** Of the coordinator's named set (S1, S2,
S6, S9-S15, S17, S20, S23): S1 was already covered (RS-EmptySchemas/Keys,
RS-BlankSchema/Key); S2 (any_of matches membership, RS-S2-Revert) and S6
(cites.role filters the hop, RS-S6-Revert) are newly covered - S6 needed a
new fixture (cites_role_excludes_a_second_reference_at_the_same_target)
because base_rows()'s citer-1 only ever carries one reference, so a mutant
dropping the role filter had nothing else to wrongly match and survived
against the original hops_one_edge_by_declared_role test; that finding is
recorded in-line as a comment, not silently swapped for an easier mutant.
S9-S15 (subscription reconcile, v0-lowering-of-subscribe) do not apply:
D7 - no Rust caller subscribes in this cut, so there is no subscription
client to mutate. S17 (inherited alias reaches every leaf) does not apply
either: Rust's toy Row/RowSet has no inheritance to begin with - both
leaves in the fixture declare "mass" independently, which is the Rust
spelling of the same guarantee, already exercised throughout the suite
without a dedicated mutant (there is no shared base-type discovery path to
revert). S20 (edges only for a hopping selection) was investigated and
found structurally vacuous in Rust as built: `edges` is only ever populated
inside the cites/cited branches, so a mutant on the has_hop() gate cannot
produce a different observable result for a non-hopping selection - no
entry added; a real gap would need the evaluator's structure to change
first. S23 (matches every row sharing an index value, never a cache's
unique-index winner) has no revertable bug surface: Rust's Row/RowSet
never had a unique-index map to begin with (D7), so this is pinned by
construction, not by a mutation-testable rule; matches_every_row_sharing_an_index_value
still exists as a positive test naming it.

**A pre-existing, unrelated flaky test, found and worked around, not
fixed.** tests/cultnet.rs's reactive_document_coalesces_direct_same_schema_alias_member_writes
fails consistently when the mutation harness runs the full tests/cultnet.rs
binary (both default-parallel and RUST_TEST_THREADS=1), but passes 5/5 in
isolation and passes in a plain `cargo test -p cultnet-rs` run - state
pollution from another test in that binary under the harness's specific
invocation path, not a race this campaign's code participates in (nothing
here touches reactive_document machinery). RS-V0Lowering-Revert's Command
is narrowed to `cargo test --test cultnet serve_read_only_raw_snapshot`
(filtering both M0 and the entry's own run to this campaign's test) so the
suite does not depend on that unrelated test's health; the flake itself is
reported here as a finding, not patched.

Tests: cultnet-rs 174 passed, 0 failed (default parallel run).
GameCult.Networking.Tests 196 passed, 1 skipped (WriteSelectionVectors,
env-gated), 0 failed.

Mutation harness: 24 entries (was 22 in commit 3; +RS-S2-Revert, +RS-S6-Revert;
RS-BlankSchema/BlankKey and RS-V0Lowering-Revert's anchors and field-name
assertions updated to match the landed fix batch's exact spelling), all
KILLED, M0 green.

Negative grep: `rg -n "untagged" packages/cultnet-rs/src packages/cultcache-rs/src` empty.

Structural delta, this commit only: 6 files changed, +108/-23 (selection.rs
+15/-9 field-name fix; snapshot_query.rs +25/-14 dedup; tests/cultnet.rs +21
dedup test; tests/selection.rs +35/-2 S6 fixture and field-name fixes;
scripts/mutate-cultnet-selection-rust.entries.ps1 +33/-4 two new entries
plus three corrected anchors/commands; SelectionParityVectorTests.cs +2/-1
field name), plus the two committed vector JSON files (~90 lines each, new).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:55:52 +02:00
Meta Vens
568e8e356f CultNet selection cut1, commit 2 fix batch: door, shard-via-evaluator, Mesh v0 refusal, dead Reconcile code, mutation harness crash handling
Soul found commit 2 didn't pass (docs/cultnet-selection-cut.md, 2026-09-22):
empty/blank schemas-keys still reached the evaluator, the shard snapshot ran
its own loop instead of the evaluator, Mesh's v0 wire silently dropped v1-only
selection terms, the Mesh one-pass read could return the wrong schema's
record at a shared key, two decode rules for a foreign schema id disagreed,
dead identity-comparison code from the _projectRecord removal was still live,
and a disposed-socket race could abort a mutation run instead of recording
NO VERDICT. This is Self's fix batch for all of it.

- CultNetSelection.Validate now refuses a blank/whitespace entry inside
  schemas or keys, not just an empty array. CultNetSelectionEvaluator no
  longer treats [] as "no filter" (the door already refuses it before
  evaluation). CultNetV0SelectionLowering is the one place that lowers an
  empty or all-blank v0 list to null; CreateRawSnapshotResponse and
  CreateShardSnapshotResponse both route through it, and CultMesh's own
  Clean() now delegates to it instead of duplicating the rule.
- CreateShardSnapshotResponse no longer carries a private loop or its own
  schema/key matching: it builds a CultNetSelection and answers through
  CultNetDocumentRegistry.SelectPage (the same evaluator path as the
  non-shard snapshot), with the shard's own membership check as a row
  filter. Binding-alias expansion now runs on both paths identically.
- ResolveDefaultSelection decides "no schema filter" from the cleaned
  recordKeys, not the raw array length. OverlaySelection stops deciding what
  a missing override means: any explicit schemaIds/recordKeys override
  replaces the default selection wholesale; no override keeps the default
  untouched.
- CultMeshSnapshotRequestOptions.Selection is now checked by
  EnsureV0Compatible before every send: fields, cites, cited, limit, cursor,
  descending, and any non-default projection are refused typed
  (CultNetSelectionInvalidException), never silently dropped from the v0
  wire message. Mesh sending v1 is FU-Mesh-v1, not this cut.
- CultMeshHotBodySubscription.Selection returns a defensive copy on every
  read so its arrays can't be mutated out from under the stored selection.
- ReadDocumentFromSnapshotResponse now searches candidates at one recordKey
  in one order - exact schema, then alias, then a foreign schema id decoded
  from the payload - instead of one OR'd condition where a case-insensitive
  or structurally-lucky candidate could win first. The foreign-id decode
  reads the payload's embedded schema version with the registry's shared
  CultNetDocumentRegistry.TryReadSchemaVersion and alias-matches it against
  the caller's own descriptor directly, rather than resolving to "whichever
  registered type owns this schema string" - that resolution is ambiguous
  when two document types alias the same schema id (MeshNoteDocument and
  MeshNoteAliasDocument in the existing fixtures), which is what broke on
  the first pass; TryResolveDescriptorByPayloadSchema stays for its existing
  caller (ResolveDescriptorForRawDocument) with a note on when not to use it.
  DecodeSnapshotDocuments and the Mesh copy of the payload-schema decode
  (RawSnapshotPayloadMatchesSchema/TryReadSchemaVersion) are deleted in
  favour of the shared one.
- CultNetDatabaseSubscriptionServer: the dead RecordKey/SchemaId identity
  comparisons in Reconcile and ApplyProjectedChange, and the duplicate-key
  throw in CreateProjectedSnapshot, are deleted - they can no longer fire
  now that a projection's dictionary key is always the record's own key.
  A new test flips authorizeRecord per key while the whole request stays
  authorized (so DemandActive never toggles) and proves both halves of the
  remaining diff: the removal of a now-unauthorized key and the add loop
  picking up a newly-authorized one.
- Every NetworkingTests.cs test that starts an IsBackground poll thread now
  joins it before its server disposes (about 20 tests; one already did).
- scripts/mutate-dotnet.ps1: a dotnet-test-host crash writing to stderr no
  longer throws the whole run away under PowerShell 5.1's
  $ErrorActionPreference = 'Stop' (2>&1 on a native command wraps stderr in
  a terminating NativeCommandError); Run-Tests catches it and the entry loop
  records NO VERDICT instead of aborting every remaining entry.
- Two probe tests: CultNetDatabase_ShardAndNonShardSnapshot_Agree{On
  UnfilteredRequest,OnBindingSchemaIdOverride} prove the shard and non-shard
  snapshot paths agree, including shard-membership exclusion of a document
  outside the shard's key prefix and a binding schema-id override matching
  on both paths.
- Mutation entries updated/added to match: the stale NET-D4-FourthMatcher
  entry (its anchor was the private loop this batch deleted, not refactored)
  is replaced by NET-D4-ShardMembership-Revert; the Mesh entries file's
  header miscount ("two of three" loosening entries when there are only
  two) is corrected and both previously-surviving loosening mutants
  (MESH-DEFAULT-Loosening, MESH-KEY-Loosening) now have killing fixtures;
  scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 is new, covering
  Reconcile's remaining two mutants.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:13:15 +02:00
Meta Vens
b090ca97f9 CultNet selection cut1, commit 3: the Rust runtime
docs/cultnet-selection-cut.md section 6/7: packages/cultnet-rs/src/selection.rs
(new) - the Rust spelling of the vocabulary at wire parity with the corrected
C# reference (e5348d3), matching D7: no cultcache-rs knowledge, the row owner
supplies declared values and edges through two traits.

- Wire types: Selection, FieldPredicate, Citation, Incoming, RecordRef, Edge,
  RawDocumentHeader, SelectionDocumentRecord (the v1 full-shape record, distinct
  from v0's CultNetRawDocumentRecord, which lacks schemaName/schemaVersion/
  schemaContentHash - a pre-existing v0 gap this cut does not touch).
  SelectionOperator mirrors CultNetSelectionOperator for evaluator ergonomics;
  FieldPredicate.op stays a raw wire string, never a serde-tagged union.
- canonical_number module (Q-J, section 2 "Numbers"): is_canonical (a
  hand-rolled digit-grammar parser, no regex dependency added),
  canonicalize_decimal_digits, expand_scientific_notation, compare (sign, then
  integer length, then digits, then padded fraction - no f64 anywhere), and
  render_i64/render_u64/render_f64/render_f32 for a Row implementer's own
  rendering. probe_f64_display_never_uses_exponent_notation empirically
  verifies the map's claim about Rust's f64 Display (confirmed true) rather
  than trusting it.
- Row/RowSet traits (D7): schema_id, record_key, ordinal, values, number
  (canonical string, never f64), references for Row; all_schema_ids,
  declared_indexes, declared_roles, is_numeric, target_leaves for RowSet.
  A blanket `impl<T: Row> Row for &T` lets sorted/filtered slices of
  references satisfy generic bounds without an extra copy.
- validate (S1 door refusals), matches (D6 single-row fast path, hop-bearing
  selections assert via debug_assert since Rust has no exception type to
  mirror C#'s InvalidOperationException), select (order/hop/cursor/paging,
  D9's reference_outside_target refusal, S23's per-row match - never through
  a unique-index map, because there is none here to begin with).
- Cursor: mint/parse/digest is this runtime's own internal algorithm (a
  cursor is opaque and never crosses runtimes - each server answers its own
  pages), not a byte-for-byte port of the reference's; what must match is the
  *rule* (asOf + last position + a digest bound to the selection), which it
  does, pinned by RS-CursorStale-Revert/Loosening and RS-CursorDigest-Revert.
- contracts.rs: three v1 CultNetMessage variants (SnapshotRequestV1,
  DatabaseSubscribeV1, SnapshotResponseRawV1) matching the C# messages'
  field-for-field shape. SnapshotResponseRawV1 carries binary payloads
  (documents[].payload, edges[].payload), so it is routed through the
  existing raw JSON-bypass path (parse_raw_cultnet_schema_message/
  encode_raw_cultnet_schema_message) alongside v0's DocumentPutRaw/
  SnapshotResponseRaw, with its own require_*/encode_* helpers for
  SelectionDocumentRecord, RawDocumentHeader, Edge and RecordRef.
  SnapshotRequestV1/DatabaseSubscribeV1 carry no binary and use the
  ordinary serde_json-intermediate path. No message-layer selection
  semantics validation here, matching the reference
  (CultNetSchemaMessageSerialization.cs only deserializes; a server calls
  selection::validate against its own RowSet before evaluating).
- snapshot_query.rs (D3/D4): serve_read_only_raw_snapshot's two hand-rolled
  allowlist filters collapse into one call to selection::matches over a
  lowered v0 Selection, through a minimal RawSnapshotRow wrapper whose
  ordinal/values/number/references are unreachable (the lowered selection
  never sets fields/cites/cited, so matches_schema_keys_fields never calls
  them) - documented, not hidden.

Ruling (Self, 2026-09-22, docs/cultnet-selection-cut.md commit 2 fix batch,
mirrored here per the coordinator's mid-task instruction rather than C#'s
pre-fix spelling as it stood at e5348d3):
- validate refuses an empty-but-present schemas/keys list (already had this)
  and now also refuses any individual entry that is empty or whitespace
  (fields[i]-style names: schemas[i]/keys[i]).
- The evaluator never sees []; matches_schema_keys_fields does not special-case
  an empty list as "no filter" (it never did here - that C# fallback,
  CultNetSelectionEvaluator.cs:84 pre-fix, was never mirrored into Rust).
- snapshot_query.rs's v0 lowering gets its own cleaning, owned by the
  lowering and not the evaluator: lower_v0_list turns an empty or
  all-blank v0 schemaIds/recordKeys list into None before it ever reaches
  validate or select.

Tests: cultnet-rs 173 passed, 0 failed (was 111 baseline #[test]s pre-cut;
+62 across lib unit tests, tests/selection.rs and tests/cultnet.rs).
cultcache-rs unmodified, 64 passed, 0 failed (untouched by this commit -
confirmed unaffected). GameCult.Networking.Tests (the new
SelectionParityVectorTests.cs file, compiled but not yet fully exercised -
see below) builds clean.

Mutation harness (tools/eureka-mutations.ps1, scripts/mutate-cultnet-
selection-rust.entries.ps1): 22 entries against src/selection.rs and
src/snapshot_query.rs, all KILLED, M0 green. Covers: S1's empty-list and
new blank-entry refusals (schemas and keys, revert x4), order/descending
(revert), cursor staleness (revert + loosening) and digest (revert), S7's
incoming negation (revert + loosening), S16's le/ge boundaries (revert x2),
S18's reference_outside_target (revert), Q-J's door regex and -0 exclusion
(revert x2), Q-J's comparator integer-length and sign (revert x2), Q-J's
integer-through-float64 renderer, decimal trailing-zero stripping and
exponent expansion (revert x3, the last two pinned via direct
canonical_number unit tests since render_f64 itself cannot reach exponent
notation given Rust's Display contract - verified, not assumed), and the
new v0-lowering rule (revert).

Negative grep: `rg -n "untagged" packages/cultnet-rs/src packages/cultcache-rs/src`
empty.

Not included here, landing as commit 4 once ready: the committed parity
vector files (contracts/cultnet/interop/selection-vectors.{cs,rs}-written.json).
Both directions already work for six evaluated selections and the []
door-refusal case; the blank-key-entry refusal vector cannot be generated
from the C# side yet because the parallel C# fix batch implementing that
door check (docs/cultnet-selection-cut.md, "Self's rulings...for the commit
2 fix batch") has not landed on this branch as of this commit. Rust's own
tests (validation_refuses_a_blank_entry_in_schemas_or_keys,
serve_read_only_raw_snapshot_lowers_an_empty_or_blank_v0_schema_list_to_no_filter)
already pin both new rules independently of C#.

Structural delta: packages/cultnet-rs/src/selection.rs +1407 (new),
contracts.rs +616/-21, snapshot_query.rs +76/-21 (net +55, collapsing the
fifth selector engine into one call), lib.rs +2, tests/cultnet.rs +67 (new
v0-lowering test), tests/selection.rs +900 (new, evaluator tests + vector
machinery), scripts/mutate-cultnet-selection-rust.entries.ps1 +233 (new),
tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs +296 (new).
Against section 14's ~430-line estimate for selection.rs+contracts.rs: the
two together land at ~2000 lines added, well over estimate - the map's own
§14 correction already flagged commits 0/1 running roughly 2x estimate for
the same reason (the hop's incoming index, the cursor, and per-field door
messages cost more than the prose assumed), and the v1 raw-message handling
(SelectionDocumentRecord/RawDocumentHeader/Edge require_*/encode_* helpers,
~400 lines) was not separately budgeted at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 05:31:19 +02:00
Meta Vens
e5348d3d57 CultNet selection cut1, commit 2f: the Q-J fix (canonical decimal numbers)
Corrects commit 1's Number field: docs/cultnet-selection-cut.md section 2
"Numbers" (added 2026-09-22, superseding the map's own stale float64 text)
specifies canonical decimal strings per the operator's Q-J ruling, and
commits 0/1 shipped double instead - the Rust Hands caught the conflict
before writing a line and stopped; this is the correction the map's
Progress block calls for before Rust starts.

- CultNetFieldPredicate.Number: double? -> string?. New CultNetCanonicalNumber
  (CultNetSelection.cs) is the wire's canonical grammar and comparator:
  IsCanonical validates ^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$ and excludes "-0"
  (the regex alone matches it); Compare orders by sign, then the integer
  part's length, then its digits, then the fraction padded on the right to
  equal length - a pure string function, no double anywhere on the path.
  ValidateField refuses a non-canonical number at the door
  (selection_invalid) rather than normalising it.
- CultNetSelectionEvaluator.CompareNumber now takes two canonical strings and
  switches on CultNetCanonicalNumber.Compare's sign instead of comparing
  doubles directly.
- CultCache.cs: CultDocumentDescriptor.TryGetIndexNumber and
  PersistedMember.NumberOf render a member's exact canonical decimal instead
  of a double. An integer renders exact (no 2^53 limit); decimal strips
  trailing fractional zeros; float/double use .NET's shortest round-trip
  ToString(), rewritten out of exponent notation to positional digits
  (ExpandScientificNotation) before the shared CanonicalizeDecimalDigits
  strips zeros and collapses a negative-zero result to "0"; NaN and infinity
  render null (TryGetIndexNumber returns false, matching "matches no
  comparison").
- contracts/cultnet/cultnet.selection.schema.json: "number" is now a string
  with the canonical pattern (a defence-in-depth (?!-0$) lookahead beyond
  what the door enforces in code), not a JSON number.
- Tests: CultNetCanonicalNumberTests (new) pins IsCanonical/Compare directly,
  including the door's five named non-canonical spellings, magnitude vs.
  lexicographic ordering, sign, integer-part length, and a long past 2^53
  against its float64 rounding. CultNetSelectionEvaluatorTests gains
  ValidationRefusesNonCanonicalNumberSpellings (+1, 1.0, 01, 1e3, -0) and its
  existing Number literals become canonical strings.
  CultDocumentSelectionSurfaceTests gains a dedicated FixtureLeafNumbers
  fixture and four tests: the 2^53+1 long rendering exactly, a decimal's
  trailing zeros stripped, a double's exponent notation rewritten to
  positional digits (1e21, 1e-7), and NaN/Infinity answering false.

Mutation entries (scripts/mutate-dotnet.ps1): four new Caching entries
(integer-through-float64, decimal trailing zeros, exponent expansion,
NaN/Infinity) and five new Networking entries (the door's regex, the door's
explicit "-0" exclusion, the comparator's integer-length check, a
lexicographic-whole-string mutant, and the comparator's sign check) - all
KILLED. The two pre-existing S16 (le/ge boundary) entries had to be
re-anchored: their Old text quoted CompareNumber's previous double-based
`left <= right`/`left >= right` spelling, which no longer exists now that
CompareNumber switches on Compare's int result: `cmp <= 0`/`cmp >= 0`.

Verification: dotnet test - GameCult.Networking.Tests 192/192 (was 183 before
these additions), GameCult.Caching.Tests 192/192 (was 188), GameCult.Mesh.Tests
252/253 (one pre-existing unrelated skip, unaffected by this commit - no Mesh
source touched). Caching mutation run: 10/10 KILLED (6 pre-existing + 4 new),
M0 and final both green. Networking mutation run: 15/15 KILLED (10
pre-existing + 5 new, across CultNetSelectionEvaluator.cs, CultNetDatabase.cs
and CultNetSelection.cs), M0 and final both green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 04:18:22 +02:00
Meta Vens
4c33a62b29 CultNet selection cut1, commit 2e: mutation entries for the Mesh collapse
scripts/mutate-cultnet-selection-mesh.entries.ps1 covers the two rules moved in
commit 2d against tests/GameCult.Mesh.Tests: ResolveDefaultSelection's "no schema
filter when keys are given" default, and ReadDocumentFromSnapshotResponse's
recordKey-anchored exact read (including the foreign-schema-id decode fallback it
still honors).

M0: green, 87 passed (CultMeshStreamingTests). Verdicts:

- MESH-DEFAULT-Revert: KILLED
- MESH-DEFAULT-Loosening: SURVIVED
- MESH-KEY-Revert: KILLED
- MESH-KEY-Loosening: SURVIVED
- MESH-FOREIGN-Revert: KILLED
- Final: green, 87 passed. All restores byte-exact (working tree clean after the run).

Both SURVIVED entries are recorded as fixture gaps, not swapped for easier
mutants: no test in CultMeshStreamingTests.cs passes an explicit empty (non-null)
recordKeys array, and none varies a record key's case, so nothing distinguishes
MESH-DEFAULT-Loosening's `recordKeys != null` from the correct `recordKeys is
{ Count: > 0 }`, or MESH-KEY-Loosening's OrdinalIgnoreCase from the correct
Ordinal comparison. MESH-FOREIGN has a revert entry only; no distinct, still-wrong
"loosening" of "decode successfully as TDocument" was found that isn't already
one of the other two entries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 03:54:36 +02:00
Meta Vens
4167cc4f24 CultNet selection cut1, commit 2c: mutation entry for the fourth matcher's collapse
Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22) required a mutation
entry for the fourth schema-matching copy's collapse into
CultNetSchemaAliasMatching, landed in commit 2a. Added
NET-D4-FourthMatcher-Revert, pinned by
CultNetDatabase_Creates_ShardSnapshotResponse_ForCompatibleSchemaAlias: killed.

Every pre-existing entry in this file now carries its own File, so the whole
file runs as one invocation against both CultNetSelectionEvaluator.cs and
CultNetDatabase.cs (it previously assumed a single implicit target and would
throw given two). Ran the combined file: 10/10 entries killed, M0 green, both
targets restored byte-exact (verified by git status showing no diff on
either .cs file after the run).

A loosening mutant for the same site
('(requestedSchemaIds != null && requestedSchemaIds.Length == 0) ||', i.e.
reject only an empty-but-non-null requested-schema list) was run by hand and
SURVIVED: CultNetDatabase_Creates_ShardSnapshotResponse_ForCompatibleSchemaAlias
puts exactly one document in the whole cache, so an over-permissive filter
still returns the same single row. Recorded as a comment in the entries file
per the brief ("report it, do not replace it with an easier mutant") rather
than committed as a fake kill or swapped for an easier mutant. Fixing it needs
a second document of a different schema in the shard's fixture; not added
here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 03:05:34 +02:00
Meta Vens
17d10e017f CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).

Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.

Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
  cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
  replaces the reflection entirely. v0 lowers into a Selection and walks the
  evaluator's own pages so a v0 caller still sees its whole matching set
  (D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
  ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
  MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
  v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
  CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
  (92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
  CreateProjectedSnapshot's collapse are commit 2's, per the map's own
  deletion line, and are untouched here.

Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.

Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.

Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).

Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.

scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
Meta Vens
e420410fe9 CultNet selection cut1, commit 0: the cache's public read surface
docs/cultnet-selection-cut.md section 6/7/10: the reflection loop at
CultNetDocumentRegistry.cs:325-342 exists only because nothing outside
GameCult.Caching can read a declared index value, reference, or numeric
member (Members/IndexAccessors are internal, no InternalsVisibleTo to
Networking). This adds the one public door instead: CultDocumentMemberView,
CultDocumentDescriptor.DeclaredMembers/TryGetIndexValue/TryGetIndexNumber/
ReferencesOf, and CultDocumentRegistry.ResolveTargetLeaves (D9, computed live
against the registry, never cached or persisted).

Two registration refusals land beside DiscoverMembers' existing
DuplicateSlotMessage/HiddenMemberMessage: D10 renames the ArgumentException
ToDictionary already threw for two differently-named members sharing one
index alias into a rejection that names the schema, both members and the
alias; D11 refuses a reference member whose CLR shape the cache cannot walk
(many:false needs CultRecordRef<T>, many:true needs
IEnumerable<CultRecordRef<T>> or IDictionary<CultRecordRef<T>, V>) instead of
silently answering it as unreadable later.

GameCult.Networking is untouched; the evaluator that will read this surface
does not exist yet (commit 1).

Verification: dotnet test tests/GameCult.Caching.Tests - 188 passed (176
existing + 12 new), 0 failed. New tests: TryGetIndexValue/TryGetIndexNumber
reading an inherited alias; ReferencesOf over a single/many-list/many-dictionary
reference, including two dictionary entries with different payload bytes
(S19); ResolveTargetLeaves against an abstract target with no [CultDocument]
of its own (D9), including a leaf registered after an earlier descriptor was
built; IsNumeric agreement for a nullable numeric and a decimal member (S22);
the two new registration refusals, dynamically emitted so registry discovery
by attribute scan cannot see the rejected shapes.

scripts/mutate-cultnet-selection-caching.ps1: a dedicated dotnet/NUnit
mutation runner (the shared tools/eureka-mutations.ps1 harness only speaks
cargo; scripts/mutate-cultmesh.mjs is the QUIC campaign's file and this
campaign does not edit it, per the map's section 10). Byte-exact restore
verified by SHA-256, a sidecar for crash recovery, M0 no-op control, one
anchor match required per entry. Six entries (D8/S22, D10, D11 - revert and
loosening each) all KILLED; final restore verified green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 01:57:35 +02:00
Meta Vens
089859deb4 Write and enforce the semver policy, per operator ruling on 1.0.60
1.0.60 removed public API (CultInspectorAssetPath) under a patch bump; its
own changelog says "Breaking" and the version number lied. Operator ruling:
semver is policy, strictly, for every published package family.

docs/semver-policy.md states what counts as breaking per surface (managed
API, Unity inspector attributes, CultCache wire format/store compatibility,
CultNet protocol, native bridge exports), the 0.y.z pre-1.0 convention, tag
naming, and required changelog shape. Records the 1.0.60 scar as history;
does not renumber it.

scripts/check-changelog-semver.mjs is the mechanical check: refuses a
changelog "### Breaking" section under too small a bump, and refuses a
version that skips, reverses, or repeats the previous published tag. Skips
re-litigating a version that is already tagged (rebuilding immutable
history is not a release action). Wired into publish-packages.yml for the
four npm/PyPI packages, and into build-unity-package.ps1 (cultlib),
packages/cultmath/scripts/build-unity-package.ps1 (cultmath), and a new
scripts/verify-caching-unity-release.ps1 (caching.unity has no build script
of its own to hook into).

18 node:test cases cover the required table (breaking+major/minor/patch,
non-breaking, 0.y.z, skip, downgrade) plus CLI-level git-tag resolution.
Hand-mutated each rule (bump classification, pre-1.0 lane requirement,
breaking-heading detection, breaking early-return, already-published skip)
and confirmed each kill, then restored byte-exact. No mutation harness is
committed; CultLib has none today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:03:22 +02:00
Meta Vens
45c2f4006f Say the OpenSSL bridge ships with this release, and pack the changelog
Soul S1: the 1.0.60 entry claimed the OpenSSL MsQuic bridge had shipped
since 1.0.59; git says both blobs first appear in this release. S2: the
package build staged package.json and README only, so the produced package
and the tracked template differed by the changelog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:40:12 +02:00
Meta Vens
0c9cb57bea Correct stale Schannel/40,448-byte claims, ship OpenSSL's license notice
Soul flagged (F3) two docs still describing the pre-release native QUIC
bridge: unity/org.gamecult.cultlib/README.md said Schannel, and
native/GameCult.Mesh.Quic.Native/README.md still described the older
40,448-byte committed plugin as current. The shipped bridge (since the
1.0.60/1.4.0 release, commit 0155ba8) is the OpenSSL MsQuic 2.5.9 build,
302,080 bytes, static CRT, no CRT import. Both docs now describe that build
and name what it replaced.

The OpenSSL build's NuGet package carries an Apache-2.0 notice for OpenSSL
itself (Microsoft.Native.Quic.MsQuic.OpenSSL's THIRD-PARTY-NOTICES) that the
committed Third Party Notices folder never carried - the Schannel build had
no such dependency to notice. build-quic-native.ps1 now copies it out
alongside MSQUIC-LICENSE.txt, build-unity-package.ps1 stages it into the
package output and the tracked template, and it is now committed as
OPENSSL-NOTICE.txt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 19:49:14 +02:00
Meta Vens
244154e2f5 Say what the restore digest is, since it is not an identity
The last report quoted the restored sha256 as though two runs agreeing on it
meant they had run the same source. They had not: `text=auto` gives a Windows
checkout carriage returns, so the win32-x64 run restored to b163cab7 and the
linux-x64 run to e3c6528a from the same commit. The digest is a restore check —
the file this run started with is the file it ended with — and it is said where
it is printed, so the next report does not borrow it for something it cannot do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 03:11:05 +02:00
Meta Vens
b985f07887 Say what the single-waiter wake actually rests on, and what is still unreached
The note beside the close's wake claimed the narrowing to `notify_one` was
equivalent, and gave the weaker half of the reason. The equivalence needs two
premises together: `closing` is set permanently under the gate before the notify
and appears in every waiter's predicate, so after a close begins no poller can
block at all and the set of waiters is fixed; and each exiting call's own scope
destructor takes the gate, decrements and wakes everyone, so one wake starts a
chain that reaches all of them. Neither carries it alone. Both are now written
where the claim is made, so it can be checked rather than believed.

Narrowing both wakes also survives, and that is not equivalent by any argument:
a wake consumed by the closer, waiting on the same variable for the count to
reach zero, leaves the chain stopped and the rest of the pollers sitting out
their timeouts. It passes on wait-queue ordering — the pollers are queued before
the closer, so a FIFO head is never the closer until it is the only waiter left.

Recorded as unreached, with the reason, and deliberately not as unreachable:
that claim has been wrong twice in this campaign. A scenario cannot force it
while the first premise holds, since the only way to put the closer ahead of a
poller in that queue is a poller that blocks after the close began. Reaching it
would need a seam that chooses which waiter a notify goes to. The standard does
not promise FIFO, so what survives here is the implementation's doing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:47:34 +02:00
Meta Vens
5ab4c627d7 Put a call in both states so the seam's own guard is pinned
The last pass's only non-comment source change was held by nothing. Reverting the
hold's guard so it fires unconditionally survived all five scenarios, because no
scenario ever had a call in both states at once: the timeout scenario never arms
the hold, and the hold scenarios never let a timeout expire.

`holdtimeout` is the missing state. The hold is armed, the runtime is idle, and
one poll asks for 150 ms while the hold stays armed for 800. A call the wait woke
is the seam's to park; a call whose own timeout expired is on its way out, and
parking it would let `holdclose` report the bridge keeping a host call inside the
library when what kept it was the fixture. The measurement is which of those two
numbers the poll comes back at.

Two entries, and the loosening is the one worth reading: the same two conditions
in the other order. The hold blocks, so the order is the rule — asked first, it
parks the call and then decides whether it should have. Both die on
`holdtimeout` at about 800 ms and on nothing else; `holdclose` and `latecall`
stay green under both, which is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:46:49 +02:00
Meta Vens
c682619fbd Close the timeout against waits derived from the argument, not only constants
The two bands ruled out a constant and ruled out nothing else. Any mapping that
is the identity at 200 and 900 passed for free, and the late tolerance was a flat
300 ms, so anything within that of the truth passed at every value forever.
Three ordinary ones survived all five scenarios on both targets: clamping the
wait to at most a second, adding 150 ms to it, and scaling it by six fifths.

The clamp is the one that matters. Its failure is verbatim the bug the last pass
claimed to fix — a host asking for five seconds gets one, forever, with nothing
reporting it — and "cap the wait so a shutdown gets noticed" is the most ordinary
spelling that line will ever be given.

So the long probe goes to 5000, above any ceiling a bridge would plausibly clamp
to, which kills the clamp and the scale on the early check and the late one. And
the late tolerance becomes a fraction of what was asked for — 60 ms plus an
eighth — which is what kills the offset: 150 ms is 75% late at the 200 ms probe
and there is no flat gap left to hide in. Measured overshoot on either target is
about 10 ms, against 85 ms allowed at the short probe and 685 at the long.

The table gets the clamp as an entry of its own, because the whole lesson here is
that a table of constants says nothing about a wait computed from the argument,
and a table of constants would go green again the moment these numbers drifted
back. Its honest limit is stated where the tolerance is defined: a scaling
smaller than an eighth survives, and nothing here claims otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:45:57 +02:00
Meta Vens
c177533b4f Offer the container line to both shells, and name the short-path requirement
The container recipe carried a PowerShell backtick continuation in a snippet
whose next paragraph invites a POSIX shell to run it, where a backtick is not a
continuation. A continuation has to pick a shell, so the line does not have one.
The Dockerfile's own copy of the recipe still mounted one machine's checkout by
absolute path; it mounts whatever checkout is running it, like the other two.

And the requirement a newcomer meets by tripping over it: on win32-x64 the
harness needs the checkout somewhere short. MSBuild's file tracker gives out on
long paths, so a run from a deep temporary directory fails the build for every
mutant, the no-op control included. That is loud — the harness stops on a red
control and prints what the child said — but what the child says is MSBuild, not
the path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:44:47 +02:00
Meta Vens
a937a0b07e Let the bridge's own timeout, refusal and two-phase poll be seen
Three rules the bridge has always promised, none of which anything committed
could see.

The timeout was the structural one. Both scenarios end their waits with a close,
so the duration the bridge waits on governed nothing either of them observes: a
hard-coded 2000 ms survived the whole Linux matrix and Windows, and a host asking
for five seconds would have got two forever with nothing reporting it. The fix is
not another entry at another constant. A timeout's only observable is how long
the call takes, so `polltimeout` lets an idle runtime's poll be ended by its own
timeout and measures it, twice at different values, because no single constant
sits in both bands. The seam's hold moves behind that too: it parks only a call
the wait woke, so a poll whose own timeout expired is on its way out rather than
held there by the fixture and counted as the bridge's doing.

The refusal during a close (the header's section 4) had no scenario at all. A
call admitted after the closer has begun increments the in-flight count behind a
wait that has already read it. `latecall` holds the close open with the seam and
makes a call into it.

And the two-phase poll was written off as needing a listener, a credential and a
connection. It needs a closed loopback port: the failure always carries a reason,
which is an 87-byte payload, in about a millisecond. Moving the pop above the
copy crashes on both targets.

18 killed, 0 survived, 0 skipped on linux-x64; 16 killed, 0 survived on
win32-x64, where the two ThreadSanitizer entries are honestly skipped. Both runs
restored the source to the same sha256, b163cab7d48ebc93.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:09:50 +02:00
Meta Vens
440709829d Commit the container the linux-x64 entries are honest in
The image the bridge is built and mutated in existed nowhere in the repository,
and the recipe that stood in for it installed a C++ toolchain and no JavaScript
runtime — so the container could not run the harness that checks the bridge it
had just built. It is a file now, pinned to the same debian:13 digest as the
release workflow, carrying ninja, Node, setarch and MsQuic's four runtime
dependencies.

The harness configures with `-G Ninja` on linux-x64, which is what
scripts/build-quic-native.sh uses. CMake's Linux default is Makefiles, the image
carries ninja rather than make, and the default configured nothing at all: the
first run in the committed container died in cmake. It said so, because the
control now prints what the child said, which is the whole point of that change.

The seccomp requirement is stated where someone hitting it will read it — the
bridge's README, beside the recipe — rather than being knowledge the runner
assumes its caller already has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:37:16 +02:00
Meta Vens
e4e09a086c Enter the rules the quiesce rests on, and stop exiting green on skips
Four entries the native target did not have. Two for the rule that a blocking
crossing is a counted host call: the revert deletes the call scope from
`cultmesh_quic_next_event`, and the loosening keeps the scope and its refusal but
drops it at the door, before the call blocks. Two for the rule that a positive
timeout blocks until an event or the close: the revert inverts the comparison,
and the loosening waits a token millisecond. All four were green before this,
because nothing checked that the pollers were inside the library at all.

The bounded-wait loosening goes from 1 ms to 50 ms. A 1 ms bound only ever lost
a race — the margin was a few hundred lock handoffs — and the same loosening at
50 ms survived on both targets. `holdclose` holds its pollers for 500 ms, so any
bound shorter than the hold now expires with calls still inside. A bound longer
than the hold is not distinguished, which is this entry's honest limit.

A run that skips entries covered those rules with nothing, and exiting 0 let a
win32-x64 run — where every ThreadSanitizer rule is skipped — be read as a green
gate. It exits 2 and says so, with a killed/survived/skipped line.

A red control arrived as a bare colour with the child's output thrown away,
which is how a configuration that could not start at all stayed invisible. The
control now prints what the child said. On linux-x64 the sanitizer
configurations are re-executed through `setarch -R`, because ThreadSanitizer
dies before `main` on the kernel's address-space randomisation; both that and
the seccomp denial behind `personality(ADDR_NO_RANDOMIZE)` stop the run with the
cause and the required invocation instead of being reported as kills.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:24:21 +02:00
Meta Vens
180917d75b Mutate the native bridge, and name the target each entry is honest on
The mutation suite covered only the TypeScript rules. The bridge's two runtime
lifetime rules now have entries of their own, each with a revert that puts back
the wrong spelling and a loosening that keeps the shape and gives away the
guarantee: a notify moved outside the gate, a notify outside it that fires only
on the last call out, the quiesce wait deleted, and the quiesce wait made
bounded. Their killer is the scenario runner, built through CMake rather than
npm, so the runner now takes a check per target instead of assuming a workspace,
and a target without a dist/ keeps the verified restore without the sentinel.

Every native entry names the platforms it is honest on, and a run elsewhere
reports it as skipped rather than killed. There is no ThreadSanitizer for MSVC,
so the notify rule cannot be seen on win32-x64 at all. Soul's Cut 3 pass found
this the hard way, reporting two mutations killed from a win32-x64 release run
where both in fact survive.

Naming a target on the command line runs only that one, which is what lets the
native entries run inside the Debian 13 container where the TypeScript workspaces
have no installed dependencies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 23:23:50 +02:00
Meta Vens
c729dff2ee Pin the Windows MsQuic to the OpenSSL build, and both of its digests
Q9 ruled A. The Schannel MsQuic refuses QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12
with QUIC_STATUS_NOT_SUPPORTED, and PKCS12 is the only credential type a
provider has on both platforms, so a Windows provider could not open a
listener against the runtime this repo was shipping. The same bridge
binary passes all twenty smoke checks and the v1 gate against the OpenSSL
build, which is what Soul verified and what this now downloads.

Two digests, not one. The zip is pinned by FEE9A664 and 19,179,817 bytes;
the msquic.dll inside it by C17C6581 and 4,181,856 bytes. The earlier
report and the cut map's first record put the zip's digest on the DLL,
which is a pin that checks the archive and then ships a file nobody looked
at. Sizes ride along with both, because a digest that fails tells you less
than a digest and a length that fail together.

The CRT is linked statically. A Node or Unity host without the Visual C++
redistributable cannot load a DLL that imports vcruntime140.dll, and
Windows reports that as "the specified module could not be found" while
naming the module that was found. Confirmed on the rebuild: msquic.dll,
CRYPT32, ntdll, KERNEL32, and no CRT import at all. The DLL goes from
83,456 to 302,080 bytes, which is the price of the host not needing
anything installed.

The Unity plugin's committed msquic.dll is still the Schannel one and this
does not touch it; the next build-unity-package.ps1 run is what replaces
it, and that is not in this batch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:04:27 +02:00
Meta Vens
23dc0939e0 Give the bridge a Linux build the deploy host's own distribution can produce
There is no Linux MsQuic binary in any NuGet package, and the v2.5.9 GitHub
release carries only test bundles, so the Windows script's approach does not
transfer. What does exist is Microsoft's Debian 13 pool, which is the same
distribution Yggdrasil runs, and the msquic source tree at the matching tag for
the POSIX headers the NuGet omits.

`scripts/build-quic-native.sh` fetches the pinned `libmsquic_2.5.9_amd64.deb`
and the three headers `msquic.h`, `msquic_posix.h` and `quic_sal_stub.h`, and
verifies all four by SHA-256 the same way `build-quic-native.ps1:14` verifies
the NuGet. A digest that stops matching is a changed dependency, not a warning.

Two details the deb forced. It carries three library files and no licence text,
so the MIT licence is copied from the one this repo already committed for the
Windows plugin — the same text for both platforms. And it ships only
`libmsquic.so.2.5.9`, with no `libmsquic.so` for `-lmsquic` to resolve against,
so the script makes that link in the extracted tree rather than in the image.

The Linux CMake branch links `-fvisibility=hidden`, so only the twelve v2
exports leave the `.so` and the Windows-only v1 five are absent by construction
rather than by discipline, and sets `RPATH $ORIGIN` so the `libmsquic.so.2`
shipped beside the bridge resolves without being installed system-wide.

`build-quic-native.ps1`'s default output directory becomes
`artifacts/quic-native/win32-x64` so the two platforms' outputs sit beside each
other instead of on top of each other. Both existing callers —
`build-unity-package.ps1:80-82` and the Native.Tests csproj `:20-22` — pass an
explicit `-OutputDirectory`, so neither is affected.

Built and smoked in the workstation's Docker `debian:13` engine pinned by
digest: g++ 14.2.0, glibc 2.41, `libgamecult_mesh_quic_native.so` 85,456 bytes
with `NEEDED libmsquic.so.2` and `RUNPATH $ORIGIN`, and the same 15-check v2 FFI
smoke that runs on Windows passes under Node 24 on that image. That is a Linux
build on a Linux host and nothing more: Q6 ruled the release artifact comes from
the Actions job, and this one is the development loop.

A Linux host still needs MsQuic's own runtime dependencies present — libssl3t64,
libnuma1, libxdp1, libnl-route-3-200 — which is now written down, because the
first smoke run failed on a missing libxdp.so.1 and the reason was not obvious
from the error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:04:14 +02:00
Meta Vens
9a4c9396b4 Stop the realtime decoder handing back a view of its own frame
Two mutants the cut-2 pass recorded as equivalent were not. Both notes were
reasoning, and both were wrong in the same way: they argued from the shape of
the code instead of trying the input that breaks it.

The decoder's length refusal reads `payloadLength < 0 || expected !== length`.
The note said the first clause could never fire on its own, because a negative
payload length makes the sum smaller than the frame. It does not: the sum also
carries the three identity lengths, and a negative payload cancels against
them. A 37-byte frame declaring `channelLength=65535, payloadLength=-65535`
sums to exactly 37, matches its own byte length, and without the clause decodes
to three blank identities and an empty payload where `CultMeshRealtimeWireProtocol`
refuses it. Two smaller spellings do the same, `ch=1, pl=-1` over 37 bytes and
`ch=2, pl=-1` over 38. No test covered that shape; one does now, and the
mutation is listed and killed rather than excused.

The payload copy was worse, because the fix and the bug looked identical. The
decoder used `bytes.slice(...)`, which copies a `Uint8Array` — and the existing
test passes one, so it was green — but Node's `Buffer` overrides `slice` with
an alias of `subarray`. A `Buffer` is what a socket read hands a Node caller,
which is exactly how the QUIC plane in cut 4 will reach this function, so every
decoded payload would have been a live view of a frame buffer the transport is
free to reuse.

`Uint8Array.prototype.slice.call(bytes, ...)` was the obvious repair and does
copy, but `%TypedArray%.prototype.slice` takes its result constructor from
`@@species`, and `Buffer`'s is `FastBuffer`: the payload then comes back a
`Buffer` from a `Buffer` frame and a `Uint8Array` from a `Uint8Array` one, so
the same frame compares equal or unequal under `deepStrictEqual` depending on
how it arrived. `new Uint8Array(bytes.subarray(...))` copies and is one type on
both. Three mutations now guard the line — return a view, return a `Buffer`
view, return a species-typed copy — and the first two die only on their own
input type.

Mutation runner: 75 mutations, 75 killed, 0 survivors, three green controls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 20:20:16 +02:00
Meta Vens
65f2b666ef Run the TypeScript workspaces by the name cultcache-ts actually has
`npm run build:ts` and `test:ts` have been dead since 8cb3b72, not 8dd5a45 as
first recorded. That commit renamed the package to @gamecult/cultcache-ts and
left `run-typescript-workspaces.mjs` asking npm for a workspace called
`cultcache-ts`, which no longer exists; npm refused at the first entry in the
list, so neither script reached any workspace at all. npm resolves a workspace
by manifest name, not directory, so the list moves whenever a package is scoped
— said out loud in the source now, since nothing else will say it.

The mutation runner is renamed `scripts/mutate-cultmesh.mjs`. It stopped being
about the route verifier alone when cut 2 gave it a third target, the realtime
frame codec, and a file named for one of its three targets invites the reader to
assume the other two are somebody else's problem.

`docs/typescript-quic-realtime-cut.md:46-47` names the old path; the map is
Self's and is updated separately.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 20:19:49 +02:00
Meta Vens
ae29910b30 Mutate the realtime frame codec, one mutation per rule
A third target in the committed runner, `realtime`, over
`packages/cultmesh-ts/src/realtime-wire.ts`, killed by that package's own
suite. It gets the machinery the other two already have: a no-op control
through the same write path, the sidecar and its verified restore, and the
post-restore rebuild that proves `dist/` no longer carries a mutant, with
`0x31545343` as the sentinel its last mutation removes.

Thirty-seven mutations, one per rule the codec owns: each header field's
offset, width and endianness; the field order the reference fixed; the magic,
the stamped header size and wire version; the delivery ordinal; UTF-8
identities and byte-counting length prefixes; the identity and payload
ceilings; the ALPN, the two QUIC error codes and the two stream kinds; the
four encode refusals from the transport contract; each decode refusal and the
order they fire in; the byte-order mark; and the two aliasing rules, that
decode reads only the viewed bytes of an offset view and that the decoded
payload does not alias the frame. A mutation that changes the encoding
symmetrically still dies, because the vector bytes were written on the other
side.

All seventy-two mutations across the three targets are killed and all three
controls are green.

One mutant is not listed and says why in the table, as the P1363 length guard
already did: removing `payloadLength < 0` from the decoder's length refusal is
equivalent. The clause mirrors the reference but cannot fire alone, because
reaching it means the frame is already 37 bytes and a negative length can then
never make the sum match. It survived when tried.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:20:57 +02:00
Meta Vens
6cbc73fb17 Encode a CultMesh realtime frame in TypeScript
One codec, `packages/cultmesh-ts/src/realtime-wire.ts`, rule for rule what
`CultMeshRealtimeWireProtocol` does: the 37-byte little-endian fixed header
with magic 0x31545343, the delivery byte as the C# enum ordinal, the i64
epoch and sequence, three u16 identity lengths, the i32 payload length, the
stamped header size and wire version, then UTF-8 identities and the payload.
It touches no socket; QUIC stream framing stays transport-owned. The blank
reading in `Validate` is `IsNullOrWhiteSpace`, whose whitespace set is not
`String.prototype.trim`'s, so it borrows `isNullOrWhiteSpaceCSharp` from
`cultnet-ts` rather than growing a second reading of the same rule.

Byte parity is proven in both directions and in two processes, which is what
the authority vectors cost us to learn. `contracts/cultmesh/realtime-frame-vectors.json`
is written by the new C# test under CULTMESH_WRITE_VECTORS=1 and decoded and
re-encoded here; `realtime-frame-vectors.ts-written.json` is written by
`scripts/write-cultmesh-realtime-frame-vectors.mjs` with this codec, over
different identities and epochs, and decoded by the same C# test with the
reference implementation alone. Both files share one field shape, so each
side reads both with one reader. The two cases that would put megabytes into
a committed contract file, identities at exactly 65,535 UTF-8 bytes and a
64 MiB payload, are recorded as a build rule plus the digest of the whole
encoding; both sides build them the same way.

Three things the C# reference gets from its type system and TypeScript does
not, each refused rather than left to surface as a stray runtime error: a
`bigint` outside the i64 range, a delivery string the union cannot spell, and
a leading U+FEFF, which `TextDecoder` deletes by default and
`Encoding.UTF8.GetString` keeps. The last one is a real divergence the test
caught; the decoder now sets `ignoreBOM`. U+FEFF is not whitespace to .NET,
so such an identity passes the transport contract and reaches the wire.

There was no C# test for this codec before; this is also its first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:20:41 +02:00
Meta Vens
fe2555da63 Repair only the file states the mutation runner itself wrote
A SIGKILL leaves the sidecar behind, and the next run wrote it over whatever
the file then held. That is right after a kill and destructive after one: a fix
written to the source between the kill and the next run was reverted to bytes
from before the kill, with a line claiming a repair, and the run then reported
mutation results for code nobody had written.

The mutation table is the record of what a live run can leave behind: the
original (which is the sidecar, and also what the no-op control writes) or the
original with exactly one of that target's mutations applied. Repair now
recognises those and nothing else. A file matching neither stops the run with
its own digest and the sidecar's and changes nothing.

Reproduced both halves against the shared target. Killed mid-mutation, then
appended a line to the source: the rerun exits 1, prints both sha256s, and the
hand-written line is still there afterwards. With the mutant put back and the
sidecar unchanged, the rerun repairs as before and now names the mutation the
dead run was inside.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 18:35:44 +02:00
Meta Vens
c64a2da81d Refuse the URL-repaired spellings in the protection gate too
`isLoopbackEndpoint` hands its endpoint to `refusedByCSharpUriShape` before
`URL` sees it; `isProtectedEndpoint` did not, and `URL` repairs eleven
spellings `System.Uri` refuses outright. Every one of them answered protected
here and unprotected in the C# reference, which is the dangerous direction:
the channel-protection gate opened on a route `IsProtected` would have closed.
`wss:x`, `wss:host`, `wss:host:8443`, `wss:/host` and `wss:///host` have no
authority for `System.Uri`; `wss://host\path` and `wss://host:8443\` carry a
backslash; `wss://ho\tst/m`, `wss://host\t/m` and `quic://ho\tst` a tab, and
`wss://host\r\n/m` a CR/LF, all of which `URL` deletes.

Soul's 71 raw-shape spellings go in as a table with the answer the C#
`IsProtected` gave for each on 2026-09-16, and the test asserts the invariant
rather than equality: TypeScript may never call an endpoint protected that C#
refuses. Eleven of the 71 now differ the safe way round, C# protected and this
not, and they are pinned by name rather than excluded, so neither a twelfth
joining them nor one of them flipping passes quietly. Two throw in `URL` before
any rule of ours runs (an IPv6 zone id, a non-breaking-space host); the other
nine are host repairs `refusedByCSharpUriShape` refuses on purpose, because the
same repair would make `isLoopbackEndpoint` lie. None is dialable as a browser
CultMesh route.

The mutation that drops the pre-check from `isProtectedEndpoint` alone, leaving
`isLoopbackEndpoint`'s in place, is killed by that test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 18:29:13 +02:00
Meta Vens
dfe582edbc Kill the browser's local reading of "unsigned", and mutate the browser too
Soul replaced the shared `isUnsignedCertificate` call in the browser's
session-acceptance check with a local empty-string comparison and every one of
the 18 browser tests still passed, because every test used an empty signature.
A whitespace signature is where the two readings part: the shared route
verifier accepts such a route as unsigned on a loopback local-development
endpoint, and an inline comparison then demands a provider proof for the
session the verifier just let through. The test now opens that session with
three spaces, U+0085 and a no-break-space/tab/newline mix as well as the empty
string, and asserts U+FEFF the other way, since it is whitespace to JavaScript
and a signature to C#.

The mutation runner grew a second target. `shared` is the one verifier, killed
by the cultnet-ts tests; `browser` is `packages/cultmesh-browser/src/index.ts`,
killed by the browser tests. A browser mutation cannot be killed by cultnet-ts
tests, so the killer is the target's own workspace, and each target carries its
own sidecar, control, sentinel and post-restore dist assertion.

Two repairs this pass earned:

- The sidecar is now removed inside the `finally`, right after the verified
  restore. A throw mid-run left it behind holding the pre-run bytes, and the
  next run "repaired" the file from them, silently reverting everything fixed
  in between. That happened here, twice, before it was noticed.
- The NUL in the loopback pre-check's strip regex had been written as a literal
  byte rather than an escape. It read the same, but git classified the file as
  binary and stopped normalising its line endings, so the whole file diffed as
  rewritten. Escaped, along with the ASCII host range beside it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:39:50 +02:00
Meta Vens
803ee12a7a Refuse the endpoint spellings WHATWG URL repairs and System.Uri does not
`isLoopbackEndpoint` handed the raw endpoint to `URL` and read the host it got
back. `URL` is a repairing parser and `System.Uri` is not, so seven spellings
were loopback here and remote in C#: a trailing dot on the host, which `URL`
drops; `ws:127.0.0.1`, `ws:/127.0.0.1` and `ws:///127.0.0.1`, whose authority
`URL` infers; a backslash path separator, which `URL` reads as a separator;
fullwidth digits and letters in the host, which `URL` folds to ASCII; and a tab
in the host, which `URL` deletes. Each of those is a local-development bypass:
a route the C# consumer would have demanded a certificate for opened unsigned
here.

`refusedByCSharpUriShape` refuses exactly those, on the raw string, before
`URL` sees it. Leading and trailing C0 controls and spaces around the whole
endpoint are stripped first, because both sides accept them. The spellings
Hands probed earlier and C# accepts -- `127.1`, hex, decimal,
`[::FFFF:7F00:1]`, userinfo, `localhost:8443` -- are untouched.

The doc comment claimed equivalence with `System.Uri.IsLoopback`. It is not
equivalent and now says so: two divergences remain, both unreachable from a
browser CultMesh route, and both named. An IPv6 zone id throws in `URL` and is
a host-local interface name no browser socket can dial; `file:` and `mailto:`
have no `URL` host and are refused by `openSocket`, which takes `ws:` and
`wss:` only.

The test is Soul's endpoint list with the C# answer as the expectation: 82 of
the 88 it probed, the six unreachable ones named in the doc comment instead.

Mutations: the trailing-dot refusal dropped, the scheme-and-authority shape
inverted, the backslash refusal dropped, and the non-ASCII host refusal
dropped -- each killed by the table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:22:06 +02:00
Meta Vens
c4c2330805 Clean the route fields before the transcript, as the C# constructors do
The C# reference signs a transcript over a *constructed* route, and the
constructors clean on the way in: `CultMeshAuthorityRoute.Clean` trims each
protocol id, drops the blank ones, keeps one of each ordinally and sorts
ordinally; `RequireNonEmpty` trims the runtime id and endpoint; `Require`
trims the provider key id and coordinates; `CultMeshRouteCertificate` trims
the Odin key id; a blank generation becomes the runtime id and endpoint
joined by "@". TypeScript cleaned none of it and transcribed the caller's
string, so a route C# signed and accepted was refused here the moment any
field carried padding or a duplicate.

Soul's tamper loop is the evidence: C# accepted a duplicated protocol id, a
padded generation and a padded Odin key id against the committed vector's own
signature, and TypeScript refused all three. The test now reruns those, plus a
padded protocol id, a blank one, a reversed list, a padded endpoint and a
padded provider key id, against the C#-written vector's signature. A padded
Odin key id must also find its root, so the trust map keys on the trimmed id
as the C# dictionary does.

`verseId` is left alone on purpose: it is not a route field, and
`CanonicalRoute` passes the caller's string into `Canonical` untrimmed. The
test pins that too.

`protocolId ?? "cultmesh.documents.v1"` is gone. A C# route with no protocol
ids is `Array.Empty<string>()` and transcribes as the empty string; reading
the handshake's `protocolId` as a one-element list wrote bytes the reference
never writes. `canonicalSession` matches its C# counterpart the other way:
it cleans nothing on the request, because the handshake message is what the
wire carried, and only the endpoint is cleaned, because the one C# passes is
`Route.Endpoint`.

Mutations: the dedupe removed, the blank-id filter removed, the generation
trim removed, the provider key id trim removed, and the Odin key id trim
removed from the lookup -- each killed by its test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:14:35 +02:00
Meta Vens
e67e695a63 Read whitespace and the signature refusals as C# does
Two edge alignments that have to land together, because the test for the
first asserts the messages of the second.

`String.prototype.trim` is not `char.IsWhiteSpace`. JavaScript counts U+FEFF,
which .NET does not; .NET counts U+0085, which JavaScript does not. Every
`trim()` in the shared verifier stood in for a C# `IsNullOrWhiteSpace` or
`Trim()`, so both were wrong at those two code points: a U+0085 signature was
an unsigned route to C# and a bad signature to TypeScript, and a U+FEFF
signature the other way round. `isCSharpWhiteSpace`, `trimCSharp` and
`isNullOrWhiteSpaceCSharp` are that set, and `isUnsignedCertificate` and the
signature trim now use it. The test pins all 34 code points Soul probed on
both sides against the answers .NET 10 gave on 2026-09-16.

The C# `Verify` also has three signature refusals, not one: a signature that
is not base64, a decoded length that is not 64, and a signature that decodes
but does not verify. TypeScript collapsed the first two into the third and
lost the distinction the reference draws. Split, with each message pinned by
its exact C# string. `verifyP256` keeps its own checks: it is the silent
`VerifySession` path, which returns false for all three.

Mutations: the whitespace-set site reverted to `String.prototype.trim`,
U+0085 dropped from the set, and the two new messages merged back into
"invalid" -- each killed by its test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:09:48 +02:00
Meta Vens
a801e0a441 Share one reading of an unsigned certificate between verifier and browser
The browser's local-development session-proof short-circuit tested
`!route.certificate`, while the shared verifier already treated an empty or
whitespace signature as unsigned, matching C# `Verify` (`:120`). A loopback
route carrying a blank-signature certificate therefore passed the verifier
and was then refused for lacking a provider key, where C# returns
`IsLocalDevelopment` and skips the session proof.

`isUnsignedCertificate` in `cultnet-ts/authority` is now the one owner of
that reading; the verifier and the browser both call it. A browser test pins
the local-development loopback open without a provider proof, the signed
loopback certificate still demanding it, and the remote counterpart refused
in both trust modes. The mutation runner gains two predicate mutations
(whitespace treated as signed; only a missing certificate treated as
unsigned), both killed by the existing whitespace-signature test in
cultnet-ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:43:09 +02:00
Meta Vens
0e4f479ac5 Make the mutation runner leave no mutant behind
Every test run inside the runner rebuilds `packages/cultnet-ts/dist/`
from the mutant under test, so after the final restore of the source the
compiled module still carried the last mutant. The runner now rebuilds
the package after the restore and asserts `dist/cultmesh-authority.js`
contains a sentinel the last listed mutation removes, checked against the
list itself so a reordering cannot make the assertion vacuous.

The original bytes are also written to `<target>.mutation-original`
before the first write and removed after the verified restore. A run
killed mid-mutation is repaired from that sidecar at the next run's
start, instead of by whoever notices git status.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:34:31 +02:00
Meta Vens
1590145a81 Pin the TypeScript-signed direction of the authority parity check
`contracts/cultmesh/authority-route-vectors.json` proves C# bytes verify
in TypeScript. Nothing in the repo proved the reverse; Soul's probe did it
once in a scratch directory. It is now a committed check in the same shape,
mirrored:

- `scripts/sign-cultmesh-authority-vectors.mjs` builds the route and
  session transcripts with the shared module, signs them with fixed
  TypeScript-owned test keys over awkward inputs (a quic scheme with a
  query, three unsorted protocol ids, non-ASCII generation text, priority
  13), self-verifies, and writes
  `contracts/cultmesh/authority-route-vectors.ts-signed.json`.
- `CultMeshAuthorityProofTests.AuthorityRouteVectorsSignedByTypeScriptVerifyHere`
  reads that file with keys taken from the file, asserts the protocol ids
  are unsorted, that `Validate` and `VerifySessionProof` accept, and that
  a priority-tampered route and a replayed nonce are refused.
- The TypeScript vector test runs over both files, so a transcript change
  on the TypeScript side fails here before C# could trust a stale file.

Checked negatively: one base64 character changed in the committed
signature fails the C# test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:27:06 +02:00
Meta Vens
c2ffc32ff6 Make the TypeScript route verifier the C# rules exactly
verifyP256 handed WebCrypto `view.slice().buffer`. For a Node Buffer,
`slice` is `subarray`, so `.buffer` is the shared pool: a pooled Buffer or
a subarray of a larger allocation verified false while the same bytes as a
plain Uint8Array verified true. Copy exactly the viewed bytes.

Trust rules that had drifted from `CultMeshAuthorityProof.cs`:

- A certificate whose signature is empty or whitespace is an unsigned
  route (C# `Verify` line 120): local-development loopback accepts it,
  everything else refuses with the unsigned message.
- Loopback is `System.Uri.IsLoopback`: any 127.0.0.0/8 host, `::1`, the
  IPv4-mapped `::ffff:127.0.0.1` (only `.1`), and the host names
  `localhost` and `loopback`. WHATWG URL normalises `127.1`, `0x7f000001`
  and `2130706433` the way System.Uri does. Probed on the C# side through
  the public policy before the rule was written; the test pins that set.
- The root lookup runs before the validity window, so an expired route
  under an unknown root is refused as untrusted on both sides.
- A policy listing one Odin key id twice is refused at first use, the
  closest a plain object gets to the C# constructor throwing.

One test per rule, one mutation per rule in the runner's list. A fixture
whose protocol ids are unsorted and whose expected transcript is the
sorted one means removing the sort is now killed.

The runner now matches anchors in the file's own line ending. On an
autocrlf checkout the two multi-line anchors it already had ("route
transcript field order", "session transcript binds the nonce") matched
nothing, so the run threw at those entries instead of testing them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:24:41 +02:00
Meta Vens
062a5c6678 Add a mutation check for the shared route verifier
One mutation per rule the cut map names, applied to the source bytes and
restored from the original bytes with a digest check; a no-op control
runs first through the same write path. A survivor is a rule no test
pins and fails the script.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 15:59:09 +02:00
Meta Vens
ae194d5eb2 Release CultLib Unity 1.0.57 with deterministic plugin assemblies
Some checks failed
Publish packages / cultcache-py to PyPI (push) Has been cancelled
Publish packages / cultmesh-py to PyPI (push) Has been cancelled
Publish packages / cultnet-py to PyPI (push) Has been cancelled
Publish packages / @gamecult/cultcache-ts to npm (push) Has been cancelled
Bumps org.gamecult.cultlib to 1.0.57 and rebuilds its managed plugins from
main, so the Unity package carries the store-routing CultCache (one home
store per type, AddBackingStore(CacheBackingStore, Type[]), atomic commit)
instead of the Sep 3 assemblies. Install docs point at the new tag.

The build script now publishes without Source Link or the source revision,
with CI path mapping and cleared Release intermediates. The previous DLLs
named the commit and the worktree they were built in, so committed plugins
could never match a fresh build of the commit that holds them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 15:18:42 +02:00
Meta Vens
96ee5fdce3 Delete the CultCache MessagePack source generator
The reflective CultDocumentRegistry is now the only descriptor authority,
and payloads always go through MessagePackSerializer with
OptionsFor(document assembly). No consumer builds AOT, IL2CPP or trimmed,
Unity always ran the reflective path, and no benchmark showed a gain,
while the generator duplicated the registry's shape rules.

Removed: GameCult.Caching.MessagePack.Generator and .Analyzers (solution,
project and analyzer references, NuGet pack list), CultGeneratedDocumentMetadata
(provider attribute, interface, definitions, loader), the generated descriptor
and codec branches in CultCache and CultDocumentMessagePackSerialization,
DocumentShapeSweepTests and the generator half of the unkeyed test, the
Microsoft.CodeAnalysis.CSharp test reference and InternalsVisibleTo.

The registry rejects, with a message naming the fix, what MessagePackSerializer
cannot serialize and what the generator used to paper over: a document without
[MessagePackObject], a non-public document without AllowPrivate = true, and a
non-public member of an AllowPrivate document that is not [IgnoreMember].
DocumentShapeTests pins every rejection message and the accepted shapes'
round trips against MessagePackSerializer bytes (keeps the 93A16E0305 pin).
Rejected shapes are emitted into a dynamic assembly, which registry
discovery skips.

Nested member type names use reflection's Outer+Inner form, not the
generator's dot form, so ReferenceHolderEntry's fixture id is re-pinned
(bd8506... -> 4ec0c5...). A scan of the schema catalogs in all 2786 .cc
stores under F:\Projects (AetheriaEve and CultLib-* worktrees, bin and obj
excluded) found no member type name naming a nested type in either form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:38:08 +02:00
Meta Vens
89687fceb4 Let the browser sample read the provider's advertisement
Eve refuses to build a command intent for a surface that has not
advertised a command boundary and receipt schema, and the intent it
builds now carries the operation under operation.operationId. The
browser sample did neither: the click was refused inside the page and
the checkpoint saw only a 30 s timeout on the receipt.

The C# provider now publishes its gamecult.eve.provider_advertisement.v1
document with the surface's worldInteraction, and the page leases that
and hands it to the lowering as `provider`. The provider owns the
declaration; the page reads it rather than restating it. The command
sink reads intent.operation.operationId.

The checkpoint now forwards the page's console and pageerror output.
"CultMesh browser operation is required." took one run to find with it
and had been a bare timeout without it.

  scripts/verify-eve-getting-started.ps1 -EveRoot ..\Eve (Eve main)
  -> Eve getting-started verification passed: clean .NET and TypeScript
     artifacts, DOM and headless convergence, real Chromium and C#
     clients, Odin discovery, authority-bound routes, receipts,
     persistence, and route replacement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:55:46 +02:00
Meta Vens
f94f5581b0 Let the verse catalog schema say what the reference emits
The C# reference publishes authorityRoutes with certificate: null for
loopback routes, and its own verifier treats null as "no certificate" in
local development. The TypeScript JSON schema only allowed the object,
so the browser client rejected every catalog the getting-started
provider served: "certificate: must be object", Odin could not resolve
the Verse, and the real browser/C# checkpoint has failed since f622ee1
(2026-08-18), behind the sample failure that hid it. TypeScript
consumers already guard route.certificate; the schema now accepts null.

The package-closure smoke also still spelled the browser identity with
providerId; it is authorityRuntimeId since c194bd4.

  npm test --workspace packages/cultnet-ts -> 54 pass
  npm run test:pack:ts -> passed

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:55:46 +02:00
Meta Vens
830a5d718f Make the Eve onboarding checkpoint run against the contract that exists
The portability workflow has been red since 2026-08-22. Three things,
each hidden behind the one before it:

- Eve started refusing to build a command intent for a surface that has
  not advertised a command boundary and receipt schema (Eve 4338a42,
  2026-08-22). The two-runtime sample never advertised one. It now passes
  a provider advertisement with worldInteraction for its surface, which is
  the declaration the lowering was right to demand.
- The sample and the package-closure script still imported "cultcache-ts";
  the package became @gamecult/cultcache-ts on 2026-09-04 (8cb3b72) and
  CI never got far enough to say so. Two READMEs carried the same import.
- The job pinned Eve to codex/eve-browser-host-isolation, a branch that was
  never merged and stopped moving on 2026-08-25. The sample is written
  against Eve main and passes there; the pin now says so.

The interop workflow's Python step ran one test file by path; it now
discovers the three split suites.

  scripts/verify-eve-two-runtime-sample.ps1 -EveRoot F:\Projects\Eve
  (Eve main ccc3ffe) -> browserCount 1, headlessCount 1, restartCount 1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:55:46 +02:00
Meta Vens
95493862bd Split the Python runtime into cultcache-py, cultnet-py, cultmesh-py
One distribution named cultcache-py carried three libraries. Rust and
TypeScript layer them as cultcache < cultnet < cultmesh; Python now does
the same, one package per layer, module names unchanged so every
`from cultmesh_py import ...` keeps working.

Two imports ran against that layering and are resolved by moving code to
where the dependency direction says it lives, not by adding a shim:

- cultnet_py.interop_peer imported the Verse/peer catalog, authority
  lease, stream, and simulation-fact contracts from cultmesh_py. They are
  wire contracts a bare peer has to speak without owning a node, which is
  why cultnet-rs and cultnet-ts keep them in the net layer. They now live
  in cultnet_py.cultmesh_contracts; cultmesh_py re-exports them.
- cultcache_py.benchmark imported cultnet_py. It measures framed CultNet
  parse beside cache paths, so it and compare_csharp move to cultnet_py.
  verify checks all three packages and moves to cultmesh_py.

The single 6,693-line test module is split by what each test touches: a
test using any cultmesh_py name lands in cultmesh-py, else cultnet_py in
cultnet-py, else cultcache-py. Each suite runs with only its layer and the
layers below on the path, so a test that reaches up fails to import
rather than quietly binding the packages back together. 11 / 53 / 78 =
the same 142.

Workflows install all three; the publish job is one matrix over the
three packages keyed by tag prefix. The TS test harnesses put all three
src directories on PYTHONPATH. cultcache-py bumps to 0.2.0 because it no
longer contains cultnet_py or cultmesh_py; the new packages start at
0.1.0. Nothing was ever published, so no PyPI consumer moves.

  cultcache-py  11 tests, path: cultcache-py/src
  cultnet-py    53 tests, path: + cultnet-py/src
  cultmesh-py   78 tests, path: + cultmesh-py/src
  python -m cultmesh_py.verify -> ok, all five checks
  cultnet_py.interop_peer imports with cultmesh_py absent

Not run here: the cultnet-ts interop ring and cultcache-ts store matrix
need dotnet/kotlin peers; their PYTHONPATH change is four lines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:10:47 +02:00