Desk: UnixSocketDeskDoor (Huginn desk door client), fixtures at 79d954c, card re-pin to CultLib 3a51bbab, card opaque (#70)

This commit is contained in:
Heimdall 2026-11-12 11:44:45 +00:00
commit c2de290628
129 changed files with 8417 additions and 1527 deletions

View file

@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MessagePack" Version="3.1.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View file

@ -4,9 +4,13 @@ using System.Text.Json.Serialization;
namespace Bifrost.Web.Features.Desk;
// The fork-desk documents Bifrost reads or writes, spelled as CultLib's contracts/cultnet/governance/fork-card.md
// (commit de8a062a) spells them: snake_case keys, every key written (nil where absent), declared in the
// (commit d3025d3c) spells them: snake_case keys, every key written (nil where absent), declared in the
// contract's key order. Bifrost authors only DeskRefusal and DeskUnavailable. AnswerRequest is the client's body;
// ForkCard, DeskAnswerDocument and AnswerResult are Huginn's documents, typed here so they pass through unchanged.
// DeskAnswerDocument, ForkList and AnswerResult are Huginn's documents, typed here so they pass through unchanged.
// The card (gamecult.fork.card.v0) is deliberately NOT typed: the Huginn door owns what a reader's card holds (the
// decider view carries ids, the non-decider view withholds them and keeps decider_count), and Bifrost neither reads
// nor edits it, so it is an opaque JsonElement that keeps every key, key order and integer. The door refuses a float,
// a binary or an extension value anywhere in a reply (the contract has none), so no number is ever rewritten.
public static class DeskSchemas
{
@ -53,32 +57,6 @@ public sealed record DeskUnavailable(
[property: JsonPropertyName("error")] string Error,
[property: JsonPropertyName("retryAfterSeconds")] int? RetryAfterSeconds);
public sealed record ForkOption(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("text")] string Text);
public sealed record ForkRef(
[property: JsonPropertyName("kind")] string Kind,
[property: JsonPropertyName("id")] string Id);
public sealed record ForkCard(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("fork_id")] string ForkId,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("context")] string Context,
[property: JsonPropertyName("options")] ForkOption[] Options,
[property: JsonPropertyName("recommendation")] string Recommendation,
[property: JsonPropertyName("depends")] string[] Depends,
[property: JsonPropertyName("leans")] string? Leans,
[property: JsonPropertyName("flag")] string? Flag,
[property: JsonPropertyName("raised_in")] ForkRef? RaisedIn,
[property: JsonPropertyName("deciders")] string[] Deciders,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("ruling")] string? Ruling,
[property: JsonPropertyName("ruled_from")] string[] RuledFrom,
[property: JsonPropertyName("order")] long Order,
[property: JsonPropertyName("options_hash")] string OptionsHash);
// One stored answer: Counted, Revision, Retraction or Refused, told apart by Outcome and Code.
public sealed record DeskAnswerDocument(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
@ -94,7 +72,9 @@ public sealed record DeskAnswerDocument(
[property: JsonPropertyName("answered_at")] string AnsweredAt,
[property: JsonPropertyName("received_at")] string ReceivedAt,
[property: JsonPropertyName("claim_in_future")] bool ClaimInFuture,
[property: JsonPropertyName("answerer")] string Answerer,
// Nil in a non-decider's view (Huginn withholds the ids). The key is required, and the door's wire walker (Closed in
// UnixSocketDeskDoor) is the one owner of that: a missing key is a door fault, never a nil Bifrost invented.
[property: JsonPropertyName("answerer")] string? Answerer,
[property: JsonPropertyName("authority")] string Authority,
[property: JsonPropertyName("request_id")] string RequestId);
@ -103,11 +83,12 @@ public sealed record ForkRecord(
[property: JsonPropertyName("fork_id")] string ForkId,
[property: JsonPropertyName("options_hash")] string OptionsHash,
[property: JsonPropertyName("counted_answer_id")] string? CountedAnswerId,
[property: JsonPropertyName("answerer_count")] int AnswererCount,
[property: JsonPropertyName("answers")] DeskAnswerDocument[] Answers);
public sealed record ForkEntry(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("card")] ForkCard Card,
[property: JsonPropertyName("card")] JsonElement Card,
[property: JsonPropertyName("record")] ForkRecord Record);
public sealed record ForkList(
@ -122,7 +103,7 @@ public sealed record ForkList(
public sealed record AnswerResult(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("answer")] DeskAnswerDocument Answer,
[property: JsonPropertyName("card")] ForkCard? Card,
[property: JsonPropertyName("card")] JsonElement? Card,
[property: JsonPropertyName("counted_answer_id")] string? CountedAnswerId);
// The refusal codes Bifrost itself writes, each with its one status and message (vectors doorRefusals).

View file

@ -46,6 +46,7 @@ public static class DeskEndpoints
services.TryAddSingleton<IDeskCredential, OidcDeskCredential>();
}
services.TryAddSingleton<IDeskDoor>(_ => new UnixSocketDeskDoor());
services.AddSingleton(provider => new DeskMindRoutes(provider.GetRequiredService<IOptions<DeskOptions>>().Value.Minds));
services.AddCors();
// Exactly the configured origin, no credentials, and only on the routes that require this policy.

View file

@ -2,8 +2,9 @@ namespace Bifrost.Web.Features.Desk;
// The two ports the desk stands on. Bifrost owns the edge's order and judgments; these two own what is not
// Bifrost's yet: validating a bearer token (the OIDC validation scheme) and speaking to a Mind's Huginn on the
// peer-checked door. No production implementation is registered here: with the Desk section configured and a port
// missing, startup fails (DeskEndpoints.MapDesk) instead of serving.
// peer-checked door. The door's production implementation is UnixSocketDeskDoor (registered by AddBifrostDesk unless
// a test or host registered its own); the credential has none registered here, so with the Desk section configured
// and a port missing, startup fails (DeskEndpoints.MapDesk) instead of serving.
// What a validated bearer token claims. The credential port verifies the token; the desk judges audience, scope and
// membership. A null Subject or Audience means the token did not carry one.

View file

@ -0,0 +1,624 @@
using System.Buffers;
using System.Buffers.Binary;
using System.Globalization;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using MessagePack;
namespace Bifrost.Web.Features.Desk;
// The door could not confirm an outcome: no connection, no whole reply in time, or a reply that is not the contract's
// (an error, a failure, a Mind refusal, an unknown variant). The desk edge maps any exception from the door to 503
// `unavailable`; this is the one type the client throws for every one of those, so a failure is never an empty page.
public sealed class DeskDoorException(string message, Exception? inner = null) : Exception(message, inner);
// The production IDeskDoor: a client of Huginn's desk door (Huginn docs/desk-feed-door.md at 232764bf). One new
// connection per call, one request frame, one reply frame, closed after, all inside one deadline. The client holds no
// connection state, so a dead socket fails only the calls addressed to that Mind.
//
// frame = u32 big-endian length || CultNet message (MessagePack map), at most 65536 bytes each way
// payload = standard base64 of the named MessagePack of Huginn's externally tagged DeskRequest / DeskResponse
//
// The reply is decoded by key, never by position, and strictly: every contract document is a closed map whose keys are
// all present with the right type (the variants' own maps too: as_of is an unsigned integer, as Huginn's DeskResponse
// declares), a key written twice anywhere is a failure, and so is a float, a binary or an extension value (the
// contract has none; integers keep every digit). The card inside an entry is not read beyond
// being a map: it stays an opaque JsonElement, so whatever keys and integers Huginn wrote leave unchanged.
public sealed partial class UnixSocketDeskDoor : IDeskDoor
{
public const int MaxFrameBytes = 65536;
public static readonly TimeSpan Deadline = TimeSpan.FromSeconds(1);
private const string ServiceId = "huginn.desk";
private const string RequestSchema = "huginn.desk_request.v1";
private const string ResponseSchema = "huginn.desk_response.v1";
private const string Encoding64 = "messagepack-base64";
private const int MaxMessageIdBytes = 256;
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
private readonly Func<string> messageIds;
public UnixSocketDeskDoor()
: this(() => Guid.NewGuid().ToString("N"))
{
}
public UnixSocketDeskDoor(Func<string> messageIds) => this.messageIds = messageIds;
public async Task<DeskAnswerOutcome> AnswerAsync(
string socket, DeskDoorContext context, ReadOnlyMemory<byte> body, CancellationToken cancellationToken)
{
byte[] request;
try
{
// Every key, null and key order of the client's body, as MessagePack.
request = MessagePackSerializer.ConvertFromJson(StrictUtf8.GetString(body.Span));
}
catch (Exception e) when (e is not OperationCanceledException)
{
throw new DeskDoorException("the answer body does not convert to the request map", e);
}
var payload = Payload("Answer", 5, (ref MessagePackWriter w) =>
{
w.Write("mind");
w.Write(context.Mind);
w.Write("subject");
w.Write(context.Subject);
w.Write("authority");
w.Write(context.Authority);
w.Write("deciders");
Strings(ref w, context.Deciders);
w.Write("request");
w.WriteRaw(request);
});
var (variant, value) = await ExchangeAsync(socket, "answer", payload, cancellationToken);
switch (variant)
{
case "Answered":
Closed(value, "Answered", string.Empty, ("answer", Kind.Map), ("counted_answer_id", Kind.TextOrNull), ("as_of", Kind.Unsigned));
var answer = AnswerDocument(value, "answer");
// Huginn's reply carries no card: a stale_fork answer gets the current card from the edge's read.
return new DeskAnswerOutcome.Answered(new AnswerResult(
DeskSchemas.AnswerResult, answer, null, OptionalString(value, "counted_answer_id")));
case "NotStored":
var code = RefusalCode(value);
return code is DeskRefusals.ForkTooLarge
? throw new DeskDoorException("fork_too_large is not an answer refusal")
: new DeskAnswerOutcome.NotStored(code);
default:
throw Unexpected(variant);
}
}
public async Task<DeskReadOutcome> ReadAsync(
string socket, DeskDoorContext context, string forkId, CancellationToken cancellationToken)
{
var payload = Payload("Fork", 4, (ref MessagePackWriter w) =>
{
w.Write("mind");
w.Write(context.Mind);
w.Write("subject");
w.Write(context.Subject);
w.Write("deciders");
Strings(ref w, context.Deciders);
w.Write("fork_id");
w.Write(forkId);
});
var (variant, value) = await ExchangeAsync(socket, "fork", payload, cancellationToken);
switch (variant)
{
case "Fork":
Closed(value, "Fork", string.Empty, ("entry", Kind.Map), ("as_of", Kind.Unsigned));
var entry = Entry(Required(value, "entry"), "entry");
// The route asked for one fork. An entry whose card names another is not the answer to it. (That the record
// agrees with the card is a rule of the entry, CultLib's validators' and Huginn's, not the door's.)
if (!entry.Card.TryGetProperty("fork_id", out var cardFork) || cardFork.ValueKind != JsonValueKind.String
|| cardFork.GetString() != forkId)
{
throw new DeskDoorException("the entry is not for the fork asked");
}
return new DeskReadOutcome.Found(entry);
case "NotStored":
var code = RefusalCode(value);
// The route's fork id, never the refusal's informational text.
return code == DeskRefusals.ForkTooLarge
? new DeskReadOutcome.ForkTooLarge(forkId)
: new DeskReadOutcome.NotFound(code);
default:
throw Unexpected(variant);
}
}
public async Task<DeskListOutcome> ListAsync(
string socket, DeskDoorContext context, DeskListRequest request, CancellationToken cancellationToken)
{
var payload = Payload("Forks", 6, (ref MessagePackWriter w) =>
{
w.Write("mind");
w.Write(context.Mind);
w.Write("subject");
w.Write(context.Subject);
w.Write("deciders");
Strings(ref w, context.Deciders);
w.Write("status");
w.Write(request.Status);
w.Write("cursor");
w.Write(request.Cursor);
w.Write("limit");
w.Write(request.Limit);
});
var (variant, value) = await ExchangeAsync(socket, "forks", payload, cancellationToken);
switch (variant)
{
case "Forks":
Closed(value, "Forks", string.Empty, ("list", Kind.Map), ("as_of", Kind.Unsigned));
var list = List(Required(value, "list"));
return list.Mind == context.Mind
? new DeskListOutcome.Listed(list)
: throw new DeskDoorException("the list is for another Mind");
case "NotStored":
var code = RefusalCode(value);
if (code != DeskRefusals.ForkTooLarge)
{
return new DeskListOutcome.NotListed(code);
}
// The fork and its order exist only in the informational text; anything but the exact form is a fault.
var text = RequiredString(value, "error");
var match = ListTooLarge().Match(text);
if (!match.Success || !int.TryParse(match.Groups["order"].ValueSpan, NumberStyles.None, CultureInfo.InvariantCulture, out var order))
{
throw new DeskDoorException("fork_too_large does not name its fork and order in the list form");
}
return new DeskListOutcome.ForkTooLarge(match.Groups["id"].Value, order);
default:
throw Unexpected(variant);
}
}
// fork_too_large: fork <id> at order <N> is past the page budget by itself; ...
[GeneratedRegex(@"\Afork_too_large: fork (?<id>\S+) at order (?<order>0|[1-9][0-9]*) is past the page budget(?:[ ;][^\r\n]*)?\z", RegexOptions.CultureInvariant)]
private static partial Regex ListTooLarge();
// ---- strict documents: a closed map, every key present with its type, then bound to the typed record ----
private enum Kind
{
Text,
TextOrNull,
Flag,
Count,
Unsigned,
Map,
Items,
}
private static readonly (string, Kind)[] AnswerKeys =
[
("schemaVersion", Kind.Text), ("answer_id", Kind.Text), ("fork_id", Kind.Text), ("outcome", Kind.Text),
("of", Kind.TextOrNull), ("retracts", Kind.TextOrNull), ("code", Kind.TextOrNull), ("options_hash", Kind.Text),
("choice", Kind.TextOrNull), ("note", Kind.TextOrNull), ("answered_at", Kind.Text), ("received_at", Kind.Text),
("claim_in_future", Kind.Flag), ("answerer", Kind.TextOrNull), ("authority", Kind.Text), ("request_id", Kind.Text),
];
private static void Closed(JsonElement map, string what, string schema, params (string Key, Kind Kind)[] keys)
{
if (map.ValueKind != JsonValueKind.Object || map.EnumerateObject().Count() != keys.Length)
{
throw new DeskDoorException($"{what} is not a closed map of {keys.Length} keys");
}
foreach (var (key, kind) in keys)
{
var ok = Property(map, key) is { } v && kind switch
{
Kind.Text => v.ValueKind == JsonValueKind.String,
Kind.TextOrNull => v.ValueKind is JsonValueKind.String or JsonValueKind.Null,
Kind.Flag => v.ValueKind is JsonValueKind.True or JsonValueKind.False,
Kind.Count => v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n) && n >= 0,
Kind.Unsigned => v.ValueKind == JsonValueKind.Number && v.TryGetUInt64(out _),
Kind.Map => v.ValueKind == JsonValueKind.Object,
_ => v.ValueKind == JsonValueKind.Array,
};
if (!ok)
{
throw new DeskDoorException($"{what}.{key} is missing or not the contract's type");
}
}
if (schema.Length > 0 && Property(map, "schemaVersion")!.Value.GetString() != schema)
{
throw new DeskDoorException($"{what} is not {schema}");
}
}
private static DeskAnswerDocument AnswerDocument(JsonElement value, string what)
{
var map = Required(value, what);
Closed(map, what, DeskSchemas.DeskAnswer, AnswerKeys);
return Bind<DeskAnswerDocument>(map, what);
}
private static ForkEntry Entry(JsonElement map, string what)
{
Closed(map, what, "gamecult.fork.entry.v0", ("schemaVersion", Kind.Text), ("card", Kind.Map), ("record", Kind.Map));
var record = Property(map, "record")!.Value;
Closed(record, what + ".record", "gamecult.fork.record.v0",
("schemaVersion", Kind.Text), ("fork_id", Kind.Text), ("options_hash", Kind.Text), ("counted_answer_id", Kind.TextOrNull),
("answerer_count", Kind.Count), ("answers", Kind.Items));
foreach (var answer in Property(record, "answers")!.Value.EnumerateArray())
{
Closed(answer, what + ".record.answers[]", DeskSchemas.DeskAnswer, AnswerKeys);
}
return Bind<ForkEntry>(map, what);
}
private static ForkList List(JsonElement map)
{
Closed(map, "list", "gamecult.fork.list.v0",
("schemaVersion", Kind.Text), ("mind", Kind.Text), ("stamp", Kind.Text), ("open_count", Kind.Count),
("recorded_count", Kind.Count), ("forks", Kind.Items), ("next_cursor", Kind.TextOrNull));
foreach (var entry in Property(map, "forks")!.Value.EnumerateArray())
{
Entry(entry, "list.forks[]");
}
return Bind<ForkList>(map, "list");
}
private static T Bind<T>(JsonElement map, string what)
{
try
{
return map.Deserialize<T>(DeskJson.Options) ?? throw new DeskDoorException($"{what} does not decode");
}
catch (JsonException e)
{
throw new DeskDoorException($"{what} does not decode", e);
}
}
private static JsonElement Required(JsonElement map, string name) =>
Property(map, name) ?? throw new DeskDoorException($"{name} is missing");
private static DeskDoorException Unexpected(string variant) =>
new($"the door answered with {(variant == "Refused" ? "a Mind refusal" : "a variant this call does not take")}");
private static string RefusalCode(JsonElement refusal)
{
Closed(refusal, "the refusal", DeskSchemas.Refusal, ("schemaVersion", Kind.Text), ("code", Kind.Text), ("error", Kind.Text));
var code = RequiredString(refusal, "code");
return code is DeskRefusals.ForkUnknown or DeskRefusals.BodyInvalid or DeskRefusals.RequestIdConflict or DeskRefusals.ForkTooLarge
? code
: throw new DeskDoorException("the refusal code is not one the contract names");
}
// ---- request ----
private delegate void FieldWriter(ref MessagePackWriter writer);
private static byte[] Payload(string variant, int fields, FieldWriter write)
{
var buffer = new ArrayBufferWriter<byte>();
var w = new MessagePackWriter(buffer);
w.WriteMapHeader(1);
w.Write(variant);
w.WriteMapHeader(fields);
write(ref w);
w.Flush();
return buffer.WrittenSpan.ToArray();
}
private static void Strings(ref MessagePackWriter w, IReadOnlyList<string> values)
{
w.WriteArrayHeader(values.Count);
foreach (var value in values)
{
w.Write(value);
}
}
// Operation messages write their keys sorted, absent optionals omitted.
private static byte[] RequestMessage(string messageId, string operation, byte[] payload)
{
var buffer = new ArrayBufferWriter<byte>();
var w = new MessagePackWriter(buffer);
w.WriteMapHeader(7);
w.Write("messageId");
w.Write(messageId);
w.Write("operation");
w.Write(operation);
w.Write("payload");
w.Write(Convert.ToBase64String(payload));
w.Write("payloadEncoding");
w.Write(Encoding64);
w.Write("payloadSchema");
w.Write(RequestSchema);
w.Write("schemaVersion");
w.Write("cultnet.operation_request.v0");
w.Write("serviceId");
w.Write(ServiceId);
w.Flush();
return buffer.WrittenSpan.ToArray();
}
// ---- exchange ----
private async Task<(string Variant, JsonElement Value)> ExchangeAsync(
string socketPath, string operation, byte[] payload, CancellationToken cancellationToken)
{
var messageId = messageIds();
if (messageId.Length == 0 || StrictUtf8.GetByteCount(messageId) > MaxMessageIdBytes)
{
throw new DeskDoorException("the message id is empty or over 256 bytes");
}
var message = RequestMessage(messageId, operation, payload);
if (message.Length > MaxFrameBytes)
{
throw new DeskDoorException("the request is past the frame bound");
}
var frame = new byte[4 + message.Length];
BinaryPrimitives.WriteUInt32BigEndian(frame, (uint)message.Length);
message.CopyTo(frame, 4);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(Deadline);
byte[] reply;
try
{
using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath), deadline.Token);
for (var sent = 0; sent < frame.Length;)
{
sent += await socket.SendAsync(frame.AsMemory(sent), SocketFlags.None, deadline.Token);
}
var header = new byte[4];
await ReadWholeAsync(socket, header, deadline.Token);
var length = BinaryPrimitives.ReadUInt32BigEndian(header);
if (length == 0 || length > MaxFrameBytes)
{
// Refused on the prefix alone: the body is never read.
throw new DeskDoorException("the reply's length prefix is outside the frame bound");
}
reply = new byte[length];
await ReadWholeAsync(socket, reply, deadline.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new DeskDoorException("no whole reply within the deadline");
}
catch (Exception e) when (e is SocketException or IOException or ObjectDisposedException or ArgumentException)
{
throw new DeskDoorException("the desk socket failed", e);
}
return Decode(reply, messageId, operation);
}
// Loops until the buffer is whole: a stream socket may hand it over in pieces. A peer closing early is a failure.
private static async Task ReadWholeAsync(Socket socket, byte[] buffer, CancellationToken cancellationToken)
{
for (var read = 0; read < buffer.Length;)
{
var count = await socket.ReceiveAsync(buffer.AsMemory(read), SocketFlags.None, cancellationToken);
if (count == 0)
{
throw new DeskDoorException("the peer closed before a whole reply frame");
}
read += count;
}
}
// ---- reply ----
// The envelope judged by key; the payload's one variant returned with its value. Everything else throws.
private static (string Variant, JsonElement Value) Decode(byte[] frame, string messageId, string operation)
{
try
{
using var envelope = ToJson(frame);
var root = envelope.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
throw new DeskDoorException("the reply is not a map");
}
// A CultNet Error message is not an operation reply (and its keys are not in operation order).
if (RequiredString(root, "schemaVersion") != "cultnet.operation_response.v0")
{
throw new DeskDoorException("the reply is not an operation response");
}
if (RequiredString(root, "serviceId") != ServiceId
|| RequiredString(root, "operation") != operation
|| RequiredString(root, "messageId") != messageId)
{
throw new DeskDoorException("the reply is not for this request");
}
var status = RequiredString(root, "status");
if (RequiredString(root, "payloadSchema") != ResponseSchema || RequiredString(root, "payloadEncoding") != Encoding64)
{
// Including gamecult.cultnet.operation_failure.v1: the envelope or payload did not decode.
throw new DeskDoorException("the reply carries no desk response");
}
// Strict standard base64: Convert would skip whitespace, so the text must be the canonical encoding of what it decodes to.
var text = RequiredString(root, "payload");
byte[] payload;
try
{
payload = Convert.FromBase64String(text);
}
catch (FormatException e)
{
throw new DeskDoorException("the payload is not base64", e);
}
if (Convert.ToBase64String(payload) != text)
{
throw new DeskDoorException("the payload is not strict standard base64");
}
using var body = ToJson(payload);
if (body.RootElement.ValueKind != JsonValueKind.Object)
{
throw new DeskDoorException("the payload is not a tagged variant");
}
var variants = body.RootElement.EnumerateObject().ToList();
if (variants.Count != 1)
{
throw new DeskDoorException("the payload is not a one-key variant");
}
var (variant, expectedStatus) = (variants[0].Name, variants[0].Name switch
{
"Answered" or "Forks" or "Fork" => "accepted",
"NotStored" or "Refused" => "rejected",
_ => null,
});
var takesVariant = operation switch
{
"answer" => variant == "Answered",
"forks" => variant == "Forks",
"fork" => variant == "Fork",
_ => false,
} || variant is "NotStored" or "Refused";
if (expectedStatus is null || !takesVariant || status != expectedStatus)
{
throw new DeskDoorException("the reply's variant or status does not fit the operation");
}
return (variant, variants[0].Value.Clone());
}
catch (Exception e) when (e is not (DeskDoorException or OperationCanceledException))
{
throw new DeskDoorException("the reply does not decode", e);
}
}
// MessagePack to JSON text by walking the reader: the JSON writer escapes every control character, keys keep their
// order, integers keep every digit. A map key that is not text, a key written twice, a float, a binary, an
// extension, invalid UTF-8 or nesting past 64 is a failure; none is in the contract.
private static JsonDocument ToJson(ReadOnlyMemory<byte> messagePack)
{
var reader = new MessagePackReader(messagePack);
var json = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(json, new JsonWriterOptions { MaxDepth = 64 }))
{
Walk(ref reader, writer, 0);
}
if (!reader.End)
{
throw new DeskDoorException("bytes follow the message");
}
return JsonDocument.Parse(json.WrittenMemory, new JsonDocumentOptions { MaxDepth = 64 });
}
private static void Walk(ref MessagePackReader reader, Utf8JsonWriter writer, int depth)
{
if (depth > 64)
{
throw new DeskDoorException("the message nests past 64");
}
switch (reader.NextMessagePackType)
{
case MessagePackType.Nil:
reader.ReadNil();
writer.WriteNullValue();
break;
case MessagePackType.Boolean:
writer.WriteBooleanValue(reader.ReadBoolean());
break;
case MessagePackType.Integer:
if (reader.NextCode == MessagePackCode.UInt64)
{
writer.WriteNumberValue(reader.ReadUInt64());
}
else
{
writer.WriteNumberValue(reader.ReadInt64());
}
break;
case MessagePackType.String:
writer.WriteStringValue(Text(ref reader));
break;
case MessagePackType.Array:
var items = reader.ReadArrayHeader();
writer.WriteStartArray();
for (var i = 0; i < items; i++)
{
Walk(ref reader, writer, depth + 1);
}
writer.WriteEndArray();
break;
case MessagePackType.Map:
var pairs = reader.ReadMapHeader();
var seen = new HashSet<string>(StringComparer.Ordinal);
writer.WriteStartObject();
for (var i = 0; i < pairs; i++)
{
if (reader.NextMessagePackType != MessagePackType.String)
{
throw new DeskDoorException("a map key is not text");
}
var key = Text(ref reader);
if (!seen.Add(key))
{
throw new DeskDoorException($"{key} is written twice");
}
writer.WritePropertyName(key);
Walk(ref reader, writer, depth + 1);
}
writer.WriteEndObject();
break;
default:
throw new DeskDoorException("the message holds a float, binary or extension value, which the contract does not use");
}
}
private static string Text(ref MessagePackReader reader) =>
StrictUtf8.GetString(reader.ReadStringSequence() ?? throw new DeskDoorException("text is nil"));
// A key's value by name; a key written twice never gets here (the walk refuses it).
private static JsonElement? Property(JsonElement map, string name) =>
map.ValueKind == JsonValueKind.Object && map.TryGetProperty(name, out var value) ? value : null;
private static string RequiredString(JsonElement map, string name) =>
Property(map, name) is { ValueKind: JsonValueKind.String } value
? value.GetString()!
: throw new DeskDoorException($"{name} is missing or not text");
private static string? OptionalString(JsonElement map, string name) =>
Property(map, name) switch
{
{ ValueKind: JsonValueKind.String } value => value.GetString(),
{ ValueKind: JsonValueKind.Null } => null,
_ => throw new DeskDoorException($"{name} is missing or not text"),
};
}

