Skip to content

Commit 7c2f386

Browse files
hotlongclaude
andauthored
fix(objectql): the tenant-scope index follows the wall's derivation (#8608) (#8752)
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. Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 845e164 commit 7c2f386

4 files changed

Lines changed: 477 additions & 33 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
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)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key is
8+
added, renamed, retired or tombstoned. One internal predicate in
9+
`packages/objectql/src/registry.ts` is rebound from the spec's injection plan to
10+
the two clauses plugin-security already derives `tenancyDisabled` from, so the
11+
index the platform declares now covers the same objects the wall filters. -->
12+
13+
Two places answered *"is this object tenant-scoped?"* and read different
14+
declarations. The platform's tenant-scope index was gated on the spec's
15+
**injection plan** (`resolveInjectedSystemColumns(...).tenant`), while
16+
plugin-security's Layer 0 wall derives `tenancyDisabled` from exactly two
17+
clauses:
18+
19+
```ts
20+
tenancy.enabled === false || systemFields.tenant === false
21+
```
22+
23+
`systemFields: false` — the hard object-level opt-out — is in the plan and in
24+
neither of those clauses. So an object using that opt-out **while declaring its
25+
own `organization_id`** had `organization_id = <org>` AND-composed onto
26+
essentially every read, with no index behind it: the deployment's hottest
27+
predicate, unindexed. Not a security hole — isolation still held; it was slow,
28+
not wrong, which is why nothing surfaced it.
29+
30+
**Both halves were measured end to end** rather than read off the source. On the
31+
pre-fix tree, for one such object, the registry answered `indexes: null` while
32+
`SecurityPlugin#getReadFilter` answered `{ organization_id: 'org-1' }` for an
33+
ordinary member.
34+
35+
The wall's derivation is authoritative and the index now follows it: the index
36+
is declared when tenancy is not disabled by the wall's two clauses **and** the
37+
object carries `organization_id` — whether the platform provisions the column or
38+
the author declared it. `managedBy: 'better-auth'` is deliberately not re-added
39+
as a third clause, because the wall does not read it either; the one shipped
40+
platform object whose answer changes is `sys_member`, which is walled on
41+
`organization_id` and whose only tenant-leading index was the composite
42+
`['organization_id', 'user_id']`.
43+
44+
Unchanged, and pinned beside the fix: `systemFields.tenant: false` and
45+
`tenancy.enabled: false` still declare no index (the wall composes no predicate
46+
there, so an index would serve nothing), a single-tenant deployment still
47+
declares none at all, an author's own tenant index still suppresses the
48+
platform's, and the hard opt-out still injects no platform columns — only the
49+
index decision was ever owed at that exit.

‎packages/objectql/src/meta-object-tenant-index-roundtrip.test.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,58 @@ describe('[#8375] the write path takes back the tenant index the read added (#43
330330
}
331331
});
332332

333+
it('[#8608] round-trips a `systemFields: false` object that declares its own organization_id', async () => {
334+
// The row #8608 moved: the hard object-level opt-out is not one of the
335+
// wall's two `tenancyDisabled` clauses, so plugin-security composes
336+
// `organization_id = <org>` on such an object and the platform now
337+
// indexes the column it filters. The stamp reaching a new row means the
338+
// STRIP owes that row too — and the write path is where the cost of
339+
// getting it wrong is permanent rather than recomputed: an entry that
340+
// is not taken back is baked into `sys_metadata.metadata`, its checksum
341+
// and every history diff (#4326).
342+
//
343+
// It is not a separate implementation to check — the strip re-stamps
344+
// the remainder through `provisionTenantScopeIndex` itself — but this
345+
// row exercises the branch the OTHER cases cannot: on the hard opt-out
346+
// the injected-column strip removes nothing (the plan's `names` is
347+
// `{ id }`), so the author's declared column is still present when the
348+
// re-stamp asks. That is what makes the field-map half of the predicate
349+
// safe here; a stripped body on any other row is answered by the
350+
// injection-plan half.
351+
//
352+
// Two cycles and the STORED ROW, for the reason the head of this file
353+
// gives: one cycle read at the served document cannot separate a strip
354+
// that is bounded from one that never fires.
355+
const authored = {
356+
...clone(AUTHORED),
357+
systemFields: false,
358+
fields: {
359+
...clone(AUTHORED).fields,
360+
organization_id: { type: 'lookup', reference: 'sys_organization', label: 'Org' },
361+
},
362+
};
363+
const host = await seed(true, authored);
364+
const firstStored = host.storedBody()!;
365+
expect(firstStored.indexes).toBeUndefined();
366+
expect(firstStored.fields.organization_id).toEqual(authored.fields.organization_id);
367+
368+
for (const cycle of [1, 2]) {
369+
const item = await served(host);
370+
expect(item.indexes, `cycle ${cycle} served`).toEqual([PLATFORM_TENANT_INDEX]);
371+
// The hard opt-out still injects nothing: the index travels, the
372+
// platform columns do not.
373+
expect(Object.keys(item.fields).sort(), `cycle ${cycle} fields`)
374+
.toEqual(['code_label', 'name', 'organization_id']);
375+
376+
await host.protocol.saveMetaItem({
377+
type: 'object', name: AUTHORED.name, item,
378+
} as never);
379+
380+
expect(host.storedBody()!.indexes, `cycle ${cycle} stored`).toBeUndefined();
381+
expect(host.storedBody(), `cycle ${cycle} body`).toEqual(firstStored);
382+
}
383+
});
384+
333385
it('adds and strips NOTHING on an object that opts out of the tenant column', async () => {
334386
// The stamp is gated on the spec's own derivation, not on the
335387
// deployment flag alone: `systemFields.tenant: false` withholds the

0 commit comments

Comments
 (0)