From 1b24bce77e7478b84bb7c94843f56130eed98036 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 02:13:34 +0000 Subject: [PATCH] desk: pin the vectors to CultLib de8a062a and replay the answer-route oversize case Co-Authored-By: Claude Sonnet 5.5 --- .../Features/Desk/DeskDocuments.cs | 2 +- tests/Bifrost.Web.Tests/DeskAnswerTests.cs | 48 ++++ tests/Bifrost.Web.Tests/DeskVectorPinTests.cs | 2 +- .../Fixtures/fork-card-vectors.json | 207 ++++++++++++++++++ .../Bifrost.Web.Tests/Support/DeskVectors.cs | 6 +- 5 files changed, 260 insertions(+), 5 deletions(-) diff --git a/src/Bifrost.Web/Features/Desk/DeskDocuments.cs b/src/Bifrost.Web/Features/Desk/DeskDocuments.cs index d41c9cd..f8658f4 100644 --- a/src/Bifrost.Web/Features/Desk/DeskDocuments.cs +++ b/src/Bifrost.Web/Features/Desk/DeskDocuments.cs @@ -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 f6c9bf5f) spells them: snake_case keys, every key written (nil where absent), declared in the +// (commit de8a062a) 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. diff --git a/tests/Bifrost.Web.Tests/DeskAnswerTests.cs b/tests/Bifrost.Web.Tests/DeskAnswerTests.cs index 709cd31..ce1bcdc 100644 --- a/tests/Bifrost.Web.Tests/DeskAnswerTests.cs +++ b/tests/Bifrost.Web.Tests/DeskAnswerTests.cs @@ -109,6 +109,54 @@ public sealed class DeskAnswerTests } } + [Fact] + public async Task A_stale_answer_over_an_oversize_fork_follows_the_vector_fresh_and_replayed() + { + var rows = DeskVectors.Rows("transport", "answerTooLarge").ToList(); + Assert.Single(rows); + foreach (var row in rows) + { + var (factory, token) = await ArrangeAsync(); + using (factory) + { + var request = row["request"]!; + var forkId = (string)row["fork_id"]!; + Assert.Equal(forkId, (string)request["fork_id"]!); + var stale = new AnswerResult( + DeskSchemas.AnswerResult, + new DeskAnswerDocument( + DeskSchemas.DeskAnswer, "n1", forkId, (string)row["outcome"]!, null, null, (string)row["code"]!, + (string)request["options_hash"]!, (string?)request["answer"]!["choice"], (string?)request["answer"]!["note"], + (string)request["answered_at"]!, "2026-11-12T19:02:05Z", false, DeskWebApplicationFactory.Alice, "Operator", + (string)request["request_id"]!), + null, + null); + factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(stale); + factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id); + var body = DeskVectors.Bytes(request); + var target = DeskReplay.Target("answer", "gamecult-chat", null); + + var fresh = await factory.SendAsync("POST", target, body, token); + var replay = await factory.SendAsync("POST", target, body, token); + + foreach (var reply in new[] { fresh, replay }) + { + Assert.Equal((int)row["httpStatus"]!, reply.Status); + var doc = JsonNode.Parse(reply.Body); + Assert.True(DeskVectors.SameRefusal(doc, row["refusal"], forkId, null), (string)row["name"]!); + Assert.Null(doc!["card"]); + } + + Assert.Equal(fresh.Body, replay.Body); + // The door got the client's bytes untouched, stored nothing counted, and was asked for the card twice. + Assert.All(factory.Door.Calls.Where(c => c.Kind == "answer"), c => Assert.Equal(body, c.Body)); + Assert.Equal(forkId, factory.Door.Calls.First(c => c.Kind == "read").ForkId); + Assert.False((bool)row["counted"]!); + Assert.NotEqual("Counted", stale.Answer.Outcome); + } + } + } + [Fact] public async Task Card_read_failure_is_unavailable() { diff --git a/tests/Bifrost.Web.Tests/DeskVectorPinTests.cs b/tests/Bifrost.Web.Tests/DeskVectorPinTests.cs index e14a79f..3ed7df6 100644 --- a/tests/Bifrost.Web.Tests/DeskVectorPinTests.cs +++ b/tests/Bifrost.Web.Tests/DeskVectorPinTests.cs @@ -6,7 +6,7 @@ namespace Bifrost.Web.Tests; public sealed class DeskVectorPinTests { [Fact] - public void Vectors_are_cultlib_f6c9bf5f() + public void Vectors_are_cultlib_de8a062a() { var sha = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(DeskVectors.Path))).ToLowerInvariant(); diff --git a/tests/Bifrost.Web.Tests/Fixtures/fork-card-vectors.json b/tests/Bifrost.Web.Tests/Fixtures/fork-card-vectors.json index 5f5e7c2..38b255d 100644 --- a/tests/Bifrost.Web.Tests/Fixtures/fork-card-vectors.json +++ b/tests/Bifrost.Web.Tests/Fixtures/fork-card-vectors.json @@ -7702,6 +7702,213 @@ "fork_id": "gamecult-chat:question:q3" } ], + "answerTooLarge": [ + { + "name": "a_stale_answer_whose_card_reread_is_past_the_cap_is_fork_too_large_over_a_stored_stale_refusal", + "entries": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q3", + "title": "Fork 3", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q2", + "title": "Fork 2", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 2, + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q2", + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answers": [] + } + } + ], + "request": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "answer": { + "choice": "yes", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000a001-0000-4000-8000-00000000a001" + }, + "refusal": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "fork_too_large", + "error": "fork_too_large: fork gamecult-chat:question:q3 alone is past the response budget" + }, + "refusalHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D950666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713320616C6F6E6520697320706173742074686520726573706F6E736520627564676574", + "httpStatus": 422, + "outcome": "Refused", + "code": "stale_fork", + "counted": false, + "fork_id": "gamecult-chat:question:q3" + } + ], "doorFaults": [ { "name": "read_door_answers_request_id_conflict_the_client_sees_unavailable", diff --git a/tests/Bifrost.Web.Tests/Support/DeskVectors.cs b/tests/Bifrost.Web.Tests/Support/DeskVectors.cs index 03ab7db..f613b9f 100644 --- a/tests/Bifrost.Web.Tests/Support/DeskVectors.cs +++ b/tests/Bifrost.Web.Tests/Support/DeskVectors.cs @@ -3,12 +3,12 @@ using System.Text.Json.Nodes; namespace Bifrost.Web.Tests.Support; -// CultLib's fork-card vectors, vendored at commit f6c9bf5f975177d2033578e9b51b4d21930b728a. +// CultLib's fork-card vectors, vendored at commit de8a062ab9d8c510c8b36e634fae8f108a570aff. public static class DeskVectors { - public const string SourceCommit = "f6c9bf5f975177d2033578e9b51b4d21930b728a"; + public const string SourceCommit = "de8a062ab9d8c510c8b36e634fae8f108a570aff"; - public const string FileSha256 = "cc15fc9e36e9c1f7d77b42279dc0f9a72c392adb251cc6ae3a59b79b7a004619"; + public const string FileSha256 = "a7129eee00cb38c602481b0de7de0182f83122685b69ff441e3123adc3e0f774"; public static string Path => System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", "fork-card-vectors.json"); -- 2.47.3