Building Relay

Phần 3 · Chương 3.16

Những gì một người dùng thấy

Bạn sẽ tạo ra: Danh sách channel phân trang bằng cursor và xếp theo hoạt động, số tin chưa đọc suy ra từ chính sequence mà đường ghi vẫn duy trì, profile người dùng được tạo ngầm ở lần xác thực đầu tiên, một người dùng đã xoá mà tin nhắn vẫn còn, và một lệnh ban có hiệu lực cả ở cửa vào lẫn trên đường gửi · khoảng 95 phút, bao gồm bài tập

Tài liệu gốc: SRS — Đặc tả yêu cầu phần mềm (tiếng Anh)

Một người dùng mở app. Trước khi một tin nhắn nào được render, client hỏi đúng một câu: tôi có những channel nào, channel nào có gì mới, và câu cuối cùng được nói là gì.

Ba field, một request. Chương này là những gì cần làm để trả lời chúng, và nó mở màn bằng một phép đo chỉ sai hướng.

0.87 milli-giây, và con số ấy là một lời nói dối

"Hoạt động gần nhất lên trước" không cần cột mới nào. Mọi tin nhắn đều mang created_at, nên thứ tự là một phép tổng hợp:

order by (select max(created_at) from messages where channel_id = c.id) desc

Đo trên test lane, nơi environment lớn nhất có 579 tin nhắn trên 32 channel: 1.942 ms, rồi 0.630, rồi 0.870. Dưới một milli-giây. Không migration, không cột phải backfill, không đường ghi nào phải sửa.

Cùng câu query đó trên một database nháp với 2.000 channel và 1.000.000 tin nhắn: 159.737 ms, 158.103 ms, 158.842 ms — kèm một lần quét tuần tự qua mọi tin nhắn trong environment, ở mỗi lần liệt kê. Một channels.last_activity_at có index trả lời cùng câu hỏi trong 1.1 ms.

flowchart TB
    q["ORDER BY max(messages.created_at)<br/>— không cần cột mới nào"]
    q --> lane["TEST LANE<br/>579 tin nhắn, 32 channel<br/>0.87 ms"]
    lane --> settle["'đủ nhanh rồi. ship thôi.'"]
    q --> real["MỘT TRIỆU TIN NHẮN<br/>159 ms<br/>Seq Scan qua mọi tin nhắn<br/>trong environment, mỗi lần liệt kê"]
    real --> col["channels.last_activity_at có index<br/>1.1 ms"]
    col --> ratio["cách nhau 145 lần, và khoảng cách còn giãn ra<br/>theo đúng con số mà một nền tảng chat<br/>chắc chắn sẽ tăng"]
    style settle fill:#7f1d1d,color:#fff,stroke:#dc2626
    style ratio fill:#064e3b,color:#fff,stroke:#059669
Cách nhau 145 lần, và khoảng cách còn giãn ra theo đúng con số mà một nền tảng chat chắc chắn sẽ tăng. Test lane không thấy được điều đó.

Một triệu tin nhắn không phải con số lạ với một nền tảng chat — đó là năm đầu tiên của một khách hàng cỡ vừa. Và 0.87 ms của lane không sai: nó đo một database nhỏ đến mức quét tuần tự còn rẻ hơn tra index, điều vừa đúng vừa vô dụng.

Thế nên channels.last_activity_at ra đời, và đường ghi duy trì nó ngay trong câu lệnh vốn đã tăng sequence:

services/api/migrations/0011_activity_and_read_positions.sql
-- Chapter 3.16 — ordering a user's channels, and knowing what they have not read.
--
-- Two changes with one thing in common: both answer a question `last_sequence`
-- looks like it should answer and cannot.
--
-- FR-CHN-08 wants a user's channels ordered by most recent activity.
-- `channels.last_sequence` is a per-channel monotonic counter, so two channels
-- both sitting at 50 say nothing about which took a message more recently. It
-- orders messages inside one channel and cannot order channels against each
-- other at all.
--
-- THE ALTERNATIVE WAS MEASURED AND IT IS 145x WORSE. Ordering by
-- `max(messages.created_at)` per channel, at 2,000 channels and 1,000,000
-- messages with one member in every channel:
--
--     aggregate over messages   159.737 ms  158.103 ms  158.842 ms
--                               -> Seq Scan on messages, 1,000,000 rows,
--                                  on every listing
--     indexed column              1.102 ms    1.496 ms    2.210 ms
--
-- AND THE TEST LANE SAYS THE OPPOSITE. Its busiest environment holds 579
-- messages, and the same aggregate answers in 0.870 ms there. Reporting that
-- number would have settled the question in favour of adding no column. The
-- cost grows with message volume, which is the one number a chat platform
-- guarantees will grow (research R4).
--
-- `now()` AS THE DEFAULT, AND A BACKFILL AFTER THIS FILE. Adding a column with
-- a constant default is fast — Postgres 11 and later store it in the catalogue
-- rather than rewriting the table. Setting every existing channel to its real
-- last activity is `max(messages.created_at)` per channel, which is the scan
-- above, so it does NOT belong in a migration: the workflow requires migrations
-- to be executable without downtime. It runs afterwards, in batches, as its own
-- step.
ALTER TABLE channels
    ADD COLUMN last_activity_at TIMESTAMPTZ NOT NULL DEFAULT now();
--> statement-breakpoint
 
-- The listing's ordering (FR-013), environment first because every listing is
-- inside one and the planner then walks the timestamp backward from there.
CREATE INDEX channels_environment_last_activity
    ON channels USING btree (environment_id, last_activity_at DESC NULLS LAST);
--> statement-breakpoint
 
-- FR-CHN-09's unread count needs to know how far a user has read, and nothing
-- in the schema recorded it. Verified before writing this: no `last_read`,
-- `read_at` or equivalent column in any table.
--
-- NO COUNTER COLUMN, and that is the whole design. Unread is
-- `greatest(channels.last_sequence - read_positions.sequence, 0)`, because the
-- write path already maintains `last_sequence` and chapter 2.2 made it the
-- sequencing authority. Three shapes measured for one page of 50 channels
-- against 1,000,000 messages:
--
--     count rows past the read position    9.807 ms  11.109 ms  13.431 ms
--     a cached counter on the position      2.129 ms   1.928 ms   1.226 ms
--     last_sequence - read position         1.122 ms   4.426 ms   4.497 ms
--
-- The cached counter is no faster and adds a value that can go stale. What the
-- subtraction accepts is that a tombstoned message still occupies a sequence,
-- so a deleted message counts as one unread; counting rows instead is 10x the
-- cost on the query a client runs to render its first screen (research R5,
-- FR-016).
--
-- environment_id IS DENORMALISED, AND channel_id ALREADY DETERMINES IT. The
-- column is here because feature 030's guard watches tables that carry one, and
-- a table without it is a table the guard cannot refuse a cross-environment
-- delete on. `members` is the counter-example worth knowing: it has no
-- environment_id, so the catalogue classifies it as `hop` — reached through a
-- foreign key — and no trigger protects it. A read position is per-user state
-- that a tenant's own operations mutate, so it takes the stronger
-- classification and becomes the guard's tenth table.
--
-- NO id COLUMN. The primary key is (channel_id, user_id) because that is what a
-- read position is. The guard's refusal message interpolates a key, and chapter
-- 3.13 installed `coalesce(to_jsonb(OLD) ->> 'id', to_jsonb(OLD)::text)` for
-- exactly the tables that have no `id` to interpolate.
CREATE TABLE read_positions (
    environment_id  UUID NOT NULL REFERENCES environments(id),
    channel_id      UUID NOT NULL REFERENCES channels(id),
    user_id         UUID NOT NULL REFERENCES users(id),
    -- Advances forwards only. A write naming a lower sequence than the stored
    -- one is accepted and changes nothing, so a client replaying an old
    -- acknowledgement cannot move a user's unread count backwards. A value past
    -- channels.last_sequence is refused (FR-018): a position nothing can reach
    -- makes every later count wrong.
    sequence        BIGINT NOT NULL,
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT read_positions_channel_id_user_id_pk PRIMARY KEY (channel_id, user_id)
);

Phần backfill là một script chứ không nằm trong migration, và đó là phép đo thứ hai. ALTER TABLE … ADD COLUMN … NOT NULL DEFAULT now() trả về ngay lập tức trên Postgres 11 trở lên — giá trị default là metadata, không phải một lần ghi lại bảng — nhưng một SET last_activity_at = (select max(...)) trong cùng transaction sẽ khoá mọi dòng channel suốt thời gian phép tổng hợp chạy. Theo các con số ở trên, đó là 159 ms mỗi environment nhân với số environment đang có, tất cả trong khi đang giữ khoá ACCESS EXCLUSIVE.

scripts/backfill-channel-activity.mjs
// Set `channels.last_activity_at` to each channel's real last activity.
//
// Chapter 3.16, T019 — and it is a SCRIPT and not part of migration 0011 for one
// reason: the constitution's workflow section requires migrations to be
// "executable without downtime", and this is a scan.
//
// Adding the column is free. Postgres 11 and later store a constant default in the
// catalogue rather than rewriting the table, so `ALTER TABLE channels ADD COLUMN
// last_activity_at TIMESTAMPTZ NOT NULL DEFAULT now()` returns immediately however
// many channels exist. What is not free is giving every existing channel its real
// value, because that is `max(messages.created_at)` per channel — the same
// aggregate research R4 measured at 159 ms over 1,000,000 messages, and the reason
// the column exists at all.
//
// SO IT RUNS AFTERWARDS, IN BATCHES, AND REPORTS. One statement per batch of
// channel ids, each batch its own transaction, so the lock is held for a batch and
// not for the table. A channel with no messages keeps the `now()` the default gave
// it: it has no activity to date from, and dating it from the epoch would sort it
// below every real channel forever.
//
// Idempotent by construction — it computes an absolute value from `messages`, so
// running it twice writes the same timestamps. Safe to re-run after a restore.
 
// The api's own pool, not a `pg` import: `pg` is a dependency of services/api and
// not of the workspace root, and `createPool` already applies this project's
// DATABASE_URL default of port 15432 — the port the code documents, against a
// compose file that defaults the host to 5432. `scripts/seed-demo-tenant.mjs`
// reaches for the same build output for the same reason.
import { createPool } from "../services/api/dist/db/client.js";
 
const BATCH = Number(process.env.RELAY_BACKFILL_BATCH ?? 500);
const pool = createPool();
 
async function main() {
  const started = Date.now();
  const { rows: all } = await pool.query(`SELECT id FROM channels ORDER BY id`);
  let batches = 0;
  let touched = 0;
 
  for (let i = 0; i < all.length; i += BATCH) {
    const ids = all.slice(i, i + BATCH).map((r) => r.id);
    // `GREATEST` and not a bare max: a channel whose newest message somehow
    // predates the row itself would otherwise move backwards, and the column is
    // NOT NULL so there is always an existing value to compare against.
    const { rowCount } = await pool.query(
      `UPDATE channels c
          SET last_activity_at = GREATEST(
                c.last_activity_at,
                COALESCE(
                  (SELECT max(m.created_at) FROM messages m WHERE m.channel_id = c.id),
                  c.last_activity_at
                )
              )
        WHERE c.id = ANY($1::uuid[])`,
      [ids],
    );
    batches += 1;
    touched += rowCount ?? 0;
  }
 
  const ms = Date.now() - started;
  // The numbers T019 asks to be recorded, printed rather than estimated.
  console.log(
    `backfill: ${all.length} channels in ${batches} batches of ${BATCH}, ` +
      `${touched} rows written, ${ms} ms`,
  );
}
 
main()
  .then(() => pool.end())
  .catch(async (error) => {
    await pool.end();
    console.error(error);
    process.exitCode = 1;
  });

Con số chưa đọc mà không có counter

Client cần một con số cho mỗi channel: bao nhiêu tin nhắn đã tới mà user này chưa đọc. Cách hiện thực dễ thấy nhất là một counter — tăng khi gửi, reset khi đọc — và nó sai vì một lý do chẳng liên quan gì tới tốc độ.

flowchart LR
    subgraph have["THỨ ĐƯỜNG GHI VẪN ĐANG DUY TRÌ"]
      seq["channels.last_sequence<br/>thẩm quyền về sequence từ chương 2.2"]
    end
    subgraph new["MỘT TABLE MỚI, MỘT CỘT"]
      pos["read_positions.sequence<br/>chỉ tiến, theo từng (channel, user)"]
    end
    seq --> sub["greatest(last_sequence - coalesce(position, 0), 0)"]
    pos --> sub
    sub --> out["số tin chưa đọc"]
    none["KHÔNG CÓ DÒNG = VỊ TRÍ 0<br/>số chưa đọc của member mới là toàn bộ lịch sử,<br/>và của member được thêm lại cũng vậy"]
    none --> sub
    clamp["greatest(..., 0) là phòng vệ trước một bug:<br/>vị trí vượt quá cuối bị từ chối ngay khi ghi,<br/>và last_sequence không bao giờ đi ngược"]
    clamp --> sub
    counter["MỘT COUNTER CACHE đo được 1.2-2.1 ms<br/>so với 1.1-4.5 ms của phép trừ này<br/>— không nhanh hơn, mà lại có thể lệch"]
    style out fill:#064e3b,color:#fff,stroke:#059669
    style counter fill:#7f1d1d,color:#fff,stroke:#dc2626
Đường ghi vốn đã duy trì last_sequence. Phần còn lại chỉ là một phép trừ.

channels.last_sequence đã là thẩm quyền về sequence từ chương 2.2, và đường ghi duy trì nó dưới khoá dòng channel. Nên con số này là phép tính trên hai giá trị vốn đã tồn tại, và nó không có gì phải vô hiệu hoá, không có gì phải backfill.

Đo cho một trang 50 channel trên 1.000.000 tin nhắn:

đếm số dòng sau vị trí đã đọc 9.8 - 13.4 ms một counter cache trên vị trí 1.2 - 2.1 ms phép trừ 1.1 - 4.5 ms

Counter không nhanh hơn. Nó nằm trong khoảng nhiễu của phép trừ, và nó thêm vào một giá trị có thể lệch khỏi chính những tin nhắn nó đếm. Một con số cache bị lệch còn tệ hơn một con số chậm hơn nhưng không thể lệch.

services/api/src/db/repository.ts
@@ -2152,6 +2152,28 @@
   id: string;
   external_id: string;
   display_name: string | null;
+  /** Chapter 3.15, FR-023. Both columns have existed since chapter 2.1 and **no route
+   * has ever written or read either one** — two of the four dead columns this feature
+   * exists to give readers. They are on the row rather than fetched by a second query
+   * because every caller that wants a profile wants all of it. */
+  avatar_url: string | null;
+  metadata: Record<string, unknown>;
+  /** Chapter 3.15, FR-031. Read on the send path and at connect. Like `deleted_at`, it
+   * is selected rather than filtered so a caller can tell the states apart — a
+   * repository that hid banned users would make the ban unobservable and the refusal
+   * untestable. */
+  banned_at: string | null;
+  /** Chapter 3.15, FR-017. A deleted user KEEPS THEIR ROW: `ON DELETE SET NULL` on
+   * `messages.user_id` would satisfy the letter of "messages are preserved" and break
+   * delivery, because `backfill.controller`'s `toFrame` drops a senderless row — so
+   * "authored by a deleted user" and "authored by nobody" are different states and
+   * only one of them is the clause.
+   *
+   * Every route that names a user in its path reads this and answers 404. It is
+   * selected here rather than filtered in the query so a caller can tell the two
+   * apart: a repository that hid deleted rows would make the marker unobservable and
+   * the deletion untestable. */
+  deleted_at: string | null;
 }
 
 export interface ChannelRow {
@@ -2222,6 +2244,19 @@
   }
 }
 
+/** A write or a connect refused because the user is banned (chapter 3.15, FR-031).
+ *
+ * FIRST IN FR-021a's ORDER, and the ban check runs BEFORE the channel is read at all —
+ * so a banned user gets one answer for every channel id, whether it exists, belongs to
+ * somebody else, or was invented. Any other position leaks: check the channel first and
+ * a banned user learns which channel ids are real. */
+export class UserBannedError extends Error {
+  constructor(public readonly userId: string) {
+    super(`user banned: ${userId}`);
+    this.name = "UserBannedError";
+  }
+}
+
 /** Timestamps cross the wire as RFC 3339 strings (constitution: UTC,
  * millisecond precision) — the driver hands back a Date or a string
  * depending on the column and the query shape. */
@@ -2553,7 +2588,18 @@
       .returning({ id: users.id });
 
     if (inserted.length > 0) {
-      return { id, external_id: externalId, display_name: displayName ?? null };
+      // `deleted_at: null` on the fresh row, stated rather than spread: a user
+      // created now is not deleted, and an insert that returned the field would cost
+      // a column in the RETURNING clause to learn what the code already knows.
+      return {
+        id,
+        external_id: externalId,
+        display_name: displayName ?? null,
+        avatar_url: null,
+        metadata: {},
+        banned_at: null,
+        deleted_at: null,
+      };
     }
     const existing = await this.getUserByExternalId(externalId);
     if (existing === null) throw new Error(`user ${externalId} could not be created or read`);
@@ -2569,6 +2615,10 @@
         id: users.id,
         external_id: users.externalId,
         display_name: users.displayName,
+        avatar_url: users.avatarUrl,
+        metadata: users.metadata,
+        bannedAt: users.bannedAt,
+        deletedAt: users.deletedAt,
       })
       .from(users)
       .where(
@@ -2577,12 +2627,27 @@
           eq(users.externalId, externalId),
         ),
       );
