Desk: a read of an oversize fork is 422 fork_too_large #42

Merged
heimdall merged 1 commit from heimdall/desk-read-fork-too-large into main 2026-11-11 01:35:07 +00:00
4 changed files with 32 additions and 3 deletions

View file

@ -151,7 +151,7 @@ public static class DeskRefusals
[RequestIdConflict] = (409, "request_id_conflict: this request_id was used for a different body"),
};
// fork_too_large (Libby's ruling) is list-only and names its fork, so it is built here, not read from the table.
// 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";
@ -164,6 +164,10 @@ public static class DeskRefusals
? $"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");
// A read of an oversize fork: the same permanent refusal, naming the fork and nothing to page.
public static DeskRefusal ForkTooLargeDocument(string forkId) => new(
DeskSchemas.Refusal, ForkTooLarge, $"fork_too_large: fork {forkId} is too large to return");
public static bool IsKnown(string code) => Table.ContainsKey(code);
public static int StatusOf(string code) => Table[code].Status;

View file

@ -189,6 +189,10 @@ public static class DeskEndpoints
{
await WriteJsonAsync(http, 200, IsDecider(context) ? entry : DeskNotes.Strip(entry));
}
else if (found is DeskReadOutcome.ForkTooLarge(var largeFork))
{
await WriteJsonAsync(http, DeskRefusals.ForkTooLargeStatus, DeskRefusals.ForkTooLargeDocument(largeFork));
}
else if (found is DeskReadOutcome.NotFound(var readCode) && DoorMayRefuse(readCode) && readCode != DeskRefusals.RequestIdConflict)
{
await WriteRefusalAsync(http, ReadListCode(readCode));

View file

@ -35,6 +35,9 @@ public abstract record DeskReadOutcome
public sealed record Found(ForkEntry Entry) : DeskReadOutcome;
public sealed record NotFound(string Code) : DeskReadOutcome;
// The fork is too large to return: permanent, and nothing to page, so no order or cursor.
public sealed record ForkTooLarge(string ForkId) : DeskReadOutcome;
}
// The list request the desk's query string stands for. Mind is the route's, resolved against the configured list.
@ -47,7 +50,7 @@ public abstract record DeskListOutcome
public sealed record NotListed(string Code) : DeskListOutcome;
// The next fork is too large for any page: the page holds nothing past the cursor. The client continues with
// cursor c<Order>; at Order 0 it has reached the end. List only: no other call can be refused this way.
// cursor c<Order>; at Order 0 it has reached the end. A read of such a fork has its own outcome (no order).
public sealed record ForkTooLarge(string ForkId, int Order) : DeskListOutcome;
}

View file

@ -150,7 +150,7 @@ public sealed class DeskReadTests
}
[Fact]
public async Task fork_too_large_is_list_only_elsewhere_the_door_saying_it_is_unavailable()
public async Task fork_too_large_as_a_bare_door_code_is_unavailable_only_the_typed_outcomes_are_the_refusal()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
@ -164,6 +164,24 @@ public sealed class DeskReadTests
}
}
[Fact]
public async Task A_read_of_an_oversize_fork_is_the_same_422_naming_the_fork_with_no_order_or_cursor()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id);
var reply = await factory.SendAsync("GET", DeskReplay.Target("read", "nibu", "nibu:question:huge"), null, ReadToken(factory));
var doc = JsonNode.Parse(reply.Body)!;
Assert.Equal(422, reply.Status);
Assert.Equal("fork_too_large", (string?)doc["code"]);
var error = (string)doc["error"]!;
Assert.Contains("nibu:question:huge", error);
Assert.DoesNotContain("order", error);
Assert.DoesNotContain("cursor", error);
}
[Fact]
public async Task A_short_page_with_a_cursor_below_its_last_entry_passes_through_unchanged()
{