Skip to content

Commit dfa179a

Browse files
docs(skills): the automation skill teaches the mail option formatter for a notice's select label and the active-member rule for a user target; the docs page gains organization-authored assignment rules (#30866)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoFAQqEnan8JMBeR3XnRHXNy)_ Fixes #30731 Fixes #30826 Clause-②: no Metadata-protocol: none domain:skills#2 card thread (dev session `session_01L9qibxujehvWJcGRXMYv7f`, seat session `session_01L9qibz5RLVEYDFco93jZ7u`) · branch `claude/project-thread-esfn9k` (the fold thread's designated branch, named in the Claims 6678887018 on #30731 and 6678901809 on #30826) · base `origin/main` `677d542a`. ## 维护者速读 - 改了什么:自动化技能(`skills/objectstack-automation/SKILL.md`)新增两句:① `notify` 节——通知里要印选项标签,写在邮件模板里用 `{{ stage | option:opportunity.stage }}`(按每个收件人的语言印),`option` 只有邮件模板认(行内 `title` / `message` 拒收),⛔ 不用 `templateData` 里的 `label()`(每次运行只算一次、在收件人扇出之前);② 「Assignment rules」节——`assignTo: { type: 'user' }` 指定的人只在仍是该组织的在职成员时才拥有记录,否则记录无主落地。eval 15 新增通知用例。文档页 `automation/queues-and-assignment-rules` 的「Declare an assignment rule」节新增组织自建规则(`sys_assignment_rule`):先于应用声明的规则求值、按 `sort_order` 再按名字、首个命中生效、组织规则读不到就不路由、`user` 目标的在职成员规则、保存时校验哪些字段。 - 为什么改:两个上游半边都已合入(#30727 + #30781 的邮件 `option` 格式化器;#30760 的组织自建分派规则),技能和文档还没教,照技能写通知的 agent 会去 `templateData` 里写 `label()`,要么保存被拒,要么所有收件人同一种语言;读文档的作者仍以为分派规则只有声明式、`user` 目标永远拥有记录。 - 风险与回滚:只改技能文本、eval 夹具、文档页与棘轮上限行(6449 → 6448,按实测锁定),不碰运行时;回滚 = revert 本 PR。为付账删掉的三处(见下文 Deletions)各有别处承载。 - 先看:① `skills/objectstack-automation/SKILL.md` 的 `notify` 节新段与「Assignment rules」改行(措辞是否与 `main` 上的渲染行为一致);② `content/docs/automation/queues-and-assignment-rules.mdx` 新增三段(与 `packages/objectql/src/assignment-rules.ts` 头注释逐条对照);③ 删掉的「CRM Automation Blueprint」节。 - 两分钟验证:`node scripts/check-skills-token-ratchet.mjs | grep objectstack-automation/SKILL.md` 预期 `6448 tokens (ceiling 6448; headroom 0)`;`pnpm check:skill-identifier-liveness` 预期退出 0。 - 需要你做的:Tier H(`skills/**`)——席位契约复审通过后,由 os-zhuang 批准/入队。 ## What changes **#30731 — `skills/objectstack-automation/SKILL.md`, `notify` section.** One paragraph after the `notify` config block: an option label in a notice is `{{ stage | option:opportunity.stage }}` in the mail `template`, printed in each recipient's language; `option` is mail-only and refused in an inline `title` / `message`; never `label()` in `templateData`, which is evaluated once per run, before fan-out. Read from `main`: `packages/formula/src/template-engine.ts` (`MAIL_ONLY_FORMATTERS = ['option']`, `optionFieldReference`: bare `object.field`), `packages/plugins/plugin-email/src/email-service.ts` (the label table is built per send in the recipient's locale), `packages/services/service-automation/src/builtin/notify-node.ts` (`templateData` is a value-slot map resolved once per run; the `template` locale is resolved per recipient after fan-out), `packages/formula/src/stdlib.ts` (`label()` is the formula-field macro). **#30731 — `skills/objectstack-automation/evals/flows-triggers-approvals.json`.** Eval 15: a stalled-opportunity e-mail whose subject shows the stage label in each recipient's language. `must_contain`: `option:opportunity.stage`, `template`, `once per run`; `must_not_contain`: `source: 'label(` and `| label` (the two wrong spellings a right answer never writes). Appended by splicing before the closing bracket, so no other case is reformatted. **#30826 — `skills/objectstack-automation/SKILL.md`, "Assignment rules".** The `assignTo: { type: 'user', value: 'ID' }` sentence now reads "names one owner, who holds the row only while an active member of its organization (else ownerless)". Read from `packages/objectql/src/assignment-rules.ts` (`organizationMemberStanding`: a `sys_member` row and no ban in force; the engine asks it of every user target at insert, declared rules included) and `packages/plugins/plugin-sharing/src/assignment-rule-hooks.ts` (the save guard asks the same predicate). **#30826 — `content/docs/automation/queues-and-assignment-rules.mdx`, "2. Declare an assignment rule".** Three paragraphs after "The rule writes `owner_id` …": the active-member rule for a `user` target; the organization's own `sys_assignment_rule` records (Setup → Assignment Rules, `manage_platform_settings`; one rule with one entry; `active`), one namespace by name with the declared rules (mirroring the page's `sys_queue` paragraph), evaluated **first** by `sort_order` then `name` with the declared `assignmentRules` as the fallback (the activation ledger has no organization column), first match wins, a failed read of the organization's rules routes nothing; and what the save guard refuses. One checklist bullet. Pages checked for existing coverage before writing (maintainer rule, no duplicate sections): `content/docs/automation/queues-and-assignment-rules.mdx` (declared rules and `sys_queue` only), `content/docs/automation/index.mdx` (one summary line, declared rules), `content/docs/automation/approvals.mdx` (the `queue` approver), `content/docs/permissions/sharing-rules.mdx` and `content/docs/permissions/rls.mdx` (queue recipients and `current_user.queues`), `content/docs/releases/v18/18-3.mdx` (release-owned); `content/docs/references/api/metadata.mdx` and `references/kernel/metadata-plugin.mdx` list the `assignment_rule` metadata type only. No page mentions `sys_assignment_rule`; the generated references carry no page for the object. **Changeset** `.changeset/30731-automation-skill-notify-label-and-org-assignment-rules.md`: `'@objectstack/skills': patch`, naming both cards. **Ratchet** `scripts/check-skills-token-ratchet.mjs`: the SKILL.md row moves DOWN 6449 → 6448, re-locked at the measurement (headroom 0). ### Readings (`skills/**` diff) | Reading | before (`origin/main` 677d542) | after (this head) | |:--|:--|:--| | `skills/objectstack-automation/SKILL.md` lines | 452 | 448 | | `skills/objectstack-automation/SKILL.md` tokens (`ceil((bytes − 20) / 4)`) | 6447 | 6448 | | whole package, all 11 `skills/*/SKILL.md`, lines | 4523 | 4519 | | whole package, all 11 `skills/*/SKILL.md`, tokens | 56716 | 56717 | | `evals/flows-triggers-approvals.json` tokens (measured, not ratcheted) | 3587 | 3883 | ### Deletions that pay for the two additions (same file) 1. **The `## CRM Automation Blueprint` section** (three lines + rule): the `src/flows/*.flow.ts` → `src/flows/index.ts` → `defineStack({ flows })` layout is carried by `skills/objectstack-platform/references/bootstrap.md` ("Use barrel exports", `import * as flows from './src/flows'` into `defineStack({ flows: Object.values(flows) })`), and the skill's frontmatter already names `*.flow.ts` as the trigger to load it. 2. **"When to Use This Skill"**: the clause "that route a new record to a queue or a person" restated the section heading `## Assignment rules — route a new record to a queue or a person`; "scheduled automation" restated "Flows (… scheduled)" three words earlier. 3. **"Verify your work"**: the line "In a scaffolded project: `npm run validate`." is carried by objectstack-formula's Verify section ("`npm run validate` in a scaffolded project"), which this skill names for CEL validation at its top. Each surviving sentence of the trimmed paragraphs was re-read against `main`; nothing deleted has no other home. No line was re-wrapped to win room. ### Neighbour overlap Seat 1's draft PR #30831 (#30805) edits `skills/objectstack-automation/SKILL.md` at lines 295 and 306–320 (the assignment-rule example's names and the `approvers` line) and eval cases 3, 7, 9, 11, 12 of `flows-triggers-approvals.json`. This PR's "Assignment rules" hunk changes the two lines directly above its `approvers` line, so whichever lands second resolves one adjacent-line conflict there; the `notify` paragraph, the two deletions and eval 15 (appended at the file's end) are outside its hunks. `origin/main` was merged and the ratchet re-measured before each push. ## Tests No package code changes. Local checks at this head: - `node scripts/check-skills-token-ratchet.mjs` → `skills/objectstack-automation/SKILL.md is 6448 tokens (ceiling 6448; headroom 0)`; `--self-test` exit 0. - Eval fixture parses (`json.load`, 15 cases, ids 1–15). - The derived gate list (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, 69 lines) — see Verification. ## Acceptance notes - The skill line keeps the `option` argument spelling to the bare `object.field` form `main` accepts; the quoted, marked-path and one-sided forms are refused by `optionFieldReference` and not taught. - `label()` stays taught where it belongs (objectstack-formula, a formula field's display in the reader's locale); the automation skill now only says where it is wrong. - Out of scope, noted and not filed (class: none of a/b/c — read-only observation): the docs page's `## See also` points at the generated `assignment-rule` schema reference; no generated page exists for the `sys_assignment_rule` object, so the new prose names its columns inline. - First commit's author is spelled as the fleet bot (a local `-c user.name` override); later commits use the container's default `Claude` identity. The squash on landing rewrites it either way. ## Verification Derived gates at head 61f2a99 (base `677d542a`): 87/87 families exit 0 — 69 derived first; the ratchet-row edit added 18 more; three (`check:skill-examples`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`) first exited 3 on an unbuilt tree and exit 0 after `pnpm build --filter='./packages/**'` (80/80 tasks, 7m58s under the verify lock). `origin/main` (five commits, none touching this PR's files) was then merged as 18600c3 and the skill-text families re-run there: `check-skills-token-ratchet` (6448 / 6448, headroom 0), `check:skill-identifier-liveness`, `check:pm-skill-id-lint`, `check:doc-authoring`, `check:role-word`, spec `check:skill-docs`, `check-empty-changeset`, `check:docs-single-h1` — all exit 0. The per-command exit lists are `/mnt/project-files/skills-seat-2/r2026-11-06/out/30731-gates-ran-61f2a99e.list` and `…-18600c34-merged.list`, reconciled with `dispatch-gates.mjs --repo objectstack-ai/objectstack --ran`. Relay writes: `pr_create` (this PR); `post-stamped.mjs` for the os-dev-report on #30731. MCP GitHub calls: reads only (`issue_read` ×6, `pull_request_read` ×1 for #30831's file list); 0 MCP writes. The harness's assignee/reviewer step is skipped (no MCP writes; the seat sets the assignee at hand-over). --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibxujehvWJcGRXMYv7f)_ --------- Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0b15730 commit dfa179a

