Building Relay

Part 3 · Chapter 3.17

The words somebody wants back

You will produce: FR-MSG-07, FR-MSG-08 and FR-MSG-10 built, and the last two of FR-RTM-05's six event kinds given their first producers: a fifth subject grammar, `revision:{channel_id}`, carrying both mutations with the kind in the payload, because a tombstone is not a `Message` and an edit is one and would be indistinguishable from a creation; `message_edits` reproduced from SAD §6.1 as published, with what the composite key costs written down; two error codes rather than the generic 403, because no credential grants authorship and no permission change makes a message yours; a tenancy check taught that reachability is not adjacency, after it refused the new table in four milliseconds; and the one soft edge documented rather than closed — a message older than a client's cursor that changed during a disconnect produces no frame **and no sequence gap**, so the mechanism that repairs every other missed frame sees nothing to repair · about 75 minutes including the exercise

Source: SAD — Software Architecture Document

Somebody sends a message with a typo in it. Somebody sends a message to the wrong channel. Somebody sends a message they immediately regret. Every chat product answers all three, and Relay has answered none of them for sixteen chapters — while carrying, the whole time, almost everything it needed to.

messages.edited_at has been in the schema since chapter 2.1. So has deleted_at, so has a nullable text that the schema's own comment calls a tombstone, so has a metadata JSONB NOT NULL DEFAULT '{}'. docs/05-sad.md §6.1 has published a message_edits table since its first draft. Nothing in the platform writes any of them. The read paths were built to cope: the channel listing has a rule for a null text, the resume backfill drops one, and both were tested against tombstones planted by hand with raw SQL, because no code path could produce one.

That is the reverse of the usual gap. The usual gap is a writer with no reader. This is four readers and a table waiting on a writer that nobody had written, and the reason is uncomfortable in a way worth sitting with: the readers were easy and the decisions were hard.

A tombstone is not a message

The wire has carried a message.deleted frame since chapter 1.3, and its payload was the same Message that message.created carries: an id, a channel, a sequence, a user, a text, and a creation timestamp.

A deleted message has no text. That is not a detail of this implementation — it is FR-MSG-08, which says a deletion "shall replace its content with a tombstone", and messages.text is nullable for exactly that reason. So the published frame could not describe the thing it was named after, and the choice was to widen messageSchema.text to nullable or to give the deletion a payload of its own.

Widening would have made every consumer of every message payload — the creation, the edit, the resume replay — accept a null text they can never receive. A frame's schema is a promise about what arrives; making it looser to accommodate a different frame is a promise nobody can rely on.

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

The fifth subject grammar

Between gateway instances, a frame crosses Redis pub/sub on a subject. There are four grammars, and each arrived with a chapter that argued for it: chan:{channel_id} in 2.6, presence:{channel_id} in the presence chapter, member:{channel_id} and member:{env}:{user} in the membership-revocation chapter, and typing:{channel_id} in the typing chapter.

chan: carries a Message. packages/protocol/src/fanout.ts says so in its own words — "the fan-out has always carried a wire frame's payload rather than a shape of its own" — and two things follow from that, the second of them fatal.

An edit is a Message. It could ride chan: by shape, and the receiver would have no way to know it was an update: session.ts stamped type: "message.created" at the call site, so the kind was never on the fabric at all. Every edit would arrive as a brand new message.

A deletion is not a Message and cannot ride that subject even in principle.

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
Four grammars and the fifth. The edit could ride chan: and would be indistinguishable from a creation; the tombstone could not ride it at all.

So this chapter takes a fifth, and the rule it takes it on has now been reached independently by four chapters: a kind that cannot share a payload type cannot share a subject.

One subject rather than two, with the kind in the payload, following ADR-20's membership.changed and its change: "added" | "removed". 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.

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

The prefix is exported and the subscriber asks this module whether a subject is one of its own. The gateway holds one Redis subscriber for both chan: and revision:, so subscriber.on("message") has to route on the subject — and a literal "revision:" in the gateway would be a second place that knows this grammar, which is the thing the rule forbids.

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

