From 68554c1aa3ba2da9ff754345620296c0e32b4e8b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 17:59:53 +0000 Subject: [PATCH] fix(objectql): the tenant-scope index follows the wall's derivation (#8608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform's tenant-scope index was gated on the spec's INJECTION plan (`resolveInjectedSystemColumns(...).tenant`), while plugin-security's Layer 0 wall derives `tenancyDisabled` from exactly two clauses: tenancy.enabled === false || systemFields.tenant === false `systemFields: false` — the hard object-level opt-out — is in the plan and in neither clause, so an object using it while declaring its own `organization_id` had the wall predicate AND-composed onto essentially every read with no index behind it. Measured end to end before the fix: the registry answered `indexes: null` while `SecurityPlugin#getReadFilter` answered `{ organization_id: 'org-1' }` for the same object. Per the triage ruling of 2026-08-14 (option A, standing meta-rule: the governed side wins and the ungoverned side rebinds to it), `carriesTenantScopeColumn` is rebound to the wall's two clauses plus "the object carries organization_id", and the `applySystemFields` exit that injects nothing now routes through `provisionTenantScopeIndex` like every other exit — it was the last one that decided the index by not asking. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 --- .changeset/tenant-index-follows-the-wall.md | 49 +++ ...meta-object-tenant-index-roundtrip.test.ts | 52 ++++ ...registry-tenant-index-follows-wall.test.ts | 285 ++++++++++++++++++ packages/objectql/src/registry.ts | 124 ++++++-- 4 files changed, 477 insertions(+), 33 deletions(-) create mode 100644 .changeset/tenant-index-follows-the-wall.md create mode 100644 packages/objectql/src/registry-tenant-index-follows-wall.test.ts diff --git a/.changeset/tenant-index-follows-the-wall.md b/.changeset/tenant-index-follows-the-wall.md new file mode 100644 index 0000000000..df248d1f10 --- /dev/null +++ b/.changeset/tenant-index-follows-the-wall.md @@ -0,0 +1,49 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): the tenant-scope index follows the WALL's derivation, so an object that opts out with `systemFields: false` while declaring its own `organization_id` stops running the wall predicate unindexed (#8608) + + + +Two places answered *"is this object tenant-scoped?"* and read different +declarations. The platform's tenant-scope index was gated on the spec's +**injection plan** (`resolveInjectedSystemColumns(...).tenant`), while +plugin-security's Layer 0 wall derives `tenancyDisabled` from exactly two +clauses: + +```ts +tenancy.enabled === false || systemFields.tenant === false +``` + +`systemFields: false` — the hard object-level opt-out — is in the plan and in +neither of those clauses. So an object using that opt-out **while declaring its +own `organization_id`** had `organization_id = ` AND-composed onto +essentially every read, with no index behind it: the deployment's hottest +predicate, unindexed. Not a security hole — isolation still held; it was slow, +not wrong, which is why nothing surfaced it. + +**Both halves were measured end to end** rather than read off the source. On the +pre-fix tree, for one such object, the registry answered `indexes: null` while +`SecurityPlugin#getReadFilter` answered `{ organization_id: 'org-1' }` for an +ordinary member. + +The wall's derivation is authoritative and the index now follows it: the index +is declared when tenancy is not disabled by the wall's two clauses **and** the +object carries `organization_id` — whether the platform provisions the column or +the author declared it. `managedBy: 'better-auth'` is deliberately not re-added +as a third clause, because the wall does not read it either; the one shipped +platform object whose answer changes is `sys_member`, which is walled on +`organization_id` and whose only tenant-leading index was the composite +`['organization_id', 'user_id']`. + +Unchanged, and pinned beside the fix: `systemFields.tenant: false` and +`tenancy.enabled: false` still declare no index (the wall composes no predicate +there, so an index would serve nothing), a single-tenant deployment still +declares none at all, an author's own tenant index still suppresses the +platform's, and the hard opt-out still injects no platform columns — only the +index decision was ever owed at that exit. 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 c46a06ad73..a8fe09065a 100644 --- a/packages/objectql/src/meta-object-tenant-index-roundtrip.test.ts +++ b/packages/objectql/src/meta-object-tenant-index-roundtrip.test.ts @@ -330,6 +330,58 @@ describe('[#8375] the write path takes back the tenant index the read added (#43 } }); + it('[#8608] round-trips a `systemFields: false` object that declares its own organization_id', async () => { + // The row #8608 moved: the hard object-level opt-out is not one of the + // wall's two `tenancyDisabled` clauses, so plugin-security composes + // `organization_id = ` on such an object and the platform now + // indexes the column it filters. The stamp reaching a new row means the + // STRIP owes that row too — and the write path is where the cost of + // getting it wrong is permanent rather than recomputed: an entry that + // is not taken back is baked into `sys_metadata.metadata`, its checksum + // and every history diff (#4326). + // + // It is not a separate implementation to check — the strip re-stamps + // the remainder through `provisionTenantScopeIndex` itself — but this + // row exercises the branch the OTHER cases cannot: on the hard opt-out + // the injected-column strip removes nothing (the plan's `names` is + // `{ id }`), so the author's declared column is still present when the + // re-stamp asks. That is what makes the field-map half of the predicate + // safe here; a stripped body on any other row is answered by the + // injection-plan half. + // + // 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), + systemFields: false, + fields: { + ...clone(AUTHORED).fields, + organization_id: { type: 'lookup', reference: 'sys_organization', label: 'Org' }, + }, + }; + const host = await seed(true, authored); + const firstStored = host.storedBody()!; + 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 hard opt-out still injects nothing: the index travels, the + // platform columns do not. + expect(Object.keys(item.fields).sort(), `cycle ${cycle} fields`) + .toEqual(['code_label', 'name', 'organization_id']); + + await host.protocol.saveMetaItem({ + type: 'object', name: AUTHORED.name, item, + } as never); + + 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-follows-wall.test.ts b/packages/objectql/src/registry-tenant-index-follows-wall.test.ts new file mode 100644 index 0000000000..8bf1ccd40f --- /dev/null +++ b/packages/objectql/src/registry-tenant-index-follows-wall.test.ts @@ -0,0 +1,285 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8608] The tenant-scope INDEX follows the WALL, not the injection plan. + * + * ## The disagreement this file closes + * + * Two places answered "is this object tenant-scoped?" and read different + * declarations. `provisionTenantScopeIndex` (this package) asked the spec's + * INJECTION plan — `resolveInjectedSystemColumns(...).tenant` — while + * plugin-security's Layer 0 derives `tenancyDisabled` from exactly two clauses: + * + * tenancy.enabled === false || systemFields.tenant === false + * + * Three of the plan's opt-out rows appear in both readings and agree. + * `systemFields: false` — the hard object-level opt-out — appears only in the + * plan. So an object using it while declaring its OWN `organization_id` had the + * wall's predicate AND-composed onto essentially every read with no index + * behind it: the "hottest predicate unindexed" shape #6810 and #8459 exist to + * prevent, reached by a third route. + * + * Both halves were measured end to end before the fix, not inferred from the + * source (the card asserted its security half from source and flagged that it + * had not run it). For `systemFields: false` + an author-declared + * `organization_id`, on merged `main`: + * + * applySystemFields(obj, { multiTenant: true }).indexes => null + * SecurityPlugin#getReadFilter('lead', ) => { organization_id: 'org-1' } + * + * ## The ruling + * + * Triage, 2026-08-14, option A, on the standing meta-rule that when one + * question has two disagreeing implementations the GOVERNED side wins and the + * ungoverned side rebinds to it: the wall's derivation is authoritative, the + * index follows it. ⛔ Option B (teach plugin-security to treat + * `systemFields: false` as tenancy-disabled) was REJECTED — it narrows a wall, + * which only an explicit product ruling can do. + * + * ## Why all three of the card's rows are pinned here, not just the one moving + * + * "Grant more indexes" passes row 1 trivially. The card's table has two rows + * that must NOT move — the object that opted out of the tenant column, and the + * plain object #8459 already covered — and a change that indexes on the + * deployment flag alone, or one that drops the wall's clauses instead of + * adopting them, is green on row 1 and red on those. They are asserted side by + * side, in one file, so the fix is visibly one row wide. + * + * ⛔ Never assert a LENGTH or a DELTA on `indexes` here, for the reason + * `registry-tenant-index-author-declared-column.test.ts` records in full: + * `indexes` concatenates under `mergeObjectDefinitions` and `declaresTenantIndex` + * guards the append, so a count assertion is green with the change absent. Every + * assertion below reads the 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'] }; + +/** The author's own tenant column: the card's repro shape. */ +const DECLARED_ORG = { type: 'lookup', reference: 'sys_organization', label: 'Org' }; + +/** + * A business object carrying the author's own `organization_id`, plus whatever + * opt-out row the case is about. + */ +const leadWith = (extra: Record = {}, fields: Record = {}) => + ({ + name: 'lead', + label: 'Lead', + fields: { + first_name: { type: 'text', label: 'First name' }, + organization_id: { ...DECLARED_ORG }, + ...fields, + }, + ...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 registry-backed `/meta` surface with no DB behind it, so the served answer + * comes through the read exit's materialization seam + * ({@link SchemaRegistry.materializeServedObjectOnto}) — the SECOND caller of + * `provisionTenantScopeIndex`. Same double as the #8459 file's, kept in step + * with it deliberately: both write verbs are pinned to the producer's own + * dispatch decisions. + */ +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('[#8608] the tenant index follows the wall’s derivation', () => { + // ── The card's measurement table, all three rows, side by side ───────────── + + describe('the card’s measurement table', () => { + it('ROW 1 (the change): `systemFields: false` + an author-declared organization_id gets the index', () => { + // The one row where the two derivations disagreed. The wall composes + // `organization_id = ` here — measured, see the head of this file — + // so the platform now indexes the column it filters on. + const out: any = applySystemFields(leadWith({ systemFields: false }), { multiTenant: true }); + + 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: 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('ROW 2 (control): `systemFields.tenant: false` still gets none', () => { + // Security AGREES this object is not walled — `computeTenantLayer0Filter` + // returns `null` when `tenancyDisabled`, and this is one of its two + // clauses — so there is no predicate for an index to serve. A fix that + // read the field map alone, or the deployment flag alone, grants here and + // is wrong. + const out: any = applySystemFields(leadWith({ systemFields: { tenant: false } }), { + multiTenant: true, + }); + + expect(out.indexes).toBeUndefined(); + // Opting out of the INJECTION never deletes a declared field. + expect(out.fields.organization_id).toEqual(DECLARED_ORG); + }); + + it('ROW 3 (control): a plain object keeps #8459’s answer', () => { + const out: any = applySystemFields(leadWith(), { multiTenant: true }); + + expect(out.indexes).toEqual([PLATFORM_TENANT_INDEX]); + }); + }); + + // ── The wall's clause set, adopted exactly — no more, no fewer ───────────── + + it('`tenancy.enabled: false` gets none either — the wall’s OTHER clause', () => { + // The second of the two clauses plugin-security reads. Pinned beside row 2 + // so the pair cannot be half-adopted: dropping either one starts indexing + // objects the wall never filters (`sys_package`, the Marketplace catalog, + // and every other cross-org shared table). + const out: any = applySystemFields(leadWith({ tenancy: { enabled: false } }), { + multiTenant: true, + }); + + expect(out.indexes).toBeUndefined(); + }); + + it('`managedBy: better-auth` + a declared organization_id GETS the index — the wall reads no managedBy clause', () => { + // Not an accident of the rewrite; the ruling's predicate, applied. The wall + // derives `tenancyDisabled` from two clauses and `managedBy` is in neither, + // so a better-auth table that DECLARES `organization_id` is walled on that + // column exactly like any other object — and adding an exclusion here that + // the wall does not have would re-open the drift this card closes. + // + // Its real instance is `sys_member` (`managedBy: 'better-auth'`, declares + // `organization_id`, and its only tenant-leading index is the COMPOSITE + // `['organization_id', 'user_id']` — which `declaresTenantIndex` + // deliberately does not accept as a substitute, a leading-column match + // being dialect-dependent). It is the ONE shipped platform object whose + // answer this card changes; every other table that would qualify already + // declares its own single-column tenant index (`sys_invitation`, + // `sys_team`, `sys_scim_provider`) or is tenancy-disabled + // (`sys_sso_provider`). + const out: any = applySystemFields(leadWith({ managedBy: 'better-auth' }), { + multiTenant: true, + }); + + expect(out.indexes).toEqual([PLATFORM_TENANT_INDEX]); + }); + + it('the hard opt-out with NO organization_id gets none — nothing to filter on', () => { + // Clause 2 of the predicate, alone. `systemFields: false` injects no tenant + // column, so unless the AUTHOR declared one there is no column for the wall + // to name and no index to declare. This is what stops the rebinding from + // becoming "index every object on a walled deployment". + const bare: any = { + name: 'lead', + label: 'Lead', + systemFields: false, + fields: { first_name: { type: 'text', label: 'First name' } }, + }; + const out: any = applySystemFields(bare, { multiTenant: true }); + + expect(out.indexes).toBeUndefined(); + expect(Object.keys(out.fields)).not.toContain('organization_id'); + }); + + // ── The injection half is untouched ─────────────────────────────────────── + + it('the hard opt-out still injects NOTHING — the index does not drag the columns back', () => { + // `systemFields: false` is the seed/migration-table opt-out: no audit + // family, no ownership anchors, no injected tenant column. The fix routes + // that exit through the index decision, and this is the pin that the exit + // still decides only the INDEX. A regression here would put four platform + // columns onto tables that exist precisely to have none. + const out: any = applySystemFields(leadWith({ systemFields: false }), { multiTenant: true }); + + expect(Object.keys(out.fields).sort()).toEqual(['first_name', 'organization_id']); + // …and the author's column is still byte-identical to what they declared. + expect(out.fields.organization_id).toEqual(DECLARED_ORG); + }); + + // ── The read exit — the second caller of the same predicate ──────────────── + + it('the READ EXIT serves it, and the registry answers it', async () => { + // `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 — the exact defect #8375 closed. + const { registry, protocol } = metaSurface(true, leadWith({ systemFields: false })); + 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(registry.getObject('lead'))).toEqual([PLATFORM_TENANT_INDEX]); + 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 controls a "grant more indexes" change fails ────────────────────── + + it('a SINGLE-TENANT deployment declares none on the same object', () => { + // Nothing filters by organization on an unwalled stack + // (`computeTenantLayer0Filter` returns `null` for the `single` posture), so + // the index is dead weight and its ABSENCE is the declaration (#6810). + const opted = leadWith({ systemFields: false }); + const { registry } = metaSurface(false, opted); + + expect(applySystemFields(opted, { multiTenant: false }).indexes).toBeUndefined(); + expect(registry.getObject('lead')!.indexes).toBeUndefined(); + }); + + it('an author who declares their OWN tenant index gets no platform entry beside it', () => { + // The deliberate escape hatch for a different index shape, and now live on + // this row too: `declaresTenantIndex` is the only thing standing between + // the newly-enabled append and a duplicate entry. + const authored = leadWith({ + systemFields: false, + 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('stamping twice appends once — the seam runs at registration AND at every read', () => { + // An array push is the one part of this injection that is not naturally + // idempotent, and the hard-opt-out row now has a live append to make. + const once: any = applySystemFields(leadWith({ systemFields: false }), { 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.ts b/packages/objectql/src/registry.ts index 5cbe37baee..9467f164e8 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, checkManagedApiMethodAffordances, LEGACY_API_METHODS, AUDIT_PROVENANCE_FIELDS } from '@objectstack/spec/data'; +import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveInjectedSystemColumns, isTenancyDisabled, 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. @@ -511,10 +511,21 @@ export function applySystemFields( const wantOwner = plan.owner; const wantOwningBusinessUnit = plan.owningBusinessUnit; - // Nothing to inject and nothing to govern — the cheap path for the two - // opt-out rows (`systemFields: false`, `managedBy: 'better-auth'`) and for - // objects whose every column is already declared. - if (!wantTenant && !wantAudit && !wantOwner && !wantOwningBusinessUnit) return schema; + // Nothing to INJECT — the two opt-out rows (`systemFields: false`, + // `managedBy: 'better-auth'`) and objects whose every column is already + // declared. + // + // [#8608] It still routes through {@link provisionTenantScopeIndex}, like + // every other exit below. An object that injects nothing can still CARRY the + // tenant column — the author declares their own `organization_id` — and the + // wall composes its predicate on exactly that column (measured, not inferred: + // plugin-security's `tenancyDisabled` reads `tenancy.enabled` and + // `systemFields.tenant` only, so `systemFields: false` leaves the wall live). + // Returning `schema` here was the last exit that decided the index by NOT + // asking, which is how the withholding survived #8375's convergence. + if (!wantTenant && !wantAudit && !wantOwner && !wantOwningBusinessUnit) { + return provisionTenantScopeIndex(schema, opts); + } const additions: Record = {}; // Platform-owned field settings that must WIN over a declared field, rather @@ -753,44 +764,91 @@ function provisionTenantScopeIndex( } /** - * [#8375, widened by #8459] Is this object tenant-scoped — i.e. does it carry an - * `organization_id` column for the wall to filter on? + * [#8375, widened by #8459, rebound to the wall by #8608] Is this object + * tenant-scoped — i.e. does the WALL filter it by `organization_id`? + * + * ## One question, one authority + * + * This read `resolveInjectedSystemColumns(schema).tenant` — the spec's + * INJECTION plan — on the reasoning that an object withholding the COLUMN has + * nothing for an index to serve. That reasoning holds for three of the plan's + * opt-out rows and fails on the fourth. `systemFields: false` is the hard + * object-level opt-out, so the plan answers "inject nothing"; plugin-security + * derives `tenancyDisabled` from exactly two clauses — + * + * tenancy.enabled === false || systemFields.tenant === false * - * 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. + * — and `systemFields: false` is in neither. An object using the hard opt-out + * while declaring its OWN `organization_id` therefore had the wall predicate + * AND-composed onto essentially every read with no index behind it: the same + * "hottest predicate unindexed" shape #6810 and #8459 exist to prevent, reached + * by a third route. Measured end to end on #8608 rather than inferred — for one + * such object the registry answered `indexes: null` while plugin-security's + * `getReadFilter` answered `{ organization_id: }`. * - * ⚠️ 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. + * Triage ruling, 2026-08-14 (option A), applying the standing meta-rule that + * when one question has two disagreeing implementations the GOVERNED side wins + * by default and the ungoverned side rebinds to it: **the wall's derivation is + * authoritative and the index follows it.** ⛔ Option B — teaching + * plugin-security to read `systemFields: false` as tenancy-disabled — was + * REJECTED, because it narrows a wall and only an explicit product ruling can + * do that. So this predicate is now the wall's, in the wall's own terms: * - * ⛔ 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 + * 1. the wall's two clauses, and nothing else, decide "is tenancy off here"; + * 2. the object carries `organization_id` — either the platform provisions it + * (the injection plan) or the author declared it. + * + * `managedBy: 'better-auth'` is deliberately NOT a third clause: the wall does + * not read it either, so a better-auth table that DECLARES `organization_id` + * (`sys_member`) is walled on that column and is now indexed on it. Re-adding + * an exclusion the wall does not have is precisely the drift this rebinding + * closes — and `declaresTenantIndex` already covers the tables that declare + * their own tenant index (`sys_invitation`, `sys_team`, `sys_scim_provider`). + * + * ⚠️ The clause-1 rows are NOT a leftover of the condition #8459 lifted: an + * object that declares itself non-tenant-scoped is one the wall composes NO + * predicate on (`computeTenantLayer0Filter` returns `null` when + * `tenancyDisabled`), so an index there would serve nothing — the same + * reasoning as `multiTenant: false`, one scope down. What #8459 removed is a + * different condition: 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. + * + * ⛔ Do NOT reduce clause 2 to its field-map half alone + * (`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 + * `organization_id` — a field-map-only 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. + * checksum and every history diff (the #4326 regression). The injection-plan + * branch is what answers on a stripped body; the field-map branch only ever + * ADDS objects whose column that plan cannot see. And the addition is safe on + * 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. */ function carriesTenantScopeColumn(schema: ServiceObject): boolean { - return resolveInjectedSystemColumns(schema).tenant; + // 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 + // plugin-security behaviour change, so the convergence itself is a + // follow-up; `isTenancyDisabled` is already the shared reading of the first). + if (isTenancyDisabled(schema)) return false; + if ((schema as { systemFields?: { tenant?: boolean } }).systemFields?.tenant === false) { + return false; + } + // Clause 2 — there is a column for the wall's predicate to filter on. + return ( + resolveInjectedSystemColumns(schema).tenant || + (schema as { fields?: Record }).fields?.organization_id != null + ); } /**