diff --git a/.changeset/tenant-index-author-declared-column.md b/.changeset/tenant-index-author-declared-column.md new file mode 100644 index 0000000000..fa95761508 --- /dev/null +++ b/.changeset/tenant-index-author-declared-column.md @@ -0,0 +1,11 @@ +--- +'@objectstack/objectql': patch +--- + +Declare the multi-tenant tenant-scope index whenever an object carries `organization_id` — whether the platform provisioned that column or the author declared it (#8459) + +On a multi-tenant deployment `SecurityPlugin`'s tenant layer AND-composes `organization_id = ` onto essentially every read of a tenant-scoped object, and the platform declares `indexes: [{ fields: ['organization_id'] }]` so that predicate is served by an index. That declaration was gated on the column being the platform's own injected definition, byte-for-byte. An author who declared their own `organization_id` — adding a label, making it required, pointing it at their own org table — kept their column and silently lost the index on it: the deployment's hottest predicate running unindexed, reached by an additive-looking authoring move that removed a guarantee the author never knew they held. Isolation still held; it was slow, not wrong, which is why it went unreported. + +The condition is lifted off the index half only. Unchanged: the platform still never overwrites an author-declared `organization_id`; an object that declares its own single-column tenant index still gets none from the platform (the opt-out for a different index shape); a single-tenant deployment still declares no tenant index at all; and an object that opts out of the tenant column (`systemFields: false`, `systemFields.tenant: false`, `tenancy.enabled: false`, `managedBy: 'better-auth'`) still gets neither column nor index. The declared column's TYPE is not inspected — a `text` org code is indexed too. + +**DDL-bearing on the next `syncSchema`** for deployments that carry author-declared `organization_id` columns: the driver will create an index it did not create before. Index creation is additive and idempotent — no data migration, no column change, and re-running it is a no-op. diff --git a/packages/objectql/src/meta-object-tenant-index-roundtrip.test.ts b/packages/objectql/src/meta-object-tenant-index-roundtrip.test.ts index 02de5125c3..07fd2f801a 100644 --- a/packages/objectql/src/meta-object-tenant-index-roundtrip.test.ts +++ b/packages/objectql/src/meta-object-tenant-index-roundtrip.test.ts @@ -279,6 +279,55 @@ describe('[#8375] the write path takes back the tenant index the read added (#43 expect(host.storedBody()!.indexes).toEqual(authored.indexes); }); + it('[#8459] round-trips an AUTHOR-DECLARED organization_id — stamp on, strip off', async () => { + // The combination that could not arise before #8459: the read now stamps + // the tenant index on an object whose `organization_id` the AUTHOR + // declared, so the write path owes the counterpart on that object too — + // and it is not a separate implementation to write. The strip re-stamps + // the remainder through `provisionTenantScopeIndex` ITSELF, so widening + // the stamp widens the strip in the same edit; this measures that it + // actually did, rather than asserting it from the code shape. + // + // Two cycles and the STORED ROW, for the reason the head of this file + // gives: one cycle read at the served document cannot separate a strip + // that is bounded from one that never fires. + const authored = { + ...clone(AUTHORED), + fields: { + ...clone(AUTHORED).fields, + // Not byte-identical to `TENANT_SCOPE_FIELD_DEF` — the author's + // own shape, which is what withheld the index before this card. + organization_id: { type: 'lookup', reference: 'sys_organization', label: 'Org' }, + }, + }; + const host = await seed(true, authored); + const firstStored = host.storedBody()!; + // Preconditions, both load-bearing: the author's row carries no index, + // and the column stored is the author's own (the strip that removes + // INJECTED columns must not have taken it — it is not the platform's). + expect(firstStored.indexes).toBeUndefined(); + expect(firstStored.fields.organization_id).toEqual(authored.fields.organization_id); + + for (const cycle of [1, 2]) { + const item = await served(host); + expect(item.indexes, `cycle ${cycle} served`).toEqual([PLATFORM_TENANT_INDEX]); + // The author's column travels out unchanged beside the index the + // platform added — the field half of the ruling, on the served body. + expect(item.fields.organization_id, `cycle ${cycle} column`) + .toEqual(authored.fields.organization_id); + + await host.protocol.saveMetaItem({ + type: 'object', name: AUTHORED.name, item, + } as never); + + // The row is where a missing strip would bake the platform's entry + // in — into `sys_metadata.metadata`, its checksum and every history + // diff (#4326). + expect(host.storedBody()!.indexes, `cycle ${cycle} stored`).toBeUndefined(); + expect(host.storedBody(), `cycle ${cycle} body`).toEqual(firstStored); + } + }); + it('adds and strips NOTHING on an object that opts out of the tenant column', async () => { // The stamp is gated on the spec's own derivation, not on the // deployment flag alone: `systemFields.tenant: false` withholds the diff --git a/packages/objectql/src/registry-tenant-index-author-declared-column.test.ts b/packages/objectql/src/registry-tenant-index-author-declared-column.test.ts new file mode 100644 index 0000000000..37036ddcfa --- /dev/null +++ b/packages/objectql/src/registry-tenant-index-author-declared-column.test.ts @@ -0,0 +1,305 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8459] On a multi-tenant deployment the platform declares the tenant-scope + * index whenever the object carries `organization_id` — whether the PLATFORM + * provisioned that column or the AUTHOR declared it. + * + * ## The behaviour this file replaces + * + * `provisionTenantScopeIndex` gated the index on the column being the + * platform's own definition (`isInjectedColumnDefinition`, byte-for-byte). An + * author who declared their own `organization_id` — a natural, additive-looking + * move: adding a label, making it `required`, pointing it at their own org + * table — kept their column and silently lost the index on it. The column is + * still THE tenant isolation key: `computeTenantLayer0Filter` + * (plugin-security) AND-composes `organization_id = ` onto essentially + * every read of that object, so the deployment's hottest predicate ran + * unindexed. Not a security hole — isolation still holds; it is slow, not + * wrong, which is exactly why nobody files it. + * + * The condition was never argued for: before #8375 the index push sat + * physically nested inside the field-injection branch, so "the author declared + * the column" and "the platform declares no index" were the same condition by + * NESTING. #8375 lifted the decision into one named predicate and preserved the + * behaviour exactly rather than widening it inside a convergence fix, which is + * what made it a decision that could be taken on purpose. + * + * Maintainer ruling, 2026-08-13 (option A): one rule, stated once — the wall's + * predicate is indexed on a walled deployment. Type-inspecting variants were + * rejected: a `text` org code must get the index too, so the `text` case below + * is a pin against re-introducing that judgement, not an incidental variant. + * + * ## What each case here is FOR + * + * Three behaviours have to hold at once and a pin proving one is silent about + * the others, so each is asserted on the stored/answered VALUE: + * + * 1. the author-declared column now carries the index (the change); + * 2. an object that already declares its own tenant index still gets none from + * the platform (the deliberate opt-out for a different index shape); + * 3. the author's column DEFINITION is still never overwritten. + * + * Plus the two controls that a change indexing unconditionally would fail: a + * single-tenant deployment declares no tenant index at all, and an object that + * opts out of the tenant column keeps withholding it. + * + * ⛔ Never assert a LENGTH or a DELTA on `indexes` here. `indexes` concatenates + * under `mergeObjectDefinitions`, which makes "the list did not grow" look like + * a strong claim; it is not. Measured with the write-side strip fully ablated, + * the served list goes `undefined -> 1 -> 1 -> 1` across round trips — one + * phantom entry, then it stabilizes, because `declaresTenantIndex` guards the + * append. A count assertion is therefore green with the change absent. Every + * assertion below reads the stored entry itself. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +// [#5619] The producer's OWN write-verb dispatch decisions, so the fake engine +// below cannot accept a call ObjectQL refuses. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { SchemaRegistry, applySystemFields } from './registry.js'; + +/** The platform's own entry — the exact value the seam appends. */ +const PLATFORM_TENANT_INDEX = { fields: ['organization_id'] }; + +/** + * An author-declared tenant column, in the two shapes the ruling names. + * + * `lookup` is the card's own repro: the same reference the platform uses, with + * the author's label — the shape an author reaches for when all they wanted was + * a nicer label on a column they already have. `text` is the shape option C + * would have excluded ("a `text` org code loses the index and looks fine"), and + * is pinned for exactly that reason. + */ +const DECLARED_LOOKUP = { type: 'lookup', reference: 'sys_organization', label: 'Org' }; +const DECLARED_TEXT = { type: 'text', label: 'Org Code' }; + +const AUTHOR_COLUMNS: Array<[string, Record]> = [ + ['lookup to sys_organization with the author’s own label', DECLARED_LOOKUP], + ['a plain text org code', DECLARED_TEXT], +]; + +/** A business object carrying the author's own `organization_id`. */ +const leadWith = (organization_id: Record, extra: Record = {}) => + ({ + name: 'lead', + label: 'Lead', + fields: { + first_name: { type: 'text', label: 'First name' }, + organization_id: { ...organization_id }, + }, + ...extra, + }) as any; + +/** Declared indexes whose column list is exactly `['organization_id']`. */ +const tenantIndexes = (def: any) => + (def?.indexes ?? []).filter( + (i: any) => Array.isArray(i?.fields) && i.fields.length === 1 && i.fields[0] === 'organization_id', + ); + +/** The stored (post-injection) definition, as `registerObject` left it. */ +const storedDefinition = (registry: SchemaRegistry, name = 'lead') => + (registry as any).objectContributors.get(name)[0].definition as any; + +/** + * The registry-backed `/meta` surface, with no DB behind it: every overlay + * lookup answers empty, so the served answer comes through the read exit's + * materialization seam ({@link SchemaRegistry.materializeServedObjectOnto}) — + * the SECOND caller of `provisionTenantScopeIndex`, and the half a + * producer-only assertion cannot see. + */ +function metaSurface(multiTenant: boolean, object: any) { + const registry = new SchemaRegistry({ multiTenant, searchCompanion: false } as never); + registry.registerObject(object, 'crm', 'crm', 'own'); + const engine = { + registry, + find: async () => [], + findOne: async () => null, + insert: async () => ({ id: 'x' }), + update: async (_t: string, data: Record, opts?: Record) => { + assertEngineUpdateDispatch(data, opts); + return { id: 'x' }; + }, + delete: async (_t: string, opts?: Record) => { + assertEngineDeleteDispatch(opts); + return { deleted: 0 }; + }, + count: async () => 0, + aggregate: async () => [], + } as any; + return { registry, protocol: new ObjectStackProtocolImplementation(engine) }; +} + +describe('[#8459] an author-declared organization_id gets the platform tenant index', () => { + // ── 1. The change ─────────────────────────────────────────────────────────── + + describe.each(AUTHOR_COLUMNS)('multiTenant, author-declared column (%s)', (_label, column) => { + it('the PRODUCER (applySystemFields) declares the tenant index', () => { + const out: any = applySystemFields(leadWith(column), { multiTenant: true }); + + // The whole list, by value — not `.length`, not "contains something + // tenant-shaped". This is the entry a driver materializes from. + expect(out.indexes).toEqual([PLATFORM_TENANT_INDEX]); + // No `name` (each driver derives its own, table-qualified on SQL) and no + // `unique` — a plain lookup index, never a constraint. Pinned because a + // UNIQUE index on the tenant column would make every table single-row + // per organization. + expect(out.indexes[0].name).toBeUndefined(); + expect(out.indexes[0].unique).toBeUndefined(); + }); + + it('the REGISTRY answers it — the card’s own repro, inverted', () => { + // `registry.getObject(name).indexes ==> undefined` is what the card + // reported. It is the resolved answer every consumer reads. + const { registry } = metaSurface(true, leadWith(column)); + + expect(tenantIndexes(registry.getObject('lead'))).toEqual([PLATFORM_TENANT_INDEX]); + expect(tenantIndexes(storedDefinition(registry))).toEqual([PLATFORM_TENANT_INDEX]); + }); + + it('the READ EXIT serves it — the second caller of the same predicate', async () => { + // [A1] `provisionTenantScopeIndex` has two callers: the producer above and + // `materializeBaseLayer`, which every `/meta` read exit replays. A change + // reaching only one of them fixes half the surface and the other half + // disagrees silently — which is the exact defect #8375 closed. + const { registry, protocol } = metaSurface(true, leadWith(column)); + const item: any = (await protocol.getMetaItem({ type: 'object', name: 'lead' })).item; + const listed: any = (await protocol.getMetaItems({ type: 'object' })).items.find( + (i: any) => i.name === 'lead', + ); + + expect(tenantIndexes(item)).toEqual([PLATFORM_TENANT_INDEX]); + expect(tenantIndexes(listed)).toEqual([PLATFORM_TENANT_INDEX]); + // Written against the registry's own answer as well as the literal: the + // claim is that the two are ONE answer, not two derivations that agree. + expect(item.indexes).toEqual(registry.getObject('lead')!.indexes); + // The served document still reads back clean — the #6810 channel. An + // index declaration that made `/meta` report the platform's own object + // invalid would be the same defect one key over. + expect(item._diagnostics).toEqual({ valid: true }); + }); + }); + + // ── 3. The field half stays exactly as it was ─────────────────────────────── + + describe.each(AUTHOR_COLUMNS)('the author’s column definition is untouched (%s)', (_label, column) => { + it('is served byte-identical to what the author declared', async () => { + // The ruling lifts the injected-column condition off the INDEX half ONLY. + // The platform still must not overwrite an author-declared + // `organization_id`; `registry.test.ts`'s "does NOT overwrite an + // author-declared organization_id" pins the producer, and this is the + // same claim on the SERVED document, beside the index that now travels + // with it. Asserted as the whole definition: a merge that layered the + // platform's `readonly`/`hidden`/`system` on top would satisfy any + // single-key check while taking the author's column over. + const { protocol } = metaSurface(true, leadWith(column)); + const item: any = (await protocol.getMetaItem({ type: 'object', name: 'lead' })).item; + + expect(item.fields.organization_id).toEqual(column); + }); + }); + + // ── 2. The opt-out is not bypassed ────────────────────────────────────────── + + describe.each(AUTHOR_COLUMNS)('an author who declares their OWN tenant index (%s)', (_label, column) => { + it('gets no platform entry beside it — the list is byte-identical to theirs', () => { + // The deliberate escape hatch for anyone who wants a different index + // shape. It is load-bearing precisely BECAUSE of this card: before it, + // the platform never stamped on an author-declared column, so nothing + // could duplicate. Now the append is live on exactly these objects and + // `declaresTenantIndex` is the only thing stopping it. + const authored = leadWith(column, { + indexes: [{ fields: ['organization_id'] }, { fields: ['first_name'] }], + }); + const out: any = applySystemFields(authored, { multiTenant: true }); + + expect(out.indexes).toEqual([ + { fields: ['organization_id'] }, + { fields: ['first_name'] }, + ]); + }); + + it('gets no platform entry beside a NAMED tenant index either', () => { + // `declaresTenantIndex` matches the single-column shape, named or not — + // an author who named their index has still declared one. + const authored = leadWith(column, { + indexes: [{ name: 'my_tenant_idx', fields: ['organization_id'] }], + }); + const out: any = applySystemFields(authored, { multiTenant: true }); + + expect(out.indexes).toEqual([{ name: 'my_tenant_idx', fields: ['organization_id'] }]); + }); + + it('DOES get one beside a composite index that merely LEADS with the column', () => { + // The other side of that boundary, unchanged by this card and asserted so + // the widening cannot quietly swallow it: a composite is a leading-column + // match on some dialects and not on others, so it is not a substitute for + // the single-column index. + const authored = leadWith(column, { + indexes: [{ fields: ['organization_id', 'first_name'] }], + }); + const out: any = applySystemFields(authored, { multiTenant: true }); + + expect(out.indexes).toEqual([ + { fields: ['organization_id', 'first_name'] }, + PLATFORM_TENANT_INDEX, + ]); + }); + }); + + // ── The two controls ──────────────────────────────────────────────────────── + + describe.each(AUTHOR_COLUMNS)('a SINGLE-TENANT deployment (%s)', (_label, column) => { + it('declares no tenant index on an author-declared column either', async () => { + // The control that fails for a change which indexes unconditionally. + // Nothing filters by organization on an unwalled stack + // (`computeTenantLayer0Filter` returns null for the `single` posture), so + // the index is dead weight — the absence IS the declaration, per #6810. + const { registry, protocol } = metaSurface(false, leadWith(column)); + const item: any = (await protocol.getMetaItem({ type: 'object', name: 'lead' })).item; + + expect(applySystemFields(leadWith(column), { multiTenant: false }).indexes).toBeUndefined(); + expect(registry.getObject('lead')!.indexes).toBeUndefined(); + expect(item.indexes).toBeUndefined(); + // …and the COLUMN is still the author's, on either deployment. + expect(item.fields.organization_id).toEqual(column); + }); + }); + + describe.each(AUTHOR_COLUMNS)('an object that opts OUT of the tenant column (%s)', (_label, column) => { + it('declares no tenant index even though it carries an organization_id', () => { + // The boundary this card does NOT move, stated as a pin because the + // ruling's sentence ("whenever the object carries organization_id") reads + // wider than the eligibility gate that stays. + // + // `systemFields.tenant: false` is the object saying it is not + // tenant-scoped, and plugin-security reads the same declaration: + // `computeTenantLayer0Filter` returns null when `tenancyDisabled`, so the + // wall composes NO predicate on this object and there is nothing for an + // index to serve. Withholding it here is the same reasoning as + // `multiTenant: false`, not a leftover of the condition that was lifted. + const opted = leadWith(column, { systemFields: { tenant: false } }); + const out: any = applySystemFields(opted, { multiTenant: true }); + + expect(out.indexes).toBeUndefined(); + // The author's column is still theirs — opting out of the INJECTION never + // deletes a declared field. + expect(out.fields.organization_id).toEqual(column); + }); + }); + + // ── Idempotence, which the widening puts back at risk ────────────────────── + + it('stamping twice appends once — the seam runs at registration AND at every read', () => { + // `provisionTenantScopeIndex` runs at the tail of `applySystemFields` and + // again at the materialization seam, so on an author-declared column the + // second pass now has a live append to make. An array push is the one part + // of this injection that is not naturally idempotent. + const once: any = applySystemFields(leadWith(DECLARED_LOOKUP), { multiTenant: true }); + const twice: any = applySystemFields(once, { multiTenant: true }); + + expect(twice.indexes).toEqual([PLATFORM_TENANT_INDEX]); + expect(twice.indexes).toEqual(once.indexes); + }); +}); diff --git a/packages/objectql/src/registry.test.ts b/packages/objectql/src/registry.test.ts index b876ceb300..989f0d8c39 100644 --- a/packages/objectql/src/registry.test.ts +++ b/packages/objectql/src/registry.test.ts @@ -739,6 +739,15 @@ describe('applySystemFields', () => { // organization_id preserved; audit fields still injected expect(out.fields.organization_id.label).toBe('Org Code'); expect(out.fields.created_at).toBeDefined(); + // [#8459] …and the INDEX is declared on it all the same. The two halves + // used to be one condition by nesting, and the ruling separated them: + // the platform never overwrites the author's column (this test's + // subject, unchanged), and it always indexes the wall's predicate on a + // walled deployment (the line below). A `text` org code is deliberately + // the fixture — indexing only a `lookup` to `sys_organization` was the + // rejected option. Full coverage in + // `registry-tenant-index-author-declared-column.test.ts`. + expect((out as any).indexes).toEqual([{ fields: ['organization_id'] }]); }); it('respects systemFields: false opt-out', () => { diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 1954a95a1d..cccebbad88 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveInjectedSystemColumns, isInjectedColumnDefinition, checkManagedApiMethodAffordances, LEGACY_API_METHODS, AUDIT_PROVENANCE_FIELDS } from '@objectstack/spec/data'; +import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveInjectedSystemColumns, checkManagedApiMethodAffordances, LEGACY_API_METHODS, AUDIT_PROVENANCE_FIELDS } from '@objectstack/spec/data'; // [#4513] The audit-family governance table, and [#6562] the injected-column // DEFINITION tables it governs — see the re-exports below for why both live in a // package `objectql` and `metadata-protocol` both depend on. @@ -646,21 +646,41 @@ export function applySystemFields( * * ## The predicate, stated exactly * - * "The object carries a tenant column the PLATFORM provisioned" — not "this call - * just injected one". The distinction is the whole reason the decision moved out - * of the injection branch: at a read exit the column is ALREADY present - * (`governServedItem` runs `applyInjectedSystemColumns` before the seam), so a - * condition reading `!schema.fields?.organization_id` is false exactly where the - * convergence is needed. `isInjectedColumnDefinition` answers the question the - * old nesting was really asking, at either site, in either order. + * "The object carries a tenant column" — not "this call just injected one", and + * [#8459] not "the PLATFORM provisioned it". The first distinction is the whole + * reason the decision moved out of the injection branch: at a read exit the + * column is ALREADY present (`governServedItem` runs + * `applyInjectedSystemColumns` before the seam), so a condition reading + * `!schema.fields?.organization_id` is false exactly where the convergence is + * needed. {@link carriesTenantScopeColumn} answers the question the old nesting + * was really asking, at either site, in either order. * - * An author-declared `organization_id` of the author's own shape therefore still - * gets NO platform index, exactly as before. The one behaviour this widens is an - * author who declares the column BYTE-IDENTICAL to `TENANT_SCOPE_FIELD_DEF` - * (all eight keys, same values) and then gets the index — accepted for the - * reason `stripProvisionedSearchCompanionFrom` accepts its twin: the two are - * indistinguishable by construction, and the platform's own answer for that - * column is the one being served either way. + * The second distinction is the 2026-08-13 maintainer ruling on #8459. This + * predicate also required the column to be the platform's OWN definition + * (`isInjectedColumnDefinition`, byte-for-byte), which #8375 preserved exactly + * rather than widening inside a convergence fix. The effect was that an author + * who declared their own `organization_id` — adding a label, making it + * `required`, pointing it at their own org table — kept the column and silently + * lost the index on it, while `computeTenantLayer0Filter` (plugin-security) + * went on AND-composing `organization_id = ` onto essentially every read of + * that object. The deployment's hottest predicate, unindexed, reached by an + * additive-looking authoring move that removed a guarantee the author never knew + * they held. + * + * The ruling is one rule, stated once: on a walled deployment the wall's + * predicate is indexed, whoever typed the column. ⛔ Not a type judgement — an + * `organization_id` declared as `text` gets the index too. Inspecting the + * declared type to decide whether it is "really" the tenant anchor was + * considered and REJECTED: it is a third predicate at the site where #8375 just + * reduced two to one, and its boundary is itself something an author gets + * subtly wrong (a `text` org code would lose the index and look fine). + * + * ⚠️ Recorded gap, deliberately left uncovered by that ruling: an author who + * wants NO index on the tenant column has no way to say so. If that need + * appears it becomes an explicit declared key — ⛔ it is not grounds to restore + * the injected-column condition. What exists today for a DIFFERENT index shape + * is {@link declaresTenantIndex}: declare your own single-column tenant index + * and the platform declares none. * * `multiTenant: false` declares NO index rather than a false one: on an unwalled * stack nothing filters by organization, so the index is dead weight — the same @@ -690,7 +710,7 @@ function provisionTenantScopeIndex( opts: { multiTenant: boolean }, ): ServiceObject { if (!opts.multiTenant) return schema; - if (!platformOwnsTenantColumn(schema)) return schema; + if (!carriesTenantScopeColumn(schema)) return schema; if (declaresTenantIndex(schema)) return schema; return { @@ -704,26 +724,44 @@ function provisionTenantScopeIndex( } /** - * [#8375] Is this object's `organization_id` the PLATFORM's column rather than - * one the author declared? + * [#8375, widened by #8459] Is this object tenant-scoped — i.e. does it carry an + * `organization_id` column for the wall to filter on? + * + * The spec's own derivation (`resolveInjectedSystemColumns`, #5378) and nothing + * else, so `systemFields: false`, `systemFields.tenant: false`, + * `tenancy.enabled: false` and `managedBy: 'better-auth'` all withhold the index + * exactly as they withhold the column. Re-deriving any of those rows here is the + * drift that plan exists to prevent. * - * Two independent conditions, and both are load-bearing: + * ⚠️ That gate is NOT a leftover of the condition #8459 lifted, and it does not + * contradict the ruling's "whenever the object carries `organization_id`": it is + * the same reasoning as `multiTenant: false`, one scope down. An object that + * declares itself non-tenant-scoped is one the wall composes NO predicate on — + * `computeTenantLayer0Filter` (plugin-security) returns `null` when + * `tenancyDisabled`, which reads the very same `systemFields.tenant` / + * `tenancy.enabled` declarations — so an index there would serve nothing. What + * #8459 removed is the SECOND condition this function used to carry: that the + * `organization_id` present be the platform's own definition byte-for-byte + * (`isInjectedColumnDefinition`). Where the column comes from is no longer part + * of the question; whether the object is walled still is. * - * - the object must carry a tenant column at all — the spec's own derivation - * (`resolveInjectedSystemColumns`, #5378), so `systemFields: false`, - * `systemFields.tenant: false`, `tenancy.enabled: false` and - * `managedBy: 'better-auth'` all withhold the index exactly as they withhold - * the column. Re-deriving any of those rows here is the drift that plan - * exists to prevent; - * - the column present must be the platform's own definition, or absent - * (the pre-injection shape). `isInjectedColumnDefinition` compares against - * the shipped table byte-for-byte, which is the same exactness - * `stripInjectedSystemColumns` uses to decide the same question in reverse. + * ⛔ Do NOT "simplify" this to a field-map check (`fields.organization_id !== + * undefined`), however closely that reads to the ruling's sentence. Measured: + * it breaks the WRITE path for ordinary platform-provisioned objects. The save + * path strips the injected COLUMNS before it strips the materialized stamps + * (`stripMaterializedFromRegistry(type, stripServedSystemColumns(type, item))`, + * `@objectstack/metadata-protocol`), so by the time + * {@link SchemaRegistry.stripProvisionedTenantIndexFrom} re-stamps the + * remainder through this function, the body no longer HAS an + * `organization_id` — a field-map predicate answers "not tenant-scoped", the + * re-stamp adds nothing, the lists differ, the strip refuses, and the + * platform's own index entry is baked into `sys_metadata.metadata`, its + * checksum and every history diff (the #4326 regression). Reading the object's + * DECLARATIONS instead reaches the same verdict on a stripped body as on a + * whole one, which is what makes the stamp and its inverse agree. */ -function platformOwnsTenantColumn(schema: ServiceObject): boolean { - if (!resolveInjectedSystemColumns(schema).tenant) return false; - const declared = (schema.fields as Record | undefined)?.organization_id; - return declared === undefined || isInjectedColumnDefinition(declared, TENANT_SCOPE_FIELD_DEF); +function carriesTenantScopeColumn(schema: ServiceObject): boolean { + return resolveInjectedSystemColumns(schema).tenant; } /**