Centralize CultMesh authority resolution

This commit is contained in:
Meta Vens 2026-07-13 14:00:26 +01:00
commit fb24e30ab6
6 changed files with 547 additions and 73 deletions

View file

@ -2978,6 +2978,7 @@ namespace GameCult.Mesh
/// <summary>
/// Creates a CultNet RUDP client transport from the first peer authorized for a Verse role.
/// </summary>
[Obsolete("Supply CultMeshAuthorityResolver and an explicit authority epoch. This compatibility path denies unverifiable leases.")]
public static CultNetRudpSocketTransportConnection CreateRudpClientForAuthorizedPeer(
string runtimeId,
uint connectionId,
@ -2991,7 +2992,27 @@ namespace GameCult.Mesh
{
if (peers == null) throw new ArgumentNullException(nameof(peers));
if (leases == null) throw new ArgumentNullException(nameof(leases));
var peer = peers.FirstAuthorized(verseId, role, leases, shardId, at);
var resolver = CultMeshAuthorityResolver.CreateDenyByDefault(leases,
at.HasValue ? new CultMeshFixedAuthorityClock(at.Value) : null);
return CreateRudpClientForAuthorizedPeer(runtimeId, connectionId, peers, resolver, 0, verseId, role, shardId, null, options);
}
/// <summary>Creates a RUDP client from the first contact accepted by the authority resolver.</summary>
public static CultNetRudpSocketTransportConnection CreateRudpClientForAuthorizedPeer(
string runtimeId,
uint connectionId,
CultMeshPeerCatalog peers,
CultMeshAuthorityResolver authority,
long authorityEpoch,
string verseId,
string role,
string? shardId = null,
string? resourceScope = null,
CultMeshRudpSocketOptions? options = null)
{
if (peers == null) throw new ArgumentNullException(nameof(peers));
if (authority == null) throw new ArgumentNullException(nameof(authority));
var peer = peers.FirstAuthorized(verseId, role, authority, authorityEpoch, shardId, resourceScope);
if (peer == null)
{
throw new InvalidOperationException($"No authorized RUDP peer for role {role} in Verse {verseId}.");
@ -3054,6 +3075,7 @@ namespace GameCult.Mesh
/// <summary>
/// Creates and handshakes a CultNet RUDP client transport from the first peer authorized for a Verse role.
/// </summary>
[Obsolete("Supply CultMeshAuthorityResolver and an explicit authority epoch. This compatibility path denies unverifiable leases.")]
public static CultNetRudpSocketTransportConnection ConnectRudpClientForAuthorizedPeer(
string runtimeId,
uint connectionId,
@ -3067,7 +3089,27 @@ namespace GameCult.Mesh
{
if (peers == null) throw new ArgumentNullException(nameof(peers));
if (leases == null) throw new ArgumentNullException(nameof(leases));
var peer = peers.FirstAuthorized(verseId, role, leases, shardId, at);
var resolver = CultMeshAuthorityResolver.CreateDenyByDefault(leases,
at.HasValue ? new CultMeshFixedAuthorityClock(at.Value) : null);
return ConnectRudpClientForAuthorizedPeer(runtimeId, connectionId, peers, resolver, 0, verseId, role, shardId, null, options);
}
/// <summary>Creates and handshakes a RUDP client from the first contact accepted by the authority resolver.</summary>
public static CultNetRudpSocketTransportConnection ConnectRudpClientForAuthorizedPeer(
string runtimeId,
uint connectionId,
CultMeshPeerCatalog peers,
CultMeshAuthorityResolver authority,
long authorityEpoch,
string verseId,
string role,
string? shardId = null,
string? resourceScope = null,
CultMeshRudpClientOptions? options = null)
{
if (peers == null) throw new ArgumentNullException(nameof(peers));
if (authority == null) throw new ArgumentNullException(nameof(authority));
var peer = peers.FirstAuthorized(verseId, role, authority, authorityEpoch, shardId, resourceScope);
if (peer == null)
{
throw new InvalidOperationException($"No authorized RUDP peer for role {role} in Verse {verseId}.");
@ -3076,6 +3118,14 @@ namespace GameCult.Mesh
return ConnectRudpClientForPeer(runtimeId, connectionId, peer, options);
}
private sealed class CultMeshFixedAuthorityClock : ICultMeshClock
{
public CultMeshFixedAuthorityClock(DateTimeOffset utcNow) => UtcNow = utcNow;
public DateTimeOffset UtcNow { get; }
public System.Threading.Tasks.Task DelayAsync(TimeSpan delay, System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.CompletedTask;
}
/// <summary>
/// Creates a committer for writing quorum simulation facts to a mesh database.
/// </summary>

View file

@ -1,17 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace GameCult.Mesh
{
/// <summary>
/// Grants one peer authority to perform roles for a Verse over a bounded interval.
/// </summary>
public sealed class CultMeshAuthorityLease
{
/// <summary>
/// Creates an authority lease.
/// </summary>
public const string CurrentSchemaVersion = "gamecult.mesh.authority_lease.v1";
public const string LegacySchemaVersion = "gamecult.mesh.authority_lease.v0";
public CultMeshAuthorityLease(
string leaseId,
string verseId,
@ -21,7 +19,10 @@ namespace GameCult.Mesh
string issuerRuntimeId,
DateTimeOffset validFrom,
DateTimeOffset expiresAt,
string? signature = null)
string? signature = null,
long? authorityEpoch = null,
IEnumerable<string>? resourceScopes = null,
string? schemaVersion = null)
{
LeaseId = RequireNonEmpty(leaseId, nameof(leaseId));
VerseId = RequireNonEmpty(verseId, nameof(verseId));
@ -32,109 +33,239 @@ namespace GameCult.Mesh
ValidFrom = validFrom;
ExpiresAt = expiresAt;
Signature = signature;
if (expiresAt <= validFrom)
{
throw new ArgumentException("Lease expiry must be after its valid-from timestamp.", nameof(expiresAt));
}
AuthorityEpoch = authorityEpoch;
ResourceScopes = Clean(resourceScopes);
SchemaVersion = schemaVersion ?? (authorityEpoch.HasValue ? CurrentSchemaVersion : LegacySchemaVersion);
if (expiresAt <= validFrom) throw new ArgumentException("Lease expiry must be after its valid-from timestamp.", nameof(expiresAt));
if (authorityEpoch < 0) throw new ArgumentOutOfRangeException(nameof(authorityEpoch));
}
/// <summary>Gets the stable lease id.</summary>
public string LeaseId { get; }
/// <summary>Gets the Verse id governed by the lease.</summary>
public string VerseId { get; }
/// <summary>Gets the peer id receiving authority.</summary>
public string PeerId { get; }
/// <summary>Gets the roles granted by this lease.</summary>
public IReadOnlyList<string> Roles { get; }
/// <summary>Gets shard ids covered by this lease. Empty means every shard in the Verse.</summary>
public IReadOnlyList<string> ShardIds { get; }
/// <summary>Gets the runtime that issued the lease.</summary>
public string IssuerRuntimeId { get; }
/// <summary>Gets the first timestamp when the lease is valid.</summary>
public DateTimeOffset ValidFrom { get; }
/// <summary>Gets the timestamp when the lease expires.</summary>
public DateTimeOffset ExpiresAt { get; }
/// <summary>Gets an optional signature over the lease.</summary>
public string? Signature { get; }
public long? AuthorityEpoch { get; }
public IReadOnlyList<string> ResourceScopes { get; }
public string SchemaVersion { get; }
/// <summary>
/// Returns whether this lease is valid at the supplied time.
/// </summary>
public bool IsValidAt(DateTimeOffset at)
{
return at >= ValidFrom && at < ExpiresAt;
}
public bool IsValidAt(DateTimeOffset at) => at >= ValidFrom && at < ExpiresAt;
/// <summary>
/// Returns whether this lease covers a peer card, role, and shard.
/// </summary>
[Obsolete("Authority decisions belong to CultMeshAuthorityResolver. This method denies leases that lack v1 signed authority evidence.")]
public bool Covers(CultMeshPeerCard peer, string role, string? shardId = null, DateTimeOffset? at = null)
{
if (peer == null) throw new ArgumentNullException(nameof(peer));
if (string.IsNullOrWhiteSpace(role)) throw new ArgumentException("Value must be non-empty.", nameof(role));
var time = at ?? DateTimeOffset.UtcNow;
return IsValidAt(time) &&
string.Equals(VerseId, peer.VerseId, StringComparison.Ordinal) &&
string.Equals(PeerId, peer.PeerId, StringComparison.Ordinal) &&
string.Equals(LeaseId, peer.AuthorityLeaseId, StringComparison.Ordinal) &&
Roles.Contains(role, StringComparer.Ordinal) &&
peer.Roles.Contains(role, StringComparer.Ordinal) &&
(string.IsNullOrWhiteSpace(shardId) ||
ShardIds.Count == 0 ||
ShardIds.Contains(shardId!, StringComparer.Ordinal));
var resolver = CultMeshAuthorityResolver.CreateDenyByDefault(
new SingleLeaseSource(this),
at.HasValue ? new FixedClock(at.Value) : CultMeshSystemClock.Instance);
return resolver.Resolve(new CultMeshAuthorityRequest(peer, role, shardId, AuthorityEpoch ?? -1)).IsAuthorized;
}
private static string[] Clean(IEnumerable<string>? values)
private static string[] Clean(IEnumerable<string>? values) =>
values?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray() ?? Array.Empty<string>();
private static string RequireNonEmpty(string value, string paramName) =>
string.IsNullOrWhiteSpace(value) ? throw new ArgumentException("Value must be non-empty.", paramName) : value;
private sealed class SingleLeaseSource : ICultMeshAuthorityLeaseSource
{
return values?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray()
?? Array.Empty<string>();
private readonly CultMeshAuthorityLease _lease;
public SingleLeaseSource(CultMeshAuthorityLease lease) => _lease = lease;
public CultMeshAuthorityLease? Get(string leaseId) => string.Equals(leaseId, _lease.LeaseId, StringComparison.Ordinal) ? _lease : null;
}
private static string RequireNonEmpty(string value, string paramName)
private sealed class FixedClock : ICultMeshClock
{
return string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value must be non-empty.", paramName)
: value;
public FixedClock(DateTimeOffset now) => UtcNow = now;
public DateTimeOffset UtcNow { get; }
public System.Threading.Tasks.Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.CompletedTask;
}
}
/// <summary>
/// Local authority lease catalog.
/// </summary>
public sealed class CultMeshAuthorityLeaseCatalog
public interface ICultMeshAuthorityLeaseSource
{
private readonly Dictionary<string, CultMeshAuthorityLease> _leases = new(StringComparer.Ordinal);
CultMeshAuthorityLease? Get(string leaseId);
}
/// <summary>Gets all known leases.</summary>
public IReadOnlyList<CultMeshAuthorityLease> Leases => _leases.Values.OrderBy(lease => lease.LeaseId, StringComparer.Ordinal).ToArray();
public interface ICultMeshAuthoritySignatureVerifier
{
bool Verify(CultMeshAuthorityLease lease);
}
/// <summary>Adds or replaces an authority lease.</summary>
public void Upsert(CultMeshAuthorityLease lease)
public interface ICultMeshAuthorityRevocationSource
{
bool IsRevoked(string leaseId, long authorityEpoch);
}
public enum CultMeshAuthorityDenialReason
{
None,
MissingLeaseReference,
LeaseNotFound,
UnsupportedLeaseVersion,
MissingSignature,
InvalidSignature,
NotYetValid,
Expired,
Revoked,
VerseMismatch,
PeerMismatch,
LeaseReferenceMismatch,
RoleNotGranted,
RoleNotAdvertised,
ShardNotGranted,
ShardNotAdvertised,
EpochMismatch,
ResourceScopeNotGranted
}
public sealed class CultMeshAuthorityRequest
{
public CultMeshAuthorityRequest(CultMeshPeerCard peer, string role, string? shardId, long authorityEpoch, string? resourceScope = null)
{
if (lease == null) throw new ArgumentNullException(nameof(lease));
_leases[lease.LeaseId] = lease;
Peer = peer ?? throw new ArgumentNullException(nameof(peer));
Role = string.IsNullOrWhiteSpace(role) ? throw new ArgumentException("Value must be non-empty.", nameof(role)) : role;
if (authorityEpoch < 0) throw new ArgumentOutOfRangeException(nameof(authorityEpoch));
ShardId = shardId;
AuthorityEpoch = authorityEpoch;
ResourceScope = resourceScope;
}
/// <summary>Gets a lease by id, if known.</summary>
public CultMeshPeerCard Peer { get; }
public string Role { get; }
public string? ShardId { get; }
public long AuthorityEpoch { get; }
public string? ResourceScope { get; }
}
public sealed class CultMeshAuthorityDecision
{
internal CultMeshAuthorityDecision(CultMeshAuthorityRequest request, CultMeshAuthorityLease? lease, CultMeshAuthorityDenialReason reason)
{
Request = request;
Lease = lease;
DenialReason = reason;
}
public CultMeshAuthorityRequest Request { get; }
public CultMeshAuthorityLease? Lease { get; }
public bool IsAuthorized => DenialReason == CultMeshAuthorityDenialReason.None;
public CultMeshAuthorityDenialReason DenialReason { get; }
public string? LeaseId => Lease?.LeaseId ?? Request.Peer.AuthorityLeaseId;
public string? IssuerRuntimeId => Lease?.IssuerRuntimeId;
}
public sealed class CultMeshAuthorityResolver
{
private readonly ICultMeshAuthorityLeaseSource _leases;
private readonly ICultMeshAuthoritySignatureVerifier _signatures;
private readonly ICultMeshAuthorityRevocationSource _revocations;
private readonly ICultMeshClock _clock;
private readonly ICultMeshDiagnosticSink _diagnostics;
private long _diagnosticSequence;
public CultMeshAuthorityResolver(
ICultMeshAuthorityLeaseSource leases,
ICultMeshAuthoritySignatureVerifier signatures,
ICultMeshAuthorityRevocationSource revocations,
ICultMeshClock clock,
ICultMeshDiagnosticSink? diagnostics = null)
{
_leases = leases ?? throw new ArgumentNullException(nameof(leases));
_signatures = signatures ?? throw new ArgumentNullException(nameof(signatures));
_revocations = revocations ?? throw new ArgumentNullException(nameof(revocations));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_diagnostics = diagnostics ?? CultMeshNullDiagnosticSink.Instance;
}
public CultMeshAuthorityDecision Resolve(CultMeshAuthorityRequest request)
{
if (request == null) throw new ArgumentNullException(nameof(request));
var leaseId = request.Peer.AuthorityLeaseId;
if (string.IsNullOrWhiteSpace(leaseId)) return Decide(request, null, CultMeshAuthorityDenialReason.MissingLeaseReference);
var lease = _leases.Get(leaseId!);
if (lease == null) return Decide(request, null, CultMeshAuthorityDenialReason.LeaseNotFound);
if (!string.Equals(lease.SchemaVersion, CultMeshAuthorityLease.CurrentSchemaVersion, StringComparison.Ordinal) || !lease.AuthorityEpoch.HasValue)
return Decide(request, lease, CultMeshAuthorityDenialReason.UnsupportedLeaseVersion);
if (string.IsNullOrWhiteSpace(lease.Signature)) return Decide(request, lease, CultMeshAuthorityDenialReason.MissingSignature);
if (!_signatures.Verify(lease)) return Decide(request, lease, CultMeshAuthorityDenialReason.InvalidSignature);
if (_clock.UtcNow < lease.ValidFrom) return Decide(request, lease, CultMeshAuthorityDenialReason.NotYetValid);
if (_clock.UtcNow >= lease.ExpiresAt) return Decide(request, lease, CultMeshAuthorityDenialReason.Expired);
if (_revocations.IsRevoked(lease.LeaseId, lease.AuthorityEpoch.Value)) return Decide(request, lease, CultMeshAuthorityDenialReason.Revoked);
if (!string.Equals(lease.VerseId, request.Peer.VerseId, StringComparison.Ordinal)) return Decide(request, lease, CultMeshAuthorityDenialReason.VerseMismatch);
if (!string.Equals(lease.PeerId, request.Peer.PeerId, StringComparison.Ordinal)) return Decide(request, lease, CultMeshAuthorityDenialReason.PeerMismatch);
if (!string.Equals(lease.LeaseId, leaseId, StringComparison.Ordinal)) return Decide(request, lease, CultMeshAuthorityDenialReason.LeaseReferenceMismatch);
if (!lease.Roles.Contains(request.Role, StringComparer.Ordinal)) return Decide(request, lease, CultMeshAuthorityDenialReason.RoleNotGranted);
if (!request.Peer.Roles.Contains(request.Role, StringComparer.Ordinal)) return Decide(request, lease, CultMeshAuthorityDenialReason.RoleNotAdvertised);
if (!string.IsNullOrWhiteSpace(request.ShardId) && lease.ShardIds.Count != 0 && !lease.ShardIds.Contains(request.ShardId!, StringComparer.Ordinal))
return Decide(request, lease, CultMeshAuthorityDenialReason.ShardNotGranted);
if (!string.IsNullOrWhiteSpace(request.ShardId) && request.Peer.ShardIds.Count != 0 && !request.Peer.ShardIds.Contains(request.ShardId!, StringComparer.Ordinal))
return Decide(request, lease, CultMeshAuthorityDenialReason.ShardNotAdvertised);
if (lease.AuthorityEpoch.Value != request.AuthorityEpoch) return Decide(request, lease, CultMeshAuthorityDenialReason.EpochMismatch);
if (!string.IsNullOrWhiteSpace(request.ResourceScope) && !lease.ResourceScopes.Contains(request.ResourceScope!, StringComparer.Ordinal))
return Decide(request, lease, CultMeshAuthorityDenialReason.ResourceScopeNotGranted);
return Decide(request, lease, CultMeshAuthorityDenialReason.None);
}
internal static CultMeshAuthorityResolver CreateDenyByDefault(ICultMeshAuthorityLeaseSource leases, ICultMeshClock? clock = null) =>
new CultMeshAuthorityResolver(leases, DenyAllSignatures.Instance, NoRevocations.Instance, clock ?? CultMeshSystemClock.Instance);
private CultMeshAuthorityDecision Decide(CultMeshAuthorityRequest request, CultMeshAuthorityLease? lease, CultMeshAuthorityDenialReason reason)
{
var decision = new CultMeshAuthorityDecision(request, lease, reason);
_diagnostics.Emit(new CultMeshDiagnosticEvent(
Interlocked.Increment(ref _diagnosticSequence), _clock.UtcNow, CultMeshReliabilityOrgan.Authority,
CultMeshDiagnosticKind.AuthorityDecision, lease?.LeaseId ?? request.Peer.AuthorityLeaseId ?? "lease:missing",
request.Peer.PeerId, decision.IsAuthorized ? "authorized" : "denied", reason.ToString(), lease?.IssuerRuntimeId ?? "",
schemaVersion: "gamecult.mesh.authority_decision.v1"));
return decision;
}
private sealed class DenyAllSignatures : ICultMeshAuthoritySignatureVerifier
{
public static DenyAllSignatures Instance { get; } = new DenyAllSignatures();
public bool Verify(CultMeshAuthorityLease lease) => false;
}
private sealed class NoRevocations : ICultMeshAuthorityRevocationSource
{
public static NoRevocations Instance { get; } = new NoRevocations();
public bool IsRevoked(string leaseId, long authorityEpoch) => false;
}
}
public sealed class CultMeshAuthorityLeaseCatalog : ICultMeshAuthorityLeaseSource
{
private readonly Dictionary<string, CultMeshAuthorityLease> _leases = new(StringComparer.Ordinal);
public IReadOnlyList<CultMeshAuthorityLease> Leases => _leases.Values.OrderBy(lease => lease.LeaseId, StringComparer.Ordinal).ToArray();
public void Upsert(CultMeshAuthorityLease lease) { if (lease == null) throw new ArgumentNullException(nameof(lease)); _leases[lease.LeaseId] = lease; }
public CultMeshAuthorityLease? Get(string leaseId)
{
if (string.IsNullOrWhiteSpace(leaseId)) throw new ArgumentException("Value must be non-empty.", nameof(leaseId));
return _leases.TryGetValue(leaseId, out var lease) ? lease : null;
}
/// <summary>
/// Returns whether a peer card is authorized for a role and optional shard.
/// </summary>
[Obsolete("Authority decisions belong to CultMeshAuthorityResolver. This compatibility path denies unsigned and unverifiable leases.")]
public bool IsAuthorized(CultMeshPeerCard peer, string role, string? shardId = null, DateTimeOffset? at = null)
{
if (peer == null) throw new ArgumentNullException(nameof(peer));
if (string.IsNullOrWhiteSpace(peer.AuthorityLeaseId))
{
return false;
}
var lease = string.IsNullOrWhiteSpace(peer.AuthorityLeaseId) ? null : Get(peer.AuthorityLeaseId!);
var epoch = lease?.AuthorityEpoch ?? -1;
if (epoch < 0) return false;
var resolver = CultMeshAuthorityResolver.CreateDenyByDefault(this, at.HasValue ? new CatalogFixedClock(at.Value) : null);
return resolver.Resolve(new CultMeshAuthorityRequest(peer, role, shardId, epoch)).IsAuthorized;
}
return _leases.TryGetValue(peer.AuthorityLeaseId!, out var lease) &&
lease.Covers(peer, role, shardId, at);
private sealed class CatalogFixedClock : ICultMeshClock
{
public CatalogFixedClock(DateTimeOffset now) => UtcNow = now;
public DateTimeOffset UtcNow { get; }
public System.Threading.Tasks.Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default) => System.Threading.Tasks.Task.CompletedTask;
}
}
}

View file

@ -28,6 +28,12 @@ namespace GameCult.Mesh
/// </summary>
public CultMeshAuthorityLeaseCatalog? AuthorityLeases { get; set; }
/// <summary>
/// Gets or sets the sole authority resolver used by the session. When omitted, the
/// session installs a deny-by-default resolver over <see cref="AuthorityLeases"/>.
/// </summary>
public CultMeshAuthorityResolver? AuthorityResolver { get; set; }
/// <summary>
/// Gets or sets simulation consensus options.
/// </summary>
@ -68,6 +74,7 @@ namespace GameCult.Mesh
VerseCatalog = options.VerseCatalog ?? new CultMeshVerseCatalog();
PeerCatalog = options.PeerCatalog ?? new CultMeshPeerCatalog();
AuthorityLeases = options.AuthorityLeases ?? new CultMeshAuthorityLeaseCatalog();
AuthorityResolver = options.AuthorityResolver ?? CultMeshAuthorityResolver.CreateDenyByDefault(AuthorityLeases);
ObservationHub = new CultNetSimulationObservationHub(options.ConsensusOptions);
FactCommitter = new CultMeshSimulationFactCommitter(Node.Database);
@ -117,6 +124,9 @@ namespace GameCult.Mesh
/// </summary>
public CultMeshAuthorityLeaseCatalog AuthorityLeases { get; }
/// <summary>Gets the session authority decision owner.</summary>
public CultMeshAuthorityResolver AuthorityResolver { get; }
/// <summary>
/// Gets the session simulation observation hub.
/// </summary>

View file

@ -192,23 +192,40 @@ namespace GameCult.Mesh
}
/// <summary>Finds peers that advertise a Verse role and are authorized by the supplied lease catalog.</summary>
[Obsolete("Supply CultMeshAuthorityResolver and an explicit authority epoch. This compatibility path denies unverifiable leases.")]
public IReadOnlyList<CultMeshPeerCard> FindAuthorized(
string verseId,
string role,
CultMeshAuthorityLeaseCatalog leases,
string? shardId = null,
DateTimeOffset? at = null)
{
if (leases == null) throw new ArgumentNullException(nameof(leases));
var resolver = CultMeshAuthorityResolver.CreateDenyByDefault(leases,
at.HasValue ? new PeerCatalogFixedClock(at.Value) : null);
return FindAuthorized(verseId, role, resolver, 0, shardId);
}
/// <summary>Finds contact candidates accepted by the authority resolver.</summary>
public IReadOnlyList<CultMeshPeerCard> FindAuthorized(
string verseId,
string role,
CultMeshAuthorityResolver resolver,
long authorityEpoch,
string? shardId = null,
string? resourceScope = null)
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(verseId)) throw new ArgumentException("Value must be non-empty.", nameof(verseId));
if (string.IsNullOrWhiteSpace(role)) throw new ArgumentException("Value must be non-empty.", nameof(role));
if (leases == null) throw new ArgumentNullException(nameof(leases));
if (resolver == null) throw new ArgumentNullException(nameof(resolver));
return Find(verseId, role)
.Where(peer => leases.IsAuthorized(peer, role, shardId, at))
.Where(peer => resolver.Resolve(new CultMeshAuthorityRequest(peer, role, shardId, authorityEpoch, resourceScope)).IsAuthorized)
.ToArray();
}
/// <summary>Returns the first peer that advertises a Verse role and is authorized by the supplied lease catalog.</summary>
[Obsolete("Supply CultMeshAuthorityResolver and an explicit authority epoch. This compatibility path denies unverifiable leases.")]
public CultMeshPeerCard? FirstAuthorized(
string verseId,
string role,
@ -219,6 +236,18 @@ namespace GameCult.Mesh
return FindAuthorized(verseId, role, leases, shardId, at).FirstOrDefault();
}
/// <summary>Returns the first contact candidate accepted by the authority resolver.</summary>
public CultMeshPeerCard? FirstAuthorized(
string verseId,
string role,
CultMeshAuthorityResolver resolver,
long authorityEpoch,
string? shardId = null,
string? resourceScope = null)
{
return FindAuthorized(verseId, role, resolver, authorityEpoch, shardId, resourceScope).FirstOrDefault();
}
/// <inheritdoc />
public void Dispose()
{
@ -239,6 +268,13 @@ namespace GameCult.Mesh
throw new ObjectDisposedException(nameof(CultMeshPeerCatalog));
}
}
private sealed class PeerCatalogFixedClock : ICultMeshClock
{
public PeerCatalogFixedClock(DateTimeOffset utcNow) => UtcNow = utcNow;
public DateTimeOffset UtcNow { get; }
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}
/// <summary>

