Part 3 · Chapter 3.24
The message that is not only text
You will produce: FR-MSG-11's external-URL half built: a message carries attachments, bounded at ten and 2,048 characters, refused unless the scheme is http or https. The media_id half is deferred to §4.14 and refused by name, with a code of its own. · about 70 minutes including the exercise
The messages table has had an attachments JSONB column since chapter 2.1. Nothing has
ever written to it. FR-MSG-11 publishes two ways to fill it — a URL you host, and a
media_id naming something Relay hosts — and this chapter builds the first one.
That is a small feature with a large blast radius, and the interesting part is not the feature. It is the question of how you find every place a new field has to go when the field is optional to the type system and mandatory to the contract.
One decision before anything else
Make the field on the wire required, not optional.
What the compiler cannot see
Four production sites and 29 test ones is the whole list, and it is not the whole problem. Three more turned up when the suites ran, and they have one cause:
messageSendSchema.parse(value) // (value: unknown) => MessageSendparse takes unknown. A fixture handed to it is invisible to the compiler no matter
what the schema requires, and so is anything that goes through JSON.stringify on its way
onto a subject. The three that turned up were a forged frame in an isolation suite, two
payloads published by hand in a typing test, and a pinned key set in a file that compares
the api's fan-out payload against a literal.
Three instruments, in order, and each found what the one before it could not:
typecheck 33 the sites a type can see — 4 production, 29 in tests
unit lane +3 fixtures and pinned key sets, all past parse(unknown)
integration +6 forged frames, published payloads, one more pin
The order is not a formality. Running the unit lane before the typecheck would have shown three failures and hidden thirty-three; running the integration lane first would have shown six and hidden the rest. Each lane is a filter with a shape, and the shapes do not nest.
The shape, and the arm that refuses
flowchart TB
u["attachmentSchema<br/>discriminatedUnion on type"]
a1["{ type: 'url',<br/>kind: image|audio|video,<br/>url }<br/>ACCEPTS"]
a2["{ type: 'media', media_id }<br/>REFUSES — media_not_available"]
f["§4.14 replaces this arm's body.<br/>The discriminator never changes."]
u --> a1
u --> a2
a2 -.-> f
style a1 fill:#065f46,color:#fff,stroke:#10b981
style a2 fill:#7f1d1d,color:#fff,stroke:#dc2626
style f fill:#334155,color:#fff,stroke:#64748bFR-MSG-11 publishes media_id and this chapter does not build it. The obvious shape is one
arm now and a second arm in Part 4. The obvious shape is wrong, and the reason is a
sentence in the requirement:
The refusal MUST say that hosted media is not available rather than that the field is invalid, because
media_idis a published part of FR-MSG-11 and a customer reading the clause will send one.
A one-arm union refuses {"type": "media"} with zod's own discriminator message. Measured
against the pinned version rather than assumed:
one arm "Invalid discriminator value. Expected 'url'"
two arms "hosted media is not available yet — attach an http or https url instead"
The first says the field is invalid, which is the one thing the requirement forbids. The second arm exists from the first version, parses its shape so the discriminator matches, and then refuses — and the gateway already forwards the failing arm's message to a socket client, so both doors get the sentence for the cost of one arm.
Why it gets a code of its own
The refusal could have been invalid_request. That code means the caller sent something
the contract does not allow — and media_id is in the contract. A developer who reads
FR-MSG-11, sends the shape it publishes, and receives invalid_request is being told to
go and check JSON that is correct. They will not find anything, because there is nothing
to find.
So: media_not_available, and 422 rather than 400. The request is well-formed and
understood; what cannot be done is the thing it asks for. That distinction is the same one
chapter 2.8 drew between a 404 and a 403, and it costs a new entry in the error registry:
codes.ts the entry and its argument
codes.test.ts the exact count, 20 -> 21, plus three assertions
docs/08-error-reference.md a section with a cause and a client action
content/docs/… its mirror, machine-written
Four places, predicted as four before the work started. The gate that reads the built registry against the published reference goes red the moment the code exists and stays red until the reference section is written — which is a phase apart, on purpose, and recorded so that a red gate in between is a schedule rather than a surprise.
One rule, three doors
flowchart LR
subgraph rules["one definition, three appliers"]
r["refineTextAndAttachments<br/>attachments.ts"]
end
a["messageSendSchema<br/>the socket frame"]
b["internalSendRequestSchema<br/>every socket send"]
c["sendMessageBodySchema<br/>the REST body"]
r --> a
r --> b
r --> c
n["A rule written into ONE of these<br/>is a rule the other doors do not have.<br/>FR-019b reached two of three<br/>before the layer gave it away."]
a -.-> n
style r fill:#065f46,color:#fff,stroke:#10b981
style n fill:#7f1d1d,color:#fff,stroke:#dc2626A message may now carry no text, when it carries an attachment. The floor moves rather than disappearing: text may be empty only when at least one attachment is present, and a send with neither is still refused.
That is one rule about a pair of fields, and there are three schemas that validate a send. Writing it into the REST body schema alone puts it on one door — that file is imported by exactly one other and never by the socket path.
The three places a socket send drops a field
sequenceDiagram
participant C as client
participant G as gateway
participant A as api
participant P as postgres
C->>G: message.send { text, attachments }
Note over G: 1 — the inbound destructure<br/>const { channel, text, idem_key } = payload
G->>A: POST /internal/messages { channel_id, text }
Note over A: 2 — the named build<br/>{ text: body.text, ...idempotency_key }
A->>P: INSERT … (text)
P-->>A: row
A-->>G: { id, seq, text }
Note over G: 3 — the outbound payload<br/>built field by field
G-->>C: message.ack { seq }
Note over C: acked as though it worked.<br/>No error anywhere in this sequence.Every REST test in this chapter passes before the socket path works at all. The REST door validates a body and hands a typed object to a service. The socket path has three places where a field is named individually, and a field that is not named is simply gone:
const { channel, text, idem_key } = frame.data.payload; // 1{ text: body.text, ...(body.idempotency_key !== undefined && { … }) } // 2await fanout?.publish({ id, channel, seq, user, text, created_at }); // 3The message commits. The client is acked. There is no error anywhere in that sequence, and the only test that can see it is one that sends over a socket and then reads the row.
NULL and an empty list are different values
flowchart LR
m["messages.attachments<br/>JSONB, NULL or an array"]
s1["listMessages<br/>history AND resume"]
s2["getMessageByIdempotencyKey<br/>the retry replay"]
s3["editMessage's read"]
s4["deleteMessage's read"]
s5["listMessagesRaw"]
s6["listChannelsForUser.last_message"]
y1["carries them — ?? [] here, once"]
y2["carries them"]
y3["carries them — the edit event needs<br/>what the message already has"]
n1["no — a tombstone's are unlinked"]
n2["no — id, seq, text"]
n3["no — a preview shows what was said"]
m --> s1 --> y1
m --> s2 --> y2
m --> s3 --> y3
m --> s4 --> n1
m --> s5 --> n2
m --> s6 --> n3
style y1 fill:#065f46,color:#fff,stroke:#10b981
style y2 fill:#065f46,color:#fff,stroke:#10b981
style y3 fill:#1e3a8a,color:#fff,stroke:#3b82f6
style n1 fill:#334155,color:#fff,stroke:#64748b
style n2 fill:#334155,color:#fff,stroke:#64748b
style n3 fill:#334155,color:#fff,stroke:#64748bThe column stores NULL for a message with no attachments. Every read returns []. Those
are different facts and exactly one place converts between them — the map in listMessages
— because putting the conversion in each caller gives the platform as many answers as it
has callers.
Removing that one ?? [] turns three tests red, which is how you know where the
decision lives.
Proving the tests can fail
Four falsifications, and the two that did not do what they were told are the ones worth keeping.
Empty the gateway's publish payload and the socket-to-socket delivery test goes red — as expected. The REST-to-socket test stays green, which the task did not expect. That is not a gap: a REST send's fan-out payload is built in the api and a socket send's is built in the gateway, so they are two constructions of one frame and a change to either is invisible to the other's test. The green half is the evidence.
Remove the ?? [] and three tests go red, not the one the task named.
Remove the attachments column from the history read entirely and the isolation test — the one asserting a non-member sees nothing — stays green. That was predicted, and it is the point: the attachment adds no second surface by construction, because the visibility check runs as a gate before the read. There is no row, so there is no column, so there is nothing to leak. The test keeps its job, which is guarding the byte-identical answer, and stops being credited with a claim it cannot fail to make.
What a deletion leaves behind
stateDiagram-v2
[*] --> live: send with attachments
live --> live: edit — text changes,<br/>attachments untouched, event carries them
live --> tombstone: delete — text NULL,<br/>attachments NULL, deleted_at set
tombstone --> tombstone: retry an old idempotency key —<br/>returns attachments: [], publishes nothing
note right of tombstone
message.deleted carries NO attachment
field at all, for the reason it carries
no text: a url is as recoverable
end noteAn edit does not change attachments. FR-MSG-07 changes message text, the edit route takes
a body of one field, and the history table it writes stores the previous text and nothing
else. A test proves the database keeps them; a falsification adding attachments to that
UPDATE's SET list watches the test go red and takes it out again.
And yet the message.updated event has to carry them — because a consumer needs one shape
for a creation and an edit, and an event that dropped the list would make an edit look like
a message that lost its picture.
The deletion event carries no attachment field at all. Not an empty one.
The frame that publishes a deletion has never carried text, for a reason its own comment
gives: a payload with 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. The safe shape is the
one where the key does not exist.
A URL is exactly as recoverable as a sentence. Somebody who attached a photograph and then
deleted the message has asked for the link to stop travelling, and an event carrying
attachments: [] would be one refactor away from carrying attachments: [ … ]. So the
deletion payload keeps its five keys, and the test that guards it compares an exact key
set rather than checking that the field is undefined — because an absent key and an
undefined value are the same thing to a truthiness check and different things to a
contract.
There is a matching case on the way in. A send that recovers a tombstone through an old
idempotency key returns the original row, which is now a tombstone: text: null,
attachments: [], and nothing published. Both publish sites are guarded on text !== null,
so the guard chapter 3.18 wrote for text carries the attachment list with it for free.
What a required field is worth
Thirty-three compiler errors, three more from the unit lane, six more from integration, and one from a test a previous chapter wrote for a different reason entirely — an edit to an empty text, which had been a 400 for a chapter and quietly became a 200 when the send's text bound relaxed. The edit schema had borrowed the send's bound by reference:
export const editMessageBodySchema = z.strictObject({
text: sendMessageBodySchema.shape.text, // until this chapter
});An edit has no attachments field, so the pair rule that restores the send's floor cannot restore the edit's. Nothing in the plan predicted it, and the compiler could not see it — the types are identical either way.
Two schemas that happen to agree are a defect this codebase has recorded twice. The sharper form, which this chapter earned: two schemas that must differ cannot share a reference at all.
And one more, found after all of that, by the coverage lane this chapter's close-out runs.
The same rule had been applied to outboxEventSchema, which is not something the platform
builds. It is what consumer/runtime.ts parses out of a durable queue — and a failed parse
there is answered with message.term(), which stops redelivery for good. On the deploy that
ships this chapter, every message.created still on that stream was written by the binary
that ran before it, with no attachments key at all. Required meant destroyed.
Six tests in consumer.itest.ts went red on exactly that, and they went red because two
fixtures nobody updated still spell the old payload. Eleven analysis passes did not find it,
and the file's own header says why: the api's suite runs with RELAY_EVENT_CONSUMER=off, so
nothing in it exercises the consumer.
So the rule has an edge, and the edge is worth stating as sharply as the rule. Required is a
claim about what you write. A reader of anything durable — a queue, a column, bytes somebody
else wrote — cannot require a field its writer did not have. .default([]) on the two event
branches, and the producer's interface stays required, because those two sentences are about
different directions.
The diff
Eight files carry the argument above, and they come first.
The shape is new, so it is stated whole rather than as a change to something.
import { z } from "zod";
/** THE ATTACHMENT SHAPE (chapter 3.24, FR-MSG-11's P2 half).
*
* Its own module, following `presence.ts`, `typing.ts` and `revision.ts`: a shape both
* doors import, plus the constants that bound it. Two schemas that happen to agree are
* the defect this chapter is trying not to repeat — `idem_key` against
* `idempotency_key` is what that looks like three chapters later — so the bound lives
* here once and the send schemas import it.
*
* TWO ARMS, AND THE SECOND ONE REFUSES. §4.14 will add hosted media, and FR-003 says a
* `media_id` attachment must be refused with a code of its own until it exists. That
* refusal has to happen at BOTH doors, and the doors do not share a controller:
* `messages.controller.ts` validates with `sendMessageBodySchema` and
* `internal.controller.ts` — every socket send — with `internalSendRequestSchema`. The
* only thing they share is this file.
*
* A one-arm union refuses `{"type":"media"}` with zod's own discriminator message,
* measured against 4.4.3:
*
* one arm "Invalid discriminator value. Expected 'url'"
* two arms "hosted media is not available yet — …"
*
* The first says THE FIELD IS INVALID, which is the one thing FR-003a forbids by name:
* `media_id` is published in FR-MSG-11 and a customer reading that clause will send one.
* The second arm carries the sentence, and `session.ts:1447` already forwards
* `issues[0].message` beside `invalid_frame`, so the socket gets it for free.
*
* WHAT IT COSTS is one word of FR-003b, which asks that §4.14 "add an arm rather than
* change one". This arm exists now and refuses, so §4.14 replaces its body. That is a
* smaller change than introducing a discriminator later, which is the thing FR-003b was
* written to prevent, and it is stated here rather than left as a contradiction. */
/** FR-005. Ten, and both doors import this rather than spelling it. */
export const MAX_ATTACHMENTS = 10;
/** FR-023. FR-MSG-11 states no length, so this is the chapter's own bound and it takes
* the platform's only precedent for a stored URL: `users.avatar_url`, capped at 2,048
* since chapter 3.16. A second number for the same kind of value would be two limits a
* customer has to remember. */
export const ATTACHMENT_URL_MAX = 2048;
/** FR-004, and it is a list rather than a regex because the list is the requirement.
*
* `z.url()` IS NOT THIS CHECK. Measured against zod 4.4.3, it accepts `javascript:`,
* `data:`, `file:`, `ftp:` and `vbscript:` — research R7 ran the table rather than
* reading the docs. A URL validator that accepts `javascript:alert(1)` is not a
* scheme rule, so the scheme is asserted separately and by name. */
export const ATTACHMENT_SCHEMES = ["http:", "https:"] as const;
const urlWithAllowedScheme = z
.string()
.min(1)
.max(ATTACHMENT_URL_MAX)
.refine(
(value) => {
// `new URL` and not a prefix match. A prefix match passes
// `https:/example.test` and `httpsx://…` depending on how it is written, and the
// parser is the thing that already knows what a scheme is.
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return false;
}
return (ATTACHMENT_SCHEMES as readonly string[]).includes(parsed.protocol);
},
{ message: "url must use the http or https scheme" },
);
/** The arm §4.14 will replace. It parses the shape so the DISCRIMINATOR matches — that
* is what makes zod run this arm's refinement instead of answering with its own generic
* message — and then refuses unconditionally. */
const mediaArm = z
.strictObject({
type: z.literal("media"),
media_id: z.string().min(1),
})
.refine(() => false, {
message:
"hosted media is not available yet — attach an http or https url instead (FR-MSG-11 §4.14)",
/** THE SCHEMA NAMES ITS OWN REFUSAL, and that is what puts FR-003a on both doors
* with one mechanism.
*
* `ZodValidationPipe` answers every validation failure with `invalid_request` and a
* 400, which is right for a malformed body and wrong here: `media_id` is published
* in FR-MSG-11, so the honest answer is that the platform cannot serve it yet — its
* own code, and a 422 because the request is understood.
*
* Checking for a media arm in the CONTROLLER cannot work: `@Body(new
* ZodValidationPipe(...))` runs before the handler, so the schema has already
* refused with a 400 and control never arrives. The pipe reads this instead. */
params: { protocolCode: "media_not_available", status: 422 },
});
const urlArm = z.strictObject({
type: z.literal("url"),
// FR-002. Three kinds, and a fourth is a refusal rather than a passthrough.
kind: z.enum(["image", "audio", "video"]),
url: urlWithAllowedScheme,
});
/** `strictObject` on both arms, so an unknown key is a refusal rather than a silent
* drop — the argument `membershipFabricSchema` and `revisionFabricSchema` both make: a
* field added on one side of a rolling deploy fails loudly on the other instead of
* vanishing. */
export const attachmentSchema = z.discriminatedUnion("type", [urlArm, mediaArm]);
/** The type the read paths cast the column to. `messages.attachments` is a bare
* `jsonb()` with no `.$type<>()`, so drizzle infers `unknown` on select and every read
* site says what it is with `sql<Attachment[] | null>`. That is a cast and not a check;
* `data-model.md` argues why the claim lives per read site rather than once in the
* schema. */
export type Attachment = z.infer<typeof attachmentSchema>;
/** THE TEXT-AND-ATTACHMENTS PAIR RULE, in one place because it is one rule.
*
* FR-019: an attachments-only message is accepted and stores `text = ""` rather than a
* null, so chapter 3.23's tombstone predicate — `text === null` — is untouched.
* FR-019b: a message with neither text nor attachments is still refused.
*
* Those are two halves of one decision about a PAIR of fields, and writing it into
* either send schema would put it on one door. `messages.schema.ts` is imported by
* exactly one file and never by the socket path, so a `superRefine` there is a rule the
* socket does not have — and the refusal is the half that goes missing, because
* relaxing the text bound is what makes the permission work and nothing then enforces
* the floor.
*
* Applied with `.superRefine`, so both doors get the same issue and the same `field`. */
export function refineTextAndAttachments(
// `| undefined` SPELLED OUT, and not just `?`. This package compiles with
// `exactOptionalPropertyTypes`, under which `attachments?: T[]` means "absent or a
// T[]" and refuses a caller whose own type is `T[] | undefined`. Both send schemas
// infer exactly that, so the optional marker alone would reject both callers — which
// the compiler said before either of them existed.
value: {
text?: string | null | undefined;
attachments?: readonly unknown[] | null | undefined;
},
ctx: z.RefinementCtx,
): void {
const hasText = typeof value.text === "string" && value.text.length > 0;
const hasAttachments = Array.isArray(value.attachments) && value.attachments.length > 0;
if (hasText || hasAttachments) return;
ctx.addIssue({
code: "custom",
// `path` names `text`, not the pair. The api's pipe joins `path` with dots into the
// error's `field`, and a caller who sent neither is most likely to have sent text
// they thought was non-empty. Naming the pair would give a field no request has.
path: ["text"],
message: "text must not be empty unless the message carries at least one attachment",
});
}@@ -1,5 +1,11 @@
import { z } from "zod";
+import {
+ attachmentSchema,
+ MAX_ATTACHMENTS,
+ refineTextAndAttachments,
+} from "./attachments.js";
+
// The wire contract, one home (ADR-01). Every frame is a JSON object with a
// `type` discriminator and a `payload` (EIR-WS-02). Schemas are the single
// source of truth: every exported static type is inferred from its schema,
@@ -11,13 +17,32 @@ export const cursorSchema = z.record(z.string(), z.number().int().positive());
/** The message on the wire — derived from the SAD §6.1 `messages` columns.
* Wire spellings follow SAD §5.1's own frame line (`channel`, `seq`).
- * metadata/attachments/edit/tombstone fields arrive with Part 2/4. */
+ *
+ * `metadata` is the one column of §6.1 this payload still does not carry.
+ *
+ * THIS COMMENT SCHEDULED ATTACHMENTS FOR PART 4 AND FR-MSG-11 IS P2, which is
+ * Part 3 — a sentence that had been wrong for two parts, in the file that
+ * publishes the contract, and no checker reads prose. Editing and deletion do
+ * not appear here either, and that is a different fact rather than the same
+ * one: they arrived in chapter 3.23 as their own frames, `message.updated`
+ * and `message.deleted`, because a deletion is not a message. */
export const messageSchema = z.strictObject({
id: z.string().min(1),
channel: z.string().min(1),
seq: z.number().int().positive(),
user: z.string().min(1),
text: z.string(),
+ /** REQUIRED, AND NOT OPTIONAL, and that is the whole of FR-022 (3.24).
+ *
+ * An optional field parses a payload that omits it, so a construction site
+ * nobody widened delivers a message whose attachments are simply absent —
+ * green tests, silent loss. Required means the compiler names every site
+ * instead: `pnpm --filter @relay/protocol build`, then `tsc --noEmit` in the
+ * api and the gateway, lists four in production and 28 in tests.
+ *
+ * FR-007 (3.24): a message with none carries `[]` rather than an absent key,
+ * so a reader needs no special case. `?? []` at the read sites, never `?? null`. */
+ attachments: z.array(attachmentSchema),
created_at: z.iso.datetime(), // UTC, RFC 3339 (constitution: timestamps)
});
@@ -40,11 +65,29 @@ export const connectionAckSchema = z.strictObject({
* server-side within 24 h (FR-MSG-04). */
export const messageSendSchema = z.strictObject({
type: z.literal("message.send"),
- payload: z.strictObject({
- idem_key: z.string().min(1).max(255),
- channel: z.string().min(1),
- text: z.string(),
- }),
+ payload: z
+ .strictObject({
+ idem_key: z.string().min(1).max(255),
+ channel: z.string().min(1),
+ text: z.string(),
+ /** OPTIONAL here and required on the outbound `messageSchema`, which is not an
+ * inconsistency: a caller may send none, and a payload the platform BUILDS must
+ * always say. The bound is imported rather than spelled — two schemas that happen
+ * to agree are what `idem_key` against `idempotency_key` looks like three chapters
+ * later. */
+ attachments: z.array(attachmentSchema).max(MAX_ATTACHMENTS).optional(),
+ })
+ /** THE PAIR RULE ON THIS DOOR TOO, AND THE REASON IS THE LAYER, NOT THE RULE.
+ *
+ * FR-019b was already met without this line: the frame parsed, the api's
+ * `internalSendRequestSchema` refused it, and the client got `invalid_request`. But
+ * the ten-item bound above is on THIS schema, so the gateway answers that one with
+ * `invalid_frame` — two rules about one payload refused at two layers under two
+ * codes, for no reason a caller could discover.
+ *
+ * Measured before it was decided: `{ text: "", attachments: [] }` came back
+ * `invalid_request` while eleven attachments came back `invalid_frame`. */
+ .superRefine(refineTextAndAttachments),
});
/** Server → sender after commit — never before (SAD §5.1, FR-MSG-05). */@@ -1,5 +1,11 @@
import { z } from "zod";
+import {
+ attachmentSchema,
+ MAX_ATTACHMENTS,
+ refineTextAndAttachments,
+} from "./attachments.js";
+
import { messageSchema } from "./frames.js";
// The INTERNAL service contract (chapter 2.5) — distinct from the wire
@@ -14,11 +20,25 @@ import { messageSchema } from "./frames.js";
// payload's shape than an external one does.
/** Gateway → api: forward the payload a `message.send` frame carried. */
-export const internalSendRequestSchema = z.strictObject({
- channel_id: z.string().uuid(),
- text: z.string().min(1).max(8000), // FR-MSG-01
- idempotency_key: z.string().min(1).max(255).optional(), // FR-MSG-04
-});
+export const internalSendRequestSchema = z
+ .strictObject({
+ channel_id: z.string().uuid(),
+ /** `.min(1)` REMOVED in chapter 3.24 (FR-019), not relaxed by accident.
+ *
+ * This is the door every socket send goes through. Leaving the minimum here
+ * would meet FR-019 on the REST door alone: a REST client could send a
+ * photograph with no caption and a socket client could not, with no
+ * requirement anywhere saying so. The 8,000 stays — FR-MSG-01 is untouched. */
+ text: z.string().max(8000), // FR-MSG-01
+ idempotency_key: z.string().min(1).max(255).optional(), // FR-MSG-04
+ attachments: z.array(attachmentSchema).max(MAX_ATTACHMENTS).optional(),
+ })
+ /** AND THE FLOOR COMES WITH THE PERMISSION. Removing `.min(1)` above carries
+ * FR-019 across; without this line FR-019b — a message with no text and no
+ * attachments MUST still be refused — is enforced on the REST door only, because
+ * `messages.schema.ts` is imported by exactly one file and never by this path.
+ * One rule, one definition, two callers. */
+ .superRefine(refineTextAndAttachments);
/** api → gateway: the committed message. `seq` is what the ack carries
* (FR-MSG-05 — after the commit, never before). */
@@ -32,6 +52,22 @@ export const internalSendResponseSchema = z.strictObject({
* 2.7's resume path reads back out of Postgres. */
user: z.string().min(1),
text: z.string().nullable(),
+ /** REQUIRED, because this payload carries a message and FR-022 (3.24) says every
+ * such payload has the field.
+ *
+ * THIS SCHEMA IS A `strictObject` AND `services/gateway/src/api-client.ts:248`
+ * PARSES THE API'S RESPONSE WITH IT. `internal.controller.ts:84` returns
+ * `{ ...message, user }`, a spread, so the moment `sendMessage` returns an
+ * attachments key the old schema would have refused the payload and every socket
+ * send would close 1011. Measured against zod 4.4.3, all three combinations:
+ *
+ * schema without the field value carries the key refused
+ * field REQUIRED key absent refused
+ * field optional key absent accepted, FR-022 broken
+ *
+ * Only the required field and `sendMessage`'s return landing together is honest,
+ * which is why they are one phase. */
+ attachments: z.array(attachmentSchema),
created_at: z.iso.datetime(),
/** True when 2.3's idempotency index recognised a retry. The PUBLIC api
* still hides this (a client cannot tell a retry from a first send);@@ -1,29 +1,53 @@
+import {
+ attachmentSchema,
+ MAX_ATTACHMENTS,
+ refineTextAndAttachments,
+} from "@relay/protocol";
import { z } from "zod";
// The send body (chapter 2.2). FR-MSG-01 fixes the limits: text up to
// 8,000 characters, metadata up to 4 KB of JSON — the length check lands
// with FR-EMJ-02's code-point counting in the emoji chapter; today the
// character bound is the honest approximation, recorded as such.
-export const sendMessageBodySchema = z.strictObject({
- text: z.string().min(1).max(8000),
- metadata: z.record(z.string(), z.unknown()).optional(),
- // Chapter 2.3 (FR-MSG-04): the client's idempotency key — minted at send
- // time (FR-SDK-06), optional because server-originated messages may not
- // carry one. The partial unique index (DR-03) ignores NULLs.
- idempotency_key: z.string().uuid().optional(),
- /** WHO IS SENDING (chapter 3.17, FR-MSG-15, FR-008).
- *
- * OPTIONAL HERE AND REQUIRED FOR ONE CREDENTIAL CLASS, which zod cannot express
- * because it cannot see who is calling. A user token's send is attributed to the
- * token's subject and naming a `user` in the body is refused; an application
- * credential must name one, because it carries no user of its own. The controller
- * resolves that per class (T029) and the service refuses what cannot be resolved.
+export const sendMessageBodySchema = z
+ .strictObject({
+ /** `.min(1)` REMOVED in chapter 3.24 (FR-019), and the floor moved to the
+ * refinement below rather than disappearing. An attachments-only message is a
+ * photograph with no caption, and it stores `text = ""` rather than a null so
+ * chapter 3.23's tombstone predicate — `text === null` — is untouched. */
+ text: z.string().max(8000),
+ metadata: z.record(z.string(), z.unknown()).optional(),
+ // Chapter 2.3 (FR-MSG-04): the client's idempotency key — minted at send
+ // time (FR-SDK-06), optional because server-originated messages may not
+ // carry one. The partial unique index (DR-03) ignores NULLs.
+ idempotency_key: z.string().uuid().optional(),
+ /** WHO IS SENDING (chapter 3.17, FR-MSG-15, FR-008).
+ *
+ * OPTIONAL HERE AND REQUIRED FOR ONE CREDENTIAL CLASS, which zod cannot express
+ * because it cannot see who is calling. A user token's send is attributed to the
+ * token's subject and naming a `user` in the body is refused; an application
+ * credential must name one, because it carries no user of its own. The controller
+ * resolves that per class (T029) and the service refuses what cannot be resolved.
+ *
+ * A CUSTOMER-SUPPLIED IDENTIFIER, not a platform id. Every other user-facing field in
+ * this API names a user the way FR-USR-01 says the customer does, and a route that
+ * took an internal uuid here would be the only one that did not. */
+ user: z.string().min(1).max(255).optional(),
+ /** FR-001 and FR-005. OPTIONAL on the way in — a caller may send none — and the
+ * bound is IMPORTED rather than spelled, because the socket's door imports the same
+ * constant and two schemas that happen to agree are what `idem_key` against
+ * `idempotency_key` looks like three chapters later. */
+ attachments: z.array(attachmentSchema).max(MAX_ATTACHMENTS).optional(),
+ })
+ /** THE PAIR RULE, IMPORTED AND NOT WRITTEN HERE (FR-019, FR-019b).
*
- * A CUSTOMER-SUPPLIED IDENTIFIER, not a platform id. Every other user-facing field in
- * this API names a user the way FR-USR-01 says the customer does, and a route that
- * took an internal uuid here would be the only one that did not. */
- user: z.string().min(1).max(255).optional(),
-});
+ * Text may be empty when at least one attachment is present, and a body with neither
+ * is refused. That is one rule about a PAIR of fields, and this file is imported by
+ * exactly one other — never by the socket path, which validates with
+ * `internalSendRequestSchema`. A `superRefine` written here would be a rule the
+ * socket does not have, and the half that goes missing is the REFUSAL: relaxing the
+ * text bound is what makes the permission work, and nothing then enforces the floor. */
+ .superRefine(refineTextAndAttachments);
export type SendMessageBody = z.infer<typeof sendMessageBodySchema>;
@@ -47,8 +71,20 @@ export type SendMessageBody = z.infer<typeof sendMessageBodySchema>;
* 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. */
+/** The edit body (chapter 3.23, FR-001). ITS OWN BOUND, AND NO LONGER THE SEND'S.
+ *
+ * This read `sendMessageBodySchema.shape.text` until chapter 3.24, which was correct
+ * while the two agreed. Then FR-019 removed `.min(1)` from the send so an
+ * attachments-only message could carry no caption — and the edit inherited the
+ * relaxation through this reference. **An edit has no attachments field**, so the pair
+ * rule that restores the send's floor cannot restore this one: `PATCH` with `text: ""`
+ * became a 200 and chapter 3.23's own test caught it.
+ *
+ * Spelled out rather than derived. Two schemas that happen to agree are a defect this
+ * chapter has already recorded twice; two schemas that must DIFFER cannot share a
+ * reference at all. */
export const editMessageBodySchema = z.strictObject({
- text: sendMessageBodySchema.shape.text,
+ text: z.string().min(1).max(8000),
});
export type EditMessageBody = z.infer<typeof editMessageBodySchema>;@@ -13,6 +13,8 @@ import {
type SQL,
} from "drizzle-orm";
+import type { Attachment } from "@relay/protocol";
+
import { DEFAULT_LIMITS, type LimitedOperation } from "../limits/policy";
import type { Db } from "./client";
import {
@@ -2255,6 +2257,19 @@ export interface MessageRow {
channel_id: string;
seq: number;
text: string | null;
+ /** Chapter 3.24 (FR-001, FR-007). **REQUIRED, unlike `edited_at` below**, and the
+ * contrast is the decision.
+ *
+ * `edited_at?` is optional so write paths need not spell `edited_at: null`, and that
+ * convenience is exactly what made chapter 3.24's `internalSendResponseSchema` a break
+ * waiting to happen: a field the type lets you omit is a field the gateway's strict
+ * parse refuses at runtime, with no compiler anywhere in between. Required here means
+ * every path that builds a row is named by `tsc` instead.
+ *
+ * `Attachment[]` AND NOT `Attachment[] | null`, so the null lives only in the column.
+ * FR-007: a message with none is returned with an empty list rather than an absent or
+ * null field, and the `?? []` that makes that true belongs at the read, once. */
+ attachments: Attachment[];
created_at: string;
/** When it was last edited, or `null` (chapter 3.23, FR-003). Optional on this
* interface rather than required, because the WRITE paths build a row that has never
@@ -3945,6 +3960,7 @@ export class Repository {
userExternalId,
text,
metadata,
+ attachments,
idempotencyKey,
senderMustBeBot = false,
}: {
@@ -3962,6 +3978,11 @@ export class Repository {
* reach. Enforcing it in the service would put it FIRST — before the ban, the
* visibility and the archive — and leak exactly what the ordering protects.
* There is no way to be both last and outside this transaction. */
+ /** FR-001 and FR-006. Optional in, and the column stores `NULL` rather than `[]`
+ * when there are none — `data-model.md` says why the two differ: NULL means the
+ * message has no attachments, and `[]` would be a list that happens to be empty.
+ * Every read converts NULL to `[]` on the way out (FR-007), once. */
+ attachments?: Attachment[] | undefined;
senderMustBeBot?: boolean;
/** REQUIRED SINCE CHAPTER 3.17 (FR-MSG-15, FR-006), and required is the whole
* mechanism. SC-003 asks that no write path be able to produce a senderless
@@ -4193,6 +4214,14 @@ export class Repository {
userId: userId ?? null,
text,
metadata: metadata ?? {},
+ // THE ARRAY AS SENT, IN ORDER (FR-006), or NULL when there are none. JSONB
+ // preserves array order, so nothing here sorts or de-duplicates: FR-021 says
+ // the same URL twice is two attachments.
+ //
+ // `?? null` AND NOT `?? []`. An empty array stored would be a message that
+ // carries a list of no attachments, which is a different fact from carrying
+ // none — and `listMessages`' own `?? []` makes both read identically anyway.
+ attachments: attachments ?? null,
idempotencyKey: idempotencyKey ?? null,
});
@@ -4275,6 +4304,9 @@ export class Repository {
seq,
user: userExternalId ?? null,
text,
+ // FR-015 and FR-017. The same list the row holds and the frame carries — a
+ // consumer and a socket client comparing the two see one message.
+ attachments: attachments ?? [],
created_at: createdAt,
},
});
@@ -4399,6 +4431,14 @@ export class Repository {
channel_id: channel.id,
seq,
text,
+ /** WHAT WAS SENT, AND THE INSERT IS NOT ENOUGH ON ITS OWN.
+ *
+ * T022 wrote the column and this line is a separate change: analysis pass 9
+ * found that nothing in the plan made this return carry the field, and the 201
+ * response and both fan-out payloads read it from here. `?? []` because the
+ * parameter is optional and FR-007 says a message with none is RETURNED with an
+ * empty list. */
+ attachments: attachments ?? [],
created_at: createdAt,
};
});
@@ -4451,6 +4491,14 @@ export class Repository {
text: messages.text,
seq: messages.sequence,
createdAt: messages.createdAt,
+ /** Chapter 3.24 (FR-015, FR-016). THIS READ CHANGES, AND T053's LIST OF FOUR
+ * BECOMES THREE.
+ *
+ * An edit does not change attachments — T045 and T046 prove that from both
+ * sides — but the `message.updated` event and the 200 response must carry the
+ * ones the message ALREADY has, and neither can invent them. So the read that
+ * feeds both selects the column. */
+ attachments: sql<Attachment[] | null>`${messages.attachments}`,
// 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
@@ -4534,6 +4582,10 @@ export class Repository {
seq: row.seq,
user: row.author,
text,
+ // FR-015: identical to the creation payload, so a consumer needs one shape for
+ // both. An edit does not change attachments (FR-016), so these are the ones the
+ // message already had — which is why phase 7 widened this read.
+ attachments: row.attachments ?? [],
created_at: toIso(row.createdAt),
},
});
@@ -4547,6 +4599,10 @@ export class Repository {
channel_id: channelId,
seq: row.seq,
text,
+ // WHAT THE MESSAGE ALREADY HAS. `?? []` for the same reason every read has one:
+ // the column holds NULL for a message with none and FR-007 says a reader sees an
+ // empty list.
+ attachments: row.attachments ?? [],
created_at: toIso(row.createdAt),
edited_at: toIso(editedAt),
prior_text: row.text,
@@ -4673,6 +4729,10 @@ export class Repository {
seq: row.seq,
text: null,
created_at: toIso(row.createdAt),
+ // `[]` AND IT STAYS `[]`. FR-012: deleting a message unlinks its
+ // attachments, so a tombstone's list is empty on every path that
+ // returns one. This is the answer, not a value a later phase fills.
+ attachments: [],
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
@@ -4748,6 +4808,10 @@ export class Repository {
seq: row.seq,
text: null,
created_at: toIso(row.createdAt),
+ // `[]` AND IT STAYS `[]`. FR-012: deleting a message unlinks its
+ // attachments, so a tombstone's list is empty on every path that
+ // returns one. This is the answer, not a value a later phase fills.
+ attachments: [],
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
@@ -5045,6 +5109,11 @@ export class Repository {
channel_id: messages.channelId,
seq: messages.sequence,
text: messages.text,
+ /** A CAST AND NOT A CHECK. `messages.attachments` is a bare `jsonb()` with no
+ * `.$type<>()`, so drizzle infers `unknown` and this names it. Postgres
+ * enforces no shape on the column; `data-model.md` argues why the claim sits
+ * at each read site rather than once in the schema. */
+ attachments: sql<Attachment[] | null>`${messages.attachments}`,
created_at: messages.createdAt,
})
.from(messages)
@@ -5063,7 +5132,15 @@ export class Repository {
`idempotency key ${idempotencyKey} conflicted but its message is missing — index inconsistency`,
);
}
- return { ...row, created_at: toIso(row.created_at) };
+ return {
+ ...row,
+ // FR-007's `?? []`, AT THE READ. The column holds NULL for a message with no
+ // attachments and `[]` is what a client gets, so exactly one place converts.
+ // `?? []` and not `|| []`: an empty array is falsy to neither, but the habit of
+ // `||` here is how a `0` or a `""` becomes a default somewhere else.
+ attachments: row.attachments ?? [],
+ created_at: toIso(row.created_at),
+ };
}
/** Does this channel resolve IN THIS TENANT? (chapter 2.8.)
@@ -5228,6 +5305,10 @@ export class Repository {
id: messages.id,
channel_id: messages.channelId,
seq: messages.sequence,
+ /** Chapter 3.24 (FR-009). A CAST AND NOT A CHECK: `messages.attachments` is a bare
+ * `jsonb()` with no `.$type<>()`, so drizzle infers `unknown` and this names it.
+ * Postgres enforces no shape on the column. */
+ attachments: sql<Attachment[] | null>`${messages.attachments}`,
// The sender joins the read path in 2.7 (the IOU 2.6 wrote): resume
// must emit frames identical to live ones, and a reader that gets a
// different shape depending on which door it came through is a client
@@ -5286,6 +5367,14 @@ export class Repository {
.limit(limit));
return rows.map((row) => ({
...row,
+ /** FR-007's `?? []`, IN THE MAP AND NOT IN THE CALLER.
+ *
+ * A message with no attachments stores NULL and is RETURNED with an empty list, so
+ * a reader needs no special case. Putting the conversion in each caller would give
+ * the platform as many answers as it has callers — and chapter 3.23 shipped a
+ * control test that was green before its field existed, because `?? null` cannot
+ * tell an absent key from a null one. This is the one place that decides. */
+ attachments: row.attachments ?? [],
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@@ -75,8 +75,15 @@ function publishContext(req: RequestWithPrincipal): {
}
// 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).
+// boundary, from the same schema family as @relay/protocol.
+//
+// THAT IS NOT THE SAME AS "CANNOT DRIFT", which this comment claimed until
+// chapter 3.24. They are two schemas validated at two controllers — this route
+// with `sendMessageBodySchema`, every socket send with
+// `internalSendRequestSchema` — and they have drifted three times: `idem_key`
+// against `idempotency_key`, the text bound, and FR-019b's pair rule, which
+// 3.24 had to be told to carry across. What keeps them together is a shared
+// DEFINITION each applies, not a family resemblance.
//
// Chapter 3.2 swapped the guard. `EnvironmentContextGuard` resolved a tenant
// from a header the caller asserted; `CredentialGuard` only asks whether the
@@ -242,6 +249,10 @@ export class MessagesController {
seq: message.seq,
user: actingExternalId,
text: message.text,
+ /** FR-008 and FR-022. ALWAYS AN ARRAY — `sendMessage` returns `[]` for a
+ * message with none, so nothing here needs a fallback and a fallback would
+ * hide the day that stops being true. */
+ attachments: message.attachments,
created_at: message.created_at,
},
publishContext(req),
@@ -253,6 +264,10 @@ export class MessagesController {
channel_id: message.channel_id,
seq: message.seq,
text: message.text,
+ /** Chapter 3.24 (FR-001), and SPELLED rather than spread for the reason this
+ * response has always been spelled: a new column joins the public surface when
+ * somebody decides it should, not when it appears on a row. */
+ attachments: message.attachments,
created_at: message.created_at,
// THE SENDER IT USED (chapter 3.17, FR-009a). A caller now required to name one
// gets told which was recorded — and for a user token, which it inferred. The
@@ -340,6 +355,10 @@ export class MessagesController {
seq: edited.seq,
user: actingExternalId,
text: edited.text!,
+ /** FR-015 and FR-022. THE MESSAGE'S EXISTING LIST, not a new one — an edit
+ * changes text and nothing else, and FR-015 asks that the creation and edit
+ * payloads carry attachments identically so a consumer needs one shape. */
+ attachments: edited.attachments,
created_at: edited.created_at,
},
},
@@ -357,6 +376,9 @@ export class MessagesController {
channel_id: edited.channel_id,
seq: edited.seq,
text: edited.text,
+ /** FR-001 and FR-022. The same list the event above carries, so a caller that
+ * edits and a consumer that watches see one message rather than two. */
+ attachments: edited.attachments,
created_at: edited.created_at,
edited_at: edited.edited_at,
user: actingExternalId,@@ -175,6 +175,15 @@ function sendError(
code: ErrorCode,
message: string,
requestId: string = newRequestId(),
+ /** WHICH FIELD, on the socket door (chapter 3.24, FR-005).
+ *
+ * `errorFrameSchema` has published this key since chapter 1.3 and no gateway code path
+ * had ever set it — the same habit `zod-validation.pipe.ts` ended for the api at
+ * chapter 3.14, whose comment cites THIS schema while fixing only its own side.
+ *
+ * Omitted when there is no path, exactly as the pipe does: an empty path means the
+ * whole frame failed and there is no field to name. */
+ field?: string,
): void {
send(socket, {
type: "error",
@@ -183,6 +192,7 @@ function sendError(
message,
docs_url: docsUrl(code),
request_id: requestId,
+ ...(field !== undefined && field.length > 0 ? { field } : {}),
},
});
}
@@ -1445,6 +1455,10 @@ export function attachSessions({
connection.socket,
"invalid_frame",
frame.error.issues[0]?.message ?? "frame failed schema validation",
+ undefined,
+ // The joined path, which is what a developer reading their own frame sees —
+ // `payload.attachments.3.kind` rather than "somewhere in this frame".
+ frame.error.issues[0]?.path.join("."),
);
return;
}
@@ -1509,11 +1523,16 @@ export function attachSessions({
}
}
- const { channel, text, idem_key } = frame.data.payload;
+ // A NAMED DESTRUCTURE, AND THAT IS THE POINT (chapter 3.24, FR-001). Widening
+ // `messageSendSchema` puts `attachments` on the wire; without naming it here nothing
+ // carries it further, the message commits without attachments, and the client is
+ // acked as though it worked. There is no error anywhere in that sequence.
+ const { channel, text, idem_key, attachments } = frame.data.payload;
try {
const committed = await api.sendMessage(connection.identity, {
channel_id: channel,
text,
+ ...(attachments !== undefined && { attachments }),
idempotency_key: idem_key,
});
const { seq } = committed;
@@ -1537,6 +1556,13 @@ export function attachSessions({
seq: committed.seq,
user: committed.user,
text: committed.text,
+ /** FR-008 and FR-022, AND THE GATEWAY BUILDS THIS ONE ITSELF.
+ *
+ * The api constructs the fan-out payload for a REST send and the gateway
+ * constructs it for a socket send — two builders for one frame, and this is
+ * the second. `internalSendResponseSchema` requires the field, so
+ * `committed.attachments` is always an array by the time it reaches here. */
+ attachments: committed.attachments,
created_at: committed.created_at,
});
}@@ -1,6 +1,8 @@
+import { attachmentSchema, type Attachment } from "@relay/protocol";
+
import { subjectFor } from "@relay/protocol";
import { z } from "zod";
// The event envelope (chapter 3.3). Built in ONE place, complete, inside the
// transaction that caused it — so the relay is a mover of bytes and never an
// author of them (ADR-04, research R7).
@@ -16,12 +18,25 @@ import { z } from "zod";
export interface MessageCreatedData {
id: string;
channel_id: string;
seq: number;
user: string | null;
text: string | null;
+ /** Chapter 3.24 (FR-015, FR-017). ON `message.created` AND `message.updated` AT ONCE,
+ * because both events carry this one interface — FR-015 asks that a consumer need one
+ * shape for both, and the type is where that stops being a promise.
+ *
+ * AND ON NEITHER `message.deleted` NOR THE MEMBERSHIP EVENTS. `MessageDeletedData`
+ * below carries no `text` because a payload with a text field can carry the words
+ * somebody asked to have removed; an attachment URL is exactly as recoverable, so the
+ * absence there is the same decision and not an omission.
+ *
+ * NOT OPTIONAL. `consumer/runtime.ts` answers a failed parse with `message.term()`,
+ * which stops redelivery for good — so a branch that has not been widened is a row
+ * destroyed rather than retried, and an optional field hides the day that happens. */
+ attachments: Attachment[];
created_at: string;
}
/** A DELETION as a consumer receives it (chapter 3.23, FR-019, FR-020).
*
* NO `text`, AND NO `text: null` EITHER. The frame `packages/protocol/src/frames.ts`
@@ -297,12 +312,30 @@ export const outboxEventSchema = z.discriminatedUnion("type", [
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(),
+ // Chapter 3.24 (FR-015). BOTH BRANCHES, and restated rather than shared for the
+ // reason the comment above gives: FR-015 is the requirement that they not drift,
+ // which is only meaningful if a change to one is visible in the other's absence.
+ //
+ // `.default([])` HERE AND REQUIRED ON `MessageCreatedData` ABOVE, and the two are
+ // not in tension — they are about different directions. That interface types what
+ // this api BUILDS, and required is what makes the compiler name every branch a new
+ // field has to reach; this schema reads BYTES OFF A DURABLE QUEUE, and some of
+ // those bytes were written by the binary that ran before this deploy. A required
+ // field here answers such a message with `message.term()` — destroyed, not retried
+ // — which is the failure this file's own header describes and the reason it exists.
+ //
+ // FOUND BY THE CLOSE-OUT COVERAGE LANE, six red tests in `consumer.itest.ts`, after
+ // eleven analysis passes and eleven phases. The comment above argues NOT OPTIONAL
+ // from `message.term()`, and that argument is correct about the producer and
+ // inverts about the reader: the same sentence that makes a missing branch loud at
+ // compile time makes a missing key fatal at runtime.
+ attachments: z.array(attachmentSchema).default([]),
created_at: z.iso.datetime(),
}),
}),
// CHAPTER 3.23. The union is exhaustive over `OUTBOX_EVENT_TYPES`, and this file's own
// comment above says why that matters: `consumer/runtime.ts:163` answers a failed
// parse with `message.term()`, which stops redelivery for good. A type added to the
@@ -318,12 +351,30 @@ export const outboxEventSchema = z.discriminatedUnion("type", [
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(),
+ // Chapter 3.24 (FR-015). BOTH BRANCHES, and restated rather than shared for the
+ // reason the comment above gives: FR-015 is the requirement that they not drift,
+ // which is only meaningful if a change to one is visible in the other's absence.
+ //
+ // `.default([])` HERE AND REQUIRED ON `MessageCreatedData` ABOVE, and the two are
+ // not in tension — they are about different directions. That interface types what
+ // this api BUILDS, and required is what makes the compiler name every branch a new
+ // field has to reach; this schema reads BYTES OFF A DURABLE QUEUE, and some of
+ // those bytes were written by the binary that ran before this deploy. A required
+ // field here answers such a message with `message.term()` — destroyed, not retried
+ // — which is the failure this file's own header describes and the reason it exists.
+ //
+ // FOUND BY THE CLOSE-OUT COVERAGE LANE, six red tests in `consumer.itest.ts`, after
+ // eleven analysis passes and eleven phases. The comment above argues NOT OPTIONAL
+ // from `message.term()`, and that argument is correct about the producer and
+ // inverts about the reader: the same sentence that makes a missing branch loud at
+ // compile time makes a missing key fatal at runtime.
+ attachments: z.array(attachmentSchema).default([]),
created_at: z.iso.datetime(),
}),
}),
z.strictObject({
...envelope,
type: z.literal("message.deleted"),@@ -308,6 +308,74 @@ describe("integrating with Relay from the outside", () => {
socket.close();
});
+ /** T033 (chapter 3.24). ATTACHMENTS THROUGH THE SHIPPED BINARY.
+ *
+ * This file is the only instrument in the repository that boots what customers run and
+ * drives it the way they do — Node's global `WebSocket`, no workspace import, the REST
+ * credential a customer's server holds. Chapter 3.23's plan scheduled a title audit
+ * over this file and no task wrote to it; this chapter writes.
+ *
+ * TWO ATTACHMENTS AND THE ORDER, for the reason every other test in this chapter gives:
+ * one cannot show an order, and FR-006 says order holds on every path that returns a
+ * message. */
+ it("delivers two attachments to a socket, in order, sent over REST", async () => {
+ const socket = new WebSocket(`${ws}/v1/ws?token=${token}`);
+ const frames: { type: string; payload?: { text?: string; attachments?: { url?: string }[] } }[] =
+ [];
+ socket.addEventListener("message", (event) => {
+ frames.push(JSON.parse(String(event.data)) as { type: string });
+ });
+ socket.addEventListener("error", () => undefined);
+ await new Promise<void>((resolve, reject) => {
+ socket.addEventListener("open", () => resolve());
+ socket.addEventListener("close", (event) =>
+ reject(new Error(`closed ${(event as CloseEvent).code}`)),
+ );
+ setTimeout(() => reject(new Error(`no socket at ${ws} within 10s`)), 10_000);
+ });
+
+ const waitFor = async (predicate: (f: { type: string }) => boolean, what: string) => {
+ const deadline = Date.now() + 10_000;
+ for (;;) {
+ const found = frames.find(predicate);
+ if (found) return found;
+ if (Date.now() > deadline) {
+ throw new Error(`no ${what}; saw ${frames.map((f) => f.type).join(", ") || "nothing"}`);
+ }
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ };
+ await waitFor((f) => f.type === "connection.ack", "connection.ack");
+
+ const text = `with pictures ${Date.now()}`;
+ const posted = await post(
+ `/v1/channels/${channelId}/messages`,
+ {
+ text,
+ user: "outside-bot",
+ idempotency_key: randomUUID(),
+ attachments: [
+ { type: "url", kind: "image", url: "https://example.test/outside-first.png" },
+ { type: "url", kind: "video", url: "https://example.test/outside-second.mp4" },
+ ],
+ },
+ credential,
+ );
+ expect(posted.status).toBe(201);
+
+ const delivered = (await waitFor(
+ (f) =>
+ f.type === "message.created" &&
+ (f as { payload?: { text?: string } }).payload?.text === text,
+ "message.created carrying the attachments",
+ )) as { payload: { attachments: { url?: string }[] } };
+ expect(delivered.payload.attachments.map((a) => a.url)).toEqual([
+ "https://example.test/outside-first.png",
+ "https://example.test/outside-second.mp4",
+ ]);
+ socket.close();
+ });
+
/** CHAPTER 3.21, T100a — **the first `socket.send` in this file's history.**
*
* `grep -c "\.send(" packages/outsider/src/integrate.itest.ts` read **0** acrossThe remaining twenty-one are the cost of the decision rather than the argument for it — every construction of a message payload that the compiler named, plus the tests and the pinned key sets that hold them. They are here because a chapter that changes a file with a chain has to say so, not because a reader has to study them.
@@ -77,7 +77,7 @@ describe("the registry is the whole vocabulary (FR-024)", () => {
// its plan did not expect. **One pinned place, not the four chapter 3.22's close code
// moved** — that chapter's task predicted two and found four, so this one counted
// before editing: this assertion is the only place in the file that names a total.
- expect(Object.keys(ERROR_CODES)).toHaveLength(20);
+ expect(Object.keys(ERROR_CODES)).toHaveLength(21);
});
it("names the non-author refusal separately from the generic 403 (chapter 3.23)", () => {
@@ -85,6 +85,14 @@ describe("the registry is the whole vocabulary (FR-024)", () => {
// 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.
+ // Chapter 3.24's one addition, and the three assertions a new code earns: it exists,
+ // it is not a synonym for the code somebody would otherwise reach for, and it says
+ // what it is about. `invalid_request` is the wrong answer for `media_id` because that
+ // field IS in the published contract.
+ expect(ERROR_CODES).toHaveProperty("media_not_available");
+ expect(ERROR_CODES.media_not_available).not.toBe(ERROR_CODES.invalid_request);
+ expect(ERROR_CODES.media_not_available).toMatch(/media/);
+
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/);@@ -221,6 +221,30 @@ export const ERROR_CODES = {
// see exists.
message_deleted:
"this message has been deleted; its text cannot be changed, and its history is unaffected",
+ /** MEDIA THAT DOES NOT EXIST YET, AND ITS OWN CODE (chapter 3.24, FR-003, FR-003a).
+ *
+ * FR-MSG-11 publishes two ways to attach: an external URL and a `media_id` naming
+ * something the platform hosts. This chapter builds the first. **A customer reading
+ * that clause will send the second**, and the refusal they get decides whether they
+ * conclude they made a mistake or that the feature is not here yet.
+ *
+ * `invalid_request` WOULD SAY THE WRONG THING. It means the caller sent something the
+ * contract does not allow, and `media_id` is in the published contract — so the honest
+ * answer is that the platform cannot serve it, not that the field is wrong. That is
+ * the same distinction chapter 2.8 drew between a 404 and a 403.
+ *
+ * 422 AND NOT 400. The body is well-formed and the request is understood; what cannot
+ * be done is the thing it asks for. `ProtocolErrorFilter` derives a code from the
+ * status for 400/401/403/404 and answers `internal_error` for everything else, so a
+ * 422 MUST supply this code explicitly through `protocolError` — an unnamed 422 ships
+ * a body calling itself an internal error, which is `gaps.md`'s five bare 422s in
+ * `webhooks.service.ts`, still open.
+ *
+ * §4.14 REPLACES THE ARM RATHER THAN THIS CODE. When hosted media ships, the
+ * `{ type: "media" }` arm starts accepting and this entry describes a state the
+ * platform no longer has — at which point it is deleted, not repurposed. */
+ media_not_available:
+ "hosted media is not available yet; attach an http or https url instead",
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:@@ -12,6 +12,11 @@ const message = {
seq: 42,
user: "u1",
text: "hello",
+ // Chapter 3.24: REQUIRED on `messageSchema`, so this fixture says. `[]` rather than a
+ // populated list, because these tests are about the frame's SHAPE — the attachment's
+ // own shape is `attachments.test.ts`'s subject and duplicating it here would give two
+ // places to change when it moves.
+ attachments: [],
created_at: "2026-08-01T09:00:00.000Z",
};
@@ -159,6 +164,34 @@ describe("malformed frames reject", () => {
// T015 and T016 (chapter 3.23). THE EXACT KEY SET, on `codes.test.ts`'s precedent: an
// exact set is what makes a payload change a decision rather than an accident, and the
// only field that must NOT be there is the one this frame exists because it cannot fill.
+describe("the message payload's exact key set (chapter 3.24, FR-022 (3.24))", () => {
+ it("names exactly seven, and attachments is one of them", () => {
+ // SIX UNTIL CHAPTER 3.24, and pinned here for the first time — the frame had no
+ // exact-set assertion at all, so `messageSchema` was the one published payload a
+ // silent addition could reach. `codes.test.ts` has pinned its set since 3.2 for the
+ // same reason: an exact set makes a change a decision rather than an accident.
+ const parsed = messageSchema.parse(message);
+ expect(Object.keys(parsed).sort()).toEqual([
+ "attachments",
+ "channel",
+ "created_at",
+ "id",
+ "seq",
+ "text",
+ "user",
+ ]);
+ });
+
+ it("refuses a payload with no attachments key, because the field is required", () => {
+ // The other half of FR-022 (3.24). An OPTIONAL field would accept this, and a
+ // construction site nobody widened would deliver a message whose attachments are
+ // simply absent — green tests, silent loss.
+ const withoutAttachments: Record<string, unknown> = { ...message };
+ delete withoutAttachments["attachments"];
+ expect(messageSchema.safeParse(withoutAttachments).success).toBe(false);
+ });
+});
+
describe("the deleted frame carries an identity and no text (chapter 3.23)", () => {
const tombstone = {
id: message.id,
@@ -182,6 +215,19 @@ describe("the deleted frame carries an identity and no text (chapter 3.23)", ()
]);
});
+ it("refuses an ATTACHMENTS field, for the same reason it refuses text (FR-013 (3.24))", () => {
+ // Chapter 3.24 gave `messageSchema` a required attachments array, and the obvious
+ // next move — symmetry — would be wrong here. A deletion's payload carries no text
+ // because a payload with a text field is a payload that can carry the words somebody
+ // asked to have removed; an attachment URL is exactly as recoverable. So this frame
+ // keeps its five keys and the absence is the assertion.
+ const withAttachments = messageDeletedSchema.safeParse({
+ type: "message.deleted",
+ payload: { ...tombstone, attachments: [] },
+ });
+ expect(withAttachments.success).toBe(false);
+ });
+
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@@ -14,3 +14,4 @@ export * from "./presence.js";
export * from "./membership.js";
export * from "./revision.js";
export * from "./typing.js";
+export * from "./attachments.js";@@ -21,6 +21,11 @@ const message = {
seq: 7,
user: "tuan",
text: "corrected",
+ // Chapter 3.24. THE COMPILER DID NOT FIND THIS ONE: `revisionFabricSchema.parse`
+ // takes `unknown`, so a fixture handed to it is invisible to `tsc` no matter what
+ // the schema requires. T014a's instrument named 33 construction sites and this was
+ // not among them — the unit lane found it.
+ attachments: [],
created_at: "2026-09-03T00:00:00.000Z",
};
@@ -65,6 +70,7 @@ describe("the revision fabric payload (chapter 3.23)", () => {
const parsed = revisionFabricSchema.parse({ kind: "updated", message });
expect(parsed.kind).toBe("updated");
expect(Object.keys(parsed.message).sort()).toEqual([
+ "attachments",
"channel",
"created_at",
"id",@@ -1110,3 +1110,317 @@ describe("deleteMessage (chapter 3.23)", () => {
).rejects.toThrow(MessageNotFoundError);
});
});
+
+// T004 (3.24) — THE READER TEST, RUN AGAINST UNCHANGED CODE.
+//
+// FR-019 (3.24) decides that an attachments-only message stores `text = ""` rather than
+// a null, so chapter 3.23's tombstone predicate is untouched. That decision rests on a
+// claim about code this chapter has not written yet: every read path already treats an
+// empty string as a live message. **This test must pass today.** If it fails, the
+// decision is wrong and the plan changes before a line of production code exists.
+//
+// Chapter 3.23 ran the equivalent and it paid twice: it proved the read path was already
+// correct, and it stopped a later phase from "fixing" something that worked.
+//
+// The row is planted with raw SQL because no write path accepts an empty text yet — the
+// send schema is `z.string().min(1)` at both doors until phase 4. `attachments` is left
+// NULL, which is a VALID value (data-model.md: NULL and `[]` are different and NULL means
+// no attachments): the read paths do not re-validate, so a malformed array planted here
+// would surface as a 1011 socket close in some later phase rather than as a failure here.
+describe("an empty text is a live message on every read path (FR-019 (3.24))", () => {
+ it("reads back as live through history both ways, the listing's preview, and the tombstone predicate", async () => {
+ const user = await repoA.createUser("t004-reader", "Reader");
+ const channel = await repoA.createChannel("t004", "public");
+ await repoA.addMember(channel.id, user.id);
+ const before = await repoA.sendMessage(channel.id, { text: "has words", userId: user.id });
+
+ // The row this chapter's phase 4 will make sendable. `sequence` continues the
+ // channel's series by hand, which is what makes this a reader test and not a
+ // writer one: the writer is not involved and must not be.
+ const plantedId = randomUUID();
+ await db.execute(sql`
+ INSERT INTO messages (id, channel_id, sequence, user_id, text, attachments, created_at)
+ VALUES (${plantedId}, ${channel.id}, ${before.seq + 1}, ${user.id}, '', NULL, now())
+ `);
+ // The listing reads `channels.last_sequence`, not the messages table (chapter 3.15's
+ // keyset), so a hand-planted row is invisible to the preview until this moves.
+ await db.execute(
+ sql`UPDATE channels SET last_sequence = ${before.seq + 1}, last_activity_at = now()
+ WHERE id = ${channel.id}`,
+ );
+
+ // 1 and 2 — history, both directions. `afterSeq: 0` is the forward page; its absence
+ // is the backward one. Both, because they are two queries in `listMessages` and a
+ // single-direction test cannot tell which one it proved.
+ 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 planted = page.find((m) => m.id === plantedId);
+ expect(planted, label).toBeDefined();
+ // `toBe("")` and not a falsy check. `expect(planted!.text).toBeFalsy()` would pass
+ // on a null too, which is the exact distinction this test exists to make.
+ expect(planted!.text, label).toBe("");
+ expect(planted!.text, label).not.toBeNull();
+ }
+
+ // 3 — the channel listing's preview. It shows what was said, and what was said here
+ // is nothing; the point is that it is not read as a deletion.
+ const { rows } = await repoA.listChannelsForUser(user.id, { limit: 10 });
+ const row = rows.find((r) => r.external_id === "t004")!;
+ expect(row.last_message?.sequence).toBe(before.seq + 1);
+ expect(row.last_message?.text).toBe("");
+ expect(row.last_message?.text).not.toBeNull();
+
+ // 4 — the tombstone predicate does not fire. `editMessage` and `deleteMessage` both
+ // read the row and throw `MessageDeletedError` on `text === null`; an empty string
+ // must reach neither. The edit is the sharper of the two because it PROVES the row
+ // was writable, not merely that a refusal was skipped.
+ const edited = await repoA.editMessage(channel.id, plantedId, {
+ userId: user.id,
+ text: "words now",
+ });
+ expect(edited.text).toBe("words now");
+
+ // And the same row, deleted, still becomes a tombstone the normal way — so the
+ // empty string is not a state the delete path mishandles either.
+ await repoA.deleteMessage(channel.id, plantedId, { userId: user.id });
+ const after = await repoA.listMessages(channel.id, { userId: user.id, limit: 10 });
+ expect(after.find((m) => m.id === plantedId)!.text).toBeNull();
+ });
+});
+
+// T023 and T024 (chapter 3.24). THE WRITER, AND THE EMPTY TEXT IT NOW ACCEPTS.
+//
+// T004 above proved the READ paths already treat `text = ''` as live, against a row
+// planted by hand. These two prove the WRITE path produces such a row and that the
+// round trip keeps what it was given, in the order it was given.
+describe("sendMessage writes attachments (FR-001 (3.24), FR-006 (3.24))", () => {
+ const url = (name: string) => ({
+ type: "url" as const,
+ kind: "image" as const,
+ url: `https://example.test/${name}.png`,
+ });
+
+ it("keeps two attachments in the order they were sent, through the round trip", async () => {
+ const user = await repoA.createUser("t023-sender", "Sender");
+ const channel = await repoA.createChannel("t023", "public");
+ await repoA.addMember(channel.id, user.id);
+
+ // TWO, AND IN A DELIBERATE ORDER. One attachment cannot show an order at all, and
+ // FR-006 says order holds on every path that returns a message — so a
+ // single-attachment test would assert the easy half of the requirement.
+ const sent = await repoA.sendMessage(channel.id, {
+ text: "two pictures",
+ userId: user.id,
+ attachments: [url("first"), url("second")],
+ });
+
+ // THE COLUMN, BECAUSE THE READ PATH IS NOT WIDENED UNTIL PHASE 5. `listMessages`
+ // returns `attachments: []` from phase 3's placeholder until T028 adds the column to
+ // its select, so asserting through it here would assert the next phase's work and
+ // fail for a reason that has nothing to do with the writer.
+ const { rows } = await db.execute(
+ sql`SELECT attachments FROM messages WHERE id = ${sent.id}`,
+ );
+ const stored = rows[0]!["attachments"] as Array<{ url: string }>;
+ expect(stored.map((a) => a.url)).toEqual([
+ "https://example.test/first.png",
+ "https://example.test/second.png",
+ ]);
+ });
+
+ it("stores NULL rather than an empty array when there are none", async () => {
+ const user = await repoA.createUser("t023-none", "None");
+ const channel = await repoA.createChannel("t023-none", "public");
+ await repoA.addMember(channel.id, user.id);
+ const sent = await repoA.sendMessage(channel.id, { text: "no pictures", userId: user.id });
+
+ // THE COLUMN, NOT THE READ. `data-model.md` decides that NULL and `[]` are different
+ // values and that a message with no attachments stores NULL; every read converts to
+ // `[]` on the way out (FR-007), so a read-side assertion cannot tell them apart.
+ const { rows } = await db.execute(
+ sql`SELECT attachments FROM messages WHERE id = ${sent.id}`,
+ );
+ expect(rows[0]!["attachments"]).toBeNull();
+
+ // And the read is `[]` — which in this phase proves nothing, because phase 3's
+ // placeholder returns `[]` for every message. T029 in phase 5 is where that
+ // assertion starts meaning something.
+ const page = await repoA.listMessages(channel.id, { userId: user.id, limit: 10 });
+ expect(page.find((m) => m.id === sent.id)!.attachments).toEqual([]);
+ });
+
+ it("stores the same url twice as two attachments (FR-021 (3.24))", async () => {
+ const user = await repoA.createUser("t023-dup", "Dup");
+ const channel = await repoA.createChannel("t023-dup", "public");
+ await repoA.addMember(channel.id, user.id);
+ const sent = await repoA.sendMessage(channel.id, {
+ text: "the same link twice",
+ userId: user.id,
+ attachments: [url("same"), url("same")],
+ });
+ const { rows } = await db.execute(
+ sql`SELECT attachments FROM messages WHERE id = ${sent.id}`,
+ );
+ expect(rows[0]!["attachments"]).toHaveLength(2);
+ });
+});
+
+describe("an attachments-only message is written and is not a tombstone (FR-019 (3.24))", () => {
+ it("stores text = '' and reads back live, with the deletion path still available", async () => {
+ const user = await repoA.createUser("t024-sender", "Sender");
+ const channel = await repoA.createChannel("t024", "public");
+ await repoA.addMember(channel.id, user.id);
+
+ const sent = await repoA.sendMessage(channel.id, {
+ text: "",
+ userId: user.id,
+ attachments: [{ type: "url", kind: "image", url: "https://example.test/only.png" }],
+ });
+
+ // THE COLUMN IS `''` AND NOT NULL, which is the whole of FR-019a. A null here would
+ // make chapter 3.23's tombstone predicate fire on a message somebody just sent.
+ const { rows } = await db.execute(sql`SELECT text FROM messages WHERE id = ${sent.id}`);
+ expect(rows[0]!["text"]).toBe("");
+ expect(rows[0]!["text"]).not.toBeNull();
+
+ // Live on the read paths, and its attachment is in the column. The read path's own
+ // list is phase 5's (T028); what this phase can assert is that the row exists, is
+ // live, and holds what it was given.
+ const page = await repoA.listMessages(channel.id, { userId: user.id, limit: 10 });
+ expect(page.find((m) => m.id === sent.id)!.text).toBe("");
+ const { rows: stored } = await db.execute(
+ sql`SELECT attachments FROM messages WHERE id = ${sent.id}`,
+ );
+ expect(stored[0]!["attachments"]).toHaveLength(1);
+
+ // AND THE TOMBSTONE PREDICATE DOES NOT FIRE. `editMessage` and `deleteMessage` both
+ // throw `MessageDeletedError` on `text === null`; an edit succeeding is the stronger
+ // of the two, because it proves the row was writable rather than that a refusal was
+ // skipped.
+ const edited = await repoA.editMessage(channel.id, sent.id, {
+ userId: user.id,
+ text: "a caption after all",
+ });
+ expect(edited.text).toBe("a caption after all");
+
+ await repoA.deleteMessage(channel.id, sent.id, { userId: user.id });
+ const after = await repoA.listMessages(channel.id, { userId: user.id, limit: 10 });
+ const tomb = after.find((m) => m.id === sent.id)!;
+ expect(tomb.text).toBeNull();
+ // FR-012: deletion unlinks them, asserted at the column for the same reason as above.
+ const { rows: gone } = await db.execute(
+ sql`SELECT attachments FROM messages WHERE id = ${sent.id}`,
+ );
+ expect(gone[0]!["attachments"]).toBeNull();
+ });
+});
+
+// T030a (chapter 3.24). BOTH BRANCHES OF `listMessages`, WHICH IS A TERNARY.
+//
+// `listMessages` is not one query with a direction flag — it is a conditional over two
+// separate builder chains, one ordered `desc` for a backward page and one `asc` for a
+// forward one, and `attachments` had to be added to the column list they share. A
+// single-direction test covers one branch and reports on both.
+describe("listMessages returns attachments on BOTH branches (FR-009 (3.24))", () => {
+ it("carries them in order through the backward page and the forward one", async () => {
+ const user = await repoA.createUser("t030a-user", "User");
+ const channel = await repoA.createChannel("t030a", "public");
+ await repoA.addMember(channel.id, user.id);
+ const sent = await repoA.sendMessage(channel.id, {
+ text: "two pictures",
+ userId: user.id,
+ attachments: [
+ { type: "url", kind: "image", url: "https://example.test/a.png" },
+ { type: "url", kind: "audio", url: "https://example.test/b.mp3" },
+ ],
+ });
+
+ 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 read = page.find((m) => m.id === sent.id)!;
+ expect(
+ read.attachments.map((a) => (a.type === "url" ? a.url : "media")),
+ label,
+ ).toEqual(["https://example.test/a.png", "https://example.test/b.mp3"]);
+ }
+ });
+
+ it("returns [] and not null on both branches for a message with none (FR-007 (3.24))", async () => {
+ const user = await repoA.createUser("t030a-none", "None");
+ const channel = await repoA.createChannel("t030a-none", "public");
+ await repoA.addMember(channel.id, user.id);
+ const sent = await repoA.sendMessage(channel.id, { text: "no pictures", 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 read = page.find((m) => m.id === sent.id)!;
+ // `toHaveProperty` rather than `toEqual([])`: an absent key satisfies the latter
+ // when the value is undefined, which is how a control test passes before its field
+ // exists.
+ expect(read, label).toHaveProperty("attachments", []);
+ }
+ });
+});
+
+// T053 (chapter 3.24). THE READ SHAPES THAT DO NOT CHANGE, ASSERTED.
+//
+// `data-model.md` names six read shapes and two of them gained the column. The plan said
+// four would not change; it is THREE, because `editMessage`'s internal read gained it at
+// phase 7 for FR-015 — the edit event must carry the attachments the message already has,
+// and that read is the only thing that holds them. Phase 3 predicted this at the site and
+// T053 says to re-check against the tree rather than copy the list forward.
+//
+// A RECORD SAYS "DECIDED"; ONLY AN ASSERTION TELLS THE NEXT READER THAT FROM "FORGOTTEN".
+// Chapter 3.23 left four sentences that had stopped being true because nothing compared
+// them with the code.
+describe("the read shapes that do NOT carry attachments (FR-009 (3.24))", () => {
+ it("the channel listing's preview has no attachments field", async () => {
+ const user = await repoA.createUser("t053-user", "User");
+ const channel = await repoA.createChannel("t053", "public");
+ await repoA.addMember(channel.id, user.id);
+ await repoA.sendMessage(channel.id, {
+ text: "with a picture",
+ userId: user.id,
+ attachments: [{ type: "url", kind: "image", url: "https://example.test/preview.png" }],
+ });
+
+ const { rows } = await repoA.listChannelsForUser(user.id, { limit: 10 });
+ const row = rows.find((r) => r.external_id === "t053")!;
+ // A PREVIEW SHOWS WHAT WAS SAID. FR-CHN-09 asks for the most recent message rather
+ // than its contents, and a listing that carried every message's attachment list would
+ // pay for them on a query a client runs to render its first screen.
+ expect(row.last_message).not.toBeNull();
+ expect(row.last_message).not.toHaveProperty("attachments");
+ });
+
+ it("listMessagesRaw returns three columns and none of them is attachments", async () => {
+ const user = await repoA.createUser("t053-raw", "Raw");
+ const channel = await repoA.createChannel("t053-raw", "public");
+ await repoA.addMember(channel.id, user.id);
+ await repoA.sendMessage(channel.id, {
+ text: "with a picture",
+ userId: user.id,
+ attachments: [{ type: "url", kind: "image", url: "https://example.test/raw.png" }],
+ });
+
+ const rows = await repoA.listMessagesRaw(channel.id);
+ // A TEST-ONLY HELPER WITH FIVE CALL SITES, all in `idempotency.itest.ts`, which count
+ // rows and read text. An exact key set rather than a negative check: this is what
+ // stops the helper growing a column nobody asked for.
+ expect(Object.keys(rows[0]!).sort()).toEqual(["id", "seq", "text"]);
+ });
+});@@ -40,6 +40,7 @@ const message = {
seq: 1,
user: "outside-bot",
text: "hello",
+ attachments: [],
created_at: "2026-08-27T00:00:00.000Z",
};
const context = { requestId: "req-1", environmentId: "env-1" };
@@ -86,6 +87,7 @@ describe("the api's fan-out publisher", () => {
const parsed = messageSchema.safeParse(JSON.parse(publishes[0]![1]));
expect(parsed.success).toBe(true);
expect(Object.keys(JSON.parse(publishes[0]![1])).sort()).toEqual([
+ "attachments",
"channel",
"created_at",
"id",@@ -112,6 +112,15 @@ function toFrame(row: MessageWithSender, channelId: string): Message[] {
seq: row.seq,
user: row.user,
text: row.text,
+ /** FR-010 and FR-006. A REPLAY MUST NOT BE A LESSER MESSAGE than the live frame it
+ * replaces — a client that was away sees what a client that stayed saw, which is
+ * SC-005's whole claim.
+ *
+ * NO `?? []` HERE, and that is the point of putting the conversion in
+ * `listMessages`' map: `MessageWithSender` extends `MessageRow`, whose
+ * `attachments` is a required `Attachment[]`, so by the time a row reaches this
+ * mapping the NULL is already an empty list. One conversion, at the read. */
+ attachments: row.attachments,
created_at: row.created_at,
},
];@@ -112,6 +112,41 @@ describe("POST /internal/backfill", () => {
});
});
+ it("replays two attachments in the order they were sent (FR-010 (3.24), SC-005 (3.24))", async () => {
+ // SC-005: A CLIENT THAT WAS AWAY ENDS WITH THE SAME VIEW AS ONE THAT STAYED. The
+ // replay is a different code path from delivery — it maps rows out of the database
+ // rather than passing a payload along — so a field threaded correctly through every
+ // live path can still be missing here.
+ //
+ // TWO, AND IN ORDER. FR-006 says order holds on every path that returns a message,
+ // and a single-attachment test cannot see an order at all.
+ const sent = await repo.sendMessage(channelId, {
+ text: "away across this one",
+ userId: tuan.id,
+ attachments: [
+ { type: "url", kind: "image", url: "https://example.test/replay-first.png" },
+ { type: "url", kind: "video", url: "https://example.test/replay-second.mp4" },
+ ],
+ });
+
+ const body = await parsed(await ask({ [channelId]: sent.seq - 1 }));
+ const page = body.channels[channelId]!;
+ const frame = page.messages.find((m) => m.seq === sent.seq)!;
+ expect(frame.attachments.map((a) => (a.type === "url" ? a.url : "media"))).toEqual([
+ "https://example.test/replay-first.png",
+ "https://example.test/replay-second.mp4",
+ ]);
+ });
+
+ it("replays a message with none as an empty list (FR-007 (3.24))", async () => {
+ const sent = await say(channelId, "nothing attached");
+ const body = await parsed(await ask({ [channelId]: sent.seq - 1 }));
+ const frame = body.channels[channelId]!.messages.find((m) => m.seq === sent.seq)!;
+ // `toHaveProperty` rather than `toEqual([])`: an absent key satisfies the latter when
+ // the value is undefined, and `messageSchema` requires the field on this frame.
+ expect(frame).toHaveProperty("attachments", []);
+ });
+
it("excludes the cursor's own message — the anchor is exclusive", async () => {
const a = await say(channelId, "already applied");
const body = await parsed(await ask({ [channelId]: a.seq }));@@ -66,6 +66,11 @@ export class InternalController {
body.channel_id,
{
text: body.text,
+ // A NAMED BUILD, WHICH IS WHERE A WIDENED SCHEMA STOPS. `internalSendRequestSchema`
+ // takes attachments from this phase, and without this line the field arrives on the
+ // wire, parses, and is dropped one statement later — the message commits without it
+ // and the socket client is acked as though it worked.
+ ...(body.attachments !== undefined && { attachments: body.attachments }),
...(body.idempotency_key !== undefined && {
idempotency_key: body.idempotency_key,
}),@@ -120,6 +120,265 @@ describe("POST /v1/channels/:channelId/messages", () => {
expect(typeof body.docs_url).toBe("string");
});
+ // T035 through T040 (chapter 3.24). THE BOUND, THE KINDS, THE SCHEMES, AND THE ONE
+ // REFUSAL THAT NEEDED A CODE OF ITS OWN.
+ describe("the refusals, through the route (US2)", () => {
+ const png = (n: number) => ({
+ type: "url",
+ kind: "image",
+ url: `https://example.test/${n}.png`,
+ });
+
+ it("refuses eleven and writes no row (FR-005 (3.24), SC-002 (3.24))", async () => {
+ const before = await fetch(`${url}/v1/channels/${channelId}/messages?limit=200`, {
+ headers: { authorization: `Bearer ${credential}` },
+ });
+ const countBefore = ((await before.json()) as { messages: unknown[] }).messages.length;
+
+ const res = await send({
+ text: "eleven",
+ user: "courier",
+ attachments: Array.from({ length: 11 }, (_, i) => png(i)),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as Record<string, unknown>;
+ expect(body.code).toBe("invalid_request");
+ // THE ARRAY, NOT AN ITEM. The bound is on the list, so naming an index would send a
+ // caller to inspect a link that is perfectly fine.
+ expect(body.field).toBe("attachments");
+
+ // AND NOTHING LANDED, which is the half SC-002 actually asks for — a 400 raised
+ // after the write would pass a status assertion and leave a row behind.
+ const after = await fetch(`${url}/v1/channels/${channelId}/messages?limit=200`, {
+ headers: { authorization: `Bearer ${credential}` },
+ });
+ expect(((await after.json()) as { messages: unknown[] }).messages.length).toBe(countBefore);
+ });
+
+ it("accepts exactly ten and returns all ten (FR-005 (3.24))", async () => {
+ // A BOUND TESTED ONLY FROM ABOVE IS A BOUND THAT COULD BE NINE.
+ const res = await send({
+ text: "ten",
+ user: "courier",
+ attachments: Array.from({ length: 10 }, (_, i) => png(i)),
+ });
+ expect(res.status).toBe(201);
+ const created = (await res.json()) as { attachments: unknown[] };
+ expect(created.attachments).toHaveLength(10);
+ });
+
+ it("stores the same url twice, twice (FR-021 (3.24))", async () => {
+ // THE SPEC ASKED THIS AS AN OPEN QUESTION AND ANSWERED IT: two identical links are
+ // two attachments, because the platform does not compare them — the same argument
+ // chapter 3.23 made for not comparing message texts to decide whether an edit
+ // happened.
+ const res = await send({
+ text: "the same twice",
+ user: "courier",
+ attachments: [png(1), png(1)],
+ });
+ expect(res.status).toBe(201);
+ const created = (await res.json()) as { attachments: Array<{ url: string }> };
+ expect(created.attachments).toHaveLength(2);
+ expect(created.attachments[0]!.url).toBe(created.attachments[1]!.url);
+ });
+
+ it("refuses a kind outside the three (FR-002 (3.24))", async () => {
+ const res = await send({
+ text: "a spreadsheet",
+ user: "courier",
+ attachments: [{ ...png(0), kind: "spreadsheet" }],
+ });
+ expect(res.status).toBe(400);
+ expect(((await res.json()) as { field: string }).field).toBe("attachments.0.kind");
+ });
+
+ it.each([
+ ["javascript:", "javascript:alert(1)"],
+ ["data:", "data:image/png;base64,iVBORw0KGgo="],
+ ["file:", "file:///etc/passwd"],
+ ["vbscript:", "vbscript:msgbox(1)"],
+ ])("refuses %s through the route (T038, FR-004 (3.24), SC-004 (3.24))", async (_label, bad) => {
+ // THROUGH THE ROUTE, NOT ONLY AT THE SCHEMA. `attachments.test.ts` proves the rule
+ // exists; this proves it FIRES on the path a caller takes — a schema nobody wired
+ // in refuses nothing. Research R7 measured `z.url()` accepting all four.
+ const res = await send({
+ text: "a bad scheme",
+ user: "courier",
+ attachments: [{ ...png(0), url: bad }],
+ });
+ expect(res.status, bad).toBe(400);
+ expect(((await res.json()) as { field: string }).field, bad).toBe("attachments.0.url");
+ });
+
+ it("answers a media_id with its own code and a 422 (FR-003a (3.24))", async () => {
+ const res = await send({
+ text: "hosted media",
+ user: "courier",
+ attachments: [{ type: "media", media_id: "m_1" }],
+ });
+ // 422 AND NOT 400: the request is understood and well-formed, and what cannot be
+ // done is the thing it asks for.
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as Record<string, unknown>;
+ // THE BODY, NOT ONLY THE STATUS (T039a). `webhooks.itest.ts:90` asserts a 422 and
+ // its message text, and the five bare 422s behind it have been emitting
+ // `internal_error` for four chapters — a status assertion cannot see that.
+ expect(body.code).toBe("media_not_available");
+ expect(body.docs_url).toMatch(/#media_not_available$/);
+ expect(String(body.message)).toMatch(/hosted media is not available/i);
+ // `attachments.0` AND NOT `attachments.0.type`. The refinement refuses the ARM, so
+ // zod's path stops at the object — and that is the honest field: nothing is wrong
+ // with the `type` key, the whole attachment names a transport the platform cannot
+ // serve yet. A caller with ten links is told which one, which is what the path is
+ // for.
+ expect(body.field).toBe("attachments.0");
+ });
+ });
+
+ // T029, T031 and T032a (chapter 3.24). THE REST DOOR, END TO END.
+ describe("attachments through the send and history routes (SC-001 (3.24))", () => {
+ const png = (n: string) => ({
+ type: "url",
+ kind: "image",
+ url: `https://example.test/${n}.png`,
+ });
+
+ it("returns two attachments from history in the order they were sent", async () => {
+ const posted = await send({
+ text: "two pictures",
+ user: "courier",
+ attachments: [png("one"), { ...png("two"), kind: "video", url: "https://example.test/two.mp4" }],
+ });
+ expect(posted.status).toBe(201);
+ const created = (await posted.json()) as {
+ seq: number;
+ attachments: Array<{ kind: string; url: string }>;
+ };
+ // T026's half: the 201 carries them too, because the response spells its fields and
+ // a caller should not have to read history to learn what it just sent.
+ expect(created.attachments.map((a) => a.url)).toEqual([
+ "https://example.test/one.png",
+ "https://example.test/two.mp4",
+ ]);
+
+ const res = await fetch(`${url}/v1/channels/${channelId}/messages?limit=10`, {
+ headers: { authorization: `Bearer ${credential}` },
+ });
+ const page = (await res.json()) as {
+ messages: Array<{ seq: number; attachments: Array<{ kind: string; url: string }> }>;
+ };
+ const read = page.messages.find((m) => m.seq === created.seq)!;
+ // BOTH KINDS AND BOTH URLS, IN ORDER. A single attachment cannot show an order, and
+ // FR-006 says order holds on every path that returns a message.
+ expect(read.attachments).toEqual([
+ { type: "url", kind: "image", url: "https://example.test/one.png" },
+ { type: "url", kind: "video", url: "https://example.test/two.mp4" },
+ ]);
+ });
+
+ it("reads back an empty list, not an absent field (FR-007 (3.24))", async () => {
+ const posted = await send({ text: "no pictures", user: "courier" });
+ const created = (await posted.json()) as { seq: number };
+ const res = await fetch(`${url}/v1/channels/${channelId}/messages?limit=10`, {
+ headers: { authorization: `Bearer ${credential}` },
+ });
+ const page = (await res.json()) as { messages: Array<Record<string, unknown>> };
+ const read = page.messages.find((m) => m["seq"] === created.seq)!;
+ // `toHaveProperty` AND NOT `toEqual([])`. An ABSENT key and a `[]` both satisfy
+ // `expect(read.attachments).toEqual([])` when the value is undefined — chapter
+ // 3.23 shipped a control test that was green before its field existed for exactly
+ // this reason. This assertion fails on an absent key.
+ expect(read).toHaveProperty("attachments", []);
+ });
+
+ it("shows a non-member nothing, and therefore no attachment (FR-014 (3.24))", async () => {
+ // WHAT THIS DOES NOT PROVE, said here rather than left to be assumed: the attachment
+ // adds no second surface BY CONSTRUCTION, not by this assertion. `channelVisibleTo`
+ // runs as a gate before the read, so a non-member's answer contains no message and
+ // therefore no attachment whatever the read path does with the column. Chapter
+ // 3.23's falsification proved this shape of test stays green when the predicate is
+ // removed. T032b runs it again here and expects green.
+ // THE PRIVATE CHANNEL OF THE SAME TENANT, which the suite already mints — and it is
+ // the only case chapter 3.15's `channelVisibleTo` alone answers, per chapter 3.23's
+ // falsification. A foreign tenant is refused by the tenant scope one layer earlier.
+ await send(
+ { text: "members only", user: "courier", attachments: [png("secret")] },
+ privateChannelId,
+ );
+
+ const outsiderKey = await tokenFor("t032a-outsider");
+ const hidden = await fetch(`${url}/v1/channels/${privateChannelId}/messages?limit=10`, {
+ headers: { authorization: `Bearer ${outsiderKey}` },
+ });
+ const missing = await fetch(
+ `${url}/v1/channels/${crypto.randomUUID()}/messages?limit=10`,
+ { headers: { authorization: `Bearer ${outsiderKey}` } },
+ );
+ expect(hidden.status).toBe(missing.status);
+ // BYTE-IDENTICAL BUT FOR `request_id`, the same strip chapter 3.12's oracle uses
+ // and the same one at :435 above: it names the request rather than the resource, so
+ // it differs by construction. Comparing raw bodies makes every such test fail for a
+ // reason that is not the finding — which is how this one failed first.
+ const strip = (b: Record<string, unknown>) => {
+ delete b["request_id"];
+ return b;
+ };
+ expect(strip((await hidden.json()) as Record<string, unknown>)).toEqual(
+ strip((await missing.json()) as Record<string, unknown>),
+ );
+ });
+ });
+
+ // T020b (chapter 3.24). THE REFUSAL'S `field`, NOT ONLY ITS CODE.
+ //
+ // The three sibling refusals measured together, so they cannot drift apart. The api's
+ // pipe joins zod's `path` with dots into `field`, and a rule with no path produces a
+ // refusal that names nothing — which is why `refineTextAndAttachments` sets one.
+ //
+ // neither text nor attachments field = text
+ // eleven attachments field = attachments
+ // a bad kind at index 3 field = attachments.3.kind
+ describe("the refusals name a field (FR-019b (3.24))", () => {
+ const png = (n: number) => ({
+ type: "url",
+ kind: "image",
+ url: `https://example.test/${n}.png`,
+ });
+
+ it("names `text` when a body carries neither text nor attachments", async () => {
+ const res = await send({ text: "", user: "courier" });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as Record<string, unknown>;
+ expect(body.code).toBe("invalid_request");
+ // NOT ABSENT. A refusal that says "Invalid input" and no field leaves the caller to
+ // work out which key it was about — chapter 3.14's whole subject, and the reason
+ // this assertion is on the field rather than the status.
+ expect(body.field).toBe("text");
+ });
+
+ it("names `attachments` when there are eleven (FR-005 (3.24))", async () => {
+ const res = await send({
+ text: "eleven",
+ user: "courier",
+ attachments: Array.from({ length: 11 }, (_, i) => png(i)),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as Record<string, unknown>;
+ expect(body.field).toBe("attachments");
+ });
+
+ it("names the index and the key for a bad kind at position 3 (FR-002 (3.24))", async () => {
+ const attachments = [png(0), png(1), png(2), { ...png(3), kind: "spreadsheet" }];
+ const res = await send({ text: "a bad kind", user: "courier", attachments });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as Record<string, unknown>;
+ // THE INDEX IS THE POINT. `attachments` alone would tell a caller with ten links
+ // to check all ten; the joined path names the one that failed.
+ expect(body.field).toBe("attachments.3.kind");
+ });
+ });
+
it("answers a foreign channel's HISTORY with that same 404 (chapter 2.8)", async () => {
// The milestone suite found the two doors disagreeing: POST said 404 for
// a channel this tenant cannot see, GET said 200 with an empty page. An
@@ -512,15 +771,24 @@ describe("PATCH /v1/channels/:channelId/messages/:messageId (chapter 3.23)", ()
});
/** A message by `author`, sent with their own token so the row carries them. */
- const sendAsAuthor = async (text: string, channel = channelId) => {
+ const sendAsAuthor = async (
+ text: string,
+ channel = channelId,
+ attachments?: unknown[],
+ ) => {
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 }),
+ body: JSON.stringify({ text, ...(attachments !== undefined ? { attachments } : {}) }),
});
expect(res.status).toBe(201);
- return (await res.json()) as { id: string; seq: number; created_at: string };
+ return (await res.json()) as {
+ id: string;
+ seq: number;
+ created_at: string;
+ attachments: Array<{ url: string }>;
+ };
};
const patch = (
@@ -1012,6 +1280,127 @@ describe("PATCH /v1/channels/:channelId/messages/:messageId (chapter 3.23)", ()
expect(rows[0]!["edited_at"]).toBeNull();
});
+ // T043, T045, T046b and T047 (chapter 3.24). THE TOMBSTONE AND THE EDIT.
+ describe("attachments through deletion and editing (US3)", () => {
+ const two = [
+ { type: "url", kind: "image", url: "https://example.test/keep-one.png" },
+ { type: "url", kind: "audio", url: "https://example.test/keep-two.mp3" },
+ ];
+
+ it("leaves attachments untouched through an edit, in order (FR-016 (3.24))", async () => {
+ // THE FAILURE THIS CATCHES IS SILENT. An `UPDATE … SET text = ?, attachments = ?`
+ // written without care drops the photograph and answers 200 — there is no error
+ // anywhere in that sequence, which is why T046 falsifies it from the other side.
+ const sent = await sendAsAuthor("before", channelId, two);
+ expect(sent.attachments).toHaveLength(2);
+
+ const res = await patch(sent.id, { text: "after" }, await tokenFor("author"));
+ expect(res.status).toBe(200);
+ const edited = (await res.json()) as {
+ text: string;
+ attachments: Array<{ url: string }>;
+ };
+ expect(edited.text).toBe("after");
+ // THE 200 CARRIES THEM (T046a). A caller that edits and a consumer that watches
+ // must see one message, not two.
+ expect(edited.attachments.map((a) => a.url)).toEqual([
+ "https://example.test/keep-one.png",
+ "https://example.test/keep-two.mp3",
+ ]);
+
+ const page = await history();
+ const read = page.messages.find((m) => m["id"] === sent.id) as unknown as {
+ attachments: Array<{ url: string }>;
+ };
+ expect(read.attachments.map((a) => a.url)).toEqual([
+ "https://example.test/keep-one.png",
+ "https://example.test/keep-two.mp3",
+ ]);
+ });
+
+ it("reports the same two on the edit's own answers, in order (FR-015 (3.24))", async () => {
+ // T045 ASSERTS THE DATABASE KEPT THEM; THIS ASSERTS WHAT THE EDIT REPORTS. Neither
+ // implies the other — an empty array on either answer passes T045 and fails this.
+ //
+ // **THE OUTBOX HALF IS PHASE 9's, AND THAT IS AN ORDERING FACT.** `MessageCreatedData`
+ // gains its field at T055, so an assertion on the `message.updated` outbox row here
+ // fails for the NEXT phase's reason — the third time in this chapter that a test
+ // was written against something a later phase builds (T023 and T022c were the
+ // others). T057 asserts the creation and edit payloads carry attachments
+ // identically, which is FR-015's other half and where it belongs.
+ const sent = await sendAsAuthor("before the edit", channelId, two);
+ const res = await patch(sent.id, { text: "after the edit" }, await tokenFor("author"));
+ expect(res.status).toBe(200);
+
+ const expected = [
+ "https://example.test/keep-one.png",
+ "https://example.test/keep-two.mp3",
+ ];
+ const answered = (await res.json()) as { attachments: Array<{ url: string }> };
+ expect(answered.attachments.map((a) => a.url)).toEqual(expected);
+
+ // AND THE READ AGREES WITH THE ANSWER, which is what makes them one message rather
+ // than two reports of one.
+ const page = await history();
+ const read = page.messages.find((m) => m["id"] === sent.id) as unknown as {
+ attachments: Array<{ url: string }>;
+ };
+ expect(read.attachments.map((a) => a.url)).toEqual(expected);
+ });
+
+ it("says nothing about attachments in the edit history (FR-016 (3.24))", async () => {
+ // `message_edits` HAS THREE COLUMNS AND THE SAD PUBLISHES THREE. Chapter 3.23 built
+ // that table to a published DDL, and an attachment column would be a fourth nobody
+ // published — so the edit history records what the text WAS and says nothing about
+ // what was attached, because nothing about that changed.
+ const sent = await sendAsAuthor("first words", channelId, two);
+ await patch(sent.id, { text: "second words" }, await tokenFor("author"));
+ const res = await fetch(
+ `${url}/v1/channels/${channelId}/messages/${sent.id}/edits`,
+ { headers: { authorization: `Bearer ${credential}` } },
+ );
+ expect(res.status).toBe(200);
+ const { edits } = (await res.json()) as {
+ edits: Array<Record<string, unknown>>;
+ };
+ expect(edits).toHaveLength(1);
+ // AN EXACT KEY SET, not `attachments === undefined`: an absent key and an
+ // undefined value are the same to a truthiness check and different to a contract.
+ expect(Object.keys(edits[0]!).sort()).toEqual(["edited_at", "prior_text"]);
+ });
+
+ it("returns a tombstone as an empty list through the history route (FR-012 (3.24), SC-003 (3.24))", async () => {
+ // THE SIX READ SHAPES `data-model.md` NAMES, and the assertion differs by shape
+ // because the shapes do. Two carry the field and get `[]`; four never carried it
+ // and the field stays ABSENT — which is the stronger answer, not a weaker one.
+ const sent = await sendAsAuthor("doomed", channelId, two);
+ const removed = await fetch(
+ `${url}/v1/channels/${channelId}/messages/${sent.id}`,
+ { method: "DELETE", headers: { authorization: `Bearer ${await tokenFor("author")}` } },
+ );
+ expect(removed.status).toBe(204);
+
+ // `listMessages` — the history route, and it serves resume too.
+ const page = await history();
+ const tomb = page.messages.find((m) => m["id"] === sent.id)!;
+ expect(tomb["text"]).toBeNull();
+ expect(tomb).toHaveProperty("attachments", []);
+
+ // THE PREVIEW IS NOT REACHABLE FROM HERE, and the first version of this test did
+ // not know that. It fetched `GET /v1/channels?limit=50` inside an
+ // `if (status === 200)` — and there IS no such route: `channels.controller.ts`
+ // exposes `GET /v1/channels/:channelId` and nothing that lists them. The
+ // conditional turned a request to a route that answers 404 into an assertion that
+ // silently did not run, and the title above it claimed "every read that carries
+ // them". `repository.itest.ts` asserts the preview where it is reachable, against
+ // `listChannelsForUser` directly.
+
+ // The other two carriers are asserted where they are reachable: the retry replay
+ // in `idempotency.itest.ts`'s recovered-tombstone test, and the edit path's read
+ // not at all — editing a tombstone is refused before any read of it returns.
+ });
+ });
+
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"));@@ -77,6 +77,10 @@ export class MessagesService {
return await this.repo.sendMessage(channelId, {
text: body.text,
metadata: body.metadata,
+ // Chapter 3.24 (FR-001). Spelled rather than spread, like every field above
+ // it: this method's own comment on `userExternalId` explains why threading a
+ // value costs nothing where a lookup would cost a query per message.
+ ...(body.attachments !== undefined && { attachments: body.attachments }),
userId,
senderMustBeBot,
...(userExternalId !== undefined && { userExternalId }),@@ -29,7 +29,36 @@ export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
// with dots, which is what a developer reading their own request body sees.
// An empty path means the whole body failed (a non-object, say), and then
// there is no field to name and the key is omitted rather than sent empty.
+ /** A SCHEMA MAY NAME ITS OWN REFUSAL (chapter 3.24, FR-003a).
+ *
+ * Everything here is `invalid_request` and 400, which is right for a body the
+ * contract does not allow. It is wrong for a field the contract DOES publish and
+ * the platform cannot serve yet — `media_id` in FR-MSG-11 — where the caller made
+ * no mistake and the honest answer is a code of its own.
+ *
+ * The alternative was a check in the controller, and it cannot work: this pipe runs
+ * before the handler, so a media arm is already refused with a 400 by the time any
+ * handler code could look. Whichever layer refuses first has to carry the code. */
+ // `params` IS ON THE ISSUE AT RUNTIME AND NOT ON ITS TYPE. Measured against the
+ // pinned zod 4.4.3: a `refine` with `params` produces an issue whose keys are
+ // `code, path, params, message`, and `$ZodIssue` declares only the first, third
+ // and fourth. Narrowed through `unknown` rather than asserted, so a zod upgrade
+ // that drops the field is a silent no-op here rather than a runtime throw.
+ const named =
+ issue !== undefined && typeof issue === "object" && "params" in issue
+ ? ((issue as { params?: unknown }).params as
+ | { protocolCode?: string; status?: number }
+ | undefined)
+ : undefined;
const path = issue?.path.join(".");
+ if (named?.protocolCode !== undefined) {
+ throw protocolError(
+ named.protocolCode as Parameters<typeof protocolError>[0],
+ issue?.message ?? "refused",
+ named.status ?? 400,
+ ...(path !== undefined && path.length > 0 ? ([path] as const) : ([] as const)),
+ );
+ }
throw protocolError(
"invalid_request",
issue?.message ?? "invalid body",@@ -331,6 +331,7 @@ describe("every connection a person holds is a first-class recipient (US3)", ()
seq: 3,
user,
text: `after the refusal ${randomUUID()}`,
+ attachments: [],
created_at: new Date(0).toISOString(),
});
for (const [i, r] of five.entries()) {
@@ -407,6 +408,7 @@ describe("every connection a person holds is a first-class recipient (US3)", ()
seq: 1,
user,
text,
+ attachments: [],
created_at: new Date(0).toISOString(),
});
@@ -502,6 +504,7 @@ describe("every connection a person holds is a first-class recipient (US3)", ()
seq: 2,
user,
text: `after b is gone ${randomUUID()}`,
+ attachments: [],
created_at: new Date(0).toISOString(),
});
@@ -888,6 +891,7 @@ describe("the count survives the gateway it was counted on (US2)", () => {
seq: 4,
user,
text: `after the re-claim ${randomUUID()}`,
+ attachments: [],
created_at: new Date(0).toISOString(),
});
await untilCount(live, "message.created", 1);@@ -36,6 +36,7 @@ function messageOn(channel: string, seq: number): Message {
seq,
user: "linh",
text: `message ${seq}`,
+ attachments: [],
created_at: new Date().toISOString(),
};
}
@@ -253,6 +254,7 @@ describe("fan-out across instances", () => {
deleted_at: new Date().toISOString(),
// @ts-expect-error the point of the test: a key the schema forbids
text: "",
+ attachments: [],
},
});
await expect(nextRevision(g2, 300)).rejects.toThrow("deadline");@@ -752,6 +752,10 @@ function sample(type: string, channel: string, user: string): unknown {
seq: 1,
user,
text: "forged",
+ // Chapter 3.24: WELL-FORMED IS THE POINT. `messageSchema` requires
+ // attachments, and a forged frame missing them is refused for its SHAPE —
+ // `invalid_frame` — a phase before the direction check this suite is about.
+ attachments: [],
created_at: new Date().toISOString(),
};
switch (type) {@@ -1316,6 +1316,7 @@ describe("presence: no durability, and the whole log vocabulary", () => {
seq: 1,
user: who,
text: "not a presence payload",
+ attachments: [],
created_at: new Date().toISOString(),
});
await quiet(500);@@ -38,6 +38,7 @@ function frame(seq: number): Message {
seq,
user: "dispatcher",
text: `m${seq}`,
+ attachments: [],
created_at: "2026-08-04T00:00:00.000Z",
};
}@@ -27,6 +27,7 @@ function frame(channel: string, seq: number): Message {
seq,
user: "tuan",
text: `m${seq}`,
+ attachments: [],
created_at: "2026-08-04T00:00:00.000Z",
};
}
@@ -148,6 +149,7 @@ describe("suppressed", () => {
seq,
user: "dispatcher",
text: `m${seq}`,
+ attachments: [],
created_at: "2026-08-19T00:00:00.000Z",
});
@@ -34,6 +34,7 @@ function committed(seq: number): InternalSendResponse {
seq,
user: "tuan",
text: "hello",
+ attachments: [],
created_at: new Date().toISOString(),
};
}
@@ -85,6 +86,7 @@ function frame(seq: number, channel = CHANNEL): Message {
seq,
user: "dispatcher",
text: `m${seq}`,
+ attachments: [],
created_at: "2026-08-04T00:00:00.000Z",
};
}
@@ -404,6 +406,7 @@ describe("the socket (chapter 2.5)", () => {
idem_key: "k-0",
channel: "11111111-1111-1111-1111-111111111111",
text: "hello",
+ attachments: [],
},
}),
);
@@ -418,6 +421,7 @@ describe("the socket (chapter 2.5)", () => {
seq: 42,
user: "tuan",
text: "hello",
+ attachments: [],
created_at: expect.any(String),
},
]);
@@ -444,6 +448,7 @@ describe("the socket (chapter 2.5)", () => {
payload: {
channel: "11111111-1111-1111-1111-111111111111",
text: "hello",
+ attachments: [],
idem_key: "k-1",
},
}),@@ -957,6 +957,10 @@ describe("a typing signal on its way out (chapter 3.21)", () => {
seq: 9,
user: "tuan",
text: "backfilled",
+ // Chapter 3.24: this payload goes onto the fabric as JSON and the gateway
+ // PARSES it, so `tsc` never saw the construction — `JSON.stringify` takes
+ // anything. Required means the parse refuses it without the field.
+ attachments: [],
created_at: new Date(0).toISOString(),
},
],
@@ -1227,6 +1231,10 @@ describe("a typing signal on its way out (chapter 3.21)", () => {
seq: 4_242,
user: "tuan",
text: "one message",
+ // Chapter 3.24: this payload goes onto the fabric as JSON and the gateway
+ // PARSES it, so `tsc` never saw the construction — `JSON.stringify` takes
+ // anything. Required means the parse refuses it without the field.
+ attachments: [],
created_at: new Date(0).toISOString(),
}),
);