Building Relay

Phần 3 · Chương 3.23

Những lời ai đó muốn lấy lại

Bạn sẽ tạo ra: FR-MSG-07, FR-MSG-08 và FR-MSG-10 được dựng, và hai kind cuối trong sáu kind của FR-RTM-05 lần đầu có bên phát: một ngữ pháp subject thứ năm, `revision:{channel_id}`, mang cả hai loại thay đổi với cái kind nằm trong payload, bởi một bia mộ không phải `Message` còn một lần sửa thì là và sẽ không phân biệt được với một lần tạo; bảng `message_edits` dựng lại đúng như SAD §6.1 đã công bố, kèm cái giá của khoá chính ghép được viết ra; hai mã lỗi thay cho 403 chung chung, bởi không credential nào cấp quyền tác giả và không thay đổi phân quyền nào biến một tin nhắn thành của bạn; một phép kiểm tenancy được dạy rằng khả năng tới được không phải là kề nhau, sau khi nó từ chối cái bảng mới trong bốn mili giây; và cái mép mềm duy nhất được ghi lại thay vì đóng lại — một tin nhắn cũ hơn con trỏ của client mà đổi trong lúc mất kết nối thì không sinh frame nào **và không có lỗ hổng số thứ tự nào**, nên cơ chế sửa chữa mọi frame bị lỡ khác chẳng thấy gì để sửa · khoảng 75 phút, bao gồm bài tập

Tài liệu gốc: SAD — Tài liệu kiến trúc phần mềm (tiếng Anh)

Ai đó gửi một tin nhắn có lỗi chính tả. Ai đó gửi một tin nhắn nhầm kênh. Ai đó gửi một tin nhắn mà ngay lập tức thấy hối hận. Mọi sản phẩm chat đều trả lời cả ba, còn Relay thì suốt hai mươi hai chương chưa trả lời cái nào — trong khi vẫn mang theo, từ đầu đến giờ, 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 vậy, một text cho phép null mà chính chú thích của schema gọi là bia mộ cũng vậy, một metadata JSONB NOT NULL DEFAULT '{}' cũng vậy. docs/05-sad.md §6.1 đã công bố bảng message_edits từ bản nháp đầu tiên. Không có gì trong nền tảng ghi vào bất kỳ thứ nào trong số đó. Các đường đọc thì được dựng để chịu được điều ấy: danh sách kênh có quy tắc cho một text null, phần backfill khi khôi phục thì bỏ qua nó, và cả hai đều được kiểm thử với những bia mộ cắm bằng tay bằng SQL thô, bởi không đường mã nào tạo ra nổi một cái.

Đó là chiều ngược lại của khoảng trống thường gặp. Khoảng trống thường gặp là một bên ghi mà 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ưa ai viết, và lý do thì khó chịu theo một cách đáng ngồi lại với nó: phần đọc thì dễ, còn các quyết định mới là phần khó.

Một bia mộ không phải là một tin nhắn

Trên đường truyền đã có frame message.deleted từ chương 1.3, và payload của nó chính là cùng một Messagemessage.created mang: một id, một kênh, một số thứ tự, một người dùng, một text, và một mốc thời gian tạo.

Một tin nhắn đã xoá thì không có text. Đó không phải chi tiết của bản hiện thực này — đó là FR-MSG-08, mệnh đề nói rằng một lần xoá "shall replace its content with a tombstone", và messages.text cho phép null đúng vì lý do đó. Vậy nên cái frame đã công bố không mô tả nổi chính thứ mà nó được đặt tên theo, và lựa chọn là: nới messageSchema.text thành nullable, hay cho lần xoá một payload của riêng nó.

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

packages/protocol/src/frames.ts
@@ -69,9 +69,41 @@ export const messageUpdatedSchema = z.strictObject({
   payload: messageSchema,
 });
 
+/** THE ONE FRAME THAT DOES NOT CARRY A MESSAGE, and chapter 3.23 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`. Chapter 3.23'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({
@@ -169,6 +201,10 @@ export const frameSchema = z.discriminatedUnion("type", [
 // 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>;
+/** Chapter 3.23. 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>;

Ngữ pháp subject thứ năm

Giữa các instance gateway, một frame băng qua Redis pub/sub trên một subject. Có bốn ngữ pháp, 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} ở 3.19, member:{channel_id}member:{env}:{user} ở 3.20, typing:{channel_id} ở 3.21.

chan: mang một Message. packages/protocol/src/fanout.ts nói thế bằng chính lời của nó — "the fan-out has always carried a wire frame's payload rather than a shape of its own" — và từ đó suy ra hai điều, điều thứ hai là chí mạng.

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

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

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 ngữ pháp và cái thứ năm. Lần sửa có thể đi trên chan: và sẽ không phân biệt được với một lần tạo; bia mộ thì không đi trên đó được chút nào.

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

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

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

Cái tiền tố được export ra và bên subscribe hỏi chính module này xem một subject có phải của nó không. Gateway giữ một Redis subscriber 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à nơi thứ hai biết ngữ pháp này, đúng thứ mà quy tắc trên cấm.

services/gateway/src/fanout.ts
@@ -1,6 +1,10 @@
 import {
   messageCreatedSchema,
   subjectForChannel,
+  subjectForChannelRevision,
+  isChannelRevisionSubject,
+  revisionFabricSchema,
+  type RevisionFabric,
   type Message,
 } from "@relay/protocol";
 import type { Logger } from "@relay/service-kit";
@@ -45,6 +49,18 @@ export interface Fanout {
   /** Publish a committed message to its channel's subject. A failure here
    * costs delivery latency, never durability. */
   publish(message: Message): Promise<void>;
+  /** Chapter 3.23, 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 chapter 3.21
+   * 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>;
@@ -60,6 +76,7 @@ export function createFanout({
   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
@@ -74,6 +91,18 @@ export function createFanout({
       logger.log("error", "fanout.unparsable", { subject });
       return;
     }
+    // CHAPTER 3.23. 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.
@@ -89,6 +118,25 @@ export function createFanout({
     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 chapter 3.23'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(
@@ -107,13 +155,25 @@ 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);
       }

Cả hai subject được subscribe 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ữ socket cho một kênh thì muốn cả tin nhắn lẫn bản sửa đổi của kênh đó, và bỏ cái này mà giữ cái kia sẽ để các bản sửa tới một kênh không ai lắng nghe — hoặc tệ hơn, ngược lại.

services/gateway/src/session.ts
@@ -10,6 +10,7 @@ import {
   type ErrorCode,
   type Frame,
   type Message,
+  type RevisionFabric,
   type TypingFabric,
   isErrorCode,
   type MembershipFabric,
@@ -349,6 +350,38 @@ export function attachSessions({
   }
   fanout?.onDelivery(deliver);
 
+  /** An edit or a deletion arriving from the revision fabric (chapter 3.23, 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 (chapter 3.21, T043).
    *
    * **DO NOT COPY `deliverPresence` BELOW, WHICH IS DELIBERATELY UNFILTERED.**

Đó là thay đổi mà ADR-24 tồn tại vì nó, và nó dài ba dòng. Giờ cái kind lấy từ payload. Hàm deliver ngay phía trên vẫn đóng dấu message.created, và đúng như vậy, bởi mọi thứ trên chan: thật sự đều là một lần tạo.

Hai mã lỗi, và vì sao không dùng forbidden

Một người dùng cuối sửa tin nhắn của người khác. Cái gì trả về?

ProtocolErrorFilter ánh xạ một 403 trần thành forbidden, nên để ngỏ câu hỏi này chính là đã trả lời nó. Và mục forbidden trong docs/08-error-reference.md tự loại mình ra hai lần: nó tự gọi mình là "the generic case: where a more specific code exists … that one is sent instead", và hành động dành cho client là "nothing the client can retry. This is a change of credential or of permission."

Quyền tác giả không phải cái nào trong hai thứ đó. 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 giành đượ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ẽ không tìm thấy gì.

packages/protocol/src/codes.ts
@@ -170,6 +170,57 @@ export const ERROR_CODES = {
   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",
+  // CHAPTER 3.23, 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",
+  // CHAPTER 3.23, 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 (3.23) 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 3.2 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:

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

Lập luận ấy sai, và phải viết mã ra mới thấy. Không ai không được phép sửa lại chạm tới lời từ chối đó: phép kiểm quyền tác giả chạy trước khi nhìn tới text, nên một người lạ bị từ chối bằng not_message_author bất kể tin nhắn có là bia mộ hay không. Người duy nhất thấy câu trả lời về bia mộ là chính tác giả — và tác giả đọc được tin nhắn đó trong lịch sử, bởi FR-011 giữ tin nhắn đã xoá đúng vị trí cũ.

Vậy một 404 ở đó nói với người gọi rằng một tin nhắn họ đang nhìn thì không tồn tại. Đó chính là lỗi của chương 2.8 nhưng nằm gọn trong một tài nguyên: chương ấy phát hiện POST trả 404 cho một kênh mà GET trả 200, và cách sửa 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 SAD đã công bố mà khô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/0014_message_edits.sql
-- Chapter 3.23 — 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 `0008_message_edits.sql` — a number already taken by
-- `0008_limit_policy.sql` — containing six whole CREATE TABLEs and fourteen
-- ALTERs replayed from migrations 0008 through 0013. Its snapshot sits at 0007
-- while this directory sits at 0013, because those six were hand-written too.
-- Applied to any database that has run them, the generated file fails on
-- `CREATE TABLE "quota_notifications"`. 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. Chapter 3.13 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)
);

Ba cột và một khoá chính ghép. Mô hình dữ liệu của chính chương này lại cho cái bảng một cột thứ tư — id UUID PRIMARY KEY — và nói, ngay trong 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 phải. Mười một lượt phân tích đi qua chỗ đó, và lý do đáng được gọi tên: mọi bộ kiểm tra trong kho này đều so định danh, còn đây là một mệnh đề. Tên bảng xuất hiện trong kế hoạch, trong danh sách việc và trong mô hình dữ liệu, và không có gì đọc đoạn DDL bên dưới nó.

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

Tạo xong cái bảng, làn kiểm thử cách ly đỏ 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à đấu trường endpoint không hỏi: có đường nào từ các dòng của mỗi bảng quay về đúng một tenant không? Một bảng không có đường như thế là một chỗ rò rỉ chưa có endpoint. Và cả ba cách khắc phục nó gợi ý đều sai ở đây. environment_id là cột SAD không công bố; một khoá ngoại thứ hai vẫn vướng phản đối ấy cộng thêm một lần phi chuẩn hoá; còn gọi một bảng chứa nội dung tin nhắn là một phần của xương sống thì không biện minh nổi, mà chính chú thích của danh sách ấy đòi phải biện minh.

Vậy là câu truy vấn 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 hiện thực chỉ chấp nhận đường dài đúng một bước — một khoá ngoại đáp thẳng vào một bảng mang environment_id — và điều đó đúng với mọi bảng từng tồn tại cho tới giờ. message_edits cách hai bước: nó tham chiếu messages, bảng này tham chiếu channels, và bảng ấy mới mang cột kia.

services/api/src/db/catalogue.ts
@@ -20,14 +20,18 @@ import type { Db } from "./client";
 // 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 (chapter 3.23). 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;
@@ -114,7 +118,7 @@ export interface CatalogueRow extends Record<string, unknown> {
 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'
@@ -123,6 +127,52 @@ export async function classifyTables(db: Db): Promise<TableClassification[]> {
         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.
+      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 (chapter 3.23). 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,
@@ -133,15 +183,10 @@ export async function classifyTables(db: Db): Promise<TableClassification[]> {
           -- the row arrives as the literal string {channels,users} and
           -- iterating it yields a brace, which is how this was found.
           -- (No backticks in here: this is inside a template literal.)
-          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

Đây chính là kiểu sai lầm mà kho mã này trả giá mãi — một khuôn mẫu khớp với những ví dụ đang bày ra trước mặt thay vì khớp với tập hợp mà quy tắc gọi tên — và lần này nó xuất hiện trong chính phép kiểm cho sản lượng cao nhất toàn cây mã.

Lần sửa

Một transaction, và dòng lịch sử là lý do: một tin nhắn cập nhật ở câu lệnh này còn lịch sử ghi thêm ở câu lệnh kia thì có thể sập ở giữa, và cái còn lại là một tin nhắn mà không ai giữ nổi văn bản cũ của nó.

services/api/src/db/repository.ts
@@ -23,6 +23,7 @@ import {
   environments,
   humans,
   members,
+  messageEdits,
   readPositions,
   memberships,
   messages,
@@ -38,7 +39,12 @@ import {
   webhookDisableNotifications,
   webhookEndpoints,
 } from "./schema";
-import { membershipEvent, messageCreatedEvent } from "../outbox/event";
+import {
+  membershipEvent,
+  messageCreatedEvent,
+  messageDeletedEvent,
+  messageUpdatedEvent,
+} from "../outbox/event";
 import { capsFor, type Caps } from "../quotas/config";
 import { thresholdsCrossed } from "../quotas/policy";
 import { creditFor, highWaterMark } from "../quotas/credit";
@@ -2250,12 +2256,33 @@ export interface MessageRow {
   seq: number;
   text: string | null;
   created_at: string;
+  /** When it was last edited, or `null` (chapter 3.23, 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 (chapter 3.23, 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
@@ -2311,6 +2338,55 @@ export class SenderNotPermittedError extends Error {
   }
 }
 
+/** The message id does not name a message of this channel (chapter 3.23, 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 (chapter 3.23, 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 (chapter 3.23, 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}`);
@@ -3996,8 +4072,9 @@ export class Repository {
       //
       // And that gate is only honest because chapter 3.15 made the public route
       // supply a user. It called `messages.send(channelId, body)` with none, and
-      // `MessagesController` declares no `@Accepts` — so the guard falls back to
-      // `EITHER` and a user token was accepted there. A check gated on a parameter
+      // `MessagesController` declared no `@Accepts` at the time — so the guard fell
+      // back to `EITHER` and a user token was accepted there. Chapter 3.17 declared it;
+      // the third of three copies of this sentence, all corrected in 3.23. 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.
       //
@@ -4327,6 +4404,420 @@ export class Repository {
     });
   }
 
+  /** Change what a message says (chapter 3.23, 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.
+      const [updated] = await tx
+        .update(messages)
+        .set({ text, editedAt: sql`now()` })
+        .where(eq(messages.id, messageId))
+        .returning({ editedAt: messages.editedAt });
+      const editedAt = updated!.editedAt!;
+
+      // 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 (chapter 3.23, 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 (chapter 3.23, 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. Chapter 3.23'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. Chapter 3.15'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!);
+
+      // 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 (chapter 3.23, 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 (chapter 3.23)?
+   *
+   * 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;
+  }
+
   /** Refuse the send if a hard cap is already met (chapter 3.10, FR-RTL-08).
    *
    * Reads the caps and the usage in ONE query, in the transaction that is about to
@@ -4744,6 +5235,19 @@ export class Repository {
       user: users.externalId,
       text: messages.text,
       created_at: messages.createdAt,
+      // WHEN IT WAS LAST EDITED, OR NULL (chapter 3.23, 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(
@@ -4780,7 +5284,14 @@ export class Repository {
           .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

Thứ không nằm trong danh sách SET chính là FR-002 viết dưới dạng mã. sequence, channelId, userIdcreatedAt đều vắng mặt, lastActivityAt cũng vậy — đường gửi dịch chuyển cột đó cùng lúc với số thứ tự, một cách có chủ ý, còn một lần sửa thì không được, bởi danh sách kênh sắp theo hoạt động gần nhất và sửa một lỗi chính tả không phải một tin nhắn mới. Một bài kiểm thử chỉ có thể khẳng định 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 nằm trong hình dạng của câu lệnh.

services/api/src/messages/messages.controller.ts
@@ -2,9 +2,13 @@ import {
   BadRequestException,
   Body,
   Controller,
+  Delete,
   Get,
+  HttpCode,
   Inject,
+  NotFoundException,
   Param,
+  Patch,
   Post,
   Query,
   Req,
@@ -18,12 +22,16 @@ 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";
 
@@ -44,6 +52,28 @@ 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. Chapter
+ * 3.23 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).
@@ -86,8 +116,14 @@ export class MessagesController {
     // 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` declares no `@Accepts`, so
-    // the guard falls back to `EITHER` and a user token is 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 CHAPTER 3.17, and it took until 3.23 to say so. That chapter
+    // added `@Accepts("application", "user")` at :64 — twenty-five lines above this
+    // sentence — and left three copies of the sentence describing its absence, here, in
+    // `messages.itest.ts:161` and in `repository.ts:3999`. 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
@@ -208,10 +244,7 @@ export class MessagesController {
           text: message.text,
           created_at: message.created_at,
         },
-        {
-          requestId: req.requestId ?? "unknown",
-          environmentId: req.principal?.environmentId ?? "unknown",
-        },
+        publishContext(req),
       );
     }
 
@@ -229,6 +262,233 @@ export class MessagesController {
     };
   }
 
+  /** Change what a message says (chapter 3.23, 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 (chapter 3.23, 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 (chapter 3.23, 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:31` argues about and chapter 3.12 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 (chapter 3.23, 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 (chapter 3.23, 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,

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à khai báo chứ không phải phép kiểm trong thân hàm:

PATCH :messageId          @Accepts("user")           một lần sửa là của tác giả, và một
                                                      principal ứng dụng không có tác giả
DELETE :messageId         (kế thừa cả hai)           tác giả, hoặc một khoá tenant (FR-MOD-02)
GET :messageId/edits      @Accepts("application")    một mặt kiểm duyệt, không dành cho
                                                      người dùng cuối

Lớp khai báo @Accepts("application", "user"), còn guard đọc bằng getAllAndOverride — nên một route thêm vào đây mà không khai báo sẽ kế thừa cả hai, và một khai báo vắng mặt không hề trung tính. Bỏ @Accepts("application") khỏi route lịch sử sửa và bài kiểm thử khẳng định 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 tin nhắn trong mọi kênh 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 tin nhắn đã xoá thì vẫn 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 yêu cầu đã hết giờ thì không nên nhận lỗi cho một trạng thái mà nó vừa xin và đã có.

Rắc rối là "thành công" không phải cái yêu cầu. Một lần xoá thứ hai mà ghi lại dòng dữ liệu sẽ dịch deleted_at, nên một client đã đọc bia mộ rồi sẽ thấy mốc thời gian đổi mà không vì lý do gì — và một sự kiện thứ hai sẽ bắn mọi webhook đang đăng ký thêm một lần nữa cho cùng một lần xoá. Cả hai đều vô hình từ mã trạng thái, vốn là 204 trong cả hai trường hợp.

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 một tin nhắn. Mọi chuyển trạng thái đều giữ nguyên số thứ tự, và đó là thứ khiến một bia mộ không để lại lỗ hổng còn một lần sửa thì vô hình với con trỏ.

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

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

FR-017 yêu cầu phải phát biểu điều này cho mọi đường đọc, còn FR-017a yêu cầu phát biểu ấy được suy ra từ mã tại thời điểm viết chứ không từ một danh sách, bởi một danh sách sẽ cũ đi còn mã 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 này đếm ra ba cho tới khi có người đo.

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

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

Điểm mù của con trỏ

Đây là phần 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 con trỏ và một lần xoá trên con trỏ. Cái này để lại lỗ hổng. Cái kia không để lại gì cả.

Một client khôi phục sẽ trình ra một con trỏ — một vị trí trong dãy thứ tự của kênh — và nhận về mọi thứ nằm trên nó. Một tin nhắn bị xoá ở trên con trỏ sẽ bị bỏ khỏi backfill, và client thấy một số thứ tự khuyết: một lỗ hổng, đúng thứ tín hiệu mà SDK dùng để sửa chữa qua lịch sử. Cơ chế ấy có từ chương 2.7 và nó hoạt động.

Một tin nhắn bị sửa ở dưới con trỏ thì không sinh frame nào và không có lỗ hổng nào. Các số thứ tự trên con trỏ vẫn liền mạch; chẳng có gì để phát hiện. Client cứ thế hiển thị một tin nhắn mà văn bản đã đổi trong lúc nó vắng mặt, và không gì trong giao thức sẽ nói cho nó biết.

Những gì các dụng cụ bắt được

Danh sách mục tiêu của đấu trường cách ly suy ra mọi route từ ứng dụng đang chạy rồi so với một bảng duy trì bằng tay, nên nó đỏ ngay ở bản dựng thêm route. Nó đỏ hai lần — một lần gọi tên một route được khai báo trước khi được viết, đó là chiều bắt được một lần đổi tên.

services/api/src/isolation/targets.ts
@@ -180,9 +180,44 @@ export const CLASSIFICATIONS: readonly Classification[] = [
     accepts: "application",
     shape: "read",
   },
+  // CHAPTER 3.23'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; chapter 3.23'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",
+  },
 
   // ── write, public ───────────────────────────────────────────────────────────
   { method: "POST", path: "/v1/channels/:channelId/messages", accepts: "application", shape: "write" },
+  // CHAPTER 3.23'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.
+  //
+  // 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" },
+  // CHAPTER 3.23'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" },
   // Chapter 3.12's two new routes, and the order they were added in is the point.
   // The derivation found them first: `targets.itest.ts` went from 22 to 24 and
   // named them as unclassified, on the build that registered the module and
services/api/src/isolation/targets.itest.ts
@@ -123,11 +123,17 @@ describe("the gauntlet's target list derives from the running application", () =
   // in the repository on the strength of five previous occasions; this is the
   // sixth, and the first where the route being added was a REVIVAL of one the
   // classification list had never carried.
+  //
+  // **AND AGAIN IN 3.23, IN BOTH DIRECTIONS AT ONCE.** That chapter declared its two
+  // routes in `targets.ts` before writing the second one, so one run named
+  // `GET …/:messageId/edits` as an entry matching no derived target — the direction
+  // that catches a rename — while the counts named the one that did exist. Two routes
+  // in this phase, and the deletion's arrives in the next.
   it("has grown from chapter 3.12's 24 by exactly the routes since", () => {
     // This assertion moves ONE line per phase, which is the point: a phase that
     // adds a route and forgets to classify it fails the test above, and a phase
     // that adds a route nobody planned fails this one.
-    const BUILT_SO_FAR = 15;
+    const BUILT_SO_FAR = 18;
     expect(derived.length).toBe(24 + BUILT_SO_FAR);
   });
 
services/api/src/db/schema.ts
@@ -23,9 +23,10 @@ import {
 // 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 arrived in 3.3 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 arrived in
+// 3.3 and is at the bottom of this file. `message_edits` ARRIVED IN 3.23 and
+// is below `messages` — the list above said "edit chapter" and this is it.
 
 // The tenancy hierarchy (chapter 3.1). Everything from here to `members`
 // below sits ABOVE the environment boundary: these rows say who owns a
@@ -396,6 +397,47 @@ export const messages = pgTable(
   ],
 );
 
+// WHAT A MESSAGE USED TO SAY (chapter 3.23, 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, chapter 3.23). 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
services/api/src/messages/messages.schema.ts
@@ -27,6 +27,32 @@ export const sendMessageBodySchema = z.strictObject({
 
 export type SendMessageBody = z.infer<typeof sendMessageBodySchema>;
 
+/** The edit body (chapter 3.23, 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` CLAMPS rather than
 // rejects — a client asking for 500 gets 200 and a next_cursor, because
services/api/src/messages/messages.service.ts
@@ -11,6 +11,10 @@ import {
   ChannelArchivedError,
   UserBannedError,
   ChannelNotFoundError,
+  type EditedMessageRow,
+  MessageDeletedError,
+  MessageNotFoundError,
+  NotMessageAuthorError,
   Repository,
   type MessageRow,
   type MessageWithSender,
@@ -18,7 +22,7 @@ import {
 } from "../db/repository";
 import { QuotaExceededError } from "../quotas/quota.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,
@@ -169,6 +173,137 @@ export class MessagesService {
     }
   }
 
+  /** Change what a message says (chapter 3.23, FR-001, FR-013, FR-014).
+   *
+   * THE VISIBILITY CHECK FIRST, AND IT IS THE SAME ONE `history` MAKES. `channelVisibleTo`
+   * is the predicate chapter 3.15 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 (chapter 3.23, 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
services/api/src/outbox/event.ts
@@ -22,6 +22,26 @@ export interface MessageCreatedData {
   created_at: string;
 }
 
+/** A DELETION as a consumer receives it (chapter 3.23, 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 (chapter 3.20, FR-WHK-02).
  *
  * `user` IS THE EXTERNAL ID and the type says so, because the repository methods
@@ -54,6 +74,18 @@ export interface MembershipChangedData {
  * line and buys that. */
 export const OUTBOX_EVENT_TYPES = [
   "message.created",
+  // CHAPTER 3.23'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;
@@ -69,7 +101,7 @@ export interface OutboxEvent {
   /** 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 {
@@ -108,6 +140,76 @@ export function messageCreatedEvent({
   };
 }
 
+/** An edit, built inside the transaction that wrote it (chapter 3.23, 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 (chapter 3.23,
+ * 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
@@ -201,6 +303,41 @@ export const outboxEventSchema = z.discriminatedUnion("type", [
       created_at: z.iso.datetime(),
     }),
   }),
+  // CHAPTER 3.23. 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"),
services/api/src/fanout/publisher.ts
@@ -1,4 +1,9 @@
-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";
@@ -45,6 +50,11 @@ export interface MessagePublisher {
    * 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>;
+  /** Chapter 3.23, 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>;
 }
 
@@ -109,6 +119,29 @@ export function createMessagePublisher({
   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
services/api/src/internal/backfill.controller.ts
@@ -80,9 +80,24 @@ export class BackfillController {
  *   - **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 arrived in
+ *     chapter 3.23 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 (3.23) 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 (3.23) 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
packages/protocol/src/index.ts
@@ -12,4 +12,5 @@ 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
@@ -71,7 +71,23 @@ describe("the registry is the whole vocabulary (FR-024)", () => {
     // failed on the build that added it** — "expected 16 but got 17", which is the
     // third time this line has turned a new code into a decision instead of an
     // accident. Chapter 3.11's close-code set did the same for 4003.
-    expect(Object.keys(ERROR_CODES)).toHaveLength(18);
+    //
+    // Eighteen until chapter 3.23 added `not_message_author`, the fourth time, and then
+    // `message_deleted` during that chapter's implementation — TWO in one chapter, which
+    // its plan did not expect. **One pinned place, not the four chapter 3.22's close code
+    // moved** — that chapter's task predicted two and found four, so this one counted
+    // before editing: this assertion is the only place in the file that names a total.
+    expect(Object.keys(ERROR_CODES)).toHaveLength(20);
+  });
+
+  it("names the non-author refusal separately from the generic 403 (chapter 3.23)", () => {
+    // FR-022 (3.23). 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.
+    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/);
   });
 
   it("contains the five the status ladder emits", () => {
packages/protocol/src/frames.test.ts
@@ -1,6 +1,6 @@
 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
@@ -27,7 +27,20 @@ const valid: Record<string, unknown> = {
   "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 (chapter 3.23). 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" },
@@ -143,6 +156,56 @@ describe("malformed frames reject", () => {
 // 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 (chapter 3.23). 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 (chapter 3.23)", () => {
+  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 (chapter 3.21)", () => {
   const members = frameSchema.options.map((o) => o.shape.type.value);
 
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 (chapter 3.23). 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 (chapter 3.23, 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 (chapter 3.23)", () => {
  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,5 +1,10 @@
 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";
@@ -39,6 +44,14 @@ const message = {
 };
 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) =>
@@ -126,6 +139,61 @@ describe("the api's fan-out publisher", () => {
     expect(lines).toHaveLength(2);
   });
 
+  it("publishes a revision to the revision subject, not the channel's", async () => {
+    // T018h, chapter 3.23. 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
services/api/src/db/repository.itest.ts
@@ -1,9 +1,18 @@
+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).
@@ -409,16 +418,26 @@ describe("the listing's keyset survives a shared last_activity_at (chapter 3.15)
 
 // ── THE TOMBSTONE, AND THE CLAMP (chapter 3.15, FR-016, FR-019) ───────────────
 //
-// BOTH STATES ARE UNREACHABLE THROUGH THE API, for different reasons, and both are
-// constructed here because this suite may hold raw SQL.
+// THE TOMBSTONES BELOW ARE STILL PLANTED BY HAND, and that is now a choice rather than a
+// necessity. These tests were written in chapter 3.15 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.
+//
+// **CHAPTER 3.23 BUILT THE WRITER** (`repository.deleteMessage`, FR-006 (3.23)), 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 3.23'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.
 //
-// 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 clamp's fixture below is still genuinely unreachable through the API.
+//
+// The listing's rule was implemented and tested here before its writer existed, which
+// 3.15 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 (chapter 3.15)", () => {
   it("reports a tombstoned last message with a null text, and still counts it", async () => {
     const user = await repoA.createUser("tomb-reader", "Tomb Reader");
@@ -449,6 +468,72 @@ describe("the listing's tombstone rule and its clamp (chapter 3.15)", () => {
     expect(row.unread).toBe(2);
   });
 
+  // ── T009 (chapter 3.23): THE READER, TESTED BEFORE THE WRITER EXISTS ────────
+  //
+  // FR-011 (3.23) and SC-003 (3.23). 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 chapter 3.23's resume
+  // decision depends on already works, and nothing had ever said so.
+  //
+  // Chapter 3.15 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 (3.23), SC-003 (3.23))", 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 chapter 3.17'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");
@@ -581,3 +666,447 @@ describe("the repository's own refusals (chapter 3.15)", () => {
     expect(row.last_message?.user).toBeNull();
   });
 });
+
+
+// ══ EDITING A MESSAGE (chapter 3.23, US1) ═══════════════════════════════════
+describe("editMessage (chapter 3.23)", () => {
+  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 (3.15) 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 — chapter
+    // 3.17 made `userId` required — and 121,250 of them exist in the lane, 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 (3.15)) 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 (chapter 3.23, US2) ══════════════════════════════════
+describe("deleteMessage (chapter 3.23)", () => {
+  /** 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. Chapter 3.15'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 — chapter 3.23'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 () => {
+    // Chapter 3.15 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);
+  });
+});
services/api/src/messages/messages.itest.ts
@@ -158,8 +158,9 @@ describe("POST /v1/channels/:channelId/messages", () => {
   // 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
   // twenty-three chapters this controller called `messages.send(channelId, body)`
-  // with no user at all — and `MessagesController` declares no `@Accepts`, so the
-  // guard falls back to `EITHER` and a user token is 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. Chapter 3.17
+  // declared it; this sentence went on describing its absence until 3.23.
   //
   // 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.
@@ -445,3 +446,578 @@ describe("POST /v1/channels/:channelId/messages", () => {
     });
   });
 });
+
+
+// ══ EDITING A MESSAGE (chapter 3.23, 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 (chapter 3.23)", () => {
+  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 chapter 3.23'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 (chapter 3.23'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
@@ -200,6 +200,156 @@ describe("POST /internal/backfill", () => {
     expect(page.messages.map((m) => m.seq)).not.toContain(anonymous.seq);
   });
 
+  // ══ WHAT A CLIENT THAT WAS AWAY CAN AND CANNOT LEARN (chapter 3.23, 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++) {
services/api/src/isolation/tenant-scope.itest.ts
@@ -93,6 +93,13 @@ describe("every table has a path to one tenant", () => {
     // `users`. The rule is existence, not uniqueness; an earlier draft of
     // data-model.md said "exactly one foreign key", which would have classified
     // neither and failed totality on both.
+    //
+    // CHAPTER 3.23 MADE THE REACH TRANSITIVE and this assertion is why the
+    // change is safe to make: `via` holds the DIRECT tables a chain arrives at,
+    // never the intermediate ones, so `message_edits` reads `channels, users`
+    // through `messages` rather than reading `messages`. Falsified twice
+    // against a live database — an orphan table and a table whose only chain
+    // leads into the spine both still fail totality (baseline.txt).
     const hops = tables.filter((t) => t.path === "hop");
     for (const hop of hops) {
       expect(hop.via.length, `${hop.table} is a hop with no target`).toBeGreaterThan(0);
services/api/src/webhooks/deliveries.itest.ts
@@ -120,6 +120,48 @@ describe("expansion", () => {
     expect(await timesHandled(db, DISPATCHER, e.eventId)).toBe(1);
   });
 
+  it("a subscriber to the two new message events is told about an edit and a deletion (FR-019, SC-011)", async () => {
+    // **`seedEndpoint` IS PARAMETERISED AND EVERY EXISTING CALL SITE PASSES
+    // `["message.created"]`.** A test that forgot to pass the new types would seed an
+    // endpoint subscribed to creations, receive nothing, and read as though the
+    // expansion were broken — so the types are passed explicitly and the negative
+    // control below is what proves the filter is doing the work.
+    const scratch = await createEnvironment(db, { name: "deliveries-itest-3-23" });
+    const scratchRepo = new Repository(db, scratch.id);
+    const secret = encryptSecret(mintSigningSecret());
+
+    const both = await scratchRepo.createEndpoint({
+      url: "https://example.test/edits-and-deletions",
+      eventTypes: ["message.updated", "message.deleted"],
+      secretCiphertext: secret,
+    });
+    // SUBSCRIBED TO CREATIONS ONLY. FR-WHK-02 spells four separate names rather than one
+    // `message.*` with a discriminator precisely so this endpoint hears nothing below —
+    // and that is the half a test with one endpoint cannot show.
+    const creationsOnly = await scratchRepo.createEndpoint({
+      url: "https://example.test/creations-only",
+      eventTypes: ["message.created"],
+      secretCiphertext: secret,
+    });
+
+    for (const type of ["message.updated", "message.deleted"] as const) {
+      const e = {
+        eventId: randomUUID(),
+        environmentId: scratch.id,
+        type,
+        payload: { id: randomUUID(), type },
+      };
+      const result = await expandEventToDeliveries(db, e);
+      expect(result.duplicate, type).toBe(false);
+      // ONE ROW, NOT TWO. The subscriber to both types gets it; the creations-only
+      // endpoint does not.
+      expect(result.created, type).toBe(1);
+      const rows = await scratchRepo.listDeliveriesForEvent(e.eventId);
+      expect(rows.map((r) => r.endpoint_id), type).toEqual([both.id]);
+      expect(rows.map((r) => r.endpoint_id), type).not.toContain(creationsOnly.id);
+    }
+  });
+
   it("invariant 8: a disabled or deleted endpoint receives nothing", async () => {
     const scratch = await createEnvironment(db, { name: "deliveries-itest-off" });
     const scratchRepo = new Repository(db, scratch.id);
services/gateway/src/session.test.ts
@@ -6,7 +6,7 @@ import type { AddressInfo } from "node:net";
 
 import { createLogger, type Logger } from "@relay/service-kit";
 import { serve } from "@relay/service-kit";
-import { CLOSE_CODES, type Frame,
+import { CLOSE_CODES, type Frame, type RevisionFabric,
   docsUrl,
 } from "@relay/protocol";
 
@@ -123,10 +123,17 @@ function stubFanout(): Fanout & {
    * 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;
+  /** Chapter 3.23. 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 = () => {};
+  // CHAPTER 3.23. 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 chapter 3.21's lesson about a module built and never passed.
+  let deliverRevision: (channelId: string, revision: RevisionFabric) => void = () => {};
   return {
     published,
     subjects,
@@ -139,9 +146,22 @@ function stubFanout(): Fanout & {
     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);
     },
@@ -609,6 +629,101 @@ describe("the socket (chapter 2.5)", () => {
     socket.close();
   });
 
+  // ── chapter 3.23: 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("chapter 3.23: 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("chapter 3.23: 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("chapter 3.23: 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(
@@ -988,6 +1103,55 @@ describe("the socket's limits (chapter 3.8)", () => {
     expect(CLOSE_CODES[4008]).toBeDefined();
     expect(CLOSE_CODES[4009]).toBeDefined();
   });
+
+  /** T049a, SC-008 (chapter 3.23) — FR-RTM-05's SIX KINDS ALL HAVE A PRODUCER.
+   *
+   * *"The system shall emit real-time events for message creation, edit, deletion,
+   * membership change, presence change, and typing."* Six, and until this chapter two
+   * of them had no producer anywhere in the platform.
+   *
+   * **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
+   * chapter 3.22 — it parses `main.ts` and asserts every module it builds is closed —
+   * and CLAUDE.md records why it had to: the defect that chapter shipped was an
+   * ARGUMENT THAT WAS NOT THERE, and every line around it executed.
+   *
+   * **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. Analysis pass 2 created the task and pass 3 found it unimplementable.
+   *
+   * 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"/);
+  });
 });
 
 // T038. THE INBOUND SET, ASSERTED BY SIZE AND BY MEMBERSHIP.
services/gateway/src/fanout.itest.ts
@@ -3,7 +3,7 @@ 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";
 
@@ -62,14 +62,44 @@ function nextDelivery(
   });
 }
 
+/** The revision fabric's equivalent (chapter 3.23). 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", () => {
@@ -146,6 +176,96 @@ describe("fan-out across instances", () => {
     await raw.fanout.close();
   });
 
+  it("delivers an edit on the revision subject and NOT on the message one", async () => {
+    // Chapter 3.23, 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 CHAPTER 3.18, 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;
services/gateway/src/isolation.itest.ts
@@ -761,8 +761,27 @@ function sample(type: string, channel: string, user: string): unknown {
       return { type, payload: { seq: 1 } };
     case "message.created":
     case "message.updated":
-    case "message.deleted":
       return { type, payload: message };
+    // CHAPTER 3.23 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":
services/gateway/src/resume.itest.ts
@@ -338,6 +338,65 @@ describe("resume across a real fabric", () => {
     socket.close();
   });
 
+  /** CHAPTER 3.23, 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("chapter 3.23: 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],
+        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
services/gateway/src/public-surface.itest.ts
@@ -31,6 +31,14 @@ import { attachSessions } from "./session.js";
 // /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 chapter 3.23 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.
packages/outsider/src/integrate.itest.ts
@@ -473,6 +473,116 @@ describe("integrating with Relay from the outside", () => {
     expect(await closed).toBe(4002);
   });
 
+  /** CHAPTER 3.23 — an edit, over the shipped binary, seen on somebody else's socket.
+   *
+   * **WRITTEN BECAUSE THIS FILE IS THE ONLY THING THAT BOOTS THE PRODUCT.** CLAUDE.md
+   * records what that bought: chapter 3.21 built a module, awaited its `close()`, never
+   * passed it to `attachSessions`, and shipped it inert past 1,174 coverage tests and
+   * 174 gateway integration tests. This file found it. The rule it left behind — a
+   * chapter that adds an argument to `attachSessions` owes an outsider test — applies
+   * here for the same reason one level out: 3.23 adds a second Redis subject, a second
+   * callback on the fan-out and a second frame kind, and every in-workspace test of
+   * that path uses a stub fan-out or the `ws` package this file refuses to import.
+   *
+   * **NO TASK CREATED THIS TEST.** T090 lists this file among "eleven files this
+   * chapter adds tests to" and nothing in the plan added one; the audit task was
+   * scheduled over work no task did. `baseline.txt` records it.
+   *
+   * What it proves that nothing else does: the api's `publishRevision` reaches a real
+   * Redis, on the subject ADR-24 took, and a real gateway process routes it by prefix
+   * to a real socket as `message.updated` — not as `message.created`, which is the
+   * failure the whole ADR exists to prevent and which no shape check can see, because
+   * the updated arm's payload IS a `Message`. */
+  it("edits a message over REST, and a member's socket hears message.updated exactly once, with no second creation", async () => {
+    const minted = await post(
+      "/auth/dev-token",
+      { user: "watcher", ttl_seconds: 3600 },
+      credential,
+    );
+    expect(minted.status).toBe(200);
+    const token = minted.body["token"] as string;
+    // The watcher has to be a member to be delivered to — the channel is public, so
+    // this is about subscription rather than permission.
+    const joined = await post(
+      `/v1/channels/${channelId}/members`,
+      // `user_ids`, and it takes a LIST. The first draft posted `{ user: "watcher" }`
+      // and got a 400 — `addMembersBodySchema` is a `strictObject` over
+      // `user_ids: [...]`, and the entry may be a bare identifier or an object with a
+      // role. An outsider test guessing a body shape is the whole reason this file
+      // exists; two earlier tests in it were written twice for the same reason.
+      { user_ids: ["watcher"] },
+      credential,
+    );
+    expect([200, 201]).toContain(joined.status);
+
+    const socket = new WebSocket(`${ws}/v1/ws?token=${token}`);
+    const frames: Array<{ type: string; payload?: Record<string, unknown> }> = [];
+    socket.addEventListener("message", (event) => {
+      frames.push(JSON.parse(String(event.data)) as { type: string });
+    });
+    socket.addEventListener("error", () => undefined);
+    const waitFor = async (
+      predicate: (f: { type: string; payload?: Record<string, unknown> }) => boolean,
+      what: string,
+    ) => {
+      const deadline = Date.now() + 10_000;
+      for (;;) {
+        const found = frames.find(predicate);
+        if (found) return found;
+        if (Date.now() > deadline) {
+          throw new Error(
+            `no ${what}; saw ${frames.map((f) => f.type).join(", ") || "nothing"}`,
+          );
+        }
+        await new Promise((r) => setTimeout(r, 50));
+      }
+    };
+    await waitFor((f) => f.type === "connection.ack", "connection.ack");
+
+    // SENT BY THE WATCHER'S OWN TOKEN, because only an author may edit (FR-013) and
+    // the edit route accepts no application credential at all (FR-013a). So the send
+    // uses the token too — a POST with a user token is attributed to its subject and
+    // must not name a `user` in the body.
+    const before = `outsider edit ${Date.now()}`;
+    const posted = await fetch(`${api}/v1/channels/${channelId}/messages`, {
+      method: "POST",
+      headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
+      body: JSON.stringify({ text: before }),
+    });
+    expect(posted.status).toBe(201);
+    const sent = (await posted.json()) as { id: string; seq: number };
+    await waitFor(
+      (f) => f.type === "message.created" && f.payload?.["text"] === before,
+      "message.created for the text just sent",
+    );
+
+    const after = `${before} (corrected)`;
+    const edited = await fetch(
+      `${api}/v1/channels/${channelId}/messages/${sent.id}`,
+      {
+        method: "PATCH",
+        headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
+        body: JSON.stringify({ text: after }),
+      },
+    );
+    expect(edited.status).toBe(200);
+
+    const frame = await waitFor(
+      (f) => f.type === "message.updated" && f.payload?.["text"] === after,
+      "message.updated for the corrected text",
+    );
+    // THE SEQUENCE IS THE ONE IT HAD (FR-002), on the wire and not only in the row.
+    expect(frame.payload?.["seq"]).toBe(sent.seq);
+    expect(frame.payload?.["id"]).toBe(sent.id);
+    // AND NO SECOND CREATION. This is the assertion ADR-24 is for: route the revision
+    // to the old callback and the edit arrives as `message.created`, indistinguishable
+    // from a new message to every client. Counting is what sees it — a `waitFor` that
+    // resolves on the first match cannot.
+    expect(frames.filter((f) => f.type === "message.created")).toHaveLength(1);
+    expect(frames.filter((f) => f.type === "message.updated")).toHaveLength(1);
+    socket.close();
+  });
+
   it("cannot see another tenant's channel, and cannot tell it apart from an absent one", async () => {
     // The documented isolation property, exercised the only way an outsider can:
     // with an id that is well formed and is not theirs. The reference says both

Bánh cóc độ phủ nổ cuối cùng, trên messages.controller.ts, và câu trả lời của nó là xoá đi hai thứ thay vì viết kiểm thử cho chúng: một ?? "unknown" trên trường tác giả của frame xoá, vốn không thể chạm tới sẽ đặt đúng chữ ấy lên đường truyền như tên một người, cùng 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. Nhánh kết thúc cao hơn mức chương này nhận được, nên cái chốt được nâng lên.

vitest.coverage.config.mts
@@ -422,11 +422,49 @@ export default defineConfig({
         // `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.
+        // CHAPTER 3.23 MOVED THIS FILE IN BOTH DIRECTIONS, and the branch number is the
+        // one worth reading. The chapter added two routes to it — an edit and a deletion,
+        // each resolving a caller, each publishing — and the first measurement after that
+        // was **91.66 / 78.84 / 100 / 93.61** against pins of 96 / 87 / 100 / 100. Three
+        // of four 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 chapter 3.15; the two new routes and the
+        // history route beside them did not. One test covering all three took lines to
+        // 97.87 and branches to 84.61.
+        //
+        // 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 of 3.23) 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.
+        //
+        // Branches finished at **92.85, above the 87 this chapter inherited**, so the pin
+        // goes UP to 92 — 0.85 of headroom, the same margin chapter 3.17 left on
+        // `repository.ts` at 92.59.
+        //
+        // LINES DROP FROM 100 TO 97, and the one uncovered statement is named: 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,
         },
 
         "services/api/src/webhooks/disable.ts": {

Các yêu cầu giờ đứng ở đâu

FR-MSG-07, FR-MSG-08 và FR-MSG-10 đã dựng xong. Cả sáu kind của FR-RTM-05 đều đã có bên phát, và một bài kiểm thử đọc session.ts như văn bản để giữ điều đó đúng — không gì khác nhìn thấy được một bên phát, bởi một union của 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 độ phủ thì thấy một dòng được chạy và không thấy được một dòng chưa từng được viết. FR-WHK-02 đi từ ba trên tám loại sự kiện lên năm.

Điều 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 đã gỡ một tin nhắn, trong messages.metadata, còn credential nào đã được trình ra là một câu hỏi khác kèm một thời hạn lưu trữ. Ranh giới được viết ra chứ không mặc định, và đó là điều trung thực nhất chương này làm được với một yêu cầu P3 mà nó khiến trở nên chạm tới được.

Và người thực hiện lần xoá thì được ghi lại mà không đọc được. Không đường đọc nào phơi nó ra — không phải lịch sử, không phải danh sách, không phải frame, không phải sự kiện webhook, tất cả đều mang tác giả của tin nhắn bởi đó là cái tên client vốn đã giữ. Bịa ra một mặt đọc cho nó sẽ là quyết định, mà không yêu cầu nào đòi hỏi, rằng ai được quyền biết một người vận hành đã gỡ tin nhắn của ai đó.