View file

@ -104,6 +104,45 @@ must never confer authority.
**Demotion:** an advertised peer card is only contact evidence. It is never an
authorization result.
#### Phase 3 body map
**Owner:** `CultMeshAuthorityResolver` is the only component that decides
whether a peer may act. Decisions are evaluated on demand and are never cached
as grants.
**Inputs:** `CultMeshAuthorityRequest`, an `ICultMeshAuthorityLeaseSource`, an
`ICultMeshAuthoritySignatureVerifier`, an
`ICultMeshAuthorityRevocationSource`, the current authority epoch, and an
injected `ICultMeshClock`.
**Outputs:** `CultMeshAuthorityDecision`, carrying either the accepted lease
evidence identifiers or one typed denial reason. Authority diagnostics carry
lease, peer, and issuer identifiers but never signature material.
**Derived state:** peer selection is a projection over contact candidates and
fresh resolver decisions. The lease catalog is only a mutable evidence source;
the peer catalog is only a contact source.
**Forbidden writers:** lease replacement, peer-card publication, successful
transport connection, cached presence, and wall-clock defaults cannot grant or
restore authority. Revocation and epoch policy remain outside both catalogs.
**Shared paths:** gameplay sessions expose their resolver; peer selection and
both privileged RUDP creation paths call that resolver. Compatibility overloads
delegate to the same resolver with deny-by-default signature policy.
**Cut line:** `CultMeshAuthorityLease.Covers`,
`CultMeshAuthorityLeaseCatalog.IsAuthorized`, and catalog-owned
`FindAuthorized`/`FirstAuthorized` no longer contain authorization logic.
Version-zero or unsigned leases are not reinterpreted as trusted epoch-zero
leases. Callers must supply a version-one lease, explicit epoch, signature
verifier, and revocation source.
**Compatibility inventory:** the old lease/catalog peer-selection and RUDP
overloads remain source-compatible for migration, but cannot authorize because
they have no signature-verification authority. They delegate to a
deny-by-default resolver and should be replaced by resolver-taking overloads.
### Content transfer scheduler
**Owner:** `CultMeshContentTransferService`.

