Skip to content

Commit 3479c33

Browse files
docs(automation): the notify click-through default says a before-create run links to its record only when the caller wrote the id (#30926)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoAWWXVQzVyUxg58HrLRViak)_ Fixes #30759 Clause-②: no Metadata-protocol: none Text only; the runtime is unchanged. Triage 6674582930 routed the card to `domain:spec` as one PR over every surface that carries the wording, so no second dialect opens. The `notify-node.ts` hunks are `domain:server` code carried by the spec lane per that triage. ## 维护者速读 - **改了什么**:notify 节点"不写链接就默认链到触发记录"的四处文案(spec 的 describe 与 docblock、服务端 notify 节点的 docblock 与配置说明、Flows 文档、automation skill 示例注释)现在写明:before-create 运行的记录还没有 id,只有调用方自己写了 id 时才会链接;`sourceId` 求值为 `null` 是"写了键但为空",半对被丢弃、不回退到默认。参考页 `io-node-config.mdx` 随之再生成。 - **为什么改**:原文把"删除以外的记录变更"都说成会链接,但 before-create 运行在驱动发 id 之前执行,通知其实不带链接;作者或 AI 会据此省掉 pair 而等不到链接。 - **风险与回滚**:零运行时改动;skill 文件净增 0 行、token 6443→6446(上限 6449)。回滚即 revert。 - **先看**:`packages/spec/src/automation/io-node-config.zod.ts`(docblock + 两条 describe)、`packages/services/service-automation/src/builtin/notify-node.ts`(docblock、`null` 失败文案、两条 description)、`skills/objectstack-automation/SKILL.md` :125。 - **两分钟验证**:`pnpm --filter @objectstack/service-automation exec vitest run src/builtin/notify-node.test.ts`,预期 25/25 通过,其中两条新 pin:无 id 的 beforeInsert 运行不写 source,调用方写了 id 的则链接。 - **需要你做的**:Tier H(`skills/**`)——席位复审后交 os-zhuang 批准。 ## What changes - **The shared wording.** The four texts now enumerate the runs that link as "a record change other than a delete or a before-create, a time-relative sweep, an action on a record", and each says why a before-create run is out: its record is the write's input, and the driver issues the id at the write, after the hook, so it links only when the caller wrote the id (the card's precision note: the input carries an `id` only when the caller wrote one). - `packages/spec/src/automation/io-node-config.zod.ts`: the `NotifyConfigSchema` docblock bullet and the `sourceObject` / `sourceId` describes. - `packages/services/service-automation/src/builtin/notify-node.ts`: the `defaultClickThroughOf` docblock and the `sourceObject` / `sourceId` config descriptions (the wording source). - `content/docs/automation/flows.mdx`: the click-through paragraph. - `skills/objectstack-automation/SKILL.md` :125: `// trigger record (not on delete/before-create), if any` — the comment edited in place, net +0 lines, no re-wrap. - `content/docs/references/automation/io-node-config.mdx`: regenerated from the describes by `check:generated --fix`, not hand-edited. - **The `null` clause.** `notify-node.ts`'s `sourceId` failure text and `flows.mdx` said a `null` "leaves the key unset". Both now say it is a written `sourceId` that resolves to nothing, so the pair is dropped rather than defaulted — what `defaultClickThroughOf` does, since it reads the written config, not the evaluated value. - **Pins** in the existing `notify click-through defaults to the trigger record` describe of `notify-node.test.ts`: a notify on a `beforeInsert` run whose record carries no id writes no `source` (so no `source_object` / `source_id`) and no URL; one whose caller wrote the id links to it. - **Changeset** `.changeset/30759-notify-click-through-before-create.md`: `@objectstack/spec` patch, `@objectstack/service-automation` patch, `@objectstack/skills` patch. Measured on `origin/main` 6ff27b3 before editing: `record-before-create` → `beforeInsert` and `record-before-write` → `beforeInsert` + `beforeUpdate` (`packages/triggers/trigger-record-change/README.md` mapping table; `record-change-trigger.ts` :520 treats `beforeInsert` as the insert event), so the insert leg of `record-before-write` and `record-before-create` are the only before-create shapes. On that leg `buildContext` has no `ctx.result` and no `ctx.previous`, so `record` is `ctx.input.data` (:477-485); `driver-memory` `create` issues `id: data.id || this.generateId(object)` at the write (:845); `triggerRecordOf` needs both `context.object` and `context.record.id` (:155-159). ## Why A before-create run is "a record change other than a delete", so the shipped text promised a link the runtime cannot make: the notification links nowhere, not to a dead link. An author or an AI leaving the pair out on a before-create notify expected a link that never comes. ## Tests - `pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 src/builtin/notify-node.test.ts` under the verify lock: `Test Files 1 passed (1)`, `Tests 25 passed (25)` (VERDICT command-exit 0). - Reverse verification of the new pin, committed fix first: `triggerRecordOf` mutated on disk to `return object ? { object, id: id ?? 'PIN_ABLATION_ID' } : undefined` (1 hit of the marker, 0 of the original line), same run → `Tests 2 failed | 23 passed (25)`: `writes no source on a before-create run whose record has no id yet` and `links nowhere on a run with no trigger record` both red (VERDICT command-exit 1). Restored with `git checkout HEAD -- <path>`: `git hash-object` equals the HEAD blob, `git diff HEAD` empty; rerun 25/25. - `pnpm --filter @objectstack/service-automation typecheck`: VERDICT command-exit 0. - Affected packages' own suites under the lock: `pnpm --filter @objectstack/spec typecheck` VERDICT command-exit 0; `pnpm --filter @objectstack/spec test` VERDICT command-exit 0, `Tests 25838 passed | 1 todo (25839)`; `pnpm --filter @objectstack/service-automation test` VERDICT command-exit 0, `Tests 3431 passed (3431)`. `test:repo` (the vitest `repo` project) is CI's run (`Test Core`), not run here. ## Verification - `pnpm --filter @objectstack/spec build` (dts emitted 38/38), then `check:generated --fix`: 1 of 14 artifacts stale (`check:docs`), regenerated `content/docs/references/automation/io-node-config.mdx` (2 lines), nothing else moved. - Gate families derived against the actual diff with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at 87404ae (the final commit): 114 lines, every one run with `</dev/null`, exit codes landed on disk before any pipe, reconciled with `--ran`: `114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN`, every line exit 0 on the final tree. Three lines first exited 3 (PREREQUISITE NOT MET, nothing measured): `check-plugin-teardown-shape.mjs --self-test` (its positive control is a commit the shallow clone could not reach; fetched that sha, rerun → exit 0, 48 cases), `check:skill-examples` and `check:dual-build-cjs-loads` (no `dist/` for the unbuilt packages; `pnpm build` under the lock, VERDICT command-exit 0, rerun → exit 0: 283 prose examples type-check; 116 entries / 71 packages / 2456 cjs files load). The ran list is `/mnt/project-files/spec-seat-1/out/30759-gates-ran-87404ae2.list`. - Local-only readings (not derived, left local): `pnpm check:skill-frame-freshness` exit 0; `pnpm check:dev-prereqs` exit 1 — "the workspace is not built" (71 of 106 packages have no dist in this worktree, which built only `@objectstack/spec` and the `service-automation` closure); a precondition of this box, not a finding about the diff. - `node scripts/check-skills-token-ratchet.mjs`: `skills/objectstack-automation/SKILL.md` 6446 tokens (ceiling 6449; headroom 3), from 6443 on main. - Tier: `node scripts/pm/check-governed-merges.mjs --test skills/objectstack-automation/SKILL.md …` → Tier H (`skills/**`). Changed lines: 76 additions + 22 deletions, generated file included. - `skills/**` readings, lines: `skills/objectstack-automation/SKILL.md` 452 before / 452 after; the whole published catalog (11 `SKILL.md` files) 4525 before / 4525 after. Tokens (`check-skills-token-ratchet`): 6443 before / 6446 after. - Open PRs touching the same files at dispatch time (`gh api search/issues`): #30866 (`skills/objectstack-automation/SKILL.md`, a different hunk at :127 and later; no overlap with :125) and #30875 (`skills/objectstack-platform/**`, no overlap). None touches `notify-node.ts` or `io-node-config.zod.ts`. The later lander merges `main`. ## Acceptance notes - Two unconsumed changesets from the PRs that wrote the wording, `.changeset/30254-notify-default-link-skill.md` and `.changeset/30689-notify-click-through-default-describe.md`, repeat the old enumeration in their prose. Not edited here (another PR's release input); this PR's changeset states the corrected enumeration, and the release compiles both. Noted for the release-notes compiler, not filed. - `packages/triggers/trigger-record-change/README.md`'s mapping table is the only in-repo text that states `record-before-write` fires on `beforeInsert` + `beforeUpdate`; the code spells the union through its hook registration, not a literal table. Read-only observation, not a defect. - The spec describes and the `notify-node.ts` descriptions were not byte-identical before this PR (the spec adds "a literal," and "not defaulted"); this PR keeps each text's own frame and changes the shared enumeration and the before-create clause in all of them. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is CI's run. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L9qibwUogojquWFFVyz422 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f108e6d commit 3479c33

7 files changed

Lines changed: 89 additions & 32 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/service-automation': patch
4+
'@objectstack/skills': patch
5+
---
6+
7+
docs(automation): the notify click-through default says a before-create run links to its record only when the caller wrote the id
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
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:
13+
14+
- the `NotifyConfigSchema` docblock and the `sourceObject` / `sourceId` descriptions in `@objectstack/spec`, and the reference page generated from them;
15+
- the `notify` node's docblock and config descriptions in `@objectstack/service-automation`, which is the wording source;
16+
- the Flows guide and the automation skill's `notify` example.
17+
18+
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.
19+
20+
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.

‎content/docs/automation/flows.mdx‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -348,14 +348,17 @@ nothing. The template handed a whole token holding a list, written as a list
348348
entry (`['{owners}']`), on as **one** recipient joined with commas (`'u1,u2'`),
349349
which reached nobody. Guard a recipient field that may be unset —
350350
`has(record.assignee) ? record.assignee : null` — so it is dropped rather than
351-
failing the run. `sourceId` takes a string or an envelope evaluating to one:
352-
`null` leaves the key unset. A node that writes none of `sourceObject`,
353-
`sourceId` and `actionUrl` links to the run's trigger record when it has one —
354-
a record change other than a delete, a time-relative sweep's row, an action
351+
failing the run. `sourceId` takes a string or an envelope evaluating to one;
352+
`null` is a written `sourceId` that resolves to nothing, so the pair is dropped,
353+
not defaulted. A node that writes none of `sourceObject`, `sourceId` and
354+
`actionUrl` links to the run's trigger record when it has one — a record change
355+
other than a delete or a before-create, a time-relative sweep's row, an action
355356
started on a record — so a record-triggered `notify` opens its record without a
356357
pair. An explicit pair or `actionUrl` wins; a half-specified pair is dropped,
357358
never defaulted; a run with no trigger record (a schedule tick, an inbound API
358-
post, a manual start with no record) links nowhere.
359+
post, a manual start with no record) links nowhere. A before-create run's record
360+
has no id until the driver issues one at the write, after the hook, so it links
361+
only when the caller wrote the id.
359362
The node's `sourceObject`, `actorId` and `payload` are
360363
**literals**, read as written: no flow computed one, so nothing is evaluated
361364
there — write the value itself (`sourceObject: 'crm_case'`). A `{…}` token

