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
48 changes: 48 additions & 0 deletions .changeset/bulk-data-event-organization-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@objectstack/spec": minor
---

feat(spec): `BulkDataEvent` names the one organization a predicate write's affected records belong to

The realtime `BulkDataEvent` payload (`@objectstack/spec/api`, the body of every
`data.records.updated` / `data.records.deleted` event) gains an optional
`organizationId`: the organization every record the predicate write affected
belongs to — one organization for the whole batch, never per-row and never a
list. It takes the same spelling, the same position beside the match term
`object`, and the same refusal of the empty string as `DataEvent`'s
`organizationId`, so a tenant-scoped consumer discriminates both event families
on one key with one comparison — never a partition of the batch.

Why one organization can be honest on a batch that names no rows: a predicate
write reaches the driver with the security layer's tenant wall AND-composed
onto the caller's filter first (under `isolated` an equality on the caller's
active organization; under `group` membership in the caller's organization
set), and nothing in business RLS or sharing can widen it. When that wall names
exactly one organization, every affected row belongs to it, and the producer
can state so from what it already holds.

What a consumer may assume — and where this deliberately diverges from
`DataEvent`:

- **Present** — every record the write affected belongs to exactly that
organization. Never fabricated, and never the caller's active organization
standing in for the rows'.
- **Absent** — the producer did not assert one organization for the batch: every
event on a `single`-posture deployment; a system, environment-wide or
cross-membership predicate write; any write whose affected rows are not known
to belong to one organization. A bulk event names no rows, so absence is a
statement about the producer's knowledge, not about the rows. It is NOT
`DataEvent`'s reading "belongs to no organization, not behind any wall". A
tenant-scoped consumer (a per-organization webhook subscription, a
per-organization realtime subscriber) must treat an absent key as not
attributable to its organization and must not deliver the event inside an
organization wall; a deployment-wide consumer may use it.

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 "not asserted" has exactly one spelling — the key is absent.

Additive and shape-preserving: every bulk event that parsed before parses
identically, and no producer emits the key yet — the ObjectQL engine's bulk
publish site is a separate change that follows this contract. `DataEvent` and
`MetadataEvent` are unchanged.
1 change: 1 addition & 0 deletions content/docs/references/api/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const result = BulkDataEventSchema.parse(data);
| **id** | `string` | ✅ | Unique event identifier |
| **type** | `Enum<'data.records.updated' \| 'data.records.deleted'>` | ✅ | Event type |
| **object** | `string` | ✅ | Object name |
| **organizationId** | `string` | optional | Organization every record the predicate write affected belongs to — one organization for the whole batch, never per-row. Present = exactly that organization, asserted by the producer from the composed tenant wall, never fabricated and never the caller's active organization standing in for the rows'; the empty string is refused. Absent = the producer did not assert one organization for the batch (every event on a single-posture deployment; a system, environment-wide or cross-membership predicate write; any write whose affected rows are not known to belong to one organization) — deliberately NOT the DataEvent reading "belongs to no organization". A tenant-scoped consumer must treat an absent key as not attributable to its organization and must not deliver the event inside an organization wall; a deployment-wide consumer may use it. |
| **matched** | `integer` | ✅ | Number of records affected |
| **userId** | `string` | optional | User who triggered the event |
| **timestamp** | `string` | ✅ | Event timestamp |
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@
"api/BulkDataEvent:id",
"api/BulkDataEvent:matched",
"api/BulkDataEvent:object",
"api/BulkDataEvent:organizationId",
"api/BulkDataEvent:timestamp",
"api/BulkDataEvent:type",
"api/BulkDataEvent:userId",
Expand Down
108 changes: 104 additions & 4 deletions packages/spec/src/api/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
DataEventType,
MetadataEventSchema,
DataEventSchema,
BulkDataEventSchema,
type MetadataEventSubject,
} from './events.zod';

