You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 3479c33
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
|**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. |
108
108
|**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 |
109
109
|**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. |
110
110
|**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 |
* [#19939] The notify value slot holding one string — `sourceId`, the
689
692
* click-through record's id: the string as written, or a CEL value envelope
690
693
* 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.)
695
700
*/
696
701
functionresolveStringSlot(
697
702
engine: AutomationEngine,
@@ -706,7 +711,7 @@ function resolveStringSlot(
706
711
ok: false,
707
712
error:
708
713
`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: '
710
715
+`\`${sourceOf(raw)}\``,
711
716
};
712
717
}
@@ -882,11 +887,11 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
882
887
// literals, read as written.
883
888
sourceObject: {
884
889
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.',
886
891
},
887
892
sourceId: {
888
893
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.',
0 commit comments