‎content/docs/references/automation/io-node-config.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,8 @@ const result = HttpConfigSchema.parse(data);
103103
| **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) |
104104
| **topic** | `string` | optional | Event topic (default: "notify") |
105105
| **severity** | `Enum<'info' \| 'warning' \| 'critical'>` | optional | Severity forwarded to the messaging service |
106-
| **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. |
107-
| **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. |
106+
| **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. |
107+
| **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. |
108108
| **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 |
109109
| **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. |
110110
| **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 |

‎packages/services/service-automation/src/builtin/notify-node.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,4 +475,29 @@ describe('notify click-through defaults to the trigger record', () => {
475475

476476
expect(messaging.emitted[0].source).toBeUndefined();
477477
});
478+
479+
// A before-create run's record is the write's input: the driver issues the
480+
// id at the write, after the hook, so the input carries one only when the
481+
// caller wrote it. With no id there is no trigger record to link.
482+
it('writes no source on a before-create run whose record has no id yet', async () => {
483+
engine.registerFlow('notify_flow', notifyFlow({ recipients: ['user_1'], title: 'Ticket incoming' }));
484+
485+
const result = await engine.execute(
486+
'notify_flow',
487+
{ record: { name: 'Pump' }, object: 'repair_ticket', event: 'beforeInsert' } as any,
488+
);
489+
490+
expect(result.success).toBe(true);
491+
expect(messaging.emitted).toHaveLength(1);
492+
expect(messaging.emitted[0].source).toBeUndefined();
493+
expect(messaging.emitted[0].payload.url).toBeUndefined();
494+
});
495+
496+
it('links a before-create run whose caller wrote the id', async () => {
497+
engine.registerFlow('notify_flow', notifyFlow({ recipients: ['user_1'], title: 'Ticket incoming' }));
498+
499+
await engine.execute('notify_flow', recordRun('beforeInsert'));
500+
501+
expect(messaging.emitted[0].source).toEqual({ object: 'repair_ticket', id: 't_7' });
502+
});
478503
});

‎packages/services/service-automation/src/builtin/notify-node.ts‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -171,11 +171,14 @@ const DELETE_EVENTS: ReadonlySet<string> = new Set(['beforeDelete', 'afterDelete
171171
* explicit `actionUrl`, and a half-written pair (still dropped, as before) are
172172
* the author's word and are kept as written.
173173
*
174-
* Only when the run has a trigger record (`triggerRecordOf`): a record-change
175-
* run, a time-relative sweep's row, an action started on a record. A schedule
176-
* tick, an inbound API post and a manual start with no record have none and
177-
* link nowhere, as before. A delete event's record is gone once the write
178-
* lands, so it is not linked: the inbox never renders a dead link.
174+
* Only when the run has a trigger record (`triggerRecordOf`): a record change
175+
* other than a delete or a before-create, a time-relative sweep's row, an
176+
* action started on a record. A schedule tick, an inbound API post and a
177+
* manual start with no record have none and link nowhere, as before. A delete
178+
* event's record is gone once the write lands, so it is not linked: the inbox
179+
* never renders a dead link. A before-create run's record is the write's
180+
* input, which carries an id only when the caller wrote one — the driver
181+
* issues the id at the write, after the hook — so it links only then.
179182
*/
180183
function defaultClickThroughOf(
181184
cfg: Pick<NotifyConfigParsed, 'sourceObject' | 'sourceId' | 'actionUrl'>,
@@ -688,10 +691,12 @@ const EMAIL_FIELD_COUNTER_PREFIX = 'notify-email-field-sends:';
688691
* [#19939] The notify value slot holding one string — `sourceId`, the
689692
* click-through record's id: the string as written, or a CEL value envelope
690693
* evaluated in the run's scope to one, through the shared per-slot resolver
691-
* (`resolveValueSlot`). `null` or a blank string leaves the key unset, as a
692-
* token that resolved to nothing did; anything else fails the node, naming
693-
* what it evaluated to — never `String(value)`. (`sourceObject` and `actorId`
694-
* are literals, read as written.)
694+
* (`resolveValueSlot`). `null` or a blank string is a written key that
695+
* resolves to nothing: the click-through pair is dropped rather than defaulted
696+
* to the trigger record (`defaultClickThroughOf` reads the written config);
697+
* anything else fails the node, naming what it evaluated to — never
698+
* `String(value)`. (`sourceObject` and `actorId` are literals, read as
699+
* written.)
695700
*/
696701
function resolveStringSlot(
697702
engine: AutomationEngine,
@@ -706,7 +711,7 @@ function resolveStringSlot(
706711
ok: false,
707712
error:
708713
`notify: ${key} evaluated to ${evaluatedKindOf(evaluated)}, not a string — write a value expression that `
709-
+ 'evaluates to the record id, or to `null` to leave it unset — source: '
714+
+ 'evaluates to the record id, or to `null`, a written sourceId that resolves to nothing, so the pair is dropped rather than defaulted — source: '
710715
+ `\`${sourceOf(raw)}\``,
711716
};
712717
}
@@ -882,11 +887,11 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
882887
// literals, read as written.
883888
sourceObject: {
884889
type: 'string',
885-
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.',
890+
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.',
886891
},
887892
sourceId: {
888893
type: 'string',
889-
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.',
894+
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.',
890895
},
891896
actorId: {
892897
type: 'string',

0 commit comments

Comments
 (0)