Both subjects are subscribed under one reference count. They are co-extensive by construction: a gateway holding a socket for a channel wants that channel's messages and its revisions, and dropping one without the other leaves edits arriving for a channel nobody is listening to — or worse, the reverse.

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

That is the change ADR-24 exists for, and it is three lines. The kind comes off the payload now. deliver above it still stamps message.created, correctly, because everything on chan: genuinely is a creation.

Two codes, and why not forbidden

An end user edits somebody else's message. What comes back?

ProtocolErrorFilter maps a bare 403 to forbidden, so leaving it undecided decides it. And 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, no permission screen confers it, and no retry acquires it. A developer sent to look for a permissions setting will not find one.

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

The second code was not in the plan. Editing a message that is already a tombstone has to be refused — prior_text TEXT NOT NULL means the alternative is a constraint violation the caller cannot act on — and the contract this chapter wrote during specification said 404, with an argument: "a 410 on a message a caller may not edit would confirm the message exists."

That argument is false, and it took writing the code to see it. Nobody who may not edit the message ever reaches that refusal: the authorship check runs before the text is looked at, so a stranger is refused with not_message_author whether the message is a tombstone or not. The only caller who sees the tombstone answer is the author — and the author can read the message in history, because FR-011 keeps deleted messages in their original position.

So a 404 there tells a caller that a message they are looking at does not exist. That is chapter 2.8's defect inside one resource: it found POST answering 404 for a channel GET answered 200 for, and its fix was to make the two agree. One resource should not answer two ways depending on the verb.

The table the SAD published and nobody built

docs/05-sad.md:435 has held this DDL since the first draft:

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

Three columns and a composite key. This chapter's own data model gave the table a fourth column — id UUID PRIMARY KEY — and said, in the sentence above it, "Its shape is the SAD's, not this chapter's." It was not. Eleven analysis passes went past that, and the reason is worth naming: every checker in this repository compares identifiers, and this was a clause. The table's name appeared in the plan, the tasks and the data model, and nothing read the DDL underneath it.

The check that only counted one hop

Creating the table turned the isolation lane red in four milliseconds.

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 asks the question the endpoint gauntlet does not: is there a path from every table's rows back to one tenant? A table with no such path is a leak with no endpoint yet. And all three remedies it offered were wrong here. environment_id is a column the SAD does not publish; a second foreign key is the same objection plus a denormalisation; and calling a table of message text part of the spine cannot be justified, which that list's own comment requires.

So the query was wrong, not the table. The rule it states is that every table has a path back to an environment. The implementation accepted only paths of length one — a foreign key landing directly on a table carrying environment_id — and that covered every table that existed until now. message_edits is two links out: it references messages, which references channels, which carries the column.

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

This is the failure mode this repository keeps paying for — a pattern matching the examples in front of it rather than the set the rule names — arriving in the check that has been the highest-yield instrument in the whole tree.

The edit

One transaction, and the history row is why: a message updated in one statement and a history appended in another can crash between them, and what survives is a message whose old text nobody has.

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

What is not in the SET list is FR-002 written as code. sequence, channelId, userId and createdAt are absent, and so is lastActivityAt — the send path moves that in the same breath as the sequence, deliberately, and an edit must not, because the channel listing orders by most recent activity and correcting a typo is not a new message. A test can only assert that the values are unchanged; a thing not done leaves no trace to assert on. The guarantee lives in the shape of the statement.

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

Three routes, three different answers to who may call this, and all three are declarations rather than checks in a handler:

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

The class declares @Accepts("application", "user"), and the guard reads getAllAndOverride — so a route added here without a declaration inherits both, and an absent declaration is not neutral. Remove @Accepts("application") from the edits route and the test that asserts an end user is refused reports expected 200 to be 403: every end user can now read what every message in every channel they can see used to say.

The deletion, and why two 204s prove nothing

Deleting an already-deleted message succeeds. FR-009 says so, and it is the obvious behaviour: a client retrying a request that timed out should not get an error for a state it asked for and got.

The trouble is that "succeeds" is not the requirement. A second deletion that rewrote the row would move deleted_at, so a client that had already read the tombstone would see its timestamp change for no reason — and a second event would fire every subscribed webhook a second time for one deletion. Both are invisible from the status code, which is 204 either way.

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
A message's life. Every transition keeps the sequence number, which is what makes a tombstone leave no gap and an edit invisible to a cursor.

So the repository returns alreadyDeleted beside the row, the fan-out is guarded on it, and the test that carries FR-009 counts outbox rows rather than status codes. The edit's answer is the opposite and both are right: every edit emits, because FR-021 says the platform does not compare message texts to decide whether an edit happened. Every definition of equality — whitespace, case, unicode normalisation, an invisible character — is a decision a customer would have to be told about.

What every read path does with a tombstone

FR-017 asks for this to be stated for every read path, and FR-017a asks for it to be derived from the code at the time of writing rather than from a list, because a list goes stale and the code does not. Read one at a time, there are four:

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
Four read paths, four answers. The fourth is not a per-state answer like the others, which is why the requirement counted three until somebody measured.

The fourth is the one that hides. A backfill page at its cap containing tombstones returns fewer frames than rows read and still reports truncated: true — dropping an unrenderable row is not a reason to tell a client to go page history, and hiding a real cap would be. That decision was made in chapter 2.7 and this chapter is the first time it could ever have run, because until now nothing could write a tombstone.

The edit's equivalent is one line: every read path returns the current text, because the superseded text lives in message_edits and only one route touches that table.

The cursor's blind side

Here is the part this chapter does not fix.

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
A client away across an edit below its cursor and a deletion above it. One leaves a gap. The other leaves nothing at all.

A resuming client presents a cursor — a position in a channel's sequence — and receives everything above it. A message deleted above the cursor is dropped from the backfill, and the client sees a missing sequence number: a gap, which is precisely the signal the SDK repairs through history. That mechanism has existed since chapter 2.7 and it works.

A message edited below the cursor produces no frame and no gap. The sequence numbers above the cursor are contiguous; there is nothing to detect. The client goes on displaying a message whose text changed while it was away, and nothing in the protocol will ever tell it.

The counter the cursor needs

The section above surveys three products and declines to add a counter. This one adds it, because the argument for declining does not survive its own conclusion: the bound was written down, and a bound nobody can act on is not a design, it is a note. A client that cannot tell an edited message from an unedited one has no way to decide to re-read history, and "re-read history" was the entire remedy ADR-07 rested on.

So Relay takes IMAP's shape — a second monotonic number per channel, beside the sequence — and pays IMAP's price: every mutation maintains it.

One column, and it rides this chapter's own migration. message_edits above preserves what a message used to say; channels.revision_sequence says how many times it changed. Those are one subject, and splitting them across two migrations would number one of them after work that has nothing to do with either.

Raised by exactly one per edit or deletion, inside the transaction that applies it, and never by a send. The increment is in the edit and the deletion paths fenced earlier in this chapter — go back and look at repository.ts: it is two lines, in each case inside the same transaction as the write it counts. Outside that transaction it would be a second statement that can fail on its own, and a counter that can miss is worse than no counter, because a client would trust it.

The count is reported and never compared. The api answers it on the one call the gateway already makes:

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

The gateway has no database and must not gain one — the same reason banned rides this response — so the counts travel with the ids that are already being fetched:

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

channelsForUser now returns a row per channel rather than an id, which is what makes the two fields incapable of disagreeing about which channels a user belongs to. Its other caller changes shape with it, and the compiler said so:

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

From there it is carriage, not logic. The authentication result gains a field, the connection holds it, and the ack reports it:

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

Every channel the user belongs to, zeros included. A channel absent from the map would be indistinguishable from a channel at zero, so the map is total over the membership: a client holding a count for a channel that is missing learns its membership changed, which is a different fact and a useful one.

And the fixtures had to say so. Five suites build a session response, and a required field means each of them states what it means to report no revisions:

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

What the instruments caught

