Skip to content

Commit 2aa8456

Browse files
claude[bot]claude
andauthored
feat(spec): DataEvent carries organizationId, the organization the record belongs to (#14635)
* feat(spec): DataEvent carries the organization the record belongs to Adds the optional, non-empty organizationId member to DataEventSchema so a tenant-scoped consumer (webhook fan-out, per-organization realtime subscriber) can discriminate an event's tenant without reading the record body. Absent = the record belongs to no organization (single posture, or an organization-less row under a wall); present = exactly that organization. No default, empty string refused: declared = enforced. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE * chore(spec): regenerate products for DataEvent.organizationId gen:schema (authorable-surface/api.json) and gen:docs (content/docs/references/api/events.mdx), as check:generated --fix proved stale; api-surface and the JSON schema manifest were already current. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c46a97a commit 2aa8456

5 files changed

Lines changed: 171 additions & 0 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `DataEvent` names the organization the record belongs to, so a tenant-scoped consumer can tell whose event it is
6+
7+
The realtime `DataEvent` payload (`@objectstack/spec/api`, the body of every
8+
`data.record.created` / `data.record.updated` / `data.record.deleted` event)
9+
gains an optional `organizationId`: the organization the record belongs to.
10+
Until now the event carried the object name, the record id and the row body,
11+
and nothing that named the tenant — so a consumer that fans events out per
12+
organization (a webhook subscription, a per-organization realtime subscriber)
13+
had no term to discriminate on short of reading the row body, which is absent
14+
on delete events and is not the consumer's to read.
15+
16+
What a consumer may assume:
17+
18+
- **Present** — exactly that organization, never a guess: the organization the
19+
record belongs to, not the caller's active organization standing in for it.
20+
- **Absent** — the record belongs to no organization. That is every event on a
21+
`single`-posture deployment (no organization wall, nothing stamps the
22+
column) and an organization-less, environment-wide row under a walled
23+
posture. Read it as "not behind any organization wall", never as "unknown,
24+
look it up".
25+
26+
Declared = enforced: the key is optional and nothing else. No default
27+
fabricates a tenant; `null` and the empty string are refused with a located
28+
issue, so "no organization" has exactly one spelling — the key is absent.
29+
30+
Additive and shape-preserving: every event that parsed before parses
31+
identically, and no producer emits the key yet — the ObjectQL engine's publish
32+
site is a separate change that follows this contract. The bulk
33+
`BulkDataEvent` (`data.records.*`) is deliberately untouched: a predicate
34+
write's affected set is its own contract with its own tenant question.

content/docs/references/api/events.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const result = BulkDataEventSchema.parse(data);
5757
| **type** | `Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted'>` || Event type |
5858
| **object** | `string` || Object name |
5959
| **recordId** | `string` || Record ID |
60+
| **organizationId** | `string` | optional | Organization the record belongs to (its organization_id), so a tenant-scoped consumer can discriminate the event's tenant without reading the record body. Absent when the record belongs to no organization: every event on a single-posture deployment (no organization wall, nothing stamps the column), and a row that carries no organization under a walled posture (environment-wide, or an object outside the wall) — read absence as "not behind any organization wall", never as "unknown". Present = exactly that organization; never fabricated, and the empty string is refused. |
6061
| **changes** | `Record<string, any>` | optional | Changed fields |
6162
| **before** | `Record<string, any>` | optional | Before state |
6263
| **after** | `Record<string, any>` | optional | After state |

packages/spec/authorable-surface/api.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,7 @@
442442
"api/DataEvent:changes",
443443
"api/DataEvent:id",
444444
"api/DataEvent:object",
445+
"api/DataEvent:organizationId",
445446
"api/DataEvent:recordId",
446447
"api/DataEvent:timestamp",
447448
"api/DataEvent:type",

packages/spec/src/api/events.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,4 +239,67 @@ describe('DataEventSchema', () => {
239239
expect(event.before).toEqual({ name: 'Old Name' });
240240
expect(event.after).toEqual({ name: 'New Name' });
241241
});
242+
243+
// The tenant term — the contract half of closing the webhook fan-out's
244+
// cross-organization delivery. Both directions are pinned so
245+
// "declared = enforced" is a measurement rather than a sentence: the key is
246+
// optional and NOTHING else — no default fabricates a tenant, absence has
247+
// exactly one spelling, and a value that is not a non-empty string is
248+
// refused at the path a producer can act on. `BulkDataEventSchema` is
249+
// deliberately untouched here: a predicate write's affected set is a
250+
// separate contract with its own tenant question (recorded on the change
251+
// that adds this member), so nothing below pins that schema either way.
252+
describe('organizationId', () => {
253+
const base = {
254+
id: '4b4720e8-97c3-4a12-9b70-b70a3d2314a6',
255+
type: 'data.record.created',
256+
object: 'account',
257+
recordId: 'rec_1',
258+
timestamp: '2026-09-02T00:00:00.000Z',
259+
} as const;
260+
261+
it('parses without the key and does not fabricate one (single posture: no wall, no organization)', () => {
262+
const event = DataEventSchema.parse(base);
263+
expect(Object.prototype.hasOwnProperty.call(event, 'organizationId')).toBe(false);
264+
expect(event.organizationId).toBeUndefined();
265+
});
266+
267+
it('parses with the key and carries it through verbatim', () => {
268+
const event = DataEventSchema.parse({ ...base, organizationId: 'org_jia' });
269+
expect(event.organizationId).toBe('org_jia');
270+
});
271+
272+
it('refuses a non-string value with invalid_type at ["organizationId"]', () => {
273+
const result = DataEventSchema.safeParse({ ...base, organizationId: 42 });
274+
expect(result.success).toBe(false);
275+
if (result.success) throw new Error('unreachable');
276+
expect(result.error.issues).toEqual([
277+
expect.objectContaining({ code: 'invalid_type', expected: 'string', path: ['organizationId'] }),
278+
]);
279+
});
280+
281+
it('refuses null — absence has exactly one spelling, the missing key', () => {
282+
const result = DataEventSchema.safeParse({ ...base, organizationId: null });
283+
expect(result.success).toBe(false);
284+
if (result.success) throw new Error('unreachable');
285+
expect(result.error.issues).toEqual([
286+
expect.objectContaining({ code: 'invalid_type', expected: 'string', path: ['organizationId'] }),
287+
]);
288+
});
289+
290+
it('refuses the empty string — "no organization" is never spelled ""', () => {
291+
const result = DataEventSchema.safeParse({ ...base, organizationId: '' });
292+
expect(result.success).toBe(false);
293+
if (result.success) throw new Error('unreachable');
294+
expect(result.error.issues).toEqual([
295+
expect.objectContaining({ code: 'too_small', minimum: 1, path: ['organizationId'] }),
296+
]);
297+
});
298+
299+
it('is the only member added — every pre-existing member is still declared', () => {
300+
expect(Object.keys(DataEventSchema.shape).sort()).toEqual([
301+
'after', 'before', 'changes', 'id', 'object', 'organizationId', 'recordId', 'timestamp', 'type', 'userId',
302+
]);
303+
});
304+
});
242305
});

packages/spec/src/api/events.zod.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,18 @@ export type MetadataEvent = z.input<typeof MetadataEventSchema>;
215215
*
216216
* Represents a data record change event (create, update, delete).
217217
* Used for real-time synchronization of data records across clients.
218+
*
219+
* **This payload IS the contract a consumer discriminates on.** It travels as
220+
* the `payload` of the `RealtimeEventPayload` envelope
221+
* (`contracts/realtime-service.ts`), and the envelope is a transport shape —
222+
* `type` / `object` / `payload` / `timestamp`, a TypeScript interface no
223+
* parse ever validates. Every consumer that must read a per-event fact
224+
* already reads it HERE, not on the envelope: the webhook fan-out takes
225+
* `recordId` from the payload at its match site, and the client SDK
226+
* `safeParse`s the payload against this schema before it delivers anything.
227+
* So the tenant term below is a member of this validated payload rather than
228+
* a second, unvalidated envelope field: one declaration, enforced at the
229+
* publish site by the same `parse` that enforces `recordId`.
218230
*/
219231
export const DataEventSchema = lazySchema(() => z.object({
220232
/** Unique event identifier */
@@ -229,6 +241,66 @@ export const DataEventSchema = lazySchema(() => z.object({
229241
/** Record ID */
230242
recordId: z.string().describe('Record ID'),
231243

244+
/**
245+
* Organization the record belongs to — its `organization_id` column, under
246+
* the camelCase spelling every published payload in this package uses for
247+
* the tenant term (`organizationId`, the blessed developer-facing name).
248+
*
249+
* **Why a first-class member and not a read of the record body.** A
250+
* tenant-scoped consumer — the webhook fan-out matching subscriptions to
251+
* events, a per-organization realtime subscriber — must discriminate the
252+
* event's tenant BEFORE it touches the record: `after` is absent on
253+
* `data.record.deleted`, `before` is absent on create, and both are the
254+
* unfiltered row body the consumer may not be entitled to read at all. The
255+
* match term therefore rides beside `object` and `recordId`, validated
256+
* with the rest of the event at the publish site.
257+
*
258+
* **Absent = the record belongs to no organization.** Two situations, one
259+
* meaning:
260+
* - a `single`-posture deployment — `postureEnforcesWall(posture)` is
261+
* `false` and `postureStampsOrganization(posture)` with it (see
262+
* `@objectstack/spec/security`): there is no organization wall and
263+
* nothing stamps the column, so EVERY event is organization-less;
264+
* - a row that carries no organization under a walled posture (`group` /
265+
* `isolated`): an environment-wide row (`organization_id IS NULL`), or a
266+
* row of an object that stands outside the wall — `tenancy.enabled:
267+
* false` by declaration, or no `organization_id` column at all (the
268+
* identity tables).
269+
* In both, a consumer may read absence as "not behind any organization
270+
* wall" — the reading it already gives an `organization_id IS NULL` row on
271+
* the read path. It may NOT read absence as "unknown, resolve it yourself":
272+
* either the producer had the organization in hand or the record has none,
273+
* and a per-event lookup on the fan-out path is exactly the hot-path read
274+
* this member exists to make unnecessary.
275+
*
276+
* **Present = exactly that organization, never a guess.** It names the
277+
* organization the RECORD belongs to — not the caller's active organization
278+
* standing in for the row's, which would mislabel an administrator's write
279+
* into another organization. It is never fabricated: no `.default()`, and
280+
* the empty string is refused, so "no organization" has exactly one
281+
* spelling — the key is absent.
282+
*
283+
* **Optional as a contract fact, not as a transition.** A `single`-posture
284+
* deployment stays organization-less for its whole life, so a required key
285+
* would either force a fabricated tenant there or leave the engine unable to
286+
* publish at all (the publish site `parse`s the event and drops it on
287+
* failure). Declared = enforced: this optionality is exactly what validation
288+
* enforces, and no consumer tolerates any other shape. The producer
289+
* obligation is the other half of the same contract: a producer that omits
290+
* the key on an organization-stamped row publishes a cross-tenant event,
291+
* which is fixed at the publish site — never by a consumer-side lookup.
292+
*/
293+
organizationId: z.string().min(1).optional().describe(
294+
'Organization the record belongs to (its organization_id), so a tenant-scoped '
295+
+ 'consumer can discriminate the event\'s tenant without reading the record body. '
296+
+ 'Absent when the record belongs to no organization: every event on a single-posture '
297+
+ 'deployment (no organization wall, nothing stamps the column), and a row that '
298+
+ 'carries no organization under a walled posture (environment-wide, or an object '
299+
+ 'outside the wall) — read absence as '
300+
+ '"not behind any organization wall", never as "unknown". Present = exactly that '
301+
+ 'organization; never fabricated, and the empty string is refused.',
302+
),
303+
232304
/** Changed fields (update events only) */
233305
changes: z.record(z.string(), z.unknown()).optional().describe('Changed fields'),
234306

0 commit comments

Comments
 (0)