Skip to content

Commit 719a502

Browse files
docs(skills): name the save_notice node in the automation skill (#31044)
<!-- claude-projects-attribution: {"github_login":"huangyiirene"} --> _Requested by **Irene** · [project thread](https://claude.ai/code/project/chan_01TJXtQ1tVD26ecLDZxFS67x?thread=cmsg_01TJXtQ1tVD26ecLDZxFS67xUyUS8qSzmmqesMRnrdULsA)_ Fixes #28787 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:`objectstack-automation` skill 的 Data Operations 表新增 `save_notice` 一行,指向 `objectstack-data` `rules/lifecycle.md` § Save Notice(仍是唯一完整教学处)。该节补上代码已做而文中缺的四点:hook 的 `ctx.notice` 文案不翻译;`save_notice` 文案的翻译键 `flows.<flow>.notices.<node_id>.message`(按保存人的语言读取);在 `subflow` / `map` 子流程里按名拒绝;其后若有可挂起子流程的 `subflow` / `map`,流程不装配(`notice_after_suspending_subflow`)。新增 eval 16。 - **为什么改**:#28251 已上线保存提示,但 automation skill 0 处提到 `save_notice`,助手写「已提交审批」提示时仍会用 `record:alert` 横幅绕路。 - **风险与回滚**:只改 skill 文本、一条 eval 和一个 changeset;两文件增长均以同文件删除重复内容支付,棘轮行不动;回滚即 revert,无运行时影响。 - **先看**:`skills/objectstack-data/rules/lifecycle.md` § Save Notice 新增的五行,对照 `packages/services/service-automation/src/builtin/save-notice-node.ts`、`subflow-node.ts` L123、`map-node.ts` L246、`flow-definitions.ts` L314。 - **两分钟验证**:`node scripts/check-skills-token-ratchet.mjs`,预期 exit 0,读数 automation `SKILL.md` 6426/6426、`lifecycle.md` 1590/1590。 - **需要你做的**:`skills/**` 属 Tier H,需要 `os-zhuang` 或 `hotlong` 批准。 ## What changes Every claim was read from the code on `main` at `3fe5f2a02`, not from the card: | Taught | Source | |:--|:--| | `save_notice` row in automation Data Operations: create/update record trigger only, not after a pause, pointer to `rules/lifecycle.md` § Save Notice | `packages/spec/src/automation/save-notice-placement.ts` (`SAVE_NOTICE_TRIGGER`, `suspendedLegRefusals`) | | A hook's `ctx.notice` message shows untranslated | `HookNoticeSetter = (message: string) => void` in `packages/spec/src/data/hook.zod.ts`; ruling A, #28251 comment 6562118323 | | The node's message translates under `flows.<flow>.notices.<node_id>.message` in the saver's locale | `flowNoticeMessageKey` in `packages/spec/src/system/i18n-resolver.ts`; `save-notice-node.ts` (`renderContext` takes `channel.locale`, the writer's) | | Refused by name in a `subflow` / `map` child | `subflow-node.ts` L123 and `map-node.ts` L246 `delete childContext.saveNotice`; `save-notice-node.ts` refusal when the run holds no channel | | After a `subflow` / `map` whose child can pause, the flow is not armed (`unboundCause: 'notice_after_suspending_subflow'`) | `packages/services/service-automation/src/subflow-notice-guard.ts`; `flow-definitions.ts` L314 | `lifecycle.md` keeps "after an `approval` / `wait`" (the parse refusal set is `FLOW_PAUSE_CAPABLE_NODE_TYPES` = `screen`, `wait`, `approval`, `approval_revise`; a `screen` cannot stand in a record-triggered flow and `approval_revise` follows an `approval`, so the two named cover a record-triggered flow). `skills/objectstack-automation/evals/flows-triggers-approvals.json` gains case 16: when a saved `deal`'s `status` is changed to `submitted`, route it for approval and tell the saver "Sent {{ record.name }} to your manager for approval" in their language. The expected answer is a `record-after-update` trigger (on update `previous` is bound, so the `previous.status` guard cannot fault), a `save_notice` before the `type: 'approval'` node, and the translation key. `must_contain`: `save_notice`, `triggerType`, `notices.`, `type: 'approval'` (each spelled in the `expected_output`); `must_not_contain`: `type: 'record:alert'` (an authored banner; the `expected_output` names no banner type). No existing eval in the skill teaches the banner workaround (`git grep -n "record:alert\|notice" -- skills/objectstack-automation/evals/` on `main`: no hit). Every claim on the card is done by the code; nothing it asks for is missing. ## Token budget Readings from `node scripts/check-skills-token-ratchet.mjs`: | File | Before (`3fe5f2a02`) | After | Ceiling | |:--|--:|--:|:--| | `skills/objectstack-automation/SKILL.md` | 6426 | 6426 | 6426 (unchanged) | | `skills/objectstack-data/rules/lifecycle.md` | 1590 | 1590 | 1590 (unchanged) | | bundle total (whole shipped tree, eval included) | 228257 | 228616 | — | No ratchet row moves. Growth is paid by deletion-only edits of restated text in the same file, under the seat's claim widening on #28787 (comment 6694188691: deletion-only edits allowed anywhere in both files, each with its surviving home named; additions stay in § Save Notice and the `save_notice` row). Deleted in `skills/objectstack-automation/SKILL.md`: 1. The `connector_action` row's ": `registerFlow` and `os validate` refuse a `{…}` token at `connectorConfig.input.<key>`". Home: Valid-but-silently-wrong item 1 (a value slot refuses a `{…}` token at `objectstack validate`, `registerFlow` and the executor); the row still calls `connectorConfig.input` a value-slot map. 2. The Triggers intro's "A `record_change` flow fires on a data event;". Home: Flow Types, `record_change` row ("Fires automatically on record create/update/delete"). 3. The Triggers intro's "— the binding is the `start` node's `config`". Home: § Trigger Configuration — on the `start` node, and § Trigger Types (start-node `config.triggerType`). Deleted in `skills/objectstack-data/rules/lifecycle.md`: 1. " — system data lifecycle & retention" in the intro. Home: the H1 title. 2. Code comment "O(1) shard DROP on SQLite". Home: Safety Semantics, Rotation bullet (expired shards are DROPped on SQLite). 3. Code comment "paused rows never reaped". Home: the block comment above it ("paused rows are live workflow state — `onlyWhen` scopes the age sweep"). 4. "— e.g. `'6h'`, `'14d'`, `'12w'`, `'7y'`" after Duration literals. Home: the Syntax examples (`'14d'`, `'1d'`, `'90d'`, `'7y'`, `'30d'`) and the ❌ `'2w'` example. 5. Safety bullet "**No `lifecycle` block = today's behavior**: `record` is the implicit default." Home: Why This Exists ("Every object without a `lifecycle` block is **immortal**") and `objectstack-data` `SKILL.md`'s field table (`lifecycle` default: `record` semantics). 6. Safety bullet's "— a compliance ledger cannot be destroyed by declaring a lifecycle" (review round 1). Home: the same sentence ("rows are simply retained") and the bullet's lead ("safe by default"). Round 1 restored two comments first cut: "reap 1 day after expires_at" (the only statement that `expireAfter` counts from `ttl.field`) and "must: after === maxAge" (the only statement that `archive.after` must equal `retention.maxAge`, `object.zod.ts` L1067–1070); both lines are byte-identical to `main` again. ## Review round 1 Contract review FAIL at `7d0260975` (comment on this PR). All five fixed in one delta commit on top, no rebase, no force-push: - M1: eval 16 accepts `record-after-update` only, and its prompt asks for a change of `status` on a saved deal, so the unguarded `previous.status` condition cannot meet a create leg. - M2 / S2: the two lifecycle comments restored (see above). - S1: `must_not_contain` is now `type: 'record:alert'`, which the `expected_output` does not contain; the `expected_output` spells `type: 'approval'`. - S3: the key line says the key is read in the saver's locale. Paid by deletion 6 and by folding: the hook line reads "A hook's message is untranslated." ## Review tier `dispatch-gates` mandates the contract-review tier for `skills/**` (clause ①); waived by the maintainer's standing order in this project (2026-11-03). The seat runs the contract review. ## Verification `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `2d885ad4c` (4 paths vs merge base `3fe5f2a02`): **45 commands**, each run under `timeout 900` with `NODE_OPTIONS=--max-old-space-size=8192` after a full `pnpm build` (119/119 tasks), exit code captured before any pipe → **45 × exit 0** (round 0 at `7d0260975` was also 45 × exit 0). `--ran` reconciliation: `45 derived famil(ies) accounted for — 45 run, 0 NOT-MEASURED`. `node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD` → exit 0 before each push. `origin/main` is now `eb96480d`; since the branch point it touches none of the three skill files nor `scripts/check-skills-token-ratchet.mjs`, and `git merge-tree` merges it cleanly, so the readings above stand. <details><summary>Ran list</summary> ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-doc-route-spelling.mjs --advisory :: exit 0 node scripts/check-doc-route-spelling.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-skills-token-ratchet.mjs :: exit 0 node scripts/check-skills-token-ratchet.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/ci/snapshot-next.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/spec run check:skill-docs :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:corpus-claim-drift :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:merge-driver :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-governed-merges :: exit 0 pnpm check:pm-skill-id-lint :: exit 0 pnpm check:published-files :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:role-word :: exit 0 pnpm check:skill-compatibility :: exit 0 pnpm check:skill-frame-sync :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:watch-hint-literal :: exit 0 ``` </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. (no names added) - [ ] 3. No formatter ran; `pnpm lint` was not run (Markdown and JSON fixture only, no lint target). - [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 (`'@objectstack/skills': patch`). - [x] 6. `packages/spec` touched: n/a. - [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, except the `## 维护者速读` summary the maintainer asks for in Chinese. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01J8VVAjrkobcs8zUPH1QvRm <!-- signed: domain:skills#3 · huangyiirene · session_01J8VVAj7CvjkGXvgXzd6RZ7 --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 226c988 commit 719a502

4 files changed

Lines changed: 41 additions & 12 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/skills': patch
3+
---
4+
5+
docs(skills): the objectstack-automation skill names the `save_notice` flow node and points to the save notice's one home
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`skills/objectstack-automation/SKILL.md` listed no `save_notice` node, so an author designing a "sent for approval" flow from this skill never found it. Its Data Operations table now carries a `save_notice` row that points at `objectstack-data` `rules/lifecycle.md` § Save Notice, which stays the one full teaching. That section gains what the code does and it lacked: a hook's `ctx.notice` message is shown untranslated; a `save_notice` message translates under `flows.<flow>.notices.<node_id>.message` in the saver's locale; the node refuses by name in a `subflow` / `map` child, and a flow with one after a `subflow` / `map` whose child can pause is not armed (`unboundCause: 'notice_after_suspending_subflow'`). `evals/flows-triggers-approvals.json` gains case 16. Growth in both files is paid by same-file deletions of restated text; no ratchet row moves.

‎skills/objectstack-automation/SKILL.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,15 @@ Flows are built from **built-in node types** (plugins register more):
8686
| `update_record` | Modify existing records |
8787
| `delete_record` | Remove records |
8888
| `get_record` | Fetch records with filters — there is **no `query_record`** node (that name has no executor and throws) |
89+
| `save_notice` | Tell the saver what this create/update started — record create/update trigger only, not after a pause: **objectstack-data** `rules/lifecycle.md` § Save Notice |
8990

9091
#### External Integration
9192

9293
| Node | Purpose |
9394
|:-----|:--------|
9495
| `http` | Call an external HTTP API — always set `timeoutMs` (unset = no timeout). `url` and `headers` are served as written with the definition to every member who can read flows — only `signingSecret` and a start node's `secret` are withheld — so never a credential there: a credential means a declared connector called via `connector_action` |
9596
| `notify` | Send a notification through the messaging service |
96-
| `connector_action` | Dispatch an action of a declared connector (§ *Integrations are declared, not coded*) or a plugin's. `connectorConfig.input` is a value-slot map like `subflow` `input`: `registerFlow` and `os validate` refuse a `{…}` token at `connectorConfig.input.<key>`. Once evaluated, it is judged against the action's `inputSchema` (`required`, `type`, `properties`, `items`) before dispatch: a breach fails the step `VALIDATION_ERROR` (`fault`-routable), nothing sent; `os validate` does not judge the `inputSchema` fit yet |
97+
| `connector_action` | Dispatch an action of a declared connector (§ *Integrations are declared, not coded*) or a plugin's. `connectorConfig.input` is a value-slot map like `subflow` `input`. Once evaluated, it is judged against the action's `inputSchema` (`required`, `type`, `properties`, `items`) before dispatch: a breach fails the step `VALIDATION_ERROR` (`fault`-routable), nothing sent; `os validate` does not judge the `inputSchema` fit yet |
9798
| `script` | Call a **registered** function named by `config.function` (see *Valid-but-silently-wrong* #3) |
9899
| `screen` | Display a UI form to the user (screen flows only). `config.fields[]`: `{ name, label, type, required, options, defaultValue }`, `type` an open widget hint; `'file'` renders the upload widget and resumes with the `sys_file` id bound to `name` — a later `create_record` / `update_record` writes it into a `file` field, which claims the file: collect first, write after the last screen. `'record_link'` (never `'record'`, an object field type) takes no input — it shows the `reference` record whose id `defaultValue` evaluates to, by display name, as a link to its record page: the result screen of a flow that created records, `{ name: 'account_link', type: 'record_link', reference: 'crm_account', defaultValue: { dialect: 'cel', source: 'account.id' } }` over a `create_record` `outputVariable: 'account'`. Both keys required; `required` / `options` / `placeholder` / `min` / `max` refused; a resume value under its name refused (`400 INVALID_SCREEN_INPUT`). A completed `end` `message` is refused and a markdown link in a screen `description` renders literally. Next / Submit and "Step N of M" are not authored: the engine reads the graph when the screen pauses and serves `progress` (`nextScreen: 'none' | 'possible' | 'certain'`; `step` / `stepCount` only when every path agrees), which alone labels the button and draws the counter. A `decision`, `fault` edge, loop back, non-screen pause (`approval`, `wait`) or `subflow` after a screen answers `possible` — "Next", no count. `progress` in a node `config` is refused; never hand-roll a button label or step counter into `fields` |
99100

@@ -328,9 +329,8 @@ for the same people is `approvers: [{ type: 'queue', value: 'crm_support_intake'
328329

329330
## Triggers — Event-Driven Automation
330331

331-
A `record_change` flow fires on a data event; there is **no standalone trigger
332-
object and no top-level `trigger` / `event` key** — the binding is the `start`
333-
node's `config`.
332+
There is **no standalone trigger
333+
object and no top-level `trigger` / `event` key**.
334334

335335
### Prerequisite — declare the capabilities your nodes need
336336

‎skills/objectstack-automation/evals/flows-triggers-approvals.json‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,23 @@
277277
"| label"
278278
]
279279
}
280+
},
281+
{
282+
"id": 16,
283+
"prompt": "When a saved `deal` record's `status` is changed to `submitted`, route it to the sales manager for approval, and tell the person who clicked Save \"Sent {{ record.name }} to your manager for approval\" on the save toast, in their own language.",
284+
"expected_output": "A flow whose start node binds `objectName: 'deal'` with `triggerType: 'record-after-update'` and the condition `previous.status != 'submitted' && record.status == 'submitted'` (on update `previous` is bound), then a `save_notice` node (`config.message: 'Sent {{ record.name }} to your manager for approval'`) placed BEFORE the approval node (`type: 'approval'`): the run is still inside the write there, while a `save_notice` reachable after the approval (or a `wait`) is refused at parse. The translation lives under `flows.<flow>.notices.<node_id>.message`, read in the saver's locale. Not a `notify` node to the saver (an inbox message, not the save answer), not a page banner component (shown whenever the record's state says so, not for this save), and not in a subflow the trigger flow calls (a child run holds no save, so the node refuses by name).",
285+
"files": [],
286+
"assertions": {
287+
"must_contain": [
288+
"save_notice",
289+
"triggerType",
290+
"notices.",
291+
"type: 'approval'"
292+
],
293+
"must_not_contain": [
294+
"type: 'record:alert'"
295+
]
296+
}
280297
}
281298
]
282299
}

‎skills/objectstack-data/rules/lifecycle.md‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Data Lifecycle & Retention
22

33
Guide for declaring how long an object's data lives and how its space is
4-
reclaimed (ADR-0057 — system data lifecycle & retention). Distinct from
4+
reclaimed (ADR-0057). Distinct from
55
**lifecycle hooks** (`beforeInsert` / `afterUpdate` …), which run logic on
66
writes: this page covers the rows' retention, rotation and archival
77
— and, at its end, the save notice a hook hands the saver.
@@ -38,7 +38,7 @@ export const ApiCallLog = ObjectSchema.create({
3838
lifecycle: {
3939
class: 'telemetry',
4040
retention: { maxAge: '14d' }, // reap past 14 days (created_at)
41-
storage: { strategy: 'rotation', shards: 14, unit: 'day' }, // O(1) shard DROP on SQLite
41+
storage: { strategy: 'rotation', shards: 14, unit: 'day' },
4242
},
4343
fields: {
4444
endpoint: Field.text({}),
@@ -79,7 +79,7 @@ export const AutomationRun = ObjectSchema.create({
7979
class: 'telemetry',
8080
retention: {
8181
maxAge: '30d',
82-
onlyWhen: { status: { $in: ['completed', 'failed'] } }, // paused rows never reaped
82+
onlyWhen: { status: { $in: ['completed', 'failed'] } },
8383
},
8484
},
8585
fields: {
@@ -88,8 +88,7 @@ export const AutomationRun = ObjectSchema.create({
8888
});
8989
```
9090

91-
Duration literals: `<n>` + `h` / `d` / `w` / `y` — e.g. `'6h'`, `'14d'`,
92-
`'12w'`, `'7y'`.
91+
Duration literals: `<n>` + `h` / `d` / `w` / `y`.
9392

9493
## Validation Rules (rejected at parse time)
9594

@@ -117,12 +116,10 @@ lifecycle: {
117116

118117
## Safety Semantics
119118

120-
- **No `lifecycle` block = today's behavior**: `record` is the implicit default.
121119
- **Archive-then-delete is atomic-ish and safe by default**: an object
122120
declaring `archive` is never hot-deleted before the copy to the archive
123121
datasource succeeded. If no datasource is registered under the `archive.to`
124-
name, rows are simply retained — a compliance ledger cannot be destroyed
125-
by declaring a lifecycle.
122+
name, rows are simply retained.
126123
- **Rotation** physically time-shards the table on SQLite (writes hit the
127124
current shard, reads go through a view under the object's name, expired
128125
shards are DROPped). Other dialects enforce the same window with an
@@ -155,6 +152,11 @@ record's state says so; the notice is for what *this* save started.
155152
import, a nested write) sets nothing.
156153
- Not a non-empty string ⇒ the write refuses; a refused write answers its
157154
error, no notice.
155+
- A hook's message is untranslated.
158156
- A flow a record create/update trigger starts sets it with a `save_notice`
159157
node (`config.message`, `{{ }}` renders); refused in any other flow, and
160158
after an `approval` / `wait`.
159+
Its message translates under `flows.<flow>.notices.<node_id>.message`
160+
in the saver's locale.
161+
Refused by name in a `subflow` / `map` child; after one whose child can
162+
pause, the flow is not armed (`unboundCause: 'notice_after_suspending_subflow'`).

0 commit comments

Comments
 (0)