Skip to content

fix(objectql): the tenant-scope index follows the wall's derivation, not the injection plan (#8608) - #8752

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-8608-tenant-index-follows-wall
Aug 14, 2026
Merged

fix(objectql): the tenant-scope index follows the wall's derivation, not the injection plan (#8608)#8752
hotlong merged 2 commits into
mainfrom
claude/issue-8608-tenant-index-follows-wall

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#8608

Implements the triage ruling of 2026-08-14 — option A: the wall's derivation is authoritative and the index follows it. Option B (narrowing the wall) is not implemented and not attempted.

The premise, measured end to end first

The card asserted its security half from source and said so. Triage made measuring it step one, because a flipped premise ends the card. So it was measured before anything was edited: SecurityPlugin booted over a fake ObjectQL serving the registry's own output for the object in question, ordinary member of org-1, isolated posture.

On the pre-fix tree:

declaration (all with an author-declared organization_id)index — applySystemFields(obj, { multiTenant: true }).indexeswall — SecurityPlugin#getReadFilter
systemFields: false (row 1)null{"organization_id":"org-1"}
systemFields.tenant: false (row 2)nullnull
plain object (row 3)[{"fields":["organization_id"]}]{"organization_id":"org-1"}

Row 1 is the disagreement, confirmed: the wall filters on a column the platform declines to index. Rows 2 and 3 agree and are controls.

The cause

provisionTenantScopeIndex asked the spec's injection plan (resolveInjectedSystemColumns(...).tenant); plugin-security derives tenancyDisabled from exactly two clauses — tenancy.enabled === false || systemFields.tenant === false. systemFields: false is in the plan and in neither clause.

There was also a second site: the applySystemFields exit for objects that inject nothing returned schema directly, so the hard-opt-out row never reached the index decision at all. It was the last exit that decided the index by not asking — which is how the withholding survived #8375's convergence.

The change

packages/objectql/src/registry.ts only, two edits:

  1. carriesTenantScopeColumn is rebound to the wall's terms — the wall's two clauses decide "is tenancy off here", then the object must carry organization_id (the platform provisions it or the author declared it).
  2. the inject-nothing exit routes through provisionTenantScopeIndex like every other exit.

managedBy: 'better-auth' is deliberately not re-added as a third clause: the wall does not read it either, and adding an exclusion the wall lacks would re-open the drift this card closes.

⚠️ The field-map half is deliberately an OR-branch, never the whole predicate. A field-map-only predicate breaks the write path (#4326): the save path strips injected columns before the materialized stamps, so the re-stamp inside stripProvisionedTenantIndexFrom sees a body with no organization_id. The injection-plan branch answers on a stripped body; the field-map branch only ever adds objects that branch cannot see — and is safe on the write path for the mirrored reason (on exactly those rows the strip removes nothing, so the author's column is still there when the re-stamp asks). Pinned by a new round-trip case, not left as an argument.

Blast radius: exactly one shipped object — please review this line

All 44 objects @objectstack/platform-objects ships were scanned before and after. One row changes: sys_member gains {"fields":["organization_id"]}.

That is the ruling's predicate applied, not drift: sys_member is managedBy: 'better-auth', declares organization_id, is not tenancy-disabled, and is therefore walled on that column — while 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 is dialect-dependent; #8459 pins that boundary). 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).

Related: the card's clause table records managedBy: 'better-auth' as "n/a (no org column exists)". That is not accurate for the shipped set — four better-auth tables declare organization_id. It does not change the ruling, and it is why the fix touches one object rather than none.

Reverse verification — direction predicted before running

Predicted non-uniform: only the row-1 family flips. Ablated the fix from the committed state (git restore --source=origin/main, no stash), re-ran all three tenant-index files:

Tests 5 failed | 34 passed (39)

The five: row 1, the better-auth row, the read exit, the idempotence case, and the new round-trip case. Both controls stayed green with the fix absent, as did the whole #8459 file — which is what makes "one row wide" a measurement rather than a claim. Fix restored from HEAD, tree verified byte-clean against it before pushing.

Tests and gates — at c56e36d, the pushed head

Merged main (bringing #8737 and #8682) before this run, so the numbers describe the merged tree.

  • packages/objectql full suite: 206 files / 3629 tests passed; typecheck clean.
  • 11 new cases in registry-tenant-index-follows-wall.test.ts (all three table rows, the wall's other clause, the better-auth row, the no-column case, the injects-nothing pin, the read exit, and the single-tenant / author-declared-index / idempotence controls) + 1 write-path case in the existing round-trip file, which reuses that file's already-pinned engine double rather than declaring a second one.
  • Gate union, exit codes read unpiped: check:nul-bytes, check:durability-log-level, check-engine-split-ratio, check:engine-double-contract, check:query-options-erasure, check:type-check-coverage, check:type-check-debt (re-measure: 33 ledger entries, none above ceiling), check:changeset-gate-self-tests, check:objectui-changeset, check-adr-0087-registration, check-changeset-no-major, check-empty-changesetall 0.
  • The last five were not in the dispatch list; they came from re-deriving scripts/pm/dispatch-gates.mjs against the actual diff.

Scope

packages/plugins/plugin-security was read to measure the premise and never edited (#8536 in flight). packages/objectql/src/engine.ts untouched. No packages/spec change and no new spec export — option C's convergence needs neither, since isTenancyDisabled is already public in @objectstack/spec/data; the remaining half of C (one shared predicate across both packages) would require a plugin-security edit, so it stays a follow-up.


Generated by Claude Code

…8608)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
@vercel

vercelBot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 14, 2026 6:29pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

15 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx(via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx(via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx(via @objectstack/objectql)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx(via packages/objectql)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/objectql)
  • content/docs/kernel/services.mdx(via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx(via @objectstack/objectql)
  • content/docs/permissions/system-context.mdx(via packages/objectql)
  • content/docs/plugins/index.mdx(via @objectstack/objectql)
  • content/docs/plugins/packages.mdx(via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx(via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/objectql)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/objectql)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 14, 2026
@hotlong
hotlong marked this pull request as ready for review August 14, 2026 18:46
@hotlong
hotlong added this pull request to the merge queueAug 14, 2026
Merged via the queue into main with commit 7c2f386Aug 14, 2026
27 checks passed
@hotlong
hotlong deleted the claude/issue-8608-tenant-index-follows-wall branch August 14, 2026 19:02
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Two derivations of "is this object tenant-scoped" disagree on systemFields: false: the wall composes its predicate, the platform withholds the index

2 participants

@hotlong@claude