diff --git a/.changeset/30066-organization-assignment-rules.md b/.changeset/30066-organization-assignment-rules.md new file mode 100644 index 00000000000..4c5db46e607 --- /dev/null +++ b/.changeset/30066-organization-assignment-rules.md @@ -0,0 +1,23 @@ +--- +'@objectstack/spec': minor +'@objectstack/objectql': minor +'@objectstack/plugin-sharing': minor +'@objectstack/cli': minor +'@objectstack/platform-objects': minor +--- + +feat(objectql,plugin-sharing): an organization authors its own assignment rules in Setup, as `sys_assignment_rule` records + +Clause-②: yes (widening) +Metadata-protocol: added +Skills-docs: follow-up owed — content/docs/automation/queues-and-assignment-rules.mdx, skills/objectstack-automation/SKILL.md + +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. + +- **`@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. +- **`@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`. +- **`@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. +- **`@objectstack/platform-objects`**: the Setup app labels the new "Assignment Rules" navigation entry in English, Chinese, Japanese and Spanish. +- **`@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. + +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. diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index ca211764af1..0b514226a3e 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -802,12 +802,14 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ { 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')] }, { 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')] }, // Ownership queues (`@objectstack/plugin-sharing`, ADR-0142): an organization - // authors its queues and their members, and the assignment engine writes its - // cursor, each under that organization. A member and a cursor name their - // queue or rule by name, not by id, so no parent anchors them. + // authors its queues, their members and its own assignment rules, and the + // assignment engine writes its cursor, each under that organization. A + // member, a cursor and a rule name their queue or rule by name, not by id, + // so no parent anchors them. { object: 'sys_queue', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] }, { object: 'sys_queue_member', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach); names its queue by name, not by id; ${D10_SINGLE}`, anchors: [] }, { object: 'sys_assignment_cursor', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach); names its rule by name, not by id; ${D10_SINGLE}`, anchors: [] }, + { 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: [] }, // Exchange rates (`@objectstack/platform-objects`, #27185): an organization's // administrators author its own rate table; no parent row anchors a rate. { object: 'sys_exchange_rate', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] }, diff --git a/packages/objectql/src/assignment-rules.ts b/packages/objectql/src/assignment-rules.ts index 875320a211a..255d9cc0611 100644 --- a/packages/objectql/src/assignment-rules.ts +++ b/packages/objectql/src/assignment-rules.ts @@ -15,6 +15,25 @@ * (ADR-0138 D1 item 3's order: hooks, then assignment rules). An update never * evaluates a rule. * + * ## Two sources, and their order + * + * A rule is DECLARED (a stack's `assignmentRules`, read from the registry) or + * AUTHORED by an organization (a `sys_assignment_rule` row, owned by + * `@objectstack/plugin-sharing`; one row is one rule with one entry, named + * after the rule). The engine is first-match, and an object's authored rules + * go FIRST — ascending by `sort_order`, then by `name` — with the declared + * rules, in registry order, as the fallback. The other order would let an + * organization's rule never win: the activation ledger that switches a + * declared rule off is one deployment-wide row per rule with no organization + * column, so an organization cannot switch a package rule off for itself + * alone (ruling A on organization-authored routing, its execution parameter + * 1). An authored row under a declared rule's name is not evaluated — one + * name means one rule (ADR-0131 D4): the declared rule answers for it, as the + * declared queue answers for a `sys_queue` row under its name. The rows are + * validated on save by plugin-sharing's guard, which compiles `criteria` and + * `load` through {@link assignmentExpressionRefusals}, the paths this module + * evaluates them by. + * * ## As which identity * * A platform step, not app automation: every read — the queue's members, a @@ -28,9 +47,14 @@ * `owner_id` (when a member or a user was chosen) and `queue` (for a queue * target). Nothing else. It never refuses an insert: an empty pool parks the * row and warns once per rule; a queue that resolves nowhere in the row's - * organization leaves the row ownerless with `queue` unset and warns; a read - * that fails leaves the row as it was (with `queue` set when the entry had - * already chosen one) and warns. Intake is never refused by assignment. + * organization leaves the row ownerless with `queue` unset and warns; a user + * target who is not an active member of the row's organization + * ({@link organizationMemberStanding}) leaves the row ownerless the same way + * and warns once; a read that fails leaves the row as it was (with `queue` + * set when the entry had already chosen one) and warns — the organization's + * own rules unreadable included, which routes the row by no rule rather than + * by the fallback its own rules would have overtaken. Intake is never refused + * by assignment. * * ## The strategies * @@ -48,7 +72,7 @@ */ import { compileCelToFilter, ExpressionEngine } from '@objectstack/formula'; -import { METADATA_ACTIVATION_TABLE } from '@objectstack/core'; +import { METADATA_ACTIVATION_TABLE, isUserBanInForce } from '@objectstack/core'; import { resolveInjectedColumnProvenance } from '@objectstack/spec/data'; import type { IOwnershipQueueService } from '@objectstack/spec/contracts'; @@ -64,6 +88,12 @@ export const OWNERSHIP_QUEUE_SOURCE_SLOT: unique symbol = Symbol.for('objectstac export const ASSIGNMENT_CURSOR_OBJECT = 'sys_assignment_cursor'; /** The registry type and the activation-ledger `metadata_type` of an assignment rule. */ export const ASSIGNMENT_RULE_TYPE = 'assignment_rule'; +/** An organization's own assignment rules (owned by `@objectstack/plugin-sharing`). */ +export const ASSIGNMENT_RULE_OBJECT = 'sys_assignment_rule'; +/** The organization membership table a user target is resolved against. */ +const MEMBER_OBJECT = 'sys_member'; +/** The user table whose ban columns a user target's standing reads. */ +const USER_OBJECT = 'sys_user'; const OWNER_FIELD = 'owner_id'; const QUEUE_FIELD = 'queue'; @@ -114,6 +144,12 @@ interface ActiveRule { entries: RuleEntry[]; } +/** One evaluation's memo: least-loaded counts and user-target standings, never older than the insert that asked. */ +interface EvaluationPass { + loads: Map>; + standings: Map>; +} + function present(v: unknown): string | null { if (v == null) return null; const s = String(v); @@ -153,6 +189,90 @@ function ruleOf(item: unknown): ActiveRule | null { return entries.length > 0 ? { name: item.name, object: item.object, entries } : null; } +/** + * A stored `sys_assignment_rule` row as the rule it is: one entry, named after + * the rule, so the round-robin cursor keys it (rule, entry, organization) like + * any declared entry. `null` for a row that does not read as a rule. + */ +function authoredRuleOf(row: Record): ActiveRule | null { + const name = present(row.name); + if (!name) return null; + return ruleOf({ + name, + object: row.object_name, + entries: [{ + name, + criteria: row.criteria, + assignTo: { type: row.recipient_type, value: row.recipient_id }, + strategy: row.strategy, + load: row.load, + }], + }); +} + +function sortOrderOf(row: Record): number { + const n = Number(row.sort_order ?? 0); + return Number.isFinite(n) ? n : 0; +} + +/** + * The refusals of an entry's `criteria` and `load`, compiled by the paths + * this module evaluates them by — `criteria` by the expression engine that + * {@link AssignmentRuleEvaluator} interprets it with, `load` by the pushdown + * compiler its least-loaded count lowers it with. Empty when both compile (or + * are absent). The save guard of an organization's `sys_assignment_rule` row + * asks this, so a rule the engine could not evaluate is refused when it is + * written, never discovered as a warning at an insert. + */ +export function assignmentExpressionRefusals(entry: { criteria?: unknown; load?: unknown }): Array<{ field: 'criteria' | 'load'; message: string }> { + const out: Array<{ field: 'criteria' | 'load'; message: string }> = []; + const criteria = expressionOf(entry.criteria); + if (criteria) { + const compiled = ExpressionEngine.compile(criteria as any); + if (!compiled.ok) { + out.push({ field: 'criteria', message: (compiled as any).error?.message ?? 'the condition does not compile' }); + } + } + const load = expressionOf(entry.load); + if (load) { + const compiled = compileCelToFilter(load.source, { variables: {} }); + if (!compiled.ok) { + out.push({ field: 'load', message: compiled.detail ?? compiled.reason }); + } + } + return out; +} + +/** + * Where a user stands in an organization, for a user target: `member` — a + * `sys_member` row in that organization, and no ban in force on the user + * (`isUserBanInForce`, the one ban predicate); `not_member`; `banned`; or + * `unconfirmable` — no membership table is registered, so no user can be + * confirmed as anyone's member. A failed read throws: the caller says what + * the row does then. + * + * The one predicate for an active member of an organization on this surface: + * the engine asks it of every user target at insert, and plugin-sharing's + * save guard asks it of an organization rule's user target. + */ +export type OrganizationMemberStanding = 'member' | 'not_member' | 'banned' | 'unconfirmable'; + +export async function organizationMemberStanding( + read: Pick, + userId: string, + organizationId: string, +): Promise { + if (!read.hasObject(MEMBER_OBJECT)) return 'unconfirmable'; + const pinned = { isSystem: true, tenantId: organizationId, positions: [], permissions: [] }; + const memberships = await read.find(MEMBER_OBJECT, { user_id: userId, organization_id: organizationId }, pinned, 1); + if (!Array.isArray(memberships) || memberships.length === 0) return 'not_member'; + if (!read.hasObject(USER_OBJECT)) return 'member'; + const users = await read.find(USER_OBJECT, { id: userId }, { isSystem: true, positions: [], permissions: [] }, 1); + const user = Array.isArray(users) ? users[0] : undefined; + if (!user) return 'not_member'; + return isUserBanInForce(user) ? 'banned' : 'member'; +} + /** Does `object` carry the platform's `queue` column, with storage behind it? */ export function carriesQueueColumn(schema: unknown): boolean { return resolveInjectedColumnProvenance(schema, QUEUE_FIELD) === 'injected-provisioned'; @@ -190,6 +310,22 @@ export class AssignmentRuleEvaluator { return rules; } + /** + * Can an insert into `object` be routed at all? The engine's cheap gate: + * `true` when a declared rule names the object, or when organizations can + * author rules (`sys_assignment_rule` is registered) and the object carries + * the queue column those rules need. Registry reads only. + */ + mayRoute(object: string): boolean { + return this.rulesFor(object).length > 0 || this.authoredRulesPossible(object); + } + + private authoredRulesPossible(object: string): boolean { + return object !== ASSIGNMENT_RULE_OBJECT + && this.host.hasObject(ASSIGNMENT_RULE_OBJECT) + && carriesQueueColumn(this.host.getSchema(object)); + } + /** * Evaluate `object`'s rules over `candidates`, writing `owner_id` / `queue` * onto each row in place. Never throws. @@ -198,20 +334,44 @@ export class AssignmentRuleEvaluator { const pending = candidates.filter((c) => present(c.row[OWNER_FIELD]) === null && present(c.row[QUEUE_FIELD]) === null); if (pending.length === 0) return; const declared = this.rulesFor(object); - if (declared.length === 0) return; - let rules: ActiveRule[]; - try { - rules = await this.switchedOn(declared); - } catch { - rules = declared; + const authoredPossible = this.authoredRulesPossible(object); + if (declared.length === 0 && !authoredPossible) return; + let fallback: ActiveRule[] = []; + if (declared.length > 0) { + try { + fallback = await this.switchedOn(declared); + } catch { + fallback = declared; + } + } + + // An organization's own rules, read once per organization in the batch; + // `null` marks an organization whose rules could not be read. + const authored = new Map(); + if (authoredPossible) { + const declaredNames = new Set(declared.map((r) => r.name)); + for (const name of this.declaredRuleNames()) declaredNames.add(name); + for (const { organizationId } of pending) { + if (organizationId && !authored.has(organizationId)) { + authored.set(organizationId, await this.authoredRules(object, organizationId, declaredNames)); + } + } } - if (rules.length === 0) return; - // Batch-local least-loaded adjustments, keyed per rule, entry and organization. - const loads = new Map>(); + // Batch-local least-loaded adjustments, keyed per rule, entry and + // organization, and user-target standings, keyed per user and organization. + const pass: EvaluationPass = { loads: new Map(), standings: new Map() }; for (const candidate of pending) { + const own = candidate.organizationId ? authored.get(candidate.organizationId) : undefined; + // The organization's rules go first; when they could not be read the row + // is written as it stood — never routed by the fallback they overtake. + if (own === null) continue; + const rules = own ? [...own, ...fallback] : fallback; + // A row with no organization is reported (inside `assignOne`) only when + // a declared rule names its object; no rule at all is the cheap case. + if (rules.length === 0) continue; try { - await this.assignOne(object, rules, candidate, loads); + await this.assignOne(object, rules, candidate, pass); } catch (err: any) { // ⛔ Intake is never refused by assignment. this.host.logger?.warn?.('[assignment] evaluation failed — the row is written as it stood', { @@ -221,11 +381,65 @@ export class AssignmentRuleEvaluator { } } + /** Every declared assignment rule's name, switched on or off, any object — the one namespace an authored row may not take. */ + private declaredRuleNames(): string[] { + let items: unknown[]; + try { + items = this.host.listRules(); + } catch { + return []; + } + return (Array.isArray(items) ? items : []) + .map((item) => (isRecord(item) && typeof item.name === 'string' ? item.name : null)) + .filter((name): name is string => name !== null); + } + + /** + * `organizationId`'s own active rules for `object`, in evaluation order + * (`sort_order`, then `name`) — or `null` when the read failed, which the + * caller answers by routing the organization's rows by no rule. + */ + private async authoredRules(object: string, organizationId: string, declaredNames: ReadonlySet): Promise { + const ctx = { isSystem: true, tenantId: organizationId, positions: [], permissions: [] }; + let rows: Record[]; + try { + rows = await this.host.find(ASSIGNMENT_RULE_OBJECT, { object_name: object, organization_id: organizationId }, ctx); + } catch (err: any) { + this.host.logger?.warn?.(`[assignment] the assignment rules of organization '${organizationId}' could not be read — its new '${object}' rows are written as they stood, routed by no rule`, { + object, organization: organizationId, error: err?.message, + }); + return null; + } + const ordered = (Array.isArray(rows) ? rows : []) + .filter(isRecord) + .sort((a, b) => sortOrderOf(a) - sortOrderOf(b) || String(a.name ?? '').localeCompare(String(b.name ?? ''))); + const rules: ActiveRule[] = []; + for (const row of ordered) { + const name = present(row.name); + if (name && declaredNames.has(name)) { + this.warnOnce(`shadowed::${organizationId}::${name}`, `[assignment] organization '${organizationId}' has an assignment rule named '${name}', which is the name of a rule an installed app declares — one name means one rule, so the app's rule answers for it and the organization's is not evaluated. Rename the organization's rule to keep it.`, { + rule: name, organization: organizationId, + }); + continue; + } + if (row.active === false || row.active === 0 || row.active === '0' || row.active === 'false') continue; + const rule = authoredRuleOf(row); + if (!rule) { + this.warnOnce(`unreadable::${organizationId}::${name ?? String(row.id ?? '')}`, `[assignment] organization '${organizationId}' has an assignment rule${name ? ` '${name}'` : ''} that does not read as a rule (no recipient, or an unknown recipient type) — it routes nothing until it is corrected`, { + rule: name, organization: organizationId, + }); + continue; + } + if (rule.object === object) rules.push(rule); + } + return rules; + } + private async assignOne( object: string, rules: readonly ActiveRule[], { row, organizationId }: AssignmentCandidate, - loads: Map>, + pass: EvaluationPass, ): Promise { if (!organizationId) { this.warnOnce(`no-org::${object}`, `[assignment] a row of '${object}' has no organization, so no assignment rule is evaluated for it — it is written ownerless`, { object }); @@ -235,7 +449,7 @@ export class AssignmentRuleEvaluator { for (const rule of rules) { for (const entry of rule.entries) { if (!this.matches(rule, entry, row)) continue; - await this.route(object, rule, entry, row, organizationId, ctx, loads); + await this.route(object, rule, entry, row, organizationId, ctx, pass); return; // the first matching entry of the first matching rule decides } } @@ -259,10 +473,10 @@ export class AssignmentRuleEvaluator { row: Record, organizationId: string, ctx: Record, - loads: Map>, + pass: EvaluationPass, ): Promise { if (entry.assignTo.type === 'user') { - row[OWNER_FIELD] = entry.assignTo.value; + await this.routeToUser(rule, entry.assignTo.value, row, organizationId, pass); return; } const queueName = entry.assignTo.value; @@ -303,7 +517,7 @@ export class AssignmentRuleEvaluator { } try { const chosen = entry.strategy === 'least_loaded' - ? await this.leastLoaded(object, rule, entry, members, organizationId, ctx, loads) + ? await this.leastLoaded(object, rule, entry, members, organizationId, ctx, pass.loads) : await this.roundRobin(rule, entry, members, organizationId, ctx); if (chosen) row[OWNER_FIELD] = chosen; } catch (err: any) { @@ -312,6 +526,50 @@ export class AssignmentRuleEvaluator { } } + /** + * A user target owns the row only while the user is an active member of the + * row's organization (ruling A's execution parameter 2). One that no longer + * resolves is a lapsed target, handled as a lapsed queue is: the row is + * written ownerless and the lapse is logged once, never refused. + */ + private async routeToUser( + rule: ActiveRule, + userId: string, + row: Record, + organizationId: string, + pass: EvaluationPass, + ): Promise { + const key = `${userId}::${organizationId}`; + let standing = pass.standings.get(key); + if (!standing) { + standing = organizationMemberStanding(this.host, userId, organizationId); + pass.standings.set(key, standing); + } + let answer: OrganizationMemberStanding; + try { + answer = await standing; + } catch (err: any) { + this.host.logger?.warn?.('[assignment] the user target\'s membership could not be read — the row is written as it stood', { + rule: rule.name, user: userId, organization: organizationId, error: err?.message, + }); + return; + } + if (answer === 'member') { + row[OWNER_FIELD] = userId; + return; + } + if (answer === 'unconfirmable') { + this.warnOnce(`unconfirmable::${rule.name}`, `[assignment] rule '${rule.name}' assigns to user '${userId}', but no organization membership table ('${MEMBER_OBJECT}') is registered, so no user can be confirmed as a member — the row is written ownerless.`, { + rule: rule.name, user: userId, organization: organizationId, + }); + return; + } + const why = answer === 'banned' ? 'whose account is banned' : `who is not a member of organization '${organizationId}'`; + this.warnOnce(`lapsed-user::${rule.name}::${userId}::${organizationId}`, `[assignment] rule '${rule.name}' assigns to user '${userId}', ${why} — the row is written ownerless. Point the rule at a current member, or at a queue.`, { + rule: rule.name, user: userId, organization: organizationId, standing: answer, + }); + } + private async leastLoaded( object: string, rule: ActiveRule, diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 83218827c0d..2f00997f3ec 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -6200,11 +6200,12 @@ export class ObjectQL implements IObjectQLEngine { // An engine-owned producer like the two above, so the post-hook `check` // (judged above, on the row as the hooks left it) does not see the owner // a rule picks — that owner is the platform's routing, not the caller's - // steering. Free for every object no rule names (`rulesFor` is a registry - // read). + // steering. Free for every object no rule can name (`mayRoute` is a + // registry read: a declared rule names the object, or organizations can + // author rules and the object carries the queue column they route by). { const evaluator = this.assignment(); - if (evaluator.rulesFor(object).length > 0) { + if (evaluator.mayRoute(object)) { const activeOrg = opCtx.context?.tenantId ? String(opCtx.context.tenantId) : null; const candidates = []; for (let i = 0; i < rows.length; i++) { diff --git a/packages/objectql/src/federated-injected-column-readers.test.ts b/packages/objectql/src/federated-injected-column-readers.test.ts index 2180ec54ffd..4d9eea01cff 100644 --- a/packages/objectql/src/federated-injected-column-readers.test.ts +++ b/packages/objectql/src/federated-injected-column-readers.test.ts @@ -443,6 +443,14 @@ const READERS: Record = { disposition: 'not-a-read', why: 'keys the sys_assignment_cursor row, a platform table the engine itself reads and writes', }, + 'assignment-rules.ts#authoredRules :: organization_id': { + disposition: 'not-a-read', + why: "keys an organization's sys_assignment_rule rows, a platform table plugin-sharing owns", + }, + 'assignment-rules.ts#organizationMemberStanding :: organization_id': { + disposition: 'not-a-read', + why: 'keys the sys_member row a user target is a member by, a platform table', + }, 'assignment-rules.ts#route :: queue': { disposition: 'not-a-read', why: 'names the queue in a log entry; the row written is the one in hand', diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 526c646892f..22309114581 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -766,3 +766,15 @@ export type { AuthzInvalidationBridgeOptions } from './authz-invalidation-bridge // package's published surface is exactly what it was. export { resolveArtifactPackageOrder } from '@objectstack/core'; export type { ArtifactPackageError } from '@objectstack/core'; + +// [ADR-0142 D5, ruling A on organization-authored routing] What the save guard +// of an organization's own `sys_assignment_rule` row (`@objectstack/plugin-sharing`) +// asks of the assignment seam, so the row is judged by the paths the engine +// evaluates it by: the table name, the CEL compile of `criteria` / `load`, and +// the one "active member of the organization" predicate for a user target. +export { + ASSIGNMENT_RULE_OBJECT, + assignmentExpressionRefusals, + organizationMemberStanding, +} from './assignment-rules.js'; +export type { OrganizationMemberStanding } from './assignment-rules.js'; diff --git a/packages/objectql/src/ownership-queue.test.ts b/packages/objectql/src/ownership-queue.test.ts index 81bd2dd04b0..4cf0748faf7 100644 --- a/packages/objectql/src/ownership-queue.test.ts +++ b/packages/objectql/src/ownership-queue.test.ts @@ -14,7 +14,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { QUEUE_FIELD_DEF } from '@objectstack/spec/data'; import { ObjectQL } from './engine.js'; import { applySystemFields, objectQueueColumnRefusal } from './registry.js'; -import { OWNERSHIP_QUEUE_SOURCE_SLOT, describeMissingOwnershipQueueService, nextMember } from './assignment-rules.js'; +import { OWNERSHIP_QUEUE_SOURCE_SLOT, assignmentExpressionRefusals, describeMissingOwnershipQueueService, nextMember } from './assignment-rules.js'; type Row = Record; @@ -127,6 +127,44 @@ const CURSOR = { }, }; +/** The organization membership table, as the platform ships it (only the keys the seam reads). */ +const MEMBER = { + name: 'sys_member', + label: 'Member', + fields: { + user_id: { type: 'text', label: 'User' }, + role: { type: 'text', label: 'Role' }, + }, +}; + +/** The user table's ban columns, which a user target's standing reads. */ +const USER = { + name: 'sys_user', + label: 'User', + fields: { + name: { type: 'text', label: 'Name' }, + banned: { type: 'boolean', label: 'Banned' }, + ban_expires: { type: 'datetime', label: 'Ban Expires' }, + }, +}; + +/** An organization's own assignment rules, as plugin-sharing ships them (only the keys the seam reads). */ +const AUTHORED_RULE = { + name: 'sys_assignment_rule', + label: 'Assignment Rule', + fields: { + name: { type: 'text', label: 'Name' }, + object_name: { type: 'text', label: 'Object' }, + criteria: { type: 'textarea', label: 'Condition' }, + sort_order: { type: 'number', label: 'Order' }, + recipient_type: { type: 'text', label: 'Assign To' }, + recipient_id: { type: 'text', label: 'Queue or User' }, + strategy: { type: 'text', label: 'Strategy' }, + load: { type: 'textarea', label: 'Open Work' }, + active: { type: 'boolean', label: 'Active' }, + }, +}; + const SYS = { isSystem: true, tenantId: 'org_a', positions: [], permissions: [] } as any; // ─── D3: the column and its refusal ──────────────────────────────────────── @@ -284,6 +322,8 @@ describe('[ADR-0142 D5] the assignment seam', () => { await engine.init(); engine.registry.registerObject(TICKET as any, 'pkg'); engine.registry.registerObject(CURSOR as any, 'plugin-sharing'); + engine.registry.registerObject(MEMBER as any, 'platform'); + d.storeFor('sys_member').set('m_lead', { id: 'm_lead', user_id: 'u_lead', organization_id: 'org_a' }); engine.registerApp({ id: 'com.acme.support', name: 'support', queues, assignmentRules: rules }); (engine as any)[OWNERSHIP_QUEUE_SOURCE_SLOT] = () => ({ resolveQueue: async (name: string, org: string | null) => @@ -427,6 +467,227 @@ describe('[ADR-0142 D5] the assignment seam', () => { }); }); +// ─── D5: user targets, and an organization's own rules ───────────────────── + +describe('[ADR-0142 D5] a user target owns the row only while the user is an active member', () => { + let engine: ObjectQL; + let d: ReturnType; + let warn: ReturnType; + + async function boot(opts: { members?: boolean; users?: boolean } = {}) { + engine = new ObjectQL(); + d = makeDriver(); + engine.registerDriver(d.driver, true); + await engine.init(); + engine.registry.registerObject(TICKET as any, 'pkg'); + if (opts.members !== false) engine.registry.registerObject(MEMBER as any, 'platform'); + if (opts.users !== false) engine.registry.registerObject(USER as any, 'platform'); + engine.registerApp({ + id: 'com.acme.support', name: 'support', queues: [], + assignmentRules: [{ name: 'ticket_owner', object: 'ticket', active: true, entries: [{ name: 'all', assignTo: { type: 'user', value: 'u_lead' } }] }], + }); + warn = vi.spyOn((engine as any).logger, 'warn'); + } + + const insert = (org = 'org_a') => engine.insert('ticket', { subject: 'A', organization_id: org }, { context: { ...SYS, tenantId: org } }); + + it('a member of the row\'s organization, not banned, owns it', async () => { + await boot(); + d.storeFor('sys_member').set('m1', { id: 'm1', user_id: 'u_lead', organization_id: 'org_a' }); + d.storeFor('sys_user').set('u_lead', { id: 'u_lead', banned: false }); + expect((await insert()).owner_id).toBe('u_lead'); + }); + + it('a user who is not a member of the row\'s organization owns nothing, the insert lands, and the lapse warns once', async () => { + await boot(); + d.storeFor('sys_member').set('m1', { id: 'm1', user_id: 'u_lead', organization_id: 'org_b' }); + d.storeFor('sys_user').set('u_lead', { id: 'u_lead', banned: false }); + const a = await insert(); + await insert(); + expect(a.owner_id ?? null).toBeNull(); + expect(a.queue ?? null).toBeNull(); + expect(warn.mock.calls.filter((c: unknown[]) => /assigns to user 'u_lead', who is not a member of organization 'org_a'/.test(String(c[0])))).toHaveLength(1); + // The other organization, where the user is a member, still routes. + expect((await insert('org_b')).owner_id).toBe('u_lead'); + }); + + it('a banned member owns nothing; a ban whose expiry passed binds no longer', async () => { + await boot(); + d.storeFor('sys_member').set('m1', { id: 'm1', user_id: 'u_lead', organization_id: 'org_a' }); + d.storeFor('sys_user').set('u_lead', { id: 'u_lead', banned: true }); + expect((await insert()).owner_id ?? null).toBeNull(); + expect(warn.mock.calls.some((c: unknown[]) => /whose account is banned/.test(String(c[0])))).toBe(true); + d.storeFor('sys_user').set('u_lead', { id: 'u_lead', banned: true, ban_expires: '2000-01-01T00:00:00Z' }); + expect((await insert()).owner_id).toBe('u_lead'); + }); + + it('no membership table registered: no user can be confirmed, so none is assigned, and it says why once', async () => { + await boot({ members: false }); + const a = await insert(); + await insert(); + expect(a.owner_id ?? null).toBeNull(); + expect(warn.mock.calls.filter((c: unknown[]) => /no organization membership table \('sys_member'\) is registered/.test(String(c[0])))).toHaveLength(1); + }); + + it('a membership read that fails writes the row as it stood, never refused', async () => { + await boot(); + const find = d.driver.find.bind(d.driver); + d.driver.find = async (object: string, ast: any) => { + if (object === 'sys_member') throw new Error('members down'); + return find(object, ast); + }; + const a = await insert(); + expect(a.owner_id ?? null).toBeNull(); + expect(warn.mock.calls.some((c: unknown[]) => /membership could not be read/.test(String(c[0])))).toBe(true); + }); +}); + +describe('[ADR-0142 D5] an organization\'s own rules (`sys_assignment_rule`) go first, the declared rules are the fallback', () => { + let members: Record; + let engine: ObjectQL; + let d: ReturnType; + let warn: ReturnType; + + async function boot(declared: unknown[] = [], opts: { authoredTable?: boolean } = {}) { + engine = new ObjectQL(); + d = makeDriver(); + engine.registerDriver(d.driver, true); + await engine.init(); + engine.registry.registerObject(TICKET as any, 'pkg'); + engine.registry.registerObject(CURSOR as any, 'plugin-sharing'); + engine.registry.registerObject(MEMBER as any, 'platform'); + if (opts.authoredTable !== false) engine.registry.registerObject(AUTHORED_RULE as any, 'plugin-sharing'); + engine.registerApp({ + id: 'com.acme.support', name: 'support', + queues: [{ name: 'support', label: 'Support', members: [] }, { name: 'vip', label: 'VIP', members: [] }], + assignmentRules: declared, + }); + (engine as any)[OWNERSHIP_QUEUE_SOURCE_SLOT] = () => ({ + resolveQueue: async (name: string, org: string | null) => + name in members && (org === 'org_a' || org === 'org_b') ? { name, label: name, source: 'declared', organizationId: null, members: [] } : null, + expandMembers: async (name: string) => [...(members[name] ?? [])].sort(), + }); + warn = vi.spyOn((engine as any).logger, 'warn'); + } + + const author = (id: string, row: Row) => + d.storeFor('sys_assignment_rule').set(id, { id, organization_id: 'org_a', object_name: 'ticket', active: true, sort_order: 0, ...row }); + const insert = (data: Row, org = 'org_a') => + engine.insert('ticket', { organization_id: org, ...data }, { context: { ...SYS, tenantId: org } }); + const fallback = [{ name: 'ticket_intake', object: 'ticket', active: true, entries: [{ name: 'all', assignTo: { type: 'queue', value: 'support' } }] }]; + + beforeEach(() => { + members = { support: ['u_a', 'u_b'], vip: ['u_v1', 'u_v2'] }; + }); + + it('an organization rule wins over a declared rule that also matches; an unmatched row falls back to the declared rule', async () => { + await boot(fallback); + author('r1', { name: 'vip_tickets', criteria: 'record.priority == "high"', recipient_type: 'queue', recipient_id: 'vip' }); + expect(await insert({ subject: 'A', priority: 'high' })).toMatchObject({ queue: 'vip' }); + expect(await insert({ subject: 'B', priority: 'low' })).toMatchObject({ queue: 'support' }); + }); + + it('routes with no declared rule at all, and only for the organization that authored it', async () => { + await boot(); + author('r1', { name: 'all_tickets', recipient_type: 'queue', recipient_id: 'vip' }); + expect(await insert({ subject: 'A' })).toMatchObject({ queue: 'vip' }); + const other = await insert({ subject: 'B' }, 'org_b'); + expect(other.queue ?? null).toBeNull(); + expect(other.owner_id ?? null).toBeNull(); + }); + + it('several organization rules are tried by `sort_order`, then by name; an inactive one is skipped', async () => { + await boot(fallback); + author('r1', { name: 'b_rule', sort_order: 1, recipient_type: 'queue', recipient_id: 'support' }); + author('r2', { name: 'a_rule', sort_order: 1, recipient_type: 'queue', recipient_id: 'vip' }); + author('r3', { name: 'z_rule', sort_order: 0, recipient_type: 'queue', recipient_id: 'support', active: false }); + expect(await insert({ subject: 'A' })).toMatchObject({ queue: 'vip' }); // a_rule before b_rule; z_rule is off + author('r4', { name: 'first', sort_order: -5, criteria: 'record.priority == "low"', recipient_type: 'queue', recipient_id: 'support' }); + expect(await insert({ subject: 'B', priority: 'low' })).toMatchObject({ queue: 'support' }); + }); + + it('a strategy rotates over the queue, its cursor keyed by the rule, its one entry (named after it) and the organization', async () => { + await boot(); + author('r1', { name: 'rotate', recipient_type: 'queue', recipient_id: 'vip', strategy: 'round_robin' }); + const owners = []; + for (const s of ['A', 'B', 'C']) owners.push((await insert({ subject: s })).owner_id); + expect(owners).toEqual(['u_v1', 'u_v2', 'u_v1']); + const cursor = [...d.storeFor('sys_assignment_cursor').values()]; + expect(cursor).toHaveLength(1); + expect(cursor[0]).toMatchObject({ rule: 'rotate', entry: 'rotate', organization_id: 'org_a' }); + }); + + it('a user target of an organization rule owns the row only while the user is a member there', async () => { + await boot(fallback); + author('r1', { name: 'to_lead', recipient_type: 'user', recipient_id: 'u_lead' }); + const lapsed = await insert({ subject: 'A' }); + expect(lapsed.owner_id ?? null).toBeNull(); + expect(lapsed.queue ?? null).toBeNull(); // the first match decides, even lapsed — no fall-through + d.storeFor('sys_member').set('m1', { id: 'm1', user_id: 'u_lead', organization_id: 'org_a' }); + expect((await insert({ subject: 'B' })).owner_id).toBe('u_lead'); + }); + + it('an organization rule under a declared rule\'s name is not evaluated — one name, one rule — and warns once', async () => { + await boot(fallback); + author('r1', { name: 'ticket_intake', recipient_type: 'queue', recipient_id: 'vip' }); + expect(await insert({ subject: 'A' })).toMatchObject({ queue: 'support' }); + await insert({ subject: 'B' }); + expect(warn.mock.calls.filter((c: unknown[]) => /named 'ticket_intake', which is the name of a rule an installed app declares/.test(String(c[0])))).toHaveLength(1); + }); + + it('organization rules that cannot be read route the row by no rule — never by the fallback they overtake', async () => { + await boot(fallback); + const find = d.driver.find.bind(d.driver); + d.driver.find = async (object: string, ast: any) => { + if (object === 'sys_assignment_rule') throw new Error('rules down'); + return find(object, ast); + }; + const row = await insert({ subject: 'A' }); + expect(row.queue ?? null).toBeNull(); + expect(row.owner_id ?? null).toBeNull(); + expect(warn.mock.calls.some((c: unknown[]) => /assignment rules of organization 'org_a' could not be read/.test(String(c[0])))).toBe(true); + }); + + it('a stored row that does not read as a rule routes nothing and warns once; the next rule decides', async () => { + await boot(fallback); + author('r1', { name: 'broken', recipient_type: 'team', recipient_id: 'x' }); + expect(await insert({ subject: 'A' })).toMatchObject({ queue: 'support' }); + await insert({ subject: 'B' }); + expect(warn.mock.calls.filter((c: unknown[]) => /rule 'broken' that does not read as a rule/.test(String(c[0])))).toHaveLength(1); + }); + + it('the cheap path: with no table and no declared rule, an insert reads no rule at all', async () => { + await boot([], { authoredTable: false }); + const seen: string[] = []; + const find = d.driver.find.bind(d.driver); + d.driver.find = async (object: string, ast: any) => { seen.push(object); return find(object, ast); }; + const row = await insert({ subject: 'A' }); + expect(row.queue ?? null).toBeNull(); + expect(seen).not.toContain('sys_assignment_rule'); + expect((engine as any).assignment().mayRoute('ticket')).toBe(false); + }); +}); + +describe('[ADR-0142 D5] assignmentExpressionRefusals — the save guard compiles by the paths the seam evaluates by', () => { + it('a condition and an open-work predicate the engine can run are not refused; absent ones neither', () => { + expect(assignmentExpressionRefusals({ criteria: 'record.priority == "high"', load: 'record.status != "closed"' })).toEqual([]); + expect(assignmentExpressionRefusals({ criteria: { dialect: 'cel', source: 'record.priority == "high"' } })).toEqual([]); + expect(assignmentExpressionRefusals({})).toEqual([]); + }); + + it('a condition that does not parse is refused at `criteria`', () => { + expect(assignmentExpressionRefusals({ criteria: 'record.priority == (' })).toEqual([ + { field: 'criteria', message: expect.any(String) }, + ]); + }); + + it('an open-work predicate the pushdown compiler cannot lower is refused at `load` — the shape the seam would park on', () => { + expect(assignmentExpressionRefusals({ load: 'size(record.subject) > 3' })).toEqual([ + { field: 'load', message: expect.any(String) }, + ]); + }); +}); + describe('[ADR-0142 D5] nextMember', () => { it('takes the member after the last one by id, wrapping, and survives a changed member set', () => { expect(nextMember(['a', 'b', 'c'], null)).toBe('a'); diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 33819f11ee2..544f5090b59 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -93,6 +93,7 @@ export const en: TranslationData = { nav_record_shares: { label: 'Record Shares' }, nav_queues: { label: 'Queues' }, nav_queue_members: { label: 'Queue Members' }, + nav_assignment_rules: { label: 'Assignment Rules' }, // `nav_sso_providers` is contributed by `@objectstack/plugin-auth` only // when the external-IdP RP is wired (`OS_SSO_ENABLED`, or the cloud // per-env `planAllowsSso`), so it is the one Setup entry diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index 9ff92dded13..da29be32475 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -63,6 +63,7 @@ export const esES: TranslationData = { nav_record_shares: { label: 'Registros Compartidos' }, nav_queues: { label: 'Colas' }, nav_queue_members: { label: 'Miembros de Colas' }, + nav_assignment_rules: { label: 'Reglas de Asignación' }, // Conditionally contributed by `@objectstack/plugin-auth` (only when an // external IdP is wired), so `check:app-nav-i18n` cannot see it — see // the rationale in `en.ts` (#6659). Wording matches this locale's diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index e3c31ffd562..65e70c2774b 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -63,6 +63,7 @@ export const jaJP: TranslationData = { nav_record_shares: { label: 'レコード共有' }, nav_queues: { label: 'キュー' }, nav_queue_members: { label: 'キューメンバー' }, + nav_assignment_rules: { label: '割り当てルール' }, // Conditionally contributed by `@objectstack/plugin-auth` (only when an // external IdP is wired), so `check:app-nav-i18n` cannot see it — see // the rationale in `en.ts` (#6659). Wording matches this locale's diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index 379d7320242..52a721fa15c 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -63,6 +63,7 @@ export const zhCN: TranslationData = { nav_record_shares: { label: '记录共享' }, nav_queues: { label: '队列' }, nav_queue_members: { label: '队列成员' }, + nav_assignment_rules: { label: '分配规则' }, // Conditionally contributed by `@objectstack/plugin-auth` (only when an // external IdP is wired), so `check:app-nav-i18n` cannot see it — see // the rationale in `en.ts` (#6659). Wording matches this locale's diff --git a/packages/plugins/plugin-sharing/src/assignment-rule-hooks.ts b/packages/plugins/plugin-sharing/src/assignment-rule-hooks.ts new file mode 100644 index 00000000000..d4e69d971c6 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/assignment-rule-hooks.ts @@ -0,0 +1,304 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0142 D5, ruling A on organization-authored routing] The save guard an + * organization's own `sys_assignment_rule` row carries, and the boot report of + * a row a later declaration shadows. + * + * Authoring a rule in Setup is a plain data write, so the guard is a hook on + * the table, the way the queue tables' guards are (`queue-hooks.ts`), never a + * check inside a service method nobody calls. It refuses the write — one + * `VALIDATION_FAILED` naming every field at fault — when the rule could not + * route a record the way it reads: + * + * | field | refused when | + * |-------------------------------|------------------------------------------------------------------------------| + * | `organization_id` | the row lands in no organization (non-null: a deployment-wide rule is a declared one) | + * | `name` | not snake_case, or a declared assignment rule's name (ADR-0131 D4, one namespace) | + * | `object_name` | not a registered object, or one with no ownership-queue column to route by | + * | the entry columns | the shape the stack schema refuses for a declared entry (`AssignmentRuleSchema`) | + * | `criteria`, `load` | the engine's CEL paths cannot compile it (`assignmentExpressionRefusals`) | + * | `recipient_id` (user target) | not an active member of the rule's organization (`organizationMemberStanding`) | + * + * The CEL compile and the membership predicate are imported from the engine's + * assignment seam, so a row is judged on save by exactly what judges it at an + * insert. At insert time the engine re-asks the membership — a member who + * leaves later is a lapsed target there, logged, never a refused insert. + * + * An update is judged on the merged row (pre-image + patch) when the patch + * touches a judged column; a patch of only the label, description, order or + * active flag is not re-judged, so an administrator can always switch off or + * rename a rule whose target lapsed. + */ + +import type { FieldErrorCode } from '@objectstack/spec/api'; +import { AssignmentRuleSchema } from '@objectstack/spec/automation'; +import { resolveInjectedColumnProvenance } from '@objectstack/spec/data'; +import { + ASSIGNMENT_RULE_OBJECT, + assignmentExpressionRefusals, + organizationMemberStanding, +} from '@objectstack/objectql'; + +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; + +/** Package id of the `sys_assignment_rule` save guard. */ +export const ASSIGNMENT_RULE_GUARD_PACKAGE = 'plugin-sharing:assignment-rule-guard'; + +/** The registry type of a declared assignment rule. */ +const ASSIGNMENT_RULE_TYPE = 'assignment_rule'; + +const RULE_NAME_PATTERN = /^[a-z][a-z0-9_]*$/; + +/** The columns a patch must touch for the update to be re-judged. */ +const JUDGED_COLUMNS = [ + 'organization_id', 'name', 'object_name', 'criteria', 'recipient_type', 'recipient_id', 'strategy', 'load', +] as const; + +interface MinimalEngine { + registerHook( + event: string, + handler: (ctx: any) => any | Promise, + options?: { object?: string | string[]; priority?: number; packageId?: string }, + ): void; + unregisterHooksByPackage(packageId: string): number; + find?(object: string, options?: any): Promise; + registry?: { + listItems?: (type: string) => unknown[]; + getObject?: (name: string) => unknown; + }; +} + +type FieldIssue = { field: string; code: FieldErrorCode; message: string }; + +/** + * Field-level refusal for a `sys_assignment_rule` row, on the data-API path: + * the shape `QueueValidationError` uses (`code: 'VALIDATION_FAILED'`, a + * `fields[]` entry per column at fault), so REST answers it as a validation + * failure naming the fields. + */ +export class AssignmentRuleValidationError extends Error { + readonly code = 'VALIDATION_FAILED'; + readonly fields: FieldIssue[]; + constructor(issues: FieldIssue[]) { + super(issues.map((i) => i.message).join(' ')); + this.name = 'ValidationError'; + this.fields = issues; + } +} + +function isRecord(v: unknown): v is Record { + return v !== null && typeof v === 'object' && !Array.isArray(v); +} + +function present(v: unknown): string | null { + if (v == null) return null; + const s = String(v); + return s.trim() === '' ? null : s; +} + +/** Every declared assignment rule's name, switched on or off. */ +function declaredRuleNames(engine: MinimalEngine): Set { + const names = new Set(); + let items: unknown[] = []; + try { + items = engine.registry?.listItems?.(ASSIGNMENT_RULE_TYPE) ?? []; + } catch { + return names; + } + for (const item of Array.isArray(items) ? items : []) { + if (isRecord(item) && typeof item.name === 'string') names.add(item.name); + } + return names; +} + +/** The column an `AssignmentRuleSchema` issue path names, for the one-entry row. */ +function columnOf(path: ReadonlyArray): string { + const [head, , key, sub] = path; + if (head === 'name') return 'name'; + if (head === 'object') return 'object_name'; + if (head !== 'entries') return '_record'; + if (key === 'name') return 'name'; // the one entry is named after the rule + if (key === 'assignTo') return sub === 'type' ? 'recipient_type' : 'recipient_id'; + if (key === 'criteria' || key === 'strategy' || key === 'load') return key; + return '_record'; +} + +/** + * Judge one row as it will be stored. `organizationId` is where it lands: the + * row's own column, else the caller's active organization. + */ +export async function assignmentRuleRowIssues( + engine: MinimalEngine, + row: Record, + organizationId: string | null, +): Promise { + const issues: FieldIssue[] = []; + if (!organizationId) { + issues.push({ + field: 'organization_id', + code: 'required', + message: + 'An assignment rule belongs to one organization, and this one would land in none. Write it from inside ' + + 'the organization it routes records for, or name that organization.', + }); + } + + const name = present(row.name); + if (name !== null && !RULE_NAME_PATTERN.test(name)) { + issues.push({ + field: 'name', + code: 'invalid_format', + message: `An assignment rule name is a machine name — lowercase letters, digits and underscores, starting with a letter (got ${JSON.stringify(name)}).`, + }); + } else if (name !== null && declaredRuleNames(engine).has(name)) { + issues.push({ + field: 'name', + code: 'invalid_value', + message: + `'${name}' is the name of an assignment rule an installed app provides, and one name means one rule. ` + + 'Choose another name for your own rule.', + }); + } + + const object = present(row.object_name); + if (object !== null && typeof engine.registry?.getObject === 'function') { + const schema = engine.registry.getObject(object); + if (!schema) { + issues.push({ field: 'object_name', code: 'reference_not_found', message: `There is no object named '${object}'.` }); + } else if (resolveInjectedColumnProvenance(schema, 'queue') !== 'injected-provisioned') { + issues.push({ + field: 'object_name', + code: 'invalid_value', + message: + `Records of '${object}' have no owner a rule could set, or are stored outside the platform, so an ` + + 'assignment rule on it would never assign anything. Choose an object whose records are owned by a person.', + }); + } + } + + // The shape a declared entry is held to, through the one schema that holds it. + const entry: Record = { + name: name ?? '', + assignTo: { type: row.recipient_type, value: present(row.recipient_id) ?? '' }, + }; + if (present(row.criteria) !== null) entry.criteria = row.criteria; + if (present(row.strategy) !== null) entry.strategy = row.strategy; + if (present(row.load) !== null) entry.load = row.load; + const parsed = AssignmentRuleSchema.safeParse({ name: name ?? '', object: object ?? '', entries: [entry] }); + if (!parsed.success) { + const judged = new Set(issues.map((i) => i.field)); + for (const issue of parsed.error.issues) { + const field = columnOf(issue.path); + // `name` / `object_name` are reported once, by the checks above, or by + // the required-field check when absent. + if (field === 'name' || field === 'object_name' || judged.has(field)) continue; + judged.add(field); + issues.push({ field, code: 'invalid_value', message: issue.message }); + } + } + + for (const refusal of assignmentExpressionRefusals({ criteria: entry.criteria, load: entry.load })) { + if (issues.some((i) => i.field === refusal.field)) continue; + issues.push({ + field: refusal.field, + code: 'invalid_value', + message: + refusal.field === 'criteria' + ? `The condition cannot be evaluated: ${refusal.message}` + : `The open-work expression cannot be counted by: ${refusal.message}`, + }); + } + + const userId = row.recipient_type === 'user' ? present(row.recipient_id) : null; + if (userId !== null && organizationId && typeof engine.find === 'function') { + const find = engine.find.bind(engine); + let standing: Awaited>; + try { + standing = await organizationMemberStanding( + { + hasObject: (o) => Boolean(engine.registry?.getObject?.(o)), + find: (o, where, context, limit) => find(o, { where, context, ...(limit ? { limit } : {}) }), + }, + userId, + organizationId, + ); + } catch (err) { + // Not the author's fault, so not a field issue — but never a save the + // guard could not judge: the write fails, saying why. + throw Object.assign( + new Error( + `[assignment] the membership of user '${userId}' in organization '${organizationId}' could not be read, ` + + `so the assignment rule cannot be judged and is not saved: ${err instanceof Error ? err.message : String(err)}`, + ), + { cause: err }, + ); + } + if (standing === 'banned') { + issues.push({ field: 'recipient_id', code: 'reference_user_banned', message: `User '${userId}' is banned, so records cannot be assigned to them.` }); + } else if (standing !== 'member') { + issues.push({ + field: 'recipient_id', + code: 'reference_not_found', + message: `User '${userId}' is not an active member of this organization, so records cannot be assigned to them. Choose a member, or a queue.`, + }); + } + } + return issues; +} + +/** Bind the `sys_assignment_rule` save guard. */ +export function bindAssignmentRuleGuards(engine: MinimalEngine): void { + if (typeof engine.registerHook !== 'function') return; + if (typeof engine.unregisterHooksByPackage === 'function') engine.unregisterHooksByPackage(ASSIGNMENT_RULE_GUARD_PACKAGE); + + const guard = (insert: boolean) => async (ctx: any) => { + const data = ctx?.input?.data; + if (!isRecord(data)) return; + if (!insert && !JUDGED_COLUMNS.some((c) => Object.prototype.hasOwnProperty.call(data, c))) return; + const row = insert ? { ...data } : { ...(isRecord(ctx?.previous) ? ctx.previous : {}), ...data }; + const organizationId = present(row.organization_id) ?? present(ctx?.session?.organizationId); + const issues = await assignmentRuleRowIssues(engine, row, organizationId); + if (issues.length > 0) throw new AssignmentRuleValidationError(issues); + }; + + const opts = { object: ASSIGNMENT_RULE_OBJECT, packageId: ASSIGNMENT_RULE_GUARD_PACKAGE, priority: 100 }; + engine.registerHook('beforeInsert', guard(true), opts); + engine.registerHook('beforeUpdate', guard(false), opts); +} + +/** + * Report, at boot, every `sys_assignment_rule` row whose name a package now + * declares. The declared rule answers for the name and the engine does not + * evaluate the row, so an organization's routing stopped without a write + * anyone made: logged at `error`, naming the row's organization, because an + * administrator has to act — rename the row — and nothing else will say so. + */ +export async function reportShadowedAuthoredAssignmentRules( + engine: MinimalEngine, + logger?: { + warn: (msg: string, meta?: Record) => void; + error?: (msg: string, meta?: Record) => void; + }, +): Promise { + if (typeof engine.find !== 'function') return; + const declared = declaredRuleNames(engine); + if (declared.size === 0) return; + let rows: any[] = []; + try { + rows = await engine.find(ASSIGNMENT_RULE_OBJECT, { fields: ['name', 'organization_id'], limit: 10000, context: SYSTEM_CTX }); + } catch { + return; // no table yet — nothing authored to shadow + } + for (const row of Array.isArray(rows) ? rows : []) { + const name = present(row?.name); + if (!name || !declared.has(name)) continue; + const msg = + `[assignment] an organization's assignment rule '${name}' is shadowed by a rule an installed app declares ` + + 'under the same name — the declared rule answers for it, and the organization\'s rule assigns nothing. ' + + 'Rename the organization\'s rule to keep it.'; + const meta = { rule: name, organization: present(row?.organization_id) }; + if (logger?.error) logger.error(msg, meta); + else logger?.warn(msg, meta); + } +} diff --git a/packages/plugins/plugin-sharing/src/index.ts b/packages/plugins/plugin-sharing/src/index.ts index dfd546f8caa..b5eba9960a5 100644 --- a/packages/plugins/plugin-sharing/src/index.ts +++ b/packages/plugins/plugin-sharing/src/index.ts @@ -16,6 +16,7 @@ export { SysQueue, SysQueueMember, SysAssignmentCursor, + SysAssignmentRule, } from './objects/index.js'; export { SysBusinessUnit, SysBusinessUnitMember } from '@objectstack/platform-objects/identity'; export { @@ -137,6 +138,15 @@ export { QUEUE_GUARD_PACKAGE, QUEUE_RECIPIENT_RECOMPUTE_PACKAGE, } from './queue-hooks.js'; +// [ADR-0142 D5, ruling A on organization-authored routing] The save guard of +// an organization's own `sys_assignment_rule` rows. +export { + bindAssignmentRuleGuards, + reportShadowedAuthoredAssignmentRules, + assignmentRuleRowIssues, + AssignmentRuleValidationError, + ASSIGNMENT_RULE_GUARD_PACKAGE, +} from './assignment-rule-hooks.js'; export { SharingServicePlugin, buildSharingMiddleware, diff --git a/packages/plugins/plugin-sharing/src/objects/index.ts b/packages/plugins/plugin-sharing/src/objects/index.ts index 0892eb0f35b..8b414f1baa1 100644 --- a/packages/plugins/plugin-sharing/src/objects/index.ts +++ b/packages/plugins/plugin-sharing/src/objects/index.ts @@ -15,3 +15,4 @@ export { SysShareLink } from './sys-share-link.object.js'; export { SysQueue } from './sys-queue.object.js'; export { SysQueueMember } from './sys-queue-member.object.js'; export { SysAssignmentCursor } from './sys-assignment-cursor.object.js'; +export { SysAssignmentRule } from './sys-assignment-rule.object.js'; diff --git a/packages/plugins/plugin-sharing/src/objects/sys-assignment-rule.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-assignment-rule.object.ts new file mode 100644 index 00000000000..62390642fa7 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/objects/sys-assignment-rule.object.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_assignment_rule — an organization's own assignment rule (ADR-0142 D5, + * ruling A on organization-authored routing). + * + * One row is one rule with one entry: when an inserted record of + * `object_name` matches `criteria`, it goes to the queue or the user the + * recipient columns name, and a queue target may pick one member by + * `strategy`. The engine reads the row the way it reads a declared rule's + * entry (`packages/objectql/src/assignment-rules.ts`). + * + * ## Two sources, one namespace + * + * A package declares assignment rules in its stack's `assignmentRules` + * (registry items, never copied into this table); an organization authors its + * own here, the model `sys_sharing_rule` and `sys_queue` already follow. + * A row may not take a declared rule's name (ADR-0131 D4): the save guard + * (`assignment-rule-hooks.ts`) refuses that write. + * + * ## Order + * + * The engine is first-match. An object's organization rules are evaluated + * first, ascending by `sort_order` and then by `name`, and the rules packages + * declare are the fallback. The other order would let an organization's rule + * never win: the activation ledger that switches a declared rule off is one + * row per deployment, with no organization column, so an organization cannot + * switch a package rule off for itself alone. + * + * ## Validated on save + * + * The save guard refuses, naming the field: a name that is not snake_case or + * that a package rule holds; an object that does not exist or has no + * ownership queue column; the shape the stack schema refuses for a declared + * entry (a strategy on a user target, `least_loaded` without `load`, …); + * a `criteria` or `load` the engine's CEL paths cannot compile; a user target + * who is not an active member of the rule's organization; a row with no + * organization. At insert time a target that no longer resolves leaves the + * record ownerless and is logged once — intake is never refused. + * + * Field `description`s here are ADMIN-FACING help text rendered under each + * input in Setup; implementation detail belongs in this comment. + * + * @namespace sys + */ +export const SysAssignmentRule = ObjectSchema.create({ + name: 'sys_assignment_rule', + label: 'Assignment Rule', + pluralLabel: 'Assignment Rules', + icon: 'route', + isSystem: true, + managedBy: 'config', + description: 'Sends each new record that matches a condition to a queue or a person.', + displayNameField: 'name', + nameField: 'name', + highlightFields: ['name', 'object_name', 'recipient_type', 'recipient_id', 'strategy', 'active'], + + listViews: { + by_object: { + type: 'grid', + name: 'by_object', + label: 'By Object', + data: { provider: 'object', object: 'sys_assignment_rule' }, + columns: ['object_name', 'sort_order', 'label', 'recipient_type', 'recipient_id', 'strategy', 'active'], + // The evaluation order: one object's rules together, in the order the + // engine tries them. + sort: [{ field: 'object_name', order: 'asc' }, { field: 'sort_order', order: 'asc' }, { field: 'name', order: 'asc' }], + pagination: { pageSize: 100 }, + }, + }, + + fields: { + // Non-null: a stored rule always belongs to one organization. A rule that + // reaches every organization is a DECLARED rule, never a row here. + organization_id: Field.lookup('sys_organization', { + label: 'Organization', + required: true, + group: 'System', + description: 'The organization this rule belongs to.', + }), + + name: Field.text({ + label: 'Name', + required: true, + maxLength: 100, + description: 'Identifies the rule. Lowercase letters, digits and underscores only, and not the name of a rule an installed app provides.', + group: 'Identity', + }), + + label: Field.text({ + label: 'Display Label', + required: true, + maxLength: 200, + group: 'Identity', + }), + + description: Field.textarea({ + label: 'Description', + required: false, + group: 'Identity', + }), + + object_name: Field.text({ + label: 'Object', + required: true, + maxLength: 100, + widget: 'object-ref', + description: 'The object whose new records this rule sends somewhere. Only objects whose records have an owner can be assigned.', + group: 'Condition', + }), + + criteria: Field.textarea({ + label: 'Condition', + required: false, + description: 'Which new records this rule takes; leave it empty to take every new record that no earlier rule took. ' + + 'Written as an expression over the record — for example record.priority == "high".', + group: 'Condition', + }), + + sort_order: Field.number({ + label: 'Order', + required: false, + defaultValue: 0, + description: 'When several of your rules could take the same record, the one with the lowest order is tried first. Your rules are always tried before the rules installed apps provide.', + group: 'Condition', + }), + + recipient_type: Field.select({ + options: [ + { value: 'queue', label: 'Queue' }, + { value: 'user', label: 'User' }, + ], + label: 'Assign To', + required: true, + defaultValue: 'queue', + description: 'Send the record to a queue, where its members can take it, or straight to one person.', + group: 'Assignment', + }), + + recipient_id: Field.text({ + label: 'Queue or User', + required: true, + maxLength: 200, + widget: 'recipient-picker', + dependsOn: ['recipient_type'], + description: 'The queue, or the person, the record goes to. A person must be an active member of this organization.', + group: 'Assignment', + }), + + strategy: Field.select({ + options: [ + { value: 'round_robin', label: 'Round robin' }, + { value: 'least_loaded', label: 'Least loaded' }, + ], + label: 'Pick a Member', + required: false, + description: + 'For a queue: give the record straight to one member, taking turns or choosing whoever has the least open ' + + 'work. Leave it empty to leave the record in the queue until a member takes it.', + group: 'Assignment', + }), + + load: Field.textarea({ + label: 'Open Work', + required: false, + description: + 'Required for "Least loaded": which records count as a member\'s open work, written as an expression over ' + + 'the record — for example record.status != "closed".', + group: 'Assignment', + }), + + active: Field.boolean({ + label: 'Active', + required: false, + defaultValue: true, + description: 'Turn off to stop this rule from assigning new records, without deleting it.', + group: 'Lifecycle', + }), + }, + + indexes: [ + { fields: ['object_name', 'active'] }, + { fields: ['name'], unique: 'organization' }, + { fields: ['organization_id'] }, + ], +}); diff --git a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.by-object.test.ts b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.by-object.test.ts index b134b458e4b..47e672e7ca3 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.by-object.test.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.by-object.test.ts @@ -56,6 +56,8 @@ describe('sharing list views — no grouping on a field that stores an object AP sys_queue: [], sys_queue_member: [], sys_assignment_cursor: [], + // An organization's own assignment rule names the object it routes. + sys_assignment_rule: ['object_name'], }); }); diff --git a/packages/plugins/plugin-sharing/src/ownership-queue.test.ts b/packages/plugins/plugin-sharing/src/ownership-queue.test.ts index 134c727e42d..22572f1ac6f 100644 --- a/packages/plugins/plugin-sharing/src/ownership-queue.test.ts +++ b/packages/plugins/plugin-sharing/src/ownership-queue.test.ts @@ -21,6 +21,12 @@ import { QUEUE_GUARD_PACKAGE, } from './queue-hooks.js'; import { ruleRegrantQueue } from './rule-hooks.js'; +import { + bindAssignmentRuleGuards, + reportShadowedAuthoredAssignmentRules, + AssignmentRuleValidationError, + ASSIGNMENT_RULE_GUARD_PACKAGE, +} from './assignment-rule-hooks.js'; type Row = Record; @@ -759,3 +765,137 @@ describe('[ADR-0142 D7] queue-recipient rules recompute on a queue-membership wr await ruleRegrantQueue.whenIdle(); }); }); + +// ─── ADR-0142 D5: an organization's own assignment rules, judged on save ───── + +describe('[ADR-0142 D5] the sys_assignment_rule save guard', () => { + const DECLARED_RULES = [{ name: 'ticket_intake', object: 'ticket', entries: [] }]; + const SCHEMAS: Record = { + ticket: { name: 'ticket', fields: {} }, // an owned object: the platform injects the queue column + catalog: { name: 'catalog', ownership: 'org', fields: {} }, // no owner, so no queue column + sys_member: { name: 'sys_member', fields: {} }, + sys_user: { name: 'sys_user', fields: {} }, + }; + let engine: ReturnType; + + beforeEach(() => { + engine = makeEngine({ + schemas: SCHEMAS, + tables: { + sys_member: [ + { id: 'm1', user_id: 'u_lead', organization_id: 'org_a' }, + { id: 'm2', user_id: 'u_banned', organization_id: 'org_a' }, + { id: 'm3', user_id: 'u_elsewhere', organization_id: 'org_b' }, + ], + sys_user: [{ id: 'u_lead' }, { id: 'u_banned', banned: true }, { id: 'u_elsewhere' }], + }, + }); + const listItems = engine.registry.listItems; + engine.registry.listItems = (type: string) => (type === 'assignment_rule' ? DECLARED_RULES : listItems(type)); + bindAssignmentRuleGuards(engine as any); + }); + + const valid = (extra: Row = {}): Row => ({ + name: 'vip_tickets', label: 'VIP tickets', object_name: 'ticket', criteria: 'record.priority == "high"', + recipient_type: 'queue', recipient_id: 'vip', ...extra, + }); + const save = (data: Row, session: Row | null = { organizationId: 'org_a' }) => + engine.fire('beforeInsert', 'sys_assignment_rule', { input: { data }, ...(session ? { session } : {}) }); + const refusal = async (p: Promise) => { + try { + await p; + } catch (err) { + return err as AssignmentRuleValidationError; + } + throw new Error('expected the save to be refused'); + }; + + it('a well-formed rule saves, queue or member target, with or without a strategy', async () => { + await expect(save(valid())).resolves.toBeUndefined(); + await expect(save(valid({ recipient_type: 'user', recipient_id: 'u_lead' }))).resolves.toBeUndefined(); + await expect(save(valid({ strategy: 'least_loaded', load: 'record.status != "closed"' }))).resolves.toBeUndefined(); + await expect(save(valid({ criteria: null, strategy: 'round_robin' }))).resolves.toBeUndefined(); + }); + + it('refuses a declared rule\'s name — one name, one rule — and a name that is not a machine name', async () => { + const taken = await refusal(save(valid({ name: 'ticket_intake' }))); + expect(taken).toBeInstanceOf(AssignmentRuleValidationError); + expect(taken).toMatchObject({ code: 'VALIDATION_FAILED', name: 'ValidationError', fields: [{ field: 'name', code: 'invalid_value' }] }); + expect((await refusal(save(valid({ name: 'VIP Tickets' })))).fields).toEqual([expect.objectContaining({ field: 'name', code: 'invalid_format' })]); + }); + + it('refuses an object that does not exist, and one whose records have no owner to set', async () => { + expect((await refusal(save(valid({ object_name: 'tickets' })))).fields).toEqual([expect.objectContaining({ field: 'object_name', code: 'reference_not_found' })]); + expect((await refusal(save(valid({ object_name: 'catalog' })))).fields).toEqual([expect.objectContaining({ field: 'object_name', code: 'invalid_value' })]); + }); + + it('refuses the entry shapes the stack schema refuses for a declared entry', async () => { + expect((await refusal(save(valid({ recipient_type: 'user', recipient_id: 'u_lead', strategy: 'round_robin' })))).fields) + .toEqual([expect.objectContaining({ field: 'strategy' })]); + expect((await refusal(save(valid({ strategy: 'least_loaded' })))).fields).toEqual([expect.objectContaining({ field: 'load' })]); + expect((await refusal(save(valid({ load: 'record.status != "closed"' })))).fields).toEqual([expect.objectContaining({ field: 'load' })]); + expect((await refusal(save(valid({ recipient_id: 'VIP Queue' })))).fields).toEqual([expect.objectContaining({ field: 'recipient_id' })]); + expect((await refusal(save(valid({ recipient_type: 'team' })))).fields).toEqual([expect.objectContaining({ field: 'recipient_type' })]); + }); + + it('refuses a condition or an open-work expression the engine\'s CEL paths cannot compile', async () => { + expect((await refusal(save(valid({ criteria: 'record.priority == (' })))).fields).toEqual([expect.objectContaining({ field: 'criteria', code: 'invalid_value' })]); + expect((await refusal(save(valid({ strategy: 'least_loaded', load: 'size(record.subject) > 3' })))).fields) + .toEqual([expect.objectContaining({ field: 'load', code: 'invalid_value' })]); + }); + + it('refuses a user target who is not an active member of the rule\'s organization', async () => { + expect((await refusal(save(valid({ recipient_type: 'user', recipient_id: 'u_elsewhere' })))).fields) + .toEqual([expect.objectContaining({ field: 'recipient_id', code: 'reference_not_found' })]); + expect((await refusal(save(valid({ recipient_type: 'user', recipient_id: 'u_banned' })))).fields) + .toEqual([expect.objectContaining({ field: 'recipient_id', code: 'reference_user_banned' })]); + // The same user saves in the organization they are a member of. + await expect(save(valid({ recipient_type: 'user', recipient_id: 'u_elsewhere' }), { organizationId: 'org_b' })).resolves.toBeUndefined(); + }); + + it('refuses a rule that would land in no organization', async () => { + expect((await refusal(save(valid(), null))).fields).toEqual([expect.objectContaining({ field: 'organization_id', code: 'required' })]); + await expect(save(valid({ organization_id: 'org_a' }), null)).resolves.toBeUndefined(); + }); + + it('names every field at fault in one refusal', async () => { + const err = await refusal(save(valid({ name: 'ticket_intake', object_name: 'catalog', criteria: 'record.x == (' }))); + expect(err.fields.map((f) => f.field).sort()).toEqual(['criteria', 'name', 'object_name']); + }); + + it('an update is judged on the merged row when it touches a judged column, and not re-judged otherwise', async () => { + const previous = { id: 'r1', organization_id: 'org_a', ...valid({ recipient_type: 'user', recipient_id: 'u_lead' }) }; + const update = (data: Row) => engine.fire('beforeUpdate', 'sys_assignment_rule', { input: { data }, previous, session: { organizationId: 'org_a' } }); + await expect(update({ label: 'Renamed', active: false, sort_order: 3 })).resolves.toBeUndefined(); + expect((await refusal(update({ recipient_id: 'u_elsewhere' }))).fields).toEqual([expect.objectContaining({ field: 'recipient_id' })]); + // The merged row carries the pre-image's user target, so a strategy alone is refused. + expect((await refusal(update({ strategy: 'round_robin' }))).fields).toEqual([expect.objectContaining({ field: 'strategy' })]); + }); + + it('a membership read that fails refuses the save, saying it could not judge — never a pass', async () => { + const failing = makeEngine({ schemas: SCHEMAS, failOn: ['sys_member'] }); + bindAssignmentRuleGuards(failing as any); + await expect(failing.fire('beforeInsert', 'sys_assignment_rule', { + input: { data: valid({ recipient_type: 'user', recipient_id: 'u_lead' }) }, session: { organizationId: 'org_a' }, + })).rejects.toThrow(/could not be read, so the assignment rule cannot be judged and is not saved/); + }); + + it('re-binding replaces, never stacks', () => { + const before = engine.hooks.filter((h) => h.packageId === ASSIGNMENT_RULE_GUARD_PACKAGE).length; + expect(before).toBe(2); + bindAssignmentRuleGuards(engine as any); + expect(engine.hooks.filter((h) => h.packageId === ASSIGNMENT_RULE_GUARD_PACKAGE).length).toBe(before); + }); + + it('a boot report names every authored rule a declaration now shadows, at error', async () => { + engine._tables.sys_assignment_rule = [ + { id: 'r1', name: 'ticket_intake', organization_id: 'org_a' }, + { id: 'r2', name: 'vip_tickets', organization_id: 'org_a' }, + ]; + const logger = { warn: vi.fn(), error: vi.fn() }; + await reportShadowedAuthoredAssignmentRules(engine as any, logger); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error.mock.calls[0][0]).toMatch(/assignment rule 'ticket_intake' is shadowed/); + expect(logger.error.mock.calls[0][1]).toEqual({ rule: 'ticket_intake', organization: 'org_a' }); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index 0b1ba372fc2..f7f7ffc24d5 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -56,10 +56,12 @@ import { SysQueue, SysQueueMember, SysAssignmentCursor, + SysAssignmentRule, } from './objects/index.js'; import { SysQueueDetailPage } from './sys-queue.page.js'; import { OwnershipQueueService, specsReadBusinessUnits, type OwnershipQueueEngine } from './ownership-queue-service.js'; import { bindQueueGuards, bindQueueRecipientRecompute, reportShadowedAuthoredQueues } from './queue-hooks.js'; +import { bindAssignmentRuleGuards, reportShadowedAuthoredAssignmentRules } from './assignment-rule-hooks.js'; import { SysBusinessUnit, SysBusinessUnitMember } from '@objectstack/platform-objects/identity'; import { SharingService, @@ -647,11 +649,13 @@ export class SharingServicePlugin implements Plugin { SysBusinessUnit, SysBusinessUnitMember, SysShareLink, - // [ADR-0142 D1/D5] Authored queues, their members, and the round-robin - // cursor the engine's assignment step advances. + // [ADR-0142 D1/D5] Authored queues, their members, the round-robin + // cursor the engine's assignment step advances, and an organization's + // own assignment rules (ruling A on organization-authored routing). SysQueue, SysQueueMember, SysAssignmentCursor, + SysAssignmentRule, ], // The queue's record page: its fields, and its members listed by the // queue's name with "New" pre-filled (`sys-queue.page.ts`). @@ -676,6 +680,7 @@ export class SharingServicePlugin implements Plugin { { id: 'nav_record_shares', type: 'object', label: 'Record Shares', objectName: 'sys_record_share', viewName: 'all_shares', icon: 'link', requiresObject: 'sys_record_share', requiredPermissions: ['manage_platform_settings'] }, { id: 'nav_queues', type: 'object', label: 'Queues', objectName: 'sys_queue', icon: 'inbox', requiresObject: 'sys_queue', requiredPermissions: ['manage_platform_settings'] }, { id: 'nav_queue_members', type: 'object', label: 'Queue Members', objectName: 'sys_queue_member', icon: 'users', requiresObject: 'sys_queue_member', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_assignment_rules', type: 'object', label: 'Assignment Rules', objectName: 'sys_assignment_rule', icon: 'route', requiresObject: 'sys_assignment_rule', requiredPermissions: ['manage_platform_settings'] }, ], }, ], @@ -780,6 +785,20 @@ export class SharingServicePlugin implements Plugin { ctx.logger.warn('SharingServicePlugin: queue guards not bound', { error: err?.message }); } + // [ADR-0142 D5, ruling A on organization-authored routing] An + // organization's own assignment rules: the save guard on the table this + // plugin owns (name namespace, object, entry shape, CEL compile, user + // target membership), bound regardless of `enforce`, and the boot report + // of an authored rule a later declaration shadows. + try { + if (typeof engine.registerHook === 'function') { + bindAssignmentRuleGuards(engine as any); + await reportShadowedAuthoredAssignmentRules(engine as any, ctx.logger as any); + } + } catch (err: any) { + ctx.logger.warn('SharingServicePlugin: assignment-rule guard not bound', { error: err?.message }); + } + // [ADR-0057 D12] Maintain sys_user.primary_business_unit_id as a // denormalised projection of sys_business_unit_member.is_primary so a // user-lookup can filter candidates by business unit. Bound regardless of diff --git a/packages/spec/scripts/liveness/empty-state-registry.mts b/packages/spec/scripts/liveness/empty-state-registry.mts index 6beee024b06..1260f4892af 100644 --- a/packages/spec/scripts/liveness/empty-state-registry.mts +++ b/packages/spec/scripts/liveness/empty-state-registry.mts @@ -193,6 +193,14 @@ export const EMPTY_STATE_REGISTRY: EmptyStateEntry[] = [ "[ADR-0142 D5] Which inserted rows an assignment rule entry routes; absent = every row, the catch-all entry at the end of an ordered list (the first entry that matches decides). It selects work to route and grants nothing by itself: the author names the target explicitly, assignment runs only for a row that reaches the engine with no owner and no queue, and a parked row is readable only by members of the queue the author named. The failure direction of a missing criteria is mis-routing among the author's own targets, never a widened grant.", evidence: 'packages/objectql/src/assignment-rules.ts (the first matching entry of the first matching rule decides; only rows with no owner and no queue are routed)', }, + { + 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)', + }, { file: 'packages/spec/src/automation/flow.zod.ts', property: 'errorCode', diff --git a/packages/spec/src/automation/assignment-rule.zod.ts b/packages/spec/src/automation/assignment-rule.zod.ts index 17d3bed3162..25e959886d2 100644 --- a/packages/spec/src/automation/assignment-rule.zod.ts +++ b/packages/spec/src/automation/assignment-rule.zod.ts @@ -128,10 +128,20 @@ export const AssignmentRuleEntrySchema = strictObject( * row's organization, or a failed read leaves the row ownerless (sitting in * the queue when one was chosen) and is logged once. * - * Assignment rules are declared only (the stack's `assignmentRules`); - * organization-authored rules are deferred (ADR-0142 merge question 11). - * Switched off through the activation ledger (`sys_metadata_activation`, - * `metadata_type: 'assignment_rule'`) or `active: false`. + * Two sources of rules combine. A package declares rules in the stack's + * `assignmentRules`, the shape below; an organization authors its own as + * `sys_assignment_rule` rows (one rule with one entry per row), which may not + * take a declared rule's name. For a row of an organization the engine tries + * that organization's active rows first, ascending by `sort_order` and then by + * name, and the declared rules after them, in registry order; across both, + * the first entry whose `criteria` match decides, and its target stands even + * when it does not resolve. When the organization's rows cannot be read, the + * row is written as it stood rather than routed by the declared rules alone. + * A user target owns the row only while the user is an active member of the + * row's organization; one that is not leaves the row ownerless and is logged + * once. A declared rule is switched off through the activation ledger + * (`sys_metadata_activation`, `metadata_type: 'assignment_rule'`) or + * `active: false`; an organization's row by its own `active`. */ export const AssignmentRuleSchema = lazySchema(() => strictObject( { diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index bf890d60745..bcabd281540 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -1299,8 +1299,9 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntryParsed[] = // members name. { type: 'queue', label: 'Queue', description: 'A named set of people records can sit in until one of them takes the record; organizations staff their own in Setup', filePatterns: ['**/*.queue.ts', '**/*.queue.yml'], supportsOverlay: false, allowEnvironmentOverlay: false, allowRuntimeCreate: false, supportsVersioning: false, executionPinned: false, loadOrder: 16, domain: 'security' }, // [ADR-0142 D5] Declared assignment rules — code-only for the `queue` - // reason, and declared only: organization-authored routing is deferred - // (merge question 11). `loadOrder: 17` — after `queue`, which an entry's + // reason; an organization authors its own as `sys_assignment_rule` rows, + // which the engine evaluates before these and which may not take a declared + // rule's name. `loadOrder: 17` — after `queue`, which an entry's // target names. Disabled through the activation ledger or `active: false`; // customized by a clone under a new name, never by an overlay. { type: 'assignment_rule', label: 'Assignment Rule', description: 'A rule a package declares to route new records to a queue or a person', filePatterns: ['**/*.assignment-rule.ts', '**/*.assignment-rule.yml'], supportsOverlay: false, allowEnvironmentOverlay: false, allowRuntimeCreate: false, supportsVersioning: false, executionPinned: false, loadOrder: 17, domain: 'security' }, diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts index 4ca2fff47ed..a018f33e7d5 100644 --- a/packages/spec/src/system/constants/platform-object-names.ts +++ b/packages/spec/src/system/constants/platform-object-names.ts @@ -117,6 +117,7 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly