Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/30759-notify-click-through-before-create.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/spec': patch
'@objectstack/service-automation': patch
'@objectstack/skills': patch
---

docs(automation): the notify click-through default says a before-create run links to its record only when the caller wrote the id

Clause-②: no
Metadata-protocol: none

A `notify` node that writes none of `sourceObject`, `sourceId` and `actionUrl` links to the run's trigger record when the run has one. Four texts enumerated that run as "a record change other than a delete", which includes a before-create run (`record-before-create`, or the insert leg of `record-before-write`). On that run the record is the write's input, and the driver issues the id at the write, after the hook, so the input carries an id only when the caller wrote one; with no id the notification links nowhere. The runtime is unchanged. The texts now say "a record change other than a delete or a before-create", and that a before-create run links only when the caller wrote the id:

- the `NotifyConfigSchema` docblock and the `sourceObject` / `sourceId` descriptions in `@objectstack/spec`, and the reference page generated from them;
- the `notify` node's docblock and config descriptions in `@objectstack/service-automation`, which is the wording source;
- the Flows guide and the automation skill's `notify` example.

The same texts said that a `sourceId` evaluating to `null` "leaves the key unset". It is a written key: the node then writes a half pair, which is dropped rather than defaulted to the trigger record. The node's failure text, the guide and the schema say so.

A test pins that a notify on a `beforeInsert` run whose record carries no id writes no `source_object` / `source_id`, and that one whose caller wrote the id links to it. No key, shape, default or refinement changes.
13 changes: 8 additions & 5 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -348,14 +348,17 @@ nothing. The template handed a whole token holding a list, written as a list
entry (`['{owners}']`), on as **one** recipient joined with commas (`'u1,u2'`),
which reached nobody. Guard a recipient field that may be unset —
`has(record.assignee) ? record.assignee : null` — so it is dropped rather than
failing the run. `sourceId` takes a string or an envelope evaluating to one:
`null` leaves the key unset. A node that writes none of `sourceObject`,
`sourceId` and `actionUrl` links to the run's trigger record when it has one —
a record change other than a delete, a time-relative sweep's row, an action
failing the run. `sourceId` takes a string or an envelope evaluating to one;
`null` is a written `sourceId` that resolves to nothing, so the pair is dropped,
not defaulted. A node that writes none of `sourceObject`, `sourceId` and
`actionUrl` links to the run's trigger record when it has one — a record change
other than a delete or a before-create, a time-relative sweep's row, an action
started on a record — so a record-triggered `notify` opens its record without a
pair. An explicit pair or `actionUrl` wins; a half-specified pair is dropped,
never defaulted; a run with no trigger record (a schedule tick, an inbound API
post, a manual start with no record) links nowhere.
post, a manual start with no record) links nowhere. A before-create run's record
has no id until the driver issues one at the write, after the hook, so it links
only when the caller wrote the id.
The node's `sourceObject`, `actorId` and `payload` are
**literals**, read as written: no flow computed one, so nothing is evaluated
there — write the value itself (`sourceObject: 'crm_case'`). A `{…}` token
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/automation/io-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ const result = HttpConfigSchema.parse(data);
| **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) |
| **topic** | `string` | optional | Event topic (default: "notify") |
| **severity** | `Enum<'info' \| 'warning' \| 'critical'>` | optional | Severity forwarded to the messaging service |
| **sourceObject** | `string` | optional | Object name of the record the notification links to (writes sys_notification.source_object) — a literal, read as written: a single-brace `{token}` is not resolved here and is refused, as is a CEL value envelope; write the value. Only takes effect together with sourceId — a half-specified click-through target is dropped at execute time, not defaulted, so the inbox never renders a dead link. When sourceObject, sourceId and actionUrl are all left out, a run started by a record (a record change other than a delete, a time-relative sweep, an action on a record) links to that record, and a run with no trigger record links nowhere; an explicit pair or an actionUrl is kept as written. |
| **sourceId** | `string \| { dialect: 'cel'; source: string; ast?: any; meta?: object }` | optional | Record id the notification links to (writes sys_notification.source_id) — a string, or a CEL value envelope `{ dialect: 'cel', source }` evaluated per run to one (`record.id`); a `{…}` template token is refused. Only takes effect together with sourceObject — a half-specified click-through target is dropped at execute time, not defaulted, so the inbox never renders a dead link. When sourceObject, sourceId and actionUrl are all left out, a run started by a record (a record change other than a delete, a time-relative sweep, an action on a record) links to that record, and a run with no trigger record links nowhere; an explicit pair or an actionUrl is kept as written. |
| **sourceObject** | `string` | optional | Object name of the record the notification links to (writes sys_notification.source_object) — a literal, read as written: a single-brace `{token}` is not resolved here and is refused, as is a CEL value envelope; write the value. Only takes effect together with sourceId — a half-specified click-through target is dropped at execute time, not defaulted, so the inbox never renders a dead link. When sourceObject, sourceId and actionUrl are all left out, a run started by a record (a record change other than a delete or a before-create, a time-relative sweep, an action on a record) links to that record, and a run with no trigger record links nowhere — a before-create run's record has no id until the driver issues one at the write, so it links only when the caller wrote the id; an explicit pair or an actionUrl is kept as written. |
| **sourceId** | `string \| { dialect: 'cel'; source: string; ast?: any; meta?: object }` | optional | Record id the notification links to (writes sys_notification.source_id) — a string, or a CEL value envelope `{ dialect: 'cel', source }` evaluated per run to one (`record.id`); a `{…}` template token is refused. Only takes effect together with sourceObject — a half-specified click-through target is dropped at execute time, not defaulted, so the inbox never renders a dead link. When sourceObject, sourceId and actionUrl are all left out, a run started by a record (a record change other than a delete or a before-create, a time-relative sweep, an action on a record) links to that record, and a run with no trigger record links nowhere — a before-create run's record has no id until the driver issues one at the write, so it links only when the caller wrote the id; an explicit pair or an actionUrl is kept as written. |
| **actorId** | `string` | optional | User id that caused the event (writes sys_notification.actor_id) — a literal, read as written: a single-brace `{token}` is not resolved here and is refused, as is a CEL value envelope; write the value |
| **actionUrl** | `string` | optional | Explicit click-through URL, rendered per run with `{{ }}` holes like `title` (`/crm_case/{{ record.id }}`); a single-brace `{token}` is refused. Overrides the link synthesized from sourceObject/sourceId, and written alone keeps the notification from linking to the record that started the run. |
| **payload** | `Record<string, any>` | optional | Extra values merged into the notification payload — literals, read as written: a single-brace `{token}` in any string of one is not resolved here and is refused, as is a CEL value envelope written as a value; write the value |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -475,4 +475,29 @@ describe('notify click-through defaults to the trigger record', () => {

expect(messaging.emitted[0].source).toBeUndefined();
});

// A before-create run's record is the write's input: the driver issues the
// id at the write, after the hook, so the input carries one only when the
// caller wrote it. With no id there is no trigger record to link.
it('writes no source on a before-create run whose record has no id yet', async () => {
engine.registerFlow('notify_flow', notifyFlow({ recipients: ['user_1'], title: 'Ticket incoming' }));

const result = await engine.execute(
'notify_flow',
{ record: { name: 'Pump' }, object: 'repair_ticket', event: 'beforeInsert' } as any,
);

expect(result.success).toBe(true);
expect(messaging.emitted).toHaveLength(1);
expect(messaging.emitted[0].source).toBeUndefined();
expect(messaging.emitted[0].payload.url).toBeUndefined();
});

it('links a before-create run whose caller wrote the id', async () => {
engine.registerFlow('notify_flow', notifyFlow({ recipients: ['user_1'], title: 'Ticket incoming' }));

await engine.execute('notify_flow', recordRun('beforeInsert'));

expect(messaging.emitted[0].source).toEqual({ object: 'repair_ticket', id: 't_7' });
});
});
29 changes: 17 additions & 12 deletions packages/services/service-automation/src/builtin/notify-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,14 @@ const DELETE_EVENTS: ReadonlySet<string> = new Set(['beforeDelete', 'afterDelete
* explicit `actionUrl`, and a half-written pair (still dropped, as before) are
* the author's word and are kept as written.
*
* Only when the run has a trigger record (`triggerRecordOf`): a record-change
* run, a time-relative sweep's row, an action started on a record. A schedule
* tick, an inbound API post and a manual start with no record have none and
* link nowhere, as before. A delete event's record is gone once the write
* lands, so it is not linked: the inbox never renders a dead link.
* Only when the run has a trigger record (`triggerRecordOf`): a record change
* other than a delete or a before-create, a time-relative sweep's row, an
* action started on a record. A schedule tick, an inbound API post and a
* manual start with no record have none and link nowhere, as before. A delete
* event's record is gone once the write lands, so it is not linked: the inbox
* never renders a dead link. A before-create run's record is the write's
* input, which carries an id only when the caller wrote one — the driver
* issues the id at the write, after the hook — so it links only then.
*/
function defaultClickThroughOf(
cfg: Pick<NotifyConfigParsed, 'sourceObject' | 'sourceId' | 'actionUrl'>,
Expand Down Expand Up @@ -688,10 +691,12 @@ const EMAIL_FIELD_COUNTER_PREFIX = 'notify-email-field-sends:';
* [#19939] The notify value slot holding one string — `sourceId`, the
* click-through record's id: the string as written, or a CEL value envelope
* evaluated in the run's scope to one, through the shared per-slot resolver
* (`resolveValueSlot`). `null` or a blank string leaves the key unset, as a
* token that resolved to nothing did; anything else fails the node, naming
* what it evaluated to — never `String(value)`. (`sourceObject` and `actorId`
* are literals, read as written.)
* (`resolveValueSlot`). `null` or a blank string is a written key that
* resolves to nothing: the click-through pair is dropped rather than defaulted
* to the trigger record (`defaultClickThroughOf` reads the written config);
* anything else fails the node, naming what it evaluated to — never
* `String(value)`. (`sourceObject` and `actorId` are literals, read as
* written.)
*/
function resolveStringSlot(
engine: AutomationEngine,
Expand All @@ -706,7 +711,7 @@ function resolveStringSlot(
ok: false,
error:
`notify: ${key} evaluated to ${evaluatedKindOf(evaluated)}, not a string — write a value expression that `
+ 'evaluates to the record id, or to `null` to leave it unset — source: '
+ 'evaluates to the record id, or to `null`, a written sourceId that resolves to nothing, so the pair is dropped rather than defaulted — source: '
+ `\`${sourceOf(raw)}\``,
};
}
Expand Down Expand Up @@ -882,11 +887,11 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
// literals, read as written.
sourceObject: {
type: 'string',
description: 'Object name of the record the notification links to (writes sys_notification.source_object) — read as written: a single-brace {token} is not resolved here and is refused, as is a CEL value envelope; write the value. Only takes effect together with sourceId — a half-specified click-through target is dropped at execute time, so the inbox never renders a dead link. When sourceObject, sourceId and actionUrl are all left out, a run started by a record (a record change other than a delete, a time-relative sweep, an action on a record) links to that record.',
description: 'Object name of the record the notification links to (writes sys_notification.source_object) — read as written: a single-brace {token} is not resolved here and is refused, as is a CEL value envelope; write the value. Only takes effect together with sourceId — a half-specified click-through target is dropped at execute time, so the inbox never renders a dead link. When sourceObject, sourceId and actionUrl are all left out, a run started by a record (a record change other than a delete or a before-create, a time-relative sweep, an action on a record) links to that record; a before-create run\'s record has no id until the driver issues one at the write, so it links only when the caller wrote the id.',
},
sourceId: {
type: 'string',
description: 'Record id the notification links to (writes sys_notification.source_id) — a string, or a CEL value envelope evaluated per run to one, such as { dialect: \'cel\', source: \'record.id\' }; a single-brace {token} is refused. Only takes effect together with sourceObject — a half-specified click-through target is dropped at execute time, so the inbox never renders a dead link. The inbox synthesizes a `/{object}/{id}` deep-link from these. When sourceObject, sourceId and actionUrl are all left out, a run started by a record (a record change other than a delete, a time-relative sweep, an action on a record) links to that record.',
description: 'Record id the notification links to (writes sys_notification.source_id) — a string, or a CEL value envelope evaluated per run to one, such as { dialect: \'cel\', source: \'record.id\' }; a single-brace {token} is refused. Only takes effect together with sourceObject — a half-specified click-through target is dropped at execute time, so the inbox never renders a dead link. The inbox synthesizes a `/{object}/{id}` deep-link from these. When sourceObject, sourceId and actionUrl are all left out, a run started by a record (a record change other than a delete or a before-create, a time-relative sweep, an action on a record) links to that record; a before-create run\'s record has no id until the driver issues one at the write, so it links only when the caller wrote the id.',
},
actorId: {
type: 'string',
Expand Down
Loading
Loading