-    return rows[0] ?? null;
+    const row = rows[0];
+    return row === undefined
+      ? null
+      : {
+          id: row.id,
+          external_id: row.external_id,
+          display_name: row.display_name,
+          avatar_url: row.avatar_url,
+          // `as` AND NOT `?? {}`. The column is `notNull().default({})`, so the driver
+          // never hands back null — and chapter 3.12 removed `addMember`'s
+          // `(inserted.rowCount ?? 0)` for exactly this reason: an arm nothing can take,
+          // bought for nothing, in the one file constitution VI asks 100% of.
+          metadata: row.metadata as Record<string, unknown>,
+          banned_at: row.bannedAt === null ? null : toIso(row.bannedAt),
+          deleted_at: row.deletedAt === null ? null : toIso(row.deletedAt),
+        };
   }
 
   /** IDEMPOTENT ON THE CUSTOMER'S OWN IDENTIFIER (FR-017, FR-CHN-02).
    *
-   * This was a plain insert until chapter 3.12, which is fine for a fixture and
+   * This was a plain insert until chapter 3.13, which is fine for a fixture and
    * cannot back an endpoint: a repeated `external_id` raises against
    * `channels_environment_id_external_id_unique`, and `ProtocolErrorFilter`
    * renders a unique violation as `internal_error`. The second call in an
@@ -2672,7 +2737,7 @@
    * is the layer's one raw SQL island, permitted by ADR-16 and kept inside
    * the wall like everything else.
    *
-   * THREE OUTCOMES, NOT A BOOLEAN (chapter 3.12, R14a). Until then this returned
+   * THREE OUTCOMES, NOT A BOOLEAN (chapter 3.13, R14a). Until then this returned
    * `false` for all of: the channel is not yours, the user is not yours, and you
    * asked twice. Conflating the first two is right and is the whole point — a
    * foreign id must be indistinguishable from an absent one. Conflating the third
@@ -2953,6 +3018,506 @@
     return rows.map((r) => r.channel_id);
   }
 
+  /** Upsert a user by external id, updating the profile fields present (chapter 3.15,
+   * FR-025, FR-026).
+   *
+   * NOT `createUser`, AND THE DIFFERENCE IS THE POINT. `createUser` is deliberately not an
+   * update: its comment says so — "the display name of the existing row wins; quietly
+   * renaming a user because someone re-sent a member list would be a write nobody asked
+   * for". That is right for the member-add, which asks for membership and happens to need a
+   * user. FR-026 asks for the opposite here: an entry naming an existing user **updates**
+   * it, because this route's subject IS the user record.
+   *
+   * Two functions rather than a flag, so neither route can accidentally get the other's
+   * behaviour. The member-add's caller keeps `createUser`.
+   *
+   * IT ALSO REVIVES A DELETED USER, which is FR-030 and not an accident.
+   * `(environment_id, external_id)` is unique and the row is still there, so presenting
+   * the id again has no other honest answer than reusing it. `deleted_at` is cleared and
+   * the profile takes whatever this call carries — a revived row does not inherit the
+   * profile the deletion wiped.
+   *
+   * `status` REPORTS WHICH HAPPENED, per entry, in the shape chapter 3.13 chose for
+   * `addMember`: a partial outcome is reported per entry rather than collapsed into one
+   * status code. */
+  async upsertUser(
+    externalId: string,
+    profile: {
+      display_name?: string | null | undefined;
+      avatar_url?: string | null | undefined;
+      metadata?: Record<string, unknown> | undefined;
+    },
+  ): Promise<{ user: UserRow; status: "created" | "updated" | "revived" }> {
+    const id = randomUUID();
+    const inserted = await this.db
+      .insert(users)
+      .values({
+        id,
+        environmentId: this.environmentId,
+        externalId,
+        displayName: profile.display_name ?? null,
+        avatarUrl: profile.avatar_url ?? null,
+        ...(profile.metadata === undefined ? {} : { metadata: profile.metadata }),
+      })
+      .onConflictDoNothing({ target: [users.environmentId, users.externalId] })
+      .returning({ id: users.id });
+
+    if (inserted.length > 0) {
+      return {
+        user: {
+          id,
+          external_id: externalId,
+          display_name: profile.display_name ?? null,
+          avatar_url: profile.avatar_url ?? null,
+          metadata: profile.metadata ?? {},
+          banned_at: null,
+          deleted_at: null,
+        },
+        status: "created",
+      };
+    }
+
+    // ONE UNREACHABLE THROW, NOT TWO, and the count is the reason. An earlier version
+    // read the row, threw if it was absent, updated it, read it back, and threw again if
+    // THAT was absent — two statements for one impossible state (the winner of an
+    // `ON CONFLICT` race having its row deleted between two statements of the same call,
+    // which nothing in the api can do). `repository.ts` already carried two throws of
+    // that class from chapter 3.12 and its lines ratchet sat at 99; a third took the file
+    // to 98.92 and the gate went red. The instrument was right: the second throw bought
+    // nothing the first did not already say.
+    //
+    // The pre-image is read for ONE fact the update cannot return — whether the row was
+    // deleted before this call, which is what makes the difference between `updated` and
+    // `revived`. `UPDATE ... RETURNING` gives post-update values, so there is no way to
+    // learn it from the write itself.
+    const [before] = await this.db
+      .select({ id: users.id, deletedAt: users.deletedAt })
+      .from(users)
+      .where(
+        and(
+          eq(users.environmentId, this.environmentId),
+          eq(users.externalId, externalId),
+        ),
+      )
+      .limit(1);
+
+    if (before !== undefined) {
+      await this.db
+        .update(users)
+        .set({
+          // Absent stays absent, exactly as the single PATCH treats it — except
+          // `deleted_at`, which a revival always clears.
+          ...(profile.display_name === undefined
+            ? {}
+            : { displayName: profile.display_name }),
+          ...(profile.avatar_url === undefined ? {} : { avatarUrl: profile.avatar_url }),
+          ...(profile.metadata === undefined ? {} : { metadata: profile.metadata }),
+          deletedAt: null,
+        })
+        .where(and(eq(users.id, before.id), eq(users.environmentId, this.environmentId)));
+    }
+
+    const after = await this.getUserByExternalId(externalId);
+    if (after === null) throw new Error(`user ${externalId} could not be created or read`);
+    return { user: after, status: before?.deletedAt != null ? "revived" : "updated" };
+  }
+
+  /** Ban and unban a user, tenant-wide (chapter 3.15, FR-031, FR-032).
+   *
+   * TENANT-SCOPED AND NOT A REMOVAL. A ban stops the user connecting and sending
+   * anywhere in the environment; it takes no membership away and hides no history. So
+   * banning a member of a private channel leaves them a member — the channel's other
+   * members still see their messages, and lifting the ban restores everything without
+   * anybody being re-added. `deleteUser` is the operation that removes memberships, and
+   * these two are deliberately not it.
+   *
+   * IDEMPOTENT, both directions, and neither reports which happened. Unlike the deletion,
+   * nothing downstream needs to tell "banned now" from "was already banned": the route
+   * answers 200 either way because the caller's intent — this user must not connect — is
+   * satisfied either way.
+   *
+   * `banned_at` HAD NO WRITER, the same omission `channels.archived_at` had. The column
+   * has been in the schema since chapter 2.1 with zero references outside tests. */
+  async banUser(userId: string): Promise<void> {
+    await this.db
+      .update(users)
+      .set({ bannedAt: new Date() })
+      .where(
+        and(
+          eq(users.id, userId),
+          eq(users.environmentId, this.environmentId),
+          isNull(users.bannedAt),
+        ),
+      );
+  }
+
+  async unbanUser(userId: string): Promise<void> {
+    await this.db
+      .update(users)
+      .set({ bannedAt: null })
+      .where(
+        and(eq(users.id, userId), eq(users.environmentId, this.environmentId)),
+      );
+  }
+
+  /** Delete a user, keeping the row (chapter 3.15, FR-027, FR-028, FR-029).
+   *
+   * WHAT GOES: the profile fields, the memberships, the read positions.
+   * WHAT STAYS: the row, the messages, and every `usage_active_users` row.
+   *
+   * THE ROW IS THE WHOLE ARGUMENT. `ON DELETE SET NULL` on `messages.user_id` satisfies
+   * the letter of "messages are preserved" and breaks delivery:
+   * `backfill.controller`'s `toFrame` drops a senderless row, so every message the user
+   * ever sent would silently disappear from every reconnecting client. "Authored by a
+   * deleted user" and "authored by nobody" are different states and only one of them is
+   * FR-028.
+   *
+   * `usage_active_users` IS UNTOUCHED (FR-029). Billing history does not vanish with a
+   * profile — a customer who deleted a user in March still owes for March.
+   *
+   * MEMBERSHIPS AND READ POSITIONS GO TOGETHER, and the read position goes because the
+   * membership does: a position is per-member state keyed by channel and user, so keeping
+   * it would leave a row pointing at a membership that no longer exists. It is the same
+   * deletion the member-removal path already performs.
+   *
+   * IDEMPOTENT, and it reports which happened, so the route can answer 200 twice while a
+   * user who never existed still gets 404. */
+  async deleteUser(userId: string): Promise<boolean> {
+    return this.db.transaction(async (tx) => {
+      const [alive] = await tx
+        .select({ id: users.id, deletedAt: users.deletedAt })
+        .from(users)
+        .where(
+          and(eq(users.id, userId), eq(users.environmentId, this.environmentId)),
+        )
+        .limit(1);
+      if (alive === undefined) return false;
+
+      await tx.delete(readPositions).where(eq(readPositions.userId, userId));
+      await tx.delete(members).where(eq(members.userId, userId));
+      await tx
+        .update(users)
+        .set({
+          displayName: null,
+          avatarUrl: null,
+          metadata: {},
+          deletedAt: alive.deletedAt ?? new Date(),
+        })
+        .where(eq(users.id, userId));
+      return true;
+    });
+  }
+
+  /** Write a user's profile (chapter 3.15, FR-023, FR-024).
+   *
+   * THE FIRST WRITER `users.avatar_url` AND `users.metadata` HAVE EVER HAD. Both columns
+   * have been in the schema since chapter 2.1 with zero references outside tests — two of
+   * the four columns this feature was specified to give readers, and giving them a reader
+   * meant giving them a writer first.
+   *
+   * PARTIAL BY CONSTRUCTION, and `undefined` is not `null`. A field absent from the patch
+   * is absent from the `set`, so it keeps its value; a field present and null is written
+   * null, which clears it. `exactOptionalPropertyTypes` makes the two distinguishable in
+   * the type rather than by convention (ADR-15's strictness).
+   *
+   * AN EMPTY PATCH DOES NOT ISSUE AN UPDATE. Drizzle throws on a `set` with no columns,
+   * and issuing `SET` with nothing to set would be a write that means nothing anyway. The
+   * caller gets the current row, which is the honest answer to a request that asked for no
+   * change.
+   *
+   * SCOPED AND ALIVE. The `where` carries the environment and `deleted_at IS NULL`: a
+   * deleted user's profile is not editable, and the route above answers 404 for the same
+   * reason. Returning null is how the caller tells "no such user" from "wrote nothing". */
+  async updateUserProfile(
+    userId: string,
+    patch: {
+      display_name?: string | null | undefined;
+      avatar_url?: string | null | undefined;
+      metadata?: Record<string, unknown> | undefined;
+    },
+  ): Promise<UserRow | null> {
+    const set: Record<string, unknown> = {};
+    if (patch.display_name !== undefined) set["displayName"] = patch.display_name;
+    if (patch.avatar_url !== undefined) set["avatarUrl"] = patch.avatar_url;
+    if (patch.metadata !== undefined) set["metadata"] = patch.metadata;
+
+    if (Object.keys(set).length > 0) {
+      const updated = await this.db
+        .update(users)
+        .set(set)
+        .where(
+          and(
+            eq(users.id, userId),
+            eq(users.environmentId, this.environmentId),
+            isNull(users.deletedAt),
+          ),
+        )
+        .returning({ externalId: users.externalId });
+      if (updated.length === 0) return null;
+      return this.getUserByExternalId(updated[0]!.externalId);
+    }
+
+    const [row] = await this.db
+      .select({ externalId: users.externalId })
+      .from(users)
+      .where(
+        and(
+          eq(users.id, userId),
+          eq(users.environmentId, this.environmentId),
+          isNull(users.deletedAt),
+        ),
+      )
+      .limit(1);
+    return row === undefined ? null : this.getUserByExternalId(row.externalId);
+  }
+
+  /** Record a read position (chapter 3.15, FR-017, FR-018).
+   *
+   * FORWARDS ONLY, and the clamp is in SQL rather than in a read-then-write. `greatest`
+   * on the conflict target means a replayed acknowledgement from a client that fell
+   * behind is a 200 that changes nothing, and two concurrent writes cannot lose the
+   * higher one — a read followed by a write would, whichever order they interleave.
+   *
+   * PAST THE END IS REFUSED (FR-018). A position beyond `channels.last_sequence` makes
+   * every count derived from it wrong for every message that arrives afterwards, and it
+   * cannot come from a client that has actually read anything. `null` is how the caller
+   * learns to answer 400; the alternative — clamping silently — would accept a client
+   * bug and hide it.
+   *
+   * THE SEQUENCE IS READ IN THE SAME TRANSACTION as the upsert, so the bound cannot
+   * move between the check and the write. It can only move UP, so a racing send makes
+   * the check conservative rather than wrong. */
+  async setReadPosition(
+    channelId: string,
+    userId: string,
+    sequence: number,
+  ): Promise<{ sequence: number } | null> {
+    return this.db.transaction(async (tx) => {
+      const [channel] = await tx
+        .select({ lastSequence: channels.lastSequence })
+        .from(channels)
+        .where(
+          and(eq(channels.id, channelId), eq(channels.environmentId, this.environmentId)),
+        )
+        .limit(1);
+      if (channel === undefined || sequence > channel.lastSequence) return null;
+
+      const [row] = await tx
+        .insert(readPositions)
+        .values({
+          environmentId: this.environmentId,
+          channelId,
+          userId,
+          sequence,
+        })
+        .onConflictDoUpdate({
+          target: [readPositions.channelId, readPositions.userId],
+          set: {
+            sequence: sql`greatest(${readPositions.sequence}, excluded.sequence)`,
+            updatedAt: new Date(),
+          },
+        })
+        .returning({ sequence: readPositions.sequence });
+      return row ?? null;
+    });
+  }
+
+  /** Mark a user deleted, keeping the row (chapter 3.15, FR-017).
+   *
+   * THE ROW SURVIVES ON PURPOSE. `ON DELETE SET NULL` on `messages.user_id` would
+   * satisfy "messages are preserved" and break delivery: `toFrame` drops a senderless
+   * row from a resume, so a deleted author would silently remove their messages from
+   * every reconnecting client. The marker keeps authorship and removes the user from
+   * the API.
+   *
+   * IDEMPOTENT, and it reports which happened. Deleting a user twice is not an error —
+   * a customer's retry after a timeout is the ordinary case — but the caller still has
+   * to be able to answer 404 the second time, and `false` is how it knows.
+   *
+   * The deletion route is this method's production caller and arrives in a later
+   * phase. It exists now because the listing has to answer 404 for a deleted user,
+   * and a 404 branch with no way to reach it is a branch no test can cover. */
+  async markUserDeleted(userId: string): Promise<boolean> {
+    const updated = await this.db
+      .update(users)
+      .set({ deletedAt: new Date() })
+      .where(
+        and(
+          eq(users.id, userId),
+          eq(users.environmentId, this.environmentId),
+          isNull(users.deletedAt),
+        ),
+      )
+      .returning({ id: users.id });
+    return updated.length > 0;
+  }
+
+  /** A user's channels, most recently active first, keyset-paginated (chapter
+   * 3.15, FR-013, FR-CHN-08).
+   *
+   * `id` IS PART OF THE KEY AND NOT DECORATION. `last_activity_at` is not unique:
+   * two channels can take a message in the same millisecond, and a keyset on a
+   * non-unique column either skips a row or repeats one at every page boundary
+   * where a tie straddles it. Postgres row comparison — `(a, b) < (x, y)` — gives
+   * the strict lexicographic "everything after this exact row" the cursor means,
+   * in one predicate the planner can drive an index with.
+   *
+   * MEMBERSHIP IS THE JOIN, NOT A FILTER AFTER THE FACT (FR-015). The listing set
+   * is the membership set: `members_user_channel` is an index on
+   * `(user_id, channel_id)`, so the join drives from the user's own rows and a
+   * channel they are not in is never a candidate. A public channel they could read
+   * by id does not appear here — the read set and the subscription set are
+   * deliberately different sets, and the chapter says so.
+   *
+   * ARCHIVED CHANNELS APPEAR, with `archived_at` on the row (FR-022). A customer
+   * who archived a channel still has to be able to find it, and hiding it here
+   * would make the archive a delete.
+   *
+   * SCOPED THROUGH `users`, the way `channelsForUser` is: `members` carries no
+   * `environment_id` of its own (it is a hop table, two links from a tenant), so
+   * the scope is asserted on the parent that has one. */
+  async listChannelsForUser(
+    userId: string,
+    { limit, after }: { limit: number; after?: { activityAt: Date; id: string } },
+  ): Promise<{
+    rows: Array<{
+      id: string;
+      external_id: string;
+      type: ChannelRow["type"];
+      name: string | null;
+      role: string;
+      archived_at: string | null;
+      last_activity_at: string;
+      last_sequence: number;
+      unread: number;
+      last_message: {
+        sequence: number;
+        text: string | null;
+        user: { id: string } | null;
+        created_at: string;
+      } | null;
+    }>;
+    nextCursor: { activityAt: Date; id: string } | null;
+  }> {
+    // ONE ROW MORE THAN ASKED FOR, which is how the caller learns whether there is
+    // a next page without a second count query. The extra row is dropped before
+    // returning and its predecessor becomes the cursor.
+    const rows = await this.db
+      .select({
+        id: channels.id,
+        externalId: channels.externalId,
+        type: sql<ChannelRow["type"]>`${channels.type}`,
+        name: channels.name,
+        role: members.role,
+        archivedAt: channels.archivedAt,
+        lastActivityAt: channels.lastActivityAt,
+        lastSequence: channels.lastSequence,
+        // THE UNREAD COUNT, WITH NO COUNTER (FR-016). `channels.last_sequence` has been
+        // the sequencing authority since chapter 2.2 and the write path maintains it, so
+        // this has nothing to invalidate and nothing to backfill. Measured for one page
+        // of 50 channels against 1,000,000 messages: counting rows past the position is
+        // 9.8-13.4 ms, a cached counter is 1.2-2.1 ms, and this subtraction is
+        // 1.1-4.5 ms. The counter is no faster and adds a value that can go stale.
+        //
+        // `greatest(..., 0)` is defence against a bug, not a reachable state: a position
+        // is refused past `last_sequence` when it is written and `last_sequence` never
+        // goes backwards. It costs nothing and turns a negative count into zero rather
+        // than into a client bug report. `repository.itest.ts` plants a position above
+        // the end to cover the arm, because nothing else can reach it.
+        //
+        // A MISSING ROW IS POSITION ZERO (FR-017a). `coalesce` on the left join, not a
+        // seeded row on join: a new member's unread count is the channel's whole
+        // history, which is the same answer a re-added member gets, because removal
+        // deleted their position with their membership.
+        unread: sql<number>`greatest(${channels.lastSequence} - coalesce(${readPositions.sequence}, 0), 0)`,
+        // THE LAST MESSAGE, AND A TOMBSTONE IS STILL THE LAST MESSAGE (FR-019).
+        //
+        // The row AT `last_sequence`, reported with `text: null` when it is a tombstone,
+        // rather than walking back to the last row that still has text. The walk-back is
+        // a second query per channel and it would disagree with the count beside it,
+        // which counts the tombstone because the sequence is kept. One rule for both
+        // fields. A client that wants a preview renders "message deleted" from the null.
+        //
+        // A LATERAL SUBQUERY AND NOT A JOIN, because `messages_channel_id_sequence_unique`
+        // makes this an index lookup per row of an already-bounded page — 26 lookups, not
+        // a join against the whole message table. A join would also have to carry the
+        // ordering, and the planner would have to be talked out of sorting messages.
+        lastMessage: sql<{
+          sequence: number;
+          text: string | null;
+          user_external_id: string | null;
+          created_at: string;
+        } | null>`(
+          select json_build_object(
+            'sequence', m.sequence,
+            'text', m.text,
+            'user_external_id', mu.external_id,
+            'created_at', m.created_at
+          )
+            from messages m
+            left join users mu on mu.id = m.user_id
+           where m.channel_id = ${channels.id} and m.sequence = ${channels.lastSequence}
+        )`,
+      })
+      .from(members)
+      .innerJoin(channels, eq(channels.id, members.channelId))
+      .innerJoin(users, eq(users.id, members.userId))
+      .leftJoin(
+        readPositions,
+        and(
+          eq(readPositions.channelId, members.channelId),
+          eq(readPositions.userId, members.userId),
+        ),
+      )
+      .where(
+        and(
+          eq(members.userId, userId),
+          eq(users.environmentId, this.environmentId),
+          eq(channels.environmentId, this.environmentId),
+          after === undefined
+            ? undefined
+            : sql`(${channels.lastActivityAt}, ${channels.id}) < (${after.activityAt}, ${after.id})`,
+        ),
+      )
+      .orderBy(desc(channels.lastActivityAt), desc(channels.id))
+      .limit(limit + 1);
+
+    const page = rows.slice(0, limit);
+    const last = page.at(-1);
+    return {
+      rows: page.map((r) => ({
+        id: r.id,
+        external_id: r.externalId,
+        type: r.type,
+        name: r.name,
+        role: r.role,
+        archived_at: r.archivedAt === null ? null : toIso(r.archivedAt),
+        last_activity_at: toIso(r.lastActivityAt),
+        last_sequence: r.lastSequence,
+        unread: Number(r.unread),
+        // `null` when the channel has never had a message: `last_sequence` is 0 and no
+        // row carries sequence 0, so the subquery finds nothing. Distinct from a
+        // tombstone, which IS a row and reports itself with a null text.
+        last_message:
+          r.lastMessage === null
+            ? null
+            : {
+                sequence: Number(r.lastMessage.sequence),
+                text: r.lastMessage.text,
+                user:
+                  r.lastMessage.user_external_id === null
+                    ? null
+                    : { id: r.lastMessage.user_external_id },
+                created_at: r.lastMessage.created_at,
+              },
+      })),
+      nextCursor:
+        rows.length > limit && last !== undefined
+          ? { activityAt: last.lastActivityAt, id: last.id }
+          : null,
+    };
+  }
+
   /** The write path (chapters 2.2 + 2.3): sequence assignment under the
    * channel row lock (ADR-03), with idempotency enforcement via the
    * partial unique index (DR-03). The transaction IS the ordering
@@ -2994,6 +3559,29 @@
       // only one of them can be `now()`.
       const period = periodOf(new Date());
 
+      // ── THE BAN, FIRST, AND AHEAD OF THE CHANNEL READ (FR-031, FR-021a) ─────
+      //
+      // T072 left this slot and only Phase 15 can fill it, because until now nothing
+      // wrote `banned_at`. The position is the requirement: **before the channel is
+      // resolved**, so a banned user gets one answer for every channel id — real,
+      // foreign or invented. Put it after the channel read and the refusal for a
+      // channel that exists differs from the refusal for one that does not, and a
+      // banned user can enumerate channel ids.
+      //
+      // ONLY FOR AN ATTRIBUTED SEND. A key-authenticated REST send carries no user, so
+      // there is nobody to be banned; the tenant acting for itself is not a banned
+      // user's send by proxy, because the tenant is who bans.
+      if (userId !== undefined) {
+        const [sender] = await tx
+          .select({ bannedAt: users.bannedAt })
+          .from(users)
+          .where(
+            and(eq(users.id, userId), eq(users.environmentId, this.environmentId)),
+          )
+          .limit(1);
+        if (sender?.bannedAt != null) throw new UserBannedError(userId);
+      }
+
       const [channel] = await tx
         .select({
           id: channels.id,
@@ -3153,9 +3741,27 @@
       }
 
       // The sequence is spent only by a message that actually landed.
+      //
+      // AND `lastActivityAt` MOVES IN THE SAME STATEMENT (chapter 3.15, FR-014).
+      // The listing orders a user's channels by their most recent activity, and
+      // FR-014's answer to what that means is: a message. Not a join, not a
+      // rename, not an archive — a column that moved for those would order by
+      // something its own name does not say, which is what T108 tests.
+      //
+      // ONE STATEMENT AND NO NEW TRANSACTION. The write path already updates this
+      // row here, so the column costs an extra assignment rather than an extra
+      // round trip. It also lands on the INSERTED branch only, beside the
+      // sequence: a recognised idempotent retry returned above without reaching
+      // this line, which is the behaviour the ordering wants — a duplicate send
+      // is not new activity.
+      //
+      // `createdAt` FROM THE ROW, NOT `now()`. The message carries a timestamp
+      // the database assigned; reading the clock a second time here would let the
+      // ordering key and the message it orders by disagree by microseconds, and
+      // the cursor is keyed on this column.
       await tx
         .update(channels)
-        .set({ lastSequence: seq })
+        .set({ lastSequence: seq, lastActivityAt: inserted[0]!.createdAt })
         .where(eq(channels.id, channel.id));
 
       const createdAt = toIso(inserted[0]!.createdAt);

Có hai chỗ trong diff đó đáng đọc kỹ.

greatest(…, 0) là phòng vệ trước một bug, không phải một trạng thái tới được. Một vị trí vượt quá last_sequence bị từ chối ngay khi ghi, và last_sequence không bao giờ đi ngược — nên phép chốt tồn tại cho một trạng thái mà platform khiến không thể dựng nên được. Nó không tốn gì và biến một số âm thành 0 thay vì thành một báo lỗi từ client. Cách duy nhất để phủ được nhánh ấy là cắm thẳng một vị trí vượt cuối vào database, và đó là việc repository.itest.ts làm — dụng cụ thứ ba trong series này chưa từng tạo ra output nào cho tới khi có một test được viết riêng để bắt nó lên tiếng.

id nằm trong keyset chứ không phải để trang trí. last_activity_at không unique — hai channel có thể nhận tin trong cùng một milli-giây — và một keyset trên cột không unique thì hoặc bỏ sót một dòng hoặc trả lại một dòng hai lần ở mỗi biên trang có cặp trùng nằm vắt qua. Phép so sánh dòng của Postgres, (a, b) < (x, y), cho đúng nghĩa từ vựng nghiêm ngặt "mọi thứ sau chính dòng này" mà cursor muốn nói, trong một vị từ duy nhất mà planner có thể lái index bằng nó.

flowchart TB
    first["TRANG ĐẦU, user ở trong 20.000 channel<br/>10.62 ms — top-N heapsort trên 20.000 dòng"]
    first --> deep["TRANG SÂU, cursor gần cuối<br/>0.03 ms — keyset đã cắt tập dữ liệu xuống"]
    deep --> rev["TRANG ĐẦU LÀ TRANG ĐẮT NHẤT,<br/>ngược hẳn với phân trang bằng OFFSET"]
    flip["Ở 50.000 PLANNER ĐỔI KẾ HOẠCH<br/>đi tuần tự có thứ tự trên channels_environment_last_activity<br/>kèm một lần dò membership — không còn Sort — và<br/>NHANH HƠN cả mốc 20.000"]
    first --> flip
    tie["id NẰM TRONG KEY vì last_activity_at không unique:<br/>chỉ dùng '&lt;' trên timestamp sẽ bỏ sót dòng thứ hai bị trùng,<br/>còn '&lt;=' thì trả lại dòng đầu mãi mãi"]
    style rev fill:#1e3a5f,color:#fff,stroke:#3b82f6
    style flip fill:#064e3b,color:#fff,stroke:#059669
Trang đầu là trang đắt nhất, ngược hẳn với phân trang bằng OFFSET

Kế hoạch truy vấn, ở bốn quy mô

Tập membership lớn nhất của lane là năm channel. Nên phần liệt kê được đo theo đúng cách câu hỏi về thứ tự đã được đo, trên một environment tổng hợp rồi xoá đi sau đó:

số membership trang đầu trang sâu 1.000 0.46 ms top-N heapsort — 5.000 2.22 ms top-N heapsort 0.49 ms 20.000 10.62 ms top-N heapsort 0.03 ms 50.000 9.06 ms KHÔNG CÓ SORT 0.03 ms

Không có quét tuần tự ở bất kỳ quy mô nào. Hai điều rơi ra từ đây còn giá trị hơn chính các con số.

Trang đầu là trang đắt nhất. Vị từ keyset thu hẹp tập đầu vào, nên càng phân trang càng rẻ — 10.62 ms cho trang đầu của một user có 20.000 channel, 0.03 ms cho trang cuối. Với OFFSET thì cùng đường đi ấy càng về sau càng đắt. Sự bất đối xứng đó chính là lý lẽ cho keyset, nói bằng một con số thay vì bằng một nguyên tắc.

Và cái index chương này thêm vào không phải cái planner dùng — cho tới khi nó dùng. Tới mốc 20.000 membership, kế hoạch lái từ members_user_channel, join sang channels theo id, rồi sort. channels_environment_last_activity không được dùng. Ở 50.000 planner đổi sang một lần đi tuần tự có thứ tự song song trên index đó kèm một lần dò membership cho mỗi dòng, không còn node Sort nào — và nhanh hơn cả mốc 20.000. Index kiếm được chỗ của nó ở đúng quy mô mà đi qua các channel của environment theo thứ tự hoạt động rẻ hơn là sort tập membership của một user, và planner tự tìm ra điểm giao mà không cần ai chỉ.

services/api/src/db/repository.itest.ts
@@ -62,7 +62,7 @@
     const channel = await repoA.getChannelByExternalId("support");
     expect(await repoA.addMember(channel!.id, user!.id)).toBe("added");
     // Asked twice is a SUCCESS and not a failure, and telling those apart is
-    // what chapter 3.12 changed here: the endpoint over this call has to be
+    // what chapter 3.13 changed here: the endpoint over this call has to be
     // idempotent, and a unique violation reached the wire as `internal_error`.
     expect(await repoA.addMember(channel!.id, user!.id)).toBe("already_a_member");
     // B holds A's REAL ids — and still cannot write or read through them. The
@@ -280,3 +280,222 @@
     expect(await repoA.memberRole(channel.id, user.id)).toBe("owner");
   });
 });
+
+// ── T113: THE TIE AT A PAGE BOUNDARY (chapter 3.15, FR-013) ───────────────────
+//
+// HERE AND NOT IN `users.itest.ts`, because constructing the tie takes a raw UPDATE:
+// `last_activity_at` is written from the message's `created_at`, `now()` is the
+// transaction timestamp, and every send is its own transaction — so two channels
+// cannot be made to share the value through the API. This suite is on the
+// driver-exempt list (the layer under test IS the query layer); the route suite is
+// not, and adding a setter to the repository so it could reach one would have put a
+// method in production code whose only caller is a test.
+describe("the listing's keyset survives a shared last_activity_at (chapter 3.15)", () => {
+  it("returns each tied channel exactly once across pages", async () => {
+    const user = await repoA.createUser("tie-lister", "Tie Lister");
+    const shared = new Date("2026-08-20T12:00:00.000Z");
+    const ids: string[] = [];
+    for (const label of ["tie-a", "tie-b", "tie-c"]) {
+      const c = await repoA.createChannel(label, "public");
+      await repoA.addMember(c.id, user.id);
+      ids.push(c.id);
+    }
+    // All three at the same instant, to the millisecond.
+    await db.execute(
+      sql`UPDATE channels SET last_activity_at = ${shared} WHERE id IN (${sql.join(
+        ids.map((id) => sql`${id}`),
+        sql`, `,
+      )})`,
+    );
+
+    const seen: string[] = [];
+    let after: { activityAt: Date; id: string } | undefined;
+    for (let page = 0; page < 6; page++) {
+      const { rows, nextCursor } = await repoA.listChannelsForUser(user.id, {
+        limit: 1,
+        ...(after === undefined ? {} : { after }),
+      });
+      seen.push(...rows.map((r) => r.external_id));
+      if (nextCursor === null) break;
+      after = nextCursor;
+    }
+
+    // THREE ROWS, ONCE EACH. A keyset on the timestamp alone would either skip the
+    // second tied row (using `<`) or return the first one for ever (using `<=`);
+    // both failures are invisible without a tie in the fixture.
+    expect(seen).toHaveLength(3);
+    expect(new Set(seen)).toEqual(new Set(["tie-a", "tie-b", "tie-c"]));
+  });
+
+  it("orders tied channels by id descending, so the order is total", async () => {
+    const user = await repoA.createUser("tie-order", "Tie Order");
+    const shared = new Date("2026-08-19T12:00:00.000Z");
+    const made: string[] = [];
+    for (const label of ["order-a", "order-b"]) {
+      const c = await repoA.createChannel(label, "public");
+      await repoA.addMember(c.id, user.id);
+      made.push(c.id);
+    }
+    await db.execute(
+      sql`UPDATE channels SET last_activity_at = ${shared} WHERE id IN (${sql.join(
+        made.map((id) => sql`${id}`),
+        sql`, `,
+      )})`,
+    );
+    const { rows } = await repoA.listChannelsForUser(user.id, { limit: 10 });
+    const tied = rows.filter((r) => r.external_id.startsWith("order-"));
+    // Whichever uuid sorts higher comes first — the point is that SOME total order
+    // exists and the query commits to it, not which id wins.
+    const expected = [...made].sort().reverse();
+    expect(tied.map((r) => r.id)).toEqual(expected);
+  });
+});
+
+// ── THE TOMBSTONE, AND THE CLAMP (chapter 3.15, FR-016, FR-019) ───────────────
+//
+// BOTH STATES ARE UNREACHABLE THROUGH THE API, for different reasons, and both are
+// constructed here because this suite may hold raw SQL.
+//
+// FR-MSG-08 — "deleting a message shall replace its content with a tombstone retaining
+// sequence number, author, timestamps" — IS NOT IMPLEMENTED. `messages.deleted_at` and a
+// null `text` are in the schema, `backfill.controller` passes `text` straight through so
+// a null already reaches the wire, and NOTHING IN THE PLATFORM WRITES EITHER. The
+// tombstone is a live reader with no writer, which is the reverse of the dead columns
+// this feature is otherwise about. The listing's rule for it is implemented and tested
+// now so the day FR-MSG-08's chapter ships, the count and the preview already agree.
+describe("the listing's tombstone rule and its clamp (chapter 3.15)", () => {
+  it("reports a tombstoned last message with a null text, and still counts it", async () => {
+    const user = await repoA.createUser("tomb-reader", "Tomb Reader");
+    const channel = await repoA.createChannel("tombstoned", "public");
+    await repoA.addMember(channel.id, user.id);
+    await repoA.sendMessage(channel.id, { text: "kept", userId: user.id });
+    const last = await repoA.sendMessage(channel.id, { text: "doomed", userId: user.id });
+
+    // What FR-MSG-08 will do when it exists.
+    await db.execute(
+      sql`UPDATE messages SET text = NULL, deleted_at = now() WHERE id = ${last.id}`,
+    );
+
+    const { rows } = await repoA.listChannelsForUser(user.id, { limit: 10 });
+    const row = rows.find((r) => r.external_id === "tombstoned")!;
+
+    // THE ROW AT `last_sequence`, NOT THE LAST ROW WITH TEXT. Walking back would be a
+    // second query per channel and would disagree with the count beside it.
+    expect(row.last_message?.sequence).toBe(last.seq);
+    expect(row.last_message?.text).toBeNull();
+    expect(row.last_message?.user).not.toBeNull();
+
+    // AND THE APPROXIMATION FR-016 REQUIRES BE STATED (T124): a deleted message still
+    // counts as one unread, because a tombstone keeps its sequence and therefore its
+    // place in the arithmetic. Counting rows instead would make a deleted message stop
+    // being unread, at 10x the cost on the query a client runs to render its first
+    // screen.
+    expect(row.unread).toBe(2);
+  });
+
+  it("reports null for a channel that has never had a message", async () => {
+    const user = await repoA.createUser("empty-reader", "Empty Reader");
+    const channel = await repoA.createChannel("never-used", "public");
+    await repoA.addMember(channel.id, user.id);
+    const { rows } = await repoA.listChannelsForUser(user.id, { limit: 10 });
+    const row = rows.find((r) => r.external_id === "never-used")!;
+    // DISTINCT FROM A TOMBSTONE. `last_sequence` is 0, no row carries sequence 0, so the
+    // subquery finds nothing — where a tombstone IS a row and reports itself with a null
+    // text. A client can tell "no messages" from "the last one was deleted".
+    expect(row.last_message).toBeNull();
+    expect(row.unread).toBe(0);
+  });
+
+  // ── T127: the clamp's arm ───────────────────────────────────────────────────
+  it("clamps a read position above the channel's end to zero rather than negative", async () => {
+    const user = await repoA.createUser("clamp-reader", "Clamp Reader");
+    const channel = await repoA.createChannel("clamped", "public");
+    await repoA.addMember(channel.id, user.id);
+    await repoA.sendMessage(channel.id, { text: "one", userId: user.id });
+
+    // `setReadPosition` REFUSES THIS, which is why the arm needs planting. The clamp is
+    // defence against a bug — a position past `last_sequence` cannot be written and
+    // `last_sequence` never goes backwards — so this is the only way the branch is ever
+    // covered. Chapter 3.12 found three instruments that had never produced output for
+    // exactly this reason.
+    expect(await repoA.setReadPosition(channel.id, user.id, 99)).toBeNull();
+    await db.execute(
+      sql`INSERT INTO read_positions (environment_id, channel_id, user_id, sequence)
+          SELECT environment_id, ${channel.id}, ${user.id}, 99 FROM channels WHERE id = ${channel.id}
+          ON CONFLICT (channel_id, user_id) DO UPDATE SET sequence = 99`,
+    );
+
+    const { rows } = await repoA.listChannelsForUser(user.id, { limit: 10 });
+    expect(rows.find((r) => r.external_id === "clamped")!.unread).toBe(0);
+  });
+});
+
+// ── THE ARMS THE ROUTES CANNOT REACH (chapter 3.15, T174a, T174b) ─────────────
+//
+// Every one of these is a repository function answering "no" to something its own route
+// answers first. `deleteUser` on an id that does not exist, `updateUserProfile` on a
+// deleted row, an upsert that creates without a display name — the service layer 404s or
+// validates ahead of each, so the arm is unreachable THROUGH the API and perfectly
+// reachable one layer down.
+//
+// IN-PROCESS ON PURPOSE (T174b). Five of this feature's tests drive new repository code
+// through the gateway's api CHILD PROCESS, whose coverage is not attributable. Chapter 3.5
+// added six operations to this file the same way and branches went 85.91% → 78.22% on the
+// next run: the instrument was right and the code was untested.
+describe("the repository's own refusals (chapter 3.15)", () => {
+  it("returns false when deleting a user that does not exist", async () => {
+    expect(await repoA.deleteUser("00000000-0000-4000-8000-000000000000")).toBe(false);
+  });
+
+  it("returns null when patching a deleted user's profile", async () => {
+    const doomed = await repoA.createUser("arm-patch-deleted", "Doomed");
+    await repoA.deleteUser(doomed.id);
+    // The route answers 404 before reaching this, because `requireUser` reads the marker.
+    // One layer down, the `isNull(deletedAt)` in the WHERE is what refuses.
+    expect(await repoA.updateUserProfile(doomed.id, { display_name: "nope" })).toBeNull();
+    // And the same for an empty patch, which takes the other branch entirely — no UPDATE
+    // is issued, so the refusal comes from the SELECT.
+    expect(await repoA.updateUserProfile(doomed.id, {})).toBeNull();
+  });
+
+  it("returns null for an empty patch on a user that does not exist", async () => {
+    expect(
+      await repoA.updateUserProfile("00000000-0000-4000-8000-000000000001", {}),
+    ).toBeNull();
+  });
+
+  it("creates through the upsert with no profile fields at all", async () => {
+    const { user, status } = await repoA.upsertUser("arm-bare-upsert", {});
+    expect(status).toBe("created");
+    expect(user.display_name).toBeNull();
+    expect(user.avatar_url).toBeNull();
+    expect(user.metadata).toEqual({});
+  });
+
+  it("updates an avatar through the upsert", async () => {
+    await repoA.upsertUser("arm-avatar", { display_name: "First" });
+    const { user, status } = await repoA.upsertUser("arm-avatar", {
+      avatar_url: "https://cdn.example.com/arm.png",
+    });
+    expect(status).toBe("updated");
+    expect(user.avatar_url).toBe("https://cdn.example.com/arm.png");
+    // The name the entry omitted is untouched, which is the other side of the same branch.
+    expect(user.display_name).toBe("First");
+  });
+
+  it("reports a last message with no author as null", async () => {
+    // AN UNATTRIBUTED MESSAGE, which is what a key-authenticated REST send writes — no
+    // `userId`, by design since chapter 3.3. The listing's `last_message.user` is then
+    // null, and that arm has no route that can reach it: every send through the public
+    // channel route now carries a user, and the internal one resolves theirs.
+    const reader = await repoA.createUser("arm-no-author", "Reader");
+    const channel = await repoA.createChannel("arm-unattributed", "public");
+    await repoA.addMember(channel.id, reader.id);
+    await repoA.sendMessage(channel.id, { text: "from the tenant, not a user" });
+
+    const { rows } = await repoA.listChannelsForUser(reader.id, { limit: 10 });
+    const row = rows.find((r) => r.external_id === "arm-unattributed")!;
+    expect(row.last_message?.text).toBe("from the tenant, not a user");
+    expect(row.last_message?.user).toBeNull();
+  });
+});
services/api/src/users/users.schema.ts
import { z } from "zod";
 
// THE USER SURFACE'S BODIES AND QUERIES (chapter 3.15, FR-013, FR-016, FR-017).
//
// `strictObject` throughout, the same as `channels.schema.ts` and
// `messages.schema.ts`: constitution VI rejects unknown fields on a write endpoint,
// and `channels.itest.ts:118` is the assertion that keeps it honest. A caller who
// writes `Limit` instead of `limit` finds out on the first call.
 
/** 4 KB, and FR-USR-03 names that number the way FR-CHN-01 names 8 KB for a channel
 * (chapter 3.15, FR-024). Two bounds, half an order of magnitude apart, and the SRS chose
 * both — but "the SRS says so" is not a reason, so here is the one that holds.
 *
 * **THE BOUND TRACKS ROW CARDINALITY.** Measured on the test lane: 94,144 users against
 * 27,337 channels, and a user belongs to 1 channel on average while a channel holds 10
 * users. Users outnumber channels 3.4:1 here and the ratio only grows — a channel is a
 * conversation a customer creates deliberately, a user row appears for every end user who
 * ever authenticates, implicitly (FR-USR-02). At a million end users, 4 KB each is 4 GB of
 * jsonb that every profile read walks past.
 *
 * The channel's 8 KB buys something the user's does not: channel metadata is where a
 * customer puts routing and configuration for a shared object, read once per conversation.
 * A user's metadata is per-person annotation. Different multipliers, different budgets.
 *
 * Measured on the JSON text, like the channels bound, because that is what the column
 * stores and what the row costs. */
