From 2d3721aa3a0a87731c516ab650dff6b7cb7935ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 02:18:29 +0000 Subject: [PATCH 1/5] fix(objectql): a published BulkDataEvent names the one organization the tenant wall named for the batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bulk producer (`publishBulkDataEvent`, behind the predicate `update()` / `delete()` branches) never set `BulkDataEventSchema.organizationId`, so every `data.records.*` event read "not asserted" on the bulk path — the remaining half of the cross-tenant webhook fan-out leak. It now stamps the key from what it already holds (the execution context the Layer 0 wall was computed from, and the posture SecurityPlugin injected), with no second query: present under `isolated` (active organization) and singleton-membership `group`; omitted for `single`, `isSystem`, multi-membership `group`, non-walled objects, no injected posture, a PLATFORM_ADMIN rung or no rung. The value coercion is shared with the per-record helper (one ladder, two readers). Pins in engine-data-events.test.ts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .changeset/bulk-event-batch-organization.md | 31 ++ .../objectql/src/engine-data-events.test.ts | 319 ++++++++++++++++++ packages/objectql/src/engine.ts | 161 ++++++++- 3 files changed, 503 insertions(+), 8 deletions(-) create mode 100644 .changeset/bulk-event-batch-organization.md diff --git a/.changeset/bulk-event-batch-organization.md b/.changeset/bulk-event-batch-organization.md new file mode 100644 index 0000000000..f86b2d844c --- /dev/null +++ b/.changeset/bulk-event-batch-organization.md @@ -0,0 +1,31 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): a published `BulkDataEvent` now names the ONE organization the tenant wall named for the batch + +`BulkDataEventSchema.organizationId` (`@objectstack/spec/api`, declared by the +contract half) is one organization for a whole predicate write, or absent. The +only bulk producer — `publishBulkDataEvent`, behind the `multi: true` branches +of `update()` / `delete()` — never set it, so every `data.records.updated` / +`data.records.deleted` event read "not asserted" and a tenant-scoped consumer +could deliver nothing per organization on the bulk path. This is the bulk half +of the cross-tenant webhook fan-out leak; the single-record half (`DataEvent`) +landed separately. + +The producer now stamps the key from what it already holds — no second query +on the publish path: under `isolated` the caller's active organization (the +Layer 0 wall's equality term), under `group` the caller's membership set when +it names exactly one organization. It is OMITTED — never the caller's active +organization standing in — on a `single`-posture deployment, on an `isSystem` +context (no wall composed), on a multi-membership `group` sweep, on an object +the wall does not key on, when no enforcement layer injected a posture, and +when the caller may have crossed the wall as a `PLATFORM_ADMIN` or carries no +resolved posture rung. `absent` here means "the producer did not assert one +organization for the batch", deliberately NOT the `DataEvent` reading +"belongs to no organization". + +`patch`, not `minor`: the contract surface widened with the spec half (at +`minor`); this change makes the producer honour a key that surface already +declares, adds no export or API shape, and follows the level the single-record +producer half landed at. diff --git a/packages/objectql/src/engine-data-events.test.ts b/packages/objectql/src/engine-data-events.test.ts index 9c5a8cf64a..4b95d8d970 100644 --- a/packages/objectql/src/engine-data-events.test.ts +++ b/packages/objectql/src/engine-data-events.test.ts @@ -26,6 +26,8 @@ * `data.records.deleted`, carrying `matched` and NO `recordId`. */ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { BulkDataEventSchema, DataEventSchema } from '@objectstack/spec/api'; import type { IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts'; @@ -633,3 +635,320 @@ describe('#14970 — a published DataEvent names the RECORD\'s organization', () expect(hasOrgKey(2)).toBe(false); }); }); + +/** + * #15225 — the producer half of `BulkDataEvent.organizationId`: the bulk + * sibling of the #14970 block above, and the remaining half of the + * cross-tenant webhook leak (#13566) whose single-record half #14970 closed. + * + * `BulkDataEventSchema.organizationId` (PR #15218) is ONE organization for the + * whole batch, asserted by the producer from the tenant wall the predicate + * write was composed under, or absent. ⚠️ `absent` means the OPPOSITE of what + * it means one block up: on a `DataEvent` it is a statement about the ROW + * ("belongs to no organization"); here it is a statement about PRODUCER + * KNOWLEDGE ("no single organization was asserted for this batch"). A pin + * written with the single-record intuition would assert the wrong thing, so + * every absence pin below names WHY the producer could not assert. + * + * What makes these pins discriminate rather than pass against the live + * defect ("the key is absent on every event" — an absence-only suite reported + * 25/25 green against exactly that on #14970): + * + * 1. **The wall, not the active organization.** Under `group` the wall is the + * caller's MEMBERSHIP SET, so the positive `group` pin sets an active + * organization the caller is NOT asserting and expects the set's only + * member; the negative `group` pin keeps an active organization and two + * memberships and expects OMISSION — substituting `tenantId` fails both. + * 2. **The wall, not the rows.** The stub driver composes no wall at all, so + * the rows a sweep touches are whatever was seeded; the negative pins + * seed rows across two organizations on purpose, and the answer is still + * decided by the wall's inputs (posture, context), never by a row read. + * 3. **Absence is asserted as OMISSION** (`hasOwnProperty === false`), never + * `=== undefined`: the schema refuses `''` outright (a fabricated empty + * value would throw at the publish site and drop the event), and an + * explicit `undefined` survives `parse` as a PRESENT key. + * + * The posture the wall enforces reaches the engine the way SecurityPlugin + * hands it over in a real composition — `setTenancyPostureProvider` — and an + * engine that was handed none is pinned as "no wall to vouch for". + */ +describe('#15225 — a published BulkDataEvent names the organization the tenant WALL named', () => { + /** Tenant-scoped: the kernel-injected `organization_id` is declared. */ + const invoice = { + name: 'invoice', + label: 'Invoice', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + amount: { name: 'amount', type: 'text' as const }, + status: { name: 'status', type: 'text' as const }, + organization_id: { name: 'organization_id', type: 'text' as const }, + }, + }; + + const ACTIVE_ORG = 'org_acme'; + const OTHER_ORG = 'org_globex'; + /** System context: the only caller that may seed rows into ANY organization. */ + const sysCtx = { isSystem: true, tenantId: ACTIVE_ORG, userId: 'usr_admin' }; + /** An ordinary resolved session under `isolated`: rung carried, active org set. */ + const member = { userId: 'usr_member', tenantId: ACTIVE_ORG, posture: 'MEMBER' }; + + let engine: ObjectQL; + let published: RealtimeEventPayload[]; + let realtime: IRealtimeService; + /** What SecurityPlugin's injected provider answers; each pin sets it. */ + let enforcedPosture: string | undefined; + + const payloadOf = (i = 0) => published[i].payload as Record; + const hasOrgKey = (i = 0) => + Object.prototype.hasOwnProperty.call(payloadOf(i), 'organizationId'); + const bulkEvent = (i = 0) => BulkDataEventSchema.parse(payloadOf(i)); + + const seed = async (rows: Array>) => { + await engine.insert('invoice', rows, { context: sysCtx } as any); + published.length = 0; + }; + const sweepUpdate = (context: Record, object = 'invoice') => + engine.update(object, { amount: '0' }, { multi: true, where: { status: 'open' }, context } as any); + const sweepDelete = (context: Record) => + engine.delete('invoice', { multi: true, where: { status: 'open' }, context } as any); + + beforeEach(async () => { + published = []; + realtime = { + publish: vi.fn(async (event: RealtimeEventPayload) => { published.push(event); }), + subscribe: vi.fn(async () => 'sub-1'), + unsubscribe: vi.fn(async () => undefined), + }; + engine = new ObjectQL(); + const { driver } = makeStubDriver(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(invoice); + engine.registry.registerObject(task); + engine.setRealtimeService(realtime); + enforcedPosture = 'isolated'; + engine.setTenancyPostureProvider(() => enforcedPosture); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + }); + + it('isolated: a member\'s predicate UPDATE names the caller\'s organization — the wall\'s equality term', async () => { + await seed([ + { amount: '1', status: 'open', organization_id: ACTIVE_ORG }, + { amount: '2', status: 'open', organization_id: ACTIVE_ORG }, + ]); + + await sweepUpdate(member); + + expect(published).toHaveLength(1); + expect(published[0].type).toBe('data.records.updated'); + const event = bulkEvent(); + expect(event.matched).toBe(2); + // PRESENT, and the wall's term: under `isolated` the Layer 0 wall is + // `organization_id = `, so every affected row is in it. + expect(hasOrgKey()).toBe(true); + expect(event.organizationId).toBe(ACTIVE_ORG); + }); + + it('isolated: the predicate DELETE path stamps it too — the branch with no post-state', async () => { + await seed([ + { amount: '1', status: 'open', organization_id: ACTIVE_ORG }, + { amount: '2', status: 'kept', organization_id: ACTIVE_ORG }, + ]); + + await sweepDelete(member); + + expect(published).toHaveLength(1); + expect(published[0].type).toBe('data.records.deleted'); + expect(bulkEvent().matched).toBe(1); + expect(hasOrgKey()).toBe(true); + expect(bulkEvent().organizationId).toBe(ACTIVE_ORG); + }); + + it('group with a SINGLETON membership names that member — not the caller\'s active organization', async () => { + enforcedPosture = 'group'; + // The discriminating case: the active organization is one the caller is + // NOT asserting for the batch. Under `group` the wall is the membership + // SET (`organization_id IN accessible_org_ids`), and `tenantId` is only the + // default write target — reading it here would name the wrong organization. + const plantAdmin = { + userId: 'usr_plant', + tenantId: 'org_hq', + accessible_org_ids: ['org_plant_a'], + posture: 'MEMBER', + }; + await seed([{ amount: '1', status: 'open', organization_id: 'org_plant_a' }]); + + await sweepUpdate(plantAdmin); + + expect(published).toHaveLength(1); + expect(hasOrgKey()).toBe(true); + expect(bulkEvent().organizationId).toBe('org_plant_a'); + expect(bulkEvent().organizationId).not.toBe('org_hq'); + }); + + it('group: a membership set that repeats its one organization still names exactly one', async () => { + enforcedPosture = 'group'; + const ctx = { userId: 'usr_plant', tenantId: 'org_plant_a', accessible_org_ids: ['org_plant_a', 'org_plant_a'], posture: 'MEMBER' }; + await seed([{ amount: '1', status: 'open', organization_id: 'org_plant_a' }]); + + await sweepUpdate(ctx); + + expect(hasOrgKey()).toBe(true); + expect(bulkEvent().organizationId).toBe('org_plant_a'); + }); + + it('group across TWO memberships publishes it ABSENT — never the active organization as a stand-in', async () => { + enforcedPosture = 'group'; + // The option-C mislabel (PR #14635 open question 1, rejected): the caller + // HAS an active organization, and it must not label a sweep that the wall + // let reach two organizations' rows. + const hqAnalyst = { + userId: 'usr_hq', + tenantId: 'org_plant_a', + accessible_org_ids: ['org_plant_a', 'org_plant_b'], + posture: 'MEMBER', + }; + await seed([ + { amount: '1', status: 'open', organization_id: 'org_plant_a' }, + { amount: '2', status: 'open', organization_id: 'org_plant_b' }, + ]); + + await sweepUpdate(hqAnalyst); + + expect(published).toHaveLength(1); + expect(bulkEvent().matched).toBe(2); + // OMITTED, asserted as omission — "not asserted", not "no organization". + expect(hasOrgKey()).toBe(false); + expect(bulkEvent().organizationId).toBeUndefined(); + }); + + it('a system-context predicate write publishes it ABSENT — the middleware composes no wall for it', async () => { + // `isSystem` short-circuits the whole security middleware, so no Layer 0 + // wall was composed and the sweep may reach every organization's rows — + // which is exactly what this seed makes it do. The caller's `tenantId` is + // set and must not be substituted. + await seed([ + { amount: '1', status: 'open', organization_id: ACTIVE_ORG }, + { amount: '2', status: 'open', organization_id: OTHER_ORG }, + ]); + + await sweepUpdate(sysCtx); + + expect(published).toHaveLength(1); + expect(bulkEvent().matched).toBe(2); + expect(hasOrgKey()).toBe(false); + expect(bulkEvent().organizationId).toBeUndefined(); + }); + + it('single posture (no wall) publishes it ABSENT even though the caller has an active organization', async () => { + enforcedPosture = 'single'; + await seed([{ amount: '1', status: 'open', organization_id: ACTIVE_ORG }]); + + await sweepUpdate(member); + + expect(published).toHaveLength(1); + expect(hasOrgKey()).toBe(false); + }); + + it('no enforcement layer injected a posture ⇒ ABSENT — a lean embedding has no wall anywhere to vouch for', async () => { + // An engine no SecurityPlugin ever handed a posture: Layer 0 was never + // composed, and the memory driver reads no `DriverOptions.tenantId` + // either, so nothing constrained this sweep to one organization — + // whatever the operator's env says. The env fallback the #8844 write + // refusal consults is deliberately NOT consulted here. + const bare = new ObjectQL(); + const { driver } = makeStubDriver(); + bare.registerDriver(driver, true); + await bare.init(); + bare.registry.registerObject(invoice); + bare.setRealtimeService(realtime); + vi.spyOn((bare as any).logger, 'warn').mockImplementation(() => undefined); + await bare.insert('invoice', [{ amount: '1', status: 'open', organization_id: ACTIVE_ORG }], { context: sysCtx } as any); + published.length = 0; + + await bare.update('invoice', { amount: '0' }, { multi: true, where: { status: 'open' }, context: member } as any); + + expect(published).toHaveLength(1); + expect(hasOrgKey()).toBe(false); + }); + + it('a carried PLATFORM_ADMIN rung publishes it ABSENT — the batch may have crossed the wall', async () => { + // ADR-0095 D3: a true PLATFORM_ADMIN crosses the wall where the object's + // posture permits. The engine holds the rung but not the superuser bypass + // bit the exemption also needs, so it declines to assert rather than + // guess which side of the wall this sweep ran on. + const platformAdmin = { userId: 'usr_platform', tenantId: ACTIVE_ORG, posture: 'PLATFORM_ADMIN' }; + await seed([ + { amount: '1', status: 'open', organization_id: ACTIVE_ORG }, + { amount: '2', status: 'open', organization_id: OTHER_ORG }, + ]); + + await sweepUpdate(platformAdmin); + + expect(published).toHaveLength(1); + expect(hasOrgKey()).toBe(false); + }); + + it('a context carrying NO rung publishes it ABSENT — the exemption is decided by a probe the engine cannot see', async () => { + // A hand-built context (no `posture`) is one the security plugin decides + // by a capability probe over its resolved permission sets. The engine has + // no view of that probe, so it asserts nothing. Every session context the + // authz resolver assembles carries the rung, so this is the hand-built + // population only. + const rungless = { userId: 'usr_member', tenantId: ACTIVE_ORG }; + await seed([{ amount: '1', status: 'open', organization_id: ACTIVE_ORG }]); + + await sweepUpdate(rungless); + + expect(published).toHaveLength(1); + expect(hasOrgKey()).toBe(false); + }); + + it('an object that is not tenant-scoped publishes it ABSENT under the same wall', async () => { + // `task` declares no `organization_id`: Layer 0 contributes nothing on it + // (`objectHasOrgIdField === false`), so there is no wall to name. + await engine.insert('task', [{ title: 'a', status: 'open' }], { context: sysCtx } as any); + published.length = 0; + + await sweepUpdate(member, 'task'); + + expect(published).toHaveLength(1); + expect(published[0].type).toBe('data.records.updated'); + expect(hasOrgKey()).toBe(false); + }); + + it('an empty active organization OMITS the key AND still publishes — the empty string never reaches the validator', async () => { + // `''` is refused by `z.string().min(1)`: handed to the publish site's + // `parse` it would throw and the event would be dropped altogether. The + // gate is in the resolver, not the error handler — same rule as the + // per-record block. + const orgless = { userId: 'usr_member', tenantId: '', posture: 'MEMBER' }; + await seed([{ amount: '1', status: 'open', organization_id: ACTIVE_ORG }]); + + await sweepUpdate(orgless); + + expect(published).toHaveLength(1); + expect(hasOrgKey()).toBe(false); + expect(() => bulkEvent()).not.toThrow(); + }); + + it('BulkDataEventSchema.parse at the publish site stays the validator, and the key is fed THROUGH it', () => { + // A source pin, on the same terms as the #7809 vocabulary weld next door: + // the runtime pins above prove the key is emitted; this one proves it is + // emitted INSIDE the `parse` call — a stamp added onto the envelope after + // validation would pass every pin above while bypassing the contract. + const testPath = expect.getState().testPath; + if (!testPath) throw new Error('vitest did not report a testPath — cannot locate engine.ts'); + const src = readFileSync(join(dirname(testPath), 'engine.ts'), 'utf8'); + const start = src.indexOf('private async publishBulkDataEvent('); + expect(start).toBeGreaterThan(-1); + const body = src.slice(start, src.indexOf('\n }\n', start)); + const parseCalls = body.match(/BulkDataEventSchema\.parse\(\{[\s\S]*?\n\s*\}\);/g) ?? []; + expect(parseCalls).toHaveLength(1); + expect(parseCalls[0]).toContain('organizationId'); + // And the value is resolved by the wall-based helper, never the row helper + // (there is no row) and never `tenantId` on its own. + expect(body).toContain('bulkEventOrganizationId('); + expect(body).not.toContain('eventOrganizationId('); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 6a73f5a86a..ad97225ccd 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -133,12 +133,19 @@ import { carriesOrganization, SystemWriteOrganizationRequiredError, ORGANIZATION_OBJECT, + DEFAULT_TENANT_FIELD, } from './tenancy/system-write-organization.js'; // [#13491] The per-object tenancy inventory that replaced the blanket // namespace exemption — the ONE reading both narrowed gates consult. import { isPlatformObjectOutOfTenantAuditScope } from './tenancy/platform-object-tenancy.js'; import { resolveTenancyPosture } from '@objectstack/types'; -import { normalizeTenancyPosture, type TenancyPosture } from '@objectstack/spec/security'; +import { + AuthzPostureSchema, + normalizeTenancyPosture, + postureEnforcesWall, + postureUsesUnionScope, + type TenancyPosture, +} from '@objectstack/spec/security'; /** * Per-row outcome of {@link ObjectQL.insertMany} (framework#3172). One entry @@ -2239,19 +2246,121 @@ function eventOrganizationId(objectSchema: unknown, row: unknown): string | unde if (!tenantField) return undefined; const body = eventRecordBody(row); if (!body) return undefined; - const value = body[tenantField]; - // The write path's own "actually supplied" predicate, so producer and - // consumer cannot disagree about what counts as an organization. + return eventOrganizationValue(body[tenantField]); +} + +/** + * The ONE coercion an organization value goes through before it becomes an + * event's `organizationId` — shared by the per-record producer + * ({@link eventOrganizationId}, which reads the row's column) and the bulk + * producer ({@link bulkEventOrganizationId}, which reads the wall's inputs + * off the execution context), so the two cannot disagree about what counts + * as an organization (#15225: one ladder, two readers — never two ladders). + * + * First the write path's own "actually supplied" predicate + * ({@link carriesOrganization}), so producer and consumer cannot disagree + * about what counts as an organization. Then the same coercion ladder + * `eventRecordId` uses for the other id on these events. Deliberately NOT a + * bare `String(value)`: `String(false)` is a perfectly valid `min(1)` string, + * and inventing an organization out of a malformed value is the "never + * fabricated" clause's exact failure mode. `undefined` means "omit the key". + */ +function eventOrganizationValue(value: unknown): string | undefined { if (!carriesOrganization(value)) return undefined; - // Then the same coercion ladder `eventRecordId` uses for the other id on - // this event. Deliberately NOT a bare `String(value)`: `String(false)` is a - // perfectly valid `min(1)` string, and inventing an organization out of a - // malformed column is the "never fabricated" clause's exact failure mode. if (typeof value === 'string') return value; if (typeof value === 'number' || typeof value === 'bigint') return String(value); return undefined; } +/** + * `BulkDataEvent.organizationId` — the ONE organization the tenant wall named + * for a predicate write, or `undefined` when the producer cannot assert one + * (#15225, the bulk half of the cross-tenant fan-out leak whose single-record + * half is {@link eventOrganizationId}). + * + * ⚠️ NOT the per-record helper's question. That one reads a ROW's column; + * a predicate write has no row in hand — `updateMany`/`deleteMany` resolve a + * count — so "which organization" is answered from the WALL the write was + * composed under, and `absent` means something different on this event: + * "the producer did not assert one organization for the batch" (a statement + * about producer knowledge), never "belongs to no organization" (a statement + * about a row). `packages/spec/src/api/events.zod.ts` records the divergence + * on the member itself. + * + * **Why the wall answers it with no second query.** The security layer + * AND-composes its Layer 0 tenant wall (ADR-0095 D1, `tenant-layer.ts`) onto + * the caller's filter before the driver, and Layer 1 cannot widen it: under + * `isolated` the wall is `organization_id = `, + * under `group` it is `organization_id IN ` + * (ADR-0105 D2). So when the wall names exactly one organization, every + * affected row belongs to it — one comparison, never a partition of the + * batch — and the producer can state that from what it already holds: the + * execution context the wall was computed from, and the posture the + * enforcement layer told this engine it enforces. + * + * **What is read, and what the answer is** (the posture table on the card, + * mirrored input-for-input against `computeTenantLayer0Filter`): + * + * - `enforcedPosture` is the SecurityPlugin-injected posture + * ({@link ObjectQL.enforcedTenancyPosture}), ⛔ never the env fallback + * `resolveEnginePosture()` also consults: no enforcement layer means no + * Layer 0 wall was composed at all, and the memory driver reads no + * `DriverOptions.tenantId` either, so a lean embedding has no wall + * ANYWHERE to vouch for. `single` (no wall) ⇒ absent. + * - `isSystem` short-circuits the whole security middleware ⇒ no wall ⇒ + * absent. Same for an absent context. + * - The wall keys on the kernel-injected `organization_id` column literally + * (`objectHasOrgIdField`), and only on a local, tenancy-enabled object — + * so the object must resolve to exactly that column and not be federated + * (a federated anchor is discounted as phantom, #7835) ⇒ else absent. + * - The Layer 0 EXEMPTION: a true `PLATFORM_ADMIN` crosses the wall where + * the object's posture permits (ADR-0095 D3). The engine holds the carried + * rung (`ExecutionContext.posture`) but not the superuser write-bypass bit + * the exemption also requires, so it answers conservatively: a carried + * `PLATFORM_ADMIN` rung ⇒ absent (the batch MAY have crossed). A context + * carrying NO rung is one the plugin decides by a capability probe over + * its resolved permission sets — invisible from here — so it is absent too: + * the engine asserts only what it can vouch for. Session contexts assembled + * by the authz resolver always carry the rung. + * - `group`: the membership set, deduplicated — present only when it names + * exactly one organization, and ⛔ never `tenantId` standing in for a + * multi-membership sweep (the option-C mislabel PR #14635's open question 1 + * rejected). An unreadable member makes the set unvouchable ⇒ absent. + * - `isolated`: the caller's active organization (`tenantId`), which IS the + * wall's equality term; missing ⇒ the wall was the deny sentinel ⇒ absent + * (and no row matched anyway). + * + * Returns `undefined` for every "not asserted" case and the caller OMITS the + * key — omission is the schema's one spelling for absence (`''` is refused, + * an explicit `undefined` survives `parse` as a present key), exactly as the + * per-record site does. + */ +function bulkEventOrganizationId( + objectSchema: unknown, + execCtx: ExecutionContext | undefined, + enforcedPosture: TenancyPosture | undefined, +): string | undefined { + if (enforcedPosture === undefined || !postureEnforcesWall(enforcedPosture)) return undefined; + if (!execCtx || execCtx.isSystem === true) return undefined; + if (resolveTenantFieldName(objectSchema) !== DEFAULT_TENANT_FIELD) return undefined; + if ((objectSchema as { external?: unknown } | null | undefined)?.external != null) return undefined; + const rung = AuthzPostureSchema.safeParse(execCtx.posture); + if (!rung.success || rung.data === 'PLATFORM_ADMIN') return undefined; + if (postureUsesUnionScope(enforcedPosture)) { + const memberships = execCtx.accessible_org_ids; + if (!Array.isArray(memberships) || memberships.length === 0) return undefined; + let named: string | undefined; + for (const member of memberships) { + const id = eventOrganizationValue(member); + if (id === undefined) return undefined; + if (named === undefined) named = id; + else if (named !== id) return undefined; + } + return named; + } + return eventOrganizationValue(execCtx.tenantId); +} + /** * Coerce a multi-row driver result into `BulkDataEvent.matched` (#4639). * @@ -3896,6 +4005,23 @@ export class ObjectQL implements IObjectQLEngine { } } + /** + * [#15225] The tenancy posture the ENFORCEMENT layer told this engine it + * enforces — the SecurityPlugin-injected provider's answer and nothing + * else, `undefined` when no enforcement layer injected one. + * + * Deliberately NOT {@link resolveEnginePosture}: that accessor falls back to + * the env resolution, which is the right fact for #8844's write REFUSAL (a + * lean embedding should still refuse an org-less system write on a walled + * install) and the wrong fact for asserting what a wall named. No + * SecurityPlugin ⇒ no Layer 0 wall composed ⇒ nothing to vouch for, whatever + * the operator configured. Read live per call, never cached — the same + * "no frozen verdict" rule the sibling accessor keeps. + */ + private enforcedTenancyPosture(): TenancyPosture | undefined { + return normalizeTenancyPosture(this.tenancyPostureProvider?.()); + } + /** * [#8844] The install's organizations, capped at two — only "none / exactly * one / several" changes any decision. @@ -5860,6 +5986,14 @@ export class ObjectQL implements IObjectQLEngine { * internals to whatever external URL a webhook points at. See * `BulkDataEventSchema`'s TSDoc for the full reasoning. * + * [#15225] `organizationId` — the ONE organization the tenant wall named + * for this batch, stamped from the execution context and the enforced + * posture the producer already holds (⛔ no second query on the publish + * path), and OMITTED whenever the producer cannot assert one. See + * {@link bulkEventOrganizationId} for the derivation and for why `absent` + * here means "not asserted", not the per-record "belongs to no + * organization". + * * Same two disciplines as the per-record twin: validate before publish, and * never throw — a realtime transport problem must not roll back a committed * write. @@ -5896,10 +6030,21 @@ export class ObjectQL implements IObjectQLEngine { try { const timestamp = new Date().toISOString(); const userId = eventUserId(input.context); + // [#15225] The organization the WALL named for the whole batch — read + // off the context the wall was computed from, never off a row (there is + // none) and never `input.context.tenantId` on its own (under `group` the + // wall is the membership set). Omitted, never `''`/`undefined`, because + // absence has exactly one spelling in the schema. + const organizationId = bulkEventOrganizationId( + this._registry.getObject(object), + input.context, + this.enforcedTenancyPosture(), + ); const event: BulkDataEvent = BulkDataEventSchema.parse({ id: generateEventUuid(), type: `data.records.${action}`, object, + ...(organizationId !== undefined ? { organizationId } : {}), matched, ...(userId !== undefined ? { userId } : {}), timestamp, From db2b0e338be83998dddb19d54b72b610c6d1a431 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 02:23:52 +0000 Subject: [PATCH 2/5] test(objectql): pin the tenancy opt-out case on a declared `tenancy.enabled: false` object A registered object gets the kernel `organization_id` column injected, and the security plugin walls on that same injected field set, so `task` is not a "not tenant-scoped" fixture. The pin now uses the declared opt-out and measures that the column was withheld before asserting the key is omitted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../objectql/src/engine-data-events.test.ts | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/objectql/src/engine-data-events.test.ts b/packages/objectql/src/engine-data-events.test.ts index 4b95d8d970..c47bb7167e 100644 --- a/packages/objectql/src/engine-data-events.test.ts +++ b/packages/objectql/src/engine-data-events.test.ts @@ -707,8 +707,8 @@ describe('#15225 — a published BulkDataEvent names the organization the tenant await engine.insert('invoice', rows, { context: sysCtx } as any); published.length = 0; }; - const sweepUpdate = (context: Record, object = 'invoice') => - engine.update(object, { amount: '0' }, { multi: true, where: { status: 'open' }, context } as any); + const sweepUpdate = (context: Record) => + engine.update('invoice', { amount: '0' }, { multi: true, where: { status: 'open' }, context } as any); const sweepDelete = (context: Record) => engine.delete('invoice', { multi: true, where: { status: 'open' }, context } as any); @@ -904,13 +904,30 @@ describe('#15225 — a published BulkDataEvent names the organization the tenant expect(hasOrgKey()).toBe(false); }); - it('an object that is not tenant-scoped publishes it ABSENT under the same wall', async () => { - // `task` declares no `organization_id`: Layer 0 contributes nothing on it - // (`objectHasOrgIdField === false`), so there is no wall to name. - await engine.insert('task', [{ title: 'a', status: 'open' }], { context: sysCtx } as any); + it('an object that opted OUT of tenancy publishes it ABSENT under the same wall', async () => { + // ⚠️ Not `task`: registering an object INJECTS the kernel `organization_id` + // column (registry.ts, `TENANT_SCOPE_FIELD_DEF`), and the security plugin + // reads that same injected field set — so a registered `task` IS walled. + // The declared way out is `tenancy: { enabled: false }` (ADR-0066): no + // column is injected, `resolveTenantFieldName` answers null, and Layer 0 + // contributes nothing (`tenancyDisabled`) — there is no wall to name. + const globalSetting = { + name: 'global_setting', + label: 'Global setting', + tenancy: { enabled: false }, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + key: { name: 'key', type: 'text' as const }, + status: { name: 'status', type: 'text' as const }, + }, + }; + engine.registry.registerObject(globalSetting as any); + // Measured, not recalled: the opt-out really withheld the injected column. + expect((engine.registry.getObject('global_setting') as any).fields.organization_id).toBeUndefined(); + await engine.insert('global_setting', [{ key: 'a', status: 'open' }], { context: sysCtx } as any); published.length = 0; - await sweepUpdate(member, 'task'); + await engine.update('global_setting', { key: 'swept' }, { multi: true, where: { status: 'open' }, context: member } as any); expect(published).toHaveLength(1); expect(published[0].type).toBe('data.records.updated'); From d08186cc7a95d1af73d9df0b202a03774c8580f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 02:44:33 +0000 Subject: [PATCH 3/5] docs(permissions): census the bulk-event `organizationId` omission as `isSystem` read site 107 The bulk producer's new `isSystem` read (no wall composed for a system write, so no batch organization is asserted) is an elevation behaviour the system-context page must anchor. Row 30 names it; rows 30-65 renumber to 31-66 with their prose cross-references; the six census-derived counts move 106 -> 107 (property reads 112 -> 113). `--fix` re-anchored the 15 engine.ts lines the insertion shifted and refused zero files once the row existed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- content/docs/permissions/system-context.mdx | 111 ++++++++++---------- 1 file changed, 56 insertions(+), 55 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 77d53a0121..cbeb76f539 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -9,7 +9,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a service self-write, a migration. This page is **the authority** for what that flag actually does. It exists -because the flag is not one concept: it is a single boolean read at **106 +because the flag is not one concept: it is a single boolean read at **107 distinct sites across 20 packages**, and knowing three of those behaviours gives no hint that the other hundred-and-three exist. Every documented app-side bug traced to `isSystem` had the same shape — the metadata was complete and correct, @@ -109,67 +109,68 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11450` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11633` | -| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10183` | +| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11595` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11778` | +| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10328` | | 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1795` | -| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10231`, `readonly-strict-errors.ts:66` | -| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:6050` | -| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3799`, `:3809`, `:3836` | +| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10376`, `readonly-strict-errors.ts:66` | +| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:6195` | +| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3908`, `:3918`, `:3945` | | 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` | | 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:99` | -| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6748` | -| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12249` | -| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12178` | +| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6893` | +| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12394` | +| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12323` | +| 30 | **Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" | objectql | Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: the security middleware composed no tenant wall for a system write, so the producer cannot vouch that every row a predicate write affected belongs to one organization, and the key is omitted rather than filled from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) | `objectql/src/engine.ts:2344` | ### 3. Sharing (`plugin-sharing`) -The largest single consumer — **17 of the 106 sites**. +The largest single consumer — **17 of the 107 sites**. | # | Behaviour when `isSystem` | What you get / what you lose | Anchor | |:--|:---|:---|:---| -| 30 | **Sharing-rule REVOCATION is skipped on the record-`afterDelete` hook** — and on that hook only | Lose: nothing permanently — the revoke is **delivered, but deferred on the unbounded shape**. The payload belongs to another subscriber: `record-share-cascade.ts` binds on every sharing-capable object and stashes for system writes on its own account (#5103). When the deleted ids are enumerable it revokes inline; when they are not — a predicate delete whose row set the stash could not resolve — it hands the reclaim to a queued background orphan sweep instead, so the share rows outlive the deleted records until that sweep runs, with the boot orphan sweep behind it. No surviving record loses access either way, and a restart re-runs the same sweep. This is one subscriber declining work another owns, not elevation silencing a consequence. ⚠️ **Grant MATERIALISATION no longer asks** — the `afterInsert` / `afterUpdate` skips, and the `before*` stash skip that fed them, were removed by the 2026-08-31 ruling on #13533; a system write materialises exactly as a user write does | `rule-hooks.ts:292` | -| 31 | Sharing write verdict short-circuits to `allow` | Get: writes pass the sharing gate unconditionally | `plugin-sharing/src/sharing-service.ts:677` | -| 32 | Record visibility / manage-shares checks return true | Get: no ownership or Modify-All requirement | `plugin-sharing/src/sharing-service.ts:943`, `:1030`, `:1787` | -| 33 | `grant()` skips the enforcement + manage-shares assertions | Get: the rule evaluator can materialise through the public API. Note it is **not** a bare skip: the system branch asserts the grant is not *inert* instead (a grant on an object no verdict can consult is refused) | `plugin-sharing/src/sharing-service.ts:1238` | -| 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1476` (guard at `:1501`) | -| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1528` | -| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1088` | -| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:469`, `:523`, `:527`, `:600`, `:630` | -| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` | -| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:202`, `:427` | +| 31 | **Sharing-rule REVOCATION is skipped on the record-`afterDelete` hook** — and on that hook only | Lose: nothing permanently — the revoke is **delivered, but deferred on the unbounded shape**. The payload belongs to another subscriber: `record-share-cascade.ts` binds on every sharing-capable object and stashes for system writes on its own account (#5103). When the deleted ids are enumerable it revokes inline; when they are not — a predicate delete whose row set the stash could not resolve — it hands the reclaim to a queued background orphan sweep instead, so the share rows outlive the deleted records until that sweep runs, with the boot orphan sweep behind it. No surviving record loses access either way, and a restart re-runs the same sweep. This is one subscriber declining work another owns, not elevation silencing a consequence. ⚠️ **Grant MATERIALISATION no longer asks** — the `afterInsert` / `afterUpdate` skips, and the `before*` stash skip that fed them, were removed by the 2026-08-31 ruling on #13533; a system write materialises exactly as a user write does | `rule-hooks.ts:292` | +| 32 | Sharing write verdict short-circuits to `allow` | Get: writes pass the sharing gate unconditionally | `plugin-sharing/src/sharing-service.ts:677` | +| 33 | Record visibility / manage-shares checks return true | Get: no ownership or Modify-All requirement | `plugin-sharing/src/sharing-service.ts:943`, `:1030`, `:1787` | +| 34 | `grant()` skips the enforcement + manage-shares assertions | Get: the rule evaluator can materialise through the public API. Note it is **not** a bare skip: the system branch asserts the grant is not *inert* instead (a grant on an object no verdict can consult is refused) | `plugin-sharing/src/sharing-service.ts:1238` | +| 35 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1476` (guard at `:1501`) | +| 36 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1528` | +| 37 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1088` | +| 38 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:469`, `:523`, `:527`, `:600`, `:630` | +| 39 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` | +| 40 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:202`, `:427` | ### 4. Approvals, reports, attachments, comments, knowledge | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:347` | -| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` | -| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:963`, `:1072`, `:3248`, `:3396`, `:3564`, `:3635`, `:3824`, `:3864` | -| 43 | Saved-report ownership is **assignable**, and an update may reassign it | plugin-reports | Get: `ownerId` from input is honoured. A non-system caller always owns what it creates and can never reassign | `plugin-reports/src/report-service.ts:404`, `:425` | -| 44 | Saved-report access / export / mutation gates bypassed | plugin-reports | Get: read, bulk-export and overwrite any report | `plugin-reports/src/report-service.ts:343`, `:372`, `:447`, `:684` | -| 45 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `attachment-access-hooks.ts:300`, `:349`, `:448`, `:524` | -| 46 | Comment access hooks return early (insert + update + delete, and the read AST) | plugin-audit | Lose: comment visibility scoping | `comment-access-hooks.ts:322`, `:449`, `:488`, `:540` | -| 47 | Knowledge search returns hits unfiltered | service-knowledge | Lose: the permission filter over search results | `service-knowledge/src/knowledge-service.ts:316` | +| 41 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:347` | +| 42 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` | +| 43 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:963`, `:1072`, `:3248`, `:3396`, `:3564`, `:3635`, `:3824`, `:3864` | +| 44 | Saved-report ownership is **assignable**, and an update may reassign it | plugin-reports | Get: `ownerId` from input is honoured. A non-system caller always owns what it creates and can never reassign | `plugin-reports/src/report-service.ts:404`, `:425` | +| 45 | Saved-report access / export / mutation gates bypassed | plugin-reports | Get: read, bulk-export and overwrite any report | `plugin-reports/src/report-service.ts:343`, `:372`, `:447`, `:684` | +| 46 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `attachment-access-hooks.ts:300`, `:349`, `:448`, `:524` | +| 47 | Comment access hooks return early (insert + update + delete, and the read AST) | plugin-audit | Lose: comment visibility scoping | `comment-access-hooks.ts:322`, `:449`, `:488`, `:540` | +| 48 | Knowledge search returns hits unfiltered | service-knowledge | Lose: the permission filter over search results | `service-knowledge/src/knowledge-service.ts:316` | ### 5. Actions, metadata plane, provenance | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` | -| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5016`, `:6430`, `:6678`, `:7109`, `:7302` | -| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | -| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:422`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | -| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | -| 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` | -| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:241`, `:274` | -| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:138`, `:189` | -| 57 | Automation run-state read, flow-authoring write and unrelated-screen read all pass | runtime | Get: run state, flow writes and screen reads with no grant | `domains/automation.ts:254`, `:545`, `:635` | -| 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` | -| 59 | Email-template / webhook provenance stamps skipped | plugin-email, plugin-webhooks | Lose: the row is not marked as an admin customization | `email-template-provenance.ts:59`, `webhook-provenance.ts:50` | -| 60 | **Automation flow data nodes re-add the `owner_id` stamp** (the one place row 2's gap is compensated inline) | service-automation | Get: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values win | `runtime-identity.ts:279`, called from `builtin/crud-nodes.ts:319` | -| 61 | Inbox caller refusal names `isSystem` as what was carried | service-messaging | Get: nothing — the refusal still fires. The flag only shapes the diagnostic, because privilege is not an authorization subject | `inbox-caller.ts:148` | +| 49 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` | +| 50 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | +| 51 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5016`, `:6430`, `:6678`, `:7109`, `:7302` | +| 52 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 51's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | +| 53 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:422`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | +| 54 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | +| 55 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` | +| 56 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:241`, `:274` | +| 57 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:138`, `:189` | +| 58 | Automation run-state read, flow-authoring write and unrelated-screen read all pass | runtime | Get: run state, flow writes and screen reads with no grant | `domains/automation.ts:254`, `:545`, `:635` | +| 59 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` | +| 60 | Email-template / webhook provenance stamps skipped | plugin-email, plugin-webhooks | Lose: the row is not marked as an admin customization | `email-template-provenance.ts:59`, `webhook-provenance.ts:50` | +| 61 | **Automation flow data nodes re-add the `owner_id` stamp** (the one place row 2's gap is compensated inline) | service-automation | Get: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values win | `runtime-identity.ts:279`, called from `builtin/crud-nodes.ts:319` | +| 62 | Inbox caller refusal names `isSystem` as what was carried | service-messaging | Get: nothing — the refusal still fires. The flag only shapes the diagnostic, because privilege is not an authorization subject | `inbox-caller.ts:148` | ### 6. Reads that only carry the flag onward @@ -179,10 +180,10 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| -| 62 | `objectql/src/engine.ts:3606` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:14693` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | -| 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report | -| 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | +| 63 | `objectql/src/engine.ts:3715` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | +| 64 | `objectql/src/engine.ts:14838` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 65 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report | +| 66 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | --- @@ -195,7 +196,7 @@ assuming `isSystem` covers it is a documented source of bugs. |:---|:---|:---| | "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:2032` (rationale at `:1942`–`1944`, #3760), `flow.zod.ts:702` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | -| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10166`–`10183` | +| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10311`–`10328` | | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1580` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` | @@ -235,7 +236,7 @@ should recognise it instead of re-deriving it. a **bug**, because a sharing rule's declared semantics is a published promise and `isSystem` names the operator, never a consequence that need not happen. Both materialisation skips and the notice that announced them are - gone; row 30 is now the `afterDelete` skip alone, which survives on the + gone; row 31 is now the `afterDelete` skip alone, which survives on the separate ground that another subscriber delivers that payload. ⚠️ The observability half of that reading is worth keeping in mind @@ -269,7 +270,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are independent decisions, and a seed loader plausibly wants the first two but not the third. The concept is nevertheless **staying as one boolean**: -- **Shipped semantics.** `isSystem` is a published contract with 106 read sites +- **Shipped semantics.** `isSystem` is a published contract with 107 read sites in 20 packages. Splitting it is a breaking contract change across all of them. (The ruling was taken when the census read 80 sites in 18 packages; the count has grown, which strengthens rather than weakens the argument.) @@ -326,13 +327,13 @@ still holds equal to the census on every pull request: | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | | — parsed as a declaration | 22 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | -| — parsed as a property **read** | 112 | ✅ | +| — parsed as a property **read** | 113 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | | — the remainder: text inside comments and string literals | 358 | — | | Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ | -| Of those reads: reads of `ExecutionContext.isSystem` | **106** | ✅ | -| — behaviour-bearing (rows 1–61 above) | 102 | ✅ | -| — carry the flag onward only (rows 62–65 above) | 4 | ✅ | +| Of those reads: reads of `ExecutionContext.isSystem` | **107** | ✅ | +| — behaviour-bearing (rows 1–62 above) | 103 | ✅ | +| — carry the flag onward only (rows 63–66 above) | 4 | ✅ | | Packages containing at least one elevation read | **20** | ✅ | | Files containing at least one elevation read | 45 | ✅ | @@ -420,4 +421,4 @@ that introduces it — CI will say so if it is not. - [Authorization Architecture](/docs/permissions/authorization) — the six-gate enforcement chain this flag short-circuits - [Security & Access Control](/docs/protocol/objectql/security) — the `readonly` write strip and its exemptions - [State Machine](/docs/protocol/objectql/state-machine) — `skipStateMachine`, `preserveAudit`, `treatAsHistorical` -- [Sharing Rules](/docs/permissions/sharing-rules) — what row 30 is skipping +- [Sharing Rules](/docs/permissions/sharing-rules) — what row 31 is skipping From 648e004335bfdc3b81c3bd37bbad1b27bd9f4110 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 04:06:04 +0000 Subject: [PATCH 4/5] fix(objectql): the bulk-event object exit is the wall's own predicate, not a re-spelling of one of its clauses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch round R2 on the contract review of PR #15687 (items 1, 3, 4, 5, 7; item 6 re-judged). `bulkEventOrganizationId` answered its object exit with `resolveTenantFieldName(schema) !== DEFAULT_TENANT_FIELD`, which mirrors ONE of the clauses Layer 0 folds into `tenancyDisabled`; an object declaring `systemFields.tenant: false` beside its own `organization_id` composes NO wall in plugin-security and was still stamped with the caller's organization — a mislabel (the reviewer's P1). The exit now reads `carriesTenantScopeColumn`, the registry's binding of the wall's predicate (exported at module level only; `dist/index.d.ts`, `dist/core.d.ts` and both entries' runtime export lists are unchanged, measured with a firing control), beside the `external != null` superset of the phantom-anchor rule. A custom `tenancy.tenantField` is no longer an exit by itself: the key follows the wall, present iff the object carries `organization_id`. Pins: the P1 fixture (absent, matched 3); a federated object (absent); a custom `tenancy.tenantField` with the kernel column (present) and without it (absent); and the no-enforcement-layer pin now sets OS_TENANCY_POSTURE=isolated in the env and still expects omission, so the env-fallback exclusion is pinned by a test that goes red under the substitution the review measured green. JSDoc, changeset and the census page name which `tenancyDisabled` clauses the engine mirrors and which the seam carries; the census re-anchor rewrote 16 anchors and refused zero, population 107 unchanged. Level stays `patch`: no member reaches this package's published surface. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .changeset/bulk-event-batch-organization.md | 36 +++- content/docs/permissions/system-context.mdx | 26 +-- .../objectql/src/engine-data-events.test.ts | 169 +++++++++++++++++- packages/objectql/src/engine.ts | 46 ++++- packages/objectql/src/registry.ts | 15 +- 5 files changed, 258 insertions(+), 34 deletions(-) diff --git a/.changeset/bulk-event-batch-organization.md b/.changeset/bulk-event-batch-organization.md index f86b2d844c..e82c5e2340 100644 --- a/.changeset/bulk-event-batch-organization.md +++ b/.changeset/bulk-event-batch-organization.md @@ -18,14 +18,34 @@ on the publish path: under `isolated` the caller's active organization (the Layer 0 wall's equality term), under `group` the caller's membership set when it names exactly one organization. It is OMITTED — never the caller's active organization standing in — on a `single`-posture deployment, on an `isSystem` -context (no wall composed), on a multi-membership `group` sweep, on an object -the wall does not key on, when no enforcement layer injected a posture, and -when the caller may have crossed the wall as a `PLATFORM_ADMIN` or carries no -resolved posture rung. `absent` here means "the producer did not assert one +context (no wall composed), on a multi-membership `group` sweep, when no +enforcement layer injected a posture (the `OS_TENANCY_POSTURE` env fallback is +deliberately not consulted), when the caller may have crossed the wall as a +`PLATFORM_ADMIN` or carries no resolved posture rung, and on an object the wall +does not key on. `absent` here means "the producer did not assert one organization for the batch", deliberately NOT the `DataEvent` reading "belongs to no organization". -`patch`, not `minor`: the contract surface widened with the spec half (at -`minor`); this change makes the producer honour a key that surface already -declares, adds no export or API shape, and follows the level the single-record -producer half landed at. +Which objects "the wall does not key on", stated exactly rather than claimed as +a mirror: plugin-security's Layer 0 composes no wall when its `tenancyDisabled` +input is true or the object carries no `organization_id`, and it folds THREE +clauses into `tenancyDisabled` — `tenancy.enabled === false`, +`systemFields.tenant === false`, and the deployment's `platformGlobalObjects` +carve-out. The producer reads the registry's binding of that predicate +(`carriesTenantScopeColumn`: the first two clauses plus the column clause) and +answers absent on a federated (`external`) object; a custom +`tenancy.tenantField` is therefore not an exit by itself — the object is walled +iff it carries `organization_id`, and the key follows the wall. The third +clause is deployment-declared and not readable by the engine: a +deployment-exempted object under an armed wall is still stamped with the +caller's organization by this producer alone, and that population's exact +answer is decided by the seam ruled on in #15706. + +`patch`, not `minor`: the act adds no member to this package's published +surface. `carriesTenantScopeColumn` is exported at module level inside +`registry.ts` only — `@objectstack/objectql`'s entries (`.`, `./core`) re-export +named members and never `export *`, so `dist/index.d.ts`, `dist/core.d.ts` and +both entries' runtime export lists are unchanged (measured on the built `dist`, +with a firing control) — and the emitted event's member was declared, typed +and paid for at `minor` by the spec half. Producer conformance to an existing +optional member under `fix(` changes no public surface of this package. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index cbeb76f539..a11b20e327 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -109,19 +109,19 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11595` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11778` | -| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10328` | +| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11623` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11806` | +| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10356` | | 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1795` | -| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10376`, `readonly-strict-errors.ts:66` | -| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:6195` | -| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3908`, `:3918`, `:3945` | +| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10404`, `readonly-strict-errors.ts:66` | +| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:6223` | +| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3936`, `:3946`, `:3973` | | 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` | | 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:99` | -| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6893` | -| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12394` | -| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12323` | -| 30 | **Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" | objectql | Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: the security middleware composed no tenant wall for a system write, so the producer cannot vouch that every row a predicate write affected belongs to one organization, and the key is omitted rather than filled from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) | `objectql/src/engine.ts:2344` | +| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6921` | +| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12422` | +| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12351` | +| 30 | **Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" | objectql | Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: the security middleware composed no tenant wall for a system write, so the producer cannot vouch that every row a predicate write affected belongs to one organization, and the key is omitted rather than filled from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) | `objectql/src/engine.ts:2371` | ### 3. Sharing (`plugin-sharing`) @@ -180,8 +180,8 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| -| 63 | `objectql/src/engine.ts:3715` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 64 | `objectql/src/engine.ts:14838` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 63 | `objectql/src/engine.ts:3743` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | +| 64 | `objectql/src/engine.ts:14866` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | | 65 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report | | 66 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | @@ -196,7 +196,7 @@ assuming `isSystem` covers it is a documented source of bugs. |:---|:---|:---| | "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:2032` (rationale at `:1942`–`1944`, #3760), `flow.zod.ts:702` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | -| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10311`–`10328` | +| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10339`–`10356` | | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1580` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` | diff --git a/packages/objectql/src/engine-data-events.test.ts b/packages/objectql/src/engine-data-events.test.ts index c47bb7167e..2c922204fa 100644 --- a/packages/objectql/src/engine-data-events.test.ts +++ b/packages/objectql/src/engine-data-events.test.ts @@ -28,7 +28,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { BulkDataEventSchema, DataEventSchema } from '@objectstack/spec/api'; import type { IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts'; import { ObjectQL } from './engine.js'; @@ -731,6 +731,17 @@ describe('#15225 — a published BulkDataEvent names the organization the tenant vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); }); + /** + * The env-fallback pin below sets `OS_TENANCY_POSTURE` on purpose; put it + * back whatever happened, so no later file in this process inherits a + * walled posture it never asked for. + */ + const savedPosture = process.env.OS_TENANCY_POSTURE; + afterEach(() => { + if (savedPosture === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = savedPosture; + }); + it('isolated: a member\'s predicate UPDATE names the caller\'s organization — the wall\'s equality term', async () => { await seed([ { amount: '1', status: 'open', organization_id: ACTIVE_ORG }, @@ -850,12 +861,22 @@ describe('#15225 — a published BulkDataEvent names the organization the tenant expect(hasOrgKey()).toBe(false); }); - it('no enforcement layer injected a posture ⇒ ABSENT — a lean embedding has no wall anywhere to vouch for', async () => { + it('no enforcement layer injected a posture ⇒ ABSENT even with OS_TENANCY_POSTURE=isolated in the env — the env fallback is not consulted', async () => { // An engine no SecurityPlugin ever handed a posture: Layer 0 was never // composed, and the memory driver reads no `DriverOptions.tenantId` // either, so nothing constrained this sweep to one organization — // whatever the operator's env says. The env fallback the #8844 write - // refusal consults is deliberately NOT consulted here. + // refusal consults (`resolveEnginePosture`) is deliberately NOT consulted + // here. + // + // ⚠️ The env is set to the WALLED posture on purpose. With it unset the + // test process resolves to `single`, and substituting + // `resolveEnginePosture()` at the publish site left every pin green + // (measured by the R1 contract review: 38/38 under the substitution). + // Under `isolated` in the env that substitution stamps the key, and + // THIS pin goes red — the only thing that makes "deliberately not the + // env fallback" a guarantee rather than a comment. + process.env.OS_TENANCY_POSTURE = 'isolated'; const bare = new ObjectQL(); const { driver } = makeStubDriver(); bare.registerDriver(driver, true); @@ -934,6 +955,148 @@ describe('#15225 — a published BulkDataEvent names the organization the tenant expect(hasOrgKey()).toBe(false); }); + it('`systemFields.tenant: false` + an author-declared `organization_id` publishes it ABSENT — the wall composes nothing there (P1)', async () => { + // The R1 contract review's P1 probe. Layer 0's `tenancyDisabled` input + // folds `systemFields.tenant === false` in beside `tenancy.enabled === + // false` (`getObjectSecurityMeta`, security-plugin.ts), so on this object + // `computeTenantLayer0Filter` returns null — NO wall — while the column + // the author declared is a perfectly readable `organization_id`. R1 read + // the column and stamped the caller's organization onto a batch that + // touched another organization's row: a MISLABEL, the one direction + // this card must never take. The object exit is now the wall's own + // predicate (`carriesTenantScopeColumn`), which answers "not walled". + const sharedCatalog = { + name: 'shared_catalog', + label: 'Shared catalog', + systemFields: { tenant: false }, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + status: { name: 'status', type: 'text' as const }, + organization_id: { name: 'organization_id', type: 'text' as const }, + }, + }; + engine.registry.registerObject(sharedCatalog as any); + // Measured: the opt-out withheld the INJECTED column and the author's + // declaration survived — the column is there to be misread. + expect((engine.registry.getObject('shared_catalog') as any).fields.organization_id).toBeDefined(); + await engine.insert('shared_catalog', [ + { status: 'open', organization_id: ACTIVE_ORG }, + { status: 'open', organization_id: OTHER_ORG }, + { status: 'open' }, + ], { context: sysCtx } as any); + published.length = 0; + + await engine.update('shared_catalog', { status: 'swept' }, { multi: true, where: { status: 'open' }, context: member } as any); + + expect(published).toHaveLength(1); + expect(bulkEvent().matched).toBe(3); + expect(hasOrgKey()).toBe(false); + }); + + it('a FEDERATED object (`external` binding) publishes it ABSENT — the wall discounts the platform\'s phantom anchor', async () => { + // [#7835] The registry injects `organization_id` on an external object + // too, but the platform provisions no storage for it, so plugin-security + // reads that anchor as PHANTOM and Layer 0 composes no wall + // (`objectHasOrgIdField: false`). A key here would name an organization + // no wall constrained the batch to — the mislabel direction — so the + // producer answers absent on `external != null`. That exit is a SUPERSET + // of the wall's provenance test: a federated object whose author declared + // a real remote `organization_id` keeps its wall and is answered absent + // too (under-delivery, never a mislabel). That variant is deliberately + // NOT pinned: a future exact provenance predicate may legitimately answer + // it present, and a pin here would forbid that. + const remoteCustomer = { + name: 'remote_customer', + label: 'Remote customer', + external: { remoteName: 'customers', writable: true }, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + status: { name: 'status', type: 'text' as const }, + }, + }; + engine.registry.registerObject(remoteCustomer as any); + // Measured: the anchor IS there (injected) — absence is decided by the + // binding, never by a missing column. + expect((engine.registry.getObject('remote_customer') as any).fields.organization_id).toBeDefined(); + await engine.insert('remote_customer', [{ status: 'open', organization_id: ACTIVE_ORG }], { context: sysCtx } as any); + published.length = 0; + + await engine.update('remote_customer', { status: 'swept' }, { multi: true, where: { status: 'open' }, context: member } as any); + + expect(published).toHaveLength(1); + expect(published[0].type).toBe('data.records.updated'); + expect(hasOrgKey()).toBe(false); + }); + + it('a custom `tenancy.tenantField` is NOT an exit by itself — PRESENT while the object still carries `organization_id`, the column the wall keys on', async () => { + // The wall never reads `tenancy.tenantField`: Layer 0 keys on the literal + // `organization_id` (`objectHasOrgIdField`). An object that declares a + // custom tenant column and still carries the kernel-injected + // `organization_id` is walled on `organization_id = `, so + // the batch IS one organization's and the producer says so. R1 answered + // absent here (an under-delivery); the wall's own predicate answers + // present. ⚠️ A claim about what the wall AND-composed, not about the + // driver's native scoping column. + const workspaceDoc = { + name: 'workspace_doc', + label: 'Workspace doc', + tenancy: { tenantField: 'workspace_id' }, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + status: { name: 'status', type: 'text' as const }, + workspace_id: { name: 'workspace_id', type: 'text' as const }, + }, + }; + engine.registry.registerObject(workspaceDoc as any); + // Measured: the registry still injected the kernel column beside the + // custom one — the declaration does not withhold it. + expect((engine.registry.getObject('workspace_doc') as any).fields.organization_id).toBeDefined(); + await engine.insert('workspace_doc', [ + { status: 'open', workspace_id: 'ws_1', organization_id: ACTIVE_ORG }, + { status: 'open', workspace_id: 'ws_2', organization_id: ACTIVE_ORG }, + ], { context: sysCtx } as any); + published.length = 0; + + await engine.update('workspace_doc', { status: 'swept' }, { multi: true, where: { status: 'open' }, context: member } as any); + + expect(published).toHaveLength(1); + expect(bulkEvent().matched).toBe(2); + expect(hasOrgKey()).toBe(true); + expect(bulkEvent().organizationId).toBe(ACTIVE_ORG); + }); + + it('a custom `tenancy.tenantField` on an object that carries NO `organization_id` publishes it ABSENT — the custom column is never a substitute', async () => { + // `systemFields: false` is the hard opt-out: the registry injects + // nothing, and — the #8608 shape the registry's predicate exists for — + // plugin-security does NOT read it as `tenancyDisabled`, so the wall is + // decided by the column clause alone: no `organization_id` + // (`objectHasOrgIdField: false`) ⇒ no wall ⇒ nothing to assert. The + // declared `workspace_id` is not read as a stand-in: the wall does not + // key on it either. + const workspaceNote = { + name: 'workspace_note', + label: 'Workspace note', + systemFields: false, + tenancy: { tenantField: 'workspace_id' }, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + status: { name: 'status', type: 'text' as const }, + workspace_id: { name: 'workspace_id', type: 'text' as const }, + }, + }; + engine.registry.registerObject(workspaceNote as any); + // Measured: no kernel column arrived. + expect((engine.registry.getObject('workspace_note') as any).fields.organization_id).toBeUndefined(); + await engine.insert('workspace_note', [{ status: 'open', workspace_id: ACTIVE_ORG }], { context: sysCtx } as any); + published.length = 0; + + await engine.update('workspace_note', { status: 'swept' }, { multi: true, where: { status: 'open' }, context: member } as any); + + expect(published).toHaveLength(1); + expect(published[0].type).toBe('data.records.updated'); + expect(hasOrgKey()).toBe(false); + }); + it('an empty active organization OMITS the key AND still publishes — the empty string never reaches the validator', async () => { // `''` is refused by `z.string().min(1)`: handed to the publish site's // `parse` it would throw and the event would be dropped altogether. The diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index ad97225ccd..f3ff938abe 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -133,7 +133,6 @@ import { carriesOrganization, SystemWriteOrganizationRequiredError, ORGANIZATION_OBJECT, - DEFAULT_TENANT_FIELD, } from './tenancy/system-write-organization.js'; // [#13491] The per-object tenancy inventory that replaced the blanket // namespace exemption — the ONE reading both narrowed gates consult. @@ -177,7 +176,11 @@ import { SECRET_MASK, } from './secret-fields.js'; import { pluralToSingular, ExternalWriteForbiddenError } from '@objectstack/spec/shared'; -import { SchemaRegistry, computeFQN, type ArtifactInstallScope } from './registry.js'; +// [#15225] `carriesTenantScopeColumn` is the wall's own object predicate — +// "does Layer 0 key on this object?" — read from the registry's binding of it +// rather than re-spelled here (the R1 re-spelling mirrored one clause of three +// and mislabelled a batch; see `bulkEventOrganizationId`). +import { SchemaRegistry, computeFQN, carriesTenantScopeColumn, type ArtifactInstallScope } from './registry.js'; import { expandSearchToFilter } from './search-filter.js'; import { isSearchCompanionRequested, stripSearchCompanion } from './search-companion.js'; import { ExpressionEngine } from '@objectstack/formula'; @@ -2299,7 +2302,9 @@ function eventOrganizationValue(value: unknown): string | undefined { * enforcement layer told this engine it enforces. * * **What is read, and what the answer is** (the posture table on the card, - * mirrored input-for-input against `computeTenantLayer0Filter`): + * read against `computeTenantLayer0Filter`'s inputs — an exact mirror on the + * POSTURE and CONTEXT inputs, a PARTIAL one on the OBJECT input; the object + * bullet below says which clauses are mirrored and which the seam carries): * * - `enforcedPosture` is the SecurityPlugin-injected posture * ({@link ObjectQL.enforcedTenancyPosture}), ⛔ never the env fallback @@ -2309,10 +2314,32 @@ function eventOrganizationValue(value: unknown): string | undefined { * ANYWHERE to vouch for. `single` (no wall) ⇒ absent. * - `isSystem` short-circuits the whole security middleware ⇒ no wall ⇒ * absent. Same for an absent context. - * - The wall keys on the kernel-injected `organization_id` column literally - * (`objectHasOrgIdField`), and only on a local, tenancy-enabled object — - * so the object must resolve to exactly that column and not be federated - * (a federated anchor is discounted as phantom, #7835) ⇒ else absent. + * - The OBJECT exit. Layer 0 composes nothing when its `tenancyDisabled` + * input is true or the object carries no `organization_id` + * (`objectHasOrgIdField`), and `getObjectSecurityMeta` + * (`security-plugin.ts`) folds THREE clauses into `tenancyDisabled`: + * ① `tenancy.enabled === false`, ② `systemFields.tenant === false`, + * ③ `orgScopingEnabled && platformGlobalObjects.has(object)` — the + * deployment's #12699 carve-out. This producer reads + * {@link carriesTenantScopeColumn}, the registry's binding of the wall's + * predicate (2026-08-14 triage ruling: the wall's derivation is + * authoritative) — clauses ① and ② plus the column clause — so an + * object the wall does not key on answers absent, and a custom + * `tenancy.tenantField` is NOT an exit by itself (the wall never reads + * it; the object is walled iff it carries `organization_id`). ⛔ What is + * NOT mirrored: clause ③ is deployment-declared and invisible to the + * engine — no `platformGlobalObjects` reading exists here — so a + * deployment-exempted object under an armed wall is still stamped with + * the caller's organization while Layer 0 composed no wall. That + * population is the seam #15706 rules on, and the reason this producer's + * PR is Blocked-by it. The wall's fourth object input, the federated + * phantom anchor (#7835: an `external` object's injected + * `organization_id` is a column no storage carries, so + * `objectHasOrgIdField` is false), is answered by the superset + * `external != null` ⇒ absent — conservative for a federated object whose + * author declared a real remote column (the wall stands there; this + * under-delivers rather than re-spelling plugin-security's provenance + * test). * - The Layer 0 EXEMPTION: a true `PLATFORM_ADMIN` crosses the wall where * the object's posture permits (ADR-0095 D3). The engine holds the carried * rung (`ExecutionContext.posture`) but not the superuser write-bypass bit @@ -2342,8 +2369,9 @@ function bulkEventOrganizationId( ): string | undefined { if (enforcedPosture === undefined || !postureEnforcesWall(enforcedPosture)) return undefined; if (!execCtx || execCtx.isSystem === true) return undefined; - if (resolveTenantFieldName(objectSchema) !== DEFAULT_TENANT_FIELD) return undefined; - if ((objectSchema as { external?: unknown } | null | undefined)?.external != null) return undefined; + if (!objectSchema || typeof objectSchema !== 'object') return undefined; + if (!carriesTenantScopeColumn(objectSchema as ServiceObject)) return undefined; + if ((objectSchema as { external?: unknown }).external != null) return undefined; const rung = AuthzPostureSchema.safeParse(execCtx.posture); if (!rung.success || rung.data === 'PLATFORM_ADMIN') return undefined; if (postureUsesUnionScope(enforcedPosture)) { diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index c0699cf0d8..6c5bb03639 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -901,8 +901,21 @@ function provisionTenantScopeIndex( * the write path for the mirrored reason: on exactly those rows the plan also * strips nothing (`plan.names` is `{ id }`), so the author's declared column is * still present when the re-stamp asks. + * + * [#15225] Exported at MODULE level for the engine's bulk-event producer + * (`bulkEventOrganizationId`, engine.ts), which must answer "is this object + * walled?" in the wall's own terms and ⛔ not re-spell them a third time: the + * R1 contract review measured a re-spelling (`resolveTenantFieldName(schema) + * !== DEFAULT_TENANT_FIELD`) that mirrored ONE of the wall's object clauses + * and stamped a batch Layer 0 had never constrained. ⚠️ Deliberately NOT + * added to the package entries — `index.ts` and `core.ts` re-export NAMED + * members of this module, never `export *` — so the published surface of + * `@objectstack/objectql` is unchanged (measured on `dist/*.d.ts`, not + * assumed): this is the registry's binding of plugin-security's predicate, + * not a contract for consumers to build on; the single exported predicate + * (option C above) remains the follow-up. */ -function carriesTenantScopeColumn(schema: ServiceObject): boolean { +export function carriesTenantScopeColumn(schema: ServiceObject): boolean { // Clause 1 — the wall's own two clauses, spelled here because // plugin-security spells them there (option C, the single exported // predicate, is bounded to no new `@objectstack/spec` export and no From 81b8329ee84beb27f07bc6aa4da09b006fa9dacb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 08:17:26 +0000 Subject: [PATCH 5/5] docs(permissions): re-anchor census row 51 on the merged tree Discharges the os-regen deferral recorded by the merge of origin/main (5315098df): the driver kept this branch's side of the MIXED census page whole, dropping main's five rest-server.ts anchor moves on row 51 (main's row 50), and `pnpm gen:system-context-census` re-derives exactly those five numbers from the merged tree. Population unchanged at 107 in 20 packages across 45 files; a second `--fix` rewrites 0 and refuses 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index a11b20e327..6d1f6564ca 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -159,7 +159,7 @@ The largest single consumer — **17 of the 107 sites**. |:--|:---|:---|:---|:---| | 49 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` | | 50 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | -| 51 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5016`, `:6430`, `:6678`, `:7109`, `:7302` | +| 51 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5084`, `:6510`, `:6758`, `:7189`, `:7382` | | 52 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 51's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | | 53 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:422`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 54 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |