Skip to content

Commit 97bcd99

Browse files
os-justinclaude
andauthored
feat(spec): BulkDataEvent carries organizationId — one organization for the whole batch, or not asserted (#15218)
* wip(spec): BulkDataEvent organizationId — one organization for the whole batch Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk * chore(spec): regenerate the BulkDataEvent docs row and authorable-surface entry Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk --------- Co-authored-by: claude <noreply@anthropic.com>
1 parent a23603e commit 97bcd99

5 files changed

Lines changed: 218 additions & 4 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `BulkDataEvent` names the one organization a predicate write's affected records belong to
6+
7+
The realtime `BulkDataEvent` payload (`@objectstack/spec/api`, the body of every
8+
`data.records.updated` / `data.records.deleted` event) gains an optional
9+
`organizationId`: the organization every record the predicate write affected
10+
belongs to — one organization for the whole batch, never per-row and never a
11+
list. It takes the same spelling, the same position beside the match term
12+
`object`, and the same refusal of the empty string as `DataEvent`'s
13+
`organizationId`, so a tenant-scoped consumer discriminates both event families
14+
on one key with one comparison — never a partition of the batch.
15+
16+
Why one organization can be honest on a batch that names no rows: a predicate
17+
write reaches the driver with the security layer's tenant wall AND-composed
18+
onto the caller's filter first (under `isolated` an equality on the caller's
19+
active organization; under `group` membership in the caller's organization
20+
set), and nothing in business RLS or sharing can widen it. When that wall names
21+
exactly one organization, every affected row belongs to it, and the producer
22+
can state so from what it already holds.
23+
24+
What a consumer may assume — and where this deliberately diverges from
25+
`DataEvent`:
26+
27+
- **Present** — every record the write affected belongs to exactly that
28+
organization. Never fabricated, and never the caller's active organization
29+
standing in for the rows'.
30+
- **Absent** — the producer did not assert one organization for the batch: every
31+
event on a `single`-posture deployment; a system, environment-wide or
32+
cross-membership predicate write; any write whose affected rows are not known
33+
to belong to one organization. A bulk event names no rows, so absence is a
34+
statement about the producer's knowledge, not about the rows. It is NOT
35+
`DataEvent`'s reading "belongs to no organization, not behind any wall". A
36+
tenant-scoped consumer (a per-organization webhook subscription, a
37+
per-organization realtime subscriber) must treat an absent key as not
38+
attributable to its organization and must not deliver the event inside an
39+
organization wall; a deployment-wide consumer may use it.
40+
41+
Declared = enforced: the key is optional and nothing else. No default
42+
fabricates a tenant; `null` and the empty string are refused with a located
43+
issue, so "not asserted" has exactly one spelling — the key is absent.
44+
45+
Additive and shape-preserving: every bulk event that parsed before parses
46+
identically, and no producer emits the key yet — the ObjectQL engine's bulk
47+
publish site is a separate change that follows this contract. `DataEvent` and
48+
`MetadataEvent` are unchanged.

content/docs/references/api/events.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const result = BulkDataEventSchema.parse(data);
3030
| **id** | `string` || Unique event identifier |
3131
| **type** | `Enum<'data.records.updated' \| 'data.records.deleted'>` || Event type |
3232
| **object** | `string` || Object name |
33+
| **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. |
3334
| **matched** | `integer` || Number of records affected |
3435
| **userId** | `string` | optional | User who triggered the event |
3536
| **timestamp** | `string` || Event timestamp |

packages/spec/authorable-surface/api.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@
279279
"api/BulkDataEvent:id",
280280
"api/BulkDataEvent:matched",
281281
"api/BulkDataEvent:object",
282+
"api/BulkDataEvent:organizationId",
282283
"api/BulkDataEvent:timestamp",
283284
"api/BulkDataEvent:type",
284285
"api/BulkDataEvent:userId",

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

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
DataEventType,
55
MetadataEventSchema,
66
DataEventSchema,
7+
BulkDataEventSchema,
78
type MetadataEventSubject,
89
} from './events.zod';
910

