Found while measuring #12978 (see the measurement comment there); filed separately because it is #11627's seam, not service-messaging's.
The gap
When MySQL refuses a declared UNIQUE index directly, syncDeclaredIndexes's catch hands the shadow route the bare column list: createHashShadowUniqueIndex(tableName, name, columns) (packages/drivers/driver-sql/src/sql-driver.ts). norm.nullSafeColumns — the ADR-0120 D3 marker saying which parts must materialize as COALESCE(organization_id, '__global__') — is not passed and not consulted. The shadow's generation expression hashes the RAW columns: UNHEX(SHA2(CONCAT(col1, 0x1f, ..., organization_id), 256)).
CONCAT returns NULL when any argument is NULL — deliberately, and pinned as CORRECT for a plain composite ("hashes a composite tuple, keeps any-NULL tuples non-conflicting", sql-driver-11627-hash-shadow-key.test.ts): a tuple containing NULL conflicts with nothing, matching MySQL's own composite-UNIQUE semantics.
But for an ORG-SCOPED unique that is exactly the semantics ADR-0120 D3 exists to replace: the declared key coalesces a NULL organization into the '__global__' bucket so NULL-org rows DO collide with each other (#5030's fix — "NULL-distinct means field-level unique: true is silently zero constraint on a single-tenant stack"). Under the shadow, every NULL-organization row hashes to NULL and is unconstrained. The shadow route therefore silently reintroduces #5030's defect shape, precisely on the deployments (single-tenant / global-default rows) where organization_id is NULL.
Live members today
sys_notification_preference (user_id, topic, channel, unique: 'organization') and sys_notification_subscription (topic, principal, unique: 'organization') — both currently over unbounded TEXT columns on MySQL, so their org-uniques already take the shadow route (ER_BLOB_KEY_WITHOUT_LENGTH → shadow). After #12978's bounds land they stay shadow-carried, because their bounded key widths (774 and 975 chars at 4 bytes/char) still exceed InnoDB's 3072-byte key budget (ER_TOO_LONG_KEY → shadow). The rows most exposed are the admin-global defaults these objects are designed to hold (user_id: '*' / topic '*' rows written without an organization).
Directions (not prescribing)
- Hash the DECLARED key rather than the raw columns: for a
nullSafeColumns part, put COALESCE(col, '__global__') inside the CONCAT so the shadow enforces the same key the direct index would have. Note the ADR-0120 D4 duplicate pre-flight probes the COALESCE'd key too, so plan-time probing stays consistent.
- Or refuse the shadow for NULL-safe uniques and keep the named refusal — weaker, but honest until the expression form lands.
Either way the choice should be explicit; today the weakening is silent and the boot log reports the constraint as carried.
Generated by Claude Code · session_01CPrUz21stTFhJRUirdc4yw · found on #12978
Found while measuring #12978 (see the measurement comment there); filed separately because it is #11627's seam, not service-messaging's.
The gap
When MySQL refuses a declared UNIQUE index directly,
syncDeclaredIndexes's catch hands the shadow route the bare column list:createHashShadowUniqueIndex(tableName, name, columns)(packages/drivers/driver-sql/src/sql-driver.ts).norm.nullSafeColumns— the ADR-0120 D3 marker saying which parts must materialize asCOALESCE(organization_id, '__global__')— is not passed and not consulted. The shadow's generation expression hashes the RAW columns:UNHEX(SHA2(CONCAT(col1, 0x1f, ..., organization_id), 256)).CONCATreturns NULL when any argument is NULL — deliberately, and pinned as CORRECT for a plain composite ("hashes a composite tuple, keeps any-NULL tuples non-conflicting",sql-driver-11627-hash-shadow-key.test.ts): a tuple containing NULL conflicts with nothing, matching MySQL's own composite-UNIQUE semantics.But for an ORG-SCOPED unique that is exactly the semantics ADR-0120 D3 exists to replace: the declared key coalesces a NULL organization into the
'__global__'bucket so NULL-org rows DO collide with each other (#5030's fix — "NULL-distinct means field-level unique: true is silently zero constraint on a single-tenant stack"). Under the shadow, every NULL-organization row hashes to NULL and is unconstrained. The shadow route therefore silently reintroduces #5030's defect shape, precisely on the deployments (single-tenant / global-default rows) where organization_id is NULL.Live members today
sys_notification_preference(user_id, topic, channel,unique: 'organization') andsys_notification_subscription(topic, principal,unique: 'organization') — both currently over unbounded TEXT columns on MySQL, so their org-uniques already take the shadow route (ER_BLOB_KEY_WITHOUT_LENGTH→ shadow). After #12978's bounds land they stay shadow-carried, because their bounded key widths (774 and 975 chars at 4 bytes/char) still exceed InnoDB's 3072-byte key budget (ER_TOO_LONG_KEY→ shadow). The rows most exposed are the admin-global defaults these objects are designed to hold (user_id: '*'/ topic'*'rows written without an organization).Directions (not prescribing)
nullSafeColumnspart, putCOALESCE(col, '__global__')inside the CONCAT so the shadow enforces the same key the direct index would have. Note the ADR-0120 D4 duplicate pre-flight probes the COALESCE'd key too, so plan-time probing stays consistent.Either way the choice should be explicit; today the weakening is silent and the boot log reports the constraint as carried.
Generated by Claude Code · session_01CPrUz21stTFhJRUirdc4yw · found on #12978