@@ -71,13 +71,15 @@ export const NotificationDelivery = ObjectSchema.create({
7171 } ,
7272 // ADR-0030 Layer 4 — the reference lives in the docblock above, not in the copy.
7373 description : 'Durable per-recipient × channel delivery outbox.' ,
74- // [ADR-0079] The record title is `display_title`, a text formula over the
75- // same two columns `titleFormat` names. With no pointer declared, the
76- // registry's designate-only pass stamped `nameField: 'id'` (the first
77- // title-eligible field), so a renderer honouring ADR-0079's order (an
78- // explicit `nameField` wins over `titleFormat`) drew the raw id as the
79- // record page's H1. `titleFormat` stays for renderers that still read it
80- // first; `notification-display-title.test.ts` holds the two to the same text.
74+ // [ADR-0079] The record title is `display_title`, a text formula. With no
75+ // pointer declared, the registry's designate-only pass stamped
76+ // `nameField: 'id'` (the first title-eligible field), so a renderer
77+ // honouring ADR-0079's order (an explicit `nameField` wins over
78+ // `titleFormat`) drew the raw id as the record page's H1. `titleFormat`
79+ // stays for renderers that still read it first; the formula names the
80+ // recipient as a PERSON where `titleFormat` names the raw recipient, so the
81+ // two no longer read the same text
82+ // (`../delivery-recipient-user.integration.test.ts`).
8183 displayNameField : 'display_title' ,
8284 nameField : 'display_title' , // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
8385 titleFormat : '{channel} → {recipient_id}' ,
@@ -87,13 +89,24 @@ export const NotificationDelivery = ObjectSchema.create({
8789 id : Field . text ( { label : 'Delivery ID' , required : true , readonly : true } ) ,
8890
8991 // [ADR-0079] The record title (`nameField` above). A formula is computed
90- // on read and has no stored column. `channel` and `recipient_id` are
91- // both required, so the expression needs no null guard.
92+ // on read and has no stored column. It names the recipient as a PERSON
93+ // where the recipient is a user, one hop through `recipient_user_id`:
94+ // the user's `name`, or their `email` where the name is blank. An
95+ // address recipient (an empty `recipient_user_id`) reads as the address
96+ // itself, `recipient_id`. `channel` and `recipient_id` are both
97+ // required, so the address leg needs no null guard.
98+ //
99+ // `recipient_user_id` is read only through the hop and `recipient_id`
100+ // only bare: one formula may not read a field both ways
101+ // (`traversal-bare-and-traversed`). A reference the read cannot resolve
102+ // (a user this reader may not read) leaves the whole title null, as on
103+ // `sys_notification_preference`; it never falls back to the raw id,
104+ // which would read as an address.
92105 display_title : Field . formula ( {
93106 label : 'Title' ,
94107 returnType : 'text' ,
95- expression : F `record.channel + ' → ' + record.recipient_id` ,
96- description : 'Record title: the delivery channel and its recipient (computed on read)' ,
108+ expression : F `record.channel + ' → ' + (isBlank(trim( record.recipient_user_id.?name.orValue(''))) ? record.recipient_user_id.?email.orValue(record. recipient_id) : record.recipient_user_id.?name.orValue('')) ` ,
109+ description : 'Record title: the delivery channel and the person it is for, or the address when the recipient is not a user (computed on read)' ,
97110 } ) ,
98111
99112 notification_id : Field . text ( {
@@ -109,10 +122,19 @@ export const NotificationDelivery = ObjectSchema.create({
109122 maxLength : 255 ,
110123 description : 'FK → sys_notification (L2 event)' ,
111124 } ) ,
125+ // The address the delivery is sent to — a `sys_user` id, or an email
126+ // address or phone number kept verbatim — and part of the dedup key.
127+ // NOT a `sys_user` reference: `RecipientResolver` keeps an email no user
128+ // holds verbatim, and the email and SMS channels send to an email-shaped
129+ // or phone-shaped recipient directly. The outbox writes under the system
130+ // context, which skips the engine's reference check, so a lookup here
131+ // would store every such address as a dangling reference. The person,
132+ // where there is one, is `recipient_user_id` below.
112133 recipient_id : Field . text ( {
113- label : 'Recipient User ' ,
134+ label : 'Recipient' ,
114135 required : true ,
115136 searchable : true ,
137+ description : 'The address the delivery is sent to: a user id, or an email address or phone number kept as given.' ,
116138 // [commit e4902d2b9] Referenced-column bound (route A, ruling 2026-08-24): a resolved
117139 // recipient is a `sys_user.id` (physical varchar(255), as above)
118140 // or an email-shaped value `RecipientResolver.resolveOne()` keeps
@@ -121,6 +143,22 @@ export const NotificationDelivery = ObjectSchema.create({
121143 // column. 255 admits both producers.
122144 maxLength : 255 ,
123145 } ) ,
146+ // [#25194] The person `recipient_id` addresses, when it is a user's
147+ // id: stamped at enqueue by `MessagingService.enqueueDeliveries` from
148+ // one `sys_user` read per emit, and left empty for an address no user
149+ // holds. It exists for the record title (`display_title` above) and
150+ // is never read to route a delivery — the channels resolve
151+ // `recipient_id` themselves.
152+ //
153+ // `set_null` (the lookup default) on purpose: the delivery is history
154+ // and outlives the user, and an emptied reference titles the row by
155+ // `recipient_id` again. No `maxLength`: a reference holds the
156+ // referenced row's id, whose width is the parent's id column.
157+ recipient_user_id : Field . lookup ( 'sys_user' , {
158+ label : 'Recipient User' ,
159+ required : false ,
160+ description : 'The user the delivery is for. Empty when the recipient is an email address or phone number no user holds.' ,
161+ } ) ,
124162 channel : Field . text ( {
125163 label : 'Channel' ,
126164 required : true ,
0 commit comments