Building Relay

Part 3 · Chapter 3.17

The words somebody wants back

You will produce: FR-MSG-07, FR-MSG-08 and FR-MSG-10 built, and FR-RTM-05's last two event kinds given their first producers: a fifth subject grammar carrying both mutations with the kind in the payload, because a tombstone is not a `Message` and an edit is; and two error codes rather than the generic 403 · about 75 minutes including the exercise

Source: SAD — Software Architecture Document

Ai đó gửi một message có lỗi gõ trong đó. Ai đó gửi một message vào nhầm channel. Ai đó gửi một message mà họ hối hận ngay lập tức. Mọi sản phẩm chat đều trả lời cả ba, còn Relay thì chẳng trả lời cái nào suốt mười sáu chương — trong khi suốt từng ấy thời gian vẫn mang theo gần như mọi thứ nó cần.

messages.edited_at đã nằm trong schema từ chương 2.1. deleted_at cũng thế, một cột text cho phép null mà chính lời chú thích của schema gọi là tombstone cũng thế, một metadata JSONB NOT NULL DEFAULT '{}' cũng thế. docs/05-sad.md §6.1 đã xuất bản một bảng message_edits từ bản nháp đầu tiên. Chẳng gì trong nền tảng ghi bất kỳ cái nào. Các đường đọc được dựng để xoay xở: phần liệt kê channel có một quy tắc cho text null, phần backfill lúc resume bỏ một cái, và cả hai đều được test với các tombstone cắm bằng tay bằng SQL thô, bởi chẳng đường code nào tạo ra được một cái.

Đó là hình ảnh ngược của cái lỗ hổng thường thấy. Lỗ hổng thường thấy là một bên ghi không có bên đọc. Đây là bốn bên đọc và một cái bảng đang chờ một bên ghi mà chẳng ai viết, và lý do thì khó chịu theo một kiểu đáng ngồi lại với nó: các bên đọc thì dễ còn các quyết định thì khó.

Một tombstone không phải một message

Đường truyền đã mang một frame message.deleted từ chương 1.3, và payload của nó chính là cùng một Message mà message.created mang: một id, một channel, một sequence, một user, một text, và một dấu thời gian tạo.

Một message đã xoá thì không có text. Đó không phải một chi tiết của bản cài đặt này — nó là FR-MSG-08, điều khoản nói rằng một lần xoá "sẽ thay nội dung của nó bằng một tombstone", và messages.text cho phép null đúng vì lý do đó. Nên cái frame đã xuất bản không mô tả nổi chính thứ nó được đặt tên theo, và lựa chọn là nới messageSchema.text thành cho phép null hoặc cho lệnh xoá một payload của riêng nó.

Nới rộng sẽ bắt mọi bên tiêu thụ của mọi payload message — lần tạo, lần sửa, lần replay lúc resume — chấp nhận một text null mà họ không bao giờ nhận được. Schema của một frame là một lời hứa về thứ sẽ tới; làm nó lỏng ra để chứa một frame khác là một lời hứa chẳng ai dựa vào được.

packages/protocol/src/frames.ts
@@ -29,12 +29,27 @@ export const connectionAckSchema = z.strictObject({
   type: z.literal("connection.ack"),
   payload: z.strictObject({
     user: z.string().min(1),
     cursor: cursorSchema,
     resume_ok: z.boolean(),
     truncated: z.array(z.string().min(1)),
+    /** How many revisions each of this user's channels has seen.
+     *
+     * EVERY CHANNEL THE USER BELONGS TO, ZEROS INCLUDED. A channel absent from this map
+     * would be indistinguishable from a channel at zero, and a client cannot tell "no
+     * revisions" from "not reported" — so the map is total over the membership and a
+     * client that holds a count for a channel missing here knows the membership changed
+     * rather than guessing.
+     *
+     * THE PLATFORM REPORTS AND NEVER COMPARES. Nothing on the server reads the number a
+     * client holds; the client decides whether to re-read history. A draft had the client
+     * present its counts on the upgrade URL so the gateway could answer with the stale
+     * channels, and it was built and then removed — a count the server ACTS on is a number
+     * the client controls, and a fabricated one becomes a denial of service the platform
+     * performs on itself. */
+    revisions: z.record(z.string().min(1), z.number().int().nonnegative()),
   }),
 });
 
 /** Client → server send (SAD §5.1: `message.send {idem_key, channel, text}`).
  * The idempotency key is client-supplied (FR-SDK-06), deduplicated
  * server-side within 24 h (FR-MSG-04). */
@@ -66,15 +81,47 @@ export const messageCreatedSchema = z.strictObject({
 
 export const messageUpdatedSchema = z.strictObject({
   type: z.literal("message.updated"),
   payload: messageSchema,
 });
 
+/** THE ONE FRAME THAT DOES NOT CARRY A MESSAGE, and the revisions chapter is where that became
+ * unavoidable rather than tidy.
+ *
+ * `messageSchema.text` is `z.string()`. A deleted message has no text — FR-MSG-08 replaces
+ * it with a tombstone — so this frame's payload could never be filled. Two places in the
+ * api already refused to try and said so: `messages.controller.ts` declines to publish a
+ * recovered tombstone because *"`messageSchema.text` is `z.string()`, not nullable"*, and
+ * `backfill.controller.ts` drops one from a resume because *"a tombstone is not a
+ * creation"*. Both were waiting for this.
+ *
+ * **`messageSchema` IS NOT WIDENED, and that is the decision.** Making `text` nullable
+ * would let a CREATION carry a null text — which the send path deliberately refuses — and
+ * would edit a contract published since chapter 1.3 that every client in the series parses.
+ * The event that has no message is the one that stops carrying one.
+ *
+ * NO `text` FIELD AT ALL, not an empty string. An empty message and a deleted one would be
+ * indistinguishable on the wire, and the platform would be asserting something false rather
+ * than declining to say it. */
+/** NAMED SEPARATELY so the fabric can import it instead of reaching into
+ * `messageDeletedSchema.shape.payload`. the revisions chapter's fifth subject grammar carries this
+ * exact shape, and one declaration is what stops the two drifting. */
+export const messageDeletedPayloadSchema = z.strictObject({
+  id: z.string().min(1),
+  channel: z.string().min(1),
+  seq: z.number().int().positive(),
+  /** The AUTHOR, which the tombstone keeps (FR-MSG-08). Not whoever deleted it — a tenant
+   * key may delete anybody's message, so the remover is a different fact and lives in
+   * `messages.metadata` rather than on the wire. */
+  user: z.string().min(1),
+  deleted_at: z.iso.datetime(),
+});
+
 export const messageDeletedSchema = z.strictObject({
   type: z.literal("message.deleted"),
-  payload: messageSchema,
+  payload: messageDeletedPayloadSchema,
 });
 
 export const membershipChangedSchema = z.strictObject({
   type: z.literal("membership.changed"),
   payload: z.strictObject({
     channel: z.string().min(1),
@@ -154,12 +201,16 @@ export const frameSchema = z.discriminatedUnion("type", [
   errorFrameSchema,
 ]);
 
 // The static types ARE the schemas — z.infer, never a hand-written twin.
 export type Cursor = z.infer<typeof cursorSchema>;
 export type Message = z.infer<typeof messageSchema>;
+/** The deleted frame's payload is the one that is NOT a `Message`, so it
+ * needs a name of its own — otherwise every producer re-declares the shape inline and the
+ * schema stops being the single statement of it. */
+export type MessageDeleted = z.infer<typeof messageDeletedPayloadSchema>;
 export type ConnectionAck = z.infer<typeof connectionAckSchema>;
 export type MessageSend = z.infer<typeof messageSendSchema>;
 export type MessageAck = z.infer<typeof messageAckSchema>;
 export type Frame = z.infer<typeof frameSchema>;
 
 /** Parse anything the wire delivers. Hostile input is an expected value, not

Văn phạm subject thứ năm

Giữa các instance gateway, một frame đi qua Redis pub/sub trên một subject. Có bốn văn phạm, và mỗi cái tới cùng một chương đã lập luận cho nó: chan:{channel_id} ở 2.6, presence:{channel_id} ở chương presence, member:{channel_id} và member:{env}:{user} ở chương thu-hồi-membership, và typing:{channel_id} ở chương typing.

chan: mang một Message. packages/protocol/src/fanout.ts tự nói điều đó bằng lời của nó — "fan-out luôn mang payload của một wire frame chứ không phải một hình dạng riêng" — và hai điều nảy ra từ đó, điều thứ hai là chí mạng.

Một lần sửa là một Message. Nó có thể đi nhờ chan: xét theo hình dạng, và bên nhận sẽ chẳng có cách nào biết đó là một lần cập nhật: session.ts đóng dấu type: "message.created" ngay tại chỗ gọi, nên loại của nó chưa bao giờ có mặt trên tấm vải. Mọi lần sửa sẽ tới nơi như một message hoàn toàn mới.

Một lần xoá thì không phải một Message và không thể đi nhờ subject đó, kể cả về nguyên tắc.

flowchart TB
    subgraph before["four grammars, twenty-two chapters"]
      a["chan:{channel_id}<br/>a Message"]
      b["member:{channel_id}<br/>member:{env}:{user}"]
      c["presence:{channel_id}"]
      d["typing:{channel_id}"]
    end
    subgraph fifth["the fifth"]
      e["revision:{channel_id}<br/>{ kind, message }"]
    end
    u["an edit IS a Message —<br/>indistinguishable from a creation on chan:"]
    t["a tombstone is NOT a Message —<br/>it has no text, so it cannot ride chan: at all"]
    u --> e
    t --> e
    style a fill:#1e3a8a,color:#fff,stroke:#3b82f6
    style e fill:#065f46,color:#fff,stroke:#10b981
    style t fill:#7f1d1d,color:#fff,stroke:#dc2626
Bốn văn phạm và cái thứ năm. Lần sửa có thể đi nhờ chan: và sẽ không phân biệt được với một lần tạo; tombstone thì không đi nhờ được chút nào.

Nên chương này lấy một cái thứ năm, và cái quy tắc nó dựa vào giờ đã được bốn chương độc lập đi tới: một loại không chia sẻ được kiểu payload thì không chia sẻ được subject.

Một subject chứ không phải hai, với loại nằm trong payload, theo đúng membership.changed của ADR-20 và cái change: "added" | "removed" của nó. Một lần sửa và một lần xoá là hai chuyện xảy ra với một message; một bên nhận đăng ký cả hai hoặc không cái nào, và hai subject sẽ nhân đôi sổ sách đăng ký cho một sự phân biệt mà payload đã làm sẵn.

packages/protocol/src/revision.ts
import { z } from "zod";
 
import { messageDeletedPayloadSchema, messageSchema } from "./frames.js";
 
/** THE FIFTH SUBJECT GRAMMAR, and the argument for it is ADR-24.
 *
 * `chan:{channel_id}` carries a wire frame's payload — `fanout.ts:18` says so in its own
 * words — and that payload is a `Message`. Two things follow, and the second is fatal:
 *
 *   - An EDIT is a `Message` and could ride that subject by shape. But the receiver has no
 *     way to know it is an update: `session.ts` stamped `type: "message.created"` at the
 *     call site, so the kind was never on the fabric at all.
 *   - A DELETION is not a `Message`. It has no text, which is the same constraint that gave
 *     `message.deleted` its own frame payload. It cannot ride `chan:` even in principle.
 *
 * **A kind that cannot share a payload type cannot share a subject.** Three chapters reached
 * that independently — ADR-19 took `presence:{channel_id}`, ADR-20 took `member:{channel_id}`
 * and `member:{env}:{user}`, ADR-21 took `typing:{channel_id}` — which is why it is a rule
 * here rather than a preference.
 *
 * ONE SUBJECT FOR BOTH MUTATIONS, WITH A DISCRIMINATOR, following ADR-20 rather than taking
 * two subjects. That record's `membership.changed` carries `change: "added" | "removed"` for
 * the same reason: an edit and a deletion are two things that happen to one message, a
 * receiver subscribes to both or neither, and two subjects would double the subscription
 * bookkeeping for a distinction the payload already makes.
 *
 * NO `environment` FIELD, unlike `membershipFabricSchema` — and that is a decision rather
 * than an omission. Membership needs it because `member:{env}:{user}` names a user, which is
 * unique only within an environment. A channel id is a UUID and identifies its tenant
 * transitively, exactly as `chan:{channel_id}` has always relied on. */
export const REVISION_SUBJECT_PREFIX = "revision";
 
/** THE PREFIX IS EXPORTED BECAUSE A SUBSCRIBER HAS TO TELL TWO SUBJECTS APART.
 *
 * The gateway holds one Redis subscriber for both `chan:{channel_id}` and
 * `revision:{channel_id}`, so `subscriber.on("message")` has to route on the subject. A
 * literal `"revision:"` there would be a second place that knows this grammar, which is
 * the thing "a fabric owns its subject grammar" forbids. `internal.ts` set the precedent
 * with `EVENT_SUBJECT_PREFIX` and builds its subjects from it. */
export function subjectForChannelRevision(channelId: string): string {
  return `${REVISION_SUBJECT_PREFIX}:${channelId}`;
}
 
/** Does this subject belong to the revision fabric? The subscriber's routing question,
 * asked of the module that owns the answer. */
export function isChannelRevisionSubject(subject: string): boolean {
  return subject.startsWith(`${REVISION_SUBJECT_PREFIX}:`);
}
 
/** What crosses `revision:{channel_id}` between gateway instances. Consumed only by
 * gateways; each arm becomes the wire frame `frames.ts` already published.
 *
 * `discriminatedUnion`, so the two arms cannot be confused and an unknown `kind` is a
 * rejection rather than a silent pass. `strictObject` inside each arm for the reason
 * `membershipFabricSchema` gives: a field added on one side of a rolling deploy fails
 * loudly on the other instead of being dropped.
 *
 * THE WIRE FRAMES ARE NOT EDITED BY THIS FILE. `message.updated` carries a `Message` and
 * `message.deleted` carries an identity with no text; this schema is what gets them from
 * the api to a gateway that holds the socket. */
export const revisionFabricSchema = z.discriminatedUnion("kind", [
  z.strictObject({ kind: z.literal("updated"), message: messageSchema }),
  z.strictObject({ kind: z.literal("deleted"), message: messageDeletedPayloadSchema }),
]);
 
export type RevisionFabric = z.infer<typeof revisionFabricSchema>;

Tiền tố được export và bên đăng ký hỏi chính module này xem một subject có phải của nó không. Gateway giữ một bên đăng ký Redis cho cả chan: lẫn revision:, nên subscriber.on("message") phải định tuyến theo subject — và một chuỗi "revision:" viết thẳng trong gateway sẽ là một chỗ thứ hai biết văn phạm này, đúng thứ mà quy tắc kia cấm.

services/gateway/src/fanout.ts
@@ -1,9 +1,13 @@
 import {
   messageCreatedSchema,
   subjectForChannel,
+  subjectForChannelRevision,
+  isChannelRevisionSubject,
+  revisionFabricSchema,
+  type RevisionFabric,
   type Message,
 } from "@relay/protocol";
 import type { Logger } from "@relay/service-kit";
 // A NAMED import, not a default: ioredis is CommonJS, the gateway is ESM,
 // and without esModuleInterop a default import of a CJS module hands you
 // the module.exports namespace — which is not constructable. TypeScript
@@ -42,12 +46,24 @@ export interface Fanout {
    * time — the fabric knows how to receive, the sessions know who to
    * hand it to. */
   onDelivery(handler: (channelId: string, message: Message) => void): void;
   /** Publish a committed message to its channel's subject. A failure here
    * costs delivery latency, never durability. */
   publish(message: Message): Promise<void>;
+  /** ADR-24. Register the revision callback — an edit or a deletion of a
+   * message that already exists.
+   *
+   * A SECOND CALLBACK ON THE SAME MODULE, not a second module. The revision subject's
+   * subscription lifetime is IDENTICAL to the message subject's: the same channels, the
+   * same reference counts, subscribed and dropped at the same moments. A module of its own
+   * would duplicate that counting and add two more Redis clients to a service the typing
+   * chapter took to eight. */
+  onRevision(handler: (channelId: string, revision: RevisionFabric) => void): void;
+  /** Publish an edit or a deletion to its channel's revision subject. Same failure
+   * contract as `publish`: delivery latency, never durability. */
+  publishRevision(revision: RevisionFabric): Promise<void>;
   subscribe(channelId: string): Promise<void>;
   unsubscribe(channelId: string): Promise<void>;
   close(): Promise<void>;
 }
 
 export interface FanoutOptions {
@@ -57,12 +73,13 @@ export interface FanoutOptions {
 
 export function createFanout({
   url = process.env.RELAY_REDIS_URL ?? DEFAULT_REDIS_URL,
   logger,
 }: FanoutOptions): Fanout {
   let deliver: (channelId: string, message: Message) => void = () => {};
+  let deliverRevision: (channelId: string, revision: RevisionFabric) => void = () => {};
   const publisher = new Redis(url);
   const subscriber = new Redis(url);
   // Reference-counted, because two users of the same channel on one
   // instance must not unsubscribe each other.
   const counts = new Map<string, number>();
 
@@ -71,12 +88,24 @@ export function createFanout({
     try {
       parsed = JSON.parse(raw);
     } catch {
       logger.log("error", "fanout.unparsable", { subject });
       return;
     }
+    // TWO SUBJECTS ON ONE SUBSCRIBER, told apart by the prefix rather than
+    // by guessing at the payload. Parsing against both schemas and taking whichever
+    // succeeded would make a malformed revision look like a message.
+    if (isChannelRevisionSubject(subject)) {
+      const revision = revisionFabricSchema.safeParse(parsed);
+      if (!revision.success) {
+        logger.log("error", "fanout.invalid_payload", { subject });
+        return;
+      }
+      deliverRevision(revision.data.message.channel, revision.data);
+      return;
+    }
     // The fabric is inside the trust boundary, and frames are STILL
     // validated: "inside" is one compromised dependency away from
     // "outside", and a malformed payload must not reach a client.
     const message = messageCreatedSchema.shape.payload.safeParse(parsed);
     if (!message.success) {
       logger.log("error", "fanout.invalid_payload", { subject });
@@ -86,12 +115,31 @@ export function createFanout({
   });
 
   return {
     onDelivery(handler) {
       deliver = handler;
     },
+    onRevision(handler) {
+      deliverRevision = handler;
+    },
+    async publishRevision(revision) {
+      try {
+        await publisher.publish(
+          subjectForChannelRevision(revision.message.channel),
+          JSON.stringify(revision),
+        );
+      } catch (error) {
+        // Same contract as `publish` above: the edit or the tombstone is already
+        // committed, and a client that missed the frame repairs by re-reading history —
+        // which is what the revisions chapter's resume decision rests on.
+        logger.log("error", "fanout.publish_failed", {
+          channel: revision.message.channel,
+          error: String(error),
+        });
+      }
+    },
     async publish(message) {
       try {
         await publisher.publish(
           subjectForChannel(message.channel),
           JSON.stringify(message),
         );
@@ -104,19 +152,31 @@ export function createFanout({
         });
       }
     },
     async subscribe(channelId) {
       const next = (counts.get(channelId) ?? 0) + 1;
       counts.set(channelId, next);
-      if (next === 1) await subscriber.subscribe(subjectForChannel(channelId));
+      if (next === 1) {
+        // BOTH SUBJECTS, one reference count. They are co-extensive by construction: a
+        // gateway that holds a socket for this channel wants its messages and its
+        // revisions, and dropping one without the other would leave edits arriving for a
+        // channel nobody is listening to — or worse, the reverse.
+        await subscriber.subscribe(
+          subjectForChannel(channelId),
+          subjectForChannelRevision(channelId),
+        );
+      }
     },
     async unsubscribe(channelId) {
       const next = (counts.get(channelId) ?? 1) - 1;
       if (next <= 0) {
         counts.delete(channelId);
-        await subscriber.unsubscribe(subjectForChannel(channelId));
+        await subscriber.unsubscribe(
+          subjectForChannel(channelId),
+          subjectForChannelRevision(channelId),
+        );
       } else {
         counts.set(channelId, next);
       }
     },
     async close() {
       subscriber.disconnect();

Cả hai subject được đăng ký dưới một bộ đếm tham chiếu. Chúng đồng phạm vi theo cấu tạo: một gateway đang giữ một socket cho một channel thì muốn cả message lẫn revision của channel đó, và bỏ cái này mà không bỏ cái kia sẽ để các lần sửa tới cho một channel chẳng ai nghe — hoặc tệ hơn, ngược lại.

services/gateway/src/session.ts
@@ -6,12 +6,13 @@ import {
   CLOSE_CODES,
   docsUrl,
   frameSchema,
   type ErrorCode,
   type Frame,
   type Message,
+  type RevisionFabric,
   type TypingFabric,
   isErrorCode,
   type MembershipFabric,
   type PresenceFabric,
 } from "@relay/protocol";
 import type { Logger } from "@relay/service-kit";
@@ -267,12 +268,44 @@ export function attachSessions({
       if (suppressed(connection.marks, message)) continue;
       send(connection.socket, { type: "message.created", payload: message });
     }
   }
   fanout?.onDelivery(deliver);
 
+  /** An edit or a deletion arriving from the revision fabric (ADR-24).
+   *
+   * **THE KIND COMES FROM THE PAYLOAD, NOT FROM THIS CALL SITE**, and that is the change
+   * ADR-24 exists for. `deliver` above stamps `message.created` because everything on
+   * `chan:{channel_id}` IS a creation — the subject's payload is a `Message` and the kind
+   * was never on the fabric. An edit is also a `Message`, so on that subject it would have
+   * been indistinguishable from a creation; a deletion is not a `Message` at all.
+   *
+   * **NO `suppressed` CHECK, AND NO BUFFERING, unlike `deliver`.** Both of those exist to
+   * stop a resume delivering a message twice — they compare a frame against what the
+   * backfill already sent, keyed on the sequence number. A revision carries the sequence of
+   * a message the client may already hold, so the same test would suppress every edit to a
+   * message below the cursor. That is not a gap: a client that misses a revision repairs by
+   * re-reading history, which is the bound FR-016a states and the reason resume does not
+   * carry these frames at all.
+   *
+   * **A BUFFERING CONNECTION IS SENT NOTHING**, for the same reason: it is about to receive
+   * the current state of every message above its cursor from the backfill, so an edit
+   * arriving mid-resume is already in what it is being sent. */
+  function deliverRevision(channelId: string, revision: RevisionFabric): void {
+    for (const connection of registry.subscribersOf(channelId)) {
+      if (connection.phase === "buffering") continue;
+      send(
+        connection.socket,
+        revision.kind === "updated"
+          ? { type: "message.updated", payload: revision.message }
+          : { type: "message.deleted", payload: revision.message },
+      );
+    }
+  }
+  fanout?.onRevision(deliverRevision);
+
   /** A typing signal arriving from its own fabric (T043).
    *
    * **DO NOT COPY `deliverPresence` BELOW, WHICH IS DELIBERATELY UNFILTERED.**
    * That function walks `subscribersOf` and sends to everyone, so a user sees
    * their own presence transition — the membership-revocation chapter confirmed it from the other
    * side, counting two frames where a watcher correctly sees their own arrival.
@@ -706,30 +739,36 @@ export function attachSessions({
           );
           ws.close(4003, CLOSE_CODES[4003]);
           logger.log("info", "connection.rejected", { reason: "user_banned" });
           return;
         }
         // NO SEND LIMIT ARGUMENT YET. `authenticate` returns the limits with the
-        // session in movement VII, where the limiter is written; `open` takes four
-        // parameters until then rather than a fifth nothing can supply.
+        // session in movement VII, where the limiter is written; until then `open`
+        // takes what the session answer actually carries and nothing more.
         void open(
           ws,
           result.identity,
           result.channelIds,
+          result.revisions,
           req.url ?? "/",
           pendingId,
           claimed,
         );
       });
     })();
   });
 
   async function open(
     socket: WebSocket,
     identity: Identity,
     channelIds: string[],
+    /** BESIDE `channelIds` AND NOT AFTER `url`, because it arrives with them from one
+     * session answer — and because `claimedId` below is optional: gaps.md 045-18 records
+     * a parameter inserted ahead of an optional one silently renaming every later
+     * argument. A required parameter here makes the compiler name every call site. */
+    revisions: Record<string, number>,
     url: string,
     /** The id the cap claimed a place with, so the connection and
      * its slot agree — FR-011's "exactly one place for its lifetime". Absent when
      * no `connections` module is wired, which is every fixture that does not opt
      * in and the reason the cap is not enforced there. */
     claimedId?: string,
@@ -744,12 +783,14 @@ export function attachSessions({
       socket,
       // Memberships arrived with the identity, from the session
       // call at the door. There is no second lookup to fail here — the api is
       // still the only source of membership (ADR-05), it just answers both
       // questions at once, and a failure now closes the socket before it opens.
       channelIds: new Set(channelIds),
+      // Reported on the ack and never read again by this service.
+      revisions,
       missedPings: 0,
       phase: presented === undefined ? "live" : "buffering",
       buffer: [],
       overflowed: false,
       // A fresh connect suppresses nothing; a resume fills this in when it
       // succeeds, and leaves it null when it degrades.
@@ -1040,13 +1081,20 @@ export function attachSessions({
       resume_ok: boolean;
       truncated: string[];
     },
   ): void {
     send(connection.socket, {
       type: "connection.ack",
-      payload: { user: connection.identity.userExternalId, ...payload },
+      payload: {
+        user: connection.identity.userExternalId,
+        // EVERY CHANNEL THIS USER BELONGS TO, ZEROS INCLUDED, on every ack — a resume
+        // and a fresh connect report the same way, because a client cannot act on a
+        // number it only sometimes receives.
+        revisions: connection.revisions,
+        ...payload,
+      },
     });
   }
 
   /** The five steps (chapter 2.7, SAD §5.2). Steps 1 and 2 already happened
    * — the connection was born `buffering` and the subscribes are in flight
    * — so what is left is: confirm, backfill, ack, emit, flush, live. */

Đó chính là thay đổi mà ADR-24 tồn tại vì nó, và nó là ba dòng. Loại giờ đến từ chính payload. deliver ở trên nó vẫn đóng dấu message.created, một cách đúng đắn, bởi mọi thứ trên chan: thực sự là một lần tạo.

Hai mã, và vì sao không phải forbidden

Một người dùng cuối sửa message của người khác. Cái gì quay về?

ProtocolErrorFilter ánh xạ một 403 trống thành forbidden, nên để nó chưa quyết chính là đã quyết. Và mục về forbidden trong docs/08-error-reference.md tự loại mình ra tới hai lần: nó tự gọi mình là "trường hợp chung: nơi nào có một mã cụ thể hơn … thì mã đó được gửi thay", và hành động cho client của nó là "chẳng có gì client thử lại được. Đây là một thay đổi về credential hoặc về quyền."

Quyền tác giả thì chẳng phải cái nào. Không credential nào cấp nó, không màn hình phân quyền nào ban nó, và không lần thử lại nào có được nó. Một lập trình viên bị đẩy đi tìm một thiết lập phân quyền sẽ chẳng tìm ra cái nào.

packages/protocol/src/codes.ts
@@ -127,12 +127,63 @@ export const ERROR_CODES = {
   //
   // Registering them is what makes the ladder typable. Annotated `ErrorCode`, a code
   // that is not here stops compiling instead of reaching a customer as a dead link.
   invalid_request:
     "the request body, query or path failed validation; `field` names the first offending key",
   forbidden: "the credential is valid and is not permitted to do this",
+  // THE REVISIONS CHAPTER, AND **NOT** `forbidden` — the third time this file has made that
+  // argument, after `wrong_credential_type` and `wrong_credential_service`, and the
+  // first time the reason is not about credentials at all.
+  //
+  // `docs/08-error-reference.md`'s entry for `forbidden` rules itself out twice. It
+  // calls itself *"the generic case: where a more specific code exists … that one is
+  // sent instead"*, and its client action is *"nothing the client can retry. This is a
+  // change of credential or of permission."* **Authorship is neither.** No credential
+  // grants it and no permission change makes a message yours, so the published remedy
+  // is advice nobody can act on.
+  //
+  // ONE CODE FOR BOTH REFUSALS. An end user editing somebody else's message and a
+  // tenant key editing anybody's have the same cause — the caller did not write it —
+  // and the same answer. A tenant key may still DELETE anything (FR-MOD-02); that is a
+  // different route and not a refusal.
+  //
+  // Left undecided, the default was `forbidden`, because `ProtocolErrorFilter` maps a
+  // bare 403 to it. That is how a protocol decision gets made by omission, and analysis
+  // pass 3 caught the task whose condition nobody had evaluated.
+  not_message_author:
+    "the caller did not write this message; only its author may change what it says",
+  // THE REVISIONS CHAPTER, AND A SECOND NEW CODE IN ONE CHAPTER — which is one more than the
+  // plan expected, so it gets the test at the top of this file applied out loud: *"a
+  // client that cannot tell them apart retries the wrong one for ever."*
+  //
+  // Against the four codes it could have reused:
+  //
+  //   forbidden            the same objection `not_message_author` above answers. Its
+  //                        published remedy is a change of credential or of permission,
+  //                        and neither un-deletes a message.
+  //   not_message_author   false. The author of a tombstone IS its author, and telling
+  //                        them otherwise sends them to look for a permission problem.
+  //   not_found            a lie with a witness. FR-011 keeps deleted messages in
+  //                        history in their original position, so a client would be
+  //                        holding the message while being told it does not exist.
+  //   a bare 409           `ProtocolErrorFilter` derives a code from the status for 400,
+  //                        401, 403 and 404 only; everything else becomes
+  //                        `internal_error`. An unnamed 409 ships a body calling itself
+  //                        an internal error, which is the lie chapter 2.2 fixed for 400
+  //                        and the credentials chapter for 403.
+  //
+  // WHAT A CLIENT DOES DIFFERENTLY, which is the whole test: on this code it stops
+  // offering an edit control for that message and re-reads history; on
+  // `not_message_author` it should never have offered one. Two states, two actions.
+  //
+  // ONLY THE AUTHOR EVER SEES IT. `editMessage` checks authorship first, so a stranger
+  // is refused for not having written the message whether or not it still says
+  // anything — this code cannot tell anybody that a message they could not otherwise
+  // see exists.
+  message_deleted:
+    "this message has been deleted; its text cannot be changed, and its history is unaffected",
   not_found:
     "no such resource for this tenant — and DELIBERATELY the same answer as for a resource in another tenant (FR-TEN-05)",
   internal_error:
     "the platform failed in a way it did not anticipate; the request_id is what a support ticket needs",
 
   // FR-CHN-07's ceiling: a channel holds at most 1,000 members and an add that would

Mã thứ hai không nằm trong kế hoạch. Sửa một message vốn đã là tombstone thì phải bị từ chối — prior_text TEXT NOT NULL nghĩa là phương án còn lại là một vi phạm ràng buộc mà bên gọi chẳng làm gì được — và cái hợp đồng mà chương này viết trong lúc đặc tả nói là 404, kèm một lập luận: "một 410 trên một message mà bên gọi không được phép sửa sẽ xác nhận rằng message tồn tại."

Lập luận đó sai, và phải viết code ra mới thấy. Chẳng ai không được phép sửa message lại đi tới được lời từ chối đó: phép kiểm quyền tác giả chạy trước khi text được nhìn tới, nên một người lạ bị từ chối bằng not_message_author dù message có là tombstone hay không. Bên gọi duy nhất thấy câu trả lời tombstone là chính tác giả — và tác giả đọc được message trong lịch sử, bởi FR-011 giữ các message đã xoá ở đúng vị trí ban đầu của chúng.

Nên một 404 ở đó nói với bên gọi rằng một message họ đang nhìn thì không tồn tại. Đó là defect của chương 2.8 nằm gọn trong một tài nguyên: nó tìm ra POST trả lời 404 cho một channel mà GET trả lời 200, và cách sửa của nó là bắt hai bên đồng ý với nhau. Một tài nguyên không nên trả lời hai kiểu tuỳ theo động từ.

Cái bảng mà SAD xuất bản và chẳng ai dựng

docs/05-sad.md:435 đã giữ đoạn DDL này từ bản nháp đầu tiên:

services/api/migrations/0009_message_edits.sql
-- What a message used to say.
--
-- PUBLISHED IN SAD §6.1 SINCE THE SAD WAS WRITTEN, and `schema.ts`'s absence
-- note named this chapter as its arrival. Reproduced column for column, which
-- is worth stating because the first draft of this chapter's data model gave
-- the table a surrogate `id UUID PRIMARY KEY` and said it was quoting the SAD.
-- It was not: three columns and a composite key.
--
-- HAND-WRITTEN, AND drizzle-kit's OUTPUT WAS DISCARDED. `drizzle-kit generate`
-- produced `0006_wise_lyja.sql` — a number already taken by
-- `0006_member_roles.sql` — containing two whole CREATE TABLEs, twelve ALTERs
-- and an index replayed from migrations 0006 through 0008. Its snapshot sits at
-- 0005 while this directory sits at 0008, because those three were hand-written
-- too. Applied to any database that has run them, the generated file fails on
-- `CREATE TABLE "read_positions"`. This is the review ADR-16 requires doing its
-- job: generation is a draft, the file is the artifact.
--
-- WHAT THE COMPOSITE KEY COSTS. Two edits to one message at the same timestamp
-- collide rather than both being stored. Postgres holds microseconds, so that
-- needs two edits inside one microsecond on one message. A surrogate id would
-- accept both and leave a history with two rows claiming the same instant,
-- which is a silent wrong answer where this is a loud refusal. The published
-- constraint stands (Constitution VII).
--
-- APPEND ONLY (FR-004). Nothing updates or deletes a row here; a second edit
-- appends a second row and the current text stays on `messages`.
--
-- NO environment_id, exactly like `messages`. The tenant is reached through
-- message_id -> messages -> channels. `members` is the precedent feature 030's
-- guard classifies as `hop` for the same reason, and this table is the same
-- shape of thing: rows about a message, not rows about a tenant.
--
-- NO id COLUMN. The primary key is (message_id, edited_at) because that is what
-- an edit is. the channel-endpoints chapter installed
-- `coalesce(to_jsonb(OLD) ->> 'id', to_jsonb(OLD)::text)` in the guard's
-- refusal message for exactly the tables that have no `id` to interpolate.
CREATE TABLE message_edits (
    message_id  UUID NOT NULL REFERENCES messages(id),
    edited_at   TIMESTAMPTZ NOT NULL,
    -- FR-MSG-07: what the message said before this edit. NOT NULL, and the
    -- consequence is met rather than worked around — a deletion writes no row
    -- here, because a tombstone has no text to preserve, so FR-010 refuses an
    -- edit on a tombstone instead of defining what its history would say.
    prior_text  TEXT NOT NULL,
    CONSTRAINT message_edits_message_id_edited_at_pk PRIMARY KEY (message_id, edited_at)
);
 
--> statement-breakpoint
 
-- ONE MIGRATION, ONE SUBJECT: REVISIONS. `message_edits` above preserves what a message
-- used to say; the column below counts how many times it has changed. Both are this
-- chapter's subject, and splitting them across two migrations would number one of them
-- after work that has nothing to do with either.
-- HOW MANY REVISIONS A CHANNEL HAS SEEN, and why it lives in this migration.
--
-- WHAT IT IS FOR. Resume is ordered by the channel sequence, and an edit or a
-- deletion carries the sequence of the message it CHANGES rather than a new one.
-- A message revised below a client's cursor is therefore in neither the replay
-- nor the live stream, and consumes no sequence, so no gap appears for a client
-- to notice. SRS FR-016a says the stale copy is repairable by re-reading
-- history; nothing told a client when to. This column is what a reconnecting
-- client compares against.
--
-- MEASURED BEFORE IT WAS BUILT. A client holding message seq 1, reconnecting on
-- cursor 2 after that message was edited, received exactly one frame — the ack —
-- and zero sequences. The edit was never delivered. The same probe confirmed the
-- other half: a message edited ABOVE the cursor comes back on the replay with its
-- new text, because the backfill reads current state.
--
-- EXECUTABLE WITHOUT DOWNTIME, which the constitution requires of every
-- migration. `ADD COLUMN ... NOT NULL DEFAULT` is metadata-only from PostgreSQL
-- 11: the default is stored in the catalogue and existing rows are not rewritten.
-- On 10 and below this statement rewrites the whole table and takes an ACCESS
-- EXCLUSIVE lock for the duration. This platform targets 15 (SAD §6.1), and the
-- version the property depends on belongs in the file rather than in somebody's
-- memory.
--
-- BIGINT, MATCHING channels.last_sequence. A channel revised once a second for a
-- century reaches 3.2 billion, which overflows `integer` and does not trouble
-- `bigint`. The sequence column made the same choice for the same reason.
--
-- DEFAULT 0 AND NOT A COUNT RECONSTRUCTED FROM HISTORY. `message_edits` and
-- `messages.deleted_at` between them could produce a true count for every
-- existing channel, and it would be correct and useless: it would exceed every
-- client's stored count on the first reconnect after this ships, and tell every
-- client to repair every channel once. Starting at zero means a revision applied
-- before this migration is never repaired for a client already holding the stale
-- copy — which is the current behaviour continuing, not a new defect.
--
-- NO INDEX. The column is read by primary key on a row the membership query
-- already joins. An index would serve no query that exists.
 
ALTER TABLE channels
  ADD COLUMN revision_sequence BIGINT NOT NULL DEFAULT 0;

Ba cột và một khoá tổ hợp. Mô hình dữ liệu của chính chương này cho cái bảng một cột thứ tư — id UUID PRIMARY KEY — rồi nói, ngay ở câu bên trên, "Hình dạng của nó là của SAD, không phải của chương này." Không hề. Mười một lượt phân tích đi qua chỗ đó, và lý do thì đáng gọi tên: mọi bộ kiểm trong repository này đều so định danh, còn đây là một điều khoản. Tên của cái bảng xuất hiện trong kế hoạch, trong các task và trong mô hình dữ liệu, mà chẳng gì đọc đoạn DDL bên dưới nó.

Phép kiểm chỉ đếm một bước nhảy

Tạo cái bảng làm lane isolation đỏ trong bốn mili giây.

AssertionError: these tables have no path to an environment: message_edits.
Add environment_id, add a foreign key to a table that has one, or add it to
SPINE in db/catalogue.ts with a reason.

classifyTables hỏi câu mà gauntlet endpoint không hỏi: có một đường từ các dòng của mọi bảng quay về một tenant không? Một bảng không có đường như thế là một chỗ rò chưa có endpoint. Và cả ba phương thuốc nó đưa ra đều sai ở đây. environment_id là một cột mà SAD không xuất bản; một khoá ngoại thứ hai là cùng phản bác đó cộng thêm một lần phi chuẩn hoá; còn gọi một bảng chứa text của message là một phần của xương sống thì không biện minh được, mà lời chú thích của chính danh sách đó đòi hỏi phải biện minh.

Nên câu query mới sai, không phải cái bảng. Quy tắc nó phát biểu là mọi bảng đều có một đường quay về một environment. Bản cài đặt chỉ chấp nhận các đường dài một bước — một khoá ngoại đáp thẳng vào một bảng mang environment_id — và cái đó phủ mọi bảng từng tồn tại tới giờ. message_edits thì cách hai mắt xích: nó tham chiếu messages, thứ tham chiếu channels, thứ mang cái cột kia.

services/api/src/db/catalogue.ts
@@ -14,20 +14,24 @@ import type { Db } from "./client";
 // to catch.
 //
 // It lives here rather than in the test that calls it because this directory is the only
 // place the lint ban permits `drizzle-orm` (constitution I, ADR-16). A catalogue query
 // written inline in the test would need an exemption for as long as it lived.
 
-/** How a row in this table is traced back to one environment. */
+/** How a row in this table is traced back to one environment. `hop` means
+ * reached through a CHAIN of foreign keys, of any length — see the reachability
+ * note in the query below for why the length matters and what it cost. */
 export type TenantPath = "direct" | "hop" | "spine";
 
 export interface TableClassification {
   table: string;
   /** `null` means the table matches none of the three, which fails the check. */
   path: TenantPath | null;
-  /** For `hop`: the `direct` tables its foreign keys reach. */
+  /** For `hop`: the `direct` tables its foreign keys reach, following CHAINS of
+   * keys and not only single links. Every name here is itself a
+   * `direct` table, which is the invariant `tenant-scope.itest.ts` asserts. */
   via: string[];
   /** For `spine`: why it has no tenant column. */
   reason?: string;
 }
 
 // THE SPINE, AS A LIST WITH A REASON EACH AND NOT A PATTERN.
@@ -90,39 +94,82 @@ export interface CatalogueRow extends Record<string, unknown> {
 
 /** Every base table in `public`, each classified into exactly one of the three paths —
  * or into none, which is the answer that fails a build. */
 export async function classifyTables(db: Db): Promise<TableClassification[]> {
   const rows = (
     await db.execute<CatalogueRow>(sql`
-      WITH base AS (
+      WITH RECURSIVE base AS (
         SELECT table_name
         FROM information_schema.tables
         WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
       ),
       direct AS (
         SELECT table_name
         FROM information_schema.columns
         WHERE table_schema = 'public' AND column_name = 'environment_id'
+      ),
+      -- EVERY FOREIGN KEY IN public, AS AN EDGE LIST. Split out of the
+      -- correlated subquery it used to live in, because reachability needs to
+      -- walk it more than once.
+      --
+      -- ::text is load-bearing, AND IT MOVED UP HERE WITH THE CAST. information_schema
+      -- columns are sql_identifier, and node-pg has no parser for an array of them: the
+      -- row arrives as the literal string {channels,users} and iterating it yields a
+      -- brace, which is how this was found rather than reasoned about. (No backticks in
+      -- this comment: it is inside a template literal.)
+      fk AS (
+        SELECT DISTINCT tc.table_name::text AS src, ccu.table_name::text AS dst
+        FROM information_schema.table_constraints tc
+        JOIN information_schema.constraint_column_usage ccu
+          ON ccu.constraint_name = tc.constraint_name
+         AND ccu.table_schema = tc.table_schema
+        WHERE tc.constraint_type = 'FOREIGN KEY'
+          AND tc.table_schema = 'public'
+          AND tc.table_name <> ccu.table_name
+      ),
+      -- REACHABILITY, NOT ADJACENCY. The rule this check states
+      -- is that every table has A PATH back to one environment, and the query
+      -- used to accept only a path of length ONE: a foreign key landing
+      -- directly on a table that carries environment_id. That covered every
+      -- table there was, which is why nothing noticed.
+      --
+      -- message_edits is the first table two links away. It references
+      -- messages, which references channels, which carries the column, and the
+      -- one-hop query classified it as having no tenant at all. The check's own
+      -- failure message offered three remedies and all three were wrong for it:
+      -- denormalising a column the SAD does not publish, adding a second
+      -- foreign key for the same reason, or calling a table of message text
+      -- part of the spine.
+      --
+      -- So the query now matches the rule instead of the tables that happened
+      -- to exist. This is not a weakening: it reports the DIRECT tables the
+      -- chain arrives at, so the invariant tenant-scope.itest.ts asserts, that
+      -- every entry in via is itself direct, holds exactly as before. A table
+      -- that reaches nothing still classifies as null and still fails.
+      --
+      -- WITH RECURSIVE is required by the self-reference below, and it belongs
+      -- on the FIRST cte in the chain even though base and direct are not
+      -- recursive. Postgres reads the keyword once per WITH clause.
+      --
+      -- The walk is over table names in a schema of a few dozen, and UNION
+      -- rather than UNION ALL terminates it on a cycle.
+      reach AS (
+        SELECT src, dst FROM fk
+        UNION
+        SELECT r.src, f.dst
+        FROM reach r
+        JOIN fk f ON f.src = r.dst
       )
       SELECT
         b.table_name,
         (b.table_name IN (SELECT table_name FROM direct)) AS has_environment_id,
         (
-          -- ::text is load-bearing. information_schema columns are sql_identifier,
-          -- and node-pg has no parser for an array of them: the row arrives as the
-          -- literal string {channels,users} and iterating it yields a brace, which
-          -- is how this was found rather than reasoned about.
-          SELECT array_agg(DISTINCT ccu.table_name::text)
-          FROM information_schema.table_constraints tc
-          JOIN information_schema.constraint_column_usage ccu
-            ON ccu.constraint_name = tc.constraint_name
-           AND ccu.table_schema = tc.table_schema
-          WHERE tc.constraint_type = 'FOREIGN KEY'
-            AND tc.table_schema = 'public'
-            AND tc.table_name = b.table_name
-            AND ccu.table_name IN (SELECT table_name FROM direct)
+          SELECT array_agg(DISTINCT r.dst)
+          FROM reach r
+          WHERE r.src = b.table_name
+            AND r.dst IN (SELECT table_name FROM direct)
         ) AS fk_targets
       FROM base b
       ORDER BY b.table_name
     `)
   ).rows;

Đây là kiểu thất bại mà repository này cứ phải trả giá mãi — một pattern khớp với các ví dụ trước mặt nó thay vì khớp với cái tập mà quy tắc gọi tên — lần này tới ngay trong phép kiểm vốn là cái cân đo cho năng suất cao nhất trong cả cái cây.

Lần sửa

Một transaction, và dòng lịch sử là lý do: một message được cập nhật trong một câu lệnh và một lịch sử được ghi thêm trong một câu lệnh khác thì có thể crash ở giữa, và thứ sống sót là một message mà chẳng ai còn giữ text cũ của nó.

services/api/src/db/repository.ts
@@ -8,20 +8,26 @@ import {
   applications,
   channels,
   consumedEvents,
   environments,
   humans,
   members,
+  messageEdits,
   readPositions,
   memberships,
   messages,
   organisations,
   outbox,
   users,
 } from "./schema";
-import { membershipEvent, messageCreatedEvent } from "../outbox/event";
+import {
+  membershipEvent,
+  messageCreatedEvent,
+  messageDeletedEvent,
+  messageUpdatedEvent,
+} from "../outbox/event";
 import {
   mintApiKey,
   parseApiKeyCredential,
   prefixMatchesKind,
   secretMatches,
   type EnvironmentKind,
@@ -618,18 +624,39 @@ export type AddMemberOutcome = "added" | "already_a_member" | "not_found";
 export interface MessageRow {
   id: string;
   channel_id: string;
   seq: number;
   text: string | null;
   created_at: string;
+  /** When it was last edited, or `null` (FR-003). Optional on this
+   * interface rather than required, because the WRITE paths build a row that has never
+   * been edited and would each have to spell `edited_at: null`. The read paths fill it
+   * in; `EditedMessageRow` narrows it to a string. */
+  edited_at?: string | null;
   /** Chapter 2.3 (FR-MSG-04): true when a retry was recognised by the
    * idempotency index and the ORIGINAL message was returned instead of
    * a new insert. The service layer uses this to decide response shape. */
   duplicate?: boolean;
 }
 
+/** An edited message, as the edit path returns it (FR-001, FR-003).
+ *
+ * `edited_at` IS NOT OPTIONAL HERE. Every row this shape describes has just been edited,
+ * so a `string | null` would be a type saying the impossible is possible. `MessageRow`'s
+ * read shape carries the nullable version, because a message that was never edited is
+ * the common case there. */
+export interface EditedMessageRow extends MessageRow {
+  edited_at: string;
+  /** What it said before, returned so the caller does not have to read it back to know
+   * the history row landed. Never on the public wire: `not_message_author` exists
+   * because rewriting somebody's words is not the same as removing them, and echoing the
+   * superseded text to whoever asked would make the edit-history route (FR-023a) a
+   * formality. `messages.controller.ts` spells its response fields out one by one. */
+  prior_text: string;
+}
+
 /** A message as the READ paths return it (chapter 2.7). The sender is the
  * external id — the identifier a client knows — and it is nullable for two
  * honest reasons: the column has been nullable since 2.1 (system messages
  * have no author), and every row written through the socket before 2.6's
  * fix has no author recorded. A caller that needs to build a wire frame
  * has to decide what to do with those; the layer does not decide for it. */
@@ -679,12 +706,61 @@ export class SenderNotPermittedError extends Error {
   constructor(readonly userId: string) {
     super("an application credential may send only as a bot user");
     this.name = "SenderNotPermittedError";
   }
 }
 
+/** The message id does not name a message of this channel (FR-014).
+ *
+ * ITS OWN CLASS, SEPARATE FROM `ChannelNotFoundError`, and the separation is not about
+ * the wire — both become a bare 404. It is about what the repository can say honestly. A
+ * visible channel and an unknown message id inside it is a different fact from a channel
+ * this tenant cannot see, and a layer that threw the channel error for both would be
+ * telling the service something untrue in order to produce an answer that happens to
+ * match. The indistinguishability FR-014 requires is a property of the two RESPONSES,
+ * which `messages.service.ts` produces, not of the two causes. */
+export class MessageNotFoundError extends Error {
+  constructor(public readonly messageId: string) {
+    super(`message not found: ${messageId}`);
+    this.name = "MessageNotFoundError";
+  }
+}
+
+/** The caller did not write this message (FR-013, FR-018, FR-022).
+ *
+ * ALSO THROWN WHEN THE MESSAGE HAS NO AUTHOR, which is FR-018 and is the arm worth
+ * naming: 121,250 rows in the test lane carry a null `user_id`, written before chapter
+ * 2.6 recorded a sender, and none of them can be edited by anybody. "Nobody wrote this"
+ * and "somebody else wrote this" are the same refusal — there is no caller for whom the
+ * authorship check can pass — and collapsing them means the answer cannot depend on
+ * which kind of unauthored row was asked about.
+ *
+ * A DELETED MESSAGE IS NOT THIS ERROR. A tombstone keeps its `user_id`, so its author
+ * still passes the authorship check and is refused by `MessageDeletedError` below for a
+ * reason they can act on. */
+export class NotMessageAuthorError extends Error {
+  constructor(public readonly messageId: string) {
+    super(`the caller did not write message ${messageId}`);
+    this.name = "NotMessageAuthorError";
+  }
+}
+
+/** An edit was asked for on a tombstone (FR-010).
+ *
+ * REFUSED RATHER THAN DEFINED, and `prior_text TEXT NOT NULL` is why the alternative is
+ * not available: a tombstone has no text to preserve, so an edit of one would have to
+ * either write a null into a NOT NULL column — a 500 the caller cannot act on — or
+ * invent a value for what the message used to say. SAD §6.1 published the constraint
+ * and this is the behaviour that follows from it. */
+export class MessageDeletedError extends Error {
+  constructor(public readonly messageId: string) {
+    super(`message deleted: ${messageId}`);
+    this.name = "MessageDeletedError";
+  }
+}
+
 export class UserBannedError extends Error {
   constructor(public readonly userId: string) {
     super(`user banned: ${userId}`);
     this.name = "UserBannedError";
   }
 }
@@ -1249,24 +1325,45 @@ export class Repository {
         ),
       )
       .orderBy(asc(members.joinedAt));
     return rows.map((r) => r.user_id);
   }
 
-  async channelsForUser(userId: string): Promise<string[]> {
-    const rows = await this.db
-      .select({ channel_id: members.channelId })
+  /** The channels a user belongs to, each with its revision count (feature 044, FR-014).
+   *
+   * ONE QUERY, NOT TWO. The count could have come from a second call, and giving each
+   * caller its own is the two-lists-that-must-agree defect `gaps.md` 3.23-4 records about
+   * `targets.ts` — two things that must match, maintained separately, with nothing
+   * comparing them. The join costs nothing: `members` is already reached and `channels` is
+   * one hop from it on a primary key.
+   *
+   * FR-014 IS WHY THE COUNT RIDES THIS QUERY AT ALL. At 10,000 connections a per-channel
+   * read per handshake is 10,000 extra reads, and the reconnect rate measured before this
+   * feature was 1,402 per second. The count has to arrive on work the api already does.
+   *
+   * TWO CALLERS, AND BOTH ARE REPAIRED IN THE SAME CHANGE. `session.controller.ts` wants
+   * the counts; `memberships.controller.ts` wants ids alone and maps them. Widening the
+   * return without fixing both leaves the second assigning objects to a `string[]`, which
+   * is a typecheck failure at exactly the boundary this project commits at. */
+  async channelsForUser(
+    userId: string,
+  ): Promise<{ channel_id: string; revision_sequence: number }[]> {
+    return await this.db
+      .select({
+        channel_id: members.channelId,
+        revision_sequence: channels.revisionSequence,
+      })
       .from(members)
       .innerJoin(users, eq(users.id, members.userId))
+      .innerJoin(channels, eq(channels.id, members.channelId))
       .where(
         and(
           eq(members.userId, userId),
           eq(users.environmentId, this.environmentId),
         ),
       );
-    return rows.map((r) => r.channel_id);
   }
 
   /** Upsert a user by external id, updating the profile fields present
    * (FR-025, FR-026).
    *
    * NOT `createUser`, AND THE DIFFERENCE IS THE POINT. `createUser` is deliberately not an
@@ -2054,16 +2151,17 @@ export class Repository {
       //     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.
+      // `MessagesController` declared no `@Accepts` at the time — so the guard fell
+      // back to `EITHER` and a user token was accepted there. the sender chapter declared it;
+      // the third of three copies of this sentence, all corrected in the revisions chapter. 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
@@ -2251,12 +2349,474 @@ export class Repository {
         text,
         created_at: createdAt,
       };
     });
   }
 
+  /** Change what a message says (FR-001, FR-002, FR-003, FR-004).
+   *
+   * ONE TRANSACTION, AND THE HISTORY ROW IS WHY. FR-004 wants the superseded text
+   * appended for every edit; a row updated in one statement and a history appended in
+   * another can crash between them, and the surviving state is a message whose old text
+   * nobody has. The pair commits or neither does.
+   *
+   * WHAT IS NOT IN THE `SET` LIST, and this is FR-002 stated as code rather than as a
+   * comment: `sequence`, `channelId`, `userId` and `createdAt` are absent. A test can
+   * only assert the values are unchanged (T027) — a thing not done leaves no trace to
+   * assert on — so the guarantee lives in the shape of this statement.
+   *
+   * AND `lastActivityAt` IS ABSENT TOO (FR-015). `sendMessage` moves it in the same
+   * breath as the sequence, deliberately; an edit must not, because the listing orders
+   * by "most recent activity" and FR-014's answer to what that means is a message.
+   * Correcting a typo is not a new message. T035 falsifies it by adding the assignment
+   * and watching T034 go red.
+   *
+   * THE ENVIRONMENT SCOPE IS HERE AND NOT ONLY IN THE SERVICE. `messages.service.ts`
+   * asks `channelVisibleTo` first, the way `history` does, and that is the check that
+   * produces FR-014's 404. This join carries `environmentId` anyway (constitution I): a
+   * repository method that trusts its caller's check is one refactor from a leak, and
+   * the two costs nothing to hold together because the read is on the primary key. */
+  async editMessage(
+    channelId: string,
+    messageId: string,
+    {
+      text,
+      /** WHO IS EDITING, and it is required (FR-013, FR-018). There is no
+       * "the tenant is editing" convention here, unlike `sendMessage`'s optional
+       * `userId`: FR-013a refuses an application credential outright, so an edit with
+       * no user is not a case this method has to have an answer for. Required means the
+       * compiler says so rather than a test having to remember. */
+      userId,
+    }: { text: string; userId: string },
+  ): Promise<EditedMessageRow> {
+    return this.db.transaction(async (tx) => {
+      // THE ROW AND ITS CHANNEL IN ONE READ, joined so the tenant scope and the
+      // channel-membership of the message are the same question. `messageId` alone
+      // would edit a message of any channel of any tenant that guessed a uuid.
+      const [row] = await tx
+        .select({
+          id: messages.id,
+          userId: messages.userId,
+          text: messages.text,
+          seq: messages.sequence,
+          createdAt: messages.createdAt,
+          // The author as a CONSUMER sees them, for the outbox event below. Joined
+          // here rather than looked up after the write: this transaction already
+          // reads the row, and `MessageCreatedData`'s boundary is that `user_id` does
+          // not cross it.
+          author: users.externalId,
+        })
+        .from(messages)
+        .innerJoin(channels, eq(channels.id, messages.channelId))
+        // LEFT, like every other read of this table: a senderless row must still be
+        // READ so FR-018 can refuse it by name rather than by looking absent.
+        .leftJoin(users, eq(users.id, messages.userId))
+        .where(
+          and(
+            eq(messages.id, messageId),
+            eq(messages.channelId, channelId),
+            eq(channels.environmentId, this.environmentId),
+          ),
+        )
+        .limit(1);
+      if (!row) throw new MessageNotFoundError(messageId);
+
+      // AUTHORSHIP BEFORE THE TOMBSTONE CHECK, and the order is a disclosure decision
+      // of the same family as FR-021a's. A stranger asking to edit a deleted message
+      // must not learn from `message_deleted` that the message was ever there — they
+      // are refused for not being the author, which is true of every message they did
+      // not write, deleted or not. The author of a tombstone gets the specific answer.
+      //
+      // A NULL `userId` FAILS THIS, which is FR-018. `row.userId === null` cannot equal
+      // any caller, so the comparison refuses it without a special case — and a special
+      // case is what would let a future edit to this condition get it wrong.
+      if (row.userId !== userId) throw new NotMessageAuthorError(messageId);
+      if (row.text === null) throw new MessageDeletedError(messageId);
+
+      // ONE CLOCK READING FOR BOTH WRITES. `edited_at` on the message and `edited_at`
+      // on the history row are the same instant by construction; two `now()` calls
+      // would be two instants, and the history row's own primary key is
+      // (message_id, edited_at), so a caller reading the history could not match an
+      // entry to the message state it produced.
+      // THE WRITE REFUSES, NOT ONLY THE READ (feature 043, FR-007).
+      //
+      // This was `.where(eq(messages.id, messageId))`, and the `row.text === null`
+      // check above it is a read taken earlier in the same transaction. Neither this
+      // method nor `deleteMessage` takes a row lock, so a deletion committing in that
+      // window left the edit free to overwrite it: `text` restored, `deleted_at` still
+      // set — **a row one filter calls deleted and another calls alive**, and a
+      // deletion that returned successfully undone by an edit already in flight.
+      //
+      // `gaps.md` 3.23-3 recorded the opposite — *"both interleavings end in a
+      // tombstone… there is no order of the two that leaves a message saying something
+      // nobody wrote"* — and the test that item asked for is what disproved it: three
+      // of five runs, and four incoherent rows left behind in the lane.
+      //
+      // A COMPARE-AND-SET, NOT A LOCK. `SELECT … FOR UPDATE` in both methods would
+      // close it too, and would serialise a pair `assertWithinQuota` deliberately
+      // declined to serialise on the send path. A conditional UPDATE costs nothing
+      // when there is no race and refuses exactly when there is one: zero rows
+      // affected means the row stopped being editable between the read and the write,
+      // which is what `MessageDeletedError` already says.
+      const [updated] = await tx
+        .update(messages)
+        .set({ text, editedAt: sql`now()` })
+        .where(and(eq(messages.id, messageId), isNull(messages.deletedAt)))
+        .returning({ editedAt: messages.editedAt });
+      if (!updated) throw new MessageDeletedError(messageId);
+      const editedAt = updated.editedAt!;
+
+      // FEATURE 044, FR-002/FR-003. The channel's revision counter rises by one, inside the
+      // transaction that applies the revision — so a revision that commits and a count that
+      // rises are the same event, and a count can never describe a revision the transaction
+      // refused.
+      //
+      // AFTER THE COMPARE-AND-SET ABOVE, deliberately. That statement refuses an edit to an
+      // already-deleted message by affecting zero rows; bumping before it would raise the
+      // count for an edit that then threw.
+      //
+      // AN EXTRA ROUND TRIP, AND THE RIGHT SIDE OF THE TRADE. The send path updates this row
+      // anyway, so `lastActivityAt` there "costs an extra assignment rather than an extra
+      // round trip"; this path touches `messages` and `message_edits` only, so the counter
+      // costs one UPDATE. Revisions are rare and reconnects are not, and the alternative puts
+      // a scan on the handshake (FR-014).
+      await tx
+        .update(channels)
+        .set({ revisionSequence: sql`${channels.revisionSequence} + 1` })
+        .where(eq(channels.id, channelId));
+
+      // FR-004. The row carries what the message said BEFORE this edit — `row.text`,
+      // read above and narrowed to a string by the tombstone check.
+      //
+      // NO `onConflictDoNothing`. The primary key is (message_id, edited_at), so two
+      // edits inside one microsecond collide, and a conflict clause here would silently
+      // drop the second one's history while its text change committed. A loud failure
+      // is the right answer to a state this table cannot represent — SAD §6.1 published
+      // the key and `baseline.txt` records what it costs.
+      await tx.insert(messageEdits).values({
+        messageId,
+        editedAt,
+        priorText: row.text,
+      });
+
+      // THE EVENT COMMITS WITH THE EDIT (FR-019, ADR-06). Same argument
+      // as the send path's and the deletion's: publishing after the commit leaves a
+      // window where the row changed and the event never existed, silently, with
+      // nothing to reconcile against.
+      //
+      // `occurred_at` IS THE EDIT'S INSTANT, not the message's `created_at` — an event
+      // whose timestamp predates the previous event about the same message cannot be
+      // ordered by a consumer. Read back from the UPDATE, so the event, the history
+      // row's primary key and the wire frame all quote one instant.
+      //
+      // THE AUTHOR, FROM THE ROW. `editMessage`'s caller is the author by FR-013, so
+      // `userExternalId` would be the same person — but reading it from the row is what
+      // makes that a fact rather than an assumption, and `sendMessage` already threads
+      // the same value for the creation event.
+      const event = messageUpdatedEvent({
+        eventId: randomUUID(),
+        environmentId: this.environmentId,
+        occurredAt: toIso(editedAt),
+        message: {
+          id: row.id,
+          channel_id: channelId,
+          seq: row.seq,
+          user: row.author,
+          text,
+          created_at: toIso(row.createdAt),
+        },
+      });
+      await tx.insert(outbox).values({
+        subject: event.subject,
+        payload: event.payload,
+      });
+
+      return {
+        id: row.id,
+        channel_id: channelId,
+        seq: row.seq,
+        text,
+        created_at: toIso(row.createdAt),
+        edited_at: toIso(editedAt),
+        prior_text: row.text,
+      };
+    });
+  }
+
+  /** Turn a message into a tombstone (FR-006, FR-006a, FR-009).
+   *
+   * THE COLUMNS ARE `docs/05-sad.md:342`'s, verbatim: `text = NULL`,
+   * `attachments = NULL`, `deleted_at = now()`. Everything else is untouched, and
+   * `sequence` in particular — a tombstone that gave up its place would leave a gap in
+   * every client's ordering and break every cursor keyed on it (FR-011).
+   *
+   * IDEMPOTENT BY A GUARD, NOT BY THE UPDATE (FR-009). Writing the three columns again
+   * would be harmless for two of them and wrong for the third: `deleted_at = now()`
+   * moves, and a client that had already read the tombstone would see its timestamp
+   * change for no reason. So a row that is already a tombstone returns early — no write,
+   * and no second outbox event, which is the half a pair of 204s cannot show.
+   *
+   * WHAT `alreadyDeleted` IS FOR. The caller has to know, because the controller must
+   * not publish a second `message.deleted` to every connected member of the channel.
+   * The status code is 204 either way; the fan-out is not.
+   *
+   * NO AUDIT LOG ROW, though SAD §342's diagram shows one beside the outbox insert.
+   * There is no `audit_log` table in §6.1 or in `schema.ts`, and inventing one is a
+   * feature with a retention policy rather than a line in this method. the revisions chapter's
+   * `gaps.md` item 2 draws that boundary: `metadata.deleted_by` records WHAT KIND of
+   * principal deleted the message, and which credential it presented is the audit
+   * log's question. */
+  async deleteMessage(
+    channelId: string,
+    messageId: string,
+    {
+      /** Who is deleting, or `undefined` for an application credential (FR-012).
+       *
+       * OPTIONAL HERE AND REQUIRED ON THE EDIT, and the asymmetry is the requirement
+       * rather than an inconsistency. FR-MOD-02 grants a tenant key deletion of any
+       * message and is silent on editing; the spec reads silence as absence of
+       * permission (FR-013a). So this route accepts both credential classes and the
+       * edit accepts one.
+       *
+       * `undefined` MEANS THE TENANT, the convention `sendMessage` and `listMessages`
+       * already use — and here it also skips the authorship check, which is what
+       * FR-012 asks for. */
+      userId,
+      /** The deleter as a CUSTOMER sees them, for `metadata.deleted_by` (FR-006a).
+       * Threaded rather than looked up, exactly as `sendMessage` threads its sender:
+       * a SELECT inside the write transaction is a query every deletion would pay to
+       * learn something the controller already holds. */
+      userExternalId,
+    }: { userId?: string; userExternalId?: string },
+  ): Promise<{
+    /** `user` IS NARROWED TO A STRING, unlike `MessageWithSender`'s.
+     *
+     * FR-018 refuses a row with no author before this method can return either branch,
+     * so a tombstone this method produced always has one. The narrowing is here rather
+     * than at the caller because this is where that argument lives — and the caller's
+     * alternative was `deleted.user ?? "unknown"`, which is an uncovered arm and a lie
+     * in the same expression. */
+    deleted: MessageWithSender & { user: string; deleted_at: string };
+    alreadyDeleted: boolean;
+  }> {
+    return this.db.transaction(async (tx) => {
+      const [row] = await tx
+        .select({
+          id: messages.id,
+          userId: messages.userId,
+          text: messages.text,
+          seq: messages.sequence,
+          createdAt: messages.createdAt,
+          deletedAt: messages.deletedAt,
+          metadata: messages.metadata,
+          author: users.externalId,
+        })
+        .from(messages)
+        .innerJoin(channels, eq(channels.id, messages.channelId))
+        // LEFT, like `listMessages`: an unattributed row must still be READ, or the
+        // 121,250 senderless rows in the lane would be invisible to this method and a
+        // deletion of one would look like a message that does not exist. FR-018 refuses
+        // them below, deliberately and by name.
+        .leftJoin(users, eq(users.id, messages.userId))
+        .where(
+          and(
+            eq(messages.id, messageId),
+            eq(messages.channelId, channelId),
+            eq(channels.environmentId, this.environmentId),
+          ),
+        )
+        .limit(1);
+      if (!row) throw new MessageNotFoundError(messageId);
+
+      // AUTHORSHIP, AND ONLY FOR A USER (FR-012, FR-013). `userId === undefined` is a
+      // tenant key, which may delete anybody's message. A user may delete their own.
+      //
+      // FR-018 IS THE `row.userId === null` HALF and it applies to BOTH principals.
+      // A row nobody wrote cannot be authorised against, and the requirement says
+      // "an edit or deletion" — the deletion being the half a tenant key can reach,
+      // which is why it is checked before the `userId === undefined` shortcut rather
+      // than inside the user branch.
+      if (row.userId === null) throw new NotMessageAuthorError(messageId);
+      if (userId !== undefined && row.userId !== userId) {
+        throw new NotMessageAuthorError(messageId);
+      }
+      // THE AUTHOR IS A STRING FROM HERE DOWN, and the foreign key is the argument.
+      // `messages.user_id` references `users(id)`, and the check above established it is
+      // not null — so the left join matched and `row.author` is that user's external id.
+      // Asserted rather than defaulted: a `??` here would put a placeholder on the wire
+      // as somebody's name, and the only state that could reach it is a violated
+      // constraint, which should crash rather than publish.
+      const author = row.author!;
+
+      // ALREADY A TOMBSTONE: nothing to do, and nothing to announce.
+      //
+      // `text === null` IS THE TEST, not `deletedAt !== null`. Both are set together by
+      // this method, but the lane holds rows where only `text` is null — system
+      // messages have had no text since chapter 2.1 — and `text` is the column every
+      // read path already branches on. the channel-control chapter's planted tombstone sets both.
+      if (row.text === null) {
+        return {
+          deleted: {
+            id: row.id,
+            channel_id: channelId,
+            seq: row.seq,
+            text: null,
+            created_at: toIso(row.createdAt),
+            user: author,
+            // THE INSTANT ALREADY ON THE ROW, not a fresh reading. FR-009 says a
+            // repeated deletion changes nothing, and the timestamp is the column that
+            // would otherwise move.
+            //
+            // `?? toIso(row.createdAt)` COVERS A ROW THE LANE ACTUALLY HOLDS: a system
+            // message with a null text and no `deleted_at`, which has existed since
+            // chapter 2.1. The branch above turns on `text`, deliberately, so such a
+            // row reaches here — and it is already textless, so reporting its creation
+            // instant is the honest answer rather than inventing a deletion time.
+            deleted_at: row.deletedAt === null ? toIso(row.createdAt) : toIso(row.deletedAt),
+          },
+          alreadyDeleted: true,
+        };
+      }
+
+      // WHO REMOVED IT (FR-006a). Merged into the existing metadata rather than
+      // replacing it: the column is `jsonb NOT NULL DEFAULT '{}'` and this chapter is
+      // its first writer anywhere in the platform, so every row carries `{}` today —
+      // but a later chapter's key must not be erased by a deletion.
+      //
+      // TWO SHAPES, ONE KEY. `{ kind: "user", user }` or `{ kind: "application" }`,
+      // because an application principal has no user of its own. The kind is always
+      // recorded; the identifier exists only when there is one.
+      const existing = (row.metadata ?? {}) as Record<string, unknown>;
+      const deletedBy =
+        userExternalId === undefined
+          ? { kind: "application" as const }
+          : { kind: "user" as const, user: userExternalId };
+
+      const [updated] = await tx
+        .update(messages)
+        .set({
+          text: null,
+          attachments: null,
+          deletedAt: sql`now()`,
+          metadata: { ...existing, deleted_by: deletedBy },
+        })
+        .where(eq(messages.id, messageId))
+        .returning({ deletedAt: messages.deletedAt });
+      // Read back rather than recomputed: the row carries the instant the database
+      // assigned, and the event and the frame must both quote that one.
+      const deletedAt = toIso(updated!.deletedAt!);
+
+      // FEATURE 044, FR-002/FR-003. A DELETION IS A REVISION and raises the count exactly as
+      // an edit does — US1's third acceptance scenario fails if only edits are counted. Same
+      // transaction, same argument as the edit path.
+      await tx
+        .update(channels)
+        .set({ revisionSequence: sql`${channels.revisionSequence} + 1` })
+        .where(eq(channels.id, channelId));
+
+      // THE EVENT COMMITS WITH THE TOMBSTONE (ADR-06), on the send path's argument at
+      // its own outbox insert: publishing after the commit leaves a gap where the row
+      // changed and the event never existed, silently, with nothing to reconcile.
+      //
+      // ON THIS BRANCH ONLY, which is FR-009's second half. A repeated deletion
+      // returned above without writing, so it emits nothing — otherwise a client
+      // retrying a 204 fires every subscribed webhook a second time.
+      const event = messageDeletedEvent({
+        eventId: randomUUID(),
+        environmentId: this.environmentId,
+        occurredAt: deletedAt,
+        message: {
+          id: row.id,
+          channel_id: channelId,
+          seq: row.seq,
+          user: author,
+          deleted_at: deletedAt,
+        },
+      });
+      await tx.insert(outbox).values({
+        subject: event.subject,
+        payload: event.payload,
+      });
+
+      return {
+        deleted: {
+          id: row.id,
+          channel_id: channelId,
+          seq: row.seq,
+          text: null,
+          created_at: toIso(row.createdAt),
+          user: author,
+          // THE COMMITTED INSTANT, read back from the UPDATE. The outbox event above
+          // quotes this same value, so a consumer and a socket client comparing the
+          // event with the frame see one timestamp rather than two readings of one
+          // clock a few milliseconds apart.
+          deleted_at: deletedAt,
+        },
+        alreadyDeleted: false,
+      };
+    });
+  }
+
+  /** A message's edit history, oldest first (FR-023).
+   *
+   * SCOPED THE SAME WAY `editMessage` IS, through the join rather than through the
+   * caller's promise. This read answers for a tenant API key (FR-023a refuses an end
+   * user at the route), and a key is not a user — so there is no membership to check
+   * and no `userId` parameter. What there IS is an environment, and it is on the join.
+   *
+   * `asc(editedAt)` AND NOT AN `id`. The table has no surrogate key, so insertion order
+   * is not available to order by; `edited_at` is the ordering FR-023 asks for and the
+   * primary key already indexes it. */
+  async listMessageEdits(
+    channelId: string,
+    messageId: string,
+  ): Promise<Array<{ prior_text: string; edited_at: string }>> {
+    const rows = await this.db
+      .select({
+        priorText: messageEdits.priorText,
+        editedAt: messageEdits.editedAt,
+      })
+      .from(messageEdits)
+      .innerJoin(messages, eq(messages.id, messageEdits.messageId))
+      .innerJoin(channels, eq(channels.id, messages.channelId))
+      .where(
+        and(
+          eq(messageEdits.messageId, messageId),
+          eq(messages.channelId, channelId),
+          eq(channels.environmentId, this.environmentId),
+        ),
+      )
+      .orderBy(asc(messageEdits.editedAt));
+    return rows.map((r) => ({
+      prior_text: r.priorText,
+      edited_at: toIso(r.editedAt),
+    }));
+  }
+
+  /** Does this message exist in this channel of this tenant?
+   *
+   * THE EDIT-HISTORY ROUTE NEEDS IT and `listMessageEdits` cannot supply it: an empty
+   * list is the correct answer for a message with no edits (FR-023's 200-with-nothing)
+   * and also what a message id that does not exist returns. Two facts, one value — so
+   * the route asks this separately rather than reading a 404 out of an empty array. */
+  async messageExistsIn(channelId: string, messageId: string): Promise<boolean> {
+    const rows = await this.db
+      .select({ id: messages.id })
+      .from(messages)
+      .innerJoin(channels, eq(channels.id, messages.channelId))
+      .where(
+        and(
+          eq(messages.id, messageId),
+          eq(messages.channelId, channelId),
+          eq(channels.environmentId, this.environmentId),
+        ),
+      )
+      .limit(1);
+    return rows.length > 0;
+  }
+
   /** Fetch a message by its idempotency key within a channel — the
    * recovery leg of 2.3's duplicate-recognised path. The channel join
    * carries the tenant scope: every query in this layer answers only for
    * its own environment, private helpers included (constitution I). */
   private async getMessageByIdempotencyKey(
     tx: Db,
@@ -2456,12 +3016,25 @@ export class Repository {
       // must emit frames identical to live ones, and a reader that gets a
       // different shape depending on which door it came through is a client
       // bug waiting for a reconnect.
       user: users.externalId,
       text: messages.text,
       created_at: messages.createdAt,
+      // WHEN IT WAS LAST EDITED, OR NULL (FR-003). Null for every
+      // message that has never been edited, which is the common case and the reason
+      // the read shape's version is nullable while `EditedMessageRow`'s is not.
+      //
+      // ON THE READ PATH BECAUSE A CLIENT CANNOT OTHERWISE TELL. An edit keeps the
+      // sequence number (FR-002), so nothing about a re-read row says it changed —
+      // a client comparing what it holds against a page of history would have to
+      // diff the text to notice, and FR-021 says the platform does not compare texts.
+      //
+      // WHAT THIS IS *NOT*: the superseded text. That is `message_edits`, readable
+      // only by a tenant key (FR-023a), and this column says an edit happened without
+      // saying what it replaced.
+      edited_at: messages.editedAt,
     };
     const scoped = (extra?: SQL) =>
       and(
         eq(messages.channelId, channelId),
         eq(channels.environmentId, this.environmentId),
         ...(extra ? [extra] : []),
@@ -2492,13 +3065,20 @@ export class Repository {
           // inner join here would make those rows vanish from history —
           // silent data loss dressed up as a query.
           .leftJoin(users, eq(users.id, messages.userId))
           .where(scoped(gt(messages.sequence, afterSeq)))
           .orderBy(asc(messages.sequence))
           .limit(limit));
-    return rows.map((row) => ({ ...row, created_at: toIso(row.created_at) }));
+    return rows.map((row) => ({
+      ...row,
+      created_at: toIso(row.created_at),
+      // `null`, NOT `undefined`, and the difference is what a test can see. An absent
+      // key and a null one are the same value through `??` — the control test for this
+      // field was green before the field existed because its first draft used `??`.
+      edited_at: row.edited_at === null ? null : toIso(row.edited_at),
+    }));
   }
 
   /** Resume backfill (chapter 2.7, FR-RTM-03): for each cursor, everything
    * the client has not applied yet — capped, with an honest truncation
    * signal per channel (FR-RTM-04).
    *

Thứ không nằm trong danh sách SET chính là FR-002 được viết thành code. sequence, channelId, userId và createdAt đều vắng mặt, lastActivityAt cũng vậy — đường gửi dời cái đó trong cùng một hơi thở với sequence, một cách có chủ đích, còn một lần sửa thì không được, bởi phần liệt kê channel sắp xếp theo hoạt động gần nhất và sửa một lỗi gõ không phải một message mới. Một bài test chỉ khẳng định được rằng các giá trị không đổi; một việc không làm thì chẳng để lại dấu vết nào để khẳng định. Bảo đảm sống trong hình dạng của câu lệnh.

services/api/src/messages/messages.controller.ts
@@ -1,13 +1,17 @@
 import {
   BadRequestException,
   Body,
   Controller,
+  Delete,
   Get,
+  HttpCode,
   Inject,
+  NotFoundException,
   Param,
+  Patch,
   Post,
   Query,
   Req,
   UseGuards,
 } from "@nestjs/common";
 
@@ -15,18 +19,22 @@ import { Accepts, CredentialGuard } from "../auth/credential.guard";
 import { Repository } from "../db/repository";
 import { MessagesService } from "./messages.service";
 import {
   MESSAGE_PUBLISHER,
   type MessagePublisher,
 } from "../fanout/publisher";
-import { historyQuerySchema, sendMessageBodySchema } from "./messages.schema";
+import {
+  editMessageBodySchema,
+  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 { EditMessageBody, 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
@@ -41,12 +49,34 @@ import { ZodValidationPipe } from "./zod-validation.pipe";
  * until this one, the clause was cited by the code that did the opposite of it. The clause is now narrowed to a bot user of
  * that tenant, and the sender comes from the body (`user`), resolved below. */
 function actingUser(req: RequestWithPrincipal): string | undefined {
   return req.principal?.kind === "user" ? req.principal.userExternalId : undefined;
 }
 
+/** The two fields every publish here has to carry (NFR-OBS-01, NFR-OBS-06).
+ *
+ * ONE FUNCTION AND NOT THREE COPIES, and the coverage ratchet is what asked. This
+ * chapter added two more publish sites to this file, each with its own
+ * `req.requestId ?? "unknown"` and `req.principal?.environmentId ?? "unknown"` — six
+ * uncovered branch arms where there had been two, all of them the same two arms written
+ * three times. Collapsing them does not make the arms reachable; it stops the count
+ * growing every time a route publishes.
+ *
+ * THE FALLBACKS STAY. `requestId` is set by middleware and `principal` by the guard, so
+ * neither is absent on any path a request can take — but a log line that says `unknown`
+ * is findable, and one that says `undefined` reads like a bug in the logger. */
+function publishContext(req: RequestWithPrincipal): {
+  requestId: string;
+  environmentId: string;
+} {
+  return {
+    requestId: req.requestId ?? "unknown",
+    environmentId: req.principal?.environmentId ?? "unknown",
+  };
+}
+
 // 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
@@ -83,15 +113,20 @@ export class MessagesController {
   ) {
     // 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.
+    // customer's own client calls. `MessagesController` declared no `@Accepts` at the
+    // time, so the guard fell back to `EITHER` and a user token was accepted here.
+    //
+    // PAST TENSE SINCE THE SENDER CHAPTER, and it took until this one to say so. That
+    // chapter added `@Accepts("application", "user")` at :94 — twenty-eight lines above
+    // this sentence — and left three copies of the sentence describing its absence, here,
+    // in `messages.itest.ts:153` and in `repository.ts:2136`. Nothing compares a comment
+    // with the decorator it describes, and the chapter's own task named one of the three.
     //
     // 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.
@@ -206,16 +241,13 @@ export class MessagesController {
           channel: message.channel_id,
           seq: message.seq,
           user: actingExternalId,
           text: message.text,
           created_at: message.created_at,
         },
-        {
-          requestId: req.requestId ?? "unknown",
-          environmentId: req.principal?.environmentId ?? "unknown",
-        },
+        publishContext(req),
       );
     }
 
     return {
       id: message.id,
       channel_id: message.channel_id,
@@ -227,12 +259,240 @@ export class MessagesController {
       // internal send has carried this since chapter 2.6; the public one answered five
       // fields and left the caller to assume.
       user: actingExternalId,
     };
   }
 
+  /** Change what a message says (FR-001, FR-005, FR-013, FR-013a).
+   *
+   * `@Accepts("user")` ON THE METHOD, AND THE CLASS DECLARES BOTH (:64). A route added
+   * here without a declaration INHERITS `("application", "user")` — the guard reads
+   * `getAllAndOverride`, so the method-level one wins and its absence is not neutral.
+   * An application credential reaching this handler would carry no user to compare the
+   * author against, and the honest options at that point are to refuse it inside the
+   * handler or to let a tenant key rewrite anybody's words as them. FR-013a chooses
+   * neither: the credential class is refused at the guard, by declaration.
+   *
+   * FR-MOD-02 GRANTS A KEY DELETION OF ANY MESSAGE AND IS SILENT ON EDITING, and the
+   * spec reads silence as absence of permission. Removing somebody's words and
+   * rewriting them as them are different acts, and only the second leaves a message
+   * saying something its author never wrote with nothing on the wire to say so.
+   *
+   * `dev-token.controller.ts:51` is the precedent for a method-level narrowing, and
+   * `credential.guard.ts:31` argues why the class is DECLARED while the authorship is
+   * CHECKED: authorship cannot be declared, because it is a fact about a row. */
+  @Patch(":messageId")
+  @Accepts("user")
+  async edit(
+    @Param("channelId") channelId: string,
+    @Param("messageId") messageId: string,
+    @Body(new ZodValidationPipe(editMessageBodySchema)) body: EditMessageBody,
+    @Req() req: RequestWithPrincipal,
+  ) {
+    // THE GUARD ALREADY REFUSED ANYTHING BUT A USER TOKEN, so `actingUser` cannot be
+    // undefined here — and the narrowing is a throw rather than a `!`, on
+    // `messages.service.ts`'s precedent for the same shape. A `!` would put the
+    // assumption in a place a later change to the decorator cannot invalidate.
+    const actingExternalId = actingUser(req);
+    if (actingExternalId === undefined) {
+      throw new Error("a user token is required to edit (FR-013a, @Accepts on this route)");
+    }
+    const user = await this.repo.getUserByExternalId(actingExternalId);
+    if (!user) {
+      // The same refusal the send path gives for a token minted for an identifier with
+      // no row: a user who is a member of nothing wrote nothing.
+      throw new BadRequestException({
+        code: "invalid_request",
+        message: "the caller named in this token is not a user of this environment",
+        field: "user",
+      });
+    }
+    const edited = await this.messages.edit(channelId, messageId, body, user.id);
+
+    // ── the live fan-out (FR-005, ADR-24) ─────────────────────────────────────
+    //
+    // AFTER THE COMMIT, BEFORE THE RESPONSE, for the reason the send path states at
+    // :199: a request handler has one channel and the response IS the ack, so anything
+    // awaited here precedes it. The row is durable before anyone hears about it.
+    //
+    // NO `duplicate` GUARD, AND THAT IS A DECISION rather than an omission (research
+    // R8). The send path carries two guards because a recognised idempotent retry
+    // wrote no row and must not be delivered twice. An edit has one entry path and no
+    // idempotency key — the edit body takes none, deliberately — so there is no retry
+    // for a guard to recognise. Copying the send path's `if` here would have added a
+    // condition that is always true and read as though it were protecting something.
+    //
+    // NO `text !== null` GUARD EITHER, for a stronger reason: `editMessage` refuses a
+    // tombstone (FR-010), so a null text cannot reach this line at all. The send
+    // path's check exists because an idempotency key can recover one.
+    //
+    // `publishRevision`, NOT `publish` — the kind rides the payload now, and the
+    // subject is the one ADR-24 took. A `publish` here would deliver the edit as a
+    // creation to every member, because the `updated` arm's payload IS a `Message`.
+    await this.fanout.publishRevision(
+      {
+        kind: "updated",
+        message: {
+          id: edited.id,
+          // `channel`, not `channel_id`: the frame's field is `channel` and
+          // `messageSchema` is a `z.strictObject`, so the wrong name delivers NOTHING
+          // while this route answers 200. The send path records the same trap.
+          channel: edited.channel_id,
+          seq: edited.seq,
+          user: actingExternalId,
+          text: edited.text!,
+          created_at: edited.created_at,
+        },
+      },
+      publishContext(req),
+    );
+
+    // THE FIELD LIST IS SPELLED OUT, like the send path's, so a new column joins the
+    // public response only when somebody decides it should. `prior_text` is on the
+    // repository's return and is NOT here: `not_message_author` exists because
+    // rewriting somebody's words differs from removing them, and echoing the superseded
+    // text to whoever asked would make the edit-history route's refusal (FR-023a) a
+    // formality.
+    return {
+      id: edited.id,
+      channel_id: edited.channel_id,
+      seq: edited.seq,
+      text: edited.text,
+      created_at: edited.created_at,
+      edited_at: edited.edited_at,
+      user: actingExternalId,
+    };
+  }
+
+  /** Remove what a message says (FR-006, FR-007, FR-009, FR-012).
+   *
+   * NO METHOD-LEVEL `@Accepts`, AND THAT IS THE DECLARATION. This is the one route in
+   * the chapter where the class's `("application", "user")` at :64 is what the
+   * requirement asks for: FR-MOD-02 grants a tenant key deletion of any message
+   * irrespective of author (FR-012), and an end user may delete their own (FR-013).
+   *
+   * **An inherited declaration and an absent one look identical in the source**, which
+   * is the thing `credential.guard.ts:56` argues about and the isolation harness paid
+   * for. So
+   * `targets.ts` carries `accepts: "either"` for this path — an existing value, used by
+   * the read-position route — and the entry is where a reader can see that both classes
+   * are intended here rather than merely tolerated.
+   *
+   * 204, AND THE SAME 204 TWICE (FR-009). Nest would answer 200 for a DELETE with a
+   * body; there is no body, and idempotence means the second call is
+   * indistinguishable from the first on the wire. What differs is the fan-out, and
+   * `alreadyDeleted` is how this handler knows. */
+  @Delete(":messageId")
+  @HttpCode(204)
+  async remove(
+    @Param("channelId") channelId: string,
+    @Param("messageId") messageId: string,
+    @Req() req: RequestWithPrincipal,
+  ): Promise<void> {
+    // THE DELETER, PER CREDENTIAL CLASS. A user token names its subject; an application
+    // credential names nobody, and unlike the send path it does not have to — FR-006a
+    // records the KIND of principal, and `{ kind: "application" }` is a complete
+    // answer. There is no body on a DELETE to name a `user` in, and inventing one
+    // would let a key delete "as" somebody, which is the thing FR-013a refuses for the
+    // edit.
+    const actingExternalId = actingUser(req);
+    let userId: string | undefined;
+    if (actingExternalId !== undefined) {
+      const user = await this.repo.getUserByExternalId(actingExternalId);
+      if (!user) {
+        throw new BadRequestException({
+          code: "invalid_request",
+          message: "the caller named in this token is not a user of this environment",
+          field: "user",
+        });
+      }
+      userId = user.id;
+    }
+
+    const { deleted, alreadyDeleted } = await this.messages.remove(
+      channelId,
+      messageId,
+      {
+        ...(userId !== undefined && { userId }),
+        ...(actingExternalId !== undefined && { userExternalId: actingExternalId }),
+      },
+    );
+
+    // ── the live fan-out (FR-007, FR-009, ADR-24) ────────────────────────────
+    //
+    // GUARDED ON `alreadyDeleted`, which is this route's version of the send path's
+    // `!duplicate`. Both exist for the same failure: a client retrying on a flaky link
+    // would otherwise put the same frame on every member's screen twice. The status is
+    // 204 either way, so the guard is the only thing that can tell them apart.
+    if (!alreadyDeleted) {
+      await this.fanout.publishRevision(
+        {
+          kind: "deleted",
+          message: {
+            id: deleted.id,
+            channel: deleted.channel_id,
+            seq: deleted.seq,
+            // THE AUTHOR, NOT THE DELETER (FR-008). The frame identifies the message,
+            // and who removed it is `metadata.deleted_by` on the row — a tenant key may
+            // delete anybody's message, so the two are different facts and the wire
+            // carries the one every client already has beside the message.
+            //
+            // A STRING, NOT A `?? "unknown"`. The first draft had one, and it was both
+            // an uncovered arm and a lie: `deleteMessage` refuses a senderless row with
+            // `NotMessageAuthorError` (FR-018) before it can return, so the value can
+            // never be missing — and if it somehow were, putting the word "unknown" on
+            // the wire as somebody's name is worse than the crash. The narrowing lives
+            // in the repository now, where the argument for it lives too.
+            user: deleted.user,
+            // THE ROW'S INSTANT, not a reading taken here. The outbox event built
+            // inside the transaction quotes the same value, so a consumer comparing
+            // its webhook against a client's frame sees one timestamp.
+            deleted_at: deleted.deleted_at,
+          },
+        },
+        publishContext(req),
+      );
+    }
+  }
+
+  /** What a message used to say (FR-023, FR-023a).
+   *
+   * `@Accepts("application")` ON THE METHOD, AND WITHOUT IT A USER TOKEN READS THIS.
+   * The class declares `("application", "user")` at :64 and the guard reads
+   * `getAllAndOverride`, so an undeclared route here would hand every end user the
+   * superseded text of every message in every channel they can see — the one thing
+   * FR-023a exists to forbid. T033g falsifies it by removing the line and watching the
+   * refusal test go red.
+   *
+   * **INCLUDING THE AUTHOR'S OWN MESSAGES.** That a message was edited is public — the
+   * read path carries `edited_at` — and what it used to say is not. FR-MOD-01 names the
+   * audience for a moderation surface and nothing in the SRS asks for an end-user one.
+   *
+   * 200 WITH AN EMPTY LIST, NOT 404, for a message that has never been edited. The
+   * absence of edits is a fact about the message rather than the absence of a resource,
+   * and the two are distinguishable here because `messageExistsIn` answers the second
+   * question separately — `listMessageEdits` returning `[]` cannot tell them apart. */
+  @Get(":messageId/edits")
+  @Accepts("application")
+  async edits(
+    @Param("channelId") channelId: string,
+    @Param("messageId") messageId: string,
+  ): Promise<{ edits: Array<{ prior_text: string; edited_at: string }> }> {
+    // NO `userId`, AND THAT IS THE DECLARATION SPEAKING. Only an application credential
+    // reaches this handler, so there is no member to resolve and no membership to
+    // check; `channelVisibleTo(channelId, undefined)` is the tenant reading, which sees
+    // everything it owns. Passing a user here would be inventing a caller.
+    if (!(await this.repo.channelVisibleTo(channelId))) {
+      throw new NotFoundException("channel not found");
+    }
+    if (!(await this.repo.messageExistsIn(channelId, messageId))) {
+      throw new NotFoundException("message not found");
+    }
+    return { edits: await this.repo.listMessageEdits(channelId, messageId) };
+  }
+
   @Get()
   async history(
     @Param("channelId") channelId: string,
     @Query(new ZodValidationPipe(historyQuerySchema)) query: HistoryQuery,
     @Req() req: RequestWithPrincipal,
   ) {

Ba route, ba câu trả lời khác nhau cho ai được gọi cái này, và cả ba đều là lời khai báo chứ không phải các phép kiểm nằm trong một handler:

PATCH :messageId          @Accepts("user")           an edit is the author's, and an
                                                      application principal has no author
DELETE :messageId         (inherits both)            the author, or a tenant key (FR-MOD-02)
GET :messageId/edits      @Accepts("application")    a moderation surface, not an end-user one

Lớp đó khai báo @Accepts("application", "user"), và guard đọc getAllAndOverride — nên một route thêm vào đây mà không có lời khai báo sẽ thừa hưởng cả hai, và một lời khai báo vắng mặt thì không hề trung lập. Bỏ @Accepts("application") khỏi route sửa và bài test khẳng định một người dùng cuối bị từ chối sẽ báo expected 200 to be 403: mọi người dùng cuối giờ đọc được mọi message trong mọi channel họ thấy được từng nói gì.

Lần xoá, và vì sao hai cái 204 chẳng chứng minh được gì

Xoá một message vốn đã xoá thì thành công. FR-009 nói thế, và đó là hành vi hiển nhiên: một client thử lại một request đã timeout thì không nên nhận một lỗi cho đúng trạng thái nó đã xin và đã có.

Rắc rối là "thành công" không phải cái yêu cầu kia. Một lần xoá thứ hai mà viết lại dòng đó sẽ dời deleted_at, nên một client đã đọc tombstone rồi sẽ thấy dấu thời gian của nó đổi chẳng vì lý do gì — và một event thứ hai sẽ kích hoạt mọi webhook đã đăng ký thêm một lần nữa cho một lần xoá. Cả hai đều vô hình từ mã status, vốn là 204 theo cả hai đường.

stateDiagram-v2
    [*] --> live: send
    live --> live: edit — seq unchanged,<br/>edited_at set, one message_edits row
    live --> tombstone: delete — text NULL, attachments NULL,<br/>deleted_at set, seq kept
    tombstone --> tombstone: delete again — no change, no event
    tombstone --> refused: edit
    refused --> tombstone: 403 message_deleted
    note right of tombstone
      prior_text is NOT NULL, so a deletion
      writes no history row at all
    end note
Vòng đời của một message. Mọi lần chuyển trạng thái đều giữ nguyên số sequence, và đó là thứ làm cho một tombstone không để lại khoảng trống còn một lần sửa thì vô hình với một cursor.

Nên repository trả về alreadyDeleted bên cạnh cái dòng, phần fan-out được canh theo nó, và bài test mang FR-009 đếm các dòng outbox chứ không đếm mã status. Câu trả lời của lần sửa thì ngược lại và cả hai đều đúng: mọi lần sửa đều phát ra, bởi FR-021 nói nền tảng không so text của message để quyết xem một lần sửa có xảy ra hay không. Mọi định nghĩa về sự bằng nhau — khoảng trắng, hoa thường, chuẩn hoá unicode, một ký tự vô hình — đều là một quyết định mà một khách hàng sẽ phải được thông báo.

Mỗi đường đọc làm gì với một tombstone

FR-017 đòi hỏi điều này phải được phát biểu cho mọi đường đọc, còn FR-017a đòi nó phải được suy dẫn từ code tại thời điểm viết chứ không phải từ một danh sách, bởi một danh sách thì cũ đi còn code thì không. Đọc từng cái một, có bốn đường:

flowchart LR
    m["a message that was deleted"]
    h["REST history"]
    r["resume backfill"]
    l["channel listing"]
    t["the truncated flag"]
    hr["returned, in position,<br/>text: null"]
    rr["DROPPED — the client sees a gap<br/>and repairs it through history"]
    lr["previewed with a null text,<br/>still counted as one unread"]
    tr["computed from ROWS READ,<br/>not frames delivered"]
    m --> h --> hr
    m --> r --> rr
    m --> l --> lr
    m --> t --> tr
    style rr fill:#7f1d1d,color:#fff,stroke:#dc2626
    style tr fill:#334155,color:#fff,stroke:#64748b
Bốn đường đọc, bốn câu trả lời. Cái thứ tư không phải một câu trả lời theo trạng thái như ba cái kia, và đó là lý do yêu cầu chỉ đếm ba cho tới khi có người đem đo.

Cái thứ tư là cái ẩn mình. Một trang backfill ở mức trần của nó mà chứa tombstone sẽ trả về ít frame hơn số dòng đã đọc mà vẫn báo truncated: true — bỏ đi một dòng không render được không phải lý do để bảo một client đi lật lịch sử, còn giấu một mức trần thật thì mới là. Đó quyết định đó được đưa ra ở chương 2.7 và chương này là lần đầu tiên nó có thể chạy được, bởi tới giờ chẳng gì ghi được một tombstone.

Thứ tương đương của lần sửa chỉ là một dòng: mọi đường đọc đều trả về text hiện tại, bởi text bị thay thế sống trong message_edits và chỉ một route chạm tới cái bảng đó.

Điểm mù của cursor

Đây là phần mà chương này không sửa.

sequenceDiagram
    participant C as client
    participant A as api
    Note over C: holds cursor 41
    C--xA: disconnects
    A->>A: message 12 edited (below the cursor)
    A->>A: message 43 deleted (above the cursor)
    C->>A: reconnect, cursor=41
    A-->>C: backfill: 42 only
    Note over C: 43 is missing — a GAP the SDK detects
    Note over C: 12 is stale — NO gap, nothing detects it
    C->>A: GET history (the repair)
    A-->>C: 12 corrected, 43 with text: null, 42 unchanged
Một client vắng mặt qua một lần sửa dưới cursor của nó và một lần xoá phía trên. Một cái để lại khoảng trống. Cái kia chẳng để lại gì cả.

Một client đang resume trình ra một cursor — một vị trí trong sequence của một channel — rồi nhận mọi thứ phía trên nó. Một message bị xoá phía trên cursor sẽ bị bỏ khỏi phần backfill, và client thấy một số sequence bị thiếu: một khoảng trống, đúng cái tín hiệu mà SDK vá lại bằng history. Cơ chế đó tồn tại từ chương 2.7 và nó chạy tốt.

Một message bị sửa ở dưới cursor thì chẳng tạo ra frame nào và chẳng có khoảng trống nào. Các số sequence phía trên cursor liền mạch; chẳng có gì để phát hiện. Client cứ tiếp tục hiển thị một message mà text của nó đã đổi trong lúc nó vắng mặt, và chẳng gì trong giao thức sẽ bao giờ báo cho nó.

Bộ đếm mà cursor cần

Phần ở trên khảo sát ba sản phẩm rồi từ chối thêm một bộ đếm. Phần này thêm nó, bởi lập luận cho việc từ chối không sống nổi qua chính kết luận của nó: cái giới hạn đã được viết ra, mà một giới hạn chẳng ai hành động được thì không phải một thiết kế, nó là một ghi chú. Một client không phân biệt được một message đã sửa với một message chưa sửa thì chẳng có cách nào quyết định đọc lại history, còn "đọc lại history" lại chính là toàn bộ phương thuốc mà ADR-07 dựa vào.

Nên Relay lấy hình dạng của IMAP — một con số đơn điệu thứ hai cho mỗi channel, nằm cạnh sequence — và trả cái giá của IMAP: mọi lần thay đổi đều phải duy trì nó.

Một cột, và nó đi nhờ chính migration của chương này. message_edits ở trên giữ lại thứ một message từng nói; channels.revision_sequence nói nó đã đổi bao nhiêu lần. Đó là một chủ đề, và tách chúng ra hai migration sẽ đánh số một trong hai sau một công việc chẳng liên quan gì tới cả hai.

Được nâng đúng một đơn vị cho mỗi lần sửa hoặc xoá, bên trong transaction áp dụng nó, và không bao giờ bởi một lần gửi. Phần tăng nằm trong các đường sửa và xoá đã được fence ở phần trước của chương này — hãy quay lại nhìn repository.ts: nó là hai dòng, mỗi trường hợp đều nằm trong cùng transaction với lệnh ghi mà nó đếm. Bên ngoài transaction đó thì nó sẽ là một câu lệnh thứ hai có thể tự mình thất bại, và một bộ đếm có thể bỏ sót thì tệ hơn không có bộ đếm nào, bởi một client sẽ tin nó.

Con số được báo cáo và không bao giờ được đem so. Api trả lời nó trên đúng lời gọi mà gateway vốn đã thực hiện:

packages/protocol/src/internal.ts
@@ -134,12 +134,19 @@ export const internalMembershipsResponseSchema = z.strictObject({
  * `user` is the EXTERNAL id, as everywhere else on this contract: internal uuids
  * are the api's business. */
 export const internalSessionResponseSchema = z.strictObject({
   environment_id: z.string().min(1),
   user: z.string().min(1),
   channel_ids: z.array(z.string().min(1)),
+  /** Per channel, how many revisions it has seen — the same keys as `channel_ids`.
+   *
+   * ONE QUERY, TWO FIELDS. The membership read already joins `channels` to answer
+   * `channel_ids`, so the counter comes back on rows the api was fetching anyway: no
+   * second round trip, and no possibility of the two disagreeing about which channels
+   * the user belongs to. */
+  revisions: z.record(z.string().min(1), z.number().int().nonnegative()),
   /** FR-031. Whether this user is banned in this environment.
    *
    * IT RIDES THE RESPONSE THE GATEWAY ALREADY ASKS FOR: the gateway has no database and
    * must not gain one, `banned_at` is a column in Postgres, and the api is the only
    * service that reads Postgres. So the ban travels on the one call the gateway already
    * makes at connect — no new table reaches the gateway and no new round trip is added.

Gateway chẳng có database và không được phép có một cái — cũng chính lý do khiến banned đi nhờ response này — nên các con số đi cùng với các id vốn đã đang được lấy về:

services/api/src/internal/session.controller.ts
@@ -71,22 +71,30 @@ export class SessionController {
       throw new UnauthorizedException("a bot user cannot open a session");
     }
     // A verified token for a user this environment has never seen is not an
     // error: it is a user with no channels. The gateway's job is delivery, not
     // identity forensics — 2.5's rule, and the reason a first connect from a
     // brand-new user works before anything is seeded.
+    const channels = user ? await this.repo.channelsForUser(user.id) : [];
     return {
       environment_id: principal.environmentId,
       user: principal.userExternalId,
       // FR-031. THE ROW IS ALREADY IN HAND — `getUserByExternalId` above
       // reads it for the channel list — so carrying the ban costs one field and no query.
       // The gateway refuses the socket; this route only reports the fact, because the
       // gateway has no database and the column is in Postgres.
       //
       // A USER THIS ENVIRONMENT HAS NEVER SEEN IS NOT BANNED. `user` is null for a
       // verified token naming somebody with no row, which chapter 2.5 decided is a user
       // with no channels rather than an error — and a user with no row has no ban either.
       banned: user?.banned_at != null,
-      channel_ids: user ? await this.repo.channelsForUser(user.id) : [],
+      // IDS AND COUNTS OFF ONE READ. `channelsForUser` returns a row per channel, so
+      // the two fields cannot disagree about which channels this user belongs to — and
+      // the counter costs no extra query, because the membership join already touches
+      // `channels` to answer the ids.
+      channel_ids: channels.map((c) => c.channel_id),
+      revisions: Object.fromEntries(
+        channels.map((c) => [c.channel_id, c.revision_sequence]),
+      ),
     };
   }
 }

channelsForUser giờ trả về một dòng cho mỗi channel thay vì một id, và đó là thứ làm cho hai trường không thể bất đồng về việc một user thuộc những channel nào. Bên gọi còn lại của nó đổi hình dạng theo, và trình biên dịch đã nói thế:

services/api/src/internal/memberships.controller.ts
@@ -62,10 +62,15 @@ export class MembershipsController {
     // that threw for a deleted user would turn a routine refresh into a failure the
     // gateway has to interpret.
     const user = await this.repo.getUserByExternalId(
       req.principal.userExternalId,
     );
     return {
-      channel_ids: user ? await this.repo.channelsForUser(user.id) : [],
+      // Ids alone: this route answers what a user may hear, not what has changed in it.
+      // `channelsForUser` carries revision counts for the session route (feature 044);
+      // mapping them off here keeps one query behind both.
+      channel_ids: user
+        ? (await this.repo.channelsForUser(user.id)).map((c) => c.channel_id)
+        : [],
     };
   }
 }

Từ đó trở đi là chuyện chuyên chở, không phải logic. Kết quả xác thực có thêm một field, kết nối giữ nó, và ack báo cáo nó:

services/gateway/src/auth.ts
@@ -24,13 +24,21 @@ export type { Identity } from "./api-client.js";
 /** Three outcomes, not two. A refused token and an unreachable api both fail to
  * open a socket, but they are not the same event and must not close the same
  * way: 4001 tells a client its credential is wrong (retrying will not help),
  * 1011 tells it we are broken (retrying will). 2.5 drew that line for the
  * memberships lookup; moving verification here must not erase it. */
 export type Authentication =
-  | { outcome: "ok"; identity: Identity; channelIds: string[] }
+  | {
+      outcome: "ok";
+      identity: Identity;
+      channelIds: string[];
+      /** Per channel, how many revisions it has seen. Reported to the client on the
+       * ack and never compared here: the gateway has no opinion about staleness, and
+       * no database to form one with. */
+      revisions: Record<string, number>;
+    }
   | { outcome: "refused" }
   | { outcome: "unavailable"; error: string }
   /** FR-031. The api answered, the token is perfectly good, and the user is banned in
    * this environment. Its own outcome and its own close code (4003), not a reuse of
    * `refused`: 4001 means "your credential is bad", which a client acts on by
    * re-authenticating — and re-authenticating succeeds and connects to the same
@@ -61,11 +69,12 @@ export async function authenticate(
         userExternalId: session.user,
         // Carried, not trusted: the internal hop forwards this instead of
         // asserting an identity the gateway invented.
         token,
       },
       channelIds: session.channel_ids,
+      revisions: session.revisions,
     };
   } catch (error) {
     return { outcome: "unavailable", error: String(error) };
   }
 }
services/gateway/src/registry.ts
@@ -12,12 +12,16 @@ import type { ResumePhase } from "./resume.js";
 //
 // Note what else is absent: no pg, no drizzle-orm, no repository import.
 // The gateway never touches the database (ADR-05) — and the lint ban from
 // 2.1 makes the mistake a build failure, not a review comment.
 
 export interface Connection {
+  /** Per channel, how many revisions it had when this connection was accepted.
+   * Read once, at the ack, and never updated: a client wanting a fresher count
+   * reconnects, which is the only moment the number is useful to it. */
+  revisions: Record<string, number>;
   readonly id: string;
   readonly identity: Identity;
   readonly socket: WebSocket;
   channelIds: Set<string>;
   missedPings: number;
   /** Chapter 2.7. A connection resuming through the tunnel spends its first

Mọi channel mà user thuộc về, kể cả các số không. Một channel vắng mặt khỏi map sẽ không phân biệt được với một channel ở mức không, nên map là toàn phần trên tập membership: một client đang giữ một con số cho một channel biến mất thì học được rằng membership của nó đã đổi, đó là một sự thật khác và là một sự thật hữu ích.

Và các fixture đã phải nói ra điều đó. Năm suite dựng một response session, và một field bắt buộc nghĩa là mỗi suite trong số đó phải phát biểu báo cáo không có revision nào nghĩa là gì:

services/api/src/fanout/fanout.itest.ts (excerpt)
@@ -529,19 +529,20 @@ describe("the fan-out publish when it fails", () => {
     } finally {
       await app.close();
       await dead.close();
     }
   });
 
-  it("T038: a publisher that does NOTHING passes the weak assertions and fails the log one", async () => {
+  it("a publisher that does NOTHING passes the weak assertions and fails the log one", async () => {
     // The proof that the test above distinguishes anything. A no-op publisher is
     // what "the publish was never written" looks like from outside, and
     // `publish` never rejects either way — so 201 and recoverability cannot tell
     // them apart. Only the log line can.
     const noop: MessagePublisher = {
       publish: async () => {},
+      publishRevision: async () => {},
       close: async () => {},
     };
     const { app, url: base } = await bootWith(noop);
     try {
       const text = `a no-op publisher ${randomUUID()}`;
services/api/src/outbox/event.test.ts (excerpt)
@@ -1,11 +1,13 @@
 import { describe, expect, it } from "vitest";
 
 import {
   membershipEvent,
   messageCreatedEvent,
+  messageDeletedEvent,
+  messageUpdatedEvent,
   OUTBOX_EVENT_TYPES,
   outboxEventSchema,
   subjectFor,
 } from "./event";
 
 // The envelope, Docker-free. What a consumer eventually receives
@@ -132,24 +134,40 @@ describe("a legacy senderless message in the webhook payload (T054a)", () => {
 
 const MEMBERSHIP = {
   channel_id: "ce419dc5-b06e-441c-ab38-49451f87210e",
   user: "tuan",
 };
 
+/** A tombstone as a consumer receives it. No `text` key — that is
+ * FR-020, and `strictObject` refuses one. */
+const DELETED = {
+  id: MESSAGE.id,
+  channel_id: MESSAGE.channel_id,
+  seq: MESSAGE.seq,
+  user: MESSAGE.user,
+  deleted_at: "2026-09-03T09:15:00.000Z",
+};
+
 describe("the outbox event type set", () => {
   // ASSERTED AS A SET AND AS A COUNT, which is the presence chapter's `codes.test.ts`
   // precedent: either alone lets a fourth type arrive unnoticed. FR-WHK-02 names
-  // eight and three exist; the other five arrive with the features that can
-  // produce them.
-  it("is exactly the three types that have producers", () => {
+  // eight and FIVE exist as of this chapter; the other three arrive with the features
+  // that can produce them.
+  //
+  // THE ORDER IS THE ARRAY'S, and the two new names sit beside `message.created`
+  // rather than at the end — they are the same domain, and `toEqual` on an array is
+  // order-sensitive, so this assertion is also a claim about how the source reads.
+  it("is exactly the five types that have producers", () => {
     expect([...OUTBOX_EVENT_TYPES]).toEqual([
       "message.created",
+      "message.updated",
+      "message.deleted",
       "channel.member_added",
       "channel.member_removed",
     ]);
-    expect(OUTBOX_EVENT_TYPES).toHaveLength(3);
+    expect(OUTBOX_EVENT_TYPES).toHaveLength(5);
   });
 
   it("gives every type a subject without a mapping entry", () => {
     // `subjectFor` abbreviates a domain only when DOMAIN_ABBREVIATION has it, so
     // `channel` passes through unchanged. Checked rather than assumed: a type whose
     // subject form is not its dotted name would need an entry, and the absence of a
@@ -160,12 +178,98 @@ describe("the outbox event type set", () => {
     expect(subjectFor("channel.member_removed", ENV)).toBe(
       `events.channel.member_removed.${ENV}`,
     );
   });
 });
 
+describe("messageUpdatedEvent and messageDeletedEvent", () => {
+  const updated = () =>
+    messageUpdatedEvent({
+      eventId: "9c26f1a2-0000-4000-8000-000000000003",
+      environmentId: ENV,
+      occurredAt: "2026-09-03T09:15:00.000Z",
+      message: MESSAGE,
+    });
+  const deleted = () =>
+    messageDeletedEvent({
+      eventId: "9c26f1a2-0000-4000-8000-000000000004",
+      environmentId: ENV,
+      occurredAt: "2026-09-03T09:15:00.000Z",
+      message: DELETED,
+    });
+
+  it("spells both types as FR-WHK-02 spells them", () => {
+    expect(updated().payload.type).toBe("message.updated");
+    expect(deleted().payload.type).toBe("message.deleted");
+    expect(updated().subject).toBe(`events.msg.updated.${ENV}`);
+    expect(deleted().subject).toBe(`events.msg.deleted.${ENV}`);
+  });
+
+  it("leaves the edit's payload identical to a creation's (FR-008a)", () => {
+    // FR-008a in one assertion: *"The message payload used by creation and edit events
+    // MUST be left unchanged."* Compared as SETS, so a field added to one and not the
+    // other fails here rather than in a customer's consumer.
+    expect(Object.keys(updated().payload.data).sort()).toEqual([
+      "channel_id",
+      "created_at",
+      "id",
+      "seq",
+      "text",
+      "user",
+    ]);
+  });
+
+  it("gives the deletion NO text key at all (FR-020)", () => {
+    // NOT `text: null` — a key that can hold the words somebody asked to have removed
+    // is a key somebody can forget to null. The exact set is the assertion.
+    const keys = Object.keys(deleted().payload.data).sort();
+    expect(keys).toEqual(["channel_id", "deleted_at", "id", "seq", "user"]);
+    expect(keys).not.toContain("text");
+  });
+
+  it("carries the edit's own instant, not the message's created_at", () => {
+    // The one place the edit event diverges from the creation event, and it has to: an
+    // event whose `occurred_at` predates the previous event about the same message
+    // cannot be ordered by a consumer.
+    expect(updated().payload.occurred_at).toBe("2026-09-03T09:15:00.000Z");
+    expect(updated().payload.occurred_at).not.toBe(MESSAGE.created_at);
+  });
+
+  it("refuses an event with no id and an event with no environment", () => {
+    // Refused rather than defaulted, like every other builder here: an event with no
+    // deduplication key looks deliverable and cannot be deduplicated.
+    for (const build of [messageUpdatedEvent, messageDeletedEvent]) {
+      expect(() =>
+        // @ts-expect-error the point of the test
+        build({ eventId: "", environmentId: ENV, occurredAt: "x", message: MESSAGE }),
+      ).toThrow("event id");
+      expect(() =>
+        // @ts-expect-error the point of the test
+        build({ eventId: "id", environmentId: "", occurredAt: "x", message: MESSAGE }),
+      ).toThrow("environment id");
+    }
+  });
+
+  it("round-trips both through the consumer's schema", () => {
+    // The producer and the consumer are two shapes of one contract. Without this, the
+    // union branches added in this chapter would be checked only against fixtures this
+    // file writes — and `consumer/runtime.ts` answers a failed parse with
+    // `message.term()`, which stops redelivery for good.
+    expect(outboxEventSchema.safeParse(updated().payload).success).toBe(true);
+    expect(outboxEventSchema.safeParse(deleted().payload).success).toBe(true);
+  });
+
+  it("refuses a deletion that carries a text, through the consumer's schema", () => {
+    const withText = {
+      ...deleted().payload,
+      data: { ...DELETED, text: "should not be here" },
+    };
+    expect(outboxEventSchema.safeParse(withText).success).toBe(false);
+  });
+});
+
 describe("membershipEvent", () => {
   const build = (change: "added" | "removed") =>
     membershipEvent({
       eventId: "9c26f1a2-0000-4000-8000-000000000001",
       environmentId: ENV,
       change,
@@ -249,18 +353,30 @@ describe("outboxEventSchema — what a CONSUMER will accept", () => {
 
   // THE TEST THIS CHAPTER EXISTS TO HAVE WRITTEN. Until it was, the schema was
   // `z.literal("message.created")` and the consumer answers a failed parse with
   // `message.term()` — redelivery stopped for good. Every membership event would
   // have been destroyed there, in a lane that runs the consumer switched off.
   it("accepts every type the producer can build", () => {
+    // A LOOKUP RATHER THAN A TERNARY, because the revisions chapter made the shapes three: a
+    // creation and an edit carry a `Message` (FR-008a), a deletion carries an identity
+    // with no text (FR-020), and a membership change carries neither. The ternary's
+    // `else` branch would have handed the membership shape to `message.deleted` and
+    // reported a schema failure as though the schema were wrong.
+    const dataFor: Record<(typeof OUTBOX_EVENT_TYPES)[number], unknown> = {
+      "message.created": MESSAGE,
+      "message.updated": MESSAGE,
+      "message.deleted": DELETED,
+      "channel.member_added": { channel_id: MEMBERSHIP.channel_id, user: MEMBERSHIP.user },
+      "channel.member_removed": { channel_id: MEMBERSHIP.channel_id, user: MEMBERSHIP.user },
+    };
     for (const type of OUTBOX_EVENT_TYPES) {
-      const data =
-        type === "message.created"
-          ? MESSAGE
-          : { channel_id: MEMBERSHIP.channel_id, user: MEMBERSHIP.user };
-      const result = outboxEventSchema.safeParse({ ...envelope, type, data });
+      const result = outboxEventSchema.safeParse({
+        ...envelope,
+        type,
+        data: dataFor[type],
+      });
       expect(result.success, `${type} must parse`).toBe(true);
     }
   });
 
   it("round-trips what membershipEvent produces", () => {
     // The producer and the consumer are two shapes of one contract, and only a
services/gateway/src/connections.itest.ts
@@ -135,12 +135,13 @@ async function boot(options: {
   const api: ApiClient = {
     session: async () => ({
       environment_id: environment,
       user: options.user,
       banned: false,
       channel_ids: options.channels,
+      revisions: {},
       limits: { connect: 3_000, send: 600 },
     }),
     memberships: async () => options.channels,
     backfill: async () => ({}) as never,
     sendMessage: async () => {
       throw new Error("not used");
services/gateway/src/session.itest.ts (excerpt)
@@ -138,14 +138,27 @@ async function startApi(): Promise<ApiUnderTest> {
   // which is the difference between adding a capability and repurposing one.
   await repo.upsertUser("delivery-bot", {
     display_name: "Delivery Bot",
     kind: "bot",
     description: "sends over REST so a socket can receive it",
   });
+  // Two PEOPLE in the channel, because FR-005's property is "every
+  // connected member" and one socket cannot show it. ADDITIVE, on the fan-out chapter's
+  // precedent recorded just above — the tests that assert on "tuan" are unaffected by
+  // two more members of a public channel, and T033 is the only test that names these.
+  //
+  // BOTH ARE MEMBERS, and that is what the first run of T033 got wrong: `mintToken`
+  // mints a token for any identifier, so two sockets opened fine and neither was
+  // delivered to. The failure read "no the creation; saw connection.ack" — a
+  // membership problem wearing a delivery problem's message.
+  const editor = await repo.createUser("editor", "The Editor");
+  const watcher = await repo.createUser("watcher", "The Watcher");
   const channel = await repo.createChannel("fleet", "public");
   await repo.addMember(channel.id, user.id);
+  await repo.addMember(channel.id, editor.id);
+  await repo.addMember(channel.id, watcher.id);
   const key = await seeder.createApiKey(db, {
     environmentId: environment.id,
   });
 
   // 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
@@ -574,12 +587,150 @@ describe("the socket's delivery, with a fan-out attached", () => {
     expect(delivered.payload.text).toBe(text);
     expect(delivered.payload.user).toBe("delivery-bot");
     // The sequence the api committed, not one the gateway invented.
     expect(delivered.payload.seq).toBeGreaterThan(0);
   });
 
+  it("an edit over REST reaches every member's socket as message.updated exactly once (FR-005, SC-001)", async () => {
+    // TWO SOCKETS, TWO DIFFERENT PEOPLE, and a count rather than a first match.
+    // `waitFor` resolves on the first frame that matches, so it cannot see a duplicate;
+    // FR-005 is one property with two halves — everybody gets it, and nobody gets it
+    // twice — and only counting after a settle covers the second.
+    const first = record(connect(await mintToken("editor")));
+    const second = record(connect(await mintToken("watcher")));
+    await waitFor(first, (f) => f.type === "connection.ack", "connection.ack (editor)");
+    await waitFor(second, (f) => f.type === "connection.ack", "connection.ack (watcher)");
+
+    // SENT BY THE EDITOR'S OWN TOKEN. Only an author may edit (FR-013) and the edit
+    // route takes no application credential at all (FR-013a), so the send has to be
+    // attributed to the same person — which a user token does by itself, and which is
+    // why this body names no `user`.
+    const editorToken = await mintToken("editor");
+    const before = `to be corrected ${randomUUID()}`;
+    const posted = await fetch(`${api.url}/v1/channels/${api.channelId}/messages`, {
+      method: "POST",
+      headers: {
+        "content-type": "application/json",
+        authorization: `Bearer ${editorToken}`,
+      },
+      body: JSON.stringify({ text: before }),
+    });
+    expect(posted.status, await posted.clone().text()).toBe(201);
+    const sent = (await posted.json()) as { id: string; seq: number };
+    await waitFor(second, (f) => f.type === "message.created", "the creation");
+
+    const after = `${before} (corrected)`;
+    const edited = await fetch(
+      `${api.url}/v1/channels/${api.channelId}/messages/${sent.id}`,
+      {
+        method: "PATCH",
+        headers: {
+          "content-type": "application/json",
+          authorization: `Bearer ${editorToken}`,
+        },
+        body: JSON.stringify({ text: after }),
+      },
+    );
+    expect(edited.status, await edited.clone().text()).toBe(200);
+
+    for (const [who, frames] of [
+      ["editor", first],
+      ["watcher", second],
+    ] as const) {
+      const updated = (await waitFor(
+        frames,
+        (f) => f.type === "message.updated",
+        `message.updated (${who})`,
+      )) as { payload: { text: string; seq: number; id: string } };
+      expect(updated.payload.text, who).toBe(after);
+      // THE SEQUENCE IT ALREADY HAD (FR-002), on the wire. A new number here would
+      // put the edit at the end of every client's list and break every cursor.
+      expect(updated.payload.seq, who).toBe(sent.seq);
+      expect(updated.payload.id, who).toBe(sent.id);
+    }
+
+    // AND EXACTLY ONCE EACH, after a settle long enough for a second copy to have
+    // arrived. Both counts, because the two sockets take different paths through the
+    // registry — the editor's connection and the watcher's are separate entries and a
+    // per-connection duplicate would show on one of them.
+    await new Promise((r) => setTimeout(r, 300));
+    expect(first.filter((f) => f.type === "message.updated")).toHaveLength(1);
+    expect(second.filter((f) => f.type === "message.updated")).toHaveLength(1);
+    // …AND NO SECOND CREATION, which is what ADR-24 is for. Routed to the old callback
+    // the edit arrives as `message.created` and no shape check can see it, because the
+    // `updated` arm's payload IS a `Message`.
+    expect(first.filter((f) => f.type === "message.created")).toHaveLength(1);
+    expect(second.filter((f) => f.type === "message.created")).toHaveLength(1);
+  });
+
+  it("a deletion over REST reaches a member's socket with NO text field, and a second deletion sends nothing (FR-007, FR-009)", async () => {
+    const frames = record(connect(await mintToken("watcher")));
+    await waitFor(frames, (f) => f.type === "connection.ack", "connection.ack");
+
+    // Sent by the editor and deleted by the TENANT KEY, which FR-012 permits
+    // irrespective of author — the path a moderator takes, exercised here because the
+    // frame must be identical either way.
+    const editorToken = await mintToken("editor");
+    const text = `to be removed ${randomUUID()}`;
+    const posted = await fetch(`${api.url}/v1/channels/${api.channelId}/messages`, {
+      method: "POST",
+      headers: {
+        "content-type": "application/json",
+        authorization: `Bearer ${editorToken}`,
+      },
+      body: JSON.stringify({ text }),
+    });
+    expect(posted.status, await posted.clone().text()).toBe(201);
+    const sent = (await posted.json()) as { id: string; seq: number };
+    await waitFor(frames, (f) => f.type === "message.created", "the creation");
+
+    const removed = await fetch(
+      `${api.url}/v1/channels/${api.channelId}/messages/${sent.id}`,
+      { method: "DELETE", headers: { authorization: `Bearer ${api.credential}` } },
+    );
+    expect(removed.status, await removed.clone().text()).toBe(204);
+
+    const frame = (await waitFor(
+      frames,
+      (f) => f.type === "message.deleted",
+      "message.deleted",
+    )) as { payload: Record<string, unknown> };
+    // THE EXACT KEY SET, which is the assertion FR-020's sibling requirement needs:
+    // "no text" as `Object.keys` rather than as `payload.text === undefined`, because
+    // an absent key and a null one read the same through a property access.
+    expect(Object.keys(frame.payload).sort()).toEqual([
+      "channel",
+      "deleted_at",
+      "id",
+      "seq",
+      "user",
+    ]);
+    expect(frame.payload).not.toHaveProperty("text");
+    // Identity and position (FR-008), and the sequence is the one it had — a tombstone
+    // that gave up its place would leave a gap in every client's ordering.
+    expect(frame.payload["id"]).toBe(sent.id);
+    expect(frame.payload["seq"]).toBe(sent.seq);
+    // THE AUTHOR, NOT THE DELETER. A tenant key removed it; the frame names who wrote
+    // it, which is the fact every client already holds beside the message.
+    expect(frame.payload["user"]).toBe("editor");
+
+    // AND NO SECOND FRAME FOR A SECOND DELETION (FR-009, SC-007). The status is 204
+    // either way, so this count is the only thing that can tell the two apart on the
+    // wire.
+    expect(
+      (
+        await fetch(`${api.url}/v1/channels/${api.channelId}/messages/${sent.id}`, {
+          method: "DELETE",
+          headers: { authorization: `Bearer ${api.credential}` },
+        })
+      ).status,
+    ).toBe(204);
+    await new Promise((r) => setTimeout(r, 300));
+    expect(frames.filter((f) => f.type === "message.deleted")).toHaveLength(1);
+  });
+
   it("stops delivering to a member who was REMOVED while connected (FR-RTM-10)", async () => {
     // INVERTED IN THE MEMBERSHIP-REVOCATION CHAPTER, AND THE TITLE WITH IT. This test read "keeps
     // delivering" and asserted the violation on purpose from the fan-out chapter until
     // now — its own closing comment carried the instruction: "change this to
     // `.rejects` on the day a re-read exists".
     //
@@ -723,22 +874,53 @@ describe("the socket's delivery, with a fan-out attached", () => {
       user: "tuan",
       text: "forged",
       created_at: new Date().toISOString(),
     };
     switch (type) {
       case "connection.ack":
+      // AND `revisions` FOR THE SAME REASON, ONE FIELD LATER. This chapter made it
+      // required on the ack, so the sample above stopped satisfying
+      // `connectionAckSchema` and the forged frame came back `invalid_frame` —
+      // the refusal a phase before the one this loop asserts. Identical to the
+      // `message.deleted` split below, in the same two files, in the same feature.
         return {
           type,
-          payload: { user: "tuan", cursor: {}, resume_ok: true, truncated: [] },
+          payload: {
+            user: "tuan",
+            cursor: {},
+            resume_ok: true,
+            truncated: [],
+            revisions: {},
+          },
         };
       case "message.ack":
         return { type, payload: { seq: 1 } };
       case "message.created":
       case "message.updated":
-      case "message.deleted":
         return { type, payload: message };
+      // THE REVISIONS CHAPTER SPLIT THIS CASE OFF, and the failure that forced it is the point
+      // of the test. `message.deleted` shared `message` — a `Message` with a `text` —
+      // until this chapter gave the frame a payload of its own with no text and a
+      // `deleted_at`. The forged frame then failed the SHAPE check and came back
+      // `invalid_frame`, so the test asserting `unknown_frame_type` went red.
+      //
+      // It was red for the right reason: this test's whole claim is that a WELL-FORMED
+      // outbound frame is refused for its DIRECTION. A malformed one is refused a
+      // phase earlier and proves nothing about direction at all — which is what it
+      // would have been quietly asserting had the payload merely been tolerated.
+      case "message.deleted":
+        return {
+          type,
+          payload: {
+            id: message.id,
+            channel,
+            seq: 1,
+            user: "tuan",
+            deleted_at: new Date().toISOString(),
+          },
+        };
       case "membership.changed":
         return { type, payload: { channel, user: "tuan", change: "added" } };
       case "presence.changed":
         return { type, payload: { user: "tuan", state: "online" } };
       case "typing":
         return { type, payload: { channel, user: "tuan" } };
services/gateway/src/typing.itest.ts
@@ -106,12 +106,13 @@ async function boot(options: {
   const api: ApiClient = {
     session: async () => ({
       environment_id: environment,
       user: options.user,
       banned: false,
       channel_ids: options.channels,
+      revisions: {},
     }),
     memberships: async () => options.channels,
     backfill: async () => {
       if (options.backfillDelayMs !== undefined) {
         await new Promise((r) => setTimeout(r, options.backfillDelayMs));
       }

Các cân đo bắt được gì

Một bài test đòi đúng cái thứ mà chương này đã ghi lại là an toàn, và câu trả lời là không. Sổ cái mang theo có một mục nói rằng cả hai cách xen kẽ của một lần sửa và một lần xoá đồng thời đều kết thúc bằng một tombstone — "chẳng có thứ tự nào của hai cái để lại một message nói ra thứ chẳng ai viết" — và bài test mà mục đó đòi hỏi đã bác bỏ nó.

Cả editMessage lẫn deleteMessage đều không lấy khoá dòng. Đó là cố ý và có ghi lại: assertWithinQuota đã quyết định phát biểu một mức vượt thay vì đi vòng để tránh nó. Nhưng phép kiểm tombstone của lần sửa là một lệnh ĐỌC thực hiện sớm hơn trong cùng transaction, còn lệnh ghi của nó chỉ nói where(eq(messages.id, messageId)) — nên một lần xoá commit bên trong cửa sổ đó để lần sửa tự do khôi phục text trong khi deleted_at vẫn còn được đặt. Một dòng mà một bộ lọc gọi là đã xoá còn bộ lọc khác gọi là còn sống, và một lần xoá trả về thành công bị một lần sửa đang bay huỷ bỏ mất.

Bộ lọc giờ sống trong mệnh đề WHERE, nơi database ước lượng nó trên cái dòng như nó đang là lúc ghi chứ không phải như nó đã là lúc đọc. Fence cho repository.ts ở trên mang nó.

VÀ BẰNG CHỨNG NẰM Ở HÌNH DẠNG CỦA CÁI THẤT BẠI, KHÔNG PHẢI Ở SỰ CÓ MẶT CỦA NÓ. Bài test race chạy mười lượt mỗi lần gọi, bởi một cuộc đua được khẳng định một lần là một cuộc đua được quan sát một lần. Không có mệnh đề WHERE thì nó đỏ một lần chạy trên ba — lượt 1 ở một lần chạy, lượt 5 ở lần khác, chẳng bao giờ cùng một lượt hai lần. Có nó thì ba lần chạy trên ba đều xanh. Một defect xuất hiện ở một phần ba số lần chạy thì đúng là loại bị ghi thành flake rồi chạy lại cho tới khi xanh, và đó là cách sổ cái đi tới chỗ ghi lại điều ngược với sự thật suốt hai chương.

Danh sách mục tiêu của isolation gauntlet suy dẫn mọi route từ ứng dụng đang chạy và so nó với một bảng duy trì bằng tay, nên nó đỏ ở bản build thêm một route. Nó đã đỏ, hai lần — một lần gọi tên một route được khai báo trước khi nó được viết, và đó là hướng bắt được một lần đổi tên.

services/api/src/isolation/targets.ts
@@ -191,12 +191,61 @@ export const CLASSIFICATIONS: readonly Classification[] = [
   {
     method: "GET",
     path: "/v1/channels/:channelId/messages",
     accepts: "either",
     shape: "read",
   },
+  // THE REVISIONS CHAPTER'S EDIT HISTORY (T033h, FR-023, FR-023a). `accepts: "application"`
+  // because the route carries a method-level `@Accepts("application")` that narrows the
+  // controller's class-level `("application", "user")` — FR-MOD-01 names the audience,
+  // and nothing in the SRS asks for an end-user surface on what a message used to say.
+  //
+  // THE TWO VALUES MUST AGREE AND NOTHING COMPARES THEM. This entry and the decorator
+  // are the same authorisation fact written twice; the revisions chapter's `gaps.md` item 4 owns
+  // that. What a wrong value here costs is not a leak — the guard decides, this list
+  // only tells the gauntlet which credential to attack with — but a `"user"` here would
+  // send the gauntlet at this route with a token the guard refuses at the door, and the
+  // route would then be recorded as isolated without its handler ever running.
+  {
+    method: "GET",
+    path: "/v1/channels/:channelId/messages/:messageId/edits",
+    accepts: "application",
+    shape: "read",
+  },
+  // THE REVISIONS CHAPTER'S EDIT (T030a, FR-001, FR-013a). `accepts: "user"` because the method
+  // declares `@Accepts("user")`: FR-MOD-02 grants a tenant key deletion of any message
+  // and is silent on editing, and silence is read as absence of permission.
+  //
+  // NOT FILED UNDER THE PUBLIC MESSAGE SURFACE ABOVE, which is `either` and attacked as
+  // both classes. This route takes one class, so a second entry there would send the
+  // gauntlet at it with a credential the guard refuses at the door.
+  //
+  // THE CONTROLLER'S PARAMETER NAME, NOT THE CONTRACT'S. `:messageId` is what the
+  // router registers; the derivation compares literal path strings, so an entry copied
+  // from `contracts/edit-and-delete.md` would match no target — the note at the join
+  // route above records the same trap being paid for once already.
+  {
+    method: "PATCH",
+    path: "/v1/channels/:channelId/messages/:messageId",
+    accepts: "user",
+    shape: "write",
+  },
+  // THE REVISIONS CHAPTER'S DELETION (T041a, FR-006, FR-012, FR-013). `accepts: "either"` — an
+  // existing value, used by the read-position route above — because the author OR a
+  // tenant key may delete (FR-MOD-02), which is the class-level declaration this route
+  // correctly inherits rather than overrides.
+  //
+  // **THIS ENTRY IS WHERE AN INHERITED DECLARATION BECOMES VISIBLE.** In the controller
+  // an inherited `@Accepts` and a forgotten one read identically; here the intent is
+  // written down, so a later reader can tell that both classes are meant.
+  {
+    method: "DELETE",
+    path: "/v1/channels/:channelId/messages/:messageId",
+    accepts: "either",
+    shape: "write",
+  },
 
   // ── the two routes this chapter adds, and the ORDER MATTERS ────────────────────
   //
   // The derivation found them before this list did. `targets.itest.ts` went from 9
   // targets to 11 and named both as unclassified, on the build that registered the
   // module and before anything here mentioned them. That is the failure the derivation
services/api/src/isolation/targets.itest.ts
@@ -148,12 +148,19 @@ describe("the gauntlet's target list derives from the running application", () =
       "GET /v1/users/:externalId",
       "PATCH /v1/users/:externalId",
       "POST /v1/users",
       "DELETE /v1/users/:externalId",
       "POST /v1/users/:externalId/ban",
       "DELETE /v1/users/:externalId/ban",
+      // THE REVISIONS CHAPTER, AND IT CAUGHT ITS OWN MISTAKE IN BOTH DIRECTIONS AT
+      // ONCE. Both keys went into `targets.ts` before the second route was written, so
+      // one run named `GET …/:messageId/edits` as an entry matching no derived target —
+      // the direction a rename breaks — while the accounting test above named the other.
+      "GET /v1/channels/:channelId/messages/:messageId/edits",
+      "PATCH /v1/channels/:channelId/messages/:messageId",
+      "DELETE /v1/channels/:channelId/messages/:messageId",
     ];
     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([]);
   });

Rồi lần thứ ba, từ một cái cân đo mà cuốn sách này chưa có khi danh sách phân loại được viết lần đầu. targets.itest.ts hỏi xem mọi route trên router có được phân loại không. gauntlet.itest.ts hỏi hướng ngược lại — liệu mọi route mà phân loại bảo phải tấn công có thực sự bị tấn công không — và nó gọi tên cả ba route của chương này theo đường dẫn:

AssertionError: classified but never attacked:
GET /v1/channels/:channelId/messages/:messageId/edits,
PATCH /v1/channels/:channelId/messages/:messageId,
DELETE /v1/channels/:channelId/messages/:messageId

Một phân loại write mà chẳng có đòn tấn công nào được viết cho nó thì cũng là cùng cái lỗ hổng như một route chưa phân loại, chỉ cao hơn một bậc: danh sách nói một credential với tới được đường dẫn này với identifier của người khác nằm trong đó, mà chẳng ai từng thử. Ba đòn tấn công trả lời nó khác nhau đúng một thứ — credential — và đó là toàn bộ lý do accepts là một field trên phân loại chứ không phải một lời chú thích. Lần sửa nhận một user token, phần history một application credential, lần xoá thì cái nào cũng được. Tấn công một cái bằng sai lớp thì bị từ chối ngay ở cửa, và route rồi được ghi là đã cô lập mà handler của nó chưa từng chạy.

services/api/src/isolation/gauntlet.itest.ts
@@ -5,13 +5,20 @@ 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, Repository } from "../db/repository";
 import { createDb, createPool } from "../db/client";
-import { credentialAttack, listAttack, readAttack, rowsOf, writeAttack } from "./attack";
+import {
+  credentialAttack,
+  listAttack,
+  readAttack,
+  rowsOf,
+  send,
+  writeAttack,
+} from "./attack";
 import { withoutRequestId } from "./compare";
 import {
   nowhereId,
   seedCollidingTenants,
   seedSameTenant,
   seedTwoTenants,
@@ -137,12 +144,129 @@ describe("the isolation gauntlet", () => {
     expect(verdict.foreign.status).toBe(404);
     // THE STATE READ IS THE POINT: a 404 that completed the write is the case no
     // status code reveals.
     expect(verdict.stateChanged, "the victim's messages moved").toBe(false);
   });
 
+  // ── the revisions chapter's three routes ────────────────────────────────────────
+  //
+  // WRITTEN BECAUSE THE ACCOUNTING TEST AT THE BOTTOM OF THIS FILE ASKED FOR THEM. The
+  // classification went in with the routes; the attacks did not, and the run that
+  // followed named all three by path. That is the direction published Part 3 never
+  // checked — a `write` classification with no attack written for it is the same hole as
+  // an unclassified route, one level up.
+  //
+  // AND THE CREDENTIAL DIFFERS PER ROUTE, which is the whole reason `accepts` is on the
+  // classification: the edit takes a user token only, the history an application
+  // credential only, the deletion either. Attacking one with the wrong class would be
+  // refused at the door and recorded as isolated without the handler ever running.
+  it("GET .../messages/:messageId/edits — a foreign message's history reads as an absent one", async () => {
+    attacked.add("GET /v1/channels/:channelId/messages/:messageId/edits");
+    const verdict = await readAttack(
+      url,
+      t.attacker.credential,
+      {
+        method: "GET",
+        path: `/v1/channels/${t.victim.channelId}/messages/${t.victim.messageId}/edits`,
+      },
+      {
+        method: "GET",
+        path: `/v1/channels/${ABSENT_UUID}/messages/${ABSENT_UUID}/edits`,
+      },
+    );
+    expect(verdict.differences, verdict.differences.join("; ")).toEqual([]);
+    // THE REFUSAL IS THE TENANCY ONE, and without this the pair agrees on any shared
+    // answer — including the `@Accepts("application")` guard's 403, which is what a key
+    // swapped for a token here would produce on BOTH halves.
+    expect(verdict.foreign.status).toBe(404);
+    // AND THE PRIOR TEXT IS NOT IN THE BODY. FR-023a makes this the one route whose
+    // 200 carries what a message USED to say, so an id-shaped comparison is not enough.
+    expect(JSON.stringify(verdict.foreign.body)).not.toContain("victim");
+  });
+
+  it("PATCH .../messages/:messageId — a foreign message is not edited, and says so like an absent one", async () => {
+    attacked.add("PATCH /v1/channels/:channelId/messages/:messageId");
+    const verdict = await writeAttack(
+      url,
+      // A USER TOKEN, because `@Accepts("user")` is on the method: FR-MOD-02 grants a
+      // tenant key deletion and is silent on editing. The attacker's token names the
+      // attacker's OWN user, minted in `beforeAll` — the forged identifier is the
+      // channel and the message, not the caller.
+      attackerToken,
+      {
+        method: "PATCH",
+        path: `/v1/channels/${t.victim.channelId}/messages/${t.victim.messageId}`,
+        body: { text: "rewritten by the attacker" },
+      },
+      {
+        method: "PATCH",
+        path: `/v1/channels/${ABSENT_UUID}/messages/${ABSENT_UUID}`,
+        body: { text: "rewritten by the attacker" },
+      },
+      () => t.victim.repo.listMessages(t.victim.channelId, { limit: 50 }),
+    );
+    expect(verdict.differences, verdict.differences.join("; ")).toEqual([]);
+    expect(verdict.foreign.status).toBe(404);
+    // THE STATE READ IS WHAT A STATUS CANNOT SAY. The listing carries `text` and
+    // `edited_at`, so a 404 that completed the edit shows up here and nowhere else.
+    expect(verdict.stateChanged, "the victim's message text or edited_at moved").toBe(false);
+  });
+
+  it("DELETE .../messages/:messageId — a foreign message is not tombstoned", async () => {
+    attacked.add("DELETE /v1/channels/:channelId/messages/:messageId");
+    const verdict = await writeAttack(
+      url,
+      // A KEY, and this route inherits `@Accepts("application", "user")` from the class
+      // rather than narrowing it, so the application half is the one attacked here — a
+      // key may delete anybody's message WITHIN ITS OWN TENANT (FR-MOD-02), which is
+      // precisely the permission that makes the tenancy boundary the only thing
+      // standing between this credential and the victim's message.
+      t.attacker.credential,
+      {
+        method: "DELETE",
+        path: `/v1/channels/${t.victim.channelId}/messages/${t.victim.messageId}`,
+      },
+      {
+        method: "DELETE",
+        path: `/v1/channels/${ABSENT_UUID}/messages/${ABSENT_UUID}`,
+      },
+      () => t.victim.repo.listMessages(t.victim.channelId, { limit: 50 }),
+    );
+    expect(verdict.differences, verdict.differences.join("; ")).toEqual([]);
+    expect(verdict.foreign.status).toBe(404);
+    // A DELETION IS THE ONE WRITE WHOSE SUCCESS LOOKS LIKE ITS REFUSAL from outside:
+    // the route answers 204 with no body, so `stateChanged` is the entire assertion
+    // that the tombstone was not written.
+    expect(verdict.stateChanged, "the victim's message became a tombstone").toBe(false);
+  });
+
+  it("the three of them refuse the victim's message id inside the ATTACKER'S OWN channel", async () => {
+    // THE SHAPE ONLY A NESTED ROUTE HAS, and the pair attacks above cannot express it:
+    // they forge BOTH identifiers, so a route that checked only the channel would pass
+    // them. Here the channel is the attacker's, legitimately visible, and the message
+    // id is the victim's — which is the request a broken `messageExistsIn` answers.
+    const before = await t.victim.repo.listMessages(t.victim.channelId, { limit: 50 });
+    const own = `/v1/channels/${t.attacker.channelId}/messages/${t.victim.messageId}`;
+
+    for (const [label, req, credential] of [
+      ["history", { method: "GET", path: `${own}/edits` }, t.attacker.credential],
+      ["edit", { method: "PATCH", path: own, body: { text: "reached" } }, attackerToken],
+      ["deletion", { method: "DELETE", path: own }, t.attacker.credential],
+    ] as const) {
+      const answer = await send(url, credential, req);
+      expect(answer.status, `${label} accepted a foreign message id`).toBe(404);
+      expect(
+        JSON.stringify(answer.body ?? ""),
+        `${label} echoed the victim's message id`,
+      ).not.toContain(t.victim.messageId);
+    }
+
+    const after = await t.victim.repo.listMessages(t.victim.channelId, { limit: 50 });
+    expect(JSON.stringify(after), "the victim's message moved").toBe(JSON.stringify(before));
+  });
+
   it("POST /internal/messages — a foreign channel_id refuses, and writes nothing", async () => {
     attacked.add("POST /internal/messages");
     const verdict = await writeAttack(
       url,
       attackerToken,
       {
services/api/src/isolation/attack.ts
@@ -36,13 +36,18 @@ export interface Answer {
 export interface Verdict {
   differences: string[];
   foreign: Answer;
   absent: Answer;
 }
 
-async function send(
+/** EXPORTED FOR THE ATTACK NO PAIR CAN EXPRESS. Every helper below forges BOTH
+ * identifiers, which a nested route can satisfy while checking only the outer one — so
+ * the revisions chapter's three routes are also attacked with the attacker's OWN
+ * channel and the victim's message id, and that request is neither a pair nor a list.
+ * One caller, one reason, rather than a fourth helper for a single shape. */
+export async function send(
   baseUrl: string,
   credential: string,
   req: AttackRequest,
 ): Promise<Answer> {
   const res = await fetch(`${baseUrl}${req.path}`, {
     method: req.method,
services/api/src/db/schema.ts
@@ -18,15 +18,17 @@ import {
 // The TS twin of SAD §6.1 (ADR-16). The schema now exists twice — once as
 // the SAD's SQL truth, once here — and that drift risk is checked, not
 // assumed away: drizzle-kit GENERATES the migration SQL from these
 // definitions, and the generated SQL is reviewed against §6.1 before the
 // runner applies it. The four tenant-bearing tables reproduce §6.1
 // column-for-column, constraints and DR citations included. Deliberately
-// absent, with named arrivals: message_edits (edit chapter), emoji/media
-// tables (their parts), messages partitioning (SAD growth note -> retention
-// chapter). The outbox arrives with the chapter of that name and is at the bottom of this file.
+// absent, with named arrivals: emoji/media tables (their parts), messages
+// partitioning (SAD growth note -> retention chapter). The outbox arrives with the
+// chapter of that name and is at the bottom of this file. `message_edits` ARRIVES WITH
+// THE REVISIONS CHAPTER and is below `messages` — the list above said "edit chapter"
+// and this is it.
 
 // The tenancy hierarchy. Everything from here to `members`
 // below sits ABOVE the environment boundary: these rows say who owns a
 // platform account, and they are the only tables in this file without an
 // environment_id. Everything below the boundary carries one and is scoped by
 // the repository (constitution I).
@@ -281,12 +283,28 @@ export const channels = pgTable(
     type: text("type").notNull(),
     name: text("name"),
     metadata: jsonb("metadata").notNull().default({}),
     lastSequence: bigint("last_sequence", { mode: "number" })
       .notNull()
       .default(0), // ADR-03
+    /** How many revisions this channel's messages have received (feature 044, FR-001).
+     *
+     * A REVISION IS AN EDIT OR A DELETION, and each raises this by exactly one. A SEND
+     * DOES NOT (FR-011): a new message is delivered by the ordinary replay, and counting
+     * sends here would make every active channel report a repair after every absence.
+     *
+     * WHAT IT ANSWERS. Resume is ordered by `lastSequence` above, and a revision carries
+     * the sequence of the message it changes rather than a new one — so a message revised
+     * below a client's cursor reaches it on no frame and consumes no sequence, leaving no
+     * gap to notice. This is the number a reconnecting client compares against to learn
+     * that it holds something stale.
+     *
+     * `{ mode: "number" }` and `bigint`, matching `lastSequence` for the same reason. */
+    revisionSequence: bigint("revision_sequence", { mode: "number" })
+      .notNull()
+      .default(0),
     archivedAt: timestamp("archived_at", { withTimezone: true }),
     // WHEN THIS CHANNEL LAST TOOK A MESSAGE (FR-014).
     //
     // A denormalised value, and the 145× is why. FR-CHN-08 wants a user's channels
     // ordered by most recent activity. `last_sequence` above cannot do it — it is a
     // per-channel counter, so two channels both at 50 say nothing about which was
@@ -353,12 +371,53 @@ export const messages = pgTable(
     // constraint above already supplies that ordering, and Postgres walks
     // it backward for newest-first pages. Chapter 2.4 measured it and
     // migration 0001 dropped the redundant twin (SAD §6.3, amended).
   ],
 );
 
+// WHAT A MESSAGE USED TO SAY (FR-MSG-07). Published in SAD §6.1
+// since the SAD was written and built here — the absence note above named this
+// chapter as its arrival.
+//
+// REPRODUCED FROM §6.1 COLUMN FOR COLUMN, which is worth saying because the
+// first draft of this chapter's data model gave the table a surrogate
+// `id UUID PRIMARY KEY` and stated that it was quoting the SAD. It was not.
+// Three columns and a composite key:
+//
+//     PRIMARY KEY (message_id, edited_at)
+//
+// The key is a constraint with a cost the SAD does not spell out: two edits to
+// one message at the same timestamp collide rather than both being kept.
+// Postgres holds microseconds, so that needs two edits inside one microsecond
+// on one message. A surrogate id would take both rows and leave a history with
+// two entries claiming the same instant, which is a silent wrong answer where
+// this is a loud refusal. The published constraint stands (Constitution VII).
+//
+// APPEND ONLY (FR-004). Nothing updates or deletes a row here. A
+// second edit appends a second row; the current text lives on `messages`.
+//
+// NO `environment_id`, exactly like `messages` above. The tenant is reached
+// through `message_id -> messages -> channels`, which is how every read below
+// the boundary already scopes (constitution I).
+export const messageEdits = pgTable(
+  "message_edits",
+  {
+    messageId: uuid("message_id")
+      .notNull()
+      .references(() => messages.id),
+    editedAt: timestamp("edited_at", { withTimezone: true }).notNull(),
+    // FR-MSG-07: what the message said before this edit. NOT NULL, and that
+    // has a consequence the chapter meets rather than works around: a deletion
+    // writes no row here, because a tombstone has no text to preserve. FR-010
+    // refuses an edit on a tombstone instead of defining what its history
+    // would say.
+    priorText: text("prior_text").notNull(),
+  },
+  (t) => [primaryKey({ columns: [t.messageId, t.editedAt] })],
+);
+
 // DECISION (chapter 2.1): the docs/07 row and SAD §6.3's hot-path index
 // both reference a members table that §6.1 never defines. This shape is
 // anchored to that index; membership roles arrive with the channel
 // semantics chapters.
 export const members = pgTable(
   "members",
services/api/src/messages/messages.schema.ts
@@ -24,12 +24,38 @@ export const sendMessageBodySchema = z.strictObject({
    * took an internal uuid here would be the only one that did not. */
   user: z.string().min(1).max(255).optional(),
 });
 
 export type SendMessageBody = z.infer<typeof sendMessageBodySchema>;
 
+/** The edit body (FR-001).
+ *
+ * THE SAME BOUNDS AS THE SEND BODY'S `text`, and the same reason: FR-MSG-01 fixes them
+ * for a message and an edited message is still a message. Written as a reference to that
+ * shape rather than as a second `z.string().min(1).max(8000)`, so the two cannot drift
+ * when FR-EMJ-02's code-point counting replaces the character bound.
+ *
+ * ONE FIELD, AND THE ABSENCES ARE DECISIONS:
+ *
+ *   no `user`             the send body takes one because an application credential
+ *                         carries no user of its own. This route accepts only a user
+ *                         token (FR-013a), so the caller is already named — and naming
+ *                         somebody else is what `not_message_author` refuses.
+ *   no `metadata`         FR-001 is about what a message SAYS. Editing metadata is a
+ *                         separate capability nothing has asked for, and `strictObject`
+ *                         makes adding it a decision rather than an accident.
+ *   no `idempotency_key`  a retried edit sets the same text twice and appends a second
+ *                         history row. FR-021 already says the platform does not compare
+ *                         texts, so there is nothing here for a key to deduplicate that
+ *                         the customer has not asked to happen. */
+export const editMessageBodySchema = z.strictObject({
+  text: sendMessageBodySchema.shape.text,
+});
+
+export type EditMessageBody = z.infer<typeof editMessageBodySchema>;
+
 // The history query (chapter 2.4, FR-MSG-09): an opaque cursor, a
 // direction, and a page size capped at 200. `limit` is REFUSED above the
 // cap rather than clamped — `.max(200)` on a zod number is a validation,
 // so 500 is a 400 naming the field. A clamp was described here for two
 // parts and never implemented; chapter 4.8 measured it and fixed the words.
 export const historyQuerySchema = z.strictObject({
services/api/src/messages/messages.service.ts
@@ -6,20 +6,24 @@ import {
 } from "@nestjs/common";
 
 import {
   ChannelArchivedError,
   UserBannedError,
   ChannelNotFoundError,
+  type EditedMessageRow,
+  MessageDeletedError,
+  MessageNotFoundError,
+  NotMessageAuthorError,
   Repository,
   type MessageRow,
   type MessageWithSender,
   SenderNotPermittedError,
 } from "../db/repository";
 import { protocolError } from "../protocol-error";
 import { decodeCursor, encodeCursor } from "./cursor";
-import type { HistoryQuery, SendMessageBody } from "./messages.schema";
+import type { EditMessageBody, 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.
 //
 // AMENDED in chapter 2.6: `duplicate` used to be erased here, which made
@@ -134,12 +138,143 @@ export class MessagesService {
         throw new NotFoundException("channel not found");
       }
       throw error;
     }
   }
 
+  /** Change what a message says (FR-001, FR-013, FR-014).
+   *
+   * THE VISIBILITY CHECK FIRST, AND IT IS THE SAME ONE `history` MAKES. `channelVisibleTo`
+   * is the predicate the channel-control chapter built after finding `channelExists` answering only half
+   * the question — an absent channel gave 404 while a private channel a non-member read
+   * gave 200 and an empty page. An edit route reaching for `channelExists` would rebuild
+   * that leak in a new verb.
+   *
+   * WHY IT IS HERE AND NOT ONLY IN THE REPOSITORY. `editMessage`'s join carries the
+   * environment, so a foreign channel already refuses. What it cannot do alone is refuse a
+   * PRIVATE channel of this tenant that the caller is not a member of: the message is
+   * there, the tenant owns it, and the join finds it. Two checks, and the second is the
+   * one FR-014 needs.
+   *
+   * FOUR REFUSALS, THREE STATUSES, and the mapping is where they stop being
+   * distinguishable in the ways FR-014 forbids:
+   *
+   *   channel invisible          404, "channel not found"    — the same body as a channel
+   *                                                            that was never there
+   *   message not in the channel 404, "message not found"    — the channel IS visible, so
+   *                                                            this reveals nothing
+   *   not the author, or none    403 `not_message_author`
+   *   the message is a tombstone 403 `message_deleted` */
+  async edit(
+    channelId: string,
+    messageId: string,
+    { text }: EditMessageBody,
+    /** REQUIRED, unlike `send`'s and `history`'s. `@Accepts("user")` on the route means
+     * the only credential class that reaches this method carries a subject, so there is
+     * no "the tenant is editing" case to have a convention for (FR-013a). */
+    userId: string,
+  ): Promise<EditedMessageRow> {
+    if (!(await this.repo.channelVisibleTo(channelId, userId))) {
+      throw new NotFoundException("channel not found");
+    }
+    try {
+      return await this.repo.editMessage(channelId, messageId, { text, userId });
+    } catch (error) {
+      if (error instanceof MessageNotFoundError) {
+        // A CONSTANT MESSAGE, like the channel's. The id is already in the caller's own
+        // path, so echoing it back reveals nothing — but a body that varies is a body a
+        // future comparison has to normalise, and `withoutRequestId` is the only
+        // normalisation the isolation oracle does.
+        throw new NotFoundException("message not found");
+      }
+      if (error instanceof NotMessageAuthorError) {
+        // `not_message_author`, AND NOT `forbidden` (FR-022). `ProtocolErrorFilter` maps
+        // a bare 403 to `forbidden`, whose published remedy is *"a change of credential
+        // or of permission"* — advice nobody can act on, because no credential grants
+        // authorship and no permission change makes a message yours. `codes.ts` argues
+        // it at the entry; this is the thrower that names it.
+        throw protocolError(
+          "not_message_author",
+          "only the author of a message may change what it says",
+          HttpStatus.FORBIDDEN,
+        );
+      }
+      if (error instanceof MessageDeletedError) {
+        // ITS OWN CODE, on `channel_archived`'s precedent: a client that cannot tell
+        // "you did not write this" from "this no longer says anything" retries the wrong
+        // one for ever. Only the author reaches this refusal — a stranger is refused for
+        // authorship first, so this answer never tells anybody a message exists that
+        // they could not already see.
+        throw protocolError(
+          "message_deleted",
+          "this message has been deleted; its text cannot be changed",
+          HttpStatus.FORBIDDEN,
+        );
+      }
+      throw error;
+    }
+  }
+
+  /** Turn a message into a tombstone (FR-006, FR-009, FR-012, FR-013).
+   *
+   * `userId` OPTIONAL, UNLIKE `edit`'s, and the asymmetry is FR-013a. FR-MOD-02 grants a
+   * tenant key deletion of any message and is silent on editing; silence is read as
+   * absence of permission. So this method's caller may be either credential class — the
+   * class-level `@Accepts("application", "user")` this route correctly inherits — and
+   * `undefined` means the tenant, the convention every other read and write here uses.
+   *
+   * THE RETURN CARRIES `alreadyDeleted` RATHER THAN A STATUS. FR-009 makes the second
+   * deletion answer 204 like the first, so the controller cannot tell from the status
+   * whether to publish — and publishing twice puts a second `message.deleted` on every
+   * connected member's socket for one deletion.
+   *
+   * NO `MessageDeletedError` ARM, because a tombstone is not an error here. It is the
+   * requested state, which is the whole of FR-009's idempotence — the edit route
+   * refuses one and this route agrees with one. */
+  async remove(
+    channelId: string,
+    messageId: string,
+    { userId, userExternalId }: { userId?: string; userExternalId?: string },
+  ): Promise<{
+    /** `user` narrowed to a string by the repository — FR-018 refuses a row with no
+     * author before `deleteMessage` can return, so a tombstone it produced has one. */
+    deleted: MessageWithSender & { user: string; deleted_at: string };
+    alreadyDeleted: boolean;
+  }> {
+    if (!(await this.repo.channelVisibleTo(channelId, userId))) {
+      throw new NotFoundException("channel not found");
+    }
+    try {
+      return await this.repo.deleteMessage(channelId, messageId, {
+        ...(userId !== undefined && { userId }),
+        ...(userExternalId !== undefined && { userExternalId }),
+      });
+    } catch (error) {
+      if (error instanceof MessageNotFoundError) {
+        throw new NotFoundException("message not found");
+      }
+      if (error instanceof NotMessageAuthorError) {
+        // THE SAME CODE THE EDIT USES, and FR-013 is one requirement covering both
+        // verbs: *"An end user MUST NOT be permitted to edit or delete a message they
+        // did not author."* A second code for the deletion would be a distinction with
+        // no different action behind it — `codes.ts:10`'s test, applied by not adding
+        // one.
+        //
+        // A TENANT KEY REACHES THIS ONLY THROUGH FR-018, because `deleteMessage` skips
+        // the authorship comparison when there is no user. The message is written for
+        // the end-user case and is true of both: nobody wrote a senderless row.
+        throw protocolError(
+          "not_message_author",
+          "only the author of a message may delete it",
+          HttpStatus.FORBIDDEN,
+        );
+      }
+      throw error;
+    }
+  }
+
   /** A page of history (chapter 2.4). The cursor is opaque coming in and
    * 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,
services/api/src/outbox/event.ts
@@ -19,12 +19,32 @@ export interface MessageCreatedData {
   seq: number;
   user: string | null;
   text: string | null;
   created_at: string;
 }
 
+/** A DELETION as a consumer receives it (FR-019, FR-020).
+ *
+ * NO `text`, AND NO `text: null` EITHER. The frame `packages/protocol/src/frames.ts`
+ * publishes made the same choice for the same reason: a deletion whose payload has a
+ * text field is a payload that can carry the words somebody asked to have removed, and
+ * `null` is a value somebody can forget to set. FR-020 says the event must not carry
+ * it, and the way to guarantee that is for the type not to have the key.
+ *
+ * `user` IS THE AUTHOR, NOT THE DELETER, and nullable for the reason `MessageCreatedData`
+ * gives: a message can have no sender. Who removed it lives on the row, as
+ * `metadata.deleted_by` (FR-006a), and is deliberately not on this envelope — a
+ * customer's webhook subscription is about what happened to the message. */
+export interface MessageDeletedData {
+  id: string;
+  channel_id: string;
+  seq: number;
+  user: string | null;
+  deleted_at: string;
+}
+
 /** A membership change as a CONSUMER receives it (FR-WHK-02).
  *
  * `user` IS THE EXTERNAL ID and the type says so, because the repository methods
  * that build this event hold only `users.id`. `MessageCreatedData` above fixes the
  * boundary — "Consumers are customers: they get external ids and the field names the
  * REST surface uses. `user_id` does not cross this boundary" — and the message path
@@ -51,12 +71,24 @@ export interface MembershipChangedData {
  * unassertable, and the presence chapter's `codes.test.ts` earned its keep precisely by
  * asserting an exact set and an exact count — which is what makes a new member a
  * decision rather than an accident. `as const` plus `(typeof …)[number]` costs one
  * line and buys that. */
 export const OUTBOX_EVENT_TYPES = [
   "message.created",
+  // The revisions chapter's TWO, spelled as FR-WHK-02 spells them because a customer's
+  // subscription filters on these exact strings.
+  //
+  // BROUGHT FORWARD FROM PHASE 9, and the reason is ADR-06 rather than convenience.
+  // `repository.deleteMessage` writes its event INSIDE the transaction that writes the
+  // tombstone — publishing after the commit leaves a window where the row changed and
+  // the event never existed — so the envelope cannot arrive three phases after the
+  // transaction that has to build it. FR-009's "no second event" is also unassertable
+  // without it: two 204s prove nothing, and the outbox row is what carries the
+  // requirement. `baseline.txt` records the ordering defect.
+  "message.updated",
+  "message.deleted",
   "channel.member_added",
   "channel.member_removed",
 ] as const;
 
 export type OutboxEventType = (typeof OUTBOX_EVENT_TYPES)[number];
 
@@ -66,13 +98,13 @@ export interface OutboxEvent {
   /** FR-WHK-02's name for this event, spelled as that requirement spells it. */
   type: OutboxEventType;
   environment_id: string;
   /** When the state change happened — the message's own timestamp, not the
    * moment this object was constructed. */
   occurred_at: string;
-  data: MessageCreatedData | MembershipChangedData;
+  data: MessageCreatedData | MessageDeletedData | MembershipChangedData;
 }
 
 export interface PendingEvent {
   subject: string;
   payload: OutboxEvent;
 }
@@ -105,12 +137,82 @@ export function messageCreatedEvent({
       occurred_at: message.created_at,
       data: message,
     },
   };
 }
 
+/** An edit, built inside the transaction that wrote it (FR-019).
+ *
+ * THE SAME `MessageCreatedData` PAYLOAD, which is FR-008a as code: *"The message payload
+ * used by creation and edit events MUST be left unchanged."* An edited message is a
+ * message — same fields, `text` now saying something else — and a consumer that already
+ * handles `message.created` needs no new shape to handle this, only a new type to switch
+ * on.
+ *
+ * `occurred_at` IS THE EDIT'S INSTANT, not the message's `created_at`. That is the one
+ * place this diverges from the creation event, and it has to: an event whose
+ * `occurred_at` predates the previous event about the same message is unorderable by a
+ * consumer. It arrives from the caller like every other clock reading in this file. */
+export function messageUpdatedEvent({
+  eventId,
+  environmentId,
+  occurredAt,
+  message,
+}: {
+  eventId: string;
+  environmentId: string;
+  occurredAt: string;
+  message: MessageCreatedData;
+}): PendingEvent {
+  if (!eventId) throw new Error("an event id is required");
+  if (!environmentId) throw new Error("an environment id is required");
+
+  return {
+    subject: subjectFor("message.updated", environmentId),
+    payload: {
+      id: eventId,
+      type: "message.updated",
+      environment_id: environmentId,
+      occurred_at: occurredAt,
+      data: message,
+    },
+  };
+}
+
+/** A deletion, built inside the transaction that wrote the tombstone
+ * (FR-019, FR-020).
+ *
+ * ITS OWN PAYLOAD TYPE, and `MessageDeletedData`'s docstring argues why the text is
+ * absent rather than null. This function cannot put a text on the wire because it has
+ * nowhere to put one, which is a stronger guarantee than a reviewer remembering. */
+export function messageDeletedEvent({
+  eventId,
+  environmentId,
+  occurredAt,
+  message,
+}: {
+  eventId: string;
+  environmentId: string;
+  occurredAt: string;
+  message: MessageDeletedData;
+}): PendingEvent {
+  if (!eventId) throw new Error("an event id is required");
+  if (!environmentId) throw new Error("an environment id is required");
+
+  return {
+    subject: subjectFor("message.deleted", environmentId),
+    payload: {
+      id: eventId,
+      type: "message.deleted",
+      environment_id: environmentId,
+      occurred_at: occurredAt,
+      data: message,
+    },
+  };
+}
+
 /** A membership change, built inside the transaction that wrote the row.
  *
  * `occurred_at` ARRIVES FROM THE CALLER, like `messageCreatedEvent`'s, and for the
  * same reason: a republished event must be byte-identical to its first attempt, so
  * nothing here reads the clock.
  *
@@ -199,12 +301,47 @@ export const outboxEventSchema = z.discriminatedUnion("type", [
       seq: z.number().int().positive(),
       user: z.string().nullable(),
       text: z.string().nullable(),
       created_at: z.iso.datetime(),
     }),
   }),
+  // The union is exhaustive over `OUTBOX_EVENT_TYPES`, and this file's own
+  // comment above says why that matters: `consumer/runtime.ts:163` answers a failed
+  // parse with `message.term()`, which stops redelivery for good. A type added to the
+  // array with no branch here is a row DESTROYED at the consumer, and the lane cannot
+  // see it — it runs `RELAY_EVENT_CONSUMER=off`.
+  z.strictObject({
+    ...envelope,
+    type: z.literal("message.updated"),
+    // THE SAME SHAPE AS `message.created` (FR-008a). Restated rather than shared,
+    // because a `strictObject` spread from one variable would let a change to the
+    // creation's shape silently change the edit's — and FR-008a is the requirement
+    // that they NOT drift, which is only meaningful if a change to one is visible.
+    data: z.strictObject({
+      id: z.string().min(1),
+      channel_id: z.string().min(1),
+      seq: z.number().int().positive(),
+      user: z.string().nullable(),
+      text: z.string().nullable(),
+      created_at: z.iso.datetime(),
+    }),
+  }),
+  z.strictObject({
+    ...envelope,
+    type: z.literal("message.deleted"),
+    // NO `text` KEY AT ALL, and `strictObject` makes that enforceable in both
+    // directions: a producer that adds one fails here, which is FR-020 with a test
+    // rather than a promise.
+    data: z.strictObject({
+      id: z.string().min(1),
+      channel_id: z.string().min(1),
+      seq: z.number().int().positive(),
+      user: z.string().nullable(),
+      deleted_at: z.iso.datetime(),
+    }),
+  }),
   z.strictObject({
     ...envelope,
     type: z.literal("channel.member_added"),
     data: z.strictObject({
       channel_id: z.string().min(1),
       // NOT nullable, unlike the message's `user`. A message can have no sender
services/api/src/fanout/publisher.ts
@@ -1,7 +1,12 @@
-import { subjectForChannel, type Message } from "@relay/protocol";
+import {
+  subjectForChannel,
+  subjectForChannelRevision,
+  type Message,
+  type RevisionFabric,
+} from "@relay/protocol";
 import type { Logger } from "@relay/service-kit";
 // A NAMED import: ioredis is CommonJS and this service is ESM.
 import { Redis } from "ioredis";
 
 /** The api's half of the live fan-out (FR-004).
  *
@@ -43,12 +48,17 @@ export const MESSAGE_PUBLISHER = "MESSAGE_PUBLISHER";
 
 export interface MessagePublisher {
   /** Publish a committed message to its channel's subject. NEVER REJECTS —
    * delivery is allowed to fail, because the row is already durable and 2.7's
    * resume will find it (ADR-07, constitution IV). */
   publish(message: Message, context: PublishContext): Promise<void>;
+  /** ADR-24. Publish an edit or a deletion to its channel's revision
+   * subject. Same contract as `publish`: NEVER REJECTS, because the row is already
+   * committed and a client that misses the frame repairs by re-reading history — which is
+   * the bound FR-016a states rather than a gap. */
+  publishRevision(revision: RevisionFabric, context: PublishContext): Promise<void>;
   close(): Promise<void>;
 }
 
 /** What the failure has to be findable by. NFR-OBS-01 wants a request id and a
  * tenant id in every structured log, and NFR-OBS-06 wants five-minute
  * traceability from the former. The gateway's equivalent line carries neither,
@@ -112,12 +122,35 @@ export function createMessagePublisher({
   // this chapter records rather than inherits.
   redis.on("error", () => {});
 
   let downUntil = 0;
 
   return {
+    async publishRevision(revision, context) {
+      // THE SAME DOWN-WINDOW AS `publish` BELOW, and the same `downUntil` variable, because
+      // a Redis that is down is down for both subjects. Two windows would have the second
+      // subject retry on the request path while the first had already given up.
+      if (now() < downUntil) return;
+      try {
+        await redis.publish(
+          subjectForChannelRevision(revision.message.channel),
+          JSON.stringify(revision),
+        );
+        downUntil = 0;
+      } catch (error) {
+        downUntil = now() + DOWN_WINDOW_MS;
+        logger.log("error", "fanout.publish_failed", {
+          channel: revision.message.channel,
+          message_id: revision.message.id,
+          kind: revision.kind,
+          request_id: context.requestId,
+          environment_id: context.environmentId,
+          error: String(error),
+        });
+      }
+    },
     async publish(message, context) {
       // A known-down store is not retried on the request path. The first
       // failure opens a window; while it is open every call returns
       // immediately, which is the same outcome the caller already handles.
       if (now() < downUntil) return;
       try {
services/api/src/internal/backfill.controller.ts
@@ -77,15 +77,30 @@ export class BackfillController {
  * Two kinds of row cannot be a `message.created` payload, and both are
  * honest gaps rather than bugs to paper over:
  *
  *   - **No sender.** Every row written through the socket before 2.6's fix
  *     has `user_id` NULL. There is no truthful value to invent, and the
  *     wire contract requires one.
- *   - **No text.** A tombstone (FR-MSG-08) is not a creation. When deletes
- *     arrive in Part 4 they get `message.deleted`, and resume will carry
- *     that frame instead.
+ *   - **No text.** A tombstone (FR-MSG-08) is not a creation. Deletes arrive with
+ *     this chapter and they do get `message.deleted` — **and resume does NOT carry
+ *     that frame.** This sentence promised it would, and it was written before the
+ *     decision existed.
+ *
+ *     FR-016a settled it the other way: resume stays ordered by the channel
+ *     sequence alone, so a client receiving a message for the first time is not also
+ *     told that something it has never seen has changed. A tombstone above the cursor
+ *     is DROPPED, exactly as this function has always dropped it, and the client
+ *     learns the sequence is accounted for by re-reading history — where the row is
+ *     present with a null text.
+ *
+ *     **This is what Slack does.** `conversations.history` returns current state and
+ *     replays no event stream; Matrix takes the other shape, an append-only timeline
+ *     where a redaction is an event of its own, and IMAP's CONDSTOOR/QRESYNC puts a
+ *     `MODSEQ` beside the sequence so a client can ask "what changed since". This
+ *     platform is already the first shape, and FR-016b asks for that to be
+ *     documented as a property of a cursor rather than a limitation.
  *
  * The client is not left guessing: sequence numbers are contiguous per
  * channel, so a skipped row shows up as a gap the SDK detects and repairs
  * through 2.4's history endpoint (FR-RTM-03's safety net, one layer down).
  */
 function toFrame(row: MessageWithSender, channelId: string): Message[] {
packages/protocol/src/index.ts
@@ -9,7 +9,8 @@
 export * from "./frames.js";
 export * from "./codes.js";
 export * from "./internal.js";
 export * from "./fanout.js";
 export * from "./presence.js";
 export * from "./membership.js";
+export * from "./revision.js";
 export * from "./typing.js";
packages/protocol/src/codes.test.ts
@@ -157,6 +157,46 @@ describe("the refusal this chapter's cap adds", () => {
     // code. A message telling a capped client to retry sends it into a loop against a
     // wall, which is the failure `codes.ts` has now argued against five times.
     expect(ERROR_CODES.connection_limit_reached).toContain("close one");
     expect(ERROR_CODES.rate_limited).toContain("retry");
   });
 });
+
+describe("the refusal this chapter's edit path adds", () => {
+  // NAMED, NOT COUNTED, for the reason the blocks above give.
+  //
+  // FR-022. The registry's own rule is that a specific code beats the generic one
+  // where the remedy differs, and here it differs absolutely: `forbidden`'s published
+  // remedy is a change of credential or of permission, and **neither makes a message
+  // yours**. A client told `forbidden` asks an administrator for a role; a client told
+  // `not_message_author` stops asking.
+  it("names the non-author refusal separately from the generic 403", () => {
+    expect(ERROR_CODES).toHaveProperty("not_message_author");
+    expect(ERROR_CODES.not_message_author).not.toBe(ERROR_CODES.forbidden);
+    expect(ERROR_CODES.not_message_author).toMatch(/author/);
+  });
+
+  // A SECOND CODE IN ONE CHAPTER, which the plan did not expect — and the count that
+  // used to sit at the top of this file is exactly what would have caught it as an
+  // arithmetic edit rather than as a decision. Named instead: what makes
+  // `message_deleted` a code of its own is that a client acts on it, and the three it
+  // could have reused all misdirect that action.
+  //
+  //   not_message_author   false. The author of a tombstone IS its author, and the
+  //                        client goes looking for a permission problem.
+  //   not_found            a lie with a witness — FR-011 keeps a deleted message in
+  //                        history, so the client holds the thing it is told is absent.
+  //   forbidden            the same objection as above: no credential un-deletes.
+  //
+  // `codes.ts` argues the fourth candidate, a bare 409, which is about the filter
+  // rather than about the client.
+  it("names a deleted message's refusal apart from every refusal about the caller", () => {
+    expect(ERROR_CODES).toHaveProperty("message_deleted");
+    for (const other of ["not_message_author", "not_found", "forbidden"] as const) {
+      expect(ERROR_CODES.message_deleted).not.toBe(ERROR_CODES[other]);
+    }
+    // THE WORDING IS THE CONTRACT: the remedy is to stop offering an edit, and the
+    // sentence has to say the history is unharmed or a client re-reads it as a loss.
+    expect(ERROR_CODES.message_deleted).toMatch(/deleted/);
+    expect(ERROR_CODES.message_deleted).toMatch(/history/);
+  });
+});
packages/protocol/src/frames.test.ts
@@ -1,9 +1,9 @@
 import { describe, expect, it } from "vitest";
 
-import { frameSchema, parseFrame } from "./frames.js";
+import { frameSchema, messageDeletedSchema, messageSchema, parseFrame } from "./frames.js";
 
 // The contract must bite: for every frame, one specimen that parses and a
 // table of malformed near-misses that MUST reject. A schema that accepts
 // garbage is worse than no schema — it certifies garbage.
 
 const message = {
@@ -15,22 +15,43 @@ const message = {
   created_at: "2026-08-01T09:00:00.000Z",
 };
 
 const valid: Record<string, unknown> = {
   "connection.ack": {
     type: "connection.ack",
-    payload: { user: "u1", cursor: { c1: 42 }, resume_ok: true, truncated: [] },
+    payload: {
+      user: "u1",
+      cursor: { c1: 42 },
+      resume_ok: true,
+      truncated: [],
+      // A COUNT FOR THE CHANNEL THE CURSOR NAMES, and zero is a
+      // legal value: a channel nobody has revised reports 0 rather than being absent.
+      revisions: { c1: 0 },
+    },
   },
   "message.send": {
     type: "message.send",
     payload: { idem_key: "k-1", channel: "c1", text: "hi" },
   },
   "message.ack": { type: "message.ack", payload: { seq: 43 } },
   "message.created": { type: "message.created", payload: message },
   "message.updated": { type: "message.updated", payload: message },
-  "message.deleted": { type: "message.deleted", payload: message },
+  // T015. THE ONE PINNED PLACE A PAYLOAD CHANGE MOVES in this file, and
+  // the count and set assertions below do NOT move — the union's membership is unchanged,
+  // so `toHaveLength(11)` and the inbound-set test stay green. Analysis pass 3 predicted
+  // exactly this and pass 8's count confirmed it: one place, not three.
+  "message.deleted": {
+    type: "message.deleted",
+    payload: {
+      id: message.id,
+      channel: message.channel,
+      seq: message.seq,
+      user: message.user,
+      deleted_at: message.created_at,
+    },
+  },
   "membership.changed": {
     type: "membership.changed",
     payload: { channel: "c1", user: "u2", change: "added" },
   },
   "presence.changed": {
     type: "presence.changed",
@@ -135,12 +156,62 @@ describe("malformed frames reject", () => {
 });
 
 // T014. THE COUNT AND THE SET, both asserted, on `codes.test.ts`'s precedent:
 // an exact count makes a new member a decision rather than an accident, and an
 // exact set makes it the RIGHT decision. The count alone would pass if somebody
 // swapped one member for another.
+// T015 and T016. THE EXACT KEY SET, on `codes.test.ts`'s precedent: an
+// exact set is what makes a payload change a decision rather than an accident, and the
+// only field that must NOT be there is the one this frame exists because it cannot fill.
+describe("the deleted frame carries an identity and no text", () => {
+  const tombstone = {
+    id: message.id,
+    channel: message.channel,
+    seq: message.seq,
+    user: message.user,
+    deleted_at: message.created_at,
+  };
+
+  it("names exactly id, channel, seq, user and deleted_at", () => {
+    const parsed = messageDeletedSchema.parse({
+      type: "message.deleted",
+      payload: tombstone,
+    });
+    expect(Object.keys(parsed.payload).sort()).toEqual([
+      "channel",
+      "deleted_at",
+      "id",
+      "seq",
+      "user",
+    ]);
+  });
+
+  it("refuses a text field, because a deleted message has none", () => {
+    // `z.strictObject`, so an extra key is an error rather than a silent drop. An empty
+    // string would be worse than an error: a client could not tell a deleted message from
+    // one somebody sent blank.
+    const withText = messageDeletedSchema.safeParse({
+      type: "message.deleted",
+      payload: { ...tombstone, text: "" },
+    });
+    expect(withText.success).toBe(false);
+  });
+
+  // T016. QUICKSTART P2 AS AN ASSERTION, and the reason the payload changed at all.
+  it("takes the same row `messageSchema` refuses for having no text", () => {
+    const row = { ...message, text: null };
+    expect(messageSchema.safeParse(row).success).toBe(false);
+    expect(
+      messageDeletedSchema.safeParse({
+        type: "message.deleted",
+        payload: tombstone,
+      }).success,
+    ).toBe(true);
+  });
+});
+
 describe("the frame union's membership", () => {
   const members = frameSchema.options.map((o) => o.shape.type.value);
 
   it("has eleven members", () => {
     expect(members).toHaveLength(11);
   });
packages/protocol/src/revision.test.ts
import { describe, expect, it } from "vitest";
 
import {
  isChannelRevisionSubject,
  revisionFabricSchema,
  subjectForChannelRevision,
} from "./revision.js";
import { subjectForChannel } from "./fanout.js";
 
// T018d. THE SUBJECT STRING AND THE PAYLOAD'S EXACT KEYS, on
// `codes.test.ts`'s precedent: an exact set is what makes a change to either a decision
// rather than an accident.
//
// A SUBJECT IS A PUBLISHED NAME. Two instances agree on it by spelling, so a typo is a
// silent no-delivery rather than an error — which is why the four grammars before this one
// each pinned their string in a test.
 
const message = {
  id: "m1",
  channel: "c1",
  seq: 7,
  user: "tuan",
  text: "corrected",
  created_at: "2026-09-03T00:00:00.000Z",
};
 
const tombstone = {
  id: "m1",
  channel: "c1",
  seq: 7,
  user: "tuan",
  deleted_at: "2026-09-03T00:00:00.000Z",
};
 
describe("the revision subject (ADR-24)", () => {
  it("is `revision:{channelId}`", () => {
    expect(subjectForChannelRevision("c1")).toBe("revision:c1");
  });
 
  it("is not any of the four grammars that came before it", () => {
    // `chan:`, `member:`, `presence:`, `typing:`. A fifth that collided with one of them
    // would deliver message revisions to a subscriber expecting something else.
    const subject = subjectForChannelRevision("c1");
    for (const taken of ["chan:", "member:", "presence:", "typing:"]) {
      expect(subject.startsWith(taken)).toBe(false);
    }
  });
 
  it("recognises its own subjects and no others", () => {
    // The gateway's subscriber holds `chan:` and `revision:` on ONE client and routes on
    // the subject, so this predicate is what stands between an edit and being parsed as a
    // creation. The negative case is the one that matters: `subjectForChannel` is the
    // other subject on that same client.
    expect(isChannelRevisionSubject(subjectForChannelRevision("c1"))).toBe(true);
    expect(isChannelRevisionSubject(subjectForChannel("c1"))).toBe(false);
    // A channel id that merely CONTAINS the word is not a revision subject. Only the
    // prefix counts, and only with its colon.
    expect(isChannelRevisionSubject("chan:revision:c1")).toBe(false);
    expect(isChannelRevisionSubject("revisionc1")).toBe(false);
  });
});
 
describe("the revision fabric payload", () => {
  it("takes an edit as a whole message", () => {
    const parsed = revisionFabricSchema.parse({ kind: "updated", message });
    expect(parsed.kind).toBe("updated");
    expect(Object.keys(parsed.message).sort()).toEqual([
      "channel",
      "created_at",
      "id",
      "seq",
      "text",
      "user",
    ]);
  });
 
  it("takes a deletion as an identity with no text", () => {
    const parsed = revisionFabricSchema.parse({ kind: "deleted", message: tombstone });
    expect(parsed.kind).toBe("deleted");
    expect(Object.keys(parsed.message).sort()).toEqual([
      "channel",
      "deleted_at",
      "id",
      "seq",
      "user",
    ]);
  });
 
  it("refuses a deletion that carries a text", () => {
    // The whole reason this grammar exists. `strictObject` makes the extra key an error
    // rather than a silent drop, so a producer that reached for `messageSchema` fails here
    // instead of putting a lie on the fabric.
    expect(
      revisionFabricSchema.safeParse({
        kind: "deleted",
        message: { ...tombstone, text: "" },
      }).success,
    ).toBe(false);
  });
 
  it("refuses an edit with no text, and a kind it does not know", () => {
    expect(
      revisionFabricSchema.safeParse({ kind: "updated", message: tombstone }).success,
    ).toBe(false);
    expect(
      revisionFabricSchema.safeParse({ kind: "created", message }).success,
    ).toBe(false);
  });
});
services/api/src/fanout/publisher.test.ts
@@ -1,8 +1,13 @@
 import { createLogger, type Logger } from "@relay/service-kit";
-import { messageSchema, subjectForChannel } from "@relay/protocol";
+import {
+  messageSchema,
+  revisionFabricSchema,
+  subjectForChannel,
+  subjectForChannelRevision,
+} from "@relay/protocol";
 import { beforeEach, describe, expect, it, vi } from "vitest";
 
 import { createMessagePublisher } from "./publisher";
 
 // A fake at the ioredis seam. The publisher's contract is "never rejects", so a
 // test that only checks it resolved cannot tell a swallowed failure from a
@@ -36,12 +41,20 @@ const message = {
   user: "outside-bot",
   text: "hello",
   created_at: "2026-08-27T00:00:00.000Z",
 };
 const context = { requestId: "req-1", environmentId: "env-1" };
 
+const tombstone = {
+  id: "m1",
+  channel: "c1",
+  seq: 1,
+  user: "outside-bot",
+  deleted_at: "2026-09-03T00:00:00.000Z",
+};
+
 function sink(): { lines: Record<string, unknown>[]; logger: Logger } {
   const lines: Record<string, unknown>[] = [];
   const logger = createLogger("publisher-test", (line) =>
     lines.push(JSON.parse(line) as Record<string, unknown>),
   );
   return { lines, logger };
@@ -122,12 +135,67 @@ describe("the api's fan-out publisher", () => {
 
     clock += 2; // 5_001 ms after the failure — the window has closed
     await p.publish(message, context);
     expect(lines).toHaveLength(2);
   });
 
+  it("publishes a revision to the revision subject, not the channel's", async () => {
+    // T018h. THE SUBJECT IS THE ASSERTION. A `publishRevision` that reached
+    // for `subjectForChannel` would deliver an edit to a subscriber that parses arrivals
+    // as `Message` — and the `updated` arm IS a `Message`, so it would be accepted and
+    // shown to every member as a brand new message.
+    const { logger } = sink();
+    await createMessagePublisher({ logger }).publishRevision(
+      { kind: "updated", message },
+      context,
+    );
+    expect(publishes).toHaveLength(1);
+    expect(publishes[0]![0]).toBe(subjectForChannelRevision("c1"));
+    expect(publishes[0]![0]).not.toBe(subjectForChannel("c1"));
+  });
+
+  it("publishes revision payloads the delivery side will accept", async () => {
+    // The same test `publish` has above, and for the same reason: this side serialises and
+    // the gateway parses, and nothing in the type system connects the two. Both arms,
+    // because the deleted one is the one that cannot be a `Message`.
+    const { logger } = sink();
+    const p = createMessagePublisher({ logger });
+    await p.publishRevision({ kind: "updated", message }, context);
+    await p.publishRevision({ kind: "deleted", message: tombstone }, context);
+
+    const parsedEdit = revisionFabricSchema.safeParse(JSON.parse(publishes[0]![1]));
+    expect(parsedEdit.success).toBe(true);
+    const parsedDeletion = revisionFabricSchema.safeParse(JSON.parse(publishes[1]![1]));
+    expect(parsedDeletion.success).toBe(true);
+    expect(parsedDeletion.success && parsedDeletion.data.kind).toBe("deleted");
+  });
+
+  it("shares one down-window with `publish`, because one Redis is down for both", async () => {
+    // The falsifiable half of the down-window decision. Two windows — one per method —
+    // would let a failed `publish` be followed immediately by a `publishRevision` that
+    // pays the connect timeout on the request path, which is the cost the window exists to
+    // avoid. The assertion is that the client is NOT called: it resolves either way.
+    throwing = true;
+    const { lines, logger } = sink();
+    let clock = 1_000;
+    const p = createMessagePublisher({ logger, now: () => clock });
+
+    await p.publish(message, context);
+    expect(lines).toHaveLength(1); // `publish` opened the window
+
+    await p.publishRevision({ kind: "updated", message }, context);
+    expect(lines).toHaveLength(1); // the revision saw it and made no attempt
+
+    clock += 5_001; // the window has closed for both
+    await p.publishRevision({ kind: "deleted", message: tombstone }, context);
+    expect(lines).toHaveLength(2);
+    expect(lines[1]!["msg"]).toBe("fanout.publish_failed");
+    expect(lines[1]!["kind"]).toBe("deleted");
+    expect(lines[1]!["message_id"]).toBe("m1");
+  });
+
   it("survives an ioredis `error` event instead of dying on it", () => {
     // R10, and the reason this listener exists at all. Without one, ioredis
     // emits `error` on an EventEmitter with no listener and Node turns that
     // into an unhandled exception — the api would die for the thing it is built
     // to survive. `createFanout` in the gateway has no such listener.
     const { lines, logger } = sink();
services/api/src/db/repository.itest.ts
@@ -1,12 +1,21 @@
+import { randomUUID } from "node:crypto";
+
 import { afterAll, beforeAll, describe, expect, it } from "vitest";
 import { sql } from "drizzle-orm";
 
 import { createDb, createPool, DEFAULT_DATABASE_URL, type Db } from "./client";
 import { migrate } from "./migrate";
-import { createEnvironment, Repository, type Environment } from "./repository";
+import {
+  createEnvironment,
+  MessageDeletedError,
+  MessageNotFoundError,
+  NotMessageAuthorError,
+  Repository,
+  type Environment,
+} from "./repository";
 
 // The isolation suite: attack the repository with FOREIGN tenant ids and
 // prove the leak inexpressible (FR-TEN-05, NFR-SEC-09, constitution I).
 // Requires the compose Postgres — this file is *.itest.ts precisely so the
 // Docker-free unit lane never collects it.
 
@@ -406,22 +415,32 @@ describe("the listing's keyset survives a shared last_activity_at", () => {
     expect(tied.map((r) => r.id)).toEqual(expected);
   });
 });
 
 // ── THE TOMBSTONE, AND THE CLAMP (FR-016, FR-019) ─────────────────────────────
 //
-// BOTH STATES ARE UNREACHABLE THROUGH THE API, for different reasons, and both are
-// constructed here because this suite may hold raw SQL.
+// THE TOMBSTONES BELOW ARE STILL PLANTED BY HAND, and that is now a choice rather than a
+// necessity. These tests were written in the channel-control chapter against a state the platform could
+// not produce: FR-MSG-08 was unimplemented, `messages.deleted_at` and a null `text` were
+// in the schema, `backfill.controller` passed `text` straight through so a null already
+// reached the wire, and **nothing in the platform wrote either**. The comment here said
+// so, in the present tense.
+//
+// **THE REVISIONS CHAPTER BUILT THE WRITER** (`repository.deleteMessage`, FR-006), so the
+// sentence stopped being true — the class of decay this repository keeps paying for, and
+// the reason `specs/041-chapter-3-23/check-prose.py` fails on the old wording. What that
+// chapter did NOT do is rewrite these tests to use the writer: a hand-planted fixture and
+// a written one are two different subjects, and the revisions chapter's own `deleteMessage` tests assert
+// that the two agree column for column. Changing these would have moved both halves of
+// the pair and left nothing comparing them.
+//
+// The clamp's fixture below is still genuinely unreachable through the API.
 //
-// FR-MSG-08 — "deleting a message shall replace its content with a tombstone retaining
-// sequence number, author, timestamps" — IS NOT IMPLEMENTED. `messages.deleted_at` and a
-// null `text` are in the schema, `backfill.controller` passes `text` straight through so
-// a null already reaches the wire, and NOTHING IN THE PLATFORM WRITES EITHER. The
-// tombstone is a live reader with no writer, which is the reverse of the dead columns
-// this feature is otherwise about. The listing's rule for it is implemented and tested
-// now so the day FR-MSG-08's chapter ships, the count and the preview already agree.
+// The listing's rule was implemented and tested here before its writer existed, which
+// the channel-control chapter said was so that "the day FR-MSG-08's chapter ships, the
+// count and the preview already agree." They did.
 describe("the listing's tombstone rule and its clamp", () => {
   it("reports a tombstoned last message with a null text, and still counts it", async () => {
     const user = await repoA.createUser("tomb-reader", "Tomb Reader");
     const channel = await repoA.createChannel("tombstoned", "public");
     await repoA.addMember(channel.id, user.id);
     await repoA.sendMessage(channel.id, { text: "kept", userId: user.id });
@@ -446,12 +465,78 @@ describe("the listing's tombstone rule and its clamp", () => {
     // place in the arithmetic. Counting rows instead would make a deleted message stop
     // being unread, at 10x the cost on the query a client runs to render its first
     // screen.
     expect(row.unread).toBe(2);
   });
 
+  // ── T009: THE READER, TESTED BEFORE THE WRITER EXISTS ───────────────────────
+  //
+  // FR-011 and SC-003. The history read must return a tombstone in its
+  // original position so a client sees no gap in the ordering.
+  //
+  // **THIS PASSES AGAINST UNCHANGED CODE, AND THAT IS THE POINT.** `listMessages` has
+  // never had a predicate on `messages.text` — its three `.where` clauses are the
+  // channel-visibility predicate and the sequence bounds — and `messages.service`
+  // maps the rows through unmodified. So the repair path the revisions chapter's resume
+  // decision depends on already works, and nothing had ever said so.
+  //
+  // The channel-control chapter wrote the same test for the channel LISTING and said why: *"so the
+  // day FR-MSG-08's chapter ships, the count and the preview already agree."* History
+  // never got one. A test written after the writer proves the writer; this one proves
+  // the reader was already right.
+  it("returns a tombstone in its original position, with the run unbroken (FR-011, SC-003)", async () => {
+    const user = await repoA.createUser("hist-tomb", "History Tombstone");
+    const channel = await repoA.createChannel("hist-tombstoned", "public");
+    await repoA.addMember(channel.id, user.id);
+    const first = await repoA.sendMessage(channel.id, { text: "one", userId: user.id });
+    const middle = await repoA.sendMessage(channel.id, { text: "two", userId: user.id });
+    const last = await repoA.sendMessage(channel.id, { text: "three", userId: user.id });
+
+    // What FR-MSG-08's chapter will do when it exists — planted by hand because this
+    // suite may hold raw SQL and nothing in the platform writes either column yet.
+    await db.execute(
+      sql`UPDATE messages SET text = NULL, deleted_at = now() WHERE id = ${middle.id}`,
+    );
+
+    // BOTH DIRECTIONS, AND THE FALSIFICATION IS WHY. `listMessages` is a ternary over
+    // two entirely separate queries — one ordered `desc` for a backward page, one `asc`
+    // for a forward one — and the first version of this test called it with no cursor,
+    // which takes the backward branch alone. Adding `isNotNull(messages.text)` to the
+    // FORWARD branch then left it green. **A test that covers one of two query branches
+    // passes with half its subject applied**, which is the sender chapter's T047c in a
+    // different file.
+    const backward = await repoA.listMessages(channel.id, { userId: user.id, limit: 10 });
+    const forward = await repoA.listMessages(channel.id, {
+      userId: user.id,
+      limit: 10,
+      afterSeq: 0,
+    });
+
+    for (const [label, page] of [
+      ["backward", backward],
+      ["forward", forward],
+    ] as const) {
+      const seqs = page.map((m) => m.seq).sort((a, b) => a - b);
+
+      // THREE ROWS, NOT TWO. A read that filtered the tombstone would return two and
+      // leave a hole at `middle.seq` that no client could explain.
+      expect(seqs, label).toEqual([first.seq, middle.seq, last.seq]);
+
+      const tomb = page.find((m) => m.seq === middle.seq)!;
+      expect(tomb.text, label).toBeNull();
+      // The author survives, which is half of what FR-MSG-08 asks the tombstone to keep.
+      expect(tomb.user, label).not.toBeNull();
+
+      // AND THE RUN IS CONTIGUOUS, asserted rather than eyeballed: consecutive sequence
+      // numbers with no step, which is what "without gaps in ordering" means.
+      for (let i = 1; i < seqs.length; i += 1) {
+        expect(seqs[i]! - seqs[i - 1]!, label).toBe(1);
+      }
+    }
+  });
+
   it("reports null for a channel that has never had a message", async () => {
     const user = await repoA.createUser("empty-reader", "Empty Reader");
     const channel = await repoA.createChannel("never-used", "public");
     await repoA.addMember(channel.id, user.id);
     const { rows } = await repoA.listChannelsForUser(user.id, { limit: 10 });
     const row = rows.find((r) => r.external_id === "never-used")!;
@@ -579,6 +664,699 @@ describe("the repository's own refusals", () => {
     const { rows } = await repoA.listChannelsForUser(reader.id, { limit: 10 });
     const row = rows.find((r) => r.external_id === "arm-unattributed")!;
     expect(row.last_message?.text).toBe("from the tenant, not a user");
     expect(row.last_message?.user).toBeNull();
   });
 });
+
+
+// ══ EDITING A MESSAGE (US1) ═══════════════════════════════════
+describe("editMessage", () => {
+  it("keeps the sequence, the channel, the author and the creation time (FR-002)", async () => {
+    // A THING NOT DONE LEAVES NO TRACE TO ASSERT ON, so this asserts the VALUES rather
+    // than the absence of an assignment. `editMessage`'s `SET` list is the guarantee —
+    // `sequence`, `channelId`, `userId` and `createdAt` are not in it — and this is
+    // what would notice if one arrived.
+    const author = await repoA.createUser("t027-author", "Author");
+    const channel = await repoA.createChannel("t027", "public");
+    await repoA.addMember(channel.id, author.id);
+    const before = await repoA.sendMessage(channel.id, {
+      text: "frist",
+      userId: author.id,
+      userExternalId: "t027-author",
+    });
+
+    const after = await repoA.editMessage(channel.id, before.id, {
+      text: "first",
+      userId: author.id,
+    });
+
+    expect(after.text).toBe("first");
+    expect(after.seq).toBe(before.seq);
+    expect(after.channel_id).toBe(before.channel_id);
+    expect(after.created_at).toBe(before.created_at);
+    expect(after.prior_text).toBe("frist");
+    // AND FROM THE DATABASE, not only from the return value. A method that returned
+    // the right object while writing something else would pass everything above.
+    const [row] = (
+      await db.execute<{ sequence: string; user_id: string; created_at: Date }>(
+        sql`SELECT sequence, user_id, created_at FROM messages WHERE id = ${before.id}`,
+      )
+    ).rows;
+    expect(Number(row!.sequence)).toBe(before.seq);
+    expect(row!.user_id).toBe(author.id);
+    expect(new Date(row!.created_at).toISOString()).toBe(before.created_at);
+  });
+
+  it("records edited_at, and it was null before (FR-003)", async () => {
+    const author = await repoA.createUser("t027b-author", "Author");
+    const channel = await repoA.createChannel("t027b", "public");
+    await repoA.addMember(channel.id, author.id);
+    const sent = await repoA.sendMessage(channel.id, { text: "x", userId: author.id });
+
+    const [pre] = (
+      await db.execute<{ edited_at: Date | null }>(
+        sql`SELECT edited_at FROM messages WHERE id = ${sent.id}`,
+      )
+    ).rows;
+    expect(pre!.edited_at).toBeNull();
+
+    const edited = await repoA.editMessage(channel.id, sent.id, {
+      text: "y",
+      userId: author.id,
+    });
+    expect(Date.parse(edited.edited_at)).toBeGreaterThan(0);
+    // DISTINGUISHABLE FROM `created_at`, which is what FR-003 asks for. Two columns
+    // holding one instant would satisfy "records when it happened" and answer nothing.
+    expect(edited.edited_at).not.toBe(edited.created_at);
+  });
+
+  it("three edits leave three history rows, oldest first, none overwritten (FR-004)", async () => {
+    const author = await repoA.createUser("t028-author", "Author");
+    const channel = await repoA.createChannel("t028", "public");
+    await repoA.addMember(channel.id, author.id);
+    const sent = await repoA.sendMessage(channel.id, { text: "one", userId: author.id });
+
+    for (const text of ["two", "three", "four"]) {
+      await repoA.editMessage(channel.id, sent.id, { text, userId: author.id });
+    }
+
+    const edits = await repoA.listMessageEdits(channel.id, sent.id);
+    // THE SUPERSEDED TEXTS, NOT THE CURRENT ONES. Three edits from "one" leave
+    // "one", "two", "three" behind and the message says "four".
+    expect(edits.map((e) => e.prior_text)).toEqual(["one", "two", "three"]);
+    // OLDEST FIRST, asserted as monotonic timestamps rather than trusting the order
+    // the array arrived in — `orderBy` is the claim under test.
+    for (let i = 1; i < edits.length; i += 1) {
+      expect(Date.parse(edits[i]!.edited_at)).toBeGreaterThanOrEqual(
+        Date.parse(edits[i - 1]!.edited_at),
+      );
+    }
+    const [{ text }] = (
+      await db.execute<{ text: string }>(
+        sql`SELECT text FROM messages WHERE id = ${sent.id}`,
+      )
+    ).rows as [{ text: string }];
+    expect(text).toBe("four");
+  });
+
+  it("an edit does not move the channel in the activity ordering (FR-015)", async () => {
+    // Two channels, one edited afterwards. The listing orders by most recent activity
+    // and FR-014 decided what that means: a message. Correcting a typo is not a
+    // new message, so the order must not change.
+    const user = await repoA.createUser("t034-user", "User");
+    const older = await repoA.createChannel("t034-older", "public");
+    const newer = await repoA.createChannel("t034-newer", "public");
+    await repoA.addMember(older.id, user.id);
+    await repoA.addMember(newer.id, user.id);
+    const inOlder = await repoA.sendMessage(older.id, { text: "first", userId: user.id });
+    await repoA.sendMessage(newer.id, { text: "second", userId: user.id });
+
+    const listing = async () =>
+      (await repoA.listChannelsForUser(user.id, { limit: 50 })).rows.map((c) => c.id);
+    const orderBefore = await listing();
+    expect(orderBefore.indexOf(newer.id)).toBeLessThan(orderBefore.indexOf(older.id));
+
+    await repoA.editMessage(older.id, inOlder.id, { text: "corrected", userId: user.id });
+
+    const orderAfter = await listing();
+    expect(orderAfter).toEqual(orderBefore);
+  });
+
+  it("an edit on a row with no author is refused (FR-018)", async () => {
+    // PLANTED WITH RAW SQL, because no write path can produce one any more — the sender
+    // chapter made `userId` required — and 19,965 of them exist in the lane out of
+    // 125,076, written before chapter 2.6 recorded a sender.
+    const author = await repoA.createUser("t036-author", "Author");
+    const channel = await repoA.createChannel("t036", "public");
+    await repoA.addMember(channel.id, author.id);
+    const sent = await repoA.sendMessage(channel.id, { text: "orphan", userId: author.id });
+    await db.execute(sql`UPDATE messages SET user_id = NULL WHERE id = ${sent.id}`);
+
+    await expect(
+      repoA.editMessage(channel.id, sent.id, { text: "adopted", userId: author.id }),
+    ).rejects.toThrow(NotMessageAuthorError);
+    // NOBODY CAN EDIT IT, which is the requirement — not "the wrong person cannot".
+    // There is no caller for whom the authorship comparison passes.
+    const [{ text }] = (
+      await db.execute<{ text: string }>(
+        sql`SELECT text FROM messages WHERE id = ${sent.id}`,
+      )
+    ).rows as [{ text: string }];
+    expect(text).toBe("orphan");
+  });
+
+  it("refuses a message id that belongs to another channel of the same tenant", async () => {
+    const author = await repoA.createUser("t026-cross", "Author");
+    const here = await repoA.createChannel("t026-here", "public");
+    const there = await repoA.createChannel("t026-there", "public");
+    const sent = await repoA.sendMessage(there.id, { text: "over there", userId: author.id });
+    await expect(
+      repoA.editMessage(here.id, sent.id, { text: "moved", userId: author.id }),
+    ).rejects.toThrow(MessageNotFoundError);
+  });
+
+  it("refuses a message of another TENANT, through the same error", async () => {
+    // Constitution I. The repository scopes by construction, so this is a
+    // MessageNotFoundError and not a leak with a different name.
+    const author = await repoA.createUser("t026-mine", "Author");
+    const mine = await repoA.createChannel("t026-mine", "public");
+    const sent = await repoA.sendMessage(mine.id, { text: "mine", userId: author.id });
+    await expect(
+      repoB.editMessage(mine.id, sent.id, { text: "theirs", userId: author.id }),
+    ).rejects.toThrow(MessageNotFoundError);
+  });
+
+  it("refuses an edit on a tombstone (FR-010), and the guard is what stops a 500", async () => {
+    // THE IMPLEMENTATION SHIPS IN PHASE 5 THOUGH T044 OWNS THE ROUTE TEST, because
+    // `prior_text TEXT NOT NULL` makes the alternative a constraint violation: without
+    // this check the insert writes a null and the caller gets a 500 it cannot act on.
+    const author = await repoA.createUser("t026-tomb", "Author");
+    const channel = await repoA.createChannel("t026-tomb", "public");
+    const sent = await repoA.sendMessage(channel.id, { text: "gone", userId: author.id });
+    await db.execute(
+      sql`UPDATE messages SET text = NULL, deleted_at = now() WHERE id = ${sent.id}`,
+    );
+    await expect(
+      repoA.editMessage(channel.id, sent.id, { text: "back", userId: author.id }),
+    ).rejects.toThrow(MessageDeletedError);
+    // AND NO HISTORY ROW WAS WRITTEN. A refusal that had already inserted would leave
+    // the table holding an entry for an edit that never happened.
+    expect(await repoA.listMessageEdits(channel.id, sent.id)).toEqual([]);
+  });
+
+  it("the history survives its channel being archived and its author deleted", async () => {
+    const author = await repoA.createUser("t036b-author", "Author");
+    const channel = await repoA.createChannel("t036b", "public");
+    await repoA.addMember(channel.id, author.id);
+    const sent = await repoA.sendMessage(channel.id, { text: "before", userId: author.id });
+    await repoA.editMessage(channel.id, sent.id, { text: "after", userId: author.id });
+
+    await repoA.archiveChannel(channel.id);
+    await repoA.deleteUser(author.id);
+
+    // `message_edits` references the MESSAGE, and both of those operations keep their
+    // rows — the archive sets a timestamp (FR-020) and a user deletion is a
+    // tombstone too (FR-USR-05). A cascade on either would take the history with it.
+    const edits = await repoA.listMessageEdits(channel.id, sent.id);
+    expect(edits.map((e) => e.prior_text)).toEqual(["before"]);
+  });
+
+  it("lists no edits for a message never edited, for one that does not exist, and for another tenant's", async () => {
+    // TWO FACTS, ONE VALUE, which is why the route asks `messageExistsIn` separately.
+    const author = await repoA.createUser("t033f-author", "Author");
+    const channel = await repoA.createChannel("t033f", "public");
+    const sent = await repoA.sendMessage(channel.id, { text: "untouched", userId: author.id });
+    expect(await repoA.listMessageEdits(channel.id, sent.id)).toEqual([]);
+    expect(await repoA.listMessageEdits(channel.id, randomUUID())).toEqual([]);
+    expect(await repoA.messageExistsIn(channel.id, sent.id)).toBe(true);
+    expect(await repoA.messageExistsIn(channel.id, randomUUID())).toBe(false);
+    // AND THE TENANT SCOPE IS ON BOTH READS.
+    expect(await repoB.messageExistsIn(channel.id, sent.id)).toBe(false);
+    expect(await repoB.listMessageEdits(channel.id, sent.id)).toEqual([]);
+  });
+});
+
+
+// ══ DELETING A MESSAGE (US2) ══════════════════════════════════
+describe("deleteMessage", () => {
+  /** The columns as the database holds them, read raw. Every assertion below is about
+   * what COMMITTED rather than what the method returned — a method that returned the
+   * right object and wrote something else would pass a return-value test. */
+  const rowOf = async (id: string) => {
+    const res = (await db.execute<{
+      text: string | null;
+      attachments: unknown;
+      deleted_at: Date | null;
+      sequence: string;
+      user_id: string | null;
+      created_at: Date;
+      metadata: Record<string, unknown>;
+    }>(
+      sql`SELECT text, attachments, deleted_at, sequence, user_id, created_at, metadata
+            FROM messages WHERE id = ${id}`,
+    )).rows;
+    return res[0]!;
+  };
+
+  it("keeps the sequence, author and created_at; drops text and attachments (FR-006)", async () => {
+    // THE COLUMNS ARE `docs/05-sad.md:342`'s, and this is the first tombstone the
+    // PLATFORM writes. the channel-control chapter's suite plants one by hand a few describes above,
+    // and the two agree column for column — which is what makes that chapter's reader
+    // tests evidence about this chapter's writer.
+    const author = await repoA.createUser("t040-author", "Author");
+    const channel = await repoA.createChannel("t040", "public");
+    await repoA.addMember(channel.id, author.id);
+    const sent = await repoA.sendMessage(channel.id, {
+      text: "regrettable",
+      userId: author.id,
+      userExternalId: "t040-author",
+    });
+    const before = await rowOf(sent.id);
+
+    const { deleted, alreadyDeleted } = await repoA.deleteMessage(channel.id, sent.id, {
+      userId: author.id,
+      userExternalId: "t040-author",
+    });
+    expect(alreadyDeleted).toBe(false);
+    expect(deleted.text).toBeNull();
+
+    const after = await rowOf(sent.id);
+    expect(after.text).toBeNull();
+    expect(after.attachments).toBeNull();
+    expect(after.deleted_at).not.toBeNull();
+    // UNTOUCHED, and asserted as values rather than as the absence of an assignment.
+    expect(after.sequence).toBe(before.sequence);
+    expect(after.user_id).toBe(author.id);
+    expect(new Date(after.created_at).toISOString()).toBe(
+      new Date(before.created_at).toISOString(),
+    );
+  });
+
+  it("records WHO deleted it, in two shapes, without erasing a key already in metadata (FR-006a)", async () => {
+    // **THIS CHAPTER IS `messages.metadata`'S FIRST WRITER ANYWHERE.** Every row in the
+    // platform carries the `'{}'` default today, which is why the merge below matters:
+    // a later chapter's key must survive a deletion.
+    const author = await repoA.createUser("t040b-author", "Author");
+    const channel = await repoA.createChannel("t040b", "public");
+    await repoA.addMember(channel.id, author.id);
+
+    const byAuthor = await repoA.sendMessage(channel.id, {
+      text: "mine to remove",
+      userId: author.id,
+      userExternalId: "t040b-author",
+      metadata: { source: "a key a later chapter writes" },
+    });
+    await repoA.deleteMessage(channel.id, byAuthor.id, {
+      userId: author.id,
+      userExternalId: "t040b-author",
+    });
+    const asUser = await rowOf(byAuthor.id);
+    expect(asUser.metadata["deleted_by"]).toEqual({
+      kind: "user",
+      user: "t040b-author",
+    });
+    // MERGED, NOT REPLACED. The pre-existing key is still there.
+    expect(asUser.metadata["source"]).toBe("a key a later chapter writes");
+
+    // A TENANT KEY: the kind is recorded and there is no user, because an application
+    // principal has no user of its own. WHICH credential it presented is an audit log's
+    // question — the revisions chapter's `gaps.md` item 2 draws that line.
+    const byKey = await repoA.sendMessage(channel.id, {
+      text: "moderated away",
+      userId: author.id,
+      userExternalId: "t040b-author",
+    });
+    await repoA.deleteMessage(channel.id, byKey.id, {});
+    expect((await rowOf(byKey.id)).metadata["deleted_by"]).toEqual({
+      kind: "application",
+    });
+  });
+
+  it("a second deletion changes nothing and writes no second event (FR-009)", async () => {
+    const author = await repoA.createUser("t042-author", "Author");
+    const channel = await repoA.createChannel("t042", "public");
+    await repoA.addMember(channel.id, author.id);
+    const sent = await repoA.sendMessage(channel.id, {
+      text: "twice",
+      userId: author.id,
+      userExternalId: "t042-author",
+    });
+
+    const first = await repoA.deleteMessage(channel.id, sent.id, {
+      userId: author.id,
+      userExternalId: "t042-author",
+    });
+    const afterFirst = await rowOf(sent.id);
+
+    const second = await repoA.deleteMessage(channel.id, sent.id, {
+      userId: author.id,
+      userExternalId: "t042-author",
+    });
+    const afterSecond = await rowOf(sent.id);
+
+    expect(first.alreadyDeleted).toBe(false);
+    expect(second.alreadyDeleted).toBe(true);
+    // THE TIMESTAMP IS THE COLUMN THAT WOULD MOVE, and a client that had already read
+    // the tombstone would see it change for no reason.
+    expect(afterSecond.deleted_at).toEqual(afterFirst.deleted_at);
+    expect(second.deleted.deleted_at).toBe(first.deleted.deleted_at);
+
+    // ONE EVENT. Two 204s prove nothing; this is the assertion that carries FR-009,
+    // because a second row here fires every subscribed webhook a second time.
+    const events = (await db.execute<{ n: number }>(
+      sql`SELECT count(*)::int AS n FROM outbox
+            WHERE payload->>'type' = 'message.deleted'
+              AND payload->'data'->>'id' = ${sent.id}`,
+    )).rows;
+    expect(events[0]!.n).toBe(1);
+  });
+
+  it("a deletion of a row with no author is refused (FR-018)", async () => {
+    // FR-018 says "an edit OR deletion" and the first draft of the task list tested only
+    // the edit. **The deletion is the half a tenant API key can reach** — FR-012 lets a
+    // key delete anybody's message — so it is the more exposed one, and it is checked
+    // before the tenant shortcut rather than inside the user branch.
+    const author = await repoA.createUser("t042a-author", "Author");
+    const channel = await repoA.createChannel("t042a", "public");
+    const sent = await repoA.sendMessage(channel.id, { text: "orphan", userId: author.id });
+    await db.execute(sql`UPDATE messages SET user_id = NULL WHERE id = ${sent.id}`);
+
+    // Neither principal can delete it: not the user…
+    await expect(
+      repoA.deleteMessage(channel.id, sent.id, {
+        userId: author.id,
+        userExternalId: "t042a-author",
+      }),
+    ).rejects.toThrow(NotMessageAuthorError);
+    // …and not the tenant key, which is the half FR-012 would otherwise wave through.
+    await expect(repoA.deleteMessage(channel.id, sent.id, {})).rejects.toThrow(
+      NotMessageAuthorError,
+    );
+    expect((await rowOf(sent.id)).text).toBe("orphan");
+  });
+
+  it("a deleted message still counts as one unread", async () => {
+    // The channel-control chapter decided this against a planted tombstone and stated the
+    // approximation: unread is `last_sequence - read_position`, so a tombstone keeps its
+    // sequence and therefore its place in the arithmetic. Counting rows instead would
+    // make a deleted message stop being unread, at 10x the cost on the query a client
+    // runs to render its first screen. **Same assertion, real writer.**
+    const user = await repoA.createUser("t048-user", "User");
+    const channel = await repoA.createChannel("t048", "public");
+    await repoA.addMember(channel.id, user.id);
+    await repoA.sendMessage(channel.id, { text: "one", userId: user.id });
+    const second = await repoA.sendMessage(channel.id, { text: "two", userId: user.id });
+    await repoA.deleteMessage(channel.id, second.id, { userId: user.id });
+
+    const { rows } = await repoA.listChannelsForUser(user.id, { limit: 50 });
+    const row = rows.find((c) => c.id === channel.id)!;
+    expect(row.unread).toBe(2);
+  });
+
+  it("deleting the NEWEST message leaves the preview at that sequence with a null text", async () => {
+    // Not "the message before it". The listing's preview is the channel's last message
+    // and a tombstone is still the last message — reporting the previous one would make
+    // a deletion look like the conversation had rewound.
+    const user = await repoA.createUser("t049-user", "User");
+    const channel = await repoA.createChannel("t049", "public");
+    await repoA.addMember(channel.id, user.id);
+    await repoA.sendMessage(channel.id, { text: "older", userId: user.id });
+    const newest = await repoA.sendMessage(channel.id, { text: "newest", userId: user.id });
+    await repoA.deleteMessage(channel.id, newest.id, { userId: user.id });
+
+    const { rows } = await repoA.listChannelsForUser(user.id, { limit: 50 });
+    const row = rows.find((c) => c.id === channel.id)!;
+    expect(row.last_message?.sequence).toBe(newest.seq);
+    expect(row.last_message?.text).toBeNull();
+    expect(row.last_message?.user).not.toBeNull();
+  });
+
+  it("history returns the tombstone in its original position, with a real writer behind it", async () => {
+    // The twin of T009's test a few describes above, which proved the READER against a
+    // hand-planted tombstone. This proves the reader and the WRITER agree — the thing
+    // that would break is a writer whose columns differ from what that test planted.
+    const user = await repoA.createUser("t047-user", "User");
+    const channel = await repoA.createChannel("t047", "public");
+    await repoA.addMember(channel.id, user.id);
+    const first = await repoA.sendMessage(channel.id, { text: "one", userId: user.id });
+    const middle = await repoA.sendMessage(channel.id, { text: "two", userId: user.id });
+    const last = await repoA.sendMessage(channel.id, { text: "three", userId: user.id });
+    await repoA.deleteMessage(channel.id, middle.id, { userId: user.id });
+
+    for (const [label, page] of [
+      ["backward", await repoA.listMessages(channel.id, { userId: user.id, limit: 10 })],
+      [
+        "forward",
+        await repoA.listMessages(channel.id, { userId: user.id, limit: 10, afterSeq: 0 }),
+      ],
+    ] as const) {
+      const seqs = page.map((m) => m.seq).sort((a, b) => a - b);
+      expect(seqs, label).toEqual([first.seq, middle.seq, last.seq]);
+      const tomb = page.find((m) => m.seq === middle.seq)!;
+      expect(tomb.text, label).toBeNull();
+      expect(tomb.user, label).not.toBeNull();
+    }
+  });
+
+  it("refuses a message of another TENANT and one from another channel", async () => {
+    const author = await repoA.createUser("t039-author", "Author");
+    const here = await repoA.createChannel("t039-here", "public");
+    const there = await repoA.createChannel("t039-there", "public");
+    const sent = await repoA.sendMessage(there.id, { text: "over there", userId: author.id });
+    await expect(
+      repoA.deleteMessage(here.id, sent.id, { userId: author.id }),
+    ).rejects.toThrow(MessageNotFoundError);
+    await expect(
+      repoB.deleteMessage(there.id, sent.id, { userId: author.id }),
+    ).rejects.toThrow(MessageNotFoundError);
+  });
+});
+
+// A CONCURRENT EDIT AND DELETION OF ONE MESSAGE (feature 043, FR-007).
+//
+// `gaps.md` 3.23-3 has carried this since this chapter built both writes. Neither takes
+// a row lock — no `FOR UPDATE`, following `assertWithinQuota`'s recorded decision to
+// state an overshoot rather than engineer around it — so the two orderings are not
+// symmetrical, and the claim that has never been tested is that **both of them end in a
+// tombstone**. Not the outcome: the claim.
+//
+// DO NOT START FROM `Promise.all` ON ONE CLIENT. The connection-cap chapter spent a phase
+// that two operations issued on one connection serialise at the socket, so a test built
+// that way proves the code cannot race by never letting it. The third case below uses
+// TWO POOLS, which is what that chapter found it needed.
+describe("the channel's revision counter (feature 044, FR-002, FR-003, FR-011)", () => {
+  const countFor = async (channelId: string): Promise<number> => {
+    const [row] = (
+      await db.execute<{ revision_sequence: string }>(
+        sql`select revision_sequence from channels where id = ${channelId}`,
+      )
+    ).rows;
+    return Number(row!.revision_sequence);
+  };
+
+  it("rises by one for an edit and by one for a deletion", async () => {
+    // A DELETION IS A REVISION. US1's third acceptance scenario fails if only edits are
+    // counted, and a counter that moved on one path would be the harder defect to see: it
+    // reports repairs correctly for half the traffic.
+    const author = await repoA.createUser("t044-a", "A");
+    const channel = await repoA.createChannel("t044-a", "public");
+    await repoA.addMember(channel.id, author.id);
+    expect(await countFor(channel.id)).toBe(0);
+
+    const m1 = await repoA.sendMessage(channel.id, {
+      text: "one", userId: author.id, userExternalId: "t044-a",
+    });
+    const m2 = await repoA.sendMessage(channel.id, {
+      text: "two", userId: author.id, userExternalId: "t044-a",
+    });
+
+    await repoA.editMessage(channel.id, m1.id, { text: "one edited", userId: author.id });
+    expect(await countFor(channel.id)).toBe(1);
+
+    await repoA.deleteMessage(channel.id, m2.id, { userId: author.id, userExternalId: "t044-a" });
+    expect(await countFor(channel.id)).toBe(2);
+  });
+
+  it("rises three times for three revisions to one message", async () => {
+    // The client learns HOW MANY it missed, not merely that it missed something — which is
+    // the difference between a bounded repair and a full refresh.
+    const author = await repoA.createUser("t044-b", "B");
+    const channel = await repoA.createChannel("t044-b", "public");
+    await repoA.addMember(channel.id, author.id);
+    const m = await repoA.sendMessage(channel.id, {
+      text: "v0", userId: author.id, userExternalId: "t044-b",
+    });
+
+    for (const text of ["v1", "v2", "v3"]) {
+      await repoA.editMessage(channel.id, m.id, { text, userId: author.id });
+    }
+    expect(await countFor(channel.id)).toBe(3);
+  });
+
+  it("does NOT rise for a send (FR-011)", async () => {
+    // THE ASSERTION THAT CATCHES THE FAILURE A CUSTOMER SEES. A counter bumped on send
+    // makes every active channel report a repair after every absence — a thundering herd
+    // arriving during a deploy, when the fleet is already reconnecting. Every other test
+    // here passes with that defect in place.
+    const author = await repoA.createUser("t044-c", "C");
+    const channel = await repoA.createChannel("t044-c", "public");
+    await repoA.addMember(channel.id, author.id);
+
+    for (const text of ["a", "b", "c", "d", "e"]) {
+      await repoA.sendMessage(channel.id, { text, userId: author.id, userExternalId: "t044-c" });
+    }
+    expect(await countFor(channel.id)).toBe(0);
+  });
+
+  it("counts per channel, so one channel's revisions do not move another's", async () => {
+    // FR-009's foundation. A counter that was environment-wide would satisfy every
+    // assertion above and tell a client to repair channels nothing touched.
+    const author = await repoA.createUser("t044-d", "D");
+    const left = await repoA.createChannel("t044-d-left", "public");
+    const right = await repoA.createChannel("t044-d-right", "public");
+    await repoA.addMember(left.id, author.id);
+    await repoA.addMember(right.id, author.id);
+    const m = await repoA.sendMessage(left.id, {
+      text: "in left", userId: author.id, userExternalId: "t044-d",
+    });
+
+    await repoA.editMessage(left.id, m.id, { text: "edited in left", userId: author.id });
+
+    expect(await countFor(left.id)).toBe(1);
+    expect(await countFor(right.id)).toBe(0);
+  });
+
+  it("carries the count on channelsForUser, for both of that query's callers (FR-014)", async () => {
+    // The count reaches the gateway on the membership query rather than on a read of its
+    // own, because at 10,000 connections a per-channel read per handshake is 10,000 reads.
+    const author = await repoA.createUser("t044-e", "E");
+    const channel = await repoA.createChannel("t044-e", "public");
+    await repoA.addMember(channel.id, author.id);
+    const m = await repoA.sendMessage(channel.id, {
+      text: "x", userId: author.id, userExternalId: "t044-e",
+    });
+    await repoA.editMessage(channel.id, m.id, { text: "y", userId: author.id });
+
+    const rows = await repoA.channelsForUser(author.id);
+    const row = rows.find((r) => r.channel_id === channel.id);
+    expect(row).toBeDefined();
+    expect(row!.revision_sequence).toBe(1);
+  });
+});
+
+// A CONCURRENT EDIT AND DELETION OF ONE MESSAGE (feature 043, FR-007).
+//
+// `gaps.md` 3.23-3 has carried this since this chapter built both writes. Neither takes
+// a row lock — no `FOR UPDATE`, following `assertWithinQuota`'s recorded decision to
+// state an overshoot rather than engineer around it — so the two orderings are not
+// symmetrical, and the claim that has never been tested is that **both of them end in a
+// tombstone**. Not the outcome: the claim.
+//
+// DO NOT START FROM `Promise.all` ON ONE CLIENT. The connection-cap chapter spent a phase
+// that two operations issued on one connection serialise at the socket, so a test built
+// that way proves the code cannot race by never letting it. The third case below uses
+// TWO POOLS, which is what that chapter found it needed.
+describe("a concurrent edit and deletion (feature 043, FR-007)", () => {
+  const seed = async (label: string) => {
+    const author = await repoA.createUser(`${label}-author`, "Author");
+    const channel = await repoA.createChannel(label, "public");
+    await repoA.addMember(channel.id, author.id);
+    const sent = await repoA.sendMessage(channel.id, {
+      text: "the original",
+      userId: author.id,
+    });
+    return { author, channel, sent };
+  };
+
+  const tombstoned = async (id: string) => {
+    const [row] = (
+      await db.execute<{ text: string | null; deleted_at: Date | null }>(
+        sql`SELECT text, deleted_at FROM messages WHERE id = ${id}`,
+      )
+    ).rows;
+    return row!.text === null && row!.deleted_at !== null;
+  };
+
+  it("delete then edit: the edit is refused and the tombstone stands", async () => {
+    const { author, channel, sent } = await seed("race-de");
+    await repoA.deleteMessage(channel.id, sent.id, { userId: author.id });
+    await expect(
+      repoA.editMessage(channel.id, sent.id, { text: "too late", userId: author.id }),
+    ).rejects.toThrow(MessageDeletedError);
+    expect(await tombstoned(sent.id)).toBe(true);
+  });
+
+  it("edit then delete: the tombstone stands and the history keeps what the edit superseded", async () => {
+    const { author, channel, sent } = await seed("race-ed");
+    await repoA.editMessage(channel.id, sent.id, { text: "corrected", userId: author.id });
+    await repoA.deleteMessage(channel.id, sent.id, { userId: author.id });
+    expect(await tombstoned(sent.id)).toBe(true);
+
+    // THE HISTORY ROW HOLDS THE TEXT THE EDIT SUPERSEDED, and that is correct rather
+    // than a leak: the edit did happen, and `message_edits` records what was replaced.
+    // A deletion removes the message's text; it does not rewrite the fact that an edit
+    // occurred before it.
+    const [edit] = (
+      await db.execute<{ prior_text: string }>(
+        sql`SELECT prior_text FROM message_edits WHERE message_id = ${sent.id}`,
+      )
+    ).rows;
+    expect(edit!.prior_text).toBe("the original");
+  });
+
+  it("both at once from two separate pools: whichever lands first, the message ends a tombstone", async () => {
+    // TWO POOLS, NOT TWO CALLS. `poolB` is a second connection pool with its own
+    // sockets, so the two statements are genuinely in flight together instead of being
+    // serialised by one client's write queue.
+    const poolB = createPool();
+    const dbB = createDb(poolB);
+    const repoB2 = new Repository(dbB, envA.id);
+    // WHICH ORDERING ACTUALLY HAPPENED, COUNTED AND REPORTED. The assertions below
+    // hold whether the edit lands first or the deletion does — which is the property,
+    // and also exactly how a test passes while exercising one branch and never the
+    // other. Counting is how a reader learns which case the run covered.
+    let editRefused = 0;
+    try {
+      // Ten attempts rather than one. A race asserted once is a race observed once,
+      // and the outcome is the same either way — which is the property.
+      for (let i = 0; i < 10; i++) {
+        const { author, channel, sent } = await seed(`race-both-${String(i)}`);
+        const results = await Promise.allSettled([
+          repoA.editMessage(channel.id, sent.id, {
+            text: `corrected ${String(i)}`,
+            userId: author.id,
+          }),
+          repoB2.deleteMessage(channel.id, sent.id, { userId: author.id }),
+        ]);
+
+        // The deletion always wins the row: it is the only one of the two that can
+        // refuse the other, and the edit's refusal is `MessageDeletedError`.
+        expect(await tombstoned(sent.id), `attempt ${String(i)}`).toBe(true);
+
+        const edit = results[0];
+        if (edit.status === "rejected") {
+          editRefused++;
+          expect(edit.reason).toBeInstanceOf(MessageDeletedError);
+        }
+        // And the deletion never fails: FR-009 makes a second one idempotent, and a
+        // concurrent edit is not a reason to refuse the first.
+        expect(results[1].status, `attempt ${String(i)}`).toBe("fulfilled");
+      }
+    } finally {
+      await poolB.end();
+    }
+    // WHAT THIS TEST CANNOT PROMISE, SAID OUT LOUD. `editRefused` counts the attempts
+    // where the deletion won, and it is NOT asserted to be greater than zero: measured
+    // over three runs it was zero in one of them, so requiring a race would make this
+    // flaky about one run in three. **A race cannot be commanded, so the test does not
+    // claim it happened.**
+    //
+    // The evidence that the interleaving is real is a measurement, not this assertion:
+    // before the compare-and-set went into `editMessage`, this same test failed in
+    // three runs of five, at attempts 3, 8 and 3, and left four rows in the lane with
+    // `deleted_at` set and `text` present. What survives here is the invariant — the
+    // message ends a tombstone whichever way the two land — and the deterministic
+    // proof of the guard is the test below.
+    expect(editRefused).toBeGreaterThanOrEqual(0);
+  }, 60_000);
+
+  it("the edit's UPDATE refuses a tombstone even if the read said otherwise", async () => {
+    // THE GUARD, DETERMINISTICALLY. The test above can only hit the compare-and-set
+    // when the two writes genuinely interleave, which no test can force. This one
+    // reproduces the state that predicate exists for — a row deleted after the edit's
+    // read — by deleting first and then issuing exactly the statement `editMessage`
+    // issues. Zero rows affected is what makes it throw `MessageDeletedError` instead
+    // of overwriting the tombstone.
+    const { author, channel, sent } = await seed("race-guard");
+    await repoA.deleteMessage(channel.id, sent.id, { userId: author.id });
+
+    const affected = await db.execute(
+      sql`UPDATE messages SET text = 'resurrected', edited_at = now()
+          WHERE id = ${sent.id} AND deleted_at IS NULL`,
+    );
+    expect(affected.rowCount).toBe(0);
+
+    // And the row is untouched: still a tombstone, still no text.
+    expect(await tombstoned(sent.id)).toBe(true);
+  });
+});
services/api/src/messages/messages.itest.ts
@@ -147,15 +147,15 @@ describe("POST /v1/channels/:channelId/messages", () => {
   // ── 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.
+  // with no user at all — and `MessagesController` declared no `@Accepts` at the time,
+  // so the guard fell back to `EITHER` and a user token was accepted here. The sender
+  // chapter declared it; this sentence went on describing its absence until this one.
   //
   // 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)", () => {
   
   // ══ THE SENDER (US2) ══════════════════════════════════════════
@@ -435,6 +435,581 @@ describe("POST /v1/channels/:channelId/messages", () => {
       const token = await tokenFor("never-seen-before");
       const refused = await sendAs(token, privateChannelId);
       expect(refused.status).toBe(400);
     });
   });
 });
+
+
+// ══ EDITING A MESSAGE (US1) ═══════════════════════════════════
+//
+// T024 WROTE THESE RED, and the route answering 404 is what "red for the right reason"
+// means here: `PATCH` on a path Nest has no handler for is a 404 from the router, not
+// from the visibility predicate, and the two are indistinguishable from outside. Every
+// test below therefore asserts something a 404 cannot satisfy.
+describe("PATCH /v1/channels/:channelId/messages/:messageId", () => {
+  let app: INestApplication;
+  let url: string;
+  let env: { id: string };
+  let credential: string;
+  let channelId: string;
+  let foreignChannelId: string;
+  let privateChannelId: string;
+  let repo: Repository;
+  let outboxDb: ReturnType<typeof createDb>;
+  let tokenFor: (user: string) => Promise<string>;
+
+  beforeAll(async () => {
+    // ITS OWN ENVIRONMENT, like every describe in this file. The suite above shares a
+    // channel between tests that archive it and remove members from it; an edit test
+    // leaning on that would fail for a reason it does not name.
+    const db = createDb(createPool());
+    outboxDb = db;
+    env = await createEnvironment(db, { name: "edit-itest" });
+    repo = new Repository(db, env.id);
+    channelId = (await repo.createChannel("general", "public")).id;
+    privateChannelId = (await repo.createChannel("members-only", "private")).id;
+    credential = (await createApiKey(db, { environmentId: env.id })).credential;
+    const other = await createEnvironment(db, { name: "edit-itest-other" });
+    foreignChannelId = (
+      await new Repository(db, other.id).createChannel("theirs", "public")
+    ).id;
+    const author = await repo.createUser("author", "The Author");
+    await repo.createUser("bystander", "A Bystander");
+    // THE AUTHOR IS A MEMBER OF THE PRIVATE CHANNEL and the bystander is not. The pair
+    // is what makes the visibility check observable — see the test that needs it.
+    await repo.addMember(privateChannelId, author.id);
+    await repo.upsertUser("courier", {
+      display_name: "Courier",
+      kind: "bot",
+      description: "delivers build results into the channel",
+    });
+    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();
+  });
+
+  afterAll(async () => {
+    await app.close();
+  });
+
+  /** A message by `author`, sent with their own token so the row carries them. */
+  const sendAsAuthor = async (text: string, channel = channelId) => {
+    const token = await tokenFor("author");
+    const res = await fetch(`${url}/v1/channels/${channel}/messages`, {
+      method: "POST",
+      headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
+      body: JSON.stringify({ text }),
+    });
+    expect(res.status).toBe(201);
+    return (await res.json()) as { id: string; seq: number; created_at: string };
+  };
+
+  const patch = (
+    messageId: string,
+    body: unknown,
+    auth: string,
+    channel = channelId,
+  ) =>
+    fetch(`${url}/v1/channels/${channel}/messages/${messageId}`, {
+      method: "PATCH",
+      headers: { "content-type": "application/json", authorization: `Bearer ${auth}` },
+      body: JSON.stringify(body),
+    });
+
+  const history = async (channel = channelId) => {
+    const res = await fetch(`${url}/v1/channels/${channel}/messages?limit=50`, {
+      headers: { authorization: `Bearer ${credential}` },
+    });
+    return (await res.json()) as { messages: Array<Record<string, unknown>> };
+  };
+
+  it("the author edits their message and the text changes (FR-001, FR-003)", async () => {
+    const sent = await sendAsAuthor("frist");
+    const res = await patch(sent.id, { text: "first" }, await tokenFor("author"));
+    expect(res.status).toBe(200);
+    const body = (await res.json()) as Record<string, unknown>;
+    expect(body["text"]).toBe("first");
+    // THE SEQUENCE IS THE SAME NUMBER (FR-002). Not "a number" — the one it had.
+    expect(body["seq"]).toBe(sent.seq);
+    expect(body["id"]).toBe(sent.id);
+    expect(typeof body["edited_at"]).toBe("string");
+    // …and the read path agrees, which a response body alone does not prove.
+    const rows = (await history()).messages.filter((m) => m["id"] === sent.id);
+    expect(rows).toHaveLength(1);
+    expect(rows[0]!["text"]).toBe("first");
+  });
+
+  it("an unedited message reports edited_at as null, with the key present (FR-003)", async () => {
+    // The control for the assertion above. `edited_at` being a string after an edit
+    // means nothing unless it is absent before one — a column defaulting to `now()`
+    // would pass the test above and fail this.
+    const sent = await sendAsAuthor("untouched");
+    const rows = (await history()).messages.filter((m) => m["id"] === sent.id);
+    // `toHaveProperty(…, null)` AND NOT `?? null`. The first draft read
+    // `rows[0]!["edited_at"] ?? null` and was green before the route existed, because
+    // an ABSENT key and a null one are the same value through `??` — so it would have
+    // stayed green if the read path never carried the field at all.
+    expect(rows[0]!).toHaveProperty("edited_at", null);
+  });
+
+  it("somebody else's message is refused with not_message_author (FR-013)", async () => {
+    const sent = await sendAsAuthor("mine");
+    const res = await patch(sent.id, { text: "yours now" }, await tokenFor("bystander"));
+    expect(res.status).toBe(403);
+    const body = (await res.json()) as Record<string, unknown>;
+    expect(body["code"]).toBe("not_message_author");
+    // AND THE TEXT DID NOT CHANGE. A 403 with the write already done is the failure
+    // this half exists to catch.
+    const rows = (await history()).messages.filter((m) => m["id"] === sent.id);
+    expect(rows[0]!["text"]).toBe("mine");
+  });
+
+  it("a tenant API key may not edit at all (FR-013a)", async () => {
+    // The decision the spec records: a key deletes anything and edits nothing. An
+    // application credential has no author to compare against, so `@Accepts("user")`
+    // on the method is what answers — and the class declares BOTH classes, so a route
+    // added without a declaration would accept the key and then have nothing to check.
+    const sent = await sendAsAuthor("not yours to fix");
+    const res = await patch(sent.id, { text: "fixed" }, credential);
+    expect(res.status).toBe(403);
+    expect(((await res.json()) as { code: string }).code).toBe("wrong_credential_type");
+  });
+
+  it("a message in a channel this tenant cannot see is a 404 (FR-014)", async () => {
+    // Indistinguishable from a message id that does not exist, which is the pair the
+    // isolation oracle asserts everywhere else in this file.
+    const token = await tokenFor("author");
+    const foreign = await patch(randomUUID(), { text: "x" }, token, foreignChannelId);
+    const missing = await patch(randomUUID(), { text: "x" }, token, randomUUID());
+    expect(foreign.status).toBe(404);
+    expect(missing.status).toBe(404);
+    expect(withoutRequestId(await foreign.json())).toEqual(
+      withoutRequestId(await missing.json()),
+    );
+  });
+
+  it("a message id that is not in this channel is a 404 (FR-014)", async () => {
+    // The pair above shares a tenant boundary. This one does not: both channels belong
+    // to this environment and the message belongs to the other one, so the only thing
+    // that can refuse it is the route checking the message against the channel in the
+    // path rather than trusting the id.
+    const elsewhere = (await repo.createChannel("elsewhere", "public")).id;
+    const sent = await sendAsAuthor("over here", elsewhere);
+    const res = await patch(sent.id, { text: "moved" }, await tokenFor("author"));
+    expect(res.status).toBe(404);
+  });
+
+  it("a non-member of a private channel gets the not-found envelope, not a 403 (FR-014)", async () => {
+    // WRITTEN BECAUSE THE FALSIFICATION CAME BACK GREEN. Removing `channelVisibleTo`
+    // from `messages.service.edit` broke nothing: `editMessage`'s join already carries
+    // the environment, so a FOREIGN channel refuses either way, and the foreign/missing
+    // pair above compares two bodies that both read "message not found" whichever check
+    // produced them. The one case only the visibility predicate answers is a private
+    // channel of THIS tenant that the caller cannot see — and without it the caller
+    // learns the message is there from a 403 naming its authorship.
+    const token = await tokenFor("bystander");
+    const inside = await (async () => {
+      const authorToken = await tokenFor("author");
+      const res = await fetch(`${url}/v1/channels/${privateChannelId}/messages`, {
+        method: "POST",
+        headers: {
+          "content-type": "application/json",
+          authorization: `Bearer ${authorToken}`,
+        },
+        body: JSON.stringify({ text: "members only" }),
+      });
+      expect(res.status).toBe(201);
+      return (await res.json()) as { id: string };
+    })();
+
+    const refused = await patch(inside.id, { text: "seen it" }, token, privateChannelId);
+    const absent = await patch(randomUUID(), { text: "seen it" }, token, randomUUID());
+    expect(refused.status).toBe(404);
+    expect(absent.status).toBe(404);
+    // BYTE-IDENTICAL, which is the half a 403 fails. Without the predicate this is a
+    // 403 `not_message_author` and the bystander has learned that a channel they cannot
+    // read holds a message somebody else wrote.
+    expect(withoutRequestId(await refused.json())).toEqual(
+      withoutRequestId(await absent.json()),
+    );
+
+    // The control: the author, who IS a member, can still edit it. Otherwise the 404
+    // above could be a private channel refusing everybody.
+    const allowed = await patch(
+      inside.id,
+      { text: "members only, corrected" },
+      await tokenFor("author"),
+      privateChannelId,
+    );
+    expect(allowed.status).toBe(200);
+  });
+
+  const edits = (messageId: string, auth: string, channel = channelId) =>
+    fetch(`${url}/v1/channels/${channel}/messages/${messageId}/edits`, {
+      headers: { authorization: `Bearer ${auth}` },
+    });
+
+  it("the edit history reads back oldest first, through the route (SC-002)", async () => {
+    // THROUGH THE ROUTE AND NOT THE DATABASE. `repository.itest.ts` proves the rows
+    // exist; only this proves anybody can retrieve them — the distinction CLAUDE.md
+    // records as "a repository test proves a check exists; only a route test proves it
+    // fires", pointed the other way.
+    const sent = await sendAsAuthor("one");
+    const token = await tokenFor("author");
+    for (const text of ["two", "three", "four"]) {
+      expect((await patch(sent.id, { text }, token)).status).toBe(200);
+    }
+
+    const res = await edits(sent.id, credential);
+    expect(res.status).toBe(200);
+    const body = (await res.json()) as {
+      edits: Array<{ prior_text: string; edited_at: string }>;
+    };
+    expect(body.edits.map((e) => e.prior_text)).toEqual(["one", "two", "three"]);
+    for (const entry of body.edits) expect(typeof entry.edited_at).toBe("string");
+  });
+
+  it("an end user is refused, including the message's author (FR-023a, SC-002a)", async () => {
+    // THE AUTHOR IS THE CASE THAT MATTERS. A refusal that let the author through would
+    // look reasonable and would still be the leak: an end user who can see a channel
+    // can see every message in it, so "only your own" is not a narrowing at all once a
+    // token can be minted for any identifier.
+    const sent = await sendAsAuthor("before");
+    const token = await tokenFor("author");
+    expect((await patch(sent.id, { text: "after" }, token)).status).toBe(200);
+
+    const asAuthor = await edits(sent.id, token);
+    expect(asAuthor.status).toBe(403);
+    expect(((await asAuthor.json()) as { code: string }).code).toBe("wrong_credential_type");
+
+    const asStranger = await edits(sent.id, await tokenFor("bystander"));
+    expect(asStranger.status).toBe(403);
+
+    // THE CONTROL: the tenant key still reads it. Otherwise the 403s above could be a
+    // route that refuses everybody.
+    const asKey = await edits(sent.id, credential);
+    expect(asKey.status).toBe(200);
+    expect(
+      ((await asKey.json()) as { edits: Array<{ prior_text: string }> }).edits.map(
+        (e) => e.prior_text,
+      ),
+    ).toEqual(["before"]);
+  });
+
+  it("a message with no edits answers 200 and an empty list, not 404", async () => {
+    // The absence of edits is a fact about the message, not the absence of a resource.
+    const sent = await sendAsAuthor("never edited");
+    const res = await edits(sent.id, credential);
+    expect(res.status).toBe(200);
+    expect((await res.json()) as unknown).toEqual({ edits: [] });
+  });
+
+  it("a message id that does not exist IS a 404, which is the other half", async () => {
+    // Without this, `{ edits: [] }` would be the answer for a message that was never
+    // there — and the route would be unable to tell a caller which of the two it got.
+    // `listMessageEdits` returning `[]` cannot distinguish them; `messageExistsIn` is
+    // the second question the handler asks for exactly this reason.
+    const res = await edits(randomUUID(), credential);
+    expect(res.status).toBe(404);
+  });
+
+  it("the edit history of a foreign channel's message is a 404", async () => {
+    const res = await edits(randomUUID(), credential, foreignChannelId);
+    expect(res.status).toBe(404);
+  });
+
+  it("editing a message to the text it already has is still an edit (FR-021)", async () => {
+    // THE PLATFORM DOES NOT COMPARE TEXTS, and the spec says why: every definition of
+    // equality — whitespace, case, unicode normalisation, an invisible character — is a
+    // decision a customer would have to be told about. So an identical edit records an
+    // edit time and appends a history row like any other.
+    const sent = await sendAsAuthor("unchanged");
+    const token = await tokenFor("author");
+    const res = await patch(sent.id, { text: "unchanged" }, token);
+    expect(res.status).toBe(200);
+    expect((await res.json())["edited_at"]).toBeTruthy();
+
+    const body = (await (await edits(sent.id, credential)).json()) as {
+      edits: Array<{ prior_text: string }>;
+    };
+    // ONE ROW, AND ITS `prior_text` EQUALS THE CURRENT TEXT. That is what "treated as
+    // an edit rather than detected and skipped" looks like in the table.
+    expect(body.edits.map((e) => e.prior_text)).toEqual(["unchanged"]);
+  });
+
+  /** How many events of one type this outbox holds for one message.
+   *
+   * READ FROM THE TABLE, NOT FROM A SPY. FR-009's requirement is that a second deletion
+   * emits no second event, and the only place that is observable is the row the
+   * transaction wrote — a mock publisher would show what the code intended to do rather
+   * than what committed. `outbox.itest.ts` reads it the same way.
+   *
+   * The api under test runs IN PROCESS here, against the same database this `db` handle
+   * holds, so there is no relay draining it: the suite's fixture leaves
+   * `RELAY_OUTBOX_RELAY` alone and nothing publishes. Rows stay put to be counted. */
+  const outboxCount = async (messageId: string, type: string): Promise<number> => {
+    // A PLAIN STRING AND NOT drizzle's `sql` TEMPLATE, because the lint rule forbids
+    // importing `drizzle-orm` outside `db/` — constitution I, and the revisions chapter's T069a
+    // restored the ban for integration tests after a second flat-config block had been
+    // replacing the rule instead of merging with it. `outbox.itest.ts` reads the table
+    // the same way for the same reason. The interpolated values are a uuid this test
+    // generated and a literal from this file.
+    const res = (await outboxDb.execute(
+      `SELECT count(*)::int AS n FROM outbox
+         WHERE payload->>'type' = '${type}'
+           AND payload->'data'->>'id' = '${messageId}'`,
+    )) as unknown as { rows: Array<{ n: number }> };
+    return res.rows[0]?.n ?? 0;
+  };
+
+  const remove = (messageId: string, auth: string, channel = channelId) =>
+    fetch(`${url}/v1/channels/${channel}/messages/${messageId}`, {
+      method: "DELETE",
+      headers: { authorization: `Bearer ${auth}` },
+    });
+
+  it("the author deletes their message and the row becomes a tombstone (FR-006)", async () => {
+    const sent = await sendAsAuthor("regrettable");
+    const res = await remove(sent.id, await tokenFor("author"));
+    expect(res.status).toBe(204);
+    expect(await res.text()).toBe("");
+
+    // FR-011: history keeps it, in its original position, with a null text.
+    const rows = (await history()).messages.filter((m) => m["id"] === sent.id);
+    expect(rows).toHaveLength(1);
+    expect(rows[0]!["text"]).toBeNull();
+    expect(rows[0]!["seq"]).toBe(sent.seq);
+    // THE AUTHOR SURVIVES, which is half of what FR-MSG-08 asks the tombstone to keep.
+    expect(rows[0]!["user"]).toBe("author");
+  });
+
+  it("a tenant API key deletes anybody's message (FR-012)", async () => {
+    // FR-MOD-02 grants a key deletion of any message irrespective of author, and this
+    // route is the one place in the chapter where the class-level
+    // `@Accepts("application", "user")` is CORRECT rather than inherited by accident.
+    const sent = await sendAsAuthor("moderated");
+    expect((await remove(sent.id, credential)).status).toBe(204);
+    const rows = (await history()).messages.filter((m) => m["id"] === sent.id);
+    expect(rows[0]!["text"]).toBeNull();
+  });
+
+  it("an end user may not delete somebody else's message (FR-013)", async () => {
+    const sent = await sendAsAuthor("not yours to remove");
+    const res = await remove(sent.id, await tokenFor("bystander"));
+    expect(res.status).toBe(403);
+    expect(((await res.json()) as { code: string }).code).toBe("not_message_author");
+    // …and it is still there, unchanged. A 403 with the write already done is what
+    // this half exists to catch.
+    const rows = (await history()).messages.filter((m) => m["id"] === sent.id);
+    expect(rows[0]!["text"]).toBe("not yours to remove");
+  });
+
+  it("deleting twice answers 204 twice, changes nothing, and emits ONE event (FR-009, SC-007)", async () => {
+    // TWO 204s PROVE NOTHING — idempotence is about what the second call DID, and the
+    // answer is the same either way. The event count is the assertion that carries the
+    // requirement, read straight out of the outbox: a second `message.deleted` there
+    // means every subscribed webhook fires twice for one deletion.
+    const sent = await sendAsAuthor("said once, deleted twice");
+    const token = await tokenFor("author");
+    expect((await remove(sent.id, token)).status).toBe(204);
+    const first = (await history()).messages.find((m) => m["id"] === sent.id)!;
+
+    expect((await remove(sent.id, token)).status).toBe(204);
+    const second = (await history()).messages.find((m) => m["id"] === sent.id)!;
+
+    // NOTHING CHANGED, including the deletion timestamp — a second `now()` written
+    // here would move it, and a client that had already read the tombstone would see
+    // it change for no reason.
+    expect(second).toEqual(first);
+
+    const events = await outboxCount(sent.id, "message.deleted");
+    expect(events).toBe(1);
+  });
+
+  it("editing a tombstone is refused with message_deleted, and a stranger is refused first (FR-010)", async () => {
+    const sent = await sendAsAuthor("about to go");
+    const token = await tokenFor("author");
+    expect((await remove(sent.id, token)).status).toBe(204);
+
+    const asAuthor = await patch(sent.id, { text: "back please" }, token);
+    expect(asAuthor.status).toBe(403);
+    expect(((await asAuthor.json()) as { code: string }).code).toBe("message_deleted");
+
+    // THE ORDER IS THE DISCLOSURE CONTROL. A stranger gets the authorship answer, not
+    // the tombstone one, so `message_deleted` never tells anybody that a message they
+    // could not otherwise reach exists.
+    const asStranger = await patch(sent.id, { text: "back please" }, await tokenFor("bystander"));
+    expect(asStranger.status).toBe(403);
+    expect(((await asStranger.json()) as { code: string }).code).toBe("not_message_author");
+  });
+
+  it("deleting a message of a channel this tenant cannot see is a 404 (FR-014)", async () => {
+    const token = await tokenFor("author");
+    const foreign = await remove(randomUUID(), token, foreignChannelId);
+    const missing = await remove(randomUUID(), token, randomUUID());
+    expect(foreign.status).toBe(404);
+    expect(missing.status).toBe(404);
+    expect(withoutRequestId(await foreign.json())).toEqual(
+      withoutRequestId(await missing.json()),
+    );
+  });
+
+  it("a non-member of a private channel gets the not-found envelope on DELETE too (FR-014)", async () => {
+    // The same leak the edit route's test covers, on the other verb — and worth its own
+    // test because the two routes resolve visibility separately.
+    const authorToken = await tokenFor("author");
+    const posted = await fetch(`${url}/v1/channels/${privateChannelId}/messages`, {
+      method: "POST",
+      headers: { "content-type": "application/json", authorization: `Bearer ${authorToken}` },
+      body: JSON.stringify({ text: "members only, briefly" }),
+    });
+    expect(posted.status).toBe(201);
+    const inside = (await posted.json()) as { id: string };
+
+    const refused = await remove(inside.id, await tokenFor("bystander"), privateChannelId);
+    expect(refused.status).toBe(404);
+    // AND THE TENANT KEY, WHICH MAY DELETE ANYTHING, still can — otherwise the 404
+    // above could be a private channel refusing every deletion.
+    expect((await remove(inside.id, credential, privateChannelId)).status).toBe(204);
+  });
+
+  it("a key's tombstone is the SAME tombstone an author's deletion produces (FR-012, SC-004)", async () => {
+    // NOT "both are null". Two messages, one deleted by its author and one by the
+    // tenant key, compared field by field through the read path — because FR-012 grants
+    // a key deletion of any message and SC-004 asks that the content be gone from every
+    // path a reader can reach it by. A moderated message that read differently from a
+    // self-deleted one would be a way to tell, from the outside, which happened.
+    const mine = await sendAsAuthor("deleted by me");
+    const theirs = await sendAsAuthor("deleted by the operator");
+    expect((await remove(mine.id, await tokenFor("author"))).status).toBe(204);
+    expect((await remove(theirs.id, credential)).status).toBe(204);
+
+    const rows = (await history()).messages;
+    const a = rows.find((m) => m["id"] === mine.id)!;
+    const b = rows.find((m) => m["id"] === theirs.id)!;
+    // The fields that must agree, named rather than compared wholesale: `id`, `seq` and
+    // `created_at` differ by construction and say nothing about the deleter.
+    for (const key of ["text", "edited_at", "user"]) {
+      expect(b[key], `${key} differs between an author's tombstone and a key's`).toEqual(
+        a[key],
+      );
+    }
+    expect(a["text"]).toBeNull();
+    // AND THE AUTHOR IS STILL THE AUTHOR ON BOTH. A key deleted one of them and the
+    // row says who WROTE it — who removed it is `metadata.deleted_by`, which no read
+    // path exposes (the revisions chapter's `gaps.md` item 2).
+    expect(a["user"]).toBe("author");
+    expect(b["user"]).toBe("author");
+  });
+
+  it("an end user who is not the author is refused on BOTH routes (FR-013)", async () => {
+    // ONE TEST FOR THE PAIR, because FR-013 is one requirement covering both verbs and
+    // the two paths reach the refusal through different methods. Same code, same status,
+    // and nothing written either way.
+    const sent = await sendAsAuthor("neither yours to change nor to remove");
+    const stranger = await tokenFor("bystander");
+
+    const edited = await patch(sent.id, { text: "rewritten" }, stranger);
+    const removed = await remove(sent.id, stranger);
+    expect([edited.status, removed.status]).toEqual([403, 403]);
+    expect(((await edited.json()) as { code: string }).code).toBe("not_message_author");
+    expect(((await removed.json()) as { code: string }).code).toBe("not_message_author");
+
+    const rows = (await history()).messages.filter((m) => m["id"] === sent.id);
+    expect(rows[0]!["text"]).toBe("neither yours to change nor to remove");
+    expect(rows[0]!["edited_at"]).toBeNull();
+  });
+
+  it("another environment's message is 404 on both routes, never 403 (FR-014)", async () => {
+    // 403 WOULD BE THE LEAK. A permission refusal on a foreign id says the id is real;
+    // chapter 2.8 made a foreign channel a 404 for exactly this, and the pair below is
+    // the assertion the isolation oracle makes everywhere else in this file.
+    const token = await tokenFor("author");
+    for (const [verb, call] of [
+      ["PATCH", (id: string, ch: string) => patch(id, { text: "x" }, token, ch)],
+      ["DELETE", (id: string, ch: string) => remove(id, token, ch)],
+    ] as const) {
+      const foreign = await call(randomUUID(), foreignChannelId);
+      const missing = await call(randomUUID(), randomUUID());
+      expect(foreign.status, verb).toBe(404);
+      expect(missing.status, verb).toBe(404);
+      expect(withoutRequestId(await foreign.json())).toEqual(
+        withoutRequestId(await missing.json()),
+      );
+    }
+  });
+
+  it("an edit writes exactly one message.updated event, and a second edit writes a second (FR-019)", async () => {
+    // THE COUNTERPART TO T043, AND THE OPPOSITE ANSWER. A repeated deletion writes no
+    // second event because the row did not change (FR-009); a repeated edit writes one
+    // every time, because FR-021 says the platform does not compare texts and every
+    // edit is an edit. Two requirements that look symmetrical and are not.
+    const sent = await sendAsAuthor("first go");
+    const token = await tokenFor("author");
+    expect((await patch(sent.id, { text: "second go" }, token)).status).toBe(200);
+    expect(await outboxCount(sent.id, "message.updated")).toBe(1);
+
+    expect((await patch(sent.id, { text: "third go" }, token)).status).toBe(200);
+    expect(await outboxCount(sent.id, "message.updated")).toBe(2);
+
+    // AND NO CREATION EVENT WAS ADDED. The send wrote one; the two edits wrote none.
+    expect(await outboxCount(sent.id, "message.created")).toBe(1);
+
+    // A REFUSED EDIT WRITES NOTHING. The transaction that would have written the event
+    // never commits, which is what putting the insert inside it buys.
+    expect(
+      (await patch(sent.id, { text: "not mine" }, await tokenFor("bystander"))).status,
+    ).toBe(403);
+    expect(await outboxCount(sent.id, "message.updated")).toBe(2);
+  });
+
+  it("refuses a token minted for an identifier with no user row, on all three routes", async () => {
+    // THE SAME ARM THE SEND PATH ALREADY TESTS, on the three routes this chapter adds
+    // and on the history route beside them. `mintUserToken` signs a token for any
+    // identifier; `POST /auth/dev-token` creates the row at mint time (FR-039a) and this
+    // suite does not go through it, so a subject with no row is still reachable — and it
+    // is the arm every one of these handlers has, because resolving the caller is the
+    // first thing each of them does.
+    //
+    // WRITTEN BECAUSE THE RATCHET NAMED IT. `messages.controller.ts` fell from 100% lines
+    // to 93.61% when this chapter added two more copies of that resolution, and the
+    // uncovered statements were exactly these throws.
+    const stranger = await tokenFor("never-seen-before");
+    const sent = await sendAsAuthor("something to aim at");
+
+    const edited = await patch(sent.id, { text: "x" }, stranger);
+    const removed = await remove(sent.id, stranger);
+    const read = await fetch(`${url}/v1/channels/${channelId}/messages?limit=1`, {
+      headers: { authorization: `Bearer ${stranger}` },
+    });
+
+    expect([edited.status, removed.status, read.status]).toEqual([400, 400, 400]);
+    expect(((await edited.json()) as { code: string }).code).toBe("invalid_request");
+    expect(((await removed.json()) as { code: string }).code).toBe("invalid_request");
+    // AND NOTHING WAS WRITTEN. A 400 raised after the write would pass every assertion
+    // above.
+    const rows = (await history()).messages.filter((m) => m["id"] === sent.id);
+    expect(rows[0]!["text"]).toBe("something to aim at");
+    expect(rows[0]!["edited_at"]).toBeNull();
+  });
+
+  it("an empty text is a 400 through the protocol envelope (FR-001)", async () => {
+    const sent = await sendAsAuthor("something");
+    const res = await patch(sent.id, { text: "" }, await tokenFor("author"));
+    expect(res.status).toBe(400);
+    const body = (await res.json()) as Record<string, unknown>;
+    expect(body["code"]).toBe("invalid_request");
+    expect(typeof body["docs_url"]).toBe("string");
+  });
+});
services/api/src/internal/backfill.itest.ts
@@ -197,12 +197,162 @@ describe("POST /internal/backfill", () => {
     expect(page.messages.map((m) => m.seq)).toEqual([withAuthor.seq]);
     // The gap is visible to the client as a missing sequence number — which
     // is precisely the signal the SDK repairs through 2.4's history endpoint.
     expect(page.messages.map((m) => m.seq)).not.toContain(anonymous.seq);
   });
 
+  // ══ WHAT A CLIENT THAT WAS AWAY CAN AND CANNOT LEARN (US4) ══
+  //
+  // **THESE TESTS MOVED HERE FROM `services/gateway/src/resume.itest.ts`**, which the
+  // task list named. That file boots the gateway against a **stubbed** api: its
+  // `environment_id: "env-1"` and `user: "tuan"` are stub return values and there is no
+  // database behind it, so nothing in it can edit or delete a message. What FR-016 and
+  // FR-016a are ABOUT is what the backfill returns, and that is this file's subject —
+  // a real repository, real rows, and the mapping in `backfill.controller.ts`.
+  //
+  // The gateway's half is that it replays what it was handed, which `resume.itest.ts`
+  // does test, with a stub that says so.
+
+  it("a message ABOVE the cursor, edited while away, replays with its CURRENT text (FR-016)", async () => {
+    // THE BACKFILL READS ROWS, IT DOES NOT REPLAY A LOG. That is the whole of FR-016's
+    // answer and it is a property of the query rather than a feature anybody built: the
+    // superseded text lives in `message_edits`, which no read path on this route
+    // touches, so a client that was away sees what the message says NOW.
+    const channel = (await repo.createChannel("resume-edited", "public")).id;
+    await repo.addMember(channel, tuan.id);
+    const sent = await say(channel, "the frist draft");
+    await repo.editMessage(channel, sent.id, { text: "the first draft", userId: tuan.id });
+
+    const page = (await parsed(await ask({ [channel]: 0 }))).channels[channel]!;
+    expect(page.messages.map((m) => m.seq)).toEqual([sent.seq]);
+    expect(page.messages[0]!.text).toBe("the first draft");
+    // AND NOT THE SUPERSEDED TEXT, asserted separately — a page that contained both
+    // would satisfy the assertion above.
+    expect(page.messages.map((m) => m.text)).not.toContain("the frist draft");
+  });
+
+  it("a message DELETED while away is not replayed at all (FR-016)", async () => {
+    // `backfill.controller.ts`'s `toFrame` already drops a null-text row and its comment
+    // says why: a tombstone is not a creation, and there is no truthful `text` to
+    // invent. **This is the first test with a real writer behind that line** — the
+    // senderless test above plants its row by hand precisely because nothing could
+    // write one.
+    const channel = (await repo.createChannel("resume-deleted", "public")).id;
+    await repo.addMember(channel, tuan.id);
+    const kept = await say(channel, "still here");
+    const gone = await say(channel, "not for long");
+    await repo.deleteMessage(channel, gone.id, { userId: tuan.id });
+
+    const page = (await parsed(await ask({ [channel]: 0 }))).channels[channel]!;
+    expect(page.messages.map((m) => m.seq)).toEqual([kept.seq]);
+    // THE CONTENT IS THE ASSERTION, not the count: a page that carried the tombstone
+    // with a null text would be a `message.created` frame the contract forbids, and a
+    // page that carried the OLD text would be the deletion undone.
+    expect(page.messages.map((m) => m.text)).not.toContain("not for long");
+    // The client sees a gap at `gone.seq` and repairs it through history, which is the
+    // safety net `toFrame`'s comment names.
+    expect(page.messages.map((m) => m.seq)).not.toContain(gone.seq);
+  });
+
+  it("truncation is reported as the READ found it, tombstones and all", async () => {
+    // `backfill.controller.ts:64` decided this and says why — *"dropping an unrenderable
+    // row does not mean the client should go page history, and hiding a real cap
+    // would"* — and `repository.ts` computes it as `rows.length > limit`. **The decision
+    // is not this chapter's and the exercise is**: until now no writer could produce a
+    // tombstone, so a truncated page containing one had never happened.
+    //
+    // A FULL PAGE PLUS ONE, WITH ONE ROW DELETED. The page must report fewer frames
+    // than the limit AND still say it was truncated.
+    const channel = (await repo.createChannel("resume-truncated", "public")).id;
+    await repo.addMember(channel, tuan.id);
+    const sent = [];
+    for (let i = 0; i < BACKFILL_LIMIT + 1; i++) sent.push(await say(channel, `m${i}`));
+    // Delete one INSIDE the page the read will return — the oldest, which the cap keeps.
+    await repo.deleteMessage(channel, sent[0]!.id, { userId: tuan.id });
+
+    const page = (await parsed(await ask({ [channel]: 0 }))).channels[channel]!;
+    expect(page.truncated).toBe(true);
+    // FEWER FRAMES THAN ROWS READ, which is the half that would break if `truncated`
+    // were computed after the mapping.
+    expect(page.messages.length).toBe(BACKFILL_LIMIT - 1);
+  }, 120_000);
+
+  it("a message BELOW the cursor, edited while away, produces no frame and no gap (FR-016a)", async () => {
+    // THE SOFT EDGE IN THE CONTRACT, demonstrated rather than asserted. Resume is
+    // ordered by the channel sequence alone: a message older than the cursor is not in
+    // the page whatever happened to it, so an edit below the cursor is invisible.
+    //
+    // **BOTH HALVES.** No frame is the obvious one. The one that matters is NO GAP: the
+    // sequence numbers above the cursor are contiguous, so the SDK's gap detector — the
+    // mechanism every other missed frame is repaired by — sees nothing to repair. That
+    // is why FR-016b asks for the bound to be documented as a property of a cursor.
+    const channel = (await repo.createChannel("resume-below", "public")).id;
+    await repo.addMember(channel, tuan.id);
+    const below = await say(channel, "said long ago");
+    const cursor = below.seq;
+    const above = await say(channel, "said since");
+    await repo.editMessage(channel, below.id, {
+      text: "said long ago, corrected",
+      userId: tuan.id,
+    });
+
+    const page = (await parsed(await ask({ [channel]: cursor }))).channels[channel]!;
+    expect(page.messages.map((m) => m.seq)).toEqual([above.seq]);
+    expect(page.messages.map((m) => m.text)).not.toContain("said long ago, corrected");
+    // NO GAP: the page starts at cursor + 1 and every step is 1.
+    const seqs = page.messages.map((m) => m.seq);
+    expect(seqs[0]).toBe(cursor + 1);
+    for (let i = 1; i < seqs.length; i += 1) expect(seqs[i]! - seqs[i - 1]!).toBe(1);
+  });
+
+  it("re-reading the range through history repairs it (SC-006)", async () => {
+    // THE DOCUMENTED REPAIR, end to end. A client away across an edit below its cursor
+    // and a deletion above it re-reads the range and ends with what a client that never
+    // left is holding: the current text for the edit, and a tombstone for the deletion.
+    //
+    // `listMessages` IS THE HISTORY ROUTE'S READ, so this is the repair the SDK
+    // performs rather than a second implementation of it.
+    const channel = (await repo.createChannel("resume-repair", "public")).id;
+    await repo.addMember(channel, tuan.id);
+    const below = await say(channel, "before the cursor");
+    const cursor = below.seq;
+    const above = await say(channel, "after the cursor");
+    const doomed = await say(channel, "about to go");
+    await repo.editMessage(channel, below.id, {
+      text: "before the cursor, corrected",
+      userId: tuan.id,
+    });
+    await repo.deleteMessage(channel, doomed.id, { userId: tuan.id });
+
+    // What resume alone hands the client: one frame, and a gap at `doomed.seq`.
+    const page = (await parsed(await ask({ [channel]: cursor }))).channels[channel]!;
+    expect(page.messages.map((m) => m.seq)).toEqual([above.seq]);
+
+    // What the repair adds. Read from the start, the way a client that distrusts its
+    // cache does.
+    const repaired = await repo.listMessages(channel, {
+      userId: tuan.id,
+      limit: 50,
+      afterSeq: 0,
+    });
+    const bySeq = new Map(repaired.map((m) => [m.seq, m]));
+    expect(bySeq.get(below.seq)!.text).toBe("before the cursor, corrected");
+    expect(bySeq.get(above.seq)!.text).toBe("after the cursor");
+    // THE TOMBSTONE IS PRESENT AND EMPTY, which is what closes the gap resume left —
+    // the client learns the sequence is accounted for rather than missing.
+    expect(bySeq.has(doomed.seq)).toBe(true);
+    expect(bySeq.get(doomed.seq)!.text).toBeNull();
+    // And every sequence in the range is accounted for, which is the property SC-006
+    // asks for: the same view as a client that stayed connected.
+    expect([...bySeq.keys()].sort((a, b) => a - b)).toEqual([
+      below.seq,
+      above.seq,
+      doomed.seq,
+    ]);
+  });
+
   it("refuses a cursor map big enough to turn one connect into a scan storm", async () => {
     const cursors: Record<string, number> = {};
     for (let i = 0; i <= MAX_RESUME_CHANNELS; i++) {
       cursors[`channel-${i}`] = 1;
     }
     expect((await ask(cursors)).status).toBe(400);
services/api/src/isolation/tenant-scope.itest.ts
@@ -83,7 +83,30 @@ describe("every table has a path to one tenant", () => {
     // A hop with no target is a hop in name only, and the query that produced it
     // would have to be wrong for this to happen — which is why it is asserted.
     for (const t of hop) {
       expect(t.via.length, `${t.table} is a hop to nowhere`).toBeGreaterThan(0);
     }
   });
+
+  it("reads the direct tables a chain arrives at, not the ones it passes through", () => {
+    // THE REACH BECAME TRANSITIVE IN THIS CHAPTER, and this test is the half of it that
+    // NOTHING ABOVE CAN SEE. `message_edits` is the first table two links away — it
+    // references `messages`, which references `channels`, which carries the column — and
+    // the one-hop query classified it as having no tenant at all.
+    //
+    // Reverting the walk to one hop turns three tests red, this one included, so the
+    // transitive half is well covered. THE SECOND EXPECTATION IS THE ONE THAT STANDS
+    // ALONE: drop the `IN (SELECT table_name FROM direct)` filter from `fk_targets` and
+    // the walk starts reporting the tables it passed THROUGH — six of them here — and
+    // every other test in this file stays green, because "a hop has some target" is truer
+    // with intermediates in the list, not less true.
+    //
+    // A `via` naming `messages` would be the catalogue reporting its own intermediate
+    // step, and `tenant-scope`'s whole claim is that `via` names tables a repository can
+    // scope by. Asserting "every via is itself direct" would not catch it: the SQL filter
+    // makes that true by construction whenever the filter is there at all.
+    const edits = tables.find((t) => t.table === "message_edits");
+    expect(edits?.path).toBe("hop");
+    expect(edits?.via).not.toContain("messages");
+    expect([...(edits?.via ?? [])].sort()).toEqual(["channels", "users"]);
+  });
 });
services/gateway/src/session.test.ts
@@ -1,14 +1,15 @@
 import { WebSocket } from "ws";
 import { afterEach, describe, expect, it } from "vitest";
+import { readFile } from "node:fs/promises";
 import type { Server } from "node:http";
 import type { AddressInfo } from "node:net";
 
 import { createLogger, type Logger } from "@relay/service-kit";
 import { serve } from "@relay/service-kit";
-import type { Frame } from "@relay/protocol";
+import type { Frame, RevisionFabric } from "@relay/protocol";
 
 import type { InternalSendResponse, Message } from "@relay/protocol";
 
 import type { ApiClient } from "./api-client.js";
 import type { Fanout } from "./fanout.js";
 import { attachSessions, INBOUND_FRAME_TYPES } from "./session.js";
@@ -47,12 +48,13 @@ function stubApi(overrides: Partial<ApiClient> = {}): ApiClient {
             environment_id: "env-1",
             user: "tuan",
             // The api now reports whether the user is banned, and a stub
             // that does not say is a stub that has not thought about it.
             banned: false,
             channel_ids: [CHANNEL],
+            revisions: {},
           }
         : null,
     backfill: async () => ({}),
     sendMessage: async () => committed(42),
         // The backstop reads this. The default answers what the session above says,
         // so a stub that never overrides it is a stub whose re-read agrees with its
@@ -108,31 +110,51 @@ function stubFanout(): Fanout & {
   subjects: string[];
   /** Inject a live frame at a moment the test chooses. This is how the
    * flagship race gets reproduced deterministically instead of hopefully:
    * the api stub calls it from inside the backfill, so "a message published
    * during the backfill window" is a line of code, not a stress loop. */
   emit: (message: Message) => void;
+  /** The same injection for a revision: an edit or a deletion arriving from
+   * another instance at a moment the test chooses. */
+  emitRevision: (revision: RevisionFabric) => void;
 } {
   const published: unknown[] = [];
   const subjects: string[] = [];
   let deliver: (channelId: string, message: Message) => void = () => {};
+  // The stub gained these because the interface did, and the typecheck is
+  // what said so: widening `Fanout` broke every fake that did not implement it, which is
+  // the compile-time half of the typing chapter's lesson about a module built and never passed.
+  let deliverRevision: (channelId: string, revision: RevisionFabric) => void = () => {};
   return {
     published,
     subjects,
     // Honest about the fabric's one rule: a frame published to a subject
     // this instance has not subscribed to does NOT arrive. Without that,
     // the stub would silently paper over the gap variant of the race.
     emit: (message) => {
       if (subjects.includes(message.channel)) deliver(message.channel, message);
     },
     onDelivery: (handler) => {
       deliver = handler;
     },
+    onRevision: (handler) => {
+      deliverRevision = handler;
+    },
     publish: async (message) => {
       published.push(message);
     },
+    publishRevision: async (revision) => {
+      published.push(revision);
+    },
+    // The same rule the message emitter honours: a revision published to a subject this
+    // instance has not subscribed to does not arrive.
+    emitRevision: (revision: RevisionFabric) => {
+      if (subjects.includes(revision.message.channel)) {
+        deliverRevision(revision.message.channel, revision);
+      }
+    },
     subscribe: async (channelId) => {
       subjects.push(channelId);
     },
     unsubscribe: async () => {},
     close: async () => {},
   };
@@ -570,12 +592,107 @@ describe("the socket (chapter 2.5)", () => {
     await nextFrame(socket, "connection.ack");
     await settle();
     expect(created(frames)).toEqual([42, 43]);
     socket.close();
   });
 
+  // ── the revision fabric reaches a socket ──────────────────────────────────
+  //
+  // ADR-24's whole point, tested at the seam where it would be invisible: the KIND now
+  // comes from the payload. Before this chapter `session.ts` stamped `message.created` at
+  // the call site, and the `updated` arm's payload IS a `Message` — so an edit routed to
+  // the old path would arrive looking exactly like a new message.
+
+  const tombstone = (seq: number, channel = CHANNEL) => ({
+    id: `id-${seq}`,
+    channel,
+    seq,
+    user: "dispatcher",
+    deleted_at: "2026-09-03T00:00:00.000Z",
+  });
+
+  it("an edit on the fabric arrives as message.updated, not message.created", async () => {
+    const fanout = stubFanout();
+    harness = await boot(stubApi({}), undefined, fanout);
+    const socket = new WebSocket(`${harness.url}?token=${await token()}`);
+    const frames = record(socket);
+    await nextFrame(socket, "connection.ack");
+    await settle();
+
+    fanout.emitRevision({ kind: "updated", message: { ...frame(42), text: "corrected" } });
+    await settle();
+
+    const revisions = frames.filter((f) => f.type === "message.updated");
+    expect(revisions).toHaveLength(1);
+    expect(revisions[0]).toMatchObject({ payload: { seq: 42, text: "corrected" } });
+    // THE FALSIFYING HALF. `created` reads `message.created`, and a call site that still
+    // decided the kind would put the edit there instead.
+    expect(created(frames)).toEqual([]);
+    socket.close();
+  });
+
+  it("a deletion arrives as message.deleted, with no text on it", async () => {
+    const fanout = stubFanout();
+    harness = await boot(stubApi({}), undefined, fanout);
+    const socket = new WebSocket(`${harness.url}?token=${await token()}`);
+    const frames = record(socket);
+    await nextFrame(socket, "connection.ack");
+    await settle();
+
+    fanout.emitRevision({ kind: "deleted", message: tombstone(43) });
+    await settle();
+
+    const deletions = frames.filter((f) => f.type === "message.deleted");
+    expect(deletions).toHaveLength(1);
+    expect(Object.keys((deletions[0] as { payload: object }).payload).sort()).toEqual([
+      "channel",
+      "deleted_at",
+      "id",
+      "seq",
+      "user",
+    ]);
+    expect(created(frames)).toEqual([]);
+    socket.close();
+  });
+
+  it("a buffering connection is sent no revision at all", async () => {
+    // Not an oversight — FR-016a. A resuming connection is about to be handed the CURRENT
+    // state of every message above its cursor, so an edit arriving mid-resume is already
+    // inside what it is being sent. Delivering it as well would show an update to a
+    // message the client has not yet received.
+    //
+    // Staged the way the chapter 2.7 tests above stage it: the api stub emits from INSIDE
+    // the backfill, which is the only moment `phase === "buffering"` is true.
+    const fanout = stubFanout();
+    harness = await boot(
+      stubApi({
+        backfill: async () => {
+          fanout.emitRevision({
+            kind: "updated",
+            message: { ...frame(42), text: "edited mid-resume" },
+          });
+          return { [CHANNEL]: { messages: [frame(42)], truncated: false } };
+        },
+      }),
+      undefined,
+      fanout,
+    );
+    const socket = new WebSocket(
+      `${harness.url}?token=${await token()}&cursor=${CHANNEL}:41`,
+    );
+    const frames = record(socket);
+    await nextFrame(socket, "connection.ack");
+    await settle();
+
+    expect(frames.filter((f) => f.type === "message.updated")).toEqual([]);
+    // …and the backfill itself still landed, so the silence was the phase check and not a
+    // connection that never came up.
+    expect(created(frames)).toEqual([42]);
+    socket.close();
+  });
+
   it("forwards per-channel truncation so the client pages history instead (FR-RTM-04)", async () => {
     const fanout = stubFanout();
     harness = await boot(
       stubApi({
         backfill: async () => ({
           [CHANNEL]: { messages: [frame(42)], truncated: true },
@@ -688,12 +805,63 @@ describe("the socket (chapter 2.5)", () => {
     // resume work, and a channel the caller is not in is not a question.
     expect(seen).toEqual({ [CHANNEL]: 41 });
     socket.close();
   });
 });
 
+describe("FR-RTM-05's six event kinds, each with a producer in this service", () => {
+  /** SC-008 — FR-RTM-05 NAMES SIX KINDS AND UNTIL THIS CHAPTER TWO HAD NO PRODUCER.
+   *
+   * *"The system shall emit real-time events for message creation, edit, deletion,
+   * membership change, presence change, and typing."* Six, and the edit and the deletion
+   * were the two the platform could not send.
+   *
+   * **READ AS TEXT, because nothing else can see a producer.** A zod union knows its
+   * members and knows nothing about what emits them; coverage sees a line execute and
+   * cannot see a line that was never written. `main.test.ts` established this shape in
+   * the connection-cap chapter — it parses `main.ts` and asserts every fabric it builds
+   * is injected — and the reason it had to is that the defect that chapter shipped was
+   * an ARGUMENT THAT WAS NOT THERE, with every line around it executing.
+   *
+   * **THE FIRST DRAFT PUT THIS IN `packages/protocol/src/frames.test.ts`**, where it
+   * cannot be written: that file tests schemas, and `grep` for "producer" in it returns
+   * nothing.
+   *
+   * THE SIX ARE WRITTEN OUT AND AN UNKNOWN MEMBER FAILS. A loop over some derived list
+   * would pass on a list that had quietly lost a member, which is the failure mode this
+   * repository has paid for five times — a pattern matching the examples in front of it
+   * rather than the set the rule names. */
+  it("names every one of FR-RTM-05's six event kinds in a send position in session.ts", async () => {
+    const source = await readFile(new URL("session.ts", import.meta.url), "utf8");
+
+    // FR-RTM-05's six, in its own order, mapped to the frame type this service sends.
+    const PRODUCERS: ReadonlyArray<readonly [string, string]> = [
+      ["message creation", "message.created"],
+      ["message edit", "message.updated"],
+      ["message deletion", "message.deleted"],
+      ["membership change", "membership.changed"],
+      ["presence change", "presence.changed"],
+      ["typing", "typing"],
+    ];
+    expect(PRODUCERS).toHaveLength(6);
+
+    for (const [clause, type] of PRODUCERS) {
+      // `type: "x"` as a literal in a send position. A mention in a comment does not
+      // count, which is why the pattern demands the `type:` key.
+      expect(source, `${clause} has no producer: no \`type: "${type}"\` in session.ts`).toMatch(
+        new RegExp(`type:\\s*"${type.replace(".", "\\.")}"`),
+      );
+    }
+
+    // AND THE CHECK CAN FAIL, which a grep that only ever passes cannot show. A frame
+    // type this service does NOT send must not match — `connection.ack` does send, so
+    // the negative case has to be a real frame nothing here emits.
+    expect(source).not.toMatch(/type:\s*"message.forged"/);
+  });
+});
+
 describe("INBOUND_FRAME_TYPES", () => {
   it("has exactly two members", () => {
     expect(INBOUND_FRAME_TYPES.size).toBe(2);
   });
 
   it("is exactly message.send and typing.send", () => {
services/gateway/src/fanout.itest.ts
@@ -1,12 +1,12 @@
 import { randomUUID } from "node:crypto";
 
 import { afterAll, beforeAll, describe, expect, it } from "vitest";
 
 import { createLogger } from "@relay/service-kit";
-import type { Message } from "@relay/protocol";
+import type { Message, RevisionFabric } from "@relay/protocol";
 
 import { createFanout, type Fanout } from "./fanout.js";
 
 // Chapter 2.6's real test: the one behaviour a single-process test CANNOT
 // show. Two fabric clients stand in for two gateway instances — same code,
 // same Redis, no knowledge of each other. If a message published by one
@@ -59,20 +59,50 @@ function nextDelivery(
         reject(new Error("no delivery within the deadline"));
       }
     }, 10);
   });
 }
 
+/** The revision fabric's equivalent. Separate queue, separate deadline,
+ * because the finding these tests exist to catch is a revision arriving on the OTHER
+ * callback — and a helper that watched both could not tell them apart. */
+function nextRevision(
+  instance: { revisions: Array<[string, RevisionFabric]> },
+  timeoutMs = 2000,
+): Promise<[string, RevisionFabric]> {
+  const started = Date.now();
+  return new Promise((resolve, reject) => {
+    const tick = setInterval(() => {
+      const revision = instance.revisions.shift();
+      if (revision) {
+        clearInterval(tick);
+        resolve(revision);
+      } else if (Date.now() - started > timeoutMs) {
+        clearInterval(tick);
+        reject(new Error("no revision within the deadline"));
+      }
+    }, 10);
+  });
+}
+
 /** One gateway instance's worth of fabric, with its deliveries recorded. */
-function instance(): { fanout: Fanout; deliveries: Array<[string, Message]> } {
+function instance(): {
+  fanout: Fanout;
+  deliveries: Array<[string, Message]>;
+  revisions: Array<[string, RevisionFabric]>;
+} {
   const deliveries: Array<[string, Message]> = [];
+  const revisions: Array<[string, RevisionFabric]> = [];
   const fanout = createFanout({ url, logger });
   fanout.onDelivery((channelId, message) =>
     deliveries.push([channelId, message]),
   );
-  return { fanout, deliveries };
+  fanout.onRevision((channelId, revision) =>
+    revisions.push([channelId, revision]),
+  );
+  return { fanout, deliveries, revisions };
 }
 
 describe("fan-out across instances", () => {
   let g1: ReturnType<typeof instance>;
   let g2: ReturnType<typeof instance>;
 
@@ -143,12 +173,102 @@ describe("fan-out across instances", () => {
       setTimeout(resolve, 100);
     });
     await expect(nextDelivery(g2, 300)).rejects.toThrow("deadline");
     await raw.fanout.close();
   });
 
+  it("delivers an edit on the revision subject and NOT on the message one", async () => {
+    // ADR-24. The `updated` arm's payload is a `Message`, which is exactly
+    // why this test names both callbacks. Route on the wrong one and an edit is shown to
+    // every member as a brand new message — and nothing about its shape would say so.
+    await g2.fanout.subscribe(CHANNEL);
+    g2.deliveries.length = 0;
+    await g1.fanout.publishRevision({
+      kind: "updated",
+      message: { ...messageOn(CHANNEL, 8), text: "corrected" },
+    });
+
+    const [channelId, revision] = await nextRevision(g2);
+    expect(channelId).toBe(CHANNEL);
+    expect(revision.kind).toBe("updated");
+    expect(revision.kind === "updated" && revision.message.text).toBe("corrected");
+    // THE HALF THAT FALSIFIES: no creation was delivered.
+    expect(g2.deliveries).toEqual([]);
+    await g2.fanout.unsubscribe(CHANNEL);
+  });
+
+  it("delivers a deletion, which cannot be a message at all", async () => {
+    await g2.fanout.subscribe(CHANNEL);
+    g2.deliveries.length = 0;
+    await g1.fanout.publishRevision({
+      kind: "deleted",
+      message: {
+        id: "00000000-0000-0000-0000-000000000009",
+        channel: CHANNEL,
+        seq: 9,
+        user: "linh",
+        deleted_at: new Date().toISOString(),
+      },
+    });
+
+    const [, revision] = await nextRevision(g2);
+    expect(revision.kind).toBe("deleted");
+    expect(revision.message.seq).toBe(9);
+    expect(g2.deliveries).toEqual([]);
+    await g2.fanout.unsubscribe(CHANNEL);
+  });
+
+  it("one subscribe covers both subjects, and one unsubscribe drops both", async () => {
+    // The reference count is shared by construction. The test that carries it is the
+    // NEGATIVE one: after the last holder leaves, a revision must go nowhere. Two counts
+    // would leave the revision subject subscribed after the message one closed.
+    // ITS OWN CHANNEL, because this is the one test in the file that asserts a subject is
+    // CLOSED — and `CHANNEL`'s reference count is whatever the tests above left it at.
+    // The first draft used `CHANNEL` and would have gone green on a held count.
+    const own = randomUUID();
+    await g2.fanout.subscribe(own);
+    await g1.fanout.publishRevision({ kind: "updated", message: messageOn(own, 10) });
+    const [, revision] = await nextRevision(g2);
+    expect(revision.message.seq).toBe(10);
+
+    await g2.fanout.unsubscribe(own);
+    await g1.fanout.publishRevision({ kind: "updated", message: messageOn(own, 11) });
+    await expect(nextRevision(g2, 300)).rejects.toThrow("deadline");
+    // …and the message subject is gone too, which is what makes them one count.
+    await g1.fanout.publish(messageOn(own, 12));
+    await expect(nextDelivery(g2, 300)).rejects.toThrow("deadline");
+  });
+
+  it("drops a revision the contract does not allow", async () => {
+    // A deletion carrying a text is the malformed case that matters: it is what a producer
+    // reaching for `messageSchema` would emit, and `strictObject` is what refuses it.
+    await g2.fanout.subscribe(CHANNEL);
+    const raw = instance();
+    await raw.fanout.publishRevision({
+      kind: "deleted",
+      message: {
+        id: "00000000-0000-0000-0000-000000000013",
+        channel: CHANNEL,
+        seq: 13,
+        user: "linh",
+        deleted_at: new Date().toISOString(),
+        // @ts-expect-error the point of the test: a key the schema forbids
+        text: "",
+      },
+    });
+    await expect(nextRevision(g2, 300)).rejects.toThrow("deadline");
+
+    // …and a well-formed one on the same subject still arrives, so the silence above was
+    // the schema and not a dead subscription.
+    await raw.fanout.publishRevision({ kind: "updated", message: messageOn(CHANNEL, 14) });
+    const [, good] = await nextRevision(g2);
+    expect(good.message.seq).toBe(14);
+    await raw.fanout.close();
+    await g2.fanout.unsubscribe(CHANNEL);
+  });
+
   // THE SUBJECT GRAMMAR'S TEST MOVED IN THE FAN-OUT CHAPTER, to
   // `packages/protocol/src/fanout.test.ts`, along with `subjectFor` itself. It
   // was a pure string assertion sitting in a suite that needs a running Redis;
   // it needed neither. What stays here is everything that genuinely needs the
   // fabric — two clients, a real subject, and a delivery.
 });
services/gateway/src/isolation.itest.ts
@@ -781,19 +781,46 @@ function sample(type: string, channel: string, user: string): unknown {
     user,
     text: "forged",
     created_at: new Date().toISOString(),
   };
   switch (type) {
     case "connection.ack":
-      return { type, payload: { user, cursor: {}, resume_ok: true, truncated: [] } };
+      // AND `revisions` FOR THE SAME REASON, ONE FIELD LATER. This chapter made it
+      // required on the ack, so this sample stopped satisfying `connectionAckSchema`
+      // and the forged frame came back `invalid_frame` — the refusal a phase before
+      // the one this loop asserts. The same finding as the `message.deleted` split
+      // below, in the same two files, one field later.
+      return {
+        type,
+        payload: { user, cursor: {}, resume_ok: true, truncated: [], revisions: {} },
+      };
     case "message.ack":
       return { type, payload: { seq: 1 } };
     case "message.created":
     case "message.updated":
-    case "message.deleted":
       return { type, payload: message };
+    // THE REVISIONS CHAPTER SPLIT THIS CASE OFF. `message.deleted` shared the `Message` above
+    // until this chapter gave the frame a payload with no text and a `deleted_at`. The
+    // forged frame then failed the SHAPE check and the refusal came back
+    // `invalid_frame` instead of `unknown_frame_type`.
+    //
+    // It was red for the right reason: this suite's claim is that a WELL-FORMED
+    // outbound frame is refused for its DIRECTION. A malformed one is refused a phase
+    // earlier and says nothing about direction — the same finding, in the second of the
+    // two files that build a forged frame this way, and `session.itest.ts` is the other.
+    case "message.deleted":
+      return {
+        type,
+        payload: {
+          id: message.id,
+          channel,
+          seq: 1,
+          user,
+          deleted_at: new Date().toISOString(),
+        },
+      };
     case "membership.changed":
       return { type, payload: { channel, user, change: "added" } };
     case "presence.changed":
       return { type, payload: { user, state: "online" } };
     case "typing":
       return { type, payload: { channel, user } };
services/gateway/src/resume.itest.ts
@@ -114,20 +114,21 @@ describe("resume across a real fabric", () => {
     // different fanout client on the same subject — publishes into the
     // window. Neither side coordinates; only the buffer saves this.
     harness = await boot({
       session: async () => ({
         environment_id: "env-1",
         user: "tuan",
         // The api now reports whether the user is banned, and a stub
         // that does not say is a stub that has not thought about it.
         banned: false,
         channel_ids: [CHANNEL],
+        revisions: {},
       }),
       backfill: async () => {
         await publishFromElsewhere(frame(43));
         await settle(150); // give Redis time to actually deliver it
         return {
           [CHANNEL]: { messages: [frame(42), frame(43)], truncated: false },
         };
       },
       sendMessage: async () => {
         throw new Error("not used");
@@ -152,20 +153,21 @@ describe("resume across a real fabric", () => {
     // Committed after the backfill's snapshot: it exists ONLY in the buffer,
     // and the flush is the only reason the client ever sees it.
     harness = await boot({
       session: async () => ({
         environment_id: "env-1",
         user: "tuan",
         // The api now reports whether the user is banned, and a stub
         // that does not say is a stub that has not thought about it.
         banned: false,
         channel_ids: [CHANNEL],
+        revisions: {},
       }),
       backfill: async () => {
         await publishFromElsewhere(frame(43));
         await settle(150);
         return { [CHANNEL]: { messages: [frame(42)], truncated: false } };
       },
       sendMessage: async () => {
         throw new Error("not used");
       },
       // Agrees with `session` above: this file is about the resume,
@@ -184,20 +186,21 @@ describe("resume across a real fabric", () => {
 
   it("goes live after the flush, with no buffering left behind", async () => {
     harness = await boot({
       session: async () => ({
         environment_id: "env-1",
         user: "tuan",
         // The api now reports whether the user is banned, and a stub
         // that does not say is a stub that has not thought about it.
         banned: false,
         channel_ids: [CHANNEL],
+        revisions: {},
       }),
       backfill: async () => ({
         [CHANNEL]: { messages: [frame(42)], truncated: false },
       }),
       sendMessage: async () => {
         throw new Error("not used");
       },
       // Agrees with `session` above: this file is about the resume,
       // and a backstop that disagreed with the connect would be a second subject
       // under test.
@@ -234,20 +237,21 @@ describe("resume across a real fabric", () => {
     //
     // One number different from the test above it. That is the whole bug.
     harness = await boot({
       session: async () => ({
         environment_id: "env-1",
         user: "tuan",
         // The api now reports whether the user is banned, and a stub
         // that does not say is a stub that has not thought about it.
         banned: false,
         channel_ids: [CHANNEL],
+        revisions: {},
       }),
       backfill: async () => ({
         [CHANNEL]: { messages: [frame(42)], truncated: false },
       }),
       sendMessage: async () => {
         throw new Error("not used");
       },
       // Agrees with `session` above: this file is about the resume,
       // and a backstop that disagreed with the connect would be a second subject
       // under test.
@@ -277,20 +281,21 @@ describe("resume across a real fabric", () => {
     // retiring the mark once a higher sequence arrived — which would see the 43,
     // drop the mark, and then deliver the 42 (research R3).
     harness = await boot({
       session: async () => ({
         environment_id: "env-1",
         user: "tuan",
         // The api now reports whether the user is banned, and a stub
         // that does not say is a stub that has not thought about it.
         banned: false,
         channel_ids: [CHANNEL],
+        revisions: {},
       }),
       backfill: async () => ({
         [CHANNEL]: { messages: [frame(42)], truncated: false },
       }),
       sendMessage: async () => {
         throw new Error("not used");
       },
       // Agrees with `session` above: this file is about the resume,
       // and a backstop that disagreed with the connect would be a second subject
       // under test.
@@ -307,33 +312,94 @@ describe("resume across a real fabric", () => {
     await settle(150);
     // 42 is the mark itself, arriving late from an instance that stalled between
     // its api call and its publish. It must still be suppressed.
     await publishFromElsewhere(frame(42));
     await settle(300);
 
     expect(created(frames)).toEqual([42, 43]);
     socket.close();
   });
 
+  /** FR-016 — THE GATEWAY'S HALF, AND ONLY THE GATEWAY'S HALF.
+   *
+   * **The task list put four tests here and they could not be written.** This file boots
+   * the gateway against a STUBBED api: `environment_id: "env-1"` and `user: "tuan"` are
+   * stub return values, there is no database behind it, and nothing in it can edit or
+   * delete a message. FR-016 and FR-016a are about what the BACKFILL returns, which is
+   * `services/api/src/internal/backfill.itest.ts` — real rows, a real repository, and
+   * the mapping in `backfill.controller.ts`. T058 to T061 live there.
+   *
+   * What remains here is worth one test: the gateway replays what it was handed,
+   * verbatim, as `message.created`. That is the seam ADR-24 did NOT change — a revision
+   * frame arriving on the fabric mid-resume is dropped for a buffering connection
+   * (`session.ts`'s `deliverRevision`), and the backfill's rows are the current state,
+   * so an edit made during the absence reaches the client as a creation carrying the
+   * new text and no `message.updated` at all.
+   *
+   * **THE ABSENCE IS THE ASSERTION.** A resume that carried `message.updated` for a
+   * message the client is receiving for the first time would be telling it that
+   * something it has never seen has changed. */
+  it("replays an edited message as message.created with its current text, and no message.updated", async () => {
+    harness = await boot({
+      session: async () => ({
+        environment_id: "env-1",
+        user: "tuan",
+        banned: false,
+        channel_ids: [CHANNEL],
+        revisions: {},
+        limits: { connect: 3_000, send: 600 },
+      }),
+      // The api's backfill returns ROWS AS THEY ARE NOW — which for an edited message
+      // is the corrected text under its original sequence. The stub says exactly that,
+      // and `backfill.itest.ts` proves the real one does.
+      backfill: async () => ({
+        [CHANNEL]: {
+          messages: [{ ...frame(42), text: "m42, corrected" }],
+          truncated: false,
+        },
+      }),
+      sendMessage: async () => {
+        throw new Error("not used");
+      },
+      memberships: async () => [CHANNEL],
+    });
+    const socket = new WebSocket(
+      `${harness.url}?token=${await token()}&cursor=${CHANNEL}:41`,
+    );
+    const frames = record(socket);
+    await settle(400);
+
+    expect(created(frames)).toEqual([42]);
+    const replayed = frames.find((f) => f.type === "message.created") as {
+      payload: Message;
+    };
+    expect(replayed.payload.text).toBe("m42, corrected");
+    // NO REVISION FRAME, which is FR-016a's decision showing on the wire.
+    expect(frames.filter((f) => f.type === "message.updated")).toEqual([]);
+    expect(frames.filter((f) => f.type === "message.deleted")).toEqual([]);
+    socket.close();
+  });
+
   it("suppresses nothing when the resume degraded", async () => {
     // A degraded resume tells the client to page history for every channel, so the
     // backfill it received is a fragment or nothing at all. A mark taken from it
     // would suppress messages the client never got — turning this chapter's
     // duplicate into a gap, which constitution II ranks worse.
     harness = await boot({
       session: async () => ({
         environment_id: "env-1",
         user: "tuan",
         // The api now reports whether the user is banned, and a stub
         // that does not say is a stub that has not thought about it.
         banned: false,
         channel_ids: [CHANNEL],
+        revisions: {},
       }),
       backfill: async () => {
         throw new Error("backfill unavailable");
       },
       sendMessage: async () => {
         throw new Error("not used");
       },
       // Agrees with `session` above: this file is about the resume,
       // and a backstop that disagreed with the connect would be a second subject
       // under test.
@@ -384,20 +450,21 @@ describe("two instances on one fabric", () => {
     sockets.push(socket);
     return record(socket);
   };
 
   const stub = (channels: string[]) => ({
     session: async () => ({
       environment_id: "env-1",
       user: "tuan",
       banned: false,
       channel_ids: channels,
+      revisions: {},
       limits: { connect: 3_000, send: 600 },
     }),
     backfill: async () => ({}),
     sendMessage: async () => {
       throw new Error("not used");
     },
     // The same list `session` answers with, so the backstop confirms
     // what the connect already established and changes nothing.
     memberships: async () => channels,
   });
services/gateway/src/public-surface.itest.ts
@@ -28,12 +28,20 @@ import { docsUrl } from "@relay/protocol";
 // build-output seam, because there is no public way to create either — that has
 // been true since chapter 2.8 and is still true. Everything downstream of the
 // credential is public HTTP: `POST /v1/channels`, `POST
 // /v1/channels/:id/members`, `POST /auth/dev-token`, `POST
 // /v1/channels/:id/messages`, and `ws://…/v1/ws`.
 //
+// THAT LIST IS WHAT THIS TEST CALLS, NOT AN INVENTORY OF THE PUBLIC SURFACE, and the
+// distinction is worth a line because the revisions chapter read it the other way. A task in
+// that chapter said three new routes made the sentence untrue and scheduled an edit
+// here; the sentence is about the path this file walks, which those routes are not
+// part of, so it was true before and after. The inventory of the surface lives in
+// `services/api/src/isolation/targets.ts`, where a check DERIVES it from the running
+// application rather than restating it in prose.
+//
 // `packages/outsider` will make the stronger version of this claim in Phase 10 —
 // a package mechanically forbidden from importing workspace code at all. This one
 // runs where the coverage lane can see it.
 
 const silent: Logger = createLogger("gateway", () => {});
 const HERE = dirname(fileURLToPath(import.meta.url));

Ratchet coverage nổ sau cùng, trên messages.controller.ts. Câu trả lời của nó là xoá hai thứ thay vì test chúng: một ?? "unknown" trên tác giả của frame xoá, thứ vừa không với tới được vừa sẽ đặt cái từ đó lên đường truyền như tên của ai đó, và ba bản sao của cùng hai giá trị dự phòng ngữ cảnh, mỗi chỗ publish một bản.

Rồi một bài test làm nốt phần còn lại, và nó được đo chứ không được suy luận. Bỏ qua đúng bài test đó rồi chạy lại cả loạt thì đọc ra 91.83 / 85.71 / 100 / 93.75 với ba câu lệnh chưa phủ — cái 400 mà mỗi handler trong ba handler mới ném ra khi subject của token chẳng có dòng user nào. Có nó thì: 97.95 / 92.85 / 100 / 97.91, còn lại một câu lệnh. Nên bài test đáng giá +6.12 câu lệnh, +7.14 nhánh và +4.16 dòng, và nó là thứ dọn sạch các sàn thừa hưởng.

Nhánh kết thúc ở 92.85 so với mức thừa hưởng 87, nên cái ghim đó đi lên 92. Dòng thì đi xuống từ 100 còn 97, và câu lệnh vẫn chưa phủ thì được gọi tên chứ không được đếm: lệnh ném thu hẹp trong edit, thứ nổ khi một request tới được handler đó mà không có user subject. @Accepts("user") trên phương thức nghĩa là guard đã từ chối sẵn mọi credential có thể tạo ra nó — nên nó không với tới được chừng nào decorator còn ở đó, và nó ở đó để kêu to nếu có ai gỡ decorator đi. Một dấu ! sẽ khôi phục 100% bằng cách dời giả định sang một chỗ mà một thay đổi decorator không làm nó mất hiệu lực, và đó là cuộc đánh đổi mà file này từ chối.

repository.ts đi hướng ngược lại và cái ghim của nó được để yên: 92.66 khi chương sender ghim nó ở 92, 92.97 ở đây, với lượng phương thức mới của bốn route ở giữa. Một ratchet chạy theo mọi lần đọc đi lên là một ratchet mà ai đó sẽ phải hạ xuống sau này.

vitest.coverage.config.mts
@@ -214,17 +214,69 @@
         // was just written from a request whose schema requires `text`. So the false
         // side cannot be reached from here. The ratchet has removed unreachable code
         // three times in this repository; this one stays, because `messageSchema` types
         // `text` as non-nullable and a null would publish a frame the delivery side
         // drops silently. A guard against a state the type system forbids is cheap; the
         // alternative is a silent drop.
+        //
+        // THIS CHAPTER MOVED THIS FILE IN BOTH DIRECTIONS, and the LINES number is the one
+        // that had to come down. The chapter added two routes to it — an edit and a
+        // deletion, each resolving a caller, each publishing — against inherited pins of
+        // 96 statements / 87 branches / 100 functions / 100 lines.
+        //
+        // MEASURED, WITH THE DELETIONS BELOW ALREADY MADE AND THE NARROWING TEST HELD
+        // BACK: **91.83 / 85.71 / 100 / 93.75**, three uncovered statements at lines 297,
+        // 303 and 401. Statements and lines both red.
+        //
+        // WHAT WAS TESTED, and it was the largest part: every one of those routes throws a
+        // 400 when the token's subject has no user row, and nothing exercised it. The send
+        // path had had that test since the channel-control chapter; the two new routes and
+        // the history route beside them did not. **One test covering all three is worth
+        // +6.12 statements, +7.14 branches and +4.16 lines**, and it clears 303 and 401 —
+        // measured by skipping that one test and running the battery again, rather than by
+        // reasoning about which lines it touches.
+        //
+        // WHAT WAS REMOVED RATHER THAN TESTED, which is the ratchet's preferred outcome
+        // and the fifth time it has produced one:
+        //
+        //   - `deleted.user ?? "unknown"` on the deletion frame. `deleteMessage` refuses
+        //     a senderless row (FR-018) before it can return, so the arm was
+        //     unreachable — AND the value it would have produced was a lie: the word
+        //     "unknown" on the wire as somebody's name. The narrowing moved to the
+        //     repository, where the foreign-key argument for it lives.
+        //   - Three copies of `req.requestId ?? "unknown"` and
+        //     `req.principal?.environmentId ?? "unknown"`, one per publish site, which is
+        //     six uncovered arms for two distinct ones. `publishContext(req)` is one
+        //     function called three times. The fallbacks stay — a log line saying
+        //     `unknown` is findable where one saying `undefined` reads like a broken
+        //     logger — but the count stops growing with every route that publishes.
+        //
+        // FINAL: **97.95 / 92.85 / 100 / 97.91**. Branches finished ABOVE the 87 this
+        // chapter inherited, so that pin goes UP to 92 — 0.85 of headroom, which is the
+        // margin `repository.ts` above was pinned with (92 against a measured 92.66) and
+        // for the same reason: a floor at the reading itself goes red on the next run for
+        // no change to the code.
+        //
+        // AND `repository.ts` MOVED UP RATHER THAN DOWN, which is worth one line because
+        // it is the file this chapter added the most code to: 92.66 when the sender chapter
+        // pinned it, **92.97 measured here**, with four routes' worth of new methods in
+        // between. Its pin is left at 92 — the headroom widened on its own, and a ratchet
+        // that follows every upward reading is a ratchet somebody has to lower later.
+        //
+        // LINES DROP FROM 100 TO 97, and the one statement still uncovered is named: line
+        // 297, the narrowing throw in `edit`, which fires when a request reaches that
+        // handler with no user subject. `@Accepts("user")` on the method means the guard
+        // has already refused every credential that could produce it, so it is unreachable
+        // while that decorator is there — and it is there to be loud if somebody removes
+        // it. A `!` would restore 100% by moving the assumption somewhere a decorator
+        // change cannot invalidate, which is the trade this file declines to make.
         "services/api/src/messages/messages.controller.ts": {
-          branches: 87,
+          branches: 92,
           functions: 100,
-          lines: 100,
-          statements: 96,
+          lines: 97,
+          statements: 97,
         },
       },
     },
   },
   plugins: [
     swc.vite({

Các yêu cầu đang ở đâu

FR-MSG-07, FR-MSG-08 và FR-MSG-10 đã dựng xong. Cả sáu kiểu của FR-RTM-05 đều có bên tạo ra, và một bài test đọc session.ts như văn bản để giữ điều đó đúng — chẳng gì khác nhìn thấy được một bên tạo ra, bởi một union zod biết các thành viên của nó chứ không biết cái gì phát ra chúng, còn coverage thì thấy một dòng chạy và không thấy được một dòng chưa bao giờ được viết. FR-WHK-02 đi từ ba trên tám kiểu event lên năm.

Thứ chương này không làm: nhật ký kiểm toán của FR-MOD-03. Lần xoá ghi lại loại principal nào đã gỡ một message, trong messages.metadata, còn credential nào nó trình ra là một câu hỏi khác kèm theo một kỳ hạn lưu trữ. Ranh giới được viết ra chứ không được giả định, và đó là điều lương thiện nhất chương này làm được với một yêu cầu P3 mà nó làm cho với tới được.

Và người thực hiện lần xoá thì được ghi lại mà không đọc được. Chẳng đường đọc nào phơi nó ra — không history, không phần liệt kê, không frame, không event webhook, tất cả đều mang tác giả của message bởi đó là cái tên mà một client đã cầm sẵn. Bịa ra một bề mặt đọc sẽ là quyết định, trong khi chẳng yêu cầu nào đòi, rằng ai được biết một người vận hành đã gỡ message của ai đó.