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 nêu ba chiều được đo: tin nhắn đã gửi, người dùng hoạt động duy nhất, và connection-minutes. Chương 3.10 xây hai chiều đầu rồi dừng, và chỗ dừng đó được chọn trước khi viết chữ nào.

Tin nhắn và người dùng vốn đã là các dòng dữ liệu. messages.user_id đã nằm trong 0000_core_tables.sql từ Phần 2, nên đếm chúng là một câu hỏi tổng hợp được trả lời ngay trong transaction mà api vốn đã sở hữu. Lệnh gửi biết con số trước và sau, nên nó biết mình đã vượt ngưỡng nào, và cả chương rút lại thành câu hỏi đặt phép +1 ở đâu.

Một kết nối thì không nằm ở bảng nào. Không gì ghi lại nó. Tiến trình duy nhất nhìn thấy một kết nối là gateway, vốn không sở hữu bảng nào — ADR-05 nói api là writer duy nhất, và chương 2.1 biến điều đó thành một lint rule để sai lầm này là lỗi build chứ không phải một dòng nhận xét khi review. registry.ts đã ghi tính chất ấy bằng lời từ chương 2.5: no pg, no drizzle-orm, no repository import.

Vậy chương này không nói về việc đếm. Nó nói về đo đạc từ một service không thể ghi: nó phải nói gì, nói bao lâu một lần, và điều gì xảy ra với con số khi thứ đang nói chết giữa câu.

Một connection-minute là gì

docs/04-srs.md ghi một câu hỏi còn để ngỏ, số bốn, gửi cho Product và Billing: đo connection-minute có cần độ chính xác từng giây không, hay làm tròn theo phút là chấp nhận được? Một chương xuất bản mà không trả lời sẽ để đơn vị tính được phát hiện qua hoá đơn.

Câu trả lời ở đây: một connection-minute là một phút dương lịch mà trong đó một kết nối đã mở ở bất kỳ khoảnh khắc nào, tính theo từng kết nối.

