Part 4 · Chapter 4.14
You will produce: The state machine's other half: every door that serves a message now serves the state its media object is in right now, read when the message is served rather than stored on it, and a frame goes out to every channel referencing the object when a verdict lands. The clause is two sentences and neither was met — the event everybody quotes, and a first sentence nobody had noticed was also unmet, because one schema was serving both the door a sender writes to and the payload the platform builds. Plus the subject question a review left open two parts ago, answered by arithmetic somebody had already written down; the cheapest shape available, killed by a field that does not exist; and four tests that asserted the absence this chapter fills, under a comment saying it would last until movement VI · about 45 minutes including the exercise
Source: SRS — Software Requirements Specification · SAD — Software Architecture Document · ADR deep dives · docs/12-part-4-structure.md
The worker from the last chapter is running. It sweeps for objects nobody has verified, streams
each one through a scanner, and writes ready or rejected into a column. Every state the
platform can hold now exists and something produces all three.
No client can see any of it.
A message carrying a photo comes back from history as {"type": "media", "media_id": "…"} —
an identifier and nothing else. The object behind it might be verified, might be rejected,
might still be in a queue. A UI showing that message has one thing it can do, which is show a
placeholder and ask again in a few seconds. And again.
The clause that fixes this is one row in the requirements, and reading it carefully is most of the chapter:
FR-MED-07. Real-time and history delivery shall include each attachment's state. A
media.updatedevent shall be delivered on the referencing channels when a pending attachment becomesreadyorrejected, so clients can replace placeholders without polling.
Every artifact that has cited this clause for three chapters has cited the second sentence. The SRS records it as unmet with a reachable subject: the states exist, the event does not, a producer is missing. That is true.
The first sentence was also unmet, and nothing had recorded it. Before this chapter, an
attachment's shape was { type, media_id } on every door in the platform. There was no field
for a state to go in.
That matters more than it sounds, because the two sentences are not two halves of one feature. They fail in different circumstances, and one of them cannot be repaired by the other.
flowchart LR
subgraph floor["THE FLOOR — every door, always"]
f1["attachment carries the state,<br/>read when the message is served"]
end
subgraph opt["THE OPTIMISATION — when there is a transition"]
o1["one frame per referencing channel"]
end
case1["attached while pending,<br/>then verified"] --> floor
case1 --> opt
case2["attached when ALREADY ready<br/>538 of the lane's 1,589 referenced objects"] --> floor
case2 -.->|"no transition left<br/>to announce"| none["no frame, ever"]
case3["client disconnected,<br/>or an old gateway during a deploy"] --> floor
case3 -.->|"frame dropped"| none
note["The frame removes polling. It is not how the answer is known."]The obvious way to add a state is to put a field on the attachment shape. There is one export
called attachmentSchema and it looks like the place.
It is four places. Three of them are doors a client writes to — the REST send body, the socket
message.send frame, and the internal send seam — and the fourth is messageSchema, which is
the payload the api builds. One schema, two rules that cannot both be obeyed:
flowchart TB
subgraph before["BEFORE: one schema, two opposite rules"]
a["attachmentSchema"] --> d1["messages.schema.ts:40<br/>REST send body"]
a --> d2["messageSendSchema<br/>socket send"]
a --> d3["internal.ts:35<br/>internal send"]
a --> d4["messageSchema (frames.ts:46)<br/>WHAT THE API BUILDS"]
r1["a sender must NOT declare a state"] -.-> d1
r2["a delivered attachment must ALWAYS carry one"] -.-> d4
end
subgraph after["AFTER: three roles, three shapes"]
b1["attachmentSchema<br/>a sender declares"] --> e1["the three request doors"]
b2["deliveredAttachmentSchema<br/>state REQUIRED"] --> e2["messageSchema — every door"]
b3["forwardedAttachmentSchema<br/>delivered | declared | loose"] --> e3["a relay that forwards"]
endThe file already knew this distinction existed. It says so about a different field, one union away:
/** 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. */
attachments: z.array(attachmentSchema).max(MAX_ATTACHMENTS).optional(),So the chapter adds a third shape rather than a field: deliveredAttachmentSchema, with state
required, and messageSchema.attachments points at it. The url arm is shared and unchanged —
nothing uploaded a URL and nothing scanned it, and giving it a state for symmetry would be a
field that is always the same value.
messages.attachments is a jsonb column holding what the sender declared. It would be easy to
write the state in there too, and then a verdict would be a write across every message
referencing the object.
sequenceDiagram
participant C as client
participant A as api
participant P as postgres
C->>A: send, attaching a pending object
A->>P: INSERT message, attachments = what was declared
A-->>C: 201, attachment state "pending"
Note over P: the worker verifies, later
P->>P: UPDATE media_objects SET state = 'ready'<br/>WHERE state = 'pending'
C->>A: read history
A->>P: the page, then ONE query for its media ids
A-->>C: the same message, attachment state "ready"
Note over A,P: the message row never changed.<br/>Storing the state would make every verdict a write<br/>across every referencing message, and give one<br/>fact two homes — constitution IV.Reading at serve time costs one extra statement per page. Not one per message — the page's distinct media ids are collected first and fetched with a single lookup on the primary key, scoped by environment. The previous chapter measured what the per-row shape costs when the operand is not a value the planner already has: 1,042 buffers against 84.
And a test has to hold this property, because both designs produce a correct-looking answer:
const columnBefore = await db.execute(
`select attachments::text as a from messages where id = '${messageId}'`,
);
const verdict = await recordMediaVerdict(db, { id, verdict: "ready", … });
expect(verdict.applied, "the compare-and-set refused a pending object").toBe(true);
expect(await stateInHistory(messageId)).toBe("ready");
const columnAfter = await db.execute(
`select attachments::text as a from messages where id = '${messageId}'`,
);
expect(columnAfter.rows[0]).toEqual(columnBefore.rows[0]);Asserting the new state twice would pass against a platform that rewrote every message. The byte-identical column is what separates the two.
The second sentence needs a subject to publish on, and this platform has five of them — one per real-time kind, each argued individually. A review objected to the shape rather than to any instance: one subject grammar per real-time kind is becoming a default rather than a measured decision.
The answer to that objection is ADR-25, and it is a number:
Consolidate onto a typed envelope when either holds: per-channel SUBSCRIBEs would exceed six, or a gateway instance's projected subject count exceeds 250,000.
So the question is not should media get a subject. It is run the rule.
flowchart TB
q["media.updated needs a subject.<br/>ADR-19: a kind that cannot share a payload type<br/>cannot share a subject."]
q --> A["A. a sixth grammar, media:{channel}"]
q --> B["B. widen chan:{channel}"]
q --> C["C. a third arm on revision:{channel}"]
q --> D["D. re-send the message as message.updated"]
A --> A1["per-channel SUBSCRIBEs 5 → 6.<br/>ADR-25 consolidates ABOVE six, so this is<br/>permitted — and spends the last of the headroom"]
B --> B1["refused by ADR-24 in writing:<br/>everything on chan: IS a creation"]
C --> C1["per-channel SUBSCRIBEs stay at 5.<br/>fanout.ts already subscribes chan: and revision:<br/>together, 'co-extensive by construction'"]
D --> D1["messageSchema has no edited_at.<br/>A client could not tell an attachment resolving<br/>from an author editing — ADR-24's own objection,<br/>one level up"]
C1 --> chosen["CHOSEN — ADR-33"]A sixth grammar is permitted — six does not exceed six — and it spends the entire remaining
headroom on a kind whose subscribers are exactly the subscribers of a subject that already
exists. fanout.ts subscribes chan:{channel} and revision:{channel} in one call under one
reference count, with a comment calling them co-extensive by construction.
The cheapest option is more interesting than the chosen one. A media transition changes what a
message shows, and the state is read at serve time — so re-delivering the affected message as
message.updated would need no new subject, no new frame, and no client changes at all.
It dies on a field that does not exist. messageSchema is {id, channel, seq, user, text, attachments, created_at} — there is no edited_at — so a client receiving message.updated
cannot tell an attachment resolving from an author editing. Every verified photo would render
its message as edited, by somebody who did not touch it.
That is ADR-24's own objection to putting edits on chan:, arriving one level up: the receiver
has no way to know.
ADR-33 takes the third arm.
The verdict route is the obvious place to publish from. It already distinguishes a transition
happened from a verdict arrived — the compare-and-set returns applied, and a stale worker's
second verdict updates no rows.
It also turned out to be the place in the api with the fewest things wired to it.
It had no tenant. recordMediaVerdict is a module-level function on a raw database handle,
deliberately outside the tenant-scoped repository, because its caller is a worker rather than a
tenant — and a worker's principal carries no environment by design. The fan-out query is scoped
by environment. There was nothing to scope it with, and the returning list did not carry one.
It had no publisher. MessagesModule declares the fabric client and deliberately does not
export it, so the internal module's controllers had nothing to inject. That failure is a runtime
one: Nest can't resolve dependencies, on the first request, after lint, typecheck and every
unit test pass. Chapter 4.10 recorded the same shape in its own words — only a running app asks
that question — which is why the first test in the producer's suite exists only to make one
request and check it is not a 500.
And the publisher it eventually got needs closing. A second Redis client in one process
leaks its connection on shutdown without an OnModuleDestroy of its own.
Constitution VI wants 100% branch coverage of tenant isolation, and the previous chapter measured what that number is worth here: an SQL clause carries no JavaScript branch, so a coverage report counts a deleted predicate as covered.
So each arm was deleted individually and the suites re-run:
arm what turned red
---------------------------------- -----------------------------------------
the `applied` gate 1 — the duplicate-verdict test, exactly
the environment predicate 1 — and the gauntlet stayed GREEN at 60
the per-channel loop 1 — the N = 2 test, and nothing else
the `ready`/`rejected` guard NOTHING
the buffering skip (gateway) 1 — the revision buffering testThe second row is 4.12's finding reproduced without modification. Deleting the tenant scope leaves the suite the constitution names as gating releases entirely green, and leaves the delivery gate green too. One test catches it, and it is the test written to ask that exact question.
The fourth row is the interesting one. The guard cannot fire: announce runs only when the
compare-and-set applied, and one that applied set the state to the verdict. It stays because it
is what narrows a string | null to the two values the fabric accepts, and deleting it needs a
cast — a claim the compiler stops checking.
Three chapters ago, when the media arm was first accepted, its author wrote assertions like this one:
// EXACTLY AS SENT, which is FR-013 and is a claim about what is ABSENT. No `state`,
// no filename, nothing the platform knows about the object — the slot is `pending`
// and stays `pending` until movement VI, and a client cannot tell from this payload.
const body = (await res.json()) as { attachments: unknown[] };
expect(body.attachments).toEqual([{ type: "media", media_id: id }]);This is movement VI. Four such assertions went red — the send response, history, the edit
response, and a fourth a directory away in messages.itest.ts that the media suite alone did not
find.
They were not stale. They were true statements about a platform that then grew a field, which is
the same thing the previous chapter's delivery gate did to ten fixtures one week earlier. The
repair keeps toEqual on the whole array rather than loosening to a property check, because
FR-013's claim is about what is absent and only whole-array equality can hold that.