Found by the class-level gate built for #12147 (node scripts/check-keyed-text-bounds.mjs), on fa5d137ab0. This is a live member set of the #11374 route-A class, in a package that no existing pin covers — packages/services/service-messaging has no keyed-text-bounds pin, and the three that exist are scoped to platform-objects, plugin-audit and plugin-security.
The mechanism, restated once
A text-family column a declared index keys on must declare a maxLength, because a bound is what lets the column be a key at all. Without one, driver-sql's keyableTextLength returns null and createColumn emits table.text(name). MySQL then refuses ALTER TABLE … ADD [UNIQUE] INDEX on a TEXT column with no prefix length (ER_BLOB_KEY_WITHOUT_LENGTH), so CREATE TABLE succeeds, the index does not, and the object lands registered-but-broken with its declared index silently absent. Measured live on MySQL 8.0.46 in PR #12058.
The severity is not uniform across the 15. sys_notification_delivery declares
{ fields: ['notification_id', 'recipient_id', 'channel'], unique: true }
over three unbounded text columns. That index is the outbox's dedup constraint ("one delivery per (event, recipient, channel)"). On MySQL it does not exist, so the constraint the comment describes is not enforced and duplicate deliveries are admitted.
The 15 columns
| column | type | index kind | plausible referent for the bound |
|---|
sys_notification_delivery.notification_id | text | UNIQUE (composite) + plain | FK to sys_notification.id |
sys_notification_delivery.recipient_id | text | UNIQUE (composite) | FK to sys_user.id |
sys_notification_delivery.channel | text | UNIQUE (composite) | channel id vocabulary |
sys_notification_delivery.digest_key | text | plain (composite) | derived `recipient |
sys_notification_preference.user_id | text | plain | FK to sys_user.id, or the literal * |
sys_notification_preference.topic | text | plain | topic vocabulary, or * |
sys_notification_preference.channel | text | plain | channel id vocabulary, or * |
sys_notification_receipt.notification_id | text | plain | FK to sys_notification.id |
sys_notification_receipt.user_id | text | plain | FK to sys_user.id |
sys_notification_receipt.channel | text | plain | channel id vocabulary |
sys_notification_subscription.topic | text | plain | topic vocabulary |
sys_notification_subscription.principal | text | plain | RecipientResolver.resolveOne() accepts an email-shaped value as well as a bare user id (#9807) |
sys_notification_template.topic | text | plain | topic vocabulary |
sys_notification_template.channel | text | plain | channel id vocabulary |
sys_notification_template.locale | text | plain | BCP-47 tag; sys_email_template.locale is bounded at 16 |
Reproduce the list with node scripts/check-keyed-text-bounds.mjs --list once the #12147 gate lands.
Why this is filed rather than fixed on #12147's PR
#12147 is a domain:devx card whose deliverable is the gate. Bounding these columns is not a mechanical drive-by:
- Each bound needs a sourced producer. Route A's shape is a NAMED producer stated in the declaration so it is vetoable in review.
topic, channel and principal have no bound that can be read off a sibling declaration — channel and topic are open string vocabularies here, and principal deliberately accepts an email-shaped value. Picking a number without sourcing it is the failure route A exists to prevent. - It is a schema migration, not just a declaration. These columns are emitted
TEXT today. Declaring a bound moves them to varchar(n), which is a drift op against every existing deployment, and the narrowing direction is categorised destructive in schema-drift.ts. That disposition belongs to the services/engine lane with a changeset, not to a devx gate PR. - The two id-family columns are the easy half and the rest are not, so a partial fix would leave the object's composite UNIQUE index still inexpressible while reading as addressed.
Until it is fixed, all 15 are recorded in the gate's per-package ALLOWLIST as kind: 'pending' rows citing this issue. That ledger is enumerated, not a wildcard: a new unbounded keyed text column anywhere in the tree still fails the gate, a row whose column gets bounded fails as stale, and a row whose column stops being keyed text fails as stale. Nothing here goes quiet.
Not in scope of this one
The separate >768-character key class (#11627 for the UNIQUE half, #11701 for the non-unique half) is a different predicate with a different disposition and is not folded in. None of the 15 declares a bound at all, so they are all route-A members first.
Found by the class-level gate built for #12147 (
node scripts/check-keyed-text-bounds.mjs), onfa5d137ab0. This is a live member set of the #11374 route-A class, in a package that no existing pin covers —packages/services/service-messaginghas no keyed-text-bounds pin, and the three that exist are scoped toplatform-objects,plugin-auditandplugin-security.The mechanism, restated once
A text-family column a declared index keys on must declare a
maxLength, because a bound is what lets the column be a key at all. Without one,driver-sql'skeyableTextLengthreturnsnullandcreateColumnemitstable.text(name). MySQL then refusesALTER TABLE … ADD [UNIQUE] INDEXon a TEXT column with no prefix length (ER_BLOB_KEY_WITHOUT_LENGTH), soCREATE TABLEsucceeds, the index does not, and the object lands registered-but-broken with its declared index silently absent. Measured live on MySQL 8.0.46 in PR #12058.The severity is not uniform across the 15.
sys_notification_deliverydeclaresover three unbounded text columns. That index is the outbox's dedup constraint ("one delivery per (event, recipient, channel)"). On MySQL it does not exist, so the constraint the comment describes is not enforced and duplicate deliveries are admitted.
The 15 columns
sys_notification_delivery.notification_idsys_notification.idsys_notification_delivery.recipient_idsys_user.idsys_notification_delivery.channelsys_notification_delivery.digest_keysys_notification_preference.user_idsys_user.id, or the literal*sys_notification_preference.topic*sys_notification_preference.channel*sys_notification_receipt.notification_idsys_notification.idsys_notification_receipt.user_idsys_user.idsys_notification_receipt.channelsys_notification_subscription.topicsys_notification_subscription.principalRecipientResolver.resolveOne()accepts an email-shaped value as well as a bare user id (#9807)sys_notification_template.topicsys_notification_template.channelsys_notification_template.localesys_email_template.localeis bounded at 16Reproduce the list with
node scripts/check-keyed-text-bounds.mjs --listonce the #12147 gate lands.Why this is filed rather than fixed on #12147's PR
#12147 is a
domain:devxcard whose deliverable is the gate. Bounding these columns is not a mechanical drive-by:topic,channelandprincipalhave no bound that can be read off a sibling declaration —channelandtopicare open string vocabularies here, andprincipaldeliberately accepts an email-shaped value. Picking a number without sourcing it is the failure route A exists to prevent.TEXTtoday. Declaring a bound moves them tovarchar(n), which is a drift op against every existing deployment, and the narrowing direction is categoriseddestructiveinschema-drift.ts. That disposition belongs to the services/engine lane with a changeset, not to a devx gate PR.Until it is fixed, all 15 are recorded in the gate's per-package
ALLOWLISTaskind: 'pending'rows citing this issue. That ledger is enumerated, not a wildcard: a new unbounded keyed text column anywhere in the tree still fails the gate, a row whose column gets bounded fails as stale, and a row whose column stops being keyed text fails as stale. Nothing here goes quiet.Not in scope of this one
The separate
>768-character key class (#11627 for the UNIQUE half, #11701 for the non-unique half) is a different predicate with a different disposition and is not folded in. None of the 15 declares a bound at all, so they are all route-A members first.