diff --git a/src/Wiki.Host/Api/ApiV1.cs b/src/Wiki.Host/Api/ApiV1.cs
index 249d1f6..ec36fb0 100644
--- a/src/Wiki.Host/Api/ApiV1.cs
+++ b/src/Wiki.Host/Api/ApiV1.cs
@@ -11,6 +11,7 @@ public static class ApiV1
var v1 = app.MapGroup("/api/v1");
v1.MapReadEndpoints();
v1.MapAskEndpoints();
+ v1.MapFactEndpoints();
v1.MapGet("/status", async (StatusQuery status, CancellationToken ct) =>
Results.Ok(await status.GetAsync(includeAdapterChecks: true, ct))).AllowAnonymous();
diff --git a/src/Wiki.Host/Api/FactEndpoints.cs b/src/Wiki.Host/Api/FactEndpoints.cs
new file mode 100644
index 0000000..809764e
--- /dev/null
+++ b/src/Wiki.Host/Api/FactEndpoints.cs
@@ -0,0 +1,46 @@
+using System.Security.Claims;
+using System.Text.Json.Serialization;
+using Wiki.Application.Facts;
+using Wiki.Core.Model;
+
+namespace Wiki.Host.Api;
+
+/// Wire shape of a deposited fact. `kind` must be "fact": the shape has no room for a hypothesis.
+public sealed record FactDto(
+ [property: JsonPropertyName("title")] string? Title,
+ [property: JsonPropertyName("source")] string? Source,
+ [property: JsonPropertyName("kind")] string? Kind,
+ [property: JsonPropertyName("observed_at")] DateTimeOffset? ObservedAt,
+ [property: JsonPropertyName("content")] string? Content,
+ [property: JsonPropertyName("tags")] IReadOnlyList? Tags);
+
+public static class FactEndpoints
+{
+ public static void MapFactEndpoints(this RouteGroupBuilder v1)
+ {
+ v1.MapPost("/facts", async (FactDto body, DepositFactUseCase deposit, ClaimsPrincipal user, TimeProvider clock, CancellationToken ct) =>
+ {
+ if (!string.Equals(body.Kind, "fact", StringComparison.OrdinalIgnoreCase))
+ return Results.UnprocessableEntity(new { error = "kind must be \"fact\"; raw sources hold verified observations only" });
+ var fact = new RawSource(
+ body.Title ?? string.Empty,
+ body.Source ?? string.Empty,
+ body.Content ?? string.Empty,
+ body.ObservedAt ?? clock.GetUtcNow(),
+ (body.Tags ?? []).Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).ToList());
+ var errors = fact.Validate();
+ if (errors.Count > 0) return Results.UnprocessableEntity(new { error = string.Join("; ", errors) });
+
+ var by = new Identity(user.Identity?.Name ?? "unknown", IdentityKind.Machine);
+ try
+ {
+ var receipt = await deposit.DepositAsync(fact, by, ct);
+ return Results.Ok(new { receipt.Path, receipt.Commit, receipt.StoredAt, capturedBy = by.Name });
+ }
+ catch (HttpRequestException e)
+ {
+ return Results.Json(new { error = e.Message }, statusCode: StatusCodes.Status502BadGateway);
+ }
+ }).RequireAuthorization(Auth.Policies.Write);
+ }
+}
diff --git a/src/Wiki.Host/Program.cs b/src/Wiki.Host/Program.cs
index 85f0796..d2425af 100644
--- a/src/Wiki.Host/Program.cs
+++ b/src/Wiki.Host/Program.cs
@@ -90,6 +90,7 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddHostedService(sp => sp.GetRequiredService());
diff --git a/test/Wiki.Host.Tests/EndpointTests.cs b/test/Wiki.Host.Tests/EndpointTests.cs
index 23cb173..8a066a0 100644
--- a/test/Wiki.Host.Tests/EndpointTests.cs
+++ b/test/Wiki.Host.Tests/EndpointTests.cs
@@ -157,7 +157,7 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture t.Tag == "ceph" && t.Count == 1);
var raw = await Client.GetFromJsonAsync>("/api/v1/raw");
- Assert.Equal("2026-09-23-pending", Assert.Single(raw!).Slug);
+ Assert.Contains(raw!, r => r.Slug == "2026-09-23-pending");
var reports = await Client.GetFromJsonAsync>("/api/v1/reports?type=audit");
Assert.Equal("Wiki audit", Assert.Single(reports!).Title);
@@ -196,6 +196,47 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture();
+ Assert.NotNull(receipt);
+ Assert.StartsWith("raw/", receipt.Path);
+ Assert.EndsWith("-talos-v1-13-10-backports-aes256k.md", receipt.Path);
+ Assert.Equal("tester", receipt.CapturedBy);
+ var stored = factory.Store.Files[receipt.Path];
+ Assert.Contains("captured_by: tester", stored);
+ Assert.Contains("type: raw", stored);
+ Assert.Contains("tags: [talos, ceph]", stored);
+ }
+
+ private sealed record FactReceiptDto(string Path, string Commit, string CapturedBy);
private sealed record AskResponse(string Judge, double? Covered, Dictionary Answers);
private sealed record NoteRow(string Slug, string Title);
private sealed record LinkRow(string? ToSlug, string? FromSlug, string Kind);