From dc666a9d9f3a70483998d78041130430a3468488 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 14:55:18 +0000 Subject: [PATCH 1/2] feat(spec): DataEvent carries the organization the record belongs to Adds the optional, non-empty organizationId member to DataEventSchema so a tenant-scoped consumer (webhook fan-out, per-organization realtime subscriber) can discriminate an event's tenant without reading the record body. Absent = the record belongs to no organization (single posture, or an organization-less row under a wall); present = exactly that organization. No default, empty string refused: declared = enforced. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE --- .changeset/data-event-organization-id.md | 34 +++++++++++ packages/spec/src/api/events.test.ts | 63 +++++++++++++++++++++ packages/spec/src/api/events.zod.ts | 72 ++++++++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 .changeset/data-event-organization-id.md diff --git a/.changeset/data-event-organization-id.md b/.changeset/data-event-organization-id.md new file mode 100644 index 0000000000..17aeee5783 --- /dev/null +++ b/.changeset/data-event-organization-id.md @@ -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. diff --git a/packages/spec/src/api/events.test.ts b/packages/spec/src/api/events.test.ts index f0214b42b5..c6e4a9d6c8 100644 --- a/packages/spec/src/api/events.test.ts +++ b/packages/spec/src/api/events.test.ts @@ -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', + ]); + }); + }); }); diff --git a/packages/spec/src/api/events.zod.ts b/packages/spec/src/api/events.zod.ts index 1246a568de..49d09953b7 100644 --- a/packages/spec/src/api/events.zod.ts +++ b/packages/spec/src/api/events.zod.ts @@ -215,6 +215,18 @@ export type MetadataEvent = z.input; * * 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 */ @@ -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'), From 307b5a69edb01e7cf0b1c748d7a06bd775bc1df2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 15:22:35 +0000 Subject: [PATCH 2/2] chore(spec): regenerate products for DataEvent.organizationId gen:schema (authorable-surface/api.json) and gen:docs (content/docs/references/api/events.mdx), as check:generated --fix proved stale; api-surface and the JSON schema manifest were already current. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE --- 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 f97a8ad110..6e21183bd1 100644 --- a/content/docs/references/api/events.mdx +++ b/content/docs/references/api/events.mdx @@ -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` | optional | Changed fields | | **before** | `Record` | optional | Before state | | **after** | `Record` | optional | After state | diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index bc5a9b3c55..82433e0da8 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -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",