flowchart LR
    subgraph clock["đồng hồ treo tường"]
      m0["phút 00:00"]
      m1["phút 00:01"]
      m2["phút 00:02"]
    end
    open["socket mở<br/>00:00:59"] --> m0
    close["socket đóng<br/>00:01:01"] --> m1
    m0 --> c1["bị tính"]
    m1 --> c2["bị tính"]
    m2 --> c3["không bị tính"]
    total["2 giây trên đồng hồ<br/>HAI 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
Một socket sống năm giây tốn một phút. Một socket mở từ 00:00:59 đến 00:01:01 tốn hai — nó hiện diện trong hai phút dương lịch, và đơn vị chính là cái phút mà nó hiện diện.

Các phương án khác đáng được gọi tên, vì chúng cho ra hoá đơn khác nhau rõ rệt. Bạn có thể cộng dồn số giây thật rồi làm tròn lên cho từng kết nối, khiến mỗi socket có sàn một phút. Hoặc cộng dồn số giây trên toàn environment rồi làm tròn một lần ở cuối, cách đọc sát nghĩa nhất của chữ "connection-minutes".

Một nghìn socket năm giây tách chúng ra:

mô hìnhmột nghìn socket 5 giâykhoá khử trùng lặp
ô phút, theo từng kết nốitối đa 1.000 phútchính danh tính của ô phút
giây, làm tròn theo kết nối1.000 phútmột bộ tích luỹ cho mỗi kết nối
giây, làm tròn một lần khi đọc83 phútmột bộ tích luỹ cho mỗi kết nối

Cách thứ ba gần như không tính tiền một cơn bão reconnect. Cách thứ nhất tính tiền sự nhấp nháy đó, và nó có một tính chất thứ hai hoá ra còn quan trọng hơn lý lẽ về hoá đơn: tập hợp các phút mà một kết nối đã chiếm chỉ có thể lớn lên. Một phép đếm trên tập đó cũng chỉ lớn lên, và "ghi nhận phần chênh" trở thành toàn bộ câu chuyện khử trùng lặp — điều ta gặp lại sau hai mục nữa.

Một service không sở hữu bảng nào, và cũng không có danh tính

Con đường duy nhất của gateway tới trạng thái là HTTP nội bộ tới api. Nó có ba lời gọi — /internal/session, /internal/backfill, /internal/messages — và mọi lời gọi đều chuyển tiếp token của chính người dùng cuối. Chương 3.2 làm nên điều đó, và api-client.ts nói vì sao:

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.

Một báo cáo sử dụng không phải hành động của ai cả. Nó là tuyên bố của chính tiến trình này về nhiều kết nối, có thể trải trên nhiều environment, về khoảng thời gian đã trôi qua. Không token nào của người dùng cho phép điều đó.

Cách chữa hiển nhiên là báo cáo theo từng kết nối bằng chính token của kết nối đó, và nó hỏng ở đúng chỗ tệ nhất: token hết hạn trong khi socket vẫn mở — handleSend vốn đã có nhánh cho lỗi 401 sinh ra từ đó — và cái socket sống lâu với token đã hết hạn chính là cái socket mang nhiều phút nhất.

Vậy gateway lần đầu tiên tự nói thay cho mình. Chương 3.5 đã dựng sẵn lớp credential cần dùng: PlatformPrincipal, @Accepts("platform"), tiền tố rk_svc_. Gateway trở thành người giữ thứ hai.

Nhận xét đã đóng khung của chương 3.2 nói gateway "holds no signing secret", và điều đó vẫn đúng sau chương này. Một platform credential không ký gì và không xác minh gì; nó nói service nào đang nói, trên lời gọi duy nhất thuộc về chính gateway chứ không phải của một người dùng.

Tổng, không phải phần chênh

Đây là quyết định mà phần còn lại của thiết kế rơi ra từ đó.

Một báo cáo nói một kết nối đã tiêu thụ bao nhiêu tổng cộng trong một kỳ. Không phải nó tiêu thụ bao nhiêu kể từ lần báo trước.

flowchart TB
    subgraph delta["giao thức gửi phần chênh"]
      d1["báo: +5"] --> d2["báo: +5 MẤT"]
      d2 --> d3["báo: +5"]
      d3 --> d4["ghi nhận 10<br/>năm phút đã mất là mất hẳn"]
    end
    subgraph total["giao thức gửi tổng"]
      t1["báo: tổng 5"] --> t2["báo: tổng 10 MẤT"]
      t2 --> t3["báo: tổng 15"]
      t3 --> t4["ghi nhận 15<br/>mất mát tự lành"]
    end
    style d4 fill:#7c2d12,color:#fff,stroke:#ea580c
    style t4 fill:#064e3b,color:#fff,stroke:#059669
Giao thức gửi phần chênh cần giao ít nhất một lần, cần một danh tính thông điệp để khử trùng lặp, và cần chỗ chứa những gì chưa gửi được. Giao thức gửi tổng không cần cả ba.

Ba tính chất đến miễn phí:

  • một báo cáo bị mất được lần sau vá lại, vì lần sau mang cùng con số tổng cộng với phần phát sinh thêm;
  • một báo cáo giao hai lần ghi nhận max(0, reported − credited) = 0 ở lần thứ hai;
  • một báo cáo không giao được thì bị bỏ đi chứ không xếp hàng.

Điều thứ ba mới thú vị. Gateway không giữ outbox nào — đúng lượng trạng thái bền vững cho một service mà cả thiết kế của nó là không giữ gì. Ba chương của series này đã dựng outbox, và đây là chương được phép không dựng.

Phép tính sống trong hai hàm không có đồng hồ, không có kho, không có framework, bởi hai dòng đó chính là giao thức:

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);
}

