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

@ -155,7 +155,7 @@ namespace GameCult.Networking
}
catch (CultNetSelectionInvalidException ex)
{
peer.SendCultNet(new CultNetErrorMessage { Error = $"selection_invalid: {ex.Message}" });
peer.SendCultNet(CultNetErrorMessage.ForSelectionInvalid(ex));
return Task.CompletedTask;
}
@ -190,6 +190,20 @@ namespace GameCult.Networking
PublishDemand(request, key, active: true);
projection.DemandActive = true;
}
catch (CultNetSelectionInvalidException ex)
{
// R-N: a hop-bearing selection re-validates inside CreateProjectedSnapshot's
// EvaluateAll, so a selection that passed the door check above can still refuse here.
Withdraw(key, sendRemovals: false, forgetRequest: true);
peer.SendCultNet(CultNetErrorMessage.ForSelectionInvalid(ex));
return Task.CompletedTask;
}
catch (CultNetSelectionReferenceOutsideTargetException ex)
{
Withdraw(key, sendRemovals: false, forgetRequest: true);
peer.SendCultNet(CultNetErrorMessage.ForReferenceOutsideTarget(ex));
return Task.CompletedTask;
}
catch
{
Withdraw(key, sendRemovals: false, forgetRequest: true);
@ -515,7 +529,13 @@ namespace GameCult.Networking
if (!CultNetSelectionEvaluator.Matches(descriptor, key, document, effectiveSelection))
return null;
var legacyRequest = request.ToLegacyShape(request.SubscriptionId);
if (_authorizeRecord?.Invoke(legacyRequest, peer, key.Value, descriptor.SchemaId) == false)
// R-P: one id reaches the authorizer, and it is the wire id, on every path. The snapshot
// path (CreateProjectedSnapshot, below) authorizes against the raw record's own SchemaId,
// which is the wire id ToRawRecord/binding emit; this live fast path used to pass
// descriptor.SchemaId instead - the CLR type's own registered id, not the id a binding
// overrides to - so a row delivered by the snapshot could be refused on its first live
// change, or leak through a denylist keyed on the wire id.
if (_authorizeRecord?.Invoke(legacyRequest, peer, key.Value, _database.Documents.WireSchemaId(descriptor)) == false)
return null;
if (kind == CultNetDatabaseChangeKind.Removed || document == null)