Building Relay

Part 3 · Chapter 3.9

The channel a customer controls

You will produce: A private channel type that decides something on all four of its doors, member removal and roles, archiving that refuses a send without announcing the channel exists, and a gauntlet that attacks your own tenant · about 85 minutes including the exercise

Source: SRS — Software Requirements Specification

A channel has had two kinds since chapter 2.1.

CONSTRAINT channels_type_check CHECK (type IN ('public','private'))

Twenty-two chapters later the column still holds 'public' in every row of every environment. The previous chapter built POST /v1/channels and gave the field a one-value enum with the sharpest edit in it — z.enum(["public"]) — on the grounds that an endpoint accepting private would sell a guarantee the platform did not keep.

That was the right call and its stated reason was slightly wrong, in a way worth opening with. The claim behind the refusal was that nothing reads the column. Count the references and there are eleven: ChannelRow declares it, two repository queries select it, and channels.controller.ts returns it in the create response. Returning a column is reading it. What was actually true is narrower and sharper — no decision consulted it. The only .type === in non-test code was on a protocol frame, not on a channel.

Fifteen analysis passes read the phrase "five dead columns" past that count without measuring it. The measurement took one grep. It is the second time in this feature that a column was called dead while a response body returned it, and both times the correction made the statement stronger rather than weaker: a value can be visible to every client and still authorize nothing.

The verb with no handler

Before writing a line, one question: does every verb that has to honour the new rule have somewhere to honour it?

GET /v1/channels/:channelId did not exist. channels.controller.ts carried a create and a member-add and no read at all, so a customer could create a channel and never read its four fields back. Three documents rested on the route being there — a success criterion naming "read by id" as one of four verbs, a requirement saying "every read", and a contract with a row for it.

Nothing found that by comparing the spec to the plan to the tasks. It came from asking the repository a question with a yes-or-no answer: does this verb have a handler? Two of this feature's eight critical findings came from that kind of question, and the other one is the same shape — a module registration no task named, without which eight routes would not have mounted.

The check the only public send could not reach

The membership check belongs in data access — the constitution puts tenant isolation there, and the same argument carries authorization inside a tenant, because a check in one service can be bypassed by the next caller to reach the repository directly. The research note said six callers inherit it.

Counted from the call graph, there are three. And the one that matters supplied nothing:

// services/api/src/messages/messages.controller.ts, before this chapter
return this.messages.send(channelId, body);

MessagesController declared no @Accepts, so the guard fell back to accepting either credential class, and a user token got in. The handler then called a service that called a repository function whose userId parameter was optional — and the check was gated on that parameter being present. So a user could send to a private channel they were not a member of and nothing anywhere would look.

A parameter nobody fills in encodes nothing. The argument was true of the signature and false of the caller, and writing out the graph is what found it. Six analysis passes had read the number six.

flowchart LR
    subgraph places["A CHECK ON THE CALLER NEEDS THREE PLACES"]
      h["the handler<br/>resolves the principal"]
      s["the service<br/>threads it"]
      r["the repository function<br/>accepts a userId"]
      h --> s --> r
    end
    r --> fires["the check can fire"]
    gap1["send: the handler passed no user<br/>send(channelId, body)"]
    gap2["history: listMessages(channelId, opts)<br/>had nowhere to put one"]
    gap1 --> dead["a parameter nobody fills in<br/>encodes nothing"]
    gap2 --> dead
    style dead fill:#7f1d1d,color:#fff,stroke:#dc2626
    style fires fill:#064e3b,color:#fff,stroke:#059669
A check on the caller needs three places, and this feature had two routes with a gap

The tasks file now carries a five-row table — one row per route whose behaviour depends on who is calling, with those three as columns. Two of the five had a gap. The second was found only because the first was: messages.controller.ts has exactly two routes, and after fixing send nobody asked whether the read path had the same shape. It did. listMessages(channelId, {beforeSeq, afterSeq, limit}) had nowhere to put a userId, so the task saying "add the same check to the history path" was asking for a check with nothing to check against.

services/api/src/messages/messages.controller.ts
@@ -1,46 +1,100 @@
 import {
+  BadRequestException,
   Body,
   Controller,
   Get,
   Param,
   Post,
   Query,
+  Req,
   UseGuards,
 } from "@nestjs/common";
 
 import { CredentialGuard } from "../auth/credential.guard";
+import { Repository } from "../db/repository";
 import { MessagesService } from "./messages.service";
 import { historyQuerySchema, sendMessageBodySchema } from "./messages.schema";
 // `import type` is required, not stylistic: with isolatedModules and
 // emitDecoratorMetadata on (ADR-15's trade-off, chapter 1.4), a type used
 // in a decorated signature must be imported as a type or TS1272 refuses
 // to compile it.
 import type { HistoryQuery, SendMessageBody } from "./messages.schema";
+import type { RequestWithPrincipal } from "../auth/principal";
 import { ZodValidationPipe } from "./zod-validation.pipe";
 
+/** The end user this request acts for, or `undefined` when the tenant is acting.
+ *
+ * SOFT, unlike `internal.controller.ts`'s `principalUser`, which throws. These two
+ * routes accept BOTH credential classes — the class-level guard declares no
+ * `@Accepts`, so `credential.guard.ts` falls back to `EITHER` — and an application
+ * key legitimately carries no user. A tenant's own server sending on a customer's
+ * behalf is FR-MSG-13, not a mistake. */
+function actingUser(req: RequestWithPrincipal): string | undefined {
+  return req.principal?.kind === "user" ? req.principal.userExternalId : undefined;
+}
+
 // The api's first product endpoint (chapter 2.2). Validation is zod at the
 // boundary — the same schema family as @relay/protocol, so the REST body
 // and the WebSocket frame payload cannot drift (1.3's payoff, again).
 //
 // The credentials chapter swapped the guard. `EnvironmentContextGuard` resolved a tenant
 // from a header the caller asserted; `CredentialGuard` only asks whether the
 // principal the middleware already resolved is allowed here. Both classes are
 // (FR-MSG-13 lets a server send on a user's behalf, and FR-AUT-10 does not
 // reserve these routes), so this one declares nothing narrower.
 @Controller("v1/channels/:channelId/messages")
 @UseGuards(CredentialGuard)
 export class MessagesController {
-  constructor(private readonly messages: MessagesService) {}
+  constructor(
+    private readonly messages: MessagesService,
+    private readonly repo: Repository,
+  ) {}
 
   @Post()
   async send(
     @Param("channelId") channelId: string,
     @Body(new ZodValidationPipe(sendMessageBodySchema)) body: SendMessageBody,
+    @Req() req: RequestWithPrincipal,
   ) {
-    const message = await this.messages.send(channelId, body);
+    // WHO IS SENDING, resolved here (FR-001, T031a).
+    //
+    // This route called `this.messages.send(channelId, body)` with no user for
+    // twenty-three chapters, and the membership check in `sendMessage` is gated on
+    // `userId` being present — so the check could not fire on the only send path a
+    // customer's own client calls. `MessagesController` declared no `@Accepts` until
+    // the sender chapter, so the guard fell back to `EITHER` and a user token was
+    // accepted here.
+    //
+    // A LOOKUP PER SEND, and it is the same one the internal route already pays.
+    // `sendMessage`'s own comment explains why the id is threaded rather than
+    // resolved inside the write transaction: a SELECT in there is a cost every
+    // message pays forever. Outside it, once, is what `internal.controller.ts`
+    // does at line 63.
+    //
+    // A USER TOKEN FOR AN IDENTIFIER WITH NO ROW IS REFUSED, and this is a
+    // behaviour change worth naming. `POST /auth/dev-token` mints tokens for
+    // identifiers that need not exist, so before this a token-authenticated send
+    // by a stranger succeeded UNATTRIBUTED — and an unattributed send is one the
+    // membership check waves through. A user with no row is a member of nothing;
+    // refusing is the honest answer, and it is the same one the internal route has
+    // given since chapter 2.6. FR-039a removes the case entirely by creating the
+    // row when the token is minted.
+    const actingExternalId = actingUser(req);
+    let userId: string | undefined;
+    if (actingExternalId !== undefined) {
+      const user = await this.repo.getUserByExternalId(actingExternalId);
+      if (!user) throw new BadRequestException("unknown user");
+      userId = user.id;
+    }
+    const message = await this.messages.send(
+      channelId,
+      body,
+      userId,
+      actingExternalId,
+    );
     // FR-MSG-04's "201-equivalent semantics" lives HERE, on the public
     // wire: the client sees the same body whether this was the original
     // send or the retry that recovered it. Moved down from the service in
     // chapter 2.6, where an internal caller turned out to need the flag.
     // The field list is spelled out rather than spread-minus-`duplicate`,
     // so a new column joins the public response only when someone decides
@@ -55,10 +109,22 @@ export class MessagesController {
   }
 
   @Get()
   async history(
     @Param("channelId") channelId: string,
     @Query(new ZodValidationPipe(historyQuerySchema)) query: HistoryQuery,
+    @Req() req: RequestWithPrincipal,
   ) {
-    return this.messages.history(channelId, query);
+    // The same resolution the send handler above does, on the other route of this
+    // controller (T041a). Both dropped the caller; the send path was
+    // found in one analysis pass and this one in the next, because finding the first
+    // did not prompt anyone to ask whether the sibling had the same shape.
+    const actingExternalId = actingUser(req);
+    let userId: string | undefined;
+    if (actingExternalId !== undefined) {
+      const user = await this.repo.getUserByExternalId(actingExternalId);
+      if (!user) throw new BadRequestException("unknown user");
+      userId = user.id;
+    }
+    return this.messages.history(channelId, query, userId);
   }
 }

Both routes now resolve their caller from the principal, and both refuse a user external id that names no row — a token minted for a user who has since been removed is a bad request, not an anonymous send.

services/api/src/messages/messages.service.ts
@@ -1,18 +1,21 @@
 import {
   BadRequestException,
+  HttpStatus,
   Injectable,
   NotFoundException,
 } from "@nestjs/common";
 
 import {
+  ChannelArchivedError,
   ChannelNotFoundError,
   Repository,
   type MessageRow,
   type MessageWithSender,
 } from "../db/repository";
+import { protocolError } from "../protocol-error";
 import { decodeCursor, encodeCursor } from "./cursor";
 import type { HistoryQuery, SendMessageBody } from "./messages.schema";
 
 // The thin layer between HTTP and the repository (chapters 2.2 + 2.3). It
 // owns two things: turning the layer's domain error into the wire's 404,
 // and carrying the write path's inputs down to the repository.
@@ -26,15 +29,21 @@ import type { HistoryQuery, SendMessageBody } from "./messages.schema";
 export class MessagesService {
   constructor(private readonly repo: Repository) {}
 
   async send(
     channelId: string,
     body: SendMessageBody,
-    /** Chapter 2.6: who wrote it. Optional because a key-authenticated public
-     * send is unattributed (the credentials chapter's recorded bound); the internal route always
-     * knows. */
+    /** Chapter 2.6: who wrote it. Optional because an APPLICATION-key send is
+     * unattributed — it acts for the tenant and there is no user to name.
+     *
+     * IT IS NO LONGER OPTIONAL FOR A USER TOKEN. THE CHANNEL-CONTROL CHAPTER made the public
+     * route resolve its principal (T031a): the membership check in `sendMessage`
+     * is gated on this parameter, and until then the public route supplied none,
+     * so the check could not fire on the route a customer's client actually calls.
+     * "A key-authenticated public send is unattributed" was the old bound and it
+     * described the whole route; now it describes one of its two credentials. */
     userId?: string,
     /** The same person as a CONSUMER will see them. The event
      * envelope carries external ids, and the internal route already holds this
      * one — it is the token's subject — so threading it costs nothing where a
      * lookup inside the write transaction would cost a query per message. */
     userExternalId?: string,
@@ -47,12 +56,24 @@ export class MessagesService {
         ...(userExternalId !== undefined && { userExternalId }),
         ...(body.idempotency_key != null && {
           idempotencyKey: body.idempotency_key,
         }),
       });
     } catch (error) {
+      if (error instanceof ChannelArchivedError) {
+        // 403 AND ITS OWN CODE (FR-021). Distinct from not-found, because the
+        // channel is there and the caller can see it, and distinct from
+        // `user_banned`, because one lifts when somebody unarchives and the other
+        // when somebody unbans. A client that cannot tell them apart retries the
+        // wrong one for ever.
+        throw protocolError(
+          "channel_archived",
+          "this channel is archived and accepts no new messages; its history is unchanged",
+          HttpStatus.FORBIDDEN,
+        );
+      }
       if (error instanceof ChannelNotFoundError) {
         // A CONSTANT message: echoing the id back would make the foreign-id
         // answer differ from the missing-id answer, and "different" is
         // itself a disclosure (FR-TEN-05).
         throw new NotFoundException("channel not found");
       }
@@ -64,34 +85,45 @@ export class MessagesService {
    * going out; the service is the only place that knows it encodes a
    * sequence. A cursor we did not mint is a 400, never a silent reset to
    * the top — serving the wrong page quietly is worse than refusing. */
   async history(
     channelId: string,
     { cursor, direction, limit }: HistoryQuery,
+    /** Who is reading (FR-002). Threaded for the same reason `send`
+     * threads it: the membership check lives in the repository, and a check gated
+     * on a parameter no caller fills in is a check that never fires. This route
+     * dropped the caller for twenty-three chapters — the same defect as the send
+     * path on the same controller, found one analysis pass later. */
+    userId?: string,
   ): Promise<{
     messages: MessageWithSender[];
     next_cursor: string | null;
     prev_cursor: string | null;
   }> {
     // A channel that does not resolve in this tenant is a 404 here, exactly
     // as it is on the send path (chapter 2.8's finding). An empty page would
     // not leak anything — a foreign channel and an empty one would look the
     // same — but it leaves a client unable to tell "no such conversation"
     // from "no messages yet", and it made one resource answer two ways
     // depending on the verb.
-    if (!(await this.repo.channelExists(channelId))) {
+    // VISIBILITY, NOT EXISTENCE (FR-003). `channelExists` answered
+    // only the first half, and the difference was a leak: an absent channel gave
+    // 404 while a private channel a non-member read gave 200 with an empty page.
+    // One predicate now produces both refusals, so the two answers cannot diverge.
+    if (!(await this.repo.channelVisibleTo(channelId, userId))) {
       throw new NotFoundException("channel not found");
     }
 
     let anchor: number | undefined;
     if (cursor !== undefined) {
       const decoded = decodeCursor(cursor);
       if (decoded === null) throw new BadRequestException("malformed cursor");
       anchor = decoded;
     }
     const messages = await this.repo.listMessages(channelId, {
+      ...(userId !== undefined && { userId }),
       limit,
       ...(direction === "newer"
         ? { afterSeq: anchor ?? 0 }
         : anchor === undefined
           ? {}
           : { beforeSeq: anchor }),

The history path is the one worth reading twice. The first fix returned an empty list for a private channel the caller could not see — which leaks, because an absent channel answers 404 and an empty page answers 200. One predicate produces both refusals instead: channelVisibleTo returns false for a channel that does not exist and for a private channel the caller is not in, and the handler above it turns one answer into one envelope.

That choice pays off later in a way that was not the reason for making it. See the gauntlet, below.

A refusal that reveals what it is refusing

Three tasks and a contract specified a private channel's send refusal as 403 not_a_member. A success criterion required send's answer to be byte-identical to a channel that does not exist. Both could not hold: a 403 announces the channel exists.

So a private channel's send answers the not-found envelope — and not_a_member turns out to have no emitter at all in this chapter. Its one emitter is the read-position route on a public channel, where a read position is per-member state and refusing a non-member is the same rule the rest of the table keeps, and that route is the next chapter's. The registry entry says so, because saying so saves the next reader a search.

flowchart TB
    req["a request naming a channel id"]
    req --> ban["1 — BANNED?<br/>checked before the channel is resolved"]
    ban -->|yes| one["one answer for every channel id,<br/>real or invented"]
    ban -->|no| vis["2 — VISIBLE?<br/>private and not a member → the not-found envelope"]
    vis -->|no| gone["byte-identical to a channel<br/>that does not exist"]
    vis -->|yes| arch["3 — ARCHIVED?<br/>channel_archived"]
    arch --> ok["the operation"]
    leak["ARCHIVE SECOND, MEMBERSHIP THIRD:<br/>a non-member of a private ARCHIVED channel<br/>gets channel_archived and learns it exists"]
    style leak fill:#7f1d1d,color:#fff,stroke:#dc2626
    style gone fill:#064e3b,color:#fff,stroke:#059669
    style one fill:#064e3b,color:#fff,stroke:#059669
Ban, then membership and visibility, then archive — and what the other order gives away

The order was the second half of the same defect. Ban, then archive, then membership means a non-member of a private archived channel gets channel_archived and learns the channel exists. The ban check runs before the channel is resolved at all, so a banned user gets one answer for every channel id, real or invented.

Here is the repository layer with all of it in place — the visibility predicate, the membership check on send, the archive check below it, bulk removal, roles, and the by-id read that had no handler.

services/api/src/db/repository.ts
@@ -1,9 +1,9 @@
 import { randomUUID } from "node:crypto";
 
-import { and, asc, desc, eq, gt, lt, sql, type SQL } from "drizzle-orm";
+import { and, asc, desc, eq, gt, inArray, lt, sql, type SQL } from "drizzle-orm";
 
 import type { Db } from "./client";
 import {
   apiKeys,
   applications,
   channels,
@@ -564,17 +564,18 @@ export interface UserRow {
 }
 
 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`. */
+   * chapter 2.1, and the channel-control chapter gave it its first DECISION. Before that it was
+   * selected and returned by the create route — read, but consulted by nothing:
+   * no conditional anywhere branched on it, so FR-CHN-05's private guarantee was
+   * unimplemented while the value round-tripped.
+   *
+   * Now `sendMessage`, the by-id read, history and join all branch on it. */
   type: "public" | "private";
   name: string | null;
   metadata: Record<string, unknown>;
 }
 
 /** What `addMember` did. `not_found` deliberately covers "the channel is not
@@ -613,12 +614,26 @@ export class ChannelNotFoundError extends Error {
   constructor(public readonly channelId: string) {
     super(`channel not found: ${channelId}`);
     this.name = "ChannelNotFoundError";
   }
 }
 
+/** A write refused because the channel is archived (FR-020, FR-021).
+ *
+ * A TYPED DOMAIN ERROR, not a `protocolError` thrown from here. The repository has
+ * raised `ChannelNotFoundError` since chapter 2.2 and let the service map it to a
+ * status — the data layer knows the fact, the service knows the wire. Reaching for
+ * `protocolError` here would put an HTTP status in the layer whose whole job is to
+ * not know about HTTP. */
+export class ChannelArchivedError extends Error {
+  constructor(public readonly channelId: string) {
+    super(`channel archived: ${channelId}`);
+    this.name = "ChannelArchivedError";
+  }
+}
+
 /** Timestamps cross the wire as RFC 3339 strings (constitution: UTC,
  * millisecond precision) — the driver hands back a Date or a string
  * depending on the column and the query shape. */
 function toIso(value: Date | string): string {
   return value instanceof Date ? value.toISOString() : String(value);
 }
@@ -773,19 +788,35 @@ export class Repository {
    * `(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> {
+  async addMember(
+    channelId: string,
+    userId: string,
+    /** FR-011b. Absent means the column's own default — `member` —
+     * which is what keeps every existing caller working unchanged. An entry that
+     * names a role is creating a member WITH one rather than changing them into one
+     * afterwards, which is what US6's first scenario asks for. */
+    role?: string,
+  ): Promise<AddMemberOutcome> {
     const inserted = await this.db.execute(
-      sql`INSERT INTO members (channel_id, user_id)
+      role === undefined
+        ? 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}
-          ON CONFLICT (channel_id, user_id) DO NOTHING`,
+          ON CONFLICT (channel_id, user_id) DO NOTHING
+          RETURNING channel_id`
+        : sql`INSERT INTO members (channel_id, user_id, role)
+          SELECT c.id, u.id, ${role} 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}
+          ON CONFLICT (channel_id, user_id) DO NOTHING
+          RETURNING channel_id`,
     );
     if ((inserted.rowCount ?? 0) > 0) return "added";
 
     const existing = await this.db
       .select({ userId: members.userId })
       .from(members)
@@ -797,12 +828,167 @@ export class Repository {
           eq(channels.environmentId, this.environmentId),
         ),
       );
     return existing.length > 0 ? "already_a_member" : "not_found";
   }
 
+  /** Archive and unarchive, both idempotent (FR-020, FR-020a).
+   *
+   * IDEMPOTENT BY THE WRITE, not by a read-then-write: setting `archived_at` on an
+   * already-archived channel writes the same state, and a caller who asks twice
+   * meant it once. The returned boolean says whether the channel was FOUND, not
+   * whether anything changed — "already archived" and "archived just now" are the
+   * same answer to the customer, which is what idempotent means here.
+   *
+   * `now()` FROM THE DATABASE rather than the app clock, because nothing compares
+   * this timestamp against another statement's value. `sendMessage` takes its period
+   * from the app clock for the opposite reason: two statements there need the same
+   * value and only one of them can be `now()`.
+   */
+  async archiveChannel(channelId: string): Promise<boolean> {
+    const updated = await this.db
+      .update(channels)
+      .set({ archivedAt: sql`now()` })
+      .where(
+        and(
+          eq(channels.id, channelId),
+          eq(channels.environmentId, this.environmentId),
+        ),
+      )
+      .returning({ id: channels.id });
+    return updated.length > 0;
+  }
+
+  async unarchiveChannel(channelId: string): Promise<boolean> {
+    const updated = await this.db
+      .update(channels)
+      .set({ archivedAt: null })
+      .where(
+        and(
+          eq(channels.id, channelId),
+          eq(channels.environmentId, this.environmentId),
+        ),
+      )
+      .returning({ id: channels.id });
+    return updated.length > 0;
+  }
+
+  /** Set a member's role (FR-011).
+   *
+   * SCOPED THROUGH THE CHANNEL, like every other write to `members`: that table
+   * carries no `environment_id`, so the `EXISTS` is what keeps another tenant's rows
+   * out of reach.
+   *
+   * The CHECK constraint is the second line of defence and the one that matters:
+   * `members_role_check` names FR-CHN-04's three, so a value that got past the
+   * schema at the edge still cannot land. R8's trap was a constraint that reused
+   * `memberships`' vocabulary — it would accept `admin`, refuse `moderator`, and
+   * read as correct in review. */
+  async setMemberRole(
+    channelId: string,
+    userId: string,
+    role: string,
+  ): Promise<"set" | "not_a_member"> {
+    const updated = await this.db
+      .update(members)
+      .set({ role })
+      .where(
+        and(
+          eq(members.channelId, channelId),
+          eq(members.userId, userId),
+          sql`EXISTS (SELECT 1 FROM channels c WHERE c.id = ${channelId}
+                       AND c.environment_id = ${this.environmentId})`,
+        ),
+      )
+      .returning({ userId: members.userId });
+    return updated.length > 0 ? "set" : "not_a_member";
+  }
+
+  /** One member's role, or null when there is no membership. Used by the tests that
+   * assert the default rather than reading it out of the DDL. */
+  async memberRole(channelId: string, userId: string): Promise<string | null> {
+    const rows = await this.db
+      .select({ role: members.role })
+      .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 rows[0]?.role ?? null;
+  }
+
+  /** Remove members by user id, up to a hundred in one call, reporting each
+   * (FR-006, FR-007, FR-008).
+   *
+   * BULK, BECAUSE THE REQUIREMENT ALWAYS WAS. FR-006 says "up to 100 in one
+   * request" and FR-007 says the result is reported per user — which is chapter
+   * the endpoints chapter's `addMembers` shape in both halves. `contracts/membership.md` specified a
+   * single-user `DELETE …/members/:userExternalId` for ten analysis passes, having
+   * read "the shape the channel-endpoints chapter chose" as *named outcomes* and dropped *bulk*.
+   * Every pass compared requirements to tasks, both said "removal", and identifier
+   * coverage read 100% the whole time.
+   *
+   * NO MESSAGES ARE TOUCHED (FR-008). The removed user's messages stay in history
+   * attributed to them: `messages.user_id` still points at a row that still exists,
+   * and their socket stops receiving the channel on its next resume because the
+   * session is built from `members`.
+   *
+   * NO READ POSITION TO REMOVE YET, AND THAT IS AN OBLIGATION AND NOT A GAP.
+   * `read_positions` is per-member state keyed by `(channel_id, user_id)`, so
+   * leaving a removed member's row would leave a non-member's position in a
+   * per-member table. The table arrives in the next chapter, and the delete has to
+   * arrive with it — this comment is here so that the chapter which creates the
+   * table finds the requirement rather than deducing it. `channels.itest.ts` gets
+   * the case in the same edit.
+   * SCOPED THROUGH THE CHANNEL, and the caller has already read it scoped. `members`
+   * carries no `environment_id` — the catalogue calls it a `hop` — so the join is
+   * what keeps a foreign channel's rows out of reach.
+   */
+  async removeMembers(
+    channelId: string,
+    userIds: string[],
+  ): Promise<Map<string, "removed" | "not_a_member">> {
+    const outcome = new Map<string, "removed" | "not_a_member">();
+    if (userIds.length === 0) return outcome;
+
+    // ONE STATEMENT FOR THE BATCH, and `inArray` rather than a built string.
+    //
+    // The first draft of this method interpolated the ids into raw SQL with
+    // `sql.raw(\`ARRAY['${'${'}userIds.join("','")}']::uuid[]\`)`. It typechecked and it
+    // would have worked, and it is an injection hole in the one layer that must not
+    // have one: these ids arrive in a request body. `inArray` parameterises, which
+    // is the only reason to reach for the query builder over a template here.
+    //
+    // A hundred round trips to answer one request is the cost chapter 2.4 measured
+    // away on the read path; there is no reason to reintroduce it on this one.
+    const deleted = await this.db
+      .delete(members)
+      .where(
+        and(
+          eq(members.channelId, channelId),
+          inArray(members.userId, userIds),
+          // The channel scoped, in the same statement. `members` carries no
+          // `environment_id` — the catalogue calls it a `hop` — so this EXISTS is
+          // what keeps another tenant's rows out of reach.
+          sql`EXISTS (SELECT 1 FROM channels c WHERE c.id = ${channelId}
+                       AND c.environment_id = ${this.environmentId})`,
+        ),
+      )
+      .returning({ userId: members.userId });
+    const removed = new Set(deleted.map((r) => r.userId));
+
+    for (const id of userIds) {
+      outcome.set(id, removed.has(id) ? "removed" : "not_a_member");
+    }
+    return outcome;
+  }
+
   /** 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)
@@ -873,22 +1059,96 @@ export class Repository {
       metadata?: unknown;
       idempotencyKey?: string;
     },
   ): Promise<MessageRow> {
     return this.db.transaction(async (tx) => {
       const [channel] = await tx
-        .select({ id: channels.id, lastSequence: channels.lastSequence })
+        .select({
+          id: channels.id,
+          lastSequence: channels.lastSequence,
+          // `channels.type` has been a `"public" | "private"` column
+          // with a CHECK since chapter 2.1 and nothing decided on it until now — it
+          // was returned by the create route and consulted by nothing.
+          type: channels.type,
+          // Declared in chapter 2.1 and read by NOTHING until here:
+          // zero non-test references, measured rather than assumed (T007).
+          archivedAt: channels.archivedAt,
+        })
         .from(channels)
         .where(
           and(
             eq(channels.id, channelId),
             eq(channels.environmentId, this.environmentId),
           ),
         )
         .for("update");
       if (!channel) throw new ChannelNotFoundError(channelId);
+
+      // MEMBERSHIP, FOR A PRIVATE CHANNEL, WHEN A USER IS SENDING
+      // (FR-001, FR-CHN-05).
+      //
+      // HERE AND NOT IN A HANDLER, because constitution I says isolation is
+      // enforced in data access. Two controllers reach this function and neither
+      // can be trusted to remember a rule the other also needs.
+      //
+      // GATED ON `userId`, and the parameter is the whole distinction:
+      //
+      //     userId present    a USER is sending. Membership applies.
+      //     userId absent     the TENANT is sending through an application key.
+      //                       It acts for the customer, carries no user, and sees
+      //                       private channels (FR-005).
+      //
+      // And that gate is only honest because the channel-control chapter made the public route
+      // supply a user. It called `messages.send(channelId, body)` with none, and
+      // `MessagesController` declared no `@Accepts` until the sender chapter — so the
+      // guard fell back to `EITHER` and a user token was accepted there. A check gated on a parameter
+      // no caller fills in is a check that never fires, and this one did not, on
+      // the only send path a customer's own client uses.
+      //
+      // `ChannelNotFoundError` AND NOT A 403. SC-002 requires the answer for a
+      // private channel the caller cannot see to be byte-identical to a channel
+      // that does not exist — same status, same body but for `request_id` — and
+      // send is one of the verbs it covers. A `403 not_a_member` here would
+      // announce that the channel exists, which is the leak FR-003 forbids and
+      // exactly what the isolation harness's indistinguishability oracle was built to
+      // catch. The refusal above throws the same error for the same reason.
+      //
+      // FR-021a's ORDER is ban, then membership and visibility, then archive. The
+      // ban goes ahead of the channel read entirely, so a banned user gets one
+      // answer for every channel id; the archive check goes below this one, so a
+      // non-member of a private archived channel never learns it exists from
+      // `channel_archived`. Both arrive with their own columns' chapters; this is
+      // the middle of the three.
+      if (channel.type === "private" && userId !== undefined) {
+        const [membership] = await tx
+          .select({ userId: members.userId })
+          .from(members)
+          .where(and(eq(members.channelId, channelId), eq(members.userId, userId)))
+          .limit(1);
+        if (!membership) throw new ChannelNotFoundError(channelId);
+      }
+
+      // ARCHIVE, AFTER VISIBILITY AND NOT BEFORE (FR-020, FR-021,
+      // FR-021a).
+      //
+      // The order is the requirement, not an implementation detail. Put this check
+      // above the membership one and a non-member of a private ARCHIVED channel
+      // learns it exists from `channel_archived` — a refusal that reveals what it is
+      // refusing, which is the defect the isolation harness's fifth analysis pass caught one
+      // phase before shipping.
+      //
+      // So: ban, then membership and visibility, then archive. The ban's slot is
+      // ahead of the channel read entirely — a banned user gets one answer for every
+      // channel id, including ids that do not exist — and it is EMPTY here on
+      // purpose: `users.banned_at` has no reader until the user-surface chapter gives it one at
+      // T155a. Leaving the slot visible is the point; a reader who finds two checks
+      // where the requirement names three should be able to see which is missing.
+      //
+      // History stays readable while archived (FR-020). Only the write refuses.
+      if (channel.archivedAt !== null) throw new ChannelArchivedError(channelId);
+
       const seq = channel.lastSequence + 1;
       const id = randomUUID();
 
       const insert = tx.insert(messages).values({
         id,
         channelId: channel.id,
@@ -1018,12 +1278,91 @@ export class Repository {
    *
    * The write path has asked since 2.2 — it needs the channel row to lock —
    * so it answers a foreign id with a 404. The read path never asked: a
    * tenant-scoped query over a foreign channel simply returns no rows, and
    * the endpoint dressed that as an empty page. The milestone suite caught
    * the two doors disagreeing about the same resource. */
+  /** One channel by its id, scoped, with the two fields the by-id route reports
+   * beyond FR-CHN-01's four (FR-003a).
+   *
+   * SCOPED IN THE WHERE CLAUSE and not filtered afterwards, for the reason
+   * `addMembers` states: a foreign id and an absent one must both miss this read,
+   * so both answer alike and neither reveals the other tenant's row.
+   *
+   * THIS ROUTE DID NOT EXIST. `channels.controller.ts` carried a create and a
+   * member-add and no read, so a customer could create a channel and never read
+   * its four fields back — while SC-001 named "read by id" as one of four verbs,
+   * FR-003 said "every read", and `contracts/membership.md` had a row for it.
+   * Three artifacts resting on a handler nobody wrote (analysis pass three). */
+  async getChannelById(
+    channelId: string,
+  ): Promise<(ChannelRow & { archived_at: Date | null }) | 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}`,
+        archived_at: channels.archivedAt,
+      })
+      .from(channels)
+      .where(
+        and(
+          eq(channels.id, channelId),
+          eq(channels.environmentId, this.environmentId),
+        ),
+      );
+    return rows[0] ?? null;
+  }
+
+  /** Whether this user is a member of this channel.
+   *
+   * No environment predicate, and that is safe rather than sloppy: `members` has
+   * no `environment_id` — it is reached through `channels` and `users`, which is
+   * why the catalogue calls it a `hop` — and every caller has already read the
+   * channel scoped. A membership row for a channel this environment cannot see is
+   * unreachable because the channel id came from a scoped read. */
+  async isMember(channelId: string, userId: string): Promise<boolean> {
+    const rows = await this.db
+      .select({ userId: members.userId })
+      .from(members)
+      .where(and(eq(members.channelId, channelId), eq(members.userId, userId)))
+      .limit(1);
+    return rows.length > 0;
+  }
+
+  /** Whether this channel exists AND this caller may see it (FR-003).
+   *
+   * `channelExists` answers the first half and every read route used it. That was
+   * enough while `channels.type` decided nothing; it is not enough now, and the gap
+   * showed up as a leak rather than as a failure:
+   *
+   *     a channel that does not exist   → channelExists false → 404
+   *     a private channel, non-member   → channelExists TRUE  → 200, empty page
+   *
+   * Two different answers, so the empty page announced that the channel was there.
+   * FR-003 says every read answers identically to a channel that does not exist, and
+   * the only way to keep that is for one predicate to produce both refusals.
+   *
+   * `userId` absent means the tenant is reading, which sees everything it owns. */
+  async channelVisibleTo(channelId: string, userId?: string): Promise<boolean> {
+    const [channel] = await this.db
+      .select({ type: sql<ChannelRow["type"]>`${channels.type}` })
+      .from(channels)
+      .where(
+        and(
+          eq(channels.id, channelId),
+          eq(channels.environmentId, this.environmentId),
+        ),
+      );
+    if (!channel) return false;
+    if (channel.type !== "private" || userId === undefined) return true;
+    return this.isMember(channelId, userId);
+  }
+
   async channelExists(channelId: string): Promise<boolean> {
     const rows = await this.db
       .select({ id: channels.id })
       .from(channels)
       .where(
         and(
@@ -1046,14 +1385,56 @@ export class Repository {
   async listMessages(
     channelId: string,
     {
       beforeSeq,
       afterSeq,
       limit,
-    }: { beforeSeq?: number; afterSeq?: number; limit: number },
+      /** Who is reading (FR-002, FR-003).
+       *
+       * THIS PARAMETER DID NOT EXIST, and its absence is why the history path had
+       * nothing to check. The task said "add the same check to the history path" and
+       * there was nowhere to put a caller: this function took a channel and a page,
+       * `messages.service.history` passed neither, and the controller resolved no
+       * principal. Three places, and a gap in any one makes a check unreachable.
+       *
+       * Absent means the TENANT is reading, the same convention `sendMessage` uses
+       * — an application credential sees private channels (FR-005). */
+      userId,
+    }: {
+      beforeSeq?: number;
+      afterSeq?: number;
+      limit: number;
+      userId?: string;
+    },
   ): Promise<MessageWithSender[]> {
+    // MEMBERSHIP FIRST, WHEN A USER IS READING (FR-002, FR-003).
+    //
+    // A scoped read below would already exclude another tenant's channel; this is
+    // the case inside one tenant, where the channel exists and the reader is not a
+    // member of it. An EMPTY PAGE is the answer, and it is the same answer a channel
+    // that does not exist gives — `listMessages` has always returned `[]` for an
+    // unknown id rather than raising, so indistinguishability here is a matter of
+    // not diverging from that.
+    //
+    // ORDERED BEFORE THE PAGE QUERY so a non-member's read costs one small lookup
+    // rather than a page of rows this function then discards.
+    if (userId !== undefined) {
+      const [channel] = await this.db
+        .select({ type: sql<ChannelRow["type"]>`${channels.type}` })
+        .from(channels)
+        .where(
+          and(
+            eq(channels.id, channelId),
+            eq(channels.environmentId, this.environmentId),
+          ),
+        );
+      if (channel?.type === "private" && !(await this.isMember(channelId, userId))) {
+        return [];
+      }
+    }
+
     const columns = {
       id: messages.id,
       channel_id: messages.channelId,
       seq: messages.sequence,
       // The sender joins the read path in 2.7 (the IOU 2.6 wrote): resume
       // must emit frames identical to live ones, and a reader that gets a

Two role vocabularies, one word apart

memberships.role has been ('owner','admin','member') since the tenancy chapter — a human's role in an organisation. A channel member's roles are ('owner','moderator','member'). One word apart, and a migration that reused the organisation's constraint would accept admin on a channel member, refuse moderator, and look correct in review.

services/api/src/db/schema.ts
@@ -83,12 +83,21 @@ export const memberships = pgTable(
     joinedAt: timestamp("joined_at", { withTimezone: true })
       .notNull()
       .defaultNow(),
   },
   (t) => [
     primaryKey({ columns: [t.organisationId, t.humanId] }),
+    // A HUMAN'S ROLE IN AN ORGANISATION (FR-TEN-07), and NOT a channel role.
+    //
+    // `members.role` below is the other one: `('owner','moderator','member')`, a user's
+    // role in a channel (FR-CHN-04). Different tables, different subjects, and ONE WORD
+    // different — `admin` here, `moderator` there. A migration that reused this constraint
+    // for channel members would accept `admin` on a channel member, refuse `moderator`,
+    // and look correct in review. The channel-control chapter's research found that before writing it;
+    // the comment sits on both sides because a warning on one side is a warning the next
+    // person does not find.
     check(
       "memberships_role_check",
       sql`${t.role} IN ('owner','admin','member')`,
     ), // FR-TEN-07
   ],
 );
@@ -272,15 +281,27 @@ export const members = pgTable(
     userId: uuid("user_id")
       .notNull()
       .references(() => users.id),
     joinedAt: timestamp("joined_at", { withTimezone: true })
       .notNull()
       .defaultNow(),
+    // A USER'S ROLE IN A CHANNEL (FR-CHN-04), default `member`.
+    //
+    // The default is what lets the channel-endpoints chapter's `addMember` keep working unchanged and
+    // gives every existing row a value the CHECK accepts.
+    role: text("role").notNull().default("member"),
   },
   (t) => [
     primaryKey({ columns: [t.channelId, t.userId] }),
+    // ITS OWN CONSTRAINT, and NOT `memberships_role_check` above.
+    //
+    // `memberships.role` is `('owner','admin','member')` — a human's role in an
+    // organisation, FR-TEN-07. This one is `('owner','moderator','member')` — FR-CHN-04's
+    // three. One word apart, and reusing the other constraint here would accept `admin`
+    // on a channel member, refuse `moderator`, and read as correct in review.
+    check("members_role_check", sql`${t.role} IN ('owner','moderator','member')`),
     // Hot-path index (SAD §6.3): the resume path's "which channels am I in".
     index("members_user_channel").on(t.userId, t.channelId),
   ],
 );
 
 // The outbox (ADR-06). For the first time in Part 3 this table is

Both CHECK constraints now carry a comment naming the other one, in both directions. The migration is new, so it appears whole:

services/api/migrations/0006_member_roles.sql
-- A member's role.
--
-- FR-CHN-04 has asked for channel member roles since the SRS was written and
-- `members` has been `(channel_id, user_id, joined_at)` the whole time. The
-- isolation harness's traceability map recorded the clause as delivered,
-- described it with a paraphrase belonging to FR-CHN-06, and was corrected while
-- this chapter was being specified.
--
-- ITS OWN CHECK CONSTRAINT, AND NOT THE ONE THAT ALREADY EXISTS. `memberships`
-- has carried `CHECK (role IN ('owner','admin','member'))` since the tenancy
-- chapter — that is FR-TEN-07, a human's role in an ORGANISATION. FR-CHN-04's
-- channel roles are 'owner', 'moderator', 'member'.
--
-- Different tables, different subjects, ONE WORD DIFFERENT. A migration that
-- reused the organisation constraint here would accept `admin` on a channel
-- member, refuse `moderator`, and look correct in review. Both constraints now
-- carry a comment naming the other, because a warning on one side of a trap is
-- a warning the next person does not find (research R8).
--
-- DEFAULT 'member', which is what lets the channel endpoints' `addMember` keep
-- working unchanged and gives every existing row a value the CHECK accepts. The
-- member-add endpoint takes an optional role per entry (FR-011b) so a member
-- can be created with one rather than only changed into one.
--
-- ROLES ONLY, AND THE ORIGINAL MIGRATION CARRIED MORE. `users.deleted_at` was
-- in the same file, added while designing FR-USR-05's deletion path — and that
-- path is the next chapter's, along with `read_positions` and
-- `channels.last_activity_at`. Two migrations split by subject rather than by
-- chapter meant this one could not be applied without the other, and the other
-- held nothing this chapter uses.
ALTER TABLE members
    ADD COLUMN role TEXT NOT NULL DEFAULT 'member';
--> statement-breakpoint
 
ALTER TABLE members
    ADD CONSTRAINT members_role_check CHECK (role IN ('owner','moderator','member'));

What reads a member's role. The listing returns it. No operation is authorized by it — no permission decision anywhere in the platform consults the column. That distinction is the statement worth making, and for thirteen analysis passes the documents said "nothing reads it" while a contract's own field table returned the field. Found by putting the two side by side, which no earlier pass had done.

services/api/src/channels/channels.schema.ts
@@ -17,39 +17,114 @@ const metadataSchema = z
   .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).
+  // BOTH, AND ONLY NOW (FR-009).
   //
-  // `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.
+  // The isolation harness pinned this enum to `public` alone with the sharpest edit in that
+  // chapter, and the reason it gave was true then: `channels.type` had been a
+  // `"public" | "private"` column with a CHECK since chapter 2.1 and NOTHING
+  // DECIDED ON IT. An endpoint accepting `private` would have sold a guarantee the
+  // platform did not keep.
   //
-  // 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"]),
+  // ONE SENTENCE IN THAT COMMENT WAS WRONG, and it shipped for three chapters:
+  // "there is no membership check on any read path". `repository.backfill` joins
+  // `members` on the caller's user id, and `session.controller` builds its channel
+  // list from `channelsForUser` — both are read paths and both check membership.
+  // What was true is narrower: the PUBLIC history and send routes checked nothing,
+  // and `POST /internal/messages` resolved a user and checked nothing. Corrected
+  // here under FR-037, in the same edit that widens the enum, because a false
+  // sentence inside a titled fence cannot wait for a later phase.
+  //
+  // WIDENED LAST, DELIBERATELY. FR-009 gates this on FR-001 to FR-003 holding
+  // first: the send path refuses a non-member, the by-id read and history answer
+  // as if the channel were absent, and the socket's session never carries it. All
+  // four are in place before this line changed. Reversed, the platform would sell
+  // the guarantee before keeping it — which is the mistake the isolation harness's fifth
+  // analysis pass caught one phase before it shipped.
+  type: z.enum(["public", "private"]),
   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. */
+/** FR-CHN-04's three, and NOT `memberships`' three (FR-011).
+ *
+ * `memberships.role` is `('owner','admin','member')` — a human's role in an
+ * organisation, FR-TEN-07. This is a user's role in a CHANNEL. One word apart, and
+ * the database CHECK names these three so a request that gets past this enum still
+ * cannot write `admin`. */
+export const CHANNEL_ROLES = ["owner", "moderator", "member"] as const;
+export const channelRoleSchema = z.enum(CHANNEL_ROLES);
+export type ChannelRole = z.infer<typeof channelRoleSchema>;
+
+/** An entry in the add body: a bare external id, or an id with a role.
+ *
+ * A UNION RATHER THAN A NEW SHAPE, because the channel-endpoints chapter shipped
+ * `{"user_ids": ["a", "b"]}` and a customer's server is sending that today. FR-011b
+ * asks that a member be creatable WITH a role — US6's first scenario — and the
+ * cheapest honest way is for an entry to be either form:
+ *
+ *     {"user_ids": ["a", {"user": "b", "role": "owner"}]}
+ *
+ * `contracts/membership.md` proposed renaming the field to `users`, which would
+ * have been a breaking change to a shipped route decided in an analysis pass. The
+ * shipped name wins and the contract is corrected. */
+export const addMemberEntrySchema = z.union([
+  z.string().min(1).max(255),
+  z.strictObject({
+    user: z.string().min(1).max(255),
+    role: channelRoleSchema.optional(),
+  }),
+]);
+
 export const addMembersBodySchema = z.strictObject({
+  user_ids: z.array(addMemberEntrySchema).min(1).max(100),
+});
+
+/** One entry, normalised. The union above is for the wire; nothing downstream should
+ * have to ask which form arrived. */
+export function normaliseEntry(
+  entry: z.infer<typeof addMemberEntrySchema>,
+): { user: string; role?: ChannelRole | undefined } {
+  // `| undefined` explicitly, because `exactOptionalPropertyTypes` is on (ADR-15's
+  // strictness, chapter 1.4): an optional property and a property that may hold
+  // `undefined` are different types here, and the union's object arm produces the
+  // second.
+  return typeof entry === "string" ? { user: entry } : entry;
+}
+
+/** FR-006's page: at most 100 users in one removal, the same bound as the add.
+ *
+ * THE SAME NUMBER FOR THE SAME REASON, not by symmetry. Both routes take a list a
+ * customer's server assembled, and a bound that differed between them would be two
+ * numbers to remember for one concept. `strictObject`, so a misspelled key is a
+ * refusal rather than a silently ignored typo. */
+export const removeMembersBodySchema = 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;
+
+export type RemoveMembersBody = z.infer<typeof removeMembersBodySchema>;
+
+/** The `PATCH` body for one member's role (FR-011).
+ *
+ * `strictObject` and a required `role`: a PATCH with an empty body would be a
+ * request that asks for nothing, and answering it 200 would be a lie about having
+ * changed something. */
+export const setMemberRoleBodySchema = z.strictObject({
+  role: channelRoleSchema,
+});
+
+export type SetMemberRoleBody = z.infer<typeof setMemberRoleBodySchema>;
services/api/src/channels/channels.service.ts
@@ -1,10 +1,18 @@
 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";
+import { protocolError } from "../protocol-error";
+import {
+  CHANNEL_MEMBER_LIMIT,
+  normaliseEntry,
+  type AddMembersBody,
+  type ChannelRole,
+  type CreateChannelBody,
+  type RemoveMembersBody,
+} 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
@@ -20,16 +28,27 @@ export interface CreatedChannel {
    * 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 MemberRemoval {
+  external_id: string;
+  /** `removed` if a membership row went away, `not_a_member` otherwise — including
+   * when the external id belongs to no user this tenant knows. */
+  result: "removed" | "not_a_member";
+}
+
 export interface MemberResult {
   user_id: string;
   external_id: string;
   status: "added" | "already_a_member";
+  /** What role the member holds AFTER the call — read back, not
+   * echoed, so an `already_a_member` reports the role they already had rather than
+   * the one the request asked for. Adding is not changing. */
+  role: string;
 }
 
 @Injectable()
 export class ChannelsService {
   constructor(private readonly repo: Repository) {}
 
@@ -41,12 +60,171 @@ export class ChannelsService {
       body.metadata,
     );
     const { created, ...channel } = result;
     return { channel, created };
   }
 
+  /** One channel by id, with the caller's membership (FR-003a, FR-003).
+   *
+   * THE ANSWER FOR A PRIVATE CHANNEL THE CALLER CANNOT SEE IS THE NOT-FOUND
+   * ENVELOPE, and it has to be byte-identical to the answer for a channel that does
+   * not exist — SC-002, over the same oracle the isolation harness built for cross-tenant
+   * pairs. A `403` naming the membership would announce that the channel exists,
+   * which is what FR-003 forbids. So both paths raise the same exception with the
+   * same constant message, and the message names no id: echoing it back would make
+   * the two answers differ, and different is itself a disclosure.
+   *
+   * `userId` ABSENT MEANS THE TENANT IS ASKING. An application credential acts for
+   * the customer, carries no user, and sees private channels (FR-005) — so it gets
+   * the row and a membership of `null`, because the tenant is not a member of
+   * anything. A user token gets `true` or `false`.
+   *
+   * FR-004's answer for the other type: a `public` channel is readable by any
+   * authenticated user of the tenant, member or not. The column decides something
+   * only because the two types differ. */
+  async read(
+    channelId: string,
+    userId?: string,
+  ): Promise<{
+    channel: ChannelRow & { archived_at: Date | null };
+    isMember: boolean | null;
+  }> {
+    const channel = await this.repo.getChannelById(channelId);
+    if (!channel) throw this.notFound();
+
+    const isMember =
+      userId === undefined ? null : await this.repo.isMember(channelId, userId);
+
+    if (channel.type === "private" && isMember === false) throw this.notFound();
+
+    return { channel, isMember };
+  }
+
+  /** Remove members by EXTERNAL id, reporting each (FR-006, FR-007).
+   *
+   * THE CHANNEL IS READ SCOPED FIRST, the same ordering `addMembers` states below
+   * and for the same reason: a foreign channel id and one that exists nowhere both
+   * fail that read, so both answer alike and neither reveals the other tenant's
+   * channel. A private channel the caller cannot see answers the same way — this is
+   * a tenant credential's route, and the tenant sees its own private channels
+   * (FR-005), so in practice only absence and foreignness refuse here.
+   *
+   * A USER THAT DOES NOT EXIST REPORTS `not_a_member`, per entry, rather than
+   * failing the request. It is not a member — that is simply true — and answering
+   * anything else would make this route a membership oracle for user ids: a caller
+   * could sweep external ids and learn which ones this tenant knows. One bad entry
+   * must not refuse the other ninety-nine.
+   */
+  async removeMembers(
+    channelId: string,
+    body: RemoveMembersBody,
+  ): Promise<MemberRemoval[]> {
+    if (!(await this.repo.channelExists(channelId))) throw this.notFound();
+
+    // External ids to row ids, and the ones with no row are already answered: no
+    // user, no membership. `getUserByExternalId` is scoped, so an id belonging to
+    // another tenant resolves to nothing here — which is the same answer as an id
+    // belonging to nobody, and deliberately so.
+    const resolved = new Map<string, string | null>();
+    for (const externalId of body.user_ids) {
+      const user = await this.repo.getUserByExternalId(externalId);
+      resolved.set(externalId, user?.id ?? null);
+    }
+
+    const ids = [...resolved.values()].filter((id): id is string => id !== null);
+    const outcomes = await this.repo.removeMembers(channelId, ids);
+
+    return body.user_ids.map((externalId) => {
+      const id = resolved.get(externalId) ?? null;
+      return {
+        external_id: externalId,
+        result: id === null ? "not_a_member" : (outcomes.get(id) ?? "not_a_member"),
+      };
+    });
+  }
+
+  /** Archive and unarchive (FR-020, FR-020a).
+   *
+   * Both answer 200 whether or not the state changed. "Already archived" is not an
+   * error: the customer asked for the channel to be archived and it is. What DOES
+   * refuse is a channel that is not there — the same not-found every other route
+   * here gives, so absence and foreignness stay one answer.
+   */
+  async setArchived(channelId: string, archived: boolean): Promise<{ archived: boolean }> {
+    const found = archived
+      ? await this.repo.archiveChannel(channelId)
+      : await this.repo.unarchiveChannel(channelId);
+    if (!found) throw this.notFound();
+    return { archived };
+  }
+
+  /** Set one member's role by external id (FR-011).
+   *
+   * THE CHANNEL FIRST, then the user, then the membership — each refusing with the
+   * same not-found so the three cases are one answer from outside. A caller who can
+   * tell "no such channel" from "no such user" from "not a member" has a probe.
+   */
+  async setMemberRole(
+    channelId: string,
+    userExternalId: string,
+    role: ChannelRole,
+  ): Promise<{ external_id: string; role: ChannelRole }> {
+    if (!(await this.repo.channelExists(channelId))) throw this.notFound();
+    const user = await this.repo.getUserByExternalId(userExternalId);
+    if (!user) throw this.notFound();
+    const outcome = await this.repo.setMemberRole(channelId, user.id, role);
+    if (outcome === "not_a_member") throw this.notFound();
+    return { external_id: userExternalId, role };
+  }
+
+  /** A user joining a channel themselves (FR-CHN-03).
+   *
+   * FR-CHN-03's exact words are that any authenticated user of the tenant "may read
+   * and join" a public channel, and JOIN is the hard half: reading needs no new
+   * operation, joining is a user acting on their own behalf rather than the tenant
+   * adding somebody. `POST …/members` is the tenant's route; this is the user's.
+   *
+   * A PRIVATE CHANNEL ANSWERS AS IF ABSENT. Not "you may not join" — that would
+   * announce it exists, and joining is one of the verbs SC-001 covers.
+   *
+   * THE CEILING IS READ, NOT REIMPLEMENTED. THE CHANNEL-ENDPOINTS CHAPTER counts members from
+   * storage and refuses at 1,000 with `channel_member_limit_exceeded`; a second
+   * count with its own limit here would be a second answer to one question. */
+  async join(channelId: string, userId: string): Promise<"joined" | "already_a_member"> {
+    const channel = await this.repo.getChannelById(channelId);
+    if (!channel) throw this.notFound();
+    if (channel.type === "private") throw this.notFound();
+
+    if (await this.repo.isMember(channelId, userId)) return "already_a_member";
+
+    const existing = await this.repo.countMembers(channelId);
+    if (existing + 1 > CHANNEL_MEMBER_LIMIT) {
+      throw protocolError(
+        "channel_member_limit_exceeded",
+        `this channel holds ${existing} of ${CHANNEL_MEMBER_LIMIT} members; ` +
+          `joining would exceed the limit`,
+        HttpStatus.UNPROCESSABLE_ENTITY,
+      );
+    }
+
+    const outcome = await this.repo.addMember(channelId, userId);
+    // `not_found` here means the channel went away between two statements of one
+    // call, which nothing in the api can do — it is the same unconstructable state
+    // `createChannel` and `createUser` throw for.
+    if (outcome === "not_found") throw this.notFound();
+    return outcome === "added" ? "joined" : "already_a_member";
+  }
+
+  /** The one refusal shape for "you cannot see this", used by every read here.
+   *
+   * A CONSTANT MESSAGE, for the reason `addMembers` gives below: a message carrying
+   * the id makes the foreign-id answer differ from the absent-id answer. */
+  private notFound(): Error {
+    return new NotFoundException("channel not found");
+  }
+
   /** 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
@@ -75,20 +253,35 @@ export class ChannelsService {
         },
         HttpStatus.UNPROCESSABLE_ENTITY,
       );
     }
 
     const results: MemberResult[] = [];
-    for (const externalId of body.user_ids) {
+    for (const entry of body.user_ids) {
+      // An entry is a bare external id or an id with a role (FR-011b). Normalised
+      // once here so nothing below has to ask which form arrived.
+      const { user: externalId, role } = normaliseEntry(entry);
       const user = await this.repo.createUser(externalId);
-      const outcome = await this.repo.addMember(channelId, user.id);
+      const outcome = await this.repo.addMember(
+        channelId,
+        user.id,
+        ...(role === undefined ? [] : ([role] as const)),
+      );
       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 });
+      results.push({
+        user_id: user.id,
+        external_id: externalId,
+        status: outcome,
+        // The role the member ends up with, read back rather than echoed: on an
+        // `already_a_member` the request's role is NOT applied, because adding is
+        // not changing. `PATCH` is the route that changes one.
+        role: (await this.repo.memberRole(channelId, user.id)) ?? "member",
+      });
     }
     return results;
   }
 }
services/api/src/channels/channels.controller.ts
@@ -1,13 +1,39 @@
-import { Body, Controller, HttpCode, Param, Post, Res, UseGuards } from "@nestjs/common";
+import {
+  BadRequestException,
+  Body,
+  Controller,
+  Delete,
+  Get,
+  HttpCode,
+  HttpStatus,
+  Param,
+  Patch,
+  Post,
+  Req,
+  Res,
+  UseGuards,
+} from "@nestjs/common";
 
 import { Accepts, CredentialGuard } from "../auth/credential.guard";
+import type { RequestWithPrincipal } from "../auth/principal";
+import { Repository } from "../db/repository";
 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";
+import {
+  addMembersBodySchema,
+  createChannelBodySchema,
+  removeMembersBodySchema,
+  setMemberRoleBodySchema,
+} from "./channels.schema";
+import type {
+  AddMembersBody,
+  CreateChannelBody,
+  RemoveMembersBody,
+  SetMemberRoleBody,
+} 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
@@ -24,13 +50,18 @@ interface HttpResponse {
 }
 
 @Controller("v1/channels")
 @UseGuards(CredentialGuard)
 @Accepts("application")
 export class ChannelsController {
-  constructor(private readonly channels: ChannelsService) {}
+  constructor(
+    private readonly channels: ChannelsService,
+    // For resolving a user token's subject to a row id. The service takes an id
+    // because the repository's membership lookup is keyed on it.
+    private readonly repo: Repository,
+  ) {}
 
   /** 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
@@ -49,12 +80,140 @@ export class ChannelsController {
       type: channel.type,
       name: channel.name,
       metadata: channel.metadata,
     };
   }
 
+  /** One channel by id (FR-003a).
+   *
+   * THIS ROUTE DID NOT EXIST, and three artifacts assumed it did: SC-001 named
+   * "read by id" as one of four verbs a non-member must not reach, FR-003 said
+   * "every read", and `contracts/membership.md` had a row for it. A customer could
+   * create a channel and never read its four fields back.
+   *
+   * `@Accepts("application", "user")` AT THE METHOD, overriding the class's
+   * `"application"`. `credential.guard.ts` resolves
+   * `getAllAndOverride([handler, class])`, so a method-level decorator wins — the
+   * pattern `dev-token.controller.ts` already uses. Both classes belong here: the
+   * tenant reads any of its channels (FR-005), and a user reads the ones they may
+   * see, which is what makes `channels.type` decide something.
+   */
+  @Get(":channelId")
+  @Accepts("application", "user")
+  async read(
+    @Param("channelId") channelId: string,
+    @Req() req: RequestWithPrincipal,
+  ) {
+    const actingExternalId =
+      req.principal?.kind === "user" ? req.principal.userExternalId : undefined;
+    let userId: string | undefined;
+    if (actingExternalId !== undefined) {
+      const user = await this.repo.getUserByExternalId(actingExternalId);
+      // A user token for an identifier with no row is a member of nothing. Refusing
+      // rather than reading as the tenant is the same choice the send path makes,
+      // and FR-039a removes the case by creating the row when the token is minted.
+      if (!user) throw new BadRequestException("unknown user");
+      userId = user.id;
+    }
+    const { channel, isMember } = await this.channels.read(channelId, userId);
+    return {
+      id: channel.id,
+      external_id: channel.external_id,
+      type: channel.type,
+      name: channel.name,
+      metadata: channel.metadata,
+      archived_at: channel.archived_at?.toISOString() ?? null,
+      // `null` when the tenant is asking: an application credential is not a member
+      // of anything, and reporting `false` would imply it could become one.
+      is_member: isMember,
+    };
+  }
+
+  /** Archive and unarchive (FR-020, FR-020a).
+   *
+   * ACTION-STYLE, and a pair rather than a `PATCH` with a boolean: "archive this"
+   * and "unarchive this" are two things a customer does, and a body carrying
+   * `{"archived": false}` makes the client assemble a state instead of naming an
+   * action. `POST …/ban` and `POST …/members/remove` take the same shape.
+   *
+   * The tenant's routes. A member does not archive the channel they are in.
+   */
+  @Post(":channelId/archive")
+  @HttpCode(HttpStatus.OK)
+  async archive(@Param("channelId") channelId: string) {
+    return this.channels.setArchived(channelId, true);
+  }
+
+  @Delete(":channelId/archive")
+  @HttpCode(HttpStatus.OK)
+  async unarchive(@Param("channelId") channelId: string) {
+    return this.channels.setArchived(channelId, false);
+  }
+
+  /** One member's role (FR-011, FR-011a).
+   *
+   * The tenant's route: an application credential decides who moderates. A member
+   * cannot promote themselves, which is why this is not `@Accepts("user")` like
+   * join.
+   */
+  @Patch(":channelId/members/:userExternalId")
+  async setMemberRole(
+    @Param("channelId") channelId: string,
+    @Param("userExternalId") userExternalId: string,
+    @Body(new ZodValidationPipe(setMemberRoleBodySchema)) body: SetMemberRoleBody,
+  ) {
+    return this.channels.setMemberRole(channelId, userExternalId, body.role);
+  }
+
+  /** Remove members, up to a hundred, reporting each (FR-006, FR-007).
+   *
+   * AN ACTION-STYLE `POST`, NOT `DELETE` WITH A BODY. A body on `DELETE` is legal
+   * and unreliable — proxies and some clients drop it — and this feature already
+   * sets the action-style precedent with `POST …/archive` and `POST …/ban`. The
+   * singular `DELETE …/members/:userExternalId` the contract carried for ten
+   * analysis passes is gone rather than kept beside this: "up to 100" covers one,
+   * and two routes for one job is two classification entries, two tests and two
+   * chances to disagree.
+   */
+  @Post(":channelId/members/remove")
+  @HttpCode(HttpStatus.OK)
+  async removeMembers(
+    @Param("channelId") channelId: string,
+    @Body(new ZodValidationPipe(removeMembersBodySchema)) body: RemoveMembersBody,
+  ) {
+    return { results: await this.channels.removeMembers(channelId, body) };
+  }
+
+  /** The user-initiated half of FR-CHN-03.
+   *
+   * `@Accepts("user")` AT THE METHOD, overriding the class's `"application"`. This
+   * is the caller joining, not the tenant adding someone, so an application key has
+   * no business here — it carries no user to join. `credential.guard.ts` resolves
+   * `getAllAndOverride([handler, class])`, so the method wins.
+   *
+   * Without this decorator every user's join would be a 403, which is the
+   * isolation harness's FR-044 hole exactly: a credential mismatch that passed
+   * chapter and then turned nine of fifteen tests red.
+   */
+  @Post(":channelId/join")
+  @HttpCode(HttpStatus.OK)
+  @Accepts("user")
+  async join(
+    @Param("channelId") channelId: string,
+    @Req() req: RequestWithPrincipal,
+  ) {
+    // The guard has already refused anything that is not a user principal, so this
+    // is narrowing for the type system rather than for trust.
+    if (req.principal?.kind !== "user") {
+      throw new BadRequestException("joining is an end user's action");
+    }
+    const user = await this.repo.getUserByExternalId(req.principal.userExternalId);
+    if (!user) throw new BadRequestException("unknown user");
+    return { result: await this.channels.join(channelId, user.id) };
+  }
+
   /** 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")

The add body is a union rather than a new shape. The previous chapter shipped {"user_ids": ["a", "b"]} and a customer's server is sending that today, so an entry is either a bare external id or an id with a role:

{"user_ids": ["ana", {"user": "bo", "role": "owner"}]}

A contract written in an analysis pass proposed renaming the field to users, which would have been a breaking change to a shipped route decided in a review. The shipped name wins and the contract was corrected.

Removal is a POST to .../members/remove, not a DELETE with an id in the path. The requirement says up to 100 users in one request and reports per user, which is the same shape the previous chapter chose for the add. A path segment holds one id.

The enum, last

Now the widening, in the same edit that fixes a sentence:

type: z.enum(["public", "private"]),

The previous chapter's comment on that field said "there is no membership check on any read path". That was false when it was written and it shipped for three chapters. repository.backfill joins members on the caller's user id, and the socket session builds its channel list from channelsForUser — both are read paths and both check membership. What was true is narrower: the public history and send routes checked nothing, and the internal send resolved a user and checked nothing.

The correction lives inside the fence above, beside the edit that widens the enum, because a false sentence inside a titled fence cannot wait for a later phase — the chapter that publishes the file publishes the sentence.

The attack the gauntlet had no fixture for

The isolation harness built four attack shapes over one oracle. All four take another tenant's identifiers. Every one of them is useless here: the attacker this chapter needs is a user of your own tenant who is not a member.

flowchart TB
    old["THE FOUR SHAPES CHAPTER 3.12 BUILT<br/>all take ANOTHER tenant's identifiers"]
    old --> s1["a foreign id on a tenant credential"]
    old --> s2["a foreign id on a user token"]
    old --> s3["a credential from another environment"]
    old --> s4["a socket frame naming a foreign channel"]
    new["THE SHAPE IT HAD NO FIXTURE FOR<br/>your OWN tenant's private channel,<br/>and you are not a member"]
    new --> pair["the pair: that id, and an id that exists nowhere"]
    pair --> oracle["withoutRequestId — byte-identical or the test fails"]
    ctrl["AND THE CONTROL: a member's token,<br/>the same channel, 200"]
    ctrl --> why["without it, two refusals for<br/>an unrelated reason also match"]
    style new fill:#1e3a5f,color:#fff,stroke:#3b82f6
    style why fill:#7f1d1d,color:#fff,stroke:#dc2626
    style oracle fill:#064e3b,color:#fff,stroke:#059669
Four cross-tenant shapes, one same-tenant shape, and the control without which none of them mean anything
services/api/src/isolation/fixtures.ts
@@ -71,6 +71,137 @@ async function seedTenant(db: Db, label: string): Promise<Tenant> {
  * failed to seed instead of rejecting whichever lost the race. */
 export async function seedTwoTenants(db: Db): Promise<TwoTenants> {
   const attacker = await seedTenant(db, `attacker-${Date.now().toString(36)}`);
   const victim = await seedTenant(db, `victim-${Date.now().toString(36)}`);
   return { attacker, victim };
 }
+
+/** A well-formed identifier belonging to nobody — the other half of every pair.
+ *
+ * It has to be a valid uuid, or the endpoint refuses it for the wrong reason: a
+ * malformed id is a 400 from validation and a foreign id is a 404 from the
+ * repository, and comparing those two would pass a suite that proves nothing. */
+export function nowhereId(): string {
+  return "00000000-0000-4000-8000-" + Math.random().toString(16).slice(2, 14).padEnd(12, "0");
+}
+
+/** ONE TENANT, TWO USERS, AND A PRIVATE CHANNEL ONE OF THEM IS NOT IN.
+ *
+ * FR-034. `seedTwoTenants` above gives every attack a victim in
+ * ANOTHER environment, and all four attack shapes take an identifier that does not
+ * exist in the attacker's own tenant. A non-member of your OWN tenant is a
+ * different case entirely: the channel is right there, the environment predicate
+ * passes, and the only thing standing between the caller and the rows is a
+ * membership check this chapter wrote.
+ *
+ * So this is new work rather than a reuse, and the suite had no fixture for it —
+ * measured before building it, which is why FR-034 says so.
+ */
+export interface SameTenant {
+  environmentId: string;
+  credential: string;
+  /** A member of the private channel. The control's subject. */
+  member: { id: string; externalId: string; token: string };
+  /** A user of the same tenant who is NOT a member of it. The attacker. */
+  stranger: { id: string; externalId: string; token: string };
+  privateChannelId: string;
+  publicChannelId: string;
+  /** A message the member wrote, so a read attack has something to fail to find. */
+  messageId: string;
+  repo: Repository;
+}
+
+export async function seedSameTenant(db: Db, mintToken: MintToken): Promise<SameTenant> {
+  const stamp = Math.random().toString(36).slice(2, 8);
+  const environment = await createEnvironment(db, { name: `iso-same-${stamp}` });
+  const key = await createApiKey(db, { environmentId: environment.id });
+  const repo = new Repository(db, environment.id);
+
+  const member = await repo.createUser(`same-${stamp}-member`, "A Member");
+  const stranger = await repo.createUser(`same-${stamp}-stranger`, "A Stranger");
+  const privateChannel = await repo.createChannel(`same-${stamp}-private`, "private");
+  const publicChannel = await repo.createChannel(`same-${stamp}-public`, "public");
+  await repo.addMember(privateChannel.id, member.id);
+  const message = await repo.sendMessage(privateChannel.id, {
+    text: "written by a member",
+    userId: member.id,
+    userExternalId: member.external_id,
+  });
+
+  return {
+    environmentId: environment.id,
+    credential: key.credential,
+    member: {
+      id: member.id,
+      externalId: member.external_id,
+      token: await mintToken(environment.id, member.external_id),
+    },
+    stranger: {
+      id: stranger.id,
+      externalId: stranger.external_id,
+      token: await mintToken(environment.id, stranger.external_id),
+    },
+    privateChannelId: privateChannel.id,
+    publicChannelId: publicChannel.id,
+    messageId: message.id,
+    repo,
+  };
+}
+
+/** THE SAME `external_id` IN TWO ENVIRONMENTS, ONE PUBLIC AND ONE PRIVATE.
+ *
+ * FR-034a. `seedTenant` above label-prefixes every identifier —
+ * `${label}-channel` — so the two tenants it mints never share one, and all four
+ * attack shapes take an id that does NOT exist in the attacker's tenant. The case
+ * where the same STRING resolves in both, to channels of different types, has no
+ * fixture at all.
+ *
+ * AND THIS FEATURE IS WHAT MAKES IT WORTH TESTING. Before it, both channels would
+ * have been `public` and the two answers matched trivially. Now one tenant's user
+ * gets 200 for a string that another tenant's non-member gets 404 for — and the
+ * reason must be the TYPE, not the tenant. A route that resolved an external id
+ * without scoping would cross the boundary and look correct doing it.
+ */
+export interface CollidingTenants {
+  /** The tenant whose channel of this name is public. */
+  open: { environmentId: string; credential: string; token: string; channelId: string };
+  /** The tenant whose channel of the SAME name is private, with a non-member. */
+  closed: { environmentId: string; credential: string; token: string; channelId: string };
+  /** The one external id both channels carry. */
+  sharedExternalId: string;
+}
+
+export async function seedCollidingTenants(
+  db: Db,
+  mintToken: MintToken,
+): Promise<CollidingTenants> {
+  const stamp = Math.random().toString(36).slice(2, 8);
+  const sharedExternalId = `collide-${stamp}`;
+
+  const seed = async (label: string, type: "public" | "private") => {
+    const environment = await createEnvironment(db, { name: `iso-collide-${label}-${stamp}` });
+    const key = await createApiKey(db, { environmentId: environment.id });
+    const repo = new Repository(db, environment.id);
+    const userExternalId = `collide-${label}-${stamp}-user`;
+    const user = await repo.createUser(userExternalId);
+    // THE SAME external id in both environments. `DR-02` makes it unique per
+    // environment, which is exactly the property under test.
+    const channel = await repo.createChannel(sharedExternalId, type);
+    // The `public` tenant's user is deliberately NOT a member either: this fixture
+    // is about type deciding the answer, and membership would confound it.
+    return {
+      environmentId: environment.id,
+      credential: key.credential,
+      token: await mintToken(environment.id, userExternalId),
+      channelId: channel.id,
+      userId: user.id,
+    };
+  };
+
+  const [open, closed] = await Promise.all([seed("open", "public"), seed("closed", "private")]);
+  return { open, closed, sharedExternalId };
+}
+
+/** How a fixture mints an end-user token. Injected rather than imported so
+ * `fixtures.ts` stays free of the auth module — the gauntlet's api-side fixtures
+ * have never needed it, and the two suites that do want tokens already know how. */
+export type MintToken = (environmentId: string, userExternalId: string) => Promise<string>;
services/api/src/isolation/gauntlet.itest.ts
@@ -2,15 +2,26 @@ import "reflect-metadata";
 
 import type { INestApplication } from "@nestjs/common";
 import { Test } from "@nestjs/testing";
 import { afterAll, beforeAll, describe, expect, it } from "vitest";
 
 import { AppModule } from "../app.module";
+import { mintUserToken } from "../auth/user-token";
+import { environmentSigningSecret } from "../db/repository";
 import { createDb, createPool } from "../db/client";
 import { credentialAttack, readAttack, writeAttack } from "./attack";
-import { seedTwoTenants, type TwoTenants } from "./fixtures";
+import { withoutRequestId } from "./compare";
+import {
+  nowhereId,
+  seedCollidingTenants,
+  seedSameTenant,
+  seedTwoTenants,
+  type CollidingTenants,
+  type SameTenant,
+  type TwoTenants,
+} from "./fixtures";
 import { CLASSIFICATIONS, targetKey } from "./targets";
 
 import type { Db } from "../db/client";
 
 // THE GAUNTLET (NFR-SEC-09, constitution I).
 //
@@ -32,18 +43,35 @@ describe("the isolation gauntlet", () => {
   let url: string;
   let db: Db;
   let pool: ReturnType<typeof createPool>;
   let t: TwoTenants;
   /** A token minted with the ATTACKER's key, for the routes that take one. */
   let attackerToken: string;
+  let same: SameTenant;
+  let colliding: CollidingTenants;
   const attacked = new Set<string>();
 
   beforeAll(async () => {
     pool = createPool();
     db = createDb(pool);
     t = await seedTwoTenants(db);
+    // A token minter the fixtures can call without `fixtures.ts` importing the auth
+    // module: it has never needed to, and the two shapes this chapter adds are the
+    // only ones that want tokens.
+    const mintForFixture = async (environmentId: string, userExternalId: string) => {
+      const secret = (await environmentSigningSecret(db, environmentId))!.signingSecret;
+      return (
+        await mintUserToken(secret, {
+          user: userExternalId,
+          environmentId,
+          ttlSeconds: 3600,
+        })
+      ).token;
+    };
+    same = await seedSameTenant(db, mintForFixture);
+    colliding = await seedCollidingTenants(db, mintForFixture);
     app = (
       await Test.createTestingModule({ imports: [AppModule] }).compile()
     ).createNestApplication({ logger: false });
     await app.listen(0);
     url = await app.getUrl();
 
@@ -227,12 +255,229 @@ describe("the isolation gauntlet", () => {
     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);
   });
 
+  // ── THE SAME-TENANT NON-MEMBER (FR-034, SC-015) ──────────────
+  //
+  // Every attack above crosses a tenant boundary. This block does not, and that is
+  // the case constitution I's suite never had: the channel is in the caller's own
+  // environment, the environment predicate passes, and the only thing between the
+  // caller and the rows is a membership check this chapter wrote.
+  //
+  // THE PAIR IS THE SAME SHAPE AS EVERY OTHER ONE HERE — the private channel the
+  // caller cannot see against an id that exists nowhere — because SC-002 asks for
+  // the same property inside a tenant that FR-TEN-05 asks for across t.
+  describe("same tenant, not a member", () => {
+    const asUser = (token: string, method: string, path: string, body?: unknown) =>
+      fetch(`${url}${path}`, {
+        method,
+        headers: {
+          authorization: `Bearer ${token}`,
+          ...(body === undefined ? {} : { "content-type": "application/json" }),
+        },
+        ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+      });
+
+    // ── the control, for the reason the cross-tenant block needed one ─────────
+    //
+    // Three of the four assertions below say NOTHING HAPPENED. A token the guard
+    // rejects outright makes nothing happen too, and would pass all of them while
+    // testing no membership at all. So the MEMBER is shown to work first.
+    describe("the control: the member's token works on the same channel", () => {
+      it("reads it by id", async () => {
+        const res = await asUser(same.member.token, "GET", `/v1/channels/${same.privateChannelId}`);
+        expect(res.status).toBe(200);
+        expect(await res.json()).toMatchObject({ is_member: true });
+      });
+
+      it("reads its history", async () => {
+        const res = await asUser(
+          same.member.token,
+          "GET",
+          `/v1/channels/${same.privateChannelId}/messages?limit=10`,
+        );
+        expect(res.status).toBe(200);
+        expect(((await res.json()) as { messages: unknown[] }).messages.length).toBeGreaterThan(0);
+      });
+
+      it("sends into it", async () => {
+        const res = await asUser(
+          same.member.token,
+          "POST",
+          `/v1/channels/${same.privateChannelId}/messages`,
+          { text: "the control speaks" },
+        );
+        expect(res.status).toBe(201);
+      });
+    });
+
+    const verbs: ReadonlyArray<
+      readonly [string, string, (channel: string) => string, unknown?]
+    > = [
+      ["read by id", "GET", (c) => `/v1/channels/${c}`],
+      ["read history", "GET", (c) => `/v1/channels/${c}/messages?limit=10`],
+      ["send", "POST", (c) => `/v1/channels/${c}/messages`, { text: "not mine" }],
+      ["join", "POST", (c) => `/v1/channels/${c}/join`],
+    ];
+
+    for (const [name, method, path, body] of verbs) {
+      it(`${name}: the private channel answers as an id that exists nowhere`, async () => {
+        const refused = await asUser(same.stranger.token, method, path(same.privateChannelId), body);
+        const absent = await asUser(same.stranger.token, method, path(nowhereId()), body);
+        expect(refused.status).toBe(absent.status);
+        // The bodies too, `request_id` excepted. Matching statuses is the easy half
+        // and says nothing on its own — the isolation harness's oracle exists because of it.
+        const a = withoutRequestId(await refused.json());
+        const b = withoutRequestId(await absent.json());
+        expect(a).toEqual(b);
+      });
+    }
+
+    it("the private channel gained nothing from the refused send", async () => {
+      // Read the state rather than infer it from the refusal. A refusal that wrote
+      // the row anyway is the failure this assertion exists for.
+      const history = await asUser(
+        same.member.token,
+        "GET",
+        `/v1/channels/${same.privateChannelId}/messages?limit=100`,
+      );
+      const body = (await history.json()) as { messages: { text: string | null }[] };
+      expect(body.messages.some((m) => m.text === "not mine")).toBe(false);
+    });
+
+    it("a PUBLIC channel of the same tenant is open to the same non-member (FR-004)", async () => {
+      // The other half of what makes `channels.type` decide something. If both types
+      // refused, the column would still be deciding nothing.
+      const res = await asUser(same.stranger.token, "GET", `/v1/channels/${same.publicChannelId}`);
+      expect(res.status).toBe(200);
+      expect(await res.json()).toMatchObject({ is_member: false });
+    });
+  });
+
+  // ── THE IDENTIFIER COLLISION (FR-034a) ─────────────────────────────────────
+  //
+  // The same `external_id` in two environments, `public` in one and `private` in the
+  // other. `seedTenant` label-prefixes every id, so the pair it mints can never
+  // collide — and all four attack shapes take an id that does NOT exist in the
+  // attacker's tenant. The case where the same STRING resolves in both had no
+  // fixture at all (analysis pass twelve).
+  describe("the same external id in two tenants, one public and one private", () => {
+    it("resolves to each tenant's own channel and never the other's", async () => {
+      const open = await fetch(`${url}/v1/channels`, {
+        method: "POST",
+        headers: {
+          "content-type": "application/json",
+          authorization: `Bearer ${colliding.open.credential}`,
+        },
+        body: JSON.stringify({ external_id: colliding.sharedExternalId, type: "public" }),
+      });
+      // The idempotent repeat returns the tenant's OWN channel, not the other's.
+      expect(open.status).toBe(200);
+      expect(await open.json()).toMatchObject({ id: colliding.open.channelId });
+
+      const closed = await fetch(`${url}/v1/channels`, {
+        method: "POST",
+        headers: {
+          "content-type": "application/json",
+          authorization: `Bearer ${colliding.closed.credential}`,
+        },
+        body: JSON.stringify({ external_id: colliding.sharedExternalId, type: "private" }),
+      });
+      expect(closed.status).toBe(200);
+      expect(await closed.json()).toMatchObject({ id: colliding.closed.channelId });
+    });
+
+    it("answers the two tenants' users differently, and the TYPE is why", async () => {
+      // The public tenant's non-member reads their channel; the private tenant's
+      // non-member cannot read theirs. Same external id, different answer — and the
+      // difference has to be the type rather than the tenant, which is what the
+      // third assertion pins down.
+      const openRead = await fetch(`${url}/v1/channels/${colliding.open.channelId}`, {
+        headers: { authorization: `Bearer ${colliding.open.token}` },
+      });
+      expect(openRead.status).toBe(200);
+
+      const closedRead = await fetch(`${url}/v1/channels/${colliding.closed.channelId}`, {
+        headers: { authorization: `Bearer ${colliding.closed.token}` },
+      });
+      expect(closedRead.status).toBe(404);
+
+      // And neither can reach the other's row with their own credential, which is
+      // the cross-tenant property holding while the ids are identical.
+      const across = await fetch(`${url}/v1/channels/${colliding.closed.channelId}`, {
+        headers: { authorization: `Bearer ${colliding.open.token}` },
+      });
+      expect(across.status).toBe(404);
+    });
+  });
+
+  // ── THE SIX ROUTES THIS CHAPTER ADDED, ATTACKED ACROSS TENANTS ────────────────
+  //
+  // The blocks above attack the same tenant's private channel — a non-member of your
+  // own environment. These six are the ordinary cross-tenant shape, and they exist
+  // because the accounting test below said so: it named all six as classified and
+  // never attacked, on the build that classified them.
+  //
+  // THE ATTACKS WERE NOT PORTED WITH THE ROUTES, which is the part worth recording.
+  // The commit that built the channel surface carried its gauntlet attacks alongside
+  // six webhook target rows, and the webhook half was deferred to the chapter that
+  // builds webhooks — so the attacks went with them. Nothing said so. What said so
+  // was a suite that compares what ran against what the classification lists, which
+  // is the only reason a deferral could not quietly become a hole.
+  //
+  // ONE PARAMETERISED BLOCK, because all six take the same shape: the victim's
+  // channel id against an id that exists nowhere, with the caller's own credential.
+  // A separate `it` per route would repeat the pair six times and hide that it is
+  // one property.
+  describe("write: the channel surface this chapter added", () => {
+    const routes: ReadonlyArray<
+      readonly [string, string, (channel: string) => string, unknown?]
+    > = [
+      ["GET /v1/channels/:channelId", "GET", (c) => `/v1/channels/${c}`],
+      ["POST /v1/channels/:channelId/join", "POST", (c) => `/v1/channels/${c}/join`],
+      [
+        "POST /v1/channels/:channelId/members/remove",
+        "POST",
+        (c) => `/v1/channels/${c}/members/remove`,
+        { users: ["nobody"] },
+      ],
+      [
+        "PATCH /v1/channels/:channelId/members/:userExternalId",
+        "PATCH",
+        (c) => `/v1/channels/${c}/members/nobody`,
+        { role: "moderator" },
+      ],
+      ["POST /v1/channels/:channelId/archive", "POST", (c) => `/v1/channels/${c}/archive`],
+      ["DELETE /v1/channels/:channelId/archive", "DELETE", (c) => `/v1/channels/${c}/archive`],
+    ];
+
+    for (const [key, method, path, body] of routes) {
+      it(`${key}: a foreign channel answers as an absent one, and changes nothing`, async () => {
+        attacked.add(key);
+        const verdict = await writeAttack(
+          url,
+          t.attacker.credential,
+          { method, path: path(t.victim.channelId), body },
+          { method, path: path(ABSENT_UUID), body },
+          // THE VICTIM'S SIDE, read with the victim's own repository. Two of these
+          // routes mutate membership and two mutate the channel row, so the state
+          // that has to be unchanged is both — a refusal that archived the channel
+          // anyway leaves the message list untouched and is still a breach.
+          async () => ({
+            channel: await t.victim.repo.getChannelById(t.victim.channelId),
+            members: await t.victim.repo.countMembers(t.victim.channelId),
+          }),
+        );
+        expect(verdict.differences, verdict.differences.join("; ")).toEqual([]);
+        expect(verdict.stateChanged, "the victim's channel or members moved").toBe(false);
+      });
+    }
+  });
+
   // ── 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

Six control tests, and they are the count that matters more than the attack count. The isolation harness shipped fourteen passing tests that compared two refusals — and two refusals for an unrelated reason are also byte-identical. Three of these prove the member's own token works on the same channel before anything asserts that a non-member's does not.

The second block is a case this feature creates. Two tenants using the same external_id, one channel public and one private: before this chapter every channel was public, so "resolves to your own tenant's channel" and "answers you the same way" matched trivially. Now the types differ, and the two answers differ because the types differ, not because the tenants do — which is the property worth a test. The existing fixture made it impossible to write, because it labels every id with its own tenant's name and the two tenants never share one.

services/api/src/isolation/targets.ts
@@ -114,12 +114,28 @@ export const CLASSIFICATIONS: readonly Classification[] = [
   {
     method: "POST",
     path: "/v1/channels/:channelId/members",
     accepts: "application",
     shape: "write",
   },
+  // The channel-control chapter's two, and the derivation found them the same way it found the
+  // pair above: the lane went red naming both as unclassified on the build that
+  // added them, before this file mentioned either. Written with the ROUTER'S
+  // parameter names — `:channelId`, not the contracts' `:externalId` — because the
+  // derivation compares literal path strings and an entry copied from a contract
+  // matches no target.
+  //
+  // `accepts: "user"` on the join: it is the caller joining, not the tenant adding
+  // somebody, and the route carries a method-level `@Accepts("user")` that overrides
+  // the controller's class-level `"application"`.
+  { method: "GET", path: "/v1/channels/:channelId", accepts: "application", shape: "read" },
+  { method: "POST", path: "/v1/channels/:channelId/join", accepts: "user", shape: "write" },
+  { method: "POST", path: "/v1/channels/:channelId/members/remove", accepts: "application", shape: "write" },
+  { method: "PATCH", path: "/v1/channels/:channelId/members/:userExternalId", accepts: "application", shape: "write" },
+  { method: "POST", path: "/v1/channels/:channelId/archive", accepts: "application", shape: "write" },
+  { method: "DELETE", path: "/v1/channels/:channelId/archive", 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
services/api/src/isolation/targets.itest.ts
@@ -97,7 +97,43 @@ describe("the gauntlet's target list derives from the running application", () =
     console.log(
       `gauntlet targets: ${derived.length} derived, ${attacked} attacked, ${counts.exempt} exempt ` +
         `(read ${counts.read}, write ${counts.write}, credential ${counts.credential})`,
     );
     expect(attacked + counts.exempt).toBe(derived.length);
   });
+
+  // ── SC-014: EVERY ROUTE THIS CHAPTER ADDS, NAMED ──────────────────────────
+  //
+  // A LIST RATHER THAN A NUMBER, and the number it replaced is why. It read
+  // `expect(derived.length).toBe(24 + BUILT_SO_FAR)` — twenty-four being another
+  // chapter's closing count, carried here as a literal and true of a tree this one
+  // is not. It failed `expected 17 to be 30`, and neither figure told a reader
+  // which route was missing.
+  //
+  // Named, the failure says which. And the direction that matters is both: a route
+  // added and never classified fails the accounting test above; a route classified
+  // and never built fails this one, because the derivation reads the running
+  // router.
+  it("derives exactly the six routes this chapter adds, and nothing else new", () => {
+    const ADDED = [
+      "GET /v1/channels/:channelId",
+      "POST /v1/channels/:channelId/join",
+      "POST /v1/channels/:channelId/members/remove",
+      "PATCH /v1/channels/:channelId/members/:userExternalId",
+      "POST /v1/channels/:channelId/archive",
+      "DELETE /v1/channels/:channelId/archive",
+    ];
+    const keys = derived.map(targetKey);
+    const missing = ADDED.filter((k) => !keys.includes(k));
+    expect(missing, `classified here and not on the router: ${missing.join(", ")}`)
+      .toEqual([]);
+  });
+
+  it("leaves nothing exempt by omission (FR-033a)", () => {
+    // Every exempt entry carries a reason — asserted above — and every DERIVED
+    // target matches an entry. What this adds is the direction that catches a route
+    // quietly dropped from the classification list: the entry count and the derived
+    // count are the same number, so a deletion here fails rather than reducing
+    // coverage silently.
+    expect(CLASSIFICATIONS.length).toBe(derived.length);
+  });
 });

The target list is derived from the running router, so a route that gets built and not classified fails the suite on the build that adds it. The count is asserted as a total and not as a delta: after − before computed inside one run is an identity and cannot fail.

gauntlet targets: 30 derived, 27 attacked, 3 exempt

Twenty-four before this feature, six routes added so far, three exemptions unchanged and each still carrying a written reason. Nothing this chapter adds is exempt.

Removing each check to see whether the suite notices

A suite of refusals cannot demonstrate that it is testing anything. So each new check came out in turn, with a rebuild and the full gauntlet:

send check disabled 2 failed | 30 passed by-id read check disabled 2 failed | 30 passed history visibility disabled 1 failed | 31 passed

Each failed its own attack and no other, and in all three the controls stayed green — that is what separates a working attacker from a broken one.

The collision test failing alongside the by-id read is correct rather than sloppy: it asserts the two tenants are answered differently because the types differ, so a removal that stops the type deciding should take it down.

services/api/src/db/repository.itest.ts
@@ -123,6 +123,160 @@ describe("idempotency must not disarm DR-01 (chapter 2.3)", () => {
     ).rejects.toThrow();
     // And nothing landed: the failed insert wrote no row.
     const rows = await repoA.listMessagesRaw(channel.id);
     expect(rows).toHaveLength(1);
   });
 });
+
+// ── A PRIVATE CHANNEL IS PRIVATE (FR-001, FR-CHN-05) ──────────────────────────
+//
+// `channels.type` has been a `"public" | "private"` column with a CHECK since
+// chapter 2.1, and until this chapter nothing DECIDED on it. It was selected and
+// returned by the create route, so it was read; no conditional anywhere branched
+// on it. The isolation harness's fifth analysis pass caught `POST /v1/channels` about to
+// accept `private` while that was still true.
+//
+// THESE CHANNELS ARE CREATED THROUGH THE REPOSITORY, not the API, and the reason
+// is FR-009's ordering: `POST /v1/channels` accepts `private` only once the read
+// paths and the send path enforce it, which is the end of the next phase.
+// `createChannel(externalId, type, …)` has always taken a type.
+//
+// AND THE REFUSAL IS THE NOT-FOUND ERROR, not a 403. SC-002 requires send's
+// answer for a private channel the caller cannot see to be byte-identical to a
+// channel that does not exist, and `ChannelNotFoundError` is what the absent
+// channel throws. A `403 not_a_member` would announce that the channel exists.
+describe("a private channel refuses a non-member's send (FR-001)", () => {
+  it("refuses a user of the tenant who is not a member, as if the channel were absent", async () => {
+    const channel = await repoA.createChannel("private-send", "private");
+    const stranger = await repoA.createUser("stranger", "A Stranger");
+
+    // The same error an absent channel raises, which is the whole property.
+    const absent = "00000000-0000-4000-8000-000000000000";
+    const forAbsent = await repoA
+      .sendMessage(absent, { userId: stranger.id, text: "nowhere" })
+      .catch((error: unknown) => error);
+    const forPrivate = await repoA
+      .sendMessage(channel.id, { userId: stranger.id, text: "not mine" })
+      .catch((error: unknown) => error);
+
+    expect(forPrivate).toBeInstanceOf(Error);
+    expect((forPrivate as Error).constructor).toBe(
+      (forAbsent as Error).constructor,
+    );
+  });
+
+  it("leaves the channel's message count unchanged after the refusal (SC-003)", async () => {
+    // A refusal that still writes a row is not a refusal, and the status code
+    // cannot tell you which one you have — only the rows can.
+    const channel = await repoA.createChannel("private-count", "private");
+    const stranger = await repoA.createUser("count-stranger", "Counter");
+    const before = await repoA.listMessagesRaw(channel.id);
+
+    await expect(
+      repoA.sendMessage(channel.id, { userId: stranger.id, text: "should not land" }),
+    ).rejects.toThrow();
+
+    const after = await repoA.listMessagesRaw(channel.id);
+    expect(after).toHaveLength(before.length);
+  });
+
+  it("accepts a member's send to the same channel", async () => {
+    // The control. Two refusals for unrelated reasons are also indistinguishable,
+    // which is what the isolation harness's fourteen passing tests turned out to be
+    // measuring — so the attacker has to be shown working before its failure
+    // means anything.
+    const channel = await repoA.createChannel("private-member", "private");
+    const member = await repoA.createUser("member", "A Member");
+    expect(await repoA.addMember(channel.id, member.id)).toBe("added");
+
+    const sent = await repoA.sendMessage(channel.id, {
+      userId: member.id,
+      text: "mine to send",
+    });
+    expect(sent.seq).toBe(1);
+  });
+
+  it("accepts an application credential with no user (FR-005)", async () => {
+    // `userId` absent means the TENANT is sending: it acts for the customer,
+    // carries no user, and is the customer's own server. FR-005 asked for this to
+    // be stated rather than assumed, and the assumption is that a private channel
+    // is not private FROM ITS OWNER.
+    const channel = await repoA.createChannel("private-app", "private");
+    const sent = await repoA.sendMessage(channel.id, { text: "from the tenant" });
+    expect(sent.seq).toBe(1);
+  });
+
+  it("does not check membership on a public channel", async () => {
+    // FR-004's answer for the other type: any authenticated user of the tenant may
+    // send to a public channel without being a member. The column becomes live
+    // only because the two types differ — require membership for both and
+    // `channels.type` still decides nothing.
+    const channel = await repoA.createChannel("public-send", "public");
+    const outsider = await repoA.createUser("public-outsider", "Outsider");
+    const sent = await repoA.sendMessage(channel.id, {
+      userId: outsider.id,
+      text: "public is open",
+    });
+    expect(sent.seq).toBe(1);
+  });
+});
+
+// ── THE ROLE CHECK IS IN THE DATABASE, NOT ONLY AT THE EDGE ───────────────────
+//
+// T068. `channels.itest.ts` proves the zod enum refuses `admin` at
+// the boundary. That is the wrong layer to trust: R8's trap is a CONSTRAINT that
+// reused the organisation's vocabulary — `memberships.role` is
+// `('owner','admin','member')`, one word apart from FR-CHN-04's three — and such a
+// constraint would accept `admin`, refuse `moderator`, and read as correct in
+// review.
+//
+// So this drives the repository, past the schema, and asserts the database says no.
+describe("members_role_check names the channel's three (FR-011, R8)", () => {
+  it("refuses `admin` — the organisation's word — at the database", async () => {
+    const channel = await repoA.createChannel("role-check", "public");
+    const user = await repoA.createUser("role-check-user");
+    expect(await repoA.addMember(channel.id, user.id)).toBe("added");
+
+    // The constraint name is in the CAUSE, not the message: drizzle's top-level
+    // text is "Failed query: update …" and the driver's error underneath it carries
+    // `constraint`. Asserting on the wrapper's message would have passed for any
+    // failed update at all — including one that failed for the wrong reason.
+    const error = await repoA
+      .setMemberRole(channel.id, user.id, "admin")
+      .then(() => null)
+      .catch((e: unknown) => e);
+    expect(error).toBeInstanceOf(Error);
+    const chain = JSON.stringify({
+      message: (error as Error).message,
+      cause: String((error as { cause?: unknown }).cause ?? ""),
+      constraint: ((error as { cause?: { constraint?: string } }).cause ?? {})
+        .constraint,
+    });
+    expect(chain).toContain("members_role_check");
+  });
+
+  it("accepts `moderator`, which the organisation's constraint would refuse", async () => {
+    // The other half of the same trap, and the one that makes the test above mean
+    // something: a constraint that refused BOTH words would pass the assertion
+    // above while being just as wrong.
+    const channel = await repoA.createChannel("role-check-ok", "public");
+    const user = await repoA.createUser("role-check-ok-user");
+    await repoA.addMember(channel.id, user.id);
+
+    expect(await repoA.setMemberRole(channel.id, user.id, "moderator")).toBe("set");
+    expect(await repoA.memberRole(channel.id, user.id)).toBe("moderator");
+  });
+
+  it("gives a member created without a role the column's default", async () => {
+    const channel = await repoA.createChannel("role-default", "public");
+    const user = await repoA.createUser("role-default-user");
+    await repoA.addMember(channel.id, user.id);
+    expect(await repoA.memberRole(channel.id, user.id)).toBe("member");
+  });
+
+  it("gives a member created WITH a role that role (FR-011b)", async () => {
+    const channel = await repoA.createChannel("role-at-insert", "public");
+    const user = await repoA.createUser("role-at-insert-user");
+    await repoA.addMember(channel.id, user.id, "owner");
+    expect(await repoA.memberRole(channel.id, user.id)).toBe("owner");
+  });
+});
services/api/src/messages/messages.itest.ts
@@ -2,14 +2,17 @@ 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 { mintUserToken } from "../auth/user-token";
+import { environmentSigningSecret } from "../db/repository";
 import { createDb, createPool } from "../db/client";
 import { createApiKey, createEnvironment, Repository } from "../db/repository";
+import { withoutRequestId } from "../isolation/compare";
 
 // The endpoint path (chapter 2.2): guard → pipe → service → repository →
 // filter, over real HTTP against the compose Postgres. Its own environment,
 // minted here — no truncate, because tenant isolation means this suite and
 // the repository suite cannot see each other's rows (2.1's property, paying
 // for itself in the test lane).
@@ -20,24 +23,52 @@ describe("POST /v1/channels/:channelId/messages", () => {
   // The tenant arrives as a CREDENTIAL now, not as a header. The
   // suite mints its own key the same way signup does — through the repository's
   // admin surface — so nothing here needs a test-only route to exist.
   let credential: string;
   let channelId: string;
   let foreignChannelId: string;
+  let privateChannelId: string;
+  let tokenFor: (user: string) => Promise<string>;
+  /** Suite-scoped so a test can build its OWN fixture rather than lean on shared
+   * state an earlier test may have changed. T072b's first draft used
+   * `privateChannelId` and failed on its control: T057 above removes `insider` from
+   * that channel, so by then the "member" was not one. A test that depends on the
+   * order it runs in is a test that will fail for a reason it does not name. */
+  let repo: Repository;
 
   beforeAll(async () => {
     const db = createDb(createPool());
     env = await createEnvironment(db, { name: "messages-itest" });
     channelId = (
       await new Repository(db, env.id).createChannel("general", "public")
     ).id;
     credential = (await createApiKey(db, { environmentId: env.id })).credential;
     const other = await createEnvironment(db, { name: "messages-itest-other" });
     foreignChannelId = (
       await new Repository(db, other.id).createChannel("theirs", "public")
     ).id;
+
+    // The channel-control chapter's fixtures: a private channel, one member, one stranger of the
+    // SAME tenant, and a way to mint their tokens. The private channel is created
+    // through the repository because `POST /v1/channels` does not accept `private`
+    // until the read paths enforce it (FR-009's ordering).
+    repo = new Repository(db, env.id);
+    privateChannelId = (await repo.createChannel("members-only", "private")).id;
+    const member = await repo.createUser("insider", "An Insider");
+    await repo.addMember(privateChannelId, member.id);
+    await repo.createUser("outsider", "An Outsider");
+    const signingSecret = (await environmentSigningSecret(db, env.id))!
+      .signingSecret;
+    tokenFor = async (subject: string) =>
+      (
+        await mintUserToken(signingSecret, {
+          user: subject,
+          environmentId: env.id,
+          ttlSeconds: 3600,
+        })
+      ).token;
     app = (
       await Test.createTestingModule({ imports: [AppModule] }).compile()
     ).createNestApplication({ logger: false });
     await app.listen(0);
     url = await app.getUrl();
   });
@@ -96,7 +127,190 @@ describe("POST /v1/channels/:channelId/messages", () => {
     const missing = await send({ text: "nobody home" }, crypto.randomUUID());
     expect(foreign.status).toBe(404);
     expect(missing.status).toBe(404);
     // Indistinguishable — no data, and no reveal that the id exists.
     expect(await foreign.json()).toEqual(await missing.json());
   });
+
+  // ── THE ROUTE A CUSTOMER'S CLIENT ACTUALLY CALLS (FR-001) ─────────────────────
+  //
+  // The membership check lives in `repository.sendMessage` and is gated on `userId`
+  // being present. `repository.itest.ts` proves the check EXISTS by driving that
+  // function directly with a user id. Only these tests prove it FIRES, because for
+  // seventeen chapters this controller called `messages.send(channelId, body)`
+  // with no user at all — and `MessagesController` declared no `@Accepts` until the
+  // sender chapter, so the guard fell back to `EITHER` and a user token was accepted
+  // here.
+  //
+  // So the repository test passed while the route it protects was open. A repository
+  // test proves a check exists; only a route test proves it fires.
+  describe("a private channel over the public route (FR-001, SC-002)", () => {
+    const sendAs = async (token: string, channel: string, text = "hello") =>
+      fetch(`${url}/v1/channels/${channel}/messages`, {
+        method: "POST",
+        headers: {
+          "content-type": "application/json",
+          authorization: `Bearer ${token}`,
+        },
+        body: JSON.stringify({ text }),
+      });
+
+    it("refuses a non-member's send with the not-found envelope, body and all", async () => {
+      const token = await tokenFor("outsider");
+      const refused = await sendAs(token, privateChannelId);
+      const absent = await sendAs(token, "00000000-0000-4000-8000-000000000000");
+
+      expect(refused.status).toBe(absent.status);
+      // Byte-identical but for `request_id`, which is SC-002's actual requirement —
+      // matching status codes is the easy half and says nothing on its own.
+      // Deleting rather than destructuring: an unused binding is a lint error and
+      // the intent is a removal either way. `request_id` is the one field that
+      // differs by construction — it names the request, not the resource — which is
+      // why the isolation harness's oracle drops exactly this one and nothing else.
+      const strip = (b: Record<string, unknown>) => {
+        delete b.request_id;
+        return b;
+      };
+      expect(strip((await refused.json()) as Record<string, unknown>)).toEqual(
+        strip((await absent.json()) as Record<string, unknown>),
+      );
+    });
+
+    it("accepts a member's send to the same channel", async () => {
+      // The control, and it is not optional. Two refusals for unrelated reasons are
+      // also indistinguishable — a token the guard rejects outright would pass the
+      // test above while proving nothing about membership.
+      const token = await tokenFor("insider");
+      const accepted = await sendAs(token, privateChannelId, "mine to send");
+      expect(accepted.status).toBe(201);
+    });
+
+    it("accepts an application key's send to the same private channel (FR-005)", async () => {
+      const accepted = await send({ text: "from the tenant" }, privateChannelId);
+      expect(accepted.status).toBe(201);
+    });
+
+    it("answers a non-member's history read exactly as an absent channel does", async () => {
+    // T041b. The history route dropped its caller the same way the send route did,
+    // and `listMessages` had no `userId` parameter to drop it INTO — so the task
+    // that said "add the same check to the history path" was asking for a check
+    // with nothing to check against. Three places, and a gap in any one makes a
+    // check unreachable: the handler resolves, the service threads, the repository
+    // accepts.
+    //
+    // AND THE FIRST ATTEMPT AT THIS TEST WAS WRONG IN AN INSTRUCTIVE WAY. It
+    // expected an empty page, because `listMessages` answers an unknown channel id
+    // with `[]` at the repository level. The ROUTE answers 404 — `messages.service`
+    // checks `channelExists` first — so an empty page for a private channel would
+    // have differed from an absent one and announced that the channel was there.
+    // Only comparing the two answers caught it, which is the whole point of pairing
+    // them rather than asserting a status.
+    const outsider = await tokenFor("outsider");
+    const insider = await tokenFor("insider");
+    await sendAs(insider, privateChannelId, "members can read this");
+
+    const hidden = await fetch(
+      `${url}/v1/channels/${privateChannelId}/messages?limit=10`,
+      { headers: { authorization: `Bearer ${outsider}` } },
+    );
+    const absent = await fetch(
+      `${url}/v1/channels/00000000-0000-4000-8000-000000000000/messages?limit=10`,
+      { headers: { authorization: `Bearer ${outsider}` } },
+    );
+    expect(hidden.status).toBe(absent.status);
+    // The whole body, `request_id` excepted — a private channel the caller cannot
+    // see and a channel that does not exist give one answer.
+    expect(withoutRequestId(await hidden.json())).toEqual(
+      withoutRequestId(await absent.json()),
+    );
+  });
+
+  it("returns the page to a member of the same channel", async () => {
+    // The control: the page exists and the reader is the variable.
+    const insider = await tokenFor("insider");
+    const res = await fetch(
+      `${url}/v1/channels/${privateChannelId}/messages?limit=10`,
+      { headers: { authorization: `Bearer ${insider}` } },
+    );
+    const body = (await res.json()) as { messages: unknown[] };
+    expect(body.messages.length).toBeGreaterThan(0);
+  });
+
+  it("returns the page to an application credential (FR-005)", async () => {
+    const res = await fetch(
+      `${url}/v1/channels/${privateChannelId}/messages?limit=10`,
+      { headers: { authorization: `Bearer ${credential}` } },
+    );
+    const body = (await res.json()) as { messages: unknown[] };
+    expect(body.messages.length).toBeGreaterThan(0);
+  });
+
+  it("refuses a removed member's send, and their messages stay (SC-004, SC-005)", async () => {
+    // T057. This is phase 3's check reading a row that is now gone — no new code
+    // path, which is the point: removal takes the membership away and the check
+    // that was already there does the rest.
+    const token = await tokenFor("insider");
+    const sent = await sendAs(token, privateChannelId, "written while a member");
+    expect(sent.status).toBe(201);
+
+    await fetch(`${url}/v1/channels/${privateChannelId}/members/remove`, {
+      method: "POST",
+      headers: { "content-type": "application/json", authorization: `Bearer ${credential}` },
+      body: JSON.stringify({ user_ids: ["insider"] }),
+    });
+
+    const refused = await sendAs(token, privateChannelId, "after removal");
+    const absent = await sendAs(token, "00000000-0000-4000-8000-000000000000");
+    expect(refused.status).toBe(absent.status);
+
+    // And what they wrote is still there, read with the tenant's key.
+    const history = await fetch(
+      `${url}/v1/channels/${privateChannelId}/messages?limit=100`,
+      { headers: { authorization: `Bearer ${credential}` } },
+    );
+    const body = (await history.json()) as { messages: { user: string | null }[] };
+    expect(body.messages.some((m) => m.user === "insider")).toBe(true);
+  });
+
+  it("an archived PRIVATE channel answers a non-member as an absent one does", async () => {
+    // T072b, and the point of FR-021a's order. Reverse the last two checks — archive
+    // before membership — and this test goes red: the non-member would get
+    // `channel_archived` and learn the channel exists. Asserted with the oracle
+    // rather than by reading the code, because the order is only observable from
+    // outside as a pair of answers.
+    // Its own channel and its own member, for the reason `repo` is suite-scoped.
+    const channel = (await repo.createChannel("archived-private", "private")).id;
+    const member = await repo.getUserByExternalId("insider");
+    await repo.addMember(channel, member!.id);
+    await fetch(`${url}/v1/channels/${channel}/archive`, {
+      method: "POST",
+      headers: { authorization: `Bearer ${credential}` },
+    });
+
+    const outsider = await tokenFor("outsider");
+    const refused = await sendAs(outsider, channel);
+    const absent = await sendAs(outsider, "00000000-0000-4000-8000-000000000000");
+    expect(refused.status).toBe(absent.status);
+    expect(withoutRequestId(await refused.json())).toEqual(
+      withoutRequestId(await absent.json()),
+    );
+
+    // The control that makes it mean something: a MEMBER of the same archived
+    // channel gets `channel_archived`, so the archive check is live and it is the
+    // ORDER that hides it from the non-member.
+    const insider = await tokenFor("insider");
+    const asMember = await sendAs(insider, channel);
+    expect(asMember.status).toBe(403);
+    expect(((await asMember.json()) as { code: string }).code).toBe("channel_archived");
+  });
+
+  it("refuses a token minted for an identifier with no user row", async () => {
+      // `POST /auth/dev-token` mints tokens for identifiers that need not exist, so
+      // before the channel-control chapter this send SUCCEEDED, unattributed — and an unattributed
+      // send is one the membership check waves through. A user with no row is a
+      // member of nothing. FR-039a removes the case by creating the row at mint time.
+      const token = await tokenFor("never-seen-before");
+      const refused = await sendAs(token, privateChannelId);
+      expect(refused.status).toBe(400);
+    });
+  });
 });
services/api/src/channels/channels.itest.ts
@@ -2,14 +2,17 @@ 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 { mintUserToken } from "../auth/user-token";
 import { createDb, createPool, type Db } from "../db/client";
 import { createApiKey, createEnvironment, Repository } from "../db/repository";
+import { environmentSigningSecret } from "../db/repository";
+import { withoutRequestId } from "../isolation/compare";
 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
@@ -21,21 +24,41 @@ describe("the public channel surface", () => {
   let url: string;
   let db: Db;
   let credential: string;
   let repo: Repository;
   let foreignChannelId: string;
   let foreignRepo: Repository;
+  let privateChannelId: string;
+  let publicChannelId: string;
+  let tokenFor: (user: string) => Promise<string>;
 
   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;
+    // A private channel, a member, a non-member of the SAME tenant,
+    // and a way to mint their tokens. Created through the repository because
+    // `POST /v1/channels` accepts `private` only from this phase's last task.
+    privateChannelId = (await repo.createChannel("members-only", "private")).id;
+    const member = await repo.createUser("insider", "An Insider");
+    await repo.addMember(privateChannelId, member.id);
+    await repo.createUser("outsider", "An Outsider");
+    publicChannelId = (await repo.createChannel("town-square", "public")).id;
+    const signingSecret = (await environmentSigningSecret(db, env.id))!.signingSecret;
+    tokenFor = async (subject: string) =>
+      (
+        await mintUserToken(signingSecret, {
+          user: subject,
+          environmentId: env.id,
+          ttlSeconds: 3600,
+        })
+      ).token;
     app = (
       await Test.createTestingModule({ imports: [AppModule] }).compile()
     ).createNestApplication({ logger: false });
     await app.listen(0);
     url = await app.getUrl();
   }, 60_000);
@@ -78,22 +101,28 @@ describe("the public channel surface", () => {
       // 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" });
+    // THE ISOLATION HARNESS ASSERTED THE OPPOSITE HERE, and it was right at the time.
+    //
+    // FR-047 pinned the enum to `public` alone because `channels.type` decided
+    // nothing: an endpoint accepting `private` would have sold a guarantee the
+    // platform did not keep. FR-009 supersedes it now that the send path, the by-id
+    // read, history and the session all honour the type — the guarantee exists, so
+    // the enum may offer it.
+    //
+    // The `field` half of that test survives intact and is worth keeping: EIR-API-04
+    // has carried `field` since chapter 1.3 and nothing set it until the error-registry chapter.
+    // A third type still names the key it refused.
+    it("refuses a type outside the two, naming the field (FR-009)", async () => {
+      const res = await create({ external_id: "secret-attempt", type: "secret" });
       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",
@@ -197,19 +226,495 @@ describe("the public channel surface", () => {
       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 a JOIN that would exceed it, with the same code", async () => {
+      // T047. The ceiling is the channel-endpoints chapter's and it is READ here, not reimplemented:
+      // `join` counts members from storage and refuses with the same
+      // `channel_member_limit_exceeded` the member-add route uses. A second limit
+      // with its own number would be a second answer to one question.
+      expect(await repo.countMembers(fullChannelId)).toBe(CHANNEL_MEMBER_LIMIT);
+      const token = await tokenFor("outsider");
+      const res = await fetch(`${url}/v1/channels/${fullChannelId}/join`, {
+        method: "POST",
+        headers: { authorization: `Bearer ${token}` },
+      });
+      expect(res.status).toBe(422);
+      const body = (await res.json()) as { code: string; message: string };
+      expect(body.code).toBe("channel_member_limit_exceeded");
+      // And nobody joined: 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);
+    });
+
     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);
     });
   });
+
+  // ── THE PRIVATE TYPE, MADE TO MEAN SOMETHING ───────────────────────────────
+  //
+  // `channels.type` has been a column with a CHECK since chapter 2.1 and until this
+  // chapter no conditional anywhere branched on it. It was selected and returned by
+  // the create route — read, and decided upon by nothing.
+  describe("GET /v1/channels/:channelId (FR-003a)", () => {
+    const readAs = (channel: string, token: string) =>
+      fetch(`${url}/v1/channels/${channel}`, {
+        headers: { authorization: `Bearer ${token}` },
+      });
+    const readAsTenant = (channel: string) =>
+      fetch(`${url}/v1/channels/${channel}`, {
+        headers: { authorization: `Bearer ${credential}` },
+      });
+
+    it("reads back the four fields a create wrote (FR-CHN-01)", async () => {
+      const made = await create({
+        external_id: "readable",
+        name: "Readable",
+        type: "public",
+        metadata: { team: "platform" },
+      });
+      const { id } = (await made.json()) as { id: string };
+      const res = await readAsTenant(id);
+      expect(res.status).toBe(200);
+      expect(await res.json()).toMatchObject({
+        id,
+        external_id: "readable",
+        type: "public",
+        name: "Readable",
+        metadata: { team: "platform" },
+        archived_at: null,
+        // `null` and not `false`: an application credential is not a member of
+        // anything, and `false` would imply it could become one.
+        is_member: null,
+      });
+    });
+
+    it("lets a member read a private channel", async () => {
+      const res = await readAs(privateChannelId, await tokenFor("insider"));
+      expect(res.status).toBe(200);
+      expect(await res.json()).toMatchObject({ type: "private", is_member: true });
+    });
+
+    it("lets a non-member read a PUBLIC channel (FR-004)", async () => {
+      // The subscription set is not the read set. A public channel is readable on
+      // demand by anyone in the tenant; membership decides what the socket carries.
+      const res = await readAs(publicChannelId, await tokenFor("outsider"));
+      expect(res.status).toBe(200);
+      expect(await res.json()).toMatchObject({ type: "public", is_member: false });
+    });
+
+    it("answers a non-member's read of a private channel as if it were absent", async () => {
+      const token = await tokenFor("outsider");
+      const refused = await readAs(privateChannelId, token);
+      const absent = await readAs("00000000-0000-4000-8000-000000000000", token);
+      expect(refused.status).toBe(absent.status);
+      expect(withoutRequestId(await refused.json())).toEqual(
+        withoutRequestId(await absent.json()),
+      );
+    });
+
+    it("answers the same for another tenant's channel", async () => {
+      // The cross-tenant half, unchanged by this chapter and worth keeping beside
+      // the same-tenant one: three ids, one answer.
+      const token = await tokenFor("outsider");
+      const foreign = await readAs(foreignChannelId, token);
+      const absent = await readAs("00000000-0000-4000-8000-000000000000", token);
+      expect(withoutRequestId(await foreign.json())).toEqual(
+        withoutRequestId(await absent.json()),
+      );
+    });
+  });
+
+  describe("POST /v1/channels/:channelId/join (FR-CHN-03)", () => {
+    const join = (channel: string, token: string) =>
+      fetch(`${url}/v1/channels/${channel}/join`, {
+        method: "POST",
+        headers: { authorization: `Bearer ${token}` },
+      });
+
+    it("joins a public channel, and says so again on the repeat", async () => {
+      const token = await tokenFor("outsider");
+      const first = await join(publicChannelId, token);
+      expect(first.status).toBe(200);
+      expect(await first.json()).toEqual({ result: "joined" });
+      const second = await join(publicChannelId, token);
+      expect(second.status).toBe(200);
+      expect(await second.json()).toEqual({ result: "already_a_member" });
+    });
+
+    it("answers a private channel as if it were absent", async () => {
+      const token = await tokenFor("outsider");
+      const refused = await join(privateChannelId, token);
+      const absent = await join("00000000-0000-4000-8000-000000000000", token);
+      expect(refused.status).toBe(absent.status);
+      expect(withoutRequestId(await refused.json())).toEqual(
+        withoutRequestId(await absent.json()),
+      );
+    });
+
+    it("refuses an application credential, which has no user to join", async () => {
+      // The method-level `@Accepts("user")` overriding the class's "application".
+      // Without it this would be a 403 for every USER instead — the isolation harness's
+      // FR-044 hole in the other direction.
+      const res = await join(publicChannelId, credential);
+      expect(res.status).toBe(403);
+    });
+  });
+
+  describe("the enum widens last (FR-009, FR-010)", () => {
+    it("accepts `private` and reads the row back as private (SC-006)", async () => {
+      const made = await create({ external_id: "now-private", type: "private" });
+      expect(made.status).toBe(201);
+      const { id } = (await made.json()) as { id: string };
+      const back = await fetch(`${url}/v1/channels/${id}`, {
+        headers: { authorization: `Bearer ${credential}` },
+      });
+      expect(await back.json()).toMatchObject({ type: "private" });
+    });
+
+    it("a repeat naming a different type returns the existing channel unchanged", async () => {
+      // FR-010. Idempotency means the second call returns the FIRST call's channel,
+      // and a type change is not a creation — so this is a read, not an update.
+      const first = await create({ external_id: "type-stays", type: "private" });
+      expect(first.status).toBe(201);
+      const second = await create({ external_id: "type-stays", type: "public" });
+      expect(second.status).toBe(200);
+      expect(await second.json()).toMatchObject({ type: "private" });
+    });
+  });
+
+  // ── REMOVAL, BULK, BECAUSE THE REQUIREMENT ALWAYS WAS ───────────────────────
+  //
+  // FR-006 says "up to 100 in one request" and FR-007 says the result is reported
+  // per user — the channel-endpoints chapter's add shape in both halves. The contract specified a
+  // single-user `DELETE` for ten analysis passes, having read "the shape the
+  // endpoints chapter chose" as *named outcomes* and dropped *bulk*. Every pass
+  // requirements to tasks, both said "removal", and identifier coverage read 100%.
+  // Comparing US2's scenario 4 — which names a hundred users — to the route's path,
+  // which named one, is what found it.
+  describe("POST /v1/channels/:channelId/members/remove (FR-006, FR-007)", () => {
+    let target: string;
+
+    const remove = (channel: string, users: string[], key = credential) =>
+      fetch(`${url}/v1/channels/${channel}/members/remove`, {
+        method: "POST",
+        headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
+        body: JSON.stringify({ user_ids: users }),
+      });
+
+    beforeAll(async () => {
+      target = (await repo.createChannel("removals", "public")).id;
+      await addMembers(target, { user_ids: ["stays", "goes", "also-goes"] });
+    });
+
+    it("reports one result per user, in request order, mixing outcomes", async () => {
+      const res = await remove(target, ["goes", "never-a-member", "also-goes"]);
+      expect(res.status).toBe(200);
+      expect(await res.json()).toEqual({
+        results: [
+          { external_id: "goes", result: "removed" },
+          // A user that does not exist is NOT a member — simply true — and answering
+          // anything else would make this a membership oracle for user ids.
+          { external_id: "never-a-member", result: "not_a_member" },
+          { external_id: "also-goes", result: "removed" },
+        ],
+      });
+      // One bad entry did not refuse the other two.
+      expect(await repo.countMembers(target)).toBe(1);
+    });
+
+    it("is idempotent: removing a non-member says so rather than failing", async () => {
+      const res = await remove(target, ["goes"]);
+      expect(res.status).toBe(200);
+      expect(await res.json()).toEqual({
+        results: [{ external_id: "goes", result: "not_a_member" }],
+      });
+    });
+
+    it("takes 100 in one request and refuses 101, naming the field", async () => {
+      const hundred = Array.from({ length: 100 }, (_, i) => `bulk-${i}`);
+      const ok = await remove(target, hundred);
+      expect(ok.status).toBe(200);
+      expect(((await ok.json()) as { results: unknown[] }).results).toHaveLength(100);
+
+      const tooMany = await remove(target, [...hundred, "one-too-many"]);
+      expect(tooMany.status).toBe(400);
+      const body = (await tooMany.json()) as { code: string; field?: string };
+      expect(body.code).toBe("invalid_request");
+      expect(body.field).toBe("user_ids");
+    });
+
+    it("answers a channel that does not exist as it answers a foreign one", async () => {
+      const absent = await remove("00000000-0000-4000-8000-000000000000", ["goes"]);
+      const foreign = await remove(foreignChannelId, ["goes"]);
+      expect(absent.status).toBe(foreign.status);
+      expect(withoutRequestId(await absent.json())).toEqual(
+        withoutRequestId(await foreign.json()),
+      );
+    });
+
+    // THE READ POSITION GOES WITH THE MEMBERSHIP, and that assertion lives in
+    // phase 12 rather than here — deliberately, twice over.
+    //
+    // Writing it here needed a read position to exist, and `setReadPosition` does
+    // not until phase 12. The first attempt planted one with raw SQL and the lint
+    // rule refused it: "the query engine lives inside the repository layer only
+    // (constitution I, ADR-16)". That rule is right and the test was wrong — a suite
+    // that reaches past the repository to set up state is testing something other
+    // than what the platform does.
+    //
+    // So `removeMembers` deletes the row (see the repository), and phase 12 asserts
+    // the consequence a customer can see: a re-added member's unread count starts at
+    // the channel's whole history. A test placed before the thing it tests is the
+    // fourth instance of that class in this feature.
+    it("keeps the removed member's messages, attributed to them (FR-008, SC-005)", async () => {
+      const author = await repo.createUser("author", "An Author");
+      await repo.addMember(target, author.id);
+      const sent = await repo.sendMessage(target, {
+        userId: author.id,
+        userExternalId: "author",
+        text: "written while a member",
+      });
+
+      await remove(target, ["author"]);
+
+      const history = await repo.listMessages(target, { limit: 100 });
+      const kept = history.find((m) => m.seq === sent.seq);
+      expect(kept).toBeDefined();
+      // Still theirs. `messages.user_id` points at a row that still exists, which is
+      // the whole reason deletion keeps the user row rather than nulling the author.
+      expect(kept?.user).toBe("author");
+    });
+
+    it("does not stop a removed member reading or sending to a PUBLIC channel", async () => {
+      // T060, and it is the case that makes FR-004's table load-bearing rather than
+      // decorative: membership was never what permitted this. A removal from a
+      // public channel takes away the subscription and nothing else.
+      const token = await tokenFor("outsider");
+      await addMembers(publicChannelId, { user_ids: ["outsider"] });
+      await remove(publicChannelId, ["outsider"]);
+
+      const read = await fetch(`${url}/v1/channels/${publicChannelId}`, {
+        headers: { authorization: `Bearer ${token}` },
+      });
+      expect(read.status).toBe(200);
+      expect(await read.json()).toMatchObject({ is_member: false });
+
+      const sent = await fetch(`${url}/v1/channels/${publicChannelId}/messages`, {
+        method: "POST",
+        headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
+        body: JSON.stringify({ text: "still open to me" }),
+      });
+      expect(sent.status).toBe(201);
+    });
+  });
+
+  // ── MEMBER ROLES (FR-CHN-04, FR-011) ───────────────────────────────────────
+  //
+  // The clause has asked for these since the SRS was written, and `members` was
+  // `(channel_id, user_id, joined_at)` the whole time. The isolation harness's traceability
+  // map recorded it as delivered and described it with a paraphrase belonging to
+  // FR-CHN-06; that was corrected while this chapter was specified.
+  describe("roles on members", () => {
+    let roleChannel: string;
+
+    const patchRole = (channel: string, user: string, role: unknown) =>
+      fetch(`${url}/v1/channels/${channel}/members/${user}`, {
+        method: "PATCH",
+        headers: { "content-type": "application/json", authorization: `Bearer ${credential}` },
+        body: JSON.stringify({ role }),
+      });
+
+    beforeAll(async () => {
+      roleChannel = (await repo.createChannel("roles", "public")).id;
+    });
+
+    it("defaults a new member to `member`, read through the API", async () => {
+      // T067. The default is declared in the migration; this exercises it rather
+      // than reading it out of the DDL — a comment about a default is not a default.
+      const res = await addMembers(roleChannel, { user_ids: ["plain"] });
+      expect(res.status).toBe(200);
+      const body = (await res.json()) as { members: { external_id: string; role: string }[] };
+      expect(body.members[0]).toMatchObject({ external_id: "plain", role: "member" });
+    });
+
+    it("accepts a role on the add body, per entry (FR-011b)", async () => {
+      // US6's first scenario: a member ADDED WITH a role. The plan had add assign the
+      // default and `PATCH` change it — two calls for one intention, and a window
+      // where the member holds a role nobody chose. Analysis pass eleven found that
+      // by comparing the scenario to the routes.
+      //
+      // Mixed forms in one request, because the entry is a union: the endpoints chapter
+      // shipped `{"user_ids": ["a", "b"]}` and a customer's server sends that today.
+      const res = await addMembers(roleChannel, {
+        user_ids: ["bare-string", { user: "with-role", role: "owner" }],
+      });
+      expect(res.status).toBe(200);
+      const body = (await res.json()) as { members: { external_id: string; role: string }[] };
+      expect(body.members).toEqual([
+        expect.objectContaining({ external_id: "bare-string", role: "member" }),
+        expect.objectContaining({ external_id: "with-role", role: "owner" }),
+      ]);
+    });
+
+    it("round-trips all three roles through PATCH (FR-011)", async () => {
+      await addMembers(roleChannel, { user_ids: ["promotable"] });
+      for (const role of ["owner", "moderator", "member"]) {
+        const res = await patchRole(roleChannel, "promotable", role);
+        expect(res.status).toBe(200);
+        expect(await res.json()).toEqual({ external_id: "promotable", role });
+      }
+    });
+
+    it("refuses a fourth role, naming the field (FR-011a, SC-009)", async () => {
+      await addMembers(roleChannel, { user_ids: ["hopeful"] });
+      const res = await patchRole(roleChannel, "hopeful", "superuser");
+      expect(res.status).toBe(400);
+      const body = (await res.json()) as { code: string; field?: string };
+      expect(body.code).toBe("invalid_request");
+      // The field name is in the envelope only because the error-registry chapter stopped
+      // `ZodValidationPipe` discarding `issues[0].path`.
+      expect(body.field).toBe("role");
+    });
+
+    it("refuses `admin` — the organisation's word, not a channel's", async () => {
+      // R8's trap from the edge: `memberships.role` is
+      // `('owner','admin','member')`, one word apart from these three. A migration
+      // that reused that constraint would take `admin` and refuse `moderator`.
+      await addMembers(roleChannel, { user_ids: ["would-be-admin"] });
+      const res = await patchRole(roleChannel, "would-be-admin", "admin");
+      expect(res.status).toBe(400);
+      expect(((await res.json()) as { field?: string }).field).toBe("role");
+    });
+
+    it("answers a non-member, an unknown user and an absent channel alike", async () => {
+      // Three cases, one answer. A caller who can tell them apart has a probe for
+      // which users this tenant knows and which channels exist.
+      const notAMember = await patchRole(roleChannel, "outsider", "owner");
+      const noSuchUser = await patchRole(roleChannel, "never-heard-of", "owner");
+      const noSuchChannel = await patchRole(
+        "00000000-0000-4000-8000-000000000000",
+        "plain",
+        "owner",
+      );
+      expect(notAMember.status).toBe(404);
+      expect(noSuchUser.status).toBe(404);
+      expect(noSuchChannel.status).toBe(404);
+      // Each body read ONCE and held: a `Response` is a stream, and reading it
+      // twice throws "Body has already been read" — which is what the first draft
+      // of this test did.
+      const a = withoutRequestId(await notAMember.json());
+      const b = withoutRequestId(await noSuchUser.json());
+      const c = withoutRequestId(await noSuchChannel.json());
+      expect(a).toEqual(b);
+      expect(a).toEqual(c);
+    });
+  });
+
+  // ── ARCHIVING (FR-020, FR-021, FR-021a) ────────────────────────────────────
+  //
+  // `channels.archived_at` was declared in chapter 2.1 and had ZERO non-test
+  // references until this chapter — measured, not assumed (T007). Archiving stops
+  // new messages and keeps everything already written.
+  describe("archiving a channel", () => {
+    let archived: string;
+
+    const archive = (channel: string) =>
+      fetch(`${url}/v1/channels/${channel}/archive`, {
+        method: "POST",
+        headers: { authorization: `Bearer ${credential}` },
+      });
+    const unarchive = (channel: string) =>
+      fetch(`${url}/v1/channels/${channel}/archive`, {
+        method: "DELETE",
+        headers: { authorization: `Bearer ${credential}` },
+      });
+    const sendTo = (channel: string) =>
+      fetch(`${url}/v1/channels/${channel}/messages`, {
+        method: "POST",
+        headers: { "content-type": "application/json", authorization: `Bearer ${credential}` },
+        body: JSON.stringify({ text: "attempted after archiving" }),
+      });
+
+    beforeAll(async () => {
+      archived = (await repo.createChannel("archivable", "public")).id;
+      await repo.sendMessage(archived, { text: "written before archiving" });
+    });
+
+    it("refuses a send with its own code, distinct from not-found and banned", async () => {
+      // T074, and FR-021's actual requirement: three refusals a client acts on
+      // differently. The comparison used to be against `not_a_member`, which cannot
+      // appear on this path at all — private channels answer not-found and public
+      // ones permit the send.
+      expect((await archive(archived)).status).toBe(200);
+      const refused = await sendTo(archived);
+      expect(refused.status).toBe(403);
+      const body = (await refused.json()) as { code: string; message: string };
+      expect(body.code).toBe("channel_archived");
+      expect(body.code).not.toBe("not_found");
+      expect(body.code).not.toBe("user_banned");
+      // The message says history is unchanged, which is the thing a client needs to
+      // know next: this is not data loss.
+      expect(body.message).toContain("history");
+    });
+
+    it("still serves history while archived (FR-020)", async () => {
+      const res = await fetch(`${url}/v1/channels/${archived}/messages?limit=10`, {
+        headers: { authorization: `Bearer ${credential}` },
+      });
+      expect(res.status).toBe(200);
+      const body = (await res.json()) as { messages: { text: string | null }[] };
+      expect(body.messages.some((m) => m.text === "written before archiving")).toBe(true);
+    });
+
+    it("is idempotent in both directions (FR-020a)", async () => {
+      // Archiving an archived channel and unarchiving an active one both answer 200.
+      // "Already archived" is not an error: the customer asked for the channel to be
+      // archived and it is.
+      expect((await archive(archived)).status).toBe(200);
+      expect((await unarchive(archived)).status).toBe(200);
+      expect((await unarchive(archived)).status).toBe(200);
+      // And sending works again, with nothing lost.
+      expect((await sendTo(archived)).status).toBe(201);
+    });
+
+    it("answers an absent channel as it answers a foreign one", async () => {
+      const absent = await archive("00000000-0000-4000-8000-000000000000");
+      const foreign = await archive(foreignChannelId);
+      expect(absent.status).toBe(foreign.status);
+      const a = withoutRequestId(await absent.json());
+      const b = withoutRequestId(await foreign.json());
+      expect(a).toEqual(b);
+    });
+
+    it("does not change what a user has left unread (FR-022, T078)", async () => {
+      // The edge case the spec names, and this is where "the count is still true"
+      // gets a definition: archiving writes ONE column on `channels` and touches no
+      // message and no read position. So `last_sequence` is what it was, every read
+      // position is what it was, and the arithmetic between them is unchanged.
+      //
+      // Asserted on the sequence rather than on a count, because the count is phase
+      // 12's route — this is the invariant that makes the count safe, tested where it
+      // can be tested.
+      const target = (await repo.createChannel("archive-unread", "public")).id;
+      const before = (await repo.sendMessage(target, { text: "unread by somebody" })).seq;
+      await archive(target);
+      const after = await repo.listMessages(target, { limit: 10 });
+      expect(after.map((m) => m.seq)).toContain(before);
+      // And the channel's sequence did not move: archiving is not a write to the log.
+      const reread = await repo.getChannelById(target);
+      expect(reread).not.toBeNull();
+      expect((await repo.listMessages(target, { limit: 10 })).length).toBe(1);
+    });
+  });
 });
services/api/src/internal/internal.itest.ts
@@ -30,24 +30,31 @@ import { mintUserToken } from "../auth/user-token";
 // deploy together.
 describe("the internal surface", () => {
   let app: INestApplication;
   let url: string;
   let env: { id: string };
   let channelId: string;
+  let privateChannelId: string;
   /** The gateway forwards the END USER'S token instead of
    * asserting two identity headers, so this suite mints tokens the same way the
    * dev-token endpoint does — with the environment's own signing secret. */
   let tokenFor: (user: string) => Promise<string>;
 
   beforeAll(async () => {
     const db = createDb(createPool());
     env = await createEnvironment(db, { name: "internal-itest" });
     const repo = new Repository(db, env.id);
     const user = await repo.createUser("tuan", "Tuan");
     channelId = (await repo.createChannel("fleet", "public")).id;
     await repo.addMember(channelId, user.id);
+    // The socket's route reaches the same `sendMessage`, so the
+    // membership check has to hold here too — this is the caller R1 counted and
+    // the one that always supplied a user.
+    privateChannelId = (await repo.createChannel("fleet-private", "private")).id;
+    await repo.addMember(privateChannelId, user.id);
+    await repo.createUser("stranger", "A Stranger");
     const signingSecret = (await environmentSigningSecret(db, env.id))!
       .signingSecret;
     tokenFor = async (subject: string) =>
       (
         await mintUserToken(signingSecret, {
           user: subject,
@@ -159,7 +166,70 @@ describe("the internal surface", () => {
     const res = await fetch(`${url}/internal/session`, {
       method: "POST",
       headers: { authorization: "Bearer not-a-token" },
     });
     expect(res.status).toBe(401);
   });
+
+  // ── THE SOCKET'S ROUTE INHERITS THE CHECK (FR-001) ──────────────────────────
+  //
+  // `POST /internal/messages` resolves the user from the forwarded token and then
+  // calls the same `messages.send` the public route does, so one check in
+  // `repository.sendMessage` covers both doors. The isolation harness recorded this route
+  // as checking nothing; what it was missing was a check, not a caller — it has
+  // always supplied `user.id` (`internal.controller.ts:65`).
+  it("refuses a non-member's send to a private channel, as if it were absent", async () => {
+    const refused = await send(
+      { channel_id: privateChannelId, text: "not mine" },
+      "stranger",
+    );
+    const absent = await send(
+      { channel_id: "00000000-0000-4000-8000-000000000000", text: "nowhere" },
+      "stranger",
+    );
+    expect(refused.status).toBe(absent.status);
+  });
+
+  it("accepts a member's send to the same private channel", async () => {
+    const accepted = await send(
+      { channel_id: privateChannelId, text: "mine to send" },
+      "tuan",
+    );
+    expect(accepted.status).toBe(201);
+  });
+
+  // ── THE SESSION CARRIES MEMBERSHIPS, WHICH IS WHY IT NEEDED NO CHANGE ───────
+  //
+  // T042. `session.controller` builds its channel list from
+  // `repository.channelsForUser`, which selects from `members` joined to `users`
+  // and filters on both the user and the environment. So a private channel a user
+  // is not a member of cannot enter a session — and neither can a PUBLIC one,
+  // which is FR-004's other half: the subscription set is not the read set.
+  //
+  // A CONFIRMATION RATHER THAN A CHECK, and the test says so. R2 measured this by
+  // reading the query; this asserts it against a running one, which is the
+  // difference between believing a comment and knowing.
+  it("names neither a private nor a public channel the user is not a member of", async () => {
+    const res = await fetch(`${url}/internal/session`, {
+      method: "POST",
+      headers: await headers("stranger"),
+    });
+    expect(res.status).toBe(200);
+    const body = (await res.json()) as { channel_ids: string[] };
+    expect(body.channel_ids).not.toContain(privateChannelId);
+    // `channelId` is the PUBLIC channel this suite's other user belongs to. The
+    // stranger can read it by id and send to it, and it is still not in their
+    // session: membership decides subscription, visibility decides reads.
+    expect(body.channel_ids).not.toContain(channelId);
+  });
+
+  it("names a channel the user IS a member of", async () => {
+    // The control. An empty list would satisfy the assertions above while proving
+    // that the session is broken rather than that it is scoped.
+    const res = await fetch(`${url}/internal/session`, {
+      method: "POST",
+      headers: await headers("tuan"),
+    });
+    const body = (await res.json()) as { channel_ids: string[] };
+    expect(body.channel_ids).toContain(privateChannelId);
+  });
 });
services/gateway/src/isolation-fixtures.ts
@@ -34,25 +34,62 @@ interface Seeder {
     createChannel: (
       externalId: string,
       type: string,
       name?: string,
     ) => Promise<{ id: string }>;
     addMember: (channelId: string, userId: string) => Promise<boolean>;
+    // WIDENED TO WHAT THE REPOSITORY ACTUALLY TAKES AND RETURNS. This cast is a
+    // hand-written declaration of another package's function — the gateway may not
+    // import service source — so it can be narrower than the truth without anything
+    // failing. It was, in both directions: no `userExternalId` and no `seq`, both of
+    // which the real signature has carried since the sender field arrived. A cast
+    // that omits a parameter makes passing it a type error, which is how this was
+    // found: by needing the parameter, not by reading the cast.
     sendMessage: (
       channelId: string,
-      input: { text: string; userId?: string },
-    ) => Promise<{ id: string }>;
+      input: {
+        text: string;
+        userId?: string;
+        userExternalId?: string;
+        metadata?: Record<string, unknown>;
+        idempotencyKey?: string;
+      },
+    ) => Promise<{ id: string; seq: number }>;
   };
 }
 
 export interface SocketTenant {
   environmentId: string;
   credential: string;
   userExternalId: string;
   userId: string;
   channelId: string;
+  /** A private channel in the same environment that this tenant's user is NOT a
+   * member of. */
+  privateChannelId: string;
+  /** That private channel's history, read with the APPLICATION key — which sees
+   * private channels (FR-005) — so a refused send can be checked against the
+   * rows rather than against its own error frame. */
+  privateHistory: () => Promise<string>;
+  /** Removes this tenant's user from its own channel via the public route. */
+  removeSelf: () => Promise<void>;
+  rejoinSelf: () => Promise<void>;
+  archiveOwnChannel: () => Promise<void>;
+  unarchiveOwnChannel: () => Promise<void>;
+  /** A token for `userExternalId`, minted through the api's own dev-token route so
+   * the signing secret never leaves the api — research R1's rule, and the reason
+   * the gateway asks rather than verifies. */
+  token: string;
+  /** Put a message in this tenant's channel, so a foreign subscriber has something
+   * it must not receive. */
+  say: (text: string) => Promise<{ id: string; seq: number }>;
+  /** This tenant's own channel history, read with its own credential through the
+   * public route. A write attack has to be checked against the victim's state and
+   * not against the attacker's refusal: a refusal that changed a row is still a
+   * breach, and only the victim's side of the wire can tell. */
+  history: () => Promise<string>;
 }
 
 export interface SocketTenants {
   /** The caller. Its token is the one every attack presents. */
   attacker: SocketTenant;
   /** The tenant whose identifiers the attacker borrows. */
@@ -114,35 +151,121 @@ export async function seedSocketTenants(): Promise<SocketTenants> {
     createDb: (pool: unknown) => unknown;
     createPool: () => unknown;
   };
   const seeder = require_(join(dist, "db", "repository.js")) as Seeder;
   const db = client.createDb(client.createPool());
 
+  // THE API STARTS FIRST NOW, and the order is forced rather than tidier. This
+  // chapter's fixture needs two things the previous one did not: a token minted
+  // through the api's own dev-token route, and a history read over the public route
+  // — both of which need a URL. Seeding before starting left `apiUrl` and `token`
+  // out of scope inside this closure, which the compiler said and a reader would
+  // not: the fields were named in the interface and filled in nowhere.
+  const api = await startApi();
+
   const seed = async (label: string): Promise<SocketTenant> => {
     const environment = await seeder.createEnvironment(db, {
       name: `socket-isolation-${label}-${randomUUID().slice(0, 8)}`,
     });
     const repo = new seeder.Repository(db, environment.id);
     const userExternalId = `${label}-user`;
     const user = await repo.createUser(userExternalId, `${label} user`);
     const channel = await repo.createChannel(`${label}-channel`, "public");
     await repo.addMember(channel.id, user.id);
-    await repo.sendMessage(channel.id, { text: `${label} says something`, userId: user.id });
+    // A PRIVATE channel in the same tenant, and this user is NOT a
+    // member of it. The four cross-tenant shapes all attack with another tenant's
+    // identifiers; a non-member of your own tenant is a different fixture, and the
+    // socket needs one too because `message.send` reaches the same check.
+    const privateChannel = await repo.createChannel(`${label}-private`, "private");
     const key = await seeder.createApiKey(db, { environmentId: environment.id });
     return {
       environmentId: environment.id,
       credential: key.credential,
       userExternalId,
       userId: user.id,
       channelId: channel.id,
+      privateChannelId: privateChannel.id,
+      // Minted through the api rather than signed here: the signing secret never
+      // leaves the api (research R1), which is also why the gateway asks the api to
+      // verify rather than verifying itself.
+      token: await mintToken(api.url, key.credential, userExternalId),
+      say: (text: string) =>
+        repo.sendMessage(channel.id, { text, userId: user.id, userExternalId }),
+      /** Remove this tenant's own user from its own public channel, through the
+       * public route — so the test asserts the consequence of the API rather than of
+       * a direct write. */
+      removeSelf: async () => {
+        const res = await fetch(
+          `${api.url}/v1/channels/${channel.id}/members/remove`,
+          {
+            method: "POST",
+            headers: {
+              "content-type": "application/json",
+              authorization: `Bearer ${key.credential}`,
+            },
+            body: JSON.stringify({ user_ids: [userExternalId] }),
+          },
+        );
+        if (!res.ok) throw new Error(`removeSelf for ${label}: ${res.status}`);
+      },
+      /** Archive and unarchive this tenant's own channel through the public routes,
+       * so the socket test observes the API's effect rather than a direct write. */
+      /** Put the membership back. A test that mutates shared fixture state has to
+       * restore it: T058 removed the attacker from their own channel and did not,
+       * and T078a two tests later failed on its control because the "member" was no
+       * longer one. The fixture's invariant — this tenant's user is a member of this
+       * tenant's channel — belongs to every test in the file, not to the first one
+       * that gets there. */
+      rejoinSelf: async () => {
+        const res = await fetch(
+          `${api.url}/v1/channels/${channel.id}/members`,
+          {
+            method: "POST",
+            headers: {
+              "content-type": "application/json",
+              authorization: `Bearer ${key.credential}`,
+            },
+            body: JSON.stringify({ user_ids: [userExternalId] }),
+          },
+        );
+        if (!res.ok) throw new Error(`rejoinSelf for ${label}: ${res.status}`);
+      },
+      archiveOwnChannel: async () => {
+        const res = await fetch(`${api.url}/v1/channels/${channel.id}/archive`, {
+          method: "POST",
+          headers: { authorization: `Bearer ${key.credential}` },
+        });
+        if (!res.ok) throw new Error(`archive for ${label}: ${res.status}`);
+      },
+      unarchiveOwnChannel: async () => {
+        const res = await fetch(`${api.url}/v1/channels/${channel.id}/archive`, {
+          method: "DELETE",
+          headers: { authorization: `Bearer ${key.credential}` },
+        });
+        if (!res.ok) throw new Error(`unarchive for ${label}: ${res.status}`);
+      },
+      privateHistory: async () => {
+        const res = await fetch(
+          `${api.url}/v1/channels/${privateChannel.id}/messages?limit=100`,
+          { headers: { authorization: `Bearer ${key.credential}` } },
+        );
+        if (!res.ok) throw new Error(`private history for ${label}: ${res.status}`);
+        return res.text();
+      },
+      history: async () => {
+        const res = await fetch(`${api.url}/v1/channels/${channel.id}/messages?limit=100`, {
+          headers: { authorization: `Bearer ${key.credential}` },
+        });
+        if (!res.ok) throw new Error(`history for ${label}: ${res.status}`);
+        return res.text();
+      },
     };
   };
 
   const attacker = await seed("attacker");
   const victim = await seed("victim");
-  const api = await startApi();
   return { attacker, victim, apiUrl: api.url, stop: api.stop };
 }
 
 /** A token for one tenant's user, minted with that tenant's key. */
 export async function mintToken(
   apiUrl: string,
services/gateway/src/isolation.itest.ts
@@ -56,30 +56,44 @@ async function firstFrame(socket: WebSocket, type: string): Promise<Record<strin
     });
     socket.on("close", (code) => reject(new Error(`closed ${code}`)));
     setTimeout(() => reject(new Error(`no ${type} within 5s`)), 5_000);
   });
 }
 
+/** ABSENCE NEEDS A DEADLINE RATHER THAN A RACE. `firstFrame` resolves on the frame it
+ * is waiting for, which is the right shape for "this is refused" and no shape at all
+ * for "nothing was delivered". This buffers everything a socket receives so a test can
+ * wait a fixed window and then read what the buffer holds. */
+function collect(socket: WebSocket): () => Record<string, unknown>[] {
+  const frames: Record<string, unknown>[] = [];
+  socket.on("message", (raw) => {
+    frames.push(JSON.parse(raw.toString()) as Record<string, unknown>);
+  });
+  return () => [...frames];
+}
+
+async function quiet(ms: number): Promise<void> {
+  await new Promise((resolve) => setTimeout(resolve, ms));
+}
+
 describe("the socket refuses another tenant's identifiers", () => {
   let t: SocketTenants;
   let server: Server;
   let url: string;
-  let attackerToken: string;
 
   beforeAll(async () => {
     t = await seedSocketTenants();
     server = serve({
       service: "gateway",
       health: () => ({}),
       logger: silent,
       notFoundDocsUrl: docsUrl("not_found"),
     });
     attachSessions({ server, api: createApiClient(t.apiUrl), logger: silent });
     await new Promise<void>((resolve) => server.listen(0, resolve));
     url = `ws://127.0.0.1:${(server.address() as AddressInfo).port}`;
-    attackerToken = await mintToken(t.apiUrl, t.attacker.credential, t.attacker.userExternalId);
   }, 90_000);
 
   afterAll(async () => {
     await new Promise<void>((resolve) => server?.close(() => resolve()));
     t?.stop();
   });
@@ -89,23 +103,23 @@ describe("the socket refuses another tenant's identifiers", () => {
     // An empty derivation is a broken derivation, not a small protocol.
     expect(types.length).toBeGreaterThan(1);
     expect(types).toContain("message.send");
   });
 
   it("a connection ack names nothing belonging to the other tenant", async () => {
-    const socket = new WebSocket(`${url}/v1/ws?token=${attackerToken}`);
+    const socket = new WebSocket(`${url}/v1/ws?token=${t.attacker.token}`);
     const ack = await firstFrame(socket, "connection.ack");
     const serialised = JSON.stringify(ack);
     expect(serialised).not.toContain(t.victim.channelId);
     expect(serialised).not.toContain(t.victim.environmentId);
     expect(serialised).not.toContain(t.victim.userId);
     socket.close();
   }, 20_000);
 
   it("message.send to the other tenant's channel is refused", async () => {
-    const socket = new WebSocket(`${url}/v1/ws?token=${attackerToken}`);
+    const socket = new WebSocket(`${url}/v1/ws?token=${t.attacker.token}`);
     await firstFrame(socket, "connection.ack");
     socket.send(
       JSON.stringify({
         type: "message.send",
         payload: {
           idem_key: randomUUID(),
@@ -133,31 +147,213 @@ describe("the socket refuses another tenant's identifiers", () => {
     //
     // So: every declared type must be refused SOMEHOW, and one well-formed server
     // frame must be refused BY THE RULE.
     const inboundOnly = declaredFrameTypes().filter((type) => type !== "message.send");
     expect(inboundOnly.length).toBeGreaterThan(0);
     for (const type of inboundOnly) {
-      const socket = new WebSocket(`${url}/v1/ws?token=${attackerToken}`);
+      const socket = new WebSocket(`${url}/v1/ws?token=${t.attacker.token}`);
       await firstFrame(socket, "connection.ack");
       socket.send(JSON.stringify({ type, payload: {} }));
       const error = await firstFrame(socket, "error");
       expect((error.payload as { code?: string }).code, `${type} was not refused`).toBeTruthy();
       socket.close();
     }
 
     // `message.ack` carries `{ seq }`, which is the easiest valid server frame to
     // build — so it is the one that proves the rule rather than the parser.
-    const socket = new WebSocket(`${url}/v1/ws?token=${attackerToken}`);
+    const socket = new WebSocket(`${url}/v1/ws?token=${t.attacker.token}`);
     await firstFrame(socket, "connection.ack");
     socket.send(JSON.stringify({ type: "message.ack", payload: { seq: 1 } }));
     const error = await firstFrame(socket, "error");
     expect((error.payload as { code?: string }).code).toBe("unknown_frame_type");
     // A protocol violation closes the connection (EIR-WS-06's 4002).
     await expect(closeCode(socket)).resolves.toBe(4002);
   }, 60_000);
 
+  // ── THE SOCKET'S SEND INTO A PRIVATE CHANNEL OF ITS OWN TENANT ─────────────
+  //
+  // FR-001. Every attack above crosses a tenant boundary; this one
+  // does not. The attacker's own tenant holds a private channel they are not a
+  // member of, and the socket reaches the same `repository.sendMessage` the REST
+  // route does — through `api-client` to `POST /internal/messages`, which has
+  // always supplied the user id.
+  //
+  // THE SAME ANSWER AS A CHANNEL THAT DOES NOT EXIST, on this surface too. A
+  // socket error frame carries a code rather than a status, so the comparison is
+  // between two frames: the refusal for a private channel the caller cannot see
+  // and the refusal for an id that exists nowhere.
+  it("a send into its own tenant's private channel is refused as if absent, and that channel gains nothing", async () => {
+    // A PAIR, AND THE PAIR IS THE ASSERTION. A code on its own says the send was
+    // refused; it does not say the refusal is indistinguishable from one for a
+    // channel that exists nowhere, and indistinguishability is the property SC-002
+    // asks for.
+    //
+    // ONE SOCKET FOR BOTH, because opening a second would let a difference in
+    // connection state stand in for a difference in the answer.
+    const socket = new WebSocket(`${url}/v1/ws?token=${t.attacker.token}`);
+    await firstFrame(socket, "connection.ack");
+
+    const text = `not a member ${randomUUID()}`;
+    socket.send(
+      JSON.stringify({
+        type: "message.send",
+        payload: { idem_key: randomUUID(), channel: t.attacker.privateChannelId, text },
+      }),
+    );
+    const refused = await firstFrame(socket, "error");
+
+    socket.send(
+      JSON.stringify({
+        type: "message.send",
+        payload: {
+          idem_key: randomUUID(),
+          channel: "00000000-0000-4000-8000-000000000000",
+          text: `nowhere ${randomUUID()}`,
+        },
+      }),
+    );
+    const absent = await firstFrame(socket, "error");
+    socket.close();
+
+    const refusedCode = (refused.payload as { code?: string }).code;
+    expect(refusedCode, "the private-channel send was not refused at all").toBeTruthy();
+    expect(refusedCode).toBe((absent.payload as { code?: string }).code);
+
+    // AND READ THE CHANNEL, rather than inferring its state from the refusal. A
+    // refusal that wrote the row first is still a breach, and only the channel's own
+    // side of the wire can tell. Read with the APPLICATION key, which sees private
+    // channels (FR-005), so the check is not itself subject to the rule under test.
+    const after = await t.attacker.privateHistory();
+    expect(after).not.toContain(text);
+  });
+
+  // ── A REMOVED MEMBER'S RECONNECTION (SC-004) ────────────────────────────────
+  //
+  // T058. The session is built from `members` — `channelsForUser` selects from that
+  // table, and `repository.backfill` joins it per cursor — so removal takes the
+  // channel out of the next session without the gateway knowing anything about
+  // removal.
+  //
+  // ASSERTED ON THE ACCEPTED CURSOR, and the two attempts before this one are worth
+  // recording because both were unfalsifiable:
+  //
+  //   1. Looking for the channel id in `connection.ack`'s payload. That frame carries
+  //      `user`, `cursor`, `resume_ok`, `truncated` and no channel list.
+  //   2. Sending a message and waiting for `message.created`. **This suite attaches
+  //      no fan-out** — `attachSessions({server, api, logger})` passes none, so
+  //      `fanout?.publish` is a no-op and nothing is ever delivered here. The control
+  //      hung for five seconds and timed out.
+  //
+  // The ack's `cursor` is what the server ACCEPTED, so it is the one place the
+  // session's membership decision is visible from outside. The control below shows a
+  // member's cursor being accepted, which is what makes the removal assertion mean
+  // something.
+  it("a removed member's resume cursor is no longer accepted", async () => {
+    // A CONTROL AND THE CASE, in that order, on one fixture. Asserting only that a
+    // removed member's cursor is refused would pass against a server that accepts no
+    // cursor at all — so the same token presents the same cursor twice, and the
+    // difference between the two acks is the whole assertion.
+    await t.attacker.say(`before removal ${randomUUID()}`);
+
+    const asMember = new WebSocket(
+      `${url}/v1/ws?token=${t.attacker.token}&cursor=${t.attacker.channelId}:0`,
+    );
+    const first = await firstFrame(asMember, "connection.ack");
+    const beforeCursor = (first.payload as { cursor?: Record<string, number> }).cursor ?? {};
+    expect(Object.keys(beforeCursor)).toContain(t.attacker.channelId);
+    asMember.close();
+
+    // Through the PUBLIC ROUTE, so the test asserts the consequence of the API rather
+    // than of a direct write — a repository call would prove the session reads
+    // `members` and nothing about whether the endpoint gets there.
+    await t.attacker.removeSelf();
+
+    const afterRemoval = new WebSocket(
+      `${url}/v1/ws?token=${t.attacker.token}&cursor=${t.attacker.channelId}:0`,
+    );
+    const frames = collect(afterRemoval);
+    const second = await firstFrame(afterRemoval, "connection.ack");
+    const afterCursor = (second.payload as { cursor?: Record<string, number> }).cursor ?? {};
+    expect(Object.keys(afterCursor)).not.toContain(t.attacker.channelId);
+
+    // And nothing is backfilled from it either. The ack's cursor is what the server
+    // ACCEPTED; this is what it DELIVERED, and the two can disagree.
+    await quiet(1_000);
+    expect(frames().filter((f) => f.type === "message.created")).toEqual([]);
+    afterRemoval.close();
+
+    // PUT IT BACK. This test mutates state every later test in the file leans on,
+    // and the next one to need it failed on its control rather than on its subject.
+    await t.attacker.rejoinSelf();
+  });
+
+  // ── AN ARCHIVED CHANNEL AND THE SOCKET (FR-022a) ───────────────────────────
+  //
+  // T078a. FR-022 asks two things and only one of them had a task for eleven
+  // analysis passes: whether an archived channel appears in a listing (it does, with
+  // a flag) and **whether the socket delivers anything for it**. This is the second.
+  //
+  // THE ANSWER IS THAT NOTHING CHANGES, AND THE GATEWAY NEEDS NO EDIT. Archiving
+  // stops writes and touches no membership, so the channel stays in the session and
+  // the resume cursor is still accepted. Nothing new arrives because nothing new can
+  // be sent — the refusal is at the write, not at the subscription.
+  //
+  // A NO-OP PROVED RATHER THAN ASSUMED. "We changed nothing so nothing broke" is the
+  // sentence this test exists to replace: archiving could plausibly have been
+  // implemented by removing memberships, and then a member would silently lose the
+  // channel from their session.
+  it("keeps an archived channel in the session and its cursor accepted", async () => {
+    await t.attacker.archiveOwnChannel();
+    try {
+      const socket = new WebSocket(
+        `${url}/v1/ws?token=${t.attacker.token}&cursor=${t.attacker.channelId}:0`,
+      );
+      const ack = await firstFrame(socket, "connection.ack");
+      const cursor = (ack.payload as { cursor?: Record<string, number> }).cursor ?? {};
+      expect(Object.keys(cursor)).toContain(t.attacker.channelId);
+      socket.close();
+    } finally {
+      // IN A `finally`, BECAUSE THE TEST ABOVE LEARNED THIS THE OTHER WAY. It left a
+      // removed membership behind and the next test failed on its control rather
+      // than on its subject. An assertion that throws must still put the state back,
+      // or the diagnosis lands in a file that did nothing wrong.
+      await t.attacker.unarchiveOwnChannel();
+    }
+  });
+
+  // ── T047: the resume ───────────────────────────────────────────────────────
+  it("a cursor naming the other tenant's channel backfills nothing", async () => {
+    // Something to backfill, planted before the socket opens — a resume that finds an
+    // empty channel proves nothing about whether it would have delivered.
+    const text = `before the resume ${randomUUID()}`;
+    await t.victim.say(text);
+
+    const socket = new WebSocket(
+      `${url}/v1/ws?token=${t.attacker.token}&cursor=${t.victim.channelId}:1`,
+    );
+    const frames = collect(socket);
+    const ack = await firstFrame(socket, "connection.ack");
+
+    // THE ACK ECHOES WHAT THE SERVER ACCEPTED, not what the client presented. A
+    // channel this token cannot see is not in it — and asserting on the echo is the
+    // cheap half, because a server that silently dropped the cursor and a server that
+    // honoured it both answer with an ack.
+    const cursor = (ack.payload as { cursor?: Record<string, number> }).cursor ?? {};
+    expect(Object.keys(cursor)).not.toContain(t.victim.channelId);
+
+    // THE EXPENSIVE HALF. Wait a window and read the buffer: the property is that no
+    // message from that channel is delivered, and only elapsed time can say so.
+    await quiet(1_000);
+    const delivered = frames().filter((f) => f.type === "message.created");
+    expect(delivered).toEqual([]);
+    // And the buffer is not empty for an unrelated reason — the ack is in it, so a
+    // collector that attached too late would fail here rather than pass vacuously.
+    expect(frames().some((f) => f.type === "connection.ack")).toBe(true);
+    socket.close();
+  });
+
   it("a token minted by one tenant cannot open a session for the other", async () => {
     // The attacker asks its OWN api for a token naming the victim's user. Either the
     // mint refuses, or the session it opens must resolve nothing of the victim's.
     const borrowed = await mintToken(
       t.apiUrl,
       t.attacker.credential,
@@ -171,7 +367,48 @@ describe("the socket refuses another tenant's identifiers", () => {
     } catch {
       // A closed socket is a refusal, which is also correct.
       await expect(closeCode(socket)).resolves.toBeGreaterThan(0);
     }
     socket.close();
   }, 20_000);
+
+  // ── THE SAME-TENANT NON-MEMBER, ON THE SOCKET (T087) ───────────────────────
+  //
+  // The protocol's frame union has exactly one inbound member — `message.send` — so
+  // there is no "subscribe" frame to attack: what a socket may see is decided at
+  // connect, from the session's channel list, and a cursor is the only thing a client
+  // gets to assert about it. So the socket's version of "a non-member reaches a
+  // private channel" is a cursor naming one.
+  //
+  // The tenant's own user is not a member of the tenant's own private channel —
+  // `seedSocketTenants` creates it and adds nobody — which makes this the same-tenant
+  // case rather than the cross-tenant one every other attack here uses.
+  it("a same-tenant non-member's cursor for a private channel is not accepted", async () => {
+    const socket = new WebSocket(
+      `${url}/v1/ws?token=${t.attacker.token}&cursor=${t.attacker.privateChannelId}:0`,
+    );
+    const frames = collect(socket);
+    const ack = await firstFrame(socket, "connection.ack");
+    const cursor = (ack.payload as { cursor?: Record<string, number> }).cursor ?? {};
+    expect(Object.keys(cursor)).not.toContain(t.attacker.privateChannelId);
+
+    // THE CONTROL IS THE REMOVAL TEST ABOVE: the same token's cursor for a channel it
+    // IS a member of gets accepted there. Without that pair, an empty cursor set here
+    // would pass whether the session was scoped or simply broken.
+    await quiet(1_000);
+    expect(frames().filter((f) => f.type === "message.created")).toEqual([]);
+    socket.close();
+  });
+
+  // ── T048: the subscribe ────────────────────────────────────────────────────
+  it("nothing from the other tenant's channel is delivered", async () => {
+    const socket = new WebSocket(`${url}/v1/ws?token=${t.attacker.token}`);
+    const frames = collect(socket);
+    await firstFrame(socket, "connection.ack");
+    await t.victim.say(`the victim speaks ${randomUUID()}`);
+    // A DEADLINE RATHER THAN A RACE, and longer than the others because this one is
+    // waiting on a fan-out that has to travel through Redis before it could arrive.
+    await quiet(1_500);
+    expect(frames().filter((f) => f.type === "message.created")).toEqual([]);
+    socket.close();
+  });
 });
packages/protocol/src/codes.ts
@@ -26,12 +26,49 @@ export const ERROR_CODES = {
   // response has to say which class was presented and which the route wanted.
   // The MESSAGE names the class and never the credential — "the key rk_dev_abc…
   // is invalid" is how a live secret reaches a support ticket (NFR-SEC-06).
   wrong_credential_type:
     "the credential class presented cannot use this route; the message names presented and expected",
 
+  // ── THIS CHAPTER'S THREE (FR-021, FR-031, research R11) ──────────────────────
+  //
+  // Three refusals a client acts on differently, which is the test this registry
+  // sets: `channel_member_limit_exceeded` above is separate from a quota because one
+  // resets on a date and the other never does. Reusing `forbidden` for all three
+  // would leave a client unable to tell "join the channel" from "wait for the archive
+  // to lift" from "contact support".
+  //
+  // `not_a_member` HAS EXACTLY ONE EMITTER, and saying so here saves the next reader
+  // a search. A PRIVATE channel the caller cannot see answers the not-found envelope,
+  // byte-identical to a channel that does not exist — SC-002 covers send along with
+  // the three reads, and a 403 naming the membership would announce that the channel
+  // exists. A PUBLIC channel permits a tenant user to read, send and join without
+  // membership (FR-004). What is left is the read-position route on a public channel,
+  // which the next chapter builds: a read position is per-member state, keyed by
+  // channel and user, and removal deletes the row — so refusing a non-member there is
+  // the same rule the rest of this table keeps.
+  not_a_member:
+    "the user is not a member of this channel; the message names neither the channel's contents nor its members",
+  // Archiving prevents new messages and preserves history, and this is the refusal a
+  // send gets.
+  //
+  // ONLY ONCE THE CALLER CAN SEE THE CHANNEL. FR-021a fixes the order at ban, then
+  // membership and visibility, then archive — reverse the last two and a non-member
+  // of a private archived channel learns it exists from this code, which is the leak
+  // FR-003 forbids.
+  channel_archived:
+    "the channel is archived and accepts no new messages; history is still readable",
+  // A ban is tenant-scope: no connecting and no sending anywhere in the environment,
+  // while the banned user's history stays readable by others.
+  //
+  // CHECKED BEFORE THE CHANNEL IS RESOLVED, so a banned user gets the same answer for
+  // every channel id. Resolve first and this becomes the existence oracle the
+  // membership refusal is not allowed to be.
+  user_banned:
+    "the user is banned in this environment and can neither connect nor send; their existing messages remain",
+
   // ── THE FOUR THE FILTER SENDS AND THIS OBJECT NEVER DECLARED (FR-024) ────────
   //
   // `ProtocolErrorFilter` maps a status to a code when the thrower names none, and
   // four of the five it can produce are absent here. They have gone out on the wire
   // since chapter 2.2 widened that ladder, while this registry called itself the
   // documented vocabulary — and `docs_url` is DERIVED from the code, so every one of
packages/protocol/src/codes.test.ts
@@ -55,12 +55,58 @@ describe("every code the REST filter can emit is registered (FR-024)", () => {
   it.each(EMITTED_BY_STATUS)("registers %s", (code) => {
     expect(ERROR_CODES).toHaveProperty(code);
     expect(ERROR_CODES[code as ErrorCode]).not.toBe("");
   });
 });
 
+describe("the three refusals this chapter's channel adds", () => {
+  // NAMED HERE RATHER THAN COUNTED. A `toHaveLength(13)` would go red for the right
+  // reason on a deletion and for the wrong reason on any addition, so every later
+  // chapter that adds a code would edit this number — and a number edited on every
+  // change is a number nobody reads. What matters about these three is that they are
+  // three and not one: a client acts differently on each.
+  const ADDED = ["not_a_member", "channel_archived", "user_banned"] as const;
+
+  it.each(ADDED)("registers %s with a description a client can act on", (code) => {
+    expect(ERROR_CODES).toHaveProperty(code);
+    expect(ERROR_CODES[code as ErrorCode]).not.toBe("");
+  });
+
+  it("keeps them distinct from forbidden, which is what they exist instead of", () => {
+    // Reusing `forbidden` for all three is the design this chapter argues against, so
+    // the assertion is that no two of them share a description with it or each other
+    // — the failure mode is a copied line, not a missing key.
+    const meanings = [...ADDED, "forbidden"].map((c) => ERROR_CODES[c as ErrorCode]);
+    expect(new Set(meanings).size).toBe(meanings.length);
+  });
+
+  // THREE CLAIMS, THREE TITLES, and the reason is what a failure looks like from
+  // outside the repository. One case asserting all three went red on the wording of
+  // `not_a_member` under a title about bans and archives — a CI summary has no tree
+  // to grep, so the title is the whole report.
+  //
+  // Asserting on wording is unusual and deliberate: these strings are the contract
+  // `docs_url` resolves to, and a client's developer reads them rather than the code.
+
+  it("says a ban is tenant-scope, not channel-scope", () => {
+    expect(ERROR_CODES.user_banned).toMatch(/environment/);
+  });
+
+  it("says an archive leaves history readable", () => {
+    expect(ERROR_CODES.channel_archived).toMatch(/history is still readable/);
+  });
+
+  it("never lets not_a_member announce that the channel exists", () => {
+    // THE LEAK FR-003 FORBIDS, in the one place it can be written by accident. A
+    // private channel the caller cannot see must answer the not-found envelope, so a
+    // description saying "the channel exists and…" would put the oracle in the text
+    // even when the status code is right.
+    expect(ERROR_CODES.not_a_member).not.toMatch(/\bexists?\b/);
+  });
+});
+
 describe("the docs URL is built in one place, with the code as the anchor", () => {
   it("appends the code VERBATIM — no slug transform, no case change", () => {
     for (const code of Object.keys(ERROR_CODES) as ErrorCode[]) {
       expect(docsUrl(code)).toBe(`${ERROR_DOCS_BASE}/${code}`);
       expect(docsUrl(code).endsWith(`/${code}`)).toBe(true);
     }

The socket had no frame to attack

The task said to add "a same-tenant non-member's subscribe frame, from the protocol's own frame union". Reading the union settles it: there is exactly one inbound member, message.send. There is no subscribe frame. What a socket may see is decided at connect, from the session's channel list, and a cursor is the only thing a client gets to assert about it.

So the attack is a cursor naming the private channel, and the assertion is that the ack's cursor set does not contain it. A task that had simply said "add a subscribe test" would have produced a test of something that does not exist; the instruction to derive it from the union is what turned it into a test that could be written.

Six routes classified, and nobody had written the attacks

The gauntlet ends with a test that compares what ran against what the classification says should have run. Adding this chapter's routes to targets.ts made it fail six times over:

AssertionError: classified but never attacked: GET /v1/channels/:channelId,
POST /v1/channels/:channelId/join, POST /v1/channels/:channelId/members/remove,
PATCH /v1/channels/:channelId/members/:userExternalId,
POST /v1/channels/:channelId/archive, DELETE /v1/channels/:channelId/archive

The attacks existed. They were written alongside the channel endpoints, in a commit that also carried six /v1/webhooks* target rows — and the webhook rows belong to the chapter that builds webhooks, so they were deferred. The attacks went with them, and nothing said so. A deferral is a decision about what to move; it has no way of knowing what leans on what it moved.

What said so was a suite that accounts for itself. Without that test the six routes would have been classified, documented, exempt from nothing, and attacked by nobody — and every count in this chapter's checkpoint would have read correctly.

And the assertion beside it was arithmetic on another tree's number:

    const BUILT_SO_FAR = 6;
    expect(derived.length).toBe(24 + BUILT_SO_FAR);

Twenty-four was a different chapter's closing count, carried here as a literal. It failed expected 17 to be 30, and neither figure names a route — the useful half of that failure is which one is missing, and a subtraction cannot say. It is a named list now, checked against the running router.

PORT=0 was safe for one of two services

The end-to-end journey failed twice with all eight tests skipped, immediately after a lane that spawns api children, and green when run on its own. Everything about that points at a port: the harness bound a fixed 4100 and gave each gateway apiPort + 1 + i, which is a band, and a band is a table nothing checks.

Replacing it with PORT=0 did not fix it.

api up on 37763
gateway 1 never became healthy

The api reports the port it bound — it reads the address back, because logging the value you were handed prints 0. The gateway logged the value it was handed. So PORT=0 produced a health probe against port zero, and the failure landed in the journey rather than in either service.

Four fixed ports are gone with it: this band, the socket suite's 4123, the journey's 4100, and the gateway band derived from it. RELAY_E2E_API_PORT and RELAY_SESSION_ITEST_API_PORT leave turbo.json's env allowlist. The only port literals left in the repository are the two services' own production defaults.

Two characters that read as a leaked secret

While the ports were being chased, the credential suite failed once:

AssertionError: expected '[{"public_id":"cbd76832c8bcb15cf50def…' not to contain 'WA'

The test proves a minted secret is unrecoverable from the row it leaves behind, and it took the secret like this:

    const secret = minted.credential.split("_").at(-1)!;

The secret is base64url, and base64url's alphabet includes _. So the assertion was on whatever followed the secret's own last underscore — usually a long tail, and the test passed for the right reason. Occasionally two characters, and it passed for no reason at all. Then those two characters appeared inside the salt stored beside the hash:

salt: 7XKYdYc_ottu61KbLY4dWA

Which reads exactly like the api returning a secret it had just hashed.

minted.prefix was already on the object the mint returns. The first repair used lastIndexOf("_") and was the same fault a second time — the last underscore in the credential can belong to the secret. There is one exact answer and it was already there.

The count that did not move, and the file that still did not change

The catalogue that proves every table has a path to exactly one tenant reports 13 base tables — 3 direct, 2 hop, 8 spine — and tenant-scope.itest.ts did not change by one character.

This chapter adds no table. It adds a column to members, and members stays hop: it carries no environment_id, so it is reached through channels and no trigger watches it. A role column feels like tenant state and the classification is not about how a column feels — it is derived from information_schema on every run.

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.

Twenty of this chapter's twenty-seven changed files are fenced in the sections above — the schema, the migration, the four doors, the endpoints, the gauntlet. What follows is the seven that arrived while the chapter was being verified rather than while it was being built: a helper lifted out of one suite so every route can share it, the port work, and one flake that had been in the tree since the credential chapter.

The oracle, lifted

services/api/src/isolation/compare.ts
/** The indistinguishability oracle.
 *
 * LIFTED FROM `messages/messages.itest.ts`, WHERE IT WAS WRITTEN AND WHERE IT WAS
 * RIGHT. Chapter 2.2's suite needed to prove that a foreign channel answers exactly
 * as an absent one; `request_id` on every error body is what forced this helper into
 * existence, and the error-registry chapter is what put it there; and there it stayed —
 * one file's private
 * function doing the thing constitution I asks of every endpoint.
 *
 * A correct assertion written once and never generalised is what separates scattered
 * isolation tests from a suite. It lives here so every route can share it, and
 * `messages.itest.ts` imports it back.
 *
 * `request_id` is unique per request BY DESIGN, so two error bodies can no longer be
 * compared whole — and comparing them whole is how a suite proves a foreign resource
 * is indistinguishable from an absent one, which is a tenant-isolation property
 * (constitution I).
 *
 * The id is the one field that reveals nothing about the resource, so it is the one
 * field the comparison must drop. Everything discriminating still has to match
 * exactly.
 *
 * WHY IT ARRIVES IN THIS CHAPTER AND NOT WITH THE HARNESS. The isolation harness
 * owns it by subject — `chapter-map.json` lists it there — and that chapter's page
 * never fenced it, so it was assigned to a chapter and delivered by none. It is here
 * because this is the first chapter whose tests cannot be written without it: three
 * of them compare a private channel's refusal against an absent channel's, and the
 * bodies differ by exactly this field. A file belongs to the chapter that creates it. */
export function withoutRequestId(body: unknown): unknown {
  if (typeof body !== "object" || body === null) return body;
  const rest: Record<string, unknown> = { ...(body as Record<string, unknown>) };
  delete rest["request_id"];
  return rest;
}

Every service reports the port it bound

services/gateway/src/main.ts
@@ -45,12 +45,29 @@ export function createServer(logger?: Logger) {
     void fanout.close();
   });
   return server;
 }
 
 if (import.meta.main) {
-  const port = Number(process.env.PORT ?? 4001);
+  const requested = Number(process.env.PORT ?? 4001);
   const logger = createLogger("gateway");
-  createServer(logger).listen(port, () => {
+  const server = createServer(logger);
+  server.listen(requested, () => {
+    // THE PORT IT GOT, NOT THE PORT IT ASKED FOR — the same fix the api carries, and
+    // for eleven chapters only the api carried it.
+    //
+    // `PORT=0` asks the operating system for a free port, which is what a test
+    // spawning a service should do. But a parent can only use the number if the
+    // child reports it, and logging `requested` prints 0. The api reads the bound
+    // address back; this did not, so `PORT=0` was safe for one of the two services
+    // and nothing said which. The e2e journey found it by asking: `api up on 37763`,
+    // then `gateway 1 never became healthy` — a health probe against port zero.
+    //
+    // NOTHING NOTICED BECAUSE NOTHING ASKED. Every suite that spawned a gateway used
+    // a fixed port, so the logged value was always the value it had passed in and
+    // always correct by accident.
+    const address = server.address() as { port?: number } | string | null;
+    const port =
+      typeof address === "object" && address !== null ? (address.port ?? requested) : requested;
     logger.log("info", "listening", { port });
   });
 }
packages/test-harness/src/bound-port.test.ts
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
 
import { describe, expect, it } from "vitest";
 
// EVERY SERVICE A TEST CAN SPAWN MUST REPORT THE PORT IT BOUND.
//
// `PORT=0` is how a spawned service avoids the fixed-port collisions this repository
// spent four suites removing. It only works if the child says which port it got, and
// a child that logs the value it was PASSED prints `0`.
//
// The api has read the bound address back since the isolation harness. The gateway
// did not, for eleven chapters, and nothing said so — every suite that spawned a
// gateway handed it a fixed port, so the logged value was the value passed in and
// correct by accident. The e2e journey found it the moment it asked: `api up on
// 37763`, then `gateway 1 never became healthy`, which is a health probe against
// port zero.
//
// WHY SOURCE-READING AND NOT A RUNNING PROBE. `main.ts` is excluded from coverage
// (`**/main.ts`) because it is reached by running the service rather than asserting
// on it, and a test that spawns both services to check one log line costs more than
// the whole unit lane. What has to be true is a property of the source: the port
// logged is derived from `address()` and not from the environment.
 
const ROOT = join(import.meta.dirname, "..", "..", "..");
 
/** Every service with a `main.ts` a test could spawn. Derived, not listed: a new
 * service added under `services/` arrives here without anyone remembering. */
function serviceMains(): string[] {
  return readdirSync(join(ROOT, "services"), { withFileTypes: true })
    .filter((e) => e.isDirectory())
    .map((e) => join("services", e.name, "src", "main.ts"))
    .filter((p) => existsSync(join(ROOT, p)));
}
 
describe("a spawned service reports the port it bound", () => {
  it("finds a main.ts for more than one service", () => {
    // A derivation that finds one file passes vacuously for the other.
    expect(serviceMains().length).toBeGreaterThan(1);
  });
 
  it.each(serviceMains())("%s reads the bound address back", (rel) => {
    const text = readFileSync(join(ROOT, rel), "utf8");
    expect(text, `${rel} never calls address()`).toMatch(/\.address\(\)/);
  });
 
  it.each(serviceMains())("%s does not log the port it asked for", (rel) => {
    const text = readFileSync(join(ROOT, rel), "utf8");
    // THE FAILURE THIS CATCHES, written as the pattern that caused it:
    //   const port = Number(process.env.PORT ?? 4001);
    //   ... logger.log("info", "listening", { port });
    // The name bound directly from the environment must not be the one logged. Both
    // services call it `requested` now, which is the convention this asserts.
    const fromEnv = /const\s+(\w+)\s*=\s*Number\(process\.env(?:\.PORT|\["PORT"\])/.exec(text);
    expect(fromEnv, `${rel} does not read PORT the way this test reads it`).not.toBeNull();
    const name = fromEnv![1]!;
    expect(name, `${rel} logs ${name}, read straight from the environment`)
      .not.toBe("port");
  });
});
packages/e2e/src/harness.ts
@@ -339,12 +339,41 @@ export async function boot({ gateways = 2 } = {}): Promise<System> {
     child.stderr?.on("data", (d: Buffer) => lines.push(d.toString().trim()));
     child.on("exit", (code, signal) => {
       if (code !== 0 && signal === null) lines.push(`exited with code ${code}`);
     });
     return child;
   };
+  /** THE PORT THE CHILD BOUND, READ FROM ITS OWN LOG LINE.
+   *
+   * `capture` already buffers every line, so the port was already here — it just was
+   * not being read. Both services log `{"msg":"listening","port":N}` once the server
+   * is up, and that line is a stronger readiness signal than a health probe: a probe
+   * can pass against a DIFFERENT process holding the port, which is exactly what a
+   * fixed port makes possible. */
+  const boundPort = async (name: string, what: string): Promise<number> => {
+    const lines = output.get(name)!;
+    const deadline = Date.now() + 30_000;
+    for (;;) {
+      for (const chunk of lines) {
+        for (const line of chunk.split("\n")) {
+          if (!line.trim().startsWith("{")) continue;
+          try {
+            const parsed = JSON.parse(line) as { msg?: string; port?: number };
+            if (parsed.msg === "listening" && typeof parsed.port === "number") {
+              return parsed.port;
+            }
+          } catch {
+            /* a partial line; the next chunk completes it */
+          }
+        }
+      }
+      if (Date.now() > deadline) throw new Error(dump(`${what} never reported a port`));
+      await new Promise((resolve) => setTimeout(resolve, 50));
+    }
+  };
+
   const dump = (what: string) => {
     const lines = [`${what}; child output follows:`];
     for (const [name, log] of output) {
       lines.push(`--- ${name} ---`, ...log.slice(-12));
     }
     return lines.join("\n");
@@ -372,42 +401,54 @@ export async function boot({ gateways = 2 } = {}): Promise<System> {
     // the line above exists — this journey asserts message delivery, and a
     // background consumer writing to a table the broker chapter's suite asserts on is a race
     // between test files rather than a property of the system.
     RELAY_EVENT_CONSUMER: "off",
   };
 
-  const apiPort = Number(process.env.RELAY_E2E_API_PORT ?? 4100);
+  // THE LAST FIXED PORT IN THE REPOSITORY, AND IT FAILED THE WAY THE OTHERS DID.
+  //
+  // This read `Number(process.env.RELAY_E2E_API_PORT ?? 4100)`, and the gateways took
+  // `apiPort + 1 + i` — a band derived from it. Run on its own the lane is green; run
+  // straight after a lane that spawns api children it failed twice with every test
+  // SKIPPED, because a child that cannot bind never becomes healthy and the whole
+  // file dies in setup. That reads like a broken journey and is a busy port.
+  //
+  // `PORT=0` for every child, and the port read back from the line it logs. The
+  // gateways no longer derive theirs from the api's, so there is no band to collide
+  // with and no arithmetic to keep true.
   children.push(
     capture(
       "api",
       spawn("node", [join(REPO, "services", "api", "dist", "main.js")], {
-        env: { ...env, PORT: String(apiPort) },
+        env: { ...env, PORT: "0" },
         stdio: ["ignore", "pipe", "pipe"],
       }),
     ),
   );
+  const apiPort = await boundPort("api", "api");
   const apiUrl = `http://127.0.0.1:${apiPort}`;
   await waitForHealth(`${apiUrl}/healthz`, "api");
   say(`api up on ${apiPort}`);
 
   const urls: string[] = [];
   for (let i = 0; i < gateways; i++) {
-    const port = apiPort + 1 + i;
+    const name = `gateway ${i + 1}`;
     children.push(
       capture(
-        `gateway ${i + 1}`,
+        name,
         spawn("pnpm", ["exec", "tsx", "src/main.ts"], {
           cwd: join(REPO, "services", "gateway"),
-          env: { ...env, PORT: String(port), RELAY_API_URL: apiUrl },
+          env: { ...env, PORT: "0", RELAY_API_URL: apiUrl },
           stdio: ["ignore", "pipe", "pipe"],
         }),
       ),
     );
-    await waitForHealth(`http://127.0.0.1:${port}/healthz`, `gateway ${i + 1}`);
+    const port = await boundPort(name, name);
+    await waitForHealth(`http://127.0.0.1:${port}/healthz`, name);
     urls.push(`ws://127.0.0.1:${port}`);
-    say(`gateway ${i + 1} up on ${port}`);
+    say(`${name} up on ${port}`);
   }
 
   const environments: string[] = [];
   const newEnvironment = async (label: string) => {
     const created = await seeder.createEnvironment(db, {
       name: `e2e-${label}-${randomUUID().slice(0, 8)}`,
services/gateway/src/session.itest.ts
@@ -110,20 +110,48 @@ async function startApi(): Promise<ApiUnderTest> {
   const channel = await repo.createChannel("fleet", "public");
   await repo.addMember(channel.id, user.id);
   const key = await seeder.createApiKey(db, {
     environmentId: environment.id,
   });
 
-  const port = Number(process.env.RELAY_SESSION_ITEST_API_PORT ?? 4123);
+  // PORT=0, AND THE PORT READ BACK FROM THE CHILD. This bound a fixed 4123 behind an
+  // environment variable nothing set — so every run took the same port, and a
+  // previous run's child still holding it answers the health check from a DIFFERENT
+  // environment. Every token this run minted is then refused by an api that has never
+  // heard of it, which reads as a credential fault and is a busy port.
   const child: ChildProcess = spawn("node", [join(dist, "main.js")], {
-    // No outbox relay in this child. This suite is about the
-    // socket's credentials; a background loop draining a table that chapter
-    // The outbox chapter's suite is asserting on turns two unrelated test files into a race.
-    env: { ...process.env, PORT: String(port), RELAY_OUTBOX_RELAY: "off" },
+    // No outbox relay in this child. This suite is about the socket's credentials; a
+    // background loop draining a table the outbox chapter's suite is asserting on
+    // turns two unrelated test files into a race.
+    env: { ...process.env, PORT: "0", RELAY_OUTBOX_RELAY: "off" },
     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) => {
+      buffered += chunk.toString();
+      for (const line of buffered.split("\n")) {
+        if (!line.trim()) continue;
+        try {
+          const parsed = JSON.parse(line) as { msg?: string; port?: number };
+          if (parsed.msg === "listening" && typeof parsed.port === "number") {
+            clearTimeout(timer);
+            resolve(parsed.port);
+            return;
+          }
+        } catch {
+          /* a partial line; the next chunk completes it */
+        }
+      }
+    });
+    child.on("exit", (code) => {
+      clearTimeout(timer);
+      reject(new Error(`api exited before listening (code ${String(code)})`));
+    });
+  });
   const url = `http://127.0.0.1:${port}`;
   await waitForHealth(`${url}/healthz`);
 
   return {
     url,
     environmentId: environment.id,
turbo.json
@@ -1,39 +1,54 @@
 {
   "$schema": "https://turborepo.com/schema.json",
   "tasks": {
     "build": {
-      "dependsOn": ["^build"],
-      "outputs": ["dist/**"]
+      "dependsOn": [
+        "^build"
+      ],
+      "outputs": [
+        "dist/**"
+      ]
     },
     "dev": {
-      "dependsOn": ["^build"],
+      "dependsOn": [
+        "^build"
+      ],
       "cache": false,
       "persistent": true
     },
     "typecheck": {
-      "dependsOn": ["^build"]
+      "dependsOn": [
+        "^build"
+      ]
     },
     "test": {
-      "dependsOn": ["^build"],
-      "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/compose.yaml"]
+      "dependsOn": [
+        "^build"
+      ],
+      "inputs": [
+        "$TURBO_DEFAULT$",
+        "$TURBO_ROOT$/compose.yaml"
+      ]
     },
     "test:integration": {
-      "dependsOn": ["^build", "build"],
+      "dependsOn": [
+        "^build",
+        "build"
+      ],
       "cache": false,
       "env": [
         "DATABASE_URL",
         "RELAY_POSTGRES_PORT",
         "RELAY_REDIS_URL",
         "RELAY_REDIS_PORT",
         "RELAY_NATS_URL",
         "RELAY_NATS_PORT",
         "RELAY_OUTBOX_RELAY",
         "RELAY_EVENT_CONSUMER",
-        "RELAY_NATS_REPLICAS",
-        "RELAY_E2E_API_PORT"
+        "RELAY_NATS_REPLICAS"
       ]
     },
     "//#lint:root": {
       "inputs": [
         "**/*.{ts,mts,cts,mjs,js}",
         "eslint.config.mjs",

The secret that was two characters long

services/api/src/auth/credentials.itest.ts
@@ -118,13 +118,34 @@ describe("credentials", () => {
 
   it("invariant 1: a key's secret is returned once and is unrecoverable afterwards", async () => {
     const minted = await createApiKey(db, {
       environmentId: env.id,
       name: "once",
     });
-    const secret = minted.credential.split("_").at(-1)!;
+    // THE SECRET IS WHAT FOLLOWS THE PREFIX, NOT WHAT FOLLOWS THE LAST UNDERSCORE.
+    //
+    // This read `credential.split("_").at(-1)`, and the secret is base64url — whose
+    // alphabet INCLUDES `_`. So the split returned whatever came after the secret's
+    // own last underscore: usually a long tail, and the assertion passed for the
+    // right reason; occasionally two characters, and it passed for no reason at all.
+    //
+    // Then it failed. `expected '[{"public_id":…' not to contain 'WA'` — a
+    // two-character tail that appears inside the base64 SALT stored beside it, which
+    // reads like the api leaking the secret it had just hashed. Measured cause, not
+    // guessed: the salt in that row was `7XKYdYc_ottu61KbLY4dWA`.
+    //
+    // The prefix is a known constant and the row stores it, so removing it by length
+    // is exact and cannot depend on the secret's contents.
+    // `minted.prefix` IS THE ANSWER AND IT WAS ALREADY BEING RETURNED. The first
+    // repair of this used `lastIndexOf("_")`, which is the SAME fault a second time:
+    // the last underscore in the credential can belong to the secret.
+    const secret = minted.credential.slice(minted.prefix.length);
+    // A GUARD, because the whole failure above was an assertion on a short string.
+    // A secret this test can compare has to be long enough that a chance collision
+    // is not the thing being measured.
+    expect(secret.length, "the secret is too short to assert on").toBeGreaterThan(20);
 
     // Nothing in the row it left behind contains what was returned. Read with
     // a plain string rather than drizzle's `sql` helper: the query engine lives
     // inside the repository layer and nowhere else (constitution I, ADR-16),
     // and the lint rule that says so does not make an exception for tests.
     const stored = JSON.stringify(