Part 4 · Chapter 4.8
You will produce: FR-ANL-07's query surface over the request log chapter 4.4 produced — tenant-scoped, paged on a composite cursor, filtered against the router's own route set — and the finding that the clause beside it cannot be built: FR-ANL-10 names a quantity this platform has never defined, over a column with 0 rows and 0 producers, and the function everyone would reach for reads the p99 at half its true value on the only real latency sample there is · about 60 minutes including the exercise
Source: SRS — Software Requirements Specification · SAD — Software Architecture Document · ADR deep dives
The brief pairs two clauses. FR-ANL-07 wants "a queryable API request log per tenant", and
chapter 4.4 already built the producer for it — 11,683 rows sitting in api_requests, one per
request, with nothing yet reading them. FR-ANL-10 wants "end-to-end delivery latency
percentiles (p50, p95, p99) per tenant per hour".
The first is a route over a table that exists. The second turns out to name a quantity this platform has never defined, over a column with no producer, computed by a function that is wrong by half on the only real sample anyone has.
This chapter builds the first, defines the second, and publishes the numbers that say why it stopped there.
Before any of the query surface, the table has a shape worth looking at. Of 11,683 rows,
7,062 carry no tenant at all — 60.45%, which is every 404, every 401, /healthz, signup,
and every call the dispatcher and gateway make on the internal seam, where the platform
principal carries no environmentId by design. Those are unreachable from any tenant's query,
which is chapter 4.4's reading of constitution I and is the easy half.
The 4,621 that do carry a tenant are the interesting half:
/internal 1,656 35.84% busiest single route: /internal/session, 1,423
/v1 1,857 40.19%
other 1,108 23.98%
no endpoint 22 32 whole-table, and 31 of those are realA third of what a customer's log would show them is the platform calling itself on their
behalf, with their end user's principal. /internal/session is the gateway asking the api
what a connection may hear. The customer never wrote that call and has never read its name.
So the surface has to decide, and there is no neutral option. Hiding them makes the log incomplete against FR-ANL-01's "every request" — those rows carry the tenant's environment id because the work was done for that tenant, and dropping them is a claim the work did not happen. Hiding them also needs a prefix rule, which is a hand-maintained table that fails open: a new internal prefix is returned by default, and this project has already deleted one hand-maintained table rather than correct it.
The argument that decides it is the third, and it only became available once the surface grew a filter. The caller can exclude these rows and the platform cannot un-hide them. A customer who wants only their own calls asks for the endpoint they called; a platform that hid the rows offers no way back. So they are returned, and the cost is stated rather than glossed: the busiest entry in a tenant's log is a route they have never heard of.
The store client is four lines of fetch around a string:
export interface AnalyticalStore {
query(sql: string): Promise<string[][]>;
}There is no parameter binding, and chapter 4.7 was fine with that because everything its
reconciler interpolated was a UUID the api minted or a YYYY-MM-01 string it computed. A
request log interpolates a window the caller sent.
The test for that was written first and run against a version that validates the window as a string. It went red ten times out of ten, with every payload intact in the statement. That is where most treatments of this stop — "the payload reaches the parser" — and it is a weaker claim than the one available. The statements were put to the server.
flowchart TB
caller["a caller asks for a window<br/>?from=2026-09-16T00:00:00Z' OR 1=1 --"]
validated["validated as a string, then interpolated<br/>WHERE environment_id = toUUID('...')<br/>AND ts >= '<the caller's text>'"]
caller --> validated
scoped["the honest query, one hour, one tenant<br/>0 rows"]
broken["the same query under the payload<br/>11,683 rows — the whole table"]
tenants["environments returned: 152<br/>the query names ONE"]
validated --> broken
validated --> tenants
scoped ~~~ broken
union["' UNION ALL SELECT name FROM system.users --<br/>first row: relay<br/>the ClickHouse account name, out through a customer's log page"]
broken --> union
wall["'; DROP TABLE ...; --<br/>Code: 62. Multi-statements are not allowed"]
union --> wall
note["The DDL half cannot execute over this transport and the READ half can.<br/>A surface defended by that alone is defended against the loud attack only."]
wall ~~~ notescoped, honest window (1 hour) 0 rows
the same query, from carrying ' OR 1=1 -- 11,683 rows
the whole table, for comparison 11,683 rows
distinct environment_id under the payload 152The injection does not widen the window. It defeats the tenant predicate, because OR
binds looser than the AND chain the scope is written in, and 152 is every tenant on the lane.
Constitution I, broken by a query parameter. And the UNION payload returns relay as its
first row — the ClickHouse account name, read out of system.users through a customer's log
page.
The remedy is a type rather than an escape. The window parses into a Date, and the only
function that turns a Date into SQL takes a Date:
export function clickHouseInstant(at: Date): string {
return at.toISOString().replace("T", " ").replace("Z", "");
}There is no path from a query string to a statement, so there is nothing to escape and nothing
to forget to escape. The same argument covers endpoint, which is the other caller-supplied
value that reaches SQL: it is validated against the set of route templates the running
router reports, which is the cross-tenant suite's own mechanism, and a value from a derived
closed set is not text.
That repair then took away the most diagnostic question the filter could ask, because the
router contains no route for the request that matched none — which is exactly what a 404
investigation opens the log for. unmatched is a member of the closed set, mapping to
endpoint IS NULL, and 31 real rows carry it.
/** FR-ANL-07's query surface, as arithmetic (chapter 4.8).
*
* EVERYTHING HERE RUNS WITH NO STORE, NO DATABASE AND NO BROKER, which is the shape
* chapter 4.7 separated `exitCodeFor` for: a contract with branches a test can drive is
* worth more than one that needs Docker. The reader and the route come next; what this
* file owns is what the caller is allowed to have asked for.
*/
import { z } from "zod";
/** The value that names the request the router matched nothing for.
*
* NOT A ROUTE, AND THAT IS WHY IT HAS TO BE SPELLED. The accepted endpoint set is derived
* from the running router (below), and the router contains no entry for a request that
* matched none of its entries — so the most diagnostic question a 404 investigation asks
* would have been the one question the filter could not express. 32 rows carry
* `endpoint IS NULL` in the lane today.
*
* It cannot collide with a derived route: every route template this api registers begins
* with `/`. */
export const UNMATCHED = "unmatched";
/** FR-ANL-07 retains thirty days. DR-09's ninety is the raw-event figure and this table is
* not raw events — `analytics/0005_connection_events.sql:4` states the reason one clause
* cannot express two, and `SHOW CREATE` reports `toDateTime(ts) + toIntervalDay(30)`. */
export const RETENTION_DAYS = 30;
/** The default window when the caller names neither end. */
export const DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1000;
/** An ISO-8601 instant, as a `Date`.
*
* THE TYPE IS THE GUARD, AND THIS IS THE LINE THAT MAKES IT ONE. `AnalyticalStore.query`
* takes a SQL string and has no parameter binding (R9), so the defence against a hostile
* window cannot be escaping — it is that nothing downstream ever holds the caller's text.
* What leaves this schema is a `Date`, and `clickHouseInstant` below is the only function
* that turns one into SQL. There is no path from a query string to a statement.
*
* `offset: true` accepts `+07:00` as well as `Z`; both are instants. A bare `2026-09-16`
* is refused, because a date is not an instant and the surface would have to invent a
* timezone to make one. */
const instant = z.iso
.datetime({ offset: true })
.transform((v) => new Date(v))
/** The belt beside the braces. `z.iso.datetime` refuses `2026-13-45T00:00:00Z`; this
* catches anything that parses as a string and not as a moment, and costs one call. */
.refine((d) => !Number.isNaN(d.getTime()), { message: "not a valid instant" });
/** The query schema, built against the endpoint set the running router reports.
*
* A FACTORY BECAUSE THE CLOSED SET IS NOT KNOWN AT IMPORT TIME. `deriveTargets` reads
* `app.getHttpAdapter().getInstance()` — the cross-tenant suite's own mechanism
* (`isolation/targets.ts`) — so the set exists only once the application is built. Passing
* it in is also what lets every refusal in this file be unit-tested against a set of three.
*
* `z.strictObject`, MIRRORING `historyQuerySchema` (`messages/messages.schema.ts`) IN FORM
* AND BOUNDS — 1..200, default 50, `older`/`newer`. Strict is the half that is easy to
* drop: a plain `z.object` accepts `limt=200` silently and serves the default 50, so a
* caller's typo becomes a wrong answer rather than a 400. Mirrored rather than moved to a
* shared module: that file carries six titled fences in each locale and is clean in the
* fence chain, and relocating a four-line shape would cost twelve hunks. */
export function buildRequestLogQuerySchema(endpoints: ReadonlySet<string>) {
return z
.strictObject({
/** INCLUSIVE. Absent means `to - 24h`; clamped to the retention edge rather than
* refused, because a caller asking for ninety days is asking a reasonable question
* the data cannot answer (R8, and FR-ANL-08 says 90 where FR-ANL-07 retains 30). */
from: instant.optional(),
/** EXCLUSIVE, and the half-open range is chapter 4.7's precedent. There a
* reconciler's off-by-one reports drift; here it duplicates a row across two pages,
* because the row on the boundary belongs to both windows. Absent means now. */
to: instant.optional(),
cursor: z.string().min(1).optional(),
direction: z.enum(["older", "newer"]).default("older"),
/** REFUSED OUT OF BOUNDS, NOT CLAMPED — see `resolveWindow` for the field that is
* clamped instead, and why the two differ. */
limit: z.coerce.number().int().min(1).max(200).default(50),
/** A MEMBER OF A CLOSED SET DERIVED FROM THE ROUTER, or `unmatched`. A value from a
* derived set is not text reaching SQL; an unknown one is a 400 rather than an empty
* page, which is the same distinction the retention edge draws between "nothing
* matched" and "this question cannot be answered". */
endpoint: z
.string()
.refine((v) => v === UNMATCHED || endpoints.has(v), {
message: "unknown endpoint",
})
.optional(),
/** `status` is `UInt16` in the store and an HTTP status here: the column's range is
* wider than the protocol's, and the narrower of the two is the honest bound. */
status: z.coerce.number().int().min(100).max(599).optional(),
})
.superRefine((q, ctx) => {
// `to` is exclusive, so equal ends describe a window that can hold nothing. A
// caller who wrote them is asking for something they did not mean.
if (q.from && q.to && q.to.getTime() <= q.from.getTime()) {
ctx.addIssue({
code: "custom",
path: ["to"],
message: "`to` must be after `from`",
});
}
});
}
export type RequestLogQuerySchema = ReturnType<typeof buildRequestLogQuerySchema>;
export type RequestLogQuery = z.infer<RequestLogQuerySchema>;
/** The window a page is actually read over. */
export interface ResolvedWindow {
/** Inclusive. */
from: Date;
/** Exclusive. */
to: Date;
/** `now - RETENTION_DAYS`, the nominal guarantee, reported to the caller. */
retentionEdge: Date;
/** `from` was older than the edge and was moved forward. */
clamped: boolean;
/** The whole requested window is older than the edge, so it can hold nothing. The
* reader skips the store for this: a query that cannot return a row should not cost
* one. */
empty: boolean;
}
/** Apply the defaults and the retention edge.
*
* `now` IS A PARAMETER. Every bound here is relative to it, and a function that reads the
* clock has no boundary a test can sit on — chapter 4.7's reconciler paid for the same
* thing in the other direction, where the smallest breaching drift had to be computable.
*
* WHY `from` IS CLAMPED WHERE `limit` IS REFUSED, which is the one asymmetry on this
* surface and is a decision rather than an accident. `limit`'s bound is published in the
* contract, so a caller outside it has made a mistake they can see and fix, and a 400 says
* which field. The retention edge is a property of the store that moves every second and
* is published as a guarantee rather than as a bound — a caller cannot know it when they
* write the request, so refusing them for missing it would refuse a reasonable question.
* They get the window the data can answer for, and `retention_edge` beside it. */
export function resolveWindow(
query: Pick<RequestLogQuery, "from" | "to">,
now: Date,
retentionDays: number = RETENTION_DAYS,
): ResolvedWindow {
const retentionEdge = new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000);
const to = query.to ?? now;
const requestedFrom = query.from ?? new Date(to.getTime() - DEFAULT_WINDOW_MS);
const clamped = requestedFrom.getTime() < retentionEdge.getTime();
const from = clamped ? retentionEdge : requestedFrom;
// A window whose exclusive end is at or before its inclusive start holds nothing. It
// arrives here one way only: the caller asked for a period entirely older than the
// edge, and the clamp moved `from` past `to`. It is NOT a refusal — the answer "that
// is gone" is the one R8 exists to give, and the caller reads it off `window` and
// `retention_edge` rather than off an empty page that could also mean a quiet day.
const empty = from.getTime() >= to.getTime();
return { from: empty ? to : from, to, retentionEdge, clamped, empty };
}
/** The only function that turns an instant into SQL.
*
* `DateTime64(3)` — milliseconds, and the store parses `YYYY-MM-DD hh:mm:ss.SSS` in the
* column's own timezone, so the value is rendered from the UTC parts rather than through
* a locale-dependent formatter. Takes a `Date` and not a string, which is the whole of the
* injection argument: there is nothing to escape because there is nothing to quote. */
export function clickHouseInstant(at: Date): string {
return at.toISOString().replace("T", " ").replace("Z", "");
}The store client returns string[][], split from a TSV body. ClickHouse writes NULL as the two
characters \N. Asked of the server directly:
\N GET 200So a reader taking the value column alone reports an endpoint of "\N" — a string, truthy, and
indistinguishable from a route name to everything downstream. The statement selects
<column> IS NULL as a column of its own and the presence column decides, which is chapter
4.7's count() move against the same class one table over.
The first version of that handled endpoint and stopped. system.columns names three nullable
columns on this table, and the one that matters is not the one you would think:
endpoint NULL on 31 real rows (23 rate-limited, 8 unmatched)
limited_operation NULL on 11,660 of 11,683 — 99.8% of the tablelimited_operation is set only when the rate limiter refused, so a reader that handled
endpoint alone would have reported the literal "\N" for almost every row it returned. The
fix is where the next defect is, one column over.
The tests for both arms go red against a reader that reads the value column alone —
expected '\N' to be null — and the suite carries a control for its own claim: it asks the
store directly and asserts the server really does answer \N, so the tests are about the
reader rather than about a store that happens never to produce that character.
The platform's other cursor stands on (channel_id, sequence) — a server-assigned, strictly
increasing number, which constitution II requires message ordering to use. api_requests has
no such column. Its key is (environment_id, ts, request_id) and ts is DateTime64(3), so
two requests in the same millisecond are ordered by request_id and nothing else.
On this lane, 42 (environment_id, ts) pairs hold more than one row, 89 rows in total. A
ts-only cursor skips or repeats every one of them, and that share grows with request rate
rather than with elapsed time — it gets worse on a busy tenant, which is the tenant that pages.
So the token carries the pair. The refusal block written beside it caught the first draft's bound on the timestamp half:
/** The request log's cursor (chapter 4.8, FR-ANL-07, EIR-API-06).
*
* OPAQUE, LIKE THE HISTORY CURSOR, AND FOR THE SAME REASON — constitution V offers cursor
* pagination and not offsets, and a client that never saw inside a token cannot break when
* the inside changes. `messages/cursor.ts` is the precedent and this is deliberately not
* an extension of it.
*
* AND IT CANNOT KEY THE SAME WAY, WHICH IS THE INTERESTING HALF. The message cursor stands
* on `(channel_id, sequence)` — a server-assigned strictly increasing number constitution
* II requires message ordering to use. `api_requests` has no such column. Its key is
* `(environment_id, ts, request_id)` and `ts` is `DateTime64(3)`, so two requests in the
* same millisecond are ordered by `request_id` and nothing else. Measured on the lane: 42
* `(environment_id, ts)` pairs hold more than one row, 89 rows in total. A `ts`-only cursor
* skips or repeats every one of them, and that share grows with request rate rather than
* with elapsed time — it gets worse on a busy tenant, which is the tenant that pages.
*
* SO THE TOKEN CARRIES THE PAIR, AND THE COMPARISON HAPPENS IN SQL. The `request_id` half
* travels as its canonical text and is compared as a `UUID` by the store, which is what
* keeps the comparison consistent with the table's own `ORDER BY`: ClickHouse does not
* order UUIDs by that text. Comparing them as strings in the statement would produce a
* page boundary the index disagrees with. */
const PREFIX = "rl:";
/** The canonical text of a UUID, and nothing else.
*
* THIS REGULAR EXPRESSION IS PART OF THE INJECTION ARGUMENT, not a tidiness check.
* `AnalyticalStore.query` takes a SQL string and has no parameter binding (R9), so a
* cursor is caller-supplied text that reaches a statement. What reaches it is a value this
* pattern admitted and a number, and neither can carry a quote. */
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
/** THE INSTANTS THE COLUMN CAN HOLD, AND `Number.isSafeInteger` IS NOT ONE OF THEM.
*
* The first draft bounded the timestamp half with `Number.isSafeInteger` and a 15-digit
* pattern, and its own refusal block caught it: `rl:999999999999999:<uuid>` is a safe
* integer, decodes to **the year 33658**, and would have been rendered into a
* `DateTime64(3)` comparison the store cannot express. A bound on the JavaScript number is
* not a bound on the instant.
*
* Both ends are facts about the column rather than round numbers. The floor is the DDL's
* own `CONSTRAINT ts_is_real CHECK ts > toDateTime64('2020-01-01 00:00:00', 3, 'UTC')` —
* no row this cursor could have been minted from is older. The ceiling is
* `DateTime64`'s representable maximum, `2299-12-31`. */
const TS_FLOOR_MS = Date.UTC(2020, 0, 1);
const TS_CEILING_MS = Date.UTC(2300, 0, 1);
export interface RequestLogPosition {
/** The row's `ts`, to the millisecond the column stores. */
ts: Date;
/** The row's `request_id`, canonical lower-case text. */
requestId: string;
}
export function encodeRequestLogCursor(at: RequestLogPosition): string {
return Buffer.from(`${PREFIX}${at.ts.getTime()}:${at.requestId}`, "utf8").toString(
"base64url",
);
}
/** Decode a cursor, or null for anything this module did not produce.
*
* NULL BECOMES A 400 AT THE ROUTE — never a 500, and never a silent fall back to the top
* of the window, which would serve a page the caller did not ask for and looks like
* working software. That is the rule `messages/cursor.ts` wrote in chapter 2.4.
*
* `Buffer.from(x, "base64url")` NEVER THROWS: it decodes what it can and drops the rest,
* so the pattern below is the whole refusal and the `try` around the decode would be
* decoration. Every field is re-checked after decoding. */
export function decodeRequestLogCursor(token: string): RequestLogPosition | null {
const raw = Buffer.from(token, "base64url").toString("utf8");
const match = /^rl:(\d{1,15}):([0-9a-fA-F-]{36})$/.exec(raw);
if (!match) return null;
const [, msText = "", idText = ""] = match;
const ms = Number(msText);
// AND NO `Number.isSafeInteger` BESIDE THIS. There was one, and the branch report said
// it could never be false: `\d{1,15}` caps the value at 999,999,999,999,999, which is
// a safe integer, so the guard was dead the moment the range check went in beside it.
// Chapter 4.6 reached 100% branches by deleting two arms that could not run; this is
// the same finding one file over, and the range check subsumes it either way — a value
// too large to represent exactly is also far past the ceiling.
if (ms < TS_FLOOR_MS || ms >= TS_CEILING_MS) return null;
const requestId = idText.toLowerCase();
if (!UUID.test(requestId)) return null;
return { ts: new Date(ms), requestId };
}And the comparison stays in SQL. request_id is a UUID column, and ClickHouse does not order
UUIDs by their canonical text — so the token carries the text and the store compares it as a
UUID. Comparing them as strings in the statement would draw a page boundary the table's own
ORDER BY disagrees with.
The research for this chapter published a performance figure: a 50-row page reads 8,194 rows, one granule, the engine's floor. Measured against the surface as built, every query reads 11,695 rows — the whole table — whatever the window and whatever the filter.
EXPLAIN indexes=1 says PrimaryKey … Granules: 1/1. The condition is right and there is
exactly one granule to select. The table declares index_granularity = 8192 and holds 11,695
rows in one granule, which is not what that setting is supposed to mean.
flowchart TB
decl["api_requests: SETTINGS index_granularity = 8192"]
part["the part on this lane: Compact, 11,695 rows, marks 2"]
decl --> part
explain["EXPLAIN indexes=1<br/>PrimaryKey · Granules: 1/1"]
part --> explain
page["a 50-row page reads 11,695 rows<br/>the whole table, every window, every filter"]
explain --> page
probe["built two ways, same rows, same declared granularity"]
wide["min_bytes_for_wide_part = 0<br/>Wide · marks 3 · 2 granules"]
compact["min_bytes_for_wide_part = 10 MiB<br/>Compact · marks 2 · 1 granule"]
probe --> wide
probe --> compact
verdict["The part type decides, not index_granularity.<br/>The key is right and there is nothing to skip<br/>until the table crosses 10 MiB."]
wide --> verdict
compact --> verdictTwo scratch tables settle it: the same 11,695 rows, the same declared granularity, differing
only in min_bytes_for_wide_part. The Wide part takes two granules and the Compact part takes
one. The part type decides, the table is Compact because it is under 10 MiB, and the
per-tenant filter starts skipping when the table crosses that line and not before.
The published 8,194 was a measurement of a Wide part — one granule of 8,192 — and calling it "the engine's floor" generalised a number that depends on how much data there is.
What FINAL costs was measured the same way, against a table with parts rather than one that
had just been merged: with merges stopped and six inserts planted, 3 ms against 2 ms at seven
parts, three runs a side, identical every time, the same rows read. It is not optional. The
lane held 11,684 rows against 11,683 distinct keys — one duplicate, across two active parts —
so a read without FINAL returns that request twice and the repeat disappears whenever a merge
happens to run. A page's correctness would then depend on merge timing.
FR-ANL-10 asks for percentiles of "end-to-end delivery latency". The phrase has three readings,
and the platform's one existing latency measures a different one — which its own schema said at
the time it was written: "How long the ENDPOINT took to answer. NOT
message_events.delivery_latency_ms."
flowchart TB
clause["FR-ANL-10: 'end-to-end delivery latency' percentiles, per tenant per hour"]
a["socket fan-out<br/>commit → frame written to a subscriber's socket"]
b["webhook<br/>commit → endpoint answered"]
c["REST read<br/>commit → returned by a history page"]
clause --> a
clause --> b
clause --> c
ax["message_events.delivery_latency_ms<br/>0 rows · 0 producers · the loader writes NULL on purpose"]
bx["webhook_attempts.latency_ms<br/>the fetch alone: no commit, no outbox,<br/>no JetStream, no claim, no retry gap"]
cx["a read nobody made has no latency<br/>and it is pull, not delivery"]
a --> ax
b --> bx
c --> cx
chosen["CHOSEN. The real-time path constitution II is about,<br/>and the reading the column was created for."]
ax --> chosen
note["Neither instant for the chosen reading is recorded.<br/>The commit is the api's and the socket write is the gateway's."]
chosen --> noteThe chapter chooses the socket reading: commit to the frame written to a subscriber's
socket. It is the real-time path constitution II is about, and it is the reading the column
was created for. The webhook reading is the last leg only — deliver.ts sets started
immediately before the fetch and takes the difference immediately after, so it excludes the
commit, the outbox, JetStream, the dispatcher's claim and every retry gap — and it already has
a column and a name of its own. A REST history read has no delivery in it at all.
The chosen reading has no producer. message_events holds 0 rows, and the one thing that
writes that table at all supplies CAST(NULL AS Nullable(UInt32)) for that column, in both
halves of its UNION ALL, on purpose. Building it needs both instants carried across a service
boundary — the commit is the api's and the socket write is the gateway's — on the busiest path
in the platform.
So nothing is computed, and the clause gains the definition it was missing. What made that worth a chapter rather than a deferral is the second measurement:
uniform sample n=4 n=100 n=155 n=10,000
p50 error 16.6667% 0.9804% 0% 0.8998%
skewed sample n=4 n=100 n=1,000 n=10,000
p50 error 2.6162% 4.7263% 4.7579% 9.5158%
p95 error 0.8200% 9.0398% 9.0404% 9.5163%quantile has no exact regime, which is the opposite shape from the uniq chapter 4.7
measured — exact below 65,536 and wrong above. Here the error does not fall as the sample
grows; on a skewed distribution, which is what a latency is, it grows. There is no n at
which the approximation is safe.
And the sharpest figure is the real one. webhook_attempts is the only latency sample this
platform holds, at n=64:
quantile(0.99) 4961.26
quantileExact(0.99) 10000 50.39% lowOnly the p99 diverges, and it diverges by half — in the direction where an alert threshold set on that number never fires. The distribution is why: 43 of the 64 attempts answer in 0–2 ms and one took ten seconds, so the tail is a single row and an approximation that smooths it reports a platform that is fine.
The clause's own grain makes it weaker still. Per tenant per hour over the request log gives
178 buckets, a median of 10 rows, and 73 of them holding fewer than five. A p99 over four
samples is a maximum wearing a percentile's name, whichever function computes it — so the
amended clause requires n published beside every percentile.
Constitution I's fourth bullet is a MUST: "an automated cross-tenant access test suite MUST attack every endpoint with foreign IDs on every build." The suite that does it derives its targets from the running application rather than from a list, for the reason its own file states: the fault it exists to prevent is a route that exists and is unattacked, and only the router knows what exists.
It found this one on the build that registered the module, before anything in the classification file mentioned it:
gauntlet targets: 43 derived, 36 attacked, 6 exempt
unclassified: ["GET /v1/request-log"]
CLASSIFICATIONS.length 42 against 43That is the seventh time in this repository, and the list has still never been ahead of the derivation. Classifying the route turned that green — and turned the gauntlet itself red, with a third accounting direction the plan had not named: "classified but never attacked." Naming a route is not covering it.
The attack for this route has to plant its own rows, which no other one in that file does.
compose.yaml runs no ingester, so both tenants' logs are empty on a fresh lane, and an
empty log passes a leak check for the same reason an empty page does — there is nothing in
it to leak. It plants three: the attacker's, the victim's, and one with a NULL
environment_id, so the attack shows two things rather than one. No other tenant's row comes
back, and no tenantless row does either.
Adding it turned up a defect in the helper the whole suite counts with:
@@ -129,16 +129,36 @@ export interface ListVerdict {
* paginated route answers `{ data: [...] }` and a bare route answers an array.
* Exported and pure because only ONE arm can execute against the routes that exist
* today, and a count of zero from an unrecognised shape reads exactly like a count of
* zero from a correctly-scoped list — which is the one answer this suite must never
* confuse with success. `listAttack` therefore asserts the shape was recognised
* rather than trusting the count. */
+/** The rows out of a list response.
+ *
+ * A NAMED SET OF SHAPES, AND IT STAYS ONE. Chapter 4.8 tried to replace this with "the
+ * first array-valued property", on the reflex that a hand-maintained table cannot be
+ * checked — and the test below refused the change, correctly. **This table IS checked**,
+ * twice over: it pins that an unrecognised shape returns `[]`, and every `list` attack
+ * carries a `count > 0` control that then fails. So an unknown shape cannot pass quietly,
+ * which is the precondition the reflex assumes is missing.
+ *
+ * And the derived version was worse in the direction that matters: it would count ANY
+ * array in the body — a cursor list, an embedded collection, an echo of what was asked
+ * for — as rows, which is a false pass where this is a loud failure. `{ requests }` is
+ * added by name.
+ *
+ * WHAT THE FAILURE READS LIKE IS THE ONE REAL COST. An unrecognised shape fails as *"the
+ * attacker's own listing came back empty"*, which names a symptom and not the cause. The
+ * test below is what turns that into a sentence about the recogniser. */
export function rowsOf(body: unknown): unknown[] {
if (Array.isArray(body)) return body;
- const data = (body as { data?: unknown } | null)?.data;
- if (Array.isArray(data)) return data;
+ const shaped = body as { data?: unknown; requests?: unknown } | null;
+ if (Array.isArray(shaped?.data)) return shaped.data;
+ // Chapter 4.8's envelope: the array is named for the resource, as
+ // `messages.service.ts` names its own. R23 refused `rows` for being a storage word.
+ if (Array.isArray(shaped?.requests)) return shaped.requests;
return [];
}
export async function listAttack(
baseUrl: string,
credential: string,The attack itself is a diff against the same file, and it lands in the series appendix
rather than here — the appendix applies after every chapter, and this file's chain state at
this point in the series is 993 lines where the repository holds 1,292. A hunk written
against the repository would be written against a file the chain has not built yet, which is
the shape of failure the fence checker reports and neither document explains.
It records no user and no channel. The producer stores principal_kind —
application, user, platform, none — which says what kind of caller made the request and
never which one. So the surface answers "what did this tenant call, and what happened", and it
cannot answer "what happened to this user".
That second question is the one docs/03-journey-map.md's Stage 8 names as the opportunity:
"per-user and per-channel message tracing for support investigations", against a pain point of
"no way to trace a specific user's reported problem." FR-ANL-07's six fields never asked for
it, so no clause is broken. A motivating document names a capability no requirement carried, and
this is the chapter that found the difference.
Closing it would need a column on api_requests, a change to chapter 4.4's producer, a
migration, and the tenancy question 4.4 settled by recording a kind instead of an identity. It
is filed rather than built.
operationsFor returns ["rest"] for every path under /v1 — there is no route list and no
exemption — so this route is counted against the tenant's REST budget from the moment it
exists, and nobody chose that. It stays counted. An exemption list is a hand-maintained table,
which is the thing this project deletes rather than corrects.
The consequence is the chapter's rather than a defect to route around: a customer investigating 429s reads their request log, the reads spend the budget they are investigating, and the log then shows the 429s the reading caused.
And reading the log writes to the log. The producer records on res.on("finish") for every
request including GETs, so each page adds a row that appears in the next one. The surface does
not exclude its own route, for the same reason it does not exclude /internal/*: excluding
makes the log incomplete, and the caller can filter.
FR-ANL-04 allows analytical events to be queryable "within 60 seconds of the originating
operation under normal conditions". Measuring that needs an ingester, and compose.yaml runs
none — so the measurement started one, said so, and stopped it afterwards.
min 0.29 s p50 1.60 s max 1.61 s n=6/6
2.7% of FR-ANL-04's 60 sThe clustering at 1.60 s is the ingester's two-second batch window. Starting one drained
ANALYTICS for everything else on the lane — 188 rows over 15 batches, the first alone writing
a 137-row backlog that had been sitting on the stream since the last time anybody ran one.
The sealed integration suite is where that lands in front of a reader. It knows two URLs and a credential, starts nothing, and by the time it reaches this route it has made a dozen calls with that credential — so the log should not be empty:
@@ -648,12 +648,63 @@ describe("integrating with Relay from the outside", () => {
// resolves on the first match cannot.
expect(frames.filter((f) => f.type === "message.created")).toHaveLength(1);
expect(frames.filter((f) => f.type === "message.updated")).toHaveLength(1);
socket.close();
});
+ /** THE REQUEST LOG (FR-ANL-07), AND WHAT AN OUTSIDER ACTUALLY FINDS THERE.
+ *
+ * By the time this runs the suite has made a dozen calls with this credential —
+ * channels, members, tokens, sends, a history read. So the log should not be empty, and
+ * **it is.** The platform ships no ingester: `compose.yaml` starts the stores, the api
+ * and the gateway, and nothing drains the analytics stream into ClickHouse. The records
+ * are published and they wait.
+ *
+ * THAT IS THE ASSERTION RATHER THAN A REASON TO OMIT THE ROUTE. A customer's-eye test
+ * showing an empty log because the platform ships no ingester is the freshness gap
+ * arriving where a customer would actually meet it — and it asserts something true,
+ * where skipping the endpoint asserts nothing at all. When an ingester ships, this test
+ * goes red and the line below is where the number goes.
+ *
+ * WHAT IS ASSERTED REGARDLESS: the envelope is the documented one, and the refusals
+ * work. Those do not depend on a row existing. */
+ it("serves a request log with the documented envelope, and it is empty", async () => {
+ const res = await fetch(`${api}/v1/request-log`, {
+ headers: { authorization: `Bearer ${credential}` },
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as Record<string, unknown>;
+ expect(Array.isArray(body["requests"])).toBe(true);
+ expect(typeof body["has_more"]).toBe("boolean");
+ expect(body["window"]).toBeTruthy();
+ expect(typeof body["retention_edge"]).toBe("string");
+ // `next_cursor` and `prev_cursor` are DECLARED and null at the ends, which is a
+ // different fact from being absent — a client that reads `next_cursor` off this
+ // response gets null rather than undefined.
+ expect(body).toHaveProperty("next_cursor");
+ expect(body).toHaveProperty("prev_cursor");
+
+ // EMPTY, AND THIS SUITE IS THE EVIDENCE THAT IT SHOULD NOT BE. Every request above
+ // was made with this credential and every one of them was recorded — to a stream
+ // nothing reads.
+ expect(body["requests"]).toEqual([]);
+ expect(body["has_more"]).toBe(false);
+ });
+
+ it("refuses a page size outside the published bound, and says which field", async () => {
+ const res = await fetch(`${api}/v1/request-log?limit=201`, {
+ headers: { authorization: `Bearer ${credential}` },
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as Record<string, unknown>;
+ expect(body["field"]).toBe("limit");
+ expect(typeof body["code"]).toBe("string");
+ expect(typeof body["docs_url"]).toBe("string");
+ expect(typeof body["request_id"]).toBe("string");
+ });
+
it("cannot see another tenant's channel, and cannot tell it apart from an absent one", async () => {
// The documented isolation property, exercised the only way an outsider can:
// with an id that is well formed and is not theirs. The reference says both
// answer identically, so this checks that rather than taking it on faith.
const nowhere = "00000000-0000-4000-8000-000000000000";
const a = await fetch(`${api}/v1/channels/${nowhere}/messages`, {
@@ -664,16 +715,24 @@ describe("integrating with Relay from the outside", () => {
});
expect(a.status).toBe(404);
expect(b.status).toBe(404);
for (const res of [a, b]) {
const body = (await res.json()) as Record<string, unknown>;
expect(body["code"]).toBe("not_found");
- // A PATH PER CODE, not a fragment on one page. `docsUrl` here is
- // `${ERROR_DOCS_BASE}/${code}`; published later moved to an anchor on a single
- // error-reference page, which is part of the debt the error-registry chapter
- // opens and puts in Part 4. Asserted as this platform actually answers.
- expect(String(body["docs_url"])).toContain("/not_found");
+ // AN ANCHOR ON ONE PAGE, AND THIS LINE SAID THE OPPOSITE FOR A PART AND A HALF.
+ //
+ // It read `toContain("/not_found")` — a path per code — under a comment ending
+ // *"Asserted as this platform actually answers."* It was not: `docsUrl` returns
+ // `${base}#${code}`, and the commit that made it an anchor landed BEFORE this
+ // suite was written. So the suite has never passed, and nothing said so, because
+ // it needs a running platform that no lane starts. Chapter 4.8 found it by
+ // standing the stack up to run its own new test in this file.
+ //
+ // The platform is right and the test was wrong: `docs/08-error-reference.md` is
+ // ONE page with a section per code, so a path per code would 404 for every
+ // refusal this platform sends.
+ expect(String(body["docs_url"])).toContain("#not_found");
// Every error carries one, and it is what a support request quotes.
expect(typeof body["request_id"]).toBe("string");
}
});
});Standing that suite up to run the new test found a third one failing, and it was not this
chapter's. The suite asserted docs_url contains /not_found — a path per code — under a
comment ending "Asserted as this platform actually answers." It was not: docsUrl returns
${base}#${code}, and the commit that made it an anchor landed before the commit that
wrote the suite. The sealed suite had never passed, and nothing said so, because it needs
a running platform no lane starts. Corrected, and green at 18 of 18 for the first time.
Which is the honest end of this chapter. The route works, the isolation holds, the numbers are measured — and on the stack this series ships, a customer who reads their own request log finds it empty, because the records are published and nothing drains them.