Phép max ở hàm đầu không phải trang trí phòng thủ. Điều duy nhất hàm này không bao giờ được làm là trừ đi khỏi một hoá đơn.

Cái socket bị đếm bằng không

Và điều này thu hẹp tuyên bố ở hai mục trước, một việc nên nói to ra thay vì để người đọc tự nhận thấy. "Báo cáo mang con số tổng, nên không cần xếp hàng gì cả" đúng với một kết nối vẫn đang mở — lần báo sau của nó mang cùng con số cộng phần phát sinh. Một kết nối đã đóng thì không có lần báo sau. Nên con số cuối của nó được giữ lại cho tới khi một báo cáo mang nó được chấp nhận, tập giữ lại có chặn trên, và một lần loại bỏ khi chạm trần được ghi log và đếm chứ không lặng lẽ biến mất.

Gateway không giữ hàng đợi, trừ đúng một trường hợp mà lập luận ngừng áp dụng. Nói vậy tốt hơn một tuyên bố chỉ đúng với phần lớn thiết kế.

Cú tắt máy vốn không tồn tại

Một cú crash mất nhiều nhất một chu kỳ báo cáo cho mỗi kết nối đang mở. Đó là chặn trên, nó được ghi rõ, và không làm gì được: một tiến trình bị giết không kịp chào tạm biệt.

Một lần deploy thì khác. Deploy là chuyện thường xuyên, và một cú dừng có trật tự biết trước các socket của nó sắp chết. Nên kế hoạch nói: xả một báo cáo cuối trên đường ra, móc vào handler server.on("close")main.ts vốn đã có.

Handler đó không bao giờ chạy.

serve() trả về một node:http Server trần. Không gì trong gateway gọi server.close(), và không gì cài signal handler — chỉ dispatcher có, ở main.ts:313. Khi docker stop, tiến trình nhận SIGTERM, hành vi mặc định của Node là thoát, và handler mà cú xả được móc vào không bao giờ được chạm tới.

Bốn tài liệu nói cú xả có xảy ra: mục nghiên cứu, yêu cầu, bảng mất mát trong contract, và chính task nối dây nó. Chúng đồng ý với nhau, đó là điều mà việc đọc tài liệu đối chiếu nhau chứng minh được, và không cái nào trong số đó là cái buộc phải đúng.

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() trở thành async đi kèm. Một cú xả được bắn đi mà không chờ là đúng cái không-bảo-đảm ấy dịch xuống một dòng: tiến trình rời đi trước khi request kịp đi.

Cái trần, ngay tại cửa

FR-RTL-05 nói enforce, không phải đo. Mỗi chiều từ chối đúng thao tác tiêu thụ nó: trần tin nhắn từ chối lệnh gửi, còn trần connection-minutes từ chối kết nối. Một cái trần chỉ từ chối lệnh gửi sẽ để một listener nằm im đốt tài nguyên được đo mà không có gì chặn lại, tức là một cái trần không chặn được thứ nó đếm.

Lời hứa của FR-RTL-08 vẫn đứng vững quanh nó. Socket đang mở vẫn mở, vẫn giao tin, và vẫn tiếp tục tích luỹ; lệnh gửi REST và đọc lịch sử không bị đụng tới. Phần vượt trần là có thật và chặn trên của nó thành thật: (số kết nối đang mở khi vượt trần) × (số phút cho tới khi mỗi cái đóng) + một chu kỳ báo cáo, và không gì trong nền tảng chặn được việc một client giữ socket bao lâu. Nói vậy tốt hơn bịa ra một mức trần.

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/>không có Retry-After"| gw
    gw -->|"error frame: quota_exceeded<br/>rồi close 4008"| client
    style api fill:#1e3a8a,color:#fff,stroke:#3b82f6
    style gw fill:#3f2d63,color:#fff,stroke:#8b5cf6
Hai chặng, hai hình dạng. api nói HTTP và trả lời 402. Client nói WebSocket và nhận đúng từ vựng của socket.

