Building Relay

Part 3 · Chapter 3.8

The endpoints and the instruments

You will produce: The two public endpoints Part 3 needed and nobody had built, idempotency enforced by a unique index rather than in memory, every validation error naming its field for the first time, the global-operation guard watching nine tables instead of five, and the api repository layer's branch coverage answered with a number · about 80 minutes including the exercise

Source: SRS — Software Requirements Specification

The isolation harness built a suite that attacks every endpoint the platform serves. While deriving the target list it turned up something the plan had not: there is no public way to create a channel, or to add a member to one.

packages/e2e/src/harness.ts has said so since chapter 2.8, in a comment nobody had cause to re-read:

/** 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. */

Part 3 ends at the outsider milestone, whose exit criterion is the SRS's Phase 2 one: an external developer integrates using only public documentation. That criterion 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 — and "Part 3's tenancy work" was the sentence standing in for the endpoints nobody had noticed were missing.

In the order this book was first written the discovery came two chapters before that milestone, which is the version of this story with the urgency in it. Rebuilding Part 3 by subject puts the endpoints here, eighteen chapters ahead of the criterion they unblock. The finding is the same and the near-miss is gone: an ordering that groups by subject finds a missing endpoint when it builds the surface, not when it tries to ship.

This chapter builds the two endpoints that unblock it, and then turns the same question on the instruments: the things that verify isolation are code too, and nobody had checked them.

Two endpoints, and what an endpoint needs that a fixture does not

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["a row came back"]
    conflict --> lost["nothing came back"]
    made --> c201["201 — created"]
    lost --> read["getChannelByExternalId<br/>the loser reads the winner's row"]
    read --> c200["200 — the existing channel"]
    members["POST /v1/channels/:channelId/members<br/>user_ids, at most 100"]
    members --> scoped["channelExists(id) — SCOPED, and FIRST"]
    scoped --> absent["404, identical for a foreign id<br/>and an id that exists nowhere"]
    scoped --> count["countMembers — from storage"]
    count --> ceiling["+ requested > 1000?"]
    ceiling --> refuse["422 channel_member_limit_exceeded<br/>nobody created, nothing written"]
    ceiling --> add["createUser then addMember,<br/>per user"]
    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 is idempotent on the customer's own identifier, and the status says which happened. Membership reads the channel scoped FIRST, so a foreign id and an absent one answer identically before any work is done.

The bodies are strict, and one field in them is the sharpest edit in this pair of chapters:

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;

The service is thin, and the ordering inside it is the isolation property rather than a convenience:

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

Three functions that were fine as fixtures

flowchart LR
    call["addMember(channelId, userId)"]
    call --> before["BEFORE: boolean"]
    call --> after["AFTER: AddMemberOutcome"]
    before --> b1["true — added"]
    before --> b2["false — the channel is not yours"]
    before --> b3["false — the user is not yours"]
    before --> b4["RAISES — you asked twice"]
    after --> a1["added"]
    after --> a2["not_found — the channel is not yours"]
    after --> a3["not_found — the user is not yours"]
    after --> a4["already_a_member"]
    b4 --> wire["unique violation → internal_error<br/>a 500 for a reasonable request"]
    a2 --> right["conflated ON PURPOSE:<br/>FR-TEN-05 needs these identical"]
    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
The old boolean conflated three answers. Two of them must be conflated — that is FR-TEN-05 — and the third was a unique violation reaching the wire as a 500.

addMember, createChannel and createUser have existed since chapter 2.1 and every one of them is a plain insert. That is correct for a fixture, which controls its own inputs. It cannot back an endpoint.

members has a primary key of (channel_id, user_id), so asking twice raised a unique violation, and ProtocolErrorFilter renders a unique violation as internal_error. A client adding a member who is already there got a 500.

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)

That change reached one existing assertion, and what happened to it is worth more than the change:

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

