Skip to content

Commit ac890f5

Browse files
feat(objectql,plugin-sharing): an organization authors its own assignment rules as sys_assignment_rule records (#30760)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D56TXAmXSP2pWYUJuvZgpnd)_ Fixes #30066 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: follow-up owed — content/docs/automation/queues-and-assignment-rules.mdx (an organization's own rules, their place before the declared ones, and the active-member rule for a user target), skills/objectstack-automation/SKILL.md (the `assignTo: { type: 'user' }` line: a user target owns a row only while the user is an active member) ## 维护者速读 - **改了什么**:组织可以在 Setup 里自己建分派规则(新对象 `sys_assignment_rule`,一行一条规则);引擎先评本组织的规则,再用应用包声明的规则兜底,第一条命中的说了算。指定给某个人的规则,只有那人仍是本组织的活跃成员时才生效。 - **为什么改**:以前只有应用包能声明分派规则,客户想换个路由就得等发版;这是裁决 A 的服务端一半,Setup 页面另起 domain:ui 卡。 - **风险与回滚**:每次插入一个带队列列的对象,多一次按组织读 `sys_assignment_rule`(表不存在时不读);包规则里写死用户 id 的,若该用户不是记录所在组织的成员,从此不再分派(记一次 warn,插入照常)。回滚即 revert 本 PR,表里已存的行不会被引擎读到。 - **先看**:`packages/objectql/src/assignment-rules.ts`(`apply()` 里组织规则在前、读失败时不走兜底;`routeToUser` 的成员校验);`packages/plugins/plugin-sharing/src/assignment-rule-hooks.ts`(保存时的全部拒收);`packages/plugins/plugin-sharing/src/objects/sys-assignment-rule.object.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/ownership-queue.test.ts`,预期全绿,其中「an organization's own rules go first」一组 9 条都通过。 - **需要你做的**:无,席位落地。 ## What changes **`@objectstack/plugin-sharing`: a new object, `sys_assignment_rule`** (`src/objects/sys-assignment-rule.object.ts`), registered in the plugin manifest, with a Setup nav entry `nav_assignment_rules` beside Queues that opens the generic record list (the dedicated Setup page is the domain:ui card). One row is one rule with one entry: `object_name`, `criteria` (CEL over `record`, empty = every row), `sort_order`, `recipient_type` (`queue` or `user`) + `recipient_id` (the existing `recipient-picker` widget, which reads a sibling literally named `recipient_type`), an optional `strategy` (`round_robin` / `least_loaded`) and `load`, and `active`. `organization_id` is required; the name is unique per organization. **The save guard** (`src/assignment-rule-hooks.ts`, bound on `kernel:ready` beside the queue guards). `beforeInsert` / `beforeUpdate` judge the merged row and refuse with one `VALIDATION_FAILED` naming every field at fault: - a name that is not snake_case, or that a declared rule holds (ruling item 4: one namespace, ADR-0131 D4); - an object that does not exist, or whose records carry no ownership `queue` column; - whatever `AssignmentRuleSchema` refuses for a declared entry of the same shape (a strategy on a user target, `least_loaded` without `load`, `load` without a strategy, a malformed queue name, an unknown recipient type); - a `criteria` or `load` the engine cannot compile, judged by the same CEL paths the seam runs (`ExpressionEngine.compile`; `compileCelToFilter`) — ruling item 5; - a user target who is not an active member of the rule's organization (`sys_member` row and no ban in force) — ruling item 2; - a row with no organization. An update that touches none of the judged columns (a label, `active`, `sort_order`) is not re-judged. A membership read that fails refuses the save rather than storing an unjudged target. A boot report logs, at `error`, any stored row whose name a later-installed package now declares. **`@objectstack/objectql`: the seam reads both sources** (`src/assignment-rules.ts`). For an insert of an object that carries the queue column, the evaluator reads the organization's `sys_assignment_rule` rows once per organization in the batch, orders them by `sort_order` then name, and puts them before the declared rules (ruling item 1: first match; the activation ledger has no organization column, so the other order would let a package rule always win). Then: - a row under a declared rule's name is not evaluated (the declared rule answers for the name) and warns once; - an inactive row is skipped; a row that does not read as a rule warns once and the next rule decides; - when the organization's rules cannot be read, its rows are written as they stood — never routed by the fallback its own rules would have overtaken; - a user target, from either source, owns the row only while `organizationMemberStanding` answers `member`; otherwise the row is written ownerless and the lapse warns once per rule, user and organization (ruling item 2). With no `sys_member` table registered, no user can be confirmed and none is assigned; - a lapsed queue keeps its ADR-0142 D5 behaviour unchanged (ruling item 3); - the round-robin cursor is keyed by (rule, its one entry named after it, organization), so an organization rule's rotation is its own. The engine's call site (`src/engine.ts`) asks `evaluator.mayRoute(object)` instead of `rulesFor(object).length > 0`, so an object with no declared rule is still routed by an organization's own. New exports for the guard: `ASSIGNMENT_RULE_OBJECT`, `assignmentExpressionRefusals`, `organizationMemberStanding` (+ type `OrganizationMemberStanding`). **`@objectstack/spec`** (domain:spec#3 consented, mailbox comment 6672529419): `PLATFORM_PROVIDED_OBJECT_NAMES` gains `sys_assignment_rule` under `plugin-sharing`, between `sys_assignment_cursor` and `sys_queue`, in the same commit as the census and inventory rows; the `[ADR-0142 D5]` comment on the `assignment_rule` registry entry drops "declared only: organization-authored routing is deferred" (ruling item 6), with the entry's `description` and flags untouched; the `AssignmentRuleSchema` docblock states how the two sources combine, in the order the engine evaluates them. No key, shape or `.describe()` text changes, and `gen:schema` / `gen:docs` regenerate with no diff (the docblock is not rendered into a generated file). **Ledgers.** The federated-injected-column-readers ledger records the two new `organization_id` keyings (both platform tables, not reads of a federated column); the cli organization-ownership inventory lists `sys_assignment_rule` (fate `attribution`); `scripts/platform-object-tenancy-census.json` is regenerated. ## Tests - `packages/objectql/src/ownership-queue.test.ts`, three new top-level describes: "a user target owns the row only while the user is an active member" (5), "an organization's own rules go first, the declared rules are the fallback" (9), "assignmentExpressionRefusals" (3). The D5 boot now registers `sys_member` with a membership for the existing user-target case. - `packages/plugins/plugin-sharing/src/ownership-queue.test.ts`, new describe "the sys_assignment_rule save guard": every refusal above by field, merged-row judging on update, a failed membership read refusing the save, re-binding not stacking hooks, and the boot report. - `packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.by-object.test.ts`: the by-object roster gains `sys_assignment_rule`. **Ablations** (each through `scripts/ablation-replace.mjs`, mutation proven on disk by anchor count and blob hash, restore proven by blob == HEAD and an empty `git diff HEAD`; the suites import the subject from `src`, so no rebuild sits between): | Mutation | Suite | Result | |:--|:--|:--| | `apply()`: `[...own, ...fallback]` → `[...fallback, ...own]` | objectql `src/ownership-queue.test.ts` | 3 failed / 38 passed: "an organization rule wins…", "several organization rules are tried by sort_order…", "a user target of an organization rule…" | | `routeToUser`: `answer === 'member'` → `answer !== 'unconfirmable'` | objectql `src/ownership-queue.test.ts` | 3 failed / 38 passed: "a user who is not a member…", "a banned member owns nothing…", "a user target of an organization rule…" | | guard: the declared-name branch short-circuited (`&& false`) | plugin-sharing `src/ownership-queue.test.ts` | 2 failed / 65 passed: "refuses a declared rule's name…", "names every field at fault in one refusal" | All three restored, then green. ## Acceptance notes - **A fourth `packages/spec` hunk, consented by `domain:spec#3` (6674042568, same terms as 6672529419).** `pnpm --filter @objectstack/spec run check:empty-state` was red on `ab98c3b8`: it reports `sys_assignment_rule.criteria` as an unclassified permissive empty state, because its description tells the admin that an empty condition takes every new record no earlier rule took. That is the real behaviour, so the description stays and the empty state is declared instead: one `scope` entry in `packages/spec/scripts/liveness/empty-state-registry.mts`, right after the declared rule's `criteria` entry, which stays byte-identical. With it the gate prints "all classified". No changeset line is owed for it (`packages/spec/scripts/**` does not publish). The entry, as committed: ```ts { file: 'packages/plugins/plugin-sharing/src/objects/sys-assignment-rule.object.ts', property: 'criteria', semantics: 'scope', rationale: "[ADR-0142 D5, ruling A on #30066] An organization's own assignment rule (`sys_assignment_rule`): which inserted rows it routes; empty = every row no earlier rule took, the catch-all at the end of the organization's ordered rules. The declared entry's contract above, row for row: it selects work to route and grants nothing by itself — the author names the target, assignment runs only for a row that reaches the engine with no owner and no queue, a user target must be an active member of the organization, and a parked row is readable only by members of the queue named. A missing condition mis-routes among the organization's own targets, never widens a grant.", evidence: 'packages/objectql/src/assignment-rules.ts (authoredRules + assignOne: the organization rules go first, the first matching rule decides); packages/plugins/plugin-sharing/src/assignment-rule-hooks.ts (the save guard)' } ``` - **The roster line is load-bearing.** Without it, `packages/spec/src/system/constants/platform-object-names.test.ts > registers exactly the objects each package declares` fails on `sys_assignment_rule` (measured: 1 failed / 13 passed without the line, 14/14 with it). - **Skills-docs:** no `skills/**` line calls organization rules deferred or assignment rules declared-only (grep for `deferred` / `declared only` over `skills/**`: no assignment hit). The owed follow-up is teaching the new behaviour: the docs page's "Declare an assignment rule" section and the skill's `assignTo: { type: 'user' }` line. - **Translations are held.** Labels for the new object and nav entry in all 11 platform languages follow once #30512's sharing PR merges, by a rebase onto it. - **Two checks beyond the ruling, added as mechanism, for the seat's review:** the save guard also refuses an object that does not exist or has no queue column (the seam could never route it), and the boot report logs at `error` a stored row a later package's rule now shadows. - **Cost:** an insert of an object with the queue column now reads `sys_assignment_rule` once per organization in the batch while the table is registered. Without the table, or for an object without the column, nothing is read (pinned: "the cheap path"). - **Behaviour change for declared rules:** a package rule whose entry names a user id now assigns only where that user is a member of the row's organization (the `assignment-rule-user-target` lint already warns against it). The changeset's Upgrading paragraph says so. - **Overlap with open PRs:** #30728 also edits `sharing-plugin.ts` (+4) and the plugin-sharing translations; #30716 moves code out of `engine.ts` (registerApp / registerDriver / registerHook), not the insert body this PR's one-line call-site change sits in. - **Commit split, accepted by the seat:** spec#3 asked for the roster line to land in the same commit as the object, so the line can never land ahead of it. The object is in `4b6296e8`, already pushed, so the line sits in a later commit of this PR (`d86cdc3e`, with the census and inventory rows) rather than rewriting published history. Both merge together in this PR, which is what the condition protects. - **Placement, accepted by the seat:** the object lives in `plugin-sharing` (beside `sys_queue` and `sys_sharing_rule`, whose model it follows) rather than `platform-objects`, and the call site changed is `engine.ts`; `engine-insert.ts` is untouched. - The ADR-0142 D5 rewording is Tier H and comes as its own docs/adr PR. ## Verification Readings at head `ab98c3b8` unless a line names another. `ab98c3b8` differs from `796614dc` only by moving one sentence of `sys_assignment_rule.criteria`'s description onto the `description:` line (so the empty-state gate attributes it to `criteria`, not `required`). <details><summary>Package tests and typecheck</summary> - `pnpm --filter @objectstack/objectql test` — Test Files 556 passed (556), Tests 11086 passed (11086); lock VERDICT command-exit 0. - `pnpm --filter @objectstack/plugin-sharing test` — Test Files 66 passed (66), Tests 1421 passed (1421); lock VERDICT command-exit 0. - `pnpm --filter @objectstack/cli exec vitest run --project unit src/utils/organization-ownership-inventory.test.ts` — 26 passed (26). The cli `integration` tier is CI's; this diff touches no spawn entry. - `typecheck` on objectql, plugin-sharing and cli (each `tsc --noEmit` plus `check:test-typecheck`) — lock VERDICT command-exit 0. - `pnpm --filter @objectstack/spec test` — Test Files 817 passed (817), Tests 25792 passed, 1 todo; lock VERDICT command-exit 0 (at `796614dc`). - `pnpm --filter @objectstack/spec exec vitest run --project repo src/system/constants/platform-object-names.test.ts` — 14 passed (14); without the roster line, 1 failed / 13 passed. - `pnpm --filter @objectstack/plugin-sharing exec vitest run src/objects src/ownership-queue.test.ts` at `ab98c3b8` — 88 passed (88). - `pnpm --filter @objectstack/spec gen:schema && gen:docs` — no file changed. </details> <details><summary>Derived gates</summary> - At `796614dc`, `dispatch-gates --commands` derived 99 families (the same 99 at `ab98c3b8`): 98 `exit 0`, and `pnpm --filter @objectstack/spec run check:empty-state` `exit 1` (see Acceptance notes). `--ran`: "99 derived famil(ies) accounted for — 99 run, 0 NOT-MEASURED (a DERIVED zero)". - Before the spec edits (the tree of `d86cdc3e` without its roster line), it derived 77 families; each was run with its exit code recorded before any pipe. Five first answered `exit 3` (prerequisite not met: a pinned fixture commit outside the shallow clone, and unbuilt `dist/` for the i18n and dual-build gates); after fetching that commit and building the closures each gate names, all five re-ran at `exit 0`. `--ran` on that run: "77 derived famil(ies) accounted for — 77 run, 0 NOT-MEASURED". - `node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD` — 5 commit messages carry no card relation and no model identifier. `check-nul-bytes` exit 0. - `pnpm lint` (repo-wide `eslint . --no-inline-config`) — exit 0 on that pre-spec tree and again at `796614dc`; `eslint --no-inline-config` on the one file `ab98c3b8` changes — exit 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4d14C8K4gMehVb6jBvA)_ <!-- signed: domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9b24f3c commit ac890f5

23 files changed

Lines changed: 1296 additions & 36 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': minor
4+
'@objectstack/plugin-sharing': minor
5+
'@objectstack/cli': minor
6+
'@objectstack/platform-objects': minor
7+
---
8+
9+
feat(objectql,plugin-sharing): an organization authors its own assignment rules in Setup, as `sys_assignment_rule` records
10+
11+
Clause-②: yes (widening)
12+
Metadata-protocol: added
13+
Skills-docs: follow-up owed — content/docs/automation/queues-and-assignment-rules.mdx, skills/objectstack-automation/SKILL.md
14+
15+
Until now only a package could declare an assignment rule, so routing new records differently meant a package release. An organization can now write its own rules in Setup (Access Control → Assignment Rules), on the same two-source model sharing rules and queues already follow: the rules packages declare keep working, and the organization's own rules sit beside them.
16+
17+
- **`@objectstack/plugin-sharing`**: a new object, `sys_assignment_rule`. One row is one rule: when a new record of the chosen object matches the condition (a CEL expression over `record`, empty for every record), it goes to a queue or to one person, and a queue target may pick a member by `round_robin` or `least_loaded` (which needs its open-work expression, `load`). Each row belongs to one organization (`organization_id` is required). The table's save guard refuses, naming each field at fault: a name that is not snake_case or that a package rule already holds (one name means one rule); an object that does not exist or whose records have no owner; an entry shape the stack schema refuses for a declared rule; a condition or open-work expression the engine cannot compile; a person who is not an active member of the organization; a rule that would land in no organization. A boot report logs, at `error`, an organization's rule whose name a later-installed package now declares. Setup gains an "Assignment Rules" entry beside Queues.
18+
- **`@objectstack/objectql`**: the engine evaluates an object's organization rules first, ascending by `sort_order` and then by name, and the rules packages declare as the fallback; the first match decides. A user target, from either source, now owns the record only while the user is an active member of the record's organization and is not banned. One that no longer resolves leaves the record ownerless, the way a queue that no longer resolves does, and is logged once; the insert is never refused. When an organization's rules cannot be read, its new records are written as they stood rather than routed by the package rules its own would have overtaken. New exports for the save guard: `ASSIGNMENT_RULE_OBJECT`, `assignmentExpressionRefusals`, `organizationMemberStanding`.
19+
- **`@objectstack/spec`**: `PLATFORM_PROVIDED_OBJECT_NAMES` lists `sys_assignment_rule` under `plugin-sharing`, and the `AssignmentRuleSchema` documentation says how the two sources combine. No key, shape or validation changes.
20+
- **`@objectstack/platform-objects`**: the Setup app labels the new "Assignment Rules" navigation entry in English, Chinese, Japanese and Spanish.
21+
- **`@objectstack/cli`**: the organization-ownership inventory lists `sys_assignment_rule`, so `os migrate organization-ownership` plans it instead of refusing it as an uninventoried platform table.
22+
23+
Upgrading: a package rule whose entry names a user id (the `assignment-rule-user-target` lint warning) stops assigning in an organization where that user is not a member; the engine logs the lapse once per rule, user and organization. Route such a rule to a queue whose members are positions.

‎packages/cli/src/utils/organization-ownership-inventory.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -802,12 +802,14 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [
802802
{ object: 'sys_mail_message', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach); the message's mailbox carries the organization`, anchors: [parent('mailbox_id', 'sys_mailbox')] },
803803
{ object: 'sys_mail_association', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach); the association's message carries the organization`, anchors: [parent('mail_message_id', 'sys_mail_message')] },
804804
// Ownership queues (`@objectstack/plugin-sharing`, ADR-0142): an organization
805-
// authors its queues and their members, and the assignment engine writes its
806-
// cursor, each under that organization. A member and a cursor name their
807-
// queue or rule by name, not by id, so no parent anchors them.
805+
// authors its queues, their members and its own assignment rules, and the
806+
// assignment engine writes its cursor, each under that organization. A
807+
// member, a cursor and a rule name their queue or rule by name, not by id,
808+
// so no parent anchors them.
808809
{ object: 'sys_queue', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] },
809810
{ object: 'sys_queue_member', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach); names its queue by name, not by id; ${D10_SINGLE}`, anchors: [] },
810811
{ object: 'sys_assignment_cursor', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach); names its rule by name, not by id; ${D10_SINGLE}`, anchors: [] },
812+
{ object: 'sys_assignment_rule', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); its organization column is required, so no NULL row has a producer; ${D10_SINGLE}`, anchors: [] },
811813
// Exchange rates (`@objectstack/platform-objects`, #27185): an organization's
812814
// administrators author its own rate table; no parent row anchors a rate.
813815
{ object: 'sys_exchange_rate', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] },

0 commit comments

Comments
 (0)