Một bản nháp trước của chương này chuyển tiếp mã trạng thái HTTP thẳng lên socket, mượn cú 429 thô của chương 3.8 ở cùng cánh cửa. Hình dạng đó có một lý do đã được ghi — "a refusal needs to say WHEN to come back; Retry-After is an HTTP header and a close frame has nowhere to put one" — và lời từ chối này cố tình không dùng Retry-After, vì một client ngủ theo header rồi thử lại là đúng với rate limit và sai với một quota vẫn sẽ cạn trong một giờ nữa.

Bỏ header đi thì lý lẽ cho hình dạng HTTP đi theo. Còn lại là hình dạng mà chương 3.8 dùng cho một token hỏng: hoàn tất bắt tay để một close code có socket mà đến. EIR-WS-05 đòi điều đó với credential không hợp lệ; EIR-WS-06 đòi đúng như vậy với quota đã cạn.

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] đã đọc là "quota exhausted" từ chương 1.3 và chưa từng có gì gửi nó. Mười chín chương xuất bản sau, đây là chương đó.

Nửa phía api là lời từ chối của chương 3.10, nguyên hình dạng và được người ném tự đặt tên:

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

Đặt tên, vì ProtocolErrorFilter@Catch()-all và suy ra mã cho bốn trạng thái. 402 không nằm trong bốn cái đó, nên một lời từ chối không tên sẽ tới gateway dưới dạng internal_error — đúng phát hiện mà lượt phân tích thứ hai của chương 3.10 đã dốc sức tìm ra.

Điều api không phân biệt được, và không cần

stateDiagram-v2
    [*] --> counting: báo cáo đầu tiên
    counting --> counting: báo cáo có tổng lớn hơn<br/>ghi nhận phần chênh
    counting --> counting: báo cáo có tổng bằng<br/>không ghi nhận gì
    counting --> stopped: báo cáo ngừng
    stopped --> [*]
    note right of stopped
      Một cú đóng sạch sẽ và một gateway
      bị giết đều đến đây giống hệt nhau.
      api không phân biệt được, và cũng
      không cần — đó là lý do trong thiết
      kế này không có reaper nào cả.
    end note
Một cú đóng sạch sẽ và một gateway bị giết đi tới cùng một trạng thái. api không bao giờ biết cái nào đã xảy ra, và không có gì trong thiết kế phụ thuộc vào việc biết.

api không bao giờ được báo khi một kết nối mở ra. Điều đầu tiên nó nghe về bất kỳ kết nối nào là một báo cáo — nên "một kết nối tôi chưa từng thấy" và "báo cáo đầu tiên của kết nối này" là cùng một trạng thái, và không có gì để phân biệt. Chấp nhận như là cái đầu tiên.

Cái bị từ chối là một báo cáo gọi tên một kết nối mà dòng dữ liệu của nó đã mang environment khác. Một kết nối không di chuyển giữa các tenant; hoà giải một cái có vẻ như vậy nghĩa là bịa ra một sự thật về việc những phút này thuộc về ai. Đó là một 409, và là lời từ chối theo hiến pháp I chứ không phải theo chất lượng dữ liệu.

Hệ quả đáng nói: không có reaper nào. Không quét mồ côi, không câu hỏi "kết nối còn mở không?", không job định kỳ nào cả. Mức sử dụng chỉ tăng khi một báo cáo tới, và transaction báo cáo biết con số trước và sau — nên nó tự ghi các lần vượt ngưỡng, đúng như transaction gửi của chương 3.10. Chương thứ hai liên tiếp đi tới kết quả ấy bằng lập luận ấy, và guard chống global operation của feature 030 không được kích hoạt ở đâu cả.

Chiều thứ ba đã tốn bao nhiêu

Chương 3.10 đã viết giá ra, hai lần — trong 0009_quotas.sql và trong 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

Đếm ra, là bảy chỗ:

