Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/data-event-organization-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
"@objectstack/spec": minor
---

feat(spec): `DataEvent` names the organization the record belongs to, so a tenant-scoped consumer can tell whose event it is

The realtime `DataEvent` payload (`@objectstack/spec/api`, the body of every
`data.record.created` / `data.record.updated` / `data.record.deleted` event)
gains an optional `organizationId`: the organization the record belongs to.
Until now the event carried the object name, the record id and the row body,
and nothing that named the tenant — so a consumer that fans events out per
organization (a webhook subscription, a per-organization realtime subscriber)
had no term to discriminate on short of reading the row body, which is absent
on delete events and is not the consumer's to read.

What a consumer may assume:

- **Present** — exactly that organization, never a guess: the organization the
record belongs to, not the caller's active organization standing in for it.
- **Absent** — the record belongs to no organization. That is every event on a
`single`-posture deployment (no organization wall, nothing stamps the
column) and an organization-less, environment-wide row under a walled
posture. Read it as "not behind any organization wall", never as "unknown,
look it up".

Declared = enforced: the key is optional and nothing else. No default
fabricates a tenant; `null` and the empty string are refused with a located
issue, so "no organization" has exactly one spelling — the key is absent.

Additive and shape-preserving: every event that parsed before parses
identically, and no producer emits the key yet — the ObjectQL engine's publish
site is a separate change that follows this contract. The bulk
`BulkDataEvent` (`data.records.*`) is deliberately untouched: a predicate
write's affected set is its own contract with its own tenant question.
1 change: 1 addition & 0 deletions content/docs/references/api/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const result = BulkDataEventSchema.parse(data);
| **type** | `Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted'>` | ✅ | Event type |
| **object** | `string` | ✅ | Object name |
| **recordId** | `string` | ✅ | Record ID |
| **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. |
| **changes** | `Record<string, any>` | optional | Changed fields |
| **before** | `Record<string, any>` | optional | Before state |
| **after** | `Record<string, any>` | optional | After state |
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@
"api/DataEvent:changes",
"api/DataEvent:id",
"api/DataEvent:object",
"api/DataEvent:organizationId",
"api/DataEvent:recordId",
"api/DataEvent:timestamp",
"api/DataEvent:type",
Expand Down
63 changes: 63 additions & 0 deletions packages/spec/src/api/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,4 +239,67 @@ describe('DataEventSchema', () => {
expect(event.before).toEqual({ name: 'Old Name' });
expect(event.after).toEqual({ name: 'New Name' });
});

// The tenant term — the contract half of closing the webhook fan-out's
// cross-organization delivery. Both directions are pinned so
// "declared = enforced" is a measurement rather than a sentence: the key is
// optional and NOTHING else — no default fabricates a tenant, absence has
// exactly one spelling, and a value that is not a non-empty string is
// refused at the path a producer can act on. `BulkDataEventSchema` is
// deliberately untouched here: a predicate write's affected set is a
// separate contract with its own tenant question (recorded on the change
// that adds this member), so nothing below pins that schema either way.
describe('organizationId', () => {
const base = {
id: '4b4720e8-97c3-4a12-9b70-b70a3d2314a6',
type: 'data.record.created',
object: 'account',
recordId: 'rec_1',
timestamp: '2026-09-02T00:00:00.000Z',
} as const;

it('parses without the key and does not fabricate one (single posture: no wall, no organization)', () => {
const event = DataEventSchema.parse(base);
expect(Object.prototype.hasOwnProperty.call(event, 'organizationId')).toBe(false);
expect(event.organizationId).toBeUndefined();
});

it('parses with the key and carries it through verbatim', () => {
const event = DataEventSchema.parse({ ...base, organizationId: 'org_jia' });
expect(event.organizationId).toBe('org_jia');
});

it('refuses a non-string value with invalid_type at ["organizationId"]', () => {
const result = DataEventSchema.safeParse({ ...base, organizationId: 42 });
expect(result.success).toBe(false);
if (result.success) throw new Error('unreachable');
expect(result.error.issues).toEqual([
expect.objectContaining({ code: 'invalid_type', expected: 'string', path: ['organizationId'] }),
]);
});

it('refuses null — absence has exactly one spelling, the missing key', () => {
const result = DataEventSchema.safeParse({ ...base, organizationId: null });
expect(result.success).toBe(false);
if (result.success) throw new Error('unreachable');
expect(result.error.issues).toEqual([
expect.objectContaining({ code: 'invalid_type', expected: 'string', path: ['organizationId'] }),
]);
});

it('refuses the empty string — "no organization" is never spelled ""', () => {
const result = DataEventSchema.safeParse({ ...base, organizationId: '' });
expect(result.success).toBe(false);
if (result.success) throw new Error('unreachable');
expect(result.error.issues).toEqual([
expect.objectContaining({ code: 'too_small', minimum: 1, path: ['organizationId'] }),
]);
});

it('is the only member added — every pre-existing member is still declared', () => {
expect(Object.keys(DataEventSchema.shape).sort()).toEqual([
'after', 'before', 'changes', 'id', 'object', 'organizationId', 'recordId', 'timestamp', 'type', 'userId',
]);
});
});
});
72 changes: 72 additions & 0 deletions packages/spec/src/api/events.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,18 @@ export type MetadataEvent = z.input<typeof MetadataEventSchema>;
*
* Represents a data record change event (create, update, delete).
* Used for real-time synchronization of data records across clients.
*
* **This payload IS the contract a consumer discriminates on.** It travels as
* the `payload` of the `RealtimeEventPayload` envelope
* (`contracts/realtime-service.ts`), and the envelope is a transport shape —
* `type` / `object` / `payload` / `timestamp`, a TypeScript interface no
* parse ever validates. Every consumer that must read a per-event fact
* already reads it HERE, not on the envelope: the webhook fan-out takes
* `recordId` from the payload at its match site, and the client SDK
* `safeParse`s the payload against this schema before it delivers anything.
* So the tenant term below is a member of this validated payload rather than
* a second, unvalidated envelope field: one declaration, enforced at the
* publish site by the same `parse` that enforces `recordId`.
*/
export const DataEventSchema = lazySchema(() => z.object({
/** Unique event identifier */
Expand All @@ -229,6 +241,66 @@ export const DataEventSchema = lazySchema(() => z.object({
/** Record ID */
recordId: z.string().describe('Record ID'),

/**
* Organization the record belongs to — its `organization_id` column, under
* the camelCase spelling every published payload in this package uses for
* the tenant term (`organizationId`, the blessed developer-facing name).
*
* **Why a first-class member and not a read of the record body.** A
* tenant-scoped consumer — the webhook fan-out matching subscriptions to
* events, a per-organization realtime subscriber — must discriminate the
* event's tenant BEFORE it touches the record: `after` is absent on
* `data.record.deleted`, `before` is absent on create, and both are the
* unfiltered row body the consumer may not be entitled to read at all. The
* match term therefore rides beside `object` and `recordId`, validated
* with the rest of the event at the publish site.
*
* **Absent = the record belongs to no organization.** Two situations, one
* meaning:
* - a `single`-posture deployment — `postureEnforcesWall(posture)` is
* `false` and `postureStampsOrganization(posture)` with it (see
* `@objectstack/spec/security`): there is no organization wall and
* nothing stamps the column, so EVERY event is organization-less;
* - a row that carries no organization under a walled posture (`group` /
* `isolated`): an environment-wide row (`organization_id IS NULL`), or a
* row of an object that stands outside the wall — `tenancy.enabled:
* false` by declaration, or no `organization_id` column at all (the
* identity tables).
* In both, a consumer may read absence as "not behind any organization
* wall" — the reading it already gives an `organization_id IS NULL` row on
* the read path. It may NOT read absence as "unknown, resolve it yourself":
* either the producer had the organization in hand or the record has none,
* and a per-event lookup on the fan-out path is exactly the hot-path read
* this member exists to make unnecessary.
*
* **Present = exactly that organization, never a guess.** It names the
* organization the RECORD belongs to — not the caller's active organization
* standing in for the row's, which would mislabel an administrator's write
* into another organization. It is never fabricated: no `.default()`, and
* the empty string is refused, so "no organization" has exactly one
* spelling — the key is absent.
*
* **Optional as a contract fact, not as a transition.** A `single`-posture
* deployment stays organization-less for its whole life, so a required key
* would either force a fabricated tenant there or leave the engine unable to
* publish at all (the publish site `parse`s the event and drops it on
* failure). Declared = enforced: this optionality is exactly what validation
* enforces, and no consumer tolerates any other shape. The producer
* obligation is the other half of the same contract: a producer that omits
* the key on an organization-stamped row publishes a cross-tenant event,
* which is fixed at the publish site — never by a consumer-side lookup.
*/
organizationId: z.string().min(1).optional().describe(
'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.',
),

/** Changed fields (update events only) */
changes: z.record(z.string(), z.unknown()).optional().describe('Changed fields'),

Expand Down
Loading