Skip to content

Commit e4902d2

Browse files
Elon Muskclaude
andauthored
fix(service-messaging): declare sourced maxLength on all 15 keyed text columns of the sys_notification_* objects (#13018)
Every bound names its producer in the declaration (#11374 route A): id-family 255 (referenced physical id column), channel 64 (registered MessagingChannel.id machine vocabulary, sys_session.revoke_reason precedent), topic 200 (= sys_notification.topic), digest_key 331 (derived recipient|channel|window), principal 520 (widest declared selector arm owner_of), locale 16 (= sys_email_template.locale). Retires the 15 pending allowlist rows in check-keyed-text-bounds.mjs (the ledger is empty as a result) and adds the value-pin test for the relations the existence gate deliberately does not check. Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw Co-authored-by: Claude <noreply@anthropic.com>
1 parent df59de0 commit e4902d2

8 files changed

Lines changed: 205 additions & 33 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/service-messaging": minor
3+
---
4+
5+
**Fix:** every keyed text column across the five `sys_notification_*` objects declares a sourced `maxLength`, so on MySQL the indexes they key are expressible **as declared** instead of the current mixed state — the UNIQUE constraints (above all `sys_notification_delivery`'s `(notification_id, recipient_id, channel)` dedup key) carried on #11627 hash-shadow columns, and every plain text-keyed index refused with a schema-sync error on each boot (#12978, the #11374 route-A class).
6+
7+
The bounds and their producers (each stated in the declaration): `notification_id` / `recipient_id` / `user_id` 255 (the referenced physical id column, `varchar(255)`); `channel` 64 (registered `MessagingChannel.id` machine vocabulary, per the `sys_session.revoke_reason` precedent); `topic` 200 (= `sys_notification.topic`, the event topic these values are matched against); `digest_key` 331 (= recipient 255 + `|` + channel 64 + `|` + window date 10); `principal` 520 (widest declared selector arm `owner_of:OBJECT:ID` = 9 + 255 + 1 + 255); `locale` 16 (= `sys_email_template.locale`, the sibling BCP-47 declaration).
8+
9+
**Operator-facing consequences.** Additive schema-sync never rewrites an existing column, so what changes depends on the deployment:
10+
11+
- **New databases (all dialects):** the columns are created `varchar(n)` and every declared index is created directly — the dedup UNIQUE key is 255+255+64 = 574 chars = 2296 utf8mb4 bytes, inside InnoDB's 3072-byte key budget. The two wide organization-scoped UNIQUEs (`sys_notification_preference` 774 chars, `sys_notification_subscription` 975 chars) still exceed that budget on MySQL and remain carried by the #11627 SHA-256 hash shadow — enforced, with the NULL-organization caveat tracked as #12998.
12+
- **Existing databases, Postgres/SQLite:** the declared indexes already existed (the refusal is MySQL-only) and no drift op is emitted for a bounded text field over a physical TEXT column (`narrow_varchar` deliberately fires only against a wider varchar — #11431; measured, with duals, on #12978). Boot behaviour is unchanged. What changes is the write seam: a value longer than the declared bound is now **refused loudly** instead of stored (`declared = enforced`; these identifier-family ceilings are storage-owned, #12144).
13+
- **Existing databases, MySQL:** the columns stay TEXT. Boot-time index sync keeps re-attempting the declared indexes: the UNIQUEs stay carried by the #11627 hash shadow (created on the first boot under a post-#11627 build — unless pre-existing duplicate rows make the shadow ALTER fail loudly, in which case deduplicate first), and each **plain** text-keyed index is still refused, logged at error level by schema-sync on every boot; the object stays registered and served. This is today's behaviour, not a new refusal — what this change adds is that the refusal's remedy becomes real: `os migrate` has **no arm** that rewrites TEXT to `varchar(n)` and never truncates, so the operator route is a hand `ALTER TABLE ... MODIFY` of the named columns to their declared widths, after which the next boot creates every declared index directly. Take a backup first; restate `NOT NULL`/`DEFAULT` on MySQL `MODIFY`; run under `STRICT_TRANS_TABLES` (the default), where an over-long stored value fails the ALTER with `ER_DATA_TOO_LONG` instead of being truncated — pre-flight with `SELECT COUNT(*) FROM sys_notification_delivery WHERE CHAR_LENGTH(channel) > 64` (and likewise per column) to find such rows first. The artifact boot-migration gate is unaffected: this change emits no `destructive` drift entry (the missing-index finding is `create_index`, category `safe`).
14+
15+
Graded `minor` for the same reason as the #11374 emitter changeset: on newly created tables the declared bound is now physically enforced where the dialect enforces `varchar`, and at the write seam everywhere, so a write longer than the bound that previously landed in unbounded TEXT is refused — the declaration becoming enforced, named here as a behaviour change.

packages/services/service-messaging/src/objects/notification-delivery.object.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,40 @@ export const NotificationDelivery = ObjectSchema.create({
4040
label: 'Notification Event',
4141
required: true,
4242
searchable: true,
43+
// [#12978] Referenced-column bound (#11374 route A): FK to
44+
// `sys_notification.id`, whose physical column is the id column
45+
// driver-sql creates — `table.string('id').primary()`, knex's
46+
// varchar(255), spelled `DEFAULT_STRING_VARCHAR_CHARS`. 255 by
47+
// transitivity from the id itself, the same sourcing the
48+
// plugin-audit record-id pins assert by value.
49+
maxLength: 255,
4350
description: 'FK → sys_notification (L2 event)',
4451
}),
45-
recipient_id: Field.text({ label: 'Recipient User', required: true, searchable: true }),
46-
channel: Field.text({ label: 'Channel', required: true }),
52+
recipient_id: Field.text({
53+
label: 'Recipient User',
54+
required: true,
55+
searchable: true,
56+
// [#12978] Referenced-column bound (#11374 route A): a resolved
57+
// recipient is a `sys_user.id` (physical varchar(255), as above)
58+
// or an email-shaped value `RecipientResolver.resolveOne()` keeps
59+
// verbatim (#9807) — RFC 5321 caps an address at 254 octets and
60+
// `sys_user.email` stores one in a string-family varchar(255)
61+
// column. 255 admits both producers.
62+
maxLength: 255,
63+
}),
64+
channel: Field.text({
65+
label: 'Channel',
66+
required: true,
67+
// [#12978] Machine channel-id vocabulary (#11374 route A): values
68+
// are the `MessagingChannel.id`s the service fans out to —
69+
// `registerChannel` registers `inbox` / `email` / `sms` today, and
70+
// the spec's `NotificationChannelSchema` widest member is
71+
// `webhook` (7 chars). 64 follows the landed machine-vocabulary
72+
// precedent (sys_session.revoke_reason, maxLength: 64; adopted by
73+
// sys_device_code.status), so a future channel id is never refused
74+
// by the column.
75+
maxLength: 64,
76+
}),
4777
topic: Field.text({ label: 'Topic', searchable: true }),
4878

4979
// P3b-2 digest: when the recipient's preference batches this channel
@@ -52,6 +82,12 @@ export const NotificationDelivery = ObjectSchema.create({
5282
// digest pass collapses all same-key rows into ONE rendered message at
5383
// window time. Null ⇒ an ordinary (immediate / quiet-hours) delivery.
5484
digest_key: Field.text({ label: 'Digest Key', searchable: true,
85+
// [#12978] Derived bound (#11374 route A): the one producer is
86+
// `enqueueDeliveries`' `${recipient}|${channel}|${digest.window}`
87+
// — recipient ≤ 255 (recipient_id above) + '|' + channel ≤ 64
88+
// (channel above) + '|' + window ≤ 10 (`digestDeferral` emits a
89+
// local ISO date, YYYY-MM-DD, for both cadences). 255+1+64+1+10.
90+
maxLength: 331,
5591
description: 'recipient|channel|window grouping key for batched (digest) deliveries; null for normal sends.' }),
5692

5793
payload: Field.json({
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#12978] The VALUE half of the keyed-text-bounds contract for this package's
4+
// five `sys_notification_*` objects (#11374 route A). The class-level gate
5+
// (`scripts/check-keyed-text-bounds.mjs`, #12147) asks whether a bound EXISTS;
6+
// it cannot ask whether the bound is the RIGHT one, because "right" here is a
7+
// RELATION to another declaration -- exactly what a later edit breaks without
8+
// noticing. Same division of labour the plugin-audit pin states for its
9+
// ActivityPointer columns, extended to the relations these five objects carry.
10+
//
11+
// Every expectation below that can be read off a sibling declaration IS read
12+
// off it rather than restated, so an edit to the producer moves the
13+
// expectation and leaves the stale STORED bound red -- never silently green.
14+
import { describe, it, expect } from 'vitest';
15+
16+
import { SysEmailTemplate, SysNotification } from '@objectstack/platform-objects';
17+
18+
import { NotificationDelivery } from './notification-delivery.object.js';
19+
import { NotificationPreference } from './notification-preference.object.js';
20+
import { NotificationReceipt } from './notification-receipt.object.js';
21+
import { NotificationSubscription } from './notification-subscription.object.js';
22+
import { NotificationTemplate } from './notification-template.object.js';
23+
24+
/**
25+
* 255 is the width of the physical `id` column `driver-sql` creates
26+
* (`table.string('id').primary()` -- knex's varchar(255), spelled
27+
* `DEFAULT_STRING_VARCHAR_CHARS`), so a column holding a record id is bounded
28+
* by transitivity from the id itself. Pinned by VALUE for the same reason the
29+
* plugin-audit pin gives: a later "tidy" to a narrower sibling convention
30+
* would silently refuse ids the id column itself accepts, and would sail
31+
* through the existence gate.
32+
*/
33+
const PHYSICAL_ID_WIDTH = 255;
34+
35+
const bound = (obj: { fields: Record<string, { maxLength?: unknown }> }, field: string): unknown =>
36+
obj.fields[field]?.maxLength;
37+
38+
describe('sys_notification_* keyed-text bounds carry their producers’ widths (#12978, #11374 route A)', () => {
39+
it('id-family columns carry the referenced physical id width, not just any bound', () => {
40+
expect(bound(NotificationDelivery, 'notification_id')).toBe(PHYSICAL_ID_WIDTH);
41+
expect(bound(NotificationDelivery, 'recipient_id')).toBe(PHYSICAL_ID_WIDTH);
42+
expect(bound(NotificationReceipt, 'notification_id')).toBe(PHYSICAL_ID_WIDTH);
43+
expect(bound(NotificationReceipt, 'user_id')).toBe(PHYSICAL_ID_WIDTH);
44+
expect(bound(NotificationPreference, 'user_id')).toBe(PHYSICAL_ID_WIDTH);
45+
});
46+
47+
it('topic columns equal sys_notification.topic’s own declared bound -- the event topic they are matched against', () => {
48+
const eventTopic = bound(SysNotification, 'topic');
49+
// Vacuity control: the producer itself must be a real declared bound.
50+
expect(typeof eventTopic).toBe('number');
51+
expect(bound(NotificationPreference, 'topic')).toBe(eventTopic);
52+
expect(bound(NotificationSubscription, 'topic')).toBe(eventTopic);
53+
expect(bound(NotificationTemplate, 'topic')).toBe(eventTopic);
54+
});
55+
56+
it('channel columns agree with each other (one machine vocabulary, one width)', () => {
57+
const channel = bound(NotificationDelivery, 'channel');
58+
expect(typeof channel).toBe('number');
59+
expect(bound(NotificationPreference, 'channel')).toBe(channel);
60+
expect(bound(NotificationReceipt, 'channel')).toBe(channel);
61+
expect(bound(NotificationTemplate, 'channel')).toBe(channel);
62+
});
63+
64+
it('digest_key equals its derivation from the sibling bounds: recipient + "|" + channel + "|" + window(10)', () => {
65+
const recipient = bound(NotificationDelivery, 'recipient_id') as number;
66+
const channel = bound(NotificationDelivery, 'channel') as number;
67+
// `digestDeferral` emits a local ISO date (`YYYY-MM-DD`) as the window
68+
// label for both cadences -- 10 chars.
69+
const WINDOW_LABEL_WIDTH = 10;
70+
expect(bound(NotificationDelivery, 'digest_key')).toBe(recipient + 1 + channel + 1 + WINDOW_LABEL_WIDTH);
71+
});
72+
73+
it('template locale equals sys_email_template.locale’s declared bound -- the sibling BCP-47 declaration', () => {
74+
const emailLocale = bound(SysEmailTemplate, 'locale');
75+
expect(typeof emailLocale).toBe('number');
76+
expect(bound(NotificationTemplate, 'locale')).toBe(emailLocale);
77+
});
78+
79+
it('principal covers the widest declared selector arm: owner_of:<object>:<id>', () => {
80+
// 'owner_of:' (9) + object API name (<= 255, storage-owned by
81+
// `sys_metadata.name`, #12144) + ':' (1) + record id (<= 255, the physical
82+
// id width above). #9807: every other arm is narrower (an email is <= 254;
83+
// 'user:' + id is 260).
84+
expect(bound(NotificationSubscription, 'principal')).toBe(9 + 255 + 1 + PHYSICAL_ID_WIDTH);
85+
});
86+
});

packages/services/service-messaging/src/objects/notification-preference.object.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ export const NotificationPreference = ObjectSchema.create({
4141
label: 'User',
4242
required: true,
4343
searchable: true,
44+
// [#12978] Referenced-column bound (#11374 route A): a
45+
// `sys_user.id` — physical varchar(255), the id column driver-sql
46+
// creates (`table.string('id').primary()`) — or the 1-char
47+
// literal '*'.
48+
maxLength: 255,
4449
description: "Recipient user id, or '*' for the admin-global default.",
4550
}),
4651

@@ -49,13 +54,27 @@ export const NotificationPreference = ObjectSchema.create({
4954
required: true,
5055
searchable: true,
5156
defaultValue: '*',
57+
// [#12978] Sibling-declaration bound (#11374 route A): rows are
58+
// matched against the event's `sys_notification.topic`
59+
// (maxLength: 200 there) — `preference-resolver` keys
60+
// `${user}|${topic}|${channel}` against `ctx.topic` — so a longer
61+
// stored topic could never match an event the platform can store.
62+
// '*' is 1 char.
63+
maxLength: 200,
5264
description: "Notification topic, or '*' for all topics.",
5365
}),
5466

5567
channel: Field.text({
5668
label: 'Channel',
5769
required: true,
5870
defaultValue: '*',
71+
// [#12978] Machine channel-id vocabulary (#11374 route A), same
72+
// sourcing as `sys_notification_delivery.channel`: registered
73+
// `MessagingChannel.id`s (inbox/email/sms today; spec's widest
74+
// enum member is 7 chars), 64 per the landed machine-vocabulary
75+
// precedent (sys_session.revoke_reason, maxLength: 64). '*' is
76+
// 1 char.
77+
maxLength: 64,
5978
description: "Channel id (inbox/email/push/…), or '*' for all channels.",
6079
}),
6180

packages/services/service-messaging/src/objects/notification-receipt.object.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ export const NotificationReceipt = ObjectSchema.create({
4848
label: 'Notification Event',
4949
required: true,
5050
searchable: true,
51+
// [#12978] Referenced-column bound (#11374 route A): FK to
52+
// `sys_notification.id` — physical varchar(255), the id column
53+
// driver-sql creates (`table.string('id').primary()`).
54+
maxLength: 255,
5155
description: 'FK → sys_notification (L2 event)',
5256
}),
5357

@@ -61,11 +65,19 @@ export const NotificationReceipt = ObjectSchema.create({
6165
label: 'Recipient User',
6266
required: true,
6367
searchable: true,
68+
// [#12978] Referenced-column bound (#11374 route A): a
69+
// `sys_user.id` — physical varchar(255), as above.
70+
maxLength: 255,
6471
}),
6572

6673
channel: Field.text({
6774
label: 'Channel',
6875
required: true,
76+
// [#12978] Machine channel-id vocabulary (#11374 route A), same
77+
// sourcing as `sys_notification_delivery.channel`: registered
78+
// `MessagingChannel.id`s, 64 per the landed machine-vocabulary
79+
// precedent (sys_session.revoke_reason, maxLength: 64).
80+
maxLength: 64,
6981
description: 'Channel id this receipt is for (inbox / email / push / …)',
7082
}),
7183

packages/services/service-messaging/src/objects/notification-subscription.object.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ export const NotificationSubscription = ObjectSchema.create({
4545
label: 'Topic',
4646
required: true,
4747
searchable: true,
48+
// [#12978] Sibling-declaration bound (#11374 route A): subscribed
49+
// topics are matched against the event's `sys_notification.topic`
50+
// (maxLength: 200 there), so a longer stored topic could never
51+
// match an event the platform can store.
52+
maxLength: 200,
4853
description: 'Notification topic this principal subscribes to.',
4954
}),
5055

@@ -57,6 +62,15 @@ export const NotificationSubscription = ObjectSchema.create({
5762
// expansion above is wired: an email-shaped value is matched against
5863
// `sys_user` (kept verbatim when no user matches), and anything otherwise
5964
// unrecognized falls through as a bare user id.
65+
// [#12978] Derived bound (#11374 route A) over the declared
66+
// selector grammar: the widest arm is `owner_of:object:id` =
67+
// 'owner_of:' (9) + object API name (≤ 255 — storage-owned by
68+
// `sys_metadata.name`, maxLength: 255, #12144) + ':' (1) + record
69+
// id (≤ 255 — the physical id column, varchar(255)) = 520. Every
70+
// other arm is narrower: an email ≤ 254 (RFC 5321) and
71+
// `sys_user.email` is a string-family varchar(255); 'user:' + id
72+
// = 260; 'role:'/'team:' + a per-org name.
73+
maxLength: 520,
6074
description:
6175
"Subscriber selector: 'role:x' | 'team:x' | 'user:id' | 'owner_of:object:id' | an email | a bare user id.",
6276
}),

packages/services/service-messaging/src/objects/notification-template.object.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,36 @@ export const NotificationTemplate = ObjectSchema.create({
3434
fields: {
3535
id: Field.text({ label: 'Template ID', required: true, readonly: true }),
3636

37-
topic: Field.text({ label: 'Topic', required: true, searchable: true }),
37+
topic: Field.text({
38+
label: 'Topic',
39+
required: true,
40+
searchable: true,
41+
// [#12978] Sibling-declaration bound (#11374 route A): template
42+
// topics are matched against the event's `sys_notification.topic`
43+
// (maxLength: 200 there).
44+
maxLength: 200,
45+
}),
3846

3947
channel: Field.text({
4048
label: 'Channel',
4149
required: true,
4250
defaultValue: 'email',
51+
// [#12978] Machine channel-id vocabulary (#11374 route A), same
52+
// sourcing as `sys_notification_delivery.channel`: registered
53+
// `MessagingChannel.id`s, 64 per the landed machine-vocabulary
54+
// precedent (sys_session.revoke_reason, maxLength: 64).
55+
maxLength: 64,
4356
description: 'Channel id this template renders for (email/inbox/push/…).',
4457
}),
4558

4659
locale: Field.text({
4760
label: 'Locale',
4861
required: true,
4962
defaultValue: 'en',
63+
// [#12978] Sibling-declaration bound (#11374 route A): the same
64+
// BCP-47 tag family `sys_email_template.locale` stores, bounded 16
65+
// there; both resolve a template by best-matching locale.
66+
maxLength: 16,
5067
description: "BCP-47 locale, e.g. 'en' / 'en-US' / 'zh-CN'.",
5168
}),
5269

0 commit comments

Comments
 (0)