chỗtốn gì
quotaConfigSchemamột khoá
environments_quota_config_shapemột mệnh đề jsonb_typeof và hai mệnh đề regex, ràng buộc bị xoá rồi dựng lại
quota_notifications_dimension_checkmột giá trị IN, xoá rồi dựng lại
usage_periodsmột cột và một CHECK, trên hai tệp
quota.error.tsmột biểu thức ba ngôi hai nhánh trở thành hai bảng Record
quota-email.tsmột khoá trong NOUN, và một bảng STOPPAGE mới
usageForhai trường trên hình dạng trả về

Dự đoán đúng về hình dạng và nhẹ tay về kích thước, và hai trong bảy chỗ nó không lường tới chút nào. Không có ALTER CONSTRAINT cho một biểu thức CHECK, nên cả hai ràng buộc liệt kê đều bị xoá rồi dựng lại chứ không sửa tại chỗ.

Cái giá của chương này, đo được

Kế hoạch sai một chỗ và chính phép đo nói ra điều đó.

Nghiên cứu chọn "một lời gọi thứ hai trên cùng request thay vì một environmentLimits nặng hơn", vì chương 3.10 đã từ chối đặt một phép join usage vào trong hàm đó. Lời từ chối ấy đúng và vẫn đúng — environmentLimits có người gọi thứ hai, rate-limit middleware, trên mọi request /v1, và nó không được trả giá cho một phép join nó không bao giờ đọc.

Nhưng hai lời gọi tốn đúng bằng một phép join, ở mức đồng thời. Độ trễ kết nối ở mức 32 luồng đi từ 15,0 ms lên 17,6 ms, bốn lần chạy nằm gọn trong 0,7 ms, tức một hồi quy thật chứ không phải nhiễu. Gộp đường kết nối về một phép đọc duy nhất lấy lại được 0,8 ms. Cơ chế là cái chương 3.10 đã ghi: một vòng khứ hồi thừa giữ một pooled connection suốt thời gian của nó, và vượt quá kích thước pool thì xếp hàng.

Con số nền để so sánh chỉ được đo một lần, một sai lầm trong chính công cụ được dựng lên để tránh phép đo không kiểm soát của chương 3.10. Ba lần chạy bản đã ship nằm trong 0,4 ms; con số 15,001 đơn lẻ thì không, nên phần dư sau khi gộp không quy kết sạch được. Phép EXPLAIN trong session.perf.itest.ts được commit chứ không phải chạy tạm, và đó mới là thứ SC-012 thực sự đòi: tra cứu theo index, không quét tỉ lệ với bất cứ thứ gì một tenant tích luỹ.

Và loạt hai mươi lần chạy tìm ra ba lỗi mà không lượng đọc nào bắt được. Cái dạy được nhiều nhất là một cổng cố định. startApi() gắn 4123 còn một describe mới gắn 4124, vốn là của limits.itest.ts; vitest chạy các tệp song song. Tệ hơn, chạy nối tiếp nhau thì tiến trình con của lần trước vẫn giữ cổng, tiến trình con mới chết vì EADDRINUSE, và phép kiểm tra sức khoẻ nhận 200 từ api — đang giữ signing secret của một environment khác. Ba khẳng định thất bại và không cái nào gọi tên cái đồ gá:

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

Tôi chẩn đoán sai hai lần trước khi đo, và cả hai giả thuyết sai đều kịp vào nhận xét trong mã trước khi bằng chứng tới. Thứ chốt lại là chạy một tệp duy nhất, năm lần — hai trên năm đỏ — điều đã giết mọi giả thuyết xuyên tệp trong một phép đo và lẽ ra phải là nước đi đầu tiên chứ không phải thứ năm.

Toàn bộ mã, theo thứ tự

Mọi thứ chương này đã đổi, phát lại lên repository tại part3-ch11. Tệp mới ở dạng đầy đủ; còn lại là diff theo hunk so với trạng thái chương 3.10 để lại.

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",