View file

@ -8,6 +8,17 @@
"resolved": "2.4.0",
"contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ=="
},
"MessagePack": {
"type": "Direct",
"requested": "[3.1.12, )",
"resolved": "3.1.12",
"contentHash": "m1QEsd59gUj82ji5tEQbZiyjV6y0F7cYfGVhil6pAlNZpyzR7ze9TucMu4lPpi8cZAV8aNl3D9/T9J1HbFz15Q==",
"dependencies": {
"MessagePack.Annotations": "3.1.12",
"MessagePackAnalyzer": "3.1.12",
"Microsoft.NET.StringTools": "17.11.4"
}
},
"Microsoft.EntityFrameworkCore.Design": {
"type": "Direct",
"requested": "[8.0.0, )",
@ -105,6 +116,16 @@
"resolved": "2.14.1",
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
},
"MessagePack.Annotations": {
"type": "Transitive",
"resolved": "3.1.12",
"contentHash": "LdI67sY7F3KK+JsrG02+DP1XFkowaZSoyfL2M88hjm1MOOsjf8amBLAJolV+zxoPUZf3uGbKzq1cMQD8PlpSpA=="
},
"MessagePackAnalyzer": {
"type": "Transitive",
"resolved": "3.1.12",
"contentHash": "iNSNatPh6bafRUbkQKlClExjpPpd8hiZgfZsodGf7bheIlkizGbMzsJ2mPui8jOHRe4Ky9eg5ou/fFzI7CT3fg=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "6.0.0",
@ -306,6 +327,11 @@
"Microsoft.IdentityModel.Logging": "8.19.2"
}
},
"Microsoft.NET.StringTools": {
"type": "Transitive",
"resolved": "17.11.4",
"contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA=="
},
"Mono.TextTemplating": {
"type": "Transitive",
"resolved": "2.2.1",

View file

@ -23,6 +23,7 @@
<ItemGroup>
<None Include="Fixtures\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="Fixtures\*.cjs" CopyToOutputDirectory="PreserveNewest" />
<None Include="Fixtures\desk-feed\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>

View file

@ -259,7 +259,7 @@ public sealed class DeskCredentialTests : IDisposable
const string secretAnswerer = "0badc0de-0000-4000-8000-00000000000d"; // not on any decider list, so no card carries it
var entry = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork));
var answer = new DeskAnswerDocument(
DeskSchemas.DeskAnswer, "n1", DeskReplay.ChatFork, "Counted", null, null, null, entry.Card.OptionsHash, "a", "private words",
DeskSchemas.DeskAnswer, "n1", DeskReplay.ChatFork, "Counted", null, null, null, entry.Card.GetProperty("options_hash").GetString(), "a", "private words",
"2026-11-12T19:00:00Z", "2026-11-12T19:00:01Z", false, secretAnswerer, "Steward", "r-1");
entry = entry with { Record = entry.Record with { Answers = [answer] } };
factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 1, 0, [entry], null));

View file

@ -64,12 +64,31 @@ public sealed class DeskDocumentTests
public void Cards_round_trip_with_their_spelling()
{
var rows = DeskVectors.Rows("card", "valid").ToList();
Assert.Equal(5, rows.Count);
Assert.Equal(7, rows.Count);
foreach (var row in rows)
{
var message = row["message"]!;
var card = message.Deserialize<ForkCard>(DeskJson.Options)!;
var card = message.Deserialize<JsonElement>(DeskJson.Options);
Assert.True(DeskVectors.SameShape(message, Write(card)), (string)row["name"]!);
}
}
// The card is opaque: whatever keys, in whatever order, with whatever numbers, leave as they came.
[Fact]
public void An_unknown_card_passes_through_byte_for_byte()
{
const string card =
"{\"order\":7,\"raised_at\":\"2026-11-11T09:00:00Z\",\"title\":\"It's \u00e9\",\"fork_id\":\"x:question:y\"," +
"\"future\":{\"z\":1.50,\"a\":[3,null,1e2],\"big\":12345678901234567890},\"options_hash\":\"ab\",\"leans\":null}";
var entryJson =
"{\"schemaVersion\":\"gamecult.fork.entry.v0\",\"card\":" + card +
",\"record\":{\"schemaVersion\":\"gamecult.fork.record.v0\",\"fork_id\":\"x:question:y\",\"options_hash\":\"ab\",\"counted_answer_id\":null,\"answerer_count\":0,\"answers\":[]}}";
var entry = JsonSerializer.Deserialize<ForkEntry>(entryJson, DeskJson.Options)!;
var written = JsonSerializer.Serialize(entry, DeskJson.Options);
Assert.Equal(entryJson, written);
var result = new AnswerResult(DeskSchemas.AnswerResult, null!, entry.Card, null);
Assert.Contains("\"card\":" + card + ",", JsonSerializer.Serialize(result, DeskJson.Options));
}
}

View file