@@ -245,10 +246,10 @@ describe('DataEventSchema', () => {
245246
// "declared = enforced" is a measurement rather than a sentence: the key is
246247
// optional and NOTHING else — no default fabricates a tenant, absence has
247248
// exactly one spelling, and a value that is not a non-empty string is
248-
// refused at the path a producer can act on. `BulkDataEventSchema` is
249-
// deliberately untouched here: a predicate write's affected set is a
250-
// separate contract with its own tenant question (recorded on the change
251-
// that adds this member), so nothing below pins that schema either way.
249+
// refused at the path a producer can act on. `BulkDataEventSchema` carries
250+
// the same key with a DIFFERENT absence reading — one organization for the
251+
// whole batch, or "not asserted" — pinned in its own block below; the two
252+
// blocks share the refusal pins so the key stays one spelling and one shape.
252253
describe('organizationId', () => {
253254
const base = {
254255
id: '4b4720e8-97c3-4a12-9b70-b70a3d2314a6',
@@ -303,3 +304,102 @@ describe('DataEventSchema', () => {
303304
});
304305
});
305306
});
307+
308+
describe('BulkDataEventSchema', () => {
309+
// The bulk twin of the DataEvent tenant term. Same spelling, same refusal
310+
// set, same optionality — and a deliberately DIFFERENT absence reading: a
311+
// bulk event names no rows, so an absent key says the producer did not
312+
// assert one organization for the batch, never "the rows belong to none".
313+
// Present means ONE organization for the whole batch (never per-row, never a
314+
// list), which is what keeps a tenant-scoped fan-out one comparison.
315+
describe('organizationId', () => {
316+
const base = {
317+
id: '4b4720e8-97c3-4a12-9b70-b70a3d2314a7',
318+
type: 'data.records.updated',
319+
object: 'account',
320+
matched: 40,
321+
timestamp: '2026-09-04T00:00:00.000Z',
322+
} as const;
323+
324+
it('parses without the key and does not fabricate one (absent = not asserted for the batch)', () => {
325+
const event = BulkDataEventSchema.parse(base);
326+
expect(Object.prototype.hasOwnProperty.call(event, 'organizationId')).toBe(false);
327+
expect(event.organizationId).toBeUndefined();
328+
});
329+
330+
it('parses with the key and carries the one batch organization through verbatim', () => {
331+
const event = BulkDataEventSchema.parse({ ...base, organizationId: 'org_jia' });
332+
expect(event.organizationId).toBe('org_jia');
333+
expect(event.matched).toBe(40);
334+
});
335+
336+
it('refuses a non-string value with invalid_type at ["organizationId"]', () => {
337+
const result = BulkDataEventSchema.safeParse({ ...base, organizationId: 42 });
338+
expect(result.success).toBe(false);
339+
if (result.success) throw new Error('unreachable');
340+
expect(result.error.issues).toEqual([
341+
expect.objectContaining({ code: 'invalid_type', expected: 'string', path: ['organizationId'] }),
342+
]);
343+
});
344+
345+
it('refuses null — "not asserted" has exactly one spelling, the missing key', () => {
346+
const result = BulkDataEventSchema.safeParse({ ...base, organizationId: null });
347+
expect(result.success).toBe(false);
348+
if (result.success) throw new Error('unreachable');
349+
expect(result.error.issues).toEqual([
350+
expect.objectContaining({ code: 'invalid_type', expected: 'string', path: ['organizationId'] }),
351+
]);
352+
});
353+
354+
it('refuses the empty string — one organization is never spelled ""', () => {
355+
const result = BulkDataEventSchema.safeParse({ ...base, organizationId: '' });
356+
expect(result.success).toBe(false);
357+
if (result.success) throw new Error('unreachable');
358+
expect(result.error.issues).toEqual([
359+
expect.objectContaining({ code: 'too_small', minimum: 1, path: ['organizationId'] }),
360+
]);
361+
});
362+
363+
it('refuses a per-row list — the batch carries one organization, never an array', () => {
364+
const result = BulkDataEventSchema.safeParse({ ...base, organizationId: ['org_jia', 'org_yi'] });
365+
expect(result.success).toBe(false);
366+
if (result.success) throw new Error('unreachable');
367+
expect(result.error.issues).toEqual([
368+
expect.objectContaining({ code: 'invalid_type', expected: 'string', path: ['organizationId'] }),
369+
]);
370+
});
371+
372+
it('is the only member added — every pre-existing member is still declared, and no plural spelling exists', () => {
373+
expect(Object.keys(BulkDataEventSchema.shape).sort()).toEqual([
374+
'id', 'matched', 'object', 'organizationId', 'timestamp', 'type', 'userId',
375+
]);
376+
expect('organizationIds' in BulkDataEventSchema.shape).toBe(false);
377+
expect('organizationIds' in DataEventSchema.shape).toBe(false);
378+
});
379+
380+
it('spells and shapes the key identically to DataEventSchema.organizationId', () => {
381+
// Structural identity of the two declarations: both optional, both a
382+
// string with the same minimum-length check, so a consumer discriminates
383+
// both event families on ONE key with ONE comparison.
384+
const bulk = BulkDataEventSchema.shape.organizationId;
385+
const single = DataEventSchema.shape.organizationId;
386+
expect(bulk.def.type).toBe(single.def.type);
387+
expect(bulk.def.type).toBe('optional');
388+
expect(bulk.def.innerType.def.type).toBe(single.def.innerType.def.type);
389+
expect(bulk.def.innerType.def.type).toBe('string');
390+
expect(bulk.def.innerType.def.checks).toEqual(single.def.innerType.def.checks);
391+
// And behaviourally: the same probes yield the same verdicts on both.
392+
for (const probe of ['', null, 42, ['org_jia']]) {
393+
const b = bulk.safeParse(probe);
394+
const s = single.safeParse(probe);
395+
expect(b.success).toBe(false);
396+
expect(s.success).toBe(false);
397+
if (b.success || s.success) throw new Error('unreachable');
398+
expect(b.error.issues.map((i) => i.code)).toEqual(s.error.issues.map((i) => i.code));
399+
}
400+
expect(bulk.safeParse('org_jia')).toEqual(single.safeParse('org_jia'));
401+
expect(bulk.safeParse(undefined).success).toBe(true);
402+
expect(single.safeParse(undefined).success).toBe(true);
403+
});
404+
});
405+
});

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,14 @@ export type DataEvent = z.input<typeof DataEventSchema>;
344344
* external URL a webhook points at. The caller's own pre-composition filter is
345345
* no better: it is a second, divergent answer to "what did this write touch".
346346
* So the event reports the count it can state truthfully and stops there.
347+
*
348+
* **The tenant term is ONE organization for the whole batch, or nothing.**
349+
* `organizationId` (below) names the organization every affected record
350+
* belongs to. It is never per-row — a bulk event names no rows, so a per-row
351+
* answer would have nothing to attach to — and never a list. That is what
352+
* keeps a tenant-scoped fan-out one comparison, never a partition of the
353+
* batch. Its ABSENCE means something different from the same key's absence on
354+
* {@link DataEventSchema}; the member's own doc states both readings.
347355
*/
348356
export const BulkDataEventSchema = lazySchema(() => z.object({
349357
/** Unique event identifier */
@@ -355,6 +363,62 @@ export const BulkDataEventSchema = lazySchema(() => z.object({
355363
/** Object name */
356364
object: z.string().describe('Object name'),
357365

366+
/**
367+
* Organization every record the predicate write affected belongs to — ONE
368+
* organization for the whole batch, never per-row and never a list. Same
369+
* spelling, same refusal of the empty string, and the same position (beside
370+
* the match term `object`) as {@link DataEventSchema}'s `organizationId`, so
371+
* a tenant-scoped consumer discriminates both event families on one key.
372+
*
373+
* **One for the batch, by construction.** A predicate write reaches the
374+
* driver with the middleware-COMPOSED query (see "Why there is no `where`"
375+
* above): under a walled posture the security layer AND-composes its tenant
376+
* wall (Layer 0, ADR-0095 D1) onto the caller's filter first — under
377+
* `isolated` an equality on the caller's active organization, under `group`
378+
* membership in the caller's organization set (ADR-0105 D2) — and nothing in
379+
* Layer 1 (business RLS, sharing's editable-rows filter) can widen it. So
380+
* when the wall names exactly one organization, every affected row belongs
381+
* to it, and the producer can state that from what it already holds: no
382+
* second query on the publish path.
383+
*
384+
* **Present = every affected record belongs to exactly this organization.**
385+
* Never fabricated (no `.default()`, the empty string is refused), and never
386+
* the caller's active organization standing in for the rows': under `group`
387+
* the wall is the caller's membership SET, so the batch is attributable to
388+
* one organization only when that set names exactly one.
389+
*
390+
* **Absent = the producer did not assert one organization for the batch.**
391+
* Deliberately DIVERGENT from `DataEventSchema.organizationId`, whose absence
392+
* means "this record belongs to no organization, not behind any wall". A
393+
* bulk event names no rows, so its absence is a statement about the
394+
* producer's knowledge, not about the rows: every event on a
395+
* `single`-posture deployment (no wall); an environment-wide or system /
396+
* unscoped predicate write (an `isSystem` context, a true `PLATFORM_ADMIN`
397+
* crossing the wall); a `group`-posture sweep across several memberships;
398+
* any write whose affected rows are not known to belong to one
399+
* organization. It is NOT the single-record reading "belongs to no
400+
* organization".
401+
*
402+
* **What a consumer may do with each reading.** A tenant-scoped consumer —
403+
* a per-organization webhook subscription, a per-organization realtime
404+
* subscriber — matches on equality when the key is present, and MUST treat
405+
* an absent key as "not attributable to my organization": it must not
406+
* deliver that event inside an organization wall. A deployment-wide consumer
407+
* may use it. Either way the fan-out filter stays one comparison.
408+
*/
409+
organizationId: z.string().min(1).optional().describe(
410+
'Organization every record the predicate write affected belongs to — one organization '
411+
+ 'for the whole batch, never per-row. Present = exactly that organization, asserted by '
412+
+ 'the producer from the composed tenant wall, never fabricated and never the caller\'s '
413+
+ 'active organization standing in for the rows\'; the empty string is refused. '
414+
+ 'Absent = the producer did not assert one organization for the batch (every event on a '
415+
+ 'single-posture deployment; a system, environment-wide or cross-membership predicate '
416+
+ 'write; any write whose affected rows are not known to belong to one organization) — '
417+
+ 'deliberately NOT the DataEvent reading "belongs to no organization". A tenant-scoped '
418+
+ 'consumer must treat an absent key as not attributable to its organization and must '
419+
+ 'not deliver the event inside an organization wall; a deployment-wide consumer may use it.',
420+
),
421+
358422
/**
359423
* Number of records the predicate write affected. The ObjectQL engine does
360424
* not publish an event at all when this would be `0` — a predicate that

0 commit comments

Comments
 (0)