Desk: pin vectors to CultLib f6c9bf5f #43

Merged
heimdall merged 4 commits from heimdall/desk-pin-vectors-f6c9bf5 into main 2026-11-11 01:49:37 +00:00
7 changed files with 2952 additions and 899 deletions

View file

@ -4,7 +4,7 @@ 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 89755b08) spells them: snake_case keys, every key written (nil where absent), declared in the
// (commit f6c9bf5f) 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.
@ -151,22 +151,20 @@ public static class DeskRefusals
[RequestIdConflict] = (409, "request_id_conflict: this request_id was used for a different body"),
};
// fork_too_large (Libby's ruling) names its fork, so it is built here, not read from the table.
// Not a literal const: the pinned vectors' doorRefusals do not name it yet, and the schema test pins literals to them.
public static string ForkTooLarge => "fork_too_large";
// fork_too_large names its fork, so it is built here, not read from the table; a bare door code of that name is
// never a refusal. The texts are the pinned vectors' (alone is past the response budget).
public const string ForkTooLarge = "fork_too_large";
public static int ForkTooLargeStatus => 422;
// A list page whose next fork alone is past the budget: the client continues with cursor c<order>; at order 0
// it has reached the end.
public static DeskRefusal ForkTooLargeDocument(string forkId, int order) => new(
DeskSchemas.Refusal,
ForkTooLarge,
order > 0
? $"fork_too_large: fork {forkId} (order {order}) is too large for a page; continue with cursor c{order}"
: $"fork_too_large: fork {forkId} (order {order}) is too large for a page; the list has reached its end");
DeskSchemas.Refusal, ForkTooLarge, $"fork_too_large: fork {forkId} (order {order}) alone is past the response budget");
// A read of an oversize fork: the same permanent refusal, naming the fork and nothing to page.
// A read of such a fork: nothing to page, so no order.
public static DeskRefusal ForkTooLargeDocument(string forkId) => new(
DeskSchemas.Refusal, ForkTooLarge, $"fork_too_large: fork {forkId} is too large to return");
DeskSchemas.Refusal, ForkTooLarge, $"fork_too_large: fork {forkId} alone is past the response budget");
public static bool IsKnown(string code) => Table.ContainsKey(code);

View file