@ -0,0 +1,200 @@
using System.Security.Cryptography;
namespace Bifrost.Web.Tests;
// Huginn's desk-feed golden bytes, vendored byte for byte from crates/huginn-mind/tests/fixtures/desk-feed/ at Huginn
// main 232764bf78fa93c807d2e18b1d77f45c36159be0 (docs/desk-feed-door.md is the contract): 99 files, the
// subdirectory cultlib-d3025d3c/ (fourteen CultLib vectors, unedited) included. The pin is the exact file list and each
// sha256; a changed byte, an extra file or a missing file fails, and the verifier is proved to catch each.
// A silent re-vendor fails here: move the pin on purpose, with the contract and the door's tests.
public sealed class DeskFeedFixturePinTests
{
public const string HuginnCommit = "232764bf78fa93c807d2e18b1d77f45c36159be0";
public static readonly string Directory = Path.Combine(AppContext.BaseDirectory, "Fixtures", "desk-feed");
public static readonly IReadOnlyDictionary<string, string> Sha256 = new Dictionary<string, string>(StringComparer.Ordinal)
{
[".gitattributes"] = "171872d6445cb1ffef0263b613d1ac318a60398449831d4cc4f772f78a7ebcff",
["README.md"] = "fd3429ee796ac1c3de9571bc2d09e24f0f91aae48d461f1c8eefa51c6b50a3bc",
["cultlib-d3025d3c/README.md"] = "b0f6ce43f4c5aa9ae263071b814e2f71445bd50db1d55c6fbcdaf2714166b47a",
["cultlib-d3025d3c/answer-counted.msgpack"] = "55d69ae3cc7ebbeeae7d501e8641640e115c3fb5aac399ea760588bca343b9e6",
["cultlib-d3025d3c/answer-refused-stale.msgpack"] = "3183bc9c483f4a625332cda11ef22a9c8560868a7728b77daa63a1960a3cd3d6",
["cultlib-d3025d3c/answer-retraction.msgpack"] = "89156f41ec3ea54f5d47bd3318913ff013785f4727916875be97785935cb7735",
["cultlib-d3025d3c/answer-revision.msgpack"] = "19d7289395ae68cbaf6de1c0a1ef6c843211b2caf9462e1d2268cd0883386315",
["cultlib-d3025d3c/answer-withheld-answerer-retraction.msgpack"] = "7d0cc555bf9b6f7c345b0da7c504bcd1ed7b46040ff51d1e57c84b2f56655213",
["cultlib-d3025d3c/answer-withheld-answerer-revision.msgpack"] = "93f7c875f963a7cfeab2a5980b6d5ce81e6753fc9f3f8bf0d7353f3f388144b7",
["cultlib-d3025d3c/answer-withheld-answerer.msgpack"] = "b66b4f921cdd374be04af3de34c59e254a5e542027ae8c57f196a3ea3406e427",
["cultlib-d3025d3c/card-operator-fork.msgpack"] = "56bfdb65ded60b562001d09d0b8da6e092900f404bf5f68f7084dff29bcc34f5",
["cultlib-d3025d3c/card-raised-at-nil.msgpack"] = "b81bb825790ffe03fabe63c3f8a61c36bd50ca268aa6dff67b5bcfad3957e58c",
["cultlib-d3025d3c/card-withheld-deciders.msgpack"] = "d13b9b77461cfdf78f943ef56c6aeab852ab6b6f0b355e26c5b361f99c79c7f7",
["cultlib-d3025d3c/entry-closed-with-ruling-and-a-refusal.msgpack"] = "f7859c7d04d338d6b1c634308b08ae58ae77b0ff459767859a5e40f0af2f16e5",
["cultlib-d3025d3c/entry-open-counted.msgpack"] = "b51f499e8dd0465cf5df9d68d8fccbe94dd1339fd3157969693dd0d4ecb2549b",
["cultlib-d3025d3c/entry-withheld-closed-with-ruling-and-a-refusal.msgpack"] = "daaa819bfa2612616826ef3217dfcf8d8d8cb71d86547d7106426c779134f827",
["cultlib-d3025d3c/entry-withheld-open-counted.msgpack"] = "62ccad7a9f41262d6e35d0247315d0bb75d1313b3c9dabe8a99d1aa1b844607f",
["error.response.frame.bin"] = "fa03f44b82d7c4796f45dd36086a54342dc1de7e660a58cf3ddff4ff59d85a4c",
["error.response.frame.hex"] = "21c75b6e1d000ddc1a5455a9cc4d6c5b37bd1640c22e1055e96ff85bdff461bb",
["failure.response.frame.bin"] = "edc2277943334744d3057902800c25bbc913ff8ed336b4612f1a19772ec46dc9",
["failure.response.frame.hex"] = "b3c262ac506b51529e8063b2d38cc8c0047b431b75fe6ede1a91adebefd014d3",
["failure.response.payload.hex"] = "13ca85b5868451892746d2531833d394b66d25b5069cd0208169c5fcc94bf350",
["failure.response.payload.msgpack"] = "4bf7c80eb2d75b9559ab430757a98ebfd424745ed3fc1888d21bc6e41ed99403",
["list-counts.request.frame.bin"] = "be338fc06f127f9c20f82952708ffe95f8b6b8de7c5098834eb8cb257893637f",
["list-counts.request.frame.hex"] = "496bbc58292a1d029361bd553eef2ccf786cfa6520b1a3ad97c831893c36cf50",
["list-counts.request.payload.hex"] = "5ec076a5d8006a0290ad269deffb2bfe45ac7ee8b6a11ea2d4968a6ac8603e4b",
["list-counts.request.payload.msgpack"] = "52f244c3d704067f79561c8d46f4069ac3f4452a73d916fb92154d28b28ea6c7",
["list-counts.response.frame.bin"] = "bf51c933fbbaa61934907cb66f5e07ac813de48e849ac80bdf3ba4f75d3952a3",
["list-counts.response.frame.hex"] = "3f395ae2302d0882fb2456fe94512bba045a1c967f98b4ebf6b306dd5e3d74a3",
["list-counts.response.payload.hex"] = "9a6f6b0be985d6ff8d313fa9e12ffe6662a59ff7305e574f4bfe8bf9a3ce2291",
["list-counts.response.payload.msgpack"] = "1f83a7c9ab1ce1af7c245a8f11f54ea67ef273fc2533554db3a1711e3f650c13",
["list-empty.response.frame.bin"] = "faf3c5008bd50c7f76121a3f27c5c4f944d1c7ad32d8bbd4e67d025c45b3f4f1",
["list-empty.response.frame.hex"] = "9b0f15bf8948e44f9e43718914f6621402c98c06e0d21bf90640c521ab7f1a45",
["list-empty.response.payload.hex"] = "d93cd65c1d9f4fc052b9c7f17d16ea6e468a7af296cafd6a31d671ae855d2860",
["list-empty.response.payload.msgpack"] = "7b1a26f303d3bdd0317d24a0eb4a2d9144ada0a878c927905a1a345efabf3a9f",
["list-non-decider.request.frame.bin"] = "ebf8664681fcf624e4c6b82eb5e23e4c31b56d0f8a03f1cfbadc766408eaa18e",
["list-non-decider.request.frame.hex"] = "f0d7f662425ca1c16dea4f5eb78d894898d0dda2ff04f9614663c74620371d26",
["list-non-decider.request.payload.hex"] = "383122413f2bd276d27c53bdfcac9f65ba704ea2b1e1c18e72b89b164aad2c2d",
["list-non-decider.request.payload.msgpack"] = "9915249f377a6afe4dda2f007c23e8f2598f759bd9d3991834c9ee162b0d6a5b",
["list-open-cursor.request.frame.bin"] = "13671e1c83c10350d86b149a86a98b69763b9a3e40eae0024c10f3e70b96ef8b",
["list-open-cursor.request.frame.hex"] = "e2270f26d122374554855bf60686edbc5d4abfd63374cde08ab479a5f310aa12",
["list-open-cursor.request.payload.hex"] = "437f4c0cbdc712e9b3f8e1f8666cf8ffe88061cce8d0da3a64e648eb7b6bb27f",
["list-open-cursor.request.payload.msgpack"] = "05889e90f8143aa1a1375539f6e76593a682413b70cb40a8aaa3c7db2882622a",
["list-open-non-decider.response.frame.bin"] = "8bf4539f305a9530ac88090bdb2f3a3c851f14f66bd2c69f8009ad4934aec3ad",
["list-open-non-decider.response.frame.hex"] = "d377e096ce781b0fb86398a58e69ee86205770c298120facac9e739937e88e7d",
["list-open-non-decider.response.payload.hex"] = "c99b932fb7c19fc28a8fb04f44b80951a55722cbab9c32114ca41c1cd13ef84e",
["list-open-non-decider.response.payload.msgpack"] = "14616093522771954b34b7414849c2896b0d328c8cf0feac6d8038116d813d8c",
["list-open.request.frame.bin"] = "2af34627a45d7bfca034b880bf61f5dfd78022a8df425f418c0a084181663903",
["list-open.request.frame.hex"] = "03f74ba78996b2ccb3fcf5fb712ae1d19f7594099ba04250ee0223c8631b9a24",
["list-open.request.payload.hex"] = "a58e8583d41d4a04cbd983c6b5a8bdae26105f2eb7f25dfb7d5f86aa81847e93",
["list-open.request.payload.msgpack"] = "431e000c3f002c254adc57273557c8111e815ca42a8dd4272762d3e7697d7f0a",
["list-open.response.frame.bin"] = "82d7b9bd8a5d3a98f2d5780b3cb59fb75b598a333069a03243c1ec83faa6f099",
["list-open.response.frame.hex"] = "ebc75b6ae8d16b1cbe0bece1ed3fa32d0ead42fc39ca939e542a8fa45e1dadee",
["list-open.response.payload.hex"] = "618918c8020e7bf673758a7e6a58b78469449838b287402bd383a7d1258adc57",
["list-open.response.payload.msgpack"] = "f16c74f6340054e40d0d0862381da717a1fa651a67f3250b7bd555cea2834a6e",
["list-response-too-large.response.frame.bin"] = "b95218e48c1f8e4162f515b97a85c324b9ccfe2b3216780ed0eca1d2e2066f19",
["list-response-too-large.response.frame.hex"] = "b4bedb2c59b1362e5059caf6dbea63952b8fe9c47b681da3825422bc677f8632",
["list-response-too-large.response.payload.hex"] = "4f5f18915e0e536c387e659eb2021570a5b0bc5e0f530521ca226dc687d6780d",
["list-response-too-large.response.payload.msgpack"] = "9749f0dc7a3ec69aba7f45ba62ef61332461747cf42b188b84998da7bbd4d5d9",
["list-too-large.response.frame.bin"] = "1210fb82189a23d7ee4eb3851d4b83013f4f2a2298533734df348156097b5c60",
["list-too-large.response.frame.hex"] = "aad762b1e1deea39330223eb9ea7ebd2bb0fdd3326e259383b90846c1310456f",
["list-too-large.response.payload.hex"] = "880ae2c51454a3d6b06d2e827e44b6990365d8cfa304b55e25eb898641117762",
["list-too-large.response.payload.msgpack"] = "710d145c1ded7e7d02343342becd61e4520da979580f13dbebe6f0eda5aabec6",
["read-non-decider.request.frame.bin"] = "3f2729eca79e3c21feebc763f22b5ee86803b333a22b39d5cc1635e2fae245b6",
["read-non-decider.request.frame.hex"] = "9dce0664c145dff66d52e2c2d21cf12b187884392908f031a310534716646aa5",
["read-non-decider.request.payload.hex"] = "32d77b81c10bc8accbc31a667d2ad631bf5e3404a485d3d3bed3889c50571f8e",
["read-non-decider.request.payload.msgpack"] = "c2ec1ecb06123fbb6292ed7a409035f7cc58408885886259131226a8c0aabc08",
["read-non-decider.response.frame.bin"] = "15da06225cdd950bf0fe61e49a7ba18d6117b073fdaa6b9732f902564e47d574",
["read-non-decider.response.frame.hex"] = "9cdb0efaaf52f8234ea4ad9c357d563f134cedc8dc7f94ae1b297a30670dd013",
["read-non-decider.response.payload.hex"] = "0ae37b5f6920c4a41f4f8fe7c45f03c90ec74e384991920811273a0ef4250f9c",
["read-non-decider.response.payload.msgpack"] = "41e947355eac70e948633986bc19506fef5c2f92d7fc4242a1783dc1aa688890",
["read-too-large.response.frame.bin"] = "5995c03914851ea95aaff4b75fa3ebaee4f19a2c7ce101eab85b7475515690bd",
["read-too-large.response.frame.hex"] = "b150aa192bf3fa415b5175f8b9f43731c5b73c2a2153c87da2dc3a4b6d986ee2",
["read-too-large.response.payload.hex"] = "18b7ad6c617ee1d9a3aa726a012e9a3af8ca9c14b8af774d082cf17879c85b90",
["read-too-large.response.payload.msgpack"] = "4a9135ede7d6588ba65e49b372b99d5da956fab9c693f1aa018e0ebe1406246a",
["read-unknown.response.frame.bin"] = "a62293ff79c0bbe626d020140f4c8cbe3f62a87ac0f730f3ba3c2a7acd3d49a5",
["read-unknown.response.frame.hex"] = "97ab11a04a2d62917186b23666ed26ef09df220ffb1c3e188d919985085733e2",
["read-unknown.response.payload.hex"] = "ca8d4d1fa4d40915b6e062767967e62343b360ce669801da96d99509e24787fb",
["read-unknown.response.payload.msgpack"] = "0d4fe47159db88b5048bc1f42a0e72857b568eac48572777a0c896ee94f7c208",
["read.request.frame.bin"] = "3a9bc16ce4ce706e8c839bf4cfe65d3e2fdf759992555e68be6fc7bcc15653ea",
["read.request.frame.hex"] = "3c3d9540c4af789d230b7108f98d5d5f3dd9b291ff618064ddc374eb2a7bb082",
["read.request.payload.hex"] = "48aed263254eac7bc156c1af1f2ee210a08dda2b453a5be2dc17083ab73800b2",
["read.request.payload.msgpack"] = "c5447f462247703af4329e071bc0ac14dce1cb94a63c85517400791d022263ca",
["read.response.frame.bin"] = "34acacf58f3bd10e86bad6b10f3f14b6a263709b54ff81873af7dcfaf3d95a06",
["read.response.frame.hex"] = "600848ae27be452d9a7845c5d822c8eabc1df07b163071d35cc38f6f2ec330bb",
["read.response.payload.hex"] = "c3322aa9989a47b9b255cd8d20d6026a159c6b9a8a5ee22f135407bf96cfd2d3",
["read.response.payload.msgpack"] = "79fb2f9dd3fde3335bca5cc4337abcf0d7e195afc341352087e6432b7f5ae0a7",
["record-whole.payload.hex"] = "871ee32813f46656aa372f6d791d36f885e83194294d3a9fa1fcb906cab73eb4",
["record-whole.payload.msgpack"] = "e85e420e7450cfecc698268299b1a26e1ed256d0420143cbad74f80b5a52731d",
["record-withheld.payload.hex"] = "a0e7afd9131387cc25d5a2ff5b5f12d546ddd38dfe2bc0d74d3c55a4439048d6",
["record-withheld.payload.msgpack"] = "aff799fe787258c35e658cf4d6dea33b95938c47f2d4e72fcdf194f0b537bfed",
["refused-unit.response.frame.bin"] = "4f86943a6947dd3c6041ee7d1b52d3f73697604dd224ca5dcf5b66064b0de1f1",
["refused-unit.response.frame.hex"] = "f25f35911d242d6fdb7ac4e1ecb3fe01901aa4b816d957104bde6f21b29a1bed",
["refused-unit.response.payload.hex"] = "858d49c8c66ce1b9c32160e2aecb320a8c00166728c80f5a02d62ec90b4f410d",
["refused-unit.response.payload.msgpack"] = "f2eedbece230b164adf1897c3a047317a4e3074b2d5fd4c5abef3fbb98336c32",
["unavailable.response.frame.bin"] = "0cb241568bb20d0ccf62f337f4daa01ca3dd82cedd476fb4c1f9e7dc3272cabe",
["unavailable.response.frame.hex"] = "86f0e5bb2d3e0ea39ae2f0dbc2c1b62e5a77a35bffc6f4adfa87d280d718bf44",
["unavailable.response.payload.hex"] = "5a8e7d6e3cd7abf3649941d6b55a511d025c2baf2d8dde08e505e57e9cb4fd22",
["unavailable.response.payload.msgpack"] = "dfa613abaf5f23e6aff5bcb0a59b3a1784f2fbf6d12cbdfa82f10e80963f0a4e",
};
// Every way the directory differs from the pin, by relative path (forward slashes).
public static List<string> Problems(string directory)
{
var present = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var file in new DirectoryInfo(directory).EnumerateFiles("*", SearchOption.AllDirectories))
{
var name = Path.GetRelativePath(directory, file.FullName).Replace('\\', '/');
present[name] = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(file.FullName))).ToLowerInvariant();
}
var problems = new List<string>();
problems.AddRange(Sha256.Keys.Except(present.Keys, StringComparer.Ordinal).Order(StringComparer.Ordinal).Select(n => $"missing: {n}"));
problems.AddRange(present.Keys.Except(Sha256.Keys, StringComparer.Ordinal).Order(StringComparer.Ordinal).Select(n => $"extra: {n}"));
problems.AddRange(Sha256.Where(p => present.TryGetValue(p.Key, out var actual) && actual != p.Value).Select(p => $"changed: {p.Key}"));
return problems;
}
[Fact]
public void Fixtures_are_huginn_232764bf_byte_for_byte()
{
Assert.Equal(99, Sha256.Count);
Assert.True(Problems(Directory).Count == 0,
$"not Huginn {HuginnCommit}:crates/huginn-mind/tests/fixtures/desk-feed/: {string.Join("; ", Problems(Directory))}. " +
"Re-vendor from that commit or move the pin on purpose.");
}
private static string Copy()
{
var copy = System.IO.Directory.CreateTempSubdirectory("pin").FullName;
foreach (var file in new DirectoryInfo(Directory).EnumerateFiles("*", SearchOption.AllDirectories))
{
var to = Path.Combine(copy, Path.GetRelativePath(Directory, file.FullName));
System.IO.Directory.CreateDirectory(Path.GetDirectoryName(to)!);
file.CopyTo(to);
}
return copy;
}
[Theory]
[InlineData("list-open.request.frame.bin", "changed")]
[InlineData("cultlib-d3025d3c/entry-open-counted.msgpack", "changed")]
[InlineData("README.md", "changed")]
public void A_changed_byte_fails_the_pin(string name, string kind)
{
var copy = Copy();
try
{
var path = Path.Combine(copy, name);
var bytes = File.ReadAllBytes(path);
bytes[^1] ^= 1;
File.WriteAllBytes(path, bytes);
Assert.Equal([$"{kind}: {name}"], Problems(copy));
}
finally
{
System.IO.Directory.Delete(copy, true);
}
}
[Fact]
public void An_extra_or_missing_file_fails_the_pin_at_either_level()
{
var copy = Copy();
try
{
File.WriteAllText(Path.Combine(copy, "extra.hex"), "00\n");
File.WriteAllText(Path.Combine(copy, "cultlib-d3025d3c", "extra.msgpack"), "x");
File.Delete(Path.Combine(copy, "read.request.frame.hex"));
File.Delete(Path.Combine(copy, "cultlib-d3025d3c", "answer-counted.msgpack"));
Assert.Equal(
["missing: cultlib-d3025d3c/answer-counted.msgpack", "missing: read.request.frame.hex", "extra: cultlib-d3025d3c/extra.msgpack", "extra: extra.hex"],
Problems(copy));
}
finally
{
System.IO.Directory.Delete(copy, true);
}
}
}

