Repository navigation
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
Activity
- addedbugSomething isn't workingSomething isn't working
on Aug 13, 2026 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:
- The 2026-08-12 "refuse what the platform cannot honour" principle does not apply — nothing is being refused here, and the behaviour is internally coherent.
- ADR-0049 enforce-or-remove does not apply — nothing is declared-but-unenforced; the index simply is not declared.
- meta:
applySystemFieldsstampsindexedonorganization_id— a keyFieldSchemarejects by name, so every registry-backed object read answers_diagnostics: { valid: false }#6810's stated intent argues for A ("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"), but intent recorded in a closed card is not a ruling on a case that card did not consider.
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 appendsorganization_id = current_user.organization_idto 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
lookuptosys_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 — viaisInjectedColumnDefinition, 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:metadatais now three: #8284, #8376, #8459.
Generated by Claude Code
Triage supplement — four-prism card face (required on every
needs-user-decisioncard; the seat's decision comment above carries the full analysis, this serializes it and adds a recommendation).- 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:
applySystemFieldsstampsindexedonorganization_id— a keyFieldSchemarejects 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. - 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.
- AI-agent error-resistance — declaring your own
organization_idis 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 (atextorg code loses the index and looks fine). - 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
syncSchemafor 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
- 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:
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_idis 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
syncSchemafor 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
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 ofprovisionTenantScopeIndexonly. Field half unchanged (no overwrite of an author-declaredorganization_id); the "author already declares a tenant index ⇒ platform declares none" opt-out stays.
Generated by Claude Code
Generated by Claude Code
- Session:
{ "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.
provisionTenantScopeIndexis called byapplySystemFields's tail (producer) and bymaterializeBaseLayer(the #8268 seam every/metaread exit replays). Measured separately per caller and per column shape: producer viaapplySystemFields(...).indexes, seam via the realObjectStackProtocolImplementationgetMetaItemandgetMetaItems, then asserted that the served answer equalsregistry.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 thedeclaresTenantIndexablation: 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 noindexesand keeps the author's ownorganization_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 leavingindexes: [], 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 (additionslose toschema.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
Landed —
0a5dc295onorigin/main(PR #8609, squash-merged). Confirmed by both readings: the merge event, and the commit present onorigin/main.Ruling A implemented inside the one named predicate #8375's convergence created:
platformOwnsTenantColumn(eligibility AND platform-provenance) becomescarriesTenantScopeColumn(eligibility alone). ⛔ Not re-nested in the field-injection branch; ⛔ no type inspection — atextorg 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
stripProvisionedTenantIndexFromre-stamps the remainder throughprovisionTenantScopeIndexitself, 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 anorganization_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 intosys_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
overridesablation 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, becausedeclaresTenantIndexguards the append. "The length did not grow" is therefore green with the change entirely absent. That trap was recorded onGET /meta/object/:namedrops the multi-tenantindexesstamp — 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
syncSchemafor deployments carrying author-declaredorganization_idcolumns — 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'stenancyDisabledreads onlytenancy.enabled/systemFields.tenant, so an object using the hard opt-out while declaring its ownorganization_idstill 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
- An ablation of mine was wrong, and the dev said so: the first
- added a commit that references this issue
on Aug 17, 2026
Filed by the #8375 dev agent, measured while converging the tenant-index stamp across the
/metaread-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 whoseorganization_idcolumn 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:The column is still THE tenant isolation key. SecurityPlugin's RLS layer appends
organization_id = current_user.organization_idto essentially every read on that object, so the deployment's hottest predicate runs unindexed — the exact condition #6810 landed theindexes[]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:
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
provisionTenantScopeIndexand preserved this behaviour deliberately and exactly (viaisInjectedColumnDefinition), 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
new SchemaRegistry({ multiTenant: true }).organization_id, e.g.{ type: 'lookup', reference: 'sys_organization', label: 'Org' }— any shape not byte-identical toTENANT_SCOPE_FIELD_DEF.registry.getObject(name).indexesisundefined; 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:
applySystemFieldsstampsindexedonorganization_id— a keyFieldSchemarejects by name, so every registry-backed object read answers_diagnostics: { valid: false }#6810's stated intent; the risk is stamping an index on an author's column whose type they chose (atextorg code, say) where the index may be unwanted.applySystemFields' docs — the author who takes the column over takes its indexing too.lookuptosys_organization(i.e. really the tenant anchor), not merely when it is byte-identical to the platform's definition.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-levelindexedkey intoindexes[], 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