From 61f2a99e6f75952da73a5efb42f7211a826eb96f Mon Sep 17 00:00:00 2001 From: "objectstack-fleet[bot]" <332303061+objectstack-fleet[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:47:03 +0000 Subject: [PATCH 1/2] docs(skills): teach the mail option formatter for a notice's select label and the active-member rule for a user target The automation skill's notify section says an option label in a notice is written in the mail template as the option formatter, which prints each recipient's language, never label() in templateData (evaluated once per run, before fan-out); its assignment-rules section says a user target holds the row only while an active member of its organization. Eval 15 grades the notice case. The queues-and-assignment-rules docs page 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 routes nothing, and the active-member rule. Paid inside SKILL.md by deleting the CRM Automation Blueprint section and two restatements; the token ceiling re-locked at the measurement. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxujehvWJcGRXMYv7f --- ...l-notify-label-and-org-assignment-rules.md | 10 +++++++++ .../queues-and-assignment-rules.mdx | 7 ++++++ scripts/check-skills-token-ratchet.mjs | 9 +++++++- skills/objectstack-automation/SKILL.md | 22 ++++++++----------- .../evals/flows-triggers-approvals.json | 17 ++++++++++++++ 5 files changed, 51 insertions(+), 14 deletions(-) create mode 100644 .changeset/30731-automation-skill-notify-label-and-org-assignment-rules.md diff --git a/.changeset/30731-automation-skill-notify-label-and-org-assignment-rules.md b/.changeset/30731-automation-skill-notify-label-and-org-assignment-rules.md new file mode 100644 index 00000000000..a0ea8de65d5 --- /dev/null +++ b/.changeset/30731-automation-skill-notify-label-and-org-assignment-rules.md @@ -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: '' }` 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. diff --git a/content/docs/automation/queues-and-assignment-rules.mdx b/content/docs/automation/queues-and-assignment-rules.mdx index e7ee9d935f9..ec98459342d 100644 --- a/content/docs/automation/queues-and-assignment-rules.mdx +++ b/content/docs/automation/queues-and-assignment-rules.mdx @@ -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: '' }` 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 or renamed. + ### 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. @@ -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: }` by id and handles `409` as "someone else took it". - Alerts about new work address a team or a position, not the queue. diff --git a/scripts/check-skills-token-ratchet.mjs b/scripts/check-skills-token-ratchet.mjs index 2d2a9b19146..2c4b5ca874a 100644 --- a/scripts/check-skills-token-ratchet.mjs +++ b/scripts/check-skills-token-ratchet.mjs @@ -490,7 +490,14 @@ 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. + ['skills/objectstack-automation/SKILL.md', 6448], // 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: diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index d1729444fc6..fd4cc0680e9 100644 --- a/skills/objectstack-automation/SKILL.md +++ b/skills/objectstack-automation/SKILL.md @@ -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 @@ -127,6 +127,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 — @@ -315,7 +320,8 @@ export const caseIntake = defineAssignmentRule({ ``` `strategy` is for a queue target only; `assignTo: { type: 'user', value: '' }` -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: '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. @@ -421,14 +427,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 @@ -440,8 +438,6 @@ os validate # CEL/predicate validation + schema # or: os build # the same gates, plus emits dist/ ``` -In a scaffolded project: `npm run validate`. - --- ## References diff --git a/skills/objectstack-automation/evals/flows-triggers-approvals.json b/skills/objectstack-automation/evals/flows-triggers-approvals.json index 714548fb312..952255af9b8 100644 --- a/skills/objectstack-automation/evals/flows-triggers-approvals.json +++ b/skills/objectstack-automation/evals/flows-triggers-approvals.json @@ -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" + ] + } } ] } From c864cad6fabd2dbdd43b6960dd4a72dbb9598a1a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 07:29:50 +0000 Subject: [PATCH 2/2] docs(automation): a lapsed-target assignment rule can be switched off, re-ordered or relabelled, not renamed The save guard re-judges a patch that touches name (one of the judged columns), so only the label, description, order and active flag escape the re-judgement. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxujehvWJcGRXMYv7f --- content/docs/automation/queues-and-assignment-rules.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/automation/queues-and-assignment-rules.mdx b/content/docs/automation/queues-and-assignment-rules.mdx index ec98459342d..3342548bd2b 100644 --- a/content/docs/automation/queues-and-assignment-rules.mdx +++ b/content/docs/automation/queues-and-assignment-rules.mdx @@ -97,7 +97,7 @@ The rule writes `owner_id` (when it chose a member) and `queue` (for a queue tar 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 or renamed. +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`