Skip to content

fix(platform-objects): scope sys_setting's declared unique index per organization (#8555) - #8631

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-8555-sys-setting-unique-scope
Aug 14, 2026
Merged

fix(platform-objects): scope sys_setting's declared unique index per organization (#8555)#8631
os-zhuang merged 2 commits into
mainfrom
claude/issue-8555-sys-setting-unique-scope

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#8555

The fork, and the measurement that decided it

The card deliberately asserted no defect. A DECLARED index's bare unique: true is the positional spelling of 'global' — the listed columns verbatim — so (namespace, key, scope, user_id) materialized as an installation-wide unique index on a tenant-scoped object (organization_id injected, no tenancy opt-out). But sys_setting carries a scope column, and IF scope encoded tenancy the installation-wide key would be correct and the right end state was an explicit 'global'.

It does not.scope is the cascade LAYER, not the tenant:

  • Its domain is global | tenant | user — a priority ladder walked env > global > tenant > user > default, ranked by scopeRank.
  • SettingsService.loadRows says the organization dimension lives elsewhere outright: "per-tenant isolation for tenant-scope rows is still enforced by the engine". The column is organization_id.
  • upsertRow bypasses the tenant audit only for scope='global' rows, "because global rows are platform-wide" — i.e. tenant/user rows do carry an organization.
  • The lifecycle manifest is built on the per-organization reading: retention_overrides is scope: 'tenant' precisely so "regulated tenants set years; dev sets days ... one deployment can carry both" (ADR-0057 §3.2).

So scope='tenant' means "the organization layer" — one row per organization — and this is the sixth instance of the #8323 class, inheriting the 2026-08-13 ruling. Branch taken: respell to 'organization'. Per the triage comment, the card's type should re-grade Task → Bug.

The card's "argument for keeping it global" is answered rather than discarded: the scope='global' LAYER survives because the organization key part is NULL-safe (COALESCE(organization_id, '__global__'), ADR-0120 D3). Platform rows carry no organization, so they share one bucket and stay unique among themselves — the installation-wide platform default the resolver reads at rung 2 is preserved without the whole index being global.

The live probe, run first

Real SqlDriver, the real shipped declaration, OS_TENANCY_POSTURE=isolated:

scope='user' org_jia POST (mail, smtp_host, user, usr_1) → 201
org_yi POST the SAME → 409 UNIQUE_VIOLATION
org_yi POST an unused key → 201 ← the ORACLE control
org_yi GET the colliding key → total 0
scope='tenant' org_jia 201 / org_yi the SAME → 201
scope='global' platform 201 / platform the SAME → 201

The 409 is the class defect: a per-value refusal on a row the caller cannot read is a cross-tenant existence oracle, and two organizations could not hold independent per-user settings for one key.

⚠️The two 201s corrected the card. It predicted a refusal on the tenant limb; there is none, because user_id is NULL there and SQL UNIQUE is NULL-distinct — so the index enforces nothing on the tenant and global limbs, not even against a same-organization duplicate. That is a second, independent defect. This PR does not fix it and does not claim to: it is pinned as a live fact in the driver suite's section 4 and filed as #8629, which is a tightening (it cannot build its index on an installation already carrying the duplicates this hole permits) whereas this PR is a pure relaxation.

#8629 is not addressed here.

Migration — the load-bearing half

Respelling changes the index's generated name, and initObjects is additive: it creates the new composite at boot and never drops the old one, so a deployed installation that takes this release without running the plan keeps the defect. Asserted, not assumed.

os migrate plan # one `replace_unique_index` on sys_setting, categorised safe
os migrate apply # no --allow-destructive needed

Verified against databases seeded with the OLD index and real rows — including deliberately-planted duplicate tenant-scope rows, which the old index permitted. One pure relaxation (not a safe-half + destructive-orphan pair), applies without --allow-destructive, preserves all 5 rows, converges to zero drift. The replacement name uniq_sys_setting_organization_id_namespace_key_scope_user_id is exactly 60 chars — the limit — so it is emitted untruncated rather than hash-suffixed, and differs from the legacy name, so the legacyName === replacement.name guard does not swallow the retirement.

Tests

25 cases driver-side (sql-driver-sys-setting-organization-unique.test.ts), 7 declaration-side. No length or delta assertions on indexes anywhere — membership and spelled scope only.

Ablations, direction predicted before running:

ablationpredictedobserved
A1 — revert shipped declaration to unique: truedeclaration pin red, driver suite unaffected (it hand-copies)4 red / 3 pass, driver untouched ✅
A2 — revert driver fixture to unique: trueshape, flip, NULL-bucket, migration, replacement-arm, posture red; section-4 NULL-hole cases stay green11 red / 14 pass, exactly those ✅
B — over-broad: unique: false (constraint removed)anti-vacuity red, flip test stays green12 red, flip green ✅

Ablation B is the one worth reading: "409 flips to 201" passes when the constraint is removed entirely, which is why the anti-vacuity arm (a same-organization duplicate must still be refused) is what actually distinguishes scoped from deleted.

Verification

  • pnpm --filter @objectstack/driver-sql test — 1598 passed / 54 skipped
  • pnpm --filter @objectstack/platform-objects test — 369 passed
  • pnpm --filter @objectstack/service-settings test — 451 passed (the consumer whose semantics decided the fork)
  • typecheck both packages clean; the new declaration pin typechecked explicitly, since platform-objects excludes *.test.ts from its tsc program
  • Gates re-derived with scripts/pm/dispatch-gates.mjs against the actual changed paths: check:i18n (green after building the CLI — it had refused as "prerequisite not met", which measures nothing), check:test-source-alias, check:type-source-resolution, check:query-options-erasure, check:type-check-coverage, all five changeset gates, check:nul-bytes

Scope

sys_setting only. The card's negative-triage list was not re-derived and not acted on; nothing in the measurement changes its verdicts. sys_notification_subscription / sys_audience_binding_suggestion (#8577) and sys_job (#8578) remain untouched.


Generated by Claude Code

…organization (#8555)
A DECLARED index's bare `unique: true` is the positional spelling of 'global'
(the listed columns verbatim), so (namespace, key, scope, user_id) materialized
as an installation-wide unique index on a tenant-scoped object.
The card left the direction open: if `scope` itself encoded tenancy, the right
end state was an explicit 'global'. Reading SettingsService settles it — `scope`
is the cascade LAYER (global | tenant | user, ranked by scopeRank) and the
organization is carried by organization_id alone. So this is the sixth instance
of the #8323 class and respells to 'organization'.
Measured live before the fix, real driver, OS_TENANCY_POSTURE=isolated:
scope='user' org_jia 201 / org_yi SAME 409 UNIQUE_VIOLATION
/ org_yi unused 201 / org_yi's own GET 0 rows
scope='tenant' org_jia 201 / org_yi SAME 201
scope='global' platform 201 / platform SAME 201
The 409 is the cross-tenant existence oracle. The two 201s are a SECOND defect
this change does not fix — user_id is NULL on tenant/global rows and SQL UNIQUE
is NULL-distinct, so the declared row identity is void there. Pinned as a live
fact in the driver suite and filed separately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
…#8555)
The second defect the probe surfaced now has a card, so the declaration, the
driver suite's section 4 and the changeset name it instead of saying "filed
separately". States why it is not a rider: this respelling is a pure relaxation
and applies to any database, while #8629 is a tightening that cannot build its
index on an installation carrying the duplicates the hole permits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
@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 4:17am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/platform-objects.

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

  • content/docs/plugins/packages.mdx(via @objectstack/platform-objects)
  • content/docs/ui/setup-app.mdx(via @objectstack/platform-objects)

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
@os-zhuang
os-zhuang marked this pull request as ready for review August 14, 2026 04:43
@os-zhuang
os-zhuang added this pull request to the merge queueAug 14, 2026
Merged via the queue into main with commit 6cb81c7Aug 14, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8555-sys-setting-unique-scope branch August 14, 2026 04:57
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.

sys_setting's unique key is installation-wide on a tenant-scoped object — but unlike the #8323 class it has a real argument for staying that way

2 participants

@os-zhuang@claude