The 2.8 seam is shorter by three. createChannel, createUser and addMember could all come off it now; createEnvironment, createApiKey and sendMessage stay, because there is still no public way to obtain the first two and the third is only used to write an unattributed row. That third reason expires with the sender a message never had: every send names a sender now, so sendMessage writes an attributed row like any other caller. The seam may still be right — nothing has moved it — but the reason recorded here is gone, and a reason that stopped being true is worse than none, because the next reader trusts it. Stated as a difference rather than as a migration: 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.

The field nobody had ever set

A private channel is refused with invalid_request, and the test asked for something the platform could not do:

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

expect(body.field).toBe("type") failed. The response was:

{
  "code": "invalid_request",
  "message": "Invalid input: expected \"public\"",
  "docs_url": "…",
  "request_id": "…"
}

No field. EIR-API-04 has carried one since chapter 1.3, errorFrameSchema declares it — and ZodValidationPipe threw issues[0].message and discarded issues[0].path. Every validation failure in the platform, for twenty-two chapters, told a developer what was wrong and not where.

A member added over the wire, and a message that never arrives

FR-020 asks that a member added over the public API receive that channel's messages on a socket. The test for it failed, and the failure is the largest finding in either chapter:

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

So FR-020 holds — for a message sent over a socket. The suite asserts both halves: a socket send between two members added over the API is delivered, and a REST send is delivered neither live nor on resume. Fixing it is a product decision either way — attributing a public send to an end-user token changes what user means on the wire for every existing caller, and a live fan-out from the api is a new coupling ADR-05 and constitution III would each want an argument for — so it is named and scheduled rather than quietly patched.

The lane's own infrastructure, which had no chapter

Everything so far in this chapter has been product code. The rest of it is the instruments — and they arrive here because the endpoints above are the first thing in Part 3 that a test suite can attack from outside, which is exactly when a lane's own correctness starts to matter.

The integration lane has been growing an unstated dependency for six chapters. Every suite runs migrations before it starts. Every suite shares one database. Several of them perform operations with no tenant predicate at all — the outbox relay drains whatever is oldest, and it does not know or care which environment planted it. Three chapters ago that was fine, because the only rows in the table were the ones the suite under test had just written. It is not fine now.

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

That pg dependency is the first thing in this repository outside services/api/src/db to declare one, and the lint rule that forbids it is the subject of a later section. It is not an oversight being deferred: the harness needs two raw connections that differ only in a connection-string option, and createPool() cannot express the difference.

Two kinds of bait, and confusing them is the bug

flowchart TB
    add["add quota_notifications to the trigger array"]
    add --> bait["plant() must leave a sentinel row,<br/>or the WHEN clause never matches"]
    bait --> claimable["delivered_at NULL<br/>= CLAIMABLE"]
    bait --> settled["delivered_at set<br/>= not claimable"]
    claimable --> drain["createQuotaRelay claims undelivered<br/>rows ACROSS EVERY environment"]
    drain --> boom["13 tests fail in quotas.itest.ts<br/>and connections.itest.ts"]
    settled --> ok["the guard watches the table;<br/>no drain reaches the bait"]
    boom --> law["bait may be claimable only where<br/>draining it is DATABASE work"]
    style boom fill:#7f1d1d,color:#fff,stroke:#dc2626
    style law fill:#1e3a8a,color:#fff,stroke:#3b82f6
    style ok fill:#064e3b,color:#fff,stroke:#059669
Being in the trigger array is not being watched: with no sentinel row in a table the WHEN clause never matches. And bait that a global drain can claim turns a guard into failures in unrelated files.

The guard is a BEFORE UPDATE OR DELETE trigger on every table carrying environment_id, and it refuses any statement that touches a row belonging to a registered sentinel. A sentinel is one test file's set of rows, keyed by a deterministic uuid derived from the file's path, so a refusal can say whose rows the statement took.

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.

The distinction that matters is between the two things a planted row can be for, and the first version of this harness conflated them.

Guard bait is a row the trigger protects. There has to be one in every table the array names, because the WHEN clause tests __is_sentinel(OLD.environment_id) and has nothing to test without a row. A name added to the array with no bait behind it installs a trigger that can never match — and it reads, in every report, exactly like protection.