@ -65,6 +65,22 @@ public sealed class DeskAnswerTests
}
}
[Fact]
public async Task A_stale_cards_read_that_is_fork_too_large_stays_unavailable_until_the_contract_says_otherwise()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork));
factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id);
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
Assert.Equal(503, reply.Status);
Assert.Equal("unavailable", (string?)JsonNode.Parse(reply.Body)!["code"]);
}
}
[Fact]
public async Task Card_read_failure_is_unavailable()
{

View file

@ -13,10 +13,18 @@ public sealed class DeskDocumentTests
public void Refusal_and_unavailable_documents_match_vectors()
{
var rows = DeskVectors.Rows("doorRefusals").ToList();
Assert.Equal(9, rows.Count);
Assert.Equal(10, rows.Count);
foreach (var row in rows)
{
var code = (string)row["message"]!["code"]!;
if (code == DeskRefusals.ForkTooLarge)
{
// It names its fork, so it is built, not looked up; the row is the list form at order 3.
Assert.True(DeskVectors.SameRefusal(Write(DeskRefusals.ForkTooLargeDocument("gamecult-chat:question:q3", 3)), row["message"], "gamecult-chat:question:q3", 3), $"refusal {code}");
Assert.Equal((int)row["httpStatus"]!, DeskRefusals.ForkTooLargeStatus);
continue;
}
Assert.True(DeskVectors.SameShape(row["message"], Write(DeskRefusals.Document(code))), $"refusal {code}");
Assert.Equal((int)row["httpStatus"]!, DeskRefusals.StatusOf(code));
}

View file

@ -102,7 +102,7 @@ public sealed class DeskReadTests
}
}
Assert.Equal(7, steps);
Assert.Equal(9, steps);
}
[Fact]
@ -123,7 +123,7 @@ public sealed class DeskReadTests
}
[Fact]
public async Task A_fork_too_large_for_a_page_is_a_422_that_names_the_fork_and_the_cursor_to_continue_with()
public async Task A_fork_too_large_for_a_page_is_a_422_that_names_the_fork_and_its_order()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
@ -136,17 +136,14 @@ public sealed class DeskReadTests
var error = (string)doc["error"]!;
Assert.Contains("nibu:question:huge", error);
Assert.Contains("order 7", error);
Assert.Contains("c7", error);
// At order 0 there is no cursor to continue with: the list has reached its end.
// At order 0 the text still names the fork and its order: the client reads "below 0" as the end.
factory.Door.ListScript = (_, _) => new DeskListOutcome.ForkTooLarge("nibu:question:first", 0);
var end = await factory.SendAsync("GET", "/api/desk/nibu/forks", null, ReadToken(factory));
var endError = (string)JsonNode.Parse(end.Body)!["error"]!;
Assert.Equal(422, end.Status);
Assert.Contains("nibu:question:first", endError);
Assert.Contains("order 0", endError);
Assert.DoesNotContain("cursor c0", endError);
Assert.Contains("end", endError);
}
[Fact]
@ -182,6 +179,105 @@ public sealed class DeskReadTests
Assert.DoesNotContain("cursor", error);
}
[Fact]
public async Task List_short_pages_and_oversize_forks_follow_the_vectors()
{
var steps = 0;
foreach (var scenario in DeskVectors.Rows("transport", "listShortPages"))
{
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 name = (string)scenario["name"]!;
if (step["refusal"] is { } refusal)
{
factory.Door.ListScript = (_, _) => new DeskListOutcome.ForkTooLarge((string)step["fork_id"]!, (int)step["order"]!);
}
else
{
var list = step["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(step["refusal"] is null ? 200 : (int)step["httpStatus"]!, reply.Status);
Assert.True(
step["refusal"] is null
? DeskVectors.SameShape(JsonNode.Parse(reply.Body), step["response"])
: DeskVectors.SameRefusal(JsonNode.Parse(reply.Body), step["refusal"], (string)step["fork_id"]!, (int)step["order"]!),
name);
steps++;
}
}
Assert.Equal(6, steps);
}
[Fact]
public async Task A_read_past_the_cap_follows_the_vectors()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var rows = DeskVectors.Rows("transport", "readTooLarge").ToList();
Assert.Single(rows);
foreach (var row in rows)
{
var forkId = (string)row["fork_id"]!;
factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id);
var reply = await factory.SendAsync("GET", DeskReplay.Target("read", (string)row["request"]!["mind"]!, forkId), null, ReadToken(factory));
Assert.Equal((int)row["httpStatus"]!, reply.Status);
Assert.True(DeskVectors.SameRefusal(JsonNode.Parse(reply.Body), row["refusal"], forkId, null), (string)row["name"]!);
}
}
[Fact]
public async Task Door_faults_follow_the_vectors_per_route()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var faults = DeskVectors.Rows("transport", "doorFaults").ToList();
Assert.Equal(3, faults.Count);
foreach (var row in faults)
{
var code = (string)row["doorCode"]!;
string method = "GET", target;
string? bearer = null;
byte[]? body = null;
switch ((string)row["route"]!)
{
case "read":
factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound(code);
target = DeskReplay.Target("read", "nibu", "nibu:question:glow-order");
bearer = ReadToken(factory);
break;
case "list":
factory.Door.ListScript = (_, _) => new DeskListOutcome.NotListed(code);
target = "/api/desk/nibu/forks";
bearer = ReadToken(factory);
break;
default:
method = "POST";
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.NotStored(code);
target = DeskReplay.Target("answer", "nibu", null);
body = DeskReplay.AnswerBody("nibu:question:glow-order");
bearer = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer");
break;
}
var reply = await factory.SendAsync(method, target, body, bearer);
Assert.Equal((int)row["httpStatus"]!, reply.Status);
Assert.True(DeskVectors.SameShape(JsonNode.Parse(reply.Body), row["response"]), (string)row["name"]!);
}
}
[Fact]
public async Task A_short_page_with_a_cursor_below_its_last_entry_passes_through_unchanged()
{

View file

@ -6,7 +6,7 @@ namespace Bifrost.Web.Tests;
public sealed class DeskVectorPinTests
{
[Fact]
public void Vectors_are_cultlib_89755b08()
public void Vectors_are_cultlib_f6c9bf5f()
{
var sha = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(DeskVectors.Path))).ToLowerInvariant();

File diff suppressed because one or more lines are too long

View file

@ -3,12 +3,12 @@ using System.Text.Json.Nodes;
namespace Bifrost.Web.Tests.Support;
// CultLib's fork-card vectors, vendored at commit 89755b08b5dd4bf72ed8e5907b5ff4717e51da12.
// CultLib's fork-card vectors, vendored at commit f6c9bf5f975177d2033578e9b51b4d21930b728a.
public static class DeskVectors
{
public const string SourceCommit = "89755b08b5dd4bf72ed8e5907b5ff4717e51da12";
public const string SourceCommit = "f6c9bf5f975177d2033578e9b51b4d21930b728a";
public const string FileSha256 = "3387debd16059621202b706761519231e7ad52d0d4bd071a32b80c0d916e42fa";
public const string FileSha256 = "cc15fc9e36e9c1f7d77b42279dc0f9a72c392adb251cc6ae3a59b79b7a004619";
public static string Path => System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", "fork-card-vectors.json");
@ -46,5 +46,21 @@ public static class DeskVectors
}
}
// The vectors' refusal `error` text is illustrative (Libby): the contract pins the code, the key set, the status
// (asserted by the caller) and that the text names the fork id, and its order on a list.
public static bool SameRefusal(JsonNode? actual, JsonNode? vector, string forkId, int? order)
{
if (actual is not JsonObject a || vector is not JsonObject v ||
!a.Select(p => p.Key).SequenceEqual(v.Select(p => p.Key)) ||
(string?)a["schemaVersion"] != (string?)v["schemaVersion"] || (string?)a["code"] != (string?)v["code"])
{
return false;
}
var error = (string?)a["error"] ?? string.Empty;
return error.Contains(forkId, StringComparison.Ordinal) &&
(order is null || error.Contains($"order {order}", StringComparison.Ordinal));
}
public static byte[] Bytes(JsonNode node) => System.Text.Encoding.UTF8.GetBytes(node.ToJsonString());
}