export const USER_METADATA_BYTES = 4 * 1024;
 
const userMetadataSchema = z
  .record(z.string(), z.unknown())
  .refine(
    (value) => Buffer.byteLength(JSON.stringify(value), "utf8") <= USER_METADATA_BYTES,
    { message: `metadata must be at most ${USER_METADATA_BYTES} bytes of JSON` },
  );
 
/** The profile body (chapter 3.15, FR-023, FR-024).
 *
 * A PATCH, so every field is optional — and `strictObject`, so a misspelled one is a
 * refusal. An empty body is accepted and changes nothing: unlike the member-role PATCH,
 * which carries exactly one required field, a profile PATCH with no fields is a coherent
 * request that asks for the current state, and the response is the profile.
 *
 * `avatar_url` IS VALIDATED AS A URL AND NOT AS A STRING. The column has existed since
 * chapter 2.1 with nothing writing it, so this is the first thing that ever decides what
 * belongs in it, and the decision is worth making now rather than after a customer has
 * stored `"none"` in a million rows. `z.url()` refuses a relative path; the field's own
 * name promises a URL.
 *
 * `null` CLEARS, and it is distinct from absent. `{"display_name": null}` removes the
 * name; `{}` leaves it. Both columns are nullable, so the API can express the difference
 * and a PATCH that could only set would leave a customer unable to undo one. */
export const userProfileBodySchema = z.strictObject({
  display_name: z.string().min(1).max(255).nullable().optional(),
  avatar_url: z.string().url().max(2048).nullable().optional(),
  metadata: userMetadataSchema.optional(),
});
 
export type UserProfileBody = z.infer<typeof userProfileBodySchema>;
 
/** An entry in the bulk upsert (chapter 3.15, FR-025, FR-026).
 *
 * THE PROFILE FIELDS, NOT JUST AN ID. FR-026 says an entry naming an existing user
 * **updates** it, so the entry carries what there is to update. An entry that was only an
 * external id could not distinguish "create this user" from "update nothing about them".
 *
 * `strictObject`, and the same 4 KB metadata bound and URL validation the single PATCH
 * uses — one schema fragment, so the two routes cannot drift into accepting different
 * things for the same column. */
export const upsertUserEntrySchema = z.strictObject({
  external_id: z.string().min(1).max(255),
  display_name: z.string().min(1).max(255).nullable().optional(),
  avatar_url: z.string().url().max(2048).nullable().optional(),
  metadata: userMetadataSchema.optional(),
});
 
/** FR-025's bound: 100 in one request, and `field: "users"` on 101.
 *
 * THE SAME 100 AS THE MEMBER-ADD AND THE REMOVAL, for the same reason: all three are "how
 * much a customer's server may hand over in one call", and three different ceilings would
 * be three numbers to remember for one idea.
 *
 * THE FIELD IS `users` AND THE CHANNEL ROUTES' IS `user_ids`, which is a real
 * inconsistency and the shipped name wins on the routes that shipped. This route is new,
 * so it takes the name that describes what it carries — these are whole user records, not
 * a list of ids. */
export const upsertUsersBodySchema = z.strictObject({
  users: z.array(upsertUserEntrySchema).min(1).max(100),
});
 
export type UpsertUsersBody = z.infer<typeof upsertUsersBodySchema>;
export type UpsertUserEntry = z.infer<typeof upsertUserEntrySchema>;
 
/** FR-013's page bound: the same 100 as the member-add and the upsert.
 *
 * ONE NUMBER FOR THE CONCEPT, not three that happen to agree. A page of channels, a
 * batch of members and a batch of users are all "how much a customer's server may
 * ask for in one request", and three different ceilings would be three things to
 * remember. */
export const LISTING_LIMIT_MAX = 100;
const LISTING_LIMIT_DEFAULT = 25;
 
/** The cursor is opaque to the client and a keyset to us: base64 of the JSON pair
 * `(last_activity_at, id)`.
 *
 * DECODED HERE AND NOT IN THE SERVICE, because a malformed cursor is a validation
 * failure with `field: "cursor"` — the shape `ZodValidationPipe` already produces
 * (chapter 3.14 gave every validation error its field). Decoding it downstream would
 * make it a 500 or a hand-rolled 400 that names nothing.
 *
 * OPAQUE IS NOT SECURITY. Base64 of JSON is readable by anyone who wants to read it;
 * what opacity buys is that the pair is ours to change without breaking a client that
 * treated the string as a token. A client that decodes it and constructs its own is
 * outside the contract. */
const cursorPayload = z.strictObject({
  a: z.string().min(1),
  id: z.string().uuid(),
});
 
export function encodeCursor(activityAt: string, id: string): string {
  return Buffer.from(JSON.stringify({ a: activityAt, id }), "utf8").toString(
    "base64url",
  );
}
 
export const listingQuerySchema = z.strictObject({
  limit: z.coerce
    .number()
    .int()
    .min(1)
    .max(LISTING_LIMIT_MAX)
    .default(LISTING_LIMIT_DEFAULT),
  cursor: z
    .string()
    .optional()
    .transform((raw, ctx) => {
      if (raw === undefined) return undefined;
      let parsed: unknown;
      try {
        parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
      } catch {
        ctx.addIssue({ code: "custom", message: "cursor is not a valid cursor" });
        return z.NEVER;
      }
      const shape = cursorPayload.safeParse(parsed);
      if (!shape.success) {
        ctx.addIssue({ code: "custom", message: "cursor is not a valid cursor" });
        return z.NEVER;
      }
      const activityAt = new Date(shape.data.a);
      if (Number.isNaN(activityAt.getTime())) {
        ctx.addIssue({ code: "custom", message: "cursor is not a valid cursor" });
        return z.NEVER;
      }
      return { activityAt, id: shape.data.id };
    }),
});
 
export type ListingQuery = z.infer<typeof listingQuerySchema>;
 
/** The read-position body (chapter 3.15, FR-017).
 *
 * `strictObject` and a required non-negative integer. Zero is legal and means "I have
 * read nothing", which is also what a missing row means — a client that wants to reset
 * writes 0 rather than deleting anything. */
export const readPositionBodySchema = z.strictObject({
  sequence: z.number().int().min(0),
});
 
export type ReadPositionBody = z.infer<typeof readPositionBodySchema>;

Hai phép xấp xỉ, được nói ra

Cả hai đều là hệ quả của việc dùng sequence thay vì đếm, và cả hai đều thuộc loại điều mà một chương phải nói to lên, không thì người đọc sẽ phát hiện ra nó như một bug.

Một tin nhắn đã xoá vẫn tính là một tin chưa đọc. Một tombstone giữ nguyên sequence của nó — đó là điều SRS đòi ở việc xoá tin nhắn — nên nó giữ nguyên chỗ của mình trong phép tính. Đếm số dòng sau vị trí đã đọc thì sẽ khiến một tin nhắn bị xoá thôi được tính là chưa đọc, với chi phí gấp 10 lần trên đúng câu query mà client chạy để vẽ màn hình đầu tiên.

Cùng luật đó quyết định last_message báo về cái gì: dòng ở đúng last_sequence, với text: null khi dòng đó là một tombstone, chứ không lần ngược về dòng cuối cùng còn text. Lần ngược ấy là một query thứ hai cho mỗi channel, và nó sẽ không nhất quán với con số nằm ngay bên cạnh.

Và tin nhắn của chính một user vẫn tính là chưa đọc cho tới khi họ xác nhận. Spec đã giả định điều ngược lại — "tin nhắn của chính một user thì họ đã đọc" — còn kịch bản thì lỏng hơn và đúng: liệu nó có tính hay không là điều phải nói ra và kiểm thử. Đo được: nó có tính. Đường ghi không tự đẩy read position của chính người gửi.

Bề mặt người dùng

Năm điều khoản SRS cần những route có chủ thể là một user, và không có controller nào cho user. Treo chúng lên channels controller sẽ đặt vòng đời của user phía sau một đường dẫn channel: POST /v1/channels/users là một câu chẳng nói về gì.

services/api/src/users/users.module.ts
import { Module, Scope } from "@nestjs/common";
import { REQUEST } from "@nestjs/core";
 
import { AuthModule } from "../auth/auth.module";
import { createDb, createPool, type Db } from "../db/client";
import { Repository } from "../db/repository";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
import type { RequestWithTenant } from "../messages/request-with-tenant";
 
// The channels module's shape, for the channels module's reasons (chapter 3.15).
//
// A SEPARATE MODULE AND NOT A ROUTE ON `ChannelsController`. Five SRS clauses need
// routes whose subject is a user — the listing, the profile read, the upsert, the
// deletion, the ban — and hanging them off the channels controller would put user
// lifecycle behind a channel path. `POST /v1/channels/users` is a sentence about
// nothing.
@Module({
  imports: [AuthModule],
  controllers: [UsersController],
  providers: [
    {
      provide: "DB",
      useFactory: (): Db => createDb(createPool()),
      scope: Scope.DEFAULT,
    },
    {
      provide: Repository,
      scope: Scope.REQUEST,
      inject: ["DB", REQUEST],
      useFactory: (db: Db, req: RequestWithTenant) =>
        new Repository(db, req.principal?.environmentId ?? ""),
    },
    UsersService,
  ],
})
export class UsersModule {}
services/api/src/app.module.ts
@@ -11,6 +11,11 @@
 import { InternalModule } from "./internal/internal.module";
 import { MessagesModule } from "./messages/messages.module";
 import { ChannelsModule } from "./channels/channels.module";
+// Chapter 3.15. Registered here for the reason `ChannelsModule` is: without this
+// line the module is compiled, exported, imported by nothing, and none of the user
+// routes exist. The file appeared in no task until an enumeration asked which
+// chapter fences it.
+import { UsersModule } from "./users/users.module";
 import { ConsumerModule } from "./consumer/consumer.module";
 import { NotificationsModule } from "./notifications/notifications.module";
 import { QuotasModule } from "./quotas/quotas.module";
@@ -32,6 +37,7 @@
     AuthModule,
     MessagesModule,
     ChannelsModule,
+    UsersModule,
     InternalModule,
     TenancyModule,
     OutboxModule,
services/api/src/users/users.service.ts
import { HttpStatus, Injectable, NotFoundException } from "@nestjs/common";
 
import { protocolError } from "../protocol-error";
import { Repository, type UserRow } from "../db/repository";
import {
  encodeCursor,
  type ListingQuery,
  type UpsertUsersBody,
  type UserProfileBody,
} from "./users.schema";
 
/** The user surface (chapter 3.15, FR-013 and the clauses after it).
 *
 * EVERY ROUTE HERE NAMES ITS USER IN THE PATH, and the credential is the tenant's.
 * So "the caller" on these routes is the application, never the user named — a
 * distinction four documents got wrong for twelve analysis passes, because FR-015's
 * "a channel the caller is not a member of MUST NOT appear in their listing" is
 * vacuous when the caller is an application key: a key is a member of nothing and an
 * empty list satisfied it. The requirement is about the user the PATH names. */
@Injectable()
export class UsersService {
  constructor(private readonly repo: Repository) {}
 
  /** A deleted user is a 404 on every route that names them (FR-017).
   *
   * The row survives deletion — a message keeps its author, and `toFrame` drops a
   * senderless row, so "authored by a deleted user" and "authored by nobody" are
   * different states and only one of them is the clause. The marker is what makes the
   * row invisible to the API without making the message anonymous. */
  private async requireUser(externalId: string): Promise<UserRow> {
    const user = await this.repo.getUserByExternalId(externalId);
    if (!user || user.deleted_at !== null) {
      throw new NotFoundException("user not found");
    }
    return user;
  }
 
  /** The profile as the API shapes it (FR-023).
   *
   * `deleted_at` IS NOT ON THE WIRE. It is read on every route that names a user and it
   * decides a 404; a client never sees a deleted user at all, so returning the marker
   * would be returning a field whose only possible value is null. */
  private static profile(user: UserRow): {
    external_id: string;
    display_name: string | null;
    avatar_url: string | null;
    metadata: Record<string, unknown>;
  } {
    return {
      external_id: user.external_id,
      display_name: user.display_name,
      avatar_url: user.avatar_url,
      metadata: user.metadata,
    };
  }
 
  async readProfile(externalId: string): Promise<ReturnType<typeof UsersService.profile>> {
    return UsersService.profile(await this.requireUser(externalId));
  }
 
  async updateProfile(
    externalId: string,
    patch: UserProfileBody,
  ): Promise<ReturnType<typeof UsersService.profile>> {
    const user = await this.requireUser(externalId);
    const updated = await this.repo.updateUserProfile(user.id, patch);
    // `null` here means the row went away between the two statements — a deletion racing
    // a patch. 404 is the same answer the read gives, which is the answer that does not
    // depend on which of the two won.
    if (updated === null) throw new NotFoundException("user not found");
    return UsersService.profile(updated);
  }
 
  async listChannels(
    externalId: string,
    query: ListingQuery,
  ): Promise<{
    data: Array<Record<string, unknown>>;
    next_cursor: string | null;
  }> {
    const user = await this.requireUser(externalId);
    const { rows, nextCursor } = await this.repo.listChannelsForUser(user.id, {
      limit: query.limit,
      ...(query.cursor === undefined ? {} : { after: query.cursor }),
    });
    return {
      data: rows.map((r) => ({
        // BOTH IDS, the shape `POST /v1/channels` already returns. `contracts/listing.md`
        // showed `"id": "c_support"` — an external id under the name `id` — which would
        // have made `id` mean the uuid on one route and the customer's own string on
        // another, in one API. The contract is corrected; the create route's shape wins
        // because it shipped.
        id: r.id,
        external_id: r.external_id,
        type: r.type,
        name: r.name,
        role: r.role,
        archived_at: r.archived_at,
        unread: r.unread,
        last_activity_at: r.last_activity_at,
        last_message: r.last_message,
      })),
      next_cursor:
        nextCursor === null
          ? null
          : encodeCursor(nextCursor.activityAt.toISOString(), nextCursor.id),
    };
  }
 
  /** Record a read position for the user the path names (chapter 3.15, FR-017, FR-018).
   *
   * THE MEMBERSHIP THIS REFUSAL TALKS ABOUT IS THE PATH'S USER, NOT THE CALLER. Under an
   * application credential the caller has no membership at all — it is the tenant — so
   * "the caller is not a member" is a sentence about nothing on this route. The
   * authorization table's member and non-member columns said nothing for this row until
   * an analysis pass noticed that, and the same mistake sat in five other places.
   *
   * AND THIS IS `not_a_member`'s ONLY EMITTER IN THE WHOLE FEATURE. A read position is
   * per-member state keyed by channel and user, and removal deletes the row with the
   * membership, so refusing a non-member here is the rule the rest of the table keeps.
   * Everywhere else a private channel answers the not-found envelope instead, because a
   * 403 would announce that the channel exists.
   *
   * SO THE ORDER MATTERS: visibility first, then membership. A private channel the user
   * is not in answers 404 — indistinguishable from a channel that does not exist. A
   * PUBLIC channel they are not in answers 403 `not_a_member`, which reveals only that a
   * public channel exists, and a public channel is readable by any user of the tenant
   * anyway. */
  async setReadPosition(
    externalId: string,
    channelId: string,
    sequence: number,
  ): Promise<{ sequence: number }> {
    const user = await this.requireUser(externalId);
 
    // Visibility for THE PATH'S USER, which is what makes the two refusals different.
    if (!(await this.repo.channelVisibleTo(channelId, user.id))) {
      throw new NotFoundException("channel not found");
    }
    if (!(await this.repo.isMember(channelId, user.id))) {
      throw protocolError(
        "not_a_member",
        "the user is not a member of this channel",
        HttpStatus.FORBIDDEN,
      );
    }
 
    const written = await this.repo.setReadPosition(channelId, user.id, sequence);
    if (written === null) {
      throw protocolError(
        "invalid_request",
        "sequence is past the channel's last message",
        HttpStatus.BAD_REQUEST,
        "sequence",
      );
    }
    return { sequence: written.sequence };
  }
 
  /** Up to 100 users in one call, reported per entry (chapter 3.15, FR-025, FR-026).
   *
   * SEQUENTIAL AND NOT A SINGLE MULTI-ROW STATEMENT. Each entry is its own upsert because
   * each carries its own partial profile: a bulk `INSERT ... ON CONFLICT DO UPDATE` has one
   * `SET` clause for every row, so "leave display_name alone for entry 3 and set it for
   * entry 7" cannot be expressed. 100 round trips inside one request is the cost of
   * FR-026's per-entry semantics, and the bound is what keeps it bounded.
   *
   * NO TRANSACTION AROUND THE BATCH, deliberately. The result array reports per entry, so a
   * caller learns exactly which entries landed; wrapping the batch would turn one bad entry
   * into a hundred silent non-writes and the array would be a lie. Validation already
   * rejected the whole body before any write, so what remains here are failures the
   * database raises, which per-entry reporting is the right shape for.
   */
  async upsertUsers(body: UpsertUsersBody): Promise<{
    data: Array<{
      external_id: string;
      status: "created" | "updated" | "revived";
      display_name: string | null;
      avatar_url: string | null;
      metadata: Record<string, unknown>;
    }>;
  }> {
    const data = [];
    for (const entry of body.users) {
      const { external_id, ...profile } = entry;
      const { user, status } = await this.repo.upsertUser(external_id, profile);
      data.push({
        external_id: user.external_id,
        status,
        display_name: user.display_name,
        avatar_url: user.avatar_url,
        metadata: user.metadata,
      });
    }
    return { data };
  }
 
  /** Delete a user (chapter 3.15, FR-027 to FR-029).
   *
   * IDEMPOTENT AT 200 AND 404 FOR A USER WHO NEVER EXISTED. `requireUser` cannot be used
   * here — it 404s a user who is already deleted, and deleting twice is the ordinary
   * outcome of a customer's retry after a timeout. So the row is read without the
   * liveness filter, and only "no row at all" is a 404. */
  async deleteUser(externalId: string): Promise<{ external_id: string; deleted: true }> {
    const user = await this.repo.getUserByExternalId(externalId);
    if (!user) throw new NotFoundException("user not found");
    await this.repo.deleteUser(user.id);
    return { external_id: externalId, deleted: true };
  }
 
  /** Ban and unban, tenant-wide (chapter 3.15, FR-031, FR-032).
   *
   * BOTH IDEMPOTENT AND BOTH 200. Banning a banned user and unbanning an unbanned one
   * are the ordinary outcomes of a retry, and the caller's intent is satisfied either
   * way. A 409 here would make a customer's reconciliation loop — "ensure these users
   * are banned" — have to distinguish success from success.
   *
   * A DELETED USER CANNOT BE BANNED, because `requireUser` 404s them. They already
   * cannot connect: the session route resolves the user and a deleted row has no
   * channels, and every route naming them answers 404. Banning one would be a state with
   * no observable difference.
   */
  async setBanned(externalId: string, banned: boolean): Promise<{ external_id: string; banned: boolean }> {
    const user = await this.requireUser(externalId);
    if (banned) await this.repo.banUser(user.id);
    else await this.repo.unbanUser(user.id);
    return { external_id: externalId, banned };
  }
}
services/api/src/users/users.controller.ts
import {
  Body,
  Controller,
  Delete,
  Get,
  HttpCode,
  Param,
  Patch,
  Post,
  Put,
  Query,
  UseGuards,
} from "@nestjs/common";
 
import { Accepts, CredentialGuard } from "../auth/credential.guard";
import { ZodValidationPipe } from "../messages/zod-validation.pipe";
import {
  listingQuerySchema,
  readPositionBodySchema,
  upsertUsersBodySchema,
  userProfileBodySchema,
  type ListingQuery,
  type ReadPositionBody,
  type UpsertUsersBody,
  type UserProfileBody,
} from "./users.schema";
import { UsersService } from "./users.service";
 
/** The user surface (chapter 3.15).
 *
 * `@Accepts("application")` AT THE CLASS LEVEL. Every route here is the tenant
 * acting on a user it names in the path — a customer's server listing a user's
 * channels, reading their profile, upserting them, banning them. A user token on
 * these routes would be a user acting on themselves through a path that says who
 * they are, which is a different route shape and not one the SRS asks for.
 *
 * Declared rather than defaulted, because chapter 3.15 found the cost of leaving it
 * out: `MessagesController` declared no `@Accepts`, the guard fell back to accepting
 * either class, and the membership check behind it was gated on a user id the public
 * route never supplied. */
@Controller("v1/users")
@UseGuards(CredentialGuard)
@Accepts("application")
export class UsersController {
  constructor(private readonly users: UsersService) {}
 
  @Get(":externalId/channels")
  async listChannels(
    @Param("externalId") externalId: string,
    @Query(new ZodValidationPipe(listingQuerySchema)) query: ListingQuery,
  ): Promise<{ data: Array<Record<string, unknown>>; next_cursor: string | null }> {
    return this.users.listChannels(externalId, query);
  }
 
  /** A read position for the user the path names (chapter 3.15, FR-017).
   *
   * `@Accepts("application", "user")` AT THE METHOD LEVEL, and it is the only route on
   * this controller that takes a user token: a user records their own position, and the
   * tenant records one on behalf of the user it names. Method-level wins over the
   * class-level `@Accepts("application")` because the guard resolves
   * `[handler, class]` in that order.
   *
   * `:channelId` IS THE UUID, like every other channel route in this API.
   * `contracts/listing.md` writes it `:channelExternalId`, and that file says of itself
   * that its paths are written with the customer's identifiers in place while the router
   * names channel parameters `:channelId` — a classification entry copied from it
   * verbatim will not match a derived target. */
  @Put(":externalId/channels/:channelId/read")
  @Accepts("application", "user")
  async setReadPosition(
    @Param("externalId") externalId: string,
    @Param("channelId") channelId: string,
    @Body(new ZodValidationPipe(readPositionBodySchema)) body: ReadPositionBody,
  ): Promise<{ sequence: number }> {
    return this.users.setReadPosition(externalId, channelId, body.sequence);
  }
 
  /** The profile, read and written (chapter 3.15, FR-023, FR-024).
   *
   * TWO OF ITS THREE FIELDS HAVE NEVER BEEN WRITTEN BY ANY ROUTE. `users.avatar_url` and
   * `users.metadata` have been in the schema since chapter 2.1 with no reference outside
   * tests. This pair of routes is what the feature's headline was about.
   *
   * `:externalId` LAST IN THE FILE AND NOT FIRST. Nest matches routes in declaration
   * order, so `GET :externalId` declared above `GET :externalId/channels` would still be
   * fine — the paths differ in segment count — but keeping the more specific route first
   * is the habit that stops the next route from shadowing something. */
  @Get(":externalId")
  async readProfile(@Param("externalId") externalId: string) {
    return this.users.readProfile(externalId);
  }
 
  @Patch(":externalId")
  async updateProfile(
    @Param("externalId") externalId: string,
    @Body(new ZodValidationPipe(userProfileBodySchema)) body: UserProfileBody,
  ) {
    return this.users.updateProfile(externalId, body);
  }
 
  /** Up to 100 users in one call (chapter 3.15, FR-025).
   *
   * DECLARED BEFORE THE `:externalId` ROUTES. Nest matches in declaration order and
   * `POST /v1/users` and `PATCH /v1/users/:externalId` differ in both method and segment
   * count, so nothing shadows anything here — but a bare-path route below a parameterised
   * one is the shape that eventually does, and the habit costs nothing.
   *
   * 200 AND NOT 201, because the array reports created, updated and revived per entry.
   * One status code for a mixed outcome would have to pick a lie. */
  @Post()
  @HttpCode(200)
  async upsertUsers(
    @Body(new ZodValidationPipe(upsertUsersBodySchema)) body: UpsertUsersBody,
  ) {
    return this.users.upsertUsers(body);
  }
 
  /** Delete a user, keeping their row and their messages (chapter 3.15, FR-027). */
  @Delete(":externalId")
  async deleteUser(@Param("externalId") externalId: string) {
    return this.users.deleteUser(externalId);
  }
 
  /** The ban pair (chapter 3.15, FR-031).
   *
   * TWO ROUTES ON ONE PATH RATHER THAN A `PATCH` WITH A BOOLEAN. `POST …/ban` and
   * `DELETE …/ban` say what they do in the method, and a customer's reconciliation loop
   * can issue either without reading the current state first. A `{"banned": false}` body
   * would be a second way to spell the same thing.
   *
   * `@HttpCode(200)` on the POST for the reason the upsert has it: nothing is created,
   * and banning an already-banned user is a 200 too. */
  @Post(":externalId/ban")
  @HttpCode(200)
  async ban(@Param("externalId") externalId: string) {
    return this.users.setBanned(externalId, true);
  }
 
  @Delete(":externalId/ban")
  async unban(@Param("externalId") externalId: string) {
    return this.users.setBanned(externalId, false);
  }
}

Hai trong ba field của profile — users.avatar_urlusers.metadata — đã nằm trong schema suốt hai mươi hai chương mà không route nào ghi hay đọc một trong hai. Đây chính là cặp route mà tiêu đề của tính năng nói về.

Ba trạng thái mà API phân biệt được, còn một endpoint chỉ biết "set" thì không:

field vắng mặt trong patch giữ nguyên giá trị field có mặt và bằng null bị xoá trắng một patch rỗng 200, không ghi gì, trả về profile hiện tại

Trường hợp cuối không phát ra UPDATE nào cả. exactOptionalPropertyTypes là thứ làm cho "vắng mặt" và "null" khác nhau ở tầng type chứ không phải theo quy ước — sự khắt khe của chương 1.4 kiếm được chỗ đứng của nó hai mươi bốn chương sau.

avatar_url được validate như một URL chứ không như một chuỗi. Chưa có gì từng quyết định cái gì thuộc về cột đó, nên đây là thứ đầu tiên quyết định — và quyết định bây giờ rẻ hơn là sau khi một khách hàng đã lưu "none" vào một triệu dòng.

Và giới hạn 4 KB giờ đã có một lý do, không chỉ một điều khoản. FR-USR-03 ghi 4 KB nơi FR-CHN-01 ghi 8 KB cho một channel. Cả hai đều nằm trong SRS, nên không cái nào bị thừa hưởng trong im lặng, nhưng "tài liệu ghi thế" không phải một lý lẽ. Đo trên test lane: 94.144 user so với 27.337 channel, tỉ lệ 3,4:1 và còn tăng, vì một channel được tạo có chủ đích còn một dòng user thì xuất hiện với mọi người dùng cuối từng xác thực. Ở một triệu người dùng cuối, 4 KB mỗi người là 4 GB jsonb mà mọi lần đọc profile phải đi qua.

Lần xoá vẫn giữ lại dòng

flowchart TB
    del["DELETE /v1/users/:externalId"]
    del --> gone["MẤT: display_name, avatar_url, metadata,<br/>mọi membership, mọi read position"]
    del --> stays["CÒN: dòng user, mọi tin nhắn,<br/>mọi dòng usage_active_users"]
    stays --> why["messages.user_id vẫn trỏ tới một dòng thật"]
    why --> frame["nên toFrame vẫn dựng được message.created,<br/>và client đang resume vẫn nhận được"]
    setnull["ON DELETE SET NULL thoả mãn<br/>câu 'tin nhắn được giữ lại'"]
    setnull --> drop["toFrame LOẠI BỎ một dòng không có người gửi —<br/>messageSchema.user là z.string().min(1)"]
    drop --> silent["mọi tin nhắn người đó từng gửi biến mất<br/>khỏi mọi client kết nối lại, dấu vết duy nhất<br/>là một khoảng trống trong sequence"]
    style frame fill:#064e3b,color:#fff,stroke:#059669
    style silent fill:#7f1d1d,color:#fff,stroke:#dc2626
Cái gì mất, cái gì còn, và cái frame lẽ ra đã biến mất

FR-USR-05 đòi rằng xoá một user thì phải giữ "tin nhắn của họ, dưới danh nghĩa một user đã bị xoá". Cách đọc dễ thấy nhất là ON DELETE SET NULL trên messages.user_id: tin nhắn còn, danh tính mất.

Điều đó thoả mãn câu chữ và làm hỏng việc phát tin. toFrame biến một dòng đã lưu thành một payload message.created hoặc thành không gì cả, và một trong hai loại dòng nó loại bỏ là dòng không có người gửi — messageSchema.userz.string().min(1), nên một tác giả null không thể là một frame. Set null cột đó sẽ giữ mọi tin nhắn trong storage và xoá chúng khỏi mọi client kết nối lại, âm thầm, dấu vết duy nhất là một khoảng trống trong sequence.

Nên "được viết bởi một user đã xoá" và "không có ai viết" là hai trạng thái khác nhau, và chỉ một trong hai là điều khoản. Dòng user ở lại, với profile bị xoá trắng và một dấu xoá được đặt.

services/gateway/src/isolation.itest.ts
@@ -126,6 +126,10 @@
   waitFor: <T = Record<string, unknown>>(type: string, timeoutMs?: number) => Promise<T>;
   frames: () => { type: string }[];
   opened: () => Promise<void>;
+  /** The close code, once it arrives (chapter 3.15, T151). Added because a refusal at
+   * connect IS a close code — the frame is only the explanation — and asserting the
+   * frame alone would pass whether the socket closed 4003, 4001 or not at all. */
+  closedWith: (timeoutMs?: number) => Promise<number>;
 }
 
 function read(socket: WebSocket): Reader {
@@ -160,7 +164,16 @@
     }
   };
 
-  return { socket, waitFor, frames: () => [...buffer], opened };
+  const closedWith = async (timeoutMs = 5_000): Promise<number> => {
+    const deadline = Date.now() + timeoutMs;
+    for (;;) {
+      if (closed !== null) return closed;
+      if (Date.now() > deadline) throw new Error("socket never closed");
+      await new Promise((r) => setTimeout(r, 25));
+    }
+  };
+
+  return { socket, waitFor, frames: () => [...buffer], opened, closedWith };
 }
 
 /** Absence needs a deadline rather than a race, so three of the four attacks below
@@ -420,6 +433,194 @@
     expect(client.frames().filter((f) => f.type === "message.created")).toEqual([]);
   });
 
+  // ── T151, T153: THE BAN AT THE DOOR, AND WHAT IT DOES TO AN OPEN SOCKET ───
+  //
+  // FR-032 asks what a ban does to a connection that is ALREADY OPEN, and T153 named two
+  // candidate answers — "closed at the next heartbeat" and "closed immediately" — noting
+  // they differ in whether the gateway has to be told.
+  //
+  // **THE ANSWER IS NEITHER, AND IT IS ALREADY BUILT.** A banned socket stops being able
+  // to SEND the instant the ban lands, because a socket send goes through the api's
+  // `/internal/messages`, which is the same repository path the ban check sits at the top
+  // of. It keeps RECEIVING until it closes for any other reason, because delivery never
+  // asks the api anything.
+  //
+  // That is not a compromise invented here — it is the shape chapter 3.2 already chose
+  // for an expired token, whose comment in `session.ts` says it in as many words: "the
+  // socket is still up and still RECEIVES, because delivery never asks the api anything.
+  // Writing does."
+  //
+  // WHY NOT CLOSE IT. Closing an open socket on ban needs the api to tell the gateway,
+  // which is new plumbing on the fan-out for an event that happens rarely; re-checking at
+  // each heartbeat needs an api call on every ping of every connection. Both buy the
+  // difference between "cannot speak" and "cannot listen", for a user the tenant has
+  // already silenced.
+  it("refuses a banned user at connect with 4003, not 4001", async () => {
+    await tenants.attacker.banSelf();
+    try {
+      const client = connect(tenants.attacker.token);
+      // The error frame arrives first, because a close reason is a short string.
+      const err = await client.waitFor<{ payload: { code: string } }>("error");
+      expect(err.payload.code).toBe("user_banned");
+      const closed = await client.closedWith();
+      // 4003 AND NOT 4001. The token is valid and the user is refused; 4001 would send a
+      // client round the re-authentication loop for ever.
+      expect(closed).toBe(4003);
+    } finally {
+      await tenants.attacker.unbanSelf();
+    }
+  });
+
+  it("stops an already-open socket from sending, and keeps delivering to it", async () => {
+    const client = connect(tenants.victim.token);
+    await client.waitFor("connection.ack");
+
+    await tenants.victim.banSelf();
+    try {
+      // SENDING STOPS. The frame is accepted by the gateway and refused by the api, so
+      // the client is told rather than disconnected.
+      client.socket.send(
+        JSON.stringify({
+          type: "message.send",
+          payload: {
+            idem_key: randomUUID(),
+            channel: tenants.victim.channelId,
+            text: "banned mid-connection",
+          },
+        }),
+      );
+      const err = await client.waitFor<{ payload: { code: string } }>("error");
+      expect(err.payload.code).toBe("user_banned");
+
+      // AND THE SOCKET IS STILL OPEN. Stated as an assertion because it is the half of
+      // FR-032 a reader will not guess: a ban silences a connection, it does not sever
+      // it, and the next reconnect is where the door closes.
+      expect(client.socket.readyState).toBe(1);
+    } finally {
+      await tenants.victim.unbanSelf();
+    }
+  });
+
+  // ── T144: A DELETED USER'S MESSAGE STILL REACHES A SOCKET (FR-028) ────────
+  //
+  // THIS IS THE ASSERTION THAT WOULD HAVE CAUGHT `ON DELETE SET NULL`, and the reason
+  // R7 chose to keep the row over satisfying the letter of the clause.
+  //
+  // `backfill.controller`'s `toFrame` turns a row into a frame **or into nothing**, and
+  // one of the two rows it drops is a senderless one — `messageSchema.user` is
+  // `z.string().min(1)`, so a null author cannot be a `message.created` payload at all.
+  // Nulling `messages.user_id` on deletion would therefore preserve every message in
+  // storage and remove it from every reconnecting client, silently, with a sequence gap
+  // as the only trace.
+  //
+  // THE RESUME PATH IS THE ONE THAT CARRIES IT. The backfill runs at connect, from the
+  // client's cursor, which is the only place in this suite where a stored message becomes
+  // a frame — the live fan-out does not reach this suite at all (see T134).
+  it("delivers a deleted user's message on resume, still attributed to them", async () => {
+    // ITS OWN FIXTURE. The first version deleted the shared `victim`, which took that
+    // tenant's membership with it and made the next test's profile PATCH answer 404 —
+    // the same shared-fixture mutation Phase 7 hit twice.
+    const { userExternalId, channelId, seq, witnessToken } =
+      await tenants.victim.seedDeletable();
+
+    const deleted = await fetch(`${api.url}/v1/users/${userExternalId}`, {
+      method: "DELETE",
+      headers: { authorization: `Bearer ${tenants.victim.credential}` },
+    });
+    expect(deleted.status).toBe(200);
+
+    // A REMAINING MEMBER RESUMES. The deletion took the doomed user's own membership, so
+    // their session no longer carries the channel — and the case that matters is that the
+    // message survives for everybody else.
+    const client = connect(witnessToken, `&cursor=${channelId}:0`);
+    const ack = await client.waitFor<{ payload: { cursor: Record<string, number> } }>(
+      "connection.ack",
+    );
+    expect(Object.keys(ack.payload.cursor)).toContain(channelId);
+
+    const mine = await client.waitFor<{ payload: Record<string, unknown> }>(
+      "message.created",
+    );
+    // THE FRAME ARRIVED, and its `user` is the deleted user's external id. Both halves
+    // matter: absent means `toFrame` dropped the row, and a null `user` means
+    // `messageSchema` would have refused it.
+    expect(mine.payload["seq"]).toBe(seq);
+    expect(mine.payload["user"]).toBe(userExternalId);
+    expect(mine.payload["text"]).toBe("sent before the deletion");
+  });
+
+  // ── T134: THE PROFILE IS STORED AND THE WIRE DID NOT MOVE ─────────────────
+  //
+  // Chapter 3.15 gives `users.display_name`, `users.avatar_url` and `users.metadata` a
+  // route that writes them and a route that reads them. **None of that reaches a
+  // socket.** `connection.ack` names who you are with a bare external id string, and
+  // `messageSchema` carries `user` the same way — no display name, no avatar, no
+  // metadata.
+  //
+  // ASSERTED RATHER THAN ASSUMED, because "we did not change the protocol" is the claim a
+  // test replaces. A later change that enriched `user` into an object would break every
+  // client parsing frames against the published schema.
+  //
+  // THE MESSAGE HALF IS CHECKED AGAINST THE SCHEMA AND NOT AGAINST A LIVE FRAME, because
+  // no `message.created` ever arrives in this suite: `say()` writes through the
+  // repository, the api publishes to no fan-out, and nothing here drains the outbox.
+  // Chapter 3.12 recorded that as its own finding — a REST-sent message reaches no socket,
+  // ever — and `public-surface.itest.ts` is what pins it. Waiting for a frame here is a
+  // 5-second timeout, which is how this test was written the first time.
+  it("keeps the socket's identity a bare external id, whatever the profile holds", async () => {
+    // A full profile written through the public route.
+    const patched = await fetch(`${api.url}/v1/users/${tenants.victim.userExternalId}`, {
+      method: "PATCH",
+      headers: {
+        "content-type": "application/json",
+        authorization: `Bearer ${tenants.victim.credential}`,
+      },
+      body: JSON.stringify({
+        display_name: "A Name On The Wire",
+        avatar_url: "https://cdn.example.com/face.png",
+        metadata: { seen: "by nobody" },
+      }),
+    });
+    expect(patched.status).toBe(200);
+
+    // THE LIVE HALF: the handshake, after the profile exists.
+    const client = connect(tenants.victim.token);
+    const ack = await client.waitFor<{ payload: { user: unknown } }>("connection.ack");
+    expect(typeof ack.payload.user).toBe("string");
+    expect(ack.payload.user).toBe(tenants.victim.userExternalId);
+
+    // THE CONTRACT HALF: the frame union refuses an enriched identity. If somebody widens
+    // `messageSchema.user` to an object, this stops failing — and that is the change this
+    // assertion exists to catch, because it is the one that breaks published clients.
+    const enriched = frameSchema.safeParse({
+      type: "message.created",
+      payload: {
+        id: randomUUID(),
+        channel: tenants.victim.channelId,
+        seq: 1,
+        user: { id: tenants.victim.userExternalId, display_name: "A Name On The Wire" },
+        text: "hello",
+        created_at: new Date().toISOString(),
+      },
+    });
+    expect(enriched.success).toBe(false);
+
+    // And the six keys, exactly: a seventh would also have to be added deliberately.
+    const bare = frameSchema.safeParse({
+      type: "message.created",
+      payload: {
+        id: randomUUID(),
+        channel: tenants.victim.channelId,
+        seq: 1,
+        user: tenants.victim.userExternalId,
+        text: "hello",
+        created_at: new Date().toISOString(),
+        avatar_url: "https://cdn.example.com/face.png",
+      },
+    });
+    expect(bare.success).toBe(false);
+  });
+
   // ── THE SAME-TENANT NON-MEMBER, ON THE SOCKET (chapter 3.15, T087) ─────────
   //
   // The protocol's frame union has exactly one inbound member — `message.send` —
services/api/src/users/users.itest.ts
import "reflect-metadata";
 
import { Test } from "@nestjs/testing";
import type { INestApplication } from "@nestjs/common";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
 
import { AppModule } from "../app.module";
import { createDb, createPool, type Db } from "../db/client";
import { mintUserToken } from "../auth/user-token";
import { periodOf } from "../quotas/period";
import {
  createApiKey,
  createEnvironment,
  environmentSigningSecret,
  Repository,
  usageFor,
} from "../db/repository";
 
// THE LISTING, END TO END (chapter 3.15, FR-013 to FR-015, FR-022, SC-007).
//
// Every route in this suite names a user in the path and carries the TENANT's
// credential, so "the caller" here is the application and never the user named. That
// distinction is why FR-015 had to be restated: "a channel the caller is not a member
// of MUST NOT appear in their listing" is vacuous when the caller is an application
// key — a key is a member of nothing and an empty list satisfies it. The requirement
// is about the user the path names, and that is what these tests assert.
 
describe("a user's channel listing", () => {
  let app: INestApplication;
  let url: string;
  let db: Db;
  let credential: string;
  let repo: Repository;
  let member: { id: string };
  /** Three channels with staggered activity, oldest first in creation order. */
  let oldest: string;
  let middle: string;
  let newest: string;
  let notAMember: string;
  let publicNotAMember: string;
  let tokenFor: (subject: string) => Promise<string>;
  let environmentId: string;
 
  beforeAll(async () => {
    db = createDb(createPool());
    const env = await createEnvironment(db, { name: "users-itest" });
    environmentId = env.id;
    repo = new Repository(db, env.id);
    credential = (await createApiKey(db, { environmentId: env.id })).credential;
    member = await repo.createUser("lister", "A Lister");
 
    // ACTIVITY IS ASSERTED BY SENDING, not by writing the column. The listing orders
    // by `last_activity_at` and the write path is what moves it; a fixture that set
    // the column directly would test the ordering against a value no send produced.
    const seed = async (label: string): Promise<string> => {
      const c = await repo.createChannel(label, "public");
      await repo.addMember(c.id, member.id);
      await repo.sendMessage(c.id, { text: `first in ${label}`, userId: member.id });
      return c.id;
    };
    oldest = await seed("oldest");
    middle = await seed("middle");
    newest = await seed("newest");
 
    // A private channel of the same tenant the user is NOT in, and a public one they
    // are not in either. The second is the one worth having: a public channel is
    // readable by any user of the tenant, so the read set and the listing set are
    // different sets and only a test says so.
    notAMember = (await repo.createChannel("private-elsewhere", "private")).id;
    publicNotAMember = (await repo.createChannel("public-elsewhere", "public")).id;
 
    const signingSecret = (await environmentSigningSecret(db, env.id))!.signingSecret;
    tokenFor = async (subject: string) =>
      (
        await mintUserToken(signingSecret, {
          user: subject,
          environmentId: env.id,
          ttlSeconds: 3600,
        })
      ).token;
 
    app = (
      await Test.createTestingModule({ imports: [AppModule] }).compile()
    ).createNestApplication({ logger: false });
    await app.listen(0);
    url = await app.getUrl();
  }, 60_000);
 
  afterAll(async () => {
    await app?.close();
  });
 
  const list = (externalId: string, query = "", key = credential) =>
    fetch(`${url}/v1/users/${externalId}/channels${query}`, {
      headers: { authorization: `Bearer ${key}` },
    });
 
  // ── T112: the ordering (SC-007) ─────────────────────────────────────────────
  it("returns the user's channels, most recently active first", async () => {
    const res = await list("lister");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { data: Array<{ external_id: string }> };
    expect(body.data.map((c) => c.external_id)).toEqual(["newest", "middle", "oldest"]);
  });
 
  it("moves a channel to the front when it takes a message", async () => {
    await repo.sendMessage(oldest, { text: "back from the dead", userId: member.id });
    const body = (await (await list("lister")).json()) as {
      data: Array<{ external_id: string }>;
    };
    expect(body.data.map((c) => c.external_id)).toEqual(["oldest", "newest", "middle"]);
  });
 
  // ── T108: only a message is activity ────────────────────────────────────────
  //
  // THE TASK NAMED THREE NON-MESSAGE WRITES AND THIS PLATFORM HAS TWO. There is no
  // rename: `POST /v1/channels` is idempotent on the external id and its repeat
  // branch returns the existing row WITHOUT writing `name` or `metadata`, and no
  // other route or repository function updates them. So a rename cannot move the
  // column because a rename cannot happen — which is worth stating rather than
  // testing, and is the second task this feature has that named an operation the
  // platform does not have (T087's subscribe frame was the first).
  it("does not move for a join or an archive", async () => {
    const before = (await (await list("lister")).json()) as {
      data: Array<{ external_id: string; last_activity_at: string }>;
    };
    const stamps = new Map(before.data.map((c) => [c.external_id, c.last_activity_at]));
 
    // Two writes to these rows, neither of them a message.
    const joiner = await repo.createUser("joiner", "A Joiner");
    await repo.addMember(middle, joiner.id);
    await repo.archiveChannel(newest);
 
    const after = (await (await list("lister")).json()) as {
      data: Array<{ external_id: string; last_activity_at: string }>;
    };
    for (const c of after.data) {
      expect(c.last_activity_at, `${c.external_id} moved`).toBe(stamps.get(c.external_id));
    }
    // And the order is the order it was.
    expect(after.data.map((c) => c.external_id)).toEqual(before.data.map((c) => c.external_id));
    await repo.unarchiveChannel(newest);
  });
 
  // ── T114: membership is the listing set (FR-015) ─────────────────────────────
  it("omits a private channel the user is not a member of", async () => {
    const body = (await (await list("lister")).json()) as { data: Array<{ external_id: string }> };
    expect(body.data.map((c) => c.external_id)).not.toContain("private-elsewhere");
    expect(notAMember).toBeTruthy();
  });
 
  it("omits a PUBLIC channel the user is not a member of, which they could read by id", async () => {
    // The control for the assertion above: this channel is readable by this tenant's
    // users, so its absence from the listing is a decision and not an accident of
    // visibility. Without this test, "the listing only shows what you can see" would
    // pass and be the wrong rule.
    const readable = await fetch(`${url}/v1/channels/${publicNotAMember}`, {
      headers: { authorization: `Bearer ${credential}` },
    });
    expect(readable.status).toBe(200);
 
    const body = (await (await list("lister")).json()) as { data: Array<{ external_id: string }> };
    expect(body.data.map((c) => c.external_id)).not.toContain("public-elsewhere");
  });
 
  // ── T115: an archived channel appears, with a flag (FR-022) ──────────────────
  it("lists an archived channel and says it is archived", async () => {
    await repo.archiveChannel(middle);
    const body = (await (await list("lister")).json()) as {
      data: Array<{ external_id: string; archived_at: string | null }>;
    };
    const row = body.data.find((c) => c.external_id === "middle");
    expect(row).toBeDefined();
    expect(row?.archived_at).not.toBeNull();
    // Every other channel reports null rather than the field being absent.
    expect(body.data.filter((c) => c.archived_at === null).length).toBe(
      body.data.length - 1,
    );
    await repo.unarchiveChannel(middle);
  });
 
  // ── T116b: the role is in the projection ────────────────────────────────────
  it("returns each channel's role for the user the path names", async () => {
    await repo.setMemberRole(newest, member.id, "moderator");
    const body = (await (await list("lister")).json()) as {
      data: Array<{ external_id: string; role: string }>;
    };
    expect(body.data.find((c) => c.external_id === "newest")?.role).toBe("moderator");
    expect(body.data.find((c) => c.external_id === "oldest")?.role).toBe("member");
    await repo.setMemberRole(newest, member.id, "member");
  });
 
  // ── T113: the cursor ────────────────────────────────────────────────────────
  //
  // THE TIE IS TESTED IN `repository.itest.ts` AND NOT HERE. Two channels sharing a
  // `last_activity_at` cannot be produced through the API: `now()` is the
  // transaction timestamp and every send is its own transaction, so constructing the
  // collision takes a raw UPDATE. `repository.itest.ts` is on the driver-exempt list
  // — the layer under test IS the query layer — and this suite is not. Adding a
  // `setLastActivityAt` to the repository to get around that would have put a method
  // in production code whose only caller is a test, in a feature about columns
  // nothing reads.
  it("pages through every channel exactly once", async () => {
    const seen: string[] = [];
    let cursor: string | null = null;
    let pages = 0;
    do {
      const q = `?limit=2${cursor === null ? "" : `&cursor=${cursor}`}`;
      const body = (await (await list("lister", q)).json()) as {
        data: Array<{ external_id: string }>;
        next_cursor: string | null;
      };
      seen.push(...body.data.map((c) => c.external_id));
      cursor = body.next_cursor;
      pages++;
      expect(pages, "the cursor did not terminate").toBeLessThan(20);
    } while (cursor !== null);
 
    expect(seen.length).toBe(new Set(seen).size);
    expect(new Set(seen)).toEqual(new Set(["oldest", "middle", "newest"]));
    expect(pages).toBeGreaterThan(1);
  });
 
  // ── T117: the cursor's refusals ─────────────────────────────────────────────
  // THREE WAYS A CURSOR CAN BE MALFORMED, and each is its own arm: the base64 does not
  // decode to JSON, the JSON decodes to the wrong shape, or the timestamp inside it is
  // not a date. All three answer identically, which is the point — a client learns "your
  // cursor is not a cursor" and nothing about which internal check caught it — and all
  // three need their own test, because one refusal reaching the wire says nothing about
  // whether the other two paths work.
  it.each([
    ["not JSON at all", "not-a-cursor"],
    [
      "JSON of the wrong shape",
      Buffer.from(JSON.stringify({ nope: 1 }), "utf8").toString("base64url"),
    ],
    [
      "a timestamp that is not a date",
      Buffer.from(
        JSON.stringify({ a: "the day before yesterday", id: "00000000-0000-4000-8000-000000000000" }),
        "utf8",
      ).toString("base64url"),
    ],
  ])("refuses a cursor that is %s with 400 and names the field", async (_what, cursor) => {
    const res = await list("lister", `?cursor=${cursor}`);
    expect(res.status).toBe(400);
    const body = (await res.json()) as { code: string; field?: string };
    expect(body.code).toBe("invalid_request");
    expect(body.field).toBe("cursor");
  });
 
  it("refuses a limit over 100 with 400 and names the field", async () => {
    const res = await list("lister", "?limit=101");
    expect(res.status).toBe(400);
    const body = (await res.json()) as { code: string; field?: string };
    expect(body.code).toBe("invalid_request");
    expect(body.field).toBe("limit");
  });
 
  it("answers a cursor naming another tenant's channel exactly as it answers an invented one", async () => {
    // T117 ASKED FOR 400 HERE AND 400 IS THE LEAK.
    //
    // The task's reason is right and its mechanism inverts it: "anything that
    // distinguishes 'exists elsewhere' from 'malformed' is the leak the suite exists
    // to catch". To answer 400 for a FOREIGN id, the server has to look the id up in
    // the global `channels` table and find it — and then a uuid that exists in
    // another tenant gets a different answer from a uuid that exists nowhere. That is
    // the distinction SC-002 forbids, built on purpose.
    //
    // So the cursor is validated for SHAPE and its id is never resolved. A keyset
    // position does not have to exist: `(last_activity_at, id) < (ts, id)` is a
    // comparison, not a lookup. A foreign id, an invented uuid and the id of a
    // channel deleted since the cursor was minted all name the same position in this
    // tenant's ordering, and all three get the same page.
    //
    // The lookup would also break a real client: a user removed from a channel
    // between pages would find their cursor rejected mid-pagination.
    const other = new Repository(
      db,
      (await createEnvironment(db, { name: "users-itest-foreign" })).id,
    );
    const theirs = await other.createChannel("theirs", "public");
    const at = new Date().toISOString();
    const cursorOf = (id: string) =>
      Buffer.from(JSON.stringify({ a: at, id }), "utf8").toString("base64url");
 
    const foreign = await list("lister", `?cursor=${cursorOf(theirs.id)}`);
    const invented = await list(
      "lister",
      `?cursor=${cursorOf("00000000-0000-4000-8000-000000000000")}`,
    );
    expect(foreign.status).toBe(invented.status);
    expect(await foreign.text()).toBe(await invented.text());
  });
 
  // ── T109c: unknown fields are refused, not ignored ───────────────────────────
  it("refuses an unknown query field rather than ignoring it", async () => {
    const res = await list("lister", "?limit=2&Limit=3");
    expect(res.status).toBe(400);
    expect(((await res.json()) as { code: string }).code).toBe("invalid_request");
  });
 
  // ── T111: a user who does not exist, and one who is deleted ──────────────────
  it("answers 404 for a user this tenant does not have", async () => {
    expect((await list("nobody")).status).toBe(404);
  });
 
  it("answers 404 for a deleted user", async () => {
    const doomed = await repo.createUser("doomed", "Doomed");
    await repo.addMember(oldest, doomed.id);
    expect((await list("doomed")).status).toBe(200);
    await repo.markUserDeleted(doomed.id);
    expect((await list("doomed")).status).toBe(404);
  });
 
  // ══ THE UNREAD COUNT (chapter 3.15, FR-016 to FR-018, SC-008) ═══════════════
 
  const setRead = (user: string, channelId: string, sequence: number, key = credential) =>
    fetch(`${url}/v1/users/${user}/channels/${channelId}/read`, {
      method: "PUT",
      headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
      body: JSON.stringify({ sequence }),
    });
 
  const unreadFor = async (user: string, external: string): Promise<number> => {
    const body = (await (await list(user, "?limit=100")).json()) as {
      data: Array<{ external_id: string; unread: number }>;
    };
    return body.data.find((c) => c.external_id === external)!.unread;
  };
 
  // ── T122: it rises, and it falls to zero (SC-008) ───────────────────────────
  it("rises with each message and falls to zero when the position reaches the end", async () => {
    const c = await repo.createChannel("counting", "public");
    await repo.addMember(c.id, member.id);
    const sender = await repo.createUser("sender", "A Sender");
    await repo.addMember(c.id, sender.id);
 
    expect(await unreadFor("lister", "counting")).toBe(0);
    const first = await repo.sendMessage(c.id, { text: "one", userId: sender.id });
    expect(await unreadFor("lister", "counting")).toBe(1);
    await repo.sendMessage(c.id, { text: "two", userId: sender.id });
    const third = await repo.sendMessage(c.id, { text: "three", userId: sender.id });
    expect(await unreadFor("lister", "counting")).toBe(3);
    expect(first.seq).toBe(1);
 
    const res = await setRead("lister", c.id, third.seq);
    expect(res.status).toBe(200);
    expect(await unreadFor("lister", "counting")).toBe(0);
  });
 
  // ── T123: no row means position zero (FR-017a) ───────────────────────────────
  it("gives a new member the channel's whole history as unread, seeding nothing", async () => {
    const c = await repo.createChannel("pre-existing", "public");
    const author = await repo.createUser("author", "An Author");
    await repo.addMember(c.id, author.id);
    for (const t of ["a", "b", "c", "d"]) {
      await repo.sendMessage(c.id, { text: t, userId: author.id });
    }
    // The member arrives AFTER the history exists.
    const late = await repo.createUser("latecomer", "A Latecomer");
    await repo.addMember(c.id, late.id);
    expect(await unreadFor("latecomer", "pre-existing")).toBe(4);
  });
 
  // ── T123a: the re-added member gets the same answer (T059a, moved here) ──────
  it("gives a re-added member the whole history again, because removal took the position", async () => {
    const c = await repo.createChannel("rejoined", "public");
    const author = await repo.createUser("rejoin-author", "Author");
    await repo.addMember(c.id, author.id);
    const rejoiner = await repo.createUser("rejoiner", "A Rejoiner");
    await repo.addMember(c.id, rejoiner.id);
    await repo.sendMessage(c.id, { text: "one", userId: author.id });
    const two = await repo.sendMessage(c.id, { text: "two", userId: author.id });
    await setRead("rejoiner", c.id, two.seq);
    expect(await unreadFor("rejoiner", "rejoined")).toBe(0);
 
    await repo.removeMembers(c.id, [rejoiner.id]);
    await repo.addMember(c.id, rejoiner.id);
 
    // TWO, NOT ZERO. Removal deleted the read position with the membership, so there
    // is no row, and no row means zero — the same rule a brand-new member gets. The
    // alternative, keeping the position through a removal, would mean a re-added
    // member silently misses everything sent while they were out.
    expect(await unreadFor("rejoiner", "rejoined")).toBe(2);
  });
 
  // ── T126: a sender's own message, and the answer is not the assumed one ──────
  //
  // THE SPEC ASSUMED "a user's own message is read by them" and left the scenario as
  // "whether it counts as unread for its author is stated and tested". Measured: it
  // COUNTS. The write path does not advance the sender's own read position, so a user
  // who sends a message sees their own unread count go to one until they acknowledge it.
  //
  // NOT CHANGED, and the reason is the cost of where it would go. Advancing the position
  // server-side is a second statement on a second table inside the send transaction —
  // the platform's highest-frequency operation, forever, for every attributed message.
  // `last_activity_at` was put in the statement that already updates `channels` for
  // exactly this reason; a read-position upsert has no statement to join.
  //
  // The client pays nothing instead: the send response already carries the sequence it
  // just wrote, so acknowledging is one field it already holds. And the public REST send
  // attributes no user at all, so a server-side advance would work on some sends and not
  // others — the worst of the three options.
  it("does raise the sender's own count until they acknowledge it", async () => {
    const c = await repo.createChannel("own-messages", "public");
    const talker = await repo.createUser("talker", "A Talker");
    await repo.addMember(c.id, talker.id);
    const sent = await repo.sendMessage(c.id, { text: "hello", userId: talker.id });
 
    // ONE, not zero. This is the assertion that would have been hidden by a test that
    // acknowledged first and then checked for zero — which is what this test did until
    // the count was measured rather than assumed.
    expect(await unreadFor("talker", "own-messages")).toBe(1);
 
    await setRead("talker", c.id, sent.seq);
    expect(await unreadFor("talker", "own-messages")).toBe(0);
  });
 
  // ── T125: the refusals ───────────────────────────────────────────────────────
  it("refuses a position past the channel's last message with 400 and names the field", async () => {
    const c = await repo.createChannel("past-the-end", "public");
    await repo.addMember(c.id, member.id);
    await repo.sendMessage(c.id, { text: "only one", userId: member.id });
    const res = await setRead("lister", c.id, 99);
    expect(res.status).toBe(400);
    const body = (await res.json()) as { code: string; field?: string };
    expect(body.code).toBe("invalid_request");
    expect(body.field).toBe("sequence");
  });
 
  it("accepts a replayed lower position as a 200 that changes nothing", async () => {
    const c = await repo.createChannel("replayed", "public");
    await repo.addMember(c.id, member.id);
    await repo.sendMessage(c.id, { text: "one", userId: member.id });
    const two = await repo.sendMessage(c.id, { text: "two", userId: member.id });
    expect((await setRead("lister", c.id, two.seq)).status).toBe(200);
 
    const replay = await setRead("lister", c.id, 1);
    expect(replay.status).toBe(200);
    // The stored position is unchanged, which is the whole point: a client replaying an
    // old acknowledgement must not move the count backwards.
    expect(((await replay.json()) as { sequence: number }).sequence).toBe(two.seq);
    expect(await unreadFor("lister", "replayed")).toBe(0);
  });
 
  // ── T120: whose membership the refusal is about ─────────────────────────────
  it("refuses a public channel the PATH'S USER is not a member of with not_a_member", async () => {
    // The caller is an application credential, which is a member of nothing. If the
    // refusal were about the caller, every one of these calls would fail.
    const res = await setRead("lister", publicNotAMember, 0);
    expect(res.status).toBe(403);
    expect(((await res.json()) as { code: string }).code).toBe("not_a_member");
  });
 
  it("answers 404 for a private channel the path's user is not a member of", async () => {
    // NOT 403. `not_a_member` on a private channel would announce that it exists, which
    // is the leak the four attack shapes exist to catch. This is the one route in the
    // feature that emits `not_a_member` at all, and only for public channels.
    const res = await setRead("lister", notAMember, 0);
    expect(res.status).toBe(404);
  });
 
  it("takes a user token as well as an application credential", async () => {
    // The only route on this controller that does: a user records their own position.
    // Method-level `@Accepts` wins over the class-level one, which is the mechanism
    // chapter 3.12 built and this is the first route to rely on it.
    const c = await repo.createChannel("own-token", "public");
    await repo.addMember(c.id, member.id);
    const one = await repo.sendMessage(c.id, { text: "one", userId: member.id });
    const res = await setRead("lister", c.id, one.seq, await tokenFor("lister"));
    expect(res.status).toBe(200);
  });
 
  // ══ THE PROFILE (chapter 3.15, FR-023, FR-024, SC-011) ══════════════════════
 
  const profile = (user: string, key = credential) =>
    fetch(`${url}/v1/users/${user}`, { headers: { authorization: `Bearer ${key}` } });
 
  const patchProfile = (user: string, body: unknown, key = credential) =>
    fetch(`${url}/v1/users/${user}`, {
      method: "PATCH",
      headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
      body: JSON.stringify(body),
    });
 
  // ── T131: the round trip, all three fields (SC-011) ─────────────────────────
  it("round-trips display name, avatar url and metadata", async () => {
    await repo.createUser("profiled", "Before");
    const res = await patchProfile("profiled", {
      display_name: "After",
      avatar_url: "https://cdn.example.com/a/b.png",
      metadata: { team: "support", tier: 3 },
    });
    expect(res.status).toBe(200);
 
    const body = (await (await profile("profiled")).json()) as {
      external_id: string;
      display_name: string | null;
      avatar_url: string | null;
      metadata: Record<string, unknown>;
    };
    expect(body).toEqual({
      external_id: "profiled",
      display_name: "After",
      avatar_url: "https://cdn.example.com/a/b.png",
      metadata: { team: "support", tier: 3 },
    });
  });
 
  it("patches one field without clearing the others", async () => {
    await patchProfile("profiled", { display_name: "Renamed" });
    const body = (await (await profile("profiled")).json()) as {
      display_name: string | null;
      avatar_url: string | null;
      metadata: Record<string, unknown>;
    };
    // ABSENT IS NOT NULL. The two fields left out of the patch keep their values.
    expect(body.display_name).toBe("Renamed");
    expect(body.avatar_url).toBe("https://cdn.example.com/a/b.png");
    expect(body.metadata).toEqual({ team: "support", tier: 3 });
  });
 
  it("clears a field when the patch names it null", async () => {
    await patchProfile("profiled", { avatar_url: null });
    const body = (await (await profile("profiled")).json()) as { avatar_url: string | null };
    expect(body.avatar_url).toBeNull();
  });
 
  it("accepts an empty patch and changes nothing", async () => {
    const before = await (await profile("profiled")).text();
    const res = await patchProfile("profiled", {});
    expect(res.status).toBe(200);
    expect(await (await profile("profiled")).text()).toBe(before);
  });
 
  // ── T132: FR-024's two bounds, each naming its field ────────────────────────
  it("refuses metadata over 4 KB with 400 and names the field", async () => {
    const res = await patchProfile("profiled", {
      metadata: { blob: "x".repeat(4 * 1024) },
    });
    expect(res.status).toBe(400);
    const body = (await res.json()) as { code: string; field?: string };
    expect(body.code).toBe("invalid_request");
    expect(body.field).toBe("metadata");
  });
 
  it("accepts metadata just under 4 KB", async () => {
    // THE CONTROL FOR THE BOUND. Without it, a refusal that rejected all metadata would
    // pass the test above.
    const res = await patchProfile("profiled", { metadata: { blob: "x".repeat(4_000) } });
    expect(res.status).toBe(200);
  });
 
  it("refuses a malformed avatar url with 400 and names the field", async () => {
    const res = await patchProfile("profiled", { avatar_url: "not-a-url" });
    expect(res.status).toBe(400);
    const body = (await res.json()) as { code: string; field?: string };
    expect(body.code).toBe("invalid_request");
    expect(body.field).toBe("avatar_url");
  });
 
  it("refuses an unknown profile field rather than ignoring it", async () => {
    const res = await patchProfile("profiled", { displayName: "camelCase" });
    expect(res.status).toBe(400);
  });
 
  // ── T129: a deleted user has no profile, on both routes ──────────────────────
  it("answers 404 on both profile routes for a deleted user", async () => {
    const gone = await repo.createUser("profile-deleted", "Going");
    expect((await profile("profile-deleted")).status).toBe(200);
    await repo.markUserDeleted(gone.id);
    expect((await profile("profile-deleted")).status).toBe(404);
    expect((await patchProfile("profile-deleted", { display_name: "x" })).status).toBe(404);
  });
 
  it("answers 404 for a user this tenant does not have", async () => {
    expect((await profile("nobody-at-all")).status).toBe(404);
  });
 
  // ══ THE BULK UPSERT AND THE DELETION (FR-025 to FR-030, SC-012) ═════════════
 
  const upsert = (users: unknown, key = credential) =>
    fetch(`${url}/v1/users`, {
      method: "POST",
      headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
      body: JSON.stringify({ users }),
    });
 
  const removeUser = (user: string, key = credential) =>
    fetch(`${url}/v1/users/${user}`, {
      method: "DELETE",
      headers: { authorization: `Bearer ${key}` },
    });
 
  // ── T138: 100 accepted, 101 refused (SC-012) ────────────────────────────────
  it("upserts 100 users in one request", async () => {
    const entries = Array.from({ length: 100 }, (_, i) => ({
      external_id: `bulk-${i}`,
      display_name: `Bulk ${i}`,
    }));
    const res = await upsert(entries);
    expect(res.status).toBe(200);
    const body = (await res.json()) as { data: Array<{ status: string }> };
    expect(body.data).toHaveLength(100);
    expect(body.data.every((e) => e.status === "created")).toBe(true);
  });
 
  it("refuses 101 with 400 and names the field", async () => {
    const entries = Array.from({ length: 101 }, (_, i) => ({ external_id: `over-${i}` }));
    const res = await upsert(entries);
    expect(res.status).toBe(400);
    const body = (await res.json()) as { code: string; field?: string };
    expect(body.code).toBe("invalid_request");
    expect(body.field).toBe("users");
  });
 
  // ── T139: an existing user is UPDATED, not refused (FR-026) ──────────────────
  it("updates an entry that names an existing user", async () => {
    await upsert([{ external_id: "bulk-updatable", display_name: "First" }]);
    const res = await upsert([
      { external_id: "bulk-updatable", display_name: "Second", metadata: { seen: 2 } },
    ]);
    const body = (await res.json()) as {
      data: Array<{ status: string; display_name: string | null }>;
    };
    // `updated`, and the name actually moved. This is where `upsertUser` differs from
    // `createUser`, whose whole comment is about NOT doing this: the member-add asks for
    // membership and must not rename anybody, and this route's subject IS the user.
    expect(body.data[0]!.status).toBe("updated");
    expect(body.data[0]!.display_name).toBe("Second");
    const read = (await (await profile("bulk-updatable")).json()) as {
      display_name: string | null;
      metadata: Record<string, unknown>;
    };
    expect(read.display_name).toBe("Second");
    expect(read.metadata).toEqual({ seen: 2 });
  });
 
  it("leaves a field the entry omits alone", async () => {
    await upsert([{ external_id: "bulk-partial", display_name: "Name", metadata: { a: 1 } }]);
    await upsert([{ external_id: "bulk-partial", metadata: { a: 2 } }]);
    const read = (await (await profile("bulk-partial")).json()) as {
      display_name: string | null;
      metadata: Record<string, unknown>;
    };
    expect(read.display_name).toBe("Name");
    expect(read.metadata).toEqual({ a: 2 });
  });
 
  // ── T140: a failing entry names its index ───────────────────────────────────
  it("names the failing entry's index in the field path", async () => {
    const entries: unknown[] = Array.from({ length: 9 }, (_, i) => ({
      external_id: `indexed-${i}`,
    }));
    entries[7] = { external_id: "indexed-7", metadata: { blob: "x".repeat(4 * 1024) } };
    const res = await upsert(entries);
    expect(res.status).toBe(400);
    const body = (await res.json()) as { field?: string };
    // `users.7.metadata` — the index, not just the leaf. A caller sending 100 entries
    // cannot act on "metadata is too big" without being told which one.
    expect(body.field).toBe("users.7.metadata");
  });
 
  // ── T143, T145: what the deletion keeps and what it takes ───────────────────
  it("keeps the row, the messages and their attribution, and takes the rest", async () => {
    const doomed = await repo.createUser("deletable", "Doomed");
    const channel = await repo.createChannel("deletion-witness", "public");
    await repo.addMember(channel.id, doomed.id);
    const sent = await repo.sendMessage(channel.id, {
      text: "still here afterwards",
      userId: doomed.id,
      userExternalId: "deletable",
    });
    await setRead("deletable", channel.id, sent.seq);
    await patchProfile("deletable", {
      avatar_url: "https://cdn.example.com/doomed.png",
      metadata: { doomed: true },
    });
 
    expect((await removeUser("deletable")).status).toBe(200);
 
    // THE MESSAGE IS STILL THERE AND STILL THEIRS (FR-028). The history route is the
    // reader; `user` is the external id the send recorded.
    const history = await fetch(
      `${url}/v1/channels/${channel.id}/messages?limit=10`,
      { headers: { authorization: `Bearer ${credential}` } },
    );
    const messages = (await history.json()) as {
      messages: Array<{ seq: number; text: string | null; user: string | null }>;
    };
    const mine = messages.messages.find((m) => m.seq === sent.seq);
    expect(mine?.text).toBe("still here afterwards");
    // ATTRIBUTED, not senderless. `ON DELETE SET NULL` would have made this null, which
    // reads the same as a message that never had an author — and `toFrame` drops those.
    expect(mine?.user).toBe("deletable");
 
    // The profile is gone and the user is invisible to the API.
    expect((await profile("deletable")).status).toBe(404);
    // The membership and the read position went with it: the listing 404s the user, so
    // the membership is asserted from the channel's side.
    const remaining = await repo.countMembers(channel.id);
    expect(remaining).toBe(0);
  });
 
  it("leaves usage_active_users rows alone (FR-029)", async () => {
    // Billing history does not vanish with a profile: a customer who deleted a user in
    // March still owes for March.
    //
    // THE ROW IS WRITTEN BY A SEND, not by a helper. `sendMessage` inserts into
    // `usage_active_users` when the send is attributed — that is the only writer — so the
    // fixture has to send. Read back through `usageFor`, which is the reader the billing
    // surface uses, rather than a raw count this suite may not hold.
    const billed = await repo.createUser("billed", "Billed");
    const channel = await repo.createChannel("billing-witness", "public");
    await repo.addMember(channel.id, billed.id);
    await repo.sendMessage(channel.id, {
      text: "counts toward the month",
      userId: billed.id,
      userExternalId: "billed",
    });
    const before = await usageFor(db, environmentId, periodOf(new Date()));
    expect(before.activeUsers).toBeGreaterThan(0);
 
    expect((await removeUser("billed")).status).toBe(200);
 
    const after = await usageFor(db, environmentId, periodOf(new Date()));
    expect(after.activeUsers).toBe(before.activeUsers);
    expect(after.messagesSent).toBe(before.messagesSent);
  });
 
  it("answers 200 on a second delete and 404 for a user who never existed", async () => {
    const twice = await repo.createUser("twice-deleted", "Twice");
    expect(twice.external_id).toBe("twice-deleted");
    expect((await removeUser("twice-deleted")).status).toBe(200);
    expect((await removeUser("twice-deleted")).status).toBe(200);
    expect((await removeUser("never-existed-at-all")).status).toBe(404);
  });
 
  // ── T146: the id comes back and the row is reused (FR-030) ──────────────────
  it("reuses the row when the same external id is presented again", async () => {
    const revived = await repo.createUser("revivable", "Before Deletion");
    await patchProfile("revivable", { metadata: { before: true } });
    await removeUser("revivable");
    expect((await profile("revivable")).status).toBe(404);
 
    const res = await upsert([{ external_id: "revivable" }]);
    const body = (await res.json()) as { data: Array<{ status: string }> };
    expect(body.data[0]!.status).toBe("revived");
 
    const back = (await (await profile("revivable")).json()) as {
      external_id: string;
      display_name: string | null;
      avatar_url: string | null;
      metadata: Record<string, unknown>;
    };
    // THE SAME ROW, EMPTY. `(environment_id, external_id)` is unique and the row never
    // left, so there is no other honest answer than reusing it — and a revived row does
    // not inherit the profile the deletion wiped.
    expect(back).toEqual({
      external_id: "revivable",
      display_name: null,
      avatar_url: null,
      metadata: {},
    });
    const after = await repo.getUserByExternalId("revivable");
    expect(after?.id).toBe(revived.id);
  });
 
  // ── T147: deleting a channel's owner ────────────────────────────────────────
  it("deletes a channel owner and leaves the channel ownerless", async () => {
    // FR-CHN-04's roles and FR-USR-05's deletion meet here, and the chapter has to say
    // what happens. MEASURED: the membership row goes, so the channel has no owner and
    // no route can appoint one — `PATCH .../members/:userExternalId` sets the role of an
    // EXISTING member, so a channel whose only owner is deleted cannot get another
    // without somebody being added first.
    //
    // Left as it is, and stated rather than fixed: nothing in the platform reads
    // `members.role` to authorize anything, so an ownerless channel behaves exactly like
    // an owned one. The day a permission consults the column, this becomes a real
    // question — and the answer will be a route, not a cascade.
    const channel = await repo.createChannel("ownerless", "public");
    const owner = await repo.createUser("the-owner", "The Owner");
    const other = await repo.createUser("the-other", "The Other");
    await repo.addMember(channel.id, owner.id, "owner");
    await repo.addMember(channel.id, other.id);
    expect(await repo.memberRole(channel.id, owner.id)).toBe("owner");
 
    await removeUser("the-owner");
 
    expect(await repo.memberRole(channel.id, owner.id)).toBeNull();
    expect(await repo.memberRole(channel.id, other.id)).toBe("member");
    // The channel is still there and still usable by its remaining member.
    const still = await fetch(`${url}/v1/channels/${channel.id}`, {
      headers: { authorization: `Bearer ${credential}` },
    });
    expect(still.status).toBe(200);
  });
 
  // ══ THE BAN (chapter 3.15, FR-031, FR-032, SC-013) ══════════════════════════
 
  const ban = (user: string, key = credential) =>
    fetch(`${url}/v1/users/${user}/ban`, {
      method: "POST",
      headers: { authorization: `Bearer ${key}` },
    });
 
  const unban = (user: string, key = credential) =>
    fetch(`${url}/v1/users/${user}/ban`, {
      method: "DELETE",
      headers: { authorization: `Bearer ${key}` },
    });
 
  const sendAs = (channelId: string, token: string, text: string) =>
    fetch(`${url}/v1/channels/${channelId}/messages`, {
      method: "POST",
      headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
      body: JSON.stringify({ text }),
    });
 
  // ── T152: banned cannot send, history survives, lifting restores ────────────
  it("refuses a banned user's send and restores it when the ban lifts", async () => {
    const channel = await repo.createChannel("ban-witness", "public");
    const speaker = await repo.createUser("bannable", "Bannable");
    await repo.addMember(channel.id, speaker.id);
    const token = await tokenFor("bannable");
 
    // THE CONTROL FIRST. A refusal proves nothing unless the same call worked a moment
    // ago — chapter 3.12's fourteen green tests are why this line exists.
    expect((await sendAs(channel.id, token, "before the ban")).status).toBe(201);
 
    expect((await ban("bannable")).status).toBe(200);
    const refused = await sendAs(channel.id, token, "during the ban");
    expect(refused.status).toBe(403);
    expect(((await refused.json()) as { code: string }).code).toBe("user_banned");
 
    // HISTORY IS UNTOUCHED. A ban is not a deletion: their earlier message is still
    // there and still theirs, readable by the tenant.
    const history = await fetch(`${url}/v1/channels/${channel.id}/messages?limit=10`, {
      headers: { authorization: `Bearer ${credential}` },
    });
    const messages = (await history.json()) as {
      messages: Array<{ text: string | null; user: string | null }>;
    };
    expect(messages.messages.some((m) => m.text === "before the ban")).toBe(true);
    expect(messages.messages.some((m) => m.text === "during the ban")).toBe(false);
 
    expect((await unban("bannable")).status).toBe(200);
    expect((await sendAs(channel.id, token, "after the ban")).status).toBe(201);
  });
 
  it("answers 200 on a repeated ban and a repeated unban", async () => {
    await repo.createUser("twice-banned", "Twice");
    expect((await ban("twice-banned")).status).toBe(200);
    expect((await ban("twice-banned")).status).toBe(200);
    expect((await unban("twice-banned")).status).toBe(200);
    expect((await unban("twice-banned")).status).toBe(200);
  });
 
  it("answers 404 for a user this tenant does not have, and for a deleted one", async () => {
    expect((await ban("never-heard-of")).status).toBe(404);
    const gone = await repo.createUser("ban-then-delete", "Gone");
    await repo.deleteUser(gone.id);
    // A DELETED USER CANNOT BE BANNED, and does not need to be: every route naming them
    // answers 404 and their session carries no channels. Banning one would be a state
    // with no observable difference.
    expect((await ban("ban-then-delete")).status).toBe(404);
  });
 
  // ── T154: the two edge cases the spec names ─────────────────────────────────
  it("bans a private channel's member without removing them", async () => {
    // THE BAN IS TENANT-SCOPED, so it is not a removal. The membership survives, the
    // channel still lists them, and lifting the ban restores everything with nobody
    // re-added.
    const priv = await repo.createChannel("ban-private", "private");
    const member2 = await repo.createUser("private-bannable", "Private Bannable");
    await repo.addMember(priv.id, member2.id);
    const token = await tokenFor("private-bannable");
    expect((await sendAs(priv.id, token, "a member speaks")).status).toBe(201);
 
    await ban("private-bannable");
    const refused = await sendAs(priv.id, token, "still a member, still banned");
    expect(refused.status).toBe(403);
    expect(((await refused.json()) as { code: string }).code).toBe("user_banned");
    // Still a member, and the listing still shows the channel.
    expect(await repo.isMember(priv.id, member2.id)).toBe(true);
    const listed = (await (await list("private-bannable", "?limit=100")).json()) as {
      data: Array<{ external_id: string }>;
    };
    expect(listed.data.map((c) => c.external_id)).toContain("ban-private");
 
    await unban("private-bannable");
    expect((await sendAs(priv.id, token, "and back")).status).toBe(201);
  });
 
  it("does not let implicit creation undo a ban", async () => {
    // A token minted for a banned user's identifier must not revive them. `createUser`
    // is idempotent and touches no other column, so the row — and the ban on it —
    // survives a mint. The upsert is the route that clears state, and it clears
    // `deleted_at` only.
    const target = await repo.createUser("mint-after-ban", "Minted");
    await ban("mint-after-ban");
    const token = await tokenFor("mint-after-ban");
    expect(token.length).toBeGreaterThan(0);
 
    const channel = await repo.createChannel("mint-room", "public");
    await repo.addMember(channel.id, target.id);
    const refused = await sendAs(channel.id, token, "minted my way in");
    expect(refused.status).toBe(403);
 
    // And an upsert naming them does not lift it either: the upsert clears `deleted_at`
    // because FR-030 asks it to, and says nothing about `banned_at`.
    await upsert([{ external_id: "mint-after-ban", display_name: "Renamed" }]);
    expect((await sendAs(channel.id, token, "upserted my way in")).status).toBe(403);
  });
});

Một dòng user ở lần xác thực đầu tiên, việc chẳng ai làm

FR-USR-02 đã đòi từ khi SRS được viết rằng "một bản ghi user phải được tạo ngầm ở lần xác thực đầu tiên nếu nó chưa tồn tại". Không gì làm việc đó, và khoảng trống ấy có một triệu chứng chứ không phải một sự im lặng: mint một token cho một identifier chưa có dòng nào, gửi qua route nội bộ, và api trả lời

{ "code": "invalid_request", "message": "unknown user" }

400, và một thông điệp gọi tên người gọi trong khi nguyên nhân là chẳng ai tạo ra một dòng. Việc tạo ngầm tồn tại chính là để ngăn câu trả lời đó.

services/api/src/auth/dev-token.controller.ts
@@ -12,7 +12,7 @@
 import { z } from "zod";
 
 import type { Db } from "../db/client";
-import { environmentSigningSecret } from "../db/repository";
+import { environmentSigningSecret, Repository } from "../db/repository";
 import { AUTH_DB } from "./authenticate.middleware";
 import { Accepts, CredentialGuard } from "./credential.guard";
 import type { RequestWithPrincipal } from "./principal";
@@ -77,6 +77,31 @@
       throw new NotFoundException("Cannot POST /auth/dev-token");
     }
 
+    // ── THE USER ROW, CREATED IF ABSENT (chapter 3.15, FR-039a, FR-039b) ────
+    //
+    // FR-USR-02: "a user record shall be created implicitly on first
+    // authentication if it does not exist." Nothing did it, and the gap had a
+    // symptom: mint a token for an identifier with no row, send through
+    // `POST /internal/messages`, and the api answered **`400 "unknown user"`** — a
+    // message that names the caller rather than the cause, which is exactly what
+    // implicit creation exists to prevent.
+    //
+    // CHAPTER 3.13'S IDEMPOTENT `createUser`, and that is the whole implementation.
+    // It is `ON CONFLICT DO NOTHING` on `(environment_id, external_id)`, so
+    // authentication and membership converge on one row for one identifier no
+    // matter which arrives first, and a second mint creates nothing.
+    //
+    // AND THE RESPONSE DOES NOT SAY WHICH HAPPENED. A status or field
+    // distinguishing "created" from "existed" would be a membership oracle: a
+    // caller could enumerate which external ids a tenant has by minting tokens
+    // and reading the answer. The token is the answer either way.
+    //
+    // IT ALSO CANNOT LIFT A BAN OR A DELETION. `createUser` touches no column on
+    // an existing row — its own comment is about refusing to rename anybody — so
+    // `banned_at` and `deleted_at` survive a mint. `upsertUser` is the route that
+    // clears state, and it clears only `deleted_at`, because FR-030 asks it to.
+    await new Repository(this.db, principal.environmentId).createUser(body.user);
+
     const { token, expiresAt } = await mintUserToken(environment.signingSecret, {
       user: body.user,
       environmentId: principal.environmentId,

Phần hiện thực là một lần gọi createUser đẳng xâm (idempotent) của chương 3.13. Ba tính chất trông như miễn phí, và mỗi cái cần một test riêng:

Xác thực và membership hội tụ về một dòng, cái nào tới trước cũng vậy — và display name còn nguyên, vì createUser không update.

Response không nói cái nào đã xảy ra. Một status hay một field phân biệt "vừa tạo" với "đã có" sẽ là một cái máy đoán membership: mint token cho những external id đoán bừa rồi đọc câu trả lời để biết tenant có những id nào. Test so status và tập key của hai response, không so nội dung.

Một lần mint không thể dỡ một lệnh ban hay một lần xoá. createUser không chạm cột nào trên một dòng đã tồn tại. Trường hợp đã xoá là trường hợp đáng nói: FR-030 nói xuất trình lại id thì dùng lại dòng cũ, và đúng thế — nhưng dòng đó vẫn đang bị xoá. POST /v1/users là server của khách hàng nói "user này trở lại rồi"; một lần mint chỉ nói "có ai đó vừa xin một token".

services/api/src/auth/credentials.itest.ts
@@ -360,4 +360,129 @@
       expect(JSON.stringify(body)).not.toContain(PLATFORM);
     });
   });
+
+  // ══ FR-USR-02: A USER ROW ON FIRST AUTHENTICATION (chapter 3.15) ════════════
+  //
+  // FR-039a and FR-039b arrived from research after the spec's nine stories were
+  // written, so these have no story label — their coverage is two edge cases and SC-020.
+  describe("a user record is created implicitly on first authentication", () => {
+    const internalSend = (token: string, channel: string, text: string) =>
+      fetch(`${url}/internal/messages`, {
+        method: "POST",
+        headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
+        body: JSON.stringify({ channel_id: channel, text }),
+      });
+
+    // ── T158: SC-020, end to end ─────────────────────────────────────────────
+    it("mints for an unknown identifier and the send is accepted", async () => {
+      const fresh = `never-seen-${Math.random().toString(36).slice(2, 8)}`;
+      const repo = new Repository(db, env.id);
+      expect(await repo.getUserByExternalId(fresh)).toBeNull();
+
+      const minted = await devToken(key.credential, { user: fresh });
+      expect(minted.status).toBe(200);
+      const { token } = (await minted.json()) as { token: string };
+
+      // THE ROW EXISTS NOW, and this is the assertion the requirement is about.
+      const created = await repo.getUserByExternalId(fresh);
+      expect(created).not.toBeNull();
+
+      // AND THE SEND WORKS. Before this chapter the same sequence answered
+      // `400 "unknown user"` — a message naming the caller rather than the cause,
+      // which is what implicit creation exists to prevent.
+      await repo.addMember(channelId, created!.id);
+      const sent = await internalSend(token, channelId, "my first message");
+      expect(sent.status).toBe(201);
+    });
+
+    // ── T159: one row, whichever arrives first (FR-039b, FR-039c) ────────────
+    it("converges on one row whether authentication or membership comes first", async () => {
+      const repo = new Repository(db, env.id);
+      const viaAuth = `via-auth-${Math.random().toString(36).slice(2, 8)}`;
+      const viaMember = `via-member-${Math.random().toString(36).slice(2, 8)}`;
+
+      // Authentication first, then membership.
+      await devToken(key.credential, { user: viaAuth });
+      const first = await repo.getUserByExternalId(viaAuth);
+      await repo.addMember(channelId, first!.id);
+      expect((await repo.getUserByExternalId(viaAuth))!.id).toBe(first!.id);
+
+      // Membership first, then authentication — the same row comes back.
+      const seeded = await repo.createUser(viaMember, "Seeded By Membership");
+      await devToken(key.credential, { user: viaMember });
+      const after = await repo.getUserByExternalId(viaMember);
+      expect(after!.id).toBe(seeded.id);
+      // AND THE DISPLAY NAME SURVIVED. `createUser` is idempotent and does not
+      // update — a mint that renamed a user to nothing would be a write nobody asked
+      // for, which is the argument that function's own comment makes.
+      expect(after!.display_name).toBe("Seeded By Membership");
+    });
+
+    it("mints twice for the same identifier and creates one row", async () => {
+      const twice = `twice-${Math.random().toString(36).slice(2, 8)}`;
+      const repo = new Repository(db, env.id);
+      await devToken(key.credential, { user: twice });
+      const one = await repo.getUserByExternalId(twice);
+      await devToken(key.credential, { user: twice });
+      const two = await repo.getUserByExternalId(twice);
+      expect(two!.id).toBe(one!.id);
+    });
+
+    // ── T160: the status does not say which happened ─────────────────────────
+    it("answers identically whether the user existed or not", async () => {
+      const repo = new Repository(db, env.id);
+      const existing = `existing-${Math.random().toString(36).slice(2, 8)}`;
+      await repo.createUser(existing, "Already Here");
+      const absent = `absent-${Math.random().toString(36).slice(2, 8)}`;
+
+      const a = await devToken(key.credential, { user: existing });
+      const b = await devToken(key.credential, { user: absent });
+      expect(a.status).toBe(b.status);
+      // The bodies' SHAPES, not their contents — a token and an expiry differ by
+      // construction. A status or a field that told the caller which happened would be
+      // a membership oracle: mint tokens for guessed ids and read the answer.
+      const bodyA = (await a.json()) as Record<string, unknown>;
+      const bodyB = (await b.json()) as Record<string, unknown>;
+      expect(Object.keys(bodyA).sort()).toEqual(Object.keys(bodyB).sort());
+      expect(Object.keys(bodyA).sort()).toEqual(["expires_at", "token"]);
+    });
+
+    // ── T161: a mint cannot lift a ban or a deletion ─────────────────────────
+    it("does not undo a ban", async () => {
+      const repo = new Repository(db, env.id);
+      const banned = `banned-${Math.random().toString(36).slice(2, 8)}`;
+      const row = await repo.createUser(banned, "Banned");
+      await repo.addMember(channelId, row.id);
+      await repo.banUser(row.id);
+
+      const minted = await devToken(key.credential, { user: banned });
+      expect(minted.status).toBe(200);
+      const { token } = (await minted.json()) as { token: string };
+
+      // The mint succeeded and the ban stands: `createUser` touches no column on an
+      // existing row, so `banned_at` survives it.
+      expect((await repo.getUserByExternalId(banned))!.banned_at).not.toBeNull();
+      const refused = await internalSend(token, channelId, "minted past the ban");
+      expect(refused.status).toBe(403);
+      expect(((await refused.json()) as { code: string }).code).toBe("user_banned");
+    });
+
+    it("reuses a deleted user's row without reviving them (FR-030)", async () => {
+      const repo = new Repository(db, env.id);
+      const gone = `deleted-${Math.random().toString(36).slice(2, 8)}`;
+      const row = await repo.createUser(gone, "Deleted");
+      await repo.deleteUser(row.id);
+
+      const minted = await devToken(key.credential, { user: gone });
+      expect(minted.status).toBe(200);
+
+      const after = await repo.getUserByExternalId(gone);
+      // THE SAME ROW, and still deleted. FR-030 says presenting the id again reuses the
+      // row; it does not say a MINT undoes a deletion. `POST /v1/users` is the route
+      // that clears `deleted_at`, because that is a customer's server saying "this user
+      // is back" — a token mint says only "somebody asked for a token".
+      expect(after!.id).toBe(row.id);
+      expect(after!.deleted_at).not.toBeNull();
+    });
+  });
 });