View file

@ -78,33 +78,6 @@ public sealed class DeskReadTests
Assert.Equal(200, bare.Status);
}
[Fact]
public async Task List_scenarios_pass_through()
{
var steps = 0;
foreach (var scenario in DeskVectors.Rows("transport", "listScenarios"))
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject()))
{
var request = step["request"]!;
var response = step["response"]!.AsObject();
var list = response.Deserialize<ForkList>(DeskJson.Options)!;
factory.Door.ListScript = (_, _) => new DeskListOutcome.Listed(list);
var query = $"?status={(string?)request["status"]}&cursor={(string?)request["cursor"]}&limit={(int)request["limit"]!}";
var reply = await factory.SendAsync("GET", $"/api/desk/{(string)request["mind"]!}/forks" + query, null, ReadToken(factory));
Assert.Equal(200, reply.Status);
Assert.True(DeskVectors.SameShape(JsonNode.Parse(reply.Body), response), (string)scenario["name"]!);
steps++;
}
}
Assert.Equal(9, steps);
}
[Fact]
public async Task Read_returns_the_entry_unchanged_and_an_unknown_fork_is_refused()
{
@ -457,7 +430,7 @@ public sealed class DeskReadTests
foreach (var account in new[] { DeskWebApplicationFactory.Member, DeskWebApplicationFactory.Alice })
{
var reply = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", null, factory.Token(account, "bifrost-desk", "desk.read"));
Assert.Equal(forks.Select(f => f.Card.ForkId), JsonNode.Parse(reply.Body)!["forks"]!.AsArray().Select(f => (string?)f!["card"]!["fork_id"]));
Assert.Equal(forks.Select(f => f.Card.GetProperty("fork_id").GetString()), JsonNode.Parse(reply.Body)!["forks"]!.AsArray().Select(f => (string?)f!["card"]!["fork_id"]));
}
}

View file

@ -0,0 +1,89 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
// CultLib d3025d3c's reader views (append-only on 3a51bbab), as Bifrost meets them: the 18-key card, DE0012 (raised_at, decider_count) stays opaque,
// the record is typed with answerer_count, and answerer is nil in a non-decider's view. Entries, records, list pages and
// stored answers are replayed from their wire bytes through the real door in DeskVectorWireTests.
public sealed class DeskReaderViewTests
{
private static readonly string[] CardKeys =
[
"schemaVersion", "fork_id", "title", "context", "options", "recommendation", "depends", "leans", "flag", "raised_in",
"raised_at", "deciders", "decider_count", "status", "ruling", "ruled_from", "order", "options_hash",
];
[Fact]
public void Valid_cards_are_the_17_key_card_with_both_views_and_pass_through_unchanged()
{
var rows = DeskVectors.Rows("card", "valid").ToList();
Assert.Equal(7, rows.Count);
foreach (var row in rows)
{
var message = row["message"]!.AsObject();
Assert.Equal(CardKeys, message.Select(p => p.Key));
Assert.StartsWith("DE0012", (string)row["messagePackHex"]!, StringComparison.Ordinal);
Assert.True(message["raised_at"] is null || message["raised_at"]!.GetValueKind() == JsonValueKind.String);
var count = (int)message["decider_count"]!;
Assert.InRange(count, 1, 64);
var deciders = message["deciders"]!.AsArray();
Assert.True(deciders.Count == 0 || deciders.Count == count, (string)row["name"]!);
var card = message.Deserialize<JsonElement>(DeskJson.Options);
Assert.True(DeskVectors.SameShape(message, JsonSerializer.SerializeToNode(card, DeskJson.Options)), (string)row["name"]!);
}
Assert.Contains(rows, r => r["message"]!["raised_at"] is null);
Assert.Contains(rows, r => r["message"]!["deciders"]!.AsArray().Count == 0 && (string)r["name"]! == "withheld_deciders");
}
[Fact]
public void The_options_hash_vectors_are_the_15_and_Bifrost_computes_no_hash()
{
Assert.Equal(15, DeskVectors.Array("optionsHash").Count);
Assert.Equal(9, DeskVectors.Array("bodyDigests").Count);
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !Directory.Exists(Path.Combine(directory.FullName, "src", "Bifrost.Web", "Features", "Desk")))
{
directory = directory.Parent;
}
Assert.NotNull(directory);
foreach (var file in Directory.GetFiles(Path.Combine(directory.FullName, "src", "Bifrost.Web", "Features", "Desk"), "*.cs"))
{
var text = File.ReadAllText(file);
Assert.DoesNotContain("System.Security.Cryptography", text, StringComparison.Ordinal);
Assert.DoesNotContain("SHA256", text, StringComparison.Ordinal);
}
}
[Fact]
public void Stored_answers_round_trip_and_every_variant_carries_a_nil_answerer_with_the_key_written()
{
var rows = DeskVectors.Rows("deskAnswer", "valid").ToList();
Assert.Equal(8, rows.Count);
Assert.Contains(rows, r => (string)r["name"]! == "withheld_answerer");
Assert.Contains(rows, r => (string)r["name"]! == "withheld_answerer_revision" && (string)r["message"]!["outcome"]! == "Revision");
Assert.Contains(rows, r => (string)r["name"]! == "withheld_answerer_retraction" && (string)r["message"]!["outcome"]! == "Retraction");
var outcomes = new HashSet<string>();
foreach (var row in rows)
{
var message = row["message"]!.AsObject();
Assert.StartsWith("DE0010", (string)row["messagePackHex"]!, StringComparison.Ordinal);
outcomes.Add((string)message["outcome"]!);
Assert.True(DeskVectors.SameShape(message, JsonSerializer.SerializeToNode(message.Deserialize<DeskAnswerDocument>(DeskJson.Options), DeskJson.Options)), (string)row["name"]!);
var withheld = JsonNode.Parse(message.ToJsonString())!.AsObject();
withheld["answerer"] = null;
var back = JsonSerializer.SerializeToNode(withheld.Deserialize<DeskAnswerDocument>(DeskJson.Options), DeskJson.Options)!.AsObject();
Assert.True(back.ContainsKey("answerer"));
Assert.Null(back["answerer"]);
Assert.True(DeskVectors.SameShape(withheld, back), (string)row["name"]!);
}
Assert.Superset(new HashSet<string> { "Counted", "Revision", "Retraction", "Refused" }, outcomes);
}
}

View file

@ -22,7 +22,7 @@ public sealed class DeskScenarioTests
{
using var factory = new DeskWebApplicationFactory();
var door = factory.Door;
door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(scenario["card"]!.Deserialize<ForkCard>(DeskJson.Options)!);
door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(scenario["card"]!.Deserialize<JsonElement>(DeskJson.Options));
var scenarioName = (string)scenario["name"]!;
foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject()))
@ -36,7 +36,7 @@ public sealed class DeskScenarioTests
if (kind == "card")
{
door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(step["card"]!.Deserialize<ForkCard>(DeskJson.Options)!);
door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(step["card"]!.Deserialize<JsonElement>(DeskJson.Options));
continue;
}
@ -155,7 +155,7 @@ public sealed class DeskScenarioTests
var answer = message["answer"]!.Deserialize<DeskAnswerDocument>(DeskJson.Options)!;
var result = message.Deserialize<AnswerResult>(DeskJson.Options)!;
factory.Door.Forks[(Socket, answer.ForkId)] = DeskReplay.Entry(
message["card"] is { } card ? card.Deserialize<ForkCard>(DeskJson.Options)! : DeskReplay.Card(answer.ForkId));
message["card"] is { } card ? card.Deserialize<JsonElement>(DeskJson.Options) : DeskReplay.Card(answer.ForkId));
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(result with { Card = null });
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);

View file

@ -36,14 +36,14 @@ public sealed class DeskSurfaceTests
}
[Fact]
public void A_configured_desk_without_its_door_stops_startup()
public void A_configured_desk_with_no_door_supplied_gets_the_production_unix_socket_door()
{
using var factory = new DeskWebApplicationFactory(registerPorts: false);
var failure = Assert.ThrowsAny<Exception>(() => factory.CreateClient());
factory.CreateClient();
// The credential is production wiring (OpenIddict validation); the door is the port a test or Huginn supplies.
Assert.Contains("IDeskDoor", failure.ToString());
// The credential is production wiring (OpenIddict validation) and so is the door; a test supplies a fake only by replacing it.
Assert.IsType<UnixSocketDeskDoor>(factory.Services.GetRequiredService<IDeskDoor>());
}
[Fact]

View file

@ -0,0 +1,99 @@
using System.Reflection;
using System.Text.Json.Nodes;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
// Every section of the vendored vectors file (each top-level key, and each key under an object section down to depth 3) is
// classified here: replayed by a named test of Bifrost's, or not judged by Bifrost because the rule belongs to CultLib's
// validators and Huginn's door. A re-pin that adds a section fails until someone classifies it; a section that goes
// fails too. A replayed section names the test that replays it, and the test must exist.
public sealed class DeskVectorCensusTests
{
private const string Wire = nameof(DeskVectorWireTests);
// path -> "Class.Method" of a test that replays it.
private static readonly Dictionary<string, string> Replayed = new(StringComparer.Ordinal)
{
["card.valid"] = $"{Wire}.{nameof(DeskVectorWireTests.Every_valid_card_leaves_the_door_inside_an_entry_unchanged)}",
["request.valid"] = "DeskBodyShapeTests.Valid_request_vectors_pass",
["request.invalid"] = "DeskBodyShapeTests.Invalid_request_vectors_refuse_with_their_code",
["deskAnswer.valid"] = $"{Wire}.{nameof(DeskVectorWireTests.Every_valid_stored_answer_leaves_an_answered_reply_unchanged_the_withheld_variants_included)}",
["results"] = "DeskScenarioTests.Answer_results_pass_through_unchanged",
["doorRefusals"] = "DeskDocumentTests.Refusal_and_unavailable_documents_match_vectors",
["unavailable"] = "DeskDocumentTests.Refusal_and_unavailable_documents_match_vectors",
["scenarios"] = "DeskScenarioTests.Every_scenario_answer_step_replays_through_http",
["transport.listRequest.valid"] = "DeskReadTests.Valid_list_queries_reach_the_door_as_typed_requests",
["transport.listRequest.invalid"] = "DeskReadTests.List_and_read_request_invalid_vectors",
["transport.entry.valid"] = $"{Wire}.{nameof(DeskVectorWireTests.Every_valid_entry_leaves_a_read_unchanged_in_both_views)}",
["transport.entry.invalid"] = $"{Wire}.{nameof(DeskVectorWireTests.The_door_refuses_the_entry_negatives_that_break_its_typing_and_judges_no_rule_of_the_contract)}",
["transport.listInvalid"] = $"{Wire}.{nameof(DeskVectorWireTests.The_door_refuses_a_negative_count_and_judges_no_other_list_rule)}",
["transport.listScenarios"] = $"{Wire}.{nameof(DeskVectorWireTests.Every_list_page_in_the_vectors_leaves_the_door_as_the_vectors_message)}",
["transport.listShortPages"] = $"{Wire}.{nameof(DeskVectorWireTests.Every_list_page_in_the_vectors_leaves_the_edge_as_the_vectors_message_for_its_reader)}",
["transport.readTooLarge"] = "DeskReadTests.A_read_past_the_cap_follows_the_vectors",
["transport.answerTooLarge"] = "DeskAnswerTests.A_stale_answer_over_an_oversize_fork_follows_the_vector_fresh_and_replayed",
["transport.doorFaults"] = "DeskReadTests.Door_faults_follow_the_vectors_per_route",
["transport.mindRoutes.minds"] = "DeskMindRoutesTests.Every_vector_segment_resolves_or_misses",
["transport.mindRoutes.forks"] = "DeskMindRouteTests.Vector_route_cases",
["transport.mindRoutes.cases"] = "DeskMindRouteTests.Vector_route_cases",
["transport.mindRoutes.sameResponse"] = "DeskMindRouteTests.Same_response_pairs_are_byte_equal",
["transport.authCases"] = "DeskAuthCaseTests.Vector_auth_cases",
};
// path -> why Bifrost does not judge it.
private static readonly Dictionary<string, string> NotJudged = new(StringComparer.Ordinal)
{
["provenance"] = "metadata text, not a vector",
["optionsHash"] = "Bifrost computes no hash of a card (DeskReaderViewTests pins the 15 and that the Desk source holds no SHA256); CultLib's validators and Huginn's door own the options hash",
["bodyDigests"] = "the body digest behind request_id_conflict is the receiver's (Huginn's door); Bifrost forwards the body as written (DeskReaderViewTests pins the 9)",
["card.invalid"] = "the card is an opaque map to Bifrost; every card rule (R-card, depends, options, deciders, hash) is CultLib's validators' and Huginn's door's",
["deskAnswer.invalid"] = "outcome, code and target pairing are CultLib's and Huginn's; the door judges only the closed 16-key typed shape (all twelve have it; DeskVectorWireTests passes them through)",
["resultsInvalid"] = "R-answer-result (the sender's own answer carries their account) is Huginn's door's rule; DeskVectorWireTests reads the three as any answer",
["rulingCites"] = "which answer a ruling may cite is a rule of CultLib's validators and the ruling's author; Bifrost forwards answers and rules on nothing",
["transport.readRequest.valid"] = "the read request is the route's own path segments (mind, fork id); the golden read.request frame pins what the door sends",
["transport.readRequest.invalid"] = "an empty fork id or mind is a routing miss before any request exists (DeskReadTests, DeskMindRoutesTests)",
};
private static IEnumerable<string> Sections(JsonNode node, string path, int depth)
{
if (node is JsonObject map && depth < 3)
{
foreach (var (key, value) in map)
{
foreach (var section in Sections(value!, path.Length == 0 ? key : path + "." + key, depth + 1))
{
yield return section;
}
}
}
else
{
yield return path;
}
}
[Fact]
public void Every_section_of_the_vendored_vectors_is_classified_replayed_or_not_judged_and_no_section_is_both()
{
var sections = Sections(DeskVectors.Root, string.Empty, 0).Order(StringComparer.Ordinal).ToList();
Assert.Empty(Replayed.Keys.Intersect(NotJudged.Keys));
Assert.Equal(sections, Replayed.Keys.Concat(NotJudged.Keys).Order(StringComparer.Ordinal).ToList());
Assert.Contains("transport.entry.invalid", sections);
Assert.Contains("resultsInvalid", sections);
}
[Fact]
public void Every_replayed_section_names_a_test_that_exists()
{
var assembly = typeof(DeskVectorCensusTests).Assembly;
foreach (var (section, test) in Replayed)
{
var (className, method) = (test[..test.IndexOf('.', StringComparison.Ordinal)], test[(test.IndexOf('.', StringComparison.Ordinal) + 1)..]);
var type = assembly.GetType("Bifrost.Web.Tests." + className);
Assert.True(type is not null, $"{section}: no test class {className}");
var found = type.GetMethods(BindingFlags.Public | BindingFlags.Instance).FirstOrDefault(m => m.Name == method);
Assert.True(found is not null && found.GetCustomAttributes<FactAttribute>().Any(), $"{section}: no test {test}");
}
}
}

View file

@ -6,8 +6,9 @@ namespace Bifrost.Web.Tests;
public sealed class DeskVectorPinTests
{
[Fact]
public void Vectors_are_cultlib_de8a062a()
public void Vectors_are_cultlib_d3025d3c()
{
Assert.StartsWith("d3025d3c", DeskVectors.SourceCommit, StringComparison.Ordinal);
var sha = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(DeskVectors.Path))).ToLowerInvariant();
Assert.True(

View file

@ -0,0 +1,236 @@
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
// CultLib's vectors, replayed from their WIRE form (responseHex / messagePackHex) through the real UnixSocketDeskDoor on
// a socket double, then compared with the vector's `message`: same keys in the same order, integers unchanged
// (compared as JSON text; the vector side is parsed JSON, so an escaped apostrophe cannot differ). Only what Huginn
// sends is served here. The refusals, 503 documents and edge-authored results in the vectors are Bifrost's own
// output and are replayed on the edge (DeskReadTests, DeskAnswerTests, DeskDocumentTests).
//
// What the door judges of a reply (the single owner is the wire walker's Closed): every contract document is a closed
// map with every key present and typed; the card inside an entry is an opaque map. What it does not judge: every
// rule a schema cannot say (R-card, R-record, R-entry, R-list, R-answer-result, option and hash rules, ordering,
// the reader-view agreement between deciders and answerers). Those belong to CultLib's validators and to Huginn's door;
// Bifrost holds no audience rule and masks nothing it cannot read.
public sealed class DeskVectorWireTests
{
private static string Mind(JsonNode request) => (string)request["mind"]!;
private static IEnumerable<(string Name, JsonNode Request, JsonNode Response, string Hex)> ListSteps()
{
foreach (var scenario in DeskVectors.Rows("transport", "listScenarios").Concat(DeskVectors.Rows("transport", "listShortPages")))
{
foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject()).Where(s => s.ContainsKey("responseHex")))
{
yield return ((string)scenario["name"]!, step["request"]!, step["response"]!, (string)step["responseHex"]!);
}
}
}
[Fact]
public async Task Every_list_page_in_the_vectors_leaves_the_door_as_the_vectors_message()
{
var steps = 0;
foreach (var (name, request, response, hex) in ListSteps())
{
var list = await DeskServe.ListAsync(Convert.FromHexString(hex), Mind(request));
Assert.True(DeskServe.Text(response) == DeskServe.Text(list), name);
steps++;
}
Assert.Equal(16, steps);
}
[Fact]
public async Task Every_list_page_in_the_vectors_leaves_the_edge_as_the_vectors_message_for_its_reader()
{
var steps = 0;
foreach (var (name, request, response, hex) in ListSteps())
{
var scenario = DeskVectors.Rows("transport", "listScenarios").FirstOrDefault(s => (string)s["name"]! == name);
var nonDecider = scenario is not null && scenario.ContainsKey("twin");
using var huginn = DeskServe.Socket(DeskServe.Forks(Convert.FromHexString(hex)));
using var factory = new DeskWebApplicationFactory(DeskServe.ConfigurationOn(Mind(request), huginn.Path), fakeDoor: false);
var reader = nonDecider ? DeskWebApplicationFactory.Member : DeskWebApplicationFactory.Alice;
await factory.EnsureAccountAsync(reader, true);
var query = $"?status={(string?)request["status"]}&cursor={(string?)request["cursor"]}&limit={(int)request["limit"]!}";
var reply = await factory.SendAsync("GET", $"/api/desk/{Mind(request)}/forks" + query, null, factory.Token(reader, "bifrost-desk", "desk.read"));
// The edge's one change for a non-decider: every note nil, the key kept.
var expected = JsonNode.Parse(response.ToJsonString())!;
if (nonDecider)
{
foreach (var answer in expected["forks"]!.AsArray().SelectMany(f => f!["record"]!["answers"]!.AsArray()))
{
answer!["note"] = null;
}
}
Assert.Equal(200, reply.Status);
Assert.True(DeskServe.Text(expected) == DeskServe.Text(JsonNode.Parse(reply.Body)!), name);
steps++;
}
Assert.Equal(16, steps);
}
[Fact]
public async Task Every_valid_entry_leaves_a_read_unchanged_in_both_views()
{
var rows = DeskVectors.Rows("transport", "entry", "valid").ToList();
Assert.Equal(8, rows.Count);
foreach (var row in rows)
{
var entry = await DeskServe.ReadAsync(Convert.FromHexString((string)row["messagePackHex"]!));
Assert.True(DeskServe.Text(row["message"]!) == DeskServe.Text(entry), (string)row["name"]!);
}
}
[Fact]
public async Task Every_valid_card_leaves_the_door_inside_an_entry_unchanged()
{
var rows = DeskVectors.Rows("card", "valid").ToList();
Assert.Equal(7, rows.Count);
foreach (var row in rows)
{
var card = row["message"]!;
var record = DeskServe.FromJson(JsonNode.Parse($$"""{"schemaVersion":"gamecult.fork.record.v0","fork_id":"{{(string)card["fork_id"]!}}","options_hash":"{{(string)card["options_hash"]!}}","counted_answer_id":null,"answerer_count":0,"answers":[]}""")!);
var entry = await DeskServe.ReadAsync(DeskServe.EntryOf(Convert.FromHexString((string)row["messagePackHex"]!), record));
Assert.True(DeskServe.Text(card) == DeskServe.Text(entry.Card), (string)row["name"]!);
}
}
[Fact]
public async Task Every_scenario_record_leaves_the_door_unchanged_the_6_key_record()
{
var count = 0;
foreach (var scenario in DeskVectors.Rows("scenarios"))
{
var hex = (string)scenario["recordHex"]!;
Assert.StartsWith("86", hex, StringComparison.Ordinal);
var entry = await DeskServe.ReadAsync(DeskServe.EntryOf(DeskServe.FromJson(scenario["card"]!), Convert.FromHexString(hex)));
Assert.True(DeskServe.Text(scenario["record"]!) == DeskServe.Text(entry.Record), (string)scenario["name"]!);
count++;
}
Assert.Equal(63, count);
}
[Fact]
public async Task Every_valid_stored_answer_leaves_an_answered_reply_unchanged_the_withheld_variants_included()
{
var rows = DeskVectors.Rows("deskAnswer", "valid").ToList();
Assert.Equal(8, rows.Count);
foreach (var row in rows)
{
var answer = await DeskServe.AnswerAsync(Convert.FromHexString((string)row["messagePackHex"]!));
Assert.True(DeskServe.Text(row["message"]!) == DeskServe.Text(answer), (string)row["name"]!);
}
Assert.Contains(rows, r => (string)r["name"]! == "withheld_answerer_revision" && (string)r["message"]!["outcome"]! == "Revision" && r["message"]!["answerer"] is null);
Assert.Contains(rows, r => (string)r["name"]! == "withheld_answerer_retraction" && (string)r["message"]!["outcome"]! == "Retraction" && r["message"]!["answerer"] is null);
}
// The entry negatives on the wire. The door refuses the four that break its closed-map typing: an entry without its
// record, and a missing, negative or nil answerer_count (never a count Bifrost may invent). The other fourteen are
// rules a schema cannot say (R-record, R-entry, the options-hash and derivation rules; the two new ones pin a
// withheld card beside a named Retraction and a count over the answers): CultLib's validators and Huginn's door
// judge them, Bifrost reads a closed map and passes it, and a change to either side shows here.
private static readonly HashSet<string> EntryNegativesTheDoorRefuses =
["entry_without_record", "answerer_count_missing", "answerer_count_negative", "answerer_count_null"];
[Fact]
public async Task The_door_refuses_the_entry_negatives_that_break_its_typing_and_judges_no_rule_of_the_contract()
{
var rows = DeskVectors.Rows("transport", "entry", "invalid").ToList();
Assert.Equal(18, rows.Count);
Assert.Contains(rows, r => (string)r["name"]! == "withheld_revision_and_retraction_beside_a_named_retraction");
Assert.Contains(rows, r => (string)r["name"]! == "withheld_revision_and_retraction_count_over_the_answers");
foreach (var row in rows)
{
var name = (string)row["name"]!;
var served = Task.Run(() => DeskServe.ReadAsync(Convert.FromHexString((string)row["messagePackHex"]!)));
if (EntryNegativesTheDoorRefuses.Contains(name))
{
await Assert.ThrowsAsync<DeskDoorException>(() => served);
}
else
{
var entry = await served;
Assert.True(DeskServe.Text(row["message"]!) == DeskServe.Text(entry), name);
}
}
}
// The list negatives on the wire. The door refuses count_negative (a count is an unsigned integer in its closed typing).
// The others are rules a schema cannot say or text grammars the door does not hold: a cursor is c<N> and a stamp is s<N>
// (Bifrost passes both on, opaque), a page is one reader's view (R-list), and an order is unique among a Mind's cards.
// CultLib's validators and Huginn's door judge them.
[Fact]
public async Task The_door_refuses_a_negative_count_and_judges_no_other_list_rule()
{
var rows = DeskVectors.Rows("transport", "listInvalid").ToList();
Assert.Equal(5, rows.Count);
foreach (var row in rows)
{
var name = (string)row["name"]!;
var served = Task.Run(() => DeskServe.ListAsync(Convert.FromHexString((string)row["messagePackHex"]!)));
if (name == "count_negative")
{
await Assert.ThrowsAsync<DeskDoorException>(() => served);
}
else
{
Assert.True(DeskServe.Text(row["message"]!) == DeskServe.Text(await served), name);
}
}
}
// The stored-answer negatives and the answer_result negatives. The door judges that an answer is a closed 16-key map with
// every key typed; all twelve deskAnswer negatives have the 16 keys, so none is refused here. They are rules (outcome
// and code pairing, targets, case, and "no_answerer": a nil answerer on a Counted answer is a withheld view only a
// reader can tell from a fault) that the contract's validators and Huginn's door own. A MISSING answerer key is the
// door's fault, pinned in UnixSocketDeskDoorWireTests. The three resultsInvalid vectors are R-answer-result (the
// sender's own answer carries their account, never nil): Huginn's rule; for Bifrost a nil answerer in an Answered
// reply is a string-or-nil key like any other. Whether the edge should add that check is not decided here.
[Fact]
public async Task The_door_judges_no_deskAnswer_rule_of_the_contract_only_its_closed_typed_shape()
{
var rows = DeskVectors.Rows("deskAnswer", "invalid").ToList();
Assert.Equal(12, rows.Count);
foreach (var row in rows)
{
var answer = await DeskServe.AnswerAsync(Convert.FromHexString((string)row["messagePackHex"]!));
Assert.True(DeskServe.Text(row["message"]!) == DeskServe.Text(answer), (string)row["name"]!);
}
}
[Fact]
public async Task The_three_answer_result_negatives_carry_an_answer_the_door_reads_as_any_answer()
{
var rows = DeskVectors.Rows("resultsInvalid").ToList();
Assert.Equal(3, rows.Count);
foreach (var row in rows)
{
var answer = row["message"]!["answer"]!;
var served = await DeskServe.AnswerAsync(DeskServe.FromJson(answer));
Assert.Null(served.Answerer);
Assert.True(DeskServe.Text(answer) == DeskServe.Text(served), (string)row["name"]!);
}
}
}

