diff --git a/content/docs/protocol/kernel/realtime-protocol.mdx b/content/docs/protocol/kernel/realtime-protocol.mdx
index e07b8811c0..98e16e0874 100644
--- a/content/docs/protocol/kernel/realtime-protocol.mdx
+++ b/content/docs/protocol/kernel/realtime-protocol.mdx
@@ -255,21 +255,45 @@ Subscribe to changes on a specific object:
**Request:**
```json
{
+ "messageId": "550e8400-e29b-41d4-a716-446655440000",
"type": "subscribe",
- "subscription_id": "sub_1",
- "object": "task",
- "events": ["created", "updated", "deleted"],
- "filter": {
- "assignee_id": "user_123"
+ "timestamp": "2024-01-16T14:30:00Z",
+ "subscription": {
+ "subscriptionId": "9f8c1e42-5b7a-4d33-9e10-6c2f8a91b4d7",
+ "events": ["data.record.created", "data.record.updated", "data.record.deleted"],
+ "objects": ["task"]
}
}
```
+**`SubscribeMessageSchema` declares exactly two things of its own** — `type: "subscribe"` and a
+`subscription` object — on top of the three `BaseWebSocketMessage` fields every message carries.
+Nothing else sits at the top level: the subscription's own fields live **inside** `subscription`
+(`EventSubscriptionSchema`), and they are camelCase.
+
**Parameters:**
-- `subscription_id`: Client-generated unique ID for this subscription
-- `object`: Object name to subscribe to
-- `events`: Array of events to listen for (default: all)
-- `filter`: Optional filter (same syntax as HTTP API filters)
+- `messageId`: UUID for this message (`BaseWebSocketMessage`)
+- `timestamp`: ISO 8601 datetime the message was sent (`BaseWebSocketMessage`)
+- `subscription.subscriptionId`: **UUID** identifying the subscription — the schema declares
+ `z.string().uuid()`, so an opaque handle like `"sub_1"` does not parse
+- `subscription.events`: array of event **patterns**, not bare verbs — lowercase dot notation with
+ wildcards (`"data.record.*"`, `"*.created"`). The platform-checked vocabulary is the closed
+ `DataEventType` / `BulkDataEventType` enums (`packages/spec/src/api/events.zod.ts`), whose members
+ are `data.record.created` / `data.record.updated` / `data.record.deleted` and their bulk siblings
+- `subscription.objects`: optional **array** of object names to filter events by (`["account",
+ "contact"]`) — plural, and an array even for a single object
+- `subscription.channels`: optional array of channel names for scoped subscriptions
+- `subscription.filters`: optional, and **declared `unknown`** — see the callout below
+
+
+ ⛔ **`filters` is declared but not enforced — it is not "the same syntax as HTTP API filters".**
+ `EventSubscriptionSchema.filters` is `z.unknown()`, and no runtime evaluates a payload filter:
+ `matchesSubscription` (`@objectstack/service-realtime`) matches on object name and event type
+ only. A subscription carrying `filters` therefore **receives every event its patterns match** —
+ the extra conditions are silently ignored, in the permissive direction. The key is typed
+ `unknown` deliberately, because validating a shape nothing reads would imply an enforcement that
+ does not exist (objectui#2945). Narrow with `objects` and `events`, and re-check on the client.
+
**Success Response:**
@@ -378,11 +402,19 @@ Stop receiving events for a subscription:
**Request:**
```json
{
+ "messageId": "b3d1a77e-4c62-4f0b-9a55-1d8e2f6c0b34",
"type": "unsubscribe",
- "subscription_id": "sub_1"
+ "timestamp": "2024-01-16T14:35:00Z",
+ "request": {
+ "subscriptionId": "9f8c1e42-5b7a-4d33-9e10-6c2f8a91b4d7"
+ }
}
```
+`UnsubscribeMessageSchema` nests its payload under `request` (`UnsubscribeRequestSchema`), whose one
+field is `subscriptionId` — a UUID, and the same UUID the subscribe message declared. Note the key
+is **not** the `subscription` of a subscribe message: the two envelopes use different wrappers.
+
**Response:** the same `ack` envelope as a subscribe acknowledgement — there is no `unsubscribed`
type either.
@@ -398,47 +430,67 @@ type either.
### Subscribe to Specific Record
-Watch a single record for changes:
+Watch a single record for changes — **narrowed on the client**, because the declared subscription
+has no record-level field:
**Request:**
```json
{
+ "messageId": "c1f4b9a2-6e78-4a31-bb05-9d3e7c2a1f80",
"type": "subscribe",
- "subscription_id": "sub_2",
- "object": "task",
- "record_id": "task_456",
- "events": ["updated", "deleted"]
+ "timestamp": "2024-01-16T14:40:00Z",
+ "subscription": {
+ "subscriptionId": "2a6d5f14-8b90-4c27-a3e1-7f05b8d29c46",
+ "events": ["data.record.updated", "data.record.deleted"],
+ "objects": ["task"]
+ }
}
```
+
+ ⛔ **There is no `recordId` on a subscription, and no `record_id` anywhere in the protocol.**
+ `EventSubscriptionSchema` declares exactly `subscriptionId`, `events`, `objects`, `filters` and
+ `channels` — the narrowest scope the contract can express is **object + event pattern**. This
+ page used to show a top-level `record_id`, which no schema declares and no runtime reads.
+ Subscribe at object scope as above and compare the incoming event's own record identity on the
+ client; `filters` cannot do it for you (see the callout under **Subscribe to Object Events**).
+
+
**Use case:** Detail pages that need to reflect live changes to the currently viewed record.
### Subscribe to Query Results
-Subscribe to a dynamic set of records matching a query:
+Subscribe to a dynamic set of records matching a query — again **narrowed on the client**, because
+the declared subscription carries no query:
**Request:**
```json
{
+ "messageId": "d8e0c3b5-1f47-4d92-8a6c-5b4e9f210a73",
"type": "subscribe",
- "subscription_id": "sub_3",
- "object": "task",
- "query": {
- "filter": {
- "status": "todo",
- "assignee_id": "user_123"
- },
- "sort": "-priority"
- },
- "events": ["created", "updated", "deleted"]
+ "timestamp": "2024-01-16T14:45:00Z",
+ "subscription": {
+ "subscriptionId": "6b3c9e07-2d54-4f18-9c8a-0e7d1b5a4632",
+ "events": ["data.record.created", "data.record.updated", "data.record.deleted"],
+ "objects": ["task"]
+ }
}
```
-**Behavior:**
-- Receive `created` events when records matching query are created
-- Receive `updated` events when subscribed records change
-- Receive `deleted` events when subscribed records are deleted
-- Automatically receive events when records enter/exit the query filter
+
+ ⛔ **There is no `query` on a subscription either, and no server-side query membership tracking.**
+ A top-level `query` object appeared on this page but is declared nowhere: `EventSubscriptionSchema`
+ has no such field, and the only payload-shaped key it does have — `filters` — is `unknown` and
+ unenforced. Nothing computes whether a record entered or left a result set, so a server cannot
+ send you an "entered/exited" signal it does not track.
+
+
+**Behavior at object scope** — what the declared subscription above actually delivers:
+- Receive `data.record.created` events for every `task` the subscription's patterns match
+- Receive `data.record.updated` events for every `task` that changes
+- Receive `data.record.deleted` events for every `task` that is deleted
+- Evaluate query membership **on the client**: apply the predicate to each event and derive the
+ enter/exit transitions yourself by comparing against the set you are already holding
**Example:** Task enters subscription:
```json
@@ -625,20 +677,26 @@ class ObjectStackClient {
}
resubscribe() {
- this.subscriptions.forEach((config, id) => {
- this.send({ ...config, subscription_id: id });
+ this.subscriptions.forEach((subscription, id) => {
+ this.send({ type: 'subscribe', subscription: { ...subscription, subscriptionId: id } });
});
}
-
- subscribe(config) {
- const id = `sub_${Date.now()}`;
- this.subscriptions.set(id, config);
- this.send({ ...config, type: 'subscribe', subscription_id: id });
+
+ subscribe(subscription) {
+ // `subscriptionId` is declared `z.string().uuid()` — not an opaque handle.
+ const id = crypto.randomUUID();
+ this.subscriptions.set(id, subscription);
+ this.send({ type: 'subscribe', subscription: { ...subscription, subscriptionId: id } });
return id;
}
-
+
send(message) {
- this.ws.send(JSON.stringify(message));
+ // Every message carries the three `BaseWebSocketMessage` fields.
+ this.ws.send(JSON.stringify({
+ messageId: crypto.randomUUID(),
+ timestamp: new Date().toISOString(),
+ ...message,
+ }));
}
}
@@ -654,10 +712,14 @@ Real-time subscriptions respect object-level and row-level permissions:
**Scenario:** User subscribes to all tasks:
```json
{
+ "messageId": "e5a7d210-3c86-4b4f-9017-8f2c6d3b5a19",
"type": "subscribe",
- "subscription_id": "sub_1",
- "object": "task",
- "events": ["created", "updated"]
+ "timestamp": "2024-01-16T14:50:00Z",
+ "subscription": {
+ "subscriptionId": "9f8c1e42-5b7a-4d33-9e10-6c2f8a91b4d7",
+ "events": ["data.record.created", "data.record.updated"],
+ "objects": ["task"]
+ }
}
```