diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs
index 8363696b..e37b07b1 100644
--- a/src/GameCult.Mesh/CultMesh.cs
+++ b/src/GameCult.Mesh/CultMesh.cs
@@ -2978,6 +2978,7 @@ namespace GameCult.Mesh
///
/// Creates a CultNet RUDP client transport from the first peer authorized for a Verse role.
///
+ [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);
+ }
+
+ /// Creates a RUDP client from the first contact accepted by the authority resolver.
+ 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
///
/// Creates and handshakes a CultNet RUDP client transport from the first peer authorized for a Verse role.
///
+ [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);
+ }
+
+ /// Creates and handshakes a RUDP client from the first contact accepted by the authority resolver.
+ 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;
+ }
+
///
/// Creates a committer for writing quorum simulation facts to a mesh database.
///
diff --git a/src/GameCult.Mesh/CultMeshAuthority.cs b/src/GameCult.Mesh/CultMeshAuthority.cs
index ed39b18f..05683317 100644
--- a/src/GameCult.Mesh/CultMeshAuthority.cs
+++ b/src/GameCult.Mesh/CultMeshAuthority.cs
@@ -1,17 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Threading;
namespace GameCult.Mesh
{
- ///
- /// Grants one peer authority to perform roles for a Verse over a bounded interval.
- ///
public sealed class CultMeshAuthorityLease
{
- ///
- /// Creates an authority lease.
- ///
+ 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? 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));
}
- /// Gets the stable lease id.
public string LeaseId { get; }
- /// Gets the Verse id governed by the lease.
public string VerseId { get; }
- /// Gets the peer id receiving authority.
public string PeerId { get; }
- /// Gets the roles granted by this lease.
public IReadOnlyList Roles { get; }
- /// Gets shard ids covered by this lease. Empty means every shard in the Verse.
public IReadOnlyList ShardIds { get; }
- /// Gets the runtime that issued the lease.
public string IssuerRuntimeId { get; }
- /// Gets the first timestamp when the lease is valid.
public DateTimeOffset ValidFrom { get; }
- /// Gets the timestamp when the lease expires.
public DateTimeOffset ExpiresAt { get; }
- /// Gets an optional signature over the lease.
public string? Signature { get; }
+ public long? AuthorityEpoch { get; }
+ public IReadOnlyList ResourceScopes { get; }
+ public string SchemaVersion { get; }
- ///
- /// Returns whether this lease is valid at the supplied time.
- ///
- public bool IsValidAt(DateTimeOffset at)
- {
- return at >= ValidFrom && at < ExpiresAt;
- }
+ public bool IsValidAt(DateTimeOffset at) => at >= ValidFrom && at < ExpiresAt;
- ///
- /// Returns whether this lease covers a peer card, role, and shard.
- ///
+ [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? values)
+ private static string[] Clean(IEnumerable? values) =>
+ values?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray() ?? Array.Empty();
+
+ 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();
+ 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;
}
}
- ///
- /// Local authority lease catalog.
- ///
- public sealed class CultMeshAuthorityLeaseCatalog
+ public interface ICultMeshAuthorityLeaseSource
{
- private readonly Dictionary _leases = new(StringComparer.Ordinal);
+ CultMeshAuthorityLease? Get(string leaseId);
+ }
- /// Gets all known leases.
- public IReadOnlyList Leases => _leases.Values.OrderBy(lease => lease.LeaseId, StringComparer.Ordinal).ToArray();
+ public interface ICultMeshAuthoritySignatureVerifier
+ {
+ bool Verify(CultMeshAuthorityLease lease);
+ }
- /// Adds or replaces an authority lease.
- 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;
}
- /// Gets a lease by id, if known.
+ 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 _leases = new(StringComparer.Ordinal);
+ public IReadOnlyList 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;
}
- ///
- /// Returns whether a peer card is authorized for a role and optional shard.
- ///
+ [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;
}
}
}
diff --git a/src/GameCult.Mesh/CultMeshGameSession.cs b/src/GameCult.Mesh/CultMeshGameSession.cs
index d34c1860..f9d34f25 100644
--- a/src/GameCult.Mesh/CultMeshGameSession.cs
+++ b/src/GameCult.Mesh/CultMeshGameSession.cs
@@ -28,6 +28,12 @@ namespace GameCult.Mesh
///
public CultMeshAuthorityLeaseCatalog? AuthorityLeases { get; set; }
+ ///
+ /// Gets or sets the sole authority resolver used by the session. When omitted, the
+ /// session installs a deny-by-default resolver over .
+ ///
+ public CultMeshAuthorityResolver? AuthorityResolver { get; set; }
+
///
/// Gets or sets simulation consensus options.
///
@@ -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
///
public CultMeshAuthorityLeaseCatalog AuthorityLeases { get; }
+ /// Gets the session authority decision owner.
+ public CultMeshAuthorityResolver AuthorityResolver { get; }
+
///
/// Gets the session simulation observation hub.
///
diff --git a/src/GameCult.Mesh/CultMeshPeerExchange.cs b/src/GameCult.Mesh/CultMeshPeerExchange.cs
index 109df07d..2489ebac 100644
--- a/src/GameCult.Mesh/CultMeshPeerExchange.cs
+++ b/src/GameCult.Mesh/CultMeshPeerExchange.cs
@@ -192,23 +192,40 @@ namespace GameCult.Mesh
}
/// Finds peers that advertise a Verse role and are authorized by the supplied lease catalog.
+ [Obsolete("Supply CultMeshAuthorityResolver and an explicit authority epoch. This compatibility path denies unverifiable leases.")]
public IReadOnlyList 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);
+ }
+
+ /// Finds contact candidates accepted by the authority resolver.
+ public IReadOnlyList 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();
}
/// Returns the first peer that advertises a Verse role and is authorized by the supplied lease catalog.
+ [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();
}
+ /// Returns the first contact candidate accepted by the authority resolver.
+ 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();
+ }
+
///
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;
+ }
}
///
diff --git a/src/GameCult.Mesh/docs/reliability-migration-plan.md b/src/GameCult.Mesh/docs/reliability-migration-plan.md
index 65e97a9c..0144524e 100644
--- a/src/GameCult.Mesh/docs/reliability-migration-plan.md
+++ b/src/GameCult.Mesh/docs/reliability-migration-plan.md
@@ -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`.
diff --git a/tests/GameCult.Mesh.Tests/CultMeshAuthorityResolverTests.cs b/tests/GameCult.Mesh.Tests/CultMeshAuthorityResolverTests.cs
new file mode 100644
index 00000000..d05fa93f
--- /dev/null
+++ b/tests/GameCult.Mesh.Tests/CultMeshAuthorityResolverTests.cs
@@ -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(), 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(), 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(), 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().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().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(), 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;
+ }
+}