From 928306e911958c4eaa954b12440a3147e2984068 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 06:09:13 +0000 Subject: [PATCH 1/2] fix(objectql): publish the record's organization on every DataEvent (#14970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DataEventSchema.organizationId` was declared and published by the spec half but populated by nothing, so every `data.record.*` event went out with the key absent — which the contract requires a consumer to read as "this record is behind no organization wall". `publishDataEvent` now resolves it from the row itself: the written record on `created`, the post-state on `updated`, and the by-id branch's already-read pre-image on `deleted`, so no per-event read is bought. The record's organization, never `ExecutionContext.tenantId` — that is the caller's active org, and the two diverge on exactly the system/unscoped write this key most needs to label correctly. Absence keeps one spelling: the key is omitted, never `''` (which the schema refuses outright, dropping the whole event) and never an explicit `undefined` (which survives `parse` as a present key). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .changeset/data-event-record-organization.md | 45 ++++ .../objectql/src/engine-data-events.test.ts | 235 ++++++++++++++++++ packages/objectql/src/engine.ts | 93 +++++++ 3 files changed, 373 insertions(+) create mode 100644 .changeset/data-event-record-organization.md diff --git a/.changeset/data-event-record-organization.md b/.changeset/data-event-record-organization.md new file mode 100644 index 0000000000..7cbb5524fa --- /dev/null +++ b/.changeset/data-event-record-organization.md @@ -0,0 +1,45 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): a published `DataEvent` now names the organization the RECORD belongs to + +`DataEventSchema.organizationId` has been declared and published since the spec +half landed, and its TSDoc states the obligation on the producer's side: *"a +producer that omits the key on an organization-stamped row publishes a +cross-tenant event, which is fixed at the publish site — never by a +consumer-side lookup."* The engine populated it on no event at all. Every +`data.record.created` / `updated` / `deleted` went out with the key absent, +which a consumer is required to read as *"this record is behind no organization +wall"* — so an organization-stamped row was published as an unwalled one, and a +tenant-scoped fan-out had nothing to discriminate on. + +`publishDataEvent` now resolves the organization from the row itself and spreads +the key in when there is one. The row is already in hand at all three call +sites — the written record on `created`, the post-state on `updated`, and the +pre-image on `deleted` (the by-id branch reads it unconditionally for its +existence gate) — so this buys **no** per-event read: the key exists precisely +to keep a per-event lookup off the fan-out path. + +Three properties are deliberate: + +- **The RECORD's organization, never the caller's.** The row's own tenant column + is the only source consulted. `ExecutionContext.tenantId` is the caller's + *active* organization; the two coincide on an ordinary tenant write and + diverge on a system or unscoped one, where substituting it would mislabel an + administrator's write into another organization as belonging to the + administrator's. +- **Absence has exactly one spelling: the key is omitted.** An object that is + not tenant-scoped, a row whose column is empty, and a value no id can be read + off all publish the key absent rather than `null`, `''` or an explicit + `undefined`. The schema refuses the empty string outright, so producing one + would have thrown at the publish site and dropped the event entirely. +- **The column is resolved the way the write path resolves it** — the + `tenancy.enabled: false` opt-out, then a declared `tenancy.tenantField`, then + the injected `organization_id` — so the event cannot name an organization for + a column the engine does not actually scope by. Note the two spellings differ: + the column is `organization_id`, the published key is `organizationId`. + +No schema, no accepted shape and no public export moves: the key was already +declared, already validated and already part of what consumers parse. Only the +implementation changed, from omitting a declared key to populating it. diff --git a/packages/objectql/src/engine-data-events.test.ts b/packages/objectql/src/engine-data-events.test.ts index 400fee62f0..9c5a8cf64a 100644 --- a/packages/objectql/src/engine-data-events.test.ts +++ b/packages/objectql/src/engine-data-events.test.ts @@ -398,3 +398,238 @@ describe('#4639 — predicate writes publish aggregate BulkDataEvents', () => { expect(warn).toHaveBeenCalled(); }); }); + +/** + * #14970 — the producer half of `DataEvent.organizationId`. + * + * `packages/spec/src/api/events.zod.ts` declared the member (PR #14635) and + * states the obligation on the producer: *"a producer that omits the key on an + * organization-stamped row publishes a cross-tenant event, which is fixed at + * the publish site — never by a consumer-side lookup."* The engine published + * it on no event at all, which left the landed spec term and the ready + * consumer piece (#13566's fan-out filter) both inert. + * + * ⚠️ **A green suite proves nothing here unless the pins discriminate.** The + * failure mode is "the key is absent on EVERY event", and a pin that only + * asserts *absent when there is no organization* passes happily against it. + * Two properties make these pins real: + * + * 1. **Caller organization ≠ record organization.** `execCtx.tenantId` is the + * CALLER's active org; the contract asks for the RECORD's. They coincide on + * an ordinary tenant write and diverge on a system/unscoped one, so every + * positive pin below writes a row into an organization the caller is not + * standing in — an administrator's write into another organization, the + * exact case the spec names. Substituting `execCtx.tenantId` fails them. + * 2. **The two spellings differ.** The row's COLUMN is snake_case + * (`organization_id`); the published KEY is camelCase (`organizationId`). + * Reading the wrong one publishes the key absent on every event while + * every absence pin still passes — so the positive pins assert BOTH + * spellings on the same event. + * + * And absence is asserted as OMISSION, not as `=== undefined`: the schema is + * `z.string().min(1).optional()`, so `''` is refused outright (which would + * throw inside the publish site and drop the event entirely) while a key set + * to an explicit `undefined` survives `parse` as a PRESENT key. + */ +describe('#14970 — a published DataEvent names the RECORD\'s organization', () => { + /** 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 }, + organization_id: { name: 'organization_id', type: 'text' as const }, + }, + }; + + // A SYSTEM context: `isSystem` is what lets a caller file a row under an + // organization that is not its own active one (the tenant write wall, #2946, + // rejects a foreign `organization_id` for everyone else). `tenantId` is the + // caller's org and is deliberately NOT the row's on every positive pin. + const CALLER_ORG = 'org_platform'; + const RECORD_ORG = 'org_acme'; + const sysCtx = { isSystem: true, tenantId: CALLER_ORG, userId: 'usr_admin' }; + + let engine: ObjectQL; + let published: RealtimeEventPayload[]; + let realtime: IRealtimeService; + + const payloadOf = (i = 0) => published[i].payload as Record; + const hasOrgKey = (i = 0) => + Object.prototype.hasOwnProperty.call(payloadOf(i), 'organizationId'); + + 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); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + }); + + it('created: names the ROW\'s organization, not the caller\'s active one', async () => { + const record = await engine.insert( + 'invoice', + { amount: '100', organization_id: RECORD_ORG }, + { context: sysCtx } as any, + ); + + expect(published).toHaveLength(1); + const event = DataEventSchema.parse(published[0].payload); + expect(event.type).toBe('data.record.created'); + expect(event.recordId).toBe(record.id); + + // The discriminating assertion: the RECORD's org, and provably not the + // caller's — `execCtx.tenantId` was a different, non-empty organization + // throughout this write. + expect(event.organizationId).toBe(RECORD_ORG); + expect(event.organizationId).not.toBe(CALLER_ORG); + + // The spelling control (see the block header): the row body carries the + // snake_case COLUMN, the event carries the camelCase KEY, and both are + // populated on this one event. Reading `row.organizationId` instead would + // leave the second one absent while the first still passed. + expect((event.after as Record).organization_id).toBe(RECORD_ORG); + }); + + it('updated: names the POST-state\'s organization, not the caller\'s', async () => { + const record = await engine.insert( + 'invoice', + { amount: '100', organization_id: RECORD_ORG }, + { context: sysCtx } as any, + ); + published.length = 0; + + await engine.update('invoice', { id: record.id, amount: '250' }, { context: sysCtx } as any); + + expect(published).toHaveLength(1); + const event = DataEventSchema.parse(published[0].payload); + expect(event.type).toBe('data.record.updated'); + expect(event.organizationId).toBe(RECORD_ORG); + expect(event.organizationId).not.toBe(CALLER_ORG); + expect((event.after as Record).organization_id).toBe(RECORD_ORG); + }); + + it('updated: a row MOVED between organizations is labelled with where it is NOW', async () => { + const record = await engine.insert( + 'invoice', + { amount: '100', organization_id: RECORD_ORG }, + { context: sysCtx } as any, + ); + published.length = 0; + + await engine.update( + 'invoice', + { id: record.id, organization_id: 'org_moved' }, + { context: sysCtx } as any, + ); + + const event = DataEventSchema.parse(published[0].payload); + // The post-state, not the pre-image — a consumer filtering on the event's + // organization must see the row where it now lives. + expect(event.organizationId).toBe('org_moved'); + expect(event.organizationId).not.toBe(RECORD_ORG); + }); + + it('deleted: names the organization off the PRE-IMAGE — the path with no `after`', async () => { + const record = await engine.insert( + 'invoice', + { amount: '100', organization_id: RECORD_ORG }, + { context: sysCtx } as any, + ); + published.length = 0; + + await engine.delete('invoice', { where: { id: record.id }, context: sysCtx } as any); + + expect(published).toHaveLength(1); + const event = DataEventSchema.parse(published[0].payload); + expect(event.type).toBe('data.record.deleted'); + expect(event.recordId).toBe(record.id); + // The delete path is the one most likely to regress silently: there is no + // post-state to read, so this value can only have come from the pre-image + // the by-id branch already holds. + expect(event.after).toBeUndefined(); + expect(event.organizationId).toBe(RECORD_ORG); + expect(event.organizationId).not.toBe(CALLER_ORG); + }); + + it('an object that is not tenant-scoped OMITS the key on all three actions', async () => { + // `task` declares no `organization_id`, so `resolveTenantFieldName` finds + // no column and nothing is published — rather than the caller's org being + // used as a stand-in, which is what makes this pin more than a tautology: + // the caller carries `tenantId: CALLER_ORG` on every one of these writes. + const record = await engine.insert('task', { title: 'no wall' }, { context: sysCtx } as any); + await engine.update('task', { id: record.id, title: 'edited' }, { context: sysCtx } as any); + await engine.delete('task', { where: { id: record.id }, context: sysCtx } as any); + + expect(published.map((e) => e.type)).toEqual([ + 'data.record.created', 'data.record.updated', 'data.record.deleted', + ]); + for (let i = 0; i < 3; i += 1) { + // OMITTED, asserted as omission: an explicitly-`undefined` key would + // survive `parse` and reach a consumer as a present key. + expect(hasOrgKey(i)).toBe(false); + expect(DataEventSchema.parse(published[i].payload).organizationId).toBeUndefined(); + } + }); + + it('a tenant-scoped object whose ROW carries no organization OMITS the key', async () => { + // Distinct from the case above: the column EXISTS, it is simply empty — + // "not behind any organization wall" for this row. The caller still has an + // active organization, and it still must not be substituted. + const record = await engine.insert( + 'invoice', + { amount: '7', organization_id: null }, + { context: sysCtx } as any, + ); + + expect(published).toHaveLength(1); + expect(hasOrgKey()).toBe(false); + expect(DataEventSchema.parse(published[0].payload).organizationId).toBeUndefined(); + expect(DataEventSchema.parse(published[0].payload).recordId).toBe(record.id); + }); + + it('an empty-string organization column OMITS the key AND still publishes the event', async () => { + // `''` is refused by `z.string().min(1)`, so handing it to the publish + // site's `parse` would throw and the event would be dropped altogether — + // a silence far worse than an absent key. The gate is in the resolver, not + // in the error handler. + await engine.insert( + 'invoice', + { amount: '9', organization_id: '' }, + { context: sysCtx } as any, + ); + + expect(published).toHaveLength(1); + expect(hasOrgKey()).toBe(false); + expect(() => DataEventSchema.parse(published[0].payload)).not.toThrow(); + }); + + it('a batch insert stamps each row with its OWN organization', async () => { + await engine.insert( + 'invoice', + [ + { amount: '1', organization_id: RECORD_ORG }, + { amount: '2', organization_id: 'org_globex' }, + { amount: '3' }, + ], + { context: sysCtx } as any, + ); + + expect(published).toHaveLength(3); + const events = published.map((e) => DataEventSchema.parse(e.payload)); + expect(events.map((e) => e.organizationId)).toEqual([RECORD_ORG, 'org_globex', undefined]); + // The org-less row omits rather than inheriting a sibling's or the + // caller's — one event per record means one organization per record. + expect(hasOrgKey(2)).toBe(false); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 9e13e1778b..860d49623b 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -2189,6 +2189,69 @@ function eventUserId(execCtx?: ExecutionContext): string | undefined { return asString === '' ? undefined : asString; } +/** + * `DataEvent.organizationId` — the organization the RECORD belongs to, read + * off that row's own tenant column (#14970). + * + * The spec declares this member as an obligation on the PRODUCER, and states + * it in terms this function exists to honour literally: *"Present = exactly + * that organization, never a guess. It names the organization the RECORD + * belongs to — not the caller's active organization standing in for the + * row's, which would mislabel an administrator's write into another + * organization."* Two consequences, neither negotiable: + * + * - ⛔ **Never `execCtx.tenantId`.** That is the CALLER's active org — the + * hook-context sense {@link ObjectQL.buildHookUser} publishes, where + * `organizationId` is deliberately "the blessed developer-facing name for + * the caller's". The two coincide on an ordinary tenant write and DIVERGE + * on a system/unscoped one, which is exactly the write this key most needs + * to label correctly. The row is the only truthful source, so this reads + * the row and nothing else. Substituting the caller's org onto a + * permission-boundary column is the defect PR #14726's blocking contract + * review found on a different column. + * - ⛔ **Never a per-event read.** Every call site already holds the row — + * the written record, the post-state, or the delete's pre-image — so this + * is a threading job, not a resolution job. A lookup here would put a query + * on the fan-out path the event exists to keep O(1); triage ruled that out + * for the consumer side on 2026-08-31 and it is equally out here. + * + * The column is resolved through {@link resolveTenantFieldName} — the write + * path's own precedence (`tenancy.enabled: false` opt-out, then a declared + * `tenancy.tenantField`, then the kernel-injected `organization_id`) — so an + * object the engine does not tenant-scope resolves NOTHING rather than being + * mined for a coincidentally-named column, and a disagreement between what the + * engine scopes by and what the event names cannot arise. ⚠️ The two spellings + * differ on purpose and are easy to conflate: the COLUMN is snake_case + * (`organization_id`, machine name), the published KEY is camelCase + * (`organizationId`, the blessed developer-facing name). + * + * Returns `undefined` for every "no organization" case — object not + * tenant-scoped, row absent, column absent, `null`, `''`, or a value no id + * can be read off — and the caller then OMITS the key. Omission is the + * schema's ONE spelling for absence (`z.string().min(1).optional()`): `''` is + * refused outright, which would make `parse` throw and drop the event + * entirely, and a key set to an explicit `undefined` survives `parse` as a + * PRESENT key. Hence the conditional spread at the publish site, not an + * assignment. + */ +function eventOrganizationId(objectSchema: unknown, row: unknown): string | undefined { + const tenantField = resolveTenantFieldName(objectSchema); + 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. + 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; +} + /** * Coerce a multi-row driver result into `BulkDataEvent.matched` (#4639). * @@ -5633,6 +5696,15 @@ export class ObjectQL implements IObjectQLEngine { recordId: unknown; changes?: unknown; after?: unknown; + /** + * The row whose tenant column names this event's `organizationId` + * (#14970) — the written record on `created`, the post-state on + * `updated`, the PRE-IMAGE on `deleted` (a delete has no post-state, and + * `previous` is what every other delete-side consumer already falls back + * to). Passed explicitly rather than inferred from `after` so the delete + * path, the one with no `after`, cannot silently publish the key absent. + */ + organizationRow?: unknown; context?: ExecutionContext; }, ): Promise { @@ -5655,6 +5727,14 @@ export class ObjectQL implements IObjectQLEngine { const changes = eventRecordBody(input.changes); const after = eventRecordBody(input.after); const userId = eventUserId(input.context); + // [#14970] The RECORD's organization, off the row itself — ⛔ never + // `input.context.tenantId`, which is the CALLER's. See + // {@link eventOrganizationId}; omitted, never `''`/`undefined`, because + // absence has exactly one spelling in the schema. + const organizationId = eventOrganizationId( + this._registry.getObject(object), + input.organizationRow, + ); const event: DataEvent = DataEventSchema.parse({ id: generateEventUuid(), type: `data.record.${action}`, @@ -5663,6 +5743,7 @@ export class ObjectQL implements IObjectQLEngine { ...(changes !== undefined ? { changes } : {}), ...(after !== undefined ? { after } : {}), ...(userId !== undefined ? { userId } : {}), + ...(organizationId !== undefined ? { organizationId } : {}), timestamp, }); @@ -10286,6 +10367,8 @@ export class ObjectQL implements IObjectQLEngine { await this.publishDataEvent('created', object, { recordId: record?.id, after: record, + // [#14970] The written row names its own organization. + organizationRow: record, context: opCtx.context, }); } @@ -11616,6 +11699,10 @@ export class ObjectQL implements IObjectQLEngine { recordId: hookContext.input.id ?? resultId, changes: hookContext.input.data, after: result, + // [#14970] The POST-state's organization, not the pre-image's: + // an update that moves a row between organizations must label + // the event with where the row is NOW. + organizationRow: result, context: opCtx.context, }); } @@ -13068,6 +13155,12 @@ export class ObjectQL implements IObjectQLEngine { const resultId = (typeof result === 'object' && result && 'id' in result) ? (result as any).id : undefined; await this.publishDataEvent('deleted', object, { recordId: hookContext.input.id ?? resultId, + // [#14970] The pre-image — a delete has no post-state, and + // this branch is the by-id one, where `priorRecord` was read + // unconditionally by #7867's existence gate and proven + // non-null before `beforeDelete` ever fired. So the row is + // already in hand and NO new read is bought here. + organizationRow: priorRecord, context: opCtx.context, }); } From 4eaa3b81424a984543170be7d54ef6f6f1f7511a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 06:30:31 +0000 Subject: [PATCH 2/2] docs(permissions): re-anchor the system-context census after the engine line shift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical repair by `node scripts/check-system-context-census.mjs --fix`, the only correct writer for this table. Pure line rot: the `eventOrganizationId` helper and its threading shifted every later line in `packages/objectql/src/engine.ts`, so 14 anchors (15 citation sites — one source line is cited twice) pointed at the wrong lines. No population and no classification change: still 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read — the same figures as before the shift. `--fix` did not refuse, and the diff is digits and nothing else (12 lines added, 12 removed, identical once digits are stripped). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- content/docs/permissions/system-context.mdx | 24 ++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index d65bc425f4..176319eea7 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -109,18 +109,18 @@ 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:11290` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11473` | -| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10025` | +| 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:11373` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11556` | +| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10106` | | 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:10073`, `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:5892` | -| 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:3736`, `:3746`, `:3773` | +| 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:10154`, `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:5973` | +| 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` | | 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:6590` | -| 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:12085` | -| 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:12014` | +| 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:6671` | +| 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:12172` | +| 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:12101` | ### 3. Sharing (`plugin-sharing`) @@ -179,8 +179,8 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| -| 62 | `objectql/src/engine.ts:3543` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:14523` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 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:14616` | 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 | @@ -195,7 +195,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:1971` (rationale at `:1881`–`1883`, #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:10008`–`10025` | +| "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:10089`–`10106` | | "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` |