A test asked for the thing this chapter recorded as safe, and the answer was no. The carried ledger held an item saying both interleavings of a concurrent edit and deletion end in a tombstone — "there is no order of the two that leaves a message saying something nobody wrote" — and the test that item asked for disproved it.

Neither editMessage nor deleteMessage takes a row lock. That is deliberate and recorded: assertWithinQuota decided to state an overshoot rather than engineer around one. But the edit's tombstone check was a READ taken earlier in the same transaction, and its write said only where(eq(messages.id, messageId)) — so a deletion committing inside that window left the edit free to restore text with deleted_at still set. A row one filter calls deleted and another calls alive, and a deletion that returned successfully undone by an edit already in flight.

The filter now lives in the WHERE clause, where the database evaluates it against the row as it is at write time rather than as it was at read time. The fence for repository.ts above carries it.

AND THE EVIDENCE IS THE SHAPE OF THE FAILURE, NOT ITS PRESENCE. The race test runs ten attempts per invocation, because a race asserted once is a race observed once. Without the WHERE clause it failed one run in three — attempt 1 on one run, attempt 5 on another, never the same attempt twice. With it, three runs in three passed. A defect that appears in a third of runs is exactly the kind that gets filed as a flake and re-run until it goes green, which is how the ledger came to record the opposite of the truth for two chapters.

The isolation gauntlet's target list derives every route from the running application and compares it against a hand-maintained table, so it goes red on the build that adds a route. It did, twice — once naming a route declared before it was written, which is the direction that catches a rename.

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

And then a third time, from an instrument this book did not have when the classification list was first written. targets.itest.ts asks whether every route on the router is classified. gauntlet.itest.ts asks the other direction — whether every route the classification says to attack was actually attacked — and it named all three of this chapter's by path:

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

A write classification with no attack written for it is the same hole as an unclassified route, one level up: the list says a credential can reach this path with somebody else's identifier in it, and nobody ever tried. The three attacks that answer it differ in exactly one thing — the credential — which is the whole reason accepts is a field on the classification rather than a comment. The edit takes a user token, the history an application credential, the deletion either. Attacking one with the wrong class is refused at the door, and the route is then recorded as isolated without its handler ever running.

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

The coverage ratchet fired last, on messages.controller.ts. Its answer was to delete two things rather than test them: a ?? "unknown" on the deletion frame's author, which was unreachable and would have put that word on the wire as somebody's name, and three copies of the same two context fallbacks, one per publish site.

Then one test did the rest, and it was measured rather than reasoned about. Skipping that one test and running the whole battery again reads 91.83 / 85.71 / 100 / 93.75 with three uncovered statements — the 400 each of the three new handlers raises when the token's subject has no user row. With it: 97.95 / 92.85 / 100 / 97.91, one statement left. So the test is worth +6.12 statements, +7.14 branches and +4.16 lines, and it is what clears the inherited floors.

Branches finished at 92.85 against an inherited 87, so that pin goes up to 92. Lines come down from 100 to 97, and the statement still uncovered is named rather than counted: 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 the 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.

repository.ts moved the other way and its pin was left alone: 92.66 when the sender chapter pinned it at 92, 92.97 here, with four routes' worth of new methods in between. A ratchet that follows every upward reading is a ratchet somebody has to lower later.

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

Where the requirements stand

FR-MSG-07, FR-MSG-08 and FR-MSG-10 are built. FR-RTM-05's six kinds all have producers, and a test reads session.ts as text to keep that true — nothing else can see a producer, because a zod union knows its members and not what emits them, and coverage sees a line execute and cannot see a line that was never written. FR-WHK-02 goes from three of eight event types to five.

What this chapter did not do: FR-MOD-03's audit log. The deletion records what kind of principal removed a message, in messages.metadata, and which credential it presented is a different question with a retention period attached. The boundary is written down rather than assumed, which is the most this chapter can honestly do about a P3 requirement it makes reachable.

And the deletion's actor is recorded and unreadable. No read path exposes it — not history, not the listing, not the frame, not the webhook event, which all carry the message's author because that is the name a client already holds. Inventing a read surface would decide, with no requirement asking, who may learn that an operator removed somebody's message.