Commit graph CultLib/tests/GameCult.Networking.Tests
Author SHA1 Message Date
Meta Vens
41b964c6f1 Narrow the escaped-observer probe to what this base can show
A database write is published after its cache write returns, so a write whose
observer threw never reaches the database observers on this base. Keep the
probe to the exception reaching the writer and later changes still publishing
and logging; the ordering question belongs to the database cut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 16:57:04 +02:00
Meta Vens
b8ceab84ac Drive the escaped-observer probe through a database write
A direct cache write is not a database change on this base, so the database
observer could not show it. PutAsync is, and the observer after the thrower must
see it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 16:54:40 +02:00
Meta Vens
ecee41e8ff CultCache: own the observer list and isolate every observer
The cache no longer fans changes out through an R3 Subject. It keeps a
copy-on-write observer array and Publish catches per observer: every observer
receives every change, an exception that escapes (a fail-fast R3 handler) joins
the OnUpdate failures rethrown to the writer after delivery, and the observer
stays subscribed. Dispose completes observers, and a disposed cache completes
new ones at once. Deletes the false "OnNext does not throw" comment.

The composition contract now states the observer policy and that a throwing
name or index getter refuses a change set, loads included.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 16:51:33 +02:00
Meta Vens
7362257f08 CultCache: read names and indexes once before minting; a throwing getter refuses the whole change set
Resolve now ends in Project, the only place an admission runs a [CultName] or
[CultIndex] getter on a landing record. It runs before land and before any
Sequence is minted, so a throwing getter refuses the admission (loads
included) with nothing written, minted or left in memory. The variant name and
index rules and the indexer read the projection; Apply runs no user code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 16:48:19 +02:00
Meta Vens
cbd3b4f3ce CultNet: a withdrawn hop subscription is refused with a typed error and its rows are withdrawn
Reconcile's withdrawal sent an untyped error with no subscription id and left the
peer holding the rows the subscription had delivered. It now sends removals for
those rows, then a variant_unsupported error whose details carry
field "subscriptionId" and the id (the existing details slots; no new wire key).
The subscribe and per-change paths use the same code. The error schema and the
Rust CultNetErrorCode accept variant_unsupported, so a Rust peer decodes it
instead of refusing an unknown code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 10:12:20 +02:00
Meta Vens
a0baa542d0 CultNet: refuse hop edges whose citer is a variant; withdraw a refused hop subscription
An edge carries the reference payload of its citer's resolved document, and the
citer is never a selected row under `cited`, so a variant citer slipped past the
selected-row refusal. Edges are now checked by the same rule (typed
NotSupportedException naming the variant), on the paged and the whole-set paths.

Reconcile refused a hop subscriber whose selection selected a variant without
withdrawing it, so it was told again on every store change. It withdraws as the
subscribe path does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:20:48 +02:00
Meta Vens
4f632fbf46 Drive the hop-subscription test through the database write path
Batch commits on the cache do not reach the database's change stream, so the subscriber's citers were written with database.PutAsync; the variant goes in through UpsertVariantAsync. On the previous source the second subscriber stops receiving after the variant exists (red); it now keeps receiving.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 08:42:32 +02:00
Meta Vens
b572823d0d Refuse variants after the selection is applied, and never tell a subscriber about the removal of one it was only refused
The selection paths (SelectPage, SelectAll) refused every stored variant before the selection ran, so once any variant existed a hop-bearing subscription threw out of its change handler and went silent. They now refuse only a variant the selection selects; the subscription server delivers that as a typed error and keeps its projection. The database server no longer sends removed for a key the subscriber only ever received as an error. A flatten racing the delivery check is recorded in a comment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 08:28:24 +02:00
Meta Vens
5d6847e220 Align the C1 tests with the unique-index rule and fix the live-subscription fixture
Hand-written and directory-store variants now override their indexed value or
name as the rules require; the subscription test writes its variant through a
committed batch so the file holds it, subscribes by key, and reports what it
saw when it times out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 07:04:01 +02:00
Meta Vens
cdb8ee5dcc CultNet refuses a variant on both live-change paths, loudly, by key
A load or write that landed a variant reached CultNet subscribers as a plain
"added" record carrying the full resolved payload; a later base edit published
only the base, so the subscriber's copy of the variant went stale. Under Q6
CultNet carries no variants until its own cut. Both live paths now refuse a
change whose key holds a variant, after selection and authorization match, and
send the matching subscriber a CultNetErrorMessage naming the variant and its
base. Refused loudly rather than omitted: an omission is a silent hole in the
subscriber's view, and the snapshot path already refuses by throwing.

Tests: a real load through a real RUDP subscription never delivers the variant
as a record and delivers the typed error; the database server's change builder
refuses the same change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:58:51 +02:00
Meta Vens
fc78b9ad2b Test the C# variant path and add the C#-written variant store as a shared vector
VariantTests drive the cache batch API and the real single-file store. The C1
vector was written by the code under test through that path; C# reads it and
rewrites it byte for byte, and the other runtimes' tests read the same bytes next.
The C0 tests that expected C# to refuse a v2 store now expect what C1 does with
it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:52:35 +02:00
Meta Vens
63a677bea9 Correct one comment still naming the deleted To.SchemaId tiebreak
Soul found this while confirming batch 8. The Survivor-5 header in the same
file was updated when the component was deleted and this one was not, so it
described a comparator that no longer exists.

Self edited this directly rather than dispatching Hands for a single stale
sentence. Naming that because Self editing code is a boundary worth saying
out loud when it is crossed, even for a comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 09:21:52 +02:00
Meta Vens
492dd0e754 R-BA: delete the unreachable to.schemaId tiebreak from both edge comparators
Soul could not construct a selection where To.SchemaId decides the
EdgesFor/page_edges order once From, Role and To.RecordKey already
tie, but flagged that as a claim, not a proof - the same shape of
argument that was wrong twice before on this comparator (R-AT).

