You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Found while implementing #8555 (which scopes this same index per organization). Filed unassigned and deliberately separate: #8555 is a pure relaxation, this one is a tightening, and the two need different migration ceremony. Same split #6417 made against #5839, and for the same stated reason.
The fact
packages/platform-objects/src/system/sys-setting.object.ts declares the object's row identity as
and the object's own header calls that the row identity. user_id is NULL on every row that is not scope='user' — SettingsService.set computes it as scope === 'user' ? ctx.userId ?? null : null — and SQL UNIQUE treats NULLs as mutually distinct. So the constraint is void on the tenant and global limbs, which are exactly the ones carrying organization-level and platform-level configuration.
The organization key part is NULL-safe (COALESCE(organization_id, '__global__'), ADR-0120 D3). user_id is not, and D1's vocabulary scopes the tenant column only — sys_view_definition's declaration says so in its own comment, about its own owner column:
⚠️ The KEY below is NULL-DISTINCT, which is a second gap the declaration cannot close on its own (#6417) … it scopes the tenant column only — owner would stay NULL-distinct.
Measured, live, on a real engine
Real SqlDriver, the real shipped declaration, OS_TENANCY_POSTURE=isolated. Identical before and after#8555 — this is not something that card introduced or fixed:
scope='tenant', user_id NULL, SAME organization, same (namespace, key)
org_jia POST (lifecycle, retention_overrides, tenant, NULL) → 201
org_jia POST the SAME AGAIN → 201 ← row identity void
rows in table: 2
scope='global', user_id NULL
platform POST (mail, smtp_host, global, NULL) → 201
platform POST the SAME AGAIN → 201 ← two competing platform defaults
control — the same rows with a NON-NULL user_id
org_jia POST (lifecycle, retention_overrides, tenant, usr_1) → 201
org_jia POST the SAME AGAIN → 409 UNIQUE_VIOLATION
The control identifies the mechanism: it is the NULL, not the scope value.
Pinned as live assertions in packages/drivers/driver-sql/src/sql-driver-sys-setting-organization-unique.test.ts, section 4 ("the NULL-distinct user_id hole"), written to go red when this card lands so a fix must come and flip them rather than leaving a stale comment behind.
User-reachable consequence
SettingsService resolves a layer with rows.find((r) => r.key === key && r.scope === 'tenant') — a positional pick over whatever the table returns. With duplicates permitted, which value an organization gets for a tenant-scoped setting is unspecified, and a write through set() upserts against where { namespace, key, scope, user_id }, which matches an arbitrary one of them. Two rows can therefore disagree indefinitely with no way for an admin to see why the effective value is not the one they set. lifecycle.retention_overrides is a live tenant-scoped key, so this reaches real retention behaviour.
Sibling objects likely share the shape — sys_setting_audit mirrors the same three-value scope — but that is unverified and deliberately not asserted here.
#8555 respells unique: true to 'organization'. That is a relaxation: it admits key pairs previously refused and refuses nothing that previously succeeded, so it cannot fail to apply on any existing database. Verified there against a database deliberately seeded with pre-existing duplicate tenant-scope rows — the replacement index builds and every row survives, precisely because it does not tighten user_id.
Making user_id NULL-safe is the opposite: it is a tightening that will fail to create the index on any installation that has already accumulated duplicates, which the void constraint has been permitting all along. That needs ADR-0120 D4's conflict-row disposition and a duplicate pre-flight in os migrate plan. Exactly the tradeoff #6417 recorded, verbatim and untranslated:
Extend the declared vocabulary so an author can mark a listed column NULL-safe. Closes the class rather than the instance, and would let the declaration state its own row identity — but it is authorable-surface design and needs its own ADR (or an ADR-0120 amendment), plus SqlDriver.createNullSafeUniqueIndex's existing MySQL functional-key fallback extended to it.
Route 2 is the ADR-0078 direction (a declaration that cannot express its own constraint is a declared-but-unenforced surface), but route 1 is what has shipped twice. Recording both rather than choosing.
Decision needed before implementation
For a duplicate-carrying installation, what happens to the extra rows — refuse to migrate and hand the operator a list, or apply a deterministic keep-one rule (newest updated_at)? Settings values are admin-authored configuration, so silently discarding one is a real data decision, not a cleanup.
Found while implementing #8555 (which scopes this same index per organization). Filed unassigned and deliberately separate: #8555 is a pure relaxation, this one is a tightening, and the two need different migration ceremony. Same split #6417 made against #5839, and for the same stated reason.
The fact
packages/platform-objects/src/system/sys-setting.object.tsdeclares the object's row identity asand the object's own header calls that the row identity.
user_idis NULL on every row that is notscope='user'—SettingsService.setcomputes it asscope === 'user' ? ctx.userId ?? null : null— and SQL UNIQUE treats NULLs as mutually distinct. So the constraint is void on thetenantandgloballimbs, which are exactly the ones carrying organization-level and platform-level configuration.The organization key part is NULL-safe (
COALESCE(organization_id, '__global__'), ADR-0120 D3).user_idis not, and D1's vocabulary scopes the tenant column only —sys_view_definition's declaration says so in its own comment, about its ownownercolumn:Measured, live, on a real engine
Real
SqlDriver, the real shipped declaration,OS_TENANCY_POSTURE=isolated. Identical before and after#8555 — this is not something that card introduced or fixed:The control identifies the mechanism: it is the NULL, not the
scopevalue.Pinned as live assertions in
packages/drivers/driver-sql/src/sql-driver-sys-setting-organization-unique.test.ts, section 4 ("the NULL-distinct user_id hole"), written to go red when this card lands so a fix must come and flip them rather than leaving a stale comment behind.User-reachable consequence
SettingsServiceresolves a layer withrows.find((r) => r.key === key && r.scope === 'tenant')— a positional pick over whatever the table returns. With duplicates permitted, which value an organization gets for a tenant-scoped setting is unspecified, and a write throughset()upserts againstwhere { namespace, key, scope, user_id }, which matches an arbitrary one of them. Two rows can therefore disagree indefinitely with no way for an admin to see why the effective value is not the one they set.lifecycle.retention_overridesis a live tenant-scoped key, so this reaches real retention behaviour.Sibling objects likely share the shape —
sys_setting_auditmirrors the same three-valuescope— but that is unverified and deliberately not asserted here.Why it is not a rider on #8555
#8555 respells
unique: trueto'organization'. That is a relaxation: it admits key pairs previously refused and refuses nothing that previously succeeded, so it cannot fail to apply on any existing database. Verified there against a database deliberately seeded with pre-existing duplicatetenant-scope rows — the replacement index builds and every row survives, precisely because it does not tightenuser_id.Making
user_idNULL-safe is the opposite: it is a tightening that will fail to create the index on any installation that has already accumulated duplicates, which the void constraint has been permitting all along. That needs ADR-0120 D4's conflict-row disposition and a duplicate pre-flight inos migrate plan. Exactly the tradeoff #6417 recorded, verbatim and untranslated:Two routes, both with precedent
metadata-protocol'sensureViewDefinitionActiveIndexissues a raw-SQLCREATE UNIQUE INDEXatkernel:ready, reusing the declared index's name sosyncDeclaredIndexes(which skips by name) never re-imposes the NULL-distinct form on a later boot.ensureOverlayIndexdoes the same withCOALESCE(package_id, '')forsys_metadata. Proven twice; leaves the declaration unable to say what it means.SqlDriver.createNullSafeUniqueIndex's existing MySQL functional-key fallback extended to it.Route 2 is the ADR-0078 direction (a declaration that cannot express its own constraint is a declared-but-unenforced surface), but route 1 is what has shipped twice. Recording both rather than choosing.
Decision needed before implementation
For a duplicate-carrying installation, what happens to the extra rows — refuse to migrate and hand the operator a list, or apply a deterministic keep-one rule (newest
updated_at)? Settings values are admin-authored configuration, so silently discarding one is a real data decision, not a cleanup.Related
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 #8555 — the per-organization respelling of this same index (the relaxation half).sys_view_definition, fixed via route 1.