milestone 28: query provenance — every logged query is exactly explainable
Gates / frontend (push) Successful in 1m36s
Gates / test (push) Successful in 1m56s
Gates / test-aarch64 (push) Successful in 7m37s
Gates / package (push) Successful in 9m12s
Gates / container (push) Successful in 13s
CI / gates (push) Successful in 19m4s

query rows gain qclass, rcode, group, policy action and reason, the
matched rule or list entry with its source, cname and safe-search
targets, route kind, forward zone, and the resolver that actually
answered — the pool and local markers die. servfails are logged and
name the resolver that lost; post-parse protocol refusals become rows.
a detail page at /queries/:id renders the ordered explanation, and
coverage watermarks distinguish an empty history from a missing one.

the schema fingerprint changes: existing query history is recreated
with the old file kept aside and the reset filed as a resolved
diagnostic. fixes an oversized udp reply being rebuilt as noerror,
which handed clients a truncated nxdomain as success.
This commit is contained in:
2026-08-22 09:16:40 +02:00
parent 7e6cb507d2
commit 0fd6bbd312
65 changed files with 7036 additions and 685 deletions
+56
View File
@@ -0,0 +1,56 @@
//! How much of the window a client asked about the query log can still answer
//! for.
//!
//! Retention deletes old rows and advances a watermark in the same transaction
//! (`queries_repo.pruneOlderThan`), so the file knows the oldest instant it is
//! complete for. Without that fact on the wire a chart draws a pruned week as a
//! week of silence, which is the one reading that is certainly wrong.
//!
//! Three endpoints carry it — `/api/queries`, `/api/stats` and
//! `/api/stats/timeseries` — and they judge it against their own effective
//! lower bound: the client's `since` for the query log, the period's aligned
//! window start for the two stats endpoints.
const std = @import("std");
const db = @import("../storage/db.zig");
const queries_repo = @import("../storage/repositories/queries_repo.zig");
pub const Coverage = struct {
/// True only when the whole requested window is inside what the file still
/// holds. A request with no lower bound at all asks about all of history,
/// which no file that has ever pruned can promise.
complete: bool,
/// The oldest instant the file is complete for, unix seconds.
available_since: i64,
};
pub fn of(available_since: i64, since: ?i64) Coverage {
return .{
.complete = if (since) |lower_bound| lower_bound >= available_since else false,
.available_since = available_since,
};
}
/// Reads the watermark for a request that is about to answer.
pub fn read(database: *db.Db, since: ?i64) db.Error!Coverage {
return of(try queries_repo.availableSince(database), since);
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
test "a window that starts at or after the watermark is complete" {
try testing.expect(of(1000, 1000).complete);
try testing.expect(of(1000, 1001).complete);
try testing.expect(!of(1000, 999).complete);
}
test "an unbounded window is never complete" {
const unbounded = of(1000, null);
try testing.expect(!unbounded.complete);
try testing.expectEqual(@as(i64, 1000), unbounded.available_since);
}
+33 -37
View File
@@ -23,7 +23,7 @@ const std = @import("std");
const address = @import("../../platform/address.zig");
const http_util = @import("../http_util.zig");
const queries_repo = @import("../../storage/repositories/queries_repo.zig");
const provenance_view = @import("../provenance_view.zig");
const server = @import("../server.zig");
const sse = @import("../sse.zig");
@@ -36,32 +36,13 @@ pub const heartbeat_interval: std.Io.Clock.Duration = .{
.clock = .awake,
};
/// One event's `data:` payload the `/api/queries` row fields (ruling 20),
/// minus `id`: a live entry precedes persistence, so no row id exists yet.
pub const EventView = struct {
ts: i64,
domain: []const u8,
client_ip: []const u8,
qtype: ?u16,
blocked: bool,
block_reason: []const u8,
response_time_us: ?i64,
cache_hit: ?bool,
upstream: []const u8,
};
/// One event's `data:` payload: the shared full-provenance DTO, exactly. A live
/// event says everything `GET /api/queries/{id}` would say about the same query
/// except its id, which does not exist yet — the entry precedes its own insert.
pub const EventView = provenance_view.Provenance;
pub fn view(entry: *const sse.Entry) EventView {
return .{
.ts = entry.timestamp,
.domain = entry.domain(),
.client_ip = entry.clientIp(),
.qtype = entry.qtype,
.blocked = entry.blocked,
.block_reason = entry.blockReason(),
.response_time_us = entry.response_time_us,
.cache_hit = entry.cache_hit,
.upstream = entry.upstream(),
};
return provenance_view.fromEntry(entry);
}
/// One `event: query` frame. JSON never contains a raw newline, so the whole
@@ -139,14 +120,18 @@ pub fn stream(
const testing = std.testing;
test "the event payload carries the /api/queries row fields, minus id" {
const row_fields = @typeInfo(queries_repo.QueryRow).@"struct".fields;
test "the event payload is the detail body minus its id, name and type for name" {
const detail_fields = @typeInfo(provenance_view.QueryDetail).@"struct".fields;
const view_fields = @typeInfo(EventView).@"struct".fields;
comptime {
std.debug.assert(view_fields.len == row_fields.len - 1);
std.debug.assert(std.mem.eql(u8, row_fields[0].name, "id"));
for (row_fields[1..], view_fields) |row_field, view_field| {
std.debug.assert(std.mem.eql(u8, row_field.name, view_field.name));
std.debug.assert(view_fields.len == detail_fields.len - 1);
std.debug.assert(std.mem.eql(u8, detail_fields[0].name, "id"));
for (detail_fields[1..], view_fields) |detail_field, view_field| {
std.debug.assert(std.mem.eql(u8, detail_field.name, view_field.name));
// Names alone would let a group keep its key while changing what it
// holds, which is the drift a live viewer would see and a detail
// page would not.
std.debug.assert(detail_field.type == view_field.type);
}
}
}
@@ -157,11 +142,19 @@ test "a frame is one event line and one data line of JSON" {
.domain = "ads.example",
.client_ip = "192.0.2.10",
.qtype = 1,
.qclass = 1,
.rcode = 0,
.blocked = true,
.block_reason = "blocklist_domain",
.group_id = 1,
.group_name = "default",
.policy_action = .block,
.policy_reason = .blocklist_domain,
.matched = "ads.example",
.source_id = 3,
.source_name = "StevenBlack",
.route_kind = .blocked,
.response_time_us = 42,
.cache_hit = false,
.upstream = "https://dns.example/dns-query",
});
var buf: [1024]u8 = undefined;
@@ -172,10 +165,12 @@ test "a frame is one event line and one data line of JSON" {
try testing.expect(std.mem.startsWith(u8, frame, "event: query\ndata: {"));
try testing.expect(std.mem.endsWith(u8, frame, "}\n\n"));
try testing.expectEqual(@as(usize, 3), std.mem.count(u8, frame, "\n"));
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"ts\":1700000000"));
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"time\":1700000000"));
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"domain\":\"ads.example\""));
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"blocked\":true"));
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"block_reason\":\"blocklist_domain\""));
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"group\":{\"id\":1,\"name\":\"default\"}"));
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"reason\":\"blocklist_domain\""));
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"source_name\":\"StevenBlack\""));
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"kind\":\"blocked\""));
}
test "an unlogged field stays null and an empty string stays a string" {
@@ -191,6 +186,7 @@ test "an unlogged field stays null and an empty string stays a string" {
const frame = writer.buffered();
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"qtype\":null"));
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"cache_hit\":null"));
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"duration_us\":null"));
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"upstream\":\"\""));
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"id\":null"));
}
+148 -10
View File
@@ -1,4 +1,5 @@
//! `GET /api/queries` — the query log, newest first (ruling 11).
//! `GET /api/queries` — the query log, newest first (ruling 11) — and
//! `GET /api/queries/{id}`, one row of it fully explained.
//!
//! Keyset pagination rather than an offset: the table is append-only and the
//! UI reads the head of it, so `id < before` is one index seek no matter how
@@ -13,9 +14,11 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const coverage = @import("../coverage.zig");
const db = @import("../../storage/db.zig");
const http_util = @import("../http_util.zig");
const logger = @import("../../storage/logger.zig");
const provenance_view = @import("../provenance_view.zig");
const queries_repo = @import("../../storage/repositories/queries_repo.zig");
const server = @import("../server.zig");
@@ -41,6 +44,10 @@ pub const Page = struct {
queries: []const queries_repo.QueryRow,
/// The cursor for the next page, or null when this page is the last one.
next_before: ?i64,
/// Whether the log still covers the window the filter asked for. A client
/// that reads rows without reading this cannot tell an empty window from a
/// pruned one.
coverage: coverage.Coverage,
};
pub const FilterError = error{
@@ -110,6 +117,7 @@ pub fn page(
return .{
.queries = rows.items,
.next_before = if (full and rows.items.len != 0) rows.items[rows.items.len - 1].id else null,
.coverage = try coverage.read(database, filter.since),
};
}
@@ -138,10 +146,37 @@ pub fn list(
return http_util.respondJson(request, .ok, result, &.{});
}
/// `GET /api/queries/{id}` — one query, fully explained.
///
/// The row is the whole answer: every field is a fact recorded when the query
/// was answered, so nothing here is joined against current configuration. A
/// group or blocklist renamed since keeps the name it had.
pub fn detail(
state: *server.WebState,
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
_ = io;
const database = state.querylog_db orelse
return http_util.respondError(request, .service_unavailable, "query log unavailable");
const row = queries_repo.detailById(database, request.arena, request.id.?) catch |err| {
log.warn("query log read failed: {s}", .{@errorName(err)});
return http_util.respondError(request, .internal_server_error, "internal error");
};
// An id retention has pruned and one that never existed are the same
// answer, and the API does not pretend to tell them apart.
const found = row orelse return http_util.respondError(request, .not_found, "not found");
return http_util.respondJson(request, .ok, provenance_view.fromDetail(found), &.{});
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const provenance = @import("../../storage/provenance.zig");
const querylog_schema = @import("../../storage/querylog_schema.zig");
const testing = std.testing;
@@ -210,21 +245,40 @@ fn openLog() !db.Db {
return database;
}
/// Rows the resolver could actually have written. Ruling 20 ties the three
/// route facts together: a block never consulted the cache, so its `cache_hit`
/// is NULL rather than false; a cache hit has no upstream to name; and only an
/// upstream answer carries one. A fixture that broke those ties would let a
/// serializer regression pass here and fail on real rows.
fn seed(database: *db.Db, count: usize) !void {
var writer = try queries_repo.BatchWriter.init(database);
defer writer.deinit();
var rows: [16]queries_repo.Row = undefined;
for (rows[0..count], 0..) |*row, i| {
const blocked = i % 2 == 0;
const from_cache = i % 4 == 1;
row.* = .{
.timestamp = 1_700_000_000 + @as(i64, @intCast(i)),
.domain = if (i % 2 == 0) "ads.example" else "safe.example",
.domain = if (blocked) "ads.example" else "safe.example",
.client_ip = "192.0.2.10",
.qtype = 1,
.blocked = i % 2 == 0,
.block_reason = if (i % 2 == 0) "blocklist_domain" else null,
.qclass = 1,
.rcode = 0,
.blocked = blocked,
.response_time_us = 500,
.cache_hit = false,
.upstream = null,
.cache_hit = if (blocked) null else from_cache,
.upstream = if (blocked or from_cache) null else "9.9.9.9",
.group_id = 1,
.group_name = "default",
.policy_action = if (blocked) .block else .allow,
.policy_reason = if (blocked) .blocklist_domain else .no_match,
.matched = if (blocked) "ads.example" else null,
.source_id = null,
.source_name = null,
.cname_target = null,
.safe_search_target = null,
.route_kind = if (blocked) .blocked else if (from_cache) .cache else .upstream,
.forward_zone = null,
};
}
try writer.writeBatch(rows[0..count]);
@@ -297,6 +351,37 @@ test "the parsed filters narrow the rows the page returns" {
try testing.expectEqual(@as(usize, 0), nobody.queries.len);
}
test "the seeded rows carry only the route shapes ruling 20 allows" {
var database = try openLog();
defer database.close();
try seed(&database, 4);
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var seen: std.EnumSet(provenance.RouteKind) = .initEmpty();
for ((try page(&database, arena.allocator(), .{})).queries) |row| {
seen.insert(row.route_kind);
switch (row.route_kind) {
.blocked => {
try testing.expectEqual(@as(?bool, null), row.cache_hit);
try testing.expectEqualStrings("", row.upstream);
},
.cache => {
try testing.expectEqual(@as(?bool, true), row.cache_hit);
try testing.expectEqualStrings("", row.upstream);
},
.upstream => {
try testing.expectEqual(@as(?bool, false), row.cache_hit);
try testing.expectEqualStrings("9.9.9.9", row.upstream);
},
.local, .forward_zone, .rejected => return error.UnseededRouteKind,
}
}
// All three, so the serializer tests below read every shape the fixture claims.
try testing.expectEqual(@as(usize, 3), seen.count());
}
test "the page serializes as the envelope ruling 11 defines" {
var database = try openLog();
defer database.close();
@@ -312,14 +397,67 @@ test "the page serializes as the envelope ruling 11 defines" {
const text = allocating.written();
try testing.expect(std.mem.startsWith(u8, text, "{\"queries\":["));
try testing.expect(std.mem.endsWith(u8, text, "\"next_before\":null}"));
for ([_][]const u8{
"\"id\":", "\"ts\":", "\"domain\":", "\"client_ip\":",
"\"qtype\":", "\"blocked\":", "\"cache_hit\":", "\"upstream\":",
"\"upstream\":", "\"response_time_us\":", "\"block_reason\":",
"\"id\":", "\"ts\":", "\"domain\":", "\"client_ip\":",
"\"qtype\":", "\"qclass\":", "\"rcode\":", "\"blocked\":",
"\"cache_hit\":", "\"upstream\":", "\"response_time_us\":", "\"policy_action\":",
"\"policy_reason\":", "\"route_kind\":",
}) |field| {
try testing.expect(std.mem.containsAtLeast(u8, text, 1, field));
}
// W1's ruling: a NULL column reads as "", and "" stays "" on the wire.
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"upstream\":\"\""));
// The column the provenance columns replaced.
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "block_reason"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"coverage\":{\"complete\":"));
}
test "the coverage of a page answers the window the filter asked for" {
var database = try openLog();
defer database.close();
try seed(&database, 1);
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const watermark = try queries_repo.availableSince(&database);
const unbounded = try page(&database, arena.allocator(), .{});
try testing.expectEqual(watermark, unbounded.coverage.available_since);
try testing.expect(!unbounded.coverage.complete);
const covered = try page(&database, arena.allocator(), .{ .since = watermark });
try testing.expect(covered.coverage.complete);
const older = try page(&database, arena.allocator(), .{ .since = watermark - 1 });
try testing.expect(!older.coverage.complete);
}
test "a detail row carries every provenance field the row stored" {
var database = try openLog();
defer database.close();
try seed(&database, 1);
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const row = (try queries_repo.detailById(&database, arena, 1)).?;
const view = provenance_view.fromDetail(row);
try testing.expectEqual(@as(i64, 1), view.id);
try testing.expectEqualStrings("ads.example", view.request.domain);
try testing.expectEqualStrings("192.0.2.10", view.request.client);
try testing.expectEqual(@as(u16, 1), view.request.qclass);
try testing.expectEqualStrings("default", view.group.name);
try testing.expectEqual(provenance.PolicyAction.block, view.policy.action);
try testing.expectEqualStrings("ads.example", view.policy.matched);
try testing.expectEqual(provenance.RouteKind.blocked, view.route.kind);
try testing.expectEqualStrings("", view.route.upstream);
try testing.expectEqual(@as(?i64, 500), view.response.duration_us);
try testing.expectEqual(
@as(?queries_repo.QueryDetail, null),
try queries_repo.detailById(&database, arena, 99),
);
}
+27 -1
View File
@@ -14,6 +14,7 @@
const std = @import("std");
const coverage = @import("../coverage.zig");
const db = @import("../../storage/db.zig");
const http_util = @import("../http_util.zig");
const queries_repo = @import("../../storage/repositories/queries_repo.zig");
@@ -99,6 +100,10 @@ pub const TotalsBody = struct {
cached: u64,
clients: u64,
avg_response_time_us: ?i64,
/// Judged against `since`, which is the window this body reports on — so a
/// dashboard can say "history starts here" instead of charting a pruned
/// stretch as a quiet one.
coverage: coverage.Coverage,
};
pub const TimeseriesBody = struct {
@@ -107,6 +112,7 @@ pub const TimeseriesBody = struct {
until: i64,
bucket_seconds: u32,
buckets: []const queries_repo.Bucket,
coverage: coverage.Coverage,
};
pub fn totals(
@@ -121,6 +127,9 @@ pub fn totals(
const result = queries_repo.statsTotals(database, span.since, span.until) catch |err| {
return internal(request, "stats totals", err);
};
const covered = coverage.read(database, span.since) catch |err| {
return internal(request, "stats coverage", err);
};
return http_util.respondJson(request, .ok, TotalsBody{
.period = period.label(),
@@ -131,6 +140,7 @@ pub fn totals(
.cached = result.cached,
.clients = result.distinct_clients,
.avg_response_time_us = result.avg_response_time_us,
.coverage = covered,
}, &.{});
}
@@ -148,6 +158,9 @@ pub fn timeseries(
const written = queries_repo.timeseries(database, span.since, span.bucket_seconds, out) catch |err| {
return internal(request, "stats timeseries", err);
};
const covered = coverage.read(database, span.since) catch |err| {
return internal(request, "stats coverage", err);
};
return http_util.respondJson(request, .ok, TimeseriesBody{
.period = period.label(),
@@ -155,6 +168,7 @@ pub fn timeseries(
.until = span.until,
.bucket_seconds = span.bucket_seconds,
.buckets = out[0..written],
.coverage = covered,
}, &.{});
}
@@ -268,11 +282,23 @@ fn writeRow(writer: *queries_repo.BatchWriter, timestamp: i64, blocked: bool, ca
.domain = "example.com",
.client_ip = "192.0.2.10",
.qtype = 1,
.qclass = 1,
.rcode = 0,
.blocked = blocked,
.block_reason = if (blocked) "blocklist_domain" else null,
.response_time_us = 1000,
.cache_hit = cached,
.upstream = null,
.group_id = 1,
.group_name = "default",
.policy_action = if (blocked) .block else .allow,
.policy_reason = if (blocked) .blocklist_domain else .no_match,
.matched = null,
.source_id = null,
.source_name = null,
.cname_target = null,
.safe_search_target = null,
.route_kind = if (blocked) .blocked else .upstream,
.forward_zone = null,
}};
try writer.writeBatch(&rows);
}
+218 -10
View File
@@ -226,14 +226,47 @@ paths:
"503":
$ref: "#/components/responses/Unavailable"
/api/queries/{id}:
get:
summary: One query, fully explained
description: |
The full provenance of one logged query: what was asked, which group's
policy applied, what that policy decided and matched on, what was
rewritten, where the answer came from, and what the client received.
Every field is a fact recorded when the query was answered, so a group
or blocklist renamed since keeps the name it had.
parameters:
- name: id
in: path
required: true
schema: { type: integer, minimum: 1 }
responses:
"200":
description: The query.
content:
application/json:
schema:
$ref: "#/components/schemas/QueryDetail"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
"429":
$ref: "#/components/responses/RateLimited"
"500":
$ref: "#/components/responses/Internal"
"503":
$ref: "#/components/responses/Unavailable"
/api/queries/live:
get:
summary: Live query stream (server-sent events)
description: |
`text/event-stream`. The stream opens with `retry: 3000`, then sends
one `event: query` frame per resolved query whose `data:` line is a
JSON object with the `/api/queries` row fields minus `id` (the entry
precedes persistence). A `: ping` comment goes out every 15 seconds.
`Provenance` object the body of `/api/queries/{id}` without its `id`,
which does not exist yet because the entry precedes its own insert.
A `: ping` comment goes out every 15 seconds.
A client that falls more than 64 events behind is disconnected and
should re-sync via `/api/queries` after reconnecting. Connections
per address are capped by `web.sse_max_connections_per_ip`; the
@@ -1855,9 +1888,33 @@ components:
type: boolean
description: False when no password is configured; no cookie is set.
PolicyAction:
type: string
description: |
Whether the filtering policy reached a verdict. `not_evaluated` is the
honest answer for a query answered before filtering could apply, and is
not the same as `allow`.
enum: [not_evaluated, allow, block]
PolicyReason:
type: string
description: |
Why the policy landed where it did. The first nine are the matcher's own
verdicts; the rest name a pipeline step that decided without consulting
the matcher.
enum: [rule_allow_exact, rule_block_exact, rule_allow_wildcard, rule_block_wildcard, rule_allow_regex, rule_block_regex, blocklist_exception, blocklist_domain, blocklist_wildcard, local_record, forward_zone, non_in_class, paused, snapshot_unavailable, no_match, protocol_error]
RouteKind:
type: string
description: Where the answer the client received came from.
enum: [blocked, local, forward_zone, upstream, cache, rejected]
QueryRow:
type: object
required: [id, ts, domain, client_ip, qtype, blocked, block_reason, response_time_us, cache_hit, upstream]
description: |
The summary projection the query-log table scans. The full provenance of
a row is one request away at `/api/queries/{id}`.
required: [id, ts, domain, client_ip, qtype, qclass, rcode, blocked, response_time_us, cache_hit, upstream, policy_action, policy_reason, route_kind]
properties:
id: { type: integer }
ts:
@@ -1868,10 +1925,11 @@ components:
qtype:
type: integer
nullable: true
qclass: { type: integer }
rcode:
type: integer
description: The twelve-bit EDNS extended code, not the four header bits alone.
blocked: { type: boolean }
block_reason:
type: string
description: Empty when the query was not blocked.
response_time_us:
type: integer
nullable: true
@@ -1880,11 +1938,155 @@ components:
nullable: true
upstream:
type: string
description: Empty for cache hits and local answers.
description: Empty for cache hits, local answers and blocked queries.
policy_action:
$ref: "#/components/schemas/PolicyAction"
policy_reason:
$ref: "#/components/schemas/PolicyReason"
route_kind:
$ref: "#/components/schemas/RouteKind"
ProvenanceRequest:
type: object
required: [time, domain, client, qtype, qclass]
properties:
time:
type: integer
description: Unix seconds.
domain: { type: string }
client: { type: string }
qtype:
type: integer
nullable: true
qclass: { type: integer }
ProvenanceGroup:
type: object
description: |
The client's filtering group at the time of the query, as a historical
fact: the id may name a group since renamed or deleted.
required: [id, name]
properties:
id:
type: integer
nullable: true
name: { type: string }
ProvenancePolicy:
type: object
required: [action, reason, matched, source_id, source_name]
properties:
action:
$ref: "#/components/schemas/PolicyAction"
reason:
$ref: "#/components/schemas/PolicyReason"
matched:
type: string
description: The rule pattern or list entry that decided; empty when nothing matched.
source_id:
type: integer
nullable: true
source_name:
type: string
description: The blocklist the match came from; empty for a rule.
ProvenanceRewrites:
type: object
required: [cname_target, safe_search_target]
properties:
cname_target:
type: string
description: Set when the decision was made about a CNAME target rather than the queried name.
safe_search_target: { type: string }
ProvenanceRoute:
type: object
required: [kind, forward_zone, upstream]
properties:
kind:
$ref: "#/components/schemas/RouteKind"
forward_zone: { type: string }
upstream:
type: string
description: |
Non-empty only for an attempted upstream or forward-zone exchange,
including one that failed. Already redacted: the userinfo, path,
query and fragment of a resolver url never reach here.
ProvenanceResponse:
type: object
required: [rcode, duration_us]
properties:
rcode:
type: integer
description: The twelve-bit EDNS extended code, not the four header bits alone.
duration_us:
type: integer
nullable: true
Provenance:
type: object
description: |
One query, fully explained, in the order a query meets the pipeline. The
`data:` payload of a live-stream `event: query` frame is exactly this.
required: [request, group, policy, rewrites, route, response]
properties:
request:
$ref: "#/components/schemas/ProvenanceRequest"
group:
$ref: "#/components/schemas/ProvenanceGroup"
policy:
$ref: "#/components/schemas/ProvenancePolicy"
rewrites:
$ref: "#/components/schemas/ProvenanceRewrites"
route:
$ref: "#/components/schemas/ProvenanceRoute"
response:
$ref: "#/components/schemas/ProvenanceResponse"
QueryDetail:
type: object
description: |
`Provenance` plus the row id. Written out rather than composed with
`allOf` so the drift guard reads one property list per schema.
required: [id, request, group, policy, rewrites, route, response]
properties:
id: { type: integer }
request:
$ref: "#/components/schemas/ProvenanceRequest"
group:
$ref: "#/components/schemas/ProvenanceGroup"
policy:
$ref: "#/components/schemas/ProvenancePolicy"
rewrites:
$ref: "#/components/schemas/ProvenanceRewrites"
route:
$ref: "#/components/schemas/ProvenanceRoute"
response:
$ref: "#/components/schemas/ProvenanceResponse"
Coverage:
type: object
description: |
How much of the requested window the query log can still answer for.
Retention deletes rows and advances the watermark in one transaction, so
a client can tell an empty window from a pruned one instead of charting
the gap as zero.
required: [complete, available_since]
properties:
complete:
type: boolean
description: |
True only when the window's lower bound is at or after
`available_since`. A request with no lower bound asks about all of
history, which no file that has ever pruned can promise.
available_since:
type: integer
description: The oldest instant the file is complete for, unix seconds.
QueriesPage:
type: object
required: [queries, next_before]
required: [queries, next_before, coverage]
properties:
queries:
type: array
@@ -1894,6 +2096,8 @@ components:
type: integer
nullable: true
description: Cursor for the next page; null on the last page.
coverage:
$ref: "#/components/schemas/Coverage"
DiagnosticEvent:
type: object
@@ -1983,7 +2187,7 @@ components:
StatsTotals:
type: object
required: [period, since, until, queries, blocked, cached, clients, avg_response_time_us]
required: [period, since, until, queries, blocked, cached, clients, avg_response_time_us, coverage]
properties:
period:
type: string
@@ -2004,6 +2208,8 @@ components:
type: integer
nullable: true
description: Null when no query in the window recorded a time.
coverage:
$ref: "#/components/schemas/Coverage"
Bucket:
type: object
@@ -2018,7 +2224,7 @@ components:
StatsTimeseries:
type: object
required: [period, since, until, bucket_seconds, buckets]
required: [period, since, until, bucket_seconds, buckets, coverage]
properties:
period:
type: string
@@ -2030,6 +2236,8 @@ components:
type: array
items:
$ref: "#/components/schemas/Bucket"
coverage:
$ref: "#/components/schemas/Coverage"
Lookup:
type: object
+278
View File
@@ -0,0 +1,278 @@
//! The wire shape of one query's provenance, defined once.
//!
//! Two surfaces answer with it: `GET /api/queries/{id}`, which reads a stored
//! row, and the `event: query` frames of `GET /api/queries/live`, which read a
//! queued entry that has not been written yet. They must describe a query the
//! same way — an operator watching the stream and an operator opening the row
//! afterwards are looking at the same facts — so the live event *is* this DTO
//! and the detail body is this DTO plus the row id.
//!
//! It is nested rather than flat because the six groups answer six different
//! questions, in the order a query meets them: what was asked, which group's
//! policy applied, what that policy decided, what was rewritten on the way,
//! where the answer came from, and what the client got back.
//!
//! The list row (`queries_repo.QueryRow`) stays a separate, flatter summary.
//! A table the operator scans wants columns, not a tree, and the full story is
//! one request away.
//!
//! Every text field follows the repository's convention: a NULL column reads as
//! `""`, and `""` on the wire means "absent". No field is ever written as an
//! empty string that means something else.
const std = @import("std");
const logger = @import("../storage/logger.zig");
const provenance = @import("../storage/provenance.zig");
const queries_repo = @import("../storage/repositories/queries_repo.zig");
/// What the client asked. `time` is unix seconds; `qtype` is null for a
/// question whose type the log never recorded.
pub const Request = struct {
time: i64,
domain: []const u8,
client: []const u8,
qtype: ?u16,
qclass: u16,
};
/// The client's filtering group at the time of the query, as a historical fact:
/// the id may name a group that has since been renamed or deleted, which is why
/// the name is stored beside it rather than joined at read time.
pub const Group = struct {
id: ?i64,
name: []const u8,
};
/// What the policy decided and what it matched on. `matched` is the rule
/// pattern or list entry that decided; `source_id`/`source_name` name the
/// blocklist it came from, and are absent for a rule.
pub const Policy = struct {
action: provenance.PolicyAction,
reason: provenance.PolicyReason,
matched: []const u8,
source_id: ?i64,
source_name: []const u8,
};
/// The two rewrites that can happen between the question and the answer.
/// `cname_target` is set when the decision was made about a CNAME target rather
/// than the queried name.
pub const Rewrites = struct {
cname_target: []const u8,
safe_search_target: []const u8,
};
/// Where the answer came from. `upstream` is non-empty only for an attempted
/// upstream or forward-zone exchange, including one that failed, and is already
/// redacted — the path, query and userinfo of a resolver url never reach here.
pub const Route = struct {
kind: provenance.RouteKind,
forward_zone: []const u8,
upstream: []const u8,
};
/// What the client saw. `rcode` is the twelve-bit EDNS extended code, not the
/// four header bits alone.
pub const Response = struct {
rcode: u16,
duration_us: ?i64,
};
/// One query, fully explained. The live stream's `data:` payload is exactly
/// this.
pub const Provenance = struct {
request: Request,
group: Group,
policy: Policy,
rewrites: Rewrites,
route: Route,
response: Response,
};
/// `GET /api/queries/{id}`: the same six groups, plus the id the caller asked
/// for. Spelled out rather than composed, because a JSON object is flat at its
/// top level and Zig has no field-splicing; the `comptime` block below is what
/// keeps the two from drifting.
pub const QueryDetail = struct {
id: i64,
request: Request,
group: Group,
policy: Policy,
rewrites: Rewrites,
route: Route,
response: Response,
};
comptime {
const detail = @typeInfo(QueryDetail).@"struct".fields;
const shared = @typeInfo(Provenance).@"struct".fields;
std.debug.assert(detail.len == shared.len + 1);
std.debug.assert(std.mem.eql(u8, detail[0].name, "id"));
for (detail[1..], shared) |a, b| {
std.debug.assert(std.mem.eql(u8, a.name, b.name));
std.debug.assert(a.type == b.type);
}
}
/// A stored row, as the detail endpoint answers it. Borrows `row`'s strings,
/// which the caller's arena owns.
pub fn fromDetail(row: queries_repo.QueryDetail) QueryDetail {
return .{
.id = row.id,
.request = .{
.time = row.ts,
.domain = row.domain,
.client = row.client_ip,
.qtype = row.qtype,
.qclass = row.qclass,
},
.group = .{ .id = row.group_id, .name = row.group_name },
.policy = .{
.action = row.policy_action,
.reason = row.policy_reason,
.matched = row.matched,
.source_id = row.source_id,
.source_name = row.source_name,
},
.rewrites = .{
.cname_target = row.cname_target,
.safe_search_target = row.safe_search_target,
},
.route = .{
.kind = row.route_kind,
.forward_zone = row.forward_zone,
.upstream = row.upstream,
},
.response = .{ .rcode = row.rcode, .duration_us = row.response_time_us },
};
}
/// A queued entry, as the live stream sends it. Borrows the entry's buffers, so
/// the result must not outlive the entry it was taken from — in the stream both
/// live in one loop iteration.
///
/// There is no id: the entry precedes its own insert, so no row id exists yet.
pub fn fromEntry(entry: *const logger.Entry) Provenance {
return .{
.request = .{
.time = entry.timestamp,
.domain = entry.domain(),
.client = entry.clientIp(),
.qtype = entry.qtype,
.qclass = entry.qclass,
},
.group = .{ .id = entry.group_id, .name = entry.groupName() },
.policy = .{
.action = entry.policy_action,
.reason = entry.policy_reason,
.matched = entry.matched(),
.source_id = entry.source_id,
.source_name = entry.sourceName(),
},
.rewrites = .{
.cname_target = entry.cnameTarget(),
.safe_search_target = entry.safeSearchTarget(),
},
.route = .{
.kind = entry.route_kind,
.forward_zone = entry.forwardZone(),
.upstream = entry.upstream(),
},
.response = .{ .rcode = entry.rcode, .duration_us = entry.response_time_us },
};
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
test "a stored row and a queued entry describe the same query identically" {
const row: queries_repo.QueryDetail = .{
.id = 7,
.ts = 1_700_000_000,
.domain = "ads.example",
.client_ip = "192.0.2.10",
.qtype = 1,
.qclass = 1,
.rcode = 3,
.blocked = true,
.response_time_us = 1234,
.cache_hit = false,
.upstream = "https://dns.example",
.group_id = 2,
.group_name = "kids",
.policy_action = .block,
.policy_reason = .blocklist_domain,
.matched = "tracker.example",
.source_id = 5,
.source_name = "StevenBlack",
.cname_target = "tracker.example",
.safe_search_target = "forcesafesearch.example",
.route_kind = .blocked,
.forward_zone = "lan",
};
const entry: logger.Entry = .init(.{
.timestamp = row.ts,
.domain = row.domain,
.client_ip = row.client_ip,
.qtype = row.qtype,
.qclass = row.qclass,
.rcode = row.rcode,
.blocked = row.blocked,
.response_time_us = row.response_time_us,
.cache_hit = row.cache_hit,
.upstream = row.upstream,
.group_id = row.group_id,
.group_name = row.group_name,
.policy_action = row.policy_action,
.policy_reason = row.policy_reason,
.matched = row.matched,
.source_id = row.source_id,
.source_name = row.source_name,
.cname_target = row.cname_target,
.safe_search_target = row.safe_search_target,
.route_kind = row.route_kind,
.forward_zone = row.forward_zone,
});
const from_row = fromDetail(row);
const from_entry = fromEntry(&entry);
try testing.expectEqual(@as(i64, 7), from_row.id);
inline for (@typeInfo(Provenance).@"struct".fields) |field| {
const a = @field(from_row, field.name);
const b = @field(from_entry, field.name);
inline for (@typeInfo(field.type).@"struct".fields) |inner| {
const left = @field(a, inner.name);
const right = @field(b, inner.name);
if (@TypeOf(left) == []const u8) {
try testing.expectEqualStrings(left, right);
} else {
try testing.expectEqual(left, right);
}
}
}
}
test "an unexplained query serializes as nulls and empty strings, not as absent keys" {
const entry: logger.Entry = .init(.{
.timestamp = 1,
.domain = "safe.example",
.client_ip = "192.0.2.11",
});
var allocating: std.Io.Writer.Allocating = .init(testing.allocator);
defer allocating.deinit();
try std.json.Stringify.value(fromEntry(&entry), .{}, &allocating.writer);
const text = allocating.written();
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"qtype\":null"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"duration_us\":null"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"id\":null"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"upstream\":\"\""));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"action\":\"not_evaluated\""));
}
+5 -1
View File
@@ -67,6 +67,10 @@ pub const table: []const router.RouteInfo = &.{
// Query log, stats, live stream, lookup.
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .handler = queries.list },
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .handler = live.stream, .rate_limit = .exempt },
// Listed after the literal `live`, which a linear first-match scan reaches
// first — though `{id}` would refuse it anyway, since it captures a
// positive integer and nothing else.
.{ .method = .GET, .pattern = "/api/queries/{id}", .auth = .session, .policy = .read, .handler = queries.detail },
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .handler = stats.totals },
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .handler = stats.timeseries },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .handler = lookup.handle },
@@ -152,7 +156,7 @@ const std = @import("std");
const testing = std.testing;
test "the table carries every endpoint of the milestone" {
try testing.expectEqual(@as(usize, 60), table.len);
try testing.expectEqual(@as(usize, 61), table.len);
}
test "no two entries claim the same method and pattern" {
+8
View File
@@ -25,6 +25,14 @@ pub const max_subscribers = 32;
/// Entries one subscriber may fall behind by. At household query rates this is
/// several seconds of slack on a stalled TCP connection.
///
/// The ring is embedded in the slot, so this multiplies `@sizeOf(logger.Entry)`
/// — about 1.8 KiB once milestone 28 widened it for provenance. One subscriber
/// therefore costs roughly 115 KiB of ring and the whole hub roughly 3.6 MiB,
/// allocated once for the life of the process. That is the reason to keep both
/// this and `max_subscribers` small: they are paid whether or not anyone is
/// watching. `logger.query_log_buffer_max` bounds the other consumer of the
/// same width, the writer queue.
pub const ring_capacity = 64;
pub const SubscriberId = enum(u8) { _ };
+709 -9
View File
@@ -38,15 +38,20 @@ const header = @import("../dns/header.zig");
const http_util = @import("http_util.zig");
const local_repo = @import("../storage/repositories/local_repo.zig");
const local_tables_mod = @import("../server/local_tables.zig");
const logger_mod = @import("../storage/logger.zig");
const manager_mod = @import("../filter/manager.zig");
const migrations = @import("../storage/migrations.zig");
const name = @import("../dns/name.zig");
const openapi = @import("openapi.zig");
const packet = @import("../dns/packet.zig");
const pause_mod = @import("../server/pause.zig");
const coverage_mod = @import("coverage.zig");
const pool_mod = @import("../upstream/pool.zig");
const provenance = @import("../storage/provenance.zig");
const provenance_view = @import("provenance_view.zig");
const queries_repo = @import("../storage/repositories/queries_repo.zig");
const querylog_schema = @import("../storage/querylog_schema.zig");
const query_sink = @import("../server/query_sink.zig");
const question = @import("../dns/question.zig");
const router = @import("router.zig");
const server = @import("server.zig");
@@ -257,7 +262,10 @@ fn contentLength(head: []const u8) ?usize {
// the environment: the real web stack over in-memory databases
// ---------------------------------------------------------------------------
const seeded_query_rows = 25;
/// The rows the uniform loop writes, before the two provenance-rich ones the
/// detail endpoint and the credential sweep read.
const seeded_plain_query_rows = 25;
const seeded_query_rows = seeded_plain_query_rows + 2;
const EnvOptions = struct {
password_hash: []const u8 = "",
@@ -270,6 +278,10 @@ const EnvOptions = struct {
/// wants; the file-authority tests below name a path.
authority: server.Authority = .database,
reconciled_at: ?i64 = null,
/// False detaches the query log from the web state, which is the box a
/// `logging.query_log = false` operator runs. Every query-log route then
/// answers 503 rather than an empty page, which would be a lie.
querylog: bool = true,
};
/// Heap-allocated because `state` and the listener hold pointers into it.
@@ -400,7 +412,7 @@ const Env = struct {
.limiter = &self.limiter,
.hub = self.hub,
.config_db = &self.config_db,
.querylog_db = &self.querylog_db,
.querylog_db = if (options.querylog) &self.querylog_db else null,
.events = &self.events_store,
.version = "w10-test",
.started_unix = std.Io.Clock.real.now(ioh).toSeconds(),
@@ -472,27 +484,108 @@ fn seedConfig(database: *db.Db) !void {
);
}
/// The oldest instant the seeded log is complete for. Pinned rather than taken
/// from `unixepoch()`, which the schema's own seed uses: the contract samples
/// are byte-compared, so a clock in `coverage.available_since` would make the
/// golden a property of the machine that generated it.
///
/// It equals the oldest seeded row's timestamp, so a request bounded at exactly
/// this instant is complete and one bounded a second earlier is not.
const seeded_available_since: i64 = 1_700_000_000;
fn seedQueryLog(database: *db.Db) !void {
try database.exec(
\\UPDATE querylog_meta SET created_at = 1700000000, available_since = 1700000000 WHERE id = 1
);
var writer = try queries_repo.BatchWriter.init(database);
defer writer.deinit();
var domain_buf: [32]u8 = undefined;
var index: usize = 0;
while (index < seeded_query_rows) : (index += 1) {
while (index < seeded_plain_query_rows) : (index += 1) {
const domain = std.fmt.bufPrint(&domain_buf, "d{d}.example", .{index}) catch unreachable;
const blocked = index % 5 == 0;
// The three states the handler can actually produce (`Context.cacheHit`
// and `route_kind` are set together): a blocked answer consulted no
// cache and named no resolver, a cache hit named no resolver, and only
// an upstream exchange did both.
const from_cache = !blocked and index % 2 == 0;
try writer.writeBatch(&.{.{
.timestamp = 1_700_000_000 + @as(i64, @intCast(index)),
.domain = domain,
.client_ip = "192.0.2.10",
.qtype = 1,
.qclass = 1,
.rcode = 0,
.blocked = blocked,
.block_reason = if (blocked) "blocklist_domain" else null,
.response_time_us = 250,
.cache_hit = if (blocked) null else (index % 2 == 0),
.upstream = if (blocked) null else "https://dns.example/dns-query",
.cache_hit = if (blocked) null else from_cache,
.upstream = if (blocked or from_cache) null else "https://dns.example/dns-query",
.group_id = 1,
.group_name = "default",
.policy_action = if (blocked) .block else .allow,
.policy_reason = if (blocked) .blocklist_domain else .no_match,
.matched = if (blocked) domain else null,
.source_id = null,
.source_name = null,
.cname_target = null,
.safe_search_target = null,
.route_kind = if (blocked) .blocked else if (from_cache) .cache else .upstream,
.forward_zone = null,
}});
}
// Two rows with provenance the loop above never produces, so the detail
// endpoint and its contract sample have a real row to read. They are the
// newest rows, so a first page shows them.
try writer.writeBatch(&.{.{
.timestamp = 1_700_000_000 + seeded_plain_query_rows,
.domain = "news.example",
.client_ip = "192.0.2.10",
.qtype = 1,
.qclass = 1,
.rcode = 0,
.blocked = false,
.response_time_us = 18_400,
.cache_hit = false,
.upstream = "https://dns.example/dns-query",
.group_id = 1,
.group_name = "default",
.policy_action = .allow,
.policy_reason = .no_match,
.matched = null,
.source_id = null,
.source_name = null,
.cname_target = null,
.safe_search_target = null,
.route_kind = .upstream,
.forward_zone = null,
}});
try writer.writeBatch(&.{.{
.timestamp = 1_700_000_000 + seeded_plain_query_rows + 1,
.domain = "shop.example",
.client_ip = "192.0.2.11",
.qtype = 1,
.qclass = 1,
.rcode = 3,
.blocked = true,
.response_time_us = 900,
.cache_hit = null,
.upstream = null,
.group_id = 2,
.group_name = "kids",
.policy_action = .block,
.policy_reason = .blocklist_wildcard,
.matched = "||tracker.example^",
.source_id = 4,
.source_name = "StevenBlack",
.cname_target = "cdn.tracker.example",
.safe_search_target = null,
.route_kind = .blocked,
.forward_zone = null,
}});
}
/// A fixed instant, like every other seeded timestamp here: the contract
@@ -653,6 +746,7 @@ const contract = [_]Contract{
// Query log, stats, live stream, upstream health.
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .target = "/api/queries?limit=10", .status = 200, .check = jsonShape(handlers_queries.Page) },
.{ .method = .GET, .pattern = "/api/queries/{id}", .auth = .session, .policy = .read, .target = "/api/queries/27", .status = 200, .check = jsonShape(provenance_view.QueryDetail) },
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .rate_limit = .exempt, .target = "/api/queries/live", .status = 200, .kind = .sse },
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .target = "/api/stats?period=1h", .status = 200, .check = jsonShape(handlers_stats.TotalsBody) },
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) },
@@ -1653,12 +1747,15 @@ fn sseStream(io: std.Io, env: *Env) anyerror!void {
.domain = "live.example",
.client_ip = "192.0.2.99",
.qtype = 1,
.qclass = 1,
.blocked = true,
.block_reason = "blocklist_domain",
.policy_action = .block,
.policy_reason = .blocklist_domain,
.route_kind = .blocked,
}));
try conn.readChunkedUntil(&seen, env.gpa, "event: query");
try conn.readChunkedUntil(&seen, env.gpa, "\"domain\":\"live.example\"");
try conn.readChunkedUntil(&seen, env.gpa, "\"blocked\":true");
try conn.readChunkedUntil(&seen, env.gpa, "\"reason\":\"blocklist_domain\"");
// The cap is per address and the environment allows one stream: a second
// subscriber from the same address is refused while the first is open.
@@ -1784,7 +1881,7 @@ fn paginationWalk(io: std.Io, env: *Env) anyerror!void {
try testing.expect(pages < 10);
}
// 25 seeded rows walk as 10, 10 and 5, with the cursor ending exactly
// 27 seeded rows walk as 10, 10 and 7, with the cursor ending exactly
// after the third page.
try testing.expectEqual(@as(usize, seeded_query_rows), total);
try testing.expectEqual(@as(usize, 3), pages);
@@ -1800,6 +1897,333 @@ test "W10 keyset pagination walks the seeded log exactly once, newest first" {
try bounded(env.io(), default_budget, paginationWalk, .{ env.io(), env });
}
// ---------------------------------------------------------------------------
// query provenance: the detail endpoint, coverage, and the credential sweep
// (milestone 28)
// ---------------------------------------------------------------------------
/// The id of the seeded CNAME-uncloaked block, which is the last row written.
const seeded_detail_id = seeded_query_rows;
fn detailWalk(io: std.Io, env: *Env) anyerror!void {
var arena_state: std.heap.ArenaAllocator = .init(env.gpa);
defer arena_state.deinit();
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
var body_buf: [64 * 1024]u8 = undefined;
var target_buf: [64]u8 = undefined;
const target = try std.fmt.bufPrint(&target_buf, "/api/queries/{d}", .{seeded_detail_id});
try conn.request("GET", target, null, null);
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
const detail = try std.json.parseFromSliceLeaky(
provenance_view.QueryDetail,
arena_state.allocator(),
response.body,
.{ .ignore_unknown_fields = false },
);
try testing.expectEqual(@as(i64, seeded_detail_id), detail.id);
try testing.expectEqualStrings("shop.example", detail.request.domain);
try testing.expectEqualStrings("192.0.2.11", detail.request.client);
try testing.expectEqual(@as(u16, 1), detail.request.qclass);
try testing.expectEqual(@as(?i64, 2), detail.group.id);
try testing.expectEqualStrings("kids", detail.group.name);
try testing.expectEqual(provenance.PolicyAction.block, detail.policy.action);
try testing.expectEqual(provenance.PolicyReason.blocklist_wildcard, detail.policy.reason);
try testing.expectEqualStrings("||tracker.example^", detail.policy.matched);
try testing.expectEqual(@as(?i64, 4), detail.policy.source_id);
try testing.expectEqualStrings("StevenBlack", detail.policy.source_name);
try testing.expectEqualStrings("cdn.tracker.example", detail.rewrites.cname_target);
try testing.expectEqualStrings("", detail.rewrites.safe_search_target);
try testing.expectEqual(provenance.RouteKind.blocked, detail.route.kind);
// A blocked query attempted no exchange, so it names no resolver.
try testing.expectEqualStrings("", detail.route.upstream);
try testing.expectEqual(@as(u16, 3), detail.response.rcode);
try testing.expectEqual(@as(?i64, 900), detail.response.duration_us);
// An id past the end of the log and an id retention would have pruned are
// the same answer.
const missing = try std.fmt.bufPrint(&target_buf, "/api/queries/{d}", .{seeded_query_rows + 1000});
try conn.request("GET", missing, null, null);
const not_found = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 404), not_found.status);
try testing.expect(std.mem.containsAtLeast(u8, not_found.body, 1, "\"error\""));
// A non-positive id never reaches SQL: the pattern captures a positive
// integer or does not match, so this is a routing 404.
try conn.request("GET", "/api/queries/0", null, null);
try testing.expectEqual(@as(u16, 404), (try conn.receive(&body_buf)).status);
}
test "W10 milestone 28: the detail endpoint answers one row and 404s the rest" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{});
defer env.destroy();
try bounded(env.io(), default_budget, detailWalk, .{ env.io(), env });
}
fn detailUnavailable(io: std.Io, env: *Env) anyerror!void {
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
var body_buf: [8 * 1024]u8 = undefined;
for ([_][]const u8{ "/api/queries/1", "/api/queries?limit=1", "/api/stats", "/api/stats/timeseries" }) |target| {
try conn.request("GET", target, null, null);
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 503), response.status);
try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "query log unavailable"));
}
}
test "W10 milestone 28: a box with no query log answers 503, not an empty page" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{ .querylog = false });
defer env.destroy();
try bounded(env.io(), default_budget, detailUnavailable, .{ env.io(), env });
}
fn coverageWalk(io: std.Io, env: *Env) anyerror!void {
var arena_state: std.heap.ArenaAllocator = .init(env.gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
var body_buf: [64 * 1024]u8 = undefined;
var target_buf: [64]u8 = undefined;
// No lower bound: the request asks about all of history, which a file that
// may have pruned cannot promise.
try conn.request("GET", "/api/queries?limit=1", null, null);
const unbounded = try std.json.parseFromSliceLeaky(
handlers_queries.Page,
arena,
(try conn.receive(&body_buf)).body,
.{ .ignore_unknown_fields = false },
);
try testing.expectEqual(seeded_available_since, unbounded.coverage.available_since);
try testing.expect(!unbounded.coverage.complete);
// Bounded exactly at the watermark.
const at = try std.fmt.bufPrint(&target_buf, "/api/queries?limit=1&since={d}", .{seeded_available_since});
try conn.request("GET", at, null, null);
const covered = try std.json.parseFromSliceLeaky(
handlers_queries.Page,
arena,
(try conn.receive(&body_buf)).body,
.{ .ignore_unknown_fields = false },
);
try testing.expect(covered.coverage.complete);
// One second earlier, and the window reaches past what the file holds.
const before = try std.fmt.bufPrint(&target_buf, "/api/queries?limit=1&since={d}", .{seeded_available_since - 1});
try conn.request("GET", before, null, null);
const partial = try std.json.parseFromSliceLeaky(
handlers_queries.Page,
arena,
(try conn.receive(&body_buf)).body,
.{ .ignore_unknown_fields = false },
);
try testing.expect(!partial.coverage.complete);
// The stats endpoints judge the same watermark against their own aligned
// window, which for any live period starts well after the seeded rows.
try conn.request("GET", "/api/stats?period=1h", null, null);
const totals = try std.json.parseFromSliceLeaky(
handlers_stats.TotalsBody,
arena,
(try conn.receive(&body_buf)).body,
.{ .ignore_unknown_fields = false },
);
try testing.expectEqual(seeded_available_since, totals.coverage.available_since);
try testing.expectEqual(totals.since >= seeded_available_since, totals.coverage.complete);
try conn.request("GET", "/api/stats/timeseries?period=1h", null, null);
const series = try std.json.parseFromSliceLeaky(
handlers_stats.TimeseriesBody,
arena,
(try conn.receive(&body_buf)).body,
.{ .ignore_unknown_fields = false },
);
try testing.expectEqual(totals.since, series.since);
try testing.expectEqual(totals.coverage.complete, series.coverage.complete);
}
test "W10 milestone 28: every window-bounded endpoint reports its own coverage" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{});
defer env.destroy();
try bounded(env.io(), default_budget, coverageWalk, .{ env.io(), env });
}
/// NextDNS's shape: the account id rides in the path, which is exactly where a
/// credential lives in a url an operator may legitimately configure.
/// `Endpoint.parse` refuses userinfo, so the path is the shape a real
/// configuration can carry a secret in — and the path is what
/// `safe_url.redact` drops.
const sweep_token = "b1c2d3";
const sweep_upstream_url = "https://dns.nextdns.io/" ++ sweep_token;
/// What every surface must show instead. The origin survives redaction — an
/// operator reading a failure has to know where the query went — so each
/// surface is checked for it too: one that showed nothing at all would pass a
/// secret check by saying nothing.
const sweep_redacted_upstream = "https://dns.nextdns.io";
/// The name the swept query asks for, so each surface can be pinned to the row
/// this test produced rather than to a seeded one.
const sweep_domain = "creds.example";
/// Names the resolver and never its token.
fn expectRedacted(text: []const u8) !void {
try testing.expect(std.mem.containsAtLeast(u8, text, 1, sweep_redacted_upstream));
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, sweep_token));
}
/// The cross-surface credential sweep, driven end to end: a real `Handler`
/// answers a real query through a resolver whose url carries a token, and the
/// entry travels the production path — `QuerySink`, then the hub and the
/// logger, then the query log the API reads. Nothing here redacts anything, so
/// a handler that stopped redacting fails this test.
///
/// Four surfaces read the same query back: the stored row, straight out of
/// SQLite, and the three the operator's browser sees — the live frame, the list
/// page and the detail body. A leak on any one of them is a secret in a browser
/// history, and the four are separate code paths to the same text.
fn credentialSweep(
io: std.Io,
env: *Env,
query_logger: *logger_mod.Logger,
handler: *dns_handler.Handler,
) anyerror!void {
var arena_state: std.heap.ArenaAllocator = .init(env.gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();
// Subscribed before the query runs: the hub publishes to whoever is
// listening at that moment and keeps nothing for a later reader.
var seen: std.ArrayList(u8) = .empty;
defer seen.deinit(env.gpa);
var stream: Conn = undefined;
try openLiveStream(io, env, &stream, &seen);
defer stream.close(io);
var query_buf: [512]u8 = undefined;
var response_buf: [512]u8 = undefined;
var scratch: dns_handler.Scratch = undefined;
const from = address.NetAddress.fromIp(.{ .ip4 = .loopback(53100) });
const query = queryFor(&query_buf, 0x4444, sweep_domain, .a);
try testing.expect(handler.handle(io, .udp, from, query, &response_buf, &scratch) == .reply);
// The live frame. Waiting on the redacted origin rather than on the whole
// frame is safe in both directions: a leaked url starts with it.
try stream.readChunkedUntil(&seen, env.gpa, sweep_redacted_upstream);
try testing.expect(std.mem.containsAtLeast(u8, seen.items, 1, sweep_domain));
try expectRedacted(seen.items);
// The stored row. The producer has already run, so closing the queue and
// running the writer inline drains it in one call: `runWriter` returns when
// a closed queue is empty, and a zero flush interval makes it commit the
// batch it holds rather than wait for company.
query_logger.shutdown(io);
try query_logger.runWriter(io, &env.querylog_db, null);
try testing.expectEqual(@as(u64, 1), query_logger.rows_written.load(.monotonic));
var stmt = try env.querylog_db.prepare(
\\SELECT query_log.id, query_log.upstream
\\FROM query_log JOIN domains ON domains.id = query_log.domain_id
\\WHERE domains.domain = ?
);
defer stmt.deinit();
try stmt.bindText(1, sweep_domain);
try testing.expect(try stmt.step());
const row_id = stmt.columnInt(0);
try testing.expectEqualStrings(sweep_redacted_upstream, stmt.columnText(1));
try testing.expect(!try stmt.step());
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
var body_buf: [64 * 1024]u8 = undefined;
var target_buf: [64]u8 = undefined;
// The list row. The swept query is the newest in the log, so a page of one
// is it.
try conn.request("GET", "/api/queries?limit=1", null, null);
const rows = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), rows.status);
const page = try std.json.parseFromSliceLeaky(
handlers_queries.Page,
arena,
rows.body,
.{ .ignore_unknown_fields = false },
);
try testing.expectEqual(@as(usize, 1), page.queries.len);
try testing.expectEqualStrings(sweep_domain, page.queries[0].domain);
try testing.expectEqualStrings(sweep_redacted_upstream, page.queries[0].upstream);
try expectRedacted(rows.body);
// The detail body, read by the row id the database just handed over.
const one = try std.fmt.bufPrint(&target_buf, "/api/queries/{d}", .{row_id});
try conn.request("GET", one, null, null);
const detail_response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), detail_response.status);
const detail = try std.json.parseFromSliceLeaky(
provenance_view.QueryDetail,
arena,
detail_response.body,
.{ .ignore_unknown_fields = false },
);
try testing.expectEqualStrings(sweep_domain, detail.request.domain);
try testing.expectEqualStrings(sweep_redacted_upstream, detail.route.upstream);
try expectRedacted(detail_response.body);
}
test "W10 milestone 28: no query surface echoes a resolver credential" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{});
defer env.destroy();
const io = env.io();
var queue_buf: [4]logger_mod.Entry = undefined;
// Zero flush interval: the drain below is synchronous, and nothing else
// will ever put an entry on this queue for the writer to wait for.
var query_logger: logger_mod.Logger = .init(.{ .query_log_flush_interval_s = 0 }, &queue_buf);
var sink: query_sink.QuerySink = .init(&query_logger, env.hub);
var fake: FakeUpstream = .{ .identity = sweep_upstream_url };
var handler: dns_handler.Handler = .{
.upstream = fake.client(),
.blocking = .{ .mode = .zero, .ttl = 5 },
.forward_read_timeout = .{ .raw = .fromSeconds(2), .clock = .awake },
.manager = &env.mgr,
.pause = &env.pauser,
.sink = &sink,
};
try bounded(io, default_budget, credentialSweep, .{ io, env, &query_logger, &handler });
try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic));
}
// ---------------------------------------------------------------------------
// mutation → reload observed (ruling 12)
// ---------------------------------------------------------------------------
@@ -1980,15 +2404,21 @@ fn queryFor(buf: []u8, id: u16, domain: []const u8, qtype: types.Type) []const u
/// can tell whether the filter let the query through.
const FakeUpstream = struct {
calls: std.atomic.Value(u64) = .init(0),
/// The resolver the handler reports as having answered. Operator-supplied
/// text in production, so the credential sweep points it at a url with a
/// token in its path.
identity: []const u8 = "fake://web-upstream",
fn exchangeFn(
ptr: *anyopaque,
io: std.Io,
query: []const u8,
response_buf: []u8,
selected: *?[]const u8,
) transport.ExchangeError![]u8 {
_ = io;
const self: *FakeUpstream = @ptrCast(@alignCast(ptr));
selected.* = self.identity;
_ = self.calls.fetchAdd(1, .monotonic);
const request = packet.parse(query) catch return error.BadResponse;
@@ -2348,6 +2778,272 @@ test "drift guard a bites: methods swapped between two documented paths fail the
try testing.expectEqual(@as(usize, 2), swapped_routes);
}
// ---------------------------------------------------------------------------
// focused schema drift guards (milestone 28)
// ---------------------------------------------------------------------------
//
// Guard a proves every served route is documented and guard b counts the
// operations, and neither looks inside a schema. A field renamed, retyped, made
// nullable or dropped from `required` passes both while breaking every client
// that reads the document — and the query-log provenance shapes are exactly
// where a rename is easy and a wrong `nullable` is silent.
//
// So these read the schema back and hold it to the Zig struct that produces it:
// the same property names, the same types, the same nullability, the same
// requiredness, and no extra property on either side. A `$ref` recurses, so
// checking `QueryDetail` checks all six of its nested objects.
//
// The YAML reader below understands only the shape this document is written in
// — two-space indentation, schemas at four, properties at eight, inline `{ ... }`
// or an indented block, and single-line flow sequences. It is not a YAML parser
// and must not become one; a document it cannot read is a document that stopped
// matching the house style.
/// One schema's body: everything from its key line to the next schema key.
fn yamlSchema(schema_name: []const u8) ?[]const u8 {
var key_buf: [64]u8 = undefined;
const key = std.fmt.bufPrint(&key_buf, "\n {s}:\n", .{schema_name}) catch return null;
const at = std.mem.indexOf(u8, openapi.yaml, key) orelse return null;
const body = openapi.yaml[at + key.len ..];
var end: usize = 0;
var lines = std.mem.splitScalar(u8, body, '\n');
while (lines.next()) |line| {
if (line.len != 0 and !std.mem.startsWith(u8, line, " ")) break;
end += line.len + 1;
}
return body[0..@min(end, body.len)];
}
/// One property's definition: the rest of its line for the inline form, or the
/// indented block that follows it.
fn yamlProperty(schema: []const u8, property_name: []const u8) ?[]const u8 {
const properties_at = std.mem.indexOf(u8, schema, "\n properties:\n") orelse return null;
const properties = schema[properties_at..];
var key_buf: [64]u8 = undefined;
const key = std.fmt.bufPrint(&key_buf, "\n {s}:", .{property_name}) catch return null;
const at = std.mem.indexOf(u8, properties, key) orelse return null;
const rest = properties[at + key.len ..];
const line_end = std.mem.indexOfScalar(u8, rest, '\n') orelse rest.len;
if (std.mem.trim(u8, rest[0..line_end], " ").len != 0) return rest[0..line_end];
var end: usize = line_end + 1;
var lines = std.mem.splitScalar(u8, rest[line_end + 1 ..], '\n');
while (lines.next()) |line| {
if (line.len != 0 and !std.mem.startsWith(u8, line, " ")) break;
end += line.len + 1;
}
return rest[0..@min(end, rest.len)];
}
/// The comma-separated items of a single-line flow sequence, `key: [a, b, c]`.
fn yamlFlowSeq(schema: []const u8, key: []const u8, out: *std.ArrayList([]const u8), gpa: Allocator) !void {
var key_buf: [32]u8 = undefined;
const needle = try std.fmt.bufPrint(&key_buf, "\n {s}: [", .{key});
const at = std.mem.indexOf(u8, schema, needle) orelse return error.TestUnexpectedResult;
const rest = schema[at + needle.len ..];
const close = std.mem.indexOfScalar(u8, rest, ']') orelse return error.TestUnexpectedResult;
var items = std.mem.splitScalar(u8, rest[0..close], ',');
while (items.next()) |item| try out.append(gpa, std.mem.trim(u8, item, " "));
}
/// The property names the schema declares, in document order.
fn yamlPropertyNames(schema: []const u8, out: *std.ArrayList([]const u8), gpa: Allocator) !void {
const properties_at = std.mem.indexOf(u8, schema, "\n properties:\n") orelse
return error.TestUnexpectedResult;
var lines = std.mem.splitScalar(u8, schema[properties_at + 1 ..], '\n');
_ = lines.next();
while (lines.next()) |line| {
if (line.len != 0 and !std.mem.startsWith(u8, line, " ")) break;
if (!std.mem.startsWith(u8, line, " ") or std.mem.startsWith(u8, line, " ")) continue;
const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue;
try out.append(gpa, line[8..colon]);
}
}
/// The OpenAPI `type` a Zig field must be documented as, or `null` when the
/// field is a nested object and must be a `$ref` instead.
fn documentedType(comptime T: type) ?[]const u8 {
const Payload = switch (@typeInfo(T)) {
.optional => |o| o.child,
else => T,
};
return switch (@typeInfo(Payload)) {
.int => "integer",
.bool => "boolean",
// A closed enum is a string on the wire, documented as its own schema.
.@"enum" => null,
.pointer => "string",
.@"struct" => null,
else => @compileError("no documented type for " ++ @typeName(Payload)),
};
}
fn isOptional(comptime T: type) bool {
return @typeInfo(T) == .optional;
}
/// The schema name a `$ref` property points at.
fn refTarget(property: []const u8) ?[]const u8 {
const marker = "$ref: \"#/components/schemas/";
const at = std.mem.indexOf(u8, property, marker) orelse return null;
const rest = property[at + marker.len ..];
const close = std.mem.indexOfScalar(u8, rest, '"') orelse return null;
return rest[0..close];
}
/// Holds `schema_name` to `T`: same properties, same types, same nullability,
/// same requiredness, nothing extra on either side. Recurses through `$ref`.
fn expectSchemaMatches(gpa: Allocator, comptime T: type, schema_name: []const u8) !void {
const schema = yamlSchema(schema_name) orelse {
std.debug.print("openapi.yaml has no schema {s}\n", .{schema_name});
return error.TestUnexpectedResult;
};
var required: std.ArrayList([]const u8) = .empty;
defer required.deinit(gpa);
try yamlFlowSeq(schema, "required", &required, gpa);
const fields = @typeInfo(T).@"struct".fields;
inline for (fields) |field| {
const property = yamlProperty(schema, field.name) orelse {
std.debug.print("{s}: no property {s}\n", .{ schema_name, field.name });
return error.TestUnexpectedResult;
};
// Ahead of both branches: a `$ref` property is as free to go null as a
// scalar one, and a nested object or enum the server may omit is
// exactly the drift a client reading the document cannot see coming.
const documented_nullable = std.mem.containsAtLeast(u8, property, 1, "nullable: true");
if (documented_nullable != isOptional(field.type)) {
std.debug.print(
"{s}.{s}: nullable is {} in the document and {} in Zig\n",
.{ schema_name, field.name, documented_nullable, isOptional(field.type) },
);
return error.TestUnexpectedResult;
}
if (comptime documentedType(field.type)) |wanted| {
var type_buf: [32]u8 = undefined;
const needle = try std.fmt.bufPrint(&type_buf, "type: {s}", .{wanted});
if (!std.mem.containsAtLeast(u8, property, 1, needle)) {
std.debug.print("{s}.{s}: not documented as {s}\n", .{ schema_name, field.name, wanted });
return error.TestUnexpectedResult;
}
} else {
const target = refTarget(property) orelse {
std.debug.print("{s}.{s}: not a $ref\n", .{ schema_name, field.name });
return error.TestUnexpectedResult;
};
const Payload = switch (@typeInfo(field.type)) {
.optional => |o| o.child,
else => field.type,
};
switch (@typeInfo(Payload)) {
.@"enum" => try expectEnumMatches(gpa, Payload, target),
else => try expectSchemaMatches(gpa, Payload, target),
}
}
var listed = false;
for (required.items) |listed_name| listed = listed or std.mem.eql(u8, listed_name, field.name);
if (!listed) {
std.debug.print("{s}.{s}: not in required\n", .{ schema_name, field.name });
return error.TestUnexpectedResult;
}
}
var documented: std.ArrayList([]const u8) = .empty;
defer documented.deinit(gpa);
try yamlPropertyNames(schema, &documented, gpa);
try testing.expectEqual(fields.len, documented.items.len);
try testing.expectEqual(fields.len, required.items.len);
}
/// Holds an enum schema to its Zig enum: the same values, in the same order.
fn expectEnumMatches(gpa: Allocator, comptime T: type, schema_name: []const u8) !void {
const schema = yamlSchema(schema_name) orelse {
std.debug.print("openapi.yaml has no schema {s}\n", .{schema_name});
return error.TestUnexpectedResult;
};
var values: std.ArrayList([]const u8) = .empty;
defer values.deinit(gpa);
try yamlFlowSeq(schema, "enum", &values, gpa);
const tags = @typeInfo(T).@"enum".fields;
try testing.expectEqual(tags.len, values.items.len);
inline for (tags, 0..) |tag, index| {
try testing.expectEqualStrings(tag.name, values.items[index]);
}
}
test "drift guard c: the query-log schemas match the structs that serialize them" {
const gpa = testing.allocator;
try expectSchemaMatches(gpa, queries_repo.QueryRow, "QueryRow");
try expectSchemaMatches(gpa, provenance_view.QueryDetail, "QueryDetail");
try expectSchemaMatches(gpa, provenance_view.Provenance, "Provenance");
try expectSchemaMatches(gpa, coverage_mod.Coverage, "Coverage");
}
test "drift guard c: the three closed enums are documented value for value" {
const gpa = testing.allocator;
try expectEnumMatches(gpa, provenance.PolicyAction, "PolicyAction");
try expectEnumMatches(gpa, provenance.PolicyReason, "PolicyReason");
try expectEnumMatches(gpa, provenance.RouteKind, "RouteKind");
}
test "drift guard c bites: a renamed, retyped or newly optional field fails it" {
const gpa = testing.allocator;
// A field the document does not name at all.
const Renamed = struct { complete: bool, available_from: i64 };
try testing.expectError(error.TestUnexpectedResult, expectSchemaMatches(gpa, Renamed, "Coverage"));
// A field the document names, with the wrong type.
const Retyped = struct { complete: bool, available_since: []const u8 };
try testing.expectError(error.TestUnexpectedResult, expectSchemaMatches(gpa, Retyped, "Coverage"));
// A field the document names and types correctly, but which Zig may now
// send as null while `nullable` is absent from the document.
const Nullable = struct { complete: bool, available_since: ?i64 };
try testing.expectError(error.TestUnexpectedResult, expectSchemaMatches(gpa, Nullable, "Coverage"));
// The same drift behind a `$ref`, where the property carries no `type:` of
// its own: a nested object the server may now omit.
const NullableObject = struct {
request: provenance_view.Request,
group: ?provenance_view.Group,
policy: provenance_view.Policy,
rewrites: provenance_view.Rewrites,
route: provenance_view.Route,
response: provenance_view.Response,
};
try testing.expectError(error.TestUnexpectedResult, expectSchemaMatches(gpa, NullableObject, "Provenance"));
// And behind a `$ref` to an enum, whose values would still line up.
const NullableEnum = struct {
action: ?provenance.PolicyAction,
reason: provenance.PolicyReason,
matched: []const u8,
source_id: ?i64,
source_name: []const u8,
};
try testing.expectError(error.TestUnexpectedResult, expectSchemaMatches(gpa, NullableEnum, "ProvenancePolicy"));
// A struct short one documented property, which excess-property checking on
// the client side would never catch.
const Narrowed = struct { complete: bool };
try testing.expectError(error.TestExpectedEqual, expectSchemaMatches(gpa, Narrowed, "Coverage"));
// An enum missing one of the document's values.
const Short = enum { not_evaluated, allow };
try testing.expectError(error.TestExpectedEqual, expectEnumMatches(gpa, Short, "PolicyAction"));
}
// ---------------------------------------------------------------------------
// contract samples: the frontend's consumed shapes against real responses
// (milestone-17 ruling 5)
@@ -2443,6 +3139,10 @@ const contract_sample_walk = [_]ContractSample{
// Query log and stats. `limit=5` reaches seeded row 21, the blocked one, so
// the page carries both the null-bearing and the populated row shape.
.{ .name = "get_queries", .ts_type = "QueriesPage", .method = "GET", .target = "/api/queries?limit=5", .status = 200 },
// The newest seeded row: a CNAME-uncloaked block with a group, a source and
// a matched pattern, so the golden exercises every nested object rather
// than a row of nulls.
.{ .name = "get_query_detail", .ts_type = "QueryDetail", .method = "GET", .target = "/api/queries/27", .status = 200 },
.{ .name = "get_stats", .ts_type = "StatsTotals", .method = "GET", .target = "/api/stats?period=1h", .status = 200 },
.{ .name = "get_stats_timeseries", .ts_type = "StatsTimeseries", .method = "GET", .target = "/api/stats/timeseries?period=1h", .status = 200 },