Expand Down Expand Up @@ -245,10 +246,10 @@ describe('DataEventSchema', () => {
// "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.
// refused at the path a producer can act on. `BulkDataEventSchema` carries
// the same key with a DIFFERENT absence reading — one organization for the
// whole batch, or "not asserted" — pinned in its own block below; the two
// blocks share the refusal pins so the key stays one spelling and one shape.
describe('organizationId', () => {
const base = {
id: '4b4720e8-97c3-4a12-9b70-b70a3d2314a6',
Expand Down Expand Up @@ -303,3 +304,102 @@ describe('DataEventSchema', () => {
});
});
});

describe('BulkDataEventSchema', () => {
// The bulk twin of the DataEvent tenant term. Same spelling, same refusal
// set, same optionality — and a deliberately DIFFERENT absence reading: a
// bulk event names no rows, so an absent key says the producer did not
// assert one organization for the batch, never "the rows belong to none".
// Present means ONE organization for the whole batch (never per-row, never a
// list), which is what keeps a tenant-scoped fan-out one comparison.
describe('organizationId', () => {
const base = {
id: '4b4720e8-97c3-4a12-9b70-b70a3d2314a7',
type: 'data.records.updated',
object: 'account',
matched: 40,
timestamp: '2026-09-04T00:00:00.000Z',
} as const;

it('parses without the key and does not fabricate one (absent = not asserted for the batch)', () => {
const event = BulkDataEventSchema.parse(base);
expect(Object.prototype.hasOwnProperty.call(event, 'organizationId')).toBe(false);
expect(event.organizationId).toBeUndefined();
});

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

it('refuses a non-string value with invalid_type at ["organizationId"]', () => {
const result = BulkDataEventSchema.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 — "not asserted" has exactly one spelling, the missing key', () => {
const result = BulkDataEventSchema.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 — one organization is never spelled ""', () => {
const result = BulkDataEventSchema.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('refuses a per-row list — the batch carries one organization, never an array', () => {
const result = BulkDataEventSchema.safeParse({ ...base, organizationId: ['org_jia', 'org_yi'] });
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('is the only member added — every pre-existing member is still declared, and no plural spelling exists', () => {
expect(Object.keys(BulkDataEventSchema.shape).sort()).toEqual([
'id', 'matched', 'object', 'organizationId', 'timestamp', 'type', 'userId',
]);
expect('organizationIds' in BulkDataEventSchema.shape).toBe(false);
expect('organizationIds' in DataEventSchema.shape).toBe(false);
});

it('spells and shapes the key identically to DataEventSchema.organizationId', () => {
// Structural identity of the two declarations: both optional, both a
// string with the same minimum-length check, so a consumer discriminates
// both event families on ONE key with ONE comparison.
const bulk = BulkDataEventSchema.shape.organizationId;
const single = DataEventSchema.shape.organizationId;
expect(bulk.def.type).toBe(single.def.type);
expect(bulk.def.type).toBe('optional');
expect(bulk.def.innerType.def.type).toBe(single.def.innerType.def.type);
expect(bulk.def.innerType.def.type).toBe('string');
expect(bulk.def.innerType.def.checks).toEqual(single.def.innerType.def.checks);
// And behaviourally: the same probes yield the same verdicts on both.
for (const probe of ['', null, 42, ['org_jia']]) {
const b = bulk.safeParse(probe);
const s = single.safeParse(probe);
expect(b.success).toBe(false);
expect(s.success).toBe(false);
if (b.success || s.success) throw new Error('unreachable');
expect(b.error.issues.map((i) => i.code)).toEqual(s.error.issues.map((i) => i.code));
}
expect(bulk.safeParse('org_jia')).toEqual(single.safeParse('org_jia'));
expect(bulk.safeParse(undefined).success).toBe(true);
expect(single.safeParse(undefined).success).toBe(true);
});
});
});
64 changes: 64 additions & 0 deletions packages/spec/src/api/events.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,14 @@ export type DataEvent = z.input<typeof DataEventSchema>;
* external URL a webhook points at. The caller's own pre-composition filter is
* no better: it is a second, divergent answer to "what did this write touch".
* So the event reports the count it can state truthfully and stops there.
*
* **The tenant term is ONE organization for the whole batch, or nothing.**
* `organizationId` (below) names the organization every affected record
* belongs to. It is never per-row — a bulk event names no rows, so a per-row
* answer would have nothing to attach to — and never a list. That is what
* keeps a tenant-scoped fan-out one comparison, never a partition of the
* batch. Its ABSENCE means something different from the same key's absence on
* {@link DataEventSchema}; the member's own doc states both readings.
*/
export const BulkDataEventSchema = lazySchema(() => z.object({
/** Unique event identifier */
Expand All @@ -355,6 +363,62 @@ export const BulkDataEventSchema = lazySchema(() => z.object({
/** Object name */
object: z.string().describe('Object name'),

/**
* Organization every record the predicate write affected belongs to — ONE
* organization for the whole batch, never per-row and never a list. Same
* spelling, same refusal of the empty string, and the same position (beside
* the match term `object`) as {@link DataEventSchema}'s `organizationId`, so
* a tenant-scoped consumer discriminates both event families on one key.
*
* **One for the batch, by construction.** A predicate write reaches the
* driver with the middleware-COMPOSED query (see "Why there is no `where`"
* above): under a walled posture the security layer AND-composes its tenant
* wall (Layer 0, ADR-0095 D1) onto the caller's filter first — under
* `isolated` an equality on the caller's active organization, under `group`
* membership in the caller's organization set (ADR-0105 D2) — and nothing in
* Layer 1 (business RLS, sharing's editable-rows filter) can widen it. So
* when the wall names exactly one organization, every affected row belongs
* to it, and the producer can state that from what it already holds: no
* second query on the publish path.
*
* **Present = every affected record belongs to exactly this organization.**
* Never fabricated (no `.default()`, the empty string is refused), and never
* the caller's active organization standing in for the rows': under `group`
* the wall is the caller's membership SET, so the batch is attributable to
* one organization only when that set names exactly one.
*
* **Absent = the producer did not assert one organization for the batch.**
* Deliberately DIVERGENT from `DataEventSchema.organizationId`, whose absence
* means "this record belongs to no organization, not behind any wall". A
* bulk event names no rows, so its absence is a statement about the
* producer's knowledge, not about the rows: every event on a
* `single`-posture deployment (no wall); an environment-wide or system /
* unscoped predicate write (an `isSystem` context, a true `PLATFORM_ADMIN`
* crossing the wall); a `group`-posture sweep across several memberships;
* any write whose affected rows are not known to belong to one
* organization. It is NOT the single-record reading "belongs to no
* organization".
*
* **What a consumer may do with each reading.** A tenant-scoped consumer —
* a per-organization webhook subscription, a per-organization realtime
* subscriber — matches on equality when the key is present, and MUST treat
* an absent key as "not attributable to my organization": it must not
* deliver that event inside an organization wall. A deployment-wide consumer
* may use it. Either way the fan-out filter stays one comparison.
*/
organizationId: z.string().min(1).optional().describe(
'Organization every record the predicate write affected belongs to — one organization '
+ 'for the whole batch, never per-row. Present = exactly that organization, asserted by '
+ 'the producer from the composed tenant wall, never fabricated and never the caller\'s '
+ 'active organization standing in for the rows\'; the empty string is refused. '
+ 'Absent = the producer did not assert one organization for the batch (every event on a '
+ 'single-posture deployment; a system, environment-wide or cross-membership predicate '
+ 'write; any write whose affected rows are not known to belong to one organization) — '
+ 'deliberately NOT the DataEvent reading "belongs to no organization". A tenant-scoped '
+ 'consumer must treat an absent key as not attributable to its organization and must '
+ 'not deliver the event inside an organization wall; a deployment-wide consumer may use it.',
),

/**
* Number of records the predicate write affected. The ObjectQL engine does
* not publish an event at all when this would be `0` — a predicate that
Expand Down
Loading