Building Relay

Phần 3 · Chương 3.8

Các endpoint và các thiết bị đo

Bạn sẽ tạo ra: Hai endpoint công khai mà Part 3 cần và chưa ai xây, tính đẳng xâm (idempotent) do unique index bảo đảm chứ không phải do bộ nhớ ứng dụng, mọi lỗi validation lần đầu tiên gọi tên field của nó, guard thao tác toàn cục canh chín bảng thay vì năm, và độ phủ nhánh của tầng repository được trả lời bằng một con số · khoảng 80 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)

Isolation harness dựng suite attack mọi endpoint platform serve. Khi derive target list, nó tìm ra điều plan bỏ sót: không có public API để tạo channel hoặc thêm member. Comment trong packages/e2e/src/harness.ts đã nói vậy từ 2.8 nhưng không ai đọc lại.

Part 3 kết thúc ở outsider milestone với exit criterion: external developer integrate chỉ bằng public documentation. Criterion này bất khả thi không phải do docs, mà vì không có cách public tạo channel để gửi message. Trong thứ tự sách ban đầu, discovery tới chỉ hai chương trước milestone. Rework theo subject đặt endpoint ở đây, sớm mười tám chương: grouping theo subject tìm surface thiếu khi xây surface, không phải lúc ship.

Chương này xây hai endpoint rồi quay cùng câu hỏi vào instrument: thứ verify isolation cũng là code, và chưa ai kiểm tra chúng.

/** DECISION (chapter 2.8): the suite seeds through the api's own repository
 * layer, imported from its build output. There is no admin API to create an
 * environment, a user or a channel yet — that is Part 3's tenancy work — and
 * inventing one for a test would be inventing product. */

Hai endpoint và thứ endpoint cần mà fixture không cần

flowchart TB
    create["POST /v1/channels<br/>external_id, type, name?, metadata?"]
    create --> conflict["INSERT … ON CONFLICT<br/>(environment_id, external_id) DO NOTHING"]
    conflict --> made["có một row trả về"]
    conflict --> lost["không có gì trả về"]
    made --> c201["201 — đã tạo"]
    lost --> read["getChannelByExternalId<br/>kẻ thua đọc row của kẻ thắng"]
    read --> c200["200 — channel đã có"]
    members["POST /v1/channels/:channelId/members<br/>user_ids, at most 100"]
    members --> scoped["channelExists(id) — ĐÃ SCOPE, và TRƯỚC TIÊN"]
    scoped --> absent["404, y hệt nhau cho id của người khác<br/>và id không tồn tại ở đâu cả"]
    scoped --> count["countMembers — đọc từ storage"]
    count --> ceiling["+ requested > 1000?"]
    ceiling --> refuse["422 channel_member_limit_exceeded<br/>không tạo ai, không ghi gì"]
    ceiling --> add["createUser rồi addMember,<br/>từng user một"]
    style c201 fill:#064e3b,color:#fff,stroke:#059669
    style c200 fill:#1e3a8a,color:#fff,stroke:#3b82f6
    style absent fill:#78350f,color:#fff,stroke:#d97706
    style refuse fill:#7f1d1d,color:#fff,stroke:#dc2626
Creation idempotent theo identifier của khách hàng; status nói outcome. Membership đọc channel đã scope trước, để foreign id và absent id trả giống nhau trước mọi work.

Body strict; một field là edit sắc nhất trong cặp chương.

Service mỏng; ordering bên trong là isolation property. channelExists scoped nên foreign channel và absent channel cùng 404/body. Nếu ceiling check hay user creation chạy trước, foreign id tiêu work trước khi ai hỏi channel thuộc ai và có thể trả khác. Ceiling được đếm từ storage trước user creation vì refused call phải không để row.

services/api/src/channels/channels.schema.ts
import { z } from "zod";
 
// THE PUBLIC CHANNEL SURFACE'S BODIES (FR-016, FR-CHN-01, FR-CHN-06, NFR-SEC-04).
//
// Both strict: an unknown field is a rejection, not a silently ignored typo. An
// integrating developer who writes `externalId` instead of `external_id` finds out
// on the first call rather than after wondering why the name never appears.
 
/** 8 KB, the same bound `channels.metadata` has had since chapter 2.1 — the
 * column is jsonb with a `{}` default, so this is a limit on what a caller may
 * send and not a new capability. Measured on the JSON text, because that is what
 * the column stores and what the row costs. */
const METADATA_BYTES = 8 * 1024;
 
const metadataSchema = z
  .record(z.string(), z.unknown())
  .refine((value) => Buffer.byteLength(JSON.stringify(value), "utf8") <= METADATA_BYTES, {
    message: `metadata must be at most ${METADATA_BYTES} bytes of JSON`,
  });
 
export const createChannelBodySchema = z.strictObject({
  external_id: z.string().min(1).max(255),
  // `public` AND NOTHING ELSE, and this is the chapter's sharpest edit (FR-047).
  //
  // `channels.type` has been a `"public" | "private"` column with a CHECK
  // constraint since chapter 2.1, and NOTHING IN THE PLATFORM READS IT. History
  // and send scope by `environment_id` alone; there is no membership check on any
  // read path. So FR-CHN-05 — a P1 clause promising that a private channel is
  // visible only to its members — is unimplemented.
  //
  // An endpoint accepting `private` would sell a guarantee the platform does not
  // keep, and it would do it in the chapter whose exit criterion is that an
  // outsider can integrate on the documentation alone. The enum is `public`
  // today; FR-CHN-03's private half goes to the channel-endpoints chapter with FR-CHN-05, where
  // the read paths are made to honour it.
  type: z.enum(["public"]),
  name: z.string().min(1).max(255).optional(),
  metadata: metadataSchema.optional(),
});
 
export type CreateChannelBody = z.infer<typeof createChannelBodySchema>;
 
/** FR-CHN-06's page: at most 100 users in one call. The channel's own ceiling is
 * 1,000 (FR-CHN-07) and is enforced in the service against a counted read — this
 * only bounds the size of a single request. */
export const addMembersBodySchema = z.strictObject({
  user_ids: z.array(z.string().min(1).max(255)).min(1).max(100),
});
 
export type AddMembersBody = z.infer<typeof addMembersBodySchema>;
 
/** FR-CHN-07. A structural limit on one channel, not a monthly quota — see
 * `channel_member_limit_exceeded` in the registry for why it is not
 * `quota_exceeded`. */
export const CHANNEL_MEMBER_LIMIT = 1000;
services/api/src/channels/channels.service.ts
import { HttpException, HttpStatus, Injectable, NotFoundException } from "@nestjs/common";
 
import { Repository, type ChannelRow } from "../db/repository";
import { CHANNEL_MEMBER_LIMIT, type AddMembersBody, type CreateChannelBody } from "./channels.schema";
 
// THE TWO ENDPOINTS PART 3 NEEDED AND NOBODY HAD BUILT (FR-016 to FR-019).
//
// `packages/e2e/src/harness.ts` has said since chapter 2.8 that creating a channel
// and adding a member is "Part 3's tenancy work". Part 3 ends at the outsider
// milestone, whose exit criterion is that an outsider integrates on public documentation
// alone — which was unreachable for a reason that had nothing to do with
// documentation: there was no public way to make a channel to send a message to.
//
// This is the minimum that unblocks it. The rest of FR-CHN and all of FR-USR go to
// the two chapters after this one.
 
export interface CreatedChannel {
  channel: ChannelRow;
  /** 201 or 200 at the controller. FR-CHN-02 says return the existing channel; it
   * does not say return the same status, and the difference is something an
   * integrating developer can act on — chapter 2.3 drew the same line for a
   * duplicate send. */
  created: boolean;
}
 
export interface MemberResult {
  user_id: string;
  external_id: string;
  status: "added" | "already_a_member";
}
 
@Injectable()
export class ChannelsService {
  constructor(private readonly repo: Repository) {}
 
  async create(body: CreateChannelBody): Promise<CreatedChannel> {
    const result = await this.repo.createChannel(
      body.external_id,
      body.type,
      body.name,
      body.metadata,
    );
    const { created, ...channel } = result;
    return { channel, created };
  }
 
  /** Members by EXTERNAL id, and a user is created on first membership (FR-CHN-04).
   *
   * THE CHANNEL IS READ SCOPED FIRST, and that ordering is the isolation property
   * rather than a convenience. A foreign channel id and one that exists nowhere both
   * fail this read, so both answer with the same 404 and neither reveals that the
   * other tenant's channel is there. If the ceiling or the user creation happened
   * first, a foreign id would spend work — and could answer differently — before
   * anyone checked whose channel it was. */
  async addMembers(channelId: string, body: AddMembersBody): Promise<MemberResult[]> {
    if (!(await this.repo.channelExists(channelId))) {
      // A CONSTANT message. Echoing the id back would make the foreign-id answer
      // differ from the absent-id answer, and different is itself a disclosure
      // (FR-TEN-05).
      throw new NotFoundException("channel not found");
    }
 
    // FR-CHN-07's ceiling, counted from storage rather than trusted from the
    // request. Checked BEFORE any user is created: a refused call must not leave
    // rows behind, and creating users for a request about to be refused would do
    // exactly that.
    const existing = await this.repo.countMembers(channelId);
    if (existing + body.user_ids.length > CHANNEL_MEMBER_LIMIT) {
      throw new HttpException(
        {
          code: "channel_member_limit_exceeded",
          message:
            `this channel holds ${existing} of ${CHANNEL_MEMBER_LIMIT} members; ` +
            `adding ${body.user_ids.length} would exceed the limit`,
        },
        HttpStatus.UNPROCESSABLE_ENTITY,
      );
    }
 
    const results: MemberResult[] = [];
    for (const externalId of body.user_ids) {
      const user = await this.repo.createUser(externalId);
      const outcome = await this.repo.addMember(channelId, user.id);
      if (outcome === "not_found") {
        // The channel was read above and both ids are this environment's, so this
        // is not reachable by a foreign request — it means the channel was deleted
        // between the read and here. Answer as the read would have.
        throw new NotFoundException("channel not found");
      }
      results.push({ user_id: user.id, external_id: externalId, status: outcome });
    }
    return results;
  }
}
services/api/src/channels/channels.controller.ts
import { Body, Controller, HttpCode, Param, Post, Res, UseGuards } from "@nestjs/common";
 
import { Accepts, CredentialGuard } from "../auth/credential.guard";
import { ZodValidationPipe } from "../messages/zod-validation.pipe";
import { ChannelsService } from "./channels.service";
import { addMembersBodySchema, createChannelBodySchema } from "./channels.schema";
import type { AddMembersBody, CreateChannelBody } from "./channels.schema";
 
// THE PUBLIC CHANNEL SURFACE (FR-016, FR-019, data-model.md §7).
//
// `@Accepts("application")` and not both classes: creating a channel and deciding
// who is in it are server-side acts. An end-user token is minted for one person
// (FR-AUT-10), and a person adding themselves to a channel is a product decision
// this chapter is not making — the channel-endpoints chapter owns the user-facing surface.
/** The one thing this controller needs from the response object.
 *
 * Declared rather than imported, which is the broker chapter's decision in
 * `signup.controller.ts` and its reason still holds: `@Res()` normally means
 * importing express's `Response` type, express 5 ships no types, and adding
 * `@types/express` for one method signature would move the api's dependency list
 * for nothing. */
interface HttpResponse {
  status(code: number): unknown;
}
 
@Controller("v1/channels")
@UseGuards(CredentialGuard)
@Accepts("application")
export class ChannelsController {
  constructor(private readonly channels: ChannelsService) {}
 
  /** 201 on creation, 200 on the idempotent repeat (FR-017, FR-CHN-02).
   *
   * `@Res({ passthrough: true })` rather than a fixed `@HttpCode`, because the
   * status is the answer here: FR-CHN-02 says return the existing channel, and an
   * integrating developer who cannot tell "I made this" from "this was already
   * here" has to go and read the body to find out. Chapter 2.3 drew the same line
   * for a duplicate send. */
  @Post()
  async create(
    @Body(new ZodValidationPipe(createChannelBodySchema)) body: CreateChannelBody,
    @Res({ passthrough: true }) res: HttpResponse,
  ) {
    const { channel, created } = await this.channels.create(body);
    res.status(created ? 201 : 200);
    return {
      id: channel.id,
      external_id: channel.external_id,
      type: channel.type,
      name: channel.name,
      metadata: channel.metadata,
    };
  }
 
  /** Members by external id, users created on first membership (FR-CHN-04).
   *
   * 200 and not 201: this is idempotent in a way creation is not — a member list
   * sent twice is the same list, and the per-user `status` says which ones were
   * already there. */
  @Post(":channelId/members")
  @HttpCode(200)
  async addMembers(
    @Param("channelId") channelId: string,
    @Body(new ZodValidationPipe(addMembersBodySchema)) body: AddMembersBody,
  ) {
    return { members: await this.channels.addMembers(channelId, body) };
  }
}
services/api/src/channels/channels.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 { ChannelsController } from "./channels.controller";
import { ChannelsService } from "./channels.service";
import type { RequestWithTenant } from "../messages/request-with-tenant";
 
// The messages module's shape, for the messages module's reasons: the repository
// is the plain 2.1 class, constructed per request with the tenant the middleware
// already resolved from a verified credential (ADR-15).
@Module({
  imports: [AuthModule],
  controllers: [ChannelsController],
  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 ?? ""),
    },
    ChannelsService,
  ],
})
export class ChannelsModule {}
services/api/src/app.module.ts
@@ -7,12 +7,13 @@ import { APP_FILTER } from "@nestjs/core";
 
 import { AuthModule } from "./auth/auth.module";
 import { AuthenticateMiddleware } from "./auth/authenticate.middleware";
 import { HealthController } from "./health.controller";
 import { InternalModule } from "./internal/internal.module";
 import { MessagesModule } from "./messages/messages.module";
+import { ChannelsModule } from "./channels/channels.module";
 import { ConsumerModule } from "./consumer/consumer.module";
 import { OutboxModule } from "./outbox/outbox.module";
 import { TenancyModule } from "./tenancy/tenancy.module";
 import { LOGGER, apiLogger } from "./logger";
 import { ProtocolErrorFilter } from "./protocol-error.filter";
 import { RequestContextMiddleware } from "./request-context.middleware";
