2026-11-08 13:41:14 +00:00
using System ;
using System.Collections.Generic ;
using System.Security.Cryptography ;
2026-11-08 14:04:37 +00:00
using GameCult.Caching ;
2026-11-08 13:41:14 +00:00
using MessagePack ;
namespace GameCult.Networking
{
/// <summary>
/// The content hash a write-receipt server binds to a (peer, messageId) dedupe key
/// (contracts/cultnet/interop/write-receipt.md, rule 5).
/// </summary>
public static class CultNetWriteContentHash
{
/// <summary>
/// SHA-256 of the MessagePack array <c>[operation, schemaId, recordKey, payload]</c>, <c>payload</c> a <c>bin</c>
/// for a put and nil for a delete.
/// </summary>
public static byte [ ] Compute ( string operation , string schemaId , string recordKey , byte [ ] ? payload )
{
var buffer = new System . Buffers . ArrayBufferWriter < byte > ( ) ;
var writer = new MessagePackWriter ( buffer ) ;
writer . WriteArrayHeader ( 4 ) ;
writer . Write ( operation ) ;
writer . Write ( schemaId ) ;
writer . Write ( recordKey ) ;
if ( payload = = null ) writer . WriteNil ( ) ;
else writer . Write ( payload ) ;
writer . Flush ( ) ;
2026-11-08 14:04:37 +00:00
using var sha = SHA256 . Create ( ) ;
return sha . ComputeHash ( buffer . WrittenSpan . ToArray ( ) ) ;
2026-11-08 13:41:14 +00:00
}
}
/// <summary>
/// Whether a peer's hello opted in to write receipts: it lists both <c>cultnet.write_admitted.v0</c> and
/// <c>cultnet.error.v1</c>. The capability changes framing only; it never authorizes.
/// </summary>
internal static class CultNetWriteReceiptCapability
{
public static bool Declares ( CultNetHelloMessage hello )
{
var versions = hello . SupportedMessageVersions ;
return versions ! = null
& & Array . IndexOf ( versions , CultNetSchemaVersions . WriteAdmitted ) > = 0
& & Array . IndexOf ( versions , CultNetSchemaVersions . ErrorV1 ) > = 0 ;
}
}
2026-11-08 15:07:23 +00:00
/// <summary>A SHA-256 digest held by value: the fixed-size stand-in for an id or a content hash.</summary>
internal readonly struct CultNetDigest : IEquatable < CultNetDigest >
{
private readonly ulong _a , _b , _c , _d ;
private CultNetDigest ( ulong a , ulong b , ulong c , ulong d ) { _a = a ; _b = b ; _c = c ; _d = d ; }
public static CultNetDigest Of ( byte [ ] bytes )
{
using var sha = SHA256 . Create ( ) ;
var hash = sha . ComputeHash ( bytes ) ;
return new CultNetDigest (
BitConverter . ToUInt64 ( hash , 0 ) , BitConverter . ToUInt64 ( hash , 8 ) ,
BitConverter . ToUInt64 ( hash , 16 ) , BitConverter . ToUInt64 ( hash , 24 ) ) ;
}
public bool Equals ( CultNetDigest other ) = > _a = = other . _a & & _b = = other . _b & & _c = = other . _c & & _d = = other . _d ;
public override bool Equals ( object? obj ) = > obj is CultNetDigest other & & Equals ( other ) ;
public override int GetHashCode ( ) = > unchecked ( ( int ) _a ^ ( int ) ( _a > > 32 ) ) ;
public static bool operator = = ( CultNetDigest left , CultNetDigest right ) = > left . Equals ( right ) ;
public static bool operator ! = ( CultNetDigest left , CultNetDigest right ) = > ! left . Equals ( right ) ;
}
2026-11-08 16:42:22 +00:00
/// <summary>
/// What a session keeps of a refusal (write-receipt.md, What is remembered): its code, the field its details name, and its
/// routing hint (a shard descriptor the server owns). Never the write's strings or an authority's reason: the replay is built
/// from fixed text and the fields of the resend, which carries the same content.
/// </summary>
internal readonly struct CultNetRememberedRefusal
{
public string? Code { get ; }
public bool HasDetails { get ; }
public string? Field { get ; }
public CultNetShardRoutingHint ? Hint { get ; }
public CultNetRememberedRefusal ( string? code , bool hasDetails , string? field , CultNetShardRoutingHint ? hint )
{
Code = code ; HasDetails = hasDetails ; Field = field ; Hint = hint ;
}
public static CultNetRememberedRefusal Of ( CultNetErrorMessage error ) = >
new ( error . Code , error . Details ! = null , error . Details ? . Field , error . RoutingHint ) ;
2026-11-08 17:01:59 +00:00
private static string? NullIfEmpty ( string value ) = > value . Length = = 0 ? null : value ;
2026-11-08 16:42:22 +00:00
/// <summary>The refusal, in fixed text, naming the write's own record key or schema id where its details do.</summary>
public CultNetErrorMessage ToError ( string schemaId , string recordKey ) = > new ( )
{
Error = Code switch
{
"unowned_schema" = > "unowned_schema: no shard owns the schema and key" ,
null = > Hint ! = null ? CultNetDatabaseServer . RoutingRefusalText : CultNetWriteReceiptSession . WriteFailedText ,
_ = > Code
} ,
Code = Code ,
RoutingHint = Hint ,
Details = HasDetails
? new CultNetErrorDetails
{
Field = Field ,
2026-11-08 17:01:59 +00:00
Value = Field switch { "recordKey" = > NullIfEmpty ( recordKey ) , "schemaId" = > NullIfEmpty ( schemaId ) , _ = > null }
2026-11-08 16:42:22 +00:00
}
: null
} ;
}
2026-11-08 13:41:14 +00:00
/// <summary>
2026-11-08 14:32:39 +00:00
/// One receipt-capable peer's writes: message id to content hash and the answer given, refusals included, so a resend of the
2026-11-08 15:07:23 +00:00
/// same content is answered again without being decided again and the same id with other content is refused. Ids and content
2026-11-08 16:42:22 +00:00
/// hashes are held as fixed-size digests; the answer is held as its kind (and, for a refusal, its code and the field its details name),
2026-11-08 15:07:23 +00:00
/// never the strings of the write.
2026-11-08 13:41:14 +00:00
/// </summary>
internal sealed class CultNetWriteReceiptSession
{
2026-11-08 14:32:39 +00:00
/// <summary>The most writes a peer is remembered for, refused ones included; a peer at the limit has new writes refused.</summary>
2026-11-08 13:41:14 +00:00
internal const int MaxRememberedWrites = 1024 ;
2026-11-08 15:07:23 +00:00
/// <summary>The most ids refused at the limit that are remembered, so their resend is judged the same way.</summary>
internal const int MaxRememberedLimitRefusals = 1024 ;
/// <summary>The longest messageId a write may carry, in UTF-8 bytes (write-receipt.md, Id size).</summary>
internal const int MaxMessageIdBytes = 256 ;
2026-11-08 14:32:39 +00:00
/// <summary>What a write is refused with when it failed for a reason no typed refusal names. Never an exception's own text.</summary>
internal const string WriteFailedText = "the write could not be applied" ;
2026-11-08 13:41:14 +00:00
private readonly object _gate = new ( ) ;
2026-11-08 15:07:23 +00:00
private readonly Dictionary < CultNetDigest , Entry > _writes = new ( ) ;
private readonly Dictionary < CultNetDigest , CultNetDigest > _limitRefusals = new ( ) ;
2026-11-08 13:41:14 +00:00
private sealed class Entry
{
2026-11-08 15:07:23 +00:00
public Entry ( CultNetDigest hash ) { Hash = hash ; }
public CultNetDigest Hash { get ; set ; }
public bool Abandoned { get ; set ; }
public bool Admitted { get ; set ; }
2026-11-08 16:42:22 +00:00
public CultNetRememberedRefusal ? Refusal { get ; set ; }
2026-11-08 13:41:14 +00:00
}
2026-11-08 15:07:23 +00:00
public enum Begin { New , ReplayAdmitted , ReplayRefusal , Unanswered , DifferentContent , Full }
private static CultNetDigest IdDigest ( string messageId ) = > CultNetDigest . Of ( System . Text . Encoding . UTF8 . GetBytes ( messageId ) ) ;
2026-11-08 13:41:14 +00:00
2026-11-08 14:32:39 +00:00
/// <summary>
/// Looks <paramref name="messageId"/> up first. An id not seen is claimed for this content (<see cref="Begin.New"/>);
2026-11-08 15:07:23 +00:00
/// otherwise the outcome says what already holds it, and <paramref name="refusal"/> is the refusal to repeat.
2026-11-08 14:32:39 +00:00
/// </summary>
2026-11-08 16:42:22 +00:00
public Begin TryBegin ( string messageId , byte [ ] hash , out CultNetRememberedRefusal ? refusal )
2026-11-08 13:41:14 +00:00
{
2026-11-08 15:07:23 +00:00
var id = IdDigest ( messageId ) ;
var content = CultNetDigest . Of ( hash ) ;
2026-11-08 13:41:14 +00:00
lock ( _gate )
{
2026-11-08 15:07:23 +00:00
refusal = null ;
if ( _writes . TryGetValue ( id , out var entry ) )
2026-11-08 13:41:14 +00:00
{
2026-11-08 15:07:23 +00:00
if ( entry . Abandoned )
{
// Not sent, so judged afresh in the slot it already holds; no slot is ever freed.
entry . Abandoned = false ;
entry . Hash = content ;
return Begin . New ;
}
if ( entry . Hash ! = content ) return Begin . DifferentContent ;
if ( entry . Admitted ) return Begin . ReplayAdmitted ;
if ( entry . Refusal ! = null )
2026-11-08 14:32:39 +00:00
{
2026-11-08 15:07:23 +00:00
refusal = entry . Refusal ;
return Begin . ReplayRefusal ;
2026-11-08 14:32:39 +00:00
}
2026-11-08 14:53:05 +00:00
// No answer yet: the first attempt is still being applied, or its primary left it unanswered for good.
return Begin . Unanswered ;
2026-11-08 13:41:14 +00:00
}
2026-11-08 15:07:23 +00:00
if ( _writes . Count > = MaxRememberedWrites )
{
if ( _limitRefusals . TryGetValue ( id , out var refused ) ) return refused = = content ? Begin . Full : Begin . DifferentContent ;
if ( _limitRefusals . Count < MaxRememberedLimitRefusals ) _limitRefusals [ id ] = content ;
return Begin . Full ;
}
_writes [ id ] = new Entry ( content ) ;
2026-11-08 13:41:14 +00:00
return Begin . New ;
}
}
2026-11-08 15:07:23 +00:00
/// <summary>The write was admitted: remember it so a resend repeats the receipt.</summary>
public void Admitted ( string messageId )
{
lock ( _gate )
{
if ( _writes . TryGetValue ( IdDigest ( messageId ) , out var entry ) ) entry . Admitted = true ;
}
}
/// <summary>The write was refused: remember the refusal so a resend repeats it.</summary>
2026-11-08 16:42:22 +00:00
public void Refused ( string messageId , CultNetRememberedRefusal refusal )
2026-11-08 14:32:39 +00:00
{
lock ( _gate )
{
2026-11-08 15:07:23 +00:00
if ( _writes . TryGetValue ( IdDigest ( messageId ) , out var entry ) ) entry . Refusal = refusal ;
2026-11-08 14:32:39 +00:00
}
}
2026-11-08 15:07:23 +00:00
/// <summary>
/// The write never reached anyone who could have applied it: a repeat is judged afresh. The entry keeps its slot, so
/// forgetting a write never makes room for an id that was refused at the limit.
/// </summary>
2026-11-08 13:41:14 +00:00
public void Abandoned ( string messageId )
{
lock ( _gate )
{
2026-11-08 15:07:23 +00:00
if ( _writes . TryGetValue ( IdDigest ( messageId ) , out var entry ) ) entry . Abandoned = true ;
2026-11-08 13:41:14 +00:00
}
}
}
2026-11-08 14:04:37 +00:00
/// <summary>
/// The messageId of a schema frame that does not decode (write-receipt.md, rule 3: a refusal carries the write's id whenever
/// the frame is readable enough to find it).
/// </summary>
internal static class CultNetReadableMessageId
{
/// <summary>The frame's top-level string <c>messageId</c>, or null when the bytes are not a map holding one.</summary>
public static string? From ( byte [ ] payload )
{
try
{
var reader = new MessagePackReader ( payload ) ;
var entries = reader . ReadMapHeader ( ) ;
for ( var index = 0 ; index < entries ; index + + )
{
var key = reader . NextMessagePackType = = MessagePackType . String ? reader . ReadString ( ) : null ;
if ( key = = "messageId" & & reader . NextMessagePackType = = MessagePackType . String )
{
var id = reader . ReadString ( ) ;
return string . IsNullOrEmpty ( id ) ? null : id ;
}
if ( key = = null ) reader . Skip ( ) ;
reader . Skip ( ) ;
}
}
catch ( Exception )
{
// Not a readable map: no id.
}
return null ;
}
}
/// <summary>
/// One write on a receipt-capable connection: its dedupe claim and its single answer (write-receipt.md, rules 1 to 5).
/// </summary>
internal sealed class CultNetReceiptedWrite
{
private readonly CultNetWriteReceiptSession _session ;
private readonly Action < ICultNetSchemaMessage > _send ;
private readonly string _operation ;
private readonly string _messageId ;
private readonly string _schemaId ;
private readonly string _recordKey ;
private readonly byte [ ] ? _payload ;
private bool _claimed ;
private bool _answered ;
public CultNetReceiptedWrite (
CultNetWriteReceiptSession session ,
Action < ICultNetSchemaMessage > send ,
string operation ,
string? messageId ,
string? schemaId ,
string? recordKey ,
byte [ ] ? payload )
{
_session = session ;
_send = send ;
_operation = operation ;
_messageId = messageId ? ? string . Empty ;
_schemaId = schemaId ? ? string . Empty ;
_recordKey = recordKey ? ? string . Empty ;
_payload = payload ;
}
/// <summary>
2026-11-08 14:32:39 +00:00
/// Looks the write's id up before anything else is decided (rule 5). True when the id is new and the write should go on
/// to authorization and the commit; false when it was answered here (a replay, a reused id, a full table) or is owed no
/// further answer (still in flight, or left silent by its primary).
2026-11-08 14:04:37 +00:00
/// </summary>
2026-11-08 14:32:39 +00:00
public bool Begin ( )
2026-11-08 14:04:37 +00:00
{
2026-11-08 15:07:23 +00:00
var outcome = _session . TryBegin ( _messageId , CultNetWriteContentHash . Compute ( _operation , _schemaId , _recordKey , _payload ) , out var refusal ) ;
2026-11-08 14:32:39 +00:00
switch ( outcome )
2026-11-08 14:04:37 +00:00
{
2026-11-08 14:32:39 +00:00
case CultNetWriteReceiptSession . Begin . New :
_claimed = true ;
return true ;
2026-11-08 15:07:23 +00:00
case CultNetWriteReceiptSession . Begin . ReplayAdmitted :
_answered = true ;
_send ( AdmittedReceipt ( ) ) ;
return false ;
case CultNetWriteReceiptSession . Begin . ReplayRefusal :
2026-11-08 14:32:39 +00:00
_answered = true ;
2026-11-08 16:42:22 +00:00
_send ( CultNetErrorV1Message . From ( refusal ! . Value . ToError ( _schemaId , _recordKey ) , _messageId ) ) ;
2026-11-08 14:32:39 +00:00
return false ;
2026-11-08 14:53:05 +00:00
case CultNetWriteReceiptSession . Begin . Unanswered :
// The first attempt is still being applied and will answer this id, or its primary left it unanswered:
// then the claim stays, so the id is never sent again on this session, and the write stays unconfirmed.
2026-11-08 14:32:39 +00:00
_answered = true ;
return false ;
case CultNetWriteReceiptSession . Begin . DifferentContent :
Refused ( CultNetErrorMessage . ForMessageInvalid ( "messageId" , "the message id was already used for different content" ) ) ;
return false ;
default :
_answered = true ;
_send ( CultNetErrorV1Message . LimitReached ( _messageId ) ) ;
return false ;
2026-11-08 14:04:37 +00:00
}
2026-11-08 14:32:39 +00:00
}
2026-11-08 14:04:37 +00:00
2026-11-08 15:07:23 +00:00
private CultNetWriteAdmittedMessage AdmittedReceipt ( ) = > new ( )
{
MessageId = _messageId ,
Operation = _operation ,
SchemaId = _schemaId ,
RecordKey = _recordKey
} ;
2026-11-08 14:32:39 +00:00
/// <summary>The write committed: remember the receipt and send it.</summary>
2026-11-08 14:04:37 +00:00
public void Admitted ( )
{
if ( _answered ) return ;
_answered = true ;
2026-11-08 15:07:23 +00:00
if ( _claimed ) _session . Admitted ( _messageId ) ;
_send ( AdmittedReceipt ( ) ) ;
2026-11-08 14:04:37 +00:00
}
2026-11-08 14:32:39 +00:00
/// <summary>The write was refused: remember the refusal (a resend repeats it) and send its one error.v1.</summary>
2026-11-08 14:04:37 +00:00
public void Refused ( CultNetErrorMessage error )
{
if ( _answered ) return ;
_answered = true ;
2026-11-08 16:42:22 +00:00
if ( _claimed )
{
// A remembered refusal is answered from what is remembered, so the replay is the refusal again.
var kept = CultNetRememberedRefusal . Of ( error ) ;
_session . Refused ( _messageId , kept ) ;
error = kept . ToError ( _schemaId , _recordKey ) ;
}
_send ( CultNetErrorV1Message . From ( error , _messageId ) ) ;
2026-11-08 14:04:37 +00:00
}
2026-11-08 14:32:39 +00:00
/// <summary>
2026-11-08 15:02:56 +00:00
/// A forward that may have reached its primary and is unconfirmed (silence, reset, timeout, an ambiguous send): no answer is
/// owed or possible, and the claim stays so the id is never forwarded again on this session. Sends nothing.
2026-11-08 14:32:39 +00:00
/// </summary>
public void LeftSilent ( )
2026-11-08 14:04:37 +00:00
{
if ( _answered ) return ;
_answered = true ;
}
2026-11-08 15:02:56 +00:00
/// <summary>A forward that was provably not sent (connect failed, or the send failed before any byte was written): forget the claim, send nothing.</summary>
public void NotSent ( )
2026-11-08 14:04:37 +00:00
{
2026-11-08 14:32:39 +00:00
if ( _answered ) return ;
_answered = true ;
if ( _claimed ) _session . Abandoned ( _messageId ) ;
_claimed = false ;
2026-11-08 14:04:37 +00:00
}
2026-11-08 15:07:23 +00:00
/// <summary>
/// Why the write's id cannot be used at all, before anything is remembered: it is missing (refused with no id) or longer
/// than <see cref="CultNetWriteReceiptSession.MaxMessageIdBytes"/> UTF-8 bytes. Null when the id is usable.
/// </summary>
public CultNetErrorMessage ? IdRefusal = >
_messageId . Length = = 0
? CultNetErrorMessage . ForMessageInvalid ( "messageId" , "a write needs a messageId" )
: System . Text . Encoding . UTF8 . GetByteCount ( _messageId ) > CultNetWriteReceiptSession . MaxMessageIdBytes
? CultNetErrorMessage . ForMessageInvalid ( "messageId" , "messageId exceeds 256 bytes" )
: null ;
2026-11-08 14:04:37 +00:00
}
2026-11-08 13:41:14 +00:00
}