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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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