Move client: a 400 is RequestInvalid only with Heimdall's invalid_request body

An nginx HTML 400, an empty 400 or any other 400 is Unknown. The fake answers its schema 400 with Heimdall's error handler body.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-14 04:14:38 +00:00
commit b7b33bc5db
4 changed files with 13 additions and 12 deletions

View file

@ -33,7 +33,7 @@ public enum HeimdallProofTier
public sealed record HeimdallMoveReceipt(
string MoveId, string AccountId, IReadOnlyList<string> MovedIdentityIds, HeimdallProofTier ProofFrom, HeimdallProofTier? ProofTo, string? ReversesMoveId);
/// <summary>Heimdall's fixed refusal codes for a move, plus the one local class: Heimdall's schema 400.</summary>
/// <summary>Heimdall's fixed refusal codes for a move, plus invalid_request, Heimdall's own schema 400.</summary>
public enum HeimdallMoveRefusal
{
AppAuthRequired,
@ -128,12 +128,6 @@ public sealed partial class HeimdallIdentityMoveClient(
return new HeimdallMoveOutcome.Unknown($"HTTP {status}");
}
// Fastify's schema 400 answers before the route runs, so it carries no Heimdall code and applied nothing.
if (status == 400)
{
return new HeimdallMoveOutcome.Refused(HeimdallMoveRefusal.RequestInvalid);
}
return Refused(await HeimdallRefusal.ReadAsync(response, token), status);
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
@ -144,7 +138,7 @@ public sealed partial class HeimdallIdentityMoveClient(
}
/// <summary>
/// Refused only for the coded refusals Heimdall's move route emits (4fe367a moveIdentitiesRoute). A 4xx without one of those codes
/// Refused only for the coded refusals Heimdall's move route emits (4fe367a moveIdentitiesRoute, and its error handler's invalid_request 400). A 4xx without one of those codes
/// (a gateway's HTML 404, a 408, a code a later Heimdall adds) proves nothing was applied, so it is Unknown. move_id_conflict is
/// Unknown too: a committed move answers it when its proofs no longer verify.
/// </summary>
@ -167,6 +161,8 @@ public sealed partial class HeimdallIdentityMoveClient(
"already_moved" => HeimdallMoveRefusal.AlreadyMoved,
"provider_conflict" => HeimdallMoveRefusal.ProviderConflict,
"identity_not_found" => HeimdallMoveRefusal.IdentityNotFound,
// Heimdall's error handler gives its own 400 this code; an nginx or other edge 400 has none, so it is not Heimdall's refusal.
"invalid_request" when status == 400 => HeimdallMoveRefusal.RequestInvalid,
_ => (HeimdallMoveRefusal?)null
};
if (code is not { } known)

View file

@ -71,7 +71,7 @@ public sealed class FakeHeimdallMoverTests
};
foreach (var json in bad)
{
Assert.Equal(HttpStatusCode.BadRequest, (await Post(host, json)).Status);
Assert.Equal((HttpStatusCode.BadRequest, "invalid_request"), await Post(host, json));
}
Assert.Single(moves.Stored);

View file

@ -292,7 +292,7 @@ public sealed class HeimdallIdentityMoveClientTests
}
[Fact]
public async Task A_400_is_RequestInvalid_a_definitive_refusal_that_moves_nothing()
public async Task A_400_carrying_Heimdalls_invalid_request_code_is_RequestInvalid_a_definitive_refusal_that_moves_nothing()
{
using var host = new HeimdallHost();
var moves = host.Heimdall.Moves;
@ -307,6 +307,11 @@ public sealed class HeimdallIdentityMoveClientTests
[Theory]
[InlineData(HttpStatusCode.NotFound, "<html><body>404 Not Found</body></html>")]
[InlineData(HttpStatusCode.NotFound, "")]
[InlineData(HttpStatusCode.BadRequest, "<html><head><title>400 Bad Request</title></head><body>nginx</body></html>")]
[InlineData(HttpStatusCode.BadRequest, "")]
[InlineData(HttpStatusCode.BadRequest, """{"statusCode":400,"error":"Bad Request","message":"x"}""")]
[InlineData(HttpStatusCode.BadRequest, """{"message":"no error field"}""")]
[InlineData(HttpStatusCode.Conflict, """{"error":"invalid_request"}""")]
[InlineData(HttpStatusCode.RequestTimeout, "")]
[InlineData(HttpStatusCode.RequestTimeout, """{"error":"request_timeout"}""")]
[InlineData(HttpStatusCode.Gone, "")]

View file

@ -102,7 +102,7 @@ public sealed class FakeMoves(Func<DateTimeOffset> now)
if (Parse(body) is not { } move)
{
return Json(HttpStatusCode.BadRequest, """{"statusCode":400,"error":"Bad Request","message":"body does not match the schema"}""");
return Json(HttpStatusCode.BadRequest, """{"error":"invalid_request","detail":"body: must match the schema"}""");
}
if (MoveStatus is { } forced)
@ -110,7 +110,7 @@ public sealed class FakeMoves(Func<DateTimeOffset> now)
return forced == HttpStatusCode.NotFound
? new HttpResponseMessage(forced)
: Json(forced, forced == HttpStatusCode.BadRequest
? """{"statusCode":400,"error":"Bad Request"}"""
? """{"error":"invalid_request","detail":"body: must match the schema"}"""
: """{"error":"server_error","detail":"HEIMDALL FREE TEXT"}""");
}