Traced instead of asserted: TryResolveReferenceTarget (C#) and
resolve_reference_target (Rust) resolve a reference purely from the
citer's schema, the role, and the record key, against the evaluation's
own fixed candidate index (byKey/by_key) - no edge- or caller-specific
input steers which candidate wins. Once From and Role tie (pinning the
citer's schema and the declared target type/leaves) and To.RecordKey
also ties, the resolved row - schema included - is forced identical by
construction, in both runtimes, every time. No mutation can distinguish
it because no input can, so it was deleted rather than recorded as
"equivalent": a component nothing can distinguish is a line every
future reader must reason about for no behavioural gain.

Deletes the To.Descriptor.SchemaId/to.schema_id() ThenBy/then_with from
CultNetSelectionEvaluator.EdgesFor and selection.rs's page_edges sort,
and updates the comments (in-code and the Survivor 5 test block) that
described the old five-key chain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 09:08:55 +02:00
Meta Vens
cda28fa2ae R-AZ: mirror the all-absent Edge vector into the C# parity test
The 246-byte all-absent Edge vector (SM-10) existed only on the Rust
side (selection_wire_parity.rs's CSHARP_EDGE_HEX). The all-absent
header gets incidental cover because it is nested inside the 376-byte
envelope test; the edge has no such cover, so a C# edge encoder that
resumed omitting a null payload/payloadEncoding would still pass every
other test here - exactly the asymmetry R-AP existed to kill.

Adds EdgeAllAbsentMatchesTheConstantCommittedIntoTheRustTest, asserting
the same constant against MessagePackSerializer.Serialize. Flipping one
hex digit of RustEdgeAllAbsentHex fails the new test only, confirming
it is not vacuous.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 09:08:42 +02:00
Meta Vens
35a450e32f R-AX: land the whole-message parity vector, and extend it past all-absent
Soul had this green in the rig at 376 bytes and called it "blocked on
unbuilt page-assembly ownership" - the wrong thing: only the two
*evaluators* producing the same page from the same rows needed that move
(a separate claim, R-AR); the two *encoders* producing identical bytes for
the same page value never did. Land the whole
cultnet.snapshot_response_raw.v1 envelope, byte-identical between Rust's
encode_cultnet_message_to_vec and C#'s CultNetSnapshotResponseRawV1Message,
on both runtimes.

The existing header/edge vectors also proved nothing about tags
(string[] vs Vec<String>) or payload (byte[] vs serde_bytes): both were
absent at every value pinned. Add a header with tags populated and an
edge with a payload populated, captured the same way against a real
MessagePack C# 3.1.7 run on Yggdrasil. Neither diverged - the two
mappings agree byte-for-byte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:40:52 +02:00
Meta Vens
22d0722d5a R-AT: fix the C# self-citer fixture's naming so it actually defeats push-order luck
The zebra/alpha pairing that kills Rust's drop-both-to-components mutant
does not kill it in C#: EvaluateAll concatenates citesEdges before
citedEdges (the opposite of cultnet_rs's single cited-then-cites Vec), so
that pairing's push order already matched the correct sort order here too
- just for the opposite reason. Swap to alpha (self) / zebra (target),
the pairing that actually disagrees with this runtime's own push order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:38:27 +02:00
Meta Vens
0bdcf6fd55 R-AT: add missing MessagePack using the C# self-citer fixture needs
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:35:37 +02:00
Meta Vens
c9ba53cfe1 R-AT: pin the edge comparator's tied-from-and-role survivor in C#
The same live mutant Soul found in Rust (dropping to.schemaId/to.recordKey
from the edge comparator) survives in C# too - both runtimes share the
comparator's shape, not a rule proving it. Land the C# mirror of Soul's
self-citing-peer counterexample from the rig, renamed so the self edge's
push order and the correct sort order actually disagree (the rig's own
"hub"/"other" naming left the drop-both mutation surviving by coincidence
of push order matching alphabetical order - see the Rust commit on this
branch for the construction).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:34:44 +02:00
Meta Vens
62ebba4ab5 R-AO: shared vector for two-in-target shared-key resolution
Adds citer-both-in-target to the shared fixture, the first row in file (and
row-order) to cite "dup-key" through Design - both leaf_a and leaf_b at that
key are in Design's declared target, unlike citer_narrow's narrow_ref above
it (only ever one in-target candidate). Adds the matching vector case to
both runtimes' case lists and regenerates both written vector files
(CULTNET_WRITE_VECTORS=1), so cross-runtime agreement on this shape is
pinned by the parity check, not just asserted per-runtime.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 07:18:11 +02:00
Meta Vens
f01c0b6112 R-AL/R-AM/R-AO: C# regression tests for the fix batch
- R-AL: DataMemberNameCollidingWithAReferenceMembersAliasIsRefusedAtRegistration
  pins D10b's registration refusal. ReferenceMembersExcludesADataMember...
  pins the restored evaluator guard directly (hand-built descriptor via
  reflection, since D10b now closes the registry door this shape used to
  slip through).
- R-AM: CultNetDatabaseServer_SnapshotV1Handler_CatchesAnUntypedFault... and
  RudpCultNetSchemaServer_DispatchBackstop_LogsEveryUntypedHandlerFault pin
  the handler's catch and the backstop's logging, each against a real
  untyped fault.
- R-AO: Evaluator_CitedWithTwoInTargetCandidates_ResolvesToWhicheverThe-
  CallerListedFirst pins "first row in the caller's order wins" when two
  in-target rows share a key, in both row orders.

Every test in this commit was checked against a hand-reverted mutation of
its own production fix and confirmed to fail before being confirmed to pass
against the real fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 07:17:21 +02:00
Meta Vens
3c4f83b038 R-AG/R-AK: checked C# cursor-length arithmetic, a dispatch backstop, and the redundant guard
R-AG. ReadLengthPrefixedFields tested pos + length > bytes.Length in int
math; a length prefix near int.MaxValue overflowed the unchecked add,
slipped past the guard, and Encoding.UTF8.GetString threw
ArgumentOutOfRangeException instead of this method returning null the
way every other malformed shape already does. Promotes to long before
the add, which cannot itself overflow (both operands stay far under
long's range), so the guard sees the true sum.

CultNetRudpSchemaServer.DispatchAsync gains a catch around the handler
invocation: HandleSnapshotRequestV1Async already answers
cursor_invalid/selection_invalid/reference_outside_target for its three
typed exceptions, but nothing stopped an untyped one from an
unanticipated bug reaching DynamicInvoke's caller and stalling the poll
loop for every other peer. Answers nothing on the wire (an exception
this generic carries no typed refusal to report) and drops the one
malformed dispatch, the same as an unregistered handler already does.

Hostile-cursor tests (CultNetSelectionFixBatchTests.cs) feed
CultNetSelectionCursor.Parse a length prefix near int.MaxValue (C#'s own
proven overflow), near usize::MAX (Rust's proven overflow, fed here too
per the ruling), wider than any integer type, and negative-looking -
each asserted to throw the typed CultNetSelectionCursorException.

R-AK. Deletes ReferenceMembers's `!member.IsReference` guard.
CultCache.ReferencesOf (CultCache.cs:262-278) already searches only its
own reference members for a role match and returns empty for anything
else; index aliases are unique per descriptor, so a data member's own
role string can never coincide with a reference member's. Verified
before deleting: traced ReferencesOf's role lookup directly rather than
trusting the ruling's premise. A second copy of the same exclusion here
was duplicate authority, not a second decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 04:44:09 +02:00
Meta Vens
b9db8702d2 R-AF/R-AJ: C# parity vectors and the ordering-composition tests they needed
R-AF: registers ParityCiterNarrow (citer_narrow's C# type, [CultIndex
"narrow_ref"] so its role matches the fixture/Rust spelling), adds
Keys-scoped vectors pinning both shapes from Soul's table - a shared key
where the only candidate is out of target refuses evaluation-time
(expectedEvaluationRefusal, a narrow addition to the vector schema
distinct from the door-only expectedRefusalField - not R-Z/R-AH's
page-bytes harness), and one where an in-target row shares a key with an
out-of-target row and must still resolve.

Landing these exposed a real, pre-existing cross-runtime scope
difference in R-W unrelated to R-AF: Rust checked every declared
reference on a row regardless of the citation's requested role;
C#'s ReferenceMembers(descriptor, citation.Role) narrows by role first.
Fixed on the Rust side (previous commit); hop_by_role_cites_exact_id and
cites_target_by_name_alias keep an explicit Keys scope here anyway as
belt-and-suspenders.

Also corrects S-12's last stale comment: cites_target_by_name_alias's
"Confirmed cross-runtime defect" note was already false - the alias port
fixed it - and still called it confirmed.

R-AJ: CultNetSelectionSurvivorTests.cs's row-tiebreak fixture
(Evaluator_BreaksAnOrdinalTieBySchemaIdThenRecordKeyInBothDirections) was
degenerate - probed by hand-reverting the source and swapping the
tiebreak's two ThenBy calls, it still passed, because whether the old
3-row fixture discriminated the swap depended on which of SelLeafA's/
SelLeafB's real schema ids happened to sort first, and on this repo's
ids it didn't. Rewritten to assign row keys relative to the schema ids
discovered at runtime (low-sorting schema gets the high-sorting key and
vice versa) so the two candidate orderings are always the exact reverse
of each other; reprobed the same mutant afterward and it now fails.

Adds a direct EdgesFor test (constructing FullEvaluation/EdgeMatch by
hand) pinning "From.Key before Role" in the five-key edge tiebreak - the
two existing EdgesFor tests each tie one of those two components, so
neither could tell a Role-first mutant from the rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 04:43:25 +02:00
Meta Vens
99e366a36d R-AA follow-up: kill ComputeDigest's flag-swap survivors, pin EdgesFor's primary key
The first Stryker pass (72.18%, up from 66%) showed the named dozen mostly
dead but surfaced two related gaps the "differs" style of assertion cannot
reach on its own:

- ComputeDigest's field-number and cites.role presence flags each had an
  equality-mutation survivor (field.Number != null / selection.Cites.Role
  != null) that a same-vs-different digest comparison cannot catch: the
  mutant swaps which of two cases gets which flag value, so both cases
  still digest differently, just under swapped labels. A handful of plain
  AppendString/sb.Append deletions survived alongside it for the same
  reason - removing content that a same-vs-different pair didn't happen
  to isolate. One independently reconstructed golden HMAC value, built
  from the method's own doc comments rather than its private helpers,
  pins the whole wire shape at once: any single append or flag polarity
  wrong breaks the exact match.
- EdgesFor's OrderBy(pageRowIndex) - the primary "page-row order" key R-B
  names before "(from, role, to)" - had no test forcing page position and
  from/role/to order to disagree, so it survived alongside (and separate
  from) the five ThenBy mutants already named.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:48:10 +02:00
Meta Vens
8ee1ebaf67 R-AA: behavioural tests for the dozen real Stryker survivors
Soul's triage on 52082ec named ~12 live survivors across
CultNetSelection.cs/CultNetSelectionEvaluator.cs, each sitting on a rule
this cut exists to enforce, plus 75 uncovered mutants behind them. Each
new test drives the public selection/evaluator surface with a fixture
built to make the rule's failure observable in the answer, not in the
code's spelling - several needed new fixtures (a mixed numeric/string
alias, an index alias differing from its member name, two reference
members resolving to one target) because their absence is why the
mutant survived in the first place.

Two of the five EdgesFor order-flip mutants (To.SchemaId/To.Key) are not
addressed: within one hop direction an edge's (From, Role) already
determines its To deterministically, so no legal selection can tie
anchor+From+Role while To differs. Recorded as a likely equivalence,
left for Stryker's own verdict rather than forcing a contrived test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:37:38 +02:00
Meta Vens
b7d77b708d Fix batch 4 (R-X, S-4/S-10): the error schema matches the wire, and the validator refuses a keyword it does not implement
CultNetErrorMessage is a MessagePack-CSharp map-mode object, so every
declared key is written on the wire, nil included - the same fact R-S
already established for the raw document header. cultnet.error.schema.json
was missing routingHint entirely and typed code/details as always-present
non-null values; Soul's P7 found real bytes failing it on exactly this.
routingHint, code and details are now `required`, with nullable types
matching CultNetErrorMessage/CultNetErrorDetails. A new test decodes real
bytes for all four refusal codes (selection_invalid, cursor_stale,
cursor_invalid, reference_outside_target) against the schema.

MiniJsonSchemaValidator:
- implements minimum, maximum, maxLength and maxItems, which were declared
  in committed schemas (fieldPredicate's number pattern, selection.limit,
  ...) but silently never checked;
- anchors `pattern` to a full match (Index==0 && Length==value.Length)
  instead of Regex.IsMatch's "search anywhere", so .NET's `$` (which,
  unlike ECMA-262's, matches immediately before one trailing newline even
  without RegexOptions.Multiline) can no longer let e.g. "5\n" pass a
  schema author wrote as "exactly one digit";
- refuses a schema node that uses a keyword this validator gives no
  constraint semantics to, instead of silently ignoring it. Running every
  committed contracts/cultnet/*.schema.json schema through this found 12
  such uses, none reached by any existing test: `allOf`/`if`/`then`/`else`
  in cultnet.database-change-raw.schema.json, and `uniqueItems` (six
  places) plus one `anyOf` in the cultmesh.verse-catalog-*.schema.json
  pair. contentEncoding, $schema/$id/$defs/title/description/default and
  the x-cult-runtime-type vendor extension are treated as metadata with no
  constraint semantics to implement, not refused.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:12:23 +02:00
Meta Vens
b3a8f6d195 Fix batch 4 (S-9/S-11): shard-scoped asOf, and the v1 subscribe door runs before the hop refusal
S-9: CurrentAsOf() returned the maximum shard-log sequence across every
shard the database holds, but that sequence is a per-shard counter -
mixing shards' counters together could report a page as exact "as of" a
sequence a different, untouched shard advanced to while the page's own
shard sat still. CultNetDatabase gains CurrentAsOf(shardId), the watermark
of one shard's own log, and CultNetDocumentRegistry.SelectPage now scopes
asOf to the single shard EnsureSingleShard already resolves the matched
rows to, falling back to the caller-supplied value only when the selection
matches no rows.

S-11: CultNetDatabaseServer.HandleSubscribeV1Async refused a hop-bearing
selection before running the door (Validate), so a selection invalid for
an unrelated reason (an empty keys list, an undeclared index, ...) got a
hop refusal that had nothing to do with why it was actually rejected. The
door now runs first, unconditionally. The hop refusal also always named
the field "cites" even when the selection carried Cited instead; it now
names whichever hop is actually set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:12:07 +02:00
Meta Vens
e99bf46520 Fix batch 4 (R-V/R-W/S-8/R-Y): row identity is (schemaId, recordKey), and the cursor digest covers its own body
R-V (S-1): byKey and BuildIncomingIndex keyed the hop's row resolution by a
bare record key. Two schemas sharing one key collapsed into a single
dictionary slot (last writer wins), so which row a reference/citation
resolved to - and therefore whether a selection matched or refused
reference_outside_target - depended on row insertion order. byKey is now a
bare-key -> row-list index, and a shared TryResolveReferenceTarget searches
each edge's declared leaf set to resolve the one row that actually belongs
to it, deterministically regardless of order (Soul's P2, tested both ways).
EdgesFor's page-row anchor index is keyed the same way for the same reason.

R-W (S-2): EnsureWithinDeclaredTarget's equivalent check (now folded into
TryResolveReferenceTarget) now runs for every edge a reference carries,
before a citation's own key/schema filters that edge out - not only for the
edge the citation happens to ask about. A many-reference edge into a schema
outside its declared target refuses the selection even when the caller's
key filter would otherwise skip past it without ever inspecting it (Soul's
P3).

S-8: the dead `targetType ?? typeof(object)` fallback is deleted. D11
already refuses every reference shape whose target the cache cannot infer
before it can reach the evaluator, so the branch had no reachable fixture.

R-Y: the cursor's HMAC now covers its own body (asOf, ordinal, schemaId,
recordKey) as well as the selection. Before this, a forged cursor could
reuse a legitimately minted digest while rewriting the ordinal or record
key underneath it, since nothing about the body was ever authenticated
(Soul's P4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:11:54 +02:00
Meta Vens
52082ec720 Add "not" support to MiniJsonSchemaValidator
fieldPredicate's oneOf branches use `not: {required: [...]}` to make
values/number exclusive; the validator had no "not" handling at all, so it
silently ignored the keyword and FieldPredicate_ValuesAndNumberTogether_FailsItsSchema
found nothing wrong. A schema matches "not" when validating against the
subschema produces zero errors, in which case the instance must not match it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:13:53 +02:00
Meta Vens
f47060d5c2 Land fix batch 3's R-S: the schemas describe the bytes C# writes
New contracts/cultnet/cultnet.raw-document-header.schema.json for
CultNetRawDocumentHeader - it has no payload/payloadEncoding members at all,
so headers must not $ref the record schema (which required both). The
response page's own schema now lists every property CultNetSnapshotResponseRawV1Message
declares as `required` (present on the wire, nil included) rather than only
the four that happened to be non-nullable - MessagePack-CSharp's map-mode
formatter writes every declared key regardless of null, so `headers`/`documents`
are always both present, one an array and the other an explicit null by
projection. The old `oneOf: [{required:[headers]},{required:[documents]}]`
matched both branches on every real page (both keys present) and so never
validated anything; it is now `oneOf` over each key's type (array vs null),
which is mutually exclusive. cultnet.raw-document-record.schema.json gained
schemaName/schemaVersion/schemaContentHash (present on every real record, not
previously in the schema at all - any real page would have failed
additionalProperties:false on them). fieldPredicate's two oneOf branches each
gained `not: {required: [the other]}`, so `values` and `number` cannot both
be present (previously only `required` was checked, which does not exclude
the sibling key).

Added MiniJsonSchemaValidator, a deliberately small draft-2020-12 subset
validator (type/required/additionalProperties/properties/items/const/enum/
pattern/minLength/minItems/oneOf/$ref, same-document and cross-document) -
no schema-validation dependency existed in this repo, and this only needs to
cover what contracts/cultnet/*.schema.json actually use. CultNetSchemaContractTests
decodes real wire bytes from a live CultNetDatabaseServer (header projection,
document projection, and a hop with edges) through MessagePackSerializer.ConvertToJson
and checks them against the committed schema files, plus three
fieldPredicate-shape tests pinning the values/number exclusivity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:12:58 +02:00
Meta Vens
a4cf54f4a9 Land fix batch 3's R-O and R-Q: keyed length-prefixed cursors, replica-apply sequence gap
R-O: CultNetSelectionCursorKey is a per-process (per-CultNetDatabase-instance) 32-byte
random key. CultNetSelectionCursor.ComputeDigest is now an HMAC-SHA256 under that key
instead of an unkeyed SHA-256, so a cursor cannot be forged without the key and does not
verify across a restart (a fresh CultNetDatabase mints a fresh key). The cursor body
(asOf/ordinal/schemaId/recordKey/digest) is length-prefixed (<byte length>:<value>, no
delimiter) instead of joined with a bare "\x01"-separated string, so a record key
carrying any character, U+241F included, round-trips. CultNetSelectionEvaluator.Select/
Page/ComputeDigest take an optional CultNetSelectionCursorKey, defaulting to a
process-wide key generated once so existing callers that never wire up a database still
get a keyed digest. CultNetDatabase gets a CursorKey property (random unless
CultNetDatabaseOptions.CursorKey is supplied) and passes it through
CultNetDatabaseServer.CreateSelectionResponse.

Rust already landed R-O (5aac120) under its own algorithm, deliberately not byte-matching
the reference: its own doc comment says a cursor is never handed across runtimes, so only
collision-freedom and keying are load-bearing, not the wire bytes. That is a real
divergence from this pass's instruction to match Rust's wire encoding exactly - reported
in full in the batch report.

R-Q: the two replica-apply paths (ApplyCommittedPutAsync, ApplyCommittedDelete) recorded
the mutation-log entry but never set _lastWriteSequence, so a row's order/cursor position
looked like "never written" until it was written again locally. Both now set it from the
entry's own sequence, matching AppendMutationLogEntry's local-commit path.
InitializeLogSequencesFromStore rebuilt only _nextLogSequences at startup; it now also
rebuilds _lastWriteSequence by replaying the durable log's Put/Delete entries in sequence
order. CultNetDatabase.ResolveShard is a new public wrapper over the existing shard
resolution; CultNetDocumentRegistry.SelectPage/CreateSelectionResponse take an optional
shardIdOf callback and refuse (reusing CultNetSelectionInvalidException/selection_invalid,
R-N) when a selection's matched rows resolve to more than one shard's log, because asOf is
a single watermark and is not exact for a shard-spanning page.
CultNetDatabaseServer.CreateSelectionResponse wires shardIdOf/cursorKey through from the
database.

Also fixes a defect the Rust Hands found while matching wire bytes:
CultNetErrorMessage.ForReferenceOutsideTarget double-prefixed "reference_outside_target: "
- the exception's own message already carries it. Now prefixed once, matching Rust's
ForReferenceOutsideTarget.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:04:22 +02:00
Meta Vens
e8a3decbab Fix batch 3 tests: R-N wire decode, R-P wire-id authorization, R-T removal/cites
Four CultNetErrorMessage wire round-trip tests (decode the bytes a peer actually
receives) proving code+details for selection_invalid, cursor_stale, cursor_invalid,
reference_outside_target. A subscription-server round trip proving snapshot and
first live change are authorized by the same wire id (R-P). A subscription-server
round trip proving a removal under a field-predicate selection is delivered with
no exception (R-T(a)). An evaluator test proving an implicitly-typed many
reference still refuses an out-of-target edge (R-T(b)).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:46:32 +02:00
Meta Vens
b1932d95ed Add the two pending cites-refusal parity vectors, now that C# refuses them
Item 1 of the fix-batch-3 brief: check whether CultNetSelection.Validate
refuses a cites.target.schemaId that CultNetSchemaAliasMatching cannot
resolve. It already does (CultNetSelection.cs:389-394), landed by an earlier
Hands pass on this branch (the "C# matrix Hands", after the parity table in
the spec was captured) - so no production code changed here.

What was still missing: SelectionParityVectorTests.RefusalCases() had a
comment explaining why refuses_unmatched_cites_target_schema stayed
"Rust-only", dated before that door check landed. The comment was stale and
the vector was never added. Added it, plus
refuses_cites_target_by_hash_shaped_alias (Self's ruling 2026-09-22: "the C#
reference's alias rule is the rule" - C# only resolves a name-form alias, so
a hash-shaped cites.target.schemaId is exactly the same unresolved-target
case and must refuse at the same field). RefusalCases() now takes the
fixture's schemas dictionary to reach schemas["leaf_a"].HashAlias.

Verified on Yggdrasil: dotnet test tests/GameCult.Networking.Tests -c Release
--filter FullyQualifiedName~SelectionParityVectorTests (the existing
cross-runtime reader against the committed rs-written.json still passes, so
this change does not depend on anything the Rust Hands owns). Regenerated
contracts/cultnet/interop/selection-vectors.cs-written.json with
CULTNET_WRITE_VECTORS=1: both new refusal vectors are present and both
refuse at "cites.target.schemaId" as expected before being committed as
vectors (WriteSelectionVectors's own Assert.Throws gate).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:22:18 +02:00
Meta Vens
5e33d81e25 R-Q3, R-R: v0 lowering preserves v0's pre-cut answers exactly
CultNetV0SelectionLowering.Lower collapsed a non-null-but-empty filter list to
null ("no filter"). That corrupted two pre-cut v0 behaviours the same
function feeds:

- CultNetDatabase.CreateShardSnapshotResponse and
  CultNetDocumentRegistry.CreateRawSnapshotResponse (b3d9cf7) both test
  `filter?.RecordKeys != null`, not a length, so `recordKeys: []` always
  answered empty. Lowering the empty list to null made it answer every row
  instead. This corrects the ruling from the commit-2 fix batch that lowered
  an empty v0 list to "no filter".
- CultMesh.ResolveDefaultSelection used the same collapse to decide whether an
  explicit empty recordKeys should still fall back to the document type's own
  schema default; under the corrected Lower it must not.

Lower(null) still means "no filter"; Lower(non-null) is always a real
(possibly empty) array. The evaluator already treats a non-null empty Keys
array as "match nothing" (CultNetSelectionEvaluator.MatchesSchemaKeysFields);
only the v0 lowering was erasing that signal before selections reached it.

Also restores the pre-cut requested-key order for
CultNetDocumentRegistry.CreateRawSnapshotResponse: pre-cut, requested record
keys were iterated in the order the caller sent them (a HashSet<string> built
from the array, which enumerates in insertion order); the new shared
evaluator's constant ordinal fell back to schema-and-key sort order instead.
ordinalOf now ranks rows by their position in the requested key list when one
was given.

Rewrote CultMeshStreamingTests.FetchDocumentsAsync_WithExplicitEmptyRecordKeys
(renamed ...AnswersEmpty) to assert the corrected behaviour instead of the
ruling it used to pin. Added
CultNetDocumentRegistry_RawSnapshot_ExplicitEmptyRecordKeys_AnswersEmpty,
CultNetDocumentRegistry_RawSnapshot_KeepsRequestedRecordKeyOrder and
CultNetV0SelectionLowering_Lower_DistinguishesNullFromExplicitEmpty to
NetworkingTests.cs.

Not done in this commit: CultNetDatabase.CreateShardSnapshotResponse (the
shard-replication snapshot path) still orders by the evaluator's default
schema-and-key tiebreak. Its own pre-cut order was raw cache iteration order,
which the new evaluator has no equivalent for and shard replication does not
appear to depend on row order; flagged as a discrepancy rather than changed
without a test that would pin the "right" order. The side-by-side b3d9cf7 vs
HEAD comparison run the spec asks for was not executed in this pass; the
pre-cut behaviour above comes from reading CultNetDatabase.cs and
CultNetDocumentRegistry.cs at b3d9cf7 directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:13:31 +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
3317dddf46 CultNet selection cut1: the shared C#/Rust parity fixture
Reconciles the two Hands' independently-regenerated parity vectors
(Soul's whole-cut pass, F3): the C# fixture used schema *names*
("leaf_a") as literal ids, the Rust fixture used invented fake
sha256-shaped ids - every vector carrying an id failed when read by
the other runtime.

Adds contracts/cultnet/interop/selection-vectors.fixture.json: the one
row set both writers now build from, keyed by each schema's real
SHA-256 content-hash id exactly as CultDocumentRegistry computes it
(dumped via a temporary DumpFixtureSchemaIds run). Carries the rows
either runtime needs - astral/BMP-private-use keys, a float tie,
cites/cited, a cites target reached by alias - plus two DIFFERENT
alias forms, because the two runtimes' alias matchers key off
different descriptor attributes and no single candidate resolves
through both (see the fixture's "//aliases" note and the report).

Rewrites SelectionParityVectorTests.cs to load the fixture instead of
hand-building rows (BuildFixture/BuildExtendedFixture deleted), assert
the fixture's declared schema ids still match what the registry
computes (fails loudly on drift instead of silently comparing wrong
ids), and use the real schema id (not SchemaName) as a row's
cross-runtime identity. Also fixes a real bug this file carried since
before the shared fixture: both the writer and the reader compared
`evaluation.Rows.Count` (the page size) against "matched" instead of
`evaluation.TotalMatched` (the total, per R-G) - selections with a
Limit were writing/checking the wrong number. The reader now collects
every vector's mismatch instead of stopping at the first one, so a
real parity defect is reported in full.

Regenerates selection-vectors.cs-written.json from the shared fixture.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 11:33:29 +02:00
Meta Vens
3d32c67821 CultNet selection cut1 fix batch: regenerate the C#-written parity vectors
Regenerated contracts/cultnet/interop/selection-vectors.cs-written.json
under R-B/R-C/R-D/R-E/R-I.

SelectionParityVectorTests.cs: the six original cases' fixture
(BuildFixture) is left untouched, because SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference
reads the already-committed selection-vectors.rs-written.json against
it, and a new row reachable by an unfiltered or loosely-filtered case
(descending_with_limit, gt_numeric_boundary) would silently change that
case's expected page. The fix-batch rows and cases live in a new
BuildExtendedFixture/Cases set, additive over BuildFixture, used only
by WriteSelectionVectors:
- float_tie_exact_ge: a row at 2233759.25f, on the round-to-even tie
  R-D's exact BigInteger rendering fixes (its canonical decimal is
  "2233759.25" regardless of which shortest form a runtime's ToString
  would have picked).
- cites_target_by_alias: cites.target.schemaId given as "leaf_a"
  (ParityLeafA's SchemaName) against a-eq's real content-hash SchemaId,
  exercising R-E's alias-matched cites (the pre-fix code required an
  exact SchemaId match and this always missed).
- astral_key_code_point_order: two rows sharing one ordinal, keyed by
  a BMP private-use character (U+E000) and an astral character
  (U+1F602, surrogate pair 😂), pinning R-C's code-point
  tiebreak order.
- header_projection/document_projection: the same hop-bearing match set
  under each projection.

Known-expected failure: SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference
fails against the currently-committed selection-vectors.rs-written.json
(incoming_exists_true's edge count) because that file predates R-B and
was not touched here - the parallel Rust Hands owns it. Re-run once it
regenerates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:40:18 +02:00
Meta Vens
915262fee2 CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix
- R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20
  mutants - dropping `wantDocument &&` in ToEdge (a header-projection
  edge would carry document payload bytes anyway) and always emitting
  Edges instead of `selection.HasHop ? ... : null` (a hop-less
  selection's page would carry an empty array instead of no Edges
  member) - plus a matched-vs-page-length test for R-G. Fixing
  ToEdge's mutant coverage surfaced a real bug the new tests caught:
  it serialized an edge payload through CultDocumentMessagePackSerialization,
  which requires the payload's type to be a registered CultDocument
  and throws for a plain declared value type (float, in the fixture);
  it now serializes directly through MessagePackSerializer.
- R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's
  $ matches immediately before a single trailing newline even without
  RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z
  and would have accepted "5\n" as canonical without this. Caching's
  TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits
  is updated for 1e-7's actual exact decimal expansion (the R-D commit's
  own effect): the old assertion pinned "0.0000001", the shortest
  round-trip form a pre-R-D render would have produced.
- R-K: every serverThread.Join() in NetworkingTests.cs that sat outside
  a try/finally now sits inside one, so a failed assertion cannot leave
  the poll thread running past the point where `using` disposes the
  socket it polls (an ObjectDisposedException on that raw background
  Thread was a host crash, not a test failure). The bodyDemand.HasConsumers
  assertions wait on the condition instead of assuming
  SubscribeAsync's completion and PublishDemand happened in an order
  a network round trip does not guarantee.
- Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias
  built a v0-shaped CultNetDatabaseSubscribeMessage to invoke
  CreateMatchedRecord by reflection; that method now takes the shared
  SubscriptionRequest (R-A), so the test builds one through the same
  reflection path instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +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
0787608cef CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher
_projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production
caller and ran after authorization with no wire consumer. Removed the field,
constructor parameter, and both call sites in Watch() and
CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a
delegate. The two tests that used projectRecord to simulate a record's
identity changing for the reconcile diff
(DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords,
…ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten
to drive a real rename through the database (delete the old key, put the new
one) instead, so they still prove authorization filtering and the Reconcile()
diff/body-demand behavior.

Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth
schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse
and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate
InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one
matcher landed in commit 1.

Networking tests: 162/162 (was 162/162 after commit 1; no count change, two
tests rewritten not added). Negative grep: _projectRecord|projectRecord empty;
MatchesRequestedSchema gone (one remaining substring hit is
RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this
commit does not touch — collapses in the next commit).

Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs,
CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this
commit; it is large enough to build on its own and follows as commit 2b.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +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
7c4bc2bdc0 Make generated and reflective document members one rule
The generator and CultDocumentRegistry now discover members by the same
steps: most-derived declaration first, override chain collapsed to one
member, attributes read up the override chain, rejections in one order
with one text (GCC001 / InvalidOperationException).

- Accessors are named per document, so documents sharing a [CultName] or
  [CultIndex] base member no longer emit duplicate methods (CS0111).
- Duplicate [Key] slots are refused, including new-hidden fields and
  properties; a hidden persisted member with a different slot is refused.
- String [Key("x")] gets its own message instead of "has no [Key]".
- A persisted property whose set accessor is private, protected or init,
  or a readonly field, is refused instead of CS0272 in generated code.
- Overrides: MessagePack's DynamicObjectResolver reads the base
  declaration's [Key]/[IgnoreMember] (it throws "key is duplicated" for a
  re-keyed override and serializes an override marked [IgnoreMember]).
  An override that changes them is refused in both paths; an override
  that repeats or omits them is accepted and byte-pinned.
- Typed Serialize<T>/Deserialize<T> used static Options and skipped
  generated codecs and declared resolvers; deleted. CultNetDocumentBinding
  and CultMeshSnapshots route through the untyped path with their registry.

DocumentShapeSweepTests compiles each shape through the generator and
compares generated vs reflective schema id, catalog, name/index values
and payload against MessagePack's own bytes, or equal rejection text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:00:16 +02:00
Meta Vens
456234018f Carry order as data: per-cache change Sequence, no delivery scheduler
bcae483 made the cache a delivery scheduler (tickets, _deliveryGate turn
waits, per-thread delivery queues). It deadlocked inside CultLib: a load's
delivery reaching a CultNetDatabaseSubscriptionServer observer that needs
_lifecycleGate, while a subscribe's demand handler holding that gate writes
the cache and waits for the delivery's turn.

The cache now owns order as data. Every admitted change takes the next value
of a per-cache in-memory sequence under the gate, published as
CultCacheDocumentChange<T>.Sequence. Delivery is the 544c087/b653f68 rule:
each call publishes its own changes on its own thread after releasing the
gate; nothing waits on another call's delivery. Cross-thread delivery order is
not guaranteed.

Deleted: ticket counter, _delivered, _deliveryGate, Monitor.Wait turns,
per-thread delivery queues, per-ticket catch-all, and the save-order contract
text with its observer-must-not-block rule.

Consumer: a cache-backed CultMesh document handle hands its observed and
reactive mirrors each snapshot's Sequence; ApplyCanonicalSnapshot drops one
not newer than the last applied.

Tests: SequenceIncreasesInAdmissionOrder,
ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries,
SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock,
WatchRecordIgnoresStaleSequence. The last three time out on bcae483.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:43:11 +02:00
Meta Vens
03a71ebd66 Pin CultNet publish-after-commit, add write-routed interop, update contract status
- NetworkingTests.CultNetPutInsideCommitPublishesOnlyAfterCommit: a record
  staged in a batch reaches no CultNet observer before the store commits; a
  put made while staging is its own write. Chosen over refusing CultNet writes
  inside a batch, which would need ambient "inside a batch" state.
- Interop peer mode write-routed <catalog.cc> <run.cc>: cultcache.interop-note
  routes to the catalog file, cultcache.interop-run-note to the run file, one
  record each.
- store-composition contract: the C# column reads implemented for routing,
  attach, schema, lookups, globals, batch and conditional commit; overwriting
  a store is open, remove, upsert, flush. persistence-format: C# implements
  batch and conditional commit.
- tests README lists the new suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:58:00 +02:00
Meta Vens
88e1c8e157 Delete CultNetDatabase's transaction wrapper
ExecuteTransactionAsync (both overloads), RequireTransactionsForAuthoritativeWrites,
EnsureAuthoritativeTransaction and the AfterCommit deferral had no consumer
outside CultNetDatabase_RequiredTransaction_DefersRawPutPublicationUntilCommit,
deleted with them. Writes publish immediately, as they already did outside a
transaction. The cache's own transaction machinery stays until Cut 3 replaces
it with the explicit batch commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:35:56 +02:00
Meta Vens
e9e9c60b0f REC-102: cut stale generated mirrors and a dead maintenance receipt
Deletes four confirmed-dead tracked files:
- CultLib.txt, src/GameCult.Caching/GameCult.Caching.txt,
  src/GameCult.Unity/Assets/UI/UI.txt: concatenated snapshot copies of live
  source, carrying stale E: drive provenance and obsolete dependency
  versions. Live source under src/ remains the owner.
- tests/GameCult.Networking.Tests/lcov.info: tracked coverage output.
  .vscode/tasks.json:14 still generates it fresh via
  "/p:CoverletOutput=./lcov.info" on every coverage run, so tracking the
  stale committed copy in git is the liability, not the generation step.
- .voidbot/status/void-memory-maintenance.json: a one-time "starting"
  receipt (added whole in 4813570, never touched again) naming a dead
  E:\Projects\CultLib path and an unimplemented repo_face_heartbeat
  launcher. VoidBot's actual heartbeat state lives at
  .voidbot/status/repo-face-heartbeats.{cc,json} (see
  packages/config/src/index.ts REPO_FACE_HEARTBEAT_STATE_PATH) — a
  different file entirely; nothing reads this one.

Kill attempts:
- git grep -n for each of "CultLib\.txt", "GameCult\.Caching\.txt",
  "UI\.txt", "lcov\.info" across origin/main, excluding each file's own
  path: only .voidbot birth/distiller snapshots (auto-generated onboarding
  file-listing catalogs, not functional readers) and the one real lcov.info
  producer in .vscode/tasks.json.
- rg across all of F:/Projects (excluding target/node_modules/.git):
  gamecult-site's docs/research/gamecult-org-repo-ledger.json lists
  "CultLib.txt" as a filename in a static historical repo-file inventory
  (not a functional consumer); Aetheria/docs/cultcache-migration-cut.md
  cites GameCult.Caching.txt:157 as an evidence line number in a
  migration-history note (stale citation after this cut, not a functional
  break — flagged for the record, not fixed here since that doc is out of
  scope).
- voidbot MCP search for void-memory-maintenance.json / repo_face_heartbeat
  found only the unrelated repo-face-heartbeats state path, confirming no
  reader.

Collision check: git diff --stat origin/main against each of the other 4
CultLib worktrees (codex/recover-aetheria-authority,
codex/delvehold-isosurface, claude/media-stream-catalog, and the two
detached-HEAD worktrees, both confirmed ancestors of origin/main) over
these five paths: all empty, no in-flight work touches them.

REC-102

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 14:10:10 +02:00
Meta Vens
8d3025816d Align RUDP reliability tests with sequence domains 2026-09-04 00:33:23 +02:00
Meta Vens
0736ae4824 Separate RUDP reliable and lossy sequence domains 2026-09-04 00:33:16 +02:00