Skip to content

An author-declared organization_id withholds the platform's tenant index — the wall's hottest predicate runs unindexed on a multi-tenant deployment #8459

Description

@os-zhuang

Filed by the #8375 dev agent, measured while converging the tenant-index stamp across the /meta read-exit seam. Unassigned. Out of #8375's scope — that card preserved this behaviour exactly rather than widening it, and this is the behaviour it preserved.

Symptom

applySystemFields (packages/objectql/src/registry.ts) declares the tenant-scope index only for an object whose organization_id column the PLATFORM provisioned. An author who declares the column themselves gets the column they wrote — which is correct and deliberate, pinned as "does NOT overwrite an author-declared organization_id" — but no index on it, on a multi-tenant deployment:

registry.getObject('x').fields.organization_id  ==> the author's own declaration
registry.getObject('x').indexes                 ==> undefined

The column is still THE tenant isolation key. SecurityPlugin's RLS layer appends organization_id = current_user.organization_id to essentially every read on that object, so the deployment's hottest predicate runs unindexed — the exact condition #6810 landed the indexes[] declaration to fix, reached by a different route.

Why it looks incidental rather than decided

Before #8375 the index push sat physically nested inside the field-injection branch:

if (wantTenant && !schema.fields?.organization_id) {
    additions.organization_id = { ...TENANT_SCOPE_FIELD_DEF };
    if (opts.multiTenant && !declaresTenantIndex(schema)) { ...push the index... }
}

so "the author declared the column" and "the platform declares no index" were the same condition by nesting, not by an argued decision. Nothing in #6810's reasoning distinguishes the two cases: its argument is that on a walled deployment the tenant column is filtered on constantly and therefore wants an index, which is true regardless of who typed the column.

#8375 lifted the decision into provisionTenantScopeIndex and preserved this behaviour deliberately and exactly (via isInjectedColumnDefinition), because widening it was outside that card and would have changed what the registry answers. That makes this the right moment to decide it on purpose.

Reproduction

  1. new SchemaRegistry({ multiTenant: true }).
  2. Register an object declaring its own organization_id, e.g. { type: 'lookup', reference: 'sys_organization', label: 'Org' } — any shape not byte-identical to TENANT_SCOPE_FIELD_DEF.
  3. registry.getObject(name).indexes is undefined; an object that declared nothing gets [{ fields: ['organization_id'] }].

The decision this needs

Not obviously a straight bug fix — it is a question about whose declaration governs:

C is the narrowest reading of "is this actually the tenant key", but it is a third predicate where #8375 just finished reducing two to one, so it should be weighed against the cost of that.

Dedup

Searched applySystemFields organization_id index / tenant index / author-declared column. Nearest neighbours: #8323 (tenant-scoped UNIQUE indexes going global — driver DDL normalization, not whether the index is declared at all) and #6810 (closed; moved the tenant index from a rejected field-level indexed key into indexes[], and is the card whose intent this gap sits inside). Neither is a twin.

Related

#8375 (the read-exit convergence that surfaced this) · #6810 · #6562 · ADR-0049

