Building Relay

Part 3 · Chapter 3.11

Counting a connection

You will produce: Connection-minutes metered from a service that owns no tables, a crash that under-bills by a bounded amount rather than over-billing for ever, and close code 4008 emitted for the first time since chapter 1.3 declared it · about 90 minutes including the exercise

Source: SRS — Software Requirements Specification · SAD — Software Architecture Document

FR-RTL-05 names three metered dimensions: messages sent, unique active users, and connection-minutes. Chapter 3.10 built two of them and stopped, and the stopping place was chosen before a word of it was written.

Messages and users were already rows. messages.user_id has been in 0000_core_tables.sql since Part 2, so counting them was an aggregation question answered inside a transaction the api already owned. The send knew the figure before and after, so it knew what it had crossed, and the whole chapter came down to where to put a +1.

A connection is not a row anywhere. Nothing records one. The only process that can see a connection is the gateway, which owns no tables — ADR-05 says the api is the only writer, and chapter 2.1 turned that into a lint rule so the mistake is a build failure rather than a review comment. registry.ts has stated the property in prose since chapter 2.5: no pg, no drizzle-orm, no repository import.

So this chapter is not about counting. It is about metering from a service that cannot write: what it has to say, how often it has to say it, and what happens to the number when the thing saying it dies mid-sentence.

What a connection-minute is

docs/04-srs.md records an open question, number four, addressed to Product and Billing: does connection-minute metering need per-second precision, or is per-minute rounding acceptable? A chapter that shipped without answering it would leave the unit to be discovered from a bill.

The answer taken here: a connection-minute is one calendar minute during which one connection was open for any part of it, charged per connection.

flowchart LR
    subgraph clock["wall clock"]
      m0["minute 00:00"]
      m1["minute 00:01"]
      m2["minute 00:02"]
    end
    open["socket opens<br/>00:00:59"] --> m0
    close["socket closes<br/>00:01:01"] --> m1
    m0 --> c1["charged"]
    m1 --> c2["charged"]
    m2 --> c3["not charged"]
    total["2 seconds of wall clock<br/>TWO connection-minutes"]
    c1 --> total
    c2 --> total
    style c1 fill:#064e3b,color:#fff,stroke:#059669
    style c2 fill:#064e3b,color:#fff,stroke:#059669
    style total fill:#1e3a8a,color:#fff,stroke:#3b82f6
A five-second socket costs one minute. A socket open from 00:00:59 to 00:01:01 costs two — it was present in two calendar minutes, and the unit is the minute it was present in.

The alternatives are worth naming because they give visibly different bills. You could accumulate real seconds and round each connection up, which gives every socket a one-minute floor. Or accumulate seconds across the environment and round once at the end, which is the most literally accurate reading of the words "connection-minutes".

A thousand five-second sockets separate them:

modela thousand 5-second socketsdedup key
minute buckets, per connectionup to 1,000 minutesthe bucket identity
seconds, rounded per connection1,000 minutesa per-connection accumulator
seconds, rounded once at read83 minutesa per-connection accumulator

The third charges a reconnect storm almost nothing. The first charges churn, and it has a second property that turns out to matter more than the billing argument: the set of minutes a connection has occupied only ever grows. A count of that set only grows too, and "credit the difference" becomes the entire deduplication story — a point we reach in two sections.

A service that owns no tables, and no identity either

The gateway's road to state is internal HTTP to the api. It has three calls — /internal/session, /internal/backfill, /internal/messages — and every one of them forwards the end user's own token. Chapter 3.2 made that so, and api-client.ts says why:

the token the client presented at connect, carried so the internal hop can FORWARD it instead of asserting who the caller is. The gateway holds it; it does not verify it and holds no secret that could.

A usage report is nobody's action. It is this process's claim about many connections, potentially across many environments, about time that has already passed. No user's token authorises it.

The obvious workaround is to report per connection using that connection's own token, and it fails in the worst possible place: a token expires while the socket stays open — handleSend already carries a branch for the 401 that produces — and the long-lived socket whose token has expired is precisely the socket with the most minutes on it.

So the gateway speaks for itself, for the first time. Chapter 3.5 already built the credential class this needs: PlatformPrincipal, @Accepts("platform"), the rk_svc_ prefix. The gateway becomes its second holder.

Chapter 3.2's fenced comment says the gateway "holds no signing secret", and that is still true after this chapter. A platform credential signs nothing and verifies nothing; it says which service is talking, on the one call that is the gateway's own rather than a user's.

Totals, not deltas

Here is the decision the rest of the design falls out of.

A report says what a connection has consumed in total in a period. Not what it consumed since the last report.

flowchart TB
    subgraph delta["a delta protocol"]
      d1["report: +5"] --> d2["report: +5 LOST"]
      d2 --> d3["report: +5"]
      d3 --> d4["credited 10<br/>the lost five are gone"]
    end
    subgraph total["a total protocol"]
      t1["report: 5 total"] --> t2["report: 10 total LOST"]
      t2 --> t3["report: 15 total"]
      t3 --> t4["credited 15<br/>the loss repaired itself"]
    end
    style d4 fill:#7c2d12,color:#fff,stroke:#ea580c
    style t4 fill:#064e3b,color:#fff,stroke:#059669
A delta protocol needs at-least-once delivery, a message identity to deduplicate on, and somewhere to keep what it could not send. A total protocol needs none of the three.

Three properties come free:

  • a report that is lost is repaired by the next one, because the next one carries the same total plus whatever accrued since;
  • a report delivered twice credits max(0, reported − credited) = 0 the second time;
  • a report that cannot be delivered is dropped rather than queued.

That third one is the interesting one. The gateway keeps no outbox — which is the right amount of durable state for a service whose entire design is that it holds none. Three chapters of this series have built an outbox by now, and this is the chapter that gets to not build one.

The arithmetic lives in two functions with no clock, no store and no framework, because those two lines are the protocol:

services/api/src/quotas/credit.ts (excerpt)
export function creditFor(reported: number, credited: number): number {
  return Math.max(0, reported - credited);
}
 
export function highWaterMark(reported: number, credited: number): number {
  return Math.max(reported, credited);
}

The max in the first is not defensive decoration. The one thing this function must never do is subtract from a bill.

The socket that counted zero

And this narrows the claim two sections ago, which is worth doing out loud rather than leaving for a reader to notice. "Reports carry totals, so nothing needs queueing" is true of a connection that is still open — its next report carries the same total plus what accrued. A connection that has closed has no next report. So its final total is retained until a report carrying it is accepted, the retained set is bounded, and a discard at the bound is logged and counted rather than dropped in silence.

The gateway holds no queue, except for the one case where the reasoning stops applying. Saying that is better than a claim that is true of most of the design.

The shutdown that was not there

A crash loses at most one reporting interval per open connection. That is the bound, it is stated, and nothing can be done about it: a process that is killed does not get to say goodbye.

A deploy is different. A deploy is the frequent one, and a graceful stop knows its sockets are about to die. So the plan said: flush a final report on the way out, hanging it off the server.on("close") handler main.ts already has.

That handler never runs.

serve() returns a bare node:http Server. Nothing in the gateway calls server.close(), and nothing installs a signal handler — only the dispatcher does, at main.ts:313. On docker stop the process takes SIGTERM, Node's default disposition exits, and the handler the flush was hung on is never reached.

Four documents said the flush happened: the research item, the requirement, the contract's loss table, and the task that wired it. They agreed with each other, which is what reading documents against each other proves, and none of them was the thing that had to be true.

services/gateway/src/main.ts (excerpt)
@@ -100,4 +110,32 @@ if (import.meta.main) {
+  for (const signal of ["SIGINT", "SIGTERM"] as const) {
+    process.on(signal, () => {
+      logger.log("info", "shutdown.signal", { signal });
+      server.close();
+      void server.shutdown().then(() => process.exit(0));
+    });
+  }
 }

sessions.close() became async to go with it. A flush that is fired rather than awaited is the same non-guarantee one line lower down: the process leaves before the request does.

The cap, at the door

FR-RTL-05 says enforce, not meter. Each dimension refuses the operation that consumes it: the messages cap refuses sends, and the connection-minutes cap refuses connects. A cap that only refused sends would leave an idle listener burning the metered resource with nothing to stop it, which is a cap that does not bound the thing it counts.

FR-RTL-08's promise holds around it. Sockets already open stay open, keep delivering, and keep accruing; REST sends and history reads are untouched. The overshoot is real and its bound is honest: (connections open when the cap was crossed) × (minutes until each closes) + one reporting interval, and nothing in the platform bounds how long a client holds a socket. Better to say that than to invent a ceiling.

flowchart LR
    client["client"] -->|"WebSocket upgrade"| gw["gateway"]
    gw -->|"POST /internal/session<br/>Bearer &lt;user token&gt;"| api["api"]
    api -->|"402 quota_exceeded<br/>no Retry-After"| gw
    gw -->|"error frame: quota_exceeded<br/>then close 4008"| client
    style api fill:#1e3a8a,color:#fff,stroke:#3b82f6
    style gw fill:#3f2d63,color:#fff,stroke:#8b5cf6
Two hops, two shapes. The api speaks HTTP and answers 402. The client speaks WebSocket and gets the socket's own vocabulary.

An earlier draft of this chapter forwarded the HTTP status onto the socket, borrowing chapter 3.8's raw 429 at the same door. That shape had a stated justification — "a refusal needs to say WHEN to come back; Retry-After is an HTTP header and a close frame has nowhere to put one" — and this refusal declines Retry-After on purpose, because a client that sleeps for a header and retries is right for a rate limit and wrong for a quota that will still be exhausted in an hour.

Take away the header and the argument for the HTTP shape goes with it. What is left is the shape chapter 3.8 used for a bad token: complete the handshake so that a close code has a socket to arrive on. EIR-WS-05 asks that of an invalid credential; EIR-WS-06 asks the same of quota exhaustion.

services/gateway/src/session.ts (excerpt)
if (result.outcome === "over_quota") {
  sendError(ws, "quota_exceeded", result.message);
  ws.close(4008, CLOSE_CODES[4008]);
  logger.log("info", "connection.rejected", { reason: "quota_exceeded" });
  return;
}

CLOSE_CODES[4008] has read "quota exhausted" since chapter 1.3 and nothing has ever sent it. Nineteen published chapters later, this is the one.

The api's half is chapter 3.10's refusal, unchanged in shape and named by the thrower:

services/api/src/internal/session.controller.ts (excerpt)
if (error instanceof QuotaExceededError) {
  throw new HttpException(
    { code: "quota_exceeded", message: error.publicMessage() },
    HttpStatus.PAYMENT_REQUIRED,
  );
}

Named, because ProtocolErrorFilter is @Catch()-all and infers a code for four statuses. 402 is not one of the four, so an unnamed refusal would have reached the gateway as internal_error — which is the finding chapter 3.10's second analysis pass spent itself on.

What the api cannot tell, and does not need to

stateDiagram-v2
    [*] --> counting: first report
    counting --> counting: report with a higher total<br/>credit the difference
    counting --> counting: report with the same total<br/>credit nothing
    counting --> stopped: reports stop
    stopped --> [*]
    note right of stopped
      A clean close and a killed gateway
      arrive here identically. The api
      cannot tell them apart and does
      not need to — which is why there
      is no reaper anywhere in this design.
    end note
A clean close and a killed gateway arrive at the same state. The api never learns which happened, and nothing in the design depends on knowing.

The api is never told when a connection opens. The first it hears of any connection is a report — so "a connection I have never seen" and "this connection's first report" are the same state, and there is nothing to tell them apart with. Accepted as the first.

What is refused is a report naming a connection whose row already carries a different environment. A connection does not move between tenants; reconciling one that appears to would mean inventing a fact about whose minutes these are. That is a 409, and it is a constitution I refusal rather than a data-quality one.

The consequence worth stating: there is no reaper. No orphan sweep, no "connection still open?" question, no periodic job of any kind. Usage rises only when a report arrives, and the report transaction knows the figure before and after — so it writes its own threshold crossings, exactly as chapter 3.10's send transaction did. Second chapter running to reach that result by that argument, and feature 030's global-operation guard is engaged nowhere.

What a third dimension cost

Chapter 3.10 wrote the price down, twice — in 0009_quotas.sql and in quotas/config.ts:

chapter 3.11 adds connection-minutes and FR-MED-12 later adds media bytes, and neither needs a table migration — a new dimension is a new key … the shape below is enforced by a CHECK that ENUMERATES the two dimensions, so a third one does cost a one-line constraint change

Counted, it is seven places:

placewhat it cost
quotaConfigSchemaone key
environments_quota_config_shapeone jsonb_typeof + two regex clauses, constraint dropped and rebuilt
quota_notifications_dimension_checkone IN value, dropped and rebuilt
usage_periodsone column and one CHECK, across two files
quota.error.tsa two-way ternary became two Record tables
quota-email.tsa key in NOUN, and a new STOPPAGE table
usageFortwo fields on its return shape

The prediction was right about the shape and light about the size, and two of the seven it did not anticipate at all. There is no ALTER CONSTRAINT for a CHECK expression, so both enumerating constraints are dropped and rebuilt rather than amended.

What this cost, measured

The plan was wrong about one thing and a measurement is what said so.

Research chose "a second call on the same request rather than a heavier environmentLimits", because chapter 3.10 had refused to put a usage join inside that function. That refusal was right and still is — environmentLimits has a second caller, the rate-limit middleware, on every /v1 request, and it must not pay for a join it never reads.

But two calls cost what a join would, at concurrency. Connect latency at 32-way went from 15.0 ms to 17.6 ms, four runs clustered inside 0.7 ms, so a real regression rather than noise. Folding the connect path into its own single read recovered 0.8 ms of it. The mechanism is the one chapter 3.10 already recorded: an extra round trip holds a pooled connection for its duration, and above the pool size that queues.

The baseline it was compared against was measured once, which was a mistake in an instrument built specifically to avoid chapter 3.10's uncontrolled benchmark. Three runs of the shipped build sit inside 0.4 ms; the single 15.001 does not, so the residual after the fold cannot be cleanly attributed. The EXPLAIN in session.perf.itest.ts is committed rather than ad-hoc, and it is what SC-012 actually asks for: index lookups, no scan proportional to anything a tenant accumulates.

And the twenty-run battery found three defects no amount of reading would have. The most instructive was a fixed port. startApi() bound 4123 and a new describe bound 4124, which is limits.itest.ts's; vitest runs files in parallel. Worse, back to back the previous run's child still holds the port, the new child dies on EADDRINUSE, and the health check gets its 200 from the old api — holding a different environment's signing secret. Three assertions failed and none of them named the fixture:

AssertionError: expected 'internal_error' to be 'unauthorized'
AssertionError: expected 1011 to be 4001
TypeError: fetch failed

I diagnosed it wrong twice before measuring, and both wrong theories reached code comments before the evidence arrived. What settled it was running one file alone, five times — two of five red — which killed every cross-file theory in a single measurement and should have been the first move rather than the fifth.

The code, in order

Everything this chapter changed, replayed onto the repository at part3-ch11. New files in full; everything else as a hunked diff against the state chapter 3.10 left behind.

services/api/migrations/0010_connection_minutes.sql
-- Chapter 3.11 — connection-minutes, the third dimension of FR-RTL-05.
--
-- Chapter 3.10 metered messages sent and distinct active users and stopped
-- there, because those two are the same kind of problem and this one is not.
-- Messages and users were already rows: `messages.user_id` has been in
-- `0000_core_tables.sql` since Part 2, so counting them was an aggregation
-- question answered inside a transaction the api already owned.
--
-- A CONNECTION IS NOT A ROW ANYWHERE. Nothing records it, and the only process
-- that can see one is the gateway, which owns no tables (ADR-05) and — until
-- this chapter — no identity of its own either. So the subject is not counting.
-- It is metering from a service that cannot write: what it has to say, how
-- often, and what happens to the number when the thing saying it dies
-- mid-sentence.
--
-- THE UNIT IS A WALL-CLOCK MINUTE BUCKET, CHARGED PER CONNECTION. A five-second
-- socket costs one minute; a socket open from 00:00:59 to 00:01:01 costs two; a
-- hundred concurrent sockets open for one minute cost a hundred. `docs/04-srs.md`
-- records "does connection-minute metering need per-second precision?" as an open
-- question addressed to Product and Billing; this is the answer, and it charges
-- reconnect churn, which summing seconds does not.
 
-- ---------------------------------------------------------------------------
-- The policy: the one-line change chapter 3.10 promised, priced.
-- ---------------------------------------------------------------------------
--
-- 0009 said out loud what a third dimension would cost:
--
--     chapter 3.11 adds connection-minutes and FR-MED-12 later adds media
--     bytes, and neither needs a table migration — a new dimension is a new
--     key … the shape below is enforced by a CHECK that ENUMERATES the two
--     dimensions, so a third one does cost a one-line constraint change
--
-- It is three clauses, not one line, and there is no `ALTER CONSTRAINT` for a
-- CHECK expression, so the whole constraint is dropped and rebuilt. The
-- prediction was right about the shape and light about the size; the chapter
-- counts what it actually cost rather than quoting what it was told it would.
 
ALTER TABLE environments
  DROP CONSTRAINT environments_quota_config_shape;
 