Lệnh ban, và nó làm gì với một kết nối đang mở

FR-032 hỏi đúng câu đó, và bản đặc tả đưa ra hai câu trả lời — đóng ở nhịp heartbeat kế tiếp, hoặc đóng ngay — kèm nhận xét rằng chúng khác nhau ở chỗ gateway có phải được thông báo hay không.

Câu trả lời là không cái nào cả, và nó đã được dựng sẵn. Một lần gửi qua socket đi qua /internal/messages của api, chính là đường repository mà check ban nằm ở đầu. Nên một socket bị ban mất khả năng gửi ngay khoảnh khắc lệnh ban có hiệu lực, và vẫn nhận tin cho tới khi nó đóng vì một lý do khác, bởi việc phát tin không hỏi api điều gì.

Đó không phải một sự thoả hiệp bày ra cho chương này. Đó là hình dáng chương 3.2 đã chọn cho một token hết hạn, và comment nó để lại nói đúng điều ấy: "socket vẫn đang mở và vẫn NHẬN, vì việc phát tin không hỏi api điều gì. Việc ghi thì có."

packages/protocol/src/internal.ts
@@ -137,6 +137,22 @@
   environment_id: z.string().min(1),
   user: z.string().min(1),
   channel_ids: z.array(z.string().min(1)),
+  /** Chapter 3.15, FR-031. Whether this user is banned in this environment.
+   *
+   * IT RIDES THIS RESPONSE FOR THE REASON THE LIMITS DO: the gateway has no database and
+   * must not gain one, `banned_at` is a column in Postgres, and the api is the only
+   * service that reads Postgres. So the ban travels on the one call the gateway already
+   * makes at connect — no new table reaches the gateway and no new round trip is added.
+   *
+   * A BOOLEAN AND NOT THE TIMESTAMP. The gateway's question is "may this socket open",
+   * which is a yes or a no; handing it `banned_at` would invite it to decide policy from
+   * a date, and policy lives where the column does.
+   *
+   * `.default(false)` so an api built before this chapter still satisfies the schema
+   * during a rolling deploy — the gateway then treats a missing field as "not banned",
+   * which is the pre-chapter behaviour and the safe direction to be wrong in for one
+   * deploy window. */
+  banned: z.boolean().default(false),
   /** Chapter 3.8. The two limits the gateway enforces, resolved from the
    * environment's policy with nulls already turned into defaults.
    *
services/gateway/src/auth.ts
@@ -50,7 +50,12 @@
   /** 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 };
+  | { outcome: "over_quota"; message: string }
+  /** Chapter 3.15, FR-031. The api answered, the token is perfectly good, and the user
+   * is banned in this environment. Its own outcome and its own close code (4003), not a
+   * reuse of `refused`: 4001 means "your credential is bad", which a client acts on by
+   * re-authenticating, and re-authenticating succeeds and connects to the same refusal. */
+  | { outcome: "banned" };
 
 export async function authenticate(
   api: ApiClient,
@@ -69,6 +74,10 @@
     if ("quotaExceeded" in session) {
       return { outcome: "over_quota", message: session.quotaExceeded };
     }
+    // A BAN IS NOT A CREDENTIAL REFUSAL EITHER. The api read `users.banned_at` and put a
+    // boolean on this response; the gateway has no database and does not need one to
+    // enforce it.
+    if (session.banned) return { outcome: "banned" };
     return {
       outcome: "ok",
       identity: {
services/gateway/src/session.ts
@@ -9,6 +9,7 @@
   type ErrorCode,
   type Frame,
   type Message,
+  isErrorCode,
 } from "@relay/protocol";
 import { newRequestId, type Logger } from "@relay/service-kit";
 import { WebSocketServer, type WebSocket } from "ws";
@@ -91,7 +92,7 @@
   socket.destroy();
 }
 
-/** `ErrorCode`, not `string` (chapter 3.12, FR-025). Every code this function is
+/** `ErrorCode`, not `string` (chapter 3.14, FR-025). Every code this function is
  * given becomes a `docs_url`, so a typo used to ship a link to a page that could
  * not exist — and the gateway is the surface where nobody sees a 404 until a
  * customer clicks it. Narrowing the parameter is what makes the registry the
@@ -267,6 +268,23 @@
           });
           return;
         }
+        if (result.outcome === "banned") {
+          // Chapter 3.15, FR-031. THE SHAPE OF THE QUOTA REFUSAL, for the same reason:
+          // the handshake completes so a close code has a socket to arrive on, and an
+          // error frame goes first because a close reason is a short string.
+          //
+          // 4003 AND NOT 4001. The token is valid; the user is refused. Closing 4001
+          // would send a client round the re-authentication loop for ever, which is the
+          // argument `codes.ts` makes for having distinct codes at all.
+          sendError(
+            ws,
+            "user_banned",
+            "this user is banned in this environment and cannot connect",
+          );
+          ws.close(4003, CLOSE_CODES[4003]);
+          logger.log("info", "connection.rejected", { reason: "user_banned" });
+          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
@@ -660,6 +678,32 @@
         );
         return;
       }
+      // ── THE API'S OWN REFUSAL, FORWARDED (chapter 3.15) ────────────────────
+      //
+      // A 4xx from the api is a fact about this request, and the api already named it:
+      // `user_banned` for a banned sender, `channel_archived` for a closed channel,
+      // `not_a_member`, `invalid_request`. Flattening those to `internal_error` told a
+      // client to retry something that will never succeed, and hid two of this feature's
+      // own refusals behind "send failed".
+      //
+      // ONLY 4xx, AND ONLY A REGISTERED CODE. A 5xx is not the client's business and its
+      // body is not a contract; an unregistered string would put a code on the wire that
+      // `codes.ts` does not define, which is the thing chapter 3.14's registry exists to
+      // prevent. Anything that fails either test stays `internal_error`.
+      if (
+        error instanceof ApiError &&
+        error.status >= 400 &&
+        error.status < 500 &&
+        error.code !== undefined &&
+        isErrorCode(error.code)
+      ) {
+        sendError(
+          connection.socket,
+          error.code,
+          error.publicMessage ?? "the request was refused",
+        );
+        return;
+      }
       sendError(connection.socket, "internal_error", "send failed");
     }
   }

banned đi theo response của session vì lý do các rate limit cũng đi theo đó: gateway không có database và không được phép có một cái, và api là service duy nhất đọc Postgres. Dòng user vốn đã nằm trong tay — cùng lần đọc ấy dựng nên danh sách channel — nên lệnh ban tốn một field và không tốn query nào. Một boolean chứ không phải timestamp, vì câu hỏi của gateway là "socket này có được mở không", còn một cái ngày sẽ dụ nó tự quyết định chính sách.

packages/protocol/src/codes.ts
@@ -7,6 +7,18 @@
 export const CLOSE_CODES = {
   4001: "invalid or expired token",
   4002: "protocol violation",
+  // Chapter 3.15, FR-031. A FIFTH CODE, AND NOT A REUSE OF 4001.
+  //
+  // A banned user's token is perfectly valid — it verifies, it names a real user, it is
+  // in date. Closing 4001 tells a client to re-authenticate, which succeeds at minting a
+  // token and fails again at connect: an infinite loop against a wall. That is the same
+  // argument this file already makes for `wrong_credential_type` and `quota_exceeded` —
+  // "a client that cannot tell them apart retries the wrong one for ever".
+  //
+  // EIR-WS-06 names four classes to distinguish — authentication, quota, shutdown,
+  // protocol violation — and a ban is none of them. Numbered here, the way chapter 1.3
+  // numbered 4002 and 4008.
+  4003: "banned in this environment",
   4008: "quota exhausted",
   4009: "server shutdown (drain)",
 } as const;
@@ -105,7 +117,7 @@
   user_banned:
     "the user is banned in this environment and can neither connect nor send; their existing messages remain",
 
-  // ── THE FIVE THE PLATFORM HAS ALWAYS SENT AND NEVER REGISTERED (chapter 3.12,
+  // ── THE FIVE THE PLATFORM HAS ALWAYS SENT AND NEVER REGISTERED (chapter 3.14,
   // FR-024) ────────────────────────────────────────────────────────────────────
   //
   // `ProtocolErrorFilter` maps a status to a code when the thrower names none,
@@ -133,6 +145,20 @@
 
 export type ErrorCode = keyof typeof ERROR_CODES;
 
+/** Whether a string the api sent is a code this registry defines (chapter 3.15).
+ *
+ * FOR FORWARDING, and forwarding is the only thing that needs it. The gateway's socket
+ * send relays the api's refusal code to the client — `user_banned`, `channel_archived` —
+ * instead of flattening every 4xx to `internal_error`. It receives that code as a plain
+ * string off a JSON body, and putting an unregistered string on the wire would defeat the
+ * registry this file exists to be.
+ *
+ * A TYPE GUARD RATHER THAN A CAST, so the narrowing is checked once here instead of
+ * asserted at every call site. */
+export function isErrorCode(value: string): value is ErrorCode {
+  return Object.hasOwn(ERROR_CODES, value);
+}
+
 /** The published reference, and the one place the URL is built (FR-027,
  * `contracts/errors.md` §2).
  *
packages/protocol/src/codes.test.ts
@@ -7,9 +7,17 @@
 // collide or go blank as chapters add to the registry.
 
 describe("close codes cover EIR-WS-06's four classes", () => {
-  it("contains exactly 4001, 4002, 4008, 4009", () => {
+  // AND ONE MORE THAN FOUR, SINCE CHAPTER 3.15. `4003` is a ban, which is none of
+  // EIR-WS-06's classes: the token verifies, names a real user and is in date, and the
+  // user is refused anyway. Reusing 4001 would tell a client to re-authenticate, which
+  // succeeds at minting a token and fails again at connect.
+  //
+  // THIS ASSERTION IS WHY THE NUMBER IS DELIBERATE. It failed on the build that added
+  // 4003 — an exact-set assertion is the only kind that makes a new close code a decision
+  // rather than an accident, and updating it is the act of making that decision.
+  it("contains exactly 4001, 4002, 4003, 4008, 4009", () => {
     expect(Object.keys(CLOSE_CODES).map(Number).sort()).toEqual([
-      4001, 4002, 4008, 4009,
+      4001, 4002, 4003, 4008, 4009,
     ]);
   });
 
@@ -34,7 +42,7 @@
   });
 });
 
-// ── THE PLATFORM HALF OF THE CLOSURE CHECK (chapter 3.12, FR-025, SC-011) ─────
+// ── THE PLATFORM HALF OF THE CLOSURE CHECK (chapter 3.14, FR-025, SC-011) ─────
 //
 // Every code the platform can emit is in `ERROR_CODES`. The tutorial repository
 // holds the other half — that every code has a section in the published reference,

Một close code thứ năm, và chính cái test mới làm nó thành một quyết định. 4003, không phải dùng lại 4001: token của một user bị ban vẫn verify được, vẫn gọi tên một user thật, vẫn còn hạn — nên đóng bằng "token không hợp lệ hoặc đã hết hạn" sẽ đẩy client vào vòng xác thực lại mãi mãi, đúng lý lẽ mà registry này vốn đã nêu ra cho việc có những code phân biệt nhau.

codes.test.ts khẳng định đúng tập hợp và đã đỏ ngay ở bản build thêm 4003 vào. Một khẳng định về đúng-tập-hợp là loại duy nhất biến một close code mới thành có chủ đích; cập nhật nó chính là hành động quyết định.

services/gateway/src/api-client.ts
@@ -32,13 +32,34 @@
 export class ApiError extends Error {
   readonly status: number;
 
-  constructor(what: string, status: number) {
+  /** The api's own error code and message, when it sent an envelope (chapter 3.15).
+   *
+   * THEY WERE THROWN AWAY UNTIL NOW, and it cost more than it looked. The socket's send
+   * path forwards a 401 by hand and answers `internal_error` for everything else, so
+   * every refusal the api can give a socket send — `user_banned` this chapter,
+   * **`channel_archived` since this feature's archive phase** — reached the client as
+   * "send failed". Chapter 3.14 built thirteen codes and one registry precisely so a
+   * client could tell refusals apart, and one hop discarded all of it.
+   *
+   * `undefined` when the response carried no envelope: a proxy's HTML 502, a timeout, a
+   * body that is not JSON. The caller then has nothing to forward and says so, which is
+   * the honest answer rather than a guessed code. */
+  readonly code: string | undefined;
+  readonly publicMessage: string | undefined;
+
+  constructor(
+    what: string,
+    status: number,
+    envelope?: { code?: string; message?: string },
+  ) {
     super(`${what} failed: ${status}`);
     this.name = "ApiError";
     // Declared and assigned rather than a constructor parameter property:
     // `erasableSyntaxOnly` is on everywhere except the api (ADR-15, chapter
     // 1.4), and the gateway keeps that guarantee.
     this.status = status;
+    this.code = envelope?.code;
+    this.publicMessage = envelope?.message;
   }
 }
 
@@ -107,7 +128,25 @@
     schema: { safeParse: (value: unknown) => { success: boolean; data?: T } },
     what: string,
   ): Promise<T> {
-    if (!res.ok) throw new ApiError(what, res.status);
+    if (!res.ok) {
+      // The envelope, if there is one. Read defensively: this is an error path, and a
+      // body that fails to parse must not replace the api's status with a JSON
+      // exception the caller cannot act on.
+      let envelope: { code?: string; message?: string } | undefined;
+      try {
+        const body: unknown = await res.json();
+        if (typeof body === "object" && body !== null && "code" in body) {
+          const { code, message } = body as { code?: unknown; message?: unknown };
+          envelope = {
+            ...(typeof code === "string" ? { code } : {}),
+            ...(typeof message === "string" ? { message } : {}),
+          };
+        }
+      } catch {
+        envelope = undefined;
+      }
+      throw new ApiError(what, res.status, envelope);
+    }
     const parsed = schema.safeParse(await res.json());
     if (!parsed.success || parsed.data === undefined) {
       throw new Error(`${what} returned a payload the contract does not allow`);
services/api/src/internal/session.controller.ts
@@ -102,6 +102,15 @@
     return {
       environment_id: principal.environmentId,
       user: principal.userExternalId,
+      // Chapter 3.15, FR-031. THE ROW IS ALREADY IN HAND — `getUserByExternalId` above
+      // reads it for the channel list — so carrying the ban costs one field and no query.
+      // The gateway refuses the socket; this route only reports the fact, because the
+      // gateway has no database and the column is in Postgres.
+      //
+      // A USER THIS ENVIRONMENT HAS NEVER SEEN IS NOT BANNED. `user` is null for a
+      // verified token naming somebody with no row, which chapter 2.5 decided is a user
+      // with no channels rather than an error — and a user with no row has no ban either.
+      banned: user?.banned_at != null,
       channel_ids: user ? await this.repo.channelsForUser(user.id) : [],
       limits: {
         connect: policy.limits.connect,
services/api/src/isolation/targets.ts
@@ -30,7 +30,22 @@
  * request naming one environment with an identifier from another. A `write` shape
  * alone cannot tell those apart, and an earlier draft of this chapter gave all
  * eight the platform attack (research R5). */
-export type CredentialClass = "application" | "user" | "platform" | "none";
+/** And `"either"`, added by chapter 3.15 for the first route that genuinely takes both
+ * (FR-017's read position: a user records their own, and the tenant records one for the
+ * user it names). Recording it as `"user"` alone would understate which attacks apply —
+ * both do, and `PUT /v1/users/:externalId/channels/:channelId/read` is attacked with a
+ * user token in the gauntlet's same-tenant block and with a tenant credential in T082a's
+ * two-identifier pair.
+ *
+ * This field is documentation for which attack applies, not part of the match:
+ * `targetKey` is method and path. So a wrong value here misleads a reader rather than
+ * letting a route through unattacked — which is why the value is stated exactly. */
+export type CredentialClass =
+  | "application"
+  | "user"
+  | "either"
+  | "platform"
+  | "none";
 
 interface Classified {
   method: string;
@@ -86,6 +101,76 @@
 
   // ── list ────────────────────────────────────────────────────────────────────
   { method: "GET", path: "/v1/webhooks", accepts: "application", shape: "list" },
+  // Chapter 3.15. A `list` and not a `read`: the attack on a listing is that a
+  // foreign identifier returns somebody else's rows, and the refusal that matters is
+  // an EMPTY page rather than an error — a 404 for a foreign user id is right here
+  // because the user is named in the path, but the shape's own assertion is that no
+  // row from another environment ever appears in a 200.
+  {
+    method: "GET",
+    path: "/v1/users/:externalId/channels",
+    accepts: "application",
+    shape: "list",
+  },
+
+  // Chapter 3.15. The bulk upsert and the deletion. Both `write`: the upsert's attack is
+  // an entry naming another tenant's user, which must create a NEW row in the caller's
+  // environment rather than touch theirs; the deletion's is a foreign external id, which
+  // must answer 404 and leave the other tenant's user alive.
+  { method: "POST", path: "/v1/users", accepts: "application", shape: "write" },
+  {
+    method: "DELETE",
+    path: "/v1/users/:externalId",
+    accepts: "application",
+    shape: "write",
+  },
+
+  // Chapter 3.15. The ban pair, both `write`. The attack is a foreign external id: a
+  // tenant must not be able to ban another tenant's user, and the refusal is the 404 a
+  // user who does not exist in THIS environment gets — which is what they are.
+  {
+    method: "POST",
+    path: "/v1/users/:externalId/ban",
+    accepts: "application",
+    shape: "write",
+  },
+  {
+    method: "DELETE",
+    path: "/v1/users/:externalId/ban",
+    accepts: "application",
+    shape: "write",
+  },
+
+  // Chapter 3.15. The profile pair. `read` for the GET; the PATCH is a `write` whose
+  // attack is a foreign external id under an own credential — a tenant must not be able
+  // to rename another tenant's user, and the refusal is the same 404 a user who does not
+  // exist gets, because in this tenant they do not.
+  {
+    method: "GET",
+    path: "/v1/users/:externalId",
+    accepts: "application",
+    shape: "read",
+  },
+  {
+    method: "PATCH",
+    path: "/v1/users/:externalId",
+    accepts: "application",
+    shape: "write",
+  },
+
+  // Chapter 3.15. The route that names TWO tenant-owned identifiers, which is why
+  // T082a attacks it both ways round: a foreign user with an own channel and an own
+  // user with a foreign channel are different code paths, and one scoped read can mask
+  // the other.
+  //
+  // `either` because a user records their own position and the tenant records one for
+  // the user it names — the only route on the users controller that takes both.
+  {
+    method: "PUT",
+    path: "/v1/users/:externalId/channels/:channelId/read",
+    accepts: "either",
+    shape: "write",
+  },
 
   // ── read ────────────────────────────────────────────────────────────────────
   { method: "GET", path: "/v1/webhooks/:id", accepts: "application", shape: "read" },
services/api/src/isolation/targets.itest.ts
@@ -119,7 +119,7 @@
     // The routes built so far. This assertion moves ONE line per phase, which is
     // the point: a phase that adds a route and forgets to classify it fails the
     // test above, and a phase that adds a route nobody planned fails this one.
-    const BUILT_SO_FAR = 6;
+    const BUILT_SO_FAR = 14;
     expect(derived.length).toBe(24 + BUILT_SO_FAR);
   });
 
services/api/src/isolation/gauntlet.itest.ts
@@ -384,6 +384,21 @@
       ["read history", "GET", (c) => `/v1/channels/${c}/messages?limit=10`],
       ["send", "POST", (c) => `/v1/channels/${c}/messages`, { text: "not mine" }],
       ["join", "POST", (c) => `/v1/channels/${c}/join`],
+      // THE FIFTH VERB (SC-001a, chapter 3.15's T121a). Its route is built in the
+      // unread-count phase rather than with the other four, so it joins the oracle
+      // here — the verb list is the authority and the count of verbs is not written
+      // down anywhere, which is the fix for a number that went three, then four,
+      // then five while its verification task stayed at three.
+      //
+      // THE USER IN THE PATH IS THE STRANGER'S OWN EXTERNAL ID. Under a user token the
+      // route's subject and the path's user are the same person, so this attacks the
+      // channel and nothing else — a mismatched pair is a different test (T082a).
+      [
+        "set a read position",
+        "PUT",
+        (c) => `/v1/users/${same.stranger.externalId}/channels/${c}/read`,
+        { sequence: 0 },
+      ],
     ];
 
     for (const [name, method, path, body] of verbs) {
@@ -411,6 +426,54 @@
       expect(body.messages.some((m) => m.text === "not mine")).toBe(false);
     });
 
+    // ── T155a: THE BAN'S OWN PAIR (FR-021a, FR-031) ──────────────────────────
+    //
+    // T072 left the slot and only Phase 15 could fill it, because until then nothing
+    // wrote `banned_at`. The ban check runs **before the channel is read**, which is what
+    // this pair asserts: a banned user gets `user_banned` for a channel that exists and
+    // for one that does not, and the two answers are byte-identical.
+    //
+    // ANY OTHER POSITION LEAKS. Check the channel first and the refusal for a real
+    // channel differs from the refusal for an invented one — so a banned user can
+    // enumerate channel ids by watching which refusal comes back. That is the same defect
+    // as the archived-channel leak one requirement over, and this is the half of FR-021a
+    // that could not be tested until now.
+    describe("a banned user gets one answer for every channel id", () => {
+      it("refuses a real channel and an invented one identically", async () => {
+        await fetch(`${url}/v1/users/${same.stranger.externalId}/ban`, {
+          method: "POST",
+          headers: { authorization: `Bearer ${same.credential}` },
+        });
+        try {
+          const real = await asUser(
+            same.stranger.token,
+            "POST",
+            `/v1/channels/${same.publicChannelId}/messages`,
+            { text: "banned but real" },
+          );
+          const invented = await asUser(
+            same.stranger.token,
+            "POST",
+            `/v1/channels/${nowhereId()}/messages`,
+            { text: "banned and invented" },
+          );
+          expect(real.status).toBe(403);
+          expect(invented.status).toBe(403);
+          const a = withoutRequestId(await real.json());
+          const b = withoutRequestId(await invented.json());
+          expect(a).toEqual(b);
+          expect((a as { code: string }).code).toBe("user_banned");
+        } finally {
+          // Unbanned in a `finally`, because every other test in this block uses the
+          // same stranger and a leaked ban would turn their refusals into this one.
+          await fetch(`${url}/v1/users/${same.stranger.externalId}/ban`, {
+            method: "DELETE",
+            headers: { authorization: `Bearer ${same.credential}` },
+          });
+        }
+      });
+    });
+
     it("a PUBLIC channel of the same tenant is open to the same non-member (FR-004)", async () => {
       // The other half of what makes `channels.type` decide something. If both types
       // refused, the column would still be deciding nothing.
services/api/src/messages/messages.service.ts
@@ -9,6 +9,7 @@
 
 import {
   ChannelArchivedError,
+  UserBannedError,
   ChannelNotFoundError,
   Repository,
   type MessageRow,
@@ -61,6 +62,23 @@
         }),
       });
     } catch (error) {
+      // THE BAN, FIRST IN THE ORDER AND FIRST IN THE MAPPING (FR-031, FR-021a).
+      //
+      // 403 `user_banned`, and it is thrown before the channel is resolved — so this
+      // refusal is the same for a channel that exists, one that belongs to another
+      // tenant, and one that was invented. The gauntlet asserts exactly that pair.
+      //
+      // NOT the not-found envelope, unlike the private-channel refusal. A ban is a fact
+      // about the CALLER, not about the channel, so saying so reveals nothing about what
+      // channels exist — and a client that cannot tell "you are banned" from "no such
+      // channel" retries for ever against a wall.
+      if (error instanceof UserBannedError) {
+        throw protocolError(
+          "user_banned",
+          "this user is banned in this environment and cannot send messages",
+          HttpStatus.FORBIDDEN,
+        );
+      }
       if (error instanceof ChannelArchivedError) {
         // 403 AND ITS OWN CODE (FR-021). Distinct from not-found, because the
         // channel is there and the caller can see it, and distinct from
services/gateway/src/isolation-fixtures.ts
@@ -60,6 +60,24 @@
   rejoinSelf: () => Promise<void>;
   archiveOwnChannel: () => Promise<void>;
   unarchiveOwnChannel: () => Promise<void>;
+  /** A user and a channel nobody else in the suite touches, with one attributed message
+   * already in it (chapter 3.15, T144).
+   *
+   * ITS OWN FIXTURE BECAUSE THE TEST DESTROYS IT. T144 deletes the user, and the first
+   * version deleted the shared `victim` — which took the membership with it and made a
+   * later test's profile PATCH answer 404. Phase 7 hit the same class twice: a test that
+   * mutates a shared fixture breaks whichever test runs after it, and the fix is a
+   * fixture of its own rather than an ordering constraint nobody can see. */
+  /** Ban and unban this tenant's own user through the public route (chapter 3.15,
+   * T153). */
+  banSelf: () => Promise<void>;
+  unbanSelf: () => Promise<void>;
+  seedDeletable: () => Promise<{
+    userExternalId: string;
+    channelId: string;
+    seq: number;
+    witnessToken: string;
+  }>;
   /** A token for `userExternalId`, minted through the api's own dev-token route so
    * the signing secret never leaves the api — research R1's rule, and the reason
    * the gateway asks rather than verifies. */
@@ -168,6 +186,39 @@
         });
         if (!res.ok) throw new Error(`archive for ${label}: ${res.status}`);
       },