5 files changed

Lines changed: 55 additions & 16 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 automation skill teaches the mail `option` formatter for a notice's select label, and that a user target owns a row only while an active member
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Two lines in `objectstack-automation/SKILL.md`, each following a platform change that landed before it. In the `notify` section: an option label in a notice is written in the mail `template` as `{{ stage | option:opportunity.stage }}`, which prints the label in each recipient's language and is mail-only (refused in an inline `title` / `message`) — never `label()` in `templateData`, which is evaluated once per run, before recipients fan out. In "Assignment rules": `assignTo: { type: 'user', value: '<id>' }` names an owner who holds the row only while an active member of its organization, else the row is written ownerless. Eval 15 of `evals/flows-triggers-approvals.json` grades the notice case. The docs page `automation/queues-and-assignment-rules` gains the organization-authored `sys_assignment_rule` rules beside the declared ones: evaluated first, by `sort_order` then name, first match wins, a failed read of the organization's rules routes nothing, and the active-member rule for a `user` target. Paid inside the skill by deleting the CRM Automation Blueprint section (the `src/flows` layout objectstack-platform's bootstrap reference carries) and two restatements.

‎content/docs/automation/queues-and-assignment-rules.mdx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ export default defineStack({
9393

9494
The rule writes `owner_id` (when it chose a member) and `queue` (for a queue target), nothing else. The row's audit columns still record who inserted it; the owner the rule chose is ownership, not attribution.
9595

96+
**A `user` target owns the row only while an active member.** `assignTo: { type: 'user', value: '<id>' }` is honoured when that user has a `sys_member` row in the record's organization and no ban in force — asked again at every insert, not only when the rule was written. A target who has left or is banned when a row arrives leaves it ownerless (`queue` unset), logged once at `warn`; the insert is never refused. Point the rule at a current member, or at a queue.
97+
98+
An organization authors its own assignment rules too. A `sys_assignment_rule` record (Setup → Assignment Rules, for holders of `manage_platform_settings`) is one rule with one entry: `object_name`, an optional `criteria`, `recipient_type` (`queue` or `user`) with `recipient_id`, and for a queue target an optional `strategy` with its `load`; `active: false` switches it off without deleting it. Like `sys_queue`, it shares **one namespace, by name** with the declared rules: a record may not take a declared rule's name, and one found under that name is not evaluated — the declared rule answers for it, logged once. Unlike a `sys_queue` record, which resolves after the declared queues, **the organization's rules are evaluated first** — ascending by `sort_order`, then by `name` — and the stack's `assignmentRules`, in registry order, are the fallback, so an organization routes ahead of an installed app's rule, which it cannot switch off for itself alone (the activation ledger has no organization column). The first match across both sources wins. When the organization's rules cannot be read, its new rows are written as they stood and routed by **no** rule — not by the declared fallback its own rules would have overtaken — logged at `warn`.
99+
100+
The record is validated on save, one `VALIDATION_FAILED` naming every field at fault: a `name` that is not snake_case or that a declared rule holds; an `object_name` that is not registered or carries no `queue` column; the shape a declared entry refuses (a `strategy` on a user target, `least_loaded` without `load`); a `criteria` or `load` the engine cannot compile; a `user` target who is not an active member of the organization. A later patch of only the label, description, order or active flag is not re-judged, so a rule whose target has lapsed can still be switched off, re-ordered or relabelled; a rename, like every other judged column, is judged again.
101+
96102
### 3. `load` is required with `least_loaded`
97103

98104
`least_loaded` counts each member's **open** rows of the object, and only you know what "open" means for it — `status != "closed"` for cases, `is_converted == false` for leads. So `load` is **required** with `least_loaded` and has no default: a default such as "every row" would count closed work and quietly make the busiest historical owner look the most loaded. `load` on any other strategy is refused, because nothing reads it.
@@ -153,6 +159,7 @@ A queue routes **records** to people. It is not an address for **messages**: a f
153159
- The object is `ownership: 'user'`, and declares no field of its own named `queue`.
154160
- The stack loads `@objectstack/plugin-sharing`; otherwise `queues` / `assignmentRules` refuse the boot.
155161
- A declared queue's members are positions; organizations add users, teams and units through `sys_queue_member`.
162+
- An organization's own `sys_assignment_rule` records are tried before the stack's rules, lowest `sort_order` first; a `user` target is an active member of the organization.
156163
- Every `least_loaded` entry has a `load` your filter compiler can lower; no other entry has one.
157164
- Code that claims a record sends `{ owner_id: <self> }` by id and handles `409` as "someone else took it".
158165
- Alerts about new work address a team or a position, not the queue.

‎scripts/check-skills-token-ratchet.mjs‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,18 @@ export const CEILINGS = new Map([
490490
// request signing, which the built-in providers serve (recipe 6 and the
491491
// `signed` kind in references/integration-recipes.md). Re-locked AT the
492492
// measurement, headroom 0 tokens.
493-
['skills/objectstack-automation/SKILL.md', 6449],
493+
// 6449 -> 6448 (cards #30731 + #30826): the notify section's option-label
494+
// line and the user-target active-member clause, paid by deleting the CRM
495+
// Automation Blueprint section (the `src/flows` layout lives in
496+
// objectstack-platform's bootstrap reference), the "When to Use" restatement
497+
// of the assignment-rules heading and the scaffolded-project validate line
498+
// (objectstack-formula's Verify section). Re-locked AT the measurement,
499+
// headroom 0 tokens — 6448 at first, then 6444 once the bare-name phase-2
500+
// rename (crm_ prefixes, "The third automation family" cut) merged in, then
501+
// 6426 after the click-through default's three tokens landed on main and
502+
// the "A Flow is a directed graph of nodes" opener was cut (the node tables
503+
// and the `nodes` + `edges` FlowSchema line already say it).
504+
['skills/objectstack-automation/SKILL.md', 6426],
494505
// 13783 -> 13892 (2026-08-31 app-repo-principles raise, see the block above).
495506
// The invariant-vs-transition-gate choice, placed in the field-conditional-rule
496507
// bullet list where the tool is actually picked. Both wrong picks are silent:

‎skills/objectstack-automation/SKILL.md‎

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ metadata:
2323
## When to Use This Skill
2424

2525
Flows (auto-launched, screen, scheduled), state machines and approvals,
26-
record-change triggers, scheduled automation, vendor integrations and their
27-
callbacks, and **assignment rules** that route a new record to a queue or a person.
26+
record-change triggers, vendor integrations and their callbacks, and
27+
**assignment rules**.
2828

2929
> **Predicates and conditions are CEL** — every `condition` / `guard` /
3030
> `entryCondition` / filter `value` is an **Expression** envelope: a plain CEL
@@ -35,8 +35,6 @@ callbacks, and **assignment rules** that route a new record to a queue or a pers
3535

3636
## Flows — Visual Logic Orchestration
3737

38-
A **Flow** is a directed graph of nodes — the primary automation building block.
39-
4038
### Flow Types
4139

4240
| Type | When to Use |
@@ -127,6 +125,11 @@ error. **RAW** keys are read as written: a
127125
} }
128126
```
129127

128+
An option label in a notice: `{{ stage | option:opportunity.stage }}` in the mail
129+
`template` (each recipient's language; mail-only, refused in inline `title` /
130+
`message`) — never `label()` in `templateData`, evaluated once per run, before
131+
fan-out.
132+
130133
### Flow Variables
131134

132135
`variables` is an **array** of `{ name, type, isInput, isOutput }` entries —
@@ -315,7 +318,8 @@ export const caseIntake = defineAssignmentRule({
315318
```
316319

317320
`strategy` is for a queue target only; `assignTo: { type: 'user', value: '<id>' }`
318-
names one owner. Queues are declared in `queues` (objectstack-data); an approval
321+
names one owner, who holds the row only while an active member of its organization
322+
(else ownerless). Queues are declared in `queues` (objectstack-data); an approval
319323
for the same people is `approvers: [{ type: 'queue', value: 'crm_support_intake' }]`
320324
([Approver Types](./references/state-machines-and-approvals.md#approver-types)).
321325
⛔ Never a `beforeInsert` hook that picks an owner, nor a round-robin counter field.
@@ -421,14 +425,6 @@ reason to write code.
421425

422426
---
423427

424-
## CRM Automation Blueprint
425-
426-
Lifecycle first, as Flow / Approval metadata: `src/flows/*.flow.ts` with explicit
427-
`variables`, `nodes` + `edges`, exported from `src/flows/index.ts` into
428-
`defineStack({ flows })`.
429-
430-
---
431-
432428
## Verify your work
433429

434430
A bare ref (`status == 'open'`) resolves (fields are flattened into scope), so
@@ -440,8 +436,6 @@ os validate # CEL/predicate validation + schema
440436
# or: os build # the same gates, plus emits dist/
441437
```
442438

443-
In a scaffolded project: `npm run validate`.
444-
445439
---
446440

447441
## References

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,23 @@
260260
"actionUrl:"
261261
]
262262
}
263+
},
264+
{
265+
"id": 15,
266+
"prompt": "Email the opportunity owner and the sales manager when an opportunity has sat in one stage for 30 days; the subject must show the `stage` as its label, in each recipient's own language.",
267+
"expected_output": "The notify node names a mail `template` (never inline `title` / `message`, which are one text for every recipient), and the template's subject prints the stage with the mail formatter `{{ stage | option:opportunity.stage }}` — the argument a bare `object.field`, no quotes — so the renderer prints that recipient's label from the field's option list in the recipient's locale. Not `label()` in `templateData`: the map is evaluated once per run, before recipients fan out, so it cannot differ per recipient; and `option` is mail-only, refused in an inline `title` / `message`. The stage value itself reaches the template as stored (`stage` in `templateData`).",
268+
"files": [],
269+
"assertions": {
270+
"must_contain": [
271+
"option:opportunity.stage",
272+
"template",
273+
"once per run"
274+
],
275+
"must_not_contain": [
276+
"source: 'label(",
277+
"| label"
278+
]
279+
}
263280
}
264281
]
265282
}

0 commit comments

Comments
 (0)