Repository navigation
Commit a00f335
feat: hooks and record-triggered flows set the save notice (ctx.notice, save_notice node) (#28753)
domain:server#3 · os-project-manager · session_01FEkQ5kE5BrPUPNMMyv6oCH
Fixes #28251
Clause-②: yes (widening)
Metadata-protocol: added
## 维护者速读
- 钩子(含沙箱化的 shipped body)现在可以调用 `ctx.notice(message)` 设置保存提示,新建/更新的应答里带回
`notice`。
- 记录触发的流程新增 `save_notice` 节点,同样设置这次写入的 `notice`。只有一条通道,就是写入自己的
`onNotice`。
- `save_notice` 只能放在由记录新建/更新触发的流程里,其他地方在发布时就会被响亮拒绝。审批或等待之后恢复的分支、子流程、map
子项在运行时会按名字拒绝。
- 流程提示可翻译,键是 `flows.<flow>.notices.<node_id>.message`,按写入者的语言渲染。`notice`
在线上仍是纯字符串。
- 范围只限单条记录的新建和更新。批量、克隆和流程节点自己的写入都不带提示。
- 钩子这一面的提示要不要也有翻译目录(`hooks.<hook>.notices.<KEY>.message`):席位已裁定 A,暂不做(卡上
`6562118323`),等第一个需要本地化提示的钩子再立卡。不需要你拍板。
## What changes
**Hook face: `ctx.notice(message)`.** `HookContextSchema` gains an
optional `notice` member (`HookNoticeSetter`). ObjectQL's `triggerHooks`
binds it on every `beforeInsert` / `afterInsert` / `beforeUpdate` /
`afterUpdate` dispatch (`packages/objectql/src/hook-notice.ts`). Every
dispatch site therefore has it, and no site can forget it. A call reads
the write's own `onNotice` listener off `ctx.input.options` at call time
and hands it the message. A write that passed no listener sets nothing.
A message that is not a non-empty string throws a `TypeError` naming the
call, so the write fails loudly instead of silently dropping the notice.
Delete and read dispatches carry no member. The body runner passes the
member through, and the QuickJS runner installs it as a host function,
so shipped bodies (and handlers the build lowers into bodies) can call
it. It needs no capability.
**Flow face: the `save_notice` node.** `FlowNodeAction` gains
`save_notice`, and its config is `SaveNoticeConfigSchema` (`{ message
}`, one `{{ }}` text slot). The record-change trigger already runs the
flow inside the write that fired it. It now hands the run that write's
channel (`AutomationContext.saveNotice`, wrapping `ctx.notice`) and
closes the channel when the handler returns. The executor renders the
message and sets it.
- At publish, the `FlowSchema` parse refuses the node at any region
depth unless the flow's start is a create or update record trigger
(`save-notice-placement.ts`). The refusal is `INVALID_METADATA` 422 at
`nodes.<i>.type`.
- At run, the node refuses by name when the run holds no channel: a leg
restored after an approval or a wait, a direct `execute`, or a subflow
or map child (both delete the channel from the child context). It also
refuses when the channel is closed, which covers a leg resumed in memory
after the write answered.
**Why a node, not a member of an existing node (measured).** The flow
that asked for this (HotCRM's deal submission, #27380 row 3) sends the
record for approval. Its graph has no record-write node before the
`approval` node that suspends the run, so a member on `update_record`
would have nowhere to go. A member on `end` would run only after the
approval, on a resumed leg, when the write has already answered. A
dedicated node is the smallest surface that can stand before the
suspension.
**Message form (Wanted 3).** The platform's translatable-message shape
is a catalog key that the server renders in the reader's locale
(`flows.<flow>.refusals.<node_id>.message`,
`hooks.<hook>.refusals.<CODE>.message`). The flow notice uses that
shape: `flows.<flow>.notices.<node_id>.message`
(`flowNoticeMessageKey`), picked in the writer's locale before the holes
are filled. `os i18n extract` scaffolds the key, the coverage gate
demands it, and `objectstack validate` refuses a key over an unknown
flow or node or a non-`save_notice` node. So the wire `notice` stays a
plain string, and no widening of `CreateDataResponseSchema.notice` /
`UpdateDataResponseSchema.notice` is needed. A hook-side catalogue is
left to a maintainer decision (see the report).
**Other surfaces.**
- The translation liveness row for `flows.notices` is `live`, cited to
its reader.
- The lint closed-binder set includes `save_notice`, which binds
nothing.
- The showcase demonstrates the node on `showcase_task_completed` (with
zh-CN).
- The platform checklist matrix item counts the new variant.
- Spec artifacts were regenerated.
## Why
#28247 added `notice` to the create and update answers, but only an
in-process caller passing `onNotice` could set it. Shipped hook bodies
and flows had no way in, so HotCRM faked the sentence with a
`record:alert` banner.
## Verification
- **Red first through REST (live showcase, `pnpm --filter
@objectstack/example-showcase dev -- --fresh`).** The probe installed
`app.probe1`, saved an object, a hook whose body calls `ctx.notice`, and
a `record-after-update` flow with a `save_notice` node, then created and
updated a record.
- Base `4bf48a8c` (spec, objectql, runtime, trigger and automation
rebuilt from base): `POST` → 201 with no `notice`; `PATCH` → 200 with no
`notice`. A `save_notice` flow under `record-after-delete` was saved,
200.
- Head: `POST` → 201 `"notice":"Sent Acme for approval"`; `PATCH` → 200
`"notice":"Moved Acme to review"`; the delete-trigger flow → 422
`INVALID_METADATA`, `issues[0].path` `nodes.1.type`.
- **New tests.**
- `spec/src/automation/save-notice-node.test.ts` (17)
- `objectql/src/protocol-save-notice-answer.test.ts`, the #28251 block
(8 of 22)
- `runtime/src/sandbox/hook-notice.integration.test.ts`: real ObjectQL,
SqlDriver and a QuickJS body
- `trigger-record-change`: `save-notice-channel.test.ts` (3), plus 2
cases in `record-change-integration.test.ts`
- `service-automation/src/builtin/save-notice-node.test.ts` (7, real
in-memory i18n)
- `lint/.../validate-translation-references.test.ts` (3)
- `cli/test/i18n-flow-save-notice-coverage.test.ts` (6)
- **Ablations, all red then restored to the HEAD blob**
(`scripts/ablation-replace.mjs`). Each one below was broken in turn:
- the `bindHookNotice` call
- the member's refusal of a non-string
- the QuickJS install
- the body-runner spread
- the trigger's channel spread
- the trigger's channel close
- the subflow delete and the map delete
- the executor's no-channel refusal
- the placement refusal
- the lint collect and orphan check
- the CLI extract
- **Gates.** `dispatch-gates.mjs --commands`: 116 derived; `--ran`
reconciles 115 run and 1 NOT MEASURED (`check:dual-build-cjs-loads`,
exit 3, needs every package's dist). `eslint` over the seven touched
packages: exit 0.
## Acceptance notes
- A notice set from a fire-and-forget async `after*` hook that resolves
after the write answered is dropped, because the answer has already
gone. Await the work, or set the notice in a `before*` hook.
- When several hooks or nodes set a notice on one write, the last one
wins, as the #28247 contract says.
- `ctx.notice(42)` in a body fails the write as a hook body fault, the
same way any other `TypeError` in a body does.
- The Studio palette (`ui/.../flow-canvas-parts.tsx` `NODE_PALETTE`)
does not list `save_notice` yet. The node still saves and runs, and the
actions feed carries its descriptor.
- HotCRM can drop its `record:alert` workaround once this lands. That
belongs to the hotcrm lane; this PR does not touch hotcrm.
## 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` passes.
- [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 by [Claude
Code](https://claude.ai/code/session_01FEkQ5omF1nCbQTGgW1nbRA)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent b48eeb1 commit a00f335
63 files changed
Lines changed: 1664 additions & 54 deletions
File tree
- .changeset
- content/docs/references
- api
- automation
- data
- system
- docs
- audits/2026-07-unknown-key-strictness-ledger.counts
- qa/platform-checklist/areas
- examples/app-showcase/src
- automation/flows
- system/translations
- packages
- cli
- src/utils
- test
- lint/src
- objectql/src
- runtime/src/sandbox
- services/service-automation/src/builtin
- spec
- api-surface
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- liveness
- state-counts
- src
- api
- automation
- contracts
- data
- system
- triggers/trigger-record-change/src
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
557 | 557 | | |
558 | 558 | | |
559 | 559 | | |
560 | | - | |
| 560 | + | |
561 | 561 | | |
562 | 562 | | |
563 | 563 | | |
| |||
3077 | 3077 | | |
3078 | 3078 | | |
3079 | 3079 | | |
3080 | | - | |
| 3080 | + | |
3081 | 3081 | | |
3082 | 3082 | | |
3083 | 3083 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
104 | 104 | | |
105 | 105 | | |
106 | 106 | | |
107 | | - | |
108 | | - | |
| 107 | + | |
| 108 | + | |
109 | 109 | | |
110 | 110 | | |
111 | 111 | | |
| |||
254 | 254 | | |
255 | 255 | | |
256 | 256 | | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
257 | 268 | | |
258 | 269 | | |
259 | 270 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
205 | 205 | | |
206 | 206 | | |
207 | 207 | | |
| 208 | + | |
208 | 209 | | |
209 | 210 | | |
210 | 211 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
111 | 111 | | |
112 | 112 | | |
113 | 113 | | |
| 114 | + | |
114 | 115 | | |
115 | 116 | | |
116 | 117 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
22 | 22 | | |
23 | 23 | | |
24 | 24 | | |
25 | | - | |
| 25 | + | |
26 | 26 | | |
27 | 27 | | |
28 | 28 | | |
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
37 | | - | |
| 37 | + | |
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
| |||
108 | 108 | | |
109 | 109 | | |
110 | 110 | | |
111 | | - | |
| 111 | + | |
112 | 112 | | |
113 | 113 | | |
114 | 114 | | |
| |||
118 | 118 | | |
119 | 119 | | |
120 | 120 | | |
121 | | - | |
| 121 | + | |
122 | 122 | | |
123 | 123 | | |
124 | 124 | | |
| |||
0 commit comments