Add CultMesh authority lease checks

This commit is contained in:
Meta Vens 2026-05-20 01:49:18 +01:00
commit e5e993e7aa
7 changed files with 211 additions and 0 deletions

View file

@ -204,6 +204,14 @@ namespace GameCult.Mesh
return new CultMeshPeerCatalog();
}
/// <summary>
/// Creates a local authority lease catalog.
/// </summary>
public static CultMeshAuthorityLeaseCatalog CreateAuthorityLeaseCatalog()
{
return new CultMeshAuthorityLeaseCatalog();
}
/// <summary>
/// Attaches Verse discovery responses to a CultMesh node.
/// </summary>

View file

@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using System.Linq;
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 CultMeshAuthorityLease(
string leaseId,
string verseId,
string peerId,
IEnumerable<string> roles,
IEnumerable<string>? shardIds,
string issuerRuntimeId,
DateTimeOffset validFrom,
DateTimeOffset expiresAt,
string? signature = null)
{
LeaseId = RequireNonEmpty(leaseId, nameof(leaseId));
VerseId = RequireNonEmpty(verseId, nameof(verseId));
PeerId = RequireNonEmpty(peerId, nameof(peerId));
Roles = Clean(roles);
ShardIds = Clean(shardIds);
IssuerRuntimeId = RequireNonEmpty(issuerRuntimeId, nameof(issuerRuntimeId));
ValidFrom = validFrom;
ExpiresAt = expiresAt;
Signature = signature;
if (expiresAt <= validFrom)
{
throw new ArgumentException("Lease expiry must be after its valid-from timestamp.", nameof(expiresAt));
}
}
/// <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; }
/// <summary>
/// Returns whether this lease is valid at the supplied time.
/// </summary>
public bool IsValidAt(DateTimeOffset at)
{
return at >= ValidFrom && at < ExpiresAt;
}
/// <summary>
/// Returns whether this lease covers a peer card, role, and shard.
/// </summary>
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));
}
private static string[] Clean(IEnumerable<string>? values)
{
return values?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray()
?? Array.Empty<string>();
}
private static string RequireNonEmpty(string value, string paramName)
{
return string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value must be non-empty.", paramName)
: value;
}
}
/// <summary>
/// Local authority lease catalog.
/// </summary>
public sealed class CultMeshAuthorityLeaseCatalog
{
private readonly Dictionary<string, CultMeshAuthorityLease> _leases = new(StringComparer.Ordinal);
/// <summary>Gets all known leases.</summary>
public IReadOnlyList<CultMeshAuthorityLease> Leases => _leases.Values.OrderBy(lease => lease.LeaseId, StringComparer.Ordinal).ToArray();
/// <summary>Adds or replaces an authority lease.</summary>
public void Upsert(CultMeshAuthorityLease lease)
{
if (lease == null) throw new ArgumentNullException(nameof(lease));
_leases[lease.LeaseId] = lease;
}
/// <summary>Gets a lease by id, if known.</summary>
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>
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;
}
return _leases.TryGetValue(peer.AuthorityLeaseId!, out var lease) &&
lease.Covers(peer, role, shardId, at);
}
}
}

View file

