Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/skills': patch
---

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

Clause-②: no
Metadata-protocol: none

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.
7 changes: 7 additions & 0 deletions content/docs/automation/queues-and-assignment-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ export default defineStack({

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.

**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.

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`.

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.

### 3. `load` is required with `least_loaded`

`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.
Expand Down Expand Up @@ -153,6 +159,7 @@ A queue routes **records** to people. It is not an address for **messages**: a f
- The object is `ownership: 'user'`, and declares no field of its own named `queue`.
- The stack loads `@objectstack/plugin-sharing`; otherwise `queues` / `assignmentRules` refuse the boot.
- A declared queue's members are positions; organizations add users, teams and units through `sys_queue_member`.
- 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.
- Every `least_loaded` entry has a `load` your filter compiler can lower; no other entry has one.
- Code that claims a record sends `{ owner_id: <self> }` by id and handles `409` as "someone else took it".
- Alerts about new work address a team or a position, not the queue.
Expand Down
13 changes: 12 additions & 1 deletion scripts/check-skills-token-ratchet.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,18 @@ export const CEILINGS = new Map([
// request signing, which the built-in providers serve (recipe 6 and the
// `signed` kind in references/integration-recipes.md). Re-locked AT the
// measurement, headroom 0 tokens.
['skills/objectstack-automation/SKILL.md', 6449],
// 6449 -> 6448 (cards #30731 + #30826): the notify section's option-label
// line and the user-target active-member clause, paid by deleting the CRM
// Automation Blueprint section (the `src/flows` layout lives in
// objectstack-platform's bootstrap reference), the "When to Use" restatement
// of the assignment-rules heading and the scaffolded-project validate line
// (objectstack-formula's Verify section). Re-locked AT the measurement,
// headroom 0 tokens — 6448 at first, then 6444 once the bare-name phase-2
// rename (crm_ prefixes, "The third automation family" cut) merged in, then
// 6426 after the click-through default's three tokens landed on main and
// the "A Flow is a directed graph of nodes" opener was cut (the node tables
// and the `nodes` + `edges` FlowSchema line already say it).
['skills/objectstack-automation/SKILL.md', 6426],
// 13783 -> 13892 (2026-08-31 app-repo-principles raise, see the block above).
// The invariant-vs-transition-gate choice, placed in the field-conditional-rule
// bullet list where the tool is actually picked. Both wrong picks are silent:
Expand Down
24 changes: 9 additions & 15 deletions skills/objectstack-automation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ metadata:
## When to Use This Skill

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

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

## Flows — Visual Logic Orchestration

A **Flow** is a directed graph of nodes — the primary automation building block.

### Flow Types

| Type | When to Use |
Expand Down Expand Up @@ -127,6 +125,11 @@ error. **RAW** keys are read as written: a
} }
```

An option label in a notice: `{{ stage | option:opportunity.stage }}` in the mail
`template` (each recipient's language; mail-only, refused in inline `title` /
`message`) — never `label()` in `templateData`, evaluated once per run, before
fan-out.

### Flow Variables

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

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

---

## CRM Automation Blueprint

Lifecycle first, as Flow / Approval metadata: `src/flows/*.flow.ts` with explicit
`variables`, `nodes` + `edges`, exported from `src/flows/index.ts` into
`defineStack({ flows })`.

---

## Verify your work

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

In a scaffolded project: `npm run validate`.

---

## References
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,23 @@
"actionUrl:"
]
}
},
{
"id": 15,
"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.",
"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`).",
"files": [],
"assertions": {
"must_contain": [
"option:opportunity.stage",
"template",
"once per run"
],
"must_not_contain": [
"source: 'label(",
"| label"
]
}
}
]
}
Loading