Phần 3 · Chương 3.17
Bạn sẽ tạo ra: FR-MSG-07, FR-MSG-08 và FR-MSG-10 được dựng, và hai kind cuối trong sáu kind của FR-RTM-05 lần đầu có bên phát: một ngữ pháp subject thứ năm, `revision:{channel_id}`, mang cả hai loại thay đổi với cái kind nằm trong payload, bởi một bia mộ không phải `Message` còn một lần sửa thì là và sẽ không phân biệt được với một lần tạo; bảng `message_edits` dựng lại đúng như SAD §6.1 đã công bố, kèm cái giá của khoá chính ghép được viết ra; hai mã lỗi thay cho 403 chung chung, bởi không credential nào cấp quyền tác giả và không thay đổi phân quyền nào biến một tin nhắn thành của bạn; một phép kiểm tenancy được dạy rằng khả năng tới được không phải là kề nhau, sau khi nó từ chối cái bảng mới trong bốn mili giây; và cái mép mềm duy nhất được ghi lại thay vì đóng lại — một tin nhắn cũ hơn con trỏ của client mà đổi trong lúc mất kết nối thì không sinh frame nào **và không có lỗ hổng số thứ tự nào**, nên cơ chế sửa chữa mọi frame bị lỡ khác chẳng thấy gì để sửa · khoảng 75 phút, bao gồm bài tập
Tài liệu gốc: SAD — Tài liệu kiến trúc phần mềm (tiếng Anh)
Bản dịch đang được chuẩn bị. Phần diễn giải của chương này chưa được dịch sang tiếng Việt. Các khối mã bên dưới là bản gốc tiếng Anh và giống hệt bản tiếng Anh của chương — bạn có thể gõ theo chúng ngay bây giờ. Bản dịch đầy đủ sẽ thay thế trang này.
@@ -66,15 +66,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 +186,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, notimport { 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>;@@ -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();@@ -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.forbidden@@ -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-- 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)
);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.@@ -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; members | f | {channels,users}
message_edits | f | {channels,users}
probe_into_spine | f |
probe_orphan | f |@@ -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";
}
}
@@ -2054,16 +2130,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` declares no `@Accepts` — so the guard falls 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 +2328,426 @@ 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.
+ const [updated] = await tx
+ .update(messages)
+ .set({ text, editedAt: sql`now()` })
+ .where(eq(messages.id, messageId))
+ .returning({ editedAt: messages.editedAt });
+ const editedAt = updated!.editedAt!;
+
+ // FR-004. The row carries what the message said BEFORE this edit — `row.text`,
+ // read above and narrowed to a string by the tombstone check.
+ //
+ // NO `onConflictDoNothing`. The primary key is (message_id, edited_at), so two
+ // edits inside one microsecond collide, and a conflict clause here would silently
+ // drop the second one's history while its text change committed. A loud failure
+ // is the right answer to a state this table cannot represent — SAD §6.1 published
+ // the key and `baseline.txt` records what it costs.
+ await tx.insert(messageEdits).values({
+ messageId,
+ editedAt,
+ priorText: row.text,
+ });
+
+ // THE EVENT COMMITS WITH THE EDIT (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!);
+
+ // 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 +2947,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 +2996,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).
*@@ -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,14 +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` declares no `@Accepts`, so
- // the guard falls back to `EITHER` and a user token is accepted here.
+ // customer's own client calls. `MessagesController` declared no `@Accepts` at the
+ // time, so the guard fell back to `EITHER` and a user token was accepted here.
+ //
+ // PAST TENSE SINCE 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.
@@ -205,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,
@@ -226,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,
) {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 oneSlack no event replay. conversations.history returns CURRENT state and a
client that was away re-reads. message_changed and message_deleted
exist only as live events.
Matrix an append-only timeline where a redaction is an event of its own, so a
resuming client receives it — at the cost of a timeline that grows with
edits rather than with messages.
IMAP CONDSTORE/QRESYNC puts a MODSEQ beside the sequence, so a client asks
"what changed since modseq N" — a second monotonic counter per mailbox
that every mutation has to maintain.@@ -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@@ -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([]);
});AssertionError: classified but never attacked:
GET /v1/channels/:channelId/messages/:messageId/edits,
PATCH /v1/channels/:channelId/messages/:messageId,
DELETE /v1/channels/:channelId/messages/:messageId@@ -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,
{@@ -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,@@ -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).
@@ -353,12 +355,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",@@ -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({@@ -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,@@ -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@@ -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 {@@ -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[] {@@ -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";@@ -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/);
+ });
+});@@ -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 = {
@@ -24,13 +24,26 @@ const valid: Record<string, unknown> = {
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 +148,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);
});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);
});
});@@ -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();@@ -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.
//
-// FR-MSG-08 — "deleting a message shall replace its content with a tombstone retaining
-// sequence number, author, timestamps" — IS NOT IMPLEMENTED. `messages.deleted_at` and a
-// null `text` are in the schema, `backfill.controller` passes `text` straight through so
-// a null already reaches the wire, and NOTHING IN THE PLATFORM WRITES EITHER. The
-// tombstone is a live reader with no writer, which is the reverse of the dead columns
-// this feature is otherwise about. The listing's rule for it is implemented and tested
-// now so the day FR-MSG-08's chapter ships, the count and the preview already agree.
+// The clamp's fixture below is still genuinely unreachable through the API.
+//
+// The listing's rule was implemented and tested here before its writer existed, which
+// 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,450 @@ 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);
+ });
+});@@ -146,15 +146,16 @@ 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
- // twenty-three chapters this controller called `messages.send(channelId, body)`
- // with no user at all — and `MessagesController` declares no `@Accepts`, so the
- // guard falls back to `EITHER` and a user token is accepted here.
+ // seventeen chapters this controller called `messages.send(channelId, body)`
+ // 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) ══════════════════════════════════════════
@@ -434,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");
+ });
+});@@ -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);@@ -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"]);
+ });
});@@ -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";
@@ -108,31 +109,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 +591,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 +804,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", () => {@@ -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.
});@@ -786,14 +786,33 @@ function sample(type: string, channel: string, user: string): unknown {
case "connection.ack":
return { type, payload: { user, cursor: {}, resume_ok: true, truncated: [] } };
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 } };@@ -311,12 +311,71 @@ describe("resume across a real fabric", () => {
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],
+ 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({@@ -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));@@ -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.