@@ -22,12 +23,13 @@ import { RequestContextMiddleware } from "./request-context.middleware";
 // provider (APP_FILTER) instead of wiring it in main.ts means every entry
 // point — including tests — gets the same error envelope for free.
 @Module({
   imports: [
     AuthModule,
     MessagesModule,
+    ChannelsModule,
     InternalModule,
     TenancyModule,
     OutboxModule,
     ConsumerModule,
   ],
   controllers: [HealthController],

Ba function từng ổn khi làm fixture

flowchart LR
    call["addMember(channelId, userId)"]
    call --> before["TRƯỚC: boolean"]
    call --> after["SAU: AddMemberOutcome"]
    before --> b1["true — đã thêm"]
    before --> b2["false — channel không phải của bạn"]
    before --> b3["false — user không phải của bạn"]
    before --> b4["NÉM LỖI — bạn hỏi hai lần"]
    after --> a1["added"]
    after --> a2["not_found — channel không phải của bạn"]
    after --> a3["not_found — user không phải của bạn"]
    after --> a4["already_a_member"]
    b4 --> wire["unique violation → internal_error<br/>một cái 500 cho một request hợp lý"]
    a2 --> right["GỘP CÓ CHỦ Ý:<br/>FR-TEN-05 cần hai cái này y hệt nhau"]
    a3 --> right
    style b4 fill:#7f1d1d,color:#fff,stroke:#dc2626
    style wire fill:#7f1d1d,color:#fff,stroke:#dc2626
    style right fill:#1e3a8a,color:#fff,stroke:#3b82f6
Boolean cũ gộp ba outcome. Hai outcome phải gộp theo FR-TEN-05; outcome thứ ba từng biến unique violation thành 500 trên wire.

addMember, createChannel, createUser tồn tại từ 2.1 và đều plain insert. Đúng cho fixture kiểm soát input, không đủ cho endpoint. Primary key membership khiến request lặp raise unique violation, rồi ProtocolErrorFilter biến thành internal_error: thêm member đã có nhận 500.

Fix dùng ON CONFLICT, không read-before-insert vì hai concurrent first add đều có thể thấy absent rồi insert. Unique index enforce idempotency; fallback read chỉ giúp loser biết winner đã write gì.

Seam 2.8 ngắn đi ba function. Ba helper khác ở lại vì chưa có public bootstrap hoặc còn write unattributed row; reason thứ ba hết hạn khi sender chapter khiến mọi send có sender. Reason stale tệ hơn không reason vì reader sẽ tin nó. Việc chuyển seeding public cũng không đổi coverage do packages/e2e bị exclude.

services/api/src/db/repository.ts
@@ -563,16 +563,28 @@ export interface UserRow {
   display_name: string | null;
 }
 
 export interface ChannelRow {
   id: string;
   external_id: string;
+  /** The column has been `"public" | "private"` with a CHECK constraint since
+   * chapter 2.1. NOTHING READS IT: history and send scope by `environment_id`
+   * alone and there is no membership check anywhere, so FR-CHN-05's private
+   * guarantee is unimplemented. The public create endpoint accepts `public` only
+   * (FR-047) — this type stays as the column is, because rows
+   * seeded before that endpoint existed can still say `private`. */
   type: "public" | "private";
   name: string | null;
+  metadata: Record<string, unknown>;
 }
 
+/** What `addMember` did. `not_found` deliberately covers "the channel is not
+ * yours", "the user is not yours" and "neither exists" — the caller must not be
+ * able to tell those apart (FR-018, FR-TEN-05). */
+export type AddMemberOutcome = "added" | "already_a_member" | "not_found";
+
 export interface MessageRow {
   id: string;
   channel_id: string;
   seq: number;
   text: string | null;
   created_at: string;
@@ -619,19 +631,32 @@ export class Repository {
     private readonly db: Db,
     private readonly environmentId: string,
   ) {}
 
   async createUser(externalId: string, displayName?: string): Promise<UserRow> {
     const id = randomUUID();
-    await this.db.insert(users).values({
-      id,
-      environmentId: this.environmentId,
-      externalId,
-      displayName: displayName ?? null,
-    });
-    return { id, external_id: externalId, display_name: displayName ?? null };
+    const inserted = await this.db
+      .insert(users)
+      .values({
+        id,
+        environmentId: this.environmentId,
+        externalId,
+        displayName: displayName ?? null,
+      })
+      .onConflictDoNothing({ target: [users.environmentId, users.externalId] })
+      .returning({ id: users.id });
+
+    if (inserted.length > 0) {
+      return { id, external_id: externalId, display_name: displayName ?? null };
+    }
+    const existing = await this.getUserByExternalId(externalId);
+    if (existing === null) throw new Error(`user ${externalId} could not be created or read`);
+    // The DISPLAY NAME OF THE EXISTING ROW WINS. A second call is not an update:
+    // FR-CHN-04 asks for membership, and quietly renaming a user because someone
+    // re-sent a member list would be a write nobody asked for.
+    return existing;
   }
 
   async getUserByExternalId(externalId: string): Promise<UserRow | null> {
     const rows = await this.db
       .select({
         id: users.id,
@@ -645,35 +670,73 @@ export class Repository {
           eq(users.externalId, externalId),
         ),
       );
     return rows[0] ?? null;
   }
 
+  /** IDEMPOTENT ON THE CUSTOMER'S OWN IDENTIFIER (FR-017, FR-CHN-02).
+   *
+   * This was a plain insert until the endpoint over it, 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
+   * integration guide would have been a 500.
+   *
+   * `ON CONFLICT DO NOTHING RETURNING` and not a read-then-insert in the
+   * service: that races, and Principle II requires idempotency enforced at the
+   * storage layer by a unique index rather than in application memory. The
+   * fallback read is not the check — it is how the loser of a race learns what
+   * the winner wrote. */
   async createChannel(
     externalId: string,
     type: ChannelRow["type"],
     name?: string,
-  ): Promise<ChannelRow> {
+    metadata?: Record<string, unknown>,
+  ): Promise<ChannelRow & { created: boolean }> {
     const id = randomUUID();
-    await this.db.insert(channels).values({
-      id,
-      environmentId: this.environmentId,
-      externalId,
-      type,
-      name: name ?? null,
-    });
-    return { id, external_id: externalId, type, name: name ?? null };
+    const inserted = await this.db
+      .insert(channels)
+      .values({
+        id,
+        environmentId: this.environmentId,
+        externalId,
+        type,
+        name: name ?? null,
+        ...(metadata !== undefined ? { metadata } : {}),
+      })
+      .onConflictDoNothing({ target: [channels.environmentId, channels.externalId] })
+      .returning({ id: channels.id });
+
+    if (inserted.length > 0) {
+      return {
+        id,
+        external_id: externalId,
+        type,
+        name: name ?? null,
+        metadata: metadata ?? {},
+        created: true,
+      };
+    }
+    const existing = await this.getChannelByExternalId(externalId);
+    if (existing === null) {
+      // Nothing inserted and nothing there: the row belongs to another
+      // environment, which this repository is scoped away from. Callers see the
+      // same answer they would see for a channel that does not exist.
+      throw new Error(`channel ${externalId} could not be created or read`);
+    }
+    return { ...existing, created: false };
   }
 
   async getChannelByExternalId(externalId: string): Promise<ChannelRow | null> {
     const rows = await this.db
       .select({
         id: channels.id,
         external_id: channels.externalId,
         type: sql<ChannelRow["type"]>`${channels.type}`,
         name: channels.name,
+        metadata: sql<Record<string, unknown>>`${channels.metadata}`,
       })
       .from(channels)
       .where(
         and(
           eq(channels.environmentId, this.environmentId),
           eq(channels.externalId, externalId),
@@ -686,31 +749,71 @@ export class Repository {
     return this.db
       .select({
         id: channels.id,
         external_id: channels.externalId,
         type: sql<ChannelRow["type"]>`${channels.type}`,
         name: channels.name,
+        metadata: sql<Record<string, unknown>>`${channels.metadata}`,
       })
       .from(channels)
       .where(eq(channels.environmentId, this.environmentId))
       .orderBy(asc(channels.externalId));
   }
 
   /** Membership joins live in channel-land, so the tenant scope rides the
    * channel: the double-scoped SELECT below is what makes a foreign channel
    * id useless. INSERT ... SELECT is where the builder falls short — this
    * is the layer's one raw SQL island, permitted by ADR-16 and kept inside
-   * the wall like everything else. */
-  async addMember(channelId: string, userId: string): Promise<boolean> {
-    const result = await this.db.execute(
+   * the wall like everything else.
+   *
+   * THREE OUTCOMES, NOT A BOOLEAN (R14a). Until the endpoint over it, 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
+   * with them is wrong, and it cannot back an endpoint: `members`' primary key is
+   * `(channel_id, user_id)`, so before the `ON CONFLICT` below a repeat raised a
+   * unique violation that reached the wire as `internal_error`.
+   *
+   * `not_found` keeps the conflation the isolation property needs. The follow-up
+   * read distinguishes it from `already_a_member` — and it is a read, not a
+   * check-then-write: the insert already happened. */
+  async addMember(channelId: string, userId: string): Promise<AddMemberOutcome> {
+    const inserted = await this.db.execute(
       sql`INSERT INTO members (channel_id, user_id)
           SELECT c.id, u.id FROM channels c, users u
           WHERE c.id = ${channelId} AND c.environment_id = ${this.environmentId}
-            AND u.id = ${userId} AND u.environment_id = ${this.environmentId}`,
+            AND u.id = ${userId} AND u.environment_id = ${this.environmentId}
+          ON CONFLICT (channel_id, user_id) DO NOTHING`,
     );
-    return (result.rowCount ?? 0) > 0;
+    if ((inserted.rowCount ?? 0) > 0) return "added";
+
+    const existing = await this.db
+      .select({ userId: members.userId })
+      .from(members)
+      .innerJoin(channels, eq(channels.id, members.channelId))
+      .where(
+        and(
+          eq(members.channelId, channelId),
+          eq(members.userId, userId),
+          eq(channels.environmentId, this.environmentId),
+        ),
+      );
+    return existing.length > 0 ? "already_a_member" : "not_found";
+  }
+
+  /** How many members a channel holds, scoped — FR-CHN-07's ceiling is checked
+   * against this rather than against a count the caller supplies. */
+  async countMembers(channelId: string): Promise<number> {
+    const rows = await this.db
+      .select({ userId: members.userId })
+      .from(members)
+      .innerJoin(channels, eq(channels.id, members.channelId))
+      .where(
+        and(eq(members.channelId, channelId), eq(channels.environmentId, this.environmentId)),
+      );
+    return rows.length;
   }
 
   async listMembers(channelId: string): Promise<string[]> {
     const rows = await this.db
       .select({ user_id: members.userId })
       .from(members)
packages/e2e/src/harness.ts
@@ -48,13 +48,39 @@ const forwarded = (...names: string[]): Record<string, string> =>
   );
 
 /** DECISION (chapter 2.8): the suite seeds through the api's own repository
  * layer, imported from its build output. There is no admin API to create an
  * environment, a user or a channel yet — that is Part 3's tenancy work — and
  * inventing one for a test would be inventing product. The import is a
- * test-only seam with a named retirement, like 2.3's `listMessagesRaw`. */
+ * test-only seam with a named retirement, like 2.3's `listMessagesRaw`.
+ *
+ * REASSESSED WHEN THOSE ENDPOINTS ARRIVED (T063a), and two of the three now have an API.
+ * `POST /v1/channels` and `POST /v1/channels/:channelId/members` are public, and
+ * the members route creates a user on first membership — so `createChannel`,
+ * `createUser` and `addMember` could all come off this seam today. The list it
+ * still NEEDS is shorter than the list it uses:
+ *
+ *   still needed   createEnvironment   no admin API, and none is planned before
+ *                                      the dashboard
+ *                  createApiKey        the same, and for the same chapter
+ *                  sendMessage         only to write an UNATTRIBUTED row, which
+ *                                      is what `journey 4` needs a foreign tenant
+ *                                      to hold; the public send writes exactly
+ *                                      that, so this one is convenience rather
+ *                                      than necessity
+ *   no longer       createChannel      POST /v1/channels
+ *                  createUser         created on first membership
+ *                  addMember          POST /v1/channels/:channelId/members
+ *
+ * NOT MIGRATED HERE, and the reason is the one this chapter keeps running into:
+ * `packages/e2e` is excluded from the coverage run by name, so moving its seeding
+ * to public HTTP would prove nothing the branch figures can see, and it would
+ * rewrite four journeys in a chapter about isolation. `services/gateway/src/
+ * public-surface.itest.ts` makes the same claim where the coverage run does look,
+ * and `packages/outsider` makes the stronger one in a package that cannot import
+ * this file at all. The migration is the channel-endpoints chapter's, with the rest of FR-CHN. */
 interface Seeder {
   createEnvironment: (
     db: unknown,
     input: { name: string },
   ) => Promise<{ id: string }>;
   /** The suite needs a real credential now, and it mints one the

Field chưa ai từng set

Private channel bị từ chối bằng invalid_request, và test hỏi một điều platform chưa làm được: body.field === "type".

services/api/src/channels/channels.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 { createApiKey, createEnvironment, Repository } from "../db/repository";
import { CHANNEL_MEMBER_LIMIT } from "./channels.schema";
 
// THE TWO ENDPOINTS, END TO END (FR-016 to FR-019, FR-047, FR-048, SC-014).
//
// The gauntlet attacks these too, from the derived list. This suite is the other
// half: that they WORK, with the shapes an integrating developer is told to
// expect. A route that refuses every cross-tenant request and also refuses every
// legitimate one passes the gauntlet perfectly.
 
describe("the public channel surface", () => {
  let app: INestApplication;
  let url: string;
  let db: Db;
  let credential: string;
  let repo: Repository;
  let foreignChannelId: string;
  let foreignRepo: Repository;
 
  beforeAll(async () => {
    db = createDb(createPool());
    const env = await createEnvironment(db, { name: "channels-itest" });
    repo = new Repository(db, env.id);
    credential = (await createApiKey(db, { environmentId: env.id })).credential;
    const other = await createEnvironment(db, { name: "channels-itest-other" });
    foreignRepo = new Repository(db, other.id);
    foreignChannelId = (await foreignRepo.createChannel("theirs", "public")).id;
    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 create = (body: unknown, key = credential) =>
    fetch(`${url}/v1/channels`, {
      method: "POST",
      headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
      body: JSON.stringify(body),
    });
 
  const addMembers = (channelId: string, body: unknown, key = credential) =>
    fetch(`${url}/v1/channels/${channelId}/members`, {
      method: "POST",
      headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
      body: JSON.stringify(body),
    });
 
  describe("POST /v1/channels", () => {
    it("creates a channel and answers 201", async () => {
      const res = await create({ external_id: "created-201", type: "public", name: "Support" });
      expect(res.status).toBe(201);
      const body = (await res.json()) as Record<string, unknown>;
      expect(body).toMatchObject({
        external_id: "created-201",
        type: "public",
        name: "Support",
      });
      expect(typeof body["id"]).toBe("string");
    });
 
    it("answers the idempotent repeat with 200 and the same channel", async () => {
      const first = await create({ external_id: "repeat", type: "public" });
      expect(first.status).toBe(201);
      const second = await create({ external_id: "repeat", type: "public" });
      // 200 rather than 201, and FR-CHN-02's existing channel rather than a new
      // one. The status is the part a client can act on without reading the body.
      expect(second.status).toBe(200);
      expect(await second.json()).toEqual(await first.json());
    });
 
    it("refuses type private, naming the field (FR-047)", async () => {
      const res = await create({ external_id: "private-attempt", type: "private" });
      expect(res.status).toBe(400);
      const body = (await res.json()) as { code: string; message: string; field?: string };
      expect(body.code).toBe("invalid_request");
      // THE FIELD IS NAMED, and until this chapter no validation error in the api
      // named one — EIR-API-04 has carried `field` since chapter 1.3 and nothing
      // ever set it. A developer who tries `private` is told which key was
      // refused instead of reading `Invalid input: expected "public"` and
      // guessing.
      expect(body.field).toBe("type");
    });
 
    it("round-trips metadata and refuses it over 8 KB", async () => {
      const ok = await create({
        external_id: "with-metadata",
        type: "public",
        metadata: { team: "fleet", tier: 2 },
      });
      expect(ok.status).toBe(201);
      expect((await ok.json()) as Record<string, unknown>).toMatchObject({
        metadata: { team: "fleet", tier: 2 },
      });
 
      const tooBig = await create({
        external_id: "metadata-too-big",
        type: "public",
        metadata: { blob: "x".repeat(9 * 1024) },
      });
      expect(tooBig.status).toBe(400);
      expect(((await tooBig.json()) as { code: string }).code).toBe("invalid_request");
    });
 
    it("refuses an unknown field rather than ignoring it", async () => {
      const res = await create({ external_id: "typo", type: "public", externalId: "typo" });
      expect(res.status).toBe(400);
    });
 
    it("lets two tenants own the same external id, independently", async () => {
      const mine = await create({ external_id: "theirs", type: "public" });
      expect(mine.status).toBe(201);
      // `theirs` is the other environment's channel's external id, seeded above.
      expect(((await mine.json()) as { id: string }).id).not.toBe(foreignChannelId);
    });
  });
 
  describe("POST /v1/channels/:channelId/members", () => {
    let channelId: string;
 
    beforeAll(async () => {
      channelId = ((await (await create({ external_id: "members", type: "public" })).json()) as {
        id: string;
      }).id;
    });
 
    it("adds members and creates the users on first membership", async () => {
      const res = await addMembers(channelId, { user_ids: ["tuan", "mai"] });
      expect(res.status).toBe(200);
      const body = (await res.json()) as { members: { external_id: string; status: string }[] };
      expect(body.members.map((m) => m.external_id)).toEqual(["tuan", "mai"]);
      expect(body.members.every((m) => m.status === "added")).toBe(true);
      // The users did not exist a moment ago. FR-CHN-04: membership creates them.
      expect(await repo.getUserByExternalId("tuan")).not.toBeNull();
      expect((await repo.listMembers(channelId)).length).toBe(2);
    });
 
    it("says already_a_member on a repeat, and is not a 500 (T052)", async () => {
      const res = await addMembers(channelId, { user_ids: ["tuan"] });
      expect(res.status).toBe(200);
      const body = (await res.json()) as { members: { status: string }[] };
      expect(body.members[0]?.status).toBe("already_a_member");
      // Before this chapter `members`' primary key raised a unique violation here
      // and `ProtocolErrorFilter` rendered it as `internal_error` — a 500 for a
      // client doing something entirely reasonable.
      expect((await repo.listMembers(channelId)).length).toBe(2);
    });
 
    it("refuses more than 100 in one call (FR-CHN-06)", async () => {
      const res = await addMembers(channelId, {
        user_ids: Array.from({ length: 101 }, (_, i) => `bulk-${i}`),
      });
      expect(res.status).toBe(400);
    });
 
    it("answers a foreign channel id exactly as an absent one (FR-018)", async () => {
      const absent = "00000000-0000-4000-8000-000000000000";
      const foreign = await addMembers(foreignChannelId, { user_ids: ["intruder"] });
      const nowhere = await addMembers(absent, { user_ids: ["intruder"] });
      expect(foreign.status).toBe(nowhere.status);
      // COMPARED WHOLE. The envelope is `code`, `message` and `docs_url`, and all
      // three must match for a foreign channel to be indistinguishable from an absent
      // one. Nothing here is per-request yet, so nothing is excluded.
      expect(await foreign.json()).toEqual(
        await nowhere.json(),
      );
      // And the other tenant's channel gained nobody. Read through ITS OWN
      // repository — a repository scoped to the empty string is not a scope, it is
      // a query that fails on an invalid uuid, which is how this line read first.
      expect(await foreignRepo.listMembers(foreignChannelId)).toEqual([]);
    });
  });
 
  // ── T058a: the ceiling, read back rather than inferred ──────────────────────
  describe("the member ceiling (FR-CHN-07, FR-048)", () => {
    let fullChannelId: string;
 
    beforeAll(async () => {
      fullChannelId = ((await (
        await create({ external_id: "at-the-ceiling", type: "public" })
      ).json()) as { id: string }).id;
      // Seeded through the repository rather than through 10 calls to the
      // endpoint: the endpoint caps a single call at 100, and this test is about
      // the CHANNEL's limit, not about how many requests it took to reach it.
      for (let i = 0; i < CHANNEL_MEMBER_LIMIT; i++) {
        const user = await repo.createUser(`ceiling-${i}`);
        await repo.addMember(fullChannelId, user.id);
      }
    }, 180_000);
 
    it("refuses the one that would exceed it with 422 and the code", async () => {
      expect(await repo.countMembers(fullChannelId)).toBe(CHANNEL_MEMBER_LIMIT);
      const res = await addMembers(fullChannelId, { user_ids: ["one-too-many"] });
      expect(res.status).toBe(422);
      const body = (await res.json()) as { code: string; message: string };
      expect(body.code).toBe("channel_member_limit_exceeded");
      expect(body.message).toContain(String(CHANNEL_MEMBER_LIMIT));
      // AND THE CHANNEL IS UNCHANGED. A refusal that added the member anyway
      // would pass a status assertion and fail the requirement.
      expect(await repo.countMembers(fullChannelId)).toBe(CHANNEL_MEMBER_LIMIT);
    });
  });
});
{
  "code": "invalid_request",
  "message": "Invalid input: expected \"public\"",
  "docs_url": "…",
  "request_id": "…"
}

Assertion fail: response không có field. EIR-API-04 đã mang field này từ chương 1.3, errorFrameSchema cũng khai báo nó, nhưng ZodValidationPipe lấy issues[0].message rồi bỏ issues[0].path. Suốt hai mươi hai chương, mọi validation failure đều nói điều gì sai nhưng không nói sai ở đâu.

Member được thêm qua wire nhưng message không bao giờ tới

FR-020 yêu cầu member được thêm bằng public API phải nhận message của channel trên socket. Test cho requirement này fail, và failure đó là finding lớn nhất trong cả hai chương.

services/gateway/src/public-surface.itest.ts
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
 
import { createLogger, serve, type Logger } from "@relay/service-kit";
import { WebSocket } from "ws";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
 
import { createApiClient } from "./api-client.js";
import { startApi } from "./isolation-fixtures.js";
import { createFanout, type Fanout } from "./fanout.js";
import { attachSessions } from "./session.js";
import { docsUrl } from "@relay/protocol";
 
// THE EXIT CRITERION, REHEARSED IN THE LANE (FR-020, SC-015).
//
// A channel created over the public API, a member added over the public API, a
// message sent over the public API, and the socket delivering it to that member.
// The channel endpoints were built for exactly this path, and this is
// the test that the path joins up.
//
// NO REPOSITORY CALL FOR ANY OF IT, which is the whole point and is a narrower
// claim than it sounds. The environment and the API key still come through the
// build-output seam, because there is no public way to create either — that has
// been true since chapter 2.8 and is still true. Everything downstream of the
// credential is public HTTP: `POST /v1/channels`, `POST
// /v1/channels/:id/members`, `POST /auth/dev-token`, `POST
// /v1/channels/:id/messages`, and `ws://…/v1/ws`.
//
// `packages/outsider` will make the stronger version of this claim in Phase 10 —
// a package mechanically forbidden from importing workspace code at all. This one
// runs where the coverage lane can see it.
 
const silent: Logger = createLogger("gateway", () => {});
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = join(HERE, "..", "..", "..");
const require_ = createRequire(import.meta.url);
 
interface Seeder {
  createEnvironment: (db: unknown, input: { name: string }) => Promise<{ id: string }>;
  createApiKey: (db: unknown, input: { environmentId: string }) => Promise<{ credential: string }>;
}
 
 
/** THE PORT COMES FROM THE CHILD, and the band that used to be here is gone.
 *
 * This bound `4800 + random(200)` under a comment that named `session.itest.ts`'s
 * 4400-4600, `isolation.itest.ts`'s 4600-4800 and `limits.itest.ts`'s fixed 4124 — a
 * table maintained by whoever remembered to read it. Two of those three files do not
 * exist at this chapter, so the comment was describing a layout that had not been
 * built, which is the clearest possible demonstration that nothing checks it.
 *
 * `startApi` lives in `isolation-fixtures.ts`, which established the mechanism: the
 * api is started with `PORT=0` and the port is read from the `listening` line it
 * logs. There is no band to overlap and no health-check loop to pass vacuously —
 * a child that has logged its port is listening on it. */
async function startApiChild(): Promise<{ url: string; credential: string; stop: () => void }> {
  const dist = join(REPO, "services", "api", "dist");
  if (!existsSync(join(dist, "main.js"))) {
    throw new Error("the api is not built — run `pnpm build` before this lane");
  }
  const client = require_(join(dist, "db", "client.js")) as {
    createDb: (pool: unknown) => unknown;
    createPool: () => unknown;
  };
  const seeder = require_(join(dist, "db", "repository.js")) as Seeder;
  const db = client.createDb(client.createPool());
  const environment = await seeder.createEnvironment(db, {
    name: `public-surface-${randomUUID().slice(0, 8)}`,
  });
  const key = await seeder.createApiKey(db, { environmentId: environment.id });
 
  // Only the flags a module reads. `RELAY_NOTIFICATION_RELAY` was set here and is
  // read by nothing at this chapter — a variable set for its own sake teaches the
  // next reader that these names are incantations rather than switches.
  const { url, stop } = await startApi({
    RELAY_OUTBOX_RELAY: "off",
    RELAY_EVENT_CONSUMER: "off",
    RELAY_AUTH_KEY_PREFIX: `rlauth-public-${randomUUID().slice(0, 8)}`,
  });
  return { url, credential: key.credential, stop };
}
 
describe("a channel, a member and a message, all over the public API", () => {
  let api: { url: string; credential: string; stop: () => void };
  let server: Server;
  let wsUrl: string;
  let fanout: Fanout;
 
  const post = (path: string, body: unknown, auth: string) =>
    fetch(`${api.url}${path}`, {
      method: "POST",
      headers: { "content-type": "application/json", authorization: `Bearer ${auth}` },
      body: JSON.stringify(body),
    });
 
  beforeAll(async () => {
    api = await startApiChild();
    server = serve({
      service: "gateway",
      health: () => ({}),
      logger: silent,
      notFoundDocsUrl: docsUrl("not_found"),
    });
    // THE FAN-OUT IS NOT OPTIONAL FOR DELIVERY, which is easy to miss because a
    // gateway without one still connects, still authenticates and still acks a
    // send. `attachSessions` takes `fanout` as an option, and without it a
    // message reaches the api and stops: the sender gets `message.ack` and every
    // other socket — on this instance or any other — hears nothing. That is how
    // this file first read, and it looked like a membership bug.
    fanout = createFanout({ logger: silent });
    attachSessions({ server, api: createApiClient(api.url), logger: silent, fanout });
    await new Promise<void>((resolve) => server.listen(0, resolve));
    wsUrl = `ws://127.0.0.1:${(server.address() as AddressInfo).port}`;
  }, 90_000);
 
  afterAll(async () => {
    await new Promise<void>((resolve) => server?.close(() => resolve()));
    await fanout?.close();
    api?.stop();
  });
 
  /** Collect from construction. `connection.ack` is sent the instant the upgrade
   * completes, and awaiting `open` first loses it — twelve tests in
   * `isolation.itest.ts` timed out learning that. */
  function reader(url: string) {
    const socket = new WebSocket(url);
    const frames: { type: string; payload?: { text?: string } }[] = [];
    socket.on("message", (raw) =>
      frames.push(JSON.parse(raw.toString()) as { type: string; payload?: { text?: string } }),
    );
    socket.on("error", () => undefined);
    const opened = new Promise<void>((resolve, reject) => {
      socket.on("open", () => resolve());
      socket.on("close", (code) => reject(new Error(`closed ${code}`)));
      setTimeout(() => reject(new Error("socket never opened")), 10_000);
    });
    const waitForText = async (text: string, ms = 10_000) => {
      const deadline = Date.now() + ms;
      for (;;) {
        if (frames.some((f) => f.type === "message.created" && f.payload?.text === text)) return;
        if (Date.now() > deadline) {
          throw new Error(`never saw "${text}"; frames were ${frames.map((f) => f.type).join(", ")}`);
        }
        await new Promise((resolve) => setTimeout(resolve, 50));
      }
    };
    return { socket, frames, opened, waitForText };
  }
 
  /** A channel and two members, all over public HTTP. Returns the channel id. */
  async function seedOverTheWire(label: string, users: string[]): Promise<string> {
    const created = await post(
      "/v1/channels",
      { external_id: `${label}-${randomUUID().slice(0, 8)}`, type: "public" },
      api.credential,
    );
    expect(created.status).toBe(201);
    const channelId = ((await created.json()) as { id: string }).id;
    const members = await post(
      `/v1/channels/${channelId}/members`,
      { user_ids: users },
      api.credential,
    );
    expect(members.status).toBe(200);
    const body = (await members.json()) as { members: { status: string }[] };
    expect(body.members.every((m) => m.status === "added")).toBe(true);
    return channelId;
  }
 
  const mint = async (user: string): Promise<string> => {
    // 200, not 201: minting a token creates nothing that has a URL.
    const res = await post("/auth/dev-token", { user, ttl_seconds: 3600 }, api.credential);
    expect(res.status).toBe(200);
    return ((await res.json()) as { token: string }).token;
  };
 
  it("delivers a message between two members added over the wire", async () => {
    const channelId = await seedOverTheWire("live", ["tuan", "mai"]);
 
    const tuan = reader(`${wsUrl}/v1/ws?token=${await mint("tuan")}`);
    const mai = reader(`${wsUrl}/v1/ws?token=${await mint("mai")}`);
    await Promise.all([tuan.opened, mai.opened]);
 
    const text = `over the wire ${randomUUID().slice(0, 8)}`;
    tuan.socket.send(
      JSON.stringify({
        type: "message.send",
        payload: { idem_key: randomUUID(), channel: channelId, text },
      }),
    );
 
    // Mai hears it, and the ONLY reason she is in this channel is the
    // `POST /v1/channels/:id/members` call above. No repository, no fixture.
    await mai.waitForText(text);
    tuan.socket.close();
    mai.socket.close();
  }, 60_000);
 
  // 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 the outsider milestone's exit criterion is that an outsider integrates on
  // the documentation alone, and this is the sentence that documentation has to
  // contain.
  //
  // Two independent mechanisms stop it, and each one is enough on its own:
  //
  //   1. NOTHING IN THE API PUBLISHES TO THE FAN-OUT. `session.ts` publishes when
  //      a SOCKET sends; the api's send path writes the row and the outbox and
  //      stops. The event consumer's handler is `createRecorder` — it records, it
  //      does not deliver. So there is no live push.
  //   2. AN UNATTRIBUTED ROW IS NOT A FRAME. `POST /v1/channels/:id/messages`
  //      never passes a user — the public controller calls
  //      `messages.send(channelId, body)` with no `userId`, so every row it writes
  //      has `user_id` NULL. `backfill.controller.ts`'s `toFrame` drops those on
  //      purpose and says why: `messageSchema` requires `user`, and there is no
  //      truthful value to invent. So resume does not carry it either.
  //
  // The route that works is the socket, and the test above proves it. An
  // integrating developer who sends over REST and waits on a socket waits for
  // ever — so the guide has to say "send over the socket", not "send a message".
  //
  // FIXING IT IS A PRODUCT DECISION AND NOT THIS CHAPTER'S. Attributing a public
  // send to an end-user token would change what `user` means on the wire for every
  // existing caller (FR-MSG-13's territory), and a live fan-out from the api is a
  // new coupling between the api and Redis. Both are named in the chapter.
  it("does NOT deliver a REST-sent message, live or on resume", async () => {
    const channelId = await seedOverTheWire("rest", ["tuan"]);
    const token = await mint("tuan");
 
    const live = reader(`${wsUrl}/v1/ws?token=${token}`);
    await live.opened;
 
    const first = `first over rest ${randomUUID().slice(0, 8)}`;
    const second = `second over rest ${randomUUID().slice(0, 8)}`;
    for (const text of [first, second]) {
      const sent = await post(`/v1/channels/${channelId}/messages`, { text }, api.credential);
      expect(sent.status).toBe(201);
    }
 
    // Both rows exist and both are unattributed — this is the row shape the drop
    // is about, asserted rather than assumed.
    const history = (await (
      await fetch(`${api.url}/v1/channels/${channelId}/messages?limit=10`, {
        headers: { authorization: `Bearer ${api.credential}` },
      })
    ).json()) as { messages: { seq: number; user: string | null; text: string }[] };
    expect(history.messages.map((m) => m.text)).toEqual([second, first]);
    expect(history.messages.every((m) => m.user === null)).toBe(true);
 
    // No live delivery.
    await new Promise((resolve) => setTimeout(resolve, 1_500));
    expect(live.frames.filter((f) => f.type === "message.created")).toEqual([]);
    live.socket.close();
 
    // And none on resume either. The cursor IS accepted — `resume_ok` is true and
    // the channel is in the echoed cursor — so this is not a rejected resume
    // dressed as an empty one. The page came back and every row in it was
    // dropped for having no sender.
    const resumed = reader(`${wsUrl}/v1/ws?token=${token}&cursor=${channelId}:1`);
    await resumed.opened;
    await new Promise((resolve) => setTimeout(resolve, 1_500));
    const ack = resumed.frames.find((f) => f.type === "connection.ack") as
      | { payload: { cursor: Record<string, number>; resume_ok: boolean } }
      | undefined;
    expect(ack?.payload.resume_ok).toBe(true);
    expect(Object.keys(ack?.payload.cursor ?? {})).toContain(channelId);
    expect(resumed.frames.filter((f) => f.type === "message.created")).toEqual([]);
    resumed.socket.close();
  }, 60_000);
});

FR-020 giữ đúng với socket send. Suite assert cả hai nửa: socket send giữa hai member được thêm qua API sẽ được deliver; REST send chưa được deliver live hay khi resume. Fix là product decision: gán attribution cho public send sẽ đổi nghĩa user trên wire, còn live fan-out từ API tạo coupling mới cần argument theo ADR-05 và constitution III. Vì vậy finding được gọi tên và schedule thay vì lặng lẽ patch.

Infrastructure của lane chưa từng có chương

Phần trên là product code; phần còn lại là instrument. Các endpoint này là thứ đầu tiên trong Part 3 mà test suite có thể attack từ bên ngoài, cũng chính là lúc correctness của lane trở nên quan trọng.

Integration lane tích lũy một dependency không được nói ra suốt sáu chương: mọi suite chạy migration và dùng chung một database; nhiều operation không có tenant predicate. Outbox relay drain thứ cũ nhất mà không quan tâm environment nào đã plant nó. Khi table chỉ có row suite vừa write thì vẫn ổn; giờ thì không.

packages/test-harness/package.json
{
  "name": "@relay/test-harness",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "test:integration": "vitest run --config vitest.integration.config.mts"
  },
  "dependencies": {
    "pg": "^8.22.0"
  },
  "devDependencies": {
    "@types/pg": "^8.20.3"
  }
}

Dependency pg ở đây là lần đầu ngoài services/api/src/db; lint exemption của nó sẽ được giải thích sau. Harness cần hai raw connection chỉ khác connection-string option, điều createPool() không biểu diễn được.

Hai loại bait, và nhầm chúng với nhau chính là bug

flowchart TB
    add["thêm quota_notifications vào mảng trigger"]
    add --> bait["plant() phải để lại một sentinel row,<br/>không thì mệnh đề WHEN không bao giờ khớp"]
    bait --> claimable["delivered_at NULL<br/>= CÓ THỂ BỊ CLAIM"]
    bait --> settled["delivered_at đã có<br/>= không claim được"]
    claimable --> drain["createQuotaRelay claim các row chưa gửi<br/>TRÊN MỌI environment"]
    drain --> boom["13 test fail trong quotas.itest.ts<br/>và connections.itest.ts"]
    settled --> ok["guard canh bảng đó;<br/>không lượt drain nào với tới bait"]
    boom --> law["bait chỉ được claim được ở nơi<br/>việc drain nó là việc CỦA DATABASE"]
    style boom fill:#7f1d1d,color:#fff,stroke:#dc2626
    style law fill:#1e3a8a,color:#fff,stroke:#3b82f6
    style ok fill:#064e3b,color:#fff,stroke:#059669
Có mặt trong trigger array không có nghĩa là đang được canh: nếu table không có sentinel row thì WHEN clause không bao giờ match. Bait mà global drain có thể claim lại biến guard thành failure trong file không liên quan.

Guard là BEFORE UPDATE OR DELETE trigger trên mọi table có environment_id, từ chối statement chạm vào row thuộc registered sentinel. Mỗi sentinel là tập row của một test file, dùng UUID deterministic từ path để lời từ chối chỉ ra row của file nào bị lấy.

packages/test-harness/src/sentinel.sql (excerpt)
-- One trigger per table carrying environment_id, firing only for a sentinel's
-- rows. Not `outbox`: it has no environment_id because it is platform
-- bookkeeping, so its bait is protected by the reader mechanism only. A stated
-- gap rather than an oversight (data-model.md).
--
-- THIS ARRAY IS NOT A COUNT, AND THAT IS DELIBERATE. Every table that carries
-- `environment_id` joins it IN THE CHAPTER THAT CREATES THE TABLE, together with
-- the sentinel row that makes the trigger's WHEN clause match and the case in
-- `guard.itest.ts` that drives it. Naming a number here — "five tables", "nine
-- tables" — would be a fact about the chapter that wrote the number, and every
-- later chapter would have to remember to change it. Nothing checks a comment.

Điểm phân biệt quan trọng là row được plant để làm gì; phiên bản harness đầu tiên đã gộp hai vai trò này.

Guard bait là row được trigger bảo vệ. Mỗi table trong array phải có một row, vì WHEN kiểm tra __is_sentinel(OLD.environment_id) và không có row thì chẳng có gì để kiểm tra. Tên có trong array nhưng không có bait tạo một trigger không bao giờ match, trong khi report vẫn trông như đã được bảo vệ.

Drain bait là row mà global operation sẽ claim. Mỗi global operation đang tồn tại cần một loại bait. Ở chương này chỉ có một: BAIT_ROWS unpublished row trong outbox, gấp đôi batch lớn nhất của mọi product reader.

Exemption làm mất chính write mà nó tuyên bố cho phép

File thực hiện global operation hợp lệ mang exemption qua connection-string option để mọi connection trong pool đều có nó. Nhánh exempt ban đầu chỉ RETURN OLD.

IF current_setting('relay.allow_global', true) = 'on' THEN
  RETURN OLD;
END IF;

Đó là sai theo hướng nguy hiểm hơn fault cần bắt. Giá trị trả về từ BEFORE trigger quyết định write có xảy ra hay không. Với UPDATE, trả OLD thay update bằng write lại giá trị cũ; rowCount vẫn là 1 và không có exception. Triệu chứng xuất hiện ở xa nguyên nhân: exempt sweep cứ disable lại cùng row mỗi pass và không bao giờ cạn.

packages/test-harness/src/sentinel.sql (excerpt)
  IF current_setting('relay.allow_global', true) = 'on' THEN
    IF TG_OP = 'DELETE' THEN
      RETURN OLD;
    END IF;
    RETURN NEW;
  END IF;
expect(rows[0]?.metadata).toEqual(JSON.parse(mark));

Có tên trong array không đồng nghĩa đang được canh, và suite phải nói rõ điều đó

guard.itest.ts generate case từ chính array trong sentinel.sql, nên thêm table sẽ có case ngay và xóa table cũng xóa case. Chính đặc tính sau mới nguy hiểm và cần assertion riêng.

packages/test-harness/src/guard.itest.ts (excerpt)
  it("installs one trigger per name in sentinel.sql's array, and no more", async () => {
    // BOTH DIRECTIONS. A name in the array with no trigger means the DO block
    // failed silently; a trigger with no name means a stale install survived a
    // removal, and `DROP TRIGGER IF EXISTS` only covers names still in the array.
    const { rows } = await plain.query<{ tgname: string; relname: string }>(
      `SELECT t.tgname, c.relname FROM pg_trigger t
         JOIN pg_class c ON c.oid = t.tgrelid
        WHERE t.tgname LIKE '__sentinel_guard_%' AND NOT t.tgisinternal`,
    );
    expect(rows.map((r) => r.relname).sort()).toEqual([...GUARDED].sort());
  });

Xóa users khỏi array không làm case fail mà làm chúng biến mất: suite từ mười một test xuống bảy và vẫn xanh. Assertion trên là thứ duy nhất nhận ra, đồng thời bắt stale trigger còn sót vì DROP TRIGGER IF EXISTS chỉ chạy cho tên vẫn nằm trong array.

Instrument verify isolation. Ai verify instrument?

flowchart TB
    subgraph before["TRƯỚC — hai block, một tên rule"]
      b1["files: **/*.ts<br/>no-restricted-imports: pg, drizzle-orm, ioredis"]
      b2["files: **/*.itest.ts<br/>no-restricted-imports: the global drains"]
      b1 -. "block sau THAY THẾ" .-> b2
      b2 --> off["mọi integration test đều import được<br/>driver và query engine"]
    end
    subgraph after["SAU — ba block, ghép lại"]
      a1["**/*.itest.ts minus BOTH lists<br/>the UNION of both sets"]
      a2["DRIVER_EXEMPT_TESTS (8)<br/>the drain set only"]
      a3["DRAIN_EXEMPT_TESTS (6)<br/>the driver set only"]
    end
    off --> measured["npx eslint quotas/period.itest.ts → exit 0<br/>while it imports drizzle-orm"]
    style off fill:#7f1d1d,color:#fff,stroke:#dc2626
    style measured fill:#78350f,color:#fff,stroke:#d97706
Rule bắt file không được liệt kê nhưng import driver. Không gì bắt một file đã được liệt kê rồi ngừng import, nên list chỉ có thể phình ra và stale entry giữ exemption thường trực cho path mà chương sau có thể dùng.

Constitution I nói isolation nằm ở data access; chương trước biến nó thành lint rule: không pg, không drizzle-orm ngoài directory sở hữu chúng. Harness cần exempt ba file, và hình dạng exemption mới là quyết định cần giải thích.

eslint.config.mjs
@@ -13,14 +13,38 @@ export default tseslint.config(
     files: ["scripts/**/*.mjs"],
     languageOptions: { globals: globals.nodeBuiltin },
   },
   {
     // Isolation lives in data access, not in handlers (constitution I):
     // only the repository layer may touch the driver.
+    //
+    // AND THE LANE'S OWN INFRASTRUCTURE, NAMED FILE BY FILE. The harness opens raw
+    // connections deliberately: one carrying the guard's exemption and one without,
+    // which is the distinction its tests are about, and `createPool()` cannot express
+    // it. So these three are exempt — as PATHS, not as a `packages/test-harness/**`
+    // pattern, because a pattern would silently absorb the next file added there and
+    // that is the failure mode the guard itself exists to remove.
+    //
+    // The exemption is checked in both directions. This rule catches an unlisted
+    // file that imports the driver; nothing here can catch a LISTED file that stopped
+    // importing it, so the list can only grow and a stale entry holds a standing
+    // exemption forever. `driver-exempt.test.ts` reads this array and asserts each
+    // path exists and still imports a module the rule below restricts — with those
+    // module names read out of the rule rather than restated.
     files: ["**/*.ts"],
-    ignores: ["services/api/src/db/**"],
+    ignores: [
+      "services/api/src/db/**",
+      // DRIVER_EXEMPT — the lane's own infrastructure. Reasons, one per path:
+      //   global-setup.ts  installs the guard against a database vitest names
+      //   setup.ts         rewrites the connection string to carry the exemption
+      //   guard.itest.ts   holds one exempt client and one plain one, and the
+      //                    difference between them is the whole test
+      "packages/test-harness/src/global-setup.ts",
+      "packages/test-harness/src/setup.ts",
+      "packages/test-harness/src/guard.itest.ts",
+    ],
     rules: {
       "no-restricted-imports": [
         "error",
         {
           paths: [
             {
packages/test-harness/src/driver-exempt.test.ts (excerpt)
  it("still imports a restricted module, for every exempt path", () => {
    // THE STALE-ENTRY CHECK. A file that no longer touches the driver does not
    // need the exemption, and leaving it listed means the next edit to that file
    // may reach for `pg` and nothing will say so.
    const modules = restricted();
    for (const path of exemptPaths()) {
      const text = readFileSync(join(ROOT, path), "utf8");
      const uses = modules.filter(
        (m) => text.includes(`from "${m}"`) || text.includes(`from "${m}/`),
      );
      expect(uses, `${path} is exempt from the driver rule and imports none of ${modules.join(", ")}`)
        .not.toEqual([]);
    }
  });

Đây là ba path cụ thể, không phải pattern packages/test-harness/**: pattern sẽ âm thầm nuốt file kế tiếp, đúng failure mode guard sinh ra để loại bỏ.

Rule chỉ kiểm tra được một chiều. File chưa list mà import driver sẽ fail lớn; file đã list nhưng thôi import thì không fail ở đâu cả. Stale entry vì thế giữ một exemption thường trực trên path không còn cần nó, hoặc tệ hơn, trên path sau này được tạo lại vì lý do khác. Không cần thêm list thứ hai; list hiện tại phải khớp với tree. Restricted module names được đọc từ rule thay vì lặp lại. Case thứ tư giữ services/api/src/db/** là glob duy nhất trong ignores.

Bait tìm ra điều không phải mục đích nó được plant

Bật harness làm vỡ ba suite, nhưng không suite nào vỡ vì lý do bait được plant.

Assertion scope rộng hơn thứ nó test. Invariant 7 của outbox.itest.ts kiểm tra mọi message trong publisher.sent có id riêng, nhưng collection giờ chứa thêm hai trăm bait row với payload {} và không có id. Hai trăm row sụp thành một entry trong Set, tạo duplicate failure dù không có duplicate. Comment đã nói “event environment này tạo”; code lại thiếu predicate nằm ngay tám dòng dưới.

Loop bị bound bằng sai đơn vị. Hai relay drain “đến khi backlog hết” qua hai mươi pass với batchSize: 7: budget 140 row trong khi bait tăng table thêm 200 row mỗi test file. Không có constant đúng vì relay global, oldest-first. Loop phải dừng khi environment này sạch hoặc một pass không move gì.

Fix một instance chưa phải fix cả class. Test shared-durable của consumer.itest.ts tạo ba environment và build cả hai runtime không filter, cùng shape mà comment phía trên đã mô tả là fixed. Instance trước được fix vì từng fail; instance này chưa fail chỉ vì stream chưa vượt budget.

AssertionError: expected 251 to be 450

Port cấp thủ công cuối cùng của lane

Suite chạy public surface end-to-end spawn API child và chọn port trong band 4800–5000. Comment giải thích band để tránh child từ run cũ, nhưng cũng tuyên bố ba suite khác giữ ba band khác nhau.

services/gateway/src/public-surface.itest.ts (excerpt)
/** 4800-5000: `session.itest.ts` holds 4400-4600, `isolation.itest.ts` 4600-4800
 * and `limits.itest.ts` 4124. A random high port per run, for the reason
 * `session.itest.ts` records — a previous run's child still holding a fixed port
 * answers the health check from a DIFFERENT environment, and every token this run
 * minted is then refused by an api that has never heard of it. */
  const port = 4800 + Math.floor(Math.random() * 200);

Hai trong ba file được nêu chưa tồn tại ở chương này; limits.itest.ts tới tận movement VII, còn isolation.itest.ts không giữ band mà hỏi operating system. Comment đã mô tả một layout chưa từng được build. Đó là hình dạng bên trong của một table không gì kiểm tra.

services/gateway/src/isolation-fixtures.ts (excerpt)
export async function startApi(
  extra: Readonly<Record<string, string>> = {},
): Promise<{ url: string; stop: () => void }> {
  const dist = join(REPO, "services", "api", "dist");
  const child: ChildProcess = spawn("node", [join(dist, "main.js")], {
    env: { ...process.env, ...extra, PORT: "0" },
    stdio: ["ignore", "pipe", "pipe"],
  });

Mechanism đã có từ socket gauntlet nên đây là deletion cộng import, không phải design mới. Health-check loop cũng biến mất: child đã log port bind thì đang listen; poll /healthz sau đó không thêm thông tin, thậm chí có thể chạy một trăm probe fail rồi vẫn trả URL.

Constitution VI, được trả lời bằng một con số

vitest.coverage.config.mts
@@ -16,12 +16,20 @@ import swc from "unplugin-swc";
 // The SWC plugin is here for the same reason `services/api/vitest.config.mts`
 // has it: esbuild strips decorators without emitting metadata, and Nest's DI
 // would silently resolve nothing. It is harmless for the packages that use no
 // decorators.
 export default defineConfig({
   test: {
+    // Feature 030: the global-operation guard. `globalSetup` migrates and
+    // then installs the trigger once per lane; `setupFiles` sets the
+    // exemption for files on the harness's list and, where the lane carries
+    // bait, plants it per file. This lane gets exemption
+    // handling and NO bait: it holds no reader-shape fault, and planting
+    // would change its workload for no return (FR-022).
+    globalSetup: ["./packages/test-harness/src/global-setup.ts"],
+    setupFiles: ["./packages/test-harness/src/setup.ts"],
     include: [
       "packages/*/src/**/*.test.ts",
       "services/*/src/**/*.test.ts",
       "packages/*/src/**/*.itest.ts",
       "services/*/src/**/*.itest.ts",
     ],
@@ -45,12 +53,23 @@ export default defineConfig({
         "packages/e2e/**",
         // Entry points and framework wiring: reached by running the service,
         // not by asserting on it. Counting them measures how much of `main.ts`
         // a test happened to touch, which is not what "business logic" means.
         "**/main.ts",
         "**/*.module.ts",
+        // THE LANE'S OWN INFRASTRUCTURE IS NOT BUSINESS LOGIC. `include` is
+        // `packages/*/src/**`, so the harness arrived inside the measurement the
+        // moment it became a package. Its files run on every integration suite and
+        // would score near the top, raising the workspace figure while saying
+        // nothing about the product — the same dilution `**/*.module.ts` is
+        // excluded for.
+        //
+        // Excluded as a directory rather than file by file, deliberately: unlike
+        // the driver exemption, absorbing the next file added here is the CORRECT
+        // behaviour, because the next file added here is also not business logic.
+        "packages/test-harness/**",
       ],
       thresholds: {
         // Constitution VI, first clause: 70% of business logic. Set to what the
         // constitution says, not to what the code achieves — a threshold tuned
         // down to pass measures nothing. Currently met with room to spare
         // (86.55% statements, 78.07% branches at the time of writing).
@@ -69,16 +88,36 @@ export default defineConfig({
         // 100% the constitution asks for, because a threshold nothing can pass
         // makes CI permanently red and teaches everyone to ignore it.
         //
         // The gap is recorded in specs/024-coverage-and-ci/notes.md with the
         // uncovered branches named. Raising these to 100 is the work; this
         // feature is the instrument that made the number sayable at all.
+        // THIS CHAPTER LOWERED `lines` 98 -> 97, AND THE REASON IS NOT A
+        // REGRESSION. Silencing the relays lane-wide removed coverage that came
+        // from a background sweep nobody asserted on: two hundred bait rows moving
+        // through `claimAndPublish` while every other suite ran. A number that
+        // depended on an unasserted loop racing the tests was never a measurement
+        // of this file, and the honest figure is the lower one.
+        //
+        // The three uncovered lines are named rather than chased, because each is a
+        // throw for a state the surrounding code says cannot arise:
+        //
+        //   118   no such environment, in a mint whose caller already resolved it
+        //   724   a channel neither inserted nor readable — the row is another
+        //         environment's, and the caller sees the not-found answer anyway
+        //   1010  an idempotency key that conflicted while its message is missing
+        //
+        // Reaching any of them from a test means corrupting the database first, and
+        // a test that does that is asserting on the corruption rather than on the
+        // guard. Measured at 97.74; pinned at 97, one point below, for the run-to-
+        // run swing this provider has (a function of forty on `session.ts` moved
+        // 87.80 -> 85.36 on identical code).
         "services/api/src/db/repository.ts": {
           branches: 85,
           functions: 100,
-          lines: 98,
+          lines: 97,
           statements: 95,
         },
         // THE DEDUPLICATION CHAPTER RAISED THIS, 93 -> 95. The chapter added two pure functions
         // to this file — the live-path suppression predicate and the scoping that
         // bounds the marks — and both are fully covered.
         //

repository.ts đạt 254/280 branch — 90.71%, từ 241/266 (90.60%) lúc bắt đầu. Trước đó nó rơi xuống 90.43% vì sáu operation mới thêm ba uncovered arm. Một arm bị xóa: addMember dùng rowCount ?? 0 dù driver không bao giờ trả null cho INSERT. Hai arm còn lại được gọi tên: các throw “could not be created or read” của loser sau ON CONFLICT, chỉ tới được nếu winner row bị xóa giữa hai statement trong cùng call.

Năm file mới có pin; ba file đạt 100% chỉ sau khi tách những arm mà suite đang PASS không thể reach—cùng argument của isolation harness về chính suite của nó, thấp hơn một layer.

Instrument tìm ra endpoint trước

Việc register module báo cho suite rằng có surface mới để attack. Không ai edit list.

the build that added the module
gauntlet targets: 11 derived, 9 attacked... {
  "unclassified": [
    "POST /v1/channels",
    "POST /v1/channels/:channelId/members"
  ]
}

Chín target thành mười một và suite gọi tên cả hai. Đây chính là failure derivation được thiết kế để tạo ra, và ordering quan trọng hơn fix: classification đổi để đáp lại derivation; derivation không bao giờ đổi để chiều classification. Route biến mất khỏi list nhưng vẫn ở router sẽ bị cùng test báo ra.

services/api/src/isolation/targets.ts
@@ -100,12 +100,27 @@ export const CLASSIFICATIONS: readonly Classification[] = [
     method: "GET",
     path: "/v1/channels/:channelId/messages",
     accepts: "either",
     shape: "read",
   },
 
+  // ── the two routes this chapter adds, and the ORDER MATTERS ────────────────────
+  //
+  // The derivation found them before this list did. `targets.itest.ts` went from 9
+  // targets to 11 and named both as unclassified, on the build that registered the
+  // module and before anything here mentioned them. That is the failure the derivation
+  // exists to produce, and the classification is what changed in answer to it — never
+  // the derivation.
+  { method: "POST", path: "/v1/channels", accepts: "application", shape: "write" },
+  {
+    method: "POST",
+    path: "/v1/channels/:channelId/members",
+    accepts: "application",
+    shape: "write",
+  },
+
   // ── the internal surface: an end-user token, so a FOREIGN CREDENTIAL is the attack
   { method: "POST", path: "/internal/messages", accepts: "user", shape: "write" },
   { method: "POST", path: "/internal/backfill", accepts: "user", shape: "write" },
   {
     // NOT A `write`, AND THE DIFFERENCE IS THE WHOLE POINT OF HAVING SHAPES. This route
     // takes no body and no path parameter: there is no identifier to forge, so a
services/api/src/isolation/gauntlet.itest.ts
@@ -178,12 +178,61 @@ describe("the isolation gauntlet", () => {
     // Whatever it answers, nothing of the victim's may appear in it.
     expect(serialised).not.toContain(t.victim.userId);
     expect(serialised).not.toContain(t.victim.channelId);
     expect(serialised).not.toContain(t.victim.environmentId);
   });
 
+  // ── the two routes this chapter added ──────────────────────────────────────────
+  //
+  // A chapter that adds an endpoint attacks it in the same chapter. The derivation
+  // found these before the classification did: `targets.itest.ts` went from 9 targets
+  // to 11 and failed naming both as unclassified.
+  it("POST /v1/channels/:channelId/members — refuses, and adds nobody", async () => {
+    attacked.add("POST /v1/channels/:channelId/members");
+    const verdict = await writeAttack(
+      url,
+      t.attacker.credential,
+      {
+        method: "POST",
+        path: `/v1/channels/${t.victim.channelId}/members`,
+        body: { user_ids: ["intruder"] },
+      },
+      {
+        method: "POST",
+        path: `/v1/channels/${ABSENT_UUID}/members`,
+        body: { user_ids: ["intruder"] },
+      },
+      () => t.victim.repo.listMembers(t.victim.channelId),
+    );
+    expect(verdict.differences, verdict.differences.join("; ")).toEqual([]);
+    expect(verdict.stateChanged, "the victim gained a member").toBe(false);
+  });
+
+  it("POST /v1/channels — the other tenant's external_id is not interference", async () => {
+    attacked.add("POST /v1/channels");
+    // THIS ROUTE CARRIES NO IDENTIFIER TO FORGE, so the pair is not foreign-versus-
+    // absent. What a caller can present is the other tenant's own `external_id`, and
+    // the property is NON-INTERFERENCE rather than indistinguishability: the call must
+    // SUCCEED. Two tenants may use the same customer-supplied id — that is the whole
+    // point of scoping it per environment — and the victim's channel must be untouched.
+    const before = await t.victim.repo.getChannelByExternalId(t.victim.channelExternalId);
+    const res = await fetch(`${url}/v1/channels`, {
+      method: "POST",
+      headers: {
+        authorization: `Bearer ${t.attacker.credential}`,
+        "content-type": "application/json",
+      },
+      body: JSON.stringify({ external_id: t.victim.channelExternalId, type: "public" }),
+    });
+    expect([200, 201]).toContain(res.status);
+    const created = (await res.json()) as { id?: string };
+    expect(created.id).not.toBe(t.victim.channelId);
+    const after = await t.victim.repo.getChannelByExternalId(t.victim.channelExternalId);
+    expect(after?.id).toBe(before?.id);
+  });
+
   // ── and the suite accounts for itself ───────────────────────────────────────────
   it("ran an attack for every route the classification says to attack", () => {
     const shouldAttack = CLASSIFICATIONS.filter((c) => c.shape !== "exempt").map(targetKey);
     const missing = shouldAttack.filter((k) => !attacked.has(k));
     // A classification saying `write` with no attack written for it is the same hole
     // as a route with no classification, one level up. Named, because the useful half

Cả hai endpoint được attack ngay trong chương thêm chúng, nhưng một endpoint cần câu hỏi khác.

Mọi validation error đều gọi tên field

Endpoint mới có body cần từ chối, nên đây là chương mà 400 thôi không còn là cái nhún vai. Pipe báo key nào fail và filter mang nó ra ngoài.

services/api/src/messages/zod-validation.pipe.ts
@@ -9,13 +9,31 @@ import type { ZodType } from "zod";
 export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
   constructor(private readonly schema: ZodType<T>) {}
 
   transform(value: unknown): T {
     const result = this.schema.safeParse(value);
     if (!result.success) {
-      throw new BadRequestException(
-        result.error.issues[0]?.message ?? "invalid body",
-      );
+      const issue = result.error.issues[0];
+      // WHICH FIELD, and the public channel endpoints are 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.
+      // Every validation failure in twenty-two chapters said `Invalid input:
+      // expected "public"` and left the caller to work out which key that was
+      // about. This is the same habit as `request_id`, which was declared in 1.3
+      // and first sent by the rate limiter: a field in the contract that the code never filled.
+      //
+      // Named here rather than in the filter because only the pipe knows the
+      // path. Zod's `path` is an array — `["metadata", "blob"]` — and it joins
+      // with dots, which is what a developer reading their own request body sees.
+      // An empty path means the whole body failed (a non-object, say), and then
+      // there is no field to name and the key is omitted rather than sent empty.
+      const path = issue?.path.join(".");
+      throw new BadRequestException({
+        code: "invalid_request",
+        message: issue?.message ?? "invalid body",
+        ...(path !== undefined && path.length > 0 ? { field: path } : {}),
+      });
     }
     return result.data;
   }
 }
services/api/src/protocol-error.filter.ts
@@ -55,12 +55,21 @@ export class ProtocolErrorFilter implements ExceptionFilter {
           ? "unauthorized"
           : status === 403
             ? "forbidden"
             : status === 404
               ? "not_found"
               : "internal_error";
+    // `field` travels the way `code` does — the thrower names it, because only the
+    // thrower knows it. Omitted rather than null when there is nothing to name: a key
+    // that is always present and usually empty teaches a client to ignore it.
+    const field =
+      typeof response === "object" &&
+      response !== null &&
+      typeof (response as { field?: unknown }).field === "string"
+        ? (response as { field: string }).field
+        : null;
     const code: ErrorCode =
       named !== null && named in ERROR_CODES ? (named as ErrorCode) : ladder;
     const message =
       exception instanceof HttpException
         ? exception.message
         : "unexpected internal error";
@@ -68,10 +77,11 @@ export class ProtocolErrorFilter implements ExceptionFilter {
     res.setHeader("content-type", "application/json");
     res.end(
       JSON.stringify({
         code,
         message,
         docs_url: docsUrl(code),
+        ...(field !== null ? { field } : {}),
       }),
     );
   }
 }
packages/protocol/src/codes.ts
@@ -43,12 +43,26 @@ export const ERROR_CODES = {
     "the request body, query or path failed validation; `field` names the first offending key",
   forbidden: "the credential is valid and is not permitted to do this",
   not_found:
     "no such resource for this tenant — and DELIBERATELY the same answer as for a resource in another tenant (FR-TEN-05)",
   internal_error:
     "the platform failed in a way it did not anticipate; the request_id is what a support ticket needs",
+
+  // FR-CHN-07's ceiling: a channel holds at most 1,000 members and an add that would
+  // cross it is refused with 422 and this code.
+  //
+  // The SRS names this code in its own worked example for EIR-API-04, which is why it is
+  // spelled this way rather than `member_limit_exceeded` — the document got there first
+  // and an integrating developer will have read it.
+  //
+  // NOT `quota_exceeded`. That is a monthly, billable, resets-on-a-date refusal whose
+  // message promises a resume date; this is a structural limit on one channel that no
+  // amount of waiting changes. Same status, different fact, and a client that retries on
+  // the wrong one waits for ever.
+  channel_member_limit_exceeded:
+    "this channel already holds the maximum number of members; remove one before adding another",
 } as const;
 
 export type ErrorCode = keyof typeof ERROR_CODES;
 
 /** Where the published error reference lives, and THE ONE PLACE THE URL IS BUILT
  * (FR-027, constitution V).

field đi giống code: thrower đặt tên vì chỉ nó biết. Khi không có gì để gọi tên, key được omit thay vì null; key luôn hiện diện nhưng thường rỗng chỉ dạy client bỏ qua nó.

Thêm một code nữa, và tên của nó không phải lựa chọn hiển nhiên.

services/api/src/db/repository.itest.ts
@@ -57,25 +57,44 @@ describe("tenant isolation is structural (FR-TEN-05)", () => {
     );
   });
 
   it("membership writes with foreign ids affect zero rows", async () => {
     const user = await repoA.getUserByExternalId("tuan");
     const channel = await repoA.getChannelByExternalId("support");
-    expect(await repoA.addMember(channel!.id, user!.id)).toBe(true);
-    // B holds A's REAL ids — and still cannot write or read through them.
-    expect(await repoB.addMember(channel!.id, user!.id)).toBe(false);
+    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 this chapter 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
+    // answer is `not_found`, which is also what B gets for ids that exist
+    // nowhere: three refusals, one word, on purpose.
+    expect(await repoB.addMember(channel!.id, user!.id)).toBe("not_found");
     expect(await repoB.listMembers(channel!.id)).toEqual([]);
     expect(await repoB.channelsForUser(user!.id)).toEqual([]);
     expect(await repoA.listMembers(channel!.id)).toEqual([user!.id]);
   });
 
   it("uniqueness is per-tenant (DR-02): both tenants may own the same external_id", async () => {
-    await expect(
-      repoB.createUser("tuan", "A different Tuan"),
-    ).resolves.toBeTruthy();
-    await expect(repoA.createUser("tuan", "Duplicate in A")).rejects.toThrow();
+    // B's own Tuan is a DIFFERENT row. That is the per-tenant half.
+    const inB = await repoB.createUser("tuan", "A different Tuan");
+    const inA = await repoA.getUserByExternalId("tuan");
+    expect(inB.id).not.toBe(inA!.id);
+
+    // THE OBSERVATION CHANGED WITH THE PUBLIC ENDPOINT AND THE PROPERTY DID NOT. This
+    // used to assert that a repeat within one tenant REJECTS, which observed the
+    // unique index by watching it raise. `createUser` is now idempotent — the
+    // members endpoint creates a user on first membership, so a repeated request
+    // would otherwise have answered `internal_error` — and the index is what
+    // makes that work rather than something that got removed. So the assertion
+    // is now that a repeat returns THE SAME ROW: still one user per tenant per
+    // external id, observed through the outcome instead of through an exception.
+    const again = await repoA.createUser("tuan", "Duplicate in A");
+    expect(again.id).toBe(inA!.id);
+    // And the existing display name wins: a second call is not an update.
+    expect(again.display_name).toBe(inA!.display_name);
   });
 });
 
 describe("sequence assignment is serialised per channel (ADR-03)", () => {
   it("two concurrent sends never interleave", async () => {
     const channel = await repoA.createChannel("ordering", "public");

Toàn bộ chương

Các excerpt trên là phần đáng tranh luận. Dưới đây là file đầy đủ khi chúng mới, và diff khi chúng đã tồn tại.

Chúng đầy đủ vì một lý do chương này lần đầu có thể cung cấp: harness từng ship như platform feature mà không có chương riêng, nên amendment sống trong fences/post-series.md, apply sau mọi chương. Mười sáu file vì thế không thuộc chain nào. Giờ harness đã thuộc một chương, chain có thể mang nó.

Các suite không nằm ở đây; từng suite được gọi tên cùng điều nó giữ:

Sự vắng mặt của chúng là gap được tuyên bố, không phải oversight: file không fence nào gọi tên là file chain không thể verify—đúng subject của phần trên.

Package

packages/test-harness/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "include": ["src"]
}
packages/test-harness/src/index.ts
// The integration lane's own infrastructure (feature 030).
//
// WHY THIS IS A PACKAGE and not a directory inside `services/api`: after the
// guard's exemption had to reach every lane, four vitest configs load it — the
// api's, the gateway's, the e2e package's and the root coverage config's, and the
// list grows with every service that gets an integration lane. A gateway test lane
// reaching into another service's `src/` is a worse precedent than a shared
// package, even in test code, and `packages/` is where this repository already
// keeps shared things (research R16).
//
// REFERENCED BY PATH, NOT BY NAME, which is why this package publishes no
// `exports` map. By name would mean adding `@relay/test-harness` to four
// `devDependencies` plus the repository root, which has no workspace
// dependencies at all. `setupFiles` and `globalSetup` take paths, and `pg` still
// resolves from this package's own `node_modules` because Node resolves from the
// importing file's location rather than from whichever config loaded it
// (research R20).
//
// Nothing here is product code. It exists only in test databases, is created by
// the lane, and is excluded from coverage for the same reason `main.ts` and
// `*.module.ts` are.
 
export { SENTINEL, sentinelFor, plant, type Sentinel } from "./sentinel.js";
export { EXEMPT_FILES, isExempt } from "./exempt.js";
export { DEFAULT_DATABASE_URL, databaseUrl } from "./db-url.js";

Guard

packages/test-harness/src/sentinel.sql
-- The global-operation guard (feature 030).
--
-- WHY THIS IS PL/pgSQL, in a repository committed to one language. The guard has
-- to raise inside the transaction that performed the mutation: that is the
-- property which makes attribution exact under parallel test execution, and no
-- TypeScript running in the test process has it. A before/after comparison cannot
-- attribute — legitimate global sweeps run on every lane pass, so it either fires
-- constantly or blames a bystander — and it cannot see a raw UPDATE at all.
--
-- Constitution VII says "Introducing a second language requires a superseding ADR
-- with profiling evidence". There is no ADR, because there is nothing for one to
-- supersede: VII's clause reads "One language (TypeScript/Node.js) across
-- services, SDK, and dashboard", its subject is the language services are
-- implemented in, and its stated harm is drift between server and SDK. This is
-- neither a service nor shipped. The repository already holds nine hand-reviewed
-- .sql migrations the constitution endorses by name.
--
-- The honest wrinkle: those nine are DECLARATIVE and this one is PROCEDURAL. A
-- RAISE EXCEPTION is closer to program logic than an ALTER TABLE is. That
-- difference is real; it is not the difference VII legislates. The long form is in
-- docs/07-tutorial-plan.md, under "Work that publishes no chapter".
--
-- THIS FILE IS NEVER A MIGRATION. It is applied by the lane's global setup against
-- a test database. A product migration carrying it would ship a trigger whose only
-- purpose is to reject the api's own legitimate sweeps (constitution IV).
 
-- The registry the per-file sentinel needs. With one shared sentinel the trigger
-- could compare against a literal id; with one per test file it tests membership.
-- `owner` is the file path, and it is what lets a refusal say whose rows were taken.
CREATE TABLE IF NOT EXISTS __sentinel_environments (
  environment_id uuid PRIMARY KEY,
  owner          text NOT NULL
);
 
-- Membership as a FUNCTION, not a subquery. A trigger's WHEN condition may not
-- contain a subquery — Postgres rejects `CREATE TRIGGER` outright with "cannot use
-- subquery in trigger WHEN condition" — but it may call a function. STABLE so the
-- planner can cache it within a statement, which matters because this runs for
-- every UPDATE and DELETE on five tables across the whole lane (research R37).
CREATE OR REPLACE FUNCTION __is_sentinel(env uuid) RETURNS boolean
LANGUAGE sql STABLE AS $$
  SELECT EXISTS (SELECT 1 FROM __sentinel_environments WHERE environment_id = env)
$$;
 
CREATE OR REPLACE FUNCTION __sentinel_guard() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
  who text;
BEGIN
  -- Refusal is the default: current_setting(..., true) returns NULL in a
  -- connection that never carried the option, and NULL is not 'on'.
  --
  -- WHICH ROW A BEFORE TRIGGER RETURNS DECIDES WHETHER THE WRITE HAPPENS, and
  -- getting it wrong here is worse than the fault this file exists to catch. A
  -- BEFORE UPDATE trigger returning OLD does not allow the update — it replaces it
  -- with a write of the old values, silently, with rowCount 1 and no error. So the
  -- exemption has to hand back NEW on an UPDATE and OLD on a DELETE, which is the
  -- only row each of them has.
  --
  -- The symptom is nowhere near the cause: an exempt sweep disables the same rows
  -- on every pass and never runs out, because every write it made was reverted by
  -- the trigger that claimed to permit it. `guard.itest.ts` asserts this by reading
  -- the value back, since a row count cannot tell the two apart.
  IF current_setting('relay.allow_global', true) = 'on' THEN
    IF TG_OP = 'DELETE' THEN
      RETURN OLD;
    END IF;
    RETURN NEW;
  END IF;
 
  SELECT owner INTO who FROM __sentinel_environments
   WHERE environment_id = OLD.environment_id;
 
  -- The message is a contract — see contracts/guard.md. Prefix, schema, table,
  -- row id, and the diagnosis. NO SUGGESTED FIX: the right scoped alternative
  -- depends on what the test meant, and a guess printed as advice is worse than
  -- silence. That guidance belongs in the lint rule, which knows the call site.
  RAISE EXCEPTION
    'global-operation guard: this statement modified sentinel row %.% (id %), which belongs to no test%',
    TG_TABLE_SCHEMA, TG_TABLE_NAME, OLD.id,
    COALESCE(' — the bait planted by ' || who, '');
END $$;
 
-- One trigger per table carrying environment_id, firing only for a sentinel's
-- rows. Not `outbox`: it has no environment_id because it is platform
-- bookkeeping, so its bait is protected by the reader mechanism only. A stated
-- gap rather than an oversight (data-model.md).
--
-- THIS ARRAY IS NOT A COUNT, AND THAT IS DELIBERATE. Every table that carries
-- `environment_id` joins it IN THE CHAPTER THAT CREATES THE TABLE, together with
-- the sentinel row that makes the trigger's WHEN clause match and the case in
-- `guard.itest.ts` that drives it. Naming a number here — "five tables", "nine
-- tables" — would be a fact about the chapter that wrote the number, and every
-- later chapter would have to remember to change it. Nothing checks a comment.
--
-- AND BEING IN THIS ARRAY IS NOT BEING WATCHED. The trigger fires only when
-- `__is_sentinel(OLD.environment_id)` is true, which needs a sentinel row sitting
-- in the table. A name added here without bait planted in `sentinel.ts` installs
-- a trigger that can never match, and it reads exactly like protection. That is
-- why the three go together: the name, the bait, and the case that turns red when
-- the name is removed.
--
-- `members` IS THE COUNTER-EXAMPLE AND BELONGS NOWHERE NEAR THIS LIST. It has no
-- `environment_id` — the catalogue classifies it `hop`, reaching the environment
-- through `channels` — so `OLD.environment_id` would not compile in the WHEN
-- clause. The rule is the column, not the intuition that a table "feels" tenant.
DO $$
DECLARE
  t text;
BEGIN
  FOREACH t IN ARRAY ARRAY[
    -- This chapter's two. Both carry `environment_id`, both hold bait planted by
    -- `sentinelFor`, and `guard.itest.ts` drives each one.
    'channels',
    'users'
  ] LOOP
    EXECUTE format('DROP TRIGGER IF EXISTS __sentinel_guard_%1$s ON %1$I', t);
    EXECUTE format(
      'CREATE TRIGGER __sentinel_guard_%1$s
         BEFORE UPDATE OR DELETE ON %1$I FOR EACH ROW
         WHEN (__is_sentinel(OLD.environment_id))
         EXECUTE FUNCTION __sentinel_guard()', t);
  END LOOP;
END $$;
packages/test-harness/src/sentinel.ts
import { createHash } from "node:crypto";
 
// The sentinel: rows that exist only to be taken (feature 030).
//
// ONE PER TEST FILE, not one shared. Files execute in parallel — no integration
// config overrides `fileParallelism` — so a shared sentinel would mean one file's
// planting deleting rows another file is mid-test against. Per-file planting and a
// shared sentinel are incompatible, and the plan had both until research R12
// (FR-023).
//
// The ids are derived from the file's path, so they are stable across runs and
// unique across files, and a developer reading a failure can tell which file owns
// the rows that were taken.
 
/** How much bait to plant, per kind.
 *
 * DOUBLE THE LARGEST DEFAULT BATCH in the codebase, so a caller who omits a bound
 * reaches bait before reaching its own rows — which is the whole mechanism.
 *
 * The number is declared here rather than imported, because none of the product's
 * three `BATCH_SIZE` constants is exported and importing `outbox/relay.ts` would
 * drag its whole dependency graph into a setup file. That trade is only acceptable
 * because `bait-size.test.ts` reads those three files and fails if any of them
 * rises past this bound: a literal that goes stale silently is the thing research
 * R7 warned about, and a literal guarded by a test is not silent (FR-002). */
export const MAX_PRODUCT_BATCH = 100;
export const BAIT_ROWS = MAX_PRODUCT_BATCH * 2;
 
/** The product files whose batch defaults this bound has to dominate. Read by
 * `bait-size.test.ts`; listed here so the two cannot drift apart. */
export const BATCH_SOURCES = [
  "services/api/src/outbox/relay.ts",
] as const;
 
// NOT `db/repository.ts`, AND THE REASON IS THE INTERESTING ONE. Its claim-and-
// publish takes `limit: number` with no default, and so does its message page — a
// caller cannot omit the bound, so there is no default for the bait to dominate.
// A required parameter beats a defaulted one here for the same reason a design in
// which a case cannot arise beats a branch that handles it: the branch is the thing
// that rots. Add a default to either and this list has to grow, which
// `bait-size.test.ts` will not tell you — it checks that every file NAMED here has
// a default, not that every file WITH one is named. That direction needs a reader.
 
export interface Sentinel {
  /** The test file that owns these rows, as a repository-relative path. */
  owner: string;
  organisationId: string;
  humanId: string;
  applicationId: string;
  environmentId: string;
  /** GUARD BAIT, not drain bait. A trigger sits on `users` and `channels`, and its
   * WHEN clause tests `__is_sentinel(OLD.environment_id)` — which needs a row IN the
   * table to have anything to test. Without these two the triggers install, report
   * as installed, and can never match. See `sentinel.sql`. */
  userId: string;
  channelId: string;
  /** `__sentinel__:<owner>`, on every row, so a failure says whose it is. */
  name: string;
}
 
/** A v4-shaped uuid derived from a string. Deterministic, so a file's sentinel is
 * the same on every run and the delete-then-insert in `plant` is exact. */
function uuidFrom(seed: string): string {
  const h = createHash("sha256").update(seed).digest("hex");
  // Set the version and variant nibbles so the value is a well-formed uuid; the
  // remaining bits are the hash. Postgres does not care, but a human comparing
  // this against `data-model.md` should not have to wonder whether it is one.
  const v = "4" + h.slice(13, 16);
  const r = ((parseInt(h[16]!, 16) & 0x3) | 0x8).toString(16) + h.slice(17, 20);
  return `${h.slice(0, 8)}-${h.slice(8, 12)}-${v}-${r}-${h.slice(20, 32)}`;
}
 
export function sentinelFor(owner: string): Sentinel {
  const id = (part: string) => uuidFrom(`relay-sentinel/${owner}/${part}`);
  return {
    owner,
    organisationId: id("organisation"),
    humanId: id("human"),
    applicationId: id("application"),
    environmentId: id("environment"),
    userId: id("user"),
    channelId: id("channel"),
    name: `__sentinel__:${owner}`,
  };
}
 
/** The shared sentinel this feature does NOT have, kept as a named export so a
 * reader looking for one finds this comment instead. */
export const SENTINEL = {
  note:
    "There is no shared sentinel. Use sentinelFor(<test file path>) — research R12.",
} as const;
 
/** Plant this file's bait, replacing whatever is there.
 *
 * IDEMPOTENT BY DELETE-THEN-INSERT rather than `ON CONFLICT`, because three of the
 * four baits are consumable and a re-insert has to restore the *count* as well as
 * the rows. The environment id makes the delete exact, so the seeder cannot become
 * the accumulation it exists to simulate (FR-003).
 *
 * THE CLIENT IS THE CALLER'S PROBLEM, and that is the point. Deleting a sentinel
 * row is exactly what the trigger forbids, so planting needs the exemption — and a
 * connection carrying the exemption must never reach a test, or that test runs
 * unguarded. `setup.ts` opens a dedicated client, passes it here, and closes it
 * before the first test (FR-024, research R12).
 *
 * TWO KINDS, AND THEY ARE NOT THE SAME MECHANISM. Confusing them is how a table
 * ends up named as guarded and watched by nothing:
 *
 *   GUARD BAIT — a row the trigger PROTECTS. One per table named in
 *   `sentinel.sql`'s array, because the WHEN clause has nothing to test without
 *   one. A `users` row and a `channels` row, at this chapter.
 *
 *   DRAIN BAIT — a row a global operation would CLAIM, so that an unscoped sweep
 *   takes something belonging to a test. One per global operation in the codebase:
 *
 *     unpublished outbox rows  ->  drainOutbox
 *
 * ONE GLOBAL OPERATION EXISTS AT THIS CHAPTER, so there is one drain bait, and the
 * list grows with the code rather than ahead of it. Bait planted for an operation
 * nobody has written yet is bait nothing can take — which is indistinguishable, in
 * a passing suite, from bait that works. */
export async function plant(
  client: { query(sql: string, values?: unknown[]): Promise<unknown> },
  s: Sentinel,
): Promise<void> {
  const q = (sql: string, values?: unknown[]) => client.query(sql, values);
 
  // Children before parents, so the deletes do not trip a foreign key. `channels`
  // before `users` is not arbitrary: chapter 9 adds a table keyed on both.
  await q(`DELETE FROM outbox   WHERE subject = $1`, [`${s.name}.bait`]);
  await q(`DELETE FROM channels WHERE environment_id = $1`, [s.environmentId]);
  await q(`DELETE FROM users    WHERE environment_id = $1`, [s.environmentId]);
 
  // Register before inserting bait: the trigger's WHEN clause tests membership,
  // so an unregistered sentinel is unguarded bait.
  await q(
    `INSERT INTO __sentinel_environments (environment_id, owner) VALUES ($1, $2)
     ON CONFLICT (environment_id) DO UPDATE SET owner = EXCLUDED.owner`,
    [s.environmentId, s.owner],
  );
  await q(
    `INSERT INTO organisations (id, name) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`,
    [s.organisationId, s.name],
  );
  // provider must be 'github' or 'google' (humans_provider_check), and the email
  // is NULL on purpose — see above.
  await q(
    `INSERT INTO humans (id, provider, provider_account_id, email)
     VALUES ($1, 'github', $2, NULL) ON CONFLICT (id) DO NOTHING`,
    [s.humanId, s.name],
  );
  await q(
    `INSERT INTO memberships (organisation_id, human_id, role)
     VALUES ($1, $2, 'owner') ON CONFLICT DO NOTHING`,
    [s.organisationId, s.humanId],
  );
  await q(
    `INSERT INTO applications (id, organisation_id, name)
     VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING`,
    [s.applicationId, s.organisationId, s.name],
  );
  // kind must be 'development' or 'production' (environments_kind_check).
  await q(
    `INSERT INTO environments (id, application_id, kind, signing_secret)
     VALUES ($1, $2, 'development', $3) ON CONFLICT (id) DO NOTHING`,
    [s.environmentId, s.applicationId, `sentinel-not-a-secret-${s.environmentId}`],
  );
 
  // GUARD BAIT. One row in each table `sentinel.sql` names, so the trigger's WHEN
  // clause has a sentinel `environment_id` to match. These are not consumable — no
  // global operation claims them — and the count does not matter; what matters is
  // that the row EXISTS, because a trigger over a table holding no sentinel row is
  // a no-op that looks exactly like a trigger doing its job.
  //
  // `type` must be 'public' or 'private' (channels_type_check), and both tables are
  // unique on (environment_id, external_id) — read off the live schema, not guessed.
  await q(
    `INSERT INTO users (id, environment_id, external_id, display_name)
     VALUES ($1, $2, $3, $3) ON CONFLICT (id) DO NOTHING`,
    [s.userId, s.environmentId, s.name],
  );
  await q(
    `INSERT INTO channels (id, environment_id, external_id, type, name)
     VALUES ($1, $2, $3, 'private', $3) ON CONFLICT (id) DO NOTHING`,
    [s.channelId, s.environmentId, s.name],
  );
 
  // DRAIN BAIT: unpublished events. `outbox` carries no environment_id — it is
  // platform bookkeeping — so the subject is what identifies these, and it is also
  // why the trigger cannot guard them (data-model.md). The count is `BAIT_ROWS` and
  // not one, because a single row cannot tell a batch that ignored its limit from
  // one that honoured it.
  //
  // AND IT IS AN `events.` SUBJECT WITH AN ENVELOPE ID, WHICH IT WAS NOT. Bait
  // imitates an unpublished event, and these rows sit in the one table the outbox
  // chapter's relay drains GLOBALLY, oldest first. A bait row is therefore reachable
  // by any test that drains for real, and the first version was unreachable in the
  // two ways that matter: `EVENTS` accepts `events.>` and nothing accepts
  // `<name>.bait`, so a real publish came back `NatsError: 503`; and `'{}'` carries
  // no `id`, so `publishPending` handed the broker `msgID: undefined` and every bait
  // row looked like the same event. Both were measured: 3,200 pending rows in 16
  // subjects, every unroutable row in this lane and no other.
  //
  // A FIXTURE THAT IMITATES A THING MUST BE USABLE EVERYWHERE THE THING IS. The
  // count, the table and the unpublished state — everything the bait is FOR — are
  // unchanged; what changed is the two fields that made it a landmine rather than
  // bait.
  await q(
    `INSERT INTO outbox (subject, payload)
     SELECT $1, jsonb_build_object('id', gen_random_uuid()::text, 'type', 'bait')
       FROM generate_series(1, $2)`,
    [`events.${s.name}.bait`, BAIT_ROWS],
  );
}

Nơi nó kết nối, và ai được exempt

packages/test-harness/src/db-url.ts
/** Where the harness connects, and why it is not simply `DATABASE_URL`.
 *
 * Every other package in this workspace falls back when the variable is unset —
 * `createPool()` reads `process.env.DATABASE_URL ?? DEFAULT_DATABASE_URL` — so
 * `pnpm test:integration` works from a clean shell against the compose stack. The
 * harness's first version threw instead, which made the guard's own lane the only
 * task in the repository that required the variable, and broke the plain
 * `pnpm test:integration` a developer runs (research R50).
 *
 * The literal is duplicated rather than imported: this is a workspace package and
 * `services/api/src/db/client.ts` is service source, so importing it here would
 * point a package at a service. `db-url.test.ts` reads that file and fails if the
 * two ever disagree — the same shape `bait-size.test.ts` uses for the batch
 * constants, and for the same reason. A duplicated constant is fine; an
 * unwatched one is not.
 */
export const DEFAULT_DATABASE_URL = "postgres://relay:relay@localhost:15432/relay";
 
export function databaseUrl(): string {
  return process.env["DATABASE_URL"] ?? DEFAULT_DATABASE_URL;
}
packages/test-harness/src/exempt.ts
// The files permitted to perform global operations (feature 030, FR-009, FR-015).
//
// A LIST OF PATHS, EACH WITH ITS REASON. Not a pattern: a pattern silently absorbs
// the next file added, which is the failure mode this whole feature is about.
//
// An exempt file is not excused from correctness. It is excused from the trigger,
// and it still has to bound its own batches — which is what four of the six
// recorded instances failed to do while being, in this sense, legitimate.
//
// This list and `eslint.config.mjs`'s ignores for the global-admin functions must
// agree. A file exempt from one and not the other is a trap for whoever adds the
// next one.
//
// AND IT IS ASSERTED IN BOTH DIRECTIONS, which the obvious version is not. An
// unlisted file performing a global operation fails loudly — that is the rule's
// whole job. A LISTED FILE THAT DOES NOT EXIST passes forever: the list can only
// grow, and a stale entry holds a standing exemption over nothing, or worse over a
// path some later chapter creates for an unrelated reason. `exempt.test.ts` checks
// that every path here names a file on disk, so deleting a suite turns this list
// red instead of leaving it quietly wrong.
 
export const EXEMPT_FILES: ReadonlyArray<{ path: string; because: string }> = [
  {
    path: "services/api/src/outbox/outbox.itest.ts",
    because: "drives the event relay, whose whole subject is a global drain",
  },
];
 
/** Is this file allowed to perform global operations?
 *
 * Matched on a repository-relative path suffix, because the same file is reached
 * as `src/outbox/outbox.itest.ts` from the api's config and as
 * `services/api/src/outbox/outbox.itest.ts` from the root coverage config. */
export function isExempt(testPath: string): boolean {
  const normalised = testPath.replace(/\\/g, "/");
  return EXEMPT_FILES.some((e) => normalised.endsWith(e.path));
}

Thứ chạy nó

packages/test-harness/src/global-setup.ts
import { readFileSync } from "node:fs";
import { join } from "node:path";
 
import pg from "pg";
 
import { databaseUrl } from "./db-url.js";
 
// Installed once per lane, before any test file (feature 030, T013).
//
// MIGRATES FIRST, and that is not tidiness. `globalSetup` runs before every suite,
// and five suites call `migrate(pool)` in their own `beforeAll` — that is, after
// this. On an unmigrated database `CREATE TRIGGER … ON channels` would hit a table
// that does not exist and the lane would die before a single test. CI is
// safe (`node services/api/dist/db/migrate.js` runs before `pnpm test:integration`)
// and `fresh-db.sh` migrates, so only a direct developer run was exposed — but
// depending on somebody else having migrated is not a property, it is a habit
// (research R25).
//
// `migrate` is idempotent, keyed on `schema_migrations`, so calling it here costs a
// no-op when the database is already current.
 
const PLATFORM = join(import.meta.dirname, "..", "..", "..");
const MIGRATE = join(PLATFORM, "services", "api", "dist", "db", "migrate.js");
const GUARD_SQL = join(import.meta.dirname, "sentinel.sql");
 
export default async function globalSetup(): Promise<void> {
  // FALLS BACK, IT DOES NOT THROW. The first version demanded the variable, which
  // made this lane the only task in the repository that required it and broke the
  // plain `pnpm test:integration` a developer runs from a clean shell (research
  // R50). `db-url.ts` carries the default and the reason.
  const connectionString = databaseUrl();
 
  // The migration runner is the api's build output. Failing here with a sentence
  // beats failing later inside a CREATE TRIGGER.
  const { migrate } = (await import(MIGRATE)) as {
    migrate: (pool: pg.Pool) => Promise<string[]>;
  };
 
  const pool = new pg.Pool({ connectionString });
  try {
    await migrate(pool);
    // The guard is applied by the LANE, never by a product migration — otherwise
    // the api ships a trigger whose only purpose is to reject its own legitimate
    // sweeps (constitution IV). `no-trigger-in-migrations.test.ts` asserts that.
    await pool.query(readFileSync(GUARD_SQL, "utf8"));
  } finally {
    await pool.end();
  }
}
packages/test-harness/src/setup.ts
import { relative } from "node:path";
 
import pg from "pg";
import { beforeAll, expect } from "vitest";
 
import { databaseUrl } from "./db-url.js";
import { isExempt, EXEMPT_FILES } from "./exempt.js";
import { plant, sentinelFor } from "./sentinel.js";
 
// Runs once per test file, before the test file is imported (feature 030).
//
// EVERYTHING THAT MUST HAPPEN AT MODULE SCOPE HAPPENS AT MODULE SCOPE, and the
// distinction is measured rather than stylistic. A setup file's top-level code runs
// before the test file's module scope — `setup-toplevel; testfile-module;` — and
// four suites create their database pool at module scope:
//
//   services/api/src/db/history-drift.itest.ts
//   services/api/src/db/repository.itest.ts
//   services/api/src/messages/history.itest.ts
//   services/api/src/messages/idempotency.itest.ts
//
// An exemption written in `beforeAll` would arrive after their pool already exists.
// The exempt suite is not written that way today, so nothing is currently broken by
// it — which is the same kind of luck this whole feature exists to remove
// (FR-026, research R14).
//
// Bait planting stays in `beforeAll`, because it is asynchronous database work.
 
const PLATFORM = new URL("../../../", import.meta.url).pathname;
 
/** The file under test, as a repository-relative path. `expect.getState()` carries
 * it at module scope, not only inside a hook — which is what makes the per-file
 * sentinel possible. */
function testFile(): string {
  const abs = expect.getState().testPath;
  if (abs === undefined) {
    throw new Error("the harness cannot identify the file under test");
  }
  return relative(PLATFORM, abs).replace(/\\/g, "/");
}
 
const FILE = testFile();
const EXEMPT = isExempt(FILE);
 
// ---------------------------------------------------------------------------
// Module scope, part 1: the exemption.
// ---------------------------------------------------------------------------
 
/** The exemption travels as a CONNECTION OPTION, so every connection a pool opens
 * carries it. A `SET` issued through `pool.query()` lands on whichever connection
 * the pool hands out — measured at two of five checkouts, `["on",null,null,"on",
 * null]` — which would make an exempt suite fail two times in five, in a way
 * indistinguishable from the flakiness this feature removes (FR-020, research R10).
 *
 * The connection string rather than the pool's config object, because that needs no
 * change to `createPool()`, a product function every service calls. */
function withExemption(url: string): string {
  const u = new URL(url);
  u.searchParams.set("options", "-c relay.allow_global=on");
  return u.toString();
}
 
// THROUGH THE SAME FALLBACK, so an exempt suite is exempt whether or not the
// variable is set. Reading `process.env` directly here would have left the
// exemption silently absent on a clean shell — the suite would run guarded, fail
// on its own legitimate global operation, and point at the guard rather than at
// the missing variable.
const BASE_URL = databaseUrl();
if (EXEMPT) {
  process.env["DATABASE_URL"] = withExemption(BASE_URL);
}
 
// ---------------------------------------------------------------------------
// Module scope, part 2: a non-exempt file may not run a relay.
// ---------------------------------------------------------------------------
 
/** A relay catches and logs its own errors, so a refusal raised inside one is a log
 * line and a green lane — the guard's sharpest limitation (research R13). Every
 * suite that spawns an api child sets these off today; that is a convention, and
 * this makes it checked (FR-025).
 *
 * TWO NAMES, ONE PER RELAY THAT EXISTS. Listing a flag no module reads would make
 * every non-exempt suite throw until it switched off a relay nobody has written —
 * and the fix a reader would reach for is to set the variable, which teaches exactly
 * the wrong habit: that these names are incantations rather than switches. Each
 * relay chapter adds its own name here, and `exempt.test.ts` asserts every name in
 * this array is read by product code, so a flag cannot arrive ahead of its relay or
 * outlive it. */
const RELAY_FLAGS = [
  "RELAY_OUTBOX_RELAY",
  "RELAY_EVENT_CONSUMER",
] as const;
 
if (!EXEMPT) {
  const running = RELAY_FLAGS.filter(
    (f) => (process.env[f] ?? "on").toLowerCase() !== "off",
  );
  if (running.length > 0 && process.env["RELAY_HARNESS_BAIT"] === "on") {
    throw new Error(
      `${FILE} is not on the exempt list but leaves ${running.join(", ")} enabled. ` +
        `A relay catches its own errors, so a refusal raised inside one is a log ` +
        `line and a green lane. Switch them off, or add this file to ` +
        `packages/test-harness/src/exempt.ts with a reason — there are ` +
        `${EXEMPT_FILES.length} entries there today.`,
    );
  }
}
 
// ---------------------------------------------------------------------------
// beforeAll: the bait.
// ---------------------------------------------------------------------------
 
beforeAll(async () => {
  // Bait goes only to the lanes where reader-shape faults live. Planting it in the
  // gateway and e2e lanes would change their workload for no return, which is the
  // failure research R4 measured (FR-022). The config that wants it says so.
  if (process.env["RELAY_HARNESS_BAIT"] !== "on") return;
 
  // A DEDICATED CLIENT THAT NEVER ENTERS THE SUITE'S POOL. Deleting a sentinel row
  // is exactly what the guard forbids, so planting needs the exemption — and a
  // connection carrying it that a test later reused would leave that test unguarded
  // (FR-024, research R12).
  const seeder = new pg.Client({ connectionString: withExemption(BASE_URL) });
  await seeder.connect();
  try {
    await plant(seeder, sentinelFor(FILE));
  } finally {
    await seeder.end();
  }
});
packages/test-harness/vitest.integration.config.mts
import { defineConfig } from "vitest/config";
 
// The guard's own lane. `globalSetup` installs the function and the triggers, the
// same file every other lane uses; there is deliberately NO `setupFiles`, because
// this suite manages its own connections — one carrying the exemption and one
// not — and a setup file that rewrote DATABASE_URL would remove the distinction
// the tests are about.
//
// AND NO `fileParallelism: false`, unlike the api and gateway lanes. Those need it
// because several suites call `migrate(pool)` concurrently and race on
// `pg_type_typname_nsp_index`; here `globalSetup` migrates once and there is one
// suite. A setting that changes nothing is worth leaving out — it reads as a
// requirement to whoever copies this file next.
export default defineConfig({
  test: {
    globalSetup: ["src/global-setup.ts"],
    include: ["src/**/*.itest.ts"],
    hookTimeout: 60_000,
  },
});

Những lane nó đi tới

services/api/vitest.integration.config.mts
@@ -4,12 +4,38 @@ import { defineConfig } from "vitest/config";
 // unit lane's include on purpose: `pnpm test` stays Docker-free, and this
 // config is what `pnpm --filter @relay/api test:integration` runs against
 // the compose Postgres. (.mts because this package compiles to CommonJS —
 // a .ts config would be loaded as CJS, which vitest refuses.)
 export default defineConfig({
   test: {
+    // Feature 030: the global-operation guard. `globalSetup` migrates and
+    // then installs the trigger once per lane; `setupFiles` sets the
+    // exemption for files on the harness's list and, where the lane carries
+    // bait, plants it per file.
+    globalSetup: ["../../packages/test-harness/src/global-setup.ts"],
+    setupFiles: ["../../packages/test-harness/src/setup.ts"],
+    // MEASURED THIS CHAPTER: eight suites in this lane import `AppModule`, and not
+    // one of them sets a relay flag. Each relay defaults to on when its flag is
+    // unset (`process.env.RELAY_OUTBOX_RELAY ?? "on"`), so those eight booted two
+    // background loops that sweep the whole database while every other suite's
+    // fixtures sit in it.
+    //
+    // The exposure looks nil if you only count the suites that spawn an api CHILD
+    // and set the flags in the child's env — they do it correctly. The suites that
+    // boot the app IN PROCESS are the ones nobody looked at.
+    //
+    // A relay catches and logs its own errors, so the guard's refusal raised inside
+    // one is a log line and a green lane. Setting the flags here makes the quiet
+    // database a property of the lane rather than a convention nobody applied — and
+    // the list is exactly the relays that exist, because `setup.ts` refuses a name
+    // no module reads.
+    env: {
+      RELAY_HARNESS_BAIT: "on",
+      RELAY_OUTBOX_RELAY: "off",
+      RELAY_EVENT_CONSUMER: "off",
+    },
     include: ["src/**/*.itest.ts"],
     // ONE FILE AT A TIME, BECAUSE THEY SHARE ONE DATABASE.
     //
     // Every suite here runs migrations before it starts. Vitest runs FILES in parallel
     // by default, so several of them issue `CREATE TYPE` against the same schema at the
     // same moment and Postgres answers `duplicate key value violates unique constraint
services/gateway/vitest.integration.config.mts
@@ -3,12 +3,20 @@ import { defineConfig } from "vitest/config";
 // The gateway's integration lane (chapter 2.6). Same convention 2.1
 // established for the api: *.itest.ts is invisible to the Docker-free unit
 // include, and this config is what `pnpm --filter @relay/gateway
 // test:integration` runs against the compose Redis.
 export default defineConfig({
   test: {
+    // Feature 030: the global-operation guard. `globalSetup` migrates and
+    // then installs the trigger once per lane; `setupFiles` sets the
+    // exemption for files on the harness's list and, where the lane carries
+    // bait, plants it per file. This lane gets exemption
+    // handling and NO bait: it holds no reader-shape fault, and planting
+    // would change its workload for no return (FR-022).
+    globalSetup: ["../../packages/test-harness/src/global-setup.ts"],
+    setupFiles: ["../../packages/test-harness/src/setup.ts"],
     include: ["src/**/*.itest.ts"],
     // ONE FILE AT A TIME, BECAUSE THEY SHARE ONE DATABASE.
     //
     // Every suite here runs migrations before it starts. Vitest runs FILES in parallel
     // by default, so several of them issue `CREATE TYPE` against the same schema at the
     // same moment and Postgres answers `duplicate key value violates unique constraint
packages/e2e/vitest.integration.config.mts
@@ -7,12 +7,20 @@ import { defineConfig } from "vitest/config";
 // a reader runs on every save.
 //
 // The whole suite is one journey, and it boots real processes — so it gets
 // a real timeout, and it does not run its files in parallel.
 export default defineConfig({
   test: {
+    // Feature 030: the global-operation guard. `globalSetup` migrates and
+    // then installs the trigger once per lane; `setupFiles` sets the
+    // exemption for files on the harness's list and, where the lane carries
+    // bait, plants it per file. This lane gets exemption
+    // handling and NO bait: it holds no reader-shape fault, and planting
+    // would change its workload for no return (FR-022).
+    globalSetup: ["../../packages/test-harness/src/global-setup.ts"],
+    setupFiles: ["../../packages/test-harness/src/setup.ts"],
     include: ["src/**/*.itest.ts"],
     testTimeout: 60_000,
     hookTimeout: 60_000,
     fileParallelism: false,
   },
 });
services/gateway/src/isolation-fixtures.ts
@@ -64,16 +64,24 @@ export interface SocketTenants {
 /** THE PORT COMES FROM THE CHILD, NOT FROM A TABLE.
  *
  * The api is started with `PORT=0` and reports the port it bound. A hand-allocated band
  * per suite is a table nothing checks: two suites eventually overlap, or a band grows to
  * contain a port the lane itself runs, and the failure is a health check that succeeds
  * against the wrong service. Asking the operating system removes the table. */
-async function startApi(): Promise<{ url: string; stop: () => void }> {
+export async function startApi(
+  // EXTRA ENV, BECAUSE THE SECOND CALLER NEEDED IT AND A SECOND COPY IS A SECOND RULE.
+  // `public-surface.itest.ts` spawns an api child too, and it arrived with a
+  // hand-allocated band of its own — 4800-5000, with a comment naming three other
+  // suites' bands and one file's fixed 4124. That comment was already wrong when it
+  // was written; two of the files it names do not exist yet. Exporting this is
+  // cheaper than keeping the table honest, which is the same argument as deleting it.
+  extra: Readonly<Record<string, string>> = {},
+): Promise<{ url: string; stop: () => void }> {
   const dist = join(REPO, "services", "api", "dist");
   const child: ChildProcess = spawn("node", [join(dist, "main.js")], {
-    env: { ...process.env, PORT: "0" },
+    env: { ...process.env, ...extra, PORT: "0" },
     stdio: ["ignore", "pipe", "pipe"],
   });
   const port = await new Promise<number>((resolve, reject) => {
     const timer = setTimeout(() => reject(new Error("api never reported a port")), 30_000);
     let buffered = "";
     child.stdout?.on("data", (chunk: Buffer) => {

Điều bait buộc phải sửa

services/api/src/outbox/outbox.itest.ts
@@ -176,13 +176,25 @@ describe("the outbox", () => {
     const published = await drainUntilClear(relay, db, env.id);
     expect(published).toBeGreaterThanOrEqual(pending.length);
     expect((await unpublishedFor(db, env.id)).length).toBe(0);
 
     // Every event this environment produced reached the destination with its
     // own id as the deduplication key.
-    const ids = publisher.sent.map((m) => m.id);
+    //
+    // FILTERED TO OURS, WHICH THE COMMENT ALWAYS SAID AND THE CODE DID NOT. The
+    // relay is global: it moves whatever is oldest, so `publisher.sent` holds other
+    // suites' events and the harness's bait. Bait rows are inserted with a `{}`
+    // payload and therefore no id at all, so two hundred of them collapse to a
+    // single Set entry and the count came back 251 against 450 — a duplicate-
+    // detection failure reported by a test that detects nothing of the kind.
+    //
+    // Same predicate as the second pass below, because it is the same question.
+    const ids = publisher.sent
+      .filter((m) => m.subject.endsWith(env.id))
+      .map((m) => m.id);
+    expect(ids.length, "no event of ours reached the publisher").toBeGreaterThan(0);
     expect(new Set(ids).size).toBe(ids.length);
 
     // A second pass has nothing of OURS to do — marked rows are done.
     //
     // Asserted per environment, not on the global count: `drainOnce()` returns
     // how many rows it moved across the whole table, and other suites in this
@@ -213,16 +225,23 @@ describe("the outbox", () => {
     }
     const a = recordingPublisher();
     const b = recordingPublisher();
     const relayA = createRelay({ db, publisher: a, logger: silent, batchSize: 7 });
     const relayB = createRelay({ db, publisher: b, logger: silent, batchSize: 7 });
 
-    // Run them at the same time, repeatedly, until the backlog is gone.
-    for (let pass = 0; pass < 20; pass++) {
+    // Run them at the same time, repeatedly, until THIS environment's backlog is
+    // gone. Same reader fix as `drainUntilClear`, and sharper here: `batchSize: 7`
+    // made twenty passes a budget of 140 rows, against a table holding thousands.
+    // The loop ends when our rows are done or when neither relay can move anything.
+    for (;;) {
       if ((await outboxDepthFor(db, env.id)) === 0) break;
-      await Promise.all([relayA.drainOnce(), relayB.drainOnce()]);
+      const [movedA, movedB] = await Promise.all([
+        relayA.drainOnce(),
+        relayB.drainOnce(),
+      ]);
+      if (movedA + movedB === 0) break;
     }
 
     expect(await outboxDepthFor(db, env.id)).toBe(0);
 
     // UNIQUE AMONG THE ROWS THIS TEST WROTE. `drainOnce` is global and ordered
     // oldest-first, so these two relays publish the whole table's backlog on the way
@@ -408,20 +427,37 @@ describe("the outbox", () => {
  * deliberately NOT tenant-scoped — one loop drains every environment's events,
  * because an outbox row is work the platform owes itself. So a batch can be
  * filled entirely by rows this suite did not write, and a test that assumes
  * otherwise passes alone and fails in a full lane. (It did exactly that here.)
  * Suites cannot isolate themselves by construction on this table the way 2.1's
  * per-suite environments let them everywhere else. */
+/*
+ * READER FIX. The comment above was right about the table and wrong about the loop.
+ *
+ * `passes = 20` bounded the DRIVING in units of batches while the work is bounded
+ * by the whole table. Twenty passes of the default batch move 2,000 rows; the
+ * harness's bait alone is 200 per test file, so the loop could return with this
+ * environment's rows untouched — a correctly scoped read of a wrongly driven relay.
+ *
+ * There is no right constant here, which is the point: the relay is global and
+ * oldest-first, so reaching this suite's rows means draining everything older than
+ * them, and how much that is depends on who else is in the database. So the loop
+ * has no pass budget. It stops on the only two conditions that mean anything —
+ * this environment is clear, or a pass moved nothing and the relay is therefore
+ * done. Each pass that moves rows reduces the global backlog, so it terminates.
+ * `safety` turns a hypothetical infinite loop into a failed test, and is derived
+ * from the work that actually exists rather than guessed.
+ */
 async function drainUntilClear(
   relay: { drainOnce: () => Promise<number> },
   db: Db,
   environmentId: string,
-  passes = 20,
 ): Promise<number> {
   let moved = 0;
-  for (let i = 0; i < passes; i++) {
+  const safety = (await outboxDepth(db)) + 100;
+  for (let i = 0; i < safety; i++) {
     if ((await outboxDepthFor(db, environmentId)) === 0) break;
     const drained = await relay.drainOnce();
     moved += drained;
     if (drained === 0) break;
   }
   return moved;
services/api/src/consumer/consumer.itest.ts
@@ -328,20 +328,50 @@ describe("the consumer", () => {
     // The ordinary deployment. A durable consumer is one position in the stream,
     // so two api processes pulling from it share the work — the property the
     // broker provides here that `SKIP LOCKED` provides for the outbox.
     const durable = `itest-shared-${Date.now()}`;
     const byA: string[] = [];
     const byB: string[] = [];
+    // ONE environment, and both runtimes filtered to it.
+    //
+    // This used to call `ENV()` three times and construct both runtimes with no
+    // filter — the same fault the test above this one already carries a comment
+    // about, in this same file. Three environments means no single subject covers
+    // them, and an unfiltered durable starts at the head of a stream holding every
+    // event earlier chapters left behind; the 400-pass budget below then has to
+    // drain all of it before reaching these three.
+    //
+    // It has never failed, which is the whole problem with the class: it passes
+    // until the stream outgrows the budget, and then it fails in whichever run
+    // happens to cross the line. FIXING AN INSTANCE IS NOT FIXING A CLASS — the
+    // fix forty lines up left this one standing, because it was made against the
+    // test that failed rather than against the shape.
+    //
+    // The environments were incidental. What this test is about is two runtimes
+    // sharing one durable, and that is unchanged.
+    const environmentId = ENV();
     const ids = [
-      await publish(ENV()),
-      await publish(ENV()),
-      await publish(ENV()),
+      await publish(environmentId),
+      await publish(environmentId),
+      await publish(environmentId),
     ];
 
-    const a = runtimeFor(db, durable, async (e) => void byA.push(e.id));
-    const b = runtimeFor(db, durable, async (e) => void byB.push(e.id));
+    const a = runtimeFor(
+      db,
+      durable,
+      async (e) => void byA.push(e.id),
+      silent,
+      environmentId,
+    );
+    const b = runtimeFor(
+      db,
+      durable,
+      async (e) => void byB.push(e.id),
+      silent,
+      environmentId,
+    );
     for (let i = 0; i < 400; i++) {
       await Promise.all([a.pollOnce(), b.pollOnce()]);
       if (ids.every((id) => byA.includes(id) || byB.includes(id))) break;
     }
     await a.stop();
     await b.stop();