@ -92,6 +92,20 @@ Peer cards are contact candidates, not authority by themselves. Public Verses
should validate authority leases or signatures before trusting a peer for
committed state.
```csharp
var leases = CultMesh.CreateAuthorityLeaseCatalog();
leases.Upsert(new CultMeshAuthorityLease(
"lease:players-us-east:42",
"aetheria-main",
"gc-us-east-1",
[CultMeshPeerRoles.ShardPrimary],
["players-us-east"],
"gamecult-operator",
DateTimeOffset.UtcNow.AddMinutes(-1),
DateTimeOffset.UtcNow.AddMinutes(10),
signature: "operator-signature"));
```
Clients can fetch more peers from known peers:
```csharp

View file

@ -14,6 +14,7 @@ Namespace: `GameCult.Mesh`
- `CultMesh.CreatePeerCatalog()`
- `CultMesh.CreateVerseDiscoveryClient(...)`
- `CultMesh.CreatePeerExchangeClient(...)`
- `CultMesh.CreateAuthorityLeaseCatalog()`
- `CultMesh.CreateClient(...)`
- `CultMesh.ConnectClient(...)`
@ -66,6 +67,14 @@ upsert them into a local catalog.
Peer cards are discovery hints; authority still requires a valid lease or
signature for the target Verse.
### Authority Leases
`CultMeshAuthorityLease` grants one peer specific roles for a Verse, optionally
scoped to shard ids and bounded by time. `CultMeshAuthorityLeaseCatalog` checks
whether a peer card is authorized for a requested role and shard. This keeps
peer exchange separate from authority instead of letting contact gossip mutate
the world.
### Shard Authority
Each shard has one primary writer for now. Non-primary writes are rejected or

View file

@ -64,3 +64,8 @@ primary shard, replica, read replica, and simulation observer roles, but those
cards do not grant authority by themselves. Public operator Verses should treat
authority lease ids and signatures as mandatory before accepting committed
state from a peer.
Authority leases are the narrow bridge between discovery and trust. A lease
binds a peer id to a Verse, role, shard scope, issuer, validity window, and
signature. Peer exchange can spread cards quickly; lease validation decides
whether a card is allowed to touch committed state.

View file

@ -74,6 +74,8 @@ Implemented:
- schema-v0 peer exchange request/response messages
- CultMesh peer cards, peer catalog, peer exchange server bridge, and peer
exchange client
- CultMesh authority leases and lease catalog checks for peer role/shard
authorization
- client-authority scopes for locally predicted input documents
- predicted and reconciled database change kinds for client-side prediction
- simulation witness observations for mesh-side opinions about frame facts
@ -123,12 +125,16 @@ Not implemented yet:
until a shard owner writes them into the log.
- Peer cards are contact candidates. They can advertise roles and authority
lease ids, but they do not grant authority without Verse-specific validation.
- Authority leases bind peer id, Verse id, roles, optional shard scope, issuer,
validity window, and signature. They are the first trust boundary above peer
exchange.
## Next Coherent Slice
Add discovery fanout and operational polish:
- add periodic gossip fanout over known peer cards
- add real signature verification for authority leases
- add snapshot throttling and size limits for public mesh edges
- after that, add simulation-frame rollback/resimulation helpers around
predicted input streams

View file

@ -1020,6 +1020,35 @@ namespace GameCult.Networking.Tests
Assert.That(updates, Has.Count.EqualTo(1));
}
[Test]
public void CultMeshAuthorityLeaseCatalog_Authorizes_PeerRoleAndShard()
{
var now = DateTimeOffset.Parse("2026-05-20T12:00:00.0000000Z");
var catalog = CultMesh.CreateAuthorityLeaseCatalog();
var peer = new CultMeshPeerCard(
"peer-primary",
"aetheria-main",
["cultnet://primary.example.test:3075"],
roles: [CultMeshPeerRoles.ShardPrimary],
shardIds: ["players-us-east"],
authorityLeaseId: "lease-primary");
catalog.Upsert(new CultMeshAuthorityLease(
"lease-primary",
"aetheria-main",
"peer-primary",
[CultMeshPeerRoles.ShardPrimary],
["players-us-east"],
"gc-operator",
now.AddMinutes(-5),
now.AddMinutes(5),
signature: "sig"));
Assert.That(catalog.IsAuthorized(peer, CultMeshPeerRoles.ShardPrimary, "players-us-east", now), Is.True);
Assert.That(catalog.IsAuthorized(peer, CultMeshPeerRoles.ShardPrimary, "players-eu", now), Is.False);
Assert.That(catalog.IsAuthorized(peer, CultMeshPeerRoles.ReadReplica, "players-us-east", now), Is.False);
Assert.That(catalog.IsAuthorized(peer, CultMeshPeerRoles.ShardPrimary, "players-us-east", now.AddMinutes(6)), Is.False);
}
[Test]
public async Task CultMeshNode_CanEnable_DurableShardLogs_WithDefaultPath()
{