View file

@ -0,0 +1,208 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using NUnit.Framework;
namespace GameCult.Mesh.Tests;
public sealed class CultMeshAuthorityResolverTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 13, 12, 0, 0, TimeSpan.Zero);
[Test]
public void Resolve_AcceptsSignedLeaseWithExactScopeAndEpoch()
{
var fixture = CreateFixture();
var decision = fixture.Resolver.Resolve(fixture.Request());
decision.IsAuthorized.Should().BeTrue();
decision.DenialReason.Should().Be(CultMeshAuthorityDenialReason.None);
decision.LeaseId.Should().Be("lease:pilot");
decision.IssuerRuntimeId.Should().Be("odin");
}
[TestCase(null, CultMeshAuthorityDenialReason.MissingSignature)]
[TestCase("bad-signature", CultMeshAuthorityDenialReason.InvalidSignature)]
public void Resolve_RejectsMissingOrInvalidSignature(string? signature, CultMeshAuthorityDenialReason expected)
{
var fixture = CreateFixture(signature: signature);
fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(expected);
}
[Test]
public void Resolve_RejectsLegacyUnsignedLeaseInsteadOfTreatingItAsEpochZero()
{
var catalog = new CultMeshAuthorityLeaseCatalog();
var lease = new CultMeshAuthorityLease("legacy", "verse", "peer", new[] { "pilot" }, null, "odin", Now.AddMinutes(-1), Now.AddMinutes(1));
catalog.Upsert(lease);
var peer = new CultMeshPeerCard("peer", "verse", Array.Empty<string>(), new[] { "pilot" }, authorityLeaseId: "legacy");
var resolver = new CultMeshAuthorityResolver(catalog, new SignatureVerifier(), new Revocations(), new ManualClock(Now));
resolver.Resolve(new CultMeshAuthorityRequest(peer, "pilot", null, 0)).DenialReason
.Should().Be(CultMeshAuthorityDenialReason.UnsupportedLeaseVersion);
#pragma warning disable CS0618
catalog.IsAuthorized(peer, "pilot", at: Now).Should().BeFalse();
#pragma warning restore CS0618
}
[Test]
public void Resolve_UsesInjectedClockForNotYetValidAndExpiry()
{
var fixture = CreateFixture(validFrom: Now.AddMinutes(1), expiresAt: Now.AddMinutes(2));
fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(CultMeshAuthorityDenialReason.NotYetValid);
fixture.Clock.Advance(TimeSpan.FromMinutes(1));
fixture.Resolver.Resolve(fixture.Request()).IsAuthorized.Should().BeTrue();
fixture.Clock.Advance(TimeSpan.FromMinutes(1));
fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(CultMeshAuthorityDenialReason.Expired);
}
[Test]
public void Resolve_RejectsRevokedLeaseAndCatalogReplacementCannotReviveIt()
{
var fixture = CreateFixture(revoked: true);
fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(CultMeshAuthorityDenialReason.Revoked);
fixture.Catalog.Upsert(CreateLease(signature: "valid-replacement"));
fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(CultMeshAuthorityDenialReason.Revoked);
}
[Test]
public void Resolve_CatalogReplacementCannotOverrideEpochPolicy()
{
var fixture = CreateFixture();
fixture.Catalog.Upsert(new CultMeshAuthorityLease(
"lease:pilot", "verse:aetheria", "peer:pilot", new[] { "pilot" }, new[] { "shard:a" }, "odin",
Now.AddMinutes(-1), Now.AddMinutes(1), "valid-new-epoch", 8, new[] { "body:world" }));
fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(CultMeshAuthorityDenialReason.EpochMismatch);
}
[Test]
public void Resolve_ClockMovementExpiresLeaseWithoutCatalogMutation()
{
var fixture = CreateFixture(expiresAt: Now.AddSeconds(1));
fixture.Resolver.Resolve(fixture.Request()).IsAuthorized.Should().BeTrue();
fixture.Clock.Advance(TimeSpan.FromSeconds(1));
fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(CultMeshAuthorityDenialReason.Expired);
}
[Test]
public void Resolve_ReportsDistinctScopeDenials()
{
AssertDenied(CreateFixture(peerVerse: "other").Request(), CreateFixture(peerVerse: "other").Resolver, CultMeshAuthorityDenialReason.VerseMismatch);
AssertDenied(CreateFixture(peerId: "other").Request(), CreateFixture(peerId: "other").Resolver, CultMeshAuthorityDenialReason.PeerMismatch);
AssertDenied(CreateFixture(requestRole: "trade").Request(), CreateFixture(requestRole: "trade").Resolver, CultMeshAuthorityDenialReason.RoleNotGranted);
AssertDenied(CreateFixture(requestShard: "shard:b").Request(), CreateFixture(requestShard: "shard:b").Resolver, CultMeshAuthorityDenialReason.ShardNotGranted);
AssertDenied(CreateFixture(requestEpoch: 8).Request(), CreateFixture(requestEpoch: 8).Resolver, CultMeshAuthorityDenialReason.EpochMismatch);
AssertDenied(CreateFixture(requestScope: "body:other").Request(), CreateFixture(requestScope: "body:other").Resolver, CultMeshAuthorityDenialReason.ResourceScopeNotGranted);
}
[Test]
public void Resolve_NewPeerPublicationCannotReviveExpiredAuthority()
{
var fixture = CreateFixture(expiresAt: Now);
fixture.CatalogPeers.Upsert(fixture.Peer);
fixture.CatalogPeers.FindAuthorized("verse:aetheria", "pilot", fixture.Resolver, 7, "shard:a", "body:world").Should().BeEmpty();
fixture.CatalogPeers.Upsert(new CultMeshPeerCard("peer:pilot", "verse:aetheria", Array.Empty<string>(), new[] { "pilot" }, new[] { "shard:a" }, authorityLeaseId: "lease:pilot"));
fixture.CatalogPeers.FindAuthorized("verse:aetheria", "pilot", fixture.Resolver, 7, "shard:a", "body:world").Should().BeEmpty();
}
[Test]
public void Resolve_NewPeerPublicationCannotReviveRevokedAuthority()
{
var fixture = CreateFixture(revoked: true);
fixture.CatalogPeers.Upsert(fixture.Peer);
fixture.CatalogPeers.FindAuthorized("verse:aetheria", "pilot", fixture.Resolver, 7, "shard:a", "body:world").Should().BeEmpty();
fixture.CatalogPeers.Upsert(new CultMeshPeerCard("peer:pilot", "verse:aetheria", Array.Empty<string>(), new[] { "pilot" }, new[] { "shard:a" }, authorityLeaseId: "lease:pilot"));
fixture.CatalogPeers.FindAuthorized("verse:aetheria", "pilot", fixture.Resolver, 7, "shard:a", "body:world").Should().BeEmpty();
}
[Test]
public void Diagnostics_CarryEvidenceIdentifiersButNeverSignatureMaterial()
{
var fixture = CreateFixture(signature: "super-secret-signature");
fixture.Resolver.Resolve(fixture.Request());
var diagnostic = fixture.Diagnostics.Snapshot().Should().ContainSingle().Subject;
diagnostic.OperationId.Should().Be("lease:pilot");
diagnostic.SubjectId.Should().Be("peer:pilot");
diagnostic.SourceId.Should().Be("odin");
string.Join("|", diagnostic.OperationId, diagnostic.SubjectId, diagnostic.SourceId, diagnostic.ReasonCode, diagnostic.Endpoint)
.Should().NotContain("super-secret-signature");
}
[Test]
public void PeerSelectionAndPrivilegedRudpHelpersUseResolverDecision()
{
var fixture = CreateFixture();
fixture.CatalogPeers.Upsert(fixture.Peer);
fixture.CatalogPeers.FirstAuthorized("verse:aetheria", "pilot", fixture.Resolver, 7, "shard:a", "body:world")
.Should().BeSameAs(fixture.Peer);
#pragma warning disable CS0618
fixture.CatalogPeers.FirstAuthorized("verse:aetheria", "pilot", fixture.Catalog, "shard:a", Now).Should().BeNull();
Action legacy = () => CultMesh.CreateRudpClientForAuthorizedPeer("client", 1, fixture.CatalogPeers, fixture.Catalog, "verse:aetheria", "pilot", "shard:a", Now);
#pragma warning restore CS0618
legacy.Should().Throw<InvalidOperationException>().WithMessage("No authorized RUDP peer*");
Action resolved = () => CultMesh.CreateRudpClientForAuthorizedPeer("client", 1, fixture.CatalogPeers, fixture.Resolver, 7, "verse:aetheria", "pilot", "shard:a", "body:world");
resolved.Should().Throw<InvalidOperationException>().WithMessage("Peer peer:pilot does not advertise a RUDP endpoint.");
}
private static void AssertDenied(CultMeshAuthorityRequest request, CultMeshAuthorityResolver resolver, CultMeshAuthorityDenialReason reason) =>
resolver.Resolve(request).DenialReason.Should().Be(reason);
private static Fixture CreateFixture(
string? signature = "valid-signature", DateTimeOffset? validFrom = null, DateTimeOffset? expiresAt = null,
bool revoked = false, string peerId = "peer:pilot", string peerVerse = "verse:aetheria",
string requestRole = "pilot", string requestShard = "shard:a", long requestEpoch = 7, string requestScope = "body:world")
{
var catalog = new CultMeshAuthorityLeaseCatalog();
catalog.Upsert(CreateLease(signature, validFrom, expiresAt));
var clock = new ManualClock(Now);
var revocations = new Revocations(revoked ? new[] { ("lease:pilot", 7L) } : null);
var diagnostics = new CultMeshDiagnosticBuffer();
var resolver = new CultMeshAuthorityResolver(catalog, new SignatureVerifier(), revocations, clock, diagnostics);
var peer = new CultMeshPeerCard(peerId, peerVerse, Array.Empty<string>(), new[] { "pilot" }, new[] { "shard:a" }, authorityLeaseId: "lease:pilot");
return new Fixture(catalog, new CultMeshPeerCatalog(), clock, diagnostics, resolver, peer, requestRole, requestShard, requestEpoch, requestScope);
}
private static CultMeshAuthorityLease CreateLease(string? signature = "valid-signature", DateTimeOffset? validFrom = null, DateTimeOffset? expiresAt = null) =>
new("lease:pilot", "verse:aetheria", "peer:pilot", new[] { "pilot" }, new[] { "shard:a" }, "odin",
validFrom ?? Now.AddMinutes(-1), expiresAt ?? Now.AddMinutes(1), signature, 7, new[] { "body:world" });
private sealed record Fixture(
CultMeshAuthorityLeaseCatalog Catalog, CultMeshPeerCatalog CatalogPeers, ManualClock Clock,
CultMeshDiagnosticBuffer Diagnostics, CultMeshAuthorityResolver Resolver, CultMeshPeerCard Peer,
string Role, string Shard, long Epoch, string Scope)
{
public CultMeshAuthorityRequest Request() => new(Peer, Role, Shard, Epoch, Scope);
}
private sealed class SignatureVerifier : ICultMeshAuthoritySignatureVerifier
{
public bool Verify(CultMeshAuthorityLease lease) => lease.Signature?.StartsWith("valid", StringComparison.Ordinal) == true;
}
private sealed class Revocations : ICultMeshAuthorityRevocationSource
{
private readonly HashSet<(string, long)> _revoked;
public Revocations(IEnumerable<(string, long)>? revoked = null) => _revoked = revoked?.ToHashSet() ?? new();
public bool IsRevoked(string leaseId, long authorityEpoch) => _revoked.Contains((leaseId, authorityEpoch));
}
private sealed class ManualClock : ICultMeshClock
{
public ManualClock(DateTimeOffset now) => UtcNow = now;
public DateTimeOffset UtcNow { get; private set; }
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default) { Advance(delay); return Task.CompletedTask; }
public void Advance(TimeSpan duration) => UtcNow += duration;
}
}