ALTER TABLE environments
  ADD CONSTRAINT environments_quota_config_shape CHECK (
    jsonb_typeof(quota_config) = 'object'
    AND (quota_config -> 'messages' IS NULL
         OR jsonb_typeof(quota_config -> 'messages') = 'object')
    AND (quota_config -> 'active_users' IS NULL
         OR jsonb_typeof(quota_config -> 'active_users') = 'object')
    AND (quota_config -> 'connection_minutes' IS NULL
         OR jsonb_typeof(quota_config -> 'connection_minutes') = 'object')
    AND (quota_config #>> '{messages,hard}' IS NULL
         OR quota_config #>> '{messages,hard}' ~ '^[0-9]+$')
    AND (quota_config #>> '{messages,soft}' IS NULL
         OR quota_config #>> '{messages,soft}' ~ '^[0-9]+$')
    AND (quota_config #>> '{active_users,hard}' IS NULL
         OR quota_config #>> '{active_users,hard}' ~ '^[0-9]+$')
    AND (quota_config #>> '{active_users,soft}' IS NULL
         OR quota_config #>> '{active_users,soft}' ~ '^[0-9]+$')
    AND (quota_config #>> '{connection_minutes,hard}' IS NULL
         OR quota_config #>> '{connection_minutes,hard}' ~ '^[0-9]+$')
    AND (quota_config #>> '{connection_minutes,soft}' IS NULL
         OR quota_config #>> '{connection_minutes,soft}' ~ '^[0-9]+$')
  );
 
-- ---------------------------------------------------------------------------
-- The roll-up gains a third figure.
-- ---------------------------------------------------------------------------
--
-- `bigint`, for the reason 0009 gave `messages_sent`: a cumulative count on a
-- billing path, where an overflow is a wrong bill rather than a wrapped counter.
-- Concretely — a tenant holding ten thousand sockets continuously accrues 5.26
-- BILLION connection-minutes a year, and `integer` tops out at 2,147,483,647,
-- about five months in.
 
ALTER TABLE usage_periods
  ADD COLUMN connection_minutes bigint NOT NULL DEFAULT 0,
  ADD CONSTRAINT usage_periods_connection_minutes_non_negative
    CHECK (connection_minutes >= 0);
 
-- ---------------------------------------------------------------------------
-- The state that makes a repeated report free.
-- ---------------------------------------------------------------------------
--
-- A REPORT SAYS WHAT A CONNECTION HAS CONSUMED IN TOTAL, not what it consumed
-- since last time, and that one decision removes the retry buffer the gateway
-- would otherwise need. A lost report is repaired by the next one, because the
-- next one carries the same total plus whatever accrued. A repeated one credits
-- `max(0, reported - credited) = 0`. A report that cannot be delivered is
-- DROPPED rather than queued — the gateway keeps no outbox, which is the right
-- amount of durable state for a service designed to hold none.
--
-- WHY NOT ONE ROW PER MINUTE. That is the naive dedup key: remember which
-- minutes have been credited. At a thousand concurrent sockets it is
-- 1,000 x 43,200 = 43.2 MILLION rows a month. This table is proportional to
-- distinct connections instead — chapter 3.10 made exactly this trade for
-- distinct users and bounded it by users rather than by traffic.
--
-- `connection_id` ALONE WOULD BE UNIQUE — it is a `randomUUID()` minted by the
-- gateway — but `period` is in the key because a connection open across a month
-- boundary owes minutes to two periods and each is credited independently.
--
-- A CONNECTION MAY NOT CHANGE ENVIRONMENT. `environment_id` is written by the
-- first report and never updated; a later report naming a different one is
-- refused with a 409 rather than reconciled. A connection moving tenants is
-- either a bug or an attempt, and constitution I makes that a correctness
-- question rather than a data-quality one.
--
-- NO SECONDARY INDEX, and that is a decision. A first draft added
-- `(environment_id, period)`; nothing reads it. The credit path looks a row up
-- by primary key and the figure an operator reads comes from `usage_periods`.
-- The one job that would have used it is pruning a finished period, which this
-- chapter declines — so nothing prunes this table, and it grows at roughly the
-- tenant's distinct connections per period: about 720,000 rows a month for a
-- thousand sockets turning over hourly. Written down here rather than
-- discovered by whoever opens the table first.
 
CREATE TABLE usage_connections (
  connection_id  uuid        NOT NULL,
  period         date        NOT NULL,
  environment_id uuid        NOT NULL REFERENCES environments(id),
  minutes        bigint      NOT NULL DEFAULT 0,
  first_seen_at  timestamptz NOT NULL DEFAULT now(),
  last_seen_at   timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (connection_id, period),
  CONSTRAINT usage_connections_minutes_non_negative CHECK (minutes >= 0)
);
 
-- ---------------------------------------------------------------------------
-- A third dimension in an existing column.
-- ---------------------------------------------------------------------------
--
-- No fifth table. Chapter 3.10 said "four concrete tables that look alike is a
-- pattern, one abstract table serving four purposes is a framework", and a third
-- dimension in the fourth table is neither. `quota_notifications_once_per_threshold`
-- already keys on `(environment_id, period, dimension, threshold)`, so
-- at-most-one-email-per-threshold holds for the new dimension without a line of
-- code.
 
ALTER TABLE quota_notifications
  DROP CONSTRAINT quota_notifications_dimension_check;
 
ALTER TABLE quota_notifications
  ADD CONSTRAINT quota_notifications_dimension_check
    CHECK (dimension IN ('messages', 'active_users', 'connection_minutes'));
services/api/src/quotas/credit.ts
/** What a usage report is worth, in one function (chapter 3.11, FR-RTL-05/FR-RTL-05).
 *
 * A report says what a connection has consumed IN TOTAL in a period, not what it
 * consumed since the last report. Everything the protocol gets from that
 * decision reduces to this line:
 *
 *   - a report delivered twice credits nothing the second time
 *   - a report that was lost is repaid by the next one, which carries the same
 *     total plus whatever accrued since
 *   - two reports that arrive out of order credit the higher one and leave the
 *     figure alone for the lower
 *
 * PURE, AND SEPARATE FROM THE TRANSACTION THAT USES IT, because those three
 * properties are the chapter's argument and each is one line to test here and a
 * database round trip to test anywhere else. Chapter 3.6 separated `disable.ts`
 * for the same reason and chapter 3.8 separated `bucket.ts`.
 *
 * `credited` is what the accounting row already holds; `reported` is what the
 * gateway now claims. Neither is ever negative — the schema refuses that at the
 * door and the CHECK refuses it at the table — but the `max` is here rather than
 * assumed, because the one thing this function must never do is subtract from a
 * bill. */
export function creditFor(reported: number, credited: number): number {
  return Math.max(0, reported - credited);
}
 
/** The new stored total after crediting. `greatest`, not `reported`, so a report
 * that arrives late and low cannot walk a figure backwards. */
export function highWaterMark(reported: number, credited: number): number {
  return Math.max(reported, credited);
}
services/gateway/src/meter.ts
import type { InternalUsageReportEntry } from "@relay/protocol";
import type { Logger } from "@relay/service-kit";
 
import type { ApiClient } from "./api-client.js";
import type { Connection, Registry } from "./registry.js";
 
// Connection-minutes, from the service that cannot write them (chapter 3.11).
//
// The api can count messages and distinct users because both are already rows.
// It cannot count a connection: nothing records one, and the only process that
// can see one is this one — which owns no tables (ADR-05, and `registry.ts`
// states the property: "no pg, no drizzle-orm, no repository import"). So the
// gateway observes and the api records, and everything interesting lives in the
// protocol between them.
//
// This file has two halves and they are separated on purpose. Below is the pure
// one — arithmetic over instants, no timer, no transport, no api client — which
// is the half every timing assertion in this chapter drives on a clock it
// supplies. The timer and the report live under it.
 
/** The usage period an instant belongs to. DUPLICATED FROM THE API, deliberately.
 *
 * `limits.ts` next door already duplicates the api's window arithmetic and says
 * why: "a package for two small functions would be an abstraction constitution
 * VII asks to be justified, and this one could not be." The same argument holds
 * here and the stakes are higher — a rate-limit window that disagrees between two
 * services costs one window of over-service, while a PERIOD that disagrees puts a
 * tenant's minutes in a month nobody reads. So the copy is pinned by a drift test
 * in both packages rather than by good intentions (research R18). */
export function periodOf(at: Date): string {
  const year = at.getUTCFullYear();
  const month = String(at.getUTCMonth() + 1).padStart(2, "0");
  return `${year}-${month}-01`;
}
 
/** The minute an instant belongs to — the unit itself, floored, in UTC. */
export function minuteOf(at: Date): string {
  const y = at.getUTCFullYear();
  const mo = String(at.getUTCMonth() + 1).padStart(2, "0");
  const d = String(at.getUTCDate()).padStart(2, "0");
  const h = String(at.getUTCHours()).padStart(2, "0");
  const mi = String(at.getUTCMinutes()).padStart(2, "0");
  return `${y}-${mo}-${d}T${h}:${mi}`;
}
 
const MINUTE_MS = 60_000;
 
/** Floor an instant to the start of its minute. The bucket a connection is
 * charged for is a span of wall clock, not an offset from when it opened. */
function floorToMinute(at: Date): number {
  return Math.floor(at.getTime() / MINUTE_MS) * MINUTE_MS;
}
 
/** How many minute buckets a connection has occupied, per period.
 *
 * A CONNECTION IS CHARGED FOR EVERY CALENDAR MINUTE IT WAS OPEN FOR ANY PART OF.
 * Open at 00:00:59 and closed at 00:01:01 is two seconds of wall clock and TWO
 * connection-minutes, because it was present in both minutes. A five-second
 * socket costs one. That charges reconnect churn, which summing seconds does
 * not, and it is the answer this chapter gives to the open question in
 * `docs/04-srs.md` about per-second precision.
 *
 * SPLIT BY PERIOD, because a socket open across midnight on the first owes
 * minutes to two months and each is credited independently (FR-RTL-05). The api is
 * never asked to do this arithmetic on the gateway's behalf: the wire carries one
 * entry per period, already decided.
 *
 * Counts from `openedAt`'s bucket through `at`'s INCLUSIVE, so a connection that
 * has just opened already owes its first minute. Returning zero for a fresh
 * socket would make a report for it indistinguishable from no report at all. */
export function bucketsFor(openedAt: Date, at: Date): Record<string, number> {
  const first = floorToMinute(openedAt);
  const last = floorToMinute(at);
  const totals: Record<string, number> = {};
  if (last < first) return totals;
  for (let ms = first; ms <= last; ms += MINUTE_MS) {
    const period = periodOf(new Date(ms));
    totals[period] = (totals[period] ?? 0) + 1;
  }
  return totals;
}
 
// --- the timer half -------------------------------------------------------
 
/** 60 seconds, to match the unit. A SECOND TIMER RATHER THAN THE HEARTBEAT'S:
 * `PING_INTERVAL_MS` is 30s because EIR-WS-04 wants a dead socket noticed
 * promptly, and billing cadence and liveness cadence are different requirements.
 * One number answering to both means the next change to either argues with the
 * other (research R10). */
export const METER_INTERVAL_MS = 60_000;
 
/** How many closed connections to hold before dropping the oldest.
 *
 * Bounded by closes since the last ACCEPTED report, not by time. At the default
 * interval a gateway would have to close four thousand sockets inside one minute
 * with the api unreachable to reach this, which is a mass disconnect during an
 * outage — and dropping the oldest under-counts, which is the same direction as
 * every other loss in this design and the opposite of billing for a socket
 * nobody holds. */
export const MAX_RETAINED_CLOSED = 4_000;
 
export interface Meter {
  /** A socket closed. Its final totals are handed over here, because the
   * registry has already forgotten it by the time anything else could ask
   * (research R19). */
  closed(connection: Connection, at: Date): void;
  /** Send one report for everything currently owed. Exposed for the timer, for
   * the shutdown flush, and for tests that drive their own clock. */
  reportOnce(at: Date): Promise<void>;
  /** How many closed connections are waiting for an accepted report. Zero for
   * open ones, always — they need no retention, because their next report
   * carries the same total plus whatever accrued. */
  retained(): number;
  /** How many entries were discarded at the cap. Counted rather than silent
   * (FR-RTL-05). */
  dropped(): number;
  stop(): void;
}
 
export interface MeterOptions {
  api: ApiClient;
  registry: Registry;
  logger: Logger;
  intervalMs?: number;
  now?: () => Date;
}
 
/** The meter (chapter 3.11).
 *
 * WHAT IT SENDS IS A TOTAL, NOT AN INCREMENT, and everything else here follows
 * from that. A lost report is repaired by the next one; a repeated one credits
 * nothing; a report that cannot be delivered is DROPPED rather than queued. The
 * gateway holds no outbox — which is the right amount of durable state for a
 * service designed to hold none (research R3).
 *
 * WITH ONE EXCEPTION, AND IT IS THE HONEST HALF OF THAT CLAIM. A connection that
 * has CLOSED has no next report to repair a lost one, so its final total is
 * retained until a report carrying it is accepted. R3's reasoning holds for open
 * connections and stops exactly here. */
export function createMeter({
  api,
  registry,
  logger,
  intervalMs = METER_INTERVAL_MS,
  now = () => new Date(),
}: MeterOptions): Meter {
  /** Keyed by `connection_id|period`, so a socket that spanned a month boundary
   * retains both of its entries and neither overwrites the other. */
  const closedEntries = new Map<string, InternalUsageReportEntry>();
  let discarded = 0;
 
  function entriesFor(
    connection: Connection,
    at: Date,
  ): InternalUsageReportEntry[] {
    return Object.entries(bucketsFor(connection.openedAt, at)).map(
      ([period, minutes]) => ({
        connection_id: connection.id,
        environment_id: connection.environmentId,
        period,
        minutes,
      }),
    );
  }
 
  function closed(connection: Connection, at: Date): void {
    for (const entry of entriesFor(connection, at)) {
      if (
        closedEntries.size >= MAX_RETAINED_CLOSED &&
        !closedEntries.has(`${entry.connection_id}|${entry.period}`)
      ) {
        // Oldest first — a Map iterates in insertion order, and the oldest
        // entry is the one whose minutes are least likely to still matter.
        const oldest = closedEntries.keys().next().value;
        if (oldest !== undefined) closedEntries.delete(oldest);
        discarded += 1;
        logger.log("error", "meter.retention_overflow", {
          discarded,
          retained: closedEntries.size,
        });
      }
      closedEntries.set(`${entry.connection_id}|${entry.period}`, entry);
    }
  }
 
  async function reportOnce(at: Date): Promise<void> {
    const open = registry.all().flatMap((c) => entriesFor(c, at));
    const closedNow = [...closedEntries.values()];
    const connections = [...closedNow, ...open];
    if (connections.length === 0) return;
 
    try {
      const answer = await api.reportUsage({ connections });
      // Null means no credential is configured, which is not an acceptance —
      // holding the closed entries would grow without bound in a gateway that
      // will never meter, so they go.
      if (answer === null) {
        closedEntries.clear();
        return;
      }
      // ACCEPTED. Only now do the closed ones go: their minutes are recorded and
      // nothing will carry them again.
      for (const entry of closedNow) {
        closedEntries.delete(`${entry.connection_id}|${entry.period}`);
      }
    } catch (error) {
      // A failed report closes nothing, refuses nothing, and fails nothing
      // (constitution III). Open connections need no action — their next report carries
      // the same total plus whatever accrued — and the closed ones stay.
      logger.log("error", "meter.report_failed", {
        connections: connections.length,
        retained: closedEntries.size,
        error: String(error),
      });
    }
  }
 
  const timer = setInterval(() => {
    void reportOnce(now());
  }, intervalMs);
 
  return {
    closed,
    reportOnce,
    retained: () => closedEntries.size,
    dropped: () => discarded,
    stop: () => clearInterval(timer),
  };
}
services/api/src/internal/usage.controller.ts
import {
  Body,
  ConflictException,
  Controller,
  HttpCode,
  Inject,
  Post,
  UseGuards,
} from "@nestjs/common";
 
import {
  internalUsageReportRequestSchema,
  type InternalUsageReportRequest,
  type InternalUsageReportResponse,
} from "@relay/protocol";
 
import { Accepts, CredentialGuard } from "../auth/credential.guard";
import type { Db } from "../db/client";
import {
  ConnectionEnvironmentConflictError,
  creditConnectionMinutes,
} from "../db/repository";
import { ZodValidationPipe } from "../messages/zod-validation.pipe";
 
// The gateway's only road to a number it can see and cannot write (chapter
// 3.11, constitution IV).
//
// A SEPARATE CONTROLLER FROM THE OTHER `/internal` ROUTES, and the reason is the
// decorator two lines below. `/internal/session`, `/internal/backfill` and
// `/internal/messages` are all `@Accepts("user")` — each is a user's action
// taken through a socket, and the gateway forwards the token it was handed. A
// usage report is nobody's action. Mixing the two credential classes inside one
// controller would make the class-level decorator stop being the answer to "who
// may call this", which is what `dispatch.controller.ts` avoided the same way.
//
// `@Accepts("platform")` AND NOTHING ELSE. An `application` credential is scoped
// to one environment by construction and a report names environments in its
// body: a route that accepted one would either be useless to the gateway or
// would have to ignore that scope, and ignoring a tenant scope is the shape a
// cross-tenant hole takes.
@Controller("internal/usage")
@UseGuards(CredentialGuard)
@Accepts("platform")
export class UsageController {
  constructor(@Inject("DB") private readonly db: Db) {}
 
  /** `POST /internal/usage/connections` — one request carries every connection
   * the reporting instance holds, open and just-closed alike.
   *
   * IDEMPOTENT IN WHOLE AND IN PART, because each entry states a TOTAL rather
   * than an increment. Re-sending the whole batch credits only what is still
   * owed, so a caller that timed out and retried never has to reason about how
   * much of its last request landed. */
  @Post("connections")
  // 200: nothing is created that the caller can address. The rows are
  // bookkeeping, and the only thing worth telling the caller is how much of what
  // it claimed was new.
  @HttpCode(200)
  async report(
    @Body(new ZodValidationPipe(internalUsageReportRequestSchema))
    body: InternalUsageReportRequest,
  ): Promise<InternalUsageReportResponse> {
    try {
      const credited = await creditConnectionMinutes(
        this.db,
        body.connections.map((c) => ({
          connectionId: c.connection_id,
          environmentId: c.environment_id,
          period: c.period,
          minutes: c.minutes,
        })),
      );
      return { credited };
    } catch (error) {
      // A connection does not move between tenants. Refused rather than
      // reconciled, because reconciling would mean inventing a fact about whose
      // minutes these are (constitution I).
      //
      // Named here rather than left to `ProtocolErrorFilter`: the filter infers
      // a code for four statuses and calls everything else `internal_error`, and
      // 409 is not one of the four.
      if (error instanceof ConnectionEnvironmentConflictError) {
        throw new ConflictException({
          code: "connection_environment_conflict",
          message:
            "this connection was first reported for a different environment",
        });
      }
      throw error;
    }
  }
}
services/api/src/quotas/period.ts
@@ -23,3 +23,38 @@ export function periodOf(at: Date): string {
   const month = String(at.getUTCMonth() + 1).padStart(2, "0");
   return `${year}-${month}-01`;
 }
+
+/** The minute an instant belongs to: `YYYY-MM-DDTHH:MM`, in UTC.
+ *
+ * THE UNIT OF THIS CHAPTER, and it is a wall-clock bucket rather than a
+ * duration. A connection open for any part of a minute has occupied that
+ * minute; five seconds costs one, and 00:00:59 to 00:01:01 costs two. That
+ * charges reconnect churn, which summing seconds does not, and it makes the
+ * identity of a minute the natural key for deduplicating a repeated report
+ * (research R2).
+ *
+ * TAKES AN INSTANT, LIKE `periodOf` ABOVE, and for a sharper reason. Every
+ * acceptance scenario this chapter has is stated in calendar minutes — three
+ * boundaries, a socket that lives inside one interval, ten intervals after a
+ * kill. A function that called `now()` would make each of them a real wait, and
+ * the suite would take longer than the twenty-run battery.
+ *
+ * A STRING, not a number, so a bucket prints legibly in a failure message and
+ * sorts lexically in the same order it sorts chronologically. */
+export function minuteOf(at: Date): string {
+  const y = at.getUTCFullYear();
+  const mo = String(at.getUTCMonth() + 1).padStart(2, "0");
+  const d = String(at.getUTCDate()).padStart(2, "0");
+  const h = String(at.getUTCHours()).padStart(2, "0");
+  const mi = String(at.getUTCMinutes()).padStart(2, "0");
+  return `${y}-${mo}-${d}T${h}:${mi}`;
+}
+
+/** The period a minute bucket belongs to — `minuteOf`'s output back to
+ * `periodOf`'s. The gateway reports buckets and the api credits periods, and a
+ * connection open across midnight on the first owes minutes to two of them
+ * (FR-RTL-05). Parsing here rather than at each call site keeps one definition of
+ * how a bucket string decomposes. */
+export function periodOfMinute(minute: string): string {
+  return `${minute.slice(0, 7)}-01`;
+}
services/api/src/quotas/config.ts
@@ -30,11 +30,16 @@ export const quotaConfigSchema = z
   .object({
     messages: capsSchema.optional(),
     active_users: capsSchema.optional(),
+    // Chapter 3.11. This is the key the comment below predicted, and adding it
+    // costs what the comment said plus three clauses in the migration's CHECK
+    // rather than one line — 0010 counts the difference.
+    connection_minutes: capsSchema.optional(),
   })
   // `.strict()` so a dimension nobody implemented is a parse failure rather than
-  // a silently ignored cap. Chapter 3.11 adds connection-minutes by adding a key
-  // here and a line to the migration's CHECK — the cost the jsonb shape trades
-  // for not needing a table migration.
+  // a silently ignored cap — which is also why a new key has to land HERE and in
+  // the migration together: the constraint would accept a `connection_minutes`
+  // config that this parser rejected, and `capsFor` fails closed, so the cap
+  // would silently become no cap.
   .strict();
 
 export type QuotaConfig = z.infer<typeof quotaConfigSchema>;
services/api/src/quotas/quota.error.ts
@@ -3,6 +3,30 @@ import type { QuotaConfig } from "./config";
 /** The dimensions a quota is measured in. `connection_minutes` is chapter 3.11. */
 export type Dimension = keyof QuotaConfig;
 
+/** What each dimension is called to a customer, and what stops when it runs out.
+ *
+ * TABLES RATHER THAN A TERNARY, because chapter 3.11 is where the ternary broke.
+ * The old code read `dimension === "messages" ? "message" : "active user"`, and
+ * `Dimension` is `keyof QuotaConfig` — so adding `connection_minutes` to the
+ * config schema widened this type on its own and a connection-minutes breach
+ * would have rendered "monthly ACTIVE USER quota exhausted". The compiler catches
+ * nothing; a `Record<Dimension, string>` makes a missing dimension a build error.
+ *
+ * And what resumes is not always sending. A connection-minutes cap refuses
+ * connects, so telling a developer at 3am that "sends resume on the first" names
+ * the wrong operation. */
+const NOUN: Record<Dimension, string> = {
+  messages: "message",
+  active_users: "active user",
+  connection_minutes: "connection-minute",
+};
+
+const RESUMES: Record<Dimension, string> = {
+  messages: "sends",
+  active_users: "sends",
+  connection_minutes: "connections",
+};
+
 /** Raised by the repository when a send would exceed a hard cap.
  *
  * NOT AN HTTP CONCERN. The repository layer does not know what status a caller
@@ -32,7 +56,8 @@ export class QuotaExceededError extends Error {
     this.period = args.period;
   }
 
-  /** The date sends resume: midnight UTC on the first of the next month.
+  /** The date the refused operation resumes: midnight UTC on the first of the
+   * next month.
    *
    * In the message rather than in a `Retry-After` header, and that is the whole
    * argument for `402` over `429`. A client that sleeps for the header's value
@@ -50,9 +75,9 @@ export class QuotaExceededError extends Error {
    * changes (contracts/quota.md §1). */
   publicMessage(): string {
     return (
-      `monthly ${this.dimension === "messages" ? "message" : "active user"} ` +
-      `quota exhausted: ${this.usage} of ${this.quota} for ${this.period}; ` +
-      `sends resume on ${this.resumesOn()}`
+      `monthly ${NOUN[this.dimension]} quota exhausted: ` +
+      `${this.usage} of ${this.quota} for ${this.period}; ` +
+      `${RESUMES[this.dimension]} resume on ${this.resumesOn()}`
     );
   }
 }
services/api/src/quotas/quota-email.ts
@@ -16,6 +16,26 @@ export interface CrossingFacts {
 const NOUN: Record<string, string> = {
   messages: "messages",
   active_users: "active users",
+  // Chapter 3.11. Hyphenated, as the customer-facing name — the column is
+  // `connection_minutes` and nobody reads a bill in snake case.
+  connection_minutes: "connection-minutes",
+};
+
+/** What stops when THIS dimension's hard cap is reached, in the words a reader
+ * needs (chapter 3.11).
+ *
+ * "Sends are now being refused" is right for two dimensions and wrong for the
+ * third: a connection-minutes cap refuses CONNECTS, and everything the tenant
+ * already has keeps working. An email that names the wrong operation sends
+ * somebody looking for a fault in the half that is fine. */
+const DEFAULT_STOPPAGE = "Sends are now being refused with `quota_exceeded`.";
+
+const STOPPAGE: Record<string, string> = {
+  messages: DEFAULT_STOPPAGE,
+  active_users: DEFAULT_STOPPAGE,
+  connection_minutes:
+    "New connections are now being refused with `quota_exceeded` and close code 4008. " +
+    "Connections already open stay open, and sends and history reads over REST are unaffected.",
 };
 
 /** The month, as a month. `2026-08-01` is a row key, not something to show a
@@ -57,7 +77,7 @@ export function quotaThreshold(facts: CrossingFacts): Mail {
     facts.threshold < 100
       ? "Nothing has been refused. This is a warning so the month does not end in a surprise."
       : facts.hardCapInForce
-        ? `Sends are now being refused with \`quota_exceeded\`. They resume in ${resumesOn(facts.period)}, or as soon as the quota is raised.`
+        ? `${STOPPAGE[facts.dimension] ?? DEFAULT_STOPPAGE} They resume in ${resumesOn(facts.period)}, or as soon as the quota is raised.`
         : "Nothing has been refused: this environment has no hard cap, only the threshold you asked to be told about.";
 
   const text = [
services/api/src/db/schema.ts
@@ -727,6 +727,12 @@ export const usagePeriods = pgTable(
     messagesSent: bigint("messages_sent", { mode: "number" })
       .notNull()
       .default(0),
+    // Chapter 3.11's third figure, same type for the same reason. Ten thousand
+    // sockets held continuously accrue 5.26 billion connection-minutes a year
+    // and `integer` stops at 2,147,483,647 — about five months in.
+    connectionMinutes: bigint("connection_minutes", { mode: "number" })
+      .notNull()
+      .default(0),
     createdAt: timestamp("created_at", { withTimezone: true })
       .notNull()
       .defaultNow(),
@@ -737,6 +743,49 @@ export const usagePeriods = pgTable(
       "usage_periods_messages_sent_non_negative",
       sql`${t.messagesSent} >= 0`,
     ),
+    check(
+      "usage_periods_connection_minutes_non_negative",
+      sql`${t.connectionMinutes} >= 0`,
+    ),
+  ],
+);
+
+// One row per connection per period, and the whole of chapter 3.11's
+// idempotency (research R4).
+//
+// A report says what a connection has consumed IN TOTAL, not since last time.
+// The api credits `max(0, reported - credited)` and stores the new total, so a
+// replayed report credits nothing and a lost one is repaired by the next. That
+// is what lets the gateway keep no outbox at all.
+//
+// NOT ONE ROW PER MINUTE, which is the obvious dedup key and 43.2 million rows a
+// month at a thousand concurrent sockets. Bounded by connections instead — the
+// same trade `usageActiveUsers` above makes for users against traffic.
+//
+// `period` is in the key because a connection open across a month boundary owes
+// minutes to two periods; `connectionId` alone would already be unique.
+export const usageConnections = pgTable(
+  "usage_connections",
+  {
+    connectionId: uuid("connection_id").notNull(),
+    period: date("period").notNull(),
+    // Written by the first report and never updated. A later report naming a
+    // different environment for this connection is refused, not reconciled: a
+    // connection does not move between tenants (constitution I).
+    environmentId: uuid("environment_id")
+      .notNull()
+      .references(() => environments.id),
+    minutes: bigint("minutes", { mode: "number" }).notNull().default(0),
+    firstSeenAt: timestamp("first_seen_at", { withTimezone: true })
+      .notNull()
+      .defaultNow(),
+    lastSeenAt: timestamp("last_seen_at", { withTimezone: true })
+      .notNull()
+      .defaultNow(),
+  },
+  (t) => [
+    primaryKey({ columns: [t.connectionId, t.period] }),
+    check("usage_connections_minutes_non_negative", sql`${t.minutes} >= 0`),
   ],
 );
 
services/api/src/db/repository.ts
@@ -29,6 +29,7 @@ import {
   outbox,
   quotaNotifications,
   usageActiveUsers,
+  usageConnections,
   usagePeriods,
   users,
   webhookDeadLetters,
@@ -39,6 +40,7 @@ import {
 import { messageCreatedEvent } from "../outbox/event";
 import { capsFor, type Caps } from "../quotas/config";
 import { thresholdsCrossed } from "../quotas/policy";
+import { creditFor, highWaterMark } from "../quotas/credit";
 import { QuotaExceededError, type Dimension } from "../quotas/quota.error";
 import { periodOf } from "../quotas/period";
 import { nextAttemptAt } from "../webhooks/schedule";
@@ -284,6 +286,313 @@ export async function environmentLimits(
   };
 }
 
+/** Write a row for each threshold a usage increase crossed (chapter 3.10,
+ * FR-RTL-07), and the organisation to tell about it.
+ *
+ * STANDALONE SINCE CHAPTER 3.11, and the reason is the same one `usageFor` and
+ * `creditConnectionMinutes` give: `Repository` closes over an `environmentId` by
+ * construction, and the caller that now needs this is the usage report route,
+ * which holds a PLATFORM principal and therefore no environment at all. The
+ * private methods on `Repository` stay as one-line delegations, so chapter 3.10's
+ * two call sites inside `sendMessage` read exactly as they did.
+ *
+ * Copying the crossing logic into the platform path instead would have made a
+ * fifth place that has to agree about thresholds. Moving it costs about forty
+ * lines and changes no behaviour, which T056a verified against the full lane
+ * before anything was written on top of it.
+ *
+ * IN THE SAME TRANSACTION AS THE THING THAT CAUSED IT. The crossing and the
+ * credit commit together or neither does — which is also why there is no periodic
+ * sweep in either chapter: usage only ever rises because of an event, and the
+ * event knows the value before and after, so it knows what it crossed.
+ *
+ * THE PERCENTAGE IS OF `hard ?? soft`. A soft threshold with no hard cap is still
+ * a figure an operator asked to be warned about.
+ *
+ * `ON CONFLICT DO NOTHING` against `quota_notifications_once_per_threshold` is
+ * what makes it at-most-once — the schema, not this code. */
+export async function recordCrossings(
+  tx: Db,
+  environmentId: string,
+  period: string,
+  dimension: Dimension,
+  before: number,
+  after: number,
+  caps: { hard: number | null; soft: number | null },
+  organisationId: string,
+): Promise<void> {
+  const reference = caps.hard ?? caps.soft;
+  if (reference === null) return;
+  const crossed = thresholdsCrossed(before, after, reference);
+  if (crossed.length === 0) return;
+
+  await tx
+    .insert(quotaNotifications)
+    .values(
+      crossed.map((threshold) => ({
+        id: randomUUID(),
+        environmentId,
+        organisationId,
+        period,
+        dimension,
+        threshold,
+        quota: reference,
+        usageAtCrossing: after,
+      })),
+    )
+    .onConflictDoNothing();
+}
+
+/** The organisation an environment belongs to — who gets told. */
+export async function organisationOf(
+  tx: Db,
+  environmentId: string,
+): Promise<string | null> {
+  const [row] = await tx
+    .select({ organisationId: applications.organisationId })
+    .from(environments)
+    .innerJoin(applications, eq(applications.id, environments.applicationId))
+    .where(eq(environments.id, environmentId));
+  return row?.organisationId ?? null;
+}
+
+/** Everything the connect path needs, in ONE round trip (chapter 3.11,
+ * FR-RTL-05, FR-RTL-06).
+ *
+ * ENFORCED AT THE DOOR, because that is the operation this dimension meters. The
+ * messages cap refuses sends; the connection-minutes cap refuses connects. A cap
+ * that only refused sends would leave an idle listener burning the metered
+ * resource with nothing to stop it.
+ *
+ * ONE QUERY, AND THE PLAN SAID TWO. Research R7 chose "a second call on the same
+ * request rather than a heavier version of the first", because chapter 3.10's H2
+ * had refused to put a usage join inside `environmentLimits`. H2 is still right
+ * and `environmentLimits` is untouched — its OTHER caller is
+ * `rate-limit.middleware.ts`, which runs on every `/v1` request and must not pay
+ * for a join it never reads.
+ *
+ * But two calls cost what a join would have, at concurrency, and it was measured:
+ * connect latency at 32-way went from 15.0ms to 17.6ms across four runs clustered
+ * inside 0.7ms, and folding them back recovered 0.8ms of it. The mechanism is the
+ * one chapter 3.10's T033 already recorded — an extra round trip holds a pooled
+ * connection for the duration, and above the pool size that queues.
+ *
+ * THE UNCONFIGURED TENANT still pays one indexed read and leaves. */
+export async function connectPolicy(
+  db: Db,
+  environmentId: string,
+  period: string,
+): Promise<{
+  limits: Record<LimitedOperation, number>;
+  used: number;
+  caps: Caps;
+}> {
+  const [row] = await db
+    .select({
+      rest: environments.restLimitPerMinute,
+      send: environments.sendLimitPerMinute,
+      connect: environments.connectLimitPerMinute,
+      quotaConfig: environments.quotaConfig,
+      connectionMinutes: usagePeriods.connectionMinutes,
+    })
+    .from(environments)
+    .leftJoin(
+      usagePeriods,
+      and(
+        eq(usagePeriods.environmentId, environments.id),
+        eq(usagePeriods.period, period),
+      ),
+    )
+    .where(eq(environments.id, environmentId));
+
+  const limits = {
+    rest: row?.rest ?? DEFAULT_LIMITS.rest,
+    send: row?.send ?? DEFAULT_LIMITS.send,
+    connect: row?.connect ?? DEFAULT_LIMITS.connect,
+  };
+  const caps = capsFor(row?.quotaConfig, "connection_minutes").caps;
+  const used = row?.connectionMinutes ?? 0;
+
+  if (caps.hard !== null && used >= caps.hard) {
+    throw new QuotaExceededError({
+      dimension: "connection_minutes",
+      usage: used,
+      quota: caps.hard,
+      period,
+    });
+  }
+  return { limits, used, caps };
+}
+
+/** The same verdict without the limits, for callers that need only the answer. */
+export async function assertConnectionsWithinQuota(
+  db: Db,
+  environmentId: string,
+  period: string,
+): Promise<{ used: number; caps: Caps }> {
+  const { used, caps } = await connectPolicy(db, environmentId, period);
+  return { used, caps };
+}
+
+/** Credit a batch of usage reports (chapter 3.11, FR-RTL-05/FR-RTL-05/FR-RTL-05).
+ *
+ * A STANDALONE FUNCTION, NOT A `Repository` METHOD, and the reason is the same
+ * one `usageFor` below gives: the caller is the platform, not a tenant.
+ * `Repository` closes over an `environmentId` by construction — that is
+ * constitution I expressed as a type — and a platform principal deliberately
+ * carries none. `expandEventToDeliveries` and `recordAttemptOutcome` set this
+ * precedent for the dispatcher's routes; this is the gateway's.
+ *
+ * WHAT MAKES A REPLAY FREE. A report says what a connection has consumed IN
+ * TOTAL, so the credit is `max(0, reported - credited)` and the stored figure is
+ * `max(reported, credited)`. Both live in `quotas/credit.ts`, pure and tested
+ * without a database, because those two lines are the whole protocol.
+ *
+ * THE LOCK CHAPTER 3.10 WANTED AND COULD NOT HAVE. Crediting is read-then-write,
+ * so it takes `SELECT … FOR UPDATE` on the accounting row. 3.10 needed the same
+ * lock on the usage row and hit `FOR UPDATE cannot be applied to the nullable
+ * side of an outer join`, because its caps and usage had become one joined read.
+ * Here the lock is a single table by primary key and Postgres allows it — the
+ * same instinct, in the one place it is permitted.
+ *
+ * A CONNECTION MAY NOT CHANGE ENVIRONMENT. The row's `environment_id` is written
+ * by the first report and never updated; a later report naming a different one
+ * throws rather than reconciling. A connection moving tenants is either a bug or
+ * an attempt, and constitution I makes that a correctness question. */
+export class ConnectionEnvironmentConflictError extends Error {
+  readonly connectionId: string;
+
+  constructor(connectionId: string) {
+    super(`connection ${connectionId} was first reported for another environment`);
+    this.name = "ConnectionEnvironmentConflictError";
+    this.connectionId = connectionId;
+  }
+}
+
+export async function creditConnectionMinutes(
+  db: Db,
+  entries: ReadonlyArray<{
+    connectionId: string;
+    environmentId: string;
+    period: string;
+    minutes: number;
+  }>,
+): Promise<number> {
+  return db.transaction(async (tx) => {
+    let credited = 0;
+    /** Per `environment|period`, the roll-up before this batch touched it and
+     * after. Collected during the credit and used for the crossings below. */
+    const moved = new Map<
+      string,
+      { environmentId: string; period: string; before: number; after: number }
+    >();
+    for (const entry of entries) {
+      const [existing] = await tx
+        .select({
+          minutes: usageConnections.minutes,
+          environmentId: usageConnections.environmentId,
+        })
+        .from(usageConnections)
+        .where(
+          and(
+            eq(usageConnections.connectionId, entry.connectionId),
+            eq(usageConnections.period, entry.period),
+          ),
+        )
+        .for("update");
+
+      if (existing && existing.environmentId !== entry.environmentId) {
+        throw new ConnectionEnvironmentConflictError(entry.connectionId);
+      }
+
+      // A report naming a connection nothing has seen is accepted as that
+      // connection's FIRST. The api is never told when a connection opens — the
+      // first it hears of any of them is a report — so "unknown" and "first" are
+      // the same state and there is nothing to tell them apart with (R20).
+      const already = existing?.minutes ?? 0;
+      const delta = creditFor(entry.minutes, already);
+      const stored = highWaterMark(entry.minutes, already);
+
+      await tx
+        .insert(usageConnections)
+        .values({
+          connectionId: entry.connectionId,
+          period: entry.period,
+          environmentId: entry.environmentId,
+          minutes: stored,
+        })
+        .onConflictDoUpdate({
+          target: [usageConnections.connectionId, usageConnections.period],
+          set: { minutes: stored, lastSeenAt: new Date() },
+        });
+
+      if (delta === 0) continue;
+      credited += delta;
+
+      const [rolled] = await tx
+        .insert(usagePeriods)
+        .values({
+          environmentId: entry.environmentId,
+          period: entry.period,
+          connectionMinutes: delta,
+        })
+        .onConflictDoUpdate({
+          target: [usagePeriods.environmentId, usagePeriods.period],
+          set: {
+            connectionMinutes: sql`${usagePeriods.connectionMinutes} + ${delta}`,
+          },
+        })
+        .returning({ after: usagePeriods.connectionMinutes });
+
+      // The figure before and after, per environment per period. `RETURNING`
+      // gives the after; the before is it minus what this entry just added,
+      // which is exact because the row is being written inside this transaction.
+      const key = `${entry.environmentId}|${entry.period}`;
+      const after = rolled?.after ?? delta;
+      const seen = moved.get(key);
+      moved.set(key, {
+        environmentId: entry.environmentId,
+        period: entry.period,
+        before: seen?.before ?? after - delta,
+        after,
+      });
+    }
+
+    // THE CROSSINGS, IN THE SAME TRANSACTION AS THE CREDIT (chapter 3.11,
+    // FR-RTL-07/FR-RTL-07). The report knows the figure before and after, so it knows
+    // which thresholds it crossed — which is why this chapter has no periodic
+    // sweep either, for the second chapter running (research R5).
+    //
+    // AFTER the credit loop rather than inside it, because a batch can carry
+    // several entries for one environment and period — a socket that spanned a
+    // month boundary, or a hundred sockets on one instance — and crossing 80%
+    // once is one email however many entries pushed it there.
+    for (const group of moved.values()) {
+      const [env] = await tx
+        .select({ quotaConfig: environments.quotaConfig })
+        .from(environments)
+        .where(eq(environments.id, group.environmentId));
+      const caps = capsFor(env?.quotaConfig, "connection_minutes").caps;
+      if (caps.hard === null && caps.soft === null) continue;
+
+      const organisationId = await organisationOf(tx, group.environmentId);
+      if (organisationId === null) continue;
+
+      await recordCrossings(
+        tx,
+        group.environmentId,
+        group.period,
+        "connection_minutes",
+        group.before,
+        group.after,
+        caps,
+        organisationId,
+      );
+    }
+    return credited;
+  });
+}
+
 /** What an environment has consumed in a period, and what it is allowed
  * (chapter 3.10, FR-RTL-05).
  *
@@ -308,12 +617,19 @@ export async function usageFor(
   period: string;
   messagesSent: number;
   activeUsers: number;
+  connectionMinutes: number;
   messageQuota: number | null;
   activeUserQuota: number | null;
+  connectionMinuteQuota: number | null;
 }> {
   const [row] = await db
     .select({
       messagesSent: usagePeriods.messagesSent,
+      // Chapter 3.11's figure, read from the ROLL-UP and never summed over
+      // `usage_connections` — that sum is proportional to the tenant's
+      // connections for the month, which is chapter 3.10's R1 argument in a new
+      // costume.
+      connectionMinutes: usagePeriods.connectionMinutes,
       quotaConfig: environments.quotaConfig,
     })
     .from(environments)
@@ -340,8 +656,11 @@ export async function usageFor(
     period,
     messagesSent: row?.messagesSent ?? 0,
     activeUsers: users?.n ?? 0,
+    connectionMinutes: row?.connectionMinutes ?? 0,
     messageQuota: capsFor(row?.quotaConfig, "messages").caps.hard,
     activeUserQuota: capsFor(row?.quotaConfig, "active_users").caps.hard,
+    connectionMinuteQuota: capsFor(row?.quotaConfig, "connection_minutes").caps
+      .hard,
   };
 }
 
@@ -2772,36 +3091,21 @@ export class Repository {
     caps: { hard: number | null; soft: number | null },
     organisationId: string,
   ): Promise<void> {
-    const reference = caps.hard ?? caps.soft;
-    if (reference === null) return;
-    const crossed = thresholdsCrossed(before, after, reference);
-    if (crossed.length === 0) return;
-
-    await tx
-      .insert(quotaNotifications)
-      .values(
-        crossed.map((threshold) => ({
-          id: randomUUID(),
-          environmentId: this.environmentId,
-          organisationId,
-          period,
-          dimension,
-          threshold,
-          quota: reference,
-          usageAtCrossing: after,
-        })),
-      )
-      .onConflictDoNothing();
+    return recordCrossings(
+      tx,
+      this.environmentId,
+      period,
+      dimension,
+      before,
+      after,
+      caps,
+      organisationId,
+    );
   }
 
   /** The organisation an environment belongs to — who gets told. */
   private async organisationOf(tx: Db): Promise<string | null> {
-    const [row] = await tx
-      .select({ organisationId: applications.organisationId })
-      .from(environments)
-      .innerJoin(applications, eq(applications.id, environments.applicationId))
-      .where(eq(environments.id, this.environmentId));
-    return row?.organisationId ?? null;
+    return organisationOf(tx, this.environmentId);
   }
 
   /** Fetch a message by its idempotency key within a channel — the
services/api/src/internal/session.controller.ts
@@ -1,6 +1,8 @@
 import {
   Controller,
   HttpCode,
+  HttpException,
+  HttpStatus,
   Inject,
   Post,
   Req,
@@ -14,8 +16,9 @@ import { AUTH_DB } from "../auth/authenticate.middleware";
 import { Accepts, CredentialGuard } from "../auth/credential.guard";
 import type { RequestWithPrincipal } from "../auth/principal";
 import type { Db } from "../db/client";
-import { environmentLimits, Repository } from "../db/repository";
-import { DEFAULT_LIMITS } from "../limits/policy";
+import { connectPolicy, Repository } from "../db/repository";
+import { periodOf } from "../quotas/period";
+import { QuotaExceededError } from "../quotas/quota.error";
 
 // `POST /internal/session` (chapter 3.2) — the route that replaced
 // `GET /internal/memberships`.
@@ -64,14 +67,43 @@ export class SessionController {
     // database and must not gain one (research R12). Null columns are already
     // defaults by the time they leave the repository, so the gateway never has to
     // know that "no override" is a state.
-    const limits = await environmentLimits(this.db, principal.environmentId);
+    // Chapter 3.11. THE CAP IS ENFORCED AT THE DOOR, because a connection is the
+    // operation that consumes connection-minutes.
+    //
+    // ONE READ FOR BOTH, and the plan said two. Research R7 chose a second call
+    // rather than a heavier `environmentLimits`, because chapter 3.10's H2 had
+    // refused to put a usage join in that function — and H2 is still right, since
+    // its other caller is the rate-limit middleware on every `/v1` request. But
+    // two calls cost what a join would have: measured, connect latency at 32-way
+    // went 15.0ms to 17.6ms, and folding them back recovered 0.8ms.
+    //
+    // THE CODE IS NAMED BY THE THROWER. `ProtocolErrorFilter` is `@Catch()`-all
+    // and infers a code for four statuses; 402 is not one of them, so an unnamed
+    // refusal would reach the gateway as `internal_error` — chapter 3.10's H3.
+    let policy;
+    try {
+      policy = await connectPolicy(
+        this.db,
+        principal.environmentId,
+        periodOf(new Date()),
+      );
+    } catch (error) {
+      if (error instanceof QuotaExceededError) {
+        throw new HttpException(
+          { code: "quota_exceeded", message: error.publicMessage() },
+          HttpStatus.PAYMENT_REQUIRED,
+        );
+      }
+      throw error;
+    }
+
     return {
       environment_id: principal.environmentId,
       user: principal.userExternalId,
       channel_ids: user ? await this.repo.channelsForUser(user.id) : [],
       limits: {
-        connect: limits?.connect ?? DEFAULT_LIMITS.connect,
-        send: limits?.send ?? DEFAULT_LIMITS.send,
+        connect: policy.limits.connect,
+        send: policy.limits.send,
       },
     };
   }
services/api/src/internal/internal.module.ts
@@ -14,6 +14,7 @@ import { BackfillController } from "./backfill.controller";
 import { InternalController } from "./internal.controller";
 import { DispatchController } from "./dispatch.controller";
 import { SessionController } from "./session.controller";
+import { UsageController } from "./usage.controller";
 
 // The internal routes reuse MessagesModule's providers wholesale — the
 // request-scoped Repository, the guard, the service. One write path, two
@@ -33,6 +34,11 @@ import { SessionController } from "./session.controller";
     BackfillController,
     SessionController,
     DispatchController,
+    // Chapter 3.11. Registered HERE and not in `app.module.ts`, which carries
+    // only `HealthController` and already imports this module — a controller
+    // nobody registers is a route that does not exist, and chapter 3.10's third
+    // analysis pass found exactly that.
+    UsageController,
   ],
   providers: [
     {
services/api/src/auth/authenticate.middleware.ts
@@ -29,29 +29,65 @@ export const AUTH_DB = "AUTH_DB";
  * key; the signature decides whether to believe the claim. Getting that order
  * backwards is how a token from environment A gets accepted for environment B.
  */
-/** The internal platform credential (chapter 3.5). Configuration, never a
- * database row and never tenant data — it authenticates a SERVICE, and services
- * are deployed, not provisioned.
+/** The internal platform credentials (chapter 3.5, extended by 3.11).
+ * Configuration, never a database row and never tenant data — they authenticate
+ * a SERVICE, and services are deployed, not provisioned.
  *
  * Absent by default, which is the safe direction: with nothing configured, no
  * request can ever present a platform principal, and the internal routes that
- * require one simply refuse everybody. */
+ * require one simply refuse everybody. Each service is absent independently.
+ *
+ * ONE SECRET PER SERVICE, AND CHAPTER 3.11 IS WHY. Until this chapter there was
+ * one caller and this function ended `service: "dispatcher"`, hardcoded — which
+ * was accurate exactly as long as the dispatcher was alone. The gateway now
+ * reports connection-minutes, and `PlatformPrincipal.service` is documented as
+ * "which internal service presented it, for logs": with one shared secret, the
+ * one field in the principal that says who is asking becomes the one field that
+ * cannot be trusted.
+ *
+ * The second property is worth more than the log line. The gateway terminates
+ * connections from the public internet and the dispatcher does not, so a shared
+ * secret lets the more exposed service set the blast radius for both.
+ *
+ * THE ALTERNATIVE, REFUSED: keep one secret and have the caller name itself in a
+ * header, trusted only for logging. Chapter 3.2 spent itself removing exactly
+ * that — the gateway used to send an environment header and a user header it had
+ * invented — and "it is only for logs" is the sentence under which an asserted
+ * header survives a review. */
 export const PLATFORM_CREDENTIAL_ENV = "RELAY_INTERNAL_CREDENTIAL";
+export const GATEWAY_CREDENTIAL_ENV = "RELAY_INTERNAL_CREDENTIAL_GATEWAY";
 const PLATFORM_PREFIX = "rk_svc_";
 
-function resolvePlatformCredential(credential: string): Principal | null {
-  const configured = process.env[PLATFORM_CREDENTIAL_ENV];
-  if (!configured || configured.length < 32) return null;
-  // Constant-time-ish: compare lengths first, then every byte. A platform
-  // credential is a shared secret, and an early-exit compare on a shared secret
-  // is the one place a timing signal is worth the two lines to remove.
-  if (credential.length !== configured.length) return null;
+/** Which variable belongs to which service. The dispatcher's keeps its original
+ * name: renaming it would be a deployment change this chapter has not earned. */
+const PLATFORM_SERVICES: ReadonlyArray<readonly [string, string]> = [
+  [PLATFORM_CREDENTIAL_ENV, "dispatcher"],
+  [GATEWAY_CREDENTIAL_ENV, "gateway"],
+];
+
+/** Constant-time-ish: compare lengths first, then every byte. A platform
+ * credential is a shared secret, and an early-exit compare on a shared secret is
+ * the one place a timing signal is worth the two lines to remove. */
+function secretMatches(presented: string, configured: string): boolean {
+  if (presented.length !== configured.length) return false;
   let mismatch = 0;
-  for (let i = 0; i < credential.length; i++) {
-    mismatch |= credential.charCodeAt(i) ^ configured.charCodeAt(i);
+  for (let i = 0; i < presented.length; i++) {
+    mismatch |= presented.charCodeAt(i) ^ configured.charCodeAt(i);
+  }
+  return mismatch === 0;
+}
+
+function resolvePlatformCredential(credential: string): Principal | null {
+  // READ AT CALL TIME, NOT AT MODULE LOAD. `credentials.itest.ts` SETS these
+  // variables during the test and says so in a comment; hoisting the read into a
+  // module-level constant would break that quietly, and the suite would go green
+  // against a credential nobody configured.
+  for (const [variable, service] of PLATFORM_SERVICES) {
+    const configured = process.env[variable];
+    if (!configured || configured.length < 32) continue;
+    if (secretMatches(credential, configured)) return { kind: "platform", service };
   }
-  if (mismatch !== 0) return null;
-  return { kind: "platform", service: "dispatcher" };
+  return null;
 }
 
 export async function resolvePrincipal(
packages/protocol/src/internal.ts
@@ -328,6 +328,51 @@ export type InternalDeliveryOutcomeResponse = z.infer<
   typeof internalDeliveryOutcomeResponseSchema
 >;
 
+/** The usage report (chapter 3.11, FR-RTL-05).
+ *
+ * THE ONE CALL THE GATEWAY MAKES FOR ITSELF. Its other three —
+ * `/internal/session`, `/internal/backfill`, `/internal/messages` — forward the
+ * END USER's token, because each is a user's action taken through the socket.
+ * A usage report is nobody's action: it is the gateway's claim about many
+ * connections, across many environments, about time that has already passed. So
+ * it presents a platform credential and names its environments in the body, the
+ * way the dispatcher's routes do.
+ *
+ * `minutes` IS A TOTAL, NOT AN INCREMENT, and that single decision is why the
+ * gateway needs no retry buffer. A lost report is repaired by the next one,
+ * because the next one carries the same total plus whatever accrued since. A
+ * report delivered twice credits `max(0, reported - credited) = 0`. Reports that
+ * cannot be delivered are dropped rather than queued (research R3).
+ *
+ * ONE ENTRY PER CONNECTION PER PERIOD. A socket open across midnight on the
+ * first of the month owes minutes to two periods and sends two entries, because
+ * each period is credited independently (FR-RTL-05). */
+export const internalUsageReportEntrySchema = z.strictObject({
+  connection_id: z.string().uuid(),
+  environment_id: z.string().uuid(),
+  /** The first of a calendar month, UTC — `usage_periods`' key, not a date the
+   * caller picked. Refined rather than merely typed, because a report that
+   * named the 14th would create a period nothing reads. */
+  period: z
+    .string()
+    .regex(/^\d{4}-\d{2}-01$/, "period must be the first of a month"),
+  minutes: z.number().int().nonnegative(),
+});
+
+export const internalUsageReportRequestSchema = z.strictObject({
+  connections: z.array(internalUsageReportEntrySchema).min(1).max(5000),
+});
+
+/** `credited` is the sum of the deltas actually applied, so a replay answers
+ * `{"credited": 0}` and a caller can see its retry changed nothing.
+ *
+ * One field, because there is nothing else the caller can act on. An earlier
+ * draft of the contract carried a `refused` count beside it, which described
+ * nothing: the only refusal in this design rejects the whole request. */
+export const internalUsageReportResponseSchema = z.strictObject({
+  credited: z.number().int().nonnegative(),
+});
+
 export type InternalSendRequest = z.infer<typeof internalSendRequestSchema>;
 export type InternalSessionResponse = z.infer<
   typeof internalSessionResponseSchema
@@ -342,3 +387,12 @@ export type InternalBackfillRequest = z.infer<
 export type InternalBackfillResponse = z.infer<
   typeof internalBackfillResponseSchema
 >;
+export type InternalUsageReportEntry = z.infer<
+  typeof internalUsageReportEntrySchema
+>;
+export type InternalUsageReportRequest = z.infer<
+  typeof internalUsageReportRequestSchema
+>;
+export type InternalUsageReportResponse = z.infer<
+  typeof internalUsageReportResponseSchema
+>;
packages/protocol/src/codes.ts
@@ -28,6 +28,17 @@ export const ERROR_CODES = {
   // is invalid" is how a live secret reaches a support ticket (NFR-SEC-06).
   wrong_credential_type:
     "the credential class presented cannot use this route; the message names presented and expected",
+  // Chapter 3.11. The socket's half of a quota refusal: an error frame carrying
+  // the dimension, the figures and the resume date, sent immediately before
+  // close code 4008.
+  //
+  // REGISTERED HERE RATHER THAN WRITTEN INLINE. The frame schema types `code` as
+  // `z.string().min(1)`, so nothing forces this — but the registry is the
+  // documented vocabulary and `codes.test.ts` enforces its uniqueness, which is
+  // why chapter 3.2 put `wrong_credential_type` in it instead of inventing it at
+  // the call site.
+  quota_exceeded:
+    "a monthly quota is exhausted; the message names the dimension, the figures and the date it resumes",
 } as const;
 
 export type ErrorCode = keyof typeof ERROR_CODES;
packages/protocol/src/internal.test.ts
@@ -4,6 +4,9 @@ import {
   ALL_ANALYTICS_SUBJECT,
   ALL_EVENTS_SUBJECT,
   analyticsSubjectFor,
+  internalUsageReportEntrySchema,
+  internalUsageReportRequestSchema,
+  internalUsageReportResponseSchema,
   subjectFor,
   webhookAttemptSubject,
 } from "./internal.js";
@@ -112,3 +115,72 @@ describe("analyticsSubjectFor builds `analytics.{domain}.{action}.{env}`", () =>
     expect(() => analyticsSubjectFor("webhook", "", ENV)).toThrow(/action is required/);
   });
 });
+
+describe("the usage report (chapter 3.11)", () => {
+  const entry = (over: Record<string, unknown> = {}) => ({
+    connection_id: "0f9c8b7a-6d5e-4c3b-8a19-8f7e6d5c4b3a",
+    environment_id: "8b21c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
+    period: "2026-08-01",
+    minutes: 17,
+    ...over,
+  });
+
+  it("accepts one connection's total for one period", () => {
+    const r = internalUsageReportRequestSchema.safeParse({
+      connections: [entry()],
+    });
+    expect(r.success).toBe(true);
+  });
+
+  it("accepts two entries for one connection across a month boundary", () => {
+    // The socket that was open at midnight on the first owes minutes to two
+    // periods, and each is credited independently.
+    const r = internalUsageReportRequestSchema.safeParse({
+      connections: [entry({ minutes: 17 }), entry({ period: "2026-09-01", minutes: 3 })],
+    });
+    expect(r.success).toBe(true);
+  });
+
+  it("refuses a period that is not the first of a month", () => {
+    // A report naming the 14th would credit a period nothing reads.
+    for (const period of ["2026-08-14", "2026-08", "2026-08-01T00:00:00Z"]) {
+      expect(internalUsageReportEntrySchema.safeParse(entry({ period })).success)
+        .toBe(false);
+    }
+  });
+
+  it("refuses a negative or fractional total", () => {
+    expect(internalUsageReportEntrySchema.safeParse(entry({ minutes: -1 })).success)
+      .toBe(false);
+    expect(internalUsageReportEntrySchema.safeParse(entry({ minutes: 1.5 })).success)
+      .toBe(false);
+  });
+
+  it("accepts zero, which is what a connection reports in its first minute", () => {
+    expect(internalUsageReportEntrySchema.safeParse(entry({ minutes: 0 })).success)
+      .toBe(true);
+  });
+
+  it("refuses an unknown field, like every other schema on this contract", () => {
+    expect(
+      internalUsageReportEntrySchema.safeParse(entry({ seconds: 60 })).success,
+    ).toBe(false);
+  });
+
+  it("refuses an empty batch", () => {
+    // A report with nothing in it is a bug in the caller, not a no-op worth
+    // spending a transaction on.
+    expect(
+      internalUsageReportRequestSchema.safeParse({ connections: [] }).success,
+    ).toBe(false);
+  });
+
+  it("answers with the delta actually applied, and nothing else", () => {
+    expect(internalUsageReportResponseSchema.safeParse({ credited: 0 }).success)
+      .toBe(true);
+    expect(
+      internalUsageReportResponseSchema.safeParse({ credited: 4, refused: 0 })
+        .success,
+    ).toBe(false);
+  });
+});
services/gateway/src/registry.ts
@@ -57,6 +57,19 @@ export interface Connection {
    * the hot path of the thing the limit protects. Beside `marks` for the same
    * reason — it describes one socket and dies with it. */
   sendLimit: number;
+  /** Chapter 3.11. When this socket opened, and which environment owes for it.
+   *
+   * The meter needs both and the registry is where they belong, beside `marks`
+   * and `sendLimit` and for the same reason: this describes one socket and dies
+   * with it. Nothing here survives the instance, and nothing needs to — a
+   * connection lives on exactly one gateway and its id is minted here.
+   *
+   * `openedAt` RATHER THAN A RUNNING TOTAL. The unit is a wall-clock minute
+   * bucket, so what a connection owes is a function of when it opened and what
+   * time it is now; keeping a counter instead would need a tick to increment it
+   * and would drift the moment one was missed. */
+  openedAt: Date;
+  environmentId: string;
 }
 
 export class Registry {
services/gateway/src/api-client.ts
@@ -2,11 +2,14 @@ import {
   internalBackfillResponseSchema,
   internalSendResponseSchema,
   internalSessionResponseSchema,
+  internalUsageReportResponseSchema,
   type InternalBackfillRequest,
   type InternalBackfillResponse,
   type InternalSendRequest,
   type InternalSendResponse,
   type InternalSessionResponse,
+  type InternalUsageReportRequest,
+  type InternalUsageReportResponse,
 } from "@relay/protocol";
 
 // The gateway's only road to state (chapter 2.5, ADR-05): internal HTTP to
@@ -53,7 +56,9 @@ export interface ApiClient {
    * hear. Null means the api answered "not valid" — distinct from a throw, which
    * means it could not answer at all, and the two must not close a socket the
    * same way. */
-  session(token: string): Promise<InternalSessionResponse | null>;
+  session(
+    token: string,
+  ): Promise<InternalSessionResponse | { quotaExceeded: string } | null>;
   /** Resume backfill (chapter 2.7): everything past the cursors, per
    * channel, already shaped as wire frames. */
   backfill(
@@ -64,9 +69,29 @@ export interface ApiClient {
     identity: Identity,
     body: InternalSendRequest,
   ): Promise<InternalSendResponse>;
+  /** Chapter 3.11: the one call the gateway makes FOR ITSELF.
+   *
+   * Every other method on this interface takes an `Identity` and forwards the
+   * token inside it. This one takes none, and the absence is the design: a usage
+   * report is not a user's action, it is this process's claim about many
+   * connections across many environments. It presents the platform credential
+   * from `RELAY_INTERNAL_CREDENTIAL_GATEWAY` instead.
+   *
+   * `null` when no credential is configured — the gateway serves sockets without
+   * metering rather than refusing to start, because metering may not be a
+   * startup dependency (constitution III). */
+  reportUsage(
+    body: InternalUsageReportRequest,
+  ): Promise<InternalUsageReportResponse | null>;
 }
 
-export function createApiClient(baseUrl: string): ApiClient {
+export function createApiClient(
+  baseUrl: string,
+  /** Chapter 3.11. Absent by default and absent in every test that does not
+   * meter, which is the same safe direction the api's side takes: with nothing
+   * configured, no report is ever sent and no route is ever reached. */
+  serviceCredential?: string,
+): ApiClient {
   // Chapter 3.2 retired two headers here. The gateway used to send
   // an environment header and a user header — values it INVENTED from a token
   // it verified with a shared development secret. It now forwards the token
@@ -103,6 +128,21 @@ export function createApiClient(baseUrl: string): ApiClient {
       // it. Everything else falls through to `parse`, which throws — the api
       // being unreachable is a different event with a different close code.
       if (res.status === 401 || res.status === 403) return null;
+      // Chapter 3.11. So is 402, and it is a DIFFERENT answer: the credential is
+      // good and the month is spent. Without this branch it would fall into
+      // `parse`, throw, and close the socket 1011 — "we are broken, retry" —
+      // which is wrong about whose fault it is and wrong about whether retrying
+      // helps.
+      //
+      // The message travels rather than the status, because what a client needs
+      // is the date it resumes and only the api knows that.
+      if (res.status === 402) {
+        const body = (await res.json()) as { message?: string };
+        return {
+          quotaExceeded:
+            body.message ?? "this environment's monthly quota is exhausted",
+        };
+      }
       return parse(res, internalSessionResponseSchema, "session");
     },
     async backfill(identity, cursors) {
@@ -114,6 +154,26 @@ export function createApiClient(baseUrl: string): ApiClient {
       const body = await parse(res, internalBackfillResponseSchema, "backfill");
       return body.channels;
     },
+    async reportUsage(body) {
+      // No credential, no report. Not an error and not a throw: a gateway with
+      // no metering configured is a gateway that serves sockets, and the caller
+      // logs the absence once at boot rather than on every tick.
+      if (serviceCredential === undefined) return null;
+      const res = await fetch(`${baseUrl}/internal/usage/connections`, {
+        method: "POST",
+        headers: {
+          "content-type": "application/json",
+          // The gateway's OWN credential. Not `headers(identity)` — there is no
+          // identity here, and reaching for one would mean picking a user to
+          // speak for, which is exactly the assertion chapter 3.2 removed.
+          authorization: `Bearer ${serviceCredential}`,
+        },
+        body: JSON.stringify(
+          body satisfies InternalUsageReportRequest,
+        ),
+      });
+      return parse(res, internalUsageReportResponseSchema, "usage report");
+    },
     async sendMessage(identity, body) {
       const res = await fetch(`${baseUrl}/internal/messages`, {
         method: "POST",
services/gateway/src/auth.ts
@@ -20,11 +20,20 @@ import type { ApiClient, Identity } from "./api-client.js";
 
 export type { Identity } from "./api-client.js";
 
-/** Three outcomes, not two. A refused token and an unreachable api both fail to
- * open a socket, but they are not the same event and must not close the same
- * way: 4001 tells a client its credential is wrong (retrying will not help),
- * 1011 tells it we are broken (retrying will). 2.5 drew that line for the
- * memberships lookup; moving verification here must not erase it. */
+/** FOUR outcomes, and chapter 3.11 added the fourth. A refused token, an
+ * unreachable api and an exhausted quota all fail to open a socket, and none of
+ * them is the same event: 4001 tells a client its credential is wrong (retrying
+ * will not help), 1011 tells it we are broken (retrying will), and 4008 tells it
+ * the month ran out (retrying will not help until a date the message names).
+ * 2.5 drew the first line for the memberships lookup; moving verification here
+ * must not erase it, and neither must adding a commercial refusal to it.
+ *
+ * WHY THE FOURTH IS NOT ONE OF THE OTHER THREE. Before this chapter a 402 fell
+ * through `api-client.ts`'s status check into `parse`, which throws — so it
+ * arrived here as `unavailable` and closed the socket 1011, telling the client
+ * we were broken and that retrying would help. Both wrong. Mapping it to
+ * `refused` instead would close 4001, "your credential is bad", which a client
+ * acts on by re-authenticating for ever. */
 export type Authentication =
   | {
       outcome: "ok";
@@ -37,7 +46,11 @@ export type Authentication =
       limits: { connect: number; send: number };
     }
   | { outcome: "refused" }
-  | { outcome: "unavailable"; error: string };
+  | { outcome: "unavailable"; error: string }
+  /** Chapter 3.11. The api answered, and the answer was "this environment has
+   * spent its month". Carries the api's own message, because the resume date is
+   * in it and a close reason string has nowhere to put one. */
+  | { outcome: "over_quota"; message: string };
 
 export async function authenticate(
   api: ApiClient,
@@ -46,10 +59,16 @@ export async function authenticate(
   if (token === null || token.length === 0) return { outcome: "refused" };
   try {
     const session = await api.session(token);
-    // The api answered, and the answer was "no". Every refusal — expired,
-    // malformed, mis-signed, for another environment, over-long — arrives here
-    // as one outcome, because the socket has one close code for all of them.
+    // The api answered, and the answer was "no". Every CREDENTIAL refusal —
+    // expired, malformed, mis-signed, for another environment, over-long —
+    // arrives here as one outcome, because the socket has one close code for all
+    // of them.
     if (session === null) return { outcome: "refused" };
+    // A quota refusal is not a credential refusal: the token is perfectly good
+    // and the month is not.
+    if ("quotaExceeded" in session) {
+      return { outcome: "over_quota", message: session.quotaExceeded };
+    }
     return {
       outcome: "ok",
       identity: {
services/gateway/src/session.ts
@@ -15,6 +15,7 @@ import { ApiError, type ApiClient } from "./api-client.js";
 import { authenticate, type Identity } from "./auth.js";
 import type { Fanout } from "./fanout.js";
 import type { Decision, GatewayLimits } from "./limits.js";
+import { createMeter, METER_INTERVAL_MS, type Meter } from "./meter.js";
 import { Registry, type Connection } from "./registry.js";
 import {
   MAX_BUFFERED_FRAMES,
@@ -128,6 +129,12 @@ export interface SessionServerOptions {
    * uncounted one. `main.ts` always supplies it, so the optionality is a test
    * affordance rather than a deployment mode. */
   limits?: GatewayLimits;
+  /** Chapter 3.11. Optional for the reason `limits` and `fanout` are: 2.5's
+   * tests and a single-process dev run have no api credential, and a socket
+   * server that refused to start without one would be a worse default than an
+   * unmetered one. `main.ts` always supplies the interval; the meter itself is
+   * built here so its timer has the same owner as the heartbeat's. */
+  meterIntervalMs?: number;
 }
 
 export function attachSessions({
@@ -138,8 +145,20 @@ export function attachSessions({
   pingIntervalMs = PING_INTERVAL_MS,
   resumeDeadlineMs = SUBSCRIBE_DEADLINE_MS,
   limits,
-}: SessionServerOptions): { registry: Registry; close: () => void } {
+  meterIntervalMs = METER_INTERVAL_MS,
+}: SessionServerOptions): {
+  registry: Registry;
+  meter: Meter;
+  close: () => Promise<void>;
+} {
   const registry = new Registry();
+  // Chapter 3.11. A second timer beside the heartbeat, not a second job for it.
+  const meter: Meter = createMeter({
+    api,
+    registry,
+    logger,
+    intervalMs: meterIntervalMs,
+  });
 
   /** A frame arriving from the fabric — born on this instance or another,
    * indistinguishable by design — becomes message.created for every local
@@ -241,6 +260,27 @@ export function attachSessions({
           });
           return;
         }
+        if (result.outcome === "over_quota") {
+          // Chapter 3.11, and this is the 4001 path's SHAPE for the 4001 path's
+          // REASON. The handshake completes so that a close code has a socket to
+          // arrive on — EIR-WS-05 asks that of a bad token and EIR-WS-06 asks the
+          // same of quota exhaustion, and `CLOSE_CODES[4008]` has read "quota
+          // exhausted" since chapter 1.3 with nothing emitting it.
+          //
+          // AN ERROR FRAME FIRST, because a close reason is a short string and
+          // what a client needs is the date it resumes. The frame carries the
+          // api's own message, four fields, exactly as a refusal over HTTP would.
+          //
+          // NOT `refuseUpgrade`. That writes chapter 3.8's raw 429 and its whole
+          // justification was `Retry-After` — a header a close frame has nowhere
+          // to put. A quota refusal declines that header on purpose, so the
+          // argument for the HTTP shape evaporates with it, and the shape that is
+          // left reaches a browser where a failed upgrade's body does not.
+          sendError(ws, "quota_exceeded", result.message);
+          ws.close(4008, CLOSE_CODES[4008]);
+          logger.log("info", "connection.rejected", { reason: "quota_exceeded" });
+          return;
+        }
         void open(
           ws,
           result.identity,
@@ -279,6 +319,12 @@ export function attachSessions({
       // succeeds, and leaves it null when it degrades.
       marks: null,
       sendLimit,
+      // Chapter 3.11. Stamped BEFORE the resume and before the ack, because the
+      // socket is already open and already costing a minute — a connection that
+      // started being metered only once it was fully established would give a
+      // reconnect storm a free window on every attempt.
+      openedAt: new Date(),
+      environmentId: identity.environmentId,
     };
 
     registry.add(connection);
@@ -307,6 +353,16 @@ export function attachSessions({
     });
     socket.on("message", (raw) => void handle(connection, raw.toString()));
     socket.on("close", (code) => {
+      // Chapter 3.11, and the ORDER MATTERS. The meter is told first, because
+      // the line below removes this connection from the registry the meter walks
+      // — and a socket that opened and closed between two reports would
+      // otherwise be counted zero. That is not a rounding error: it is the one
+      // thing the wall-clock-minute unit was chosen to charge (research R19).
+      //
+      // Handing over totals rather than reporting them. This handler is already
+      // documented as the last place that should throw, and a mass disconnect
+      // would turn one event into a burst of HTTP requests.
+      meter?.closed(connection, new Date());
       registry.remove(connection.id);
       // Releasing a subscription can fail — a broker that went away, or a
       // fabric already closed while sockets were still draining — and a
@@ -617,8 +673,16 @@ export function attachSessions({
 
   return {
     registry,
-    close: () => {
+    meter,
+    // ASYNC, because of what it now has to wait for (research R11, FR-RTL-05). A
+    // final report on the way out takes the graceful case's loss to zero and
+    // leaves R10's one-interval bound for the case that cannot be helped. A
+    // flush that is not awaited is the same non-guarantee one line further down:
+    // the process leaves before the request does.
+    close: async () => {
       clearInterval(heartbeat);
+      meter.stop();
+      await meter.reportOnce(new Date());
       wss.close();
     },
   };
services/gateway/src/main.ts
@@ -32,32 +32,98 @@ export function createServer(logger?: Logger) {
   // protocols, which is what an upgrade handshake is for.
   // Every instance is both publisher and subscriber: there is no leader
   // here, and no instance knows how many others exist (ADR-07). Scaling
   // out is adding a process.
   const fanout = createFanout({ logger: log });
   // Chapter 3.8. A SECOND Redis client, not fanout's — one of fanout's two is a
   // subscriber, and a connection in subscribe mode cannot run `INCR`. It is
   // created here rather than inside `attachSessions` so the tests that call
   // that function directly stay Redis-free, and so its close has an owner.
   const limits = createGatewayLimits();
+  // Chapter 3.11. THE FIRST SECRET THIS SERVICE HAS EVER HELD, and it is not a
+  // signing secret: chapter 3.2's claim that "the gateway holds no signing
+  // secret" is untouched, because this one verifies nothing and signs nothing.
+  // It only says which service is talking, on the one call that is the
+  // gateway's own rather than a user's.
+  //
+  // ABSENT BY DEFAULT AND NOT A STARTUP DEPENDENCY. A gateway with no credential
+  // serves sockets and meters nothing, and says so once here rather than on
+  // every tick — metering may not be able to refuse a connection (constitution III), and
+  // the loudest version of that rule is that it cannot refuse a boot either.
+  const serviceCredential = process.env.RELAY_INTERNAL_CREDENTIAL_GATEWAY;
+  if (serviceCredential === undefined) {
+    log.log("info", "metering.disabled", {
+      reason: "RELAY_INTERNAL_CREDENTIAL_GATEWAY is not set",
+    });
+  }
   const sessions = attachSessions({
     server,
-    api: createApiClient(process.env.RELAY_API_URL ?? DEFAULT_API_URL),
+    api: createApiClient(
+      process.env.RELAY_API_URL ?? DEFAULT_API_URL,
+      serviceCredential,
+    ),
     logger: log,
     fanout,
     limits,
+    // Overridable so `meter.itest.ts` can drive a spawned gateway without
+    // waiting a real minute per assertion. The two tests there are the ones an
+    // in-process gateway cannot run — a signal has to arrive at a process — and
+    // sixty seconds each would put them past the suite's timeout.
+    //
+    // Spread rather than assigned `undefined`: `exactOptionalPropertyTypes` is
+    // on, and "absent" and "present but undefined" are different things to it.
+    ...(process.env.RELAY_METER_INTERVAL_MS
+      ? { meterIntervalMs: Number(process.env.RELAY_METER_INTERVAL_MS) }
+      : {}),
   });
+  // `server.on("close")` has nowhere to await, so the teardown that MUST be
+  // waited for is handed back instead. The listener stays for the paths that
+  // close the server without leaving the process — tests, mostly — and the
+  // signal handler below awaits the same work before exiting.
   server.on("close", () => {
-    sessions.close();
-    void fanout.close();
-    void limits.close();
+    void shutdown();
   });
-  return server;
+  async function shutdown(): Promise<void> {
+    await sessions.close();
+    await fanout.close();
+    await limits.close();
+  }
+  return Object.assign(server, { shutdown });
 }
 
 if (import.meta.main) {
   const port = Number(process.env.PORT ?? 4001);
   const logger = createLogger("gateway");
-  createServer(logger).listen(port, () => {
+  const server = createServer(logger).listen(port, () => {
     logger.log("info", "listening", { port });
   });
+
+  // A GRACEFUL SHUTDOWN, WHICH THIS SERVICE DID NOT HAVE (research R11, FR-RTL-05).
+  //
+  // `serve()` returns a bare `node:http` Server, and nothing here ever called
+  // `server.close()` — so the `server.on("close")` handler above, which four
+  // documents said flushed a final usage report, ran on no path at all. On
+  // `docker stop` the process took SIGTERM, Node's default disposition exited,
+  // and the handler was never reached. Every document agreed with every other
+  // document and none of them was the thing that had to be true.
+  //
+  // AWAITED, not fired. A flush that is not waited for is the same non-guarantee
+  // one line further down: the process leaves before the request does. This is
+  // the difference between losing a minute per crash and losing a minute per
+  // deploy times every open socket, and a deploy is the frequent one.
+  //
+  // The shape is the dispatcher's, at `services/dispatcher/src/main.ts:313`.
+  //
+  // 4009 IS NOT EMITTED HERE. `CLOSE_CODES[4009]` reads "server shutdown
+  // (drain)" and this is the first shutdown path the gateway has ever had, so
+  // the code is sitting right there — but draining is telling clients to
+  // reconnect elsewhere, which is a feature with its own semantics. Reaching for
+  // it because a handler happened to arrive is the "declared, so use it" that
+  // chapter 3.8 refused by name.
+  for (const signal of ["SIGINT", "SIGTERM"] as const) {
+    process.on(signal, () => {
+      logger.log("info", "shutdown.signal", { signal });
+      server.close();
+      void server.shutdown().then(() => process.exit(0));
+    });
+  }
 }
services/gateway/src/session.test.ts
@@ -57,6 +57,10 @@ function stubApi(overrides: Partial<ApiClient> = {}): ApiClient {
         : null,
     backfill: async () => ({}),
     sendMessage: async () => committed(42),
+    // Chapter 3.11. Null is what a gateway with no metering credential gets, and
+    // it is the right default here: every test in this file is about the socket,
+    // and a meter that reported would only add a call nobody asserts on.
+    reportUsage: async () => null,
     ...overrides,
   };
 }
@@ -184,7 +188,7 @@ async function boot(
   return {
     url: `ws://127.0.0.1:${port}/v1/ws`,
     close: async () => {
-      sessions.close();
+      await sessions.close();
       await new Promise<void>((resolve) => server.close(() => resolve()));
     },
   };
@@ -926,30 +930,44 @@ describe("the socket's limits (chapter 3.8)", () => {
     reconnected.close();
   });
 
-  it("STILL emits close code 4008 from nowhere (quickstart V7)", async () => {
-    // 4008 reads "quota exhausted". There is no quota yet — quotas are a later
-    // chapter — and reaching for the code because it was declared would collapse
-    // the distinction this chapter is built on: a rate limit is a smoothing
-    // instruction, a quota is a commercial one, and they do not deserve the same
-    // signal. So does 4009, "server shutdown (drain)", for the same kind of
-    // reason (NFR-REL-03).
+  it("emits 4008 for a quota, and 4009 from nowhere (chapter 3.11)", async () => {
+    // CHAPTER 3.8 WROTE THIS TEST INVERTED, and said why:
     //
-    // Grep rather than behaviour, because the claim is about absence: no input
-    // makes the gateway send it, and the only way to check "no input" is to read
-    // what the source can send.
+    //   4008 reads "quota exhausted". There is no quota yet — quotas are a
+    //   later chapter — and reaching for the code because it was declared would
+    //   collapse the distinction this chapter is built on: a rate limit is a
+    //   smoothing instruction, a quota is a commercial one, and they do not
+    //   deserve the same signal.
+    //
+    // This is the later chapter. The distinction it was protecting survives and
+    // is now visible on the wire rather than asserted in a comment: a rate limit
+    // refuses the handshake with a raw 429 and a `Retry-After`, and a quota
+    // COMPLETES the handshake, sends an error frame carrying the resume date, and
+    // closes 4008. Two refusals at one door, and a client can tell them apart.
+    //
+    // 4009 IS STILL EMITTED BY NOTHING. Chapter 3.11 gave the gateway its first
+    // shutdown path, so "server shutdown (drain)" is closer than it has ever
+    // been — and draining is a feature with its own semantics rather than a code
+    // to reach for because a handler arrived.
+    //
+    // Grep rather than behaviour, because half the claim is about ABSENCE: no
+    // input makes this service send 4009, and the only way to check "no input"
+    // is to read what the source can send.
     const source = await Promise.all(
-      ["session.ts", "limits.ts", "resume.ts", "main.ts"].map((file) =>
+      ["session.ts", "limits.ts", "resume.ts", "main.ts", "meter.ts"].map((file) =>
         readFile(new URL(file, import.meta.url), "utf8"),
       ),
     );
+    const joined = source.join("");
+
+    expect(joined).toMatch(/close\(\s*4008/);
     for (const text of source) {
-      expect(text).not.toMatch(/close\(\s*400[89]/);
+      expect(text).not.toMatch(/close\(\s*4009/);
     }
-    // A grep that can only pass is not a check. The SAME pattern, aimed at the
-    // codes this file does emit, has to match — otherwise "nothing sends 4008"
-    // would also be true of a typo in the regex.
-    expect(source.join("")).toMatch(/close\(\s*400[12]/);
-    // And the vocabulary still declares them, so this is "unused", not "gone".
+    // A grep that can only pass is not a check — the same pattern, aimed at a
+    // code this file does emit, has to match.
+    expect(joined).toMatch(/close\(\s*400[12]/);
+    // And the vocabulary still declares both, so 4009 is "unused", not "gone".
     expect(CLOSE_CODES[4008]).toBeDefined();
     expect(CLOSE_CODES[4009]).toBeDefined();
   });
services/api/src/limits/rate-limit.middleware.ts
@@ -28,11 +28,18 @@ const now0 = (): number => Date.now();
 // by environment and the environment comes from the credential.
 //
 // COUNT EACH OPERATION ONCE, AT THE DOOR IT ENTERED (research R17). The exemption
-// cannot key off the principal, because the gateway forwards the END USER's token
-// on all three of its api calls — `/internal/session`, `/internal/backfill`,
-// `/internal/messages` are all `@Accepts("user")` and resolve exactly like
-// customer traffic. Only the dispatcher carries the platform credential. So the
-// route decides, not the caller:
+// cannot key off the principal, and CHAPTER 3.11 STRENGTHENED THAT RATHER THAN
+// WEAKENING IT.
+//
+// Three of the gateway's four api calls forward the END USER's token —
+// `/internal/session`, `/internal/backfill` and `/internal/messages` are all
+// `@Accepts("user")` and resolve exactly like customer traffic. The fourth,
+// `/internal/usage/connections`, is `@Accepts("platform")` and carries the
+// gateway's own credential, so the gateway is no longer the only service without
+// one: chapter 3.11 gave it `RELAY_INTERNAL_CREDENTIAL_GATEWAY`, and the
+// dispatcher is no longer the sole holder of a platform credential.
+//
+// So the caller's class now tells you even less than it did. The route decides:
 //
 //   /v1/…            counted. A message send decrements both budgets (FR-RTL-01).
 //   /internal/…      not counted. The gateway already counted the handshake
compose.yaml
@@ -126,6 +126,8 @@ services:
       # and the api refuses to start in production without the first.
       RELAY_WEBHOOK_SECRET_KEY: ${RELAY_WEBHOOK_SECRET_KEY:-}
       RELAY_INTERNAL_CREDENTIAL: ${RELAY_INTERNAL_CREDENTIAL:-rk_svc_local_development_credential_0000}
+      # The api verifies both service credentials, so it holds both.
+      RELAY_INTERNAL_CREDENTIAL_GATEWAY: ${RELAY_INTERNAL_CREDENTIAL_GATEWAY:-rk_svc_local_development_gateway_00000}
       PORT: "4000"
     ports:
       - "${RELAY_API_PORT:-4000}:4000"
@@ -149,6 +151,12 @@ services:
       RELAY_API_URL: http://api:4000
       RELAY_REDIS_URL: redis://redis:6379
       PORT: "4001"
+      # Chapter 3.11: the gateway's first credential of its own. Every other
+      # call it makes forwards the END USER's token; a usage report is nobody's
+      # user action, so it speaks for itself. Its own variable rather than the
+      # dispatcher's, because `PlatformPrincipal.service` has to stay true and
+      # because this service faces the public internet and that one does not.
+      RELAY_INTERNAL_CREDENTIAL_GATEWAY: ${RELAY_INTERNAL_CREDENTIAL_GATEWAY:-rk_svc_local_development_gateway_00000}
     ports:
       - "${RELAY_GATEWAY_PORT:-4001}:4001"
     depends_on:
turbo.json
@@ -30,6 +30,8 @@
         "RELAY_OUTBOX_RELAY",
         "RELAY_DELIVERY_RELAY",
         "RELAY_INTERNAL_CREDENTIAL",
+        "RELAY_INTERNAL_CREDENTIAL_GATEWAY",
+        "RELAY_METER_INTERVAL_MS",
         "RELAY_AUTH_FAILURES_PER_MINUTE",
         "RELAY_AUTH_KEY_PREFIX",
         "RELAY_WEBHOOK_SECRET_KEY",