Drain bait is a row a global operation would claim. One per global operation in the codebase, so that an unscoped sweep takes something belonging to a test rather than nothing at all. One such operation exists at this chapter, so there is one drain bait: BAIT_ROWS unpublished rows in outbox, which is twice the largest batch any product reader takes.

The exemption discarded the write it claimed to permit

A file that legitimately performs a global operation carries an exemption, and the exemption travels as a connection-string option so that every connection a pool opens has it. Inside the trigger the exempt path was one line:

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

That is wrong, and it is wrong in the direction that is worse than the fault this file exists to catch. Which row a BEFORE trigger returns decides whether the write happens. A BEFORE UPDATE trigger returning OLD does not allow the update — it replaces it with a write of the old values. rowCount comes back 1. Nothing throws.

The symptom appears 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.

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;

Being in the array is not being watched, and the suite has to say so

guard.itest.ts generates its cases from sentinel.sql's array rather than restating it, so a table added to the array arrives with its cases already written and one removed takes its cases with it. That last property is the interesting one, and it needs an assertion of its own:

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

Removing users from the array does not make its cases fail. It makes them disappear — the suite goes from eleven tests to seven and stays green, because the cases were generated from the list that no longer names it. The assertion above is the only thing that notices, and it notices for a second reason as well: the trigger it finds on users is a stale install, since DROP TRIGGER IF EXISTS runs only for names the array still holds.

The instruments verify isolation. Who verifies them?

flowchart TB
    subgraph before["BEFORE — two blocks, one rule name"]
      b1["files: **/*.ts<br/>no-restricted-imports: pg, drizzle-orm, ioredis"]
      b2["files: **/*.itest.ts<br/>no-restricted-imports: the global drains"]
      b1 -. "later block REPLACES" .-> b2
      b2 --> off["every integration test could import<br/>the driver and the query engine"]
    end
    subgraph after["AFTER — three blocks, composed"]
      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
The rule catches an unlisted file that imports the driver. Nothing catches a listed file that stopped — so the list can only grow, and a stale entry holds a standing exemption over a path some later chapter may create.

Constitution I says isolation lives in data access, and an earlier chapter turned that into a lint rule: no pg, no drizzle-orm outside the directories that own them. The counter store gets the same treatment in the chapter that writes it; there is no Redis in the repository layer yet to protect.