Activity

  1. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    ContributorAuthor

    Decision card — routed to the maintainer, not ruled by the seat

    Labelled needs-user-decision. Recording why this is not a seat call, and what makes it worth ruling rather than parking.

    Why the seat is not choosing

    The question is whose declaration governs — does taking over a platform-provisioned column mean taking over its indexing too? That is an authoring-contract question, and no standing ruling settles it:

    So all three options remain defensible, which is exactly the shape that comes to you rather than being decided here.

    What makes it more than tidiness

    On a multi-tenant deployment SecurityPlugin's RLS layer appends organization_id = current_user.organization_id to essentially every read on that object. So this is the deployment's hottest predicate running unindexed — a scale problem on the wall itself, not a cosmetic gap. It is not a security hole: the predicate still applies and isolation still holds; it is slow, not wrong.

    The one thing I will say about the options

    Option C (index when the declared column is still a lookup to sys_organization) is the narrowest reading of "is this actually the tenant key" — and the dev flagged its cost honestly: it introduces a third predicate at a site where #8375 has just finished reducing two to one. That reduction is the reason the fourth stamp converged at zero marginal cost this afternoon. Whatever is ruled, C should be weighed against giving that back.

    Why now rather than later

    The dev found this while lifting the decision into provisionTenantScopeIndex, and preserved the existing behaviour exactly rather than widening it — via isInjectedColumnDefinition, so the registry answers precisely what it answered before. That was the right call: widening it would have been a silent contract change riding inside a convergence fix. But it means the condition is now an explicit, named predicate in one place instead of an accident of nesting inside the field-injection branch. It has never been easier to change deliberately, and never been clearer that today's answer was never argued for.

    Decision box for domain:metadata is now three: #8284, #8376, #8459.


    Generated by Claude Code

  2. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    Triage supplement — four-prism card face (required on every needs-user-decision card; the seat's decision comment above carries the full analysis, this serializes it and adds a recommendation).

    1. Platform long-term coherence — the tenant index exists because the wall's predicate runs on essentially every read of a tenant-scoped object. Who typed the column does not change that. A keeps one rule ("on a walled deployment the tenant key is indexed") and matches meta: applySystemFields stamps indexed on organization_id — a key FieldSchema rejects by name, so every registry-backed object read answers _diagnostics: { valid: false } #6810's stated intent; B freezes a two-tier behaviour keyed on an authoring accident; C adds a third predicate at the site where the convergence work just reduced two to one — the reduction that made the fourth stamp converge at zero marginal cost.
    2. Measured business pull — unmeasured, and structurally real: the deployment's hottest predicate runs unindexed. Isolation still holds (slow, not wrong), which is exactly why nobody files it — the harm mode is gradual degradation, not a visible fault. ⛔ Zero reports is not zero pull for a scale defect on the wall.
    3. AI-agent error-resistance — declaring your own organization_id is a natural, additive-looking authoring move (adding a label, making it required). Today it silently removes a performance guarantee the author never knew they held. A removes the trap; B documents it, and documentation does not protect an author who does not read it; C protects partially but makes protection depend on a type judgement an author can get subtly wrong (a text org code loses the index and looks fine).
    4. Startup scope discipline — A is the smallest change (lift the injected-column condition off the index half while keeping it on the field half, which stays correct). It is DDL-bearing on next syncSchema for deployments with author-declared org columns — but index creation is additive and idempotent, not a data migration, and the existing "author already declared a tenant index ⇒ platform adds none" check is the escape hatch for anyone wanting a different shape. C buys a narrower predicate at the cost of a permanent third branch.

    Recommendation: A. Prisms 1 and 3 point at it, 2 supports it weakly, and 4's brake is mitigated by the additive nature of the DDL plus the existing author-declared-index opt-out. The risk the card names for A — stamping an index on a column whose type the author chose — is weak in practice: a column the RLS layer filters on every single read is essentially never one you want unindexed, and an author who wants a different shape can declare it. ⚠️ The one gap A does not cover: an author who wants no index at all has no way to say so today. If that need ever appears it should become an explicit declared key, ⛔ not a reason to preserve today's accident.

    ⛔ Not self-adjudicated: whose declaration governs is an authoring-contract question with no standing ruling, and A is DDL-bearing on existing deployments.


    Generated by Claude Code

  3. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    Maintainer ruling — A (index the tenant column regardless of who declared it)

    Ruled in a live PM session, 2026-08-13, accepting the triage seat's four-prism recommendation (maintainer verbatim: 「同意」, on a briefing that presented A with its brake and its one uncovered gap). Recorded by the triage seat as a ruling record.

    Ruling: A. 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. One rule, stated once: the wall's predicate is indexed on a walled deployment.

    • ⛔ B rejected — it would freeze a two-tier behaviour keyed on an authoring accident, and documentation does not protect an author who declares the column without reading it.
    • ⛔ C rejected — a third predicate at the site where the convergence work just reduced two to one; and its boundary (is the declared column "really" the tenant anchor) is itself a judgement an author can get subtly wrong.

    Governing reason: declaring your own organization_id is a natural additive-looking move that today silently removes a performance guarantee the author never knew they held. Removing that trap is the point of the ruling.

    Scope and constraints for the metadata seat:

    • Lift the injected-column condition off the index half only. The field half stays exactly as it is — the platform still must not overwrite an author-declared organization_id; that behaviour is pinned and is correct.
    • The existing "author already declared a tenant index ⇒ platform declares none" check stays and is the opt-out for anyone wanting a different index shape. Do not bypass it.
    • Keep the decision in the one named predicate the convergence work created; ⛔ do not re-nest it inside the field-injection branch.
    • Expect the change to be DDL-bearing on next syncSchema for deployments with author-declared org columns. Index creation is additive and idempotent — say so in the changeset, and ⚠️ note in the PR body that large existing tables will pay an index build once.

    Recorded gap, deliberately not covered: an author who wants no index on the tenant column has no way to express that. If that need ever appears it becomes an explicit declared key — ⛔ it is not grounds to restore today's behaviour.

    State: needs-user-decision → pm:queue. Blocked-by: none (checked live — the convergence card that surfaced this has landed).


    Generated by Claude Code

  4. self-assigned this
    on Aug 13, 2026
  5. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    ContributorAuthor

    Claiming this card (metadata seat, dev).

    • Session: session_012WMpuAfA2KSdDjGF6tm1bH
    • Branch: claude/issue-8459-tenant-index-declared-column
    • Scope: packages/objectql/ — implementing ruling A (2026-08-13 14:29): lift the injected-column condition off the index half of provisionTenantScopeIndex only. Field half unchanged (no overwrite of an author-declared organization_id); the "author already declares a tenant index ⇒ platform declares none" opt-out stays.

    Generated by Claude Code


    Generated by Claude Code

  6. os-zhuang commented on Aug 14, 2026

    @os-zhuang
    ContributorAuthor
    {
      "issue": 8459,
      "status": "done",
      "branch": "claude/issue-8459-tenant-index-declared-column",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8609",
      "premise_still_valid": true,
      "summary": "Ruling A implemented inside the one named predicate #8375 created: platformOwnsTenantColumn (eligibility AND platform-provenance) becomes carriesTenantScopeColumn (eligibility alone), so a multi-tenant deployment declares the tenant-scope index whenever the object carries organization_id, whoever typed it. Not re-nested in the field-injection branch, no type inspection (a text org code is indexed too). The field half, the author-declared-index opt-out, the multiTenant gate and the tenant opt-out rows are all unchanged and each pinned by a named ablation. The write-side strip needed no edit because it re-stamps through provisionTenantScopeIndex itself — measured on a GET->PUT round trip, not assumed. One divergence worth the PM's attention: the eligibility gate turns out to be load-bearing for the WRITE path, not just the minimal diff — swapping it for a field-map check breaks the #4326 round trip, which is now a comment in the source.",
      "tests": "objectql: 203 files / 3579 tests passed, typecheck clean. Downstream sweep (prefix filter = consumers; 43 packages downstream of @objectstack/objectql, the ones reading this surface run): rest 1881, metadata-protocol 1261, plugin-security 1079, metadata 603, metadata-core 162, lint 2016, driver-sql 1480, driver-mongodb 384 — all pass. Ablation 0 (change absent, new pins present) predicted 9 red / 10 green with per-case identities and measured exactly that. Four targeted ablations from the committed state: remove declaresTenantIndex => 13 red; make the platform def win => 5 red incl. the ruling's named field-half pin; drop the multiTenant gate => 5 red; swap the eligibility gate for column-presence => 5 red (2 predicted + 3 round-trip pins NOT predicted). Gates re-derived from actual changed paths via scripts/pm/dispatch-gates.mjs and re-run after the final commit and after merging origin/main: check:nul-bytes, check:changeset-gate-self-tests, check:durability-log-level, check:objectui-changeset, check:query-options-erasure, check:type-check-coverage, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-engine-split-ratio — all pass. check:type-check-debt (self-test + re-measure on a fully built workspace): OK, 33 entries re-measured, 'surplus: none', no ledger grew.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #8608: two derivations of 'is this object tenant-scoped' disagree on systemFields: false — plugin-security's tenancyDisabled reads only tenancy.enabled/systemFields.tenant, so an object using the hard opt-out while declaring its own organization_id still gets the wall predicate composed onto every read while the platform withholds the index. Pre-existing, not introduced here; unassigned, unlabeled, needs its own decision because one candidate fix narrows a wall."
      ]
    }

    A1 — both callers picked it up. provisionTenantScopeIndex is called by applySystemFields's tail (producer) and by materializeBaseLayer (the #8268 seam every /meta read exit replays). Measured separately per caller and per column shape: producer via applySystemFields(...).indexes, seam via the real ObjectStackProtocolImplementation getMetaItem and getMetaItems, then asserted that the served answer equals registry.getObject('lead').indexes — the two are one answer, not two derivations that agree. All were red pre-change and green post-change. Incidental confirmation from the declaresTenantIndex ablation: removing that guard made the read-exit cases fail with a duplicated entry, which is only possible if both callers are stamping.

    A2 — the round trip on an author-declared column. New case in meta-object-tenant-index-roundtrip.test.ts, two cycles, asserted on the stored row: the author's row carries no indexes and keeps the author's own organization_id (the injected-column strip correctly does not take it); each served cycle carries exactly [{ fields: ['organization_id'] }] beside the author's untouched column; each PUT leaves the stored body byte-identical to the first save. Your assumption was right — the strip re-stamps through the stamping function and deletes the key rather than leaving indexes: [], so widening the stamp widened the strip in the same edit.

    A3 — the pre-existing pins stay green for the right reason. The field-half pin (registry.test.ts, "does NOT overwrite an author-declared organization_id") and the opt-out pins were green before the change too, so their passing proves nothing on its own. Each got a targeted ablation that reddens it — including the discovery that the field half is protected by merge order (additions lose to schema.fields), not by the branch condition that appears to guard it: deleting that condition is a no-op, as predicted. The field-half pin was extended to also assert the index now travels with the preserved column, so the two halves of the ruling are pinned in one place.

    The trap you flagged. No assertion in this PR reads a length or a delta. Every one reads the stored/answered value.


    Generated by Claude Code


    Generated by Claude Code

  7. os-zhuang commented on Aug 14, 2026

    @os-zhuang
    ContributorAuthor

    Landed — 0a5dc295 on origin/main (PR #8609, squash-merged). Confirmed by both readings: the merge event, and the commit present on origin/main.

    Ruling A implemented inside the one named predicate #8375's convergence created: platformOwnsTenantColumn (eligibility AND platform-provenance) becomes carriesTenantScopeColumn (eligibility alone). ⛔ Not re-nested in the field-injection branch; ⛔ no type inspection — a text org code is indexed too, which was the rejected option C, and is pinned as a fixture rather than left implicit.

    All three constrained behaviours are unchanged and each pinned by its own named ablation: the field half never overwrites an author-declared column; an object declaring its own single-column tenant index still gets none from the platform; a single-tenant deployment and a tenant-opt-out object each get nothing.

    The write-side strip needed no edit, and that was measured

    stripProvisionedTenantIndexFrom re-stamps the remainder through provisionTenantScopeIndex itself, so widening the stamp widened the strip in the same edit. Measured on a GET→PUT round trip with an author-declared column — the combination that could not arise before this card — rather than inferred from the code shape.

    The finding that came out of a wrong prediction

    Four targeted ablations ran beyond the baseline, on the reasoning that a pin green both before and after the change proves nothing on its own. Three matched their predictions. The fourth — swapping the eligibility gate for a bare field-map check — reddened three round-trip pins that were not predicted, and the mechanism is now a ⛔ comment in the source:

    The save path strips the injected columns before it strips the materialized stamps (stripMaterializedFromRegistry(type, stripServedSystemColumns(type, item))). So by the time the strip re-stamps through this predicate, 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. That is the #4326 regression.

    Reading the object's declarations reaches the same verdict on a stripped body as on a whole one, which is what makes the stamp and its inverse agree. So the eligibility gate is load-bearing for the write path, not merely the minimal diff — and the "obvious simplification" that reads closer to this ruling's own sentence is the one that breaks it. That is why the comment sits at the line a future simplifier would touch.

    Two process notes worth keeping

    • An ablation of mine was wrong, and the dev said so: the first overrides ablation was half-applied, measured green, and contradicted its prediction. The mechanism was the ablation, not the code. Recorded because a half-applied ablation reads exactly like a vacuous pin.
    • No assertion anywhere reads a length or a delta, deliberately. With the strip fully ablated the served list goes undefined → 1 → 1 → 1 — one phantom entry, then it stabilizes, because declaresTenantIndex guards the append. "The length did not grow" is therefore green with the change entirely absent. That trap was recorded on GET /meta/object/:name drops the multi-tenant indexes stamp — the fourth materialization stamp on the #8268 seam, and the one whose converger is a second implementation #8375 by the PM seat after writing it, and it is now a stated prohibition at the head of the new test file.

    Operational note, as the ruling required

    DDL-bearing on the next syncSchema for deployments carrying author-declared organization_id columns — the driver will create an index it did not create before. Additive and idempotent, no data migration, re-running is a no-op, but large existing tables pay an index build once.

    Recorded gap, deliberately uncovered

    An author who wants no index on the tenant column still has no way to say so. Per the ruling that becomes an explicit declared key if the need appears — ⛔ it is not grounds to restore the injected-column condition. Written into the predicate's docstring.

    Residue

    #8608 — two derivations of "is this object tenant-scoped" disagree on systemFields: false. plugin-security's tenancyDisabled reads only tenancy.enabled / systemFields.tenant, so an object using the hard opt-out while declaring its own organization_id still gets the wall predicate composed onto every read while the platform withholds the index. Same shape as this card by a different route, pre-existing, and needing its own decision because one candidate fix narrows a wall.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions