Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/31790-queue-reads-declaring-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@objectstack/objectql': patch
'@objectstack/plugin-approvals': patch
---

feat(objectql, plugin-approvals): a declared assignment rule and an approval step read the queue they name from the package that declares them

Clause-②: yes (widening); narrowing per ADR-0150 D3 declared below
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type of either package root changes: a queue name a package-declared assignment rule or approval step writes is now resolved from that package, through the ownership-queue service's existing optional callerPackageId. Nothing an author writes changes, so there is nothing for a migration to rewrite and no tombstone. -->

A declared assignment rule (`assignmentRules`) now passes the package that ships it as `callerPackageId` when it resolves its queue and reads the queue's members. An approval step passes the package of the flow body the engine runs (the automation engine's `armedFlowPackage`) when it expands a `queue` approver, on the node's slate, its fallback slate and the tally of a request opened before the slate snapshot existed. The queue service resolves the name from that package (ADR-0150 D3): the package's own queue, else the one holder in its declared dependencies, else a refusal naming every candidate. An organization's own assignment rule, a declared rule no package ships and a flow authored in the deployment read environment-wide, exactly as before. So does a step in an environment-authored override of a packaged flow (a declared `.custom` override, ADR-0146 D9): a reference written in an overlay is the environment's (ADR-0150 D3), never read from the package it customizes.

When a `queue` approver's name is refused this way, the approval node still refuses with `APPROVER_DIRECTORY_UNREADABLE`, but the message now says the name was refused (ADR-0150 D3), with the refusal and its remedy, instead of asking for the flow to run again once the directory can be read. Its `retryable` is `false`.

Widening, per ADR-0150 D3. A rule or step in package P that names queue `q` used to be refused because two packages declared the name, and is now served:

- P's dependency D and an unrelated package U both declare `q`: it reaches D's `q`;
- P declares `q` itself and an unrelated package U does too: it reaches P's own `q`.

Measured first-party: 0 (no first-party package declares a queue, an assignment rule or a `queue` approver).

Narrowing, per ADR-0150 D3. A rule or step in package P whose queue name P cannot reach used to be served environment-wide and is now refused:

- the only holder is a package outside P's dependencies (`outside-closure`, naming it);
- P's dependency D holds the name but is disabled, and an unrelated U holds it too (`outside-closure`, naming U; a disabled package holds no queue for this read);
- the only holder is an item authored in the environment (`outside-closure`, naming the environment).

An assignment rule then writes the row ownerless and in no queue, and warns; an approval node refuses to open, naming every candidate and the remedy below. An organization's `sys_queue` row under a name no package declares is unchanged. To resolve it, declare a dependency on the package whose queue you mean, enable it, or ship the queue in P. Measured first-party: 0 (no first-party package declares a queue, an assignment rule or a `queue` approver).
49 changes: 42 additions & 7 deletions packages/objectql/src/assignment-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@
* `load` through {@link assignmentExpressionRefusals}, the paths this module
* evaluates them by.
*
* ## Which queue a name means
*
* [ADR-0150 D3] A declared rule is written inside a package, so the queue it
* names is read from that package: the queue service is passed the package
* the rule's registry item is registered under (`_packageId`, the registry's
* stamp) as `callerPackageId`, and answers the package's own queue, else the
* one holder in its declared dependencies (plus `sys`), else a refusal naming
* every candidate. The rule never picks a holder itself: the one resolver is
* the queue service's (`IOwnershipQueueService`), and a refused name reads as
* a queue that does not resolve (the row is written ownerless, in no queue).
* An organization's authored rule, and a declared rule no package ships, is
* read with no package: environment-wide, as before the key existed.
*
* ## As which identity
*
* A platform step, not app automation: every read — the queue's members, a
Expand Down Expand Up @@ -142,6 +155,12 @@ interface ActiveRule {
name: string;
object: string;
entries: RuleEntry[];
/**
* [ADR-0150 D3] The package that ships the declared rule — the package its
* queue names are read from — or `null` for an organization's authored rule
* and a declared rule no package ships (read environment-wide).
*/
packageId: string | null;
}

/** One evaluation's memo: least-loaded counts and user-target standings, never older than the insert that asked. */
Expand Down Expand Up @@ -169,6 +188,11 @@ function expressionOf(v: unknown): { dialect: string; source: string } | undefin
return undefined;
}

/** The package a declared rule's registry item is registered under (`_packageId`, the registry's stamp), or `null`. */
function declaredPackageOf(item: Record<string, unknown>): string | null {
return typeof item._packageId === 'string' && item._packageId !== '' ? item._packageId : null;
}

function ruleOf(item: unknown): ActiveRule | null {
if (!isRecord(item) || typeof item.name !== 'string' || typeof item.object !== 'string') return null;
if (item.active === false) return null;
Expand All @@ -186,7 +210,7 @@ function ruleOf(item: unknown): ActiveRule | null {
...(expressionOf(e.load) ? { load: expressionOf(e.load)!.source } : {}),
});
}
return entries.length > 0 ? { name: item.name, object: item.object, entries } : null;
return entries.length > 0 ? { name: item.name, object: item.object, entries, packageId: declaredPackageOf(item) } : null;
}

/**
Expand All @@ -197,7 +221,7 @@ function ruleOf(item: unknown): ActiveRule | null {
function authoredRuleOf(row: Record<string, unknown>): ActiveRule | null {
const name = present(row.name);
if (!name) return null;
return ruleOf({
const rule = ruleOf({
name,
object: row.object_name,
entries: [{
Expand All @@ -208,6 +232,8 @@ function authoredRuleOf(row: Record<string, unknown>): ActiveRule | null {
load: row.load,
}],
});
// An organization's row belongs to no package, whatever columns it carries.
return rule ? { ...rule, packageId: null } : null;
}

function sortOrderOf(row: Record<string, unknown>): number {
Expand Down Expand Up @@ -485,16 +511,23 @@ export class AssignmentRuleEvaluator {
this.warnOnce(`no-service::${rule.name}`, `[assignment] rule '${rule.name}' routes into a queue, but no ownership-queue service is registered — the row is written ownerless`, { rule: rule.name });
return;
}
// [ADR-0150 D3] The name is read from the rule's package; no package, no
// key — the environment-wide read, exactly as before the key existed.
const fromPackage = rule.packageId ? { callerPackageId: rule.packageId } : undefined;
let resolved: unknown;
try {
resolved = await queues.resolveQueue(queueName, organizationId);
resolved = fromPackage
? await queues.resolveQueue(queueName, organizationId, fromPackage)
: await queues.resolveQueue(queueName, organizationId);
} catch (err: any) {
this.host.logger?.warn?.('[assignment] the queue could not be resolved — the row is written as it stood', { rule: rule.name, queue: queueName, organization: organizationId, error: err?.message });
this.host.logger?.warn?.('[assignment] the queue could not be resolved — the row is written as it stood', { rule: rule.name, queue: queueName, organization: organizationId, package: rule.packageId, error: err?.message });
return;
}
if (!resolved) {
this.warnOnce(`unresolved::${rule.name}::${queueName}::${organizationId}`, `[assignment] rule '${rule.name}' routes into queue '${queueName}', which does not resolve in organization '${organizationId}' — the row is written ownerless, in no queue. Declare the queue, or author it in that organization.`, {
rule: rule.name, queue: queueName, organization: organizationId,
this.warnOnce(`unresolved::${rule.name}::${queueName}::${organizationId}`, `[assignment] rule '${rule.name}' routes into queue '${queueName}', which does not resolve in organization '${organizationId}'`
+ (rule.packageId ? ` from the rule's package '${rule.packageId}' (its own queue, else the one holder among its dependencies)` : '')
+ ` — the row is written ownerless, in no queue. Declare the queue, or author it in that organization.`, {
rule: rule.name, queue: queueName, organization: organizationId, package: rule.packageId,
});
return;
}
Expand All @@ -504,7 +537,9 @@ export class AssignmentRuleEvaluator {

let members: string[];
try {
members = await queues.expandMembers(queueName, organizationId);
members = fromPackage
? await queues.expandMembers(queueName, organizationId, fromPackage)
: await queues.expandMembers(queueName, organizationId);
} catch (err: any) {
this.host.logger?.warn?.('[assignment] the queue\'s members could not be read — the row parks in the queue', { rule: rule.name, queue: queueName, error: err?.message });
return;
Expand Down
152 changes: 152 additions & 0 deletions packages/objectql/src/ownership-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { QUEUE_FIELD_DEF } from '@objectstack/spec/data';
import { ObjectQL } from './engine.js';
import { applySystemFields, objectQueueColumnRefusal } from './registry.js';
import { OWNERSHIP_QUEUE_SOURCE_SLOT, assignmentExpressionRefusals, describeMissingOwnershipQueueService, nextMember } from './assignment-rules.js';
import { resolvePackageScopedName, type PackageScopedRefusal, type PackageScopedRegistry } from './package-scoped-resolver.js';
import type { SchemaRegistry } from './registry.js';

type Row = Record<string, unknown>;

Expand Down Expand Up @@ -467,6 +469,156 @@ describe('[ADR-0142 D5] the assignment seam', () => {
});
});

// ─── ADR-0150 D3: a declared rule's queue is read from the rule's package ──

describe('[ADR-0150 D3] a declared rule reads its queue name from the package that ships the rule', () => {
const P = 'com.acme.p';
const D = 'com.acme.d';
const U = 'com.acme.u';
/** Who staffs each holder's `support` queue in org_a. */
const STAFF: Record<string, string[]> = { [D]: ['u_d'], [U]: ['u_u'], [P]: ['u_p'] };

let engine: ObjectQL;
let d: ReturnType<typeof makeDriver>;
let warn: ReturnType<typeof vi.spyOn>;
/** Every read the seam made of the queue service: [method, name, organization, options]. */
let asked: unknown[][];
/** Every refusal the queue service answered a read with. */
let refusals: PackageScopedRefusal[];

const manifest = (id: string, deps: string[] = []) => ({
id, name: id, version: '1.0.0', type: 'app', namespace: id.split('.').pop(),
...(deps.length > 0 ? { dependencies: Object.fromEntries(deps.map((dep) => [dep, '^1.0.0'])) } : {}),
}) as never;
const queue = (name: string) => ({ name, label: name, members: [] });
const rule = { name: 'ticket_intake', label: 'Intake', object: 'ticket', active: true, entries: [{ name: 'all', assignTo: { type: 'queue', value: 'support' }, strategy: 'round_robin' }] };

/**
* The ownership-queue service's D3 read, standing in for
* `@objectstack/plugin-sharing`'s (which depends on this package, so this
* suite cannot construct it): the ONE resolver, `resolvePackageScopedName`,
* over the holders the listing serves — a disabled package's left out, as
* plugin-sharing's `listedHoldersView` does — and a refused name read as
* nowhere (`null` / `[]`), the contract's answer for the `nobody` reader.
*/
function d3Queues(registry: SchemaRegistry) {
const listed = {
getPackage: (id: string) => registry.getPackage(id),
getAllPackages: () => registry.getAllPackages(),
packageScopedHoldersOf: (type: string, name: string) =>
registry.packageScopedHoldersOf(type, name).filter((id) => !registry.isPackageDisabled(id)),
getPackageScopedItem: (type: string, name: string, packageId: string, member?: string) => registry.getPackageScopedItem(type, name, packageId, member),
getEnvironmentScopedItem: (type: string, name: string, member?: string) => registry.getEnvironmentScopedItem(type, name, member),
} as PackageScopedRegistry;
const holderOf = (name: string, options?: { callerPackageId?: string | null }): string | null => {
const resolution = resolvePackageScopedName(listed, { type: 'queue', name, callerPackageId: options?.callerPackageId || null });
if (resolution.status === 'refused') {
refusals.push(resolution);
return null;
}
return resolution.status === 'resolved' ? (resolution.holder.kind === 'package' ? resolution.holder.packageId : 'environment') : null;
};
return {
resolveQueue: async (name: string, org: string | null, options?: { callerPackageId?: string | null }) => {
asked.push(['resolveQueue', name, org, options]);
const holder = holderOf(name, options);
return holder && org === 'org_a' ? { name, label: name, source: 'declared', organizationId: null, members: [], packageId: holder } : null;
},
expandMembers: async (name: string, org: string | null, options?: { callerPackageId?: string | null }) => {
asked.push(['expandMembers', name, org, options]);
const holder = holderOf(name, options);
return holder && org === 'org_a' ? [...(STAFF[holder] ?? [])] : [];
},
};
}

/** Install the packages (`[id, deps]`, in order), declare `support` in each holder, and the rule in `rulePackage`. */
async function boot(packages: Array<[string, string[]]>, holders: string[], rulePackage: string | null) {
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');
for (const [id, deps] of packages) engine.registry.installPackage(manifest(id, deps), 'code');
for (const holder of holders) engine.registry.registerItem('queue', { ...queue('support'), _packageId: holder }, 'name', holder);
if (rulePackage) engine.registry.registerItem('assignment_rule', { ...rule, _packageId: rulePackage }, 'name', rulePackage);
else engine.registry.registerItem('assignment_rule', { ...rule }, 'name');
asked = [];
refusals = [];
(engine as any)[OWNERSHIP_QUEUE_SOURCE_SLOT] = () => d3Queues(engine.registry);
warn = vi.spyOn((engine as any).logger, 'warn');
}

const insert = () => engine.insert('ticket', { subject: 'A', organization_id: 'org_a' }, { context: SYS });

it('a rule in P naming `support`, held by P\'s dependency D and by unrelated U, routes to D\'s queue', async () => {
await boot([[D, []], [U, []], [P, [D]]], [D, U], P);
const row = await insert();
expect(row).toMatchObject({ queue: 'support', owner_id: 'u_d' });
expect(asked).toEqual([
['resolveQueue', 'support', 'org_a', { callerPackageId: P }],
['expandMembers', 'support', 'org_a', { callerPackageId: P }],
]);
expect(refusals).toEqual([]);
});

it('two holders in P\'s closure: refused, naming both — the row is written ownerless, in no queue', async () => {
await boot([[D, []], [U, []], [P, [D, U]]], [D, U], P);
const row = await insert();
expect(row.owner_id ?? null).toBeNull();
expect(row.queue ?? null).toBeNull();
expect(refusals).toHaveLength(1);
expect(refusals[0]).toMatchObject({
reason: 'ambiguous-in-closure',
callerPackageId: P,
candidates: [{ kind: 'package', packageId: D }, { kind: 'package', packageId: U }],
});
const hit = warn.mock.calls.find((c: unknown[]) => /does not resolve in organization 'org_a' from the rule's package 'com\.acme\.p'/.test(String(c[0])));
expect(hit, 'the seam says the name did not resolve from the rule\'s package').toBeTruthy();
expect(hit![1]).toMatchObject({ rule: 'ticket_intake', queue: 'support', package: P });
});

it('D disabled, unrelated U holding `support`: refused as outside P\'s closure, naming U — U\'s members are never served', async () => {
await boot([[D, []], [U, []], [P, [D]]], [D, U], P);
engine.registry.disablePackage(D);
const row = await insert();
expect(row.owner_id ?? null).toBeNull();
expect(row.queue ?? null).toBeNull();
expect(refusals).toHaveLength(1);
expect(refusals[0]).toMatchObject({
reason: 'outside-closure',
callerPackageId: P,
candidates: [{ kind: 'package', packageId: U }],
});
});

it('CONTROL — a declared rule no package ships reads environment-wide, as before: the same two holders are refused, naming both', async () => {
await boot([[D, []], [U, []], [P, [D]]], [D, U], null);
const row = await insert();
expect(row.owner_id ?? null).toBeNull();
expect(row.queue ?? null).toBeNull();
expect(asked).toEqual([['resolveQueue', 'support', 'org_a', undefined]]);
expect(refusals).toHaveLength(1);
expect(refusals[0]).toMatchObject({
reason: 'ambiguous-in-environment',
candidates: [{ kind: 'package', packageId: D }, { kind: 'package', packageId: U }],
});
});

it('CONTROL — an organization\'s own rule belongs to no package: it reads environment-wide, whatever the declared rules carry', async () => {
await boot([[D, []], [P, [D]]], [D], P);
engine.registry.registerObject(AUTHORED_RULE as any, 'plugin-sharing');
d.storeFor('sys_assignment_rule').set('ar1', {
id: 'ar1', name: 'org_intake', object_name: 'ticket', organization_id: 'org_a', sort_order: 1,
recipient_type: 'queue', recipient_id: 'support', strategy: 'round_robin', active: true,
});
const row = await insert();
expect(row).toMatchObject({ queue: 'support', owner_id: 'u_d' });
expect(asked[0]).toEqual(['resolveQueue', 'support', 'org_a', undefined]);
});
});

// ─── 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', () => {
Expand Down
Loading
Loading