+      banSelf: async () => {
+        const res = await fetch(`${apiUrl}/v1/users/${userExternalId}/ban`, {
+          method: "POST",
+          headers: { authorization: `Bearer ${key.credential}` },
+        });
+        if (!res.ok) throw new Error(`ban ${userExternalId}: ${res.status}`);
+      },
+      unbanSelf: async () => {
+        const res = await fetch(`${apiUrl}/v1/users/${userExternalId}/ban`, {
+          method: "DELETE",
+          headers: { authorization: `Bearer ${key.credential}` },
+        });
+        if (!res.ok) throw new Error(`unban ${userExternalId}: ${res.status}`);
+      },
+      seedDeletable: async () => {
+        const label2 = `${label}-del-${Math.random().toString(36).slice(2, 7)}`;
+        const doomed = await repo.createUser(`${label2}-doomed`, "Doomed");
+        const witness = await repo.createUser(`${label2}-witness`, "Witness");
+        const room = await repo.createChannel(`${label2}-room`, "public");
+        await repo.addMember(room.id, doomed.id);
+        await repo.addMember(room.id, witness.id);
+        const sent = await repo.sendMessage(room.id, {
+          text: "sent before the deletion",
+          userId: doomed.id,
+          userExternalId: `${label2}-doomed`,
+        });
+        return {
+          userExternalId: `${label2}-doomed`,
+          channelId: room.id,
+          seq: sent.seq,
+          witnessToken: await mintToken(apiUrl, key.credential, `${label2}-witness`),
+        };
+      },
       unarchiveOwnChannel: async () => {
         const res = await fetch(`${apiUrl}/v1/channels/${channel.id}/archive`, {
           method: "DELETE",
vitest.coverage.config.mts
@@ -36,7 +36,7 @@
     hookTimeout: 60_000,
     coverage: {
       provider: "v8",
-      // `json` joins the other two for chapter 3.12's FR-040, which asks for every
+      // `json` joins the other two for chapter 3.13's FR-040, which asks for every
       // uncovered branch to be NAMED and not merely counted. `json-summary` carries
       // totals and percentages; the per-branch locations are only in `coverage-final.json`.
       // Found by trying to list the 25 uncovered arms in `repository.ts` and getting a
@@ -120,8 +120,31 @@
         // It now reads 90.71%, above where the chapter found it and below 91, so
         // the pin stays at 90 rather than moving to a number the next chapter
         // would have to earn back.
+        //
+        // CHAPTER 3.15 RAISED IT, which is the first time this file's branch ratchet has
+        // moved up. The feature added roughly 600 lines here — the membership check, the
+        // visibility predicate, bulk removal, roles, archiving, the ban, the read
+        // position, the listing with its unread arithmetic — and branches went
+        // **89.51% → 92.11%**. Chapter 3.5's precedent was the opposite: six operations
+        // on this file took branches 85.91% → 78.22% on the next run.
+        //
+        // PINNED AT 91 AND NOT 92, on the reasoning above. 92.11 clears 92 by a tenth,
+        // which is the thin margin chapter 3.12 declined to pin against; 91 locks in most
+        // of the gain and leaves the next chapter more than a rounding error of room.
+        //
+        // WHAT IS STILL UNCOVERED, and every one is the same class the comment above
+        // names: three `could not be created or read` throws — `createChannel`,
+        // `createUser`, `upsertUser` — each the loser of an `ON CONFLICT` race finding no
+        // row, which means the winner's row was deleted between two statements of one
+        // call. Nothing in the api deletes from either table.
+        //
+        // A FOURTH ONE WAS REMOVED RATHER THAN NAMED. `upsertUser` first read the row,
+        // threw if absent, updated it, read it back and threw again — two statements for
+        // one impossible state. The third instance of that class took lines to 98.92% and
+        // the gate went red against its pin of 99; the instrument was right, because the
+        // second throw said nothing the first had not. One throw, and lines read 99.13%.
         "services/api/src/db/repository.ts": {
-          branches: 90,
+          branches: 91,
           functions: 100,
           lines: 99,
           statements: 97,
@@ -249,11 +272,69 @@
         // from storage before any user is created. The one uncovered branch is the
         // `not_found` outcome after a successful scoped read — the channel deleted
         // between two statements of one call — which nothing in the api can do.
+        //
+        // CHAPTER 3.15 RAISED THIS TOO, and T174d predicted the opposite. That task
+        // expected `functions: 100` to go red on the first partially-covered new
+        // function — the file gains read-by-id, join, archive, unarchive, bulk removal
+        // and role-setting — and it did not, because every one of the six has a route
+        // test in `channels.itest.ts` or the gauntlet. Measured 96.97 / 86.54 / 100 /
+        // 96.97 against a pin of 94 / 75 / 100 / 94.
+        //
+        // The prediction was reasonable and the reason it failed is worth keeping: a new
+        // method reached only through a child process would have done exactly what T174d
+        // feared, which is what T174b checks for separately.
         "services/api/src/channels/channels.service.ts": {
-          branches: 75,
+          branches: 86,
+          functions: 100,
+          lines: 96,
+          statements: 96,
+        },
+
+        // ── CHAPTER 3.15's USER SURFACE ────────────────────────────────────────
+        //
+        // The controller and the schema at 100 on everything, and they earn it: the
+        // controller is eight handlers that delegate, and the schema's every refusal path
+        // is driven — including all THREE ways a cursor can be malformed, which needed
+        // three tests because one refusal reaching the wire says nothing about whether
+        // the other two paths work.
+        "services/api/src/users/users.controller.ts": {
+          branches: 100,
+          functions: 100,
+          lines: 100,
+          statements: 100,
+        },
+        "services/api/src/users/users.schema.ts": {
+          branches: 100,
+          functions: 100,
+          lines: 100,
+          statements: 100,
+        },
+        // The service holds the decisions: which refusal a read position gets, whether a
+        // user is alive, what a partial profile patch leaves alone. 97.62 / 90.91 / 100 /
+        // 97.62 — the two uncovered statements are `requireUser`'s and `updateProfile`'s
+        // 404 throws reached from a direction no route takes.
+        "services/api/src/users/users.service.ts": {
+          branches: 90,
+          functions: 100,
+          lines: 97,
+          statements: 97,
+        },
+
+        // A FLOOR, NOT AN ACHIEVEMENT. `messages.service.ts` measures 70.83 / 61.76 /
+        // 100 / 70.83, and the six uncovered statements are all PRE-EXISTING: the quota
+        // refusal and its rethrow (chapter 3.10) and the history cursor's decode (chapter
+        // 2.4). This feature's additions to the file — the ban mapping, the archive
+        // mapping, the visibility predicate on the history path — are covered.
+        //
+        // Pinned anyway, because an unpinned file is a figure that can slide (chapter
+        // 3.11's T033c) and this feature changed the file. A ratchet at 61 does not bless
+        // 61; it forbids 60. Raising it is the next chapter's work, and the arms are named
+        // here so that chapter knows what it is buying.
+        "services/api/src/messages/messages.service.ts": {
+          branches: 61,
           functions: 100,
-          lines: 94,
-          statements: 94,
+          lines: 70,
+          statements: 70,
         },
 
         "services/api/src/webhooks/disable.ts": {

Table thứ mười của guard cần mồi, và cái mồi cần một tính chất mà chín table kia không có.

packages/test-harness/src/sentinel.sql (excerpt)
-- TEN AS OF CHAPTER 3.16. `read_positions` carries `environment_id`, so it belongs
-- here, and it has no `id` — which is what the message expression above was changed
-- for. `members` is the counter-example and is deliberately absent: it has no
-- `environment_id`, so the catalogue classifies it as `hop` and no trigger watches
-- it. Adding a table to this array is not the same as the guard watching it, which
-- is why `guard.itest.ts` drives each one and why removing a name from here has to
-- turn a test red.
DO $$
DECLARE
  t text;
BEGIN
  FOREACH t IN ARRAY ARRAY[
    'webhook_endpoints',

Một excerpt, và fences/post-series.md mang phần bổ sung — trình kiểm tra fence áp phụ lục sau mọi chương, nên một chương ở phía trên dòng của chính phần bổ sung của nó và không thể phát biểu một trạng thái mà phụ lục mới dựng nên. Chương 3.12 học điều đó từ chiều ngược lại, và tính năng này học nó từ chiều này: một chương cũng không thể làm thay việc của phụ lục. Cái diff repository.ts của nó đã chạm đúng trạng thái của repository, và thế là để lại cho hunk của phụ lục không còn gì để áp.

read_positions không thể bị chiếm về mặt cấu tạo, và đó là tính chất chín table kia không có. Không gì trong platform quét xoá read position xuyên environment — lần xoá hàng loạt duy nhất là lần mà việc xoá một user thực hiện, và nó chỉ trong phạm vi user đó — nên cái mồi không thể bị một operation toàn cục hợp pháp gom mất. Với quota_notifications thì mồi phải được cắm rất cẩn thận; ở đây chính cách truy cập của table đã làm việc đó.

Hai chương này tốn những gì, bằng số

số file 25 → 29 → 34 → 36 → 38 → 40 → 41 → 43 số lần sửa lại tám, và sáu lần trong đó trước khi có một chữ prose nào 3.15 20 file được dạy, 2.947 chữ prose, 20 fence 3.16 26 file được dạy, 11 file nữa phải fence mà không dạy lane 407 test → 550, và trung bình dịch từ 193,0 s → 193,55 s hai mươi lần chạy 20/20 xanh, 192–197 s, lệch chuẩn 1,54 s

Tám lần sửa lại một con số, và mỗi lần đến từ một câu hỏi khác. Danh sách điều khoản cho 25. Danh sách task cho 29. Hỏi chương nào fence từng file cho 34 — và tìm ra app.module.ts, dòng đăng ký không nằm trong task nào. Viết call graph của đường send ra cho 36. Đếm các đường dẫn mà task gọi tên, thay vì đọc con số tổng, cho 38. Hỏi git diff xem thực sự có gì đổi cho 40 — và tìm ra hai file không nằm trong ô nào cùng một file mà tính năng chẳng hề chạm. Việc hiện thực tìm ra một khiếm khuyết cho 41. Và hỏi git diff thêm một lần nữa ở cuối cho 43.

Lần sửa thứ tám là lần quan trọng, vì nó tách con số ấy thành hai. Cái một chương dạy không phải cái nó phải fence. Mười một file ở đây đổi đúng một từ — một citation chương được sửa, không một dòng thực chất nào — và chuỗi fence không quan tâm vì sao một file đổi: trạng thái của một đường dẫn đã được nhận phải khớp với repository. Vậy là mười một fence không có chủ đề, mười trong số đó xuất hiện ở đây:

packages/outsider/vitest.integration.config.mts
@@ -1,6 +1,6 @@
 import { defineConfig } from "vitest/config";
 
-// THE SEALED INTEGRATION (chapter 3.12, FR-030, FR-031).
+// THE SEALED INTEGRATION (chapter 3.14, FR-030, FR-031).
 //
 // This package holds one suite that behaves like a customer: it reads two URLs
 // and a credential from the environment, speaks HTTP and WebSocket, and knows
packages/service-kit/src/index.ts
@@ -56,7 +56,7 @@
   health: () => Record<string, unknown>;
   logger?: Logger;
   /** The `docs_url` for the not-found envelope this server answers unknown routes
-   * with (chapter 3.12, FR-027).
+   * with (chapter 3.14, FR-027).
    *
    * REQUIRED, AND THE DEPENDENCY INVERTS RATHER THAN BEING ADDED. The obvious move
    * is to import `docsUrl` from `@relay/protocol` here — and this package declares
@@ -78,7 +78,7 @@
  * answers with the service's health payload, and unknown routes get the
  * EIR-API-04 error shape.
  *
- * The docs_url is no longer a placeholder — chapter 3.12 made it a required option
+ * The docs_url is no longer a placeholder — chapter 3.14 made it a required option
  * and the caller derives it from `@relay/protocol`'s registry, which is how a
  * package with no dependencies can still emit a URL the registry owns. */
 export function serve(options: ServeOptions): Server {
scripts/seed-demo-tenant.mjs
@@ -1,4 +1,4 @@
-// A tenant an outsider can integrate against (chapter 3.12, FR-032).
+// A tenant an outsider can integrate against (chapter 3.14, FR-032).
 //
 // The constitution asks that `docker compose up` yield a working local platform
 // "including a seeded demo tenant". Nothing seeded one, and until this chapter
services/api/src/messages/zod-validation.pipe.ts
@@ -15,7 +15,7 @@
     const result = this.schema.safeParse(value);
     if (!result.success) {
       const issue = result.error.issues[0];
-      // WHICH FIELD, and chapter 3.12 is where that stopped being optional.
+      // WHICH FIELD, and chapter 3.14 is where that stopped being optional.
       //
       // EIR-API-04's error shape has carried a `field` since chapter 1.3 and
       // `errorFrameSchema` declares it — and nothing in the api had ever set it.
services/api/src/protocol-error.filter.ts
@@ -39,7 +39,7 @@
       typeof (response as { code?: unknown }).code === "string"
         ? (response as { code: string }).code
         : null;
-    // TYPED AS `ErrorCode` (chapter 3.12, FR-025). The ladder emitted five codes
+    // TYPED AS `ErrorCode` (chapter 3.14, FR-025). The ladder emitted five codes
     // that were not in the registry for twenty-two chapters — `invalid_request`,
     // `forbidden`, `not_found`, `internal_error` and the frame codes — and
     // `docs_url` is derived from the code, so each one shipped a link to a page
services/api/src/protocol-error.ts
@@ -1,7 +1,7 @@
 import { HttpException } from "@nestjs/common";
 import type { ErrorCode } from "@relay/protocol";
 
-/** An HTTP failure that NAMES ITS OWN CODE, typed (chapter 3.12, FR-025, FR-026).
+/** An HTTP failure that NAMES ITS OWN CODE, typed (chapter 3.14, FR-025, FR-026).
  *
  * Chapter 3.2 introduced the convention that a thrower may name its code, because
  * `wrong_credential_type` is a distinction a status cannot carry. What it could not
services/dispatcher/src/dispatcher.itest.ts
@@ -346,7 +346,7 @@
     second = customerEndpoint();
     secondUrl = await second.listen();
 
-    // A RANDOM HIGH PORT (chapter 3.12, T077). This bound a fixed 4131, which is
+    // A RANDOM HIGH PORT (chapter 3.13, T077). This bound a fixed 4131, which is
     // the second instance of the fault CLAUDE.md names only for
     // `limits.itest.ts` — the audit is what found it. The integration lane runs
     // one package at a time, so nothing races this file WITHIN a run; what does
services/gateway/src/main.ts
@@ -28,7 +28,7 @@
     }),
     logger: log,
     // The registry owns the URL; `service-kit` owns no dependencies. So the URL
-    // crosses the boundary as data (chapter 3.12, FR-027, R9).
+    // crosses the boundary as data (chapter 3.14, FR-027, R9).
     notFoundDocsUrl: docsUrl("not_found"),
   });
   // The socket server rides the SAME listener as health — one port, two
services/gateway/src/public-surface.itest.ts
@@ -213,7 +213,7 @@
 
   // A MESSAGE SENT OVER THE PUBLIC REST API CANNOT REACH A SOCKET AT ALL, and
   // that is the platform's behaviour rather than this test's shortcoming. Pinned
-  // here because chapter 3.12's exit criterion is that an outsider integrates on
+  // here because chapter 3.14's exit criterion is that an outsider integrates on
   // the documentation alone, and this is the sentence that documentation has to
   // contain.
   //
services/gateway/src/resume.itest.ts
@@ -113,10 +113,13 @@
     // window. Neither side coordinates; only the buffer saves this.
     harness = await boot({
       session: async () => ({
         environment_id: "env-1",
         user: "tuan",
+        // Chapter 3.15: the api now reports whether the user is banned, and a stub
+        // that does not say is a stub that has not thought about it.
+        banned: false,
         channel_ids: [CHANNEL],
         // Chapter 3.8: the limits ride the session response now. Generous, and
         // beside the point of every test in this file.
         limits: { connect: 3_000, send: 600 },
       }),
@@ -147,10 +150,13 @@
     // and the flush is the only reason the client ever sees it.
     harness = await boot({
       session: async () => ({
         environment_id: "env-1",
         user: "tuan",
+        // Chapter 3.15: the api now reports whether the user is banned, and a stub
+        // that does not say is a stub that has not thought about it.
+        banned: false,
         channel_ids: [CHANNEL],
         // Chapter 3.8: the limits ride the session response now. Generous, and
         // beside the point of every test in this file.
         limits: { connect: 3_000, send: 600 },
       }),
@@ -175,10 +181,13 @@
   it("goes live after the flush, with no buffering left behind", async () => {
     harness = await boot({
       session: async () => ({
         environment_id: "env-1",
         user: "tuan",
+        // Chapter 3.15: the api now reports whether the user is banned, and a stub
+        // that does not say is a stub that has not thought about it.
+        banned: false,
         channel_ids: [CHANNEL],
         // Chapter 3.8: the limits ride the session response now. Generous, and
         // beside the point of every test in this file.
         limits: { connect: 3_000, send: 600 },
       }),
@@ -221,10 +230,13 @@
     // One number different from the test above it. That is the whole bug.
     harness = await boot({
       session: async () => ({
         environment_id: "env-1",
         user: "tuan",
+        // Chapter 3.15: the api now reports whether the user is banned, and a stub
+        // that does not say is a stub that has not thought about it.
+        banned: false,
         channel_ids: [CHANNEL],
         // Chapter 3.8: the limits ride the session response now. Generous, and
         // beside the point of every test in this file.
         limits: { connect: 3_000, send: 600 },
       }),
@@ -260,10 +272,13 @@
     // drop the mark, and then deliver the 42 (research R3).
     harness = await boot({
       session: async () => ({
         environment_id: "env-1",
         user: "tuan",
+        // Chapter 3.15: the api now reports whether the user is banned, and a stub
+        // that does not say is a stub that has not thought about it.
+        banned: false,
         channel_ids: [CHANNEL],
         // Chapter 3.8: the limits ride the session response now. Generous, and
         // beside the point of every test in this file.
         limits: { connect: 3_000, send: 600 },
       }),
@@ -299,10 +314,13 @@
     // duplicate into a gap, which constitution II ranks worse.
     harness = await boot({
       session: async () => ({
         environment_id: "env-1",
         user: "tuan",
+        // Chapter 3.15: the api now reports whether the user is banned, and a stub
+        // that does not say is a stub that has not thought about it.
+        banned: false,
         channel_ids: [CHANNEL],
         // Chapter 3.8: the limits ride the session response now. Generous, and
         // beside the point of every test in this file.
         limits: { connect: 3_000, send: 600 },
       }),
services/gateway/src/session.test.ts
@@ -49,6 +49,9 @@
         ? {
             environment_id: "env-1",
             user: "tuan",
+            // Chapter 3.15: the api now reports whether the user is banned, and a stub
+            // that does not say is a stub that has not thought about it.
+            banned: false,
             channel_ids: [CHANNEL],
             // Chapter 3.8. The limits ride the session response because the
             // gateway has no database to read them from — so the stub supplies
@@ -857,6 +860,9 @@
         session: async () => ({
           environment_id: "env-1",
           user: "tuan",
+          // Chapter 3.15: the api now reports whether the user is banned, and a stub
+          // that does not say is a stub that has not thought about it.
+          banned: false,
           channel_ids: [CHANNEL],
           limits: { connect: 2, send: 600 },
         }),
@@ -890,6 +896,9 @@
         session: async () => ({
           environment_id: "env-1",
           user: "tuan",
+          // Chapter 3.15: the api now reports whether the user is banned, and a stub
+          // that does not say is a stub that has not thought about it.
+          banned: false,
           channel_ids: [CHANNEL],
           limits: { connect: 3_000, send: configured },
         }),

Một lần sửa, làm một lần, vì một lý do: tính năng trước được đặc tả là một chương và ship thành ba, nên 31 file trích "chương 3.12" cho những thay đổi mà 3.13 và 3.14 mới dạy. Hai mươi hai citation giờ gọi đúng tên chương; hai mươi lăm cái vẫn gọi 3.12, và gọi đúng.

File thứ mười một dạy cho luật ấy giới hạn của nó. eslint.config.mjs mang citation của mình trên một dòng được soạn bên trong fences/post-series.md, như một dòng thêm vào trong một hunk của phụ lục — nên phụ lục mang bản sửa và chương này không fence gì cho file đó. Phép phân loại đã tìm ra hai mươi hai cái kia bỏ sót cái này, bởi nó tìm trong repository của platform còn dòng ấy sống trong repository của tutorial.

Cái cột không có ai đọc

Chủ đề của tính năng này là bốn cột không ai đọc, cùng một cột thứ năm được một response body trả về trong khi không quyết định nào tra tới nó. Cả năm giờ đã có người đọc, và channels.type có quyết định đầu tiên của nó.

Nó để lại một cột, và là cột của chính nó: read_positions.updated_at, được ghi bởi mọi lần ghi vị trí và không ai đọc. Một field audit không có người audit.

Con số ấy đi từ ba, xuống hai, xuống một trong lúc đặc tả. users.deleted_at được lần xoá ghi vào và lần hồi sinh xoá đi mà không ai đọc, cho tới khi một lượt phân tích cho nó người đọc; members.role bị gọi là chết cho tới khi một lượt sau đó nhận ra phần liệt kê trả nó về — và trả một cột về chính là đọc nó, bản sửa làm phát biểu sắc hơn chứ không yếu đi. Điều trung thực còn lại để nói về updated_at là: lựa chọn của nó là một người đọc hoặc một migration xoá nó đi.

Nên nó ở lại, và câu nói ra điều đó nằm ngay cạnh cột chứ không chỉ trong một tài liệu tổng kết — bởi một cột không ai chọn giữ và một cột có người chọn giữ trông y hệt nhau trong schema.

services/api/src/db/schema.ts
@@ -437,6 +437,16 @@
     // `channels.last_sequence` is refused (FR-018) — a position nothing can reach makes
     // every later count wrong.
     sequence: bigint("sequence", { mode: "number" }).notNull(),
+    // WRITTEN BY EVERY POSITION WRITE AND READ BY NOTHING, and that is a decision rather
+    // than an oversight (chapter 3.16's `gaps.md` §5).
+    //
+    // Chapters 3.15 and 3.16 exist because five columns had no reader, so leaving a sixth
+    // behind needs a sentence or it becomes the next feature's finding. The two options
+    // were a reader — an operations view answering "when did this user last catch up" —
+    // or a migration dropping it. Kept, on the expectation that the reader arrives.
+    //
+    // A column nobody chose to keep and a column somebody chose to keep look identical in
+    // a schema. This comment is the only thing that tells them apart.
     updatedAt: timestamp("updated_at", { withTimezone: true })
       .notNull()
       .defaultNow(),

Số cột không có người đọc không về 0 ở cuối một tính năng nói về những cột không ai đọc. Nó về một, một cách có chủ đích, và cái một đó là của chính chúng ta.

Trước · Chương 3.15hiện chỉ có bản tiếng Anh

Kênh mà khách hàng kiểm soát

Tiếp theo · Chương 3.17hiện chỉ có bản tiếng Anh

Người gửi mà một tin nhắn chưa từng có

← Về mục lục