The harness needs three files exempted from that rule, and the shape of the exemption is the decision worth explaining.

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: [
             {

Three paths, not a packages/test-harness/** pattern. A pattern silently absorbs the next file added there, which is the failure mode this whole guard exists to remove — and the argument is the same one that made the exempt list a list of paths with reasons rather than a glob in the first place.

And the rule can only ever check one direction. An unlisted file that imports the driver fails loudly; that is the rule's whole job and it does it well. A listed file that stops importing one fails nothing, anywhere, ever. The exemption simply never fires. So the list can only grow, and a stale entry holds a standing exemption over a path that no longer needs it — or worse, over a path some later chapter creates for an unrelated reason.

There is no second list to compare against, and asking for one would move the problem rather than solve it. What the list has to agree with is the tree:

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([]);
    }
  });

The restricted module names are read out of the rule rather than restated, so adding a third restricted module needs no edit here. A fourth case asserts that services/api/src/db/** is the only glob in that ignores array, which is what turns "three paths, not a pattern" from a comment into a property.

What the bait found, which was not what it was planted for

Turning the harness on broke three suites, and none of the three broke for the reason the bait was planted.

An assertion scoped wider than the thing it tests. outbox.itest.ts's invariant 7 asserted that every message the relay published had a distinct id — over publisher.sent, which after this chapter holds two hundred bait rows as well. Bait is inserted with a {} payload and therefore no id at all, so two hundred of them collapse to a single Set entry:

AssertionError: expected 251 to be 450

A duplicate-detection failure, reported by a test that detects nothing of the kind. The comment above the assertion already said every event this environment produced; the code did not, and the predicate it needed was sitting eight lines below in the same test.

A loop bounded in the wrong units. Two relays drained "until the backlog is gone", for twenty passes at batchSize: 7 — a budget of 140 rows against a table the bait now grows by 200 per test file. There is no right constant, because the relay is global and oldest-first: 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 ends when this environment is clear, or when a pass moved nothing.

And fixing an instance is not fixing a class. consumer.itest.ts's shared-durable test minted three environments and built both runtimes unfiltered — the same shape a comment forty lines above it already describes as fixed, in the same file. That earlier fix was made against the test that failed, and this one had never failed, because the stream had not yet outgrown its budget.

The lane's last hand-allocated port

The suite that drives the public surface end to end spawns an api child, and it picked its port like this:

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

The diagnosis in that comment is exactly right, and it is the reason the band exists rather than a fixed number. But read what the first two lines claim: three other suites hold three other bands, one of them a fixed 4124.

Two of those three files do not exist yet. limits.itest.ts arrives with the rate limiter, in movement VII; isolation.itest.ts is the socket half of the gauntlet and holds no band at all, because it asks the operating system. The comment describes a layout nobody has built, and it has been describing it for as long as it has existed.

That is what a table nothing checks looks like from the inside. It is not wrong because somebody was careless — it is wrong because there is no mechanism by which it could have become right, and no mechanism by which anyone would find out.

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"],
  });

The mechanism already existed — the socket gauntlet established it several chapters ago — so this is a deletion and an import, not a design. Exporting it with an env parameter is cheaper than keeping a second copy honest, which is the same argument as deleting the band.

And the health-check loop goes with the port. A child that has logged the port it bound is listening on it; a loop that polls /healthz afterwards can only tell you something the listening line already did — or, if the path is wrong, cheerfully run a hundred failed probes and return the URL anyway. Two services in this repository did exactly that for several chapters, each probing a path the other one served.

Constitution VI, answered with a number

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 reads 254/280 branches — 90.71%, against 241/266 (90.60%) at the start. It fell to 90.43% first: six new operations, three new uncovered arms. One was removed rather than named — addMember's rowCount ?? 0, where the driver types rowCount as number | null and never returns null for an INSERT, so the ?? was an arm nothing could take in the one file constitution VI asks 100% of. Two are named: the ON CONFLICT losers' "could not be created or read" throws, reachable only if the winner's row were deleted between two statements of one call.

Five new files got pins, and three of them reached 100% only after the arms a PASSING suite cannot reach were separated out — which is the isolation harness's argument about its own suite, one layer down.

The instruments found the endpoints first

Registering the module is what told the suite there was something new to attack. Nobody edited a list:

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

Nine targets became eleven, and the suite failed naming both. That is the failure the derivation exists to produce — and the order matters more than the fix. The classification changed in answer to the derivation; the derivation never changed in answer to the classification. If a route ever disappears from that list without disappearing from the router, the same test says so.

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

Both are attacked in the chapter that adds them, and one of them needed a different question asking.

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

Every validation error names its field

The endpoints arrive with bodies to reject, which makes this the chapter where a 400 stops being a shrug. The pipe reports which key failed, and the filter carries it out:

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 } : {}),
       }),
     );
   }
 }

field travels the way code does — the thrower names it, because only the thrower knows it. And it is 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.

One more code, and its name is not the obvious one:

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).
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");

The chapter in full

The excerpts above are the parts worth arguing about. These are the files, whole where they are new and as diffs where they are not.

And they are whole for a reason this chapter is the first able to give. The harness shipped as a platform feature with no chapter of its own, so its amendment lived in fences/post-series.md — applied after every chapter, because a chapter cannot amend a state that a later file builds. Sixteen files were therefore in nobody's chain: excerpted here, whole nowhere, compared to nothing. The harness belongs to a chapter now, so the chain can carry it.

The suites are not here, and each is named with what it holds:

Their absence is a stated gap and not an oversight: a file no fence names is a file the chain cannot verify, which is the whole subject of the section above.

The 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";

The 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],
  );
}

Where it connects, and who is excused

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

What runs it

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

The lanes it reaches

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) => {

What the bait forced

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