Relay — Software Requirements Specification
Version: 1.17 (draft)
Status: For review
Companion documents: 01-product-vision.md, 02-personas.md, 03-journey-map.md
Structure: adapted from ISO/IEC/IEEE 29148
Table of contents
- Introduction
- Overall description
- External interface requirements
- Functional requirements
- Non-functional requirements
- Data requirements
- Traceability
- Appendices
1. Introduction
1.1 Purpose
This document specifies the functional and non-functional requirements for Relay, a multi-tenant chat infrastructure platform delivered as an API. It is the reference for implementation, test design, and acceptance.
It defines what the system must do. Component decomposition, technology selection beyond stated constraints, and deployment topology belong to the architecture and design documents.
1.2 Scope
Relay provides real-time messaging as a service to software companies who embed it in their own applications. It supplies message transport, persistence, membership, delivery guarantees, webhooks, and usage analytics. It does not supply a user-facing chat application.
In scope
Multi-tenant organisation and application management · API key lifecycle · delegated end-user authentication · channels and membership · message send, edit, delete, and history · real-time delivery over WebSocket · presence and typing indicators · Unicode emoji and customer-defined emoji packs that end users browse and install · hosted media attachments (image, audio, video) with direct-to-storage upload, scanning, and signed delivery · webhook dispatch with retry · per-tenant rate limiting and quotas · usage metering · a developer dashboard · a JavaScript client SDK.
Out of scope for v1
End-to-end encryption · voice and video calls · native iOS and Android SDKs · a hosted end-user chat application · identity provision (customers own their user directory) · message search · threads and reactions · media transcoding beyond thumbnail/poster generation · on-premise deployment.
1.3 Definitions
| Term | Meaning |
|---|---|
| Organisation | A Relay customer account. The billing and ownership boundary. |
| Application | A product belonging to an organisation. The tenancy boundary for data. |
| Environment | An isolated instance of an application: development or production. Separate keys, quotas, and data. |
| Tenant | An application–environment pair. All data is scoped to a tenant. |
| API key | A secret credential identifying an application environment, used for server-to-server calls. |
| User token | A short-lived JWT, signed by the customer's backend, authorising one end user. |
| End user | A person using the customer's product. Represented in Relay as a User. |
| Channel | A named conversation with a membership list. |
| Member | An end user's association with a channel, carrying a role and read state. |
| Idempotency key | A client-supplied identifier making a write operation safely repeatable. |
| Tombstone | The residual record of a deleted message, preserving order and audit trail. |
| Emoji pack | A named, environment-scoped collection of custom emoji defined by the customer, browsable and installable by end users. |
| Shortcode | The textual identifier of a custom emoji, written :name: inside message text, unique within an environment. |
| Resolution map | A sidecar object returned with messages, mapping each shortcode present in the returned text to its image URL and pack. |
| Media object | A file (image, audio, or video) uploaded to Relay-managed object storage, owned by a tenant, with a lifecycle of `pending → ready |
| Upload slot | A short-lived presigned URL authorising one direct client upload to object storage, issued against declared size and MIME type. |
| Signed delivery URL | A time-limited URL granting read access to a media object, issued only to callers whose channel membership permits reading the referencing message. |
| Delivery event | An analytical record of a message send, connection, API call, or webhook attempt. |
1.4 Requirement conventions
Priority — aligned to the phased roadmap in the vision document:
| Level | Meaning | Phase |
|---|---|---|
| P1 | Core loop. Product does not exist without it. | Phase 1 |
| P2 | Platform surface. Required for external customers. | Phase 2 |
| P3 | Analytics and metering. Required for commercial operation. | Phase 3 |
| P4 | Developer experience. Required for adoption at scale. | Phase 4 |
| P5 | Deferred. Recorded, not scheduled. | Later |
Verification method — T test (automated) · D demonstration · I inspection ·
A analysis (including load testing).
Requirements use shall for obligations. Each has a unique, stable identifier; identifiers are never reused after removal.
2. Overall description
2.1 Product perspective
Relay is a new, self-contained system with no predecessor. It sits between a customer's application backend and their end-user clients.
flowchart TB
clients["Customer's end-user clients (web / mobile)"]
backend["Customer's application backend"]
subgraph relay [RELAY]
rest["REST API"]
gw["WebSocket gateway"]
whd["Webhook dispatcher"]
pg[("PostgreSQL<br/>(OLTP)")]
q[["Queue"]]
ch[("ClickHouse<br/>(OLAP)")]
rest --> pg
gw --> pg
q --> whd
q --> ch
end
clients -- "user token (JWT)<br/>WebSocket + REST" --> relay
relay -- "real-time events" --> clients
backend -- "API key (REST)" --> relay
relay -- "signed webhooks" --> backend
The customer's backend is the trust anchor: it authenticates its own users and mints user tokens. Relay verifies those tokens but never holds end-user credentials.
2.2 User classes
| Class | Description | Access mechanism | Frequency |
|---|---|---|---|
| Integrating developer (Mai) | Builds and maintains the integration | Dashboard + API key | Daily during integration, weekly after |
| Engineering lead (David) | Approves spend, monitors cost and reliability | Dashboard, read-only + billing | Monthly |
| Customer backend | Server-to-server automation | API key | Continuous |
| End user (Tuan) | Sends and receives messages | User token | Continuous |
| Relay operator | Internal support and platform operations | Internal admin, audited | As required |
2.3 Operating environment
Server: Linux, containerised, horizontally scalable. PostgreSQL 15+ for operational data; ClickHouse 24+ for analytical data; Redis or NATS for pub/sub fan-out and presence; a durable queue (Kafka, Redpanda, or NATS JetStream) between the operational and analytical paths.
Client: the SDK shall support evergreen Chrome, Firefox, Safari, and Edge; Node.js 18+; and React Native 0.72+.
2.4 Constraints
| ID | Constraint |
|---|---|
| CON-01 | Operational and analytical storage shall be physically separate. Analytical queries shall never execute against the operational database. |
| CON-02 | The system shall be horizontally scalable with no single-instance state; WebSocket connections shall not require sticky routing for correctness. |
| CON-03 | All external interfaces shall be authenticated. No unauthenticated endpoint shall return tenant data. |
| CON-04 | All timestamps shall be stored and transmitted in UTC, RFC 3339 format, with millisecond precision. |
| CON-05 | The public REST API shall be versioned in the URL path (/v1/...). Breaking changes require a new version. |
| CON-06 | The system shall not store end-user passwords or credentials of any kind. |
2.5 Assumptions and dependencies
| ID | Assumption | Consequence if false |
|---|---|---|
| ASM-01 | Customers can run server-side code to mint user tokens | Client-only applications cannot integrate securely; a hosted token endpoint would be required |
| ASM-02 | Customers maintain their own user identity system | Relay would need to become an identity provider — a stated non-goal |
| ASM-03 | End-user clients tolerate eventual delivery over WebSocket with reconnection backfill | Guaranteed synchronous delivery would require a different transport |
| ASM-04 | Message volume per tenant remains below 10 million per day in v1 | Sharding strategy would require redesign |
| ASM-05 | Customers accept server-side plaintext message storage | Non-negotiable for moderation and history; excludes E2E-required segments |
| ASM-06 | An S3-compatible object store with presigned URL support is available in every deployment target | Media (§4.14) would require a proxied upload path through Relay compute, invalidating NFR-PRF-08's design premise |
3. External interface requirements
3.1 REST API
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| EIR-API-01 | The system shall expose a JSON over HTTPS REST API at /v1. | P1 | I |
| EIR-API-02 | All requests and responses shall use application/json with UTF-8 encoding. | P1 | T |
| EIR-API-03 | The API shall use conventional HTTP status codes: 200, 201, 204, 400, 401, 403, 404, 409, 422, 429, 500, 503. | P1 | T |
| EIR-API-04 | Error responses shall carry a machine-readable code, a human-readable message, an optional field naming the offending parameter, a docs_url, and the request_id. The five fields shall be top-level in the response body, not nested under an envelope key. | P1 | T |
| EIR-API-05 | Every response shall include a unique X-Request-Id header, referenced in all error responses and in the request log. | P2 | T |
| EIR-API-06 | List endpoints shall use opaque cursor pagination with limit and cursor parameters, returning next_cursor and has_more. Offset pagination shall not be offered. | P1 | T |
| EIR-API-07 | An OpenAPI 3.1 specification shall be published, machine-readable and complete for every public endpoint. | P4 | I |
Error response format
{
"code": "channel_member_limit_exceeded",
"message": "Channel has reached its maximum of 1000 members.",
"field": "user_ids",
"docs_url": "https://docs.relay.dev/errors/channel_member_limit_exceeded",
"request_id": "req_01HQ8X..."
}
Amended in 1.3. This example nested the five fields under an
errorkey until 2026-08-20. The implementation has emitted them top-level since the walking skeleton, and the document is being brought to the code rather than the reverse — recorded because the constitution's Governance section requires a conflict with this document to be resolved by explicit amendment rather than ignored.The flat shape is also the one that keeps EIR-API-04's stated purpose intact. The same five fields travel on the WebSocket surface inside a frame's
payload(EIR-WS-02), so a REST body nested undererrorwould make the two surfaces differ in two ways instead of one: the transport wrapper, which is unavoidable, and the field container, which is not.
3.2 WebSocket interface
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| EIR-WS-01 | The system shall expose a WebSocket endpoint accepting a user token as a connection parameter. | P1 | T |
| EIR-WS-02 | All frames shall be JSON objects carrying a type discriminator and a payload. | P1 | T |
| EIR-WS-03 | The server shall send a connection.ack frame containing the resolved user identity, a resume cursor, and a per-channel revision count within 1 second of a successful handshake. The revision count is reported for every channel of which the user is a member, including those at zero, so that a client with nothing to repair still receives the baseline it will compare against at its next reconnection (feature 044). | P1 | T |
| EIR-WS-04 | The server shall send a ping every 30 seconds and close connections that fail to respond to two consecutive pings. | P1 | T |
| EIR-WS-05 | The server shall reject connections with an expired or invalid token using close code 4001 and a diagnostic reason. | P1 | T |
| EIR-WS-06 | Close codes shall be documented and distinguish authentication failure, quota exhaustion, server shutdown, and protocol violation. | P2 | I |
| EIR-WS-07 | The protocol shall be fully documented, including reconnection, ordering, and backfill semantics. | P2 | I |
3.3 Webhook interface
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| EIR-WHK-01 | The system shall deliver events to customer-configured HTTPS endpoints via HTTP POST with a JSON body. | P2 | T |
| EIR-WHK-02 | Each request shall carry an X-Relay-Signature header containing an HMAC-SHA256 of the timestamp and raw body, keyed on the endpoint's signing secret. | P2 | T |
| EIR-WHK-03 | Each request shall carry an X-Relay-Timestamp header to permit replay-window validation by the recipient. | P2 | T |
| EIR-WHK-04 | Each event shall carry a stable id, enabling recipient-side deduplication. | P2 | T |
| EIR-WHK-05 | A 2xx response shall be treated as success; any other response or a timeout exceeding 10 seconds shall be treated as failure. | P2 | T |
3.4 Dashboard interface
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| EIR-DSH-01 | The dashboard shall be a responsive web application usable at viewport widths from 1280 px down to 768 px. | P2 | D |
| EIR-DSH-02 | The dashboard shall consume only the same public API available to customers, except for internal billing endpoints. | P2 | I |
| EIR-DSH-03 | The dashboard shall meet WCAG 2.1 Level AA for keyboard navigation, focus visibility, and colour contrast. | P4 | I |
4. Functional requirements
4.1 Tenancy and application management — FR-TEN
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| FR-TEN-01 | A user shall be able to create an organisation by authenticating with GitHub or Google OAuth, providing no information beyond that granted by the provider. | P2 | T |
| FR-TEN-02 | On organisation creation the system shall automatically create one application and one development environment, without further user input. | P2 | D |
| FR-TEN-03 | An organisation shall support multiple applications, each with independent data, keys, and quotas. | P2 | T |
| FR-TEN-04 | Each application shall have exactly two environments, development and production, with separate credentials, quotas, and datastores. | P2 | T |
| FR-TEN-05 | No API operation shall return, modify, or reveal the existence of data belonging to another tenant, under any input. | P1 | T, A |
| FR-TEN-06 | Every persisted operational and analytical record shall carry a non-null tenant identifier. | P1 | I, T |
| FR-TEN-07 | An organisation shall support multiple human members with roles owner, admin, and member. Only owner may delete the organisation or change billing. | P2 | T |
| FR-TEN-08 | Deleting an application shall irreversibly delete all associated operational data within 30 days and shall be confirmed by typing the application name. | P2 | T |
FR-TEN-05 is the single most important requirement in this document. A violation is classified Sev-0. Verification includes an automated cross-tenant access test suite executed against every endpoint on every build.
4.2 Authentication and authorisation — FR-AUT
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| FR-AUT-01 | The system shall issue API keys scoped to a single application environment, presented as a bearer token. | P2 | T |
| FR-AUT-02 | API key secrets shall be displayed exactly once, at creation, and stored only as a salted hash thereafter. | P2 | I, T |
| FR-AUT-03 | API keys shall carry a visible, non-secret prefix identifying the environment (rk_dev_, rk_live_). | P2 | T |
| FR-AUT-04 | The system shall support multiple concurrent active keys per environment to permit rotation without downtime. | P2 | T |
| FR-AUT-05 | Revoking a key shall take effect within 5 seconds across all instances. | P2 | T |
| FR-AUT-06 | The system shall accept end-user authentication via JWT signed with the application's secret using HS256, containing sub (user ID), exp, and iat. | P1 | T |
| FR-AUT-07 | User tokens with an exp more than 24 hours beyond iat shall be rejected. | P2 | T |
| FR-AUT-08 | User tokens shall be rejected if expired, malformed, incorrectly signed, or issued for a different application. | P1 | T |
| FR-AUT-09 | In the development environment only, the system shall provide an endpoint that mints a user token from an API key, so that a developer may reach a first message before implementing token signing. | P2 | T |
| FR-AUT-10 | User tokens shall not authorise administrative operations. Creating channels with arbitrary membership, deleting others' messages, and all tenant management shall require an API key. | P1 | T |
| FR-AUT-11 | Expiry of a user token shall not terminate an established WebSocket connection; the client shall be permitted to supply a refreshed token over the existing connection. | P2 | T |
| FR-AUT-12 | Failed authentication attempts shall be rate limited per source IP address. | P2 | T |
Design note (journey Stage 4). Confusion between API keys and user tokens is the most common first-integration failure. FR-AUT-03 and FR-AUT-09 exist specifically to reduce it, and the error message for using the wrong credential type must name the mistake explicitly.
4.3 User management — FR-USR
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| FR-USR-01 | The system shall represent end users by an identifier supplied by the customer, unique within a tenant. Relay shall not generate end-user identities. | P1 | T |
| FR-USR-02 | A user record shall be created implicitly on first authentication if it does not exist. | P1 | T |
| FR-USR-03 | Customers shall be able to set and update display name, avatar URL, and an arbitrary JSON metadata object of up to 4 KB per user. | P1 | T |
| FR-USR-04 | The system shall support upserting up to 100 users in a single request. | P2 | T |
| FR-USR-05 | Deleting a user shall remove profile data and memberships while preserving their messages as authored by a deleted user, unless message deletion is explicitly requested. THIS IS THE CLAUSE THAT WON AGAINST FR-MOD-04, AND IT IS NAMED HERE SO THE TRADE IS FINDABLE FROM BOTH SIDES (feature 067, chapter 4.21). FR-MOD-04 erases messages; this clause preserves them. The trailing unless is not that request: it reads as a caller asking for deletion of messages, where a compliance erasure asks for removal of a PERSON. So the erasure keeps the conversation and destroys the author — the reading Slack and Microsoft Teams both take, and the one in which neither clause has to be broken. WHAT ERASURE ADDS TO THIS VERB is external_id, the attributed media objects and their bytes, the analytical rows, a receipt and an audit entry. What it does NOT add is deleting the row: preserving their messages and NO ACTION on all five foreign keys together make the row undeletable, which is the premise ADR-37 rests on. (Revision 1.28.) | P2 | T |
| FR-USR-06 | The system shall support banning a user at tenant scope, preventing connection and message send while preserving history. | P3 | T |
| FR-USR-07 | Customers shall be able to create bot users representing their own software, each carrying a description of what it is and why it posts. A bot user shall not authenticate. | P2 | T |
AMENDMENT, chapter 3.17 — FR-USR-07 and FR-MSG-15 added, FR-MSG-13 and FR-RTL-05 narrowed. Until this chapter a message sent with an application key named nobody, and the SRS had no word for the thing a customer's software is. Three chapters since have made the sender decide what is rendered, what is delivered and what may be seen, so a message with no sender became a row those three chapters cannot describe.
FR-USR-07 is new: a bot user, carrying a description, that cannot authenticate. It is not a new kind of row — it is a
usersrow with a stored property — because every reader built since chapter 3.15 already reads that table.FR-MSG-13 was already here, and it is being MET rather than introduced. It has said since v1 that a key may send on behalf of a user, and chapter 3.3 satisfied it by sending unattributed — a decision that was right when nothing read the sender, and that
messages.controller.tshas recorded ever since as "A tenant's own server sending on a customer's behalf is FR-MSG-13, not a mistake." This chapter is the first implementation that names the user. The clause is narrowed from any user to a bot user of that tenant, in place: adding a clause beside it would have left this document asserting both that a key may send as anyone and that it may send only as a bot.FR-MSG-15 carries only what FR-MSG-13 does not — that every message has a sender at all.
FR-RTL-05 is narrowed from "unique active users" to "unique active persons", and FR-ANL-05 is deliberately unchanged. A bot is metered like any user and is exempt from the enforced ceiling: the ceiling bounds a customer's human population, and a customer's own software should not be able to lock their people out of sending. Metering and enforcement were already two clauses in two families before this chapter needed them to be.
FR-RTL-08's exception, stated rather than discovered. "Quota exhaustion shall degrade predictably: sends rejected with a specific error code." A bot's send is not rejected at exhaustion. Uncited that is a defect report; here it is a decision.
FR-MSG-01 is unchanged — it describes what a message contains and does not mention a sender. A later reader must not look for the sender rule there. FR-MSG-08 is unchanged too, and worth naming: its tombstone already retains the author, which agrees with FR-MSG-15 and has never been built.
How the two new clauses are verified (T073).
FR-USR-07by test: the bot round trip, the description rules at the boundary and at the database, and the promotion in all three of its states.FR-MSG-15by compilation as well as by test —repository.sendMessagetakesuserId: string, so a senderless write does not build, and the evidence is atypechecktranscript rather than a failing test. A compile-time guarantee has no red test to watch, which is why the amendment says so here rather than leaving a reader to look for one.FR-USR-01 still holds for bots. "Relay shall not generate end-user identities" — a bot's identifier is customer-supplied like any other, and the platform invents nothing. The alternative this chapter rejected was a synthetic sender the platform mints for a key, which is what chapter 3.10 argued against and still argues against; a bot is a declared identity the customer creates, names and describes.
Verification, FR-USR-02 to FR-USR-06 (chapters 3.15 and 3.16). Five of these six clauses were unimplemented until then, and two things are worth recording beyond "tested".
FR-USR-02 had a symptom rather than a gap: minting a token for an unknown identifier and sending through the internal route answered
400 "unknown user", which names the caller when the cause is that nobody created a row. Implicit creation now happens on the mint path, and the response deliberately does not distinguish "created" from "existed" — a status that did would let a caller enumerate a tenant's external ids.FR-USR-03's two bounds are 4 KB for a user against FR-CHN-01's 8 KB for a channel. The difference is row cardinality and is now stated where the constant is: users outnumber channels 3.4:1 on the test lane and the ratio only grows, because a channel is created deliberately and a user row appears for every end user who authenticates.
FR-USR-05 keeps the row.
ON DELETE SET NULLonmessages.user_idsatisfies the letter of "preserving their messages" and breaks delivery: the resume path drops a senderless row, so every message a deleted user ever sent would vanish from every reconnecting client with a sequence gap as the only trace. Verified on the socket, not only in storage.FR-USR-06 is enforced at connect and on the send path, and the ban check runs before the channel is resolved — so a banned user gets one answer for every channel id and cannot enumerate them. An already-open socket stops writing and keeps receiving, which is the shape FR-AUT-11 already required for an expired token.
4.4 Channels and membership — FR-CHN
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| FR-CHN-01 | The system shall support creating a channel with a customer-supplied identifier unique within a tenant, a type, a display name, and up to 8 KB of JSON metadata. | P1 | T |
| FR-CHN-02 | Channel creation shall be idempotent on the customer-supplied identifier: repeating the request shall return the existing channel rather than an error. | P1 | T |
| FR-CHN-03 | The system shall support channel types public (any authenticated user of the tenant may read and join) and private (members only). | P1 | T |
| FR-CHN-04 | Members shall hold one of the roles owner, moderator, or member. | P2 | T |
| FR-CHN-05 | A user shall not read messages from, send messages to, or observe presence in a private channel of which they are not a member. | P1 | T |
| FR-CHN-06 | The system shall support adding and removing up to 100 members in a single request. | P1 | T |
| FR-CHN-07 | A channel shall support up to 1,000 members. Exceeding the limit shall return 422 with a specific error code. | P1 | T |
| FR-CHN-08 | The system shall support listing a user's channels, ordered by most recent activity, with cursor pagination. | P1 | T |
| FR-CHN-09 | Channel listings shall include the caller's unread count and the most recent message. | P2 | T |
| FR-CHN-10 | Archiving a channel shall preserve history and prevent new messages. Archiving shall be reversible. | P2 | T |
| FR-CHN-11 | Every public route that names a channel in its path shall accept the customer-supplied identifier for that channel, and shall continue to accept the Relay identifier. Where a path segment could name both, the customer-supplied identifier wins. No value in such a path shall produce a 5xx. THE CLAUSE EXISTS BECAUSE THIRTEEN ROUTES CONTRADICTED FR-USR-01 AND ADR-18 AND NOTHING SAID THEY SHOULD NOT (feature 068, chapter 4.22). FR-CHN-01 takes a customer-supplied identifier at creation and FR-CHN-02 makes that creation idempotent on it; no clause said the identifier could then be used to name the channel, and none of the thirteen routes beneath /v1/channels/{channelId} accepted one. GET /v1/channels/order-88412 answered 500, measured — 'order-88412'::uuid raises in Postgres before the OR beside it can short-circuit — so the natural first attempt told an integrating developer the platform was broken rather than that they had used the wrong key. That is the lookup table FR-CHN-01 exists to remove and the one docs/03-journey-map.md Stage 1 promises Mai will not need. THE TIE-BREAK IS A CORRECTNESS CHOICE AND NOT A PERFORMANCE ONE. An external_id may itself be a uuid — 0 of 41,772 channels have one — and the two lookups cost the same, measured twice a day apart with the ordering reversing between runs. Under key-first a customer who named a channel with another channel's uuid could never reach their own, with no error they could act on; under identity-first the other channel stays reachable by its uuid from any caller holding it. AND THE NO-5xx HALF CLOSES A GAP RATHER THAN ADDING A GUARANTEE: a value that cannot parse as a uuid is resolved as an identity and never cast, which removes the error at its source on the thirteen channelId routes, and a shape check closes the three that also take a messageId. THE INTERNAL DOORS ARE DELIBERATELY EXCLUDED — POST /internal/messages, /internal/backfill and /internal/dispatch/* name a channel in a body and keep the Relay identifier, because packages/protocol/src/internal.ts says of that contract "internal uuids are the api's business". AND THE REAL-TIME SURFACE IS NOT COVERED: every gateway frame carries channel: <uuid> and a socket send is forwarded to a door typed z.string().uuid(), so a customer holding a socket still keeps the mapping this clause removes from REST. (Revision 1.29.) | P1 | T |
Verification, FR-CHN-03 to FR-CHN-10 (chapters 3.15 and 3.16).
channels.typehad carriedpublic | privatewith a CHECK constraint since the first migration while no decision consulted it — it was declared, selected and returned by the create route, so "nothing reads it" was false and "nothing decides on it" was the true and sharper statement. FR-CHN-03 and FR-CHN-05 are what made it decide.FR-CHN-05 has four doors — the send path, the read-by-id path, the history path and the socket session — and the enum accepting
privatewas widened last, after all four held. Each refusal is byte-identical to a channel that does not exist, verified by the same indistinguishability oracle NFR-SEC-09's suite uses for cross-tenant attacks, extended with a same-tenant fixture. A403naming the membership would announce that the channel exists.FR-CHN-04's roles are not
FR-TEN-07's.owner,moderator,memberagainst an organisation'sowner,admin,member— one word apart, and a migration reusing the organisation's constraint would acceptadminon a channel member and look correct in review. Both CHECK constraints now name the other. The listing returns a member's role and no operation is authorized by it, which is the statement worth making rather than "nothing reads it".FR-CHN-09's count has no counter:
greatest(last_sequence − read_position, 0), becauselast_sequenceis already the sequencing authority. Measured against 1,000,000 messages, counting rows past the position is 9.8–13.4 ms, a cached counter is 1.2–2.1 ms and the subtraction is 1.1–4.5 ms — the counter is no faster and adds a value that can go stale. The approximation this accepts is stated: a deleted message still counts as one unread, because a tombstone keeps its sequence.FR-CHN-08's ordering key is indexed and the index is earned. Ordering by the last message's timestamp costs 0.87 ms on the test lane and 159 ms at 1,000,000 messages with a sequential scan over every message in the environment; an indexed
channels.last_activity_atis 1.1 ms. The listing's own plan was measured at four scales, and the first page is the most expensive one — the reverse of an offset paginator, because a keyset narrows the input set as it pages.FR-CHN-10's refusal order is part of the requirement. Ban, then membership and visibility, then archive. Reverse the last two and a non-member of a private archived channel learns it exists from
channel_archived.
4.5 Messaging — FR-MSG
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| FR-MSG-01 | The system shall accept a message containing text of up to 8,000 characters and up to 4 KB of JSON metadata. | P1 | T |
| FR-MSG-02 | The system shall assign every message a monotonically increasing, gap-tolerant sequence number, unique and strictly ordered within a channel. | P1 | T |
| FR-MSG-03 | Message ordering within a channel shall be determined solely by server-assigned sequence number, never by client timestamp. | P1 | T, A |
| FR-MSG-04 | The system shall accept a client-supplied idempotency key on send. A repeated key within 24 hours shall return the original message with 201-equivalent semantics and shall not create a duplicate. | P1 | T |
| FR-MSG-05 | The system shall acknowledge a send only after the message is durably persisted. | P1 | T |
| FR-MSG-06 | An acknowledged message shall not be lost. | P1 | A |
| FR-MSG-07 | The system shall support editing message text, preserving the original sequence number and recording an immutable edit history with timestamps. THE WORD immutable WAS A DESCRIPTION UNTIL CHAPTER 4.19 AND IS NOW A MECHANISM. The history has existed since 3.23; nothing enforced the word, measured rather than inferred — an UPDATE and a DELETE on message_edits both succeeded, while audit_log had carried a guard since 4.18. A BEFORE UPDATE OR DELETE trigger refuses both, demonstrated through psql with the row surviving each attempt. THE SCOPE IS PUBLISHED WITH THE CLAIM and it is ADR-35's, re-measured on this second table rather than assumed to transfer: SET session_replication_role = replica and DROP TRIGGER both still succeed, so the history is immutable to the application and to accident and not to somebody holding the database password, which this api does. AND THE KEY'S COLLISION WINDOW WAS A THOUSAND TIMES WIDER THAN THE SCHEMA CLAIMED: edited_at is timestamptz(6) and every value ever written to it arrived through the driver as a millisecond Date — 5,149 of 5,149 rows land exactly on a millisecond boundary — so two writes to one message inside one millisecond collide on (message_id, edited_at). 4.19's deletion path writes now() in SQL and keeps the microseconds; the edit path still writes a Date and is recorded as a gap. (Revision 1.26.) | P2 | T |
| FR-MSG-08 | Deleting a message shall replace its content with a tombstone retaining sequence number, author, timestamps, and deletion metadata. Hard deletion shall occur only on a compliance path. AMENDED FROM only via the compliance deletion endpoint (feature 066, chapter 4.20): FR-MOD-06 requires expired messages to be hard-deleted and this sentence forbade it, which is a conflict three documents shared. ADR-36's first decision reads the constitution's own word — principle II says path, not endpoint — and a retention policy exists to keep a promise a customer made to an auditor, so a retention sweep is a compliance path. There are two such paths and they are named: FR-MOD-04's erasure endpoint, and FR-MOD-06's retention sweep. Anything else is still forbidden. CHAPTER 4.19 CITED THIS CLAUSE AS THE ONE ITS DEFECT BROKE — losing a message's final text at a moderation delete was a hard deletion on the path this sentence reserves for erasure — and that reading is unchanged: what the amendment admits is a sweep acting on an environment's published policy, not a deletion nobody asked for. | P2 | T |
| FR-MSG-09 | The system shall support retrieving channel history in both directions from a cursor, with a maximum page size of 200. | P1 | T |
| FR-MSG-10 | History responses shall include tombstones so that clients can render deletions without gaps in ordering. | P2 | T |
| FR-MSG-11 | The system shall support up to 10 attachments per message. An attachment is either a reference to a Relay-hosted media object (media_id, see §4.14) or an external URL with a declared kind (image, audio, video). | P2 | T |
| FR-MSG-12 | The system shall record and expose per-user read state as the highest sequence number read in a channel. | P2 | T |
| FR-MSG-13 | The system shall support sending a message on behalf of a bot user of that tenant via API key, for backend-originated messages. | P2 | T |
| FR-MSG-14 | Threads, reactions, and full-text search shall not be implemented in v1. Emoji reactions remain excluded; emoji within message content is specified in §4.13. | P5 | I |
| FR-MSG-15 | Every message shall carry a sender. A message accepted with no sender shall not be created. | P1 | T |
Message state machine (client-observable)
stateDiagram-v2
composing --> sending: send()
sending --> sent: ack
sending --> failed: timeout / 5xx
failed --> sending: retry with same idempotency key
sent --> edited: edit
sent --> deleted: delete
The SDK shall surface sending, sent, and failed distinctly (see FR-SDK-05) so that
customer interfaces can represent delivery honestly rather than optimistically.
4.6 Real-time delivery — FR-RTM
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| FR-RTM-01 | A connected client shall receive messages for every channel of which it is a member, without per-channel subscription. | P1 | T |
| FR-RTM-02 | Delivery shall function correctly when sender and recipient are connected to different gateway instances. | P1 | T, A |
| FR-RTM-03 | On reconnection with a resume cursor, the system shall deliver all messages with a sequence number greater than the cursor for every channel of which the user is a member. A message revised below the cursor is not delivered and produces no sequence gap, because an edit or a deletion carries the sequence of the message it changes rather than a new one; the system shall instead report a per-channel count of revisions in connection.ack (EIR-WS-03), from which a client determines which channels to repair by re-reading history. The system shall not perform that comparison and shall not refuse a connection on the basis of a count a client presents (feature 044). | P1 | T |
| FR-RTM-04 | Backfill exceeding 500 messages in a channel shall return a truncation indicator instructing the client to refetch history rather than streaming the entire backlog. | P2 | T |
| FR-RTM-05 | The system shall emit real-time events for message creation, edit, deletion, membership change, presence change, and typing. Edit and deletion events reach only clients connected when they occur; a client that was disconnected learns of them from the revision count in connection.ack (FR-RTM-03) rather than from a replayed event. | P1 | T |
| FR-RTM-06 | The system shall track presence with states online and offline, derived from connection state with a 30-second grace period. | P1 | T |
| FR-RTM-07 | Presence updates shall be delivered only to users sharing at least one channel with the subject. | P1 | T |
| FR-RTM-08 | Typing indicators shall expire automatically after 5 seconds without renewal and shall not be persisted. | P2 | T |
| FR-RTM-09 | A user shall be permitted up to 5 concurrent connections; each shall receive all events independently. The cap is best-effort: it is enforced from a connection registry that is not a source of truth, and when that registry is unreachable the connection is admitted and the gateway records that the cap was not checked. A user may therefore exceed 5 concurrent connections while the registry is unavailable (ADR-23). | P2 | T |
| FR-RTM-10 | Events shall not be delivered to a client whose membership no longer grants access, effective within 5 seconds of the membership change. When the membership fabric is unavailable the five-second bound does not hold and revocation is carried by a per-connection re-read every 60 seconds, so a revocation lands within 60 seconds — exceeding the five-second bound by 55. Revocation remains guaranteed; what is bounded is how late it can be (ADR-20). | P1 | T |
| FR-RTM-11 | Every field that names a channel in a frame delivered to a connected client shall carry the customer-supplied identifier, and every field that accepts a channel from a client shall accept it, while continuing to accept the Relay identifier. The fan-out subjects, the resume cursors' internal keying and the api-facing contract keep the Relay identifier. THE CLAUSE EXISTS BECAUSE FR-CHN-11 STOPPED AT REST AND NOTHING SAID THE SOCKET SHOULD FOLLOW (feature 070, chapter 4.23). Ten FR-RTM clauses describe what is delivered and when, and none of them says which identifier names the channel, so the real-time surface had no requirement to contradict — the same silence FR-CHN had before FR-CHN-11, one surface over. THE SURFACE IS LARGER THAN A COUNT OF channel FIELDS SHOWS. Seven client-facing schemas carry one; the connection.ack payload names channels three more times without using the word, in revisions and cursor (both keyed by channel) and in truncated (a list of them). A requirement written against the seven would have left the first frame every client receives carrying uuids. AND THE TRANSLATION BELONGS AT THE CLIENT EDGE, WHICH IS A CORRECTNESS CHOICE. Of twenty-one places the gateway writes a channel, three are client frames, eleven are log lines, six are internal publishes and one is a comment — and message.created reaches a client as a payload forwarded from the api, written by no gateway expression at all. Translating per site would rename log lines an operator reads against the subjects, and would miss the commonest frame on the socket. THE INBOUND FORMS CANNOT BE TOLD APART BY SHAPE. A resume cursor is a record keyed by strings and a customer's identifier may itself be a uuid: 19 of 44,574 channels carry one, and every one of the nineteen is another channel's Relay identifier (measured 2026-10-08, against 0 of 41,772 four days earlier). So both forms are accepted and the identifier wins, as it does on REST. WHAT THIS CLAUSE DOES NOT COVER: webhook payloads still carry channel_id as a Relay identifier on a boundary whose own contract says consumers receive external ids, which is recorded rather than closed here. (Revision 1.30.) | P1 | T |
4.7 Webhooks — FR-WHK
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| FR-WHK-01 | Customers shall be able to configure up to 5 webhook endpoints per environment, each subscribing to a selected set of event types. | P2 | T |
| FR-WHK-02 | The system shall emit at minimum: message.created, message.updated, message.deleted, channel.created, channel.member_added, channel.member_removed, user.connected, user.disconnected. | P2 | T |
| FR-WHK-03 | Failed deliveries shall be retried with exponential backoff at approximately 1 s, 5 s, 30 s, 5 min, 30 min, 2 h — six attempts in total. | P2 | T |
| FR-WHK-04 | After exhausting retries an event shall be moved to a dead-letter store, retained for 7 days, and made inspectable and manually replayable from the dashboard. | P2 | T |
| FR-WHK-05 | Webhook delivery shall be asynchronous and shall never delay or block message delivery to end users. | P2 | T, A |
| FR-WHK-06 | Every delivery attempt shall be recorded with timestamp, response status, latency, and error, and shall be queryable for 30 days. | P3 | T |
| FR-WHK-07 | An endpoint returning failures for more than 1 hour continuously shall be automatically disabled, and the organisation notified by email. | P3 | T |
| FR-WHK-08 | Each endpoint shall have an independently rotatable signing secret. | P2 | T |
| FR-WHK-09 | The dashboard shall permit sending a synthetic test event to any configured endpoint. | P4 | D |
4.8 Rate limiting and quotas — FR-RTL
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| FR-RTL-01 | The system shall enforce per-tenant rate limits on REST requests, message sends, and connection establishment. | P2 | T |
| FR-RTL-02 | Every rate-limited response shall include X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers. Headers shall be present on successful responses, not only on 429. | P2 | T |
| FR-RTL-03 | Rate-limited requests shall return 429 with a Retry-After header. | P2 | T |
| FR-RTL-04 | Development and production environments shall have entirely independent limits and counters. | P2 | T |
| FR-RTL-05 | The system shall enforce configurable quotas per environment on four quantities. Three are monthly flows — messages sent, unique active persons, and connection-minutes — accumulated in usage_periods against a calendar month and reset when it turns. Stored media bytes are a level, not a flow: the figure is the sum of the tenant's live media objects, it falls when objects are deleted, and it does not reset on the first of the month. A storage quota shall therefore be enforced against that sum and shall not promise a resume date. | P3 | T |
| FR-RTL-06 | Organisations shall be able to configure a hard spending cap that suspends the environment on breach, and a soft threshold that only alerts. | P3 | T |
| FR-RTL-07 | The system shall email organisation admins at 50%, 80%, and 100% of configured quota. | P3 | T |
| FR-RTL-08 | Quota exhaustion shall degrade predictably: sends rejected with a specific error code; existing connections and history reads unaffected. | P3 | T |
Design note. FR-RTL-06 is a purchasing requirement, not a technical one. Unbounded cost exposure is David's principal objection (journey, diligence phase).
4.9 Analytics and metering — FR-ANL
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| FR-ANL-01 | The system shall emit an analytical event for every message send, connection open and close, API request, and webhook delivery attempt. | P3 | T |
| FR-ANL-02 | Analytical events shall be written to the analytical store via a durable queue, never synchronously on the request path. | P3 | I, A |
| FR-ANL-03 | Failure or backlog of the analytical pipeline shall not affect message delivery, API availability, or webhook dispatch. | P3 | A |
| FR-ANL-04 | Analytical events shall be available for query within 60 seconds of the originating operation under normal conditions. | P3 | A |
| FR-ANL-05 | The system shall meter, per tenant per day: messages sent, unique active users, connection-minutes, and stored message count. A connection-minute is a calendar minute during any part of which a connection was open (Appendix C question 4, closed in v1.13). | P3 | T |
| FR-ANL-06 | Metered totals shall agree with counts derived from operational data to within 0.1%, verified by a daily reconciliation job that raises an alert on breach. The comparison is per tenant and per period; an aggregate one reports a number nobody would question. Measured at chapter 4.7: the two operational counters of messages sent disagree by 0.2630% across the lane while 49 of 1,385 tenant-periods breach the bound and one is wrong by everything it has. "Counts derived from operational data" is not one number, so the report names the table it read: usage_periods for messages sent and connection-minutes, usage_active_users for unique active users, and nothing at all for stored message count, which has no operational counterpart in this platform. A quantity with no operational source is not-comparable and a tenant with nothing on either side is no-data; neither is a breach and neither is agreement. At chapter 4.7 every tenant in the platform was in one of those two states — 4 environment ids in the rollup, none of which exist in Postgres, against 1,313 with operational usage, none of which have rollup rows. AND THE 0.1% BOUND IS UNREACHABLE FOR THREE OF THE FOUR QUANTITIES, FOR REASONS THAT ARE NOT DEFECTS. Unique active users: uniq is exact to 65,536 distinct and off by 0.5676% at 65,537 (gaps.md 047-1, re-measured at 4.7). Any quantity read at the raw retention boundary: a daily rollup's finest grain is a day and the TTL cuts at a timestamp, so the oldest day in the window disagrees by 0% at midnight rising to 1.0989% just before it, on the same data. Connection-minutes: the meter bills every calendar minute a connection was open and the records bill only connections that closed, and wss.close() leaving established sockets to the process exit is chapter 4.5's decision rather than its defect. AND "RAISES AN ALERT" HAS NO MECHANISM HERE. This platform has no alerting integration; the job exits non-zero, and the two mail paths that exist (quotas/quota-email.ts, notifications/notification-relay.ts) share one transport whose default is a local catcher. An exit code is what the clause can currently mean. VERIFICATION STATUS, 2026-09-17 (feature 054, chapter 4.9), AND IT COVERS ONE OF THE CLAUSE'S THREE PARTS. The letter is T, so the discharge is a test and not a document: services/api/src/metering/reconcile.itest.ts plants a drift at the bound and one unit past it and runs on every push. The comparison is verified. The daily job is not — nothing invokes scripts/reconcile-usage.mjs: zero occurrences in either repository's package.json, in turbo.json, in ci.yml or in any shell script, and ci.yml has no schedule: trigger (ADR-28, which records the absence rather than building a sixth background relay that would report no-data for every tenant on the platform). The alert is not, for the reason two sentences above. And the bound's resolution is measured rather than asserted: docs/13-metering-measurement-2026-09-17.md publishes 121,057 against 121,057 — 0.0000% — in one tenant-period where the smallest expressible drift is 122 messages, 0.1008%, with a planted 121 passing and 122 breaching in both directions. That document is evidence about the bound, not the verification of the clause; docs/11 could be the verification for NFR-SCL-01 only because that clause's letter is A. | P3 | T |
| FR-ANL-07 | The system shall retain a queryable API request log per tenant for 30 days, recording request ID, timestamp, endpoint, method, status, and latency. Request and response bodies shall not be recorded. | P3 | T |
| FR-ANL-08 | Analytical queries over 90 days of a single tenant's data shall return within 2 seconds at p95. Over api_requests the ninety days cannot exist, and that is a property of the schema rather than of any lane's size (feature 053, chapter 4.8). FR-ANL-07 retains that table for 30 days and analytics/0005_connection_events.sql:4 records why one clause cannot express two; measured at 4.8, two rows planted for one environment at now - 60 days and now - 1 day leave one survivor, and OPTIMIZE TABLE … FINAL changes nothing — the TTL removes rows at INSERT (chapter 4.2's finding, reproduced). So no fixture can put a 90-day window in front of this clause, at any volume. Where the two are read over the same table, FR-ANL-07's retention governs and this clause is measured over whatever the retention allows, with the window stated beside the figure. The clause is unchanged for the tables DR-09 retains for 90 days, which is what it was written for. | P3 | A |
| FR-ANL-09 | Usage shall be attributable by application, environment, channel, and day. | P3 | T |
| FR-ANL-10 | The system shall compute end-to-end delivery latency percentiles (p50, p95, p99) per tenant per hour, computed with quantileExact and never with quantile. "End-to-end delivery latency" is the interval from a message's commit to the frame being written to a subscriber's socket — the real-time path constitution II is about, and the reading message_events.delivery_latency_ms was created for. analytics/0003_webhook_attempts.sql:20 has said so since it was written: that column is "how long the ENDPOINT took to answer. NOT message_events.delivery_latency_ms, which is how long a message took to reach a client." The two readings not chosen, and what each would cost. Webhook delivery — commit to the endpoint answering — is the last leg only: services/dispatcher/src/deliver.ts sets started immediately before the fetch and takes the difference immediately after, so it excludes the commit, the outbox, JetStream, the dispatcher's claim and every retry gap; it already has a column and a name of its own, and relabelling it would publish a number under a name meaning something else. A REST history read has no delivery in it at all — a read nobody made has no latency — and it is pull rather than push. AND THE CHOSEN READING HAS NO PRODUCER, WHICH IS A COST AND NOT AN OVERSIGHT. Measured at chapter 4.8: message_events holds 0 rows and 0 of its rows carry a latency; the three occurrences of the table name under services/ are a comment, a cleanup and an insert, all belonging to one test (gaps.md 048-2, carried through six features and re-measured here). The one thing that writes the table at all, scripts/scale/load-analytics.mjs, writes CAST(NULL AS Nullable(UInt32)) into that column twice, on purpose. Building it needs both instants carried across a service boundary — the commit is the api's and the socket write is the gateway's — on the busiest path in the platform, which is the same cost chapter 4.6 weighed and deferred for a different column. AND quantile HAS NO EXACT REGIME, WHICH IS THE OPPOSITE SHAPE FROM uniq. uniq is exact below 65,536 and wrong above; quantile is approximate everywhere and the error does not fall as the sample grows. Measured against the server at chapter 4.8, over a uniform sample the p50 error is 16.67% at n=4, reaches 0 at n=155, and is back to 0.90% at n=10,000; over a skewed sample — which is what a latency is — it grows with the sample, reaching 9.52% on all three percentiles at n=10,000. On the one real latency sample this platform holds, webhook_attempts at n=64, quantile(0.99) answers 4,961.26 where quantileExact(0.99) answers 10,000 — 50.39% low, and low is the direction that matters, because an alert threshold set on it never fires. AND THE BUCKET THIS CLAUSE ASKS FOR IS THE SMALL ONE. Per tenant per hour over the lane's request log: 178 buckets, median 10 rows, p95 155, max 171, and 73 of the 178 hold fewer than five rows. A p99 over four samples is a maximum wearing a percentile's name, so any implementation publishes n beside every percentile. | P3 | T |
| FR-ANL-11 | Analytical records containing message content shall store only length and metadata, never message text. | P3 | I |
| FR-ANL-12 | The system shall alert an organisation when daily usage exceeds twice the trailing 7-day average. | P4 | T |
FR-ANL-11 matters. The analytical store is optimised for aggregate queries, retained longer, and accessed by more internal systems. Message content stays in the operational store, where deletion semantics are enforceable.
4.10 Moderation and compliance — FR-MOD
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| FR-MOD-01 | The system shall permit retrieving any channel's complete history, including tombstones and edit history, via API key. FIVE OBLIGATIONS, AND FOUR WERE ALREADY MET WHEN CHAPTER 4.19 OPENED — docs/12 §7.5 told that chapter to check its premise before writing and the check is most of what it found. Any channel irrespective of membership, tombstones in place with text: null, the edit history route, and via API key as an enforced access decision all predate it. WHAT complete DID NOT INCLUDE IS THE TEXT A MESSAGE HELD WHEN IT WAS REMOVED. An edit recorded the text it REPLACED and a deletion recorded nothing, so a message deleted after N edits left N of the N+1 texts that existed and a message deleted with no edits left zero of one. The composition of this clause's two nouns lost exactly one version, every time, and nothing recorded that it had. 4.19 preserves it — FR-MSG-08's "hard deletion shall occur only via the compliance deletion endpoint" is the clause it was breaking, because losing the final text at a moderation delete is a hard deletion on the wrong path. AND complete STILL EXCLUDES FOUR THINGS, NAMED RATHER THAN LEFT TO BE DISCOVERED: the current text of a live message, which has not stopped being current and comes from history; WHO ended a version, which is FR-MOD-03's and is joined by nothing automatic; what was attached, since FR-MED-10 unlinks a tombstone's attachments; and anything deleted before 4.19 shipped — 5,060 tombstones on the development lane, of which 3,762 have no recoverable text at all and 1,298 keep their earlier texts and look served. No migration recovers any of them. (Revision 1.26.) | P2 | T |
| FR-MOD-02 | The system shall permit deleting any message via API key, irrespective of author. | P2 | T |
| FR-MOD-03 | Every moderation action shall be recorded in an immutable audit log with actor, action, target, timestamp, and request ID, retained for 1 year. TEN OBLIGATIONS, NINE MET OR DEMONSTRATED AT CHAPTER 4.18. The clause names a population and gives no membership rule, and supplying one is most of what the chapter did: the router serves 24 tenant-reachable mutating routes, each classified in services/api/src/audit/moderation-routes.ts with a reason, checked in both directions against the routes a booted application reports. The set is eight, where a reasonable reader would predict nine — edit another author's message is not in it because it is not a reachable action: that route accepts a user token only, since FR-MOD-02 grants a key deletion of any message and is silent on editing. One route is classified by the CREDENTIAL rather than by itself (DELETE …/messages/:messageId): under a key it is FR-MOD-02, under a user token deleting their own message it is FR-013 of chapter 3.23, and a compliance log that recorded the second would fill with ordinary user activity. THE ACTOR IS A CREDENTIAL AND NOT A PERSON. actor_kind is application, user or platform and actor_id is the key id or the end-user external id — this platform has no notion of a human operator, so an entry names which credential acted and a tenant holding one key for its whole support tool will see that key on every row. Naming the person is a different clause and a different identity model. immutable IS SCOPED, AND THE SCOPE IS THE PART WORTH READING. A BEFORE UPDATE OR DELETE trigger refuses both verbs, demonstrated rather than asserted. REVOKE UPDATE, DELETE was measured inert — the api connects as a superuser — and two bypasses succeed: SET session_replication_role = replica and DROP TRIGGER. So the log is immutable to the application and to accident, and it is not immutable to somebody holding the database password, which this api does (ADR-35). retained for 1 year: UNMET BY DECISION, on ADR-28's precedent and FR-ANL-06's and DR-17's — this platform has no scheduler of any kind, so nothing prunes this table and nothing enforces a year. What the chapter refused to do is publish a retention_edge beside the read that the platform does not enforce; the request log publishes one because its table has a TTL. AND NFR-SEC-10 IS NOT SATISFIED BY THIS CLAUSE, though it asks for the same words: its actor is administrative access to production data, which is exactly the actor the trigger cannot refuse. The two are different artifacts and a separate non-superuser role for the application is the one change that would serve both. (Revision 1.25.) | P3 | T |
| FR-MOD-04 | The system shall provide an endpoint that permanently erases all data for a specified end user — messages, memberships, profile, and analytical records — completing within 30 days and returning a completion receipt. FOUR OBLIGATIONS, AND THE SECOND IS FOUR NOUNS THAT DO NOT GET ONE VERDICT (feature 067, chapter 4.21). The endpoint is DELETE /v1/users/{externalId}/data — a separate path rather than a flag on FR-USR-05's deletion, because two verbs differing only in what they preserve must not differ only in a query parameter. The receipt ships with five outcomes. The 30-day bound is met trivially and is therefore unexercised: the erasure is synchronous at 53 ms measured on its slow path, and ADR-28's absent scheduler means there is no asynchronous mode for the bound to govern — the fifth clause bounded by that absence, and the first where the absence costs nothing. THE SECOND OBLIGATION, NOUN BY NOUN. Profile: met — display_name, avatar_url and metadata are cleared and external_id is REPLACED with erased:<users.id>, because the column is NOT NULL under users_environment_id_external_id_unique and there is no null to write. Memberships: met. Messages: UNMET BY DECISION — FR-USR-05 preserves them as authored by a deleted user, and this clause is read as erasing the PERSON rather than the conversation, which is what Slack and Microsoft Teams both do. The author is erased and the text is not, and the receipt says so. AND THE ROW CANNOT BE DELETED, WHICH IS THE WHOLE STRUCTURE. Keeping the messages means messages.user_id stays; all five foreign keys to users are NO ACTION; so the row is unreachable and erasure leaves a tombstone. That tombstone is what makes every store referencing the user BY KEY stop naming anybody without being touched — ADR-37. AND ANALYTICAL RECORDS IS FOUR STORES WITH THREE ANSWERS. connection_events holds user_external_id — the identity — and is deleted, scoped by environment and bound, because an external id is unique per environment and not globally: on the development lane 1,576 are reused across environments, and an unscoped delete of the worst takes 152 rows from 110 other tenants against 4 correctly, 97.4% wrong. api_requests holds no user column at all over 205,697 rows, which is chapter 4.4's design and the cheapest erasure there is. message_events holds 0 rows and has no producer. daily_usage_* holds seven AggregateFunction(uniq, Nullable(UUID)) columns keyed on the internal uuid, from which no one user can be removed — and under ADR-37 none needs to be. ONE STORE CANNOT COMPLY BY CONSTRUCTION AND IT IS NOT ANALYTICAL. FR-MOD-03's audit_log holds target_id text — the person's EXTERNAL id — on 1,357 of 1,357 user-target rows, not one of them a uuid — and ADR-35 made it append-only. The erasure writes one more, deliberately: that entry is the operator's only proof the erasure happened. So the one place the name survives is the record that the name was erased, and the receipt reports it as cannot_erase rather than omitting it. (Revision 1.28.) | P3 | T |
| FR-MOD-05 | The system shall support exporting all data for a tenant as newline-delimited JSON, generated asynchronously with a notification on completion. | P3 | T |
| FR-MOD-06 | Configurable message retention per environment (30 / 90 / 365 days / indefinite) shall be supported, with expired messages hard-deleted by a scheduled job. THREE OBLIGATIONS, AND THE PREMISE CHECK FOUND NONE OF THEM MET (feature 066, chapter 4.20) — the inverse of FR-MOD-01's, which found four of five already built. environments.retention_days had existed since chapter 2.1, was named in §6.1 and the SAD, and was set on 0 of 33,051 environments with nothing reading it. Configurable and the four options are now met: PATCH /v1/environments/{id} writes the column and environments_retention_days_check bounds it to the clause's enumeration rather than to a range, because 45 is not a stricter policy somebody chose. Hard deletion is demonstrated and WAS REFUSED BY THIS PLATFORM'S OWN SCHEMA until this chapter — a pincer on 5,495 messages: message_edits_message_id_fkey refuses the parent, message_edits_append_only refuses the children, and ON DELETE CASCADE is refused too, because a cascade issues an ordinary DELETE that a row trigger fires on. ADR-36 licenses the exception and narrows ADR-35's scope clause for that table. by a scheduled job is UNMET BY DECISION, the fourth clause bounded by ADR-28's absence after FR-ANL-06, DR-17 and FR-MOD-03's year — and the one whose absence is a customer telling an auditor that data does not exist rather than a report drifting, so no surface publishes an expires_at a compliance team could read as a deadline. | P3 | T |
| FR-MOD-07 | The system shall support an optional pluggable content classifier evaluated before persistence, capable of rejecting or flagging a message. | P5 | T |
4.11 Developer dashboard — FR-DSH
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| FR-DSH-01 | The dashboard shall display a development API key on the first screen following signup, copyable in a single action. | P2 | D |
| FR-DSH-02 | The dashboard shall provide a live event stream showing connections, messages, and API calls as they occur, with a latency under 2 seconds. | P2 | D |
| FR-DSH-03 | The dashboard shall provide a searchable request log filterable by endpoint, status, and time range. "With full request and response detail" is struck (feature 053, chapter 4.8): FR-ANL-07 was amended at revision 1.11 to record no bodies at all, and EIR-DSH-02 permits the dashboard "only the same public API available to customers" — so the dashboard can show only what that API returns, and that API returns no bodies. The three clauses could not all hold, and the two that reach further are the ones that stayed: FR-ANL-11 keeps message content out of the analytical store and constitution VI keeps it out of logs. 1.11's own sentence is the loss, unchanged and now carried to the clause it also governs: a request log with no payload cannot answer "what exactly did they send", only "what did they call and what happened". What the dashboard can show is exactly the surface chapter 4.8 built — GET /v1/request-log, with the endpoint, status and time-range filters this clause names and a page envelope conforming to EIR-API-06. | P3 | D |
| FR-DSH-04 | The dashboard shall display usage against quota for the current billing period, with a projected month-end figure. | P3 | D |
| FR-DSH-05 | The dashboard shall chart message volume, active users, error rate, and delivery latency percentiles over selectable ranges up to 90 days. | P3 | D |
| FR-DSH-06 | The dashboard shall list webhook deliveries with status, and permit inspection and replay of failures. | P3 | D |
| FR-DSH-07 | The dashboard shall provide a channel browser for inspecting channels and messages during development. | P4 | D |
| FR-DSH-08 | The dashboard shall provide a cost estimator accepting projected user count and messages per user per day. | P4 | D |
FR-DSH-02 is the highest-leverage single feature in the product (journey, Stage 4). Seeing the first message arrive in the dashboard converts uncertainty into confidence faster than documentation can.
4.12 Client SDK — FR-SDK
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| FR-SDK-01 | A JavaScript SDK shall be published to npm, functioning in browsers, Node.js 18+, and React Native. | P4 | T |
| FR-SDK-02 | The SDK shall ship complete TypeScript type definitions. | P4 | I |
| FR-SDK-03 | The SDK shall provide transport, state, and events only. It shall contain no UI components and no styling. | P4 | I |
| FR-SDK-04 | The SDK shall reconnect automatically with exponential backoff and jitter, capped at 30 seconds, resuming from its last cursor. | P4 | T |
| FR-SDK-05 | The SDK shall expose sending, sent, and failed states for every outbound message, and shall support optimistic local insertion. | P4 | T |
| FR-SDK-06 | The SDK shall generate idempotency keys automatically and reuse them across retries of the same logical send. | P4 | T |
| FR-SDK-07 | The SDK shall queue outbound messages while disconnected and flush them in order on reconnection. | P4 | T |
| FR-SDK-08 | The SDK shall be under 30 KB minified and gzipped, excluding peer dependencies. | P4 | T |
| FR-SDK-09 | The SDK shall accept a token provider callback rather than a static token, so that expiry is handled without reconnection. | P4 | T |
| FR-SDK-10 | A reference chat client shall be published, built exclusively against the public API and SDK, with source available. | P4 | D |
4.13 Emoji and emoji packs — FR-EMJ
Two distinct capabilities: Unicode emoji (standard emoji inside message text — a correctness obligation, Phase 1) and custom emoji packs (customer-defined emoji that end users browse and install — a feature, Phase 3+).
Unicode emoji
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| FR-EMJ-01 | The system shall accept, store, and return Unicode emoji byte-exactly in message text, channel names, and user display names, including ZWJ sequences, skin-tone modifiers, and flag sequences. No normalisation shall alter emoji content. | P1 | T |
| FR-EMJ-02 | The message length limit (FR-MSG-01) shall be counted in Unicode code points, and this shall be documented. A single displayed emoji may therefore consume multiple code points; the SDK shall expose the same counting function so client-side validation agrees with the server. | P1 | T |
Custom emoji packs
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| FR-EMJ-03 | Customers shall be able to create, update, and delete emoji packs at environment scope via API key. A pack shall have a customer-supplied external ID, a display name, an optional description and cover image URL, and up to 200 emoji. | P3 | T |
| FR-EMJ-04 | Each custom emoji shall have a shortcode matching ^[a-z0-9_]{2,64}$, an image URL, and optional search tags. Shortcodes shall be unique within an environment across all packs, so that :name: resolves unambiguously. | P3 | T |
| FR-EMJ-05 | A pack shall be listed (appears in the browsable catalog for all users of the environment) or unlisted (installable only by direct pack ID). | P3 | T |
| FR-EMJ-06 | End users shall be able to browse listed packs with cursor pagination and search them by name and tag, via user token. | P3 | T |
| FR-EMJ-07 | End users shall be able to install and uninstall packs on their account, and retrieve their installed set. A user may install up to 50 packs. Installation state shall be per-user, per-environment. | P3 | T |
| FR-EMJ-08 | Custom emoji shall be written in message text as :shortcode:. Message and history responses shall include a resolution map covering every shortcode present in the returned messages, so clients render without additional round trips. | P3 | T |
| FR-EMJ-09 | Installation shall be a picker convenience, not a permission: a user may send any shortcode valid in the environment regardless of installed packs. Authorisation of content is the customer's moderation concern, not the emoji system's. | P3 | T |
| FR-EMJ-10 | Deleting a pack or emoji shall not mutate historical messages. Unresolvable shortcodes shall be marked as such in the resolution map, and clients shall fall back to rendering the literal text. | P3 | T |
| FR-EMJ-11 | The system shall record aggregate emoji usage (shortcode or code point, count, per tenant per day) as analytical events. Only the emoji identifier shall be recorded, never surrounding message text, preserving FR-ANL-11's content prohibition. | P4 | T |
| FR-EMJ-12 | The dashboard shall provide pack management (create, upload emoji list, reorder, delete) and a top-emoji usage view. | P4 | D |
| FR-EMJ-13 | The SDK shall provide shortcode parsing/segmentation of message text, a resolution-map cache, and a data source suitable for building an emoji picker (installed packs + search). Consistent with FR-SDK-03, the SDK shall ship no picker UI. | P4 | T |
Design notes. (1) Relay hosts no emoji images in v1 — packs reference customer-hosted URLs, consistent with FR-MSG-11 and the file-storage exclusion. Image availability and CDN caching are the customer's responsibility and shall be documented. (2) FR-EMJ-09 is a deliberate stance: making installation a permission would put an authorisation check on the message hot path and create a support-facing failure mode ("my emoji stopped working") for no security benefit — shortcodes are environment-public by construction. (3) FR-EMJ-10 exists for Priya: a dispute record must render as it was written, even if the pack was deleted since (journey 3, stage 3).
4.14 Hosted media — FR-MED
Relay hosts media attachments: image, audio, and video. The design principle, defended in SAD ADR-13: bytes never transit Relay's compute — clients upload directly to object storage via presigned URLs and download via signed URLs; Relay's services handle only metadata, authorisation, and the scan pipeline.
Upload
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| FR-MED-01 | The system shall issue an upload slot on request (user token or API key), given a declared filename, MIME type, and byte size. The slot shall contain a presigned URL valid for 15 minutes and a media_id in state pending. | P3 | T |
| FR-MED-02 | Upload slots shall be refused when: the MIME type is outside the allowed set (image/jpeg, png, gif, webp; audio/mpeg, mp4, ogg, wav; video/mp4, webm); the declared size exceeds the per-kind cap (image 10 MB, audio 25 MB, video 100 MB); or the environment's storage quota would be exceeded. Each refusal shall use a distinct error code. | P3 | T |
| FR-MED-03 | The system shall verify actual object size and content type after upload; objects that contradict their declaration shall be rejected and deleted. | P3 | T |
| FR-MED-04 | Every uploaded object shall be virus-scanned and content-probed (dimensions for images; duration for audio/video) asynchronously before transitioning pending → ready. Objects failing the scan shall transition to rejected and be deleted, retaining only the audit record. PARTLY MET AS OF CHAPTER 4.13, BY DECISION AND NOT BY OVERSIGHT — the scan, the declaration check and the image dimensions ship; duration does not. A PNG's dimensions are 24 bytes of a 4,722-byte file and the other three image formats are fixed offsets or a short segment walk, where duration is four unrelated container parsers with MP3 variable bitrate as a genuinely hard case: a VBR file's length is not in its header. What a client gives up is a scrubber length before playback. (Revision 1.20.) | P3 | T |
| FR-MED-05 | The system shall generate a thumbnail for images and a poster frame for videos during processing, stored as derived objects sharing the parent's lifecycle. THE IMAGE HALF IS MET AT CHAPTER 4.15. THE VIDEO HALF IS UNMET BY DECISION, NOT BY OVERSIGHT — a poster frame needs a video DECODER where a thumbnail needs an image one, and the tool that does it measured 113,994,336 B added to the worker's image against the image half's 30,380,799 B: 3.75x, for the harder half of a clause whose easier half this specification already declined. FR-MED-04 is PARTLY MET because duration needs four container parsers and MP3 VBR has no header answer; a frame is strictly harder than a duration, so it is recorded on the same precedent (revisions 1.18 and 1.20). What a client gives up is a preview image for video; it knows the attachment is a video from its mime type and shows its own placeholder, which is what it does today for every attachment. Reversal condition: when video attachments are a measured fraction of stored objects rather than a hypothesis, or when this platform already carries ffmpeg for another clause, the 114 MB buys two things instead of one and the decision is re-opened (ADR-34). AND THE LAST FIVE WORDS WERE THE HARD PART. "Sharing the parent's lifecycle" reads like a storage note and is the only part of the clause that needed a migration: a rendition is named by no message, so FR-MED-08 refuses to serve it — its own rule for an object with no referencing message — and FR-MED-10 would reap it after 24 hours. Both are correct. What the clause adds is that a rendition's reachability IS its parent's, which 0020 gives to the database as a composite foreign key with ON DELETE CASCADE rather than to a predicate somebody maintains. The bound is measured, not chosen: at or below 320 px on the long edge a thumbnail is 97.3% of the parent and the same pixels, so an image already inside the bound gets none. (Revision 1.22.) | P4 | T |
Attachment and delivery
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| FR-MED-06 | A message may attach a media_id in state pending or ready, provided the media object belongs to the same environment. For a user token the object must additionally have been uploaded by the sending user, or by the tenant itself — an object whose uploader is unrecorded was taken by an application credential acting for the environment, and a user of that environment may attach it. For an application credential every object of the environment is attachable. Attaching another tenant's or another user's media shall fail, and all three refusals — another tenant's object, another user's, and an id no object has — shall be indistinguishable to the caller, because a refusal that named the cause would report whether somebody else's object exists. (Revision 1.18: the uploader clause said only "was uploaded by the sending user", which withheld permission for an unrecorded uploader and mandated refusal for neither case — leaving the commonest server-side shape undefined.) | P3 | T |
| FR-MED-07 | Real-time and history delivery shall include each attachment's state. UNMET AS OF CHAPTER 4.11, BY DECISION AND NOT BY OVERSIGHT — the clause cost nothing while no attachment had a state, and that chapter delivers the first ones that do while asserting the state is absent. Movement VI's verification chapter is where it is met; until then no attachment carries a state on any door, and nothing in the schema can hold one but pending. (Revision 1.18.) AND CHAPTER 4.13 MAKES IT REACHABLE WITHOUT MEETING IT: migration 0018 widens the constraint to three states and the media worker produces the other two, so an attachment now HAS a state that a delivery could carry — and this clause asks for a media.updated EVENT, which is a producer no chapter has written. It moves from vacuous to unmet with a reachable subject, which is a different sentence and a smaller gap. (Revision 1.20.) A media.updated event shall be delivered on the referencing channels when a pending attachment becomes ready or rejected, so clients can replace placeholders without polling. MET AT CHAPTER 4.14, AND THE FIRST SENTENCE WAS UNMET TOO — "real-time and history delivery" names two doors and the derivation finds more, including the send response, the edit response and resume, each of which leaves a caller holding a placeholder. The doors are whatever the derivation finds and this clause deliberately does not list them; what it does require is that the state be read when the message is served rather than stored on it, because the alternative gives one fact two homes and turns every verdict into a write across every referencing message. The state is carried by messageSchema, the outbound contract, on a shape distinct from the one a sender writes to: a sender must not declare a state and a delivered attachment must always carry one, and one schema was serving both. The event is an optimisation over that floor and must not change any answer — FR-MED-06 permits attaching an object that is already terminal, for which no transition remains to announce, so a client that never receives a frame must still report the right state. (Revision 1.21.) | P3 | T |
| FR-MED-08 | Media objects shall be readable only via signed delivery URLs with a validity of 1 hour, issued only to callers authorised to read a referencing message. Authorised to read is the platform's own channel-visibility rule, not membership: a user token may read any message of a public channel of its environment and a private channel's messages only as a member, and an application credential may read any message of its environment. Where an object is referenced by more than one message, the caller is authorised if any referencing message sits in a channel it may read. An object with no referencing message is readable by nobody, including the credential that uploaded it. Object storage shall not be publicly readable. | P3 | T |
| FR-MED-09 | A rejected attachment shall render as an explicit rejection marker in history — never as a broken link — preserving the record that something was sent (Priya's reconstruction must distinguish "rejected upload" from "deleted message"). CHAPTER 4.17 SPLIT THIS INTO A HALF THAT IS MET AND A HALF THAT CANNOT BE, AND THE SECOND IS STRUCTURAL RATHER THAN AN OVERSIGHT. The data half is MET and measured from outside the platform: a message whose only attachment the deployed worker refused is still returned by history — checked as a premise before it was asserted, because a route that filtered it would have made the clause unbuildable rather than unmet — and carries {"type":"media","media_id":…,"state":"rejected"}. Priya's distinction holds, in two fields rather than one, which is the part a client author needs: a refused upload keeps the sender's text and carries an attachment whose state says so; a message with nothing attached keeps its text and an empty array; a deleted message has text: null and an empty array (FR-MSG-09's tombstone). So a client reading only attachments cannot tell a deletion from a plain message, and one reading only text cannot tell a refusal from a delivery. And the object itself is unreachable: GET /v1/media/{id} for a rejected object is byte-identical to the same call for an id no object has, apart from request_id — FR-MED-08's indistinguishability, checked for the first time with a real refusal on one side rather than a state set by a fixture. The rendering half is UNMET AND UNREACHABLE FROM THIS REPOSITORY: "renders as" is a claim about a client, there is no client here, and FR-MED-14's reference client is P4 and unbuilt. This is FR-MED-07's treatment at chapter 4.11 and FR-MED-12's at 4.16 — a clause recorded unmet by structure rather than claimed on the strength of the data being available. What the platform owes a renderer is published, and the renderer is somebody else's chapter. (Revision 1.24.) | P3 | T |
Lifecycle, quota, and compliance
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| FR-MED-10 | Deleting a message (tombstone) shall unlink its attachments; unreferenced media objects shall be hard-deleted by a scheduled job after 24 hours. Compliance erasure (FR-MOD-04) shall delete a user's media objects and derived objects within the same 30-day bound. THREE OBLIGATIONS, AND CHAPTER 4.21 MOVED EXACTLY ONE (feature 067). The unlink was already met before that chapter opened — deleteMessage writes attachments: [] — and saying so is most of what the premise check was for. The 24-hour reaper is still unbuilt: unreferencedMediaIn has had no caller since chapter 4.15, chapter 4.20 declined it deliberately because its population is objects nothing ever attached, and ADR-28's absence bounds it. The erasure half is chapter 4.21's and is demonstrated, renditions going by media_objects_parent_fk's cascade. AND A USER'S MEDIA OBJECTS IS UNDEFINED FOR MOST OF THEM. media_objects.user_id is nullable and 11,173 of 15,189 objects — 73.6% — record no uploader, so an erasure that takes the attributed ones is correct and cannot be complete. That is not a defect in the traversal: a server-side upload has no user to record, which FR-MED-06 made the column nullable for. The receipt names the figure in its own note, because the person who needs it reads the receipt and not this clause. (Revision 1.28.) | P3 | T |
| FR-MED-11 | Environment retention policy (FR-MOD-06) shall apply to media: expired messages delete their objects with them, unless a message that has not expired still references the object. THE CLAUSE HAS NO REFERENTIAL INTEGRITY BEHIND IT AND CANNOT BE DELEGATED TO THE DATABASE (feature 066): media_objects has no foreign key to messages — the link is a media_id inside a jsonb array, which is this clause's own shape — so unless shared is a reverse lookup the application performs on every sweep. Measured: the operand must be a BOUND value or the planner cannot use messages_attachments_gin at all, 107 buffers against 3,423 and 127.9 ms with the index idle under a parallel sequential scan; batched a hundred at a time it is a BitmapOr at about 9.8 buffers an object. Renditions go by media_objects_parent_fk's cascade, which chapter 4.15 chose so a rendition's reachability is its parent's. This clause's population is not FR-MED-10's, and the distinction is load-bearing: that clause reaps objects nothing ever attached, 48 of them in one environment on the development lane, and a retention sweep must not destroy them. | P3 | T |
| FR-MED-12 | The system shall meter stored media bytes per tenant per day and count uploads by kind, enforced as FR-RTL-05's storage quota — against the current sum, not against a monthly total — and visible in the dashboard alongside other usage (FR-DSH-04/05). The daily figure is a time series for the dashboard; the quota reads the level. AND DERIVED BYTES COUNT, WHICH CHAPTER 4.15 SETTLED AND 4.16 INHERITS. reserveMediaSlot sums declared_bytes over every non-rejected row of the environment, so a rendition is charged from the moment its row exists — deliberately, because the alternative is a storage discount with no clause behind it, which is the argument revision 1.17 already made about tolerance in FR-MED-03. Two consequences are stated rather than discovered. A rendition can carry a tenant past the quota with no request to refuse: it is written by the platform after a verdict, not asked for by a caller, so there is no slot request at that moment to answer with storage_quota_exceeded. The overshoot is bounded by the rendition's own size — single-digit kilobytes, against a cap in gigabytes — and the next slot request is refused normally. And the sum is approximate by one rendition: reserveMediaSlot locks the environment row FOR UPDATE before summing and the verdict transaction takes no such lock, so a slot request concurrent with a rendition insert sums without it. No second lock is added; chapter 4.10 measured that the existing one earns its place in the slot-versus-slot race, which this does not change, and a lock here would make every verdict wait on every slot request. AND CHAPTER 4.16 BUILT IT, WHICH SPLIT THE CLAUSE INTO THREE ANSWERS. Metered per tenant per day: MET — media_events carries a signed bytes_delta per cause and mv_billing_storage accumulates it into daily_usage_billing.stored_bytes_delta, read by uploadsByKind and storedBytes. Count uploads by kind: MET, and the clause's own ambiguity settled once — a rendition is not an upload, because nobody uploaded it, while its bytes are in the level; sumMapIf(map(kind, 1), event = 'reserved') is where that lives and every reader is asserted against it. Visible in the dashboard alongside other usage (FR-DSH-04/05): UNMET, and not by this chapter's decision — there is no dashboard in this platform and both cited clauses are verified by demonstration and unbuilt. The figure is readable without one, which is the part this chapter could discharge. AND THE DAILY FIGURE IS BOUNDED BY THE ROLLUP'S RETENTION HORIZON, which no clause had said: daily_usage_billing carries TTL toDateTime(day) + toIntervalMonth(25) (DR-09, applied at chapter 4.6), and a level accumulated from deltas is understated by exactly the deltas the TTL removed — permanently, with nothing in the system able to notice. So FR-MED-12's figure means the bytes stored, as recorded in the last 25 months, and DR-17's inventory is what re-bases it: the object store holds the level directly rather than as a sum, so a reconciliation can restate a truncated figure where no amount of re-reading the rollup can. stored_delta on the same table is a different quantity — stored MESSAGES — and the two sit side by side; see docs/05-sad.md §4. (Revision 1.23.) | P3 | T |
| FR-MED-13 | Media upload/processing events (media.uploaded, media.ready, media.rejected) shall be available as webhook event types (extending FR-WHK-02). | P4 | T |
| FR-MED-14 | The SDK shall provide upload helpers (slot request, direct PUT, progress, retry) and attachment-state handling; the reference client shall demonstrate image and audio send/receive. | P4 | T |
Design notes. (1) FR-MED-06 permits attaching
pendingmedia so a photo can be sent the moment upload completes, with recipients seeing a placeholder until the scan clears — matching user expectations set by consumer messengers. The scan is a delivery gate for bytes, never for the message. (2) FR-MED-08 makes authorisation follow the message, not the file: media access control inherits channel visibility rather than inventing a parallel ACL system. Inherits is load-bearing and was measured against the platform at chapter 4.12: reading the clause's earlier wording literally, as a membership check, would have refused a user the photo in a message whose text they can read — 11,557 public channels against 1,016 private on the development lane, so the common case is a channel where membership is not the rule. The clause is satisfied by construction only if no second predicate is written, which is why the delivery route calls the samechannelVisibleToevery read route calls. The same reasoning settles the unreferenced object: granting its uploader access would be a second authorisation rule that does not follow a message, and FR-MED-10 hard-deletes such objects after 24 hours, so it would be a read path to a thing already scheduled for destruction. (3) This section reverses Appendix B's original exclusion; the reversal rationale is recorded there.
5. Non-functional requirements
5.1 Performance — NFR-PRF
| ID | Requirement | Target | Ver |
|---|---|---|---|
| NFR-PRF-01 | End-to-end delivery latency: send acknowledged to recipient receipt, same region | p50 < 100 ms · p95 < 250 ms · p99 < 500 ms | A |
| NFR-PRF-02 | REST write latency, excluding network | p95 < 150 ms | A |
| NFR-PRF-03 | History read latency, 50 messages | p95 < 100 ms | A |
| NFR-PRF-04 | WebSocket handshake to connection.ack | p95 < 1 s | A |
| NFR-PRF-05 | Reconnection with backfill of 100 messages | p95 < 2 s | A |
| NFR-PRF-06 | Analytical query over 90 days, single tenant | p95 < 2 s | A |
| NFR-PRF-07 | Dashboard initial paint | p95 < 2 s | A |
| NFR-PRF-08 | Upload-slot issuance and signed-URL generation (metadata only — bytes go direct to storage) | p95 < 100 ms | A |
| NFR-PRF-09 | Scan-pipeline latency, upload complete → ready, images ≤ 10 MB | p95 < 10 s | A |
5.2 Scalability — NFR-SCL
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| NFR-SCL-01 | The system shall sustain 10,000 concurrent WebSocket connections per gateway instance. | P1 | A |
| NFR-SCL-02 | Gateway instances shall be addable and removable without connection loss on surviving instances and without configuration change. | P1 | A |
| NFR-SCL-03 | The system shall sustain 1,000 messages per second aggregate at the stated latency targets. | P2 | A |
| NFR-SCL-04 | The system shall support 10,000 tenants without degradation of per-tenant performance. | P3 | A |
| NFR-SCL-05 | The analytical store shall sustain 10,000 event inserts per second. | P3 | A |
| NFR-SCL-06 | No component shall hold state preventing horizontal scaling; connection-to-instance mapping shall be discoverable through the pub/sub fabric. | P1 | I |
5.3 Reliability and availability — NFR-REL
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| NFR-REL-01 | Messaging API availability shall be at least 99.9% monthly, measured externally. | P2 | A |
| NFR-REL-02 | No acknowledged message shall be lost, including during instance failure, deployment, or datastore failover. | P1 | A |
| NFR-REL-03 | Deployments shall cause no message loss and no more than a single client reconnection cycle. | P2 | A |
| NFR-REL-04 | Loss of a gateway instance shall disconnect only its own clients, which shall reconnect and backfill automatically. | P1 | A |
| NFR-REL-05 | Loss of the analytical store shall not affect messaging; events shall accumulate in the queue and drain on recovery. | P3 | A |
| NFR-REL-06 | Operational data shall be backed up daily with point-in-time recovery to any moment in the preceding 7 days. | P2 | D |
| NFR-REL-07 | Recovery time objective 1 hour; recovery point objective 5 minutes. | P3 | D |
| NFR-REL-08 | The queue shall retain events for at least 24 hours to absorb consumer outages. | P3 | I |
5.4 Security — NFR-SEC
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| NFR-SEC-01 | All external traffic shall use TLS 1.2 or higher. Plaintext HTTP shall redirect to HTTPS and never serve data. | P1 | T |
| NFR-SEC-02 | API key secrets and webhook signing secrets shall be stored only as salted hashes or under envelope encryption. | P2 | I |
| NFR-SEC-03 | Data shall be encrypted at rest in both operational and analytical stores. | P2 | I |
| NFR-SEC-04 | All input shall be validated against a schema before processing; unknown fields shall be rejected on write endpoints. | P1 | T |
| NFR-SEC-05 | Message content shall be stored and returned as received, without HTML interpretation; escaping is the client's responsibility and shall be documented. | P1 | T |
| NFR-SEC-06 | Secrets, tokens, and message content shall never appear in application logs. | P1 | I, T |
| NFR-SEC-07 | The system shall pass an automated OWASP Top 10 scan with no high or critical findings before production release. | P2 | T |
| NFR-SEC-08 | Dependencies shall be scanned on every build; critical vulnerabilities shall block release. | P2 | T |
| NFR-SEC-09 | Cross-tenant access shall be verified by an automated test suite covering every endpoint, executed on every build. | P1 | T |
| NFR-SEC-10 | Administrative access to production data shall require multi-factor authentication and shall be logged to an immutable audit trail. | P3 | I |
5.5 Observability — NFR-OBS
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| NFR-OBS-01 | All services shall emit structured JSON logs including request ID, tenant ID, and correlation ID. | P2 | I |
| NFR-OBS-02 | Distributed tracing shall be implemented with OpenTelemetry, spanning ingress through datastore. | P2 | D |
| NFR-OBS-03 | The system shall expose Prometheus-compatible metrics for request rate, error rate, latency distribution, connection count, and queue depth. | P2 | D |
| NFR-OBS-04 | Alerts shall fire on error rate above 1%, p95 latency above target for 5 minutes, queue depth growth over 10 minutes, and reconciliation drift. | P3 | D |
| NFR-OBS-05 | A public status page shall report component health and incident history. | P3 | D |
| NFR-OBS-06 | Any customer-reported issue shall be traceable from a request ID to complete logs and traces within 5 minutes. | P3 | D |
5.6 Maintainability and portability — NFR-MNT
| ID | Requirement | Pri | Ver |
|---|---|---|---|
| NFR-MNT-01 | Each service shall be independently deployable without coordinated release of others. | P2 | D |
| NFR-MNT-02 | Automated test coverage of business logic shall be at least 70%; message ordering, idempotency, and tenant isolation shall be at 100% branch coverage. | P2 | T |
| NFR-MNT-03 | The full stack shall be startable locally with a single command. | P1 | D |
| NFR-MNT-04 | Database migrations shall be versioned, forward-only, and executable without downtime. | P2 | I |
| NFR-MNT-05 | The public API shall follow semantic versioning; breaking changes require a new path version and 6 months of parallel support. | P4 | I |
| NFR-MNT-06 | The system shall deploy to any Kubernetes-compatible environment with no dependency on a single cloud provider's proprietary services. | P3 | I |
5.7 Developer experience — NFR-USE
These are product requirements expressed as measurable outcomes; they are the acceptance criteria for journey Stages 2–4.
| ID | Requirement | Target | Ver |
|---|---|---|---|
| NFR-USE-01 | Median elapsed time from account creation to first message delivered between two clients | < 10 minutes | A |
| NFR-USE-02 | Proportion of signups reaching a first delivered message | > 60% | A |
| NFR-USE-03 | The quickstart shall run without modification, verified by automated execution in CI against the published documentation | 100% pass | T |
| NFR-USE-04 | Complete API reference shall be publicly accessible without authentication | — | I |
| NFR-USE-05 | Every error code shall have a documentation page reachable from the docs_url in the error response | 100% coverage | T — docs/08-error-reference.md, one h2 per code; the test is relay-tutorial/scripts/check-error-codes.mjs, which compares ERROR_CODES against the reference's headings in both directions so an undocumented code and a documented non-code both fail. It runs as a CI step rather than a turbo task: docs/ is above $TURBO_ROOT$ and cannot be a turbo input, so a cached task would pass after the reference changed |
| NFR-USE-06 | Documentation shall specify reconnection, ordering, idempotency, and rate limit behaviour explicitly | — | I |
6. Data requirements
6.1 Operational entities (PostgreSQL)
erDiagram
Organisation ||--o{ Application : "1:N"
Application ||--|{ Environment : "1:2 (dev/prod)"
Environment ||--o{ ApiKey : "1:N"
Environment ||--o{ User : "1:N"
Environment ||--o{ MediaObject : "1:N"
Environment ||--o{ Channel : "1:N"
Environment ||--o{ EmojiPack : "1:N"
Environment ||--o{ WebhookEndpoint : "1:N"
Environment ||--o{ AuditLogEntry : "1:N"
User }o--o{ EmojiPack : "installs (N:M)"
MediaObject ||--o{ DerivedObject : "1:N"
Channel ||--o{ Member : "1:N"
Member }o--|| User : "N:1"
Channel ||--o{ Message : "1:N"
Message ||--o{ MessageEdit : "1:N"
EmojiPack ||--o{ Emoji : "1:N"
WebhookEndpoint ||--o{ WebhookDelivery : "1:N"
Key attributes
| Entity | Notable fields |
|---|---|
Environment | id, application_id, kind (dev/prod), signing_secret, retention_days, quota_config |
User | environment_id, external_id, display_name, avatar_url, metadata, banned_at |
Channel | environment_id, external_id, type, name, metadata, last_sequence, archived_at |
Member | channel_id, user_id, role, last_read_sequence, joined_at |
Message | channel_id, sequence, user_id, text, metadata, attachments, idempotency_key, created_at, edited_at, deleted_at |
EmojiPack | environment_id, external_id, name, description, cover_url, visibility, emoji_version |
Emoji | pack_id, shortcode, image_url, tags, deleted_at |
UserEmojiPack | user_id, pack_id, installed_at |
MediaObject | environment_id, uploader_user_id, kind, mime, declared_bytes, actual_bytes, status (pending/ready/rejected), object_key, probe (dims/duration), created_at, deleted_at |
Integrity requirements
| ID | Requirement |
|---|---|
| DR-01 | (channel_id, sequence) shall be unique and shall constitute the primary ordering key. |
| DR-02 | (environment_id, external_id) shall be unique for both User and Channel. |
| DR-03 | (channel_id, idempotency_key) shall be unique where the key is present, enforced by a partial index. |
| DR-04 | Sequence assignment shall be atomic under concurrent sends to the same channel. |
| DR-05 | Every table shall carry environment_id, directly or through a single foreign key hop. |
| DR-06 | Deleted messages shall retain their row; only text and attachments shall be cleared — until the row expires under FR-MOD-06's retention policy, which destroys it (feature 066, chapter 4.20). AMENDED BECAUSE A TOMBSTONE IS A MESSAGE AND EXPIRES LIKE ONE: nothing in the sweep's predicate distinguishes them, which is the answer to does a deleted message ever expire being yes. This is the most specific of the three clauses that reserved hard deletion and the one a reader checking whether a tombstone survives would find first, so leaving it unamended would have made the sweep look like a defect. The retention of the row is still the rule on every other path; expiry is the exception and it is bounded by a policy the customer sets. |
| DR-12 | Shortcodes shall be unique per environment across packs, enforced by a unique index on active emoji, satisfying FR-EMJ-04. Deleted emoji release their shortcode. |
| DR-13 | EmojiPack.emoji_version shall increment monotonically on any mutation of the pack or its emoji, providing a cache-invalidation token for resolution maps (see SAD ADR-12). |
| DR-15 | Object keys shall be {environment_id}/{media_id} — tenant-prefixed so bucket-level lifecycle rules and tenant export/erasure operate on prefixes, and unguessable because media_id is a UUID. |
| DR-16 | messages.attachments entries referencing hosted media shall store media_id only, never object keys or URLs — signed URLs are minted at read time and must never be persisted. |
6.2 Analytical events (ClickHouse)
| Table | Purpose | Key dimensions |
|---|---|---|
message_events | Metering, volume analytics | environment_id, channel_id, user_id, ts, text_length, attachment_count, delivery_latency_ms |
connection_events | Connection-minutes, presence analytics | environment_id, user_id, ts, event (open/close), duration_ms, close_code |
api_requests | Request log, error analytics | environment_id, ts, request_id, endpoint, method, status, latency_ms |
webhook_deliveries | Delivery reliability | environment_id, endpoint_id, event_type, ts, attempt, status, latency_ms |
emoji_events | Emoji usage analytics (FR-EMJ-11) | environment_id, ts, kind (unicode/custom), identifier (code point or shortcode), pack_id |
media_events | Storage metering (FR-MED-12, DR-17) | environment_id, media_id, ts, event (reserved/rejected/rendition/deleted), kind, bytes_delta |
| ID | Requirement |
|---|---|
| DR-07 | All tables shall be partitioned by month and ordered by (environment_id, ts) to make tenant-scoped time-range queries efficient. |
| DR-08 | No table shall contain message text. emoji_events.identifier carries only the emoji identifier, per FR-EMJ-11. |
| DR-09 | Raw events shall be retained for 90 days; daily aggregates for 25 months. The second half went unimplemented for three features and was filed as absent twice — gaps.md 047-3 and 048-4 both read "no clause says how long metering history is kept" while quoting DR-10, one row below this one. Chapter 4.6 applied it: TTL toDateTime(day) + INTERVAL 25 MONTH on both rollups. It does not contradict chapter 4.2's decision that metering must not lose history when raw events do (specs/047-chapter-4-2/spec.md FR-003a — a feature-local id, not a clause in this document, and cited here as one until v1.14) — 25 months against 90 days outlives the source roughly eightfold, and "not the raw table's TTL" was all that decision ever asked for. |
| DR-10 | Materialised views shall maintain daily per-tenant rollups for metering, so billing never scans raw events. A rollup satisfies this clause only if it is both read and reachable from the data: chapter 4.6 measured a conforming view over a source with no producer, read by nothing, and a second whose key carried an attribution dimension and made the billing read touch more rows than the raw table it replaced (147,534 rows against 281; 32,778 read against 32,768). Attribution and billing take separate rollups. And a materialised view is a trigger on future inserts, so any deployment applying these statements to a store that already holds data needs a backfill — which recovers only what the raw retention has not yet removed. |
| DR-11 | Inserts shall be batched or use asynchronous insert mode; single-row synchronous inserts are prohibited. The ingester's bounds are 2 seconds or 10,000 rows, whichever comes first, and it takes BOTH because each fails alone: a row count never flushes for a quiet tenant, and an interval has no ceiling under load. Measured: the two cross at 5,000 records/second. Added in revision 1.12 — docs/05-sad.md §4 had carried those two numbers since its first draft and attributed them here, and this clause named neither. |
| DR-14 | emoji_events shall follow the same partitioning, ordering, and 90-day raw retention as other event tables, with a daily per-tenant top-emoji rollup maintained by materialised view. |
| DR-17 | Stored-bytes-per-tenant shall be maintained as a daily rollup summing media_events deltas, reconciled weekly against an object-storage inventory listing — the media analogue of FR-ANL-06. THE PARENTHETICAL SAID (uploaded/deleted) AND NEITHER WORD IS AN EVENT THIS PLATFORM EMITS. Chapter 4.16 built the four that exist — reserved, rejected, rendition, deleted — and the difference is not vocabulary: the meter charges at RESERVATION, not at upload, because FR-MED-01 reserves the slot against the quota the moment it is issued and reserveMediaSlot has summed declared_bytes since chapter 4.10. A clause that metered uploads would disagree with the quota it is supposed to explain. The rollup: MET. Weekly: UNMET by decision, on ADR-28's precedent — this platform has no scheduler of any kind, no schedule: trigger and no job runner, exactly as recorded for FR-ANL-06's "daily" at chapter 4.9; scripts/reconcile-storage.mjs is invokable and nothing invokes it, and its exit code is the whole of what "raises an alert" can currently mean. AND THE COMPARISON HAS A BLIND SPOT THAT WILL CLOSE ON ITS OWN. deleted has no producer: no code path removes a media_objects row (chapter 4.15 — the rejection path subtracts the bytes and keeps the row on purpose), so the metered level never falls for a deletion and the failure a delta-summed level is most exposed to — a lost deleted record leaving the meter permanently over — cannot arise. A run of green verdicts is evidence about three causes and not four, until the erasure chapter ships a reaper. AND THE TWO SIDES MEASURE DIFFERENT THINGS, WHICH MEASUREMENT FOUND AND NO READING WOULD HAVE: 99.9% of what the quota charges on the development lane is slots reserved and never uploaded to, so the comparison carries a reservations-only verdict — the difference exists and is entirely accounted for — beside agreement and the two directions of drift. (Revision 1.23.) |
7. Traceability
7.1 Requirements by persona
| Persona | Primary requirements |
|---|---|
| Mai — integrating developer | EIR-API-04/06 · FR-AUT-03/09 · FR-DSH-01/02/03 · FR-SDK-01→10 · FR-EMJ-08/13 · NFR-USE-01→06 |
| David — engineering lead | FR-RTL-05/06/07 · FR-ANL-05/06/09 · FR-MOD-05 · NFR-REL-01 · NFR-SEC-* · NFR-OBS-05 |
| Priya — support and operations | FR-MSG-07/08/10 · FR-MOD-01→04 · FR-USR-06 · FR-EMJ-10 · FR-MED-04/09 |
| Tuan — end user | FR-MSG-02/03/04/06 · FR-RTM-03/04 · FR-SDK-04/05/06/07 · FR-EMJ-01/06/07 · FR-MED-01/07/14 · NFR-PRF-01 · NFR-REL-02/04 |
7.2 Requirements by journey stage
| Stage | Requirements addressing the stage's principal pain points |
|---|---|
| 2 — Evaluate | NFR-USE-04/06 · EIR-WS-07 · FR-DSH-08 |
| 3 — Sign up | FR-TEN-01/02 · FR-DSH-01 |
| 4 — First message ★ | FR-AUT-03/09 · FR-DSH-02 · EIR-API-04 · NFR-USE-01/02/03 |
| 5 — Build | FR-DSH-03 · FR-SDK-02 · FR-WHK-09 · EIR-API-05 |
| 6 — Test | FR-TEN-04 · FR-RTL-02/04 · EIR-WS-06 |
| 7 — Launch | FR-AUT-01 · FR-DSH-02/04 · NFR-OBS-04 |
| 8 — Operate | FR-ANL-07/09/12 · FR-RTL-06/07 · FR-DSH-05 · NFR-MNT-05 |
7.3 Phase summary
| Phase | Requirement groups | Exit criterion |
|---|---|---|
| 1 — Core loop | FR-USR, FR-CHN, FR-MSG, FR-RTM, FR-EMJ-01/02 (P1) | Two clients exchange messages through the public API, surviving a forced disconnect with correct ordering and no duplicates |
| 2 — Platform | FR-TEN, FR-AUT, FR-WHK, FR-RTL (P2) | An external developer integrates using only public documentation, with no assistance |
| 3 — Analytics & media | FR-ANL, FR-MOD, FR-DSH, FR-EMJ-03→10, FR-MED-01→12 (P3) | Metered usage reconciles against operational counts within 0.1% for 7 consecutive days; an image survives upload → scan → send → signed delivery → erasure |
| 4 — Experience | FR-SDK, remaining FR-DSH, FR-EMJ-11→13, FR-MED-13/14, NFR-USE (P4) | Median time to first message under 10 minutes across 10 observed first-time integrations |
8. Appendices
Appendix A — Requirement count
| Group | P1 | P2 | P3 | P4 | P5 | Total |
|---|---|---|---|---|---|---|
| External interfaces | 10 | 7 | 0 | 2 | 0 | 19 |
| Functional | 29 | 42 | 43 | 19 | 2 | 135 |
| Non-functional | 12 | 20 | 17 | 4 | 0 | 53 |
| Data | 6 | 5 | 6 | 0 | 0 | 17 |
| Total | 57 | 74 | 66 | 25 | 2 | 224 |
Phase 1 delivers 57 requirements. That is the realistic first milestone; everything beyond it is sequenced, not simultaneous. Note that only two emoji requirements (Unicode correctness) sit in Phase 1 — the pack system is deliberately deferred to Phase 3, where its browse/install surface and usage analytics arrive together. Hosted media (§4.14) also lands in Phase 3: it depends on tenancy and quotas (Phase 2) and shares Phase 3's queue and metering machinery, and it has visibly grown that phase — Phase 3 is now the second largest and should be watched for schedule risk.
Appendix B — Deliberately excluded, with reasons
| Excluded | Reason |
|---|---|
| End-to-end encryption | Incompatible with FR-MOD-01/02; contradicts the target market's moderation needs |
| Message search | Requires a third datastore; the operational store cannot serve it and the analytical store holds no text (DR-08) |
| Threads and reactions | Multiplies the data model and client state for a feature the wedge market has not requested |
| Native mobile SDKs | One well-made JavaScript SDK usable from React Native satisfies ASM and reaches the target market |
| Voice and video calls | Different transport, different economics, different competitive set. Media files are in scope as of v1.2 — see below |
| Original reason: storage cost, virus scanning, and CDN concerns disproportionate to v1. Reversal rationale: presigned direct-to-storage upload keeps bytes off Relay compute (answering the infrastructure cost); scanning is an async queue consumer on existing machinery; storage is metered and billed per tenant from day one (answering the cost-growth risk); and media proved to be a wedge-market requirement, not a luxury (a driver's photo of a damaged parcel is the message). See §4.14 and SAD ADR-13/14. |
Appendix C — Open questions
| # | Question | Blocks | Owner |
|---|---|---|---|
| 1 | Sequence numbers per channel or globally per tenant? Per channel is simpler; per tenant simplifies cross-channel resume. | FR-MSG-02, FR-RTM-03 | Architecture |
| 2 | Is 1,000 members per channel adequate for the marketplace segment? | FR-CHN-07 | Product |
Should presence be opt-in per channel to reduce fan-out at scale? No — not opt-in. Presence publishes on every channel the subject belongs to; there is no per-channel presence flag. ADR-10 resolved this provisionally and ADR-19 confirms it. FR-RTM-07 is discharged; NFR-SCL-01 is not. Chapter 3.19 measured the fan-out cost at one SUBSCRIBE per channel per instance and could not measure it at scale: its lane's largest membership set is five channels, and NFR-SCL-01 asks about ten thousand concurrent connections per instance. ADR-10's revisit trigger — presence fan-out above ~30% of gateway publish volume — remains undischarged. | FR-RTM-07, NFR-SCL-01 | Architecture | |
Does connection-minute metering need per-second precision, or is per-minute rounding acceptable? Per-minute rounding, and the minute is a CALENDAR bucket rather than a rounded duration. A connection is charged for every calendar minute it was open for any part of: 00:00:59 to 00:01:01 is two seconds of wall clock and two connection-minutes. That is what meter.ts has billed since chapter 3.24 and what chapter 4.6's rollup computes, so the two agree by construction rather than by reconciliation. Measured over 55 real connections: 56 calendar minutes against 0.6513 elapsed minutes, a factor of 86, because the median connection lasted 252 ms and each one occupies a whole minute. The alternative — bill elapsed duration, which is literally what a connection record contains — was rejected on that number: a client reconnecting every quarter-second would consume a connection slot continuously and pay almost nothing, and the cost a connection imposes is a handshake, a slot claim and a Redis round trip rather than the seconds it then sits idle. What is given up is stated: the calendar bucket over-reports short connections against wall-clock intuition, and a customer comparing their own timings against an invoice will find the invoice larger. | FR-ANL-05 | Product / Billing | |
| 5 | Should the dev-mode token endpoint (FR-AUT-09) be rate limited more aggressively to prevent production misuse? | FR-AUT-09/12 | Security |
| 6 | Should emoji packs be shareable across applications within an organisation, or promoted to a cross-tenant catalog (marketplace model)? v1 assumes strict environment scope. | FR-EMJ-03/05 | Product |
Appendix D — Revision history
| Version | Date | Author | Change |
|---|---|---|---|
| 1.0 | 2026-07-26 | — | Initial draft |
| 1.1 | 2026-07-26 | — | Added §4.13 FR-EMJ (Unicode emoji + custom emoji packs); DR-12/13/14; traceability, phase, and count updates |
| 1.2 | 2026-07-30 | — | Reversed the hosted-file-storage exclusion. Added §4.14 FR-MED (hosted media: image/audio/video); revised FR-MSG-11; NFR-PRF-08/09; ASM-06; DR-15/16/17; Appendix B reversal record; counts to 224 |
| 1.3 | 2026-08-20 | — | EIR-API-04 brought to the implementation. The error body's five fields are top-level, not nested under an error key, and request_id is now required rather than shown only in the example. Found while specifying chapter 3.8, which adds request_id to that envelope: the platform has emitted the flat shape since the walking skeleton and the document had said otherwise since 1.0 |
| 1.4 | 2026-08-29 | — | Closed Appendix C open question 3 as not opt-in per channel, confirming ADR-10 and citing ADR-19. No clause changed: FR-RTM-05, FR-RTM-06, FR-RTM-07 and FR-CHN-05 already said what chapter 3.19 built. NFR-SCL-01 recorded as undischarged rather than answered |
| 1.5 | 2026-09-03 | — | FR-RTM-09 built (chapter 3.22). No clause changed, and the clause's two halves were in different states: the five-connection cap was enforced nowhere, and "each shall receive all events independently" has been true since delivery walked connections rather than users — chapter 3.22 gave that half tests rather than behaviour. Recorded as ADR-23, which supersedes the SAD's §6.3 conn:{env}:{user} row: five slot keys claimed with SET NX PX, not the sorted set that row prescribed. NFR-SCL-01 remains undischarged |
| 1.6 | 2026-09-03 | — | FR-MSG-07, FR-MSG-08 and FR-MSG-10 built (chapter 3.23). No clause changed. Three consequences worth recording: FR-RTM-05's six kinds all have producers now — message edit and message deletion had none anywhere in the platform, and a test reads session.ts as text to keep that true, because nothing else can see a producer. FR-WHK-02 goes from three of eight event types to five, message.updated and message.deleted spelled as that clause spells them. And FR-MSG-08's "deletion metadata" is read as more than deleted_at, because the clause itemises timestamps separately — the deletion's actor is recorded in messages.metadata as deleted_by, needing no migration. Recorded as ADR-24: message revisions take a fifth subject grammar, revision:{channel_id}, because a kind that cannot share a payload type cannot share a subject. message_edits is built to §6.1 as published — three columns and a composite key. Two new error codes, not_message_author and message_deleted, both argued against the generic 403 in codes.ts. The one soft edge is documented rather than closed: resume is ordered by the channel sequence alone, so a message older than a client's cursor that changed during a disconnect produces no frame and no sequence gap, and history is the repair — which is what Slack does |
| 1.7 | 2026-09-04 | — | FR-MSG-11's external-URL half built (chapter 3.24). No clause changed. The clause publishes two ways to attach and one is built: a {"type": "url", "kind", "url"} attachment is accepted, bounded at ten per message and 2,048 characters per URL, and refused unless its scheme is http or https. The media_id half is deferred to §4.14 and refused by name: a new error code, media_not_available, 422, because media_id is in the published contract and a caller sending one has made no mistake — invalid_request would send them to check their JSON. The shape is a discriminated union from its first version, so §4.14 replaces that arm's body rather than introducing a discriminator. Three consequences worth recording: a message may now carry no text when it carries an attachment, storing text = "" rather than a null so the tombstone predicate is untouched; the same URL twice is two attachments, because the platform does not compare them; and no bytes cross Relay compute, which is §4.14's premise and the reason the scheme rule is at the boundary. |
| 1.8 | 2026-09-06 | — | Two clauses amended to state what the platform does (feature 043). No behaviour changed and no clause was weakened: each amendment was written from the measured bound and then read back against it. FR-RTM-09 gains the word best-effort and the registry-unavailable case — connections.ts admits the connection and logs that five was not checked, because the registry is not a source of truth (principle IV), so the clause now says a user may exceed five while it is unreachable. FR-RTM-10 gains the degraded-mode bound ADR-20 had already derived and the SRS had never carried: under fabric loss revocation rides a 60-second per-connection re-read (DEFAULT_REREAD_INTERVAL_MS, asserted at membership.test.ts:16), which exceeds the five-second promise by 55. Both were findings in docs/09-platform-implementation-review-2026-09-03.md, which asked for an explicit amendment rather than a silent divergence — and the governance clause requires exactly that. The review's own quota and webhook-remedy rows were corrected in the same pass, both annotated in place rather than rewritten. |
| 1.9 | 2026-09-06 | — | NFR-SCL-01 discharged by measurement. The clause — 10,000 concurrent WebSocket connections per gateway instance — has been P1 and unverified since 1.0. Its verification method is A, analysis including load testing, and that is what was run: one gateway instance sustained 10,000 real client sockets for 90 seconds past two 30-second ping intervals with zero closures at 160 MB RSS, and 20,000 also held. No clause changed. Three things are worth recording beyond "met". The subscription cost is exactly 5 × channels + 1 × connected users, so which term dominates is a property of the customer's data rather than of the platform — 9% of subjects at 200 channels, 83% at one channel per user. DEFAULT_LIMITS.connect is 3,000 per minute against a gateway that accepts 1,125-1,675 per second, so filling an instance from cold is bounded by policy at 3m20s where capacity would take 7.7s — deliberate, and never before stated where an operator planning a rolling restart would find it. And the measurement supplies the threshold the implementation review asked for on subject-grammar consolidation. Full record: docs/11-scalability-measurement-2026-09-06.md. |
| 1.10 | 2026-09-06 | — | Three clauses amended to publish the remedy beside the limit (feature 044). Chapter 3.23 documented a soft edge — a message older than a client's cursor that changed during a disconnect produces no frame and no sequence gap — and recorded history as the repair. Nothing told a client when to perform it, so the limit was published and the remedy was not reachable from it. EIR-WS-03 now enumerates the ack's third field, a per-channel revision count reported for every channel of which the user is a member, including those at zero: a client with nothing to repair still needs the baseline it will compare against next time. FR-RTM-03 states the limit in the clause that describes what resume delivers, rather than only in this appendix, and names the count as what a client compares — and states the two negatives that make the signal safe: the platform performs no comparison and refuses no connection over a number a client supplied, so a fabricated count cannot become a denial of service the client controls. FR-RTM-05 says where an edit or a deletion reaches a client that was not connected, which is the count and not a replayed event. No behaviour was weakened and no clause was narrowed; the platform gained one column, channels.revision_sequence, raised by exactly one per revision inside the transaction that applies it, and not raised by a send — counting sends would make every active channel report a repair after every absence. The measurement behind SC-004 is docs/11-scalability-measurement-2026-09-06.md; the counts ride /internal/session, a call the gateway already makes, because at 10,000 connections a per-channel query per handshake is the cost this could not pay. The task that ordered this amendment named the wrong clauses: it said SRS FR-016a and FR-016b, which are chapter 3.23's specification ids and appear nowhere in this document — the SRS carried that limit in revision 1.6's narrative and in FR-RTM-03's silence. Reading the clauses rather than the identifiers found three to amend where the task expected two. |
| 1.11 | 2026-09-14 | — | FR-ANL-07 amended to drop "truncated payload" (feature 049, chapter 4.4). The clause asked the API request log to record a truncated payload, and two others forbid it: FR-ANL-11 keeps message content out of the analytical store, and constitution VI keeps it out of logs. POST /v1/channels/{id}/messages has a body whose whole content is message text, so a request log that truncates it stores message text — and truncation makes it worse rather than better, because it keeps the beginning, which is the part a person wrote. The alternative considered and rejected was an allow-list of endpoints whose bodies are safe: that is a hand-maintained table, it fails open because a new route is included by default, and this project has already paid for a hand-maintained table once in the nine api ports. What the clause loses is real and is named rather than glossed: a request log with no payload cannot answer "what exactly did they send", only "what did they call and what happened". The producer built for this clause records identifiers, statuses and durations, and its shaper names every field individually rather than spreading its input — an allow-list fails closed when somebody adds a field, a spread fails open. |
| 1.12 | 2026-09-15 | — | DR-11 amended to carry the bounds it was being credited with (feature 050, chapter 4.5). The clause read "Inserts shall be batched or use asynchronous insert mode; single-row synchronous inserts are prohibited" — no interval and no row count — while docs/05-sad.md §4 described the ingester as batching "every 2 s or 10k rows (DR-11)" and services/ingester/src/ingest.ts stated those figures as the clause's own. Two hops, and the code comment was the furthest-travelled copy. The numbers are now here, with 049's measurement of where the two bounds cross (5,000 records/second) as the reason it takes both. And this document's header had read Version: 1.3 since that revision while the table below ran to 1.11 — check-revision-order reads the table and never the header, so eight revisions passed with nothing to notice. Corrected in the same pass. |
| 1.13 | 2026-09-15 | — | Appendix C open question 4 closed, FR-ANL-05 given its unit, and a constitution conflict recorded (feature 051, chapter 4.6). The question — per-second precision or per-minute rounding — had been open since before Part 4 and was owned by Product / Billing; the chapter that puts connection-minutes in a billing rollup is the one that cannot leave the unit undefined. Closed as a calendar bucket, which is what meter.ts has billed since 3.24, on a measurement rather than a preference: 56 calendar minutes against 0.6513 elapsed over 55 real connections, a factor of 86, because the median connection lasted 252 ms. FR-ANL-05 states the unit now instead of leaving it to an appendix. AND A SECOND THING IS RECORDED RATHER THAN AMENDED, because it is the constitution's to change and not this document's: constitution III says "Analytical queries MUST NEVER execute against the operational database (PostgreSQL); billing, metering, and dashboard analytics read only from the analytical store (ClickHouse), fed via a durable queue (SRS CON-01)." Measured at chapter 4.6: metering reads only Postgres — usage_periods is a meter in the operational database, maintained synchronously on the send path — and message_events, the source of three of FR-ANL-05's four quantities, has no producer at all, its only writer being scripts/scale/load-analytics.mjs:53, which is postgresql('${PG_HOST}', …): ClickHouse executing a query against Postgres, the clause's first prohibition rather than its queue qualifier. So the only way to demonstrate the rollup the clause asks for is to run the thing the clause forbids. docs/12 §4 justifies the Postgres counter existing — a quota must refuse a send synchronously, so its counter cannot live downstream of a lossy stream — and justifies nothing about the sentence above it. Constitution VII requires the conflict "resolved explicitly by amendment rather than silent divergence"; the amendment is the constitution's and is named here so the next feature inherits a decision rather than a discovery. AND THIS REVISION REPRODUCED 1.12's OWN DEFECT WHILE WRITING IT. That entry records the header reading 1.3 while the table ran to 1.11, because check-revision-order reads the table and never the header. This row was added and the header left at 1.12 — and check:docs passed. Corrected by looking, which is the only instrument there is for it. DR-09 and DR-10 are also amended. DR-09's "daily aggregates for 25 months" had been unimplemented since the first draft and filed as a missing clause twice — gaps.md 047-3 and 048-4 both say "no clause says how long metering history is kept", and both were quoting DR-10, the next row down. Applied at chapter 4.6 as a 25-month TTL on both rollups. DR-10 now states the two conditions a rollup must meet to satisfy it, because a view that conforms to its letter satisfied it over a table receiving no events. |
| 1.14 | 2026-09-16 | — | FR-ANL-06 amended where this chapter's measurements show the clause as written cannot hold (feature 052, chapter 4.7), and the constitution conflict 1.13 recorded is now named in its second, sharper form. The clause asked for agreement "to within 0.1%" between metered totals and "counts derived from operational data" and left four things to the reader. (1) WHICH OPERATIONAL COUNT. messages (through channels) and usage_periods.messages_sent are both derived from operational data and they disagree: 19,012 against 18,962, 0.2630% across the lane. Every disagreement is attributable — a raw INSERT INTO messages in a fixture bypasses the counter, a hard DELETE FROM messages removes a row the counter already counted — and 0 of 1,385 tenant-periods disagree for a non-fixture reason, because sendMessage writes the message and increments the counter in one transaction. The clause names its source per quantity now, and the report names the table it read. (2) PER TENANT, NOT IN AGGREGATE. 0.2630% is the number a reconciler that sums first would publish; underneath it 49 tenant-periods breach and one is wrong by 100%. (3) ABSENCE IS NOT DISAGREEMENT. not-comparable (no operational counterpart exists — the stored message count has none) and no-data (this tenant has nothing on either side) are verdicts of their own. Measured here: every tenant in the platform is in one of the two. (4) THE BOUND IS UNREACHABLE FOR THREE OF THE FOUR QUANTITIES. uniq is exact to 65,536 distinct and 0.5676% at 65,537 — the cliff is at 2^16 and one distinct user wide, which sharpens gaps.md 047-1's "exact to 60,000". The retention boundary makes the oldest day in any window disagree by 0% at midnight and 1.0989% just before it, on the same data, which sharpens 047-1's single 0.49% into the curve that figure sits on. And connection-minutes cannot agree while any deploy leaves established sockets to the process exit, which chapter 4.5 decided is correct. These are clauses that cannot be met rather than defects to fix, and the chapter publishes no percentage that implies otherwise. AND CONSTITUTION III, A SECOND TIME, AND THE ARCHITECTURE HAS ALREADY ASSUMED THE ANSWER. The clause says "billing, metering, and dashboard analytics read only from the analytical store" while FR-ANL-06 requires comparing against PostgreSQL. The reading that makes both true is that the reconciler is none of those three roles — it is an auditor, and an auditor confined to one side of a fence cannot check the fence. That reading is not this document's to ratify; it is the constitution's, under VII's "resolved explicitly by amendment rather than silent divergence", and it is named here so the next feature inherits a decision rather than a discovery. What is new since 1.13 is that docs/05-sad.md's ADR-06 has said it all along: its accepted trade-off reads "mitigated because the only strict consumer (metering) reconciles daily against Postgres (FR-ANL-06)" — so the cross-path read is the mitigation that makes choosing NATS over Kafka acceptable, not an oversight, and removing it would cost that ADR its argument. AND A CITATION IN THIS DOCUMENT POINTED AT NOTHING. DR-09's 1.13 amendment cited "FR-003a" as though it were a published clause. There is no FR-003 in this document; FR-003a is a feature-local id used by four different features to mean four different things. Corrected to name the decision instead of the id, here and at docs/05-sad.md:879, where the same citation sat beside a sentence 4.6 had already falsified — "the rollups carry no TTL", when DR-09's 25-month TTL was applied to both in that chapter. |
| 1.15 | 2026-09-17 | — | Three analytics clauses amended where this chapter's measurements falsify them, and one architecture decision recorded (feature 053, chapter 4.8). FR-ANL-10 had no quantity. It asked for "end-to-end delivery latency percentiles" and the phrase has three readings; the platform's one existing latency measures a different one, which analytics/0003_webhook_attempts.sql:20 has said since it was written — deliver.ts times the fetch alone, so webhook_attempts.latency_ms excludes the commit, the outbox, JetStream, the claim and every retry gap. The clause now names commit to the frame written to a subscriber's socket, which is the real-time path constitution II is about and the reading message_events.delivery_latency_ms was created for; the two readings not chosen are recorded with what each would cost. The producer still does not exist and the clause says so. That column holds 0 rows and has 0 producers (gaps.md 048-2, carried through six features and re-measured here — 4.7's "0 occurrences under services/" is now 3, and all three belong to one test, so "0 producers" is what was always meant). The one thing that writes the table, scripts/scale/load-analytics.mjs, supplies CAST(NULL AS Nullable(UInt32)) for that column in both halves of its UNION ALL. Building it needs both instants carried across a service boundary on the busiest path in the platform. AND THE CLAUSE NOW FORBIDS quantile. Measured against the server: over a uniform sample the p50 error is 16.67% at n=4 and is not monotone — 0 at n=155 and back to 0.90% at n=10,000 — and over a skewed sample, which is what a latency is, it grows with the sample to 9.52% on all three percentiles at n=10,000. On the platform's one real latency sample, webhook_attempts at n=64, quantile(0.99) answers 4,961.26 where quantileExact(0.99) answers 10,000 — 50.39% low, and low is the direction in which an alert threshold never fires. This is the opposite shape from uniq, which is exact below 65,536 and wrong above: there is no n at which quantile is safe. And the clause's own grain is the small one — per tenant per hour over the request log gives 178 buckets, median 10 rows, 73 of them under five — so any implementation publishes n beside every percentile. FR-DSH-03's twin had been amended without it. Revision 1.11 struck "truncated payload" from FR-ANL-07 because FR-ANL-11 keeps message content out of the analytical store and constitution VI keeps it out of logs; FR-DSH-03 went on asking the dashboard for a request log "with full request and response detail", and EIR-DSH-02 permits the dashboard no other source than the public API. The three could not all hold. "With full request and response detail" is struck, carrying 1.11's own sentence rather than writing a new argument: a request log with no payload cannot answer "what exactly did they send", only "what did they call and what happened". An amendment that fixes one clause and leaves its twin standing is the defect, not the clause — and this is the third time this project has found that shape, after DR-09's second half filed as absent twice and FR-003a cited as a clause in two documents. FR-ANL-08's ninety days cannot exist over api_requests. FR-ANL-07 retains that table for 30 days. Measured: two rows planted at now - 60 days and now - 1 day leave one survivor, and OPTIMIZE TABLE … FINAL changes nothing, because the TTL removes rows at INSERT (chapter 4.2's finding, reproduced). So no fixture can put a 90-day window in front of the clause at any volume, which is stronger than the lane being small. Where the two are read over the same table, retention governs and the window is stated beside the figure. AND ADR-26 IS NEW, IN BOTH DOCUMENTS. The api now serves a customer request from the analytical store — the first time the platform's availability depends on ClickHouse answering, and a failure mode it did not have. Its rejected alternatives are measurements: a client-side deadline alone leaves the query running (SETTINGS max_execution_time = 1 answers HTTP 408 in 1.002 s), LIMIT BY changes what LIMIT counts, and Postgres has nothing to serve. FINAL costs 3 ms against 2 ms at seven parts, measured with merges stopped rather than on a table that had just been merged. Its reversal condition is the analytical store's availability appearing in the API's error budget, and the 503 analytics_unavailable refusal shipped in the same chapter so the signal exists before the decision needs reviewing. AND A CITATION IN THIS FEATURE'S OWN DRAFT REPRODUCED 1.14's DEFECT. Two published paragraphs were written citing FR-025 — a feature-local id, not a clause in this document — three months after 1.14 corrected FR-003a for the same reason. Caught by running the check rather than by reading: every prefixed identifier this feature cites resolves, and the bare-numbered ones do not, which is the only difference between the two classes. Corrected to name the decision instead of the id. |
| 1.16 | 2026-09-17 | — | FR-ANL-06's verification status recorded, and it covers one of the clause's three parts (feature 054, chapter 4.9). The clause asks for a comparison within 0.1%, verified by a daily job, that alerts on breach. The platform has the first. The planted-drift suite runs on every push and the bound was measured at a volume where 0.1% is a threshold rather than a rounding of nothing: 121,057 against 121,057, 0.0000%, where the smallest expressible drift is 122 messages — planted at 121 it passes and at 122 it breaches, in both directions, through the real job. The daily job has no runner of any kind, which no document had recorded: zero occurrences of reconcile-usage in either repository's package.json, in turbo.json, in ci.yml or in any *.sh, and ci.yml triggers on push and pull_request only. ADR-28 decides that, and decides it as a recorded absence — a daily sweep today would report no-data for every tenant, because no environment in the platform has both sides of the comparison. AND THE GATE THAT CARRIES THE T COULD NOT SIGNAL. pnpm test:integration had been red on every run since chapter 4.4 for six failures unrelated to metering, so a planted drift made a red run redder — the signal was indistinguishable rather than absent, which is not what four chapters recorded — and --concurrency=1 meant five lanes had not run at all. ADR-27 fixes both and makes the gate report suites executed against suites present (54 of 54), because turbo's own summary counts tasks and does not reproduce run to run. One unset environment variable was the whole of two of the six failures: limits.itest.ts said so loudly, while three attacks in the cross-tenant suite constitution VI names as gating releases returned at their first line and reported green — 1ms, 0ms and 1ms against 27ms, 6ms and 33ms once it was set. And constitution III's fourth bullet is still unamended, now with a written proposal (specs/054-chapter-4-9/constitution-amendment.md) rather than a third deferral: it says 0.1% flat and a daily job that alerts, where this document has said since 1.14 that three of four quantities cannot meet the bound and that the alert has no mechanism. |
| 1.17 | 2026-09-19 | — | FR-RTL-05 amended to say which kind of quantity each of its quotas is, and FR-MED-12 re-read against it (feature 056, chapter 4.10). The clause read "configurable monthly quotas on messages sent, unique active persons, and connection-minutes" — three quantities, none of them storage — while two other clauses cite it for a fourth: FR-MED-02 refuses an upload on "the environment's storage quota" and FR-MED-12 says stored bytes are "included in quota enforcement (FR-RTL-05)". Two clauses citing a third for something it does not define. AND THE MISSING WORD IS MONTHLY, WHICH IS THE PART THAT WOULD HAVE BEEN IMPLEMENTED WRONG. The three existing dimensions are flows: usage_periods is keyed on a calendar month and creditFor's own comment says "the one thing this function must never do is subtract from a bill". Stored bytes are a level — the figure falls when objects are deleted — so putting it in that row would need a subtraction the function forbids, and would reset on the 1st: a tenant holding 100 GB would start every month at zero and be allowed another 100. The cap is configuration in environments.quota_config beside the other three; the accounting is sum(declared_bytes) over the tenant's media rows. The clause now says which is which, and says that a storage refusal must not promise a resume date — the platform's other three do, in the sentence QuotaExceededError.publicMessage() builds, and for this dimension that date would be false. media_storage_exhausted exists so the refusal never reaches that sentence. AND THE READ AND THE WRITE NEEDED A LOCK, WHICH A TRANSACTION IS NOT. Postgres defaults to READ COMMITTED, so two slot requests inside two transactions both read the same sum, both find room, and both insert — measured by hand with two clients: 1,200 bytes committed against a cap of 1,000. SELECT ... FOR UPDATE on the environment row serialises them per tenant, at no extra statement because the cap is on that row. And the obvious probe for it measured the wrong lock: media_objects has a foreign key to environments, so the INSERT takes FOR KEY SHARE on the parent and blocks against a FOR UPDATE holder whether or not the method asked for anything. A holder at FOR NO KEY UPDATE — which conflicts with FOR UPDATE and not with FOR KEY SHARE — is the discriminator, and the test goes red without the explicit lock only under that mode. |
| 1.18 | 2026-09-19 | — | FR-MED-06 gains the case it left undefined, and FR-MED-07 is recorded as unmet by decision (feature 057, chapter 4.11). The uploader clause read "(for user tokens) was uploaded by the sending user"; a slot taken by an application credential records no uploader, which the first sentence withheld permission for and the second did not mandate refusal for — the clause was silent, not restrictive, on the commonest shape a customer's backend produces. Chapter 4.10 made that column nullable expressly so this chapter could ask the question, and answering "an unrecorded uploader always fails" would have made the nullability pointless because any sentinel would have done. The clause now states three cases and requires the three refusals to be indistinguishable, which was built and tested as one property rather than three assertions that agree. FR-MED-07 is the adjacent row and this chapter turns it from vacuous to unmet: it is the first to deliver an attachment that HAS a state. |
| 1.19 | 2026-09-20 | — | FR-MED-08's authorisation rule replaced with the platform's own, and two cases it never named written down (feature 058, chapter 4.12). The clause said "authorised to read the referencing message (channel membership or API key)", and the parenthesis is stricter than the message it guards. This platform checks membership for private channels only — repository.ts:5589 has answered history that way since the channel chapter — so a literal implementation would refuse a user the photo in a message whose text they can read. Measured on the development lane: 11,557 public channels against 1,016 private, so the case the literal reading breaks is the common one, not an edge. The clause now names the platform's rule and the note's "inherits channel membership" becomes "inherits channel visibility", which is what makes the no-parallel-ACL sentence satisfiable by construction: the delivery route calls the same channelVisibleTo every read route calls, and the "or API key" arm is that helper's own userId === undefined branch rather than a second path. AND THE SINGULAR WAS WRONG. "The referencing message" does not describe a platform where FR-MSG-11 has allowed the same id twice since chapter 3.24 — forwarding a photo is the ordinary way one object acquires a second reference — so authorisation is a disjunction over every referencing channel. A lookup that stopped at the first reference would refuse a caller whose channel happened to be second, which presents as flakiness rather than as a refusal; the test that catches it fails with expected 404 to be 200 when the lookup is truncated. AND THE OBJECT WITH NO REFERENCING MESSAGE IS NOW ANSWERED. The clause withheld permission and mandated nothing, exactly as FR-MED-06 did before 1.18. It is readable by nobody, including its uploader: the permissive reading is the parallel ACL the clause's own note forbids, and FR-MED-10 destroys unreferenced objects after 24 hours, so it would be a read path to a thing already scheduled for deletion. What the clause still cannot promise is revocation. A URL issued at minute 0 to a caller removed from the channel at minute 1 works until minute 60; the store checks a signature and has never heard of a channel, and standing the api in front of the bytes is what ADR-13 was decided against. Published as a cost with the window named rather than hidden (gaps.md 058-1). |
| 1.20 | 2026-09-26 | — | FR-MED-04 recorded partly met, FR-MED-07 re-read now that a state can exist, and FR-MED-03's "contradict their declaration" settled as EXACT (feature 059, chapter 4.13). The third of those is the one that changed an implementation. FR-MED-03 says objects that "contradict their declaration" shall be rejected, and this feature's own specification and tasks both read that as "materially larger" without giving a tolerance — and the quota is what settles it. reserveMediaSlot charges sum(declared_bytes) (revision 1.17), so under any tolerance a client that under-declares is billed for the declaration and stores the difference: the tolerance would be a storage discount with no clause behind it. Exact comparison makes the quota correct by construction — for every ready object, verified_bytes = declared_bytes, asserted over the tenant's whole set rather than over one row. The tests are one byte, in both directions, because a five-thousand-byte mismatch is rejected under a tolerance too. AND THE TYPE HALF CANNOT BE ASKED OF THE STORE. A presigned PUT of twelve MP4 bytes sent with content-type: image/png answers HEAD with image/png — measured — so the store echoes the client's own claim and a worker reading that header has verified the declaration against itself. content-length is the store's own count and is evidence; content-type is not. The type comes from the bytes, for all ten types FR-MED-02 allows. AND THE SCAN RUNS FIRST, UNCONDITIONALLY, because FR-MED-04's "every uploaded object" cannot be read one way for the type check and another way for the size check — an object declaring one byte while holding five megabytes is at least as worth scanning as one whose type is wrong. AND A SCANNER THAT CANNOT ANSWER PRODUCES NO VERDICT AT ALL (feature 059's own FR-009 — a feature-local id, not a clause in this document; the published obligation is docs/05-sad.md's degradation row): not rejected, which would destroy a customer's bytes to record an outage, and not ready, which would let an unscanned object through. There is deliberately no retry verdict in the contract, because a row saying "we could not tell" is one somebody later reads as a fact. |
| 1.21 | 2026-09-28 | — | FR-MED-07 met, and its FIRST sentence had also been unmet (feature 060, chapter 4.14). Three chapters cited this clause for the event it names and recorded the state machine as the gap; the sentence before it — "real-time and history delivery shall include each attachment's state" — had no field to put a state in. attachmentSchema was embedded by four things: three request doors and messageSchema, the payload the api BUILDS, and those carry opposite rules — a sender must not declare a state, a delivered attachment must always carry one. A third shape, deliveredAttachmentSchema with state required, and messageSchema points at it; required is what makes the compiler name every construction site, which is how the door set was derived after a hand list of three and a second of six were both wrong. AND THE TWO SENTENCES FAIL IN DIFFERENT CIRCUMSTANCES: FR-MED-06 permits attaching an object already ready, for which no transition remains to announce — 538 of the development lane's 1,589 referenced objects are in that position — so the state on the attachment is the mechanism and the event is an optimisation over it, the shape 4.13 recorded for the upload sweep. The state is read when the message is served, never stored on it (constitution IV), at one extra query per page rather than one per row. The event takes revision:{channel_id}'s third arm rather than a sixth subject grammar (ADR-33): ADR-25's threshold is per-channel SUBSCRIBEs exceeding six, so a sixth would have been permitted and would have spent the last of the headroom, and the cheapest option — re-delivering the message as message.updated — dies because messageSchema has no edited_at and a client could not tell an attachment resolving from an author editing. One frame per referencing channel, published on the verdict's existing compare-and-set so a duplicate announces nothing; an object nobody attached announces nothing and the verdict still succeeds, which is the common case at 4,725 of 5,403 objects. |
| 1.22 | 2026-09-29 | — | FR-MED-05's image half met, its video half recorded unmet by decision, and the clause's last five words identified as the hard part (feature 061, chapter 4.15). Three chapters read "stored as derived objects sharing the parent's lifecycle" as a storage note. It is the only part of the clause that needed a migration: a rendition is referenced by no message, so FR-MED-08 refuses it and FR-MED-10 reaps it, both working exactly as written. 0020 answers with a composite foreign key — (parent_id, environment_id) against (id, environment_id), ON DELETE CASCADE — so constitution I is enforced by the database and FR-MED-10's "its reachability is its parent's" is a key rather than a second predicate arm. The video half is priced rather than deferred: ffmpeg is 113,994,336 B against the image half's 30,380,799 B, for the harder half of a clause whose easier half revision 1.20 already declined, and ADR-34 carries the reversal condition. Three figures the chapter publishes and one it refuses: a thumbnail costs about 7 kB and 15.2 ms of CPU; the extra whole-object GET the worker does not currently make is 8.2 ms against a 52.0 ms resize, so the I/O is 14% and not the dominant term; peak RSS moved 0.4 MB where the arithmetic for a 4000x3000 bitmap says 36, because libvips works in strips. The ratio to the parent is refused as a figure — it spans 775x across a real corpus while the thumbnail's own size spans 4x, so it is a fact about the parent and publishing it as the cost publishes the wrong variable. And the lane cannot exercise any of it at scale: 4 of its 5,996 images are above the bound. |
| 1.23 | 2026-09-30 | — | FR-MED-12 split into three answers, DR-17's parenthetical corrected, and both bounded by what the platform can currently do (feature 062, chapter 4.16). The clause's daily figure and per-kind counts are met; "visible in the dashboard" is unmet because there is no dashboard and FR-DSH-04/05 are unbuilt clauses verified by demonstration. DR-17's (uploaded/deleted) named two events this platform does not emit, and the correction is not vocabulary: the meter charges at RESERVATION, because FR-MED-01 reserves the slot against the quota the moment it is issued, so a clause metering uploads would disagree with the quota it exists to explain. The four causes are reserved, rejected, rendition, deleted, and the data dictionary's row for media_events — which named bytes, processing_ms and an uploaded/ready pair, none of which the table has — is corrected with them. The weekly cadence is unmet by decision on ADR-28's precedent: no scheduler exists, which chapter 4.9 already recorded for FR-ANL-06's "daily", and an exit code is the whole of what "raises an alert" can mean here. And FR-MED-12's figure is now bounded at the rollup's 25-month retention horizon (DR-09, applied at 4.6) — a level accumulated from deltas is understated by exactly what the TTL removed, permanently and invisibly, and DR-17's inventory is named as what re-bases it because the object store holds the level directly rather than as a sum. The rendition question is answered once in this clause and asserted at every reader: a rendition is not an upload for the count and its bytes are in the level. (It carried a feature-local FR-0nn id here until the diff check below caught it — gaps.md 052-7's defect, in the revision row announcing a clause correction.) And the comparison's own blind spot is recorded rather than left to look like agreement — nothing deletes a media_objects row, so the metered level never falls for a deletion and a lost deleted record cannot be the drift that is found. Measured on the development lane: 99.9% of the charged bytes are slots never uploaded to, which is why the comparison has a reservations-only verdict beside agreement and the two directions of drift. |
| 1.24 | 2026-10-01 | — | FR-MED-09 split into a data half that is met and a rendering half that cannot be met here, and the end-to-end path asserted for the first time (feature 063, chapter 4.17). Seven chapters built upload, scan, verdict, state, rendition, delivery and announcement, and no test joined them: the worker's own suite runs it in process, the state machine's is driven by SQL, the thumbnail's is a unit test over a buffer, and the delivery gate's sets its states by hand. The sealed suite now carries one image from slot to delivered bytes with the verdict made by the container a deployment runs, and a second image refused by it. And the premise this feature was specified on was partly wrong, which reading the whole of integrate.itest.ts rather than the assertion that had been quoted is what showed: that suite has let a real scan decide what it fetches since chapter 4.13. What was genuinely missing is narrower and is what shipped — the state a RECIPIENT sees changing, the thumbnail fetched by the id history hands over, and a media.updated frame watched from outside on both terminal arms. The comment that explained the old assertion away was false in the job that runs it — "this suite runs no media worker", where CI's sealed job starts one under --profile services — and a race with a four-second margin is the kind nothing ever catches. It is replaced by the measurement and the reason is now checked rather than written. What the milestone costs, with its sample size: every step a client controls is under 20 ms and the one it waits on is three orders of magnitude larger. Over 25 independent trials the wait from upload to verdict is min 1,097 · p50 3,398 · max 36,164 ms against a 5,000 ms sweep whose passes were measured at 5.36–5.41 s from a different process's access log. The shape — uniform on [0, interval] plus work under half a second — is what travels; the numbers are this deployment's. And the tail is published rather than smoothed: one upload in six waits five or six passes and the cause is neither the timer nor the scanner (2 ms at p50 over 20 scans). It is recorded as an open gap, because this chapter adds no mechanism. |
| 1.25 | 2026-10-02 | — | FR-MOD-03 answered as ten obligations rather than one sentence, nine of them met or demonstrated (feature 064, chapter 4.18). The clause names a population and supplies no membership rule, so most of the chapter is a decision: 24 tenant-reachable mutating routes derived from a booted application, each classified with a reason, checked in both directions against the router. The set is eight where a reasonable reader predicts nine — edit another author's message is not a reachable action, because FR-MOD-02 grants a key deletion and is silent on editing and FR-013a of feature 041 reads silence as absence of permission. The rule the specification proposed misclassifies two routes in the same direction, and correcting them named the line the set actually draws: standing, not data. immutable COST A MECHANISM THE SCHEMA HAD NEVER USED, because the obvious one was measured inert. REVOKE UPDATE, DELETE does nothing — the api connects as a superuser — so immutability is a BEFORE UPDATE OR DELETE trigger, and a unit test had forbidden every trigger in services/api/migrations/ for a reason that is the opposite of this one: the guard it was written about rejects the api's own legitimate sweeps, and this refuses writes the api must never make. The rule was narrowed to the guard by name, with the narrowing itself asserted. Both bypasses are published rather than omitted — one SET disables every trigger in the session — so the claim is that the log is immutable to the application and to accident and not to somebody holding the database password (ADR-35). retained for 1 year is unmet by decision on ADR-28's precedent, the third clause in this document to be bounded by the absence of a scheduler after FR-ANL-06 and DR-17; NFR-SEC-10 asks for the same artifact and is not satisfied by it, because its actor is the one the trigger cannot refuse. AND THE PROBES FOUND TWO SCOPES THAT WERE INVISIBLE AND ONE GUARD AIMED AT THE WRONG CASE. Deleting the controller's 403 for a principal with no environment turned nothing red anywhere, because @Accepts("application") refuses such a principal before the handler runs — while deleting that decorator answers an end-user token 200, with the tenant's whole moderation history. The branch defends a case that cannot arise; the decorator is the decision, and nothing had tested it. What the record costs, measured: the entry is 0.43 ms wherever it goes, and the transaction four actions had to gain costs about as much again — an archive is 94.6% slower than before the chapter, which is the figure worth publishing rather than "adds an insert". |
| 1.26 | 2026-10-03 | — | FR-MOD-01's two nouns were both built and their composition lost a version every time (feature 065, chapter 4.19). An edit records the text it replaced and a deletion recorded nothing, so a message deleted after N edits yielded N of N+1 texts and one deleted with no edits yielded zero of one — measured end to end before the specification was written, which is what docs/12 §7.5 asked for. Four of FR-MOD-01's five obligations were already met, so the chapter's product is the fifth plus a boundary: 5,060 tombstones predate it and no migration recovers them, 1,298 of those keep earlier texts and look served, and the distinction between those two figures is the one a single number hides. FR-MSG-08 needed no amendment and is the clause the defect broke — losing the final text at a moderation delete is a hard deletion on the path that sentence reserves for erasure. FR-MSG-07's immutable became a mechanism, on ADR-35's precedent applied to a second table with its two bypasses re-measured rather than assumed. And two measurements cost more than the feature did. message_edits.edited_at is a microsecond column holding only millisecond values — 5,149 of 5,149 — because every write arrives as a JavaScript Date, so the primary key's collision window was 1000x the schema's stated one; a concurrent edit and deletion collided 1 run in 10 and left the message un-tombstoned until the deletion path was changed to write now() in SQL. And a required deleted_at on MessageRow reached the strict schema on the internal send seam, breaking the send response in three tests — the field belongs on the READ shape, which is 4.11's rule that an argument right about the producer can invert about the reader. The route's tenancy has three scoped reads and removing any two of them turns nothing red, with this chapter's own suite blind to all three. |
| 1.27 | 2026-10-04 | — | FR-MOD-06's three obligations were none of them met, which is the inverse of the premise check chapter 4.19 ran (feature 066, chapter 4.20). environments.retention_days had existed since chapter 2.1, was named in §6.1 and the SAD, and was set on 0 of 33,051 environments with nothing reading it; there was no scheduler; and the hard deletion the clause names was refused by the platform's own schema on 5,495 messages. THE REFUSAL IS A PINCER AND IT IS CHAPTER 4.19's: the foreign key refuses the parent, the append-only trigger refuses the children, and ON DELETE CASCADE is refused too, because a cascade issues an ordinary DELETE and a row trigger fires on it — the error names the generated statement. The only thing that worked unchanged was session_replication_role = replica, which is the hole ADR-35 published as the limit of its own guarantee. So the chapter's products are a narrow auditable exception and ADR-36, superseding ADR-35's scope clause for that table because constitution VII makes an accepted ADR immutable. FR-MSG-08 AND DR-06 ARE AMENDED AND THE CONSTITUTION IS NOT: principle II says hard deletion exists only on the compliance path where FR-MSG-08 said endpoint, so the rule hardest to change is the one that already permitted this and the two narrower clauses moved to match. by a scheduled job is unmet by decision, the fourth clause bounded by ADR-28's absence — and unlike FR-ANL-06, DR-17 and FR-MOD-03's year it is not a reporting obligation whose absence costs accuracy, it is a customer telling an auditor that data does not exist, so nothing publishes a deadline the platform does not keep. WHAT THE LANE COULD NOT SHOW: nothing here is thirty days old — the oldest message is 2026-09-14 and crosses on 2026-10-14 — so every figure is from a backdated fixture. Measured that way, a message costs 0.04 ms and a media object 2.05 ms, about 48×, because the object store has no foreign keys and each one is a round trip. AND FR-MED-11's POPULATION IS NOT FR-MED-10's: reusing the orphan reaper's predicate would have destroyed 48 objects in one environment that no message ever referenced. |
| 1.28 | 2026-10-04 | — | FR-MOD-04 split into four obligations, and its second noun into four stores (feature 067, chapter 4.21). The erasure endpoint ships. The clause's hardest word turned out to be messages, and the answer is that FR-USR-05 wins: a compliance erasure asks for removal of a PERSON, not of a conversation, so the author is erased and the text is not — which is what Slack and Microsoft Teams both do and the only reading in which neither clause has to be broken. Both clauses now name the trade, so it is findable from either side. AND THE STRUCTURE FOLLOWS FROM THAT ONE DECISION. Keeping the messages means messages.user_id stays; all five foreign keys to users are NO ACTION; so the row cannot be deleted and erasure leaves a tombstone holding nothing. ADR-37 takes the consequence: a key into an erased row names nobody, so usage_active_users keeps all its rows with its count unchanged to the row, and the seven uniq sketches need no subtract operation that does not exist. What would have been published as the chapter's weakest limitation is instead its argument. WHAT COULD NOT BE DONE, AND BY WHICH KIND OF OBSTACLE. audit_log holds the person's external id on 1,357 of 1,357 user-target rows and ADR-35 made it append-only — and the erasure writes one more, deliberately, because that entry is the operator's only proof. media_objects.user_id is nullable on 73.6% of objects, so the media half is correct and cannot be complete. FR-MED-10's 24-hour reaper is still unbuilt. FR-MOD-05's export — the clause that would let a tenant hold a copy before asking, since there is no undo — is named for the third chapter running without moving. AND THE 30-DAY BOUND IS MET AND UNEXERCISABLE: 53 ms measured on the slow path, which makes it the fifth clause bounded by ADR-28's absent scheduler and the first where the absence costs nothing. BOUND PARAMETERS ON THE ANALYTICAL CLIENT ARE WHY THAT HALF IS SAFE AT ALL. AnalyticalStore.query had no parameter binding and an external id is 255 characters of anything the customer chooses; interpolated, ' OR 1=1 -- takes a tenant-scoped count from 0 to the whole table, because OR binds looser than the AND chain the scope is written in. Measured both ways against the running store: one erasure of one user destroyed 1,113 of 1,116 connection_events rows belonging to other tenants. Chapter 4.8's remedy — a type, not an escape — does not transfer, because a free-text id has no type; the client takes bound parameters instead. |
| 1.29 | 2026-10-06 | — | THIRTEEN ROUTES CONTRADICTED FR-USR-01 AND ADR-18, AND NO CLAUSE SAID THEY SHOULD NOT (feature 068, chapter 4.22). FR-CHN-01 took a customer-supplied identifier at creation and FR-CHN-02 made that creation idempotent on it; nothing said the identifier could then be used to NAME the channel, so the behaviour had no requirement and FR-CHN-11 is a new clause rather than a widening — reading FR-CHN-01 as covering addressing would make a creation clause carry a retrieval rule. All ten FR-CHN clauses were read before it was written and none of 03 to 10 touches addressing. THE DEFECT WAS A FAILED CAST AND THE FIX AND THE REPAIR ARE ONE EDIT. GET /v1/channels/order-88412 answered 500: 'order-88412'::uuid raises in Postgres before the OR beside it can short-circuit, so the natural first attempt told an integrating developer the platform was broken rather than that they had used the wrong key — and the api logged "status":500 with no 22P02 and nothing an operator could act on. A value that cannot parse as a uuid is now resolved as an identity and never cast. 058-3 IS CLOSED, 16 TO 0: that gap counted sixteen routes where a malformed uuid in a path is a caller-triggered 500 — thirteen taking channelId, three taking messageId, one mediaId validated at 4.12 — and the resolution closes the thirteen by never casting while a shape check closes the three. THE TIE-BREAK IS A CORRECTNESS CHOICE: both lookups cost the same, measured twice a day apart with the ordering reversing between runs, so the order is decided by which failure is worse — under key-first a customer who named a channel with another channel's uuid could never reach their own. AND THE MECHANISM IS A PIPE BECAUSE MIDDLEWARE RUNS BEFORE GUARDS, so it has no principal and an unscoped resolution is a cross-tenant read. The pipe resolves and never refuses: the first version threw its own 404 and a banned user could then tell a real channel from an invented one, which gauntlet.itest.ts caught — a pipe answers before every check the handler makes, and two of those come first on purpose. ADR-38 states the rule for the next noun; exactly two tables carry a customer-supplied identifier, from information_schema rather than judgement. AND ADR-37's OPENING SENTENCE WAS FALSE. It said users.id is exposed "nowhere a caller can act on"; POST /v1/channels/{channelId}/members returned members[].user_id on every member added, a value no route accepts. Removed, and the ADR amended in both homes — the live edge it named, the GET /v1/users listing cursor, does not exist: there is no such route and the cursor that does carries a channel uuid. WHAT THIS CLAUSE DOES NOT COVER: the real-time surface still addresses channels by uuid in every frame, so a customer holding a socket keeps the mapping REST no longer needs. (Revision 1.29.) |
| 1.30 | 2026-10-08 | — | FR-CHN-11 STOPPED AT REST AND NOTHING SAID THE SOCKET SHOULD FOLLOW (feature 070, chapter 4.23). Ten FR-RTM clauses describe what is delivered and when and none of them names an identifier, so FR-RTM-11 is a new clause rather than a widening — the same silence FR-CHN had before FR-CHN-11, one surface over. All ten were read beside FR-CHN-11 before the eleventh was written, and two survive it unchanged on purpose: FR-RTM-03 calls the ack's revision count "a per-channel count" without saying how a channel is named, and FR-RTM-04 names a "truncation indicator" rather than its contents. THE SURFACE WAS LARGER THAN A COUNT OF channel FIELDS SHOWED. Seven client-facing schemas carry one; connection.ack names channels three more times WITHOUT the word — revisions and cursor are keyed by channel and truncated is a list of them — so a requirement written against the seven would have left the first frame every client receives carrying uuids. AND THE TRANSLATION BELONGS AT ONE PLACE, WHICH IS A CORRECTNESS CHOICE AND NOT A TIDY. Of twenty-one places the gateway writes a channel, three are client frames, eleven are LOG LINES, six are internal publishes and one is a comment — and message.created, the commonest frame on the socket, arrives as a payload FORWARDED from the api and is written by no gateway expression at all. Translating per site would rename log lines an operator reads against the subjects and would miss that frame entirely; translating at the single send also keeps connection.buffer, marks and channelIds keyed, which is what stops a resuming client being re-sent its whole backlog and a revoked channel's backlog being flushed anyway. THE INBOUND FORMS CANNOT BE TOLD APART BY SHAPE: a resume cursor is a record keyed by strings and an identifier may itself be a uuid — 19 of 44,574 channels carry one, every one of them another channel's Relay identifier, measured 2026-10-08 against 0 of 41,772 four days earlier — so both are accepted and the identifier wins. A CHANNEL THE MAP CANNOT NAME DROPS ITS FRAME AND LOGS, because a dropped frame is recoverable through the resume cursor and an emitted uuid is not recoverable at all. AND THE BACKSTOP NEEDED THE SAME WIDENING: GET /internal/memberships carries pairs too, because an addition found on its timer announces a channel the connection has never heard of. WHAT THIS REVISION DOES NOT COVER: webhook payloads still carry channel_id as a Relay identifier on a boundary whose own contract says consumers receive external ids — three payload types in services/api/src/outbox/event.ts, recorded and nobody's chapter. (Revision 1.30.) |