The dispose test showed journal-first alone still lost the change: Dispose won the race against the writer's publication and cleared the stash. Dispose now drains the stash through the publisher after the journal is disposed (so nothing is stashed afterwards) and before the subject completes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Q1: HandlePutAsync/HandleDeleteAsync catch a CultNetShardLogException (or an
AggregateException holding one) before the generic catch and answer nothing: the
write committed and was published, so the peer is not told it was refused. It is
logged at warning level. The wire has no committed-with-degraded-log message.
Q2: GetAsync/GetAll/GetByName/GetByIndex, the shard-less snapshot, the selection
and the subscription snapshot serve only rows a shard owns, so read and watch
agree; the CultMesh collections follow through GetAll, and the Mesh untyped
variant fallback checks ownership. The raw cache still holds everything.
Q3: a replica log apply skips a row its shard does not own, as the snapshot path
does, and still records the entry so a chained replica sees no gap.
API: rowFilter is internal (public CreateSelectionResponse and
CreateRawSnapshotResponse signatures restored, internal overloads added);
exception tags on the write methods; Commit's unwrap rule is XML doc; the
unowned refusal answers on the wire with code unowned_schema (schema enum in both
copies, Rust CultNetErrorCode, CultNetErrorMessage.ForUnownedSchema) and put and
delete log it at warning level.
Dispose keeps journal-first; a test now fails if the observer goes first.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
ResolveShard now throws for an unowned schema, so one such cached row would
have failed the whole selection. The server passes a row filter
(CultNetDatabase.Owns) to CreateSelectionResponse, which gains an optional
rowFilter that SelectPage already took, so those rows are not part of what the
database serves and the rest is answered.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
ResolveShardInternal throws CultNetUnownedSchemaException instead of falling
back to the first shard. PutAsync, DeleteAsync, PutPredictedAsync,
ApplyPutAsync and ApplyDeleteAsync refuse an unowned schema before the cache is
touched; a remote put reaches the server's generic catch and is answered as an
application rejection like any other refused remote put. ResolveShard throws
(documented), CanWriteAuthoritatively returns false, and a bare cache write no
shard owns is neither logged nor published. The unused string overload of
EnsureClientAuthority is deleted. No GameCult consumer configures shards
outside vendored CultLib copies; the default shard owns everything.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
PutAsync and DeleteAsync carry no door, so the journal cannot place an unowned
schema and now skips it; accepting the write anyway was a silent loss of
replication. They resolve their shard strictly and throw
CultNetUnownedSchemaException (an InvalidOperationException naming the schema)
before the cache is touched. Doors keep the fallback shard they were resolved
to. No GameCult consumer configures shards outside vendored CultLib copies:
AetheriaEve's node uses the default PrimaryAll shard, which owns everything.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
F1: InitializeLogSequencesFromStore raises the in-memory floor to the highest
gap above the store's compaction point, so a restart after a double refusal or
an interrupted compaction answers compacted_history instead of a gapped log.
F2: the journal raises one CultNetShardLogException per call carrying every
burned (shard, sequence); the unwrap rule for a cache-aggregated hold is
documented on the exception and on the cache commit.
F3: TakeDoor no longer refuses a door whose instance is the cached one, so
mutate-then-predict publishes Predicted and is not logged.
F6: a door-less change no shard matches is not logged; doors keep the
fallback shard they were resolved to.
Dispose disposes the journal before the observer. The log store doc says a
slow store blocks cache readers. Soul F4 is ignored as an accepted follow-up,
M1 is explicit.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The change kind in the door's wire entry was restated from the log entry by
NormalizeWireLogEntry, so computing it was dead code (Stryker survivor). The
entry now carries just the door's put or delete message.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Cut 4b-4d of the publication map. The reorder buffer, drainer, prediction gate
and the logging inside the Watch handler are gone. The database registers the
Watch observer, then one cache journal. The journal runs under the gate in
Sequence order: it takes the door, decides the change kind and prediction
bookkeeping, logs (a primary mints, a replica records the primary's entry) and
stashes the change; the Watch observer publishes the stash entry by Sequence.
A primary that cannot log a committed change burns that sequence, raises its
own compaction floor, tries CompactThrough, and throws CultNetShardLogException
(not an InvalidOperationException) after publication. A replica's refused
append reaches the pull loop and the cursor does not advance.
Fixes four defects the map named: the log is appended to disk before memory
(and memory is a SortedList keyed by sequence); the snapshot reads its log
sequence before its documents; a restart resumes above the compaction point;
GetCompactedMutationLogSequence and GetLatestMutationLogSequence honour the
in-memory floor. The snapshot removal door sets Removal.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Cut 4a: brings the publication tests and the door table from
hands/cultnet-publish-commits at 28248ebe. The reorder buffer, drainer and
prediction gate die in 4b.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
FlushReliable throws for writes an ended session forgot. A server-mode
transport and the listener answer a Connect from the peer they already hold
with AnswerRepeatedConnect: the Accept still owed, or an Ack, queueing
nothing. The listener replaced the peer session on every Connect, reusing
sequences and never reporting PeerDisconnected; it now replaces only on a
different connect payload and reports the old peer, as the Rust hub does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CultNetRudpPacketCodec.Decode did checked((int)length), so a datagram declaring
0x80000000 threw OverflowException, which no receive loop catches; the length
is now compared as a long and takes the ordinary malformed-packet drop.
Server mode claimed its endpoint on the first datagram of any type; only a
Connect claims it now. A session that refuses a packet has already recorded
its reliable sequence, so the socket transport and the listener end it: the
session forgets the peer (so the goodbye does not acknowledge the refused
frame), the peer gets a Disconnect, the listener drops the peer and raises
PeerDisconnected, and a server-mode transport forgets its endpoint.
CultNetRudpSession gains ResetPeerState, and the session and the listener
reject an InitialSequence of uint.MaxValue.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A malformed frame, a foreign connection id or a packet the session refuses
threw out of TryReceiveOnce. They are now counted in Stats.PacketsDropped and
the read reports progress. The client-side transport checks the connection
id before it claims the peer endpoint; the server listener drops refused
packets instead of throwing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The handler now parks a change ahead of a gap and drains one thread at a time, so
the log and stream follow cache admission order and a subscriber that writes the
cache queues behind the change it handles. A door context matches only a change of
its own shape: a put door wrote an instance no cache held, a removal door removes
it, so neither a plain removal nor an in-place re-upsert can wear it. A replica
records every primary entry even when the key is absent, so chained replicas see
no gap. Predictions are locked and cleared by the handler before publication.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous cut left two owners of publication: doors published and logged their
own change while a flow-scoped key mark told the Watch handler to skip it. A write
an observer made to the door key inside that scope was dropped, a door change and
its re-resolved variant were logged in the wrong order, and log state was
unlocked. Doors now register the context they alone know (wire message,
reconciled prediction, replicated entry) under the identity of the instance they
write or remove; the Watch handler consumes it once for that change and is the
sole publisher and logger. Only a shard primary mints log sequences; a replica
logs only the primary entries its replica door hands over. Log state sits under
one gate. The AsyncLocal mark and every door-side publish or append are deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CultNetDatabase watched OnUpdate, which fires for loads alone, so cache.Commit,
cache.UpsertAsync, variant dependents re-resolved by a base edit and Flatten never
reached subscribers or the mutation log, and a load removal carried an empty key.
The database now observes cache.Watch, the one owner of admitted changes. A write
door of the database (PutAsync, DeleteAsync, ApplyPut/Delete, replica apply, sync)
marks the key it is committing and publishes that change itself, since it carries
the shard-log entry; every other change publishes from the cache, keyed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
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>
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>
A variant record stores [baseKey, overrides[]] in record slot 4 and an empty
payload; the cache resolves it against its base inside the hold that admits it or
any record on its base chain, and re-resolves dependents in the same Apply, so a
base change reaches its variants with contiguous sequences and no stamp. Plain
Upsert at a variant key is refused (Flatten is the explicit way); cycle, missing
base, different concrete type, evicted base with survivors and a name that
shadows the base are refused naming the keys. Overrides of a dropped slot or an
undecodable value are ignored with a warning and reported as compatible drift.
The store writes cultcache.store.v2 only when it holds a variant and reads v1 and
v2. The codec pair CultInspectorModel took becomes CultCodec, shared with the
cache, which needs it to clone documents and overlay override values. The
directory store, CultMesh single-file reads and CultNet row building refuse a
variant by name until their own cuts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
HandleSnapshotRequestV1Async only caught the three typed selection
exceptions, unlike its siblings HandleShardLogRequestAsync/HandlePutAsync,
which catch Exception and answer a CultNetErrorMessage. An untyped fault
(e.g. a null Selection) escaped into the RUDP dispatch backstop, which
swallowed it with no log - the same backstop that hid SM-6's
ArgumentNullException regression for a whole batch.
- HandleSnapshotRequestV1Async now catches Exception like its siblings and
answers a CultNetErrorMessage.
- RudpCultNetSchemaServer gets a Logger (ILogger/NullLogger, same pattern as
Server/Client), and DispatchAsync's backstop always logs the fault it
swallows, unwrapping DynamicInvoke's TargetInvocationException wrapper so
the logged message is the handler's real exception, not the generic
invocation-wrapper text.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CultNetSelectionEvaluator.ReferenceMembers dropped the `!member.IsReference`
guard on the premise that a data member's role string could never coincide
with a reference member's alias - false. CultCache's D10 only rejects a
collision between two *aliased* members; an unaliased data member's bare
name was never checked against another member's alias. A data member named
`Owner` plus a reference member aliased `Owner` registered fine, then faulted
every hop through it: ReferenceMembers yielded the data member, ReferencesOf
matched it to the reference member's edges by role string, and
`member.TargetType!` was null.
Two independent fixes, both regression-tested:
- Restore the guard, delete the false comment. Pinned directly by hand-
building the colliding descriptor via reflection (bypassing the registry,
since D10b below now refuses that shape at the only production door).
- D10b: registration now refuses a plain member whose bare name collides
with a reference member's alias in the reference-role namespace
(IndexAlias ?? MemberName), not just alias-vs-alias.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
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>
AppendString (CultNetSelectionEvaluator.cs) wrote value.Length, .NET's UTF-16
code-unit count, but Cursor.Parse's ReadLengthPrefixedFields slices that many
bytes out of the UTF-8-encoded body. An astral character (a surrogate pair:
2 UTF-16 units, 4 UTF-8 bytes) made the two counts disagree, so a cursor
minted for a row keyed with one (Evaluator_CursorPositionUsesCodePointOrderMatchingTheRowTiebreak's
astral key) failed to parse back - caught by that existing test on Yggdrasil,
not a new one. The prefix is now Encoding.UTF8.GetByteCount(value); the
digest's canonical string never round-trips through the byte-sliced reader
(it is hashed whole), so this only had to be right for the cursor body, but
one counting rule for both is what keeps AppendString actually unambiguous.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CultNetDatabase already had a public ResolveShard(schemaId, key) wrapping
ResolveShardInternal; the previous commit added a second one with the same
signature. CultNetDatabaseServer.CreateSelectionResponse's shardIdOf callback
uses the pre-existing one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
R-N: cultnet.error.v0 gains additive code/details fields (contracts/cultnet).
CultNetErrorMessage grows ForSelectionInvalid/ForCursor/ForReferenceOutsideTarget
factories; both C# servers (CultNetDatabaseServer for snapshot+live,
CultNetDatabaseSubscriptionServer) use them at every existing catch site, plus
two sites that previously let the exception escape uncaught instead of reaching
the peer (CultNetDatabaseServer's v1 subscribe snapshot branch,
CultNetDatabaseSubscriptionServer's Subscribe around CreateProjectedSnapshot).
CultNetSelectionCursorException now carries AsOf/Current for cursor_stale.
R-P: CultNetDatabaseSubscriptionServer.CreateMatchedRecord (the live fast path)
authorized against descriptor.SchemaId; the snapshot path (CreateProjectedSnapshot)
already authorized against the wire id. Both now call the same
CultNetDocumentRegistry.WireSchemaId (made internal, was private) so one id
reaches the authorizer on the snapshot, live and reconcile paths alike.
R-T(a): CultNetSelectionEvaluator.MatchesSchemaKeysFields no longer reads a
Removed change's (always-null) document against Fields - it matches on
keys/schemas alone and treats a field predicate as unknown instead of throwing
TryGetIndexValue(null).
R-T(b): EnsureWithinDeclaredTarget no longer skips the out-of-target check when
TargetType is null; it checks against every registered leaf (unconstrained is a
real, checked target set, not a bypass). CultCache's ResolveReferenceTarget also
now infers the target from a many reference's CultRecordRef<T> element when no
explicit [CultReference(typeof(...))] is declared, which was the one remaining
source of a null TargetType reaching that check on a real, testable path.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The first attempt at R-R built a CultNetSelection with an empty (non-null)
Keys/Schemas array straight from the lowered v0 filter and handed it to the
shared evaluator. CultNetSelectionValidation.ValidateShape refuses exactly
that shape ("selection.keys is present and empty"), because for the v1 typed
door an empty list is a caller mistake. v0 and v1 read the same emptiness
differently on purpose (v0: answer nothing; v1: refuse), so v0 must decide
its answer before the selection ever reaches Validate.
CultNetDocumentRegistry.CreateRawSnapshotResponse and
CultNetDatabase.CreateShardSnapshotResponse now short-circuit to an empty
document set when the lowered schemas or keys list is present-and-empty,
without constructing a CultNetSelection or calling into the evaluator at all.
Caught by the test added in the previous commit
(CultNetDocumentRegistry_RawSnapshot_ExplicitEmptyRecordKeys_AnswersEmpty),
which failed with CultNetSelectionInvalidException before this fix. Verified
on Yggdrasil: dotnet test tests/GameCult.Networking.Tests -c Release, all
green (217 passed, 2 skipped by design).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
- 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>
Wires v1 into both C# servers (R-A): CultNetDatabaseServer registers
CultNetSnapshotRequestV1Message/CultNetDatabaseSubscribeV1Message
listeners and Server.cs's pre-verification allowlist gains them;
CultNetDatabaseSubscriptionServer's subscribe path is generalised to a
shared SubscriptionRequest so v0 (lowered) and v1 selections run the
same Subscribe/Watch/Reconcile core, with D6's rule wired in: a
hop-bearing selection runs the full reconcile diff on every change
instead of the single-row fast path. CultNetDatabase.CurrentAsOf gives
both servers a real snapshot watermark.
Fixes the confirmed parity and correctness defects from Soul's
whole-cut pass:
- R-B: cited/cites edges are anchored to the row the direction actually
reports against (the citer under cites, the cited row under cited),
not filtered by "From is on the page" regardless of direction; order
is page-row order then (from, role, to) in code-point order.
- R-C: a new CultNetCodePointComparer replaces every StringComparer.Ordinal/
string.CompareOrdinal in the row tiebreak, cursor position and edge
order, so an astral key sorts the same way here as it does under
Rust's UTF-8 byte comparison.
- R-D: CultCache renders a float/double's exact decimal expansion from
its IEEE-754 bits via BigInteger (RenderExactFloat/RenderExactDouble),
never .NET's shortest round-trip form, which rounded a tie the
opposite way from Rust at some values. The now-dead ExpandScientificNotation
is deleted.
- R-E: MatchesCitation compares a cites target through
CultNetSchemaAliasMatching instead of an exact SchemaId string
compare; ToEdge and ToRawRecord now emit the same wire schema id
(WireSchemaId) for the same row, where ToEdge previously used the
descriptor's own id and ToRawRecord the binding's override.
- R-F: CultNetSelectionEvaluator.Select validates first, unconditionally
- the validate:false/true flag on SelectPage/CreateRawSnapshotResponse
is gone, so no public evaluation path can skip the door. Mesh's
EnsureV0Compatible runs the new CultNetSelectionValidation.ValidateShape
(the declaration-independent half of the door) before its own v0
transport refusals, so Keys=[]/[""] is refused there too.
- R-G: Evaluation.TotalMatched is the selection's whole matching count;
SelectPage's Matched field now reports it instead of the page's row
count. CreateRawSnapshotResponse and CreateShardSnapshotResponse
(v0/shard full walks) call the new CultNetDocumentRegistry.SelectAll,
which evaluates once via CultNetSelectionEvaluator.EvaluateAll and
projects every matched row, instead of looping SelectPage per 200-row
page and re-filtering/re-sorting the whole row set each time.
- R-H: the cursor digest is length-prefixed for every string and list
(AppendString/AppendList) instead of delimiter-joined, closing the
collision ["a|b"] == ["a","b"] and ["a,b"] == ["a","b"].
- R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical
rendering, not the string getter's culture-dependent ToString().
- R-M: WithBindingSchemaAlias is deleted; both servers' single-change
fast path calls CultNetDocumentRegistry.ExpandSchemaBindingAliases
instead, the one binding-alias reconciler. The duplicated v0/shard
lower-and-page loop is gone with SelectAll above.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
_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>
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>
Brings Cuts 1-6 of the Aetheria CultCache migration (one home store per
type, fixed routes, atomic commit with compare-exchange, parked SoA) and
CultMath 0.2.0 onto main, which had moved on with the CultNet contract
consolidation. The two lines touch disjoint files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
PlayerData, CultMeshSimulationFact, the Networking interop peer's
CultNetInteropNote, the cultnet-py benchmark item and the Caching test
documents lacked [MessagePackObject]. Only the generated codec could
serialize them; MessagePackSerializer refuses such types. Non-public test
documents take [MessagePackObject(AllowPrivate = true)], as consumer
internal documents already do, and ConditionalCommitTests.Slow marks its
private backing fields [IgnoreMember].
Same [Key]s, so payloads are unchanged: a pre/post probe over the
Networking assembly and both interop peers showed identical schema id,
content hash and payload bytes for all 9 documents, PlayerData equal once
its Guid.NewGuid() default id is masked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
A direct MessagePackAnalyzer reference with PrivateAssets="all" in
Caching.MessagePack and Networking keeps MessagePack 3's generator out of
consumer builds, where it emitted a reference to a nonexistent
GeneratedMessagePackResolver formatter for Dictionary<CultRecordRef<T>, float>
(CS0426). CultLib composes StandardResolver itself and never registers a
generated resolver.
tests/GameCult.Caching.ConsumerBuild is RefKeyedDictionaryCompilesInAConsumer:
its build is the test. On 825b4f7 it failed with CS0426; now it builds and
obj has no MessagePack.SourceGenerator output.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
REC-032 commit 1: move the canonical 33 TS + witness-artifact-bundle
schema files (34 total) into runtime-neutral CultLib/contracts/cultnet,
move the interop-note schema (and delete the zero-consumer discovery-*
schemas per D3, tagged parked/cultnet-discovery-contracts) into
contracts/cultnet/interop, and delete the 27 C# and 24 Rust duplicate
copies after confirming byte-for-byte equivalence (only
cultnet.snapshot-request differs: TS carries shardId/shardEpoch, a
known drift already resolved in favor of the TS shape).
Rewrite every $id from https://github.com/GameCult/cultnet-ts/contracts/
and .../cultnet-ts/integration/contracts/ to
https://github.com/GameCult/CultLib/contracts/cultnet/(interop/); give
the two shard-catalog schemas URL ids per D1, replacing the bare
cultnet.shard_catalog_*.v0 identifiers; leave ghostlight.agent-state's
Ghostlight-owned $id untouched per D5.
Repoint every runtime's load path and id literal at the new location:
C# csproj EmbeddedResource + LogicalName, Rust include_str! paths,
TS contracts.ts imports + swarm-contracts manifest/tooling + package.json
files/prepack, and the id/path literals in the C#, Rust, Python and
Kotlin interop tests and peers. Python and Kotlin still build their own
schema JSON in-process (D2); only their base constants, id literals,
and (for Kotlin) contentHash literals were swapped. Every Kotlin
contentHash was recomputed from the final canonical files using the
runtime's own stable-stringify + SHA-256 function; all matched the
planner's precomputed values except cultnet.schema-catalog-response,
where the planner's proposed value did not match recomputation and the
freshly computed value was used instead.
REC-032, decisions D1/D2/D3/D5.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Removes FlushBackingStore, both PrepareForReloadOrShutdown overloads,
PullBackingStoreRecordsAsync, TryGet<T>, TryGetByName, TryGetByIndex,
Resolve<T>, the cache and store Logger, and LastSuccessfulFlushAtUtc on the
cache and stores. GetStoredDocuments<T> folds into GetAll<T>.
The soft flag goes from FlushAllBackingStores, ExecuteTransactionAsync,
CommitBatch and PushAll. It stays on FlushAsync(bool) because
CultCacheStudioWindow reflects on that signature until Cut 6.
CultMesh.FlushAsync and CultNetLocal.FlushAsync keep their parameter and
stop forwarding it.
The store's EntryAdded/EntryUpdated/EntryDeleted subjects become
protected internal Loaded/Unloaded callbacks set by the cache at attach.
AddStoredDocumentInternal is synchronous, so the GetAwaiter().GetResult()
bridges on the store subscriptions and the global materializer are gone.
Tests: drop the LastSuccessfulFlushAtUtc asserts; OpenAsync and GetAll tests
read through Get/GetAll; the persisted-global test proves the durable global
loads without staging a default instead of going through selective hydration;
the lease-before-observers test observes through CultCache.Watch; the
PullSelected selective-hydration test is deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>