Fix batch 3: R-N wire refusal codes, R-P one authorizer id, R-T removal/cites gaps

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>
This commit is contained in:
Meta Vens 2026-09-22 19:44:18 +02:00
commit 66b5e60b43
7 changed files with 191 additions and 18 deletions

View file

@ -245,15 +245,15 @@ namespace GameCult.Networking
}
catch (CultNetSelectionInvalidException ex)
{
peer.SendCultNet(new CultNetErrorMessage { Error = $"selection_invalid: {ex.Message}" });
peer.SendCultNet(CultNetErrorMessage.ForSelectionInvalid(ex));
}
catch (CultNetSelectionCursorException ex)
{
peer.SendCultNet(new CultNetErrorMessage { Error = $"{ex.Code}: {ex.Message}" });
peer.SendCultNet(CultNetErrorMessage.ForCursor(ex));
}
catch (CultNetSelectionReferenceOutsideTargetException ex)
{
peer.SendCultNet(new CultNetErrorMessage { Error = $"reference_outside_target: {ex.Message}" });
peer.SendCultNet(CultNetErrorMessage.ForReferenceOutsideTarget(ex));
}
return Task.CompletedTask;
@ -379,7 +379,12 @@ namespace GameCult.Networking
// refused here rather than reaching Matches' own InvalidOperationException on the first change.
if (message.Selection.HasHop)
{
peer.SendCultNet(new CultNetErrorMessage { Error = "selection_invalid: selection.cites/selection.cited need the subscription server's reconcile loop (D6); this server only fast-matches a single row." });
peer.SendCultNet(new CultNetErrorMessage
{
Error = "selection_invalid: selection.cites/selection.cited need the subscription server's reconcile loop (D6); this server only fast-matches a single row.",
Code = "selection_invalid",
Details = new CultNetErrorDetails { Field = "cites" }
});
return Task.CompletedTask;
}
@ -389,7 +394,7 @@ namespace GameCult.Networking
}
catch (CultNetSelectionInvalidException ex)
{
peer.SendCultNet(new CultNetErrorMessage { Error = $"selection_invalid: {ex.Message}" });
peer.SendCultNet(CultNetErrorMessage.ForSelectionInvalid(ex));
return Task.CompletedTask;
}
@ -405,11 +410,26 @@ namespace GameCult.Networking
if (message.IncludeSnapshot)
{
peer.SendCultNet(CreateSelectionResponse(new CultNetSnapshotRequestV1Message
try
{
MessageId = message.MessageId,
Selection = message.Selection
}));
peer.SendCultNet(CreateSelectionResponse(new CultNetSnapshotRequestV1Message
{
MessageId = message.MessageId,
Selection = message.Selection
}));
}
catch (CultNetSelectionInvalidException ex)
{
peer.SendCultNet(CultNetErrorMessage.ForSelectionInvalid(ex));
}
catch (CultNetSelectionCursorException ex)
{
peer.SendCultNet(CultNetErrorMessage.ForCursor(ex));
}
catch (CultNetSelectionReferenceOutsideTargetException ex)
{
peer.SendCultNet(CultNetErrorMessage.ForReferenceOutsideTarget(ex));
}
}
else
{