View file

@ -0,0 +1,3 @@
fork-card-vectors.json text eol=lf
desk-feed/**/*.md text eol=lf
desk-feed/*.md text eol=lf

View file

@ -0,0 +1,3 @@
*.bin binary
*.msgpack binary
*.hex text eol=lf

View file

@ -0,0 +1,50 @@
# Desk feed golden fixtures
The bytes on a Mind's desk socket for a list or a read. The contract is `docs/desk-feed-door.md`.
`crates/huginn-mind/tests/desk_feed_fixtures.rs` writes these files with Huginn's own encoders and
checks them on every `cargo test -p huginn-mind --test desk_feed_fixtures`. Set
`HUGINN_WRITE_DESK_FIXTURES=1` to rewrite them. Do not edit them by hand.
Each case has up to four files:
- `<case>.frame.bin` and `<case>.frame.hex`: the whole frame on the socket (u32 big-endian length,
then the CultNet message).
- `<case>.payload.msgpack` and `<case>.payload.hex`: the base64-decoded `payload` of an operation
message (named MessagePack). The `record-*` cases are bare payload fragments with no frame.
Hex is lowercase with one trailing newline. The fork id is a question Eureka's mind holds. The
record's answers, notes and hash are synthetic.
**The listing gate is not live.** On `main` every showable question goes to every reader. Do not
put a Mind that holds private questions behind a live receiver before `cut-desk-feed-listing`
lands (see `docs/desk-feed-door.md`).
| case | direction | content |
|---|---|---|
| `list-open.request` | Bifrost to Huginn | `Forks`, status open, no cursor, limit 50 |
| `list-open-cursor.request` | Bifrost to Huginn | `Forks`, status open, cursor `c42`, limit 50 |
| `list-counts.request` | Bifrost to Huginn | `Forks`, status open, limit 0 (counts and stamp only) |
| `read.request` | Bifrost to Huginn | `Fork` for one fork id |
| `list-non-decider.request` | Bifrost to Huginn | `Forks` as above, from a subject who is not on the call's deciders |
| `read-non-decider.request` | Bifrost to Huginn | `Fork`, from a subject who is not on the call's deciders |
| `list-open.response` | Huginn to Bifrost | a decider's page of one open fork: card with `raised_at` a string, `decider_count` 2 and both ids, record with `answerer_count` 3 and every `answerer` named |
| `list-open-non-decider.response` | Huginn to Bifrost | the same page for a non-decider: `deciders` empty, `decider_count` 2, every `answerer` nil, `answerer_count` 3; no account id anywhere in its bytes |
| `read.response` | Huginn to Bifrost | a decider's read of one fork, `raised_at` nil |
| `read-non-decider.response` | Huginn to Bifrost | the same read for a non-decider, `raised_at` nil, no account id |
| `list-empty.response` | Huginn to Bifrost | an empty Mind, or nothing listed to this reader: counts 0, `forks: []` |
| `list-counts.response` | Huginn to Bifrost | the limit-0 answer: open 3, recorded 5, `forks: []` |
| `read-unknown.response` | Huginn to Bifrost | `NotStored` `fork_unknown` (Bifrost: 404) |
| `list-too-large.response` | Huginn to Bifrost | `NotStored` `fork_too_large` naming the cursor to continue (Bifrost: 422) |
| `read-too-large.response` | Huginn to Bifrost | `NotStored` `fork_too_large`, read form: names the fork, no order or cursor (Bifrost: 422) |
| `list-response-too-large.response` | Huginn to Bifrost | `Refused` `ResponseTooLarge` (Bifrost: 503) |
| `refused-unit.response` | Huginn to Bifrost | `Refused` with a unit variant: a bare string `"MissingIdentity"` (shape only; Bifrost: 503) |
| `unavailable.response` | Huginn to Bifrost | `Refused` `Unavailable` (Bifrost: 503 for this Mind) |
| `failure.response` | Huginn to Bifrost | operation failure, `payloadSchema` `gamecult.cultnet.operation_failure.v1` (Bifrost: 503) |
| `error.response` | Huginn to Bifrost | CultNet `Error` message, no payload (Bifrost: 503) |
| `record-whole` | fragment | `gamecult.fork.record.v0` in a decider's view: all four answer variants (Counted, Revision, Refused, Retraction) by three accounts, answerer ids and notes whole, `answerer_count` 3 (6 keys) |
| `record-withheld` | fragment | the same record in a non-decider's view: every `answerer` nil, notes whole, `answerer_count` 3 |
The reader-count shapes (`raised_at`, `decider_count`, `answerer_count`, nullable `answerer`) are
also CultLib's own bytes, vendored unedited in `cultlib-d3025d3c/` (see its README for the pinned
commit and each vector). Huginn's types decode every one of them and re-encode it byte for byte;
the fixtures above are what Huginn's encoders write for the same shapes.

View file

@ -0,0 +1,28 @@
# CultLib desk-feed vectors, vendored
Source: `GameCult/CultLib` at commit `d3025d3c19674803ad32eb2335461b8cbc22bd28` (#118, on
`3a51bbab` #117, on `af914253` #115), file `contracts/cultnet/governance/fork-card-vectors.json`. Each `.msgpack` file is
the vector's `messagePackHex` decoded to bytes, nothing else changed. Do not edit them or rewrite them
from Huginn's encoders: they are the reference Huginn's types are checked against (see
`docs/desk-feed-door.md`). To re-pin, take the new commit's vectors and update the sha here, in
`docs/desk-feed-door.md` and in `CULTLIB_SHA` in `desk_feed_fixtures.rs`.
| file | upstream vector | what it pins |
|---|---|---|
| `card-operator-fork` | `card.valid` `operator_fork` | 18 keys (`DE 00 12`), `raised_at` a string, a decider's `deciders` and `decider_count` 1 |
| `card-raised-at-nil` | `card.valid` `raised_at_nil` | `raised_at` nil, key kept |
| `card-withheld-deciders` | `card.valid` `withheld_deciders` | empty `deciders`, `decider_count` 2 |
| `answer-counted` | `deskAnswer.valid` `counted` | 16 keys (`DE 00 10`), `answerer` a string |
| `answer-revision` | `deskAnswer.valid` `revision` | a `Revision`, `answerer` a string |
| `answer-retraction` | `deskAnswer.valid` `retraction` | a `Retraction`, `answerer` a string |
| `answer-withheld-answerer` | `deskAnswer.valid` `withheld_answerer` | `answerer` nil |
| `answer-withheld-answerer-revision` | `deskAnswer.valid` `withheld_answerer_revision` | a `Revision`, `answerer` nil |
| `answer-withheld-answerer-retraction` | `deskAnswer.valid` `withheld_answerer_retraction` | a `Retraction`, `answerer` nil |
| `answer-refused-stale` | `deskAnswer.valid` `refused_stale` | a `Refused` answer, `answerer` a string |
| `entry-open-counted` | `transport.entry.valid` `open_counted` | decider view: entry `83`, record `86`, `answerer_count` 1 |
| `entry-withheld-open-counted` | `transport.entry.valid` `withheld_open_counted` | non-decider twin: `deciders` empty, `answerer` nil, count 1 |
| `entry-closed-with-ruling-and-a-refusal` | `transport.entry.valid` `closed_with_ruling_and_a_refusal` | decider view, a refusal among the answers, `answerer_count` 2 |
| `entry-withheld-closed-with-ruling-and-a-refusal` | `transport.entry.valid` `withheld_closed_with_ruling_and_a_refusal` | its non-decider twin, count 2 |
The test `desk_feed_fixtures.rs` checks each file's SHA-256, decodes it with a decoder of its own
(not `rmp_serde`), then decodes it into Huginn's real type and re-encodes it, byte for byte.

View file

@ -0,0 +1 @@
0000007385ad736368656d6156657273696f6eb063756c746e65742e6572726f722e7630a56572726f72d92f687567696e6e2e6465736b20726566757365732061206d6573736167655f6964206f76657220323536206279746573ab726f7574696e6748696e74c0a4636f6465c0a764657461696c73c0

View file

@ -0,0 +1 @@
000001708aab646961676e6f737469637391b57061796c6f61642d6e6f742d612d72657175657374a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d95c6771526a6232526c745842686557787659575174626d39304c574574636d56786457567a644b64745a584e7a5957646c76325a705a57786b49455a76636d747a4c6d787062576c304f6942766458517462325974636d46755a32553daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61d92567616d6563756c742e63756c746e65742e6f7065726174696f6e5f6661696c7572652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564

View file

@ -0,0 +1 @@
82a4636f6465b57061796c6f61642d6e6f742d612d72657175657374a76d657373616765bf6669656c6420466f726b732e6c696d69743a206f75742d6f662d72616e6765

View file

@ -0,0 +1 @@
うcodeオpayload-not-a-requestァmessageソfield Forks.limit: out-of-range

View file

@ -0,0 +1 @@
000001a287a96d6573736167654964a36d2d33a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f06761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d7147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d5933567963323979774b5673615731706441413daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b

View file

@ -0,0 +1 @@
81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72c0a56c696d697400

View file

@ -0,0 +1 @@
0000019289a96d6573736167654964a36d2d33a96f7065726174696f6ea5666f726b73a77061796c6f6164d9b06761564762334a7263344b6b62476c7a6449657463324e6f5a573168566d567963326c766272566e5957316c593356736443356d62334a724c6d787063335175646a436b62576c755a4b5a6c64584a6c6132476c633352686258436d637a45774d546b77716d39775a57356659323931626e5144726e4a6c593239795a47566b58324e76645735304261566d62334a7263354372626d56346446396a64584a7a62334c417057467a5832396d7a53664faf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a86163636570746564

View file

@ -0,0 +1 @@
81a5466f726b7382a46c69737487ad736368656d6156657273696f6eb567616d6563756c742e666f726b2e6c6973742e7630a46d696e64a6657572656b61a57374616d70a6733130313930aa6f70656e5f636f756e7403ae7265636f726465645f636f756e7405a5666f726b7390ab6e6578745f637572736f72c0a561735f6f66cd27ce

View file

@ -0,0 +1 @@
▼Forksうlist<EFBFBD>schemaVersionオgamecult.fork.list.v0、mindヲeureka・stampヲs10190ェopen_countョrecorded_count・forks性next_cursorタ・as_ofヘ'ホ

View file

@ -0,0 +1 @@
0000019289a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d9b06761564762334a7263344b6b62476c7a6449657463324e6f5a573168566d567963326c766272566e5957316c593356736443356d62334a724c6d787063335175646a436b62576c755a4b5a6c64584a6c6132476c633352686258436d637a45774d546b77716d39775a57356659323931626e5141726e4a6c593239795a47566b58324e7664573530414b566d62334a7263354372626d56346446396a64584a7a62334c417057467a5832396d7a53664faf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a86163636570746564

View file

@ -0,0 +1 @@
81a5466f726b7382a46c69737487ad736368656d6156657273696f6eb567616d6563756c742e666f726b2e6c6973742e7630a46d696e64a6657572656b61a57374616d70a6733130313930aa6f70656e5f636f756e7400ae7265636f726465645f636f756e7400a5666f726b7390ab6e6578745f637572736f72c0a561735f6f66cd27ce

View file

@ -0,0 +1 @@
000001a287a96d6573736167654964a36d2d35a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f06761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4f5755345a44646a4e6d49744e5745304f5330304d7a67794c574978595441745a6a6c6c4f475133597a5a694e5745307147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d5933567963323979774b5673615731706444493daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b

View file

@ -0,0 +1 @@
81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92439653864376336622d356134392d343338322d623161302d663965386437633662356134a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72c0a56c696d697432

View file

@ -0,0 +1 @@
<EFBFBD>¥Forks†¤mind¦eureka§subjectÙ$9e8d7c6b-5a49-4382-b1a0-f9e8d7c6b5a4¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d¦status¤open¦cursorÀ¥limit2

View file

@ -0,0 +1 @@
000001a687a96d6573736167654964a36d2d32a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f46761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d7147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d59335679633239796f324d304d715673615731706444493daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b

View file

@ -0,0 +1 @@
81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72a3633432a56c696d697432

View file

@ -0,0 +1 @@
<EFBFBD>¥Forks†¤mind¦eureka§subjectÙ$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d¦status¤open¦cursor£c42¥limit2

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
000001a287a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f06761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d7147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d5933567963323979774b5673615731706444493daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b

View file

@ -0,0 +1 @@
81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72c0a56c696d697432

View file

@ -0,0 +1 @@
<EFBFBD>¥Forks†¤mind¦eureka§subjectÙ$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d¦status¤open¦cursorÀ¥limit2

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
0000012689a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d944676164535a575a316332566b676242535a584e776232357a5a56527662307868636d646c677156696558526c63383441415246777057787062576c307a6741424141413daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564

View file

@ -0,0 +1 @@
81a75265667573656481b0526573706f6e7365546f6f4c6172676582a56279746573ce00011170a56c696d6974ce00010000

View file

@ -0,0 +1 @@
0000020b89a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164da012867616c4f62335254644739795a57534472584e6a6147567459565a6c636e4e70623236345a3246745a574e31624851755a6d3979617935795a575a31633246734c6e597770474e765a4757755a6d39796131393062323966624746795a32576c5a584a7962334c5a6a575a76636d74666447397658327868636d646c4f69426d62334a7249475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c306553426864434276636d526c636941304d5342706379427759584e304948526f5a5342775957646c49474a315a47646c644342696553427064484e6c6247593749474e76626e5270626e566c4948647064476767593356796332397949474d304d513d3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564

View file

@ -0,0 +1 @@
81a94e6f7453746f72656483ad736368656d6156657273696f6eb867616d6563756c742e666f726b2e7265667573616c2e7630a4636f6465ae666f726b5f746f6f5f6c61726765a56572726f72d98d666f726b5f746f6f5f6c617267653a20666f726b20657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479206174206f7264657220343120697320706173742074686520706167652062756467657420627920697473656c663b20636f6e74696e7565207769746820637572736f7220633431

View file

@ -0,0 +1 @@
<EFBFBD>NotStored<EFBFBD>schemaVersion經amecult.fork.refusal.v0勺ode宸ork_too_large占rror<6F>fork_too_large: fork eureka-substrate:question:desk-listing-authority at order 41 is past the page budget by itself; continue with cursor c41

View file

@ -0,0 +1 @@
000001ca87a96d6573736167654964a36d2d36a96f7065726174696f6ea4666f726ba77061796c6f6164da01186761524762334a72684b52746157356b706d5631636d56725961647a64574a715a574e30325351355a54686b4e324d3259693031595451354c54517a4f444974596a46684d43316d4f5755345a44646a4e6d49315954536f5a47566a6157526c636e4f53325351315a6a426a4d6d49785a5330345954526b4c54526a4d3249744f5755335a69307859544a694d324d305a44566c4e6d625a4a4442694f5751335a544e684c545a6a4d6a45744e4759314f4331684d4751304c546c6c4f47593359545a694e574d305a4b646d62334a7258326c6b3254426c64584a6c6132457463335669633352795958526c4f6e46315a584e30615739754f6d526c6332737462476c7a64476c755a7931686458526f62334a7064486b3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b

View file

@ -0,0 +1 @@
81a4466f726b84a46d696e64a6657572656b61a77375626a656374d92439653864376336622d356134392d343338322d623161302d663965386437633662356134a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479

View file

@ -0,0 +1 @@
<EFBFBD>¤Fork„¤mind¦eureka§subjectÙ$9e8d7c6b-5a49-4382-b1a0-f9e8d7c6b5a4¨deciders’Ù$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fÙ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d§fork_idÙ0eureka-substrate:question:desk-listing-authority

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
000001d989a96d6573736167654964a36d2d34a96f7065726174696f6ea4666f726ba77061796c6f6164d9f867616c4f62335254644739795a57534472584e6a6147567459565a6c636e4e70623236345a3246745a574e31624851755a6d3979617935795a575a31633246734c6e597770474e765a4757755a6d39796131393062323966624746795a32576c5a584a7962334c5a616d5a76636d74666447397658327868636d646c4f69426d62334a7249475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c30655342706379427759584e304948526f5a5342795a584e776232357a5a534269623356755a4342696553427064484e6c6247593daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564

View file

@ -0,0 +1 @@
81a94e6f7453746f72656483ad736368656d6156657273696f6eb867616d6563756c742e666f726b2e7265667573616c2e7630a4636f6465ae666f726b5f746f6f5f6c61726765a56572726f72d96a666f726b5f746f6f5f6c617267653a20666f726b20657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f7269747920697320706173742074686520726573706f6e736520626f756e6420627920697473656c66

View file

@ -0,0 +1 @@
<EFBFBD>NotStored<EFBFBD>schemaVersion經amecult.fork.refusal.v0勺ode宸ork_too_large占rror揂fork_too_large: fork eureka-substrate:question:desk-listing-authority is past the response bound by itself

View file

@ -0,0 +1 @@
0000016989a96d6573736167654964a36d2d34a96f7065726174696f6ea4666f726ba77061796c6f6164d98867616c4f62335254644739795a57534472584e6a6147567459565a6c636e4e70623236345a3246745a574e31624851755a6d3979617935795a575a31633246734c6e597770474e765a4757735a6d397961313931626d74756233647570575679636d3979756d5a76636d746664573572626d3933626a6f67626d38676333566a6143426d62334a72af7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564

View file

@ -0,0 +1 @@
81a94e6f7453746f72656483ad736368656d6156657273696f6eb867616d6563756c742e666f726b2e7265667573616c2e7630a4636f6465ac666f726b5f756e6b6e6f776ea56572726f72ba666f726b5f756e6b6e6f776e3a206e6f207375636820666f726b

View file

@ -0,0 +1 @@
<EFBFBD>NotStored<EFBFBD>schemaVersion經amecult.fork.refusal.v0勺ode柏ork_unknown占rror榻ork_unknown: no such fork

View file

@ -0,0 +1 @@
000001ca87a96d6573736167654964a36d2d34a96f7065726174696f6ea4666f726ba77061796c6f6164da01186761524762334a72684b52746157356b706d5631636d56725961647a64574a715a574e30325351315a6a426a4d6d49785a5330345954526b4c54526a4d3249744f5755335a69307859544a694d324d305a44566c4e6d616f5a47566a6157526c636e4f53325351315a6a426a4d6d49785a5330345954526b4c54526a4d3249744f5755335a69307859544a694d324d305a44566c4e6d625a4a4442694f5751335a544e684c545a6a4d6a45744e4759314f4331684d4751304c546c6c4f47593359545a694e574d305a4b646d62334a7258326c6b3254426c64584a6c6132457463335669633352795958526c4f6e46315a584e30615739754f6d526c6332737462476c7a64476c755a7931686458526f62334a7064486b3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b

View file

@ -0,0 +1 @@
81a4466f726b84a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479

Some files were not shown because too many files have changed in this diff Show more