From 7dfe65b85077e892c84e7f063040d0b587516a51 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 4 Sep 2026 05:47:24 +0000 Subject: [PATCH 1/2] =?UTF-8?q?wip(spec):=20BulkDataEvent=20organizationId?= =?UTF-8?q?=20=E2=80=94=20one=20organization=20for=20the=20whole=20batch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk --- .changeset/bulk-data-event-organization-id.md | 48 ++++++++ packages/spec/src/api/events.test.ts | 108 +++++++++++++++++- packages/spec/src/api/events.zod.ts | 64 +++++++++++ 3 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 .changeset/bulk-data-event-organization-id.md diff --git a/.changeset/bulk-data-event-organization-id.md b/.changeset/bulk-data-event-organization-id.md new file mode 100644 index 0000000000..b5f68561d9 --- /dev/null +++ b/.changeset/bulk-data-event-organization-id.md @@ -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. diff --git a/packages/spec/src/api/events.test.ts b/packages/spec/src/api/events.test.ts index c6e4a9d6c8..3de3de7fa9 100644 --- a/packages/spec/src/api/events.test.ts +++ b/packages/spec/src/api/events.test.ts @@ -4,6 +4,7 @@ import { DataEventType, MetadataEventSchema, DataEventSchema, + BulkDataEventSchema, type MetadataEventSubject, } from './events.zod'; @@ -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', @@ -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); + }); + }); +}); diff --git a/packages/spec/src/api/events.zod.ts b/packages/spec/src/api/events.zod.ts index 49d09953b7..ea9532c079 100644 --- a/packages/spec/src/api/events.zod.ts +++ b/packages/spec/src/api/events.zod.ts @@ -344,6 +344,14 @@ export type DataEvent = z.input; * 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 */ @@ -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 From 9ffb659b38c07ae5f83c41f45fdd596729d38b6e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 05:53:47 +0000 Subject: [PATCH 2/2] chore(spec): regenerate the BulkDataEvent docs row and authorable-surface entry Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk --- content/docs/references/api/events.mdx | 1 + packages/spec/authorable-surface/api.json | 1 + 2 files changed, 2 insertions(+) diff --git a/content/docs/references/api/events.mdx b/content/docs/references/api/events.mdx index 6e21183bd1..8e2bb9e711 100644 --- a/content/docs/references/api/events.mdx +++ b/content/docs/references/api/events.mdx @@ -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 | diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 0026219134..fddc7a4a9f 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -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",