From aa801fb655346b301ccc3c0df319191bfb778da3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 12:24:26 +0000 Subject: [PATCH 01/23] feat(spec): ownership queue and assignment rule contract (ADR-0142) Queue and assignment_rule metadata types, the stack's queues and assignmentRules keys, the queue sharing recipient and queue approver re-admitted, the ownership-queue service contract, the injected queue column, the public-form strip, explain kinds and the ledger rows. Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- .../src/injected-system-columns.ts | 1 + .../spec/src/api/error-code-ledger.zod.ts | 13 ++ packages/spec/src/automation/approval.test.ts | 41 ++-- packages/spec/src/automation/approval.zod.ts | 76 ++++--- .../src/automation/assignment-rule.zod.ts | 193 ++++++++++++++++++ packages/spec/src/automation/index.ts | 2 + packages/spec/src/contracts/index.ts | 2 + .../src/contracts/ownership-queue-service.ts | 96 +++++++++ .../src/contracts/sharing-service.test.ts | 28 +-- .../spec/src/contracts/sharing-service.ts | 12 +- .../data/injected-system-column-provenance.ts | 27 +++ .../spec/src/data/injected-system-columns.ts | 24 ++- .../spec/src/kernel/metadata-plugin.zod.ts | 25 +++ .../spec/src/kernel/metadata-type-schemas.ts | 7 + .../manifest-collection-spelling.ts | 3 + packages/spec/src/security/explain.zod.ts | 7 + packages/spec/src/security/index.ts | 2 + packages/spec/src/security/public-form.ts | 5 + packages/spec/src/security/queue.zod.ts | 122 +++++++++++ packages/spec/src/security/sharing.zod.ts | 42 +++- .../src/shared/metadata-collection.zod.ts | 2 + packages/spec/src/stack.zod.ts | 28 +++ .../spec/src/system/constants/system-names.ts | 12 ++ 23 files changed, 700 insertions(+), 70 deletions(-) create mode 100644 packages/spec/src/automation/assignment-rule.zod.ts create mode 100644 packages/spec/src/contracts/ownership-queue-service.ts create mode 100644 packages/spec/src/security/queue.zod.ts diff --git a/packages/metadata-core/src/injected-system-columns.ts b/packages/metadata-core/src/injected-system-columns.ts index 6def43fa014..5c57581edf0 100644 --- a/packages/metadata-core/src/injected-system-columns.ts +++ b/packages/metadata-core/src/injected-system-columns.ts @@ -68,6 +68,7 @@ export { TENANT_SCOPE_FIELD_DEF, OWNER_FIELD_DEF, OWNING_BUSINESS_UNIT_FIELD_DEF, + QUEUE_FIELD_DEF, injectedSystemColumnDefs, platformProvisionsStorage, resolveInjectedColumnProvenance, diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index d49f4f9e4c2..6bbb259995c 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -766,6 +766,13 @@ export const ERROR_CODE_LEDGER = { 'OS_PROTOCOL_INCOMPATIBLE', ], '@objectstack/objectql': [ + // [ADR-0142 D4] A by-id write carrying the engine-internal precondition + // (`expect`) matched no row: another writer moved the row first. 409 — + // the claim race's loser ("the record was taken") and the assignment + // cursor's lost advance, raised in `engine.ts`'s by-id update branch. + // Second EMITTER of the code `@objectstack/types` registers for the data + // door's optimistic-lock refusal; one condition, one vocabulary. + 'CONCURRENT_UPDATE', // [#8846] single-row insert: the autonumber counter was re-seeded and the // value re-issued, and the driver still refused the write (`engine.ts`; // carries the driver's failure as `cause`). One of four unswept members of @@ -1351,6 +1358,12 @@ export const ERROR_CODE_LEDGER = { // member: it is an extension code registered here, under that package and // now this one. 'NOT_OVERRIDABLE', + // [ADR-0142 D3/D4] 422: a caller-supplied `queue` (an insert naming one, or + // a move) names no queue that resolves in the row's organization — neither + // a declared queue switched on there nor one of its `sys_queue` rows. + // Raised by the ownership gate (step 3.5) before the write; a rule's + // unresolved target is never a refusal (D5). + 'QUEUE_NOT_FOUND', 'SUGGESTION_NOT_FOUND', 'SUGGESTION_STATE', // suggestion exists but is not in a confirmable/dismissable state ], diff --git a/packages/spec/src/automation/approval.test.ts b/packages/spec/src/automation/approval.test.ts index 50499b39747..dcd682cc321 100644 --- a/packages/spec/src/automation/approval.test.ts +++ b/packages/spec/src/automation/approval.test.ts @@ -53,26 +53,27 @@ describe('ApproverType', () => { // Cross-repo contract: the published node configSchema must carry // `xEnumDeprecated` on the approver type, or the Studio designer (objectui) // derives its dropdown straight from `enum` and keeps offering `role` — the - // exact trap ADR-0090 D3 retires — and `queue`, which the runtime never - // resolves (#3508). Renderers read this to omit these members from pickers - // while still rendering a stored value. - it('publishes xEnumDeprecated on the approver type so pickers drop `role` and `queue`', () => { + // exact trap ADR-0090 D3 retires. Renderers read this to omit these members + // from pickers while still rendering a stored value. + it('publishes xEnumDeprecated on the approver type so pickers drop `role`', () => { const schema = getApprovalNodeConfigJsonSchema() as any; const typeNode = schema?.properties?.approvers?.items?.properties?.type; expect(typeNode?.enum).toContain('role'); // still parses (back-compat) - expect(typeNode?.enum).toContain('queue'); // still parses (stored rows render) + expect(typeNode?.enum).toContain('queue'); expect(typeNode?.enum).toContain('org_membership_level'); expect(typeNode?.xEnumDeprecated).toEqual([...NON_AUTHORABLE_APPROVER_TYPES]); expect(typeNode?.xEnumDeprecated).toContain('role'); - expect(typeNode?.xEnumDeprecated).toContain('queue'); + // ADR-0142 D6: the queue approver resolves to the queue's members, so the + // picker offers it again. + expect(typeNode?.xEnumDeprecated).not.toContain('queue'); }); - // #3508: queue is declared-but-unenforced. It stays parseable (stored flows - // keep loading) but is not authorable, and every deprecated spelling is - // non-authorable too. - it('marks queue non-authorable while keeping it parseable', () => { + // Every deprecated spelling is non-authorable; `queue` is authorable again + // since the runtime resolves it (ADR-0142 D6, closing #3508's + // declared-but-unenforced gap). + it('keeps every deprecated spelling non-authorable and re-admits queue', () => { expect(() => ApproverType.parse('queue')).not.toThrow(); - expect(NON_AUTHORABLE_APPROVER_TYPES).toContain('queue'); + expect(NON_AUTHORABLE_APPROVER_TYPES).not.toContain('queue'); for (const spelling of Object.keys(DEPRECATED_APPROVER_TYPES)) { expect(NON_AUTHORABLE_APPROVER_TYPES).toContain(spelling); } @@ -175,14 +176,17 @@ describe('APPROVER_VALUE_BINDINGS — an approver value is picked from the recor expect(APPROVER_VALUE_BINDINGS.role).toEqual(APPROVER_VALUE_BINDINGS.org_membership_level); expect(APPROVER_VALUE_BINDINGS.manager.source).toBe('auto'); expect(APPROVER_VALUE_BINDINGS.field.source).toBe('trigger-field'); - expect(APPROVER_VALUE_BINDINGS.queue.source).toBe('unsupported'); + // ADR-0142 D6: a queue is named by its machine name, declared or authored. + expect(APPROVER_VALUE_BINDINGS.queue).toEqual({ + source: 'registry-and-record', type: 'queue', object: 'sys_queue', valueField: 'name', + }); }); it('publishes a manager mapping in the value xRef so designers can render auto-resolution', () => { const schema = getApprovalNodeConfigJsonSchema() as any; const valueNode = schema?.properties?.approvers?.items?.properties?.value; expect(valueNode?.xRef?.map?.manager).toBe('manager'); - // queue stays mapped so stored rows keep rendering during the window. + // queue maps to the queue picker (declared queues and `sys_queue` rows). expect(valueNode?.xRef?.map?.queue).toBe('queue'); }); }); @@ -194,8 +198,9 @@ describe('APPROVER_VALUE_SOURCES — where each picker finds its candidates, pub it('covers every ApproverType member, exactly like the bindings it projects', () => { for (const t of ApproverType.options) { expect(APPROVER_VALUE_SOURCES[t], `no source published for '${t}'`).toBeDefined(); + const bound = APPROVER_VALUE_BINDINGS[t].source; expect(APPROVER_VALUE_SOURCES[t].source).toBe( - APPROVER_VALUE_BINDINGS[t].source === 'record' ? 'data' : APPROVER_VALUE_BINDINGS[t].source, + bound === 'record' ? 'data' : bound === 'registry-and-record' ? 'registry-and-data' : bound, ); } }); @@ -225,7 +230,10 @@ describe('APPROVER_VALUE_SOURCES — where each picker finds its candidates, pub expect(APPROVER_VALUE_SOURCES.manager).toEqual({ source: 'auto' }); expect(APPROVER_VALUE_SOURCES.field).toEqual({ source: 'trigger-field' }); expect(APPROVER_VALUE_SOURCES.expression).toEqual({ source: 'expression' }); - expect(APPROVER_VALUE_SOURCES.queue).toEqual({ source: 'unsupported' }); + // ADR-0142 D6: both sources, one committed name. + expect(APPROVER_VALUE_SOURCES.queue).toEqual({ + source: 'registry-and-data', type: 'queue', object: 'sys_queue', valueField: 'name', + }); }); it('ships on the published JSON schema, where the designer reads it', () => { @@ -265,13 +273,12 @@ describe('ApproverType declaration order is the authoring recommendation', () => it('leads with indirect bindings and puts the literal `user` last', () => { expect(authorable).toEqual([ - 'manager', 'position', 'department', 'team', 'field', 'expression', 'org_membership_level', 'user', + 'manager', 'position', 'department', 'team', 'queue', 'field', 'expression', 'org_membership_level', 'user', ]); }); it('never offers a non-authorable spelling, wherever it sits in the enum', () => { expect(authorable).not.toContain('role'); - expect(authorable).not.toContain('queue'); }); }); diff --git a/packages/spec/src/automation/approval.zod.ts b/packages/spec/src/automation/approval.zod.ts index c860b8b8f34..549a6c66611 100644 --- a/packages/spec/src/automation/approval.zod.ts +++ b/packages/spec/src/automation/approval.zod.ts @@ -33,6 +33,14 @@ export const ApproverType = z.enum([ 'position', // Holders of a position (sys_user_position, ADR-0090 D3) 'department', // Members of a department + all descendant departments (sys_business_unit) 'team', // Members of a flat collaboration team (sys_team) + // [ADR-0142 D6] The members of an ownership queue, named by its machine + // name (a declared queue or the organization's `sys_queue` row), resolved at + // node entry in the request's organization through the ownership-queue + // service — the one expander every queue consumer reads. An empty queue + // resolves to nobody and the node's `onEmptyApprovers` decides. It was + // parse-only (`xEnumDeprecated`, no runtime branch) until the queue entity + // landed; it now sits with the other indirect bindings, ahead of `user`. + 'queue', 'field', // User ID defined in a record field /** * #3447 P2: a CEL expression resolved AT NODE ENTRY against three explicit @@ -62,14 +70,6 @@ export const ApproverType = z.enum([ // where it sits. 'role', 'user', // A specific user id — least portable; see the note above - // @deprecated #3508 — declared-but-unenforced: `resolveApproverSpec` in - // `plugin-approvals` has no queue branch, so a queue approver resolves to - // nobody (the dead `queue:` fallback literal). The sharing engine - // retired its `queue` recipient the same way (sys-sharing-rule). Still - // parses so stored flows keep rendering, but designers must not offer it - // (see NON_AUTHORABLE_APPROVER_TYPES). Re-admit only together with a real - // ownership-queue implementation (queue entity + membership + claim). - 'queue', ]) // The generated reference page renders THIS string under `## ApproverType`; // the JSDoc above the enum renders nowhere (`renderSchemaSection` prints @@ -127,15 +127,16 @@ export function canonicalApproverType(type: string): string { * Approver types that still PARSE but must not be offered for new authoring. * Published to designers as `xEnumDeprecated` on the approver `type` (see * {@link ApprovalNodeApproverSchema}): a stored value keeps rendering, the - * dropdown just stops handing authors the entry. Two sources: - * - the deprecated spellings in {@link DEPRECATED_APPROVER_TYPES} (ADR-0090 D3); - * - `queue`, declared-but-unenforced in the runtime (#3508) — offering it - * would advertise a capability the engine doesn't deliver (Prime - * Directive #10: declared ≠ enforced). + * dropdown just stops handing authors the entry. One source: the deprecated + * spellings in {@link DEPRECATED_APPROVER_TYPES} (ADR-0090 D3). + * + * `queue` was the second source while the runtime had no queue branch + * (offering it would have advertised a capability the engine did not deliver, + * Prime Directive #10). It left this list in the change that made the queue + * approver resolve to the queue's members (ADR-0142 D6). */ export const NON_AUTHORABLE_APPROVER_TYPES: readonly string[] = [ ...Object.keys(DEPRECATED_APPROVER_TYPES), - 'queue', ]; /** @@ -181,6 +182,16 @@ export type ApproverValueBinding = * the stored value. */ | { source: 'registry'; type: string } + /** + * [ADR-0142 D6, open item 1] `value` is the machine name of an item that has + * TWO sources under one name (ADR-0131 D6 regime C): a declared item of + * metadata `type`, listed by `GET /api/v1/meta/`, and an organization's + * own row of `object`, listed through the data API, whose `valueField` holds + * the same name. The picker lists both and commits the name either way — + * never a row id, because a declared item has none and the two sources share + * one namespace (an authored row may not take a declared name). + */ + | { source: 'registry-and-record'; type: string; object: string; valueField: 'name' } /** Closed value set — render a strict select, never free text. */ | { source: 'enum'; values: readonly string[] } /** @@ -238,7 +249,9 @@ export const APPROVER_VALUE_BINDINGS = { department: { source: 'record', object: 'sys_business_unit', valueField: 'id' }, manager: { source: 'auto' }, field: { source: 'trigger-field' }, - queue: { source: 'unsupported' }, + // A declared queue or an organization's `sys_queue` row, one name — see the + // `registry-and-record` member of ApproverValueBinding. + queue: { source: 'registry-and-record', type: 'queue', object: 'sys_queue', valueField: 'name' }, expression: { source: 'expression', roots: APPROVER_EXPRESSION_ROOTS }, } as const satisfies Record, ApproverValueBinding>; @@ -255,7 +268,7 @@ export const APPROVER_VALUE_BINDINGS = { * (#3508). * * The cure is NOT "never the registry": it is "the source the kind's value - * actually names". So two lookup sources reach the wire: + * actually names". So three lookup sources reach the wire: * * - `data` — a directory row. Query the data API for `object`, commit the * row's `valueField`. `user`, `team` and `department`. @@ -264,6 +277,9 @@ export const APPROVER_VALUE_BINDINGS = { * (ADR-0131 D2), referenced by machine name and resolved registry-first * (D4). A renderer that still reads `sys_position` rows for it lists a * mirror the ADR-0131 cutover retires, not the catalog. + * - `registry-and-data` — [ADR-0142 D6] both at once, one name: list the + * declared items (`GET /api/v1/meta/`) AND the organization's rows + * of `object` (the data API), commit the `name` either way. `queue`. * * Publishing the binding closes that gap at the source: a renderer reads where * to look and what to commit off the schema instead of re-deriving it, and a @@ -287,14 +303,18 @@ export const APPROVER_VALUE_SOURCES = Object.fromEntries( // `registry` names the metadata registry, for a kind whose value IS a // registry item's name (ADR-0131 D4). ? { source: 'registry', type: binding.type } - : binding.source === 'enum' - ? { source: 'enum', values: [...binding.values] } - : { source: binding.source }, + : binding.source === 'registry-and-record' + // [ADR-0142 D6] Both lookups, one committed name. + ? { source: 'registry-and-data', type: binding.type, object: binding.object, valueField: binding.valueField } + : binding.source === 'enum' + ? { source: 'enum', values: [...binding.values] } + : { source: binding.source }, ]), ) as Record< z.infer, | { source: 'data'; object: string; valueField: string } | { source: 'registry'; type: string } + | { source: 'registry-and-data'; type: string; object: string; valueField: string } | { source: 'enum'; values: string[] } | { source: 'auto' | 'trigger-field' | 'expression' | 'unsupported' } >; @@ -354,7 +374,9 @@ export const APPROVER_ORG_SCOPED = { department: true, manager: false, field: false, - queue: false, + // [ADR-0142 D6] A queue is an organization's: it resolves, and its members + // expand, in the request's organization or the one `organization` targets. + queue: true, expression: true, } as const satisfies Record, boolean>; @@ -484,9 +506,9 @@ export const ApprovalNodeApproverSchema = lazySchema(() => strictObject( // `xEnumDeprecated` lists enum members that still PARSE but must not be // offered for new authoring. Without it the Studio designer derives its // approver-type dropdown straight from this enum and keeps offering `role` - // — the exact trap ADR-0090 D3 is retiring — one click away from `position` - // — and `queue`, which the runtime never resolves (#3508). Renderers omit - // these from pickers while still rendering a stored value. + // — the exact trap ADR-0090 D3 is retiring — one click away from `position`. + // (`queue` was listed too until the runtime resolved it, ADR-0142 D6.) + // Renderers omit these from pickers while still rendering a stored value. type: ApproverType.meta({ xEnumDeprecated: [...NON_AUTHORABLE_APPROVER_TYPES], }), @@ -494,7 +516,7 @@ export const ApprovalNodeApproverSchema = lazySchema(() => strictObject( * The approver reference, interpreted per `type`: a user id (`user`), a * membership tier — owner/admin/member (`org_membership_level`), a position * machine name (`position`), team/department id (`team`/`department`), field - * name holding a user id (`field`), or queue id (`queue`). Omitted for + * name holding a user id (`field`), or queue machine name (`queue`). Omitted for * `manager` (resolved from the submitter's `manager_id`). */ // `xRef` marks this string as a *polymorphic* typed reference (ADR-0018 @@ -511,13 +533,13 @@ export const ApprovalNodeApproverSchema = lazySchema(() => strictObject( // entry: it maps to the SAME picker as the canonical spelling, so a stored // legacy node still renders correctly for its deprecation window. `manager` // maps to a picker kind that renders as "auto-resolved" (no free text); - // `queue` stays mapped so stored rows keep rendering even though the type - // is no longer authorable (see NON_AUTHORABLE_APPROVER_TYPES). + // `queue` maps to the queue picker, which lists declared queues and the + // organization's `sys_queue` rows (ADR-0142 D6). // `expression` is intentionally ABSENT from the map: an unmapped type keeps // the value as free text, so the designer renders a plain input for the CEL // source until a dedicated expression editor lands (objectui follow-up). value: z.string().optional().meta({ - description: 'User id / membership tier / position / team / department / field — per `type`; ' + description: 'User id / membership tier / position / team / department / field / queue name — per `type`; ' + 'for `expression`, a CEL expression over `current.*` / `trigger.*` / `vars.*`', xRef: { kindFrom: 'type', diff --git a/packages/spec/src/automation/assignment-rule.zod.ts b/packages/spec/src/automation/assignment-rule.zod.ts new file mode 100644 index 00000000000..6fd31a6da02 --- /dev/null +++ b/packages/spec/src/automation/assignment-rule.zod.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; +import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; +import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +import { strictObject } from '../shared/strict-object'; +import { lazySchema } from '../shared/lazy-schema'; + +const BORN_CLOSED = 'Declared closed from its first day: an unrecognized key is refused, never dropped.'; + +/** + * [ADR-0142 D5] How an assignment entry with a queue target picks one member. + * + * - `least_loaded` — for each member, count the object's rows in the + * organization that the member owns and that match the entry's `load` + * predicate; the smallest count wins, a tie goes to the earlier member + * (members are ordered by user id). + * - `round_robin` — the member after the one the entry's cursor last chose. + * + * An entry with a queue target and no strategy PARKS the row: it sits in the + * queue, `owner_id` empty, until a member claims it. + */ +export const AssignmentStrategy = z.enum(['round_robin', 'least_loaded']); +export type AssignmentStrategy = z.input; + +/** + * [ADR-0142 D5] Where an entry sends a matching row: a queue, named by its + * machine name, or one user, named by id. + */ +export const AssignmentTargetSchema = strictObject( + { + surface: 'this assignment target', + aliases: { id: 'value', name: 'value', queue: 'value', user: 'value' }, + history: BORN_CLOSED, + }, + { + type: z.enum(['queue', 'user']).describe("'queue' routes into a queue (by machine name); 'user' names one owner (by id)"), + value: z.string().min(1).describe('The queue machine name, or the user id'), + }, +).superRefine((target, ctx) => { + // A queue is referenced by its machine name everywhere (ADR-0142 D1); an id + // or a label would resolve nowhere and the row would be left ownerless. + if (target.type === 'queue' && !/^[a-z][a-z0-9_]*$/.test(target.value)) { + ctx.addIssue({ + code: 'custom', + path: ['value'], + message: + `\`assignTo.value\` must name a queue by its machine name when \`type\` is 'queue' ` + + `(got ${JSON.stringify(target.value)}): the snake_case name a stack's \`queues\` entry declares or an ` + + "organization's `sys_queue` row carries.", + }); + } +}); + +/** + * [ADR-0142 D5] One entry of an assignment rule. Entries are evaluated in + * order; the first whose `criteria` match the row in flight wins. + */ +export const AssignmentRuleEntrySchema = strictObject( + { + surface: 'this assignment rule entry', + aliases: { condition: 'criteria', when: 'criteria', filter: 'criteria', target: 'assignTo', assignto: 'assignTo' }, + history: BORN_CLOSED, + }, + { + name: SnakeCaseIdentifierSchema.describe('Entry machine name, unique within the rule (the round-robin cursor is kept per entry)'), + criteria: EvaluatedExpressionInputSchema.optional().describe( + 'CEL over `record.*`, interpreted against the row being inserted (it is not stored yet). Absent = every row.', + ), + assignTo: AssignmentTargetSchema.describe('Where a matching row goes: a queue or one user'), + strategy: AssignmentStrategy.optional().describe( + "How one member of the target queue is chosen: 'round_robin' or 'least_loaded'. Only with a queue " + + 'target; absent = the row parks in the queue for a member to claim.', + ), + load: EvaluatedExpressionInputSchema.optional().describe( + "Required with 'least_loaded', refused otherwise: a CEL pushdown predicate over the object naming the " + + "rows that count as a member's open load (e.g. `record.status != \"closed\"`). There is no platform " + + 'default, because what is open differs per object.', + ), + }, +).superRefine((entry, ctx) => { + if (entry.strategy !== undefined && entry.assignTo.type !== 'queue') { + ctx.addIssue({ + code: 'custom', + path: ['strategy'], + message: + `\`strategy: '${entry.strategy}'\` distributes rows over a queue's members, but this entry assigns to ` + + "one user. Remove `strategy`, or set `assignTo: { type: 'queue', value: '' }`.", + }); + } + if (entry.strategy === 'least_loaded' && entry.load === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['load'], + message: + "`strategy: 'least_loaded'` needs `load`: the CEL predicate naming the rows that count as a member's " + + 'open work (e.g. `record.status != "resolved" && record.status != "closed"`). There is no default — ' + + 'counting every row would count closed work too.', + }); + } + if (entry.load !== undefined && entry.strategy !== 'least_loaded') { + ctx.addIssue({ + code: 'custom', + path: ['load'], + message: + "`load` is read only by `strategy: 'least_loaded'`, and this entry " + + (entry.strategy === undefined ? 'declares no strategy' : `declares '${entry.strategy}'`) + + ". Remove `load`, or set `strategy: 'least_loaded'`.", + }); + } +}); + +/** + * Assignment Rule — declared routing of an inserted row to a queue or a user + * (ADR-0142 D5). + * + * The engine evaluates an object's active rules ONCE per inserted row, after + * every `beforeInsert` hook has run and before the write, and only for a row + * whose `owner_id` and `queue` are both still empty: a writer-supplied owner + * was adjudicated by the ownership gate, and a hook that set or cleared one + * spoke for the scenario first. An update never evaluates a rule. + * + * The evaluation is a platform step: its reads run under a system context + * pinned to the row's organization, and the owner it picks is ownership, not + * attribution — the row's audit columns still record the writer. It never + * refuses an insert: an empty queue, a queue that does not resolve in the + * 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`. + */ +export const AssignmentRuleSchema = lazySchema(() => strictObject( + { + surface: 'this assignment rule', + aliases: { title: 'label', rules: 'entries', enabled: 'active', objectname: 'object' }, + guidance: { + runAs: + '`runAs` is not an assignment-rule field — the evaluation is a platform step that reads under a ' + + "system context pinned to the row's organization, never a hook's or a flow's declared identity " + + '(ADR-0073 D2).', + on: + '`on` is not an assignment-rule field — a rule is evaluated once, on insert, for a row that is still ' + + 'ownerless after every hook. Re-routing on an update is not part of this surface.', + }, + history: BORN_CLOSED, + }, + { + name: SnakeCaseIdentifierSchema.describe('Rule machine name (lowercase snake_case)'), + label: z.string().optional().describe('Display label'), + description: z.string().optional().describe('Administrative notes'), + object: SnakeCaseIdentifierSchema.describe('The object whose inserted rows the rule routes (an `ownership: user` object)'), + active: z.boolean().default(true).describe('Whether the rule is evaluated'), + entries: z.array(AssignmentRuleEntrySchema).min(1).describe( + 'Ordered entries; the first whose criteria match the inserted row decides where it goes', + ), + + // ADR-0010 — runtime protection envelope (internal — set by loader). + ...MetadataProtectionFields, + }, +).superRefine((rule, ctx) => { + const seen = new Set(); + rule.entries.forEach((entry, i) => { + if (seen.has(entry.name)) { + ctx.addIssue({ + code: 'custom', + path: ['entries', i, 'name'], + message: + `Two entries of assignment rule '${rule.name}' are named '${entry.name}'. Entry names are unique ` + + 'within a rule: the round-robin cursor is kept per entry, so two entries sharing one would share ' + + 'one rotation.', + }); + } + seen.add(entry.name); + }); +})); + +export type AssignmentRule = z.input; +/** Post-parse shape of {@link AssignmentRule} — defaults applied, transforms run (ADR-0122). */ +export type AssignmentRuleParsed = z.infer; +export type AssignmentRuleEntry = z.input; +export type AssignmentTarget = z.input; + +/** + * Type-safe factory for an assignment rule. Validates at authoring time via + * `.parse()` and accepts input-shape config. + */ +export function defineAssignmentRule(config: z.input): AssignmentRuleParsed { + return AssignmentRuleSchema.parse(config); +} diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts index a44b90d5f9b..a8bb57d9a41 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -15,6 +15,8 @@ export { flowForm } from './flow.form'; export * from './execution.zod'; export * from './webhook.zod'; export * from './approval.zod'; +// [ADR-0142 D5] Declared routing of an inserted row to a queue or a user. +export * from './assignment-rule.zod'; // `etl.zod.ts` (L2 "ETL Pipeline": ETLPipeline, ETLPipelineRun, their // source/destination/transformation vocabulary, the ETLEndpointType / // ETLTransformationType / ETLSyncMode / ETLRunStatus enums and the `ETL` diff --git a/packages/spec/src/contracts/index.ts b/packages/spec/src/contracts/index.ts index 428e1e0d36b..e158e09748c 100644 --- a/packages/spec/src/contracts/index.ts +++ b/packages/spec/src/contracts/index.ts @@ -57,6 +57,8 @@ export * from './sms-service.js'; export * from './security-service.js'; export * from './sharing-service.js'; export * from './rls-membership-resolver.js'; +// [ADR-0142 D1/D2] The ownership-queue service (registered as `ownership-queue`, never `queue`). +export * from './ownership-queue-service.js'; export * from './share-link-service.js'; // './report-service.js' (the saved-report contract and its seven types) removed // (#20102): the saved-report stack it described had zero callers in any repo. diff --git a/packages/spec/src/contracts/ownership-queue-service.ts b/packages/spec/src/contracts/ownership-queue-service.ts new file mode 100644 index 00000000000..e1123ac1648 --- /dev/null +++ b/packages/spec/src/contracts/ownership-queue-service.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @objectstack/spec/contracts/ownership-queue-service + * + * [ADR-0142 D1/D2] The ownership-queue service: resolve a queue by name in an + * organization, expand its members, test one user's membership, list the + * queues a user belongs to. `@objectstack/plugin-sharing` implements it, beside + * the expanders and the owner match it already owns. + * + * ## Why the service is not named `queue` + * + * The kernel service name `queue` belongs to the JOB queue + * (`@objectstack/service-queue`, the core in-memory fallback, and every + * `getService('queue')` caller that enqueues durable work). An ownership queue + * is a queue of PEOPLE that records sit in — a different noun, as `sys_queue` + * and `sys_job_queue` are different tables. The service therefore registers + * under {@link OWNERSHIP_QUEUE_SERVICE}, and every probe for "is a queue + * service present" — the engine's boot refusal for a stack that declares + * `queues` or `assignmentRules` included — tests that name, never `queue`: a + * composition that loads the job queue and no ownership-queue service would + * otherwise pass the check with nothing behind its declared queues. + * + * ## One expander for every consumer + * + * {@link IOwnershipQueueService.expandMembers} is the ONE function the five + * consumers read: the sitting-record access path, the claim admission, the + * assignment pool, the approver slate and the sharing recipient. Its answer is + * de-duplicated and ordered by user id ascending, so round-robin and the + * least-loaded tie-break see one stable order wherever they run. + * + * ## Absence is not a fault, and never a grant + * + * A name that resolves nowhere answers `null` / `[]` / `false`; an empty queue + * answers `[]`. Each consumer says what it does with that (ADR-0142 D5, D6, + * D7) — and every queue-dependent ADMISSION fails closed when the service is + * not registered at all. + */ + +import type { QueueMemberType } from '../security/queue.zod.js'; + +/** Kernel service name the ownership-queue service registers under. ⛔ Not `queue` — see the module header. */ +export const OWNERSHIP_QUEUE_SERVICE = 'ownership-queue'; + +/** One member specification: a recipient kind and the value that kind takes in a sharing rule. */ +export interface QueueMemberSpec { + type: QueueMemberType; + value: string; +} + +/** A queue as the service resolves it in one organization. */ +export interface ResolvedQueue { + /** The machine name every reference uses. */ + name: string; + label: string; + /** + * `declared` — a stack's `queues` entry, served from the registry. + * `authored` — an organization's `sys_queue` row. + */ + source: 'declared' | 'authored'; + /** The organization an authored queue belongs to; `null` for a declared queue (it is served to every organization). */ + organizationId: string | null; + /** The member specifications, as declared or authored — not yet expanded. */ + members: QueueMemberSpec[]; + /** The package that ships a declared queue, when it names one. */ + packageId?: string; +} + +/** + * The ownership-queue service. Every method resolves the queue in + * `organizationId` — the organization of the request or of the row — with + * declared queues first and the organization's `sys_queue` rows second + * (ADR-0131 D4's registry-first rule). A declared queue switched off in the + * activation ledger resolves nowhere. + */ +export interface IOwnershipQueueService { + /** The queue named `name` in `organizationId`, or `null` when it resolves nowhere. */ + resolveQueue(name: string, organizationId: string | null): Promise; + + /** + * The queue's members in `organizationId`: the union of each member + * specification's expansion, de-duplicated and ordered by user id + * ascending. `[]` for an empty queue and for a name that resolves nowhere. + */ + expandMembers(name: string, organizationId: string | null): Promise; + + /** Is `userId` a member of the queue named `name` in `organizationId`? `false` when the name resolves nowhere. */ + isMember(userId: string, name: string, organizationId: string | null): Promise; + + /** + * The machine names of every queue `userId` is a member of in + * `organizationId` — the set the sitting-record access path reads as + * `current_user.queues`. Ordered by name. + */ + queuesOf(userId: string, organizationId: string | null): Promise; +} diff --git a/packages/spec/src/contracts/sharing-service.test.ts b/packages/spec/src/contracts/sharing-service.test.ts index 4537b4f0103..d695b0b9b38 100644 --- a/packages/spec/src/contracts/sharing-service.test.ts +++ b/packages/spec/src/contracts/sharing-service.test.ts @@ -62,40 +62,44 @@ describe('Sharing Service Contract — recipient vocabularies, each under its ow }); it('security ShareRecipientType is the RULE vocabulary and shares no declaration', () => { - // The authorable rule-recipient enum: SharingRuleRecipientType minus the - // reserved `queue`. Distinct concept, distinct values — `group` / `guest` - // were deliberately removed from it (ADR-0078) while the record-share ROW - // vocabulary keeps persisting `group` for forward compatibility (its - // `guest` went too, ADR-0138 D8). + // The authorable rule-recipient enum. Distinct concept, distinct values — + // `group` / `guest` were deliberately removed from it (ADR-0078) while the + // record-share ROW vocabulary keeps persisting `group` for forward + // compatibility (its `guest` went too, ADR-0138 D8). expect(ShareRecipientType.options).toEqual([ 'user', 'team', 'position', 'unit_and_subordinates', 'business_unit', + // [ADR-0142 D7] re-admitted with its implementation: the members of an + // ownership queue, expanded through the ownership-queue service. + 'queue', // [#14103] the record-relative recipient — a user-typed field on the // matched record; per-record expansion is the services half (#15072). 'field', ]); const ruleRecipient: SharingRuleRecipientType = 'queue'; - // @ts-expect-error `queue` is reserved to the runtime rule contract — not authorable - const notAuthorable: (typeof ShareRecipientType.options)[number] = 'queue'; - expect(ruleRecipient).toBe(notAuthorable); + const authorable: (typeof ShareRecipientType.options)[number] = 'queue'; + expect(ruleRecipient).toBe(authorable); }); - it('the stored-row union is exactly the authoring enum plus the reserved `queue`', () => { + it('the stored-row union is exactly the authoring enum (ADR-0142 D7)', () => { // `plugin-sharing`'s declared-rule bootstrap copies `sharedWith.type` onto // `sys_sharing_rule.recipient_type` member-for-member (an unmapped value is // skipped with a warning), so a member added to one list and not the other // is a rule that parses and is never seeded — the ADR-0078 shape. Pinned at // the TYPE level so the drift is a compile error, not a runtime surprise. + // Until `queue` was re-admitted the union was this enum plus that one + // reserved member; the two are now the same seven. type Authorable = (typeof ShareRecipientType.options)[number]; - const inStep: Assert, Authorable>> = true; + const inStep: Assert> = true; expect(inStep).toBe(true); // And at the VALUE level: every authorable member is assignable to the // stored-row union (a runtime list, so a reader sees the members). - const stored: SharingRuleRecipientType[] = [...ShareRecipientType.options, 'queue']; - expect(stored).toHaveLength(ShareRecipientType.options.length + 1); + const stored: SharingRuleRecipientType[] = [...ShareRecipientType.options]; + expect(stored).toHaveLength(7); + expect(stored).toContain('queue'); expect(stored).toContain('field'); }); }); diff --git a/packages/spec/src/contracts/sharing-service.ts b/packages/spec/src/contracts/sharing-service.ts index 49438e806b5..40dea8f0338 100644 --- a/packages/spec/src/contracts/sharing-service.ts +++ b/packages/spec/src/contracts/sharing-service.ts @@ -452,10 +452,11 @@ export interface ISharingService { * `sys_sharing_rule.recipient_type`. * * The authoring enum `ShareRecipientType` (`spec/security`, `sharing.zod.ts`) - * is this union minus `queue`: `plugin-sharing`'s declared-rule bootstrap maps + * is this union, member for member, since `queue` was re-admitted with its + * implementation (ADR-0142 D7): `plugin-sharing`'s declared-rule bootstrap maps * `sharedWith.type` onto this column member-for-member (an unmapped value is * skipped with a warning, never widened), and `sharing-service.test.ts` beside - * this file pins the two lists in step. + * this file pins the two lists equal. * * - `user` — a specific user id (no expansion) * - `team` — a flat collaboration team (`sys_team` + `sys_team_member`) @@ -471,8 +472,11 @@ export interface ISharingService { * empty column shares with nobody). Expansion is * per record, not per rule, and re-materialises on * the record's own write. Executor: #15072. - * - `queue` — reserved: no `sys_queue` yet, `expandRecipient` - * returns `[]`, deliberately not authorable + * - `queue` — every member of an ownership queue, named by + * its machine name (a declared queue or the + * organization's `sys_queue` row; ADR-0142 D7), + * expanded through the ownership-queue service and + * re-materialised when its membership moves */ export type SharingRuleRecipientType = 'user' | 'team' | 'business_unit' | 'position' | 'unit_and_subordinates' | 'queue' | 'field'; diff --git a/packages/spec/src/data/injected-system-column-provenance.ts b/packages/spec/src/data/injected-system-column-provenance.ts index e4e5e90c478..68c13496558 100644 --- a/packages/spec/src/data/injected-system-column-provenance.ts +++ b/packages/spec/src/data/injected-system-column-provenance.ts @@ -162,6 +162,32 @@ export const OWNING_BUSINESS_UNIT_FIELD_DEF: Readonly> = 'NULL until the stamping middleware lands.', }; +/** + * [ADR-0142 D3] `queue` — the ownership queue a record sits in while its + * `owner_id` is empty, and its home queue once an owner takes it. A text + * column holding a queue MACHINE NAME, not a lookup: a declared queue is a + * catalog item with no row to point at, and an organization's `sys_queue` row + * is referenced by the same name. + * + * Shaped after `owner_id`, not after `organization_id`: `readonly: false`, + * because claim, release and move are ordinary writes the ownership gate + * adjudicates (and an assignment rule stamps it as the platform). `hidden: + * true`, because a free-text box for a machine name on every user-owned + * object's default layout would invite exactly the typo the gate refuses with + * `QUEUE_NOT_FOUND`; a Console renders the queue through its own picker. + */ +export const QUEUE_FIELD_DEF: Readonly> = { + type: 'text', + label: 'Queue', + required: false, + hidden: true, + readonly: false, + system: true, + description: + 'Ownership queue (a queue machine name). With no owner the record sits in this queue for its members to ' + + 'claim; with an owner it is the home queue a release returns the record to.', +}; + /** * The injected columns THIS object carries, as `name -> definition`. * @@ -183,6 +209,7 @@ export function injectedSystemColumnDefs(def: unknown): Record` or a `sys_*` name | no ownership anchors (either tier) | * | `ownership: 'org' \| 'none'` | no ownership anchors (either tier) | - * | `ownership: 'business_unit'` | `owning_business_unit_id` but NOT `owner_id` | - * | `ownership: 'user'` / omitted | both ownership anchors | + * | `ownership: 'business_unit'` | `owning_business_unit_id` but NOT `owner_id` or `queue` | + * | `ownership: 'user'` / omitted | both ownership anchors, and `queue` | * * The ownership rows are a POSITIVE list, not a deny-list, for the reason * ADR-0117 D1 / #5677 record at the injection site: under a deny-list a NEW @@ -196,7 +206,7 @@ export function resolveInjectedSystemColumns( // even on the two "nothing is injected" rows below. const names = new Set([PRIMARY_KEY_COLUMN]); const nothing: InjectedSystemColumnPlan = { - tenant: false, audit: false, owner: false, owningBusinessUnit: false, names, + tenant: false, audit: false, owner: false, owningBusinessUnit: false, queue: false, names, }; // 1. Hard opt-out at object level (e.g. seed/migration tables). @@ -231,11 +241,15 @@ export function resolveInjectedSystemColumns( const owner = ownershipEligible && (ownership === undefined || ownership === 'user'); const owningBusinessUnit = owner || (ownershipEligible && ownership === 'business_unit'); + // [ADR-0142 D3] The queue column rides with the owner anchor, never apart + // from it: "sits in Q" is `queue == Q AND owner_id IS NULL`. + const queue = owner; if (tenant) names.add(TENANT_SCOPE_COLUMN); if (audit) for (const f of AUDIT_PROVENANCE_FIELDS) names.add(f); if (owner) names.add(OWNER_COLUMN); if (owningBusinessUnit) names.add(OWNING_BUSINESS_UNIT_COLUMN); + if (queue) names.add(QUEUE_COLUMN); - return { tenant, audit, owner, owningBusinessUnit, names }; + return { tenant, audit, owner, owningBusinessUnit, queue, names }; } diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index 818bb75b12f..b83e0b2b8e1 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -188,6 +188,15 @@ export const MetadataTypeSchema = lazySchema(() => z.enum([ // wall. Regime C (ADR-0131 D6) has two sources and only two; the registry // entry declares the type code-only so both doors read one sealed answer. 'sharing_rule', // Package-declared sharing rules (SharingRuleSchema, ADR-0131 D6 regime C) + // [ADR-0142 D1] An ownership queue — a named set of people records sit in. + // Regime C exactly as `sharing_rule`: a package declares it (the stack's + // `queues`, read from the registry) and an organization authors its own + // (`sys_queue` rows); the registry entry below is code-only. + 'queue', // Package-declared ownership queues (QueueSchema, ADR-0142 D1) + // [ADR-0142 D5] Declared routing of an inserted row to a queue or a user, + // evaluated by the engine once per insert. Declared only (merge question + // 11); switched off through the activation ledger. + 'assignment_rule', // Package-declared assignment rules (AssignmentRuleSchema, ADR-0142 D5) // AI Protocol 'agent', // AI agent definitions (AgentSchema) @@ -1252,6 +1261,22 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntryParsed[] = // the glob. `supportsOverlay: false` — a declared rule is customized by a // clone, never by an overlay of it. { type: 'sharing_rule', label: 'Sharing Rule', description: 'A rule a package declares to share records; organizations author their own in Setup', filePatterns: ['**/*.sharing-rule.ts', '**/*.sharing-rule.yml'], supportsOverlay: false, allowEnvironmentOverlay: false, allowRuntimeCreate: false, supportsVersioning: false, executionPinned: false, loadOrder: 16, domain: 'security' }, + // [ADR-0142 D1, regime C] Declared ownership queues — modelled on the + // `sharing_rule` entry above, for the same two-source reason: a package + // declares a queue (the stack's `queues`, served from the registry, switched + // off through the activation ledger) and an organization authors its own as + // `sys_queue` rows, where the same-name refusal and the organization wall + // hold. A runtime `sys_metadata` item is neither, so the type is code-only. + // `position`'s entry allows runtime creation and is deliberately not the + // model. `loadOrder: 16` — after `position` (15), which a declared queue's + // 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 + // 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' }, // AI Protocol // `agent`: executionPinned — long-running conversations must stick to the diff --git a/packages/spec/src/kernel/metadata-type-schemas.ts b/packages/spec/src/kernel/metadata-type-schemas.ts index e3b53667b1e..43d31521b53 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.ts @@ -54,6 +54,8 @@ import { CubeSchema } from '../data/analytics.zod'; import { DeclarativeConnectorEntrySchema } from '../integration/connector.zod'; import { SharingRuleSchema } from '../security/sharing.zod'; +import { QueueSchema } from '../security/queue.zod'; +import { AssignmentRuleSchema } from '../automation/assignment-rule.zod'; import { ApiEndpointSchema } from '../api/endpoint.zod'; @@ -192,6 +194,11 @@ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial> = // ADR-0010 envelope. It is an AUTHOR-shape check applied where author shapes // are submitted, never a filter on rows already in `sys_sharing_rule`. sharing_rule: SharingRuleSchema, + // [ADR-0142 D1/D5] Bound to the schemas the stack's `queues` and + // `assignmentRules` are validated against — the same author-shape check the + // `sharing_rule` binding above makes, never a filter on `sys_queue` rows. + queue: QueueSchema, + assignment_rule: AssignmentRuleSchema, // AI Protocol agent: AgentSchema, diff --git a/packages/spec/src/meta-spelling/manifest-collection-spelling.ts b/packages/spec/src/meta-spelling/manifest-collection-spelling.ts index 4bbab747f92..75d11da8902 100644 --- a/packages/spec/src/meta-spelling/manifest-collection-spelling.ts +++ b/packages/spec/src/meta-spelling/manifest-collection-spelling.ts @@ -82,6 +82,9 @@ export const PLURAL_TO_SINGULAR: Record = { // `'capabilities'`, a store nothing reads. capabilities: 'capability', sharingRules: 'sharing_rule', + // [ADR-0142 D1/D5] Ownership queues and the assignment rules that route into them. + queues: 'queue', + assignmentRules: 'assignment_rule', apis: 'api', webhooks: 'webhook', agents: 'agent', diff --git a/packages/spec/src/security/explain.zod.ts b/packages/spec/src/security/explain.zod.ts index ae5c22a9384..2d6c72c5458 100644 --- a/packages/spec/src/security/explain.zod.ts +++ b/packages/spec/src/security/explain.zod.ts @@ -94,6 +94,13 @@ export const ExplainMatchedRuleSchema = lazySchema(() => z.object({ 'team', // team / account-team membership grant 'territory', // territory assignment grant 'rls_policy', // a business RLS policy predicate + // [ADR-0142 D3] the record sits in a queue the principal is a member of + // (`owner_id IS NULL AND queue IN current_user.queues`) — read, update and + // transfer, never delete. + 'queue_membership', + // [ADR-0142 D4] the transfer is a claim: the record is unowned, the new + // owner is the principal, and the sharing model positively admits them. + 'queue_claim', ]).describe('The row-visibility source kind evaluated for this record at this layer.'), /** Identifier of the concrete rule/share/policy (sharing-rule name, policy id, share row id). */ name: z.string().describe('Stable identifier of the concrete rule, share, or policy that was evaluated.'), diff --git a/packages/spec/src/security/index.ts b/packages/spec/src/security/index.ts index 4fa3140b545..5605b629cdf 100644 --- a/packages/spec/src/security/index.ts +++ b/packages/spec/src/security/index.ts @@ -22,6 +22,8 @@ export * from './high-privilege'; export * from './public-form'; export * from './explain.zod'; export * from './sharing.zod'; +// [ADR-0142 D1/D2] The ownership queue: a named set of people records sit in. +export * from './queue.zod'; export * from './rls.zod'; export * from './tenancy-posture'; export * from './tenant-layer0-verdict'; diff --git a/packages/spec/src/security/public-form.ts b/packages/spec/src/security/public-form.ts index 1750ce23cab..751a8f7bd18 100644 --- a/packages/spec/src/security/public-form.ts +++ b/packages/spec/src/security/public-form.ts @@ -38,6 +38,11 @@ export const PUBLIC_FORM_SERVER_MANAGED_FIELDS: ReadonlySet = new Set([ // stamped a forged value moves the row behind another department's wall, and // a denylist entry added after the column ships is a hole with a release in it. 'owning_business_unit_id', + // Queue anchor (ADR-0142 D3): with `owner_id` empty, a forged value parks + // the row in a queue the visitor chose — readable and editable by its + // members. A guest never supplies it; the endpoint doors refuse it in the + // ownership gate's guest branch, this door strips it. + 'queue', // Tenant anchors — a forged value lands the row in another tenant. 'organization_id', 'tenant_id', diff --git a/packages/spec/src/security/queue.zod.ts b/packages/spec/src/security/queue.zod.ts new file mode 100644 index 00000000000..43936aa81ac --- /dev/null +++ b/packages/spec/src/security/queue.zod.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; +import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +import { strictObject } from '../shared/strict-object'; +import { lazySchema } from '../shared/lazy-schema'; +import { ShareRecipientType } from './sharing.zod'; + +/** + * [ADR-0142 D2] The recipient kinds a queue's members are written with: the + * five SET-VALUED members of {@link ShareRecipientType}, derived from it so the + * two vocabularies cannot drift. `field` is excluded because it is + * record-relative and names no set of people; `queue` is excluded because a + * queue does not contain queues. + * + * Each kind takes the `value` it takes in a sharing rule — a user id, a team + * id, a position machine name, a business-unit id — and is expanded by the + * expanders the sharing engine already runs, scoped to the queue's + * organization. There is no new principal vocabulary. + */ +export const QueueMemberType = ShareRecipientType.exclude(['field', 'queue']); +export type QueueMemberType = z.input; + +/** + * The one sentence a declared queue refuses a non-position member with. Its + * own constant because the refusal names the place the member belongs. + */ +const DECLARED_QUEUE_MEMBER_TYPE_REFUSAL = + "A declared queue's members name positions only (`type: 'position'`, `value: ''`). " + + 'A position is a catalog item a package can know by name; a user, a team or a business unit is an ' + + "organization's own row, so it joins a queue through that organization's `sys_queue_member` rows in Setup."; + +/** + * [ADR-0142 D1, merge question 18] One member of a DECLARED queue: a position, + * named by its machine name. Users, teams and business units join a queue + * through the organization's authored `sys_queue_member` rows, where any + * {@link QueueMemberType} is admitted. + */ +export const DeclaredQueueMemberSchema = strictObject( + { + surface: 'this queue member', + aliases: { id: 'value', position: 'value', name: 'value' }, + history: 'Declared closed from its first day: an unrecognized key is refused, never dropped.', + }, + { + type: z.literal('position', { error: () => DECLARED_QUEUE_MEMBER_TYPE_REFUSAL }) + .describe("The member kind. A declared queue names positions only ('position')."), + value: SnakeCaseIdentifierSchema.describe('The position machine name whose holders are members of the queue'), + }, +); + +/** + * Queue — a named set of people that records can sit in (ADR-0142 D1). + * + * A record of an `ownership: 'user'` object SITS IN a queue when its + * `owner_id` is empty and its system-managed `queue` column names the queue. + * The queue's members read and edit what sits in it, any one of them takes a + * record by claiming it (an update of `owner_id` to themselves, adjudicated by + * the ownership-transfer gate), and an assignment rule can route inserted rows + * into it. + * + * A queue has the two sources a sharing rule has (ADR-0131 D6, regime C): + * + * - **Declared** — this schema, the stack's `queues`. Read from the registry + * on every use and never copied into rows; switched off through the + * activation ledger (`sys_metadata_activation`, `metadata_type: 'queue'`). + * Its members name positions only (see {@link DeclaredQueueMemberSchema}). + * - **Authored** — an organization's `sys_queue` row with its + * `sys_queue_member` rows, any of the five member kinds. + * + * One namespace, by name: a `sys_queue` row may not take a declared queue's + * name. The word `queue` here is a queue of PEOPLE; the job-queue service and + * its `sys_job_queue` table are a different noun. + */ +export const QueueSchema = lazySchema(() => strictObject( + { + surface: 'this queue', + aliases: { title: 'label', users: 'members', recipients: 'members' }, + guidance: { + organization_id: + '`organization_id` is not a queue field — a declared queue is a catalog item served to every ' + + "organization; an organization's own queue is a `sys_queue` row it authors in Setup.", + strategy: + '`strategy` is not a queue field — how a record is chosen for a member is the assignment rule\'s ' + + 'declaration (`assignmentRules[].entries[].strategy`), so one queue can be routed into differently ' + + 'per object.', + }, + history: 'Declared closed from its first day: an unrecognized key is refused, never dropped.', + }, + { + name: SnakeCaseIdentifierSchema.describe( + 'Queue machine name (lowercase snake_case); the one name the `queue` column, an assignment rule, an ' + + 'approver and a sharing recipient reference it by', + ), + label: z.string().describe('Display label (e.g. Support Intake)'), + description: z.string().optional().describe('Administrative notes'), + members: z.array(DeclaredQueueMemberSchema).default([]).describe( + 'The positions whose holders are members, resolved in the organization a record or request belongs to. ' + + 'An empty list is a queue nobody staffs yet: records still sit in it.', + ), + + // ADR-0010 — runtime protection envelope (internal — set by loader). The + // shape is strict, and both metadata load paths stamp this envelope on + // every item, so it is declared here for the reason `sharing.zod.ts` + // gives for its own spread. + ...MetadataProtectionFields, + }, +)); + +export type Queue = z.input; +/** Post-parse shape of {@link Queue} — defaults applied (ADR-0122). */ +export type QueueParsed = z.infer; +export type DeclaredQueueMember = z.input; + +/** + * Type-safe factory for a declared queue. Validates at authoring time via + * `.parse()`. + */ +export function defineQueue(config: z.input): QueueParsed { + return QueueSchema.parse(config); +} diff --git a/packages/spec/src/security/sharing.zod.ts b/packages/spec/src/security/sharing.zod.ts index 0571671e98f..a86071f9f7a 100644 --- a/packages/spec/src/security/sharing.zod.ts +++ b/packages/spec/src/security/sharing.zod.ts @@ -49,7 +49,8 @@ export const SharingRuleType = z.enum([ * making it byte-equivalent to `edit`. An admin picking it in Setup was told * they had granted delete rights and had not; a level that validates and then * silently does nothing is an authoring trap (ADR-0078, ADR-0049) — the same - * reason `ShareRecipientType` below dropped `queue` / `guest`. + * reason `ShareRecipientType` below dropped `guest`, and kept `queue` out until + * its implementation landed (ADR-0142 D7). * * This is also where the industry model lands: record sharing widens WHICH * ROWS a principal reaches, never WHICH VERBS they may use. Salesforce sharing @@ -93,6 +94,15 @@ export const SharingLevel = enumWithRetiredValues([ * - `unit_and_subordinates` — a business unit plus every descendant unit's * members (ADR-0057 D5 subtree widening). * - `business_unit` — exactly one business unit's members (no subtree). + * - `queue` — every member of an ownership queue (ADR-0142 D7), named by its + * machine name: a declared queue (`QueueSchema`) or an organization's + * `sys_queue` row. Expanded rule-wide through the queue service, the one + * expander every queue consumer reads, and re-materialised when the + * queue's membership moves (a `sys_queue_member` write, or a write to a + * membership table one of its members reads). It shares the rule's + * criteria records with the queue's people; it does NOT confer the + * sitting-record access a queue's members have on the records whose + * `queue` column names it — that is the owner match's own path. * - `field` — the RECORD-RELATIVE recipient (maintainer ruling 2026-09-02, * #14103): `value` names a user-typed field ON THE RECORD, and each record * the rule's criteria match is shared with the user or users that column @@ -120,8 +130,9 @@ export const SharingLevel = enumWithRetiredValues([ * Removed (never enforced): `group` (renamed → `team`) and `guest` — anonymous * access is served by the public-form grant and share links, not sharing rules; * a guest recipient that silently no-ops is an authoring trap (ADR-0078). The - * runtime contract additionally reserves `queue` (no `sys_queue` yet) — it is - * deliberately NOT authorable until the implementation lands. + * authorable enum and the runtime `SharingRuleRecipientType` union are the same + * seven members since `queue` was re-admitted with its implementation + * (ADR-0142 D7). */ export const ShareRecipientType = z.enum([ 'user', @@ -129,6 +140,7 @@ export const ShareRecipientType = z.enum([ 'position', 'unit_and_subordinates', 'business_unit', + 'queue', 'field', ]); @@ -203,7 +215,8 @@ const BaseSharingRuleSchema = strictObject( { type: ShareRecipientType, value: z.string().describe( - 'The recipient principal: the id or code of the user / team / position / business unit — or, for ' + + 'The recipient principal: the id or code of the user / team / position / business unit, the machine ' + + 'name of a queue — or, for ' + "`type: 'field'`, the snake_case name of a user-typed field on the record whose value names the " + 'user or users to share each matched record with', ), @@ -217,6 +230,24 @@ const BaseSharingRuleSchema = strictObject( // spelled as a value — exactly what the ruling's ⛔ on a `manager` member // refuses). Scoped to `field`, so nothing that parsed before parses // differently: the other members' `value` stays the opaque string it was. + // + // [ADR-0142 D7] A `queue` recipient's `value` is a queue MACHINE NAME — the + // one name the queue is referenced by everywhere (D1) — so it carries the + // same snake_case grammar. A queue id or label would resolve nowhere and + // share with nobody. + if (recipient.type === 'queue') { + if (!/^[a-z][a-z0-9_]*$/.test(recipient.value)) { + ctx.addIssue({ + code: 'custom', + path: ['value'], + message: + `\`sharedWith.value\` must name a queue by its machine name when \`type\` is 'queue' ` + + `(got ${JSON.stringify(recipient.value)}). A queue name is snake_case (\`support_intake\`): ` + + "the name a stack's `queues` entry declares or an organization's `sys_queue` row carries.", + }); + } + return; + } if (recipient.type !== 'field') return; if (!/^[a-z_][a-z0-9_]*$/.test(recipient.value)) { ctx.addIssue({ @@ -269,7 +300,8 @@ export const CriteriaSharingRuleSchema = lazySchema(() => BaseSharingRuleSchema. * `@objectstack/formula` compiler at seed / `defineRule` time, and ENFORCED: * records matching the criteria materialise `sys_record_share` grants for the * resolved recipients. Supported recipients: `user` / `team` / `position` / - * `unit_and_subordinates` / `business_unit` (ADR-0057 D5; ADR-0090 D3) — every + * `unit_and_subordinates` / `business_unit` (ADR-0057 D5; ADR-0090 D3) / `queue` + * (ADR-0142 D7) — every * authorable recipient expands at runtime (`plugin-sharing` `expandRecipient`) * — plus `field`, the record-relative recipient (#14103): resolved per matched * record from a user-typed column on it. Its services half, #15072, landed: diff --git a/packages/spec/src/shared/metadata-collection.zod.ts b/packages/spec/src/shared/metadata-collection.zod.ts index bcb4ef777eb..07df8dcac2c 100644 --- a/packages/spec/src/shared/metadata-collection.zod.ts +++ b/packages/spec/src/shared/metadata-collection.zod.ts @@ -85,6 +85,8 @@ export const MAP_SUPPORTED_FIELDS = [ 'positions', 'permissions', 'sharingRules', + 'queues', + 'assignmentRules', 'apis', 'webhooks', 'agents', diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 75556b0f999..155f43e8a0c 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -66,6 +66,8 @@ import { PermissionSetSchema } from './security/permission.zod'; import { judgePermissionSetOwnerBinding } from './security/permission-set-owner-binding'; import { CapabilityDeclarationSchema } from './security/capabilities'; import { SharingRuleSchema } from './security/sharing.zod'; +import { QueueSchema } from './security/queue.zod'; +import { AssignmentRuleSchema } from './automation/assignment-rule.zod'; import { ApiEndpointSchema, type ApiEndpoint } from './api/endpoint.zod'; import { @@ -823,6 +825,28 @@ const STACK_DEFINITION_COLLECTIONS_SHAPE = { capabilities: z.array(CapabilityDeclarationSchema).optional() .describe('[ADR-0066 D1] Authorization capabilities this package defines (seeded with package provenance)'), sharingRules: z.array(SharingRuleSchema).optional().describe('Record Sharing Rules'), + /** + * [ADR-0142 D1] Ownership queues this package DECLARES — named sets of + * people records can sit in until one of them claims the record. A declared + * queue's members name positions only; an organization staffs its own queues + * as `sys_queue` rows in Setup. Served from the registry, never copied into + * rows, and switched off through the activation ledger. + * + * A composition that declares queues must register the ownership-queue + * service (`@objectstack/plugin-sharing`): without it nothing resolves the + * names, so the boot is refused rather than served with inert queues. + */ + queues: z.array(QueueSchema).optional() + .describe('[ADR-0142 D1] Ownership queues — named sets of people records can sit in until a member claims them'), + /** + * [ADR-0142 D5] Assignment rules this package DECLARES: per object, ordered + * entries routing an inserted, still-ownerless row to a queue (parked, or + * given to one member by `round_robin` / `least_loaded`) or to one user. + * Evaluated by the engine once per insert, after every `beforeInsert` hook; + * never on update. Requires the ownership-queue service, as `queues` does. + */ + assignmentRules: z.array(AssignmentRuleSchema).optional() + .describe('[ADR-0142 D5] Assignment rules — route new ownerless records to a queue or a person, once, on insert'), /** * ObjectAPI: API Layer — the platform's OUTWARD integration face. @@ -1446,6 +1470,8 @@ export const COMPOSE_KEY_DISPOSITIONS = Object.freeze({ permissions: 'concat', capabilities: 'concat', sharingRules: 'concat', + queues: 'concat', + assignmentRules: 'concat', apis: 'concat', webhooks: 'concat', agents: 'concat', @@ -2942,6 +2968,8 @@ const SAME_STACK_ENTRY_IDENTITY = Object.freeze({ permissions: lastWinsByName('Permission set', 'permission sets'), capabilities: lastWinsByName('Capability', 'capabilities'), sharingRules: lastWinsByName('Sharing rule', 'sharing rules'), + queues: lastWinsByName('Queue', 'queues'), + assignmentRules: lastWinsByName('Assignment rule', 'assignment rules'), webhooks: lastWinsByName('Webhook', 'webhooks'), hooks: { identity: readEntryName, diff --git a/packages/spec/src/system/constants/system-names.ts b/packages/spec/src/system/constants/system-names.ts index f585fd65892..3c7c9394f0d 100644 --- a/packages/spec/src/system/constants/system-names.ts +++ b/packages/spec/src/system/constants/system-names.ts @@ -219,6 +219,18 @@ export const SystemFieldName = { * @see docs/adr/0117-owning-business-unit-record-stamp.md */ OWNING_BUSINESS_UNIT_ID: 'owning_business_unit_id', + /** + * [ADR-0142 D3] The ownership queue — a queue machine name. A record SITS IN + * queue Q when this column is Q and {@link SystemFieldName.OWNER_ID} is + * empty; with an owner set it names the record's home queue, conferring no + * access. **INJECTED** exactly where `owner_id` is (`ownership: 'user'` or + * omitted), and an eligible object that declares its own `queue` field is + * refused at registration rather than letting the author's field win. + * Written by an assignment rule (as the platform) and by claim / release / + * move through the ownership gate; on the public-form denylist with the other + * anchors. + */ + QUEUE: 'queue', /** * THE tenant isolation key — a lookup to `sys_organization`. INJECTED unless * tenancy is disabled for the object; org-scoping stamps it on insert (under From e99e3647fc6bcad275471025ce4e3b0c78288b7a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 12:49:04 +0000 Subject: [PATCH 02/23] feat(plugin-sharing): ownership-queue service, queue tables and the sitting-record access path ADR-0142 phase 2, plugin-sharing half: - sys_queue, sys_queue_member and sys_assignment_cursor, owned here. - OwnershipQueueService, registered under `ownership-queue` in init(): registry-first resolution, the activation-ledger switch, one expander ordered by user id, with an access/addressing purpose split. - The read filter and the update arm gain `owner_id IS NULL AND queue IN current_user.queues`; checkEdit tests the basis before the public abstain. - The `queue` sharing recipient expands through the service, with the recompute on queue, position, team and business-unit writes. - Name guards on the authored tables and a boot report for shadowed rows. - Spec: the contract's `purpose` option and `declaredQueueNamed`, the dropped-refinements baseline, and the regenerated schema artifacts. Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- .../plugin-sharing/src/access-level.ts | 3 +- .../plugin-sharing/src/bu-tree-recompute.ts | 23 +- .../src/declared-sharing-rules.ts | 4 + packages/plugins/plugin-sharing/src/index.ts | 33 +- .../plugin-sharing/src/objects/index.ts | 3 + .../objects/sys-assignment-cursor.object.ts | 72 +++ .../src/objects/sys-queue-member.object.ts | 89 +++ .../src/objects/sys-queue.object.ts | 78 +++ .../sys-sharing-rule.by-object.test.ts | 4 + .../src/objects/sys-sharing-rule.object.ts | 17 +- .../src/ownership-queue-service.ts | 515 ++++++++++++++++++ .../src/ownership-queue.test.ts | 426 +++++++++++++++ .../plugins/plugin-sharing/src/queue-hooks.ts | 270 +++++++++ .../src/queue-recipient.test.ts | 143 +++++ .../src/recipient-membership-recompute.ts | 34 +- .../plugin-sharing/src/sharing-plugin.ts | 76 ++- .../src/sharing-rule-option-labels.test.ts | 2 +- .../src/sharing-rule-service.ts | 41 +- .../plugin-sharing/src/sharing-service.ts | 137 ++++- .../spec/authorable-defaults/automation.json | 1 + .../spec/authorable-defaults/security.json | 1 + .../spec/authorable-surface/automation.json | 20 + .../spec/authorable-surface/security.json | 13 + .../spec/dropped-refinements.baseline.json | 34 +- .../spec/json-schema.manifest/automation.json | 4 + .../spec/json-schema.manifest/security.json | 3 + .../src/contracts/ownership-queue-service.ts | 25 +- 27 files changed, 2028 insertions(+), 43 deletions(-) create mode 100644 packages/plugins/plugin-sharing/src/objects/sys-assignment-cursor.object.ts create mode 100644 packages/plugins/plugin-sharing/src/objects/sys-queue-member.object.ts create mode 100644 packages/plugins/plugin-sharing/src/objects/sys-queue.object.ts create mode 100644 packages/plugins/plugin-sharing/src/ownership-queue-service.ts create mode 100644 packages/plugins/plugin-sharing/src/ownership-queue.test.ts create mode 100644 packages/plugins/plugin-sharing/src/queue-hooks.ts create mode 100644 packages/plugins/plugin-sharing/src/queue-recipient.test.ts diff --git a/packages/plugins/plugin-sharing/src/access-level.ts b/packages/plugins/plugin-sharing/src/access-level.ts index e110f89b34c..ed2dc97afbe 100644 --- a/packages/plugins/plugin-sharing/src/access-level.ts +++ b/packages/plugins/plugin-sharing/src/access-level.ts @@ -13,7 +13,8 @@ * it was byte-equivalent to `edit`. An admin picking "Full Access" in Setup was * told they had granted delete rights and had not — declared-but-unenforced * metadata, the exact authoring trap ADR-0078 / ADR-0049 ban, and the reason - * `recipient_type: 'queue'` was removed before it. + * `recipient_type: 'queue'` was off the authoring surface until ADR-0142 D7 + * gave it an evaluator. * * It is not coming back as an enum member. Record sharing widens **which rows** * a principal reaches, never **which verbs** they may use — the same split diff --git a/packages/plugins/plugin-sharing/src/bu-tree-recompute.ts b/packages/plugins/plugin-sharing/src/bu-tree-recompute.ts index 3527f7ccbc6..7a3f02fe564 100644 --- a/packages/plugins/plugin-sharing/src/bu-tree-recompute.ts +++ b/packages/plugins/plugin-sharing/src/bu-tree-recompute.ts @@ -33,7 +33,7 @@ * | `business_unit` | `BusinessUnitGraphService.expandUnitMembers` | YES | * | `position` | `PositionGraphService` (`sys_user_position`, `sys_member`) | no | * | `unit_and_subordinates` | `BusinessUnitGraphService.expandUsers` | YES | - * | `queue` | returns `[]` (no `sys_queue` yet) | no | + * | `queue` | the ownership-queue service's expansion of the queue's member specifications (ADR-0142 D2) | only when the queue has a `business_unit` or `unit_and_subordinates` member | * | `field` | the matched record's own user column, per record (#15072; `expandRecipientForRecord`, never rule-wide) | no | * * `business_unit` stays in that set after #7807 narrowed it to exactly one @@ -113,10 +113,11 @@ export const BU_TREE_RECOMPUTE_PACKAGE = 'plugin-sharing:bu-tree-recompute'; * The recipient kinds whose expansion reads the business-unit graph. * * This set is the non-regression guarantee. A rule recipient that never reads - * the BU tree (`user` / `team` / `position` / `queue` / `field`) is not recomputed by a + * the BU tree (`user` / `team` / `position` / `field`) is not recomputed by a * BU write at all — not more cheaply, not at all — so a deployment whose rules * are all `user`-recipient pays one `sys_sharing_rule` read per BU write and - * nothing else. + * nothing else. A `queue` recipient is decided per rule, by its queue's + * members ({@link QueueReadsBusinessUnits}), so it is not in this set. */ export const BU_TREE_RECIPIENT_TYPES: ReadonlySet = new Set([ 'business_unit', @@ -155,6 +156,14 @@ interface MinimalEngine { unregisterHooksByPackage(packageId: string): number; } +/** + * [ADR-0142 D2] Does the queue a `queue`-recipient rule names read the + * business-unit graph — has it a `business_unit` or `unit_and_subordinates` + * member? Answered by the ownership-queue service; absent, no queue rule is + * recomputed by a BU write. + */ +export type QueueReadsBusinessUnits = (rule: SharingRuleRow) => Promise; + /** The slice of {@link SharingRuleService} this module drives. */ export interface BuTreeRecomputeRuleService { listRules( @@ -224,6 +233,7 @@ export function bindBusinessUnitTreeRecompute( engine: MinimalEngine, service: BuTreeRecomputeRuleService, logger?: OptionalSharingLogger, + queueReadsBusinessUnits?: QueueReadsBusinessUnits, ): void { if (typeof engine.registerHook !== 'function') return; if (typeof engine.unregisterHooksByPackage === 'function') { @@ -264,6 +274,13 @@ export function bindBusinessUnitTreeRecompute( if (!writeCanChangeExpansion(objectName, event, hookCtx)) return; const rules = await service.listRules({ activeOnly: true }, SYSTEM_CTX as any); const affected = (rules ?? []).filter((r) => BU_TREE_RECIPIENT_TYPES.has(r.recipient_type)); + // [ADR-0142 D2] A queue recipient reads the BU tree exactly when its + // queue has a business-unit member; asked per queue rule, never assumed. + if (queueReadsBusinessUnits) { + for (const r of rules ?? []) { + if (r.recipient_type === 'queue' && (await queueReadsBusinessUnits(r).catch(() => true))) affected.push(r); + } + } if (affected.length === 0) return; for (const rule of affected) { // Safety half — synchronous and complete. Failing one rule must not diff --git a/packages/plugins/plugin-sharing/src/declared-sharing-rules.ts b/packages/plugins/plugin-sharing/src/declared-sharing-rules.ts index c00981ded35..f5f55bf2c71 100644 --- a/packages/plugins/plugin-sharing/src/declared-sharing-rules.ts +++ b/packages/plugins/plugin-sharing/src/declared-sharing-rules.ts @@ -71,6 +71,10 @@ function mapRecipientType(t: unknown): SharingRuleRecipientType | null { // `value` is a field NAME, held to the `FieldSchema.name` grammar at parse, // and `SharingRuleService` reads that column on each matched record. case 'field': return 'field'; + // [ADR-0142 D7] A queue, by machine name — expanded through the + // ownership-queue service to the queue's members in the rule's + // organization. + case 'queue': return 'queue'; // Defensive only: the authoring enum matches the cases above 1:1, but a // stale pre-built package could still register a retired shape — skip, // never seed match-all. diff --git a/packages/plugins/plugin-sharing/src/index.ts b/packages/plugins/plugin-sharing/src/index.ts index 162e5eb847e..1016a067f28 100644 --- a/packages/plugins/plugin-sharing/src/index.ts +++ b/packages/plugins/plugin-sharing/src/index.ts @@ -9,7 +9,14 @@ * authenticated execution context. */ -export { SysRecordShare, SysSharingRule, SysShareLink } from './objects/index.js'; +export { + SysRecordShare, + SysSharingRule, + SysShareLink, + SysQueue, + SysQueueMember, + SysAssignmentCursor, +} from './objects/index.js'; export { SysBusinessUnit, SysBusinessUnitMember } from '@objectstack/platform-objects/identity'; export { SharingService, @@ -108,6 +115,27 @@ export { type DeclaredSharingRule, type DeclaredSharingRuleSkip, } from './declared-sharing-rules.js'; +export { + // [ADR-0142 D1/D2] The ownership-queue service, registered under + // `OWNERSHIP_QUEUE_SERVICE` (never `queue` — that name is the job queue's). + OwnershipQueueService, + specsReadBusinessUnits, + QUEUE_OBJECT, + QUEUE_MEMBER_OBJECT, + QUEUE_ACTIVATION_TYPE, + type OwnershipQueueServiceOptions, + type OwnershipQueueEngine, +} from './ownership-queue-service.js'; +export { + bindQueueGuards, + bindQueueRecipientRecompute, + reportShadowedAuthoredQueues, + recomputeQueueRecipientRules, + pendingQueueRecipientRegrants, + QueueValidationError, + QUEUE_GUARD_PACKAGE, + QUEUE_RECIPIENT_RECOMPUTE_PACKAGE, +} from './queue-hooks.js'; export { SharingServicePlugin, buildSharingMiddleware, @@ -145,4 +173,5 @@ export type { ShareLinkPermission, ShareLinkAudience, } from '@objectstack/spec/contracts'; -export { SHARE_LINK_SERVICE } from '@objectstack/spec/contracts'; +export type { IOwnershipQueueService, ResolvedQueue, QueueMemberSpec } from '@objectstack/spec/contracts'; +export { SHARE_LINK_SERVICE, OWNERSHIP_QUEUE_SERVICE } from '@objectstack/spec/contracts'; diff --git a/packages/plugins/plugin-sharing/src/objects/index.ts b/packages/plugins/plugin-sharing/src/objects/index.ts index dba72bff411..0892eb0f35b 100644 --- a/packages/plugins/plugin-sharing/src/objects/index.ts +++ b/packages/plugins/plugin-sharing/src/objects/index.ts @@ -12,3 +12,6 @@ export { SysRecordShare } from './sys-record-share.object.js'; export { SysSharingRule } from './sys-sharing-rule.object.js'; 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'; diff --git a/packages/plugins/plugin-sharing/src/objects/sys-assignment-cursor.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-assignment-cursor.object.ts new file mode 100644 index 00000000000..cf8ef91b3cb --- /dev/null +++ b/packages/plugins/plugin-sharing/src/objects/sys-assignment-cursor.object.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_assignment_cursor — the round-robin position of one assignment-rule + * entry in one organization (ADR-0142 D5). + * + * One row per (rule, entry, organization), holding the member the entry last + * chose and a version. The engine advances it under its by-id write + * precondition (`expect: { version }` as read), so two concurrent inserts + * cannot both read the same position and pick the same member; the loser + * re-reads and retries. A member set that changed since the last assignment + * is handled by position: the next member after `last_user_id` in the current + * ordering. + * + * Engine-owned: no user writes it, and nothing reads it but the assignment + * step. + * + * @namespace sys + */ +export const SysAssignmentCursor = ObjectSchema.create({ + name: 'sys_assignment_cursor', + label: 'Assignment Cursor', + pluralLabel: 'Assignment Cursors', + icon: 'rotate-cw', + isSystem: true, + managedBy: 'engine-owned', + description: 'Where round-robin assignment last left off, per assignment rule entry.', + displayNameField: 'rule', + nameField: 'rule', + titleFormat: '{rule} / {entry}', + highlightFields: ['rule', 'entry', 'last_user_id', 'version'], + + fields: { + rule: Field.text({ + label: 'Assignment Rule', + required: true, + maxLength: 100, + description: 'The name of the assignment rule.', + group: 'Rule', + }), + + entry: Field.text({ + label: 'Entry', + required: true, + maxLength: 100, + description: 'The name of the rule entry whose rotation this is.', + group: 'Rule', + }), + + last_user_id: Field.text({ + label: 'Last Assigned User', + required: false, + maxLength: 200, + description: 'The member this entry assigned a record to most recently.', + group: 'State', + }), + + version: Field.number({ + label: 'Version', + required: true, + defaultValue: 0, + description: 'Advanced by one on every assignment; a concurrent advance that read an older value retries.', + group: 'State', + }), + }, + + indexes: [ + { fields: ['rule', 'entry'], unique: 'organization' }, + ], +}); diff --git a/packages/plugins/plugin-sharing/src/objects/sys-queue-member.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-queue-member.object.ts new file mode 100644 index 00000000000..9c5e59e041a --- /dev/null +++ b/packages/plugins/plugin-sharing/src/objects/sys-queue-member.object.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_queue_member — one member specification of an organization's queue + * (ADR-0142 D1/D2). + * + * A row names a set of people the way a sharing rule's recipient does — a + * user, a team, a position, one business unit, or a unit and every unit below + * it — and the ownership-queue service expands the union of a queue's rows, + * de-duplicated and ordered by user id, in the queue's organization. The + * members of a DECLARED queue are positions only, named in its declaration; an + * organization adds users, teams and units to a declared queue here too, under + * the declared queue's name. + * + * `field` and `queue` are not member kinds: a field names people per record, + * and a queue does not contain queues. + * + * @namespace sys + */ +export const SysQueueMember = ObjectSchema.create({ + name: 'sys_queue_member', + label: 'Queue Member', + pluralLabel: 'Queue Members', + icon: 'users', + isSystem: true, + managedBy: 'config', + description: 'Who belongs to a queue: a user, a team, a position or a business unit.', + displayNameField: 'queue', + nameField: 'queue', + titleFormat: '{queue}', + highlightFields: ['queue', 'recipient_type', 'recipient_id'], + + listViews: { + by_queue: { + type: 'grid', + name: 'by_queue', + label: 'By Queue', + data: { provider: 'object', object: 'sys_queue_member' }, + columns: ['queue', 'recipient_type', 'recipient_id', 'updated_at'], + sort: [{ field: 'queue', order: 'asc' }], + pagination: { pageSize: 100 }, + }, + }, + + fields: { + queue: Field.text({ + label: 'Queue', + required: true, + maxLength: 100, + description: 'The name of the queue this member belongs to.', + group: 'Queue', + }), + + recipient_type: Field.select({ + options: [ + { value: 'user', label: 'User' }, + { value: 'team', label: 'Team' }, + { value: 'position', label: 'Position' }, + { value: 'business_unit', label: 'Business unit' }, + { value: 'unit_and_subordinates', label: 'Business unit and subordinates' }, + ], + label: 'Member Type', + required: true, + defaultValue: 'position', + description: + 'What kind of member this is. A team, position or business unit makes everyone in it a member. ' + + '"Business unit and subordinates" also covers every unit below the one you pick.', + group: 'Member', + }), + + recipient_id: Field.text({ + label: 'Member', + required: true, + maxLength: 200, + widget: 'recipient-picker', + dependsOn: ['recipient_type'], + description: 'The specific user, team, position or business unit.', + group: 'Member', + }), + }, + + indexes: [ + { fields: ['queue'] }, + { fields: ['recipient_type', 'recipient_id'] }, + { fields: ['queue', 'recipient_type', 'recipient_id'], unique: 'organization' }, + ], +}); diff --git a/packages/plugins/plugin-sharing/src/objects/sys-queue.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-queue.object.ts new file mode 100644 index 00000000000..6b9bcb729d1 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/objects/sys-queue.object.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_queue — an organization's own ownership queue (ADR-0142 D1). + * + * A queue is a named set of people that records of any `ownership: 'user'` + * object can sit in: a record SITS IN queue Q while its `owner_id` is empty and + * its `queue` column names Q, and Q's members read, edit and claim it. + * + * Two sources, one namespace (ADR-0131 D6, regime C): a package declares + * queues in its stack's `queues` (registry items, never copied into this + * table); an organization authors its own here, with its members in + * `sys_queue_member`. Every reference — the `queue` column, an assignment + * rule's target, an approver, a sharing recipient — names a queue by `name`, + * resolved registry-first. A row may not take a declared queue's name: the + * ownership-queue service refuses that write. + * + * ⛔ Not the job queue. `sys_job_queue` and the `queue` kernel service are a + * queue of WORK; this is a queue of PEOPLE. + * + * @namespace sys + */ +export const SysQueue = ObjectSchema.create({ + name: 'sys_queue', + label: 'Queue', + pluralLabel: 'Queues', + icon: 'inbox', + isSystem: true, + managedBy: 'config', + description: 'A named group of people that unassigned records can wait in until one of them takes the record.', + displayNameField: 'name', + nameField: 'name', + titleFormat: '{label}', + highlightFields: ['name', 'label'], + + listViews: { + all_queues: { + type: 'grid', + name: 'all_queues', + label: 'All', + data: { provider: 'object', object: 'sys_queue' }, + columns: ['label', 'name', 'updated_at'], + sort: [{ field: 'label', order: 'asc' }], + pagination: { pageSize: 50 }, + }, + }, + + fields: { + name: Field.text({ + label: 'Name', + required: true, + maxLength: 100, + description: + 'Identifies the queue everywhere it is used — on records, in assignment rules, approvals and sharing ' + + 'rules. Lowercase letters, digits and underscores only.', + group: 'Identity', + }), + + label: Field.text({ + label: 'Display Label', + required: true, + maxLength: 200, + group: 'Identity', + }), + + description: Field.textarea({ + label: 'Description', + required: false, + group: 'Identity', + }), + }, + + indexes: [ + { fields: ['name'], unique: 'organization' }, + ], +}); 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 12ef8637541..b134b458e4b 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 @@ -52,6 +52,10 @@ describe('sharing list views — no grouping on a field that stores an object AP sys_record_share: ['object_name'], sys_sharing_rule: ['object_name'], sys_share_link: ['object_name'], + // [ADR-0142] The queue tables name a queue and a rule, never an object. + sys_queue: [], + sys_queue_member: [], + sys_assignment_cursor: [], }); }); diff --git a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts index d0b860e90c8..052f73f76d0 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts @@ -169,11 +169,11 @@ export const SysSharingRule = ObjectSchema.create({ }), recipient_type: Field.select( - // `queue` was removed: it is declared-but-unenforced (the evaluator returns - // no users for it), so offering it would author a silently-inert rule - // (ADR-0078). The six values below are the ones the evaluator expands — + // `queue` returned with the queue entity (ADR-0142 D7): the evaluator + // expands it to the queue's members through the ownership-queue + // service. The seven values below are the ones the evaluator expands — // `field` (#15072, the record-relative recipient ruled on #14103) per - // matched record, the other five once per rule. The list is held equal to + // matched record, the other six once per rule. The list is held equal to // the authorable `ShareRecipientType` by `field-recipient.test.ts`, so a // stored row of any authorable kind is never refused at this select. // Each option carries its label: the bare-string shorthand would show @@ -186,6 +186,7 @@ export const SysSharingRule = ObjectSchema.create({ { value: 'business_unit', label: 'Business unit' }, { value: 'position', label: 'Position' }, { value: 'unit_and_subordinates', label: 'Business unit and subordinates' }, + { value: 'queue', label: 'Queue' }, { value: 'field', label: 'Field' }, ], label: 'Recipient Type', @@ -193,7 +194,7 @@ export const SysSharingRule = ObjectSchema.create({ defaultValue: 'business_unit', // The engine detail this used to spell out (which tree is walked, which // expansion is flat, the ADRs behind each) lives in the class doc above. - description: 'Who receives access. Picking a team, business unit or position gives access to everyone in it. "Business unit and subordinates" also covers every unit below the one you pick. "Field" shares each record with the user or users held in one of its own fields — name that field as the recipient.', + description: 'Who receives access. Picking a team, business unit, position or queue gives access to everyone in it. "Business unit and subordinates" also covers every unit below the one you pick. "Field" shares each record with the user or users held in one of its own fields — name that field as the recipient.', group: 'Recipient', }, ), @@ -222,13 +223,13 @@ export const SysSharingRule = ObjectSchema.create({ // input in silence. widget: 'recipient-picker', dependsOn: ['recipient_type', 'object_name'], - description: 'The specific user, team, business unit or position that receives access — or, for the "Field" recipient type, the name of the record field that holds the user or users to share with.', + description: 'The specific user, team, business unit, position or queue that receives access — or, for the "Field" recipient type, the name of the record field that holds the user or users to share with.', group: 'Recipient', }), access_level: Field.select( - // `full` was removed for the same reason as `queue` above: it was - // declared-but-unenforced. Labelled "Full Access (Transfer, Share, + // `full` was removed because it was declared-but-unenforced (the reason + // `queue` was off the recipient list until ADR-0142 D7 enforced it). Labelled "Full Access (Transfer, Share, // Delete)", it was matched only as `access_level in ('edit','full')` by // the two enforcement sites, so it granted exactly what `edit` grants — // offering it told an admin they had granted delete rights they had not diff --git a/packages/plugins/plugin-sharing/src/ownership-queue-service.ts b/packages/plugins/plugin-sharing/src/ownership-queue-service.ts new file mode 100644 index 00000000000..d6808f5f8c3 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/ownership-queue-service.ts @@ -0,0 +1,515 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0142 D1/D2] The ownership-queue service — the one place a queue name is + * resolved and a queue's members are expanded. + * + * ## Two sources, one namespace + * + * A queue is DECLARED (a stack's `queues`, read from the engine registry on + * every use, never copied into rows) or AUTHORED (an organization's + * `sys_queue` row). Resolution reads the registry first, then the + * organization's rows (ADR-0131 D4): a declared queue switched off in the + * activation ledger (`sys_metadata_activation`, `metadata_type: 'queue'`) + * resolves nowhere, and a `sys_queue` row may not take a declared queue's name + * ({@link bindQueueGuards} refuses that write). + * + * A queue's members are the union of its member specifications: a declared + * queue's own (positions only), plus the organization's `sys_queue_member` rows + * naming the queue (any of the five set-valued kinds), whichever source the + * queue has. + * + * ## One expander + * + * {@link OwnershipQueueService.expandMembers} is the function the access path, + * the claim admission, the assignment pool, the approver slate and the sharing + * recipient all read. It expands each specification through the expanders the + * sharing engine already runs (`TeamGraphService`, `PositionGraphService`, + * `BusinessUnitGraphService`), scoped to the queue's organization, and answers + * the union de-duplicated and ordered by user id ascending — so round-robin and + * the least-loaded tie-break see one stable order wherever they run. + * + * ## Absence is not a fault + * + * A name that resolves nowhere answers `null` / `[]` / `false`, and an empty + * queue answers `[]`. A failed read of a member table contributes nobody for + * that specification (fail closed for every admission that reads it); a failed + * read of the activation ledger leaves the declared default — on — for that + * call and is logged, the posture the sharing-rule service takes for the same + * ledger. + */ + +import type { + IOwnershipQueueService, + QueueExpansionPurpose, + QueueMemberSpec, + ResolvedQueue, +} from '@objectstack/spec/contracts'; +import { QueueMemberType } from '@objectstack/spec/security'; +import { METADATA_ACTIVATION_TABLE } from '@objectstack/core'; +import type { OptionalSharingLogger } from './logger-shapes.js'; +import type { SharingEngine } from './sharing-service.js'; +import { TeamGraphService, teamHasMember } from './team-graph.js'; +import { PositionGraphService } from './position-graph.js'; +import { BusinessUnitGraphService } from './business-unit-graph.js'; + +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; + +/** The authored queue table. */ +export const QUEUE_OBJECT = 'sys_queue'; +/** The authored member-specification table. */ +export const QUEUE_MEMBER_OBJECT = 'sys_queue_member'; +/** The `sys_metadata_activation.metadata_type` a declared queue is switched by. */ +export const QUEUE_ACTIVATION_TYPE = 'queue'; + +/** Upper bound on one organization's authored queues / member rows read in one call. */ +const QUEUE_READ_LIMIT = 10000; + +/** The member kinds a specification may carry, as a set for the row guard. */ +const MEMBER_KINDS: ReadonlySet = new Set(QueueMemberType.options); + +/** The member kinds that read the business-unit graph. */ +const BU_MEMBER_KINDS: ReadonlySet = new Set(['business_unit', 'unit_and_subordinates']); + +/** The engine slice the service reads through: the sharing engine plus its registry. */ +export interface OwnershipQueueEngine extends SharingEngine { + registry?: { + listItems?: (type: string) => unknown[]; + getObject?: (name: string) => unknown; + }; +} + +export interface OwnershipQueueServiceOptions { + /** + * The engine, resolved at use and never at construction: the service is + * registered in the plugin's `init` so the engine's `kernel:ready` boot + * refusal finds it, before the engine is necessarily reachable. + */ + engine: () => OwnershipQueueEngine | null | undefined; + logger?: OptionalSharingLogger; +} + +/** A declared queue as the registry serves it — only the keys this service reads. */ +interface DeclaredQueueItem { + name: string; + label?: string; + members?: Array<{ type?: unknown; value?: unknown }>; + _packageId?: string; + packageId?: string; +} + +function str(v: unknown): string | null { + if (v == null) return null; + const s = String(v); + return s === '' ? null : s; +} + +function isRecord(v: unknown): v is Record { + return v !== null && typeof v === 'object' && !Array.isArray(v); +} + +/** + * One call's memo of the reads several specifications share: a position's + * activation and holding, a team's membership, a unit's expansion. Scoped to a + * single public call so an answer is never older than the call that asked. + */ +interface ExpansionPass { + /** `'addressing'` keeps a switched-off position's holders (see the contract's `expandMembers`). */ + purpose: QueueExpansionPurpose; + positionActive: Map; + expansions: Map>; +} + +function newPass(purpose: QueueExpansionPurpose = 'access'): ExpansionPass { + return { purpose, positionActive: new Map(), expansions: new Map() }; +} + +export class OwnershipQueueService implements IOwnershipQueueService { + private readonly engine: () => OwnershipQueueEngine | null | undefined; + private readonly logger?: OptionalSharingLogger; + private readonly warned = new Set(); + + constructor(options: OwnershipQueueServiceOptions) { + this.engine = options.engine; + this.logger = options.logger; + } + + // ── The contract ─────────────────────────────────────────────────────────── + + async resolveQueue(name: string, organizationId: string | null): Promise { + const engine = this.engine(); + if (!engine || !name) return null; + const declared = await this.declaredQueueItem(name); + if (declared) { + const authoredMembers = await this.authoredMemberSpecs(engine, [name], organizationId); + return { + name, + label: declared.label ?? name, + source: 'declared', + organizationId: null, + members: [...declaredMemberSpecs(declared), ...(authoredMembers.get(name) ?? [])], + ...(declared._packageId ?? declared.packageId + ? { packageId: String(declared._packageId ?? declared.packageId) } + : {}), + }; + } + // A declared name switched off in the ledger resolves nowhere — not even to + // an authored row, which may not take a declared queue's name. + if (this.declaredItems().some((q) => q.name === name)) return null; + const row = await this.authoredQueueRow(engine, name, organizationId); + if (!row) return null; + const authoredMembers = await this.authoredMemberSpecs(engine, [name], organizationId); + return { + name, + label: str(row.label) ?? name, + source: 'authored', + organizationId: str(row.organization_id), + members: authoredMembers.get(name) ?? [], + }; + } + + async expandMembers( + name: string, + organizationId: string | null, + options?: { purpose?: QueueExpansionPurpose }, + ): Promise { + const queue = await this.resolveQueue(name, organizationId); + if (!queue) return []; + return this.expandSpecs(queue.members, organizationId, newPass(options?.purpose)); + } + + async isMember(userId: string, name: string, organizationId: string | null): Promise { + if (!userId) return false; + const queue = await this.resolveQueue(name, organizationId); + if (!queue) return false; + return this.specsReachUser(queue.members, userId, organizationId, newPass()); + } + + async queuesOf(userId: string, organizationId: string | null): Promise { + const engine = this.engine(); + if (!engine || !userId) return []; + const declared = await this.activeDeclaredQueues(); + const authored = await this.authoredQueueNames(engine, organizationId, new Set(this.declaredItems().map((q) => q.name))); + const names = [...new Set([...declared.map((q) => q.name), ...authored])].sort(); + if (names.length === 0) return []; + const memberRows = await this.authoredMemberSpecs(engine, names, organizationId); + const declaredByName = new Map(declared.map((q) => [q.name, q] as const)); + const pass = newPass(); + const out: string[] = []; + for (const name of names) { + const specs = [ + ...(declaredByName.has(name) ? declaredMemberSpecs(declaredByName.get(name)!) : []), + ...(memberRows.get(name) ?? []), + ]; + if (await this.specsReachUser(specs, userId, organizationId, pass)) out.push(name); + } + return out; + } + + // ── Helpers the plugin's other modules read ──────────────────────────────── + + /** + * The declared queue named `name`, unless the activation ledger switches it + * off — the reader the activation door looks a `queue` row's subject up + * through, and the first half of {@link resolveQueue}. + */ + async declaredQueueNamed(name: string): Promise<{ name: string; packageId?: string } | null> { + const item = await this.declaredQueueItem(name); + if (!item) return null; + const packageId = str(item._packageId ?? item.packageId); + return { name: item.name, ...(packageId ? { packageId } : {}) }; + } + + private async declaredQueueItem(name: string): Promise { + const item = this.declaredItems().find((q) => q.name === name); + if (!item) return null; + const off = await this.switchedOff(QUEUE_ACTIVATION_TYPE, [name]); + return off.has(name) ? null : item; + } + + /** Is `name` a declared queue's name, switched on or off? The authored-row guard's question. */ + isDeclaredQueueName(name: string): boolean { + return this.declaredItems().some((q) => q.name === name); + } + + /** + * The queues in `organizationId` whose member specifications name one of + * `values` under `kind` — the recompute's look-through from a position, team + * or business-unit write to the queue-recipient sharing rules it moves. + */ + async queuesNaming(kind: string, values: ReadonlySet, organizationId: string | null): Promise { + const engine = this.engine(); + if (!engine || values.size === 0) return []; + const out = new Set(); + if (kind === 'position') { + for (const q of await this.activeDeclaredQueues()) { + if (declaredMemberSpecs(q).some((s) => values.has(s.value))) out.add(q.name); + } + } + try { + const rows = await engine.find(QUEUE_MEMBER_OBJECT, { + where: { + recipient_type: kind, + ...(organizationId ? { organization_id: organizationId } : {}), + }, + fields: ['queue', 'recipient_id'], + limit: QUEUE_READ_LIMIT, + context: SYSTEM_CTX, + }); + for (const r of (Array.isArray(rows) ? rows : []) as Array>) { + const queue = str(r.queue); + const value = str(r.recipient_id); + if (queue && value && (BU_MEMBER_KINDS.has(kind) || values.has(value))) out.add(queue); + } + } catch (err: any) { + this.warnOnce(`naming::${kind}`, '[queue] member read failed while looking through queues — the rules naming those queues are re-evaluated on their next write', { kind, error: err?.message }); + } + return [...out].sort(); + } + + // ── Expansion ────────────────────────────────────────────────────────────── + + private async expandSpecs( + specs: readonly QueueMemberSpec[], + organizationId: string | null, + pass: ExpansionPass, + ): Promise { + const users = new Set(); + for (const spec of specs) { + for (const u of await this.expandSpec(spec, organizationId, pass)) users.add(u); + } + return [...users].sort(); + } + + private expandSpec(spec: QueueMemberSpec, organizationId: string | null, pass: ExpansionPass): Promise { + const key = `${spec.type}::${spec.value}`; + let pending = pass.expansions.get(key); + if (!pending) { + pending = this.expandSpecUncached(spec, organizationId, pass).catch((err: any) => { + this.warnOnce(`expand::${key}`, '[queue] a member specification could not be expanded — it contributes nobody until it can', { + type: spec.type, value: spec.value, error: err?.message, + }); + return []; + }); + pass.expansions.set(key, pending); + } + return pending; + } + + private async expandSpecUncached( + spec: QueueMemberSpec, + organizationId: string | null, + pass: ExpansionPass, + ): Promise { + const engine = this.engine(); + if (!engine || !spec.value) return []; + const team = new TeamGraphService({ engine, organizationId }); + switch (spec.type) { + case 'user': + return [spec.value]; + case 'team': + return team.expandUsers(spec.value); + case 'position': { + if (!(await this.positionActive(spec.value, pass))) return []; + const positions = new PositionGraphService({ engine, organizationId, teamGraph: team }); + return positions.expandPositionUsers(spec.value, organizationId ?? undefined); + } + case 'business_unit': + return new BusinessUnitGraphService({ engine, organizationId, teamGraph: team }).expandUnitMembers(spec.value); + case 'unit_and_subordinates': + return new BusinessUnitGraphService({ engine, organizationId, teamGraph: team }).expandUsers(spec.value); + default: + return []; + } + } + + /** + * Does any specification reach `userId`? The user-scoped twin of + * {@link expandSpecs}: a position or a team is answered by an indexed + * holder check rather than a full expansion, the reason + * `PositionGraphService.holdsPosition` exists. + */ + private async specsReachUser( + specs: readonly QueueMemberSpec[], + userId: string, + organizationId: string | null, + pass: ExpansionPass, + ): Promise { + const engine = this.engine(); + if (!engine) return false; + for (const spec of specs) { + if (!spec.value) continue; + switch (spec.type) { + case 'user': + if (spec.value === userId) return true; + break; + case 'team': + if (await teamHasMember(engine, spec.value, userId)) return true; + break; + case 'position': { + if (!(await this.positionActive(spec.value, pass))) break; + const positions = new PositionGraphService({ engine, organizationId }); + if (await positions.holdsPosition(userId, spec.value, organizationId ?? undefined)) return true; + break; + } + default: + if ((await this.expandSpec(spec, organizationId, pass)).includes(userId)) return true; + } + } + return false; + } + + // ── Reads ────────────────────────────────────────────────────────────────── + + private declaredItems(): DeclaredQueueItem[] { + const items = this.engine()?.registry?.listItems?.('queue'); + if (!Array.isArray(items)) return []; + return items.filter((q): q is DeclaredQueueItem => isRecord(q) && typeof q.name === 'string' && q.name !== ''); + } + + private async activeDeclaredQueues(): Promise { + const items = this.declaredItems(); + if (items.length === 0) return []; + const off = await this.switchedOff(QUEUE_ACTIVATION_TYPE, items.map((q) => q.name)); + return items.filter((q) => !off.has(q.name)); + } + + private async authoredQueueRow( + engine: OwnershipQueueEngine, + name: string, + organizationId: string | null, + ): Promise | null> { + try { + const rows = await engine.find(QUEUE_OBJECT, { + where: { name, ...(organizationId ? { organization_id: organizationId } : {}) }, + fields: ['id', 'name', 'label', 'organization_id'], + limit: 1, + context: SYSTEM_CTX, + }); + return Array.isArray(rows) && isRecord(rows[0]) ? rows[0] : null; + } catch (err: any) { + this.warnOnce('read::sys_queue', '[queue] sys_queue read failed — authored queues resolve nowhere until it succeeds', { error: err?.message }); + return null; + } + } + + private async authoredQueueNames( + engine: OwnershipQueueEngine, + organizationId: string | null, + declaredNames: ReadonlySet, + ): Promise { + try { + const rows = await engine.find(QUEUE_OBJECT, { + where: organizationId ? { organization_id: organizationId } : {}, + fields: ['name'], + limit: QUEUE_READ_LIMIT, + context: SYSTEM_CTX, + }); + const out: string[] = []; + for (const r of (Array.isArray(rows) ? rows : []) as Array>) { + const name = str(r.name); + // A row under a declared name is not a second queue (the guard refuses + // the write; a row that predates the declaration is reported there). + if (name && !declaredNames.has(name)) out.push(name); + } + return out; + } catch (err: any) { + this.warnOnce('read::sys_queue', '[queue] sys_queue read failed — authored queues resolve nowhere until it succeeds', { error: err?.message }); + return []; + } + } + + /** The organization's `sys_queue_member` rows for `names`, grouped by queue. */ + private async authoredMemberSpecs( + engine: OwnershipQueueEngine, + names: readonly string[], + organizationId: string | null, + ): Promise> { + const out = new Map(); + if (names.length === 0) return out; + try { + const rows = await engine.find(QUEUE_MEMBER_OBJECT, { + where: { + queue: names.length === 1 ? names[0] : { $in: [...names] }, + ...(organizationId ? { organization_id: organizationId } : {}), + }, + fields: ['queue', 'recipient_type', 'recipient_id'], + limit: QUEUE_READ_LIMIT, + context: SYSTEM_CTX, + }); + for (const r of (Array.isArray(rows) ? rows : []) as Array>) { + const queue = str(r.queue); + const type = str(r.recipient_type); + const value = str(r.recipient_id); + if (!queue || !type || !value || !MEMBER_KINDS.has(type)) continue; + const list = out.get(queue) ?? []; + list.push({ type: type as QueueMemberSpec['type'], value }); + out.set(queue, list); + } + } catch (err: any) { + this.warnOnce('read::sys_queue_member', '[queue] sys_queue_member read failed — authored members count as nobody until it succeeds', { error: err?.message }); + } + return out; + } + + /** + * A deactivated position confers nothing, here as for a sharing rule's + * position recipient: read from the activation ledger, no row = active. + */ + private async positionActive(name: string, pass: ExpansionPass): Promise { + if (pass.purpose === 'addressing') return true; + const memo = pass.positionActive.get(name); + if (memo !== undefined) return memo; + const verdict = !(await this.switchedOff('position', [name])).has(name); + pass.positionActive.set(name, verdict); + return verdict; + } + + /** + * The names of `type` the activation ledger switches off. A composition with + * no ledger object issues no read; a failed read switches nothing off for + * this call and says so. + */ + private async switchedOff(type: string, names: readonly string[]): Promise> { + const off = new Set(); + const engine = this.engine(); + if (!engine || names.length === 0) return off; + const getObject = engine.registry?.getObject; + if (typeof getObject === 'function' && !getObject.call(engine.registry, METADATA_ACTIVATION_TABLE)) return off; + try { + const rows = await engine.find(METADATA_ACTIVATION_TABLE, { + filter: { metadata_type: type, name: names.length === 1 ? names[0] : { $in: [...names] } }, + fields: ['metadata_type', 'name', 'active'], + limit: names.length, + context: SYSTEM_CTX, + }); + for (const row of (Array.isArray(rows) ? rows : []) as Array<{ name?: unknown; active?: unknown }>) { + if (row.active === false || row.active === 0) off.add(String(row.name)); + } + } catch (err: any) { + this.warnOnce(`ledger::${type}`, `[queue] ${METADATA_ACTIVATION_TABLE} read failed — '${type}' items treated as switched ON for this call`, { error: err?.message }); + } + return off; + } + + private warnOnce(key: string, message: string, meta: Record): void { + if (this.warned.has(key)) return; + this.warned.add(key); + this.logger?.warn?.(message, meta); + } +} + +/** A declared queue's member specifications — positions, by the schema's own rule. */ +function declaredMemberSpecs(q: DeclaredQueueItem): QueueMemberSpec[] { + const out: QueueMemberSpec[] = []; + for (const m of Array.isArray(q.members) ? q.members : []) { + const value = str(m?.value); + if (m?.type === 'position' && value) out.push({ type: 'position', value }); + } + return out; +} + +/** Does any of a queue's specifications read the business-unit graph? */ +export function specsReadBusinessUnits(specs: readonly QueueMemberSpec[]): boolean { + return specs.some((s) => BU_MEMBER_KINDS.has(s.type)); +} diff --git a/packages/plugins/plugin-sharing/src/ownership-queue.test.ts b/packages/plugins/plugin-sharing/src/ownership-queue.test.ts new file mode 100644 index 00000000000..26de972b76a --- /dev/null +++ b/packages/plugins/plugin-sharing/src/ownership-queue.test.ts @@ -0,0 +1,426 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0142 D1/D2/D3/D7] The ownership queue in plugin-sharing: the one + * expander, the sitting-record access path, the authored-table guards and the + * queue-recipient recompute. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { QUEUE_FIELD_DEF } from '@objectstack/spec/data'; +import { METADATA_ACTIVATION_TABLE } from '@objectstack/core'; +import { OwnershipQueueService, specsReadBusinessUnits } from './ownership-queue-service.js'; +import { SharingService } from './sharing-service.js'; +import { + bindQueueGuards, + bindQueueRecipientRecompute, + recomputeQueueRecipientRules, + reportShadowedAuthoredQueues, + QueueValidationError, + QUEUE_GUARD_PACKAGE, +} from './queue-hooks.js'; +import { ruleRegrantQueue } from './rule-hooks.js'; + +type Row = Record; + +/** Equality, `$in` and `$or` — every predicate shape the queue reads send. */ +function matches(row: Row, filter: any): boolean { + if (!filter || typeof filter !== 'object') return true; + if (Array.isArray(filter.$or) && !filter.$or.some((f: any) => matches(row, f))) return false; + for (const [k, v] of Object.entries(filter)) { + if (k === '$or') continue; + const rv = row[k] ?? null; + if (v != null && typeof v === 'object' && '$in' in (v as any)) { + if (!(v as any).$in.includes(rv)) return false; + continue; + } + if (rv !== v) return false; + } + return true; +} + +function makeEngine(opts: { + declared?: Row[]; + tables?: Record; + schemas?: Record; + failOn?: Set; +} = {}) { + const tables: Record = { ...(opts.tables ?? {}) }; + const reads: string[] = []; + const hooks: Array<{ event: string; object: string; handler: (ctx: any) => any; packageId?: string }> = []; + const engine = { + _tables: tables, + reads, + hooks, + registry: { + listItems: (type: string) => (type === 'queue' ? (opts.declared ?? []) : []), + getObject: (name: string) => (name === METADATA_ACTIVATION_TABLE ? { name } : opts.schemas?.[name]), + }, + getSchema: (name: string) => opts.schemas?.[name], + async find(object: string, options?: any) { + reads.push(object); + if (opts.failOn?.has(object)) throw new Error(`${object} unavailable`); + const filter = options?.filter ?? options?.where; + return (tables[object] ?? []).filter((r) => matches(r, filter)).slice(0, options?.limit ?? 1000); + }, + async insert(object: string, data: any) { + (tables[object] ??= []).push({ ...data }); + return data; + }, + async update() { return {}; }, + async delete() { return {}; }, + registerHook(event: string, handler: (ctx: any) => any, o?: { object?: string; packageId?: string }) { + hooks.push({ event, object: String(o?.object), handler, packageId: o?.packageId }); + }, + unregisterHooksByPackage(packageId: string) { + const before = hooks.length; + for (let i = hooks.length - 1; i >= 0; i--) if (hooks[i].packageId === packageId) hooks.splice(i, 1); + return before - hooks.length; + }, + async fire(event: string, object: string, ctx: any) { + for (const h of hooks.filter((x) => x.event === event && x.object === object)) await h.handler(ctx); + }, + }; + return engine; +} + +const SUPPORT_DECLARED = [{ name: 'support_tier1', label: 'Support Tier 1', members: [{ type: 'position', value: 'support_agent' }], _packageId: 'com.acme.support' }]; + +const BASE_TABLES = (): Record => ({ + sys_user_position: [ + { position: 'support_agent', user_id: 'u_b', organization_id: 'org_a' }, + { position: 'support_agent', user_id: 'u_a', organization_id: 'org_a' }, + { position: 'support_agent', user_id: 'u_other_org', organization_id: 'org_b' }, + { position: 'billing_clerk', user_id: 'u_c', organization_id: 'org_a' }, + ], + sys_team_member: [{ team_id: 't_night', user_id: 'u_d' }, { team_id: 't_night', user_id: 'u_a' }], + sys_queue: [ + { id: 'q1', name: 'billing', label: 'Billing', organization_id: 'org_a' }, + { id: 'q2', name: 'billing', label: 'Billing B', organization_id: 'org_b' }, + ], + sys_queue_member: [ + { queue: 'billing', recipient_type: 'position', recipient_id: 'billing_clerk', organization_id: 'org_a' }, + { queue: 'billing', recipient_type: 'user', recipient_id: 'u_e', organization_id: 'org_a' }, + { queue: 'support_tier1', recipient_type: 'team', recipient_id: 't_night', organization_id: 'org_a' }, + { queue: 'billing', recipient_type: 'user', recipient_id: 'u_b_only', organization_id: 'org_b' }, + ], + [METADATA_ACTIVATION_TABLE]: [], +}); + +function service(engine: ReturnType, logger?: any) { + return new OwnershipQueueService({ engine: () => engine as any, logger }); +} + +describe('[ADR-0142 D1/D2] OwnershipQueueService — resolution', () => { + it('a declared queue resolves registry-first, with its own positions plus the organization\'s member rows', async () => { + const svc = service(makeEngine({ declared: SUPPORT_DECLARED, tables: BASE_TABLES() })); + const q = await svc.resolveQueue('support_tier1', 'org_a'); + expect(q).toMatchObject({ name: 'support_tier1', label: 'Support Tier 1', source: 'declared', organizationId: null, packageId: 'com.acme.support' }); + expect(q?.members).toEqual([{ type: 'position', value: 'support_agent' }, { type: 'team', value: 't_night' }]); + }); + + it('an authored queue resolves in its own organization only', async () => { + const svc = service(makeEngine({ tables: BASE_TABLES() })); + expect(await svc.resolveQueue('billing', 'org_a')).toMatchObject({ source: 'authored', label: 'Billing', organizationId: 'org_a' }); + expect((await svc.resolveQueue('billing', 'org_b'))?.members).toEqual([{ type: 'user', value: 'u_b_only' }]); + expect(await svc.resolveQueue('billing', 'org_c')).toBeNull(); + expect(await svc.resolveQueue('nowhere', 'org_a')).toBeNull(); + }); + + it('a declared queue the activation ledger switches off resolves nowhere — not even to an authored row under the name', async () => { + const tables = BASE_TABLES(); + tables[METADATA_ACTIVATION_TABLE] = [{ metadata_type: 'queue', name: 'support_tier1', active: false }]; + tables.sys_queue.push({ id: 'q3', name: 'support_tier1', label: 'Shadow', organization_id: 'org_a' }); + const svc = service(makeEngine({ declared: SUPPORT_DECLARED, tables })); + expect(await svc.resolveQueue('support_tier1', 'org_a')).toBeNull(); + expect(await svc.expandMembers('support_tier1', 'org_a')).toEqual([]); + expect(await svc.declaredQueueNamed('support_tier1')).toBeNull(); + // The name stays reserved: the authored-row guard still refuses it. + expect(svc.isDeclaredQueueName('support_tier1')).toBe(true); + }); + + it('declaredQueueNamed answers the declared queue with its package', async () => { + const svc = service(makeEngine({ declared: SUPPORT_DECLARED, tables: BASE_TABLES() })); + expect(await svc.declaredQueueNamed('support_tier1')).toEqual({ name: 'support_tier1', packageId: 'com.acme.support' }); + expect(await svc.declaredQueueNamed('billing')).toBeNull(); + }); +}); + +describe('[ADR-0142 D2] OwnershipQueueService — the one expander', () => { + it('expands the union, de-duplicated and ordered by user id, scoped to the organization', async () => { + const svc = service(makeEngine({ declared: SUPPORT_DECLARED, tables: BASE_TABLES() })); + // support_agent holders in org_a (u_a, u_b) ∪ team t_night (u_a, u_d); u_other_org is org_b's. + expect(await svc.expandMembers('support_tier1', 'org_a')).toEqual(['u_a', 'u_b', 'u_d']); + expect(await svc.expandMembers('billing', 'org_a')).toEqual(['u_c', 'u_e']); + }); + + it('an empty queue and an unresolved name both answer []', async () => { + const svc = service(makeEngine({ declared: [{ name: 'empty_q', label: 'Empty', members: [] }], tables: BASE_TABLES() })); + expect(await svc.expandMembers('empty_q', 'org_a')).toEqual([]); + expect(await svc.expandMembers('nowhere', 'org_a')).toEqual([]); + }); + + it('a switched-off position confers nothing on the access paths and still addresses on the approver path', async () => { + const tables = BASE_TABLES(); + tables[METADATA_ACTIVATION_TABLE] = [{ metadata_type: 'position', name: 'support_agent', active: false }]; + const svc = service(makeEngine({ declared: SUPPORT_DECLARED, tables })); + expect(await svc.expandMembers('support_tier1', 'org_a')).toEqual(['u_a', 'u_d']); + expect(await svc.expandMembers('support_tier1', 'org_a', { purpose: 'addressing' })).toEqual(['u_a', 'u_b', 'u_d']); + expect(await svc.isMember('u_b', 'support_tier1', 'org_a')).toBe(false); + }); + + it('a failed member-table read contributes nobody and is logged once', async () => { + const warn = vi.fn(); + const svc = service(makeEngine({ tables: BASE_TABLES(), failOn: new Set(['sys_queue_member']) }), { warn }); + expect(await svc.expandMembers('billing', 'org_a')).toEqual([]); + expect(await svc.expandMembers('billing', 'org_a')).toEqual([]); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch(/sys_queue_member read failed/); + }); + + it('a failed ledger read leaves the declared default — on — and says so', async () => { + const warn = vi.fn(); + const svc = service(makeEngine({ declared: SUPPORT_DECLARED, tables: BASE_TABLES(), failOn: new Set([METADATA_ACTIVATION_TABLE]) }), { warn }); + expect(await svc.expandMembers('support_tier1', 'org_a')).toEqual(['u_a', 'u_b', 'u_d']); + expect(warn).toHaveBeenCalled(); + }); + + it('isMember answers by holder checks', async () => { + const svc = service(makeEngine({ declared: SUPPORT_DECLARED, tables: BASE_TABLES() })); + expect(await svc.isMember('u_b', 'support_tier1', 'org_a')).toBe(true); // position + expect(await svc.isMember('u_d', 'support_tier1', 'org_a')).toBe(true); // team + expect(await svc.isMember('u_e', 'billing', 'org_a')).toBe(true); // user + expect(await svc.isMember('u_other_org', 'support_tier1', 'org_a')).toBe(false); + expect(await svc.isMember('u_e', 'billing', 'org_b')).toBe(false); + expect(await svc.isMember('u_a', 'nowhere', 'org_a')).toBe(false); + expect(await svc.isMember('', 'support_tier1', 'org_a')).toBe(false); + }); + + it('queuesOf lists every queue the user is in, declared and authored, ordered by name', async () => { + const tables = BASE_TABLES(); + tables.sys_queue_member.push({ queue: 'billing', recipient_type: 'user', recipient_id: 'u_a', organization_id: 'org_a' }); + const svc = service(makeEngine({ declared: SUPPORT_DECLARED, tables })); + expect(await svc.queuesOf('u_a', 'org_a')).toEqual(['billing', 'support_tier1']); + expect(await svc.queuesOf('u_c', 'org_a')).toEqual(['billing']); + expect(await svc.queuesOf('u_nobody', 'org_a')).toEqual([]); + }); + + it('queuesNaming looks through from a position or a team to the queues it staffs', async () => { + const svc = service(makeEngine({ declared: SUPPORT_DECLARED, tables: BASE_TABLES() })); + expect(await svc.queuesNaming('position', new Set(['support_agent']), 'org_a')).toEqual(['support_tier1']); + expect(await svc.queuesNaming('position', new Set(['billing_clerk']), 'org_a')).toEqual(['billing']); + expect(await svc.queuesNaming('team', new Set(['t_night']), 'org_a')).toEqual(['support_tier1']); + expect(await svc.queuesNaming('team', new Set(['t_day']), 'org_a')).toEqual([]); + }); + + it('specsReadBusinessUnits names the two unit kinds', () => { + expect(specsReadBusinessUnits([{ type: 'position', value: 'p' }])).toBe(false); + expect(specsReadBusinessUnits([{ type: 'business_unit', value: 'bu1' }])).toBe(true); + expect(specsReadBusinessUnits([{ type: 'unit_and_subordinates', value: 'bu1' }])).toBe(true); + }); + + it('no engine yet — every answer is empty, never a throw', async () => { + const svc = new OwnershipQueueService({ engine: () => null }); + expect(await svc.resolveQueue('x', 'org_a')).toBeNull(); + expect(await svc.expandMembers('x', 'org_a')).toEqual([]); + expect(await svc.isMember('u', 'x', 'org_a')).toBe(false); + expect(await svc.queuesOf('u', 'org_a')).toEqual([]); + }); +}); + +// ─── D3: the sitting-record access path ──────────────────────────────────── + +const TICKET_SCHEMA = { + name: 'ticket', + sharingModel: 'private', + fields: { id: {}, subject: {}, owner_id: {}, queue: { ...QUEUE_FIELD_DEF } }, +}; +/** A declared `queue` that is the author's own field, not the platform's column. */ +const AUTHOR_QUEUE_SCHEMA = { + name: 'job', + sharingModel: 'private', + fields: { id: {}, owner_id: {}, queue: { type: 'text', label: 'Print queue' } }, +}; +const OPEN_SCHEMA = { + name: 'idea', + sharingModel: 'public_read_write', + fields: { id: {}, owner_id: {}, queue: { ...QUEUE_FIELD_DEF } }, +}; + +describe('[ADR-0142 D3] SharingService — a record sitting in a queue the caller belongs to', () => { + let engine: ReturnType; + let sharing: SharingService; + const ctx = (userId: string, extra: Record = {}) => ({ userId, tenantId: 'org_a', ...extra }) as any; + + beforeEach(() => { + engine = makeEngine({ + declared: SUPPORT_DECLARED, + tables: { + ...BASE_TABLES(), + ticket: [ + { id: 't1', owner_id: null, queue: 'support_tier1', organization_id: 'org_a' }, + { id: 't2', owner_id: 'u_z', queue: 'support_tier1', organization_id: 'org_a' }, + { id: 't3', owner_id: null, queue: 'support_tier1', organization_id: 'org_b' }, + ], + job: [{ id: 'j1', owner_id: null, queue: 'support_tier1', organization_id: 'org_a' }], + idea: [{ id: 'i1', owner_id: null, queue: 'support_tier1', organization_id: 'org_a' }], + sys_record_share: [], + }, + schemas: { ticket: TICKET_SCHEMA, job: AUTHOR_QUEUE_SCHEMA, idea: OPEN_SCHEMA, sys_record_share: { name: 'sys_record_share' } }, + }); + const queues = service(engine); + sharing = new SharingService({ engine: engine as any, ownershipQueues: () => queues }); + }); + + it('the read filter carries `owner_id IS NULL AND queue IN current_user.queues` beside the owner match', async () => { + const filter = await sharing.buildReadFilter('ticket', ctx('u_a')); + expect(JSON.stringify(filter)).toContain(JSON.stringify({ owner_id: null, queue: 'support_tier1' })); + }); + + it('a caller in no queue gets the owner match alone', async () => { + const filter = await sharing.buildReadFilter('ticket', ctx('u_nobody')); + expect(JSON.stringify(filter)).not.toContain('"queue"'); + }); + + it('the guest never reaches a queue', async () => { + const guest = { tenantId: 'org_a', principalKind: 'guest' } as any; + expect(await sharing.callerQueues(guest)).toEqual([]); + expect(await sharing.checkEdit('ticket', 't1', guest)).toBe('deny'); + }); + + it('an author\'s own `queue` field is not the platform\'s column — no disjunct, no edit', async () => { + const filter = await sharing.buildReadFilter('job', ctx('u_a')); + expect(JSON.stringify(filter ?? {})).not.toContain('"queue"'); + expect(await sharing.checkEdit('job', 'j1', ctx('u_a'))).toBe('deny'); + }); + + it('checkEdit allows a member on a sitting record and nobody else', async () => { + expect(await sharing.checkEdit('ticket', 't1', ctx('u_a'))).toBe('allow'); + expect(await sharing.checkEdit('ticket', 't1', ctx('u_nobody'))).toBe('deny'); + }); + + it('an owned record does not sit in its home queue — a member gets no edit through it', async () => { + expect(await sharing.checkEdit('ticket', 't2', ctx('u_a'))).toBe('deny'); + }); + + it('a record of another organization is never reached through this organization\'s queue', async () => { + expect(await sharing.checkEdit('ticket', 't3', ctx('u_a'))).toBe('deny'); + }); + + it('the basis never reaches delete', async () => { + expect(await sharing.checkDelete('ticket', 't1', ctx('u_a'))).toBe('deny'); + }); + + it('on an open OWD the basis answers allow ahead of the public abstain', async () => { + expect(await sharing.checkEdit('idea', 'i1', ctx('u_a'))).toBe('allow'); + expect(await sharing.checkEdit('idea', 'i1', ctx('u_nobody'))).toBe('abstain'); + }); + + it('with no queue service the basis fails closed', async () => { + const bare = new SharingService({ engine: engine as any }); + expect(await bare.checkEdit('ticket', 't1', ctx('u_a'))).toBe('deny'); + expect(JSON.stringify(await bare.buildReadFilter('ticket', ctx('u_a')))).not.toContain('"queue"'); + }); + + it('membership is resolved once per request context', async () => { + const queuesOf = vi.fn(async () => ['support_tier1']); + const s = new SharingService({ engine: engine as any, ownershipQueues: () => ({ queuesOf, isMember: async () => false }) }); + const c = ctx('u_a'); + await s.buildReadFilter('ticket', c); + await s.buildReadFilter('ticket', c); + expect(queuesOf).toHaveBeenCalledTimes(1); + }); +}); + +// ─── D1: the authored-table guards ───────────────────────────────────────── + +describe('[ADR-0142 D1] sys_queue / sys_queue_member guards', () => { + const engine = makeEngine(); + const queues = { isDeclaredQueueName: (n: string) => n === 'support_tier1' }; + bindQueueGuards(engine as any, queues); + + it('refuses a non-machine name and a declared queue\'s name', async () => { + await expect(engine.fire('beforeInsert', 'sys_queue', { input: { data: { name: 'Billing Team' } } })).rejects.toBeInstanceOf(QueueValidationError); + await expect(engine.fire('beforeInsert', 'sys_queue', { input: { data: { name: 'support_tier1' } } })).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + fields: [{ field: 'name', code: 'duplicate' }], + }); + await expect(engine.fire('beforeInsert', 'sys_queue', { input: { data: { name: 'billing' } } })).resolves.toBeUndefined(); + }); + + it('an update that leaves the name alone is not re-judged', async () => { + await expect(engine.fire('beforeUpdate', 'sys_queue', { input: { data: { label: 'X' } } })).resolves.toBeUndefined(); + await expect(engine.fire('beforeUpdate', 'sys_queue', { input: { data: { name: 'Bad Name' } } })).rejects.toBeInstanceOf(QueueValidationError); + }); + + it('a member row names its queue by machine name', async () => { + await expect(engine.fire('beforeInsert', 'sys_queue_member', { input: { data: { queue: 'q1-id' } } })).rejects.toMatchObject({ fields: [{ field: 'queue' }] }); + await expect(engine.fire('beforeInsert', 'sys_queue_member', { input: { data: { queue: 'billing' } } })).resolves.toBeUndefined(); + }); + + it('re-binding replaces, never stacks', () => { + const before = engine.hooks.filter((h) => h.packageId === QUEUE_GUARD_PACKAGE).length; + bindQueueGuards(engine as any, queues); + expect(engine.hooks.filter((h) => h.packageId === QUEUE_GUARD_PACKAGE).length).toBe(before); + }); + + it('a boot report names every authored row a declaration now shadows', async () => { + const e = makeEngine({ tables: { sys_queue: [{ name: 'support_tier1', organization_id: 'org_a' }, { name: 'billing', organization_id: 'org_a' }] } }); + const error = vi.fn(); + await reportShadowedAuthoredQueues(e as any, queues, { error } as any); + expect(error).toHaveBeenCalledTimes(1); + expect(error.mock.calls[0][1]).toEqual({ queue: 'support_tier1', organization: 'org_a' }); + }); +}); + +// ─── D7: the queue-recipient recompute ───────────────────────────────────── + +describe('[ADR-0142 D7] queue-recipient rules recompute on a queue-membership write', () => { + function ruleService() { + return { + listRules: vi.fn(async () => [ + { id: 'r1', name: 'tier1_reads', recipient_type: 'queue', recipient_id: 'support_tier1' }, + { id: 'r2', name: 'billing_reads', recipient_type: 'queue', recipient_id: 'billing' }, + { id: 'r3', name: 'team_reads', recipient_type: 'team', recipient_id: 'support_tier1' }, + ] as any[]), + revokeRuleGrantsForRetiredRecipients: vi.fn(async () => 0), + evaluateRule: vi.fn(async () => ({})), + }; + } + + it('revokes synchronously and re-grants on the shared queue, for the queue-recipient rules naming the queue only', async () => { + const svc = ruleService(); + await recomputeQueueRecipientRules(svc, new Set(['support_tier1']), undefined, { object: 'sys_queue_member', event: 'afterInsert' }); + expect(svc.revokeRuleGrantsForRetiredRecipients).toHaveBeenCalledTimes(1); + expect((svc.revokeRuleGrantsForRetiredRecipients.mock.calls[0] as any[])[0]).toMatchObject({ id: 'r1' }); + await ruleRegrantQueue.whenIdle(); + expect(svc.evaluateRule).toHaveBeenCalledWith('r1', expect.anything()); + expect(svc.evaluateRule).toHaveBeenCalledTimes(1); + }); + + it('a member-row write recomputes the queue before and after the write', async () => { + const engine = makeEngine(); + const svc = ruleService(); + bindQueueRecipientRecompute(engine as any, svc); + await engine.fire('afterUpdate', 'sys_queue_member', { + previous: { queue: 'support_tier1' }, + result: { queue: 'billing' }, + input: { data: { queue: 'billing' } }, + }); + const revoked = svc.revokeRuleGrantsForRetiredRecipients.mock.calls.map((c: any[]) => c[0].id).sort(); + expect(revoked).toEqual(['r1', 'r2']); + await ruleRegrantQueue.whenIdle(); + }); + + it('a failing recompute never fails the operator\'s write', async () => { + const engine = makeEngine(); + const warn = vi.fn(); + bindQueueRecipientRecompute(engine as any, { + listRules: async () => { throw new Error('down'); }, + revokeRuleGrantsForRetiredRecipients: async () => 0, + evaluateRule: async () => ({}), + }, { warn }); + await expect(engine.fire('afterDelete', 'sys_queue', { previous: { name: 'billing' } })).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalled(); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/queue-hooks.ts b/packages/plugins/plugin-sharing/src/queue-hooks.ts new file mode 100644 index 00000000000..5fb61dc314b --- /dev/null +++ b/packages/plugins/plugin-sharing/src/queue-hooks.ts @@ -0,0 +1,270 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0142 D1/D2/D7] The hooks the authored queue tables carry. + * + * ## The name guard — one namespace + * + * A queue is referenced everywhere by its machine name, declared or authored, + * resolved registry-first. So a `sys_queue` row may not take a declared + * queue's name — it would be a second queue under a name that already answers + * — and a name must be snake_case, because the `queue` column, an assignment + * rule's target, an approver's value and a sharing recipient's value all carry + * it verbatim. Authoring a queue in Setup is a plain data insert, so the guard + * is a hook on the table rather than a check inside a service method nobody + * calls. A declaration that arrives OVER an existing row (a package installed + * after the organization authored the name) is not a write this guard sees: it + * is reported at boot by {@link reportShadowedAuthoredQueues}, loudly, and the + * declared queue answers for the name. + * + * ## The queue-recipient recompute + * + * A sharing rule whose recipient is a queue materialises its grants per member + * (ADR-0142 D7), so a write that changes who is in the queue must move them. + * Three write paths can: + * + * | written table | module | + * |-------------------------------------|---------------------------------------| + * | `sys_queue_member`, `sys_queue` | this one — the queue's own rows | + * | `sys_user_position`, `sys_team_member` | `recipient-membership-recompute.ts`, which looks through queues naming the written position or team | + * | `sys_business_unit(_member)` | `bu-tree-recompute.ts`, for a queue with a business-unit member | + * + * A member row names a SET of people, not one user, so this module recomputes + * the way the business-unit module does: the revocation half synchronously + * (`revokeRuleGrantsForRetiredRecipients`) and the re-grant half on the shared + * re-grant queue, coalesced per rule. ⛔ The recipient never confers + * sitting-record access — that is the access path's (`SharingService`), read + * per request, and needs no recompute at all. + */ + +import type { SharingRuleRow } from '@objectstack/spec/contracts'; +import type { OptionalSharingLogger } from './logger-shapes.js'; +import { ruleRegrantQueue } from './rule-hooks.js'; +import { QUEUE_MEMBER_OBJECT, QUEUE_OBJECT, type OwnershipQueueService } from './ownership-queue-service.js'; + +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; + +/** Package id of the name / row guards. */ +export const QUEUE_GUARD_PACKAGE = 'plugin-sharing:queue-guard'; +/** Package id of the queue-recipient recompute hooks. */ +export const QUEUE_RECIPIENT_RECOMPUTE_PACKAGE = 'plugin-sharing:queue-recipient-recompute'; + +const QUEUE_NAME_PATTERN = /^[a-z][a-z0-9_]*$/; + +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; +} + +/** + * Field-level refusal for a queue row, on the data-API path. The same shape + * `SharingCriteriaValidationError` uses (`code: 'VALIDATION_FAILED'`, a + * `fields[]` entry), so REST answers it as a validation failure naming the + * field. + */ +export class QueueValidationError extends Error { + readonly code = 'VALIDATION_FAILED'; + readonly fields: Array<{ field: string; code: string; message: string }>; + constructor(field: string, code: string, message: string) { + super(message); + this.name = 'ValidationError'; + this.fields = [{ field, code, message }]; + } +} + +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 === '' ? null : s; +} + +/** Bind the `sys_queue` name guard and the `sys_queue_member` queue-name guard. */ +export function bindQueueGuards(engine: MinimalEngine, queues: Pick): void { + if (typeof engine.registerHook !== 'function') return; + if (typeof engine.unregisterHooksByPackage === 'function') engine.unregisterHooksByPackage(QUEUE_GUARD_PACKAGE); + + const queueRowGuard = (insert: boolean) => (ctx: any) => { + const data = ctx?.input?.data; + if (!isRecord(data)) return; + if (!insert && !Object.prototype.hasOwnProperty.call(data, 'name')) return; + const name = data.name; + if (typeof name !== 'string' || !QUEUE_NAME_PATTERN.test(name)) { + throw new QueueValidationError( + 'name', + 'invalid_format', + `A queue name is a machine name — lowercase letters, digits and underscores, starting with a letter (got ${JSON.stringify(name)}). Records, assignment rules, approvals and sharing rules refer to the queue by it.`, + ); + } + if (queues.isDeclaredQueueName(name)) { + throw new QueueValidationError( + 'name', + 'duplicate', + `'${name}' is the name of a queue an installed app declares, and one name means one queue. ` + + `To staff that queue, add members to it under its name; for a queue of your own, choose another name.`, + ); + } + }; + + const memberRowGuard = (insert: boolean) => (ctx: any) => { + const data = ctx?.input?.data; + if (!isRecord(data)) return; + if (!insert && !Object.prototype.hasOwnProperty.call(data, 'queue')) return; + const queue = data.queue; + if (typeof queue !== 'string' || !QUEUE_NAME_PATTERN.test(queue)) { + throw new QueueValidationError( + 'queue', + 'invalid_format', + `Name the queue by its machine name — lowercase letters, digits and underscores (got ${JSON.stringify(queue)}).`, + ); + } + }; + + const opts = (object: string) => ({ object, packageId: QUEUE_GUARD_PACKAGE, priority: 100 }); + engine.registerHook('beforeInsert', queueRowGuard(true), opts(QUEUE_OBJECT)); + engine.registerHook('beforeUpdate', queueRowGuard(false), opts(QUEUE_OBJECT)); + engine.registerHook('beforeInsert', memberRowGuard(true), opts(QUEUE_MEMBER_OBJECT)); + engine.registerHook('beforeUpdate', memberRowGuard(false), opts(QUEUE_MEMBER_OBJECT)); +} + +/** + * Report, at boot, every `sys_queue` row whose name a package now declares. + * The declared queue answers for the name (registry-first), so the row is + * shadowed: its members still join the declared queue, its label does not. + * 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 reportShadowedAuthoredQueues( + engine: MinimalEngine, + queues: Pick, + logger?: OptionalSharingLogger & { error?: (msg: string, meta?: Record) => void }, +): Promise { + if (typeof engine.find !== 'function') return; + let rows: any[] = []; + try { + rows = await engine.find(QUEUE_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 || !queues.isDeclaredQueueName(name)) continue; + (logger?.error ?? logger?.warn)?.( + `[queue] an organization's queue '${name}' is shadowed by a queue an installed app declares under the same ` + + 'name — the declared queue answers for it. Rename the organization\'s queue to keep it.', + { queue: name, organization: present(row?.organization_id) }, + ); + } +} + +/** The slice of `SharingRuleService` the recompute drives. */ +export interface QueueRecipientRuleService { + listRules(filter: { object?: string; activeOnly?: boolean }, context: any): Promise; + revokeRuleGrantsForRetiredRecipients(rule: SharingRuleRow): Promise; + evaluateRule(idOrName: string, context: any): Promise; +} + +/** Rules with a queue-membership re-grant queued and not yet started. */ +const pendingQueueRegrants = new Set(); + +/** Test seam: are any coalesced queue re-grants outstanding? */ +export function pendingQueueRecipientRegrants(): number { + return pendingQueueRegrants.size; +} + +/** + * Recompute the queue-recipient rules naming `queueNames`: revoke from the + * members a rule no longer reaches now, queue the re-grant. Exported for the + * two other recompute modules' look-through. + */ +export async function recomputeQueueRecipientRules( + service: QueueRecipientRuleService, + queueNames: ReadonlySet, + logger: OptionalSharingLogger | undefined, + source: { object: string; event: string }, +): Promise { + if (queueNames.size === 0) return; + const rules = await service.listRules({ activeOnly: true }, SYSTEM_CTX as any); + const affected = (rules ?? []).filter((r) => r.recipient_type === 'queue' && queueNames.has(String(r.recipient_id))); + for (const rule of affected) { + try { + const retired = await service.revokeRuleGrantsForRetiredRecipients(rule); + if (retired > 0) { + logger?.info?.('[sharing-rule] queue membership changed — withdrew grants from members the rule no longer reaches', { + rule: rule.name ?? rule.id, ...source, recipientsRetired: retired, + }); + } + } catch (err: any) { + logger?.warn?.( + '[sharing-rule] queue-membership revocation failed — this rule may still grant access to former members ' + + 'until the next reconcile', + { rule: rule.name ?? rule.id, ...source, error: err?.message }, + ); + } + const ruleId = String(rule.id); + if (pendingQueueRegrants.has(ruleId)) continue; + pendingQueueRegrants.add(ruleId); + ruleRegrantQueue.enqueue( + async () => { + pendingQueueRegrants.delete(ruleId); + await service.evaluateRule(ruleId, SYSTEM_CTX as any); + }, + (err: any) => { + pendingQueueRegrants.delete(ruleId); + logger?.warn?.( + '[sharing-rule] queue-membership re-grant failed — new members stay without the rule\'s access until the ' + + 'next reconcile (any sharing-rule write, or a restart)', + { rule: rule.name ?? ruleId, error: err?.message }, + ); + }, + ); + } +} + +/** The queue names a `sys_queue` / `sys_queue_member` hook context touches, before and after. */ +function queueNamesOf(object: string, hookCtx: any): Set { + const column = object === QUEUE_OBJECT ? 'name' : 'queue'; + const out = new Set(); + for (const row of [hookCtx?.previous, hookCtx?.result, hookCtx?.input?.data]) { + if (!isRecord(row)) continue; + const name = present(row[column]); + if (name) out.add(name); + } + return out; +} + +/** Bind the recompute on the queue's own tables. */ +export function bindQueueRecipientRecompute( + engine: MinimalEngine, + service: QueueRecipientRuleService, + logger?: OptionalSharingLogger, +): void { + if (typeof engine.registerHook !== 'function') return; + if (typeof engine.unregisterHooksByPackage === 'function') { + engine.unregisterHooksByPackage(QUEUE_RECIPIENT_RECOMPUTE_PACKAGE); + } + for (const object of [QUEUE_OBJECT, QUEUE_MEMBER_OBJECT]) { + for (const event of ['afterInsert', 'afterUpdate', 'afterDelete']) { + engine.registerHook( + event, + async (hookCtx: any) => { + try { + await recomputeQueueRecipientRules(service, queueNamesOf(object, hookCtx), logger, { object, event }); + } catch (err: any) { + // A hook must not fail an operator's write. + logger?.warn?.('[sharing-rule] queue-recipient recompute failed', { object, event, error: err?.message }); + } + }, + { object, packageId: QUEUE_RECIPIENT_RECOMPUTE_PACKAGE, priority: 190 }, + ); + } + } +} diff --git a/packages/plugins/plugin-sharing/src/queue-recipient.test.ts b/packages/plugins/plugin-sharing/src/queue-recipient.test.ts new file mode 100644 index 00000000000..85b68de80a9 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/queue-recipient.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0142 D7] A sharing rule whose recipient is a queue grants the rule's + * matched records to the queue's members, through the one expander, in the + * rule's organization. Absent a queue service, it grants nobody. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql'; +import { SharingService } from './sharing-service.js'; +import { SharingRuleService } from './sharing-rule-service.js'; + +interface Row { [k: string]: any } + +const SYS = { isSystem: true, positions: [], permissions: [] } as any; + +function matches(row: Row, f: any): boolean { + if (!f || typeof f !== 'object') return true; + // A combinator is CONJOINED with its sibling field keys, never a + // short-circuit that returns before they are read (#7676): `listRules` + // composes `{object_name, active, $or:[…org scope…]}`, and a matcher that + // returned on the `$or` alone would match the whole table here while + // driver-sql and driver-memory conjoin the two. A fake looser than the + // contract it stands in for is how a green suite ships a broken filter. + if (Array.isArray(f.$or) && !f.$or.some((x: any) => matches(row, x))) return false; + if (Array.isArray(f.$and) && !f.$and.every((x: any) => matches(row, x))) return false; + for (const [k, v] of Object.entries(f)) { + if (k === '$or' || k === '$and') continue; + const rv = row[k]; + if (v != null && typeof v === 'object' && !Array.isArray(v)) { + const op: any = v; + if ('$in' in op) { if (!op.$in.includes(rv)) return false; continue; } + // `descendants()` filters children with `active: { $ne: false }`, so an + // undefined `active` must PASS — the graph treats absent as active. + if ('$ne' in op) { if (rv === op.$ne) return false; continue; } + if ('$gte' in op) { if (!(rv >= op.$gte)) return false; continue; } + } + if (rv !== v) return false; + } + return true; +} + +function makeEngine() { + const tables: Record = {}; + const ensure = (n: string) => (tables[n] ??= []); + let seq = 0; + return { + _tables: tables, + getSchema() { return undefined; }, + seed(object: string, rows: Row[]) { ensure(object).push(...rows.map((r) => ({ ...r }))); }, + async find(o: string, opts?: any) { + const f = opts?.filter ?? opts?.where; + return ensure(o).filter((r) => matches(r, f)).slice(0, opts?.limit ?? 10000); + }, + async insert(o: string, data: any) { + const row = { id: data.id ?? `${o}_${++seq}`, ...data }; + ensure(o).push(row); + return row; + }, + // Both write verbs open with the PRODUCER's own dispatch predicate + // (#4550 / #5480 / #6277) rather than a hand-mirrored guard, so a fixture + // that drifts to a call shape `ObjectQL` would refuse fails loudly here + // instead of collecting a green from a check that never ran. + async update(o: string, data: any, options?: any) { + const verdict = assertEngineUpdateDispatch(data, options); + const t = ensure(o); + const targets = verdict.kind === 'by-id' + ? t.filter((r) => r.id === verdict.id) + : t.filter((r) => matches(r, options?.where)); + for (const r of targets) Object.assign(r, data); + return verdict.kind === 'by-id' ? (targets[0] ?? null) : targets.length; + }, + async delete(o: string, opts?: any) { + assertEngineDeleteDispatch(opts); + const t = ensure(o); + const where = opts?.where ?? (opts?.id != null ? { id: opts.id } : {}); + for (let i = t.length - 1; i >= 0; i--) if (matches(t[i], where)) t.splice(i, 1); + return { ok: true }; + }, + }; +} + +const RULE = 'billing_reads_invoices'; + +describe('[ADR-0142 D7] the queue recipient', () => { + let engine: ReturnType; + const granteesOf = (recordId: string): string[] => + (engine._tables.sys_record_share ?? []) + .filter((r) => r.record_id === recordId && r.source === 'rule') + .map((r) => String(r.recipient_id)) + .sort(); + + beforeEach(() => { + engine = makeEngine(); + engine.seed('invoice', [{ id: 'inv1', status: 'open', owner_id: 'author', organization_id: 'org_a' }]); + engine.seed('sys_sharing_rule', [{ + id: 'srule_q', organization_id: 'org_a', name: RULE, label: 'Invoices → Billing queue', + object_name: 'invoice', criteria_json: JSON.stringify({ status: 'open' }), + recipient_type: 'queue', recipient_id: 'billing', access_level: 'read', active: true, + }]); + }); + + it('grants each member the expander returns, asked in the rule\'s organization', async () => { + const expandMembers = vi.fn(async () => ['u_c', 'u_e']); + const rules = new SharingRuleService({ + engine: engine as any, + sharing: new SharingService({ engine: engine as any }), + ownershipQueues: () => ({ expandMembers, isMember: async (u: string) => u === 'u_c' || u === 'u_e' }), + }); + await rules.evaluateRule(RULE, SYS); + expect(granteesOf('inv1')).toEqual(['u_c', 'u_e']); + expect(expandMembers).toHaveBeenCalledWith('billing', 'org_a'); + }); + + it('a member who left loses the grant on the next recompute', async () => { + let members = ['u_c', 'u_e']; + const rules = new SharingRuleService({ + engine: engine as any, + sharing: new SharingService({ engine: engine as any }), + ownershipQueues: () => ({ expandMembers: async () => members, isMember: async (u: string) => members.includes(u) }), + }); + await rules.evaluateRule(RULE, SYS); + members = ['u_c']; + const [rule] = await rules.listRules({ activeOnly: true }, SYS); + await rules.revokeRuleGrantsForRetiredRecipients(rule); + expect(granteesOf('inv1')).toEqual(['u_c']); + }); + + it('with no queue service the rule grants nobody', async () => { + const rules = new SharingRuleService({ engine: engine as any, sharing: new SharingService({ engine: engine as any }) }); + await rules.evaluateRule(RULE, SYS); + expect(granteesOf('inv1')).toEqual([]); + }); + + it('defineRule holds a queue recipient to a machine name', async () => { + const rules = new SharingRuleService({ engine: engine as any, sharing: new SharingService({ engine: engine as any }) }); + await expect(rules.defineRule({ + name: 'bad_queue_rule', label: 'Bad', object: 'invoice', criteria: { status: 'open' }, + recipientType: 'queue', recipientId: 'Billing Team', accessLevel: 'read', + } as any, SYS)).rejects.toThrow(/machine name/); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/recipient-membership-recompute.ts b/packages/plugins/plugin-sharing/src/recipient-membership-recompute.ts index a671e8d121b..2c16f4f4817 100644 --- a/packages/plugins/plugin-sharing/src/recipient-membership-recompute.ts +++ b/packages/plugins/plugin-sharing/src/recipient-membership-recompute.ts @@ -27,9 +27,16 @@ * `bindRuleHooks` binds only on each rule's own `object_name`, and * `bu-tree-recompute.ts` covers the two recipient kinds that read the * business-unit graph; this module covers the kinds whose expansion reads a - * membership table instead. `user` (a literal id), `queue` (expands to nobody) - * and `field` (per record, re-derived by the record's own write) have no - * membership table to watch. + * membership table instead. `user` (a literal id) and `field` (per record, + * re-derived by the record's own write) have no membership table to watch. + * + * [ADR-0142 D2/D7] A `queue` recipient expands through its member + * specifications, so a position or team write moves it too: the rule + * selection LOOKS THROUGH QUEUES — the queues whose specifications name the + * written position or team ({@link QueueLookThrough}) — and reconciles the + * queue-recipient rules naming them for the written user, exactly as it does a + * position or team rule. A write to the queue's own rows (`sys_queue_member`) + * names a set, not a user, and is recomputed by `queue-hooks.ts`. * * The position expansion also unions `sys_member.role`, the ADR-0057 D4 * transition source, and so does the holder check: a user who still holds the @@ -161,6 +168,14 @@ interface MinimalEngine { unregisterHooksByPackage(packageId: string): number; } +/** + * [ADR-0142 D2] The ownership-queue service's look-through: the queues in an + * organization whose member specifications name one of `values` under `kind`. + */ +export interface QueueLookThrough { + queuesNaming(kind: string, values: ReadonlySet, organizationId: string | null): Promise; +} + /** The slice of `SharingRuleService` this module drives. */ export interface RecipientMembershipRuleService { listRules( @@ -242,6 +257,7 @@ export function bindRecipientMembershipRecompute( engine: MinimalEngine, service: RecipientMembershipRuleService, logger?: OptionalSharingLogger, + queues?: QueueLookThrough, ): void { if (typeof engine.registerHook !== 'function') return; if (typeof engine.unregisterHooksByPackage === 'function') { @@ -267,10 +283,18 @@ export function bindRecipientMembershipRecompute( : null; const rules = await service.listRules({}, SYSTEM_CTX as any); + // [ADR-0142 D2] The queues naming the written recipient, in the row's + // organization(s) — or everywhere when the organization is unreadable. + const queueNames = new Set(); + if (queues && (rules ?? []).some((r) => r.recipient_type === 'queue')) { + for (const org of orgs ?? [null]) { + for (const name of await queues.queuesNaming(source.recipientType, recipients, org)) queueNames.add(name); + } + } const affected = (rules ?? []).filter( (rule) => - rule.recipient_type === source.recipientType - && recipients.has(String(rule.recipient_id)) + ((rule.recipient_type === source.recipientType && recipients.has(String(rule.recipient_id))) + || (rule.recipient_type === 'queue' && queueNames.has(String(rule.recipient_id)))) && ruleReadsRowsOf(source, rule, orgs), ); for (const rule of affected) { diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index e19ba4a77b1..b78a290455d 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -33,6 +33,7 @@ import type { IMetadataService, IObjectQLEngine, } from '@objectstack/spec/contracts'; +import { OWNERSHIP_QUEUE_SERVICE } from '@objectstack/spec/contracts'; // [commit 8e13ca876] The share-link routes' context is the FULL authorization envelope — // it feeds enforcement (`engine.find`), so it is an `ExecutionContext`, never // the route-local `ShareLinkExecutionContext`. @@ -48,7 +49,16 @@ import { markFilterSubtreeProvenance } from '@objectstack/spec/data'; // mechanism for a second producer, and commit aa5994e17 landed this middleware's key // (`record_write_denied`) into it ahead of this consumer half. import { renderOperationMessage, type ValidationMessageTranslator } from '@objectstack/spec/system'; -import { SysRecordShare, SysSharingRule, SysShareLink } from './objects/index.js'; +import { + SysRecordShare, + SysSharingRule, + SysShareLink, + SysQueue, + SysQueueMember, + SysAssignmentCursor, +} from './objects/index.js'; +import { OwnershipQueueService, specsReadBusinessUnits, type OwnershipQueueEngine } from './ownership-queue-service.js'; +import { bindQueueGuards, bindQueueRecipientRecompute, reportShadowedAuthoredQueues } from './queue-hooks.js'; import { SysBusinessUnit, SysBusinessUnitMember } from '@objectstack/platform-objects/identity'; import { SharingService, @@ -389,6 +399,13 @@ export class SharingServicePlugin implements Plugin { private linkService?: ShareLinkService; /** Resolved once in `kernel:ready`; reused by the `kernel:bootstrapped` backfills. */ private engine?: SharingEngine; + /** + * [ADR-0142 D1] The ownership-queue service, constructed and registered in + * `init` — before any `kernel:ready` handler runs — so the engine's boot + * refusal for a stack declaring `queues` / `assignmentRules` finds it. It + * reaches the engine lazily, at use. + */ + private queues?: OwnershipQueueService; /** * [#4433] Has the `kernel:bootstrapped` rule-grant backfill finished? @@ -622,7 +639,18 @@ export class SharingServicePlugin implements Plugin { scope: 'system', defaultDatasource: 'cloud', namespace: 'sys', - objects: [SysRecordShare, SysSharingRule, SysBusinessUnit, SysBusinessUnitMember, SysShareLink], + objects: [ + SysRecordShare, + SysSharingRule, + SysBusinessUnit, + SysBusinessUnitMember, + SysShareLink, + // [ADR-0142 D1/D5] Authored queues, their members, and the round-robin + // cursor the engine's assignment step advances. + SysQueue, + SysQueueMember, + SysAssignmentCursor, + ], // ADR-0029 D7 — contribute the sharing entries into the Setup app's // `group_access_control` slot (priority 200 so they sit after plugin- // security's Roles / Permission Sets). This plugin owns these objects (K2). @@ -642,6 +670,8 @@ export class SharingServicePlugin implements Plugin { { id: 'nav_sharing_rules', type: 'object', label: 'Sharing Rules', objectName: 'sys_sharing_rule', icon: 'share-2', requiresObject: 'sys_sharing_rule', requiredPermissions: ['manage_platform_settings'] }, { id: 'nav_declared_sharing_rules', type: 'url', label: 'Declared Sharing Rules', url: '/apps/setup/metadata/sharing_rule?scope=environment', icon: 'package', requiredPermissions: ['manage_platform_settings'] }, { 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'] }, ], }, ], @@ -662,6 +692,18 @@ export class SharingServicePlugin implements Plugin { } catch { /* i18n optional */ } }); } + // [ADR-0142 D1] The ownership-queue service — registered here, in `init`, + // under its own name (⛔ never `queue`, the job queue's): the engine's + // `kernel:ready` boot refusal tests for it by that name, and a `kernel:ready` + // registration could lose the race to it. The engine is resolved at use. + this.queues = new OwnershipQueueService({ + engine: () => { + if (this.engine) return this.engine as OwnershipQueueEngine; + try { return ctx.getService('objectql'); } catch { return null; } + }, + logger: ctx.logger as any, + }); + ctx.registerService(OWNERSHIP_QUEUE_SERVICE, this.queues); ctx.logger.info('SharingServicePlugin: schema registered'); } @@ -710,9 +752,23 @@ export class SharingServicePlugin implements Plugin { try { return ctx.getService('tenancy'); } catch { return null; } }, + // [ADR-0142 D3] Records sitting in the caller's queues. + ownershipQueues: () => this.queues ?? null, }); ctx.registerService('sharing', this.service); + // [ADR-0142 D1] The authored queue tables' name guards — data hygiene on + // tables this plugin owns, bound regardless of `enforce` — and the boot + // report of an authored queue a later declaration shadows. + try { + if (this.queues && typeof engine.registerHook === 'function') { + bindQueueGuards(engine as any, this.queues); + await reportShadowedAuthoredQueues(engine as any, this.queues, ctx.logger as any); + } + } catch (err: any) { + ctx.logger.warn('SharingServicePlugin: queue guards 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 @@ -786,6 +842,8 @@ export class SharingServicePlugin implements Plugin { engine: engine as SharingEngine, sharing: this.service, logger: ctx.logger as any, + // [ADR-0142 D7] A `queue` recipient expands through the queue service. + ownershipQueues: () => this.queues ?? null, declaredRules: createDeclaredSharingRuleSource({ engine, metadata: () => { @@ -864,7 +922,12 @@ export class SharingServicePlugin implements Plugin { // `unbindAllRuleHooks` (which every rebind calls) cannot tear it // down. Inside `enforce`, unlike the primary-BU projection next to // it: this one IS an access-control surface. - bindBusinessUnitTreeRecompute(engine, this.ruleService, ctx.logger as any); + bindBusinessUnitTreeRecompute(engine, this.ruleService, ctx.logger as any, async (rule) => { + // [ADR-0142 D2] A queue recipient reads the BU tree exactly when + // its queue has a business-unit member. + const queue = await this.queues?.resolveQueue(rule.recipient_id, rule.organization_id ?? null); + return queue ? specsReadBusinessUnits(queue.members) : false; + }); // …and a rule whose recipient is a POSITION or a TEAM changes // audience when a membership row (`sys_user_position`, @@ -873,7 +936,12 @@ export class SharingServicePlugin implements Plugin { // the rule's grants and a former holder loses them before the write // returns. Bound outside the rebind seam for the same reason as the // BU recompute above: it reads `listRules()` live on every write. - bindRecipientMembershipRecompute(engine, this.ruleService, ctx.logger as any); + bindRecipientMembershipRecompute(engine, this.ruleService, ctx.logger as any, this.queues); + + // [ADR-0142 D7] …and a rule whose recipient is a QUEUE changes + // audience when the queue's own rows move: a member specification + // added or removed, the queue deleted. Same placement and reasoning. + bindQueueRecipientRecompute(engine, this.ruleService, ctx.logger as any); // [#3896] Authoring a rule in Setup is a plain INSERT on // sys_sharing_rule — it bypasses defineRule's validation, so the diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-option-labels.test.ts b/packages/plugins/plugin-sharing/src/sharing-rule-option-labels.test.ts index 97d76bfede9..5ff9346d116 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-option-labels.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-option-labels.test.ts @@ -22,7 +22,7 @@ const optionsOf = (field: 'recipient_type' | 'access_level'): Option[] => describe('sys_sharing_rule select options', () => { it('recipient_type keeps its stored values', () => { expect(optionsOf('recipient_type').map((o) => o.value)).toEqual( - ['user', 'team', 'business_unit', 'position', 'unit_and_subordinates', 'field'], + ['user', 'team', 'business_unit', 'position', 'unit_and_subordinates', 'queue', 'field'], ); }); diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts index c1c9152c2b5..23bbe5b4c39 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts @@ -7,6 +7,7 @@ import type { SharingRuleEvaluationResult, ShareAccessLevel, SharingRuleRecipientType, + IOwnershipQueueService, } from '@objectstack/spec/contracts'; // [#7136] The full `resolveAuthzContext` envelope — what `ISharingRuleService` // has declared for every one of these context parameters since commit aa4b90d9a (the @@ -77,6 +78,9 @@ export const SHARING_RULE_ACTIVATION_TYPE = 'sharing_rule'; */ export const FIELD_NAME_GRAMMAR = /^[a-z_][a-z0-9_]*$/; +/** [ADR-0142 D7] The grammar a `queue` recipient's `recipientId` is held to: a queue machine name. */ +const QUEUE_NAME_GRAMMAR = /^[a-z][a-z0-9_]*$/; + /** * [#15072] The user ids a record's user-typed column names. * @@ -420,6 +424,12 @@ export interface SharingRuleServiceOptions { * `sys_sharing_rule` rows alone. */ declaredRules?: DeclaredSharingRuleSource; + /** + * [ADR-0142 D7] The ownership-queue service, resolved at use: a `queue` + * recipient expands to the queue's members through it. Absent (or answering + * nothing), a queue rule grants nobody — the recipient fails closed. + */ + ownershipQueues?: () => Pick | null | undefined; logger?: { info?: (msg: any, ...rest: any[]) => void; /** @@ -518,6 +528,7 @@ export class SharingRuleService implements ISharingRuleService { /** [ADR-0131 D2] Where declared rules are read. */ private readonly declaredSource?: DeclaredSharingRuleSource; + private readonly ownershipQueues?: SharingRuleServiceOptions['ownershipQueues']; /** * [ADR-0131 D2] The scopes a declared rule is served in: the deployment * under `single` (one {@link DeploymentWideSharingRule} projection), every @@ -543,6 +554,7 @@ export class SharingRuleService implements ISharingRuleService { this.sharing = opts.sharing; this.logger = opts.logger; this.declaredSource = opts.declaredRules; + this.ownershipQueues = opts.ownershipQueues; } /** @@ -763,6 +775,13 @@ export class SharingRuleService implements ISharingRuleService { // (`owner.manager_id`: a graph walk spelled as a value, the shape the // ruling's ⛔ on a `manager` member refuses) would reach the executor and // share with nobody in silence. + // [ADR-0142 D7] A queue is named by its machine name, never an id. + if (input.recipientType === 'queue' && !QUEUE_NAME_GRAMMAR.test(String(input.recipientId))) { + throw new Error( + "VALIDATION_FAILED: recipientId must name a queue by its machine name when recipientType is 'queue' " + + `(got ${JSON.stringify(input.recipientId)})`, + ); + } if (input.recipientType === 'field' && !FIELD_NAME_GRAMMAR.test(String(input.recipientId))) { throw new Error( "VALIDATION_FAILED: recipientId must name a user-typed field on the object when recipientType is 'field' " + @@ -1708,6 +1727,12 @@ export class SharingRuleService implements ISharingRuleService { return positionGraph.holdsPosition(userId, rule.recipient_id, rule.organization_id ?? undefined); } if (rule.recipient_type === 'team') return teamHasMember(this.engine, rule.recipient_id, userId); + // [ADR-0142 D7] A queue reaches the user its specifications reach — the + // queue service's holder check, not a full expansion. + if (rule.recipient_type === 'queue') { + const queues = this.ownershipQueues?.(); + return queues ? queues.isMember(userId, rule.recipient_id, rule.organization_id ?? null) : false; + } return (await this.expandRecipient(rule, pass)).includes(userId); } @@ -2136,7 +2161,17 @@ export class SharingRuleService implements ISharingRuleService { this.warnOnEmptyUnitExpansion(rule, members); return members; } - // queue — v1 stores literal; treat as no-op until queue impl lands. + if (rule.recipient_type === 'queue') { + // [ADR-0142 D7] The queue's members in the rule's organization, through + // the one expander every queue consumer reads. Grants are materialised + // per member and recomputed when membership moves (`queue-hooks.ts` and + // the two recompute modules' look-through). ⛔ This grants the rule's + // matched records to the queue's people; it confers no sitting-record + // access, which is the access path's (`SharingService`). + const queues = this.ownershipQueues?.(); + if (!queues) return []; + return queues.expandMembers(rule.recipient_id, rule.organization_id ?? null); + } return []; } @@ -2162,8 +2197,8 @@ export class SharingRuleService implements ISharingRuleService { * recipient cannot be empty, and `team` / `position` / `queue` have their own * reasons for an empty set that this issue did not measure. ⛔ Do not widen * this into "warn whenever any recipient expands to zero" without measuring - * those — `queue` expands to `[]` by construction today, so a blanket warn - * would fire on every pass of every queue rule, and a `field` rule's empty + * those — an empty `queue` is a queue nobody staffs yet (ADR-0142 D2: an + * empty expansion is a fact, not a fault), and a `field` rule's empty * column is a legitimate state (an unassigned record shares with nobody, * #15072), not a misconfiguration. * diff --git a/packages/plugins/plugin-sharing/src/sharing-service.ts b/packages/plugins/plugin-sharing/src/sharing-service.ts index c1f616f503c..8694e4a478d 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.ts @@ -11,7 +11,9 @@ import type { GrantShareInput, ShareAccessLevel, SharingWriteVerdict, + IOwnershipQueueService, } from '@objectstack/spec/contracts'; +import { resolveInjectedColumnProvenance } from '@objectstack/spec/data'; import { normalizeTenancyPosture, postureEnforcesWall, @@ -190,6 +192,9 @@ function nonUserRecipientTypeRemedy(value: unknown): string { */ const OWNER_FIELD = 'owner_id'; +/** [ADR-0142 D3] The ownership-queue column, injected beside `owner_id`. */ +const QUEUE_FIELD = 'queue'; + /** * Effective sharing model — collapses the authorable OWD vocabulary onto the * three behaviours this service enforces: @@ -311,6 +316,18 @@ function hasOwnerField(schema: any): boolean { return Boolean(schema?.fields && OWNER_FIELD in schema.fields); } +/** + * [ADR-0142 D3] Does the object carry the platform's own `queue` column, with + * storage behind it? Provenance, not presence: the column is injected on + * exactly the `ownership: 'user'` objects (a declared `queue` on one of those + * is refused at registration), and on an ADR-0015 federated object the + * injected column has no storage, so a predicate over it could never resolve. + */ +function hasQueueColumn(schema: any): boolean { + if (!schema?.fields || !(QUEUE_FIELD in schema.fields)) return false; + return resolveInjectedColumnProvenance(schema, QUEUE_FIELD) === 'injected-provisioned'; +} + /** * The `record_id` column of a `sys_record_share` read, as the members of a * security `$in`. A row whose `record_id` is nullish or empty contributes @@ -491,6 +508,13 @@ export interface SharingServiceOptions { * refuses: an unresolvable posture must not be read as "no wall, carry on". */ tenancy?: () => SharingTenancyProbe | null | undefined; + /** + * [ADR-0142 D3] The ownership-queue service, resolved at use. A record whose + * `owner_id` is empty and whose `queue` names a queue the caller belongs to + * is readable and editable by the caller — the access path's fifth basis. + * Absent, no record sits anywhere: the basis fails closed. + */ + ownershipQueues?: () => Pick | null | undefined; /** [#5103] Optional logger for the record-delete cascade / orphan sweep. */ logger?: { info?: (msg: any, ...rest: any[]) => void; @@ -540,8 +564,16 @@ export class SharingService implements ISharingService { private readonly hierarchyResolver?: () => IHierarchyScopeResolver | null | undefined; private readonly securityService?: () => SharingSecurityProbe | null | undefined; private readonly tenancy?: () => SharingTenancyProbe | null | undefined; + private readonly ownershipQueues?: SharingServiceOptions['ownershipQueues']; private readonly logger?: SharingServiceOptions['logger']; + /** + * [ADR-0142 D3] `current_user.queues`, resolved once per request context: + * the same context object is threaded through every gate one operation + * touches, so a find and its per-record checks resolve membership once. + */ + private readonly callerQueueMemo = new WeakMap>(); + /** * [#8418] Objects that have already reported {@link * PHANTOM_ANCHOR_WRITE_DENY_NOTICE}. @@ -567,6 +599,7 @@ export class SharingService implements ISharingService { this.hierarchyResolver = options.hierarchyResolver; this.securityService = options.securityService; this.tenancy = options.tenancy; + this.ownershipQueues = options.ownershipQueues; this.logger = options.logger; this.bypassObjects = new Set([ 'sys_record_share', @@ -659,17 +692,13 @@ export class SharingService implements ISharingService { }); const grantedIds: string[] = grantedRecordIds(grants); - - if (grantedIds.length === 0) { - return ownerMatch; - } - - return { - $or: [ - ownerMatch, - { id: { $in: grantedIds } }, - ], - }; + // [ADR-0142 D3] Beside the owner match: the records sitting in a queue the + // caller belongs to. + const queueMatch = await this.queueSittingMatch(schema, context); + const disjuncts: unknown[] = [ownerMatch]; + if (queueMatch) disjuncts.push(queueMatch); + if (grantedIds.length > 0) disjuncts.push({ id: { $in: grantedIds } }); + return disjuncts.length === 1 ? ownerMatch : { $or: disjuncts }; } /** @@ -738,9 +767,85 @@ export class SharingService implements ISharingService { context: SYSTEM_CTX, }); const grantedIds: string[] = grantedRecordIds(grants); + // [ADR-0142 D3] The update arm only: a queue member edits what sits in the + // queue and deletes none of it (rows, never verbs — the delete arm above + // returned before this line). + const queueMatch = await this.queueSittingMatch(schema, context); + const disjuncts: unknown[] = [ownerMatch]; + if (queueMatch) disjuncts.push(queueMatch); + if (grantedIds.length > 0) disjuncts.push({ id: { $in: grantedIds } }); + return disjuncts.length === 1 ? ownerMatch : { $or: disjuncts }; + } + + /** + * [ADR-0142 D3] The queue names the caller belongs to in the active + * organization — `current_user.queues` — memoised on the request context. + * `[]` with no queue service, no user, or for the guest; a failed + * resolution is `[]` too (the basis fails closed). + */ + callerQueues(context: ExecutionContext): Promise { + const queues = this.ownershipQueues?.(); + if (!queues || !context?.userId || isGuestPrincipal(context)) return Promise.resolve([]); + const key = context as unknown as object; + let pending = this.callerQueueMemo.get(key); + if (!pending) { + pending = queues.queuesOf(String(context.userId), context.tenantId ? String(context.tenantId) : null) + .catch((err: unknown) => { + this.logger?.warn?.( + '[sharing] queue membership could not be resolved — records sitting in queues are not reachable ' + + 'through the queue for this request', + { userId: context.userId, error: err instanceof Error ? err.message : String(err) }, + ); + return []; + }); + this.callerQueueMemo.set(key, pending); + } + return pending; + } - if (grantedIds.length === 0) return ownerMatch; - return { $or: [ownerMatch, { id: { $in: grantedIds } }] }; + /** + * [ADR-0142 D3] The read / update-arm disjunct + * `owner_id IS NULL AND queue IN (current_user.queues)`, or `null` when the + * object has no provisioned `queue` column or the caller sits in no queue. + */ + private async queueSittingMatch(schema: any, context: ExecutionContext): Promise | null> { + if (!hasQueueColumn(schema)) return null; + const names = await this.callerQueues(context); + if (names.length === 0) return null; + return { [OWNER_FIELD]: null, [QUEUE_FIELD]: names.length === 1 ? names[0] : { $in: names } }; + } + + /** + * [ADR-0142 D3] Does `(object, recordId)` sit in a queue the caller belongs + * to? Membership is asked in the record's own organization (the active one + * when the row carries none, and never one the request is not in) — one + * holder check, not the caller's whole queue set. + */ + private async sitsInCallersQueue( + schema: any, + object: string, + recordId: string, + context: ExecutionContext, + ): Promise { + if (!hasQueueColumn(schema) || !context?.userId || isGuestPrincipal(context)) return false; + const queues = this.ownershipQueues?.(); + if (!queues) return false; + const rows = await this.engine.find(object, { + where: { id: recordId }, + fields: ['id', OWNER_FIELD, QUEUE_FIELD, 'organization_id'], + limit: 1, + context: SYSTEM_CTX, + }); + const row = Array.isArray(rows) ? (rows[0] as Record | undefined) : undefined; + if (!row || (row[OWNER_FIELD] != null && row[OWNER_FIELD] !== '')) return false; + const queue = row[QUEUE_FIELD]; + if (typeof queue !== 'string' || queue === '') return false; + // A row of another organization is never reached through a queue of this + // one: membership is per organization, and the request's is the active one. + const rowOrg = row.organization_id == null || row.organization_id === '' ? null : String(row.organization_id); + const activeOrg = context.tenantId ? String(context.tenantId) : null; + if (rowOrg && activeOrg && rowOrg !== activeOrg) return false; + return queues.isMember(String(context.userId), queue, rowOrg ?? activeOrg); } /** @@ -928,6 +1033,12 @@ export class SharingService implements ISharingService { try { const schema = this.engine.getSchema?.(object); if (!schema) return 'abstain'; + // [ADR-0142 D3] A record sitting in a queue the caller belongs to is a + // POSITIVE basis of its own — tested before the public-OWD abstain, so on + // an open OWD too the answer is `allow` (which lifts the platform's + // `created_by` floor for a member), and apart from `matchesOwnerScope`, + // whose fast path `checkDelete` shares: the basis never reaches delete. + if (await this.sitsInCallersQueue(schema, object, recordId, context)) return 'allow'; // [Ruling of record 6355206139] The EXTERNAL OWD floor (buildReadFilter). const model = owdForPrincipal(schema, context, object, 'write'); if (model === 'public') return 'abstain'; diff --git a/packages/spec/authorable-defaults/automation.json b/packages/spec/authorable-defaults/automation.json index b2ca7240c42..f118f612622 100644 --- a/packages/spec/authorable-defaults/automation.json +++ b/packages/spec/authorable-defaults/automation.json @@ -19,6 +19,7 @@ "automation/ApprovalNodeConfig:lockRecord = true", "automation/ApprovalNodeConfig:maxRevisions = 3", "automation/ApprovalNodeConfig:onEmptyApprovers = \"admin_rescue\"", + "automation/AssignmentRule:active = true", "automation/BpmnElementMapping:bidirectional = true", "automation/BpmnExportOptions:includeExtensions = false", "automation/BpmnExportOptions:includeLayout = true", diff --git a/packages/spec/authorable-defaults/security.json b/packages/spec/authorable-defaults/security.json index 0b0fa9d8688..809e1d6aaad 100644 --- a/packages/spec/authorable-defaults/security.json +++ b/packages/spec/authorable-defaults/security.json @@ -30,6 +30,7 @@ "security/ObjectPermission:modifyAllRecords = false", "security/ObjectPermission:viewAllRecords = false", "security/PermissionSet:isDefault = false", + "security/Queue:members = []", "security/RowLevelSecurityPolicy:enabled = true", "security/SharingRule:accessLevel = \"read\"", "security/SharingRule:active = true" diff --git a/packages/spec/authorable-surface/automation.json b/packages/spec/authorable-surface/automation.json index 7347885224e..1f665298474 100644 --- a/packages/spec/authorable-surface/automation.json +++ b/packages/spec/authorable-surface/automation.json @@ -47,6 +47,26 @@ "automation/AssignmentExpressionValue:dialect", "automation/AssignmentExpressionValue:meta", "automation/AssignmentExpressionValue:source", + "automation/AssignmentRule:_lock", + "automation/AssignmentRule:_lockDocsUrl", + "automation/AssignmentRule:_lockReason", + "automation/AssignmentRule:_lockSource", + "automation/AssignmentRule:_packageId", + "automation/AssignmentRule:_packageVersion", + "automation/AssignmentRule:_provenance", + "automation/AssignmentRule:active", + "automation/AssignmentRule:description", + "automation/AssignmentRule:entries", + "automation/AssignmentRule:label", + "automation/AssignmentRule:name", + "automation/AssignmentRule:object", + "automation/AssignmentRuleEntry:assignTo", + "automation/AssignmentRuleEntry:criteria", + "automation/AssignmentRuleEntry:load", + "automation/AssignmentRuleEntry:name", + "automation/AssignmentRuleEntry:strategy", + "automation/AssignmentTarget:type", + "automation/AssignmentTarget:value", "automation/BpmnDiagnostic:bpmnElementId", "automation/BpmnDiagnostic:message", "automation/BpmnDiagnostic:nodeId", diff --git a/packages/spec/authorable-surface/security.json b/packages/spec/authorable-surface/security.json index 83730c36570..70369605b30 100644 --- a/packages/spec/authorable-surface/security.json +++ b/packages/spec/authorable-surface/security.json @@ -49,6 +49,8 @@ "security/CriteriaSharingRule:object", "security/CriteriaSharingRule:sharedWith", "security/CriteriaSharingRule:type", + "security/DeclaredQueueMember:type", + "security/DeclaredQueueMember:value", "security/DefaultRowScope:check", "security/DefaultRowScope:description", "security/DefaultRowScope:enabled", @@ -143,6 +145,17 @@ "security/PermissionSet:rowLevelSecurity", "security/PermissionSet:systemPermissions", "security/PermissionSet:tabPermissions", + "security/Queue:_lock", + "security/Queue:_lockDocsUrl", + "security/Queue:_lockReason", + "security/Queue:_lockSource", + "security/Queue:_packageId", + "security/Queue:_packageVersion", + "security/Queue:_provenance", + "security/Queue:description", + "security/Queue:label", + "security/Queue:members", + "security/Queue:name", "security/RLSEvaluationResult:checkResult", "security/RLSEvaluationResult:durationMs", "security/RLSEvaluationResult:error", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 23014d0a90e..d6556161018 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 244, - "droppedRefinementSites": 838, + "publishedSchemasWithDroppedRefinements": 247, + "droppedRefinementSites": 856, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -75,6 +75,9 @@ "manifest.agents.element.memory", "manifest.agents.element.structuredOutput.schema", "manifest.analyticsCubes.element.measures.valueType", + "manifest.assignmentRules.element", + "manifest.assignmentRules.element.entries.element", + "manifest.assignmentRules.element.entries.element.assignTo", "manifest.connectors.element.out", "manifest.dashboards.element.globalFilters.element", "manifest.dashboards.element.header.actions.element", @@ -200,6 +203,9 @@ "data.options[1].manifest.agents.element.memory", "data.options[1].manifest.agents.element.structuredOutput.schema", "data.options[1].manifest.analyticsCubes.element.measures.valueType", + "data.options[1].manifest.assignmentRules.element", + "data.options[1].manifest.assignmentRules.element.entries.element", + "data.options[1].manifest.assignmentRules.element.entries.element.assignTo", "data.options[1].manifest.connectors.element.out", "data.options[1].manifest.dashboards.element.globalFilters.element", "data.options[1].manifest.dashboards.element.header.actions.element", @@ -321,6 +327,9 @@ "options[1].manifest.agents.element.memory", "options[1].manifest.agents.element.structuredOutput.schema", "options[1].manifest.analyticsCubes.element.measures.valueType", + "options[1].manifest.assignmentRules.element", + "options[1].manifest.assignmentRules.element.entries.element", + "options[1].manifest.assignmentRules.element.entries.element.assignTo", "options[1].manifest.connectors.element.out", "options[1].manifest.dashboards.element.globalFilters.element", "options[1].manifest.dashboards.element.header.actions.element", @@ -388,6 +397,9 @@ "data.packages.element.options[1].manifest.agents.element.memory", "data.packages.element.options[1].manifest.agents.element.structuredOutput.schema", "data.packages.element.options[1].manifest.analyticsCubes.element.measures.valueType", + "data.packages.element.options[1].manifest.assignmentRules.element", + "data.packages.element.options[1].manifest.assignmentRules.element.entries.element", + "data.packages.element.options[1].manifest.assignmentRules.element.entries.element.assignTo", "data.packages.element.options[1].manifest.connectors.element.out", "data.packages.element.options[1].manifest.dashboards.element.globalFilters.element", "data.packages.element.options[1].manifest.dashboards.element.header.actions.element", @@ -540,6 +552,24 @@ "out.catchall" ] }, + "automation/AssignmentRule": { + "sites": [ + "", + "entries.element", + "entries.element.assignTo" + ] + }, + "automation/AssignmentRuleEntry": { + "sites": [ + "", + "assignTo" + ] + }, + "automation/AssignmentTarget": { + "sites": [ + "" + ] + }, "automation/AssignmentValue": { "sites": [ "" diff --git a/packages/spec/json-schema.manifest/automation.json b/packages/spec/json-schema.manifest/automation.json index 2475ad78cdb..c17100332c4 100644 --- a/packages/spec/json-schema.manifest/automation.json +++ b/packages/spec/json-schema.manifest/automation.json @@ -13,6 +13,10 @@ "automation/ApproverType", "automation/AssignmentConfig", "automation/AssignmentExpressionValue", + "automation/AssignmentRule", + "automation/AssignmentRuleEntry", + "automation/AssignmentStrategy", + "automation/AssignmentTarget", "automation/AssignmentValue", "automation/BpmnDiagnostic", "automation/BpmnElementMapping", diff --git a/packages/spec/json-schema.manifest/security.json b/packages/spec/json-schema.manifest/security.json index 4680fbf868e..d86240b90bc 100644 --- a/packages/spec/json-schema.manifest/security.json +++ b/packages/spec/json-schema.manifest/security.json @@ -8,6 +8,7 @@ "security/AuthzPosture", "security/CapabilityDeclaration", "security/CriteriaSharingRule", + "security/DeclaredQueueMember", "security/DefaultRowScope", "security/EffectiveObjectPermission", "security/ExplainDecision", @@ -26,6 +27,8 @@ "security/OrgScopingEntitlement", "security/PermissionSet", "security/PlatformGlobalObjects", + "security/Queue", + "security/QueueMemberType", "security/RLSEvaluationResult", "security/RLSOperation", "security/RLSUserContext", diff --git a/packages/spec/src/contracts/ownership-queue-service.ts b/packages/spec/src/contracts/ownership-queue-service.ts index e1123ac1648..4f2185e41e5 100644 --- a/packages/spec/src/contracts/ownership-queue-service.ts +++ b/packages/spec/src/contracts/ownership-queue-service.ts @@ -42,6 +42,9 @@ import type { QueueMemberType } from '../security/queue.zod.js'; /** Kernel service name the ownership-queue service registers under. ⛔ Not `queue` — see the module header. */ export const OWNERSHIP_QUEUE_SERVICE = 'ownership-queue'; +/** Which reader an expansion is for — see {@link IOwnershipQueueService.expandMembers}. */ +export type QueueExpansionPurpose = 'access' | 'addressing'; + /** One member specification: a recipient kind and the value that kind takes in a sharing rule. */ export interface QueueMemberSpec { type: QueueMemberType; @@ -81,8 +84,20 @@ export interface IOwnershipQueueService { * The queue's members in `organizationId`: the union of each member * specification's expansion, de-duplicated and ordered by user id * ascending. `[]` for an empty queue and for a name that resolves nowhere. + * + * `purpose` carries the platform's split between the two kinds of reader + * (maintainer ruling 2026-08-15: "Access-conferring paths filter + * deactivated positions; addressing paths do not"). `'access'`, the + * default, drops the holders of a position the activation ledger switches + * off — the sitting-record path, the claim, the assignment pool and the + * sharing recipient all confer access. `'addressing'` keeps them — the + * approver slate routes work, and dropping holders there is fail-open. */ - expandMembers(name: string, organizationId: string | null): Promise; + expandMembers( + name: string, + organizationId: string | null, + options?: { purpose?: QueueExpansionPurpose }, + ): Promise; /** Is `userId` a member of the queue named `name` in `organizationId`? `false` when the name resolves nowhere. */ isMember(userId: string, name: string, organizationId: string | null): Promise; @@ -93,4 +108,12 @@ export interface IOwnershipQueueService { * `current_user.queues`. Ordered by name. */ queuesOf(userId: string, organizationId: string | null): Promise; + + /** + * The declared queue named `name` — `null` when no package declares it or + * the activation ledger switches it off. Optional: the activation door looks + * a `queue` row's subject up through it, and answers 503 when the + * registered service does not carry it. + */ + declaredQueueNamed?(name: string): Promise<{ name: string; packageId?: string } | null>; } From 082f181b3ef296f8c73cb65ae53d2bf5a7563227 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 14:16:45 +0000 Subject: [PATCH 03/23] feat(objectql): ownership-queue column, write precondition and assignment rules at insert (ADR-0142) - Inject the system-managed `queue` text column on ownership:'user' objects, beside `owner_id`, and refuse an author-declared `queue` at registration (INVALID_METADATA, 422) with a rename prescription. - Lower an update's write precondition to a guarded `updateMany` plus a readback; zero rows answers 409 CONCURRENT_UPDATE ("the record was taken"). A driver with no `updateMany` degrades to a plain update, warned once. - Evaluate declared assignment rules once per inserted row, after every beforeInsert hook, for rows whose owner and queue are both empty: park, round_robin (cursor under a version precondition, retried) and least_loaded, under a system context pinned to the row's organization. Assignment never refuses an insert. - Refuse kernel:ready when a stack declares queues or assignment rules and no `ownership-queue` service is registered. - Spec: govern `queue` and `assignment_rule` in the liveness ledger, add the strictness-ledger rows, localize the queue column label, register the three plugin-sharing platform objects, keep `queue` out of title derivation, and regenerate the reference docs and API artifacts. Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- .../docs/references/api/automation-api.mdx | 3 +- content/docs/references/api/contract.mdx | 3 +- .../docs/references/api/error-code-ledger.mdx | 1 + content/docs/references/api/metadata.mdx | 6 +- .../references/api/package-api-assembled.mdx | 4 + .../docs/references/automation/approval.mdx | 18 +- .../references/automation/assignment-rule.mdx | 101 ++++ content/docs/references/automation/index.mdx | 3 +- content/docs/references/automation/meta.json | 1 + content/docs/references/index.mdx | 14 +- .../references/kernel/metadata-plugin.mdx | 8 +- content/docs/references/security/explain.mdx | 2 +- content/docs/references/security/index.mdx | 3 +- content/docs/references/security/meta.json | 1 + content/docs/references/security/queue.mdx | 77 +++ content/docs/references/security/sharing.mdx | 13 +- .../automation.md | 9 +- .../security.md | 9 +- .../2026-07-unknown-key-strictness-ledger.md | 2 + .../test/injected-column-provenance.test.ts | 16 +- packages/objectql/src/assignment-rules.ts | 459 ++++++++++++++++++ .../objectql/src/concurrent-update-error.ts | 36 ++ ...gine-security-catalog-package-door.test.ts | 6 + packages/objectql/src/engine.ts | 148 +++++- .../federated-injected-column-readers.test.ts | 31 ++ packages/objectql/src/ownership-queue.test.ts | 380 +++++++++++++++ packages/objectql/src/plugin.ts | 29 ++ .../protocol-meta-effective-schema.test.ts | 1 + packages/objectql/src/registry.ts | 95 +++- packages/spec/api-surface/automation.json | 10 + packages/spec/api-surface/contracts.json | 5 + packages/spec/api-surface/data.json | 1 + packages/spec/api-surface/security.json | 8 + packages/spec/declaration-map/automation.json | 7 + packages/spec/declaration-map/security.json | 5 + packages/spec/export-origins/automation.json | 9 + packages/spec/export-origins/contracts.json | 5 + packages/spec/export-origins/data.json | 1 + packages/spec/export-origins/security.json | 7 + packages/spec/liveness/README.md | 4 +- packages/spec/liveness/assignment_rule.json | 88 ++++ packages/spec/liveness/queue.json | 44 ++ packages/spec/liveness/sharing_rule.json | 2 +- .../liveness/state-counts/assignment_rule.md | 15 + packages/spec/liveness/state-counts/queue.md | 15 + .../spec/scripts/liveness/check-liveness.mts | 2 +- packages/spec/src/data/display-name.ts | 15 +- .../src/data/import-mapping-target.test.ts | 2 +- .../injected-system-column-provenance.test.ts | 9 +- .../src/data/injected-system-columns.test.ts | 8 +- .../src/kernel/metadata-create-seeds.test.ts | 6 + .../src/kernel/metadata-type-schemas.test.ts | 8 +- .../meta-spelling/meta-url-data.generated.ts | 5 + packages/spec/src/security/sharing.test.ts | 5 +- .../system/constants/platform-object-names.ts | 11 +- .../spec/src/system/i18n-resolver.test.ts | 7 +- packages/spec/src/system/i18n-resolver.ts | 4 +- 57 files changed, 1721 insertions(+), 66 deletions(-) create mode 100644 content/docs/references/automation/assignment-rule.mdx create mode 100644 content/docs/references/security/queue.mdx create mode 100644 packages/objectql/src/assignment-rules.ts create mode 100644 packages/objectql/src/concurrent-update-error.ts create mode 100644 packages/objectql/src/ownership-queue.test.ts create mode 100644 packages/spec/liveness/assignment_rule.json create mode 100644 packages/spec/liveness/queue.json create mode 100644 packages/spec/liveness/state-counts/assignment_rule.md create mode 100644 packages/spec/liveness/state-counts/queue.md diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 7a8d5fa41e9..34437e18959 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -354,7 +354,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +344 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +345 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | | **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds | ### Allowed Values: `FlowInnerRefusal.code` @@ -620,6 +620,7 @@ const result = AutomationApiErrorCode.parse(data); * `PROJECT_PROVISIONING` * `PROJECT_PROVISIONING_FAILED` * `QUERY_OBJECT_MISMATCH` +* `QUEUE_NOT_FOUND` * `RAW_SQL_UNSUPPORTED` * `READ_BACK_FAILED` * `READ_SCOPE_COMPILE_FAILED` diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index c1d6f119dfd..f7e5270906d 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +344 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +345 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -301,6 +301,7 @@ const result = ApiErrorSchema.parse(data); * `PROJECT_PROVISIONING` * `PROJECT_PROVISIONING_FAILED` * `QUERY_OBJECT_MISMATCH` +* `QUEUE_NOT_FOUND` * `RAW_SQL_UNSUPPORTED` * `READ_BACK_FAILED` * `READ_SCOPE_COMPILE_FAILED` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 806298c7c96..b10754c2fc8 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -468,6 +468,7 @@ const result = ErrorCode.parse(data); * `PROJECT_PROVISIONING` * `PROJECT_PROVISIONING_FAILED` * `QUERY_OBJECT_MISMATCH` +* `QUEUE_NOT_FOUND` * `RAW_SQL_UNSUPPORTED` * `READ_BACK_FAILED` * `READ_SCOPE_COMPILE_FAILED` diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 425b799a95d..68eae94ff4f 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -648,7 +648,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'sharing_rule' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | +| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'sharing_rule' \| 'queue' \| 'assignment_rule' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | | **namespaces** | `string[]` | optional | Filter by namespaces | | **packageId** | `string` | optional | Filter by owning package | | **search** | `string` | optional | Full-text search query | @@ -715,7 +715,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| … +14 more>` | ✅ | Metadata type | +| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| … +16 more>` | ✅ | Metadata type | | **name** | `string` | ✅ | Item name (snake_case) | | **data** | `Record` | ✅ | Metadata payload | | **namespace** | `string` | optional | Optional namespace | @@ -748,6 +748,8 @@ Metadata query with filtering, sorting, and pagination * `position` * `capability` * `sharing_rule` +* `queue` +* `assignment_rule` * `agent` * `tool` * `skill` diff --git a/content/docs/references/api/package-api-assembled.mdx b/content/docs/references/api/package-api-assembled.mdx index 2327d521811..70dbedb8b79 100644 --- a/content/docs/references/api/package-api-assembled.mdx +++ b/content/docs/references/api/package-api-assembled.mdx @@ -131,6 +131,8 @@ Installed package row whose manifest is the assembled package body | **books** | `{ name: string; label?: string; description?: string; slug?: string; … }[]` | optional | Documentation navigation spines — ordered groups with derived membership (ADR-0046 §6) | | **positions** | `{ name: string; label: string; description?: string; permissionSets?: string[]; … }[]` | optional | Positions — flat capability-distribution groups (ADR-0090 D3) | | **sharingRules** | `{ name: string; label?: string; description?: string; object: string; … }[]` | optional | Record Sharing Rules | +| **queues** | `{ name: string; label: string; description?: string; members?: object[]; … }[]` | optional | [ADR-0142 D1] Ownership queues — named sets of people records can sit in until a member claims them | +| **assignmentRules** | `{ name: string; label?: string; description?: string; object: string; … }[]` | optional | [ADR-0142 D5] Assignment rules — route new ownerless records to a queue or a person, once, on insert | | **apis** | `{ name: string; path: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; summary?: string; … }[]` | optional | API Endpoints — declared endpoints are live from protocol 17; each is gated at publish (ADR-0121) | | **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Outbound Webhooks | | **agents** | `{ name: string; label: string; avatar?: string; role: string; … }[]` | optional | AI Agents — platform-internal (ADR-0063 §2): the kernel ships exactly two (ask/build); third parties extend via skills, not agents | @@ -377,6 +379,8 @@ This schema accepts one of the following structures: | **books** | `{ name: string; label?: string; description?: string; slug?: string; … }[]` | optional | Documentation navigation spines — ordered groups with derived membership (ADR-0046 §6) | | **positions** | `{ name: string; label: string; description?: string; permissionSets?: string[]; … }[]` | optional | Positions — flat capability-distribution groups (ADR-0090 D3) | | **sharingRules** | `{ name: string; label?: string; description?: string; object: string; … }[]` | optional | Record Sharing Rules | +| **queues** | `{ name: string; label: string; description?: string; members?: object[]; … }[]` | optional | [ADR-0142 D1] Ownership queues — named sets of people records can sit in until a member claims them | +| **assignmentRules** | `{ name: string; label?: string; description?: string; object: string; … }[]` | optional | [ADR-0142 D5] Assignment rules — route new ownerless records to a queue or a person, once, on insert | | **apis** | `{ name: string; path: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; summary?: string; … }[]` | optional | API Endpoints — declared endpoints are live from protocol 17; each is gated at publish (ADR-0121) | | **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Outbound Webhooks | | **agents** | `{ name: string; label: string; avatar?: string; role: string; … }[]` | optional | AI Agents — platform-internal (ADR-0063 §2): the kernel ships exactly two (ask/build); third parties extend via skills, not agents | diff --git a/content/docs/references/automation/approval.mdx b/content/docs/references/automation/approval.mdx index d8129c9d603..4f2ea890b3d 100644 --- a/content/docs/references/automation/approval.mdx +++ b/content/docs/references/automation/approval.mdx @@ -53,8 +53,8 @@ const result = ApprovalDecision.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'manager' \| 'position' \| 'department' \| 'team' \| 'field' \| 'expression' \| 'org_membership_level' \| 'role' \| 'user' \| 'queue'>` | ✅ | Approval step approver type. `manager` is resolved from the directory rather than configured here: it reads the submitter's `sys_user.manager_id` at runtime, and that column is not a profile field an author edits, so until an operator populates it, a manager step resolves to nobody and the request falls to the node's `onEmptyApprovers` policy. `os lint` reports that at authoring time as `approval-approvers-may-resolve-empty`, and `os explain approval-approvers-may-resolve-empty` carries the graded population routes and the full remedy, the node-level `onEmptyApprovers` fallback included; the Approvals guide states the same remedy in prose. | -| **value** | `string` | optional | User id / membership tier / position / team / department / field — per `type`; for `expression`, a CEL expression over `current.*` / `trigger.*` / `vars.*` | +| **type** | `Enum<'manager' \| 'position' \| 'department' \| 'team' \| 'queue' \| 'field' \| 'expression' \| 'org_membership_level' \| 'role' \| 'user'>` | ✅ | Approval step approver type. `manager` is resolved from the directory rather than configured here: it reads the submitter's `sys_user.manager_id` at runtime, and that column is not a profile field an author edits, so until an operator populates it, a manager step resolves to nobody and the request falls to the node's `onEmptyApprovers` policy. `os lint` reports that at authoring time as `approval-approvers-may-resolve-empty`, and `os explain approval-approvers-may-resolve-empty` carries the graded population routes and the full remedy, the node-level `onEmptyApprovers` fallback included; the Approvals guide states the same remedy in prose. | +| **value** | `string` | optional | User id / membership tier / position / team / department / field / queue name — per `type`; for `expression`, a CEL expression over `current.*` / `trigger.*` / `vars.*` | | **resolveAs** | `Enum<'user' \| 'department' \| 'position' \| 'team'>` | optional | How an `expression` result is expanded into approvers (default 'user') | | **group** | `string` | optional | Group label for per_group sign-off (e.g. "legal", "finance") | | **organization** | `string` | optional | ADR-0105 D9 — organization whose directory resolves this approver: `$root` (group org), `$parent` (one level up), or an organization slug. Omitted = the request's own organization. | @@ -68,14 +68,14 @@ const result = ApprovalDecision.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **approvers** | `{ type: Enum<'manager' \| 'position' \| 'department' \| 'team' \| 'field' \| 'expression' \| …>; value?: string; resolveAs?: Enum<'user' \| 'department' \| 'position' \| 'team'>; group?: string; … }[]` | ✅ | Allowed approvers for this node | +| **approvers** | `{ type: Enum<'manager' \| 'position' \| 'department' \| 'team' \| 'queue' \| 'field' \| …>; value?: string; resolveAs?: Enum<'user' \| 'department' \| 'position' \| 'team'>; group?: string; … }[]` | ✅ | Allowed approvers for this node | | **excludeSubmitter** | `boolean` | optional | Segregation of duties for this step: when true, the request's submitter (the user whose action started the flow run, recorded on the approval request as `submitter_id`) is removed from the slate `approvers` resolves to, whatever approver type produced them. If that leaves nobody, the request takes the node's `onEmptyApprovers` route (`admin_rescue` by default, or `fallbackApprovers` under `'fallback'`) and never passes silently, and the approval record shows that the exclusion changed the route. `onEmptyApprovers: 'auto_approve'` is refused beside `excludeSubmitter: true`, because it would approve exactly the request this option exists to stop; choose `'admin_rescue'`, `'fail'` or `'fallback'`. Omitted or false (the default), the slate stays as resolved. | | **behavior** | `Enum<'first_response' \| 'unanimous' \| 'quorum' \| 'per_group'>` | optional (default: `"first_response"`) | How to combine multiple approvers | | **minApprovals** | `integer` | optional | Approvals required — total (quorum) or per group (per_group). Omitted ⇒ all resolvable approvers for quorum, 1 per group for per_group | | **lockRecord** | `boolean` | optional (default: `true`) | Lock the record from editing while pending | | **approvalStatusField** | `string` | optional | Business-object field to mirror request status onto | | **onEmptyApprovers** | `Enum<'admin_rescue' \| 'fail' \| 'auto_approve' \| 'fallback'>` | optional (default: `"admin_rescue"`) | Behavior when no concrete approver resolves at node entry — 'admin_rescue' (default) opens the request on the organization's administrators; 'fallback' opens it on fallbackApprovers instead, naming who decides | -| **fallbackApprovers** | `{ type: Enum<'manager' \| 'position' \| 'department' \| 'team' \| 'field' \| 'expression' \| …>; value?: string; resolveAs?: Enum<'user' \| 'department' \| 'position' \| 'team'>; group?: string; … }[]` | optional | Approvers the request opens on when onEmptyApprovers is 'fallback' | +| **fallbackApprovers** | `{ type: Enum<'manager' \| 'position' \| 'department' \| 'team' \| 'queue' \| 'field' \| …>; value?: string; resolveAs?: Enum<'user' \| 'department' \| 'position' \| 'team'>; group?: string; … }[]` | optional | Approvers the request opens on when onEmptyApprovers is 'fallback' | | **decisionOutputs** | `(string \| { key: string; label?: string; type?: Enum<'text' \| 'user' \| 'department' \| 'position' \| 'team'>; multiple?: boolean; … })[]` | optional | Author-declared decision outputs — bare keys or typed `{ key, type, multiple }` declarations | | **escalation** | `{ enabled?: boolean; timeoutHours: number; action?: Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>; escalateTo?: string; … }` | optional | Per-node SLA escalation | | **maxRevisions** | `integer` | optional (default: `3`) | Max send-backs for revision before auto-reject (0 = send-back disabled) | @@ -84,8 +84,8 @@ const result = ApprovalDecision.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'manager' \| 'position' \| 'department' \| 'team' \| 'field' \| 'expression' \| …>` | ✅ | Approval step approver type. `manager` is resolved from the directory rather than configured here: it reads the submitter's `sys_user.manager_id` at runtime, and that column is not a profile field an author edits, so until an operator populates it, a manager step resolves to nobody and the request falls to the node's `onEmptyApprovers` policy. `os lint` reports that at authoring time as `approval-approvers-may-resolve-empty`, and `os explain approval-approvers-may-resolve-empty` carries the graded population routes and the full remedy, the node-level `onEmptyApprovers` fallback included; the Approvals guide states the same remedy in prose. | -| **value** | `string` | optional | User id / membership tier / position / team / department / field — per `type`; for `expression`, a CEL expression over `current.*` / `trigger.*` / `vars.*` | +| **type** | `Enum<'manager' \| 'position' \| 'department' \| 'team' \| 'queue' \| 'field' \| …>` | ✅ | Approval step approver type. `manager` is resolved from the directory rather than configured here: it reads the submitter's `sys_user.manager_id` at runtime, and that column is not a profile field an author edits, so until an operator populates it, a manager step resolves to nobody and the request falls to the node's `onEmptyApprovers` policy. `os lint` reports that at authoring time as `approval-approvers-may-resolve-empty`, and `os explain approval-approvers-may-resolve-empty` carries the graded population routes and the full remedy, the node-level `onEmptyApprovers` fallback included; the Approvals guide states the same remedy in prose. | +| **value** | `string` | optional | User id / membership tier / position / team / department / field / queue name — per `type`; for `expression`, a CEL expression over `current.*` / `trigger.*` / `vars.*` | | **resolveAs** | `Enum<'user' \| 'department' \| 'position' \| 'team'>` | optional | How an `expression` result is expanded into approvers (default 'user') | | **group** | `string` | optional | Group label for per_group sign-off (e.g. "legal", "finance") | | **organization** | `string` | optional | ADR-0105 D9 — organization whose directory resolves this approver: `$root` (group org), `$parent` (one level up), or an organization slug. Omitted = the request's own organization. | @@ -94,8 +94,8 @@ const result = ApprovalDecision.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'manager' \| 'position' \| 'department' \| 'team' \| 'field' \| 'expression' \| …>` | ✅ | Approval step approver type. `manager` is resolved from the directory rather than configured here: it reads the submitter's `sys_user.manager_id` at runtime, and that column is not a profile field an author edits, so until an operator populates it, a manager step resolves to nobody and the request falls to the node's `onEmptyApprovers` policy. `os lint` reports that at authoring time as `approval-approvers-may-resolve-empty`, and `os explain approval-approvers-may-resolve-empty` carries the graded population routes and the full remedy, the node-level `onEmptyApprovers` fallback included; the Approvals guide states the same remedy in prose. | -| **value** | `string` | optional | User id / membership tier / position / team / department / field — per `type`; for `expression`, a CEL expression over `current.*` / `trigger.*` / `vars.*` | +| **type** | `Enum<'manager' \| 'position' \| 'department' \| 'team' \| 'queue' \| 'field' \| …>` | ✅ | Approval step approver type. `manager` is resolved from the directory rather than configured here: it reads the submitter's `sys_user.manager_id` at runtime, and that column is not a profile field an author edits, so until an operator populates it, a manager step resolves to nobody and the request falls to the node's `onEmptyApprovers` policy. `os lint` reports that at authoring time as `approval-approvers-may-resolve-empty`, and `os explain approval-approvers-may-resolve-empty` carries the graded population routes and the full remedy, the node-level `onEmptyApprovers` fallback included; the Approvals guide states the same remedy in prose. | +| **value** | `string` | optional | User id / membership tier / position / team / department / field / queue name — per `type`; for `expression`, a CEL expression over `current.*` / `trigger.*` / `vars.*` | | **resolveAs** | `Enum<'user' \| 'department' \| 'position' \| 'team'>` | optional | How an `expression` result is expanded into approvers (default 'user') | | **group** | `string` | optional | Group label for per_group sign-off (e.g. "legal", "finance") | | **organization** | `string` | optional | ADR-0105 D9 — organization whose directory resolves this approver: `$root` (group org), `$parent` (one level up), or an organization slug. Omitted = the request's own organization. | @@ -133,12 +133,12 @@ Approval step approver type. `manager` is resolved from the directory rather tha * `position` * `department` * `team` +* `queue` * `field` * `expression` * `org_membership_level` * `role` * `user` -* `queue` --- diff --git a/content/docs/references/automation/assignment-rule.mdx b/content/docs/references/automation/assignment-rule.mdx new file mode 100644 index 00000000000..7cffde710f7 --- /dev/null +++ b/content/docs/references/automation/assignment-rule.mdx @@ -0,0 +1,101 @@ +--- +title: Assignment Rule — Automation Protocol +navTitle: Assignment Rule +description: "Assignment Rule schemas of the ObjectStack Automation Protocol: AssignmentRule and 3 more — each property with its type, default and a TypeScript example." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + + +**Source:** `packages/spec/src/automation/assignment-rule.zod.ts` + + +## TypeScript Usage + +```typescript +import { AssignmentRuleSchema, AssignmentRuleEntrySchema, AssignmentStrategy, AssignmentTargetSchema } from '@objectstack/spec/automation'; +import type { AssignmentRule, AssignmentRuleEntry, AssignmentStrategy, AssignmentTarget } from '@objectstack/spec/automation'; + +// Validate data +const result = AssignmentRuleSchema.parse(data); +``` + +--- + +## AssignmentRule + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Rule machine name (lowercase snake_case) | +| **label** | `string` | optional | Display label | +| **description** | `string` | optional | Administrative notes | +| **object** | `string` | ✅ | The object whose inserted rows the rule routes (an `ownership: user` object) | +| **active** | `boolean` | optional (default: `true`) | Whether the rule is evaluated | +| **entries** | `{ name: string; criteria?: string \| object; assignTo: object; strategy?: Enum<'round_robin' \| 'least_loaded'>; … }[]` | ✅ | Ordered entries; the first whose criteria match the inserted row decides where it goes | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | + +### Nested Shape: `AssignmentRule.entries[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Entry machine name, unique within the rule (the round-robin cursor is kept per entry) | +| **criteria** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL over `record.*`, interpreted against the row being inserted (it is not stored yet). Absent = every row. | +| **assignTo** | `{ type: Enum<'queue' \| 'user'>; value: string }` | ✅ | Where a matching row goes: a queue or one user | +| **strategy** | `Enum<'round_robin' \| 'least_loaded'>` | optional | How one member of the target queue is chosen: 'round_robin' or 'least_loaded'. Only with a queue target; absent = the row parks in the queue for a member to claim. | +| **load** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Required with 'least_loaded', refused otherwise: a CEL pushdown predicate over the object naming the rows that count as a member's open load (e.g. `record.status != "closed"`). There is no platform default, because what is open differs per object. | + + +--- + +## AssignmentRuleEntry + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Entry machine name, unique within the rule (the round-robin cursor is kept per entry) | +| **criteria** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL over `record.*`, interpreted against the row being inserted (it is not stored yet). Absent = every row. | +| **assignTo** | `{ type: Enum<'queue' \| 'user'>; value: string }` | ✅ | Where a matching row goes: a queue or one user | +| **strategy** | `Enum<'round_robin' \| 'least_loaded'>` | optional | How one member of the target queue is chosen: 'round_robin' or 'least_loaded'. Only with a queue target; absent = the row parks in the queue for a member to claim. | +| **load** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Required with 'least_loaded', refused otherwise: a CEL pushdown predicate over the object naming the rows that count as a member's open load (e.g. `record.status != "closed"`). There is no platform default, because what is open differs per object. | + +### Nested Shape: `AssignmentRuleEntry.assignTo` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `Enum<'queue' \| 'user'>` | ✅ | 'queue' routes into a queue (by machine name); 'user' names one owner (by id) | +| **value** | `string` | ✅ | The queue machine name, or the user id | + + +--- + +## AssignmentStrategy + +### Allowed Values + +* `round_robin` +* `least_loaded` + + +--- + +## AssignmentTarget + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `Enum<'queue' \| 'user'>` | ✅ | 'queue' routes into a queue (by machine name); 'user' names one owner (by id) | +| **value** | `string` | ✅ | The queue machine name, or the user id | + + +--- + diff --git a/content/docs/references/automation/index.mdx b/content/docs/references/automation/index.mdx index 402ee94e3f8..8bbb6ca4b88 100644 --- a/content/docs/references/automation/index.mdx +++ b/content/docs/references/automation/index.mdx @@ -1,7 +1,7 @@ --- title: Automation Protocol — schema reference navTitle: Automation Protocol -description: "The ObjectStack Automation Protocol in 14 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." +description: "The ObjectStack Automation Protocol in 15 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -11,6 +11,7 @@ This section contains all protocol schemas for the automation layer of ObjectSta + diff --git a/content/docs/references/automation/meta.json b/content/docs/references/automation/meta.json index 37b4edca403..67a1ec3efe6 100644 --- a/content/docs/references/automation/meta.json +++ b/content/docs/references/automation/meta.json @@ -14,6 +14,7 @@ "approval", "---More---", "api-trigger-secret-ref", + "assignment-rule", "builtin-node-config", "flow-function", "io-node-config", diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 1767273fd9c..d6e90024f23 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1544 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1551 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -22,19 +22,19 @@ counts are sums of the rows they head. Regenerate with | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | | [API Protocol](/docs/references/api) | 32 | 438 | REST contracts, endpoints, routing, realtime, batch, discovery. | -| [Automation Protocol](/docs/references/automation) | 14 | 73 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | +| [Automation Protocol](/docs/references/automation) | 15 | 77 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 30 | 180 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 12 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 29 | 151 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [Marketplace Protocol](/docs/references/marketplace) | 4 | 30 | The package & marketplace format — package identity and versions, listing, publish, review, search, install, template manifests. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | -| [Security Protocol](/docs/references/security) | 5 | 34 | Permission sets, row-level security, sharing rules, tenancy posture. | +| [Security Protocol](/docs/references/security) | 6 | 37 | Permission sets, row-level security, sharing rules, tenancy posture. | | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 286 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 171 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **199** | **1544** | 14 protocol modules | +| **Total** | **201** | **1551** | 14 protocol modules | --- @@ -106,7 +106,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. ## Automation Protocol -**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **14 pages, 73 schemas** +**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **15 pages, 77 schemas** Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. @@ -114,6 +114,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu | :--- | :--- | | [`api-trigger-secret-ref.zod.ts`](/docs/references/automation/api-trigger-secret-ref) | `ApiTriggerSecretRef` | | [`approval.zod.ts`](/docs/references/automation/approval) | `ApprovalDecision`, `ApprovalEscalation`, `ApprovalNodeApprover`, `ApprovalNodeConfig`, `ApproverType`, `DecisionOutputDef` | +| [`assignment-rule.zod.ts`](/docs/references/automation/assignment-rule) | `AssignmentRule`, `AssignmentRuleEntry`, `AssignmentStrategy`, `AssignmentTarget` | | [`bpmn-interop.zod.ts`](/docs/references/automation/bpmn-interop) | `BpmnDiagnostic`, `BpmnElementMapping`, `BpmnExportOptions`, `BpmnImportOptions`, `BpmnInteropResult`, `BpmnUnmappedStrategy`, `BpmnVersion` | | [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `AssignmentConfig`, `AssignmentExpressionValue`, `AssignmentValue`, `CreateRecordConfig`, `DeleteRecordConfig`, `EndConfig`, `FlowCollectionSlot`, `FlowValueSlot`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` | | [`control-flow.zod.ts`](/docs/references/automation/control-flow) | `FlowRegion`, `LoopConfig`, `ParallelBranch`, `ParallelConfig`, `RetryPolicy`, `TryCatchConfig`, `TryCatchErrorValue` | @@ -267,7 +268,7 @@ Declarative test suites — scenarios, steps, actions and assertions. ## Security Protocol -**Source:** `packages/spec/src/security/` · **Import:** `@objectstack/spec/security` · **5 pages, 34 schemas** +**Source:** `packages/spec/src/security/` · **Import:** `@objectstack/spec/security` · **6 pages, 37 schemas** Permission sets, row-level security, sharing rules, tenancy posture. @@ -276,6 +277,7 @@ Permission sets, row-level security, sharing rules, tenancy posture. | [`explain.zod.ts`](/docs/references/security/explain) | `AccessMatrix`, `AccessMatrixEntry`, `AuthzPosture`, `ExplainDecision`, `ExplainLayer`, `ExplainMatchedRule`, `ExplainOperation`, `ExplainRecordAttribution`, `ExplainRequest` | | [`misc`](/docs/references/security/misc) *(no single source file)* | `CapabilityDeclaration`, `OrgScopingEntitlement`, `PlatformGlobalObjects`, `TenancyPosture`, `TenantLayer0Verdict` | | [`permission.zod.ts`](/docs/references/security/permission) | `AdminScope`, `EffectiveObjectPermission`, `FieldPermission`, `ObjectAccessScope`, `ObjectPermission`, `PermissionSet` | +| [`queue.zod.ts`](/docs/references/security/queue) | `DeclaredQueueMember`, `Queue`, `QueueMemberType` | | [`rls.zod.ts`](/docs/references/security/rls) | `DefaultRowScope`, `MembershipKey`, `MembershipKeyName`, `MembershipKeys`, `RLSEvaluationResult`, `RLSOperation`, `RLSUserContext`, `RowLevelSecurityPolicy` | | [`sharing.zod.ts`](/docs/references/security/sharing) | `CriteriaSharingRule`, `OWDModel`, `ShareRecipientType`, `SharingLevel`, `SharingRule`, `SharingRuleType` | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index b2ec876114d..66fe8a53daf 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -179,7 +179,7 @@ const result = MetadataBulkResultSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'sharing_rule' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | +| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'sharing_rule' \| 'queue' \| 'assignment_rule' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | | **namespaces** | `string[]` | optional | Filter by namespaces | | **packageId** | `string` | optional | Filter by owning package | | **search** | `string` | optional | Full-text search query | @@ -251,6 +251,8 @@ const result = MetadataBulkResultSchema.parse(data); * `position` * `capability` * `sharing_rule` +* `queue` +* `assignment_rule` * `agent` * `tool` * `skill` @@ -264,7 +266,7 @@ const result = MetadataBulkResultSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| … +14 more>` | ✅ | Metadata type identifier | +| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| … +16 more>` | ✅ | Metadata type identifier | | **label** | `string` | ✅ | Display label for the metadata type | | **description** | `string` | optional | Description of the metadata type | | **filePatterns** | `string[]` | ✅ | Glob patterns to discover files of this type | @@ -306,6 +308,8 @@ const result = MetadataBulkResultSchema.parse(data); * `position` * `capability` * `sharing_rule` +* `queue` +* `assignment_rule` * `agent` * `tool` * `skill` diff --git a/content/docs/references/security/explain.mdx b/content/docs/references/security/explain.mdx index 9f684a4e05c..569c088a6ac 100644 --- a/content/docs/references/security/explain.mdx +++ b/content/docs/references/security/explain.mdx @@ -184,7 +184,7 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **kind** | `Enum<'tenant_filter' \| 'owd_baseline' \| 'ownership' \| 'record_share' \| 'sharing_rule' \| 'team' \| 'territory' \| 'rls_policy'>` | ✅ | The row-visibility source kind evaluated for this record at this layer. | +| **kind** | `Enum<'tenant_filter' \| 'owd_baseline' \| 'ownership' \| 'record_share' \| 'sharing_rule' \| 'team' \| 'territory' \| 'rls_policy' \| 'queue_membership' \| 'queue_claim'>` | ✅ | The row-visibility source kind evaluated for this record at this layer. | | **name** | `string` | ✅ | Stable identifier of the concrete rule, share, or policy that was evaluated. | | **grants** | `Enum<'read' \| 'edit' \| 'full'>` | optional | Access level a sharing source grants on the record (authorable: read/edit; `full` appears only for legacy rows pending normalisation). | | **via** | `string` | optional | How the rule reached the principal — recipient group/position, ownership, or the matching criteria. | diff --git a/content/docs/references/security/index.mdx b/content/docs/references/security/index.mdx index bf57ca81b46..bf804b4793e 100644 --- a/content/docs/references/security/index.mdx +++ b/content/docs/references/security/index.mdx @@ -1,7 +1,7 @@ --- title: Security Protocol — complete schema reference navTitle: Security Protocol -description: "The ObjectStack Security Protocol in 5 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." +description: "The ObjectStack Security Protocol in 6 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -12,6 +12,7 @@ This section contains all protocol schemas for the security layer of ObjectStack + diff --git a/content/docs/references/security/meta.json b/content/docs/references/security/meta.json index 098724fd6c7..975f129ca54 100644 --- a/content/docs/references/security/meta.json +++ b/content/docs/references/security/meta.json @@ -4,6 +4,7 @@ "explain", "misc", "permission", + "queue", "rls", "sharing" ] diff --git a/content/docs/references/security/queue.mdx b/content/docs/references/security/queue.mdx new file mode 100644 index 00000000000..50bd726405c --- /dev/null +++ b/content/docs/references/security/queue.mdx @@ -0,0 +1,77 @@ +--- +title: Queue schema — Security Protocol reference +navTitle: Queue +description: "Queue schemas of the ObjectStack Security Protocol: DeclaredQueueMember, Queue, QueueMemberType — each property with its type, default and a TypeScript example." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + + +**Source:** `packages/spec/src/security/queue.zod.ts` + + +## TypeScript Usage + +```typescript +import { DeclaredQueueMemberSchema, QueueSchema, QueueMemberType } from '@objectstack/spec/security'; +import type { DeclaredQueueMember, Queue, QueueMemberType } from '@objectstack/spec/security'; + +// Validate data +const result = DeclaredQueueMemberSchema.parse(data); +``` + +--- + +## DeclaredQueueMember + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'position'` | ✅ | The member kind. A declared queue names positions only ('position'). | +| **value** | `string` | ✅ | The position machine name whose holders are members of the queue | + + +--- + +## Queue + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Queue machine name (lowercase snake_case); the one name the `queue` column, an assignment rule, an approver and a sharing recipient reference it by | +| **label** | `string` | ✅ | Display label (e.g. Support Intake) | +| **description** | `string` | optional | Administrative notes | +| **members** | `{ type: 'position'; value: string }[]` | optional (default: `[]`) | The positions whose holders are members, resolved in the organization a record or request belongs to. An empty list is a queue nobody staffs yet: records still sit in it. | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | + +### Nested Shape: `Queue.members[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'position'` | ✅ | The member kind. A declared queue names positions only ('position'). | +| **value** | `string` | ✅ | The position machine name whose holders are members of the queue | + + +--- + +## QueueMemberType + +### Allowed Values + +* `user` +* `team` +* `position` +* `unit_and_subordinates` +* `business_unit` + + +--- + diff --git a/content/docs/references/security/sharing.mdx b/content/docs/references/security/sharing.mdx index 53581b611c7..7d60124877a 100644 --- a/content/docs/references/security/sharing.mdx +++ b/content/docs/references/security/sharing.mdx @@ -34,7 +34,7 @@ const result = CriteriaSharingRuleSchema.parse(data); | **object** | `string` | ✅ | Target Object Name | | **active** | `boolean` | optional (default: `true`) | | | **accessLevel** | `Enum<'read' \| 'edit'>` | optional (default: `"read"`) | | -| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>; value: string }` | ✅ | The recipient of the shared access: a principal resolved once per rule, or — `type: field` — the user or users named by a field on each matched record | +| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'queue' \| 'field'>; value: string }` | ✅ | The recipient of the shared access: a principal resolved once per rule, or — `type: field` — the user or users named by a field on each matched record | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | @@ -49,8 +49,8 @@ const result = CriteriaSharingRuleSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>` | ✅ | | -| **value** | `string` | ✅ | The recipient principal: the id or code of the user / team / position / business unit — or, for `type: 'field'`, the snake_case name of a user-typed field on the record whose value names the user or users to share each matched record with | +| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'queue' \| 'field'>` | ✅ | | +| **value** | `string` | ✅ | The recipient principal: the id or code of the user / team / position / business unit, the machine name of a queue — or, for `type: 'field'`, the snake_case name of a user-typed field on the record whose value names the user or users to share each matched record with | --- @@ -76,6 +76,7 @@ const result = CriteriaSharingRuleSchema.parse(data); * `position` * `unit_and_subordinates` * `business_unit` +* `queue` * `field` @@ -103,7 +104,7 @@ const result = CriteriaSharingRuleSchema.parse(data); | **object** | `string` | ✅ | Target Object Name | | **active** | `boolean` | optional (default: `true`) | | | **accessLevel** | `Enum<'read' \| 'edit'>` | optional (default: `"read"`) | | -| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>; value: string }` | ✅ | The recipient of the shared access: a principal resolved once per rule, or — `type: field` — the user or users named by a field on each matched record | +| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'queue' \| 'field'>; value: string }` | ✅ | The recipient of the shared access: a principal resolved once per rule, or — `type: field` — the user or users named by a field on each matched record | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | @@ -118,8 +119,8 @@ const result = CriteriaSharingRuleSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>` | ✅ | | -| **value** | `string` | ✅ | The recipient principal: the id or code of the user / team / position / business unit — or, for `type: 'field'`, the snake_case name of a user-typed field on the record whose value names the user or users to share each matched record with | +| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'queue' \| 'field'>` | ✅ | | +| **value** | `string` | ✅ | The recipient principal: the id or code of the user / team / position / business unit, the machine name of a queue — or, for `type: 'field'`, the snake_case name of a user-typed field on the record whose value names the user or users to share each matched record with | --- diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md index 91417373d67..92d2a3c20af 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `automation/` | 62 | 38 | 0 | 1 | 23 | +| `automation/` | 65 | 41 | 0 | 1 | 23 | ## `automation/` — sites @@ -32,6 +32,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | File | Sites | |---|---| | `approval.zod.ts` | 4 | +| `assignment-rule.zod.ts` | 3 | | `bpmn-interop.zod.ts` | 5 | | `builtin-node-config.zod.ts` | 10 | | `control-flow.zod.ts` | 6 | @@ -43,7 +44,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `schemaless-node-config.zod.ts` | 4 | | `time-relative-trigger.zod.ts` | 1 | | `webhook.zod.ts` | 1 | -| **total** | **62** | +| **total** | **65** | ## `automation/` — open @@ -51,7 +52,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**23 strip of 62**, in 5 file(s). +**23 strip of 65**, in 5 file(s). | File | Strip | Sites | |---|---|---| @@ -60,7 +61,7 @@ over it is here. | `execution.zod.ts` | 12 | 12 | | `flow.zod.ts` | 1 | 11 | | `node-executor.zod.ts` | 4 | 4 | -| **total** | **23** | **62** | +| **total** | **23** | **65** | | Bucket | Sites | |---|---| diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/security.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/security.md index 226723df44c..e2f5ec35cb8 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/security.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/security.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `security/` | 22 | 9 | 0 | 0 | 13 | +| `security/` | 24 | 11 | 0 | 0 | 13 | ## `security/` — sites @@ -33,9 +33,10 @@ classify and is not listed (it becomes reportable the day it grows its first sit |---|---| | `explain.zod.ts` | 11 | | `permission.zod.ts` | 4 | +| `queue.zod.ts` | 2 | | `rls.zod.ts` | 5 | | `sharing.zod.ts` | 2 | -| **total** | **22** | +| **total** | **24** | ## `security/` — open @@ -43,13 +44,13 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**13 strip of 22**, in 2 file(s). +**13 strip of 24**, in 2 file(s). | File | Strip | Sites | |---|---|---| | `explain.zod.ts` | 11 | 11 | | `rls.zod.ts` | 2 | 5 | -| **total** | **13** | **22** | +| **total** | **13** | **24** | | Bucket | Sites | |---|---| diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 6790b5e4460..37cc7d12c06 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -749,6 +749,7 @@ column does not move and the `strip` column falls by the count of what left. | `webhook.zod.ts` | authorable | **strict as of #4001 batch 11**, and the `(p)` resolved to the opposite of what the old note ("spec-only") implied. Three parse doors, not zero: `defineWebhook()`, `defineStack({ webhooks })` via `StackSchema`, and — the one that mattered — `plugin-webhooks`' `bootstrapDeclaredWebhooks`, which re-`parse()`s every declared webhook at BOOT before materializing it into `sys_webhook`, warning and SKIPPING on failure. Which is why the ADR-0010 envelope landed in the same change rather than as a follow-up: both metadata load paths call `applyProtection` on EVERY type, so a package-loaded webhook reaches that boot parse already carrying `_packageId` / `_provenance`. `.strip` discarded them; `.strict()` alone would have converted every package-shipped webhook into a **skipped subscription after redeploy**, with one `warn` to say so. This is the envelope debt the registered-type batches paid down eight times — `webhook` is not a registered type (no `BUILTIN_METADATA_TYPE_SCHEMAS` entry), which is exactly why the invariant test that guards those never looked here. ⚠️ Strictness also rides `.extend()` onto `integration/connector.zod.ts`'s `WebhookConfigSchema` (verified against real zod, and pinned in `connector.test.ts`); its two extra keys are named in `extraKeys` — except `events`, deliberately, because it is also an alias TARGET here and listing it would walk a base-surface author through two rejections into a key the base does not accept (finding 7, arriving via hand-written `extraKeys` rather than the shape) | | `time-relative-trigger.zod.ts` | authorable | **Undeclared until the #4001 re-measurement, and invisible for the worst possible reason**: `TimeRelativeTriggerSchema` is written `z\n .object({`, the old textual counter matched zero sites, and a zero-site file is SKIPPED by the coverage walk as "nothing to classify". So the gate whose whole promise is "no undeclared surface" reported green over an authorable schema — the same shape as `data/driver/`, one layer subtler, because this time the file was not hidden by the walk but by the counter feeding it. Classification is not a guess: the file's own `@example` blocks author it by hand into a flow start node (`config: { timeRelative: { object, dateField, offsetDays, filter } }`), which is the authoring door. A stripped key here means the sweep silently never matches — `offsetDay` for `offsetDays` returns a trigger that never fires, reported as configured. **Strict as of #4001 batch 11**, and closing it turned up one thing the triage did not predict: this schema is `safeParse`d at BIND time by `TimeRelativeTriggerPlugin` (`time-relative-trigger.ts`), not only at authoring — so the descriptor sitting under the deliberately-OPEN node `config` slot (ADR-0018) now has exactly one gate, and it is a runtime one. The behaviour change is the campaign's whole thesis in miniature: `{ …valid, offsetDay: 7 }` used to bind a sweep that ran daily with the author's narrowing discarded; it now refuses to bind and the plugin's warning carries the key and the rename | | `flow-function.zod.ts` | authorable | `FlowFunctionDeclarationSchema` (#4396) — the `{ handler, effect }` form of a `defineStack({ functions })` entry. Authored, but note what an undeclared key here would be: a sibling of a **live function**, not data. `defineStack`'s union already rejects a record whose `handler` is not callable, and the boot-path reader is the hand-written `normalizeFlowFunctionEntry` rather than a `.parse()` (re-validating a live handler every boot buys nothing), so strictness would bind at authoring only. **Strict as of #4001 batch 11**, and the verify-first pass confirmed that reading exactly — stated in the code rather than left implied, because a tightening must not claim reach it does not have. It is still worth having for the reason the reading first made it look pointless: `normalizeFlowFunctionEntry` takes TWO keys and ignores the rest **by construction**, so a misspelled `effect` was dropped at the schema and then not looked for by the reader — and the failure runs the quiet way. The function registers, runs, and its writes are counted as none, which is precisely what keeps #4354's broken-sweep query (`selected > 0 AND acted = 0 AND unmeasured = 0`) silent on the one run that needed it | +| `assignment-rule.zod.ts` | authorable | **strict from birth (#25769, ADR-0142 D5)** — the rule, its entries and the entry target are `strictObject` from the first commit, the stack's `assignmentRules` being the authoring door; nothing ever relied on a strip. The entry's cross-field rules (`strategy` only with a queue target, `load` exactly with `least_loaded`) are `superRefine`s on the closed shape, not key gates | `state-machine.zod.ts` had a row here (authorable, **strict as of #4001 批 10** — all six sites: `ActionRef` / `GuardRef` / `Transition` / `StateNode` + `.meta` / `StateMachine`) until **#21320 retired the file whole** (ADR-0049 enforce-or-remove; ruled D, retire, on objectstack-ai/cloud#2569). The row's own finding was that ADR-0020 had already removed every record-lifecycle door to this XState shape and that ONE authoring door survived — `ai/agent.zod.ts`'s `lifecycle` — so the file was authorable surface rather than dead surface, and was closed rather than reclassified. That door was then measured to have no reader in either repository: the agent conversation state machine was parsed and never enforced. `agent.lifecycle` became a `retiredKey()` tombstone (the ADR-0087 conversion `agent-lifecycle-removed` deletes it from stored rows and sources), the file lost its last importer, and its five published defs (`automation/StateMachine`, `StateNode`, `Transition`, `ActionRef`, `GuardRef`) entered `RETIRED_DEFS_BY_MAJOR` with the D3 entry `agent-lifecycle-retired`. A record's legal transitions remain the `state_machine` rule in `data/validation.zod.ts`, which never used this file. @@ -791,6 +792,7 @@ column does not move and the `strip` column falls by the count of what left. | `permission.zod.ts` | authorable | **strict as of #4001**; `EffectiveObjectPermissionSchema` explicitly `.strip()`s (wire) | | `rls.zod.ts` | authorable | **`RowLevelSecurityPolicySchema` strict as of #4001 step 2** (a stripped RLS key is a silent policy hole); `RLSUserContextSchema` / `RLSEvaluationResultSchema` are runtime shapes — stay tolerant | | `sharing.zod.ts` | authorable | **strict as of #4001 step 2** — rule + recipient shapes; strictness and the error map ride the base into the criteria extension | +| `queue.zod.ts` | authorable | **strict from birth (#25769, ADR-0142 D1)** — the declared queue and its member are `strictObject` from the first commit, the stack's `queues` being the authoring door. The declared member's `type` is a `'position'` literal whose refusal names where users, teams and business units belong (an organization's `sys_queue_member` rows) | ### `studio/` — file-level triage diff --git a/packages/metadata-core/test/injected-column-provenance.test.ts b/packages/metadata-core/test/injected-column-provenance.test.ts index 31035d773b1..ec22360e1d0 100644 --- a/packages/metadata-core/test/injected-column-provenance.test.ts +++ b/packages/metadata-core/test/injected-column-provenance.test.ts @@ -20,6 +20,7 @@ import { TENANT_SCOPE_FIELD_DEF, OWNER_FIELD_DEF, OWNING_BUSINESS_UNIT_FIELD_DEF, + QUEUE_FIELD_DEF, injectedSystemColumnDefs, platformProvisionsStorage, resolveInjectedColumnProvenance, @@ -37,6 +38,9 @@ const SEVEN_ANCHORS = [ 'owning_business_unit_id', ] as const; +/** Every injected anchor today: the measured seven plus the ownership `queue` (ADR-0142 D3). */ +const INJECTED_ANCHORS = [...SEVEN_ANCHORS, 'queue'] as const; + const remoteFields = () => ({ name: { type: 'text', label: 'Name' }, email: { type: 'text', label: 'Email' }, @@ -63,6 +67,7 @@ const withInjectedAnchors = (def: Record) => ({ ...AUDIT_FIELD_DEFS, owner_id: { ...OWNER_FIELD_DEF }, owning_business_unit_id: { ...OWNING_BUSINESS_UNIT_FIELD_DEF }, + queue: { ...QUEUE_FIELD_DEF }, ...(def.fields as Record), }, }); @@ -194,12 +199,12 @@ describe('[#7865] resolveInjectedColumnProvenance — the marker', () => { [ "ownership: 'org'", { ...external(), ownership: 'org' }, - ['owner_id', 'owning_business_unit_id'], + ['owner_id', 'owning_business_unit_id', 'queue'], ], [ "ownership: 'business_unit'", { ...external(), ownership: 'business_unit' }, - ['owner_id'], + ['owner_id', 'queue'], ], ]; for (const [label, def, withheld] of cases) { @@ -221,10 +226,10 @@ describe('[#7865] resolveInjectedColumnProvenance — the marker', () => { }); describe('[#7865] unprovisionedInjectedColumns — the enumerable marker', () => { - it('lists exactly the seven measured anchors for the showcase-shaped federated object', () => { - expect(unprovisionedInjectedColumns(external()).sort()).toEqual([...SEVEN_ANCHORS].sort()); + it('lists exactly the injected anchors (the seven measured plus `queue`) for the showcase-shaped federated object', () => { + expect(unprovisionedInjectedColumns(external()).sort()).toEqual([...INJECTED_ANCHORS].sort()); expect(unprovisionedInjectedColumns(withInjectedAnchors(external())).sort()).toEqual( - [...SEVEN_ANCHORS].sort(), + [...INJECTED_ANCHORS].sort(), ); }); @@ -251,6 +256,7 @@ describe('[#7865] the fence: the marker lives in the API, never in the data', () ['TENANT_SCOPE_FIELD_DEF', TENANT_SCOPE_FIELD_DEF], ['OWNER_FIELD_DEF', OWNER_FIELD_DEF], ['OWNING_BUSINESS_UNIT_FIELD_DEF', OWNING_BUSINESS_UNIT_FIELD_DEF], + ['QUEUE_FIELD_DEF', QUEUE_FIELD_DEF], ...Object.entries(AUDIT_FIELD_DEFS).map( ([k, v]) => [`AUDIT_FIELD_DEFS.${k}`, v] as [string, Readonly>], ), diff --git a/packages/objectql/src/assignment-rules.ts b/packages/objectql/src/assignment-rules.ts new file mode 100644 index 00000000000..a98126503f5 --- /dev/null +++ b/packages/objectql/src/assignment-rules.ts @@ -0,0 +1,459 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0142 D5] The assignment seam: declared routing of an inserted row to a + * queue or a user, evaluated by the engine once per inserted row. + * + * ## When, and on which rows + * + * `ObjectQL.insert` calls {@link AssignmentRuleEvaluator.apply} after every + * `beforeInsert` hook has run and after the system-insert organization is + * resolved, before the write — and only rows whose `owner_id` and `queue` are + * both still empty are evaluated. A writer-supplied owner was adjudicated by + * the ownership gate, a middleware-stamped owner is the acting user's own + * record, and a hook that set or cleared one spoke for the scenario first + * (ADR-0138 D1 item 3's order: hooks, then assignment rules). An update never + * evaluates a rule. + * + * ## As which identity + * + * A platform step, not app automation: every read — the queue's members, a + * member's load, the cursor — runs under an explicit system context pinned to + * the row's organization. The owner it picks is ownership, not attribution: + * the row's audit columns still record the writer. A row with no organization + * evaluates no rule (logged once). + * + * ## What it writes, and what it never does + * + * `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. + * + * ## The strategies + * + * Over the queue's member expansion, ordered by user id (the queue service's + * one expander): + * + * - `least_loaded` — per member, the count of the object's rows in the + * organization the member owns and that match the entry's `load` predicate + * (lowered by the one canonical CEL compiler). The smallest wins; a tie goes + * to the earlier member. Counts are adjusted locally within one insert call. + * - `round_robin` — the member after the one the entry's cursor last chose + * (`sys_assignment_cursor`, one row per rule, entry and organization), + * advanced through the by-id write precondition `{ version }` and retried on + * a lost race. + */ + +import { compileCelToFilter, ExpressionEngine } from '@objectstack/formula'; +import { METADATA_ACTIVATION_TABLE } from '@objectstack/core'; +import { resolveInjectedColumnProvenance } from '@objectstack/spec/data'; +import type { IOwnershipQueueService } from '@objectstack/spec/contracts'; + +/** + * The engine slot where `ObjectQLPlugin` puts the SOURCE of the registered + * ownership-queue service — resolved at use, never at init, for the reason + * {@link SECURITY_SERVICE_SOURCE_SLOT} gives. A wiring seam between this + * package's plugin and its engine: ⛔ not re-exported by `index.ts`. + */ +export const OWNERSHIP_QUEUE_SOURCE_SLOT: unique symbol = Symbol.for('objectstack.objectql.ownershipQueueSource'); + +/** The round-robin cursor table (owned by `@objectstack/plugin-sharing`). */ +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'; + +const OWNER_FIELD = 'owner_id'; +const QUEUE_FIELD = 'queue'; +/** How many times a lost cursor race is re-read and retried before the row parks. */ +const CURSOR_RETRIES = 5; + +/** The slice of the queue service the seam reads. */ +export type AssignmentQueueService = Pick; + +/** What the engine lends the seam. Every read and write runs under `context`, a system context the seam builds. */ +export interface AssignmentHost { + /** The declared assignment rules, as the registry serves them. */ + listRules(): unknown[]; + getSchema(object: string): unknown; + hasObject(object: string): boolean; + queues(): AssignmentQueueService | null; + find(object: string, where: Record, context: Record, limit?: number): Promise[]>; + count(object: string, where: Record, context: Record): Promise; + insert(object: string, data: Record, context: Record): Promise; + /** A by-id update under the engine-internal precondition; throws `CONCURRENT_UPDATE` when it matched nothing. */ + updateExpecting( + object: string, + id: unknown, + data: Record, + expect: Record, + context: Record, + ): Promise; + logger?: { warn?: (msg: string, meta?: Record) => void }; +} + +/** One row the insert is about to write, with the organization it lands in. */ +export interface AssignmentCandidate { + row: Record; + organizationId: string | null; +} + +interface RuleEntry { + name: string; + criteria?: { dialect: string; source: string }; + assignTo: { type: 'queue' | 'user'; value: string }; + strategy?: 'round_robin' | 'least_loaded'; + load?: string; +} + +interface ActiveRule { + name: string; + object: string; + entries: RuleEntry[]; +} + +function present(v: unknown): string | null { + if (v == null) return null; + const s = String(v); + return s === '' ? null : s; +} + +function isRecord(v: unknown): v is Record { + return v !== null && typeof v === 'object' && !Array.isArray(v); +} + +/** A CEL slot as authored (`'…'`) or as parsed (`{ dialect, source }`). */ +function expressionOf(v: unknown): { dialect: string; source: string } | undefined { + if (typeof v === 'string' && v.trim() !== '') return { dialect: 'cel', source: v }; + if (isRecord(v) && typeof v.source === 'string' && v.source.trim() !== '') { + return { dialect: typeof v.dialect === 'string' ? v.dialect : 'cel', source: v.source }; + } + return undefined; +} + +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; + const entries: RuleEntry[] = []; + for (const e of Array.isArray(item.entries) ? item.entries : []) { + if (!isRecord(e) || typeof e.name !== 'string' || !isRecord(e.assignTo)) continue; + const type = e.assignTo.type; + const value = present(e.assignTo.value); + if ((type !== 'queue' && type !== 'user') || !value) continue; + entries.push({ + name: e.name, + criteria: expressionOf(e.criteria), + assignTo: { type, value }, + ...(e.strategy === 'round_robin' || e.strategy === 'least_loaded' ? { strategy: e.strategy } : {}), + ...(expressionOf(e.load) ? { load: expressionOf(e.load)!.source } : {}), + }); + } + return entries.length > 0 ? { name: item.name, object: item.object, entries } : null; +} + +/** 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'; +} + +/** A read failed inside the evaluation: the row keeps what it had, the failure is logged. */ +class AssignmentReadFailure extends Error {} + +export class AssignmentRuleEvaluator { + private readonly warned = new Set(); + + constructor(private readonly host: AssignmentHost) {} + + /** + * The object's active declared rules, in registry order — `[]` (the cheap + * path every insert takes) when none names it or it carries no `queue`. + */ + rulesFor(object: string): ActiveRule[] { + let items: unknown[]; + try { + items = this.host.listRules(); + } catch { + return []; + } + const rules: ActiveRule[] = []; + for (const item of Array.isArray(items) ? items : []) { + const rule = ruleOf(item); + if (rule && rule.object === object) rules.push(rule); + } + if (rules.length === 0) return rules; + if (!carriesQueueColumn(this.host.getSchema(object))) { + this.warnOnce(`no-column::${object}`, `[assignment] '${object}' has assignment rules but no ownership-queue column (its records have no user owner, or it is federated) — the rules route nothing`, { object, rules: rules.map((r) => r.name) }); + return []; + } + return rules; + } + + /** + * Evaluate `object`'s rules over `candidates`, writing `owner_id` / `queue` + * onto each row in place. Never throws. + */ + async apply(object: string, candidates: readonly AssignmentCandidate[]): Promise { + 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; + } + if (rules.length === 0) return; + + // Batch-local least-loaded adjustments, keyed per rule, entry and organization. + const loads = new Map>(); + for (const candidate of pending) { + try { + await this.assignOne(object, rules, candidate, loads); + } catch (err: any) { + // ⛔ Intake is never refused by assignment. + this.host.logger?.warn?.('[assignment] evaluation failed — the row is written as it stood', { + object, error: err?.message, + }); + } + } + } + + private async assignOne( + object: string, + rules: readonly ActiveRule[], + { row, organizationId }: AssignmentCandidate, + loads: Map>, + ): 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 }); + return; + } + const ctx = { isSystem: true, tenantId: organizationId, positions: [], permissions: [] }; + 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); + return; // the first matching entry of the first matching rule decides + } + } + } + + /** The entry's `criteria`, interpreted against the row in flight (it is not stored yet). Absent = every row. */ + private matches(rule: ActiveRule, entry: RuleEntry, row: Record): boolean { + if (!entry.criteria) return true; + const r = ExpressionEngine.evaluate(entry.criteria as any, { record: row } as any); + if (r.ok) return r.value === true; + this.warnOnce(`criteria::${rule.name}::${entry.name}`, `[assignment] the criteria of rule '${rule.name}' entry '${entry.name}' could not be evaluated — the entry does not match until it can`, { + rule: rule.name, entry: entry.name, error: (r as any).error?.message, + }); + return false; + } + + private async route( + object: string, + rule: ActiveRule, + entry: RuleEntry, + row: Record, + organizationId: string, + ctx: Record, + loads: Map>, + ): Promise { + if (entry.assignTo.type === 'user') { + row[OWNER_FIELD] = entry.assignTo.value; + return; + } + const queueName = entry.assignTo.value; + const queues = this.host.queues(); + if (!queues) { + 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; + } + let resolved: unknown; + try { + resolved = 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 }); + 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, + }); + return; + } + // From here the row sits in the queue whatever happens next. + row[QUEUE_FIELD] = queueName; + if (!entry.strategy) return; // park + + let members: string[]; + try { + members = 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; + } + if (members.length === 0) { + this.warnOnce(`empty::${rule.name}`, `[assignment] rule '${rule.name}' routes into queue '${queueName}', which has no members in organization '${organizationId}' — the row parks in the queue. Staff the queue to have rows assigned.`, { + rule: rule.name, queue: queueName, organization: organizationId, + }); + return; + } + try { + const chosen = entry.strategy === 'least_loaded' + ? await this.leastLoaded(object, rule, entry, members, organizationId, ctx, loads) + : await this.roundRobin(rule, entry, members, organizationId, ctx); + if (chosen) row[OWNER_FIELD] = chosen; + } catch (err: any) { + if (!(err instanceof AssignmentReadFailure)) throw err; + this.host.logger?.warn?.('[assignment] a strategy read failed — the row parks in the queue', { rule: rule.name, entry: entry.name, error: err.message }); + } + } + + private async leastLoaded( + object: string, + rule: ActiveRule, + entry: RuleEntry, + members: readonly string[], + organizationId: string, + ctx: Record, + loads: Map>, + ): Promise { + // The count reads the object's own `owner_id` and `organization_id`: only + // on an object whose platform columns have storage behind them (a + // federated object's are the injection's alone, and `rulesFor` admitted + // none — asked again here, at the read). + if (!carriesQueueColumn(this.host.getSchema(object))) return null; + const compiled = entry.load ? compileCelToFilter(entry.load, { variables: {} }) : null; + if (!compiled || !compiled.ok) { + // The schema refuses `least_loaded` without `load`, and the authoring + // gate refuses a `load` the compiler cannot lower — so this is a body + // that skipped both. Never "count everything". + this.warnOnce(`load::${rule.name}::${entry.name}`, `[assignment] rule '${rule.name}' entry '${entry.name}' has no \`load\` the platform can count by — the row parks in the queue`, { + rule: rule.name, entry: entry.name, ...(compiled && !compiled.ok ? { reason: compiled.reason, detail: compiled.detail } : {}), + }); + return null; + } + const key = `${rule.name}::${entry.name}::${organizationId}`; + let counts = loads.get(key); + if (!counts) { + counts = new Map(); + for (const member of members) { + try { + counts.set(member, await this.host.count(object, { $and: [{ [OWNER_FIELD]: member, organization_id: organizationId }, compiled.filter] }, ctx)); + } catch (err: any) { + throw new AssignmentReadFailure(err?.message ?? 'count failed'); + } + } + loads.set(key, counts); + } + let best: string | null = null; + let bestCount = Infinity; + for (const member of members) { + const n = counts.get(member) ?? 0; + if (n < bestCount) { + best = member; + bestCount = n; + } + } + if (best) counts.set(best, bestCount + 1); + return best; + } + + private async roundRobin( + rule: ActiveRule, + entry: RuleEntry, + members: readonly string[], + organizationId: string, + ctx: Record, + ): Promise { + const where = { rule: rule.name, entry: entry.name, organization_id: organizationId }; + for (let attempt = 0; attempt < CURSOR_RETRIES; attempt++) { + let cursor: Record | undefined; + try { + cursor = (await this.host.find(ASSIGNMENT_CURSOR_OBJECT, where, ctx, 1))[0]; + } catch (err: any) { + throw new AssignmentReadFailure(err?.message ?? 'cursor read failed'); + } + const next = nextMember(members, present(cursor?.last_user_id)); + try { + if (!cursor) { + await this.host.insert(ASSIGNMENT_CURSOR_OBJECT, { ...where, last_user_id: next, version: 1 }, ctx); + } else { + const version = Number(cursor.version ?? 0); + await this.host.updateExpecting( + ASSIGNMENT_CURSOR_OBJECT, cursor.id, { last_user_id: next, version: version + 1 }, { version }, ctx, + ); + } + return next; + } catch (err: any) { + // A lost race — another insert advanced or created the cursor first. + if (err?.code === 'CONCURRENT_UPDATE' || err?.code === 'DUPLICATE_RECORD') continue; + throw new AssignmentReadFailure(err?.message ?? 'cursor write failed'); + } + } + this.host.logger?.warn?.('[assignment] the round-robin cursor kept moving under concurrent inserts — the row parks in the queue', { rule: rule.name, entry: entry.name }); + return null; + } + + /** The active subset of `rules`: an `assignment_rule` row with `active: false` in the activation ledger switches a rule off. */ + private async switchedOn(rules: readonly ActiveRule[]): Promise { + if (!this.host.hasObject(METADATA_ACTIVATION_TABLE)) return [...rules]; + const names = rules.map((r) => r.name); + let rows: Record[] = []; + try { + rows = await this.host.find( + METADATA_ACTIVATION_TABLE, + { metadata_type: ASSIGNMENT_RULE_TYPE, name: names.length === 1 ? names[0] : { $in: names } }, + { isSystem: true, positions: [], permissions: [] }, + names.length, + ); + } catch (err: any) { + this.warnOnce('ledger', `[assignment] ${METADATA_ACTIVATION_TABLE} read failed — assignment rules treated as switched ON for this insert`, { error: err?.message }); + return [...rules]; + } + const off = new Set(rows.filter((r) => r.active === false || r.active === 0).map((r) => String(r.name))); + return rules.filter((r) => !off.has(r.name)); + } + + private warnOnce(key: string, message: string, meta: Record): void { + if (this.warned.has(key)) return; + this.warned.add(key); + this.host.logger?.warn?.(message, meta); + } +} + +/** + * The member after `last` in `members` (ordered by user id). A member set that + * changed since the last assignment is handled by position: the first member + * whose id sorts after `last`, wrapping to the first. + */ +export function nextMember(members: readonly string[], last: string | null): string { + if (last === null) return members[0]!; + return members.find((m) => m > last) ?? members[0]!; +} + +/** + * [ADR-0142 D1] The `kernel:ready` boot refusal: a stack that declares + * `queues` or `assignmentRules` with no ownership-queue service registered + * would have nothing behind its declarations. The probe tests the + * `ownership-queue` service name — never the job queue's `queue`. + */ +export function describeMissingOwnershipQueueService( + declared: { queues: readonly unknown[]; assignmentRules: readonly unknown[] }, + serviceRegistered: boolean, +): Error | null { + if (serviceRegistered) return null; + const keys: string[] = []; + if (declared.queues.length > 0) keys.push(`queues (${declared.queues.length})`); + if (declared.assignmentRules.length > 0) keys.push(`assignmentRules (${declared.assignmentRules.length})`); + if (keys.length === 0) return null; + return new Error( + `[ObjectQLPlugin] the stack declares ${keys.join(' and ')}, but no 'ownership-queue' service is registered, ` + + 'so no queue would resolve and no assignment rule would route a row. Add @objectstack/plugin-sharing to ' + + "the composition (it registers 'ownership-queue'); the job-queue service named 'queue' is a different service " + + 'and does not serve ownership queues.', + ); +} diff --git a/packages/objectql/src/concurrent-update-error.ts b/packages/objectql/src/concurrent-update-error.ts new file mode 100644 index 00000000000..ba83f022293 --- /dev/null +++ b/packages/objectql/src/concurrent-update-error.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0142 D4] The refusal a by-id write under the engine-internal + * precondition raises when the precondition matched no row: another writer + * moved the row first. + * + * `CONCURRENT_UPDATE`, 409 — the data path's existing code for "another writer + * got there first" (the optimistic-lock refusal of the data door), so one + * condition carries one 409 (ADR-0112 D1). `@objectstack/types`' error + * classification answers it by `code` / `name`, as it answers the data door's. + * Two users today: the claim of an unowned record (`{ owner_id: null }`, set by + * the ownership gate) — whose loser reads "the record was taken" — and the + * assignment cursor's advance (`{ version }`), which re-reads and retries. + * + * ⛔ Not exported from the package entry: an in-process caller branches on + * `code`, which survives the dual build where `instanceof` does not. + */ +export const CONCURRENT_UPDATE_CODE = 'CONCURRENT_UPDATE' as const; + +export class ConcurrentUpdateError extends Error { + readonly code = CONCURRENT_UPDATE_CODE; + readonly status = 409; + /** The same number under ADR-0112 D5's spelling. */ + readonly httpStatus = 409; + + constructor( + /** The object the refused write targeted. */ + public readonly object: string, + /** The record the write addressed. */ + public readonly recordId: string, + ) { + super('The record was taken: another user changed it first. Reload it to see who has it now.'); + this.name = 'ConcurrentUpdateError'; + } +} diff --git a/packages/objectql/src/engine-security-catalog-package-door.test.ts b/packages/objectql/src/engine-security-catalog-package-door.test.ts index 7baa851c5dd..89c93c2f799 100644 --- a/packages/objectql/src/engine-security-catalog-package-door.test.ts +++ b/packages/objectql/src/engine-security-catalog-package-door.test.ts @@ -78,6 +78,12 @@ const BODIES: Record> = { condition: "record.status == 'open'", accessLevel: 'read', sharedWith: { type: 'position', value: 'probe_position' }, }, + // [ADR-0142 D1/D5] The ownership queue and the assignment rule routing into it. + queue: { name: NAME, label: 'Probe', members: [{ type: 'position', value: 'probe_position' }] }, + assignment_rule: { + name: NAME, label: 'Probe', object: 'probe_object', + entries: [{ name: 'all', assignTo: { type: 'queue', value: 'probe_queue' } }], + }, }; /** The overlayable control: `allowEnvironmentOverlay: true`, declared through a stack collection. */ diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 257dbfc44ee..8be75217eba 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -223,6 +223,8 @@ import { applyArtifactForwardConversions, resolveDeclaredRange } from '@objectst // #5841 retired. import { isUniqueViolationError, uniqueViolationColumn } from '@objectstack/types'; import { DuplicateRecordError, envelopeUniqueViolation } from './duplicate-record-error.js'; +import { ConcurrentUpdateError } from './concurrent-update-error.js'; +import { AssignmentRuleEvaluator, ASSIGNMENT_RULE_TYPE, OWNERSHIP_QUEUE_SOURCE_SLOT, type AssignmentQueueService } from './assignment-rules.js'; // [#8682] The write-path loggers' redaction — bound values never reach the log. // [#21274] …and its boundary face — nor the error that leaves the engine. // [#21385] It lives in `@objectstack/types` now, so `driver-sql`'s own log lines @@ -3898,6 +3900,24 @@ export interface OperationContext { * write is one it does not gate. The engine never invents one. */ postHookWriteImageCheck?: PostHookWriteImageCheck; + + /** + * [ADR-0142 D4] The by-id write PRECONDITION: an equality predicate over the + * stored row (`{ : }`) the write is conditional on. Set by an + * enforcement layer's middleware — the ownership gate sets `{ owner_id: null }` + * on a claim, so two members claiming at once produce exactly one owner — and + * by the engine's own assignment cursor (`{ version }`). Engine-internal: + * reachable from no data door, and read only on a by-id `update`. + * + * The engine lowers the write to the driver's conditional + * `updateMany(object, { where: { id, ...expect } }, data)`; one affected row + * is re-read by id and the by-id branch continues unchanged (hooks, formulas, + * the event, the roll-up, the response); zero rows answer 409 + * `CONCURRENT_UPDATE` ("the record was taken"). A driver with no `updateMany` + * degrades to the pre-image check its setter already made, and the write + * goes through unconditionally, logged once per process at `warn`. + */ + writePrecondition?: Readonly>; } /** @@ -4203,6 +4223,11 @@ const METADATA_ARRAY_KEYS = [ // `security`-domain type (`engine-security-catalog-package-door.test.ts`). // `roles` stays in the list as the inert retired spelling it already was. 'roles', 'positions', 'permissions', 'capabilities', 'profiles', 'sharingRules', 'policies', + // [ADR-0142 D1/D5] Ownership queues and the assignment rules that route + // inserted rows into them — both read from the registry on every use (the + // queue service, the assignment seam), so a declaration must reach it with + // its package stamped, as every collection on this list does. + 'queues', 'assignmentRules', // AI Protocol 'agents', 'tools', 'skills', 'ragPipelines', // API Protocol @@ -4746,6 +4771,14 @@ export type UnservableControlledByParentMaster = { */ export const SECURITY_SERVICE_SOURCE_SLOT: unique symbol = Symbol.for('objectstack.objectql.securityServiceSource'); +/** + * [ADR-0142 D4] The engine's own channel for {@link OperationContext.writePrecondition} + * on an `update` it issues itself (the assignment cursor). A module-private + * symbol, so no options object a door builds — and nothing outside this + * module — can carry it. + */ +const WRITE_PRECONDITION_OPTION: unique symbol = Symbol('objectql.writePrecondition'); + export class ObjectQL implements IObjectQLEngine { /** * Ambient transaction store (ADR-0034). While a `transaction()` callback @@ -15890,6 +15923,77 @@ export class ObjectQL implements IObjectQLEngine { * returned row would make every update of a row carrying a retired column * record that column as changed, its stored value included. */ + /** + * [ADR-0142 D4] The by-id write under {@link OperationContext.writePrecondition}: + * the driver's conditional `updateMany` over `{ id, ...expect }`, then a + * readback by id. Zero rows matched is {@link ConcurrentUpdateError}. A driver + * with no `updateMany` writes unconditionally, logged once per process. + */ + private async updateByIdExpecting( + driver: IDataDriver, + object: string, + id: unknown, + data: Record, + expect: Readonly>, + options: unknown, + ): Promise { + if (typeof driver.updateMany !== 'function') { + if (!this.preconditionDegradedWarned) { + this.preconditionDegradedWarned = true; + this.logger.warn( + `[objectql] the driver for '${object}' has no conditional update (updateMany), so a by-id write ` + + 'precondition degrades to the pre-image check and the write goes through unconditionally: two ' + + 'concurrent claims of one record may both land, and the round-robin cursor may repeat a member.', + { object }, + ); + } + return driver.update(object, id as string, data, options as any); + } + const affected = await driver.updateMany(object, { object, where: { ...expect, id } } as QueryAST, data, options as any); + if (!(Number(affected) > 0)) throw new ConcurrentUpdateError(object, String(id)); + return driver.findOne(object, { object, where: { id }, limit: 1 } as QueryAST, options as any); + } + + /** [ADR-0142 D4] Set once the precondition's no-`updateMany` degradation has been logged. */ + private preconditionDegradedWarned = false; + + /** [ADR-0142 D5] The assignment seam, built on first use. */ + private assignmentEvaluator?: AssignmentRuleEvaluator; + + /** The registered ownership-queue service, through the plugin's slot — `null` on every other state. */ + private ownershipQueueService(): AssignmentQueueService | null { + const source = (this as unknown as Record)[OWNERSHIP_QUEUE_SOURCE_SLOT]; + if (typeof source !== 'function') return null; + try { + const svc = (source as () => unknown)(); + return svc && typeof (svc as AssignmentQueueService).resolveQueue === 'function' + && typeof (svc as AssignmentQueueService).expandMembers === 'function' + ? (svc as AssignmentQueueService) + : null; + } catch { + return null; + } + } + + private assignment(): AssignmentRuleEvaluator { + if (!this.assignmentEvaluator) { + this.assignmentEvaluator = new AssignmentRuleEvaluator({ + listRules: () => this._registry.listItems(ASSIGNMENT_RULE_TYPE) as unknown[], + getSchema: (object) => this._registry.getObject(object), + hasObject: (object) => Boolean(this._registry.getObject(object)), + queues: () => this.ownershipQueueService(), + find: async (object, where, context, limit) => + (await this.find(object, { where, ...(limit ? { limit } : {}) } as EngineQueryOptions, { context: context as ExecutionContext })) as Record[], + count: (object, where, context) => this.count(object, { where } as EngineCountOptions, { context: context as ExecutionContext }), + insert: (object, data, context) => this.insert(object, data, { context: context as ExecutionContext }), + updateExpecting: (object, id, data, expect, context) => + this.update(object, { ...data, id }, { context: context as ExecutionContext, [WRITE_PRECONDITION_OPTION]: expect } as any), + logger: this.logger, + }); + } + return this.assignmentEvaluator; + } + private async readUpdatePriorRow( object: string, id: unknown, @@ -16616,6 +16720,10 @@ export class ObjectQL implements IObjectQLEngine { // the platform's. // - `normalizeMultiValueFields` coerces a declared multi-value field to // its stored array shape. + // - [ADR-0142 D5] the assignment seam writes `owner_id` and `queue` onto a + // row whose owner and queue are both still EMPTY — routing chosen by a + // declared rule, never a value the caller supplied (a supplied one + // means the row is not evaluated at all). // // So the invariant this seam buys is stated to its real edge: a stored row // satisfies the insert `check` on every field the CALLER can steer. ⛔ It @@ -16667,6 +16775,32 @@ export class ObjectQL implements IObjectQLEngine { opCtx.context, rowHookContexts.map((rowCtx) => rowCtx.input.data as Record), ); + + // [ADR-0142 D5] The assignment seam — after every `beforeInsert` hook + // and the organization the row lands in, before the write. It writes + // `owner_id` / `queue` onto a row whose owner and queue are both still + // empty, as the platform, and never refuses: see `assignment-rules.ts`. + // 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). + { + const evaluator = this.assignment(); + if (evaluator.rulesFor(object).length > 0) { + const activeOrg = opCtx.context?.tenantId ? String(opCtx.context.tenantId) : null; + const candidates = []; + for (let i = 0; i < rows.length; i++) { + if (rowErrors[i] !== undefined) continue; + const row = rows[i]; + if (!row || typeof row !== 'object') continue; + const own = (row as Record).organization_id; + const organizationId = own != null && own !== '' ? String(own) : activeOrg ?? derivedOrganizationId ?? null; + candidates.push({ row: row as Record, organizationId }); + } + await evaluator.apply(object, candidates); + } + } const optionsBase = rowHookContexts[0]?.input.options as any; const driverOptions = this.buildDriverOptions( object, @@ -17327,6 +17461,12 @@ export class ObjectQL implements IObjectQLEngine { options, context: options?.context, }; + // [ADR-0142 D4] The engine's own precondition (the assignment cursor). A + // middleware sets the member directly; nothing else can reach it. + const internalPrecondition = (options as Record | undefined)?.[WRITE_PRECONDITION_OPTION]; + if (internalPrecondition && typeof internalPrecondition === 'object') { + opCtx.writePrecondition = internalPrecondition as Record; + } // [#2982] A no-single-id update routes to `driver.updateMany` below with // an AST that used to be REBUILT from `options.where` AFTER the @@ -18609,7 +18749,13 @@ export class ObjectQL implements IObjectQLEngine { // there would attribute a violation raised by a nested driver // call inside a hook to THIS object. try { - result = await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record, hookContext.input.options as any); + // [ADR-0142 D4] A write under a precondition is the driver's + // conditional update plus a readback — see + // `OperationContext.writePrecondition`. + const precondition = opCtx.writePrecondition; + result = precondition && Object.keys(precondition).length > 0 + ? await this.updateByIdExpecting(driver, object, hookContext.input.id, hookContext.input.data as Record, precondition, hookContext.input.options) + : await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record, hookContext.input.options as any); } catch (driverError) { throw envelopeUniqueViolation(driverError, object, this.getSchema(object)); } diff --git a/packages/objectql/src/federated-injected-column-readers.test.ts b/packages/objectql/src/federated-injected-column-readers.test.ts index 7bf9ab42181..95fe4e2eaa5 100644 --- a/packages/objectql/src/federated-injected-column-readers.test.ts +++ b/packages/objectql/src/federated-injected-column-readers.test.ts @@ -355,6 +355,37 @@ const READERS: Record = { disposition: 'not-a-read', why: 'the name the injection writes the ADR-0117 D1 anchor under', }, + 'registry.ts# :: queue': { + disposition: 'not-a-read', + why: 'the name the injection writes the ADR-0142 D3 ownership-queue column under', + }, + 'assignment-rules.ts# :: owner_id': { disposition: 'not-a-read', why: VOCABULARY }, + 'assignment-rules.ts# :: queue': { disposition: 'not-a-read', why: VOCABULARY }, + 'assignment-rules.ts#carriesQueueColumn :: resolveInjectedColumnProvenance()': { + disposition: 'excludes', + why: 'an object whose queue column is unprovisioned (federated) evaluates no assignment rule', + }, + 'assignment-rules.ts#leastLoaded :: organization_id': { + disposition: 'excludes', + via: 'carriesQueueColumn', + why: "counts a member's load only on an object whose platform columns have storage behind them", + }, + 'assignment-rules.ts#roundRobin :: organization_id': { + disposition: 'not-a-read', + why: 'keys the sys_assignment_cursor row, a platform table the engine itself reads and writes', + }, + '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', + }, + 'assignment-rules.ts#ruleOf :: queue': { + disposition: 'not-a-read', + why: "compares an assignment target's kind, not the column; it reads no row", + }, + 'plugin.ts#assertOwnershipQueueServicePresent :: queue': { + disposition: 'not-a-read', + why: 'names the metadata type the boot refusal lists; it reads no row', + }, 'registry.ts#declaresTenantIndex :: organization_id': { disposition: 'not-a-read', why: 'reads an index declaration, not a row', diff --git a/packages/objectql/src/ownership-queue.test.ts b/packages/objectql/src/ownership-queue.test.ts new file mode 100644 index 00000000000..b5e5e3a1403 --- /dev/null +++ b/packages/objectql/src/ownership-queue.test.ts @@ -0,0 +1,380 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0142] The engine's half of the ownership queue: the injected `queue` + * column and its registration refusal (D3), the by-id write precondition + * (D4), the assignment seam with its two strategies (D5), the two stack keys + * and the boot refusal (D1). + * + * A stand-in driver rather than `@objectstack/driver-memory`, for the reason + * `engine-aggregate-rows-ast.test.ts` records (the driver-memory census). + */ + +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'; + +type Row = Record; + +function matchesWhere(row: Row, where: any): boolean { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const a = row[k] === undefined ? null : row[k]; + if (v && typeof v === 'object' && Array.isArray((v as any).$in)) { + if (!(v as any).$in.includes(a)) return false; + continue; + } + if (v && typeof v === 'object' && '$ne' in (v as any)) { + if (a === (v as any).$ne) return false; + continue; + } + const b = v === undefined ? null : v; + if (a !== b) return false; + } + return true; +} + +function makeDriver(opts: { withUpdateMany?: boolean } = {}) { + const stores = new Map>(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) stores.set(o, (s = new Map())); + return s; + }; + let nextId = 0; + const calls: string[] = []; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return { ...r }; + return null; + }, + async create(object: string, data: Row) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return { ...row }; + }, + async update(object: string, id: string, data: Row) { + calls.push(`update:${object}`); + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return { ...updated }; + }, + async upsert(object: string, data: Row) { return this.create(object, data); }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, { where: ast?.where })).length; }, + async bulkCreate(object: string, rows: Row[]) { return Promise.all(rows.map((r) => this.create(object, r))); }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + if (opts.withUpdateMany !== false) { + driver.updateMany = async (object: string, ast: any, data: Row) => { + calls.push(`updateMany:${object}`); + let n = 0; + for (const [id, r] of storeFor(object)) { + if (!matchesWhere(r, ast?.where)) continue; + storeFor(object).set(id, { ...r, ...data, id }); + n++; + } + return n; + }; + } + return { driver, stores, storeFor, calls }; +} + +const TICKET = { + name: 'ticket', + label: 'Ticket', + fields: { + subject: { type: 'text', label: 'Subject' }, + status: { type: 'text', label: 'Status' }, + priority: { type: 'text', label: 'Priority' }, + }, +}; + +/** The cursor table as plugin-sharing ships it (only the keys the seam reads). */ +const CURSOR = { + name: 'sys_assignment_cursor', + label: 'Assignment Cursor', + managedBy: 'engine-owned', + fields: { + rule: { type: 'text', label: 'Rule' }, + entry: { type: 'text', label: 'Entry' }, + last_user_id: { type: 'text', label: 'Last' }, + version: { type: 'number', label: 'Version' }, + }, +}; + +const SYS = { isSystem: true, tenantId: 'org_a', positions: [], permissions: [] } as any; + +// ─── D3: the column and its refusal ──────────────────────────────────────── + +describe('[ADR-0142 D3] the injected `queue` column', () => { + it('rides with the owner anchor: injected on the user tier, withheld on the others', () => { + const fieldsOf = (schema: any) => Object.keys(applySystemFields(schema, { multiTenant: true }).fields ?? {}); + expect(fieldsOf({ name: 'ticket', fields: {} })).toEqual(expect.arrayContaining(['owner_id', 'queue'])); + expect(fieldsOf({ name: 'ticket', ownership: 'user', fields: {} })).toContain('queue'); + for (const ownership of ['business_unit', 'org', 'none']) { + expect(fieldsOf({ name: 'catalog', ownership, fields: {} })).not.toContain('queue'); + } + expect(fieldsOf({ name: 'sys_widget', fields: {} })).not.toContain('queue'); + const injected = applySystemFields({ name: 'ticket', fields: {} } as any, { multiTenant: true }).fields as any; + expect(injected.queue).toEqual(QUEUE_FIELD_DEF); + }); + + it('a declared `queue` on an eligible object is refused at registration — INVALID_METADATA, 422', () => { + const engine = new ObjectQL(); + let thrown: any; + try { + engine.registry.registerObject({ name: 'job', fields: { queue: { type: 'text', label: 'Print queue' } } } as any, 'pkg'); + } catch (err) { + thrown = err; + } + expect(thrown).toMatchObject({ code: 'INVALID_METADATA', status: 422, name: 'ObjectQueueColumnRefusedError', objectName: 'job', fieldName: 'queue' }); + expect(thrown.message).toMatch(/Rename the business field/); + expect(engine.registry.getObject('job')).toBeUndefined(); + }); + + it('the platform\'s own definition coming back passes, and so does `queue` on a tier that carries none', () => { + expect(objectQueueColumnRefusal({ name: 'ticket', fields: { queue: { ...QUEUE_FIELD_DEF } } })).toBeNull(); + expect(objectQueueColumnRefusal({ name: 'catalog', ownership: 'org', fields: { queue: { type: 'text' } } })).toBeNull(); + expect(objectQueueColumnRefusal({ name: 'sys_job_queue', fields: { queue: { type: 'text' } } })).toBeNull(); + }); +}); + +// ─── D4: the by-id write precondition ────────────────────────────────────── + +describe('[ADR-0142 D4] the by-id write precondition', () => { + async function boot(withUpdateMany = true) { + const engine = new ObjectQL(); + const d = makeDriver({ withUpdateMany }); + engine.registerDriver(d.driver, true); + await engine.init(); + engine.registry.registerObject(TICKET as any, 'pkg'); + // The ownership gate's stand-in: a claim sets `{ owner_id: null }`. + engine.registerMiddleware(async (opCtx: any, next: () => Promise) => { + if (opCtx.operation === 'update' && opCtx.context?.claim) opCtx.writePrecondition = { owner_id: null }; + await next(); + }); + d.storeFor('ticket').set('t1', { id: 't1', subject: 'Printer', owner_id: null, queue: 'support' }); + return { engine, ...d }; + } + + it('the first claim wins and is read back; the second answers 409 CONCURRENT_UPDATE', async () => { + const { engine, calls } = await boot(); + const won = await engine.update('ticket', { id: 't1', owner_id: 'u_a' }, { context: { ...SYS, claim: true } }); + expect(won).toMatchObject({ id: 't1', owner_id: 'u_a', queue: 'support' }); + expect(calls).toContain('updateMany:ticket'); + await expect(engine.update('ticket', { id: 't1', owner_id: 'u_b' }, { context: { ...SYS, claim: true } })).rejects.toMatchObject({ + code: 'CONCURRENT_UPDATE', + status: 409, + name: 'ConcurrentUpdateError', + message: expect.stringMatching(/The record was taken/), + }); + }); + + it('two claims at once produce exactly one owner', async () => { + const { engine, storeFor } = await boot(); + const results = await Promise.allSettled([ + engine.update('ticket', { id: 't1', owner_id: 'u_a' }, { context: { ...SYS, claim: true } }), + engine.update('ticket', { id: 't1', owner_id: 'u_b' }, { context: { ...SYS, claim: true } }), + ]); + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); + expect(results.filter((r) => r.status === 'rejected').map((r: any) => r.reason.code)).toEqual(['CONCURRENT_UPDATE']); + expect(['u_a', 'u_b']).toContain(storeFor('ticket').get('t1')!.owner_id); + }); + + it('a write without a precondition takes the ordinary by-id branch', async () => { + const { engine, calls } = await boot(); + await engine.update('ticket', { id: 't1', subject: 'Printer jam' }, { context: SYS }); + expect(calls).toEqual(['update:ticket']); + }); + + it('a driver with no updateMany degrades: the write goes through, warned once per process', async () => { + const { engine, storeFor } = await boot(false); + const warn = vi.spyOn((engine as any).logger, 'warn'); + await engine.update('ticket', { id: 't1', owner_id: 'u_a' }, { context: { ...SYS, claim: true } }); + await engine.update('ticket', { id: 't1', owner_id: 'u_b' }, { context: { ...SYS, claim: true } }); + expect(storeFor('ticket').get('t1')!.owner_id).toBe('u_b'); + expect(warn.mock.calls.filter((c) => /no conditional update/.test(String(c[0])))).toHaveLength(1); + }); +}); + +// ─── D5: the assignment seam ─────────────────────────────────────────────── + +describe('[ADR-0142 D5] the assignment seam', () => { + let members: Record; + let engine: ObjectQL; + let d: ReturnType; + let warn: ReturnType; + + async function boot(rules: unknown[], queues: unknown[] = [{ name: 'support', label: 'Support', members: [] }]) { + 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.registerApp({ id: 'com.acme.support', name: 'support', queues, assignmentRules: rules }); + (engine as any)[OWNERSHIP_QUEUE_SOURCE_SLOT] = () => ({ + resolveQueue: async (name: string, org: string | null) => + name in members && org === 'org_a' ? { 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 insert = (data: Row, context: any = SYS) => engine.insert('ticket', { organization_id: 'org_a', ...data }, { context }); + const rule = (entries: unknown[], extra: Row = {}) => ({ name: 'ticket_intake', label: 'Intake', object: 'ticket', active: true, entries, ...extra }); + + beforeEach(() => { + members = { support: ['u_c', 'u_a', 'u_b'] }; + }); + + it('the two stack keys reach the registry with their package', async () => { + await boot([rule([{ name: 'all', assignTo: { type: 'queue', value: 'support' } }])]); + expect(engine.registry.listItems('queue').map((q: any) => q.name)).toEqual(['support']); + expect((engine.registry.listItems('assignment_rule')[0] as any)._packageId).toBe('com.acme.support'); + }); + + it('no strategy parks the row in the queue, ownerless', async () => { + await boot([rule([{ name: 'all', assignTo: { type: 'queue', value: 'support' } }])]); + const row = await insert({ subject: 'A' }); + expect(row).toMatchObject({ queue: 'support' }); + expect(row.owner_id ?? null).toBeNull(); + }); + + it('round_robin walks the members in user-id order and wraps', async () => { + await boot([rule([{ name: 'all', assignTo: { type: 'queue', value: 'support' }, strategy: 'round_robin' }])]); + const owners = []; + for (const s of ['A', 'B', 'C', 'D']) owners.push((await insert({ subject: s })).owner_id); + expect(owners).toEqual(['u_a', 'u_b', 'u_c', 'u_a']); + const cursor = [...d.storeFor('sys_assignment_cursor').values()]; + expect(cursor).toHaveLength(1); + expect(cursor[0]).toMatchObject({ rule: 'ticket_intake', entry: 'all', organization_id: 'org_a', last_user_id: 'u_a', version: 4 }); + }); + + it('round_robin under concurrent inserts never hands two rows the same turn', async () => { + await boot([rule([{ name: 'all', assignTo: { type: 'queue', value: 'support' }, strategy: 'round_robin' }])]); + await insert({ subject: 'seed' }); // the cursor row exists + const rows = await Promise.all(['A', 'B', 'C'].map((s) => insert({ subject: s }))); + expect(rows.map((r: any) => r.owner_id).sort()).toEqual(['u_a', 'u_b', 'u_c']); + }); + + it('least_loaded counts each member\'s open rows, breaks a tie by user id, and adjusts within a batch', async () => { + await boot([rule([{ name: 'all', assignTo: { type: 'queue', value: 'support' }, strategy: 'least_loaded', load: 'record.status != "closed"' }])]); + const t = d.storeFor('ticket'); + t.set('o1', { id: 'o1', owner_id: 'u_a', status: 'open', organization_id: 'org_a' }); + t.set('o2', { id: 'o2', owner_id: 'u_a', status: 'open', organization_id: 'org_a' }); + t.set('o3', { id: 'o3', owner_id: 'u_b', status: 'open', organization_id: 'org_a' }); + t.set('o4', { id: 'o4', owner_id: 'u_c', status: 'closed', organization_id: 'org_a' }); + t.set('o5', { id: 'o5', owner_id: 'u_c', status: 'open', organization_id: 'org_b' }); + // u_c: 0 open here (closed and another org do not count); u_b: 1; u_a: 2. + const batch = await engine.insert('ticket', [ + { subject: 'A', status: 'open', organization_id: 'org_a' }, + { subject: 'B', status: 'open', organization_id: 'org_a' }, + { subject: 'C', status: 'open', organization_id: 'org_a' }, + ], { context: SYS }); + expect((batch as any[]).map((r) => r.owner_id)).toEqual(['u_c', 'u_b', 'u_c']); + }); + + it('an empty pool parks the row and warns once per rule', async () => { + members = { support: [] }; + await boot([rule([{ name: 'all', assignTo: { type: 'queue', value: 'support' }, strategy: 'round_robin' }])]); + const a = await insert({ subject: 'A' }); + await insert({ subject: 'B' }); + expect(a).toMatchObject({ queue: 'support' }); + expect(a.owner_id ?? null).toBeNull(); + expect(warn.mock.calls.filter((c) => /has no members/.test(String(c[0])))).toHaveLength(1); + }); + + it('a target that resolves nowhere in the row\'s organization leaves it ownerless, in no queue, and warns', async () => { + await boot([rule([{ name: 'all', assignTo: { type: 'queue', value: 'support' }, strategy: 'round_robin' }])]); + const row = await engine.insert('ticket', { subject: 'A', organization_id: 'org_b' }, { context: { ...SYS, tenantId: 'org_b' } }); + expect(row.owner_id ?? null).toBeNull(); + expect(row.queue ?? null).toBeNull(); + expect(warn.mock.calls.some((c) => /does not resolve in organization 'org_b'/.test(String(c[0])))).toBe(true); + }); + + it('a row that arrives with an owner or a queue is not evaluated', async () => { + await boot([rule([{ name: 'all', assignTo: { type: 'queue', value: 'support' }, strategy: 'round_robin' }])]); + expect(await insert({ subject: 'A', owner_id: 'u_z' })).toMatchObject({ owner_id: 'u_z' }); + expect((await insert({ subject: 'B', queue: 'billing' })).owner_id ?? null).toBeNull(); + expect(d.storeFor('sys_assignment_cursor').size).toBe(0); + }); + + it('the first entry whose criteria match decides; a user target names the owner', async () => { + await boot([rule([ + { name: 'vip', criteria: 'record.priority == "high"', assignTo: { type: 'user', value: 'u_lead' } }, + { name: 'rest', assignTo: { type: 'queue', value: 'support' } }, + ])]); + const vip = await insert({ subject: 'A', priority: 'high' }); + expect(vip.owner_id).toBe('u_lead'); + expect(vip.queue ?? null).toBeNull(); + expect(await insert({ subject: 'B', priority: 'low' })).toMatchObject({ queue: 'support' }); + }); + + it('a rule switched off — `active: false`, or in the activation ledger — routes nothing', async () => { + await boot([rule([{ name: 'all', assignTo: { type: 'queue', value: 'support' } }], { active: false })]); + expect((await insert({ subject: 'A' })).queue ?? null).toBeNull(); + + await boot([rule([{ name: 'all', assignTo: { type: 'queue', value: 'support' } }])]); + engine.registry.registerObject({ name: 'sys_metadata_activation', managedBy: 'platform', fields: { + metadata_type: { type: 'text', label: 'Type' }, name: { type: 'text', label: 'Name' }, active: { type: 'boolean', label: 'Active' }, + } } as any, 'platform'); + d.storeFor('sys_metadata_activation').set('a1', { id: 'a1', metadata_type: 'assignment_rule', name: 'ticket_intake', active: false }); + expect((await insert({ subject: 'B' })).queue ?? null).toBeNull(); + }); + + it('a failed read never refuses the insert', async () => { + await boot([rule([{ name: 'all', assignTo: { type: 'queue', value: 'support' }, strategy: 'round_robin' }])]); + (engine as any)[OWNERSHIP_QUEUE_SOURCE_SLOT] = () => ({ + resolveQueue: async () => ({ name: 'support' }), + expandMembers: async () => { throw new Error('down'); }, + }); + const row = await insert({ subject: 'A' }); + expect(row).toMatchObject({ queue: 'support' }); + expect(row.owner_id ?? null).toBeNull(); + }); +}); + +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'); + expect(nextMember(['a', 'b', 'c'], 'a')).toBe('b'); + expect(nextMember(['a', 'b', 'c'], 'c')).toBe('a'); + expect(nextMember(['a', 'c'], 'b')).toBe('c'); // b left the queue + }); +}); + +describe('[ADR-0142 D1] the boot refusal', () => { + it('names the declared keys and the ownership-queue service, never the job queue', () => { + const err = describeMissingOwnershipQueueService({ queues: [{}], assignmentRules: [{}, {}] }, false); + expect(err?.message).toMatch(/queues \(1\) and assignmentRules \(2\)/); + expect(err?.message).toMatch(/'ownership-queue'/); + expect(describeMissingOwnershipQueueService({ queues: [{}], assignmentRules: [] }, true)).toBeNull(); + expect(describeMissingOwnershipQueueService({ queues: [], assignmentRules: [] }, false)).toBeNull(); + }); +}); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 9c79e8cb773..f3b670f6c92 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -1,6 +1,8 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectQL, SECURITY_SERVICE_SOURCE_SLOT, type UnservableControlledByParentMaster } from './engine.js'; +import { OWNERSHIP_QUEUE_SOURCE_SLOT, describeMissingOwnershipQueueService } from './assignment-rules.js'; +import { OWNERSHIP_QUEUE_SERVICE } from '@objectstack/spec/contracts'; import { assembleMetadataProtocol } from '@objectstack/metadata-protocol'; import { isTenantAuthored, readLegacyOrganizationRows } from '@objectstack/metadata-core'; import type { MetadataAuthoringChannel } from '@objectstack/metadata-protocol'; @@ -552,6 +554,23 @@ export class ObjectQLPlugin implements Plugin { * not honour. Reads the service without warning — a junk key was warned * about at `start()` and reads as the same empty set. */ + /** [ADR-0142 D1] See the `kernel:ready` call site. */ + private assertOwnershipQueueServicePresent(ctx: PluginContext): void { + const registry = this.ql?.registry; + if (!registry) return; + let registered = false; + try { + registered = Boolean(ctx.getService(OWNERSHIP_QUEUE_SERVICE)); + } catch { + registered = false; + } + const refusal = describeMissingOwnershipQueueService( + { queues: registry.listItems('queue'), assignmentRules: registry.listItems('assignment_rule') }, + registered, + ); + if (refusal) throw refusal; + } + private assertDeploymentPlatformGlobalObjectsUnchanged(ctx: PluginContext): void { let service: unknown; try { @@ -691,6 +710,10 @@ export class ObjectQLPlugin implements Plugin { // `$in: []` (fail closed); a throw — the in-repo kernels' answer for a // name nothing registered — is one of them. (this.ql as unknown as Record)[SECURITY_SERVICE_SOURCE_SLOT] = () => ctx.getService('security'); + // [ADR-0142 D5] The assignment seam reads the ownership-queue service the + // same way: a source, resolved at use. ⛔ `ownership-queue`, never the job + // queue's `queue`. + (this.ql as unknown as Record)[OWNERSHIP_QUEUE_SOURCE_SLOT] = () => ctx.getService(OWNERSHIP_QUEUE_SERVICE); // Register as provider for Core Kernel Services ctx.registerService('objectql', this.ql); @@ -959,6 +982,12 @@ export class ObjectQLPlugin implements Plugin { // this plugin's start() was never read, so its objects were provisioned // WITH the column. Refused here, by name, rather than served. this.assertDeploymentPlatformGlobalObjectsUnchanged(ctx); + // [ADR-0142 D1] A stack that declares `queues` or `assignmentRules` + // needs the service that serves them. Judged here, where every plugin + // has registered its services and its packages, by the service NAME + // `ownership-queue` — a composition that loads only the job queue + // (`queue`) would otherwise pass with nothing behind its declarations. + this.assertOwnershipQueueServicePresent(ctx); // A field naming a picklist no package declares fails the boot, naming // the field and the package — never served as a select with nothing to // choose — and so does an extension of such a list, whose values would diff --git a/packages/objectql/src/protocol-meta-effective-schema.test.ts b/packages/objectql/src/protocol-meta-effective-schema.test.ts index c8c934b26e0..6105448a0a4 100644 --- a/packages/objectql/src/protocol-meta-effective-schema.test.ts +++ b/packages/objectql/src/protocol-meta-effective-schema.test.ts @@ -220,6 +220,7 @@ describe.each([true, false])('[#6562] /meta object read — effective schema (mu 'created_at', 'created_by', 'organization_id', 'owner_id', 'owning_business_unit_id', + 'queue', 'updated_at', 'updated_by', ]); }); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 7653523e9b8..ce4815c5501 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveInjectedSystemColumns, isInjectedColumnDefinition, isTenancyDisabled, checkManagedApiMethodAffordances, LEGACY_API_METHODS, AUDIT_PROVENANCE_FIELDS, materializeTreeSelfReferences } from '@objectstack/spec/data'; +import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveInjectedSystemColumns, resolveInjectedColumnProvenance as injectedColumnProvenance, isInjectedColumnDefinition, isTenancyDisabled, checkManagedApiMethodAffordances, LEGACY_API_METHODS, AUDIT_PROVENANCE_FIELDS, materializeTreeSelfReferences } from '@objectstack/spec/data'; // [#4513] The audit-family governance table, and [#6562] the injected-column // DEFINITION tables it governs — see the re-exports below for why both live in a // package `objectql` and `metadata-protocol` both depend on. @@ -11,6 +11,7 @@ import { TENANT_SCOPE_FIELD_DEF, OWNER_FIELD_DEF, OWNING_BUSINESS_UNIT_FIELD_DEF, + QUEUE_FIELD_DEF, // [#7774] The i18n-bundle identity table and its value reader, sunk into // metadata-core so this registry and the `/meta` list merge in // `@objectstack/metadata-protocol` answer "what is this type's identity?" @@ -764,7 +765,7 @@ export interface SchemaRegistryOptions { * two field sets, and nothing telling the caller which it had received. The read * exits and this injection now derive one answer from one table. */ -export { AUDIT_FIELD_DEFS, TENANT_SCOPE_FIELD_DEF, OWNER_FIELD_DEF, OWNING_BUSINESS_UNIT_FIELD_DEF }; +export { AUDIT_FIELD_DEFS, TENANT_SCOPE_FIELD_DEF, OWNER_FIELD_DEF, OWNING_BUSINESS_UNIT_FIELD_DEF, QUEUE_FIELD_DEF }; /** * [#7865] Injected-column PROVENANCE — the registry face of the 2026-08-12 @@ -815,6 +816,9 @@ export { AUDIT_FIELD_GOVERNANCE }; const OWNING_BUSINESS_UNIT_FIELD: typeof SystemFieldName.OWNING_BUSINESS_UNIT_ID = 'owning_business_unit_id'; +/** [ADR-0142 D3] The injected ownership-queue column, annotated the same way. */ +const QUEUE_FIELD: typeof SystemFieldName.QUEUE = 'queue'; + export function applySystemFields( schema: ServiceObject, opts: { @@ -939,6 +943,9 @@ export function applySystemFields( // reaches the same verdict instead of guessing (#5378). const wantOwner = plan.owner; const wantOwningBusinessUnit = plan.owningBusinessUnit; + // [ADR-0142 D3] The queue column rides with the owner anchor (the plan's + // `queue` is its `owner`): "sits in Q" is `queue == Q AND owner_id IS NULL`. + const wantQueue = plan.queue; // Nothing to INJECT — the two opt-out rows (`systemFields: false`, // `managedBy: 'better-auth'`) and objects whose every column is already @@ -952,7 +959,7 @@ export function applySystemFields( // `systemFields.tenant` only, so `systemFields: false` leaves the wall live). // Returning `schema` here was the last exit that decided the index by NOT // asking, which is how the withholding survived #8375's convergence. - if (!wantTenant && !wantAudit && !wantOwner && !wantOwningBusinessUnit) { + if (!wantTenant && !wantAudit && !wantOwner && !wantOwningBusinessUnit && !wantQueue) { return provisionTenantScopeIndex(schema, opts); } @@ -1068,6 +1075,22 @@ export function applySystemFields( additions[OWNING_BUSINESS_UNIT_FIELD] = { ...OWNING_BUSINESS_UNIT_FIELD_DEF }; } + // [ADR-0142 D3] The ownership-queue column: the queue machine name a record + // with no owner SITS IN, and an owned record's home queue. Text, not a lookup + // (a declared queue has no row to point at); `readonly: false` like + // `owner_id`, because its writers are adjudicated where the owner's are — the + // assignment seam stamps it as the platform, and a client's move is a + // transfer-class write at plugin-security's step 3.5. `hidden: true`: the + // Console's queue affordances read it by name, and a raw machine name on every + // layout is not a reading a person needs. Landed with its writers, never ahead + // of them (ADR-0078). A declared `queue` never reaches this line on an + // eligible object: `registerObject` refuses it ({@link objectQueueColumnRefusal}), + // so the `!schema.fields` guard below only ever admits the platform's own + // definition coming back through a re-registration. + if (wantQueue && !schema.fields?.[QUEUE_FIELD]) { + additions[QUEUE_FIELD] = { ...QUEUE_FIELD_DEF }; + } + // [#8375] The tenant INDEX is decided over the MERGED result, never over the // input — so this function and the read-exit seam ask the same question of the // same document shape. Both cases below route through it, including the one @@ -1711,6 +1734,63 @@ export function objectFieldTypeRefusal(schema: unknown): ObjectFieldTypeRefusal return violation ? new ObjectFieldTypeRefusedError(violation) : null; } +/** + * {@link ObjectQueueColumnRefusedError}'s code — `INVALID_METADATA`, as + * {@link ObjectFieldTypeRefusedError}'s is: a body this registry cannot + * register as written. ⛔ Not a new code; the condition is named by the + * error's `name`. + */ +export const OBJECT_QUEUE_COLUMN_REFUSED_CODE = 'INVALID_METADATA' as const; + +/** + * [ADR-0142 D3, merge question 15] An object the platform injects `queue` on + * (`ownership: 'user'`, the default tier) declared its own field named + * `queue`. + * + * `owner_id`'s "the author's declaration wins" rule is deliberately not copied + * here: a skipped anchor would leave every queue admission — the sitting-record + * access path, the claim, the move, the assignment seam — silently + * inapplicable on that object, because each of them reads the platform's + * column by name and would read the author's business field instead. So the + * declaration is refused with a prescription, at the door Studio and the + * metadata API reach at request time. A body carrying the platform's own + * definition (a served document coming back) is the platform's and passes. + * + * The ADR-0112 envelope (`code` + `status: 422`), never a bare throw. + */ +class ObjectQueueColumnRefusedError extends Error { + readonly code = OBJECT_QUEUE_COLUMN_REFUSED_CODE; + readonly status = 422; + /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */ + readonly httpStatus = 422; + readonly objectName: string; + readonly fieldName = QUEUE_FIELD; + + constructor(objectName: string) { + super( + `Object "${objectName}" declares a field named \`queue\`, which the platform reserves on an object ` + + 'whose records have a user owner (`ownership: \'user\'`, the default): it holds the ownership queue a ' + + 'record without an owner sits in, and the platform injects it. Rename the business field (for ' + + 'example `support_queue`), or, when the object\'s records have no user owner, declare ' + + '`ownership: \'business_unit\'`, `\'org\'` or `\'none\'`.', + ); + this.name = 'ObjectQueueColumnRefusedError'; + this.objectName = objectName; + } +} + +/** + * [ADR-0142 D3] The registration-door `queue` check: the refusal when the + * platform plans the `queue` column on this object and the body declares a + * `queue` field that is not the platform's own definition; `null` otherwise. + */ +export function objectQueueColumnRefusal(schema: unknown): Error | null { + if (!resolveInjectedSystemColumns(schema).queue) return null; + if (injectedColumnProvenance(schema, QUEUE_FIELD) !== 'author') return null; + const name = (schema as { name?: unknown })?.name; + return new ObjectQueueColumnRefusedError(typeof name === 'string' ? name : '(unnamed)'); +} + /** * Platform namespaces that multiple packages may legitimately share, so the * install-time namespace-uniqueness gate (ADR-0048 Phase 1) must never fire on @@ -3517,6 +3597,15 @@ export class SchemaRegistry { const fieldTypeRefusal = objectFieldTypeRefusal(schema); if (fieldTypeRefusal) throw fieldTypeRefusal; + // [ADR-0142 D3] Beside it, for its reason: an eligible object's own + // `queue` field is refused before the injection could be skipped for it — + // see {@link ObjectQueueColumnRefusedError}. A base layer only: an `extend` + // body is never planned on its own (#24314, below). + if (ownership !== 'extend') { + const queueRefusal = objectQueueColumnRefusal(schema); + if (queueRefusal) throw queueRefusal; + } + // [ADR-0095 D1 note; ruling E′ item 4] An `extend` contribution never sets // the object's default row scope — see // {@link ExtensionDefaultRowScopeRefusedError}. Here, beside the field-`type` diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index cc6d1246070..50e00457995 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -44,6 +44,15 @@ "AssignmentExpressionValue (type)", "AssignmentExpressionValueParsed (type)", "AssignmentExpressionValueSchema (const)", + "AssignmentRule (type)", + "AssignmentRuleEntry (type)", + "AssignmentRuleEntrySchema (const)", + "AssignmentRuleParsed (type)", + "AssignmentRuleSchema (const)", + "AssignmentStrategy (const)", + "AssignmentStrategy (type)", + "AssignmentTarget (type)", + "AssignmentTargetSchema (const)", "AssignmentValue (type)", "AssignmentValueParsed (type)", "AssignmentValueSchema (const)", @@ -297,6 +306,7 @@ "closedScopeTemplateRefusal (function)", "collectFlowGraphs (function)", "defineActionDescriptor (function)", + "defineAssignmentRule (function)", "defineFlow (function)", "defineWebhook (function)", "describeMissingScheduleOrganization (function)", diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index 7e5114fba47..76d847bfd9c 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -146,6 +146,7 @@ "IMetadataService (interface)", "INotificationService (interface)", "IObjectQLEngine (interface)", + "IOwnershipQueueService (interface)", "IPackageService (interface)", "IPluginLifecycleEvents (interface)", "IPluginValidator (interface)", @@ -223,6 +224,7 @@ "NotificationChannel (type)", "NotificationMessage (interface)", "NotificationResult (interface)", + "OWNERSHIP_QUEUE_SERVICE (const)", "ObjectDraft (interface)", "OwnershipFloorAlternate (interface)", "PRINCIPAL_SESSION_MAX_TTL_SECONDS (const)", @@ -243,7 +245,9 @@ "PubSubMessage (interface)", "PublishOptions (interface)", "QueueBackoffPolicy (interface)", + "QueueExpansionPurpose (type)", "QueueHandler (type)", + "QueueMemberSpec (interface)", "QueueMessage (interface)", "QueueMessageRecord (interface)", "QueuePublishOptions (interface)", @@ -261,6 +265,7 @@ "RenderTemplateResult (interface)", "ResolveDependenciesInput (interface)", "ResolveShareLinkResult (interface)", + "ResolvedQueue (interface)", "ResumeFailureReport (interface)", "ResumeSignal (interface)", "RlsMembershipContext (interface)", diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 9449990146a..e9afe38cddd 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -594,6 +594,7 @@ "QUERY_TRANSPORT_ALIAS_SLOTS (const)", "QUERY_TRANSPORT_DOLLAR_ALIASES (const)", "QUERY_TRANSPORT_DOLLAR_PARAMS (const)", + "QUEUE_FIELD_DEF (const)", "QueryAST (type)", "QueryAliasConflict (interface)", "QueryAliasSlot (interface)", diff --git a/packages/spec/api-surface/security.json b/packages/spec/api-surface/security.json index 5c71b3085b0..8429e096492 100644 --- a/packages/spec/api-surface/security.json +++ b/packages/spec/api-surface/security.json @@ -19,6 +19,8 @@ "CriteriaSharingRule (type)", "CriteriaSharingRuleParsed (type)", "CriteriaSharingRuleSchema (const)", + "DeclaredQueueMember (type)", + "DeclaredQueueMemberSchema (const)", "DefaultRowScope (type)", "DefaultRowScopeParsed (type)", "DefaultRowScopeSchema (const)", @@ -76,6 +78,11 @@ "PlatformCapability (interface)", "PlatformGlobalObjects (type)", "PlatformGlobalObjectsSchema (const)", + "Queue (type)", + "QueueMemberType (const)", + "QueueMemberType (type)", + "QueueParsed (type)", + "QueueSchema (const)", "RLS (const)", "RLSEvaluationResult (type)", "RLSEvaluationResultSchema (const)", @@ -102,6 +109,7 @@ "TenantLayer0VerdictSchema (const)", "defineCapability (function)", "definePermissionSet (function)", + "defineQueue (function)", "defineSharingRule (function)", "describeAnchorForbiddenBits (function)", "describeHighPrivilegeBits (function)", diff --git a/packages/spec/declaration-map/automation.json b/packages/spec/declaration-map/automation.json index c88ced4b5c9..d586e36fa3a 100644 --- a/packages/spec/declaration-map/automation.json +++ b/packages/spec/declaration-map/automation.json @@ -22,6 +22,13 @@ "AssignmentConfigSchema": "automation/AssignmentConfig", "AssignmentExpressionValue": "automation/AssignmentExpressionValue", "AssignmentExpressionValueSchema": "automation/AssignmentExpressionValue", + "AssignmentRule": "automation/AssignmentRule", + "AssignmentRuleEntry": "automation/AssignmentRuleEntry", + "AssignmentRuleEntrySchema": "automation/AssignmentRuleEntry", + "AssignmentRuleSchema": "automation/AssignmentRule", + "AssignmentStrategy": "automation/AssignmentStrategy", + "AssignmentTarget": "automation/AssignmentTarget", + "AssignmentTargetSchema": "automation/AssignmentTarget", "AssignmentValue": "automation/AssignmentValue", "AssignmentValueSchema": "automation/AssignmentValue", "BpmnDiagnostic": "automation/BpmnDiagnostic", diff --git a/packages/spec/declaration-map/security.json b/packages/spec/declaration-map/security.json index 549eced02b2..26b088e2ae5 100644 --- a/packages/spec/declaration-map/security.json +++ b/packages/spec/declaration-map/security.json @@ -14,6 +14,8 @@ "CapabilityDeclarationSchema": "security/CapabilityDeclaration", "CriteriaSharingRule": "security/CriteriaSharingRule", "CriteriaSharingRuleSchema": "security/CriteriaSharingRule", + "DeclaredQueueMember": "security/DeclaredQueueMember", + "DeclaredQueueMemberSchema": "security/DeclaredQueueMember", "DefaultRowScope": "security/DefaultRowScope", "DefaultRowScopeSchema": "security/DefaultRowScope", "EffectiveObjectPermission": "security/EffectiveObjectPermission", @@ -49,6 +51,9 @@ "PermissionSetSchema": "security/PermissionSet", "PlatformGlobalObjects": "security/PlatformGlobalObjects", "PlatformGlobalObjectsSchema": "security/PlatformGlobalObjects", + "Queue": "security/Queue", + "QueueMemberType": "security/QueueMemberType", + "QueueSchema": "security/Queue", "RLSEvaluationResult": "security/RLSEvaluationResult", "RLSEvaluationResultSchema": "security/RLSEvaluationResult", "RLSOperation": "security/RLSOperation", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 87c2d1fc5d6..45bef6a8854 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -42,6 +42,14 @@ "AssignmentExpressionValue": "src/automation/builtin-node-config.zod.ts#AssignmentExpressionValue (type)", "AssignmentExpressionValueParsed": "src/automation/builtin-node-config.zod.ts#AssignmentExpressionValueParsed (type)", "AssignmentExpressionValueSchema": "src/automation/builtin-node-config.zod.ts#AssignmentExpressionValueSchema (const)", + "AssignmentRule": "src/automation/assignment-rule.zod.ts#AssignmentRule (type)", + "AssignmentRuleEntry": "src/automation/assignment-rule.zod.ts#AssignmentRuleEntry (type)", + "AssignmentRuleEntrySchema": "src/automation/assignment-rule.zod.ts#AssignmentRuleEntrySchema (const)", + "AssignmentRuleParsed": "src/automation/assignment-rule.zod.ts#AssignmentRuleParsed (type)", + "AssignmentRuleSchema": "src/automation/assignment-rule.zod.ts#AssignmentRuleSchema (const)", + "AssignmentStrategy": "src/automation/assignment-rule.zod.ts#AssignmentStrategy (type)", + "AssignmentTarget": "src/automation/assignment-rule.zod.ts#AssignmentTarget (type)", + "AssignmentTargetSchema": "src/automation/assignment-rule.zod.ts#AssignmentTargetSchema (const)", "AssignmentValue": "src/automation/builtin-node-config.zod.ts#AssignmentValue (type)", "AssignmentValueParsed": "src/automation/builtin-node-config.zod.ts#AssignmentValueParsed (type)", "AssignmentValueSchema": "src/automation/builtin-node-config.zod.ts#AssignmentValueSchema (const)", @@ -291,6 +299,7 @@ "closedScopeTemplateRefusal": "src/automation/flow-text-slot-template.ts#closedScopeTemplateRefusal (function)", "collectFlowGraphs": "src/automation/control-flow.zod.ts#collectFlowGraphs (function)", "defineActionDescriptor": "src/automation/node-executor.zod.ts#defineActionDescriptor (function)", + "defineAssignmentRule": "src/automation/assignment-rule.zod.ts#defineAssignmentRule (function)", "defineFlow": "src/automation/flow.zod.ts#defineFlow (function)", "defineWebhook": "src/automation/webhook.zod.ts#defineWebhook (function)", "describeMissingScheduleOrganization": "src/automation/schedule-organization.zod.ts#describeMissingScheduleOrganization (function)", diff --git a/packages/spec/export-origins/contracts.json b/packages/spec/export-origins/contracts.json index bd493c6eda4..c3fe1eb25c2 100644 --- a/packages/spec/export-origins/contracts.json +++ b/packages/spec/export-origins/contracts.json @@ -146,6 +146,7 @@ "IMetadataService": "src/contracts/metadata-service.ts#IMetadataService (interface)", "INotificationService": "src/contracts/notification-service.ts#INotificationService (interface)", "IObjectQLEngine": "src/contracts/objectql-engine.ts#IObjectQLEngine (interface)", + "IOwnershipQueueService": "src/contracts/ownership-queue-service.ts#IOwnershipQueueService (interface)", "IPackageService": "src/contracts/package-service.ts#IPackageService (interface)", "IPluginLifecycleEvents": "src/contracts/plugin-lifecycle-events.ts#IPluginLifecycleEvents (interface)", "IPluginValidator": "src/contracts/plugin-validator.ts#IPluginValidator (interface)", @@ -223,6 +224,7 @@ "NotificationChannel": "src/system/notification.zod.ts#NotificationChannel (type)", "NotificationMessage": "src/contracts/notification-service.ts#NotificationMessage (interface)", "NotificationResult": "src/contracts/notification-service.ts#NotificationResult (interface)", + "OWNERSHIP_QUEUE_SERVICE": "src/contracts/ownership-queue-service.ts#OWNERSHIP_QUEUE_SERVICE (const)", "ObjectDraft": "src/contracts/external-datasource-service.ts#ObjectDraft (interface)", "OwnershipFloorAlternate": "src/contracts/security-service.ts#OwnershipFloorAlternate (interface)", "PRINCIPAL_SESSION_MAX_TTL_SECONDS": "src/contracts/auth-service.ts#PRINCIPAL_SESSION_MAX_TTL_SECONDS (const)", @@ -243,7 +245,9 @@ "PubSubMessage": "src/contracts/cluster-service.ts#PubSubMessage (interface)", "PublishOptions": "src/contracts/cluster-service.ts#PublishOptions (interface)", "QueueBackoffPolicy": "src/contracts/queue-service.ts#QueueBackoffPolicy (interface)", + "QueueExpansionPurpose": "src/contracts/ownership-queue-service.ts#QueueExpansionPurpose (type)", "QueueHandler": "src/contracts/queue-service.ts#QueueHandler (type)", + "QueueMemberSpec": "src/contracts/ownership-queue-service.ts#QueueMemberSpec (interface)", "QueueMessage": "src/contracts/queue-service.ts#QueueMessage (interface)", "QueueMessageRecord": "src/contracts/queue-service.ts#QueueMessageRecord (interface)", "QueuePublishOptions": "src/contracts/queue-service.ts#QueuePublishOptions (interface)", @@ -261,6 +265,7 @@ "RenderTemplateResult": "src/contracts/email-service.ts#RenderTemplateResult (interface)", "ResolveDependenciesInput": "src/contracts/package-service.ts#ResolveDependenciesInput (interface)", "ResolveShareLinkResult": "src/contracts/share-link-service.ts#ResolveShareLinkResult (interface)", + "ResolvedQueue": "src/contracts/ownership-queue-service.ts#ResolvedQueue (interface)", "ResumeFailureReport": "src/contracts/approval-service.ts#ResumeFailureReport (interface)", "ResumeSignal": "src/contracts/automation-service.ts#ResumeSignal (interface)", "RlsMembershipContext": "src/contracts/rls-membership-resolver.ts#RlsMembershipContext (interface)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 457d665180b..95c878b409e 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -583,6 +583,7 @@ "QUERY_TRANSPORT_ALIAS_SLOTS": "src/data/data-engine.zod.ts#QUERY_TRANSPORT_ALIAS_SLOTS (const)", "QUERY_TRANSPORT_DOLLAR_ALIASES": "src/data/data-engine.zod.ts#QUERY_TRANSPORT_DOLLAR_ALIASES (const)", "QUERY_TRANSPORT_DOLLAR_PARAMS": "src/data/data-engine.zod.ts#QUERY_TRANSPORT_DOLLAR_PARAMS (const)", + "QUEUE_FIELD_DEF": "src/data/injected-system-column-provenance.ts#QUEUE_FIELD_DEF (const)", "QueryAST": "src/data/query.zod.ts#QueryAST (type)", "QueryAliasConflict": "src/data/data-engine.zod.ts#QueryAliasConflict (interface)", "QueryAliasSlot": "src/data/data-engine.zod.ts#QueryAliasSlot (interface)", diff --git a/packages/spec/export-origins/security.json b/packages/spec/export-origins/security.json index ebe62fb6a65..025ce1925c4 100644 --- a/packages/spec/export-origins/security.json +++ b/packages/spec/export-origins/security.json @@ -19,6 +19,8 @@ "CriteriaSharingRule": "src/security/sharing.zod.ts#CriteriaSharingRule (type)", "CriteriaSharingRuleParsed": "src/security/sharing.zod.ts#CriteriaSharingRuleParsed (type)", "CriteriaSharingRuleSchema": "src/security/sharing.zod.ts#CriteriaSharingRuleSchema (const)", + "DeclaredQueueMember": "src/security/queue.zod.ts#DeclaredQueueMember (type)", + "DeclaredQueueMemberSchema": "src/security/queue.zod.ts#DeclaredQueueMemberSchema (const)", "DefaultRowScope": "src/security/rls.zod.ts#DefaultRowScope (type)", "DefaultRowScopeParsed": "src/security/rls.zod.ts#DefaultRowScopeParsed (type)", "DefaultRowScopeSchema": "src/security/rls.zod.ts#DefaultRowScopeSchema (const)", @@ -75,6 +77,10 @@ "PlatformCapability": "src/security/capabilities.ts#PlatformCapability (interface)", "PlatformGlobalObjects": "src/security/tenancy-posture.ts#PlatformGlobalObjects (type)", "PlatformGlobalObjectsSchema": "src/security/tenancy-posture.ts#PlatformGlobalObjectsSchema (const)", + "Queue": "src/security/queue.zod.ts#Queue (type)", + "QueueMemberType": "src/security/queue.zod.ts#QueueMemberType (type)", + "QueueParsed": "src/security/queue.zod.ts#QueueParsed (type)", + "QueueSchema": "src/security/queue.zod.ts#QueueSchema (const)", "RLS": "src/security/rls.zod.ts#RLS (const)", "RLSEvaluationResult": "src/security/rls.zod.ts#RLSEvaluationResult (type)", "RLSEvaluationResultSchema": "src/security/rls.zod.ts#RLSEvaluationResultSchema (const)", @@ -97,6 +103,7 @@ "TenantLayer0VerdictSchema": "src/security/tenant-layer0-verdict.ts#TenantLayer0VerdictSchema (const)", "defineCapability": "src/security/capabilities.ts#defineCapability (function)", "definePermissionSet": "src/security/permission.zod.ts#definePermissionSet (function)", + "defineQueue": "src/security/queue.zod.ts#defineQueue (function)", "defineSharingRule": "src/security/sharing.zod.ts#defineSharingRule (function)", "describeAnchorForbiddenBits": "src/security/high-privilege.ts#describeAnchorForbiddenBits (function)", "describeHighPrivilegeBits": "src/security/high-privilege.ts#describeHighPrivilegeBits (function)", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index fbfdcc6d3d2..ca80f2a3bac 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -864,7 +864,7 @@ The governed set is `GOVERNED` at the top of `check-liveness.mts`. To add a type RecordDetailView had been gating the History tab on it the whole time (#2707). 4. Add the type to `GOVERNED`; confirm the gate is green. -## Current state — 41 governed types (complete registry coverage) +## Current state — 43 governed types (complete registry coverage) > **This heading is now checked** (#7257). `check:liveness` reconciles the table > against `GOVERNED` in both directions — a governed type with no row fails, a row @@ -995,6 +995,8 @@ marker where the Notes cell goes, never a guess at what belongs there. | sharing_rule | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, and the first one PAID (`connector` and `analytics_cube` are still owed on that card). Not a registered kind: it is bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reaches the walk through `getMetadataTypeSchema`'s unregistered-kind fallback, so this ledger governs a type `listMetadataTypeSchemaTypes()` still does not enumerate. One shape fact decides every row: the AUTHORING shape is not the ENFORCED shape. ADR-0057 D6 makes the `sys_sharing_rule` row canonical (`object_name` + `criteria_json` + `recipient_type`/`recipient_id` + `access_level`) and `bootstrapDeclaredSharingRules` translates each authored key into it at boot — nothing re-parses `SharingRuleSchema` at enforcement time — so every consumer cited reads a COLUMN and every row carries the `producer` (#4837) that populates it, which is the `seed.env` lesson applied to a whole type rather than to one key. Preview read points ENUMERATED per the #7131 rule and the answer recorded rather than skipped: `registerBuiltinPreviews()` (objectui @dda8f381) registers twenty types and `sharing_rule` is not one of them; what objectui does consume is the whole shape, on the CREATE door only (`AUTHOR_SHAPE_ONLY_TYPES` — the EDIT door is deliberately ungated because a served body carries the `_diagnostics` decoration this `.strict()` schema rejects). The single non-`live` row is `type`, the `SharingRuleType` discriminator: one member, `criteria`, whose only reader is a defensive `=== 'owner'` comparison that is unreachable for every value the schema admits. `planned` on the `action.operation` precedent (a one-member discriminator held `planned` until a runtime half dispatched on it, #15080), and deliberately NOT an enforce-or-remove candidate: the key is required, so removing it would break every authored rule to delete nothing. | | connector | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, paid in the same diff as `analytics_cube`, which empties that map. Not a registered kind: bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reached through `getMetadataTypeSchema`'s unregistered-kind fallback. **What the walk actually resolves, measured:** the binding names `DeclarativeConnectorEntrySchema`. ⚠️ The MECHANISM changed with the `connectionTimeoutMs` retirement and the prior sentence here is corrected rather than carried: that schema USED TO BE `ConnectorSchema.superRefine(...)`, a Zod 4 check attached to the same object def, and the key-set conclusion used to rest on that attachment. It is now a `z.preprocess` PIPE — both published carriers wrap one shared private `ConnectorBaseSchema` in the ADR-0049 retired-default residue stage, the entry schema adding the ADR-0097 cross-field rules on the base before wrapping, so the two are SIBLINGS rather than parent and child, and what preserves the walked shape is the pipe's read-through `shape`, NOT a `superRefine` attachment. The CONCLUSION is unchanged and re-measured on the built entry rather than inherited: both carriers expose 30 keys and the key sets are byte-identical, with no entry-only and no base-only key. The gate cannot tell the two schemas apart; what the entry schema buys is REFUSALS, invisible to the walk and visible only in the two rows where they are the whole verdict (`authentication` and `actions`; `triggers` was the third until its retirement made it a tombstone both carriers refuse). **ONE SCHEMA, TWO DOORS** is the shape fact behind the 31/1/10 split (live/planned/dead; counts read from the generated `state-counts/connector.md` shard, never hand-kept here): the ledger's denominator entry exists for the AUTHORING doors (`defineStack({ connectors })`, `PUT /meta/connector/:name`), while the same `ConnectorSchema` is what `AutomationEngine.registerConnector` parses for a def a PLUGIN or an ADR-0097 provider factory builds in code — so a key can have a real consumer and still do nothing when a metadata author writes it. The keys an authored entry can reach are exactly the author-supplied `ConnectorProviderContext` fields plus `provider` and `enabled` — `name` is itself one of those fields (the former "plus `name`" tail double-counted it), `loadPackageFile` is host-injected rather than authored, and `provider` selects the factory without ever reaching the context; `type` and `icon` reach that context and are dropped by all three shipped factories, and each says so on its own row. `authentication` is the ledger's `planned`, and ⛔ NOT "refused outright" — the former tail here said exactly that and all three instruments contradict it, including the one it cites: the KEY is ACCEPTED (`connector.zod.ts` declares `authentication: ConnectorAuthConfigSchema.optional().default({ type: 'none' })`, and the accepted value does nothing); what #7990 refuses is a non-`none` VALUE (`if (entry.authentication && entry.authentication.type !== 'none')`, whose own message prescribes "drop `authentication` (or set `{ type: 'none' }`)"); and ADR-0097 §3, titled "Credentials are references", rejects **inline secrets** in stack metadata, not the key. Accepted-and-ignored, plus a loud refusal of every value but `{ type: 'none' }`, is exactly the basis of the `planned` verdict — which the row itself already stated ("the accepted value does nothing"), so the summary, not the row, was the wrong half. The 10 `dead`, partitioned so every row is counted exactly once: `metadata` plus the nine top-level `retiredKey` tombstones `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status`, `webhooks`, `triggers`, `syncConfig` and `fieldMappings`. That sums to 10, the dead count the generated `state-counts/connector.md` shard carries. ⚠️ It was 23 until connector-attached sync left the connector (ADR-0049, ruled ENFORCE with the definition MOVED to the target side — `mapping.connectorSource`, `planned` in mapping.json): `syncConfig` counted 8 drilled rows and `fieldMappings` 7 (its `transform` tombstone among them), and each is now ONE leaf tombstone row, by the same gate rule as `health` below. ⚠️ It was 25 until `actions.description` and `actions.outputSchema` went `live` (#20287: the flow designer reads both, objectui#11028). ⚠️ It was 30 until the connector `triggers` array was retired (ADR-0049; ADR-0041 unchanged): `triggers` counted 6 drilled rows (`key`, `label`, `description`, `type`, `intervalSeconds` and the `interval` rename tombstone — dead because nothing read a connector trigger, which the schema's own docblock said: #3197) and is now ONE leaf tombstone row, by the same gate rule as `health` below. ⚠️ It was 44 until the connector resilience family was retired (ADR-0049): `health` counted 15 drilled rows (both sub-blocks plus the `monitoringWindow` tombstone) and is now ONE leaf tombstone row — the gate refuses `children` under a property that is no longer a container — and `webhooks` left the undrilled baseline for the same reason; `status` and `webhooks` stayed one row each and changed only from dead-awaiting-a-decision to dead-and-tombstoned. ⚠️ `retryConfig` IS NO LONGER IN THIS LIST: all eight of its sub-keys went `live` when #18975 made the declared policy execute at the one platform fetch site, which is the same measurement the falsification note at the end of this row records — so a reader who still finds "`retryConfig` (8)" among the dead is reading a stale copy. ⚠️ Nor is it "the two timeouts" any more: `requestTimeoutMs` is `live` (it becomes `resilientFetch`'s per-attempt deadline) and `connectionTimeoutMs` is the retired tombstone named above. ⭐ NINE rows in this ledger are `retiredKey` tombstones that keep their rows because the key stays in the walked shape (the `rls.priority` precedent) — `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status`, `webhooks`, `triggers`, `syncConfig` and `fieldMappings` — but ⛔ that nine is NOT a separate addend: they ARE the top-level tombstones counted above, which is exactly the double-count that made the previous "and four `retiredKey` tombstones" tail drift. (`fieldMappings.transform` was one of them until its subtree left whole with `fieldMappings`, whose own leaf row took its place.) (`triggers.interval` was one of the eight until its array left whole with `triggers`, whose own leaf row took its place — the count held at eight by a swap, not by standing still.) (`health.circuitBreaker.monitoringWindow` was the ninth until its block left whole with `health`.) Count them by name, never by adding the tail. **A prior in-repo claim is recorded here with its DIRECTION measured rather than remembered, because this row's job is the history of how the type got here**: the conversion registry's note inside `connector-rate-limit-config-removed`'s fixture reads "`retryConfig` and the timeouts beside it are untouched by THIS conversion — a statement about its scope, not a liveness verdict. They are not live: declared, defaulted and documented, and read by nothing." ⚠️ It asserts they are NOT live, and it scopes "untouched" to that one conversion. The former tail here quoted it as asserting the OPPOSITE ("they are live") and called it false when seeded — an inversion that turned this whole passage upside down, and it is corrected rather than carried. Measured direction: the note was TRUE when this ledger was seeded (2026-09-17) and is STALE now, #18975 having made the declared policy execute at the one platform fetch site (`connectorFetchOptions` → `resilientFetch`), so `retryConfig`'s eight sub-keys are `live` on their own rows and `requestTimeoutMs` is `live` beside them; only `connectionTimeoutMs` still answers to it, as the retired tombstone. ⛔ The stale comment is not rewritten from here — it is #19729's, as a dated note beside it — and it is not a line this PR's diff touches. ⚠️ The seeding note's supporting census — "the word does not occur outside `packages/spec` at all" — is FALSE at this head and is corrected rather than carried: `git grep -n retryConfig 14fdebd766 -- . ':!packages/spec'` returns 67 **matching lines** over 15 files — `git grep -o` on the same tree and pathspec returns 77 **occurrences**, and a line is not an occurrence, which is the trap a re-measurer falls into next (26 matching lines in the materializer `packages/services/service-automation/src/plugin.ts` and its materialization test, 22 across `connector-rest` and `connector-openapi` — providers, connectors and their tests — 13 in five `.changeset` fragments, and 6 on two `content/docs` pages). ⛔ Re-read that as the standing lesson of this row: a census is a count plus the tree it was taken against, and a bare "does not occur" with no commit behind it is the shape that rots first. The timeouts half is settled on its own rows: `requestTimeoutMs` is `live`, `connectionTimeoutMs` is retired | | analytics_cube | seeded 2026-09-17 (#18582) — the third debt, paid in the same diff as `connector`. Not a registered kind either: bound in `UNREGISTERED_KIND_SCHEMAS` by commit 2306a765c and reached through the same unregistered-kind fallback. **ONE Cube shape, THREE producers, one registry** is what decides every row: `cube-registry.ts` names them itself — authored cubes (`analyticsCubes[]` / `defineCube()`, threaded by the CLI into `AnalyticsServiceConfig.cubes`), COMPILED DATASETS (ADR-0021, where `dataset-compiler` mints a Cube), and ad-hoc query inference. Only the first is the authoring door governed here, so a key whose only reader sits on the compiled-dataset path is not live for an authored cube however busy that reader is — the #4837 producer rule on a shape with three producers. That kept `dimensions.granularities` (read only by `dataset-executor#granularityOf`, whose argument is a `CompiledDataset` an authored cube never becomes) and `measures.format` (written by the compiler, threaded to the wire from the DATASET measure instead) `dead` until **#20282**'s second stage (2026-09-29) read both on the query doors off whichever cube answers the name: `analytics-service#withDeclaredMeasureFormats` describes each measure column's `fields[].format`, and `#withDeclaredGranularityDefaults` buckets a grouped time dimension at the default `dataset-executor#declaredDefaultGranularity` reads — the one reading (a single-entry list) the dataset path's `granularityOf` now shares. The query path is genuinely live: `sql` is the FROM table AND the object whose RLS read scope is injected, `measures.type` picks the aggregate, `measures.sql`/`dimensions.sql` the column, `joins[].name` the joined table. The 3 `dead` are the `refreshKey` tombstone and the inner `name` on each of `measures`/`dimensions`, where the record KEY is the identity — RETIRED by #20300 (ADR-0049 enforce-or-remove) as `retiredKey()` tombstones on the member `strictObject`s, so those two rows STAY `dead` (the tombstone keeps the key in the walked shape) and the count does not move. It was 6 until **#20282**'s third stage (2026-09-29) published the three `description`s on discovery: `analytics-service#getMeta` copies each onto its `CubeMeta` entry, the read point the `title`/`label` rows already cite (display-shaped, the #7131 split). It was 7 until #20637 RETIRED `refreshKey` whole (ADR-0049 enforce-or-remove, maintainer letter C): the caching block's `every` and `sql` were two drilled `dead` rows — no refresh scheduler, pre-aggregation or analytics result cache exists anywhere, re-measured 2026-09-29 — and the `retiredKey()` tombstone that replaced the block is ONE leaf row, because the gate refuses `children` on a property that is no longer a container (the connector `health` precedent). **#20282** flips the tenth, the visibility flag `public`, `dead` → `live` 2026-09-27: seeded as a knob that was never wired (three internal mints wrote `false`, nothing read it), it is now read by `service-analytics`' `cube-visibility.ts#isCubePublic` — `getMeta` omits a hidden cube and `query()` / `generateSql()` refuse it — in the same change that moved its default from `false` to the Cube.dev `true`, since enforcing the old default would have hidden every authored cube. It was 12 until #18612 RETIRED `joins[].relationship` and the REQUIRED `joins[].sql` (ADR-0049 enforce-or-remove, maintainer-ruled batch #154): the ON clause is SYNTHESISED as an FK equality and the authored one was never consulted, so a declared join condition came back REPLACED under a 200. `CubeJoinSchema` is a `strictObject`, so the route was strict deletion plus a `guidance` prescription and the two rows left this ledger with the keys — not the `retiredKey()` route, which keeps the row. **The end-to-end measurement is not prejudged**: whether cube authoring is live end to end is still its own measurement — this ledger answers the per-key question only | +| queue | seeded 2026-10-26 with the type itself (#25769, ADR-0142 phase 2): registered governed, never a `PENDING_GOVERNANCE` debt. A declared ownership queue is never copied into a row (ADR-0131 D6, regime C): every consumer reads the registry item through the one reader they share, plugin-sharing's `OwnershipQueueService` (`resolveQueue`, `expandMembers`, `isMember`, `queuesOf`), and each row's producer is `registerMetadataCollections` loading the stack's `queues`. All rows `live`: `name` is the one reference the `queue` column, an assignment target, a queue approver and a sharing recipient carry, and the namespace an authored `sys_queue` row may not take; `members.type` is a one-member literal (`position`) that `expandSpec` still dispatches on beside the authored member kinds; `label` is served as the resolved queue's label; `label`/`description` are shown in the Studio metadata list's default columns (the `webhook` precedent). An organization's authored queues are `sys_queue`/`sys_queue_member` rows, governed by their object definitions, not here. | +| assignment_rule | seeded 2026-10-26 with the type itself (#25769, ADR-0142 D5): registered governed. Declared only — organization-authored rules are deferred (ADR-0142 merge question 11) — and read on every insert by the engine's `AssignmentRuleEvaluator`, after every `beforeInsert` hook, for a row whose `owner_id` and `queue` are both still empty. All rows `live`, and `entries.assignTo` is drilled (`type`, `value`) rather than inherited. `name` and `entries.name` key the round-robin cursor (`sys_assignment_cursor`, per rule, entry and organization), so a rename restarts a rotation; `object` gates evaluation to objects carrying the platform `queue` column; `entries.load` is the `least_loaded` count predicate, required with that strategy and refused otherwise. `kernel:ready` refuses a stack that declares rules or queues with no `ownership-queue` service registered, so no row is inert by a missing service. | The `dead` set across types is the enforce-or-remove worklist (ADR-0049); a `dead` row warns its authors at compile time by its verdict alone, with no `authorWarn` diff --git a/packages/spec/liveness/assignment_rule.json b/packages/spec/liveness/assignment_rule.json new file mode 100644 index 00000000000..81b220e6c80 --- /dev/null +++ b/packages/spec/liveness/assignment_rule.json @@ -0,0 +1,88 @@ +{ + "type": "assignment_rule", + "_note": "AssignmentRuleSchema (packages/spec/src/automation/assignment-rule.zod.ts) — declared routing of an inserted row to a queue or a user, the stack's `assignmentRules` (ADR-0142 D5). Seeded 2026-10-26 with the type itself (#25769): registered governed, not opened as a PENDING_GOVERNANCE debt. Declared only — organization-authored rules are deferred (ADR-0142 merge question 11) — and never copied into a row: the one consumer is the engine's insert seam, packages/objectql/src/assignment-rules.ts#AssignmentRuleEvaluator, which reads the registry items on every insert of an object that has rules (packages/objectql/src/engine.ts#assignment wires `listRules` to `registry.listItems('assignment_rule')`). Every row's `producer` is therefore the registry thread, packages/objectql/src/engine.ts#registerMetadataCollections, which loads the stack's `assignmentRules` array (METADATA_ARRAY_KEYS) under type `assignment_rule`. `kernel:ready` refuses to boot a stack that declares rules (or queues) with no `ownership-queue` service registered (packages/objectql/src/assignment-rules.ts#describeMissingOwnershipQueueService), so no row below can be inert by a missing service. PREVIEW READ POINTS: no metadata-admin preview is registered for `assignment_rule`; the Studio metadata list falls through to its default columns (ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx#defaultColumns — objectui merged in-tree under ui/, not a REPO_ROOT, so the resolver does not check the path).", + "props": { + "name": { + "status": "live", + "verifiedAt": "2026-10-26", + "evidence": "packages/objectql/src/assignment-rules.ts#ruleOf drops an item with no `name`; packages/objectql/src/assignment-rules.ts#roundRobin keys the `sys_assignment_cursor` row by `rule` (this name), `entry` and `organization_id`, so a rename restarts the rotation; packages/objectql/src/assignment-rules.ts#switchedOn reads the activation ledger (`metadata_type: 'assignment_rule'`) by it.", + "producer": "packages/objectql/src/engine.ts#registerMetadataCollections" + }, + "label": { + "status": "live", + "verifiedAt": "2026-10-26", + "evidenceScope": "cross-repo", + "evidence": "Shown in the Studio metadata list's default `label` column — ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx#defaultColumns (objectui, merged in-tree under ui/), the `webhook.label` / `email_template.label` precedent; served on `GET /api/v1/meta/assignment_rule` by packages/rest/src/meta-item-read-gate.ts#createMetaListAnswer with no metadata-type redactor.", + "producer": "packages/objectql/src/engine.ts#registerMetadataCollections", + "note": "Display-shaped (#7131); the evaluator never reads it." + }, + "description": { + "status": "live", + "verifiedAt": "2026-10-26", + "evidenceScope": "cross-repo", + "evidence": "Shown in the Studio metadata list's default `description` column — ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx#defaultColumns (objectui, merged in-tree under ui/); served on `GET /api/v1/meta/assignment_rule` by packages/rest/src/meta-item-read-gate.ts#createMetaListAnswer.", + "producer": "packages/objectql/src/engine.ts#registerMetadataCollections", + "note": "Administrative notes; not `authorWarn`ed." + }, + "object": { + "status": "live", + "verifiedAt": "2026-10-26", + "evidence": "packages/objectql/src/assignment-rules.ts#rulesFor — a rule is evaluated only for inserts into the object it names (`rule.object === object`), and only when that object carries the platform `queue` column with storage behind it (packages/objectql/src/assignment-rules.ts#carriesQueueColumn); otherwise the rules route nothing and that is warned once, naming the object.", + "producer": "packages/objectql/src/engine.ts#registerMetadataCollections", + "note": "An object without the column is one whose records have no user owner (`ownership` other than `user`) or a federated one; routing into it would write an owner the access path never reads." + }, + "active": { + "status": "live", + "verifiedAt": "2026-10-26", + "evidence": "packages/objectql/src/assignment-rules.ts#ruleOf — `if (item.active === false) return null`: an inactive rule is never evaluated. The activation ledger is the second switch, read by packages/objectql/src/assignment-rules.ts#switchedOn.", + "producer": "packages/objectql/src/engine.ts#registerMetadataCollections" + }, + "entries": { + "children": { + "name": { + "status": "live", + "verifiedAt": "2026-10-26", + "evidence": "packages/objectql/src/assignment-rules.ts#roundRobin — the round-robin cursor is kept per `(rule, entry, organization_id)`, so the entry name decides which rotation a row advances; packages/objectql/src/assignment-rules.ts#ruleOf drops an entry with no name. The schema refuses two entries of one rule sharing a name for exactly that reason.", + "producer": "packages/objectql/src/engine.ts#registerMetadataCollections" + }, + "criteria": { + "status": "live", + "verifiedAt": "2026-10-26", + "evidence": "packages/objectql/src/assignment-rules.ts#matches — the CEL is evaluated against the row in flight (`{ record: row }`); the first matching entry of the first matching rule decides (packages/objectql/src/assignment-rules.ts#assignOne). A criteria that cannot be evaluated does not match, warned once per entry.", + "producer": "packages/objectql/src/engine.ts#registerMetadataCollections", + "note": "Absent = every row: the empty state is `open` by declaration, and harmless here — the row it admits is routed, never granted access." + }, + "assignTo": { + "children": { + "type": { + "status": "live", + "verifiedAt": "2026-10-26", + "evidence": "packages/objectql/src/assignment-rules.ts#route — `type: 'user'` writes `owner_id` to the named id and stops; `type: 'queue'` resolves the queue in the row's organization through the `ownership-queue` service and writes the `queue` column; packages/objectql/src/assignment-rules.ts#ruleOf drops an entry whose target type is neither.", + "producer": "packages/objectql/src/engine.ts#registerMetadataCollections" + }, + "value": { + "status": "live", + "verifiedAt": "2026-10-26", + "evidence": "packages/objectql/src/assignment-rules.ts#route — the user id written as `owner_id`, or the queue machine name the `ownership-queue` service resolves (`resolveQueue(value, organization)`); a queue name that resolves nowhere in the row's organization leaves the row ownerless and is warned once, never refused; packages/objectql/src/assignment-rules.ts#ruleOf drops an entry with an empty value.", + "producer": "packages/objectql/src/engine.ts#registerMetadataCollections", + "note": "Two grammars behind one key, split by `type`: the schema holds a queue target's value to the snake_case machine-name grammar, so a queue id or label is refused at authoring rather than resolving nowhere at insert." + } + } + }, + "strategy": { + "status": "live", + "verifiedAt": "2026-10-26", + "evidence": "packages/objectql/src/assignment-rules.ts#route — absent PARKS the row in the queue for a member to claim; `round_robin` picks the member after the cursor's last (packages/objectql/src/assignment-rules.ts#roundRobin, advancing the cursor under a `{version}` precondition with retry); `least_loaded` counts each member's matching rows in the organization and takes the smallest (packages/objectql/src/assignment-rules.ts#leastLoaded, tie to the earlier member by user id).", + "producer": "packages/objectql/src/engine.ts#registerMetadataCollections" + }, + "load": { + "status": "live", + "verifiedAt": "2026-10-26", + "evidence": "packages/objectql/src/assignment-rules.ts#leastLoaded — the CEL is lowered through `compileCelToFilter` and ANDed with `owner_id` and `organization_id` into each member's count, so it decides what counts as open work.", + "producer": "packages/objectql/src/engine.ts#registerMetadataCollections", + "note": "Required with `least_loaded` and refused otherwise by the schema, so there is no platform default to drift from." + } + } + } + } +} diff --git a/packages/spec/liveness/queue.json b/packages/spec/liveness/queue.json new file mode 100644 index 00000000000..fe470978794 --- /dev/null +++ b/packages/spec/liveness/queue.json @@ -0,0 +1,44 @@ +{ + "type": "queue", + "_note": "QueueSchema (packages/spec/src/security/queue.zod.ts) — a DECLARED ownership queue, the stack's `queues` (ADR-0142 D1). Seeded 2026-10-26 with the type itself (#25769): the type was registered governed, not opened as a PENDING_GOVERNANCE debt. THE SHAPE FACT THAT DECIDES EVERY ROW BELOW: a declared queue is never copied into a row. ADR-0131 D6 (regime C) applies — the registry item is read on every use, through the one reader every queue consumer shares, packages/plugins/plugin-sharing/src/ownership-queue-service.ts#OwnershipQueueService (`resolveQueue`, `expandMembers`, `isMember`, `queuesOf`), and switched off through the activation ledger (`sys_metadata_activation`, `metadata_type: 'queue'`). So each row's consumer is that service, and each row's `producer` is the registry thread that puts the authored item where the service reads it: packages/objectql/src/engine.ts#registerMetadataCollections, which loads the stack's `queues` array (METADATA_ARRAY_KEYS) into the registry under type `queue`. An organization's AUTHORED queues (`sys_queue` + `sys_queue_member`) are rows, not this type, and are governed by their object definitions. The word `queue` here is a queue of PEOPLE; the job queue and `sys_job_queue` are a different noun. PREVIEW READ POINTS: no metadata-admin preview is registered for `queue` (the type is new); the Studio metadata list falls through to its default columns, read in-tree at ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx#defaultColumns (objectui, merged under ui/ — not a REPO_ROOT, so the evidence resolver does not check that path).", + "props": { + "name": { + "status": "live", + "verifiedAt": "2026-10-26", + "evidence": "packages/plugins/plugin-sharing/src/ownership-queue-service.ts#resolveQueue — the declared item is looked up by `name` (registry-first) and a declared name switched off in the ledger resolves nowhere, not even to an authored row; packages/plugins/plugin-sharing/src/queue-hooks.ts#bindQueueGuards refuses a `sys_queue` row that takes a declared queue's name (`isDeclaredQueueName`), so the authored name owns its namespace; packages/objectql/src/assignment-rules.ts#route resolves an assignment entry's queue target by this name, and the record's `queue` column stores it verbatim.", + "producer": "packages/objectql/src/engine.ts#registerMetadataCollections — loads the stack's `queues` entries into the registry under type `queue`, keyed by `name`; packages/plugins/plugin-sharing/src/ownership-queue-service.ts#declaredQueueNamed is the lookup the activation door names a `queue` ledger row's subject through.", + "note": "Identity, and the one reference every queue consumer carries: the `queue` column, `assignTo.value`, a queue approver's value and a sharing recipient's value. A rename is a migration, not an edit." + }, + "label": { + "status": "live", + "verifiedAt": "2026-10-26", + "evidence": "packages/plugins/plugin-sharing/src/ownership-queue-service.ts#resolveQueue — `label: declared.label ?? name` serves the authored label as the resolved queue's label, the one display string the service contract (`ResolvedQueue.label`) carries to its consumers; the Studio metadata list shows it in its default `label` column (ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx#defaultColumns, in-tree objectui), the `webhook.label` / `email_template.label` precedent.", + "producer": "packages/objectql/src/engine.ts#registerMetadataCollections", + "note": "Display-shaped: being shown to a human is the whole of the claimed effect (#7131). Required by the schema, so a queue always has one." + }, + "description": { + "status": "live", + "verifiedAt": "2026-10-26", + "evidence": "Shown in the Studio metadata list's default `description` column — ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx#defaultColumns (objectui, merged in-tree under ui/), the `webhook.description` / `email_template.description` precedent; served on `GET /api/v1/meta/queue` by packages/rest/src/meta-item-read-gate.ts#createMetaListAnswer with no metadata-type redactor.", + "producer": "packages/objectql/src/engine.ts#registerMetadataCollections", + "note": "Administrative notes; nothing gates on them. Not `authorWarn`ed — a description that only describes does not mislead its author." + }, + "members": { + "children": { + "type": { + "status": "live", + "verifiedAt": "2026-10-26", + "evidence": "packages/plugins/plugin-sharing/src/ownership-queue-service.ts#declaredMemberSpecs — only `type === 'position'` yields a member specification; packages/plugins/plugin-sharing/src/ownership-queue-service.ts#expandSpec then branches on it to the position-holder expansion (deactivated positions filtered on the access purpose, not on addressing — ADR-0142 D2).", + "producer": "packages/objectql/src/engine.ts#registerMetadataCollections", + "note": "A one-member literal on the declared form (`'position'`): users, teams and business units are an organization's rows and join a queue through `sys_queue_member`, where every QueueMemberType is admitted. The literal is still dispatched on — the authored `sys_queue_member.recipient_type` takes the other branches of the same `expandSpec`." + }, + "value": { + "status": "live", + "verifiedAt": "2026-10-26", + "evidence": "packages/plugins/plugin-sharing/src/ownership-queue-service.ts#expandSpec — the position machine name whose holders, in the record's or request's organization, are the queue's members; packages/plugins/plugin-sharing/src/ownership-queue-service.ts#queuesNaming matches a written position against it, which is how a `sys_user_position` write re-materialises a queue-recipient sharing rule's grants.", + "producer": "packages/objectql/src/engine.ts#registerMetadataCollections" + } + } + } + } +} diff --git a/packages/spec/liveness/sharing_rule.json b/packages/spec/liveness/sharing_rule.json index 373815465b0..e8122c09f4d 100644 --- a/packages/spec/liveness/sharing_rule.json +++ b/packages/spec/liveness/sharing_rule.json @@ -53,7 +53,7 @@ "verifiedAt": "2026-09-17", "evidence": "packages/plugins/plugin-sharing/src/sharing-rule-service.ts#expandRecipient branches on the stored member and the branches differ in WIDTH: `user` resolves to the id itself, `team` to `TeamGraphService.expandUsers`, `business_unit` to exactly one unit's members, `unit_and_subordinates` to the business-unit SUBTREE walk, `position` to the position's holders with a deactivated-position check first; packages/plugins/plugin-sharing/src/sharing-rule-service.ts#expandRecipientForRecord routes `field` — and only `field` — to a per-record expansion instead of the rule-wide one.", "producer": "packages/plugins/plugin-sharing/src/declared-sharing-rules.ts#mapRecipientType maps the authored member onto the runtime `recipient_type` those branches read; an unmappable value returns null and packages/plugins/plugin-sharing/src/declared-sharing-rules.ts#translateDeclaredSharingRule refuses the rule (warned once, by packages/plugins/plugin-sharing/src/declared-sharing-rules.ts#createDeclaredSharingRuleSource) rather than serving it.", - "note": "The authorable enum is the authorable SUBSET of the runtime recipient contract: `queue` is reserved by the runtime and deliberately not authorable, and `group`/`guest` were removed (ADR-0078). `business_unit` vs `unit_and_subordinates` is the pair where the two widths are the contract — they shared an expansion call until #7807, differing only in their comments." + "note": "The authorable enum is the authorable SUBSET of the runtime recipient contract: `group`/`guest` were removed (ADR-0078). `queue` was reserved and not authorable until ADR-0142 D7 (#25769) re-admitted it WITH its implementation: packages/plugins/plugin-sharing/src/sharing-rule-service.ts#expandRecipient expands it rule-wide through the `ownership-queue` service, and the queue's membership writes re-materialise it (packages/plugins/plugin-sharing/src/queue-hooks.ts#recomputeQueueRecipientRules). It confers the criteria records' grant only — never the sitting-record access a queue's members have, which is the owner match's own path. `business_unit` vs `unit_and_subordinates` is the pair where the two widths are the contract — they shared an expansion call until #7807, differing only in their comments." }, "value": { "status": "live", diff --git a/packages/spec/liveness/state-counts/assignment_rule.md b/packages/spec/liveness/state-counts/assignment_rule.md new file mode 100644 index 00000000000..2fd5884d64c --- /dev/null +++ b/packages/spec/liveness/state-counts/assignment_rule.md @@ -0,0 +1,15 @@ + + + +# `assignment_rule` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `assignment_rule` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `assignment_rule` | 18 | 0 | 0 | 0 | 0 | 18 | diff --git a/packages/spec/liveness/state-counts/queue.md b/packages/spec/liveness/state-counts/queue.md new file mode 100644 index 00000000000..faf4489e8fa --- /dev/null +++ b/packages/spec/liveness/state-counts/queue.md @@ -0,0 +1,15 @@ + + + +# `queue` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `queue` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `queue` | 12 | 0 | 0 | 0 | 0 | 12 | diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index 141dc51f5a6..7602f472654 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -276,7 +276,7 @@ const ledgerRoot = ledgerRootArg // Governed metadata types, rolled out highest-frequency / highest-risk first. // (`query` is not a metadata type — see SPEC_ONLY_SCHEMAS below.) -const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard', 'webhook', 'query', 'datasource', 'app', 'book', 'doc', 'email_template', 'job', 'mapping', 'picklist', 'seed', 'translation', 'validation', 'api', 'capability', 'qa', 'manifest', 'crud_endpoints', 'metadata_endpoints', 'batch_endpoints', 'route_generation', 'rest_api', 'realtime_subscription', 'sharing_rule', 'connector', 'analytics_cube']; +const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard', 'webhook', 'query', 'datasource', 'app', 'book', 'doc', 'email_template', 'job', 'mapping', 'picklist', 'seed', 'translation', 'validation', 'api', 'capability', 'qa', 'manifest', 'crud_endpoints', 'metadata_endpoints', 'batch_endpoints', 'route_generation', 'rest_api', 'realtime_subscription', 'sharing_rule', 'connector', 'analytics_cube', 'queue', 'assignment_rule']; // Authorable metadata types that are NOT yet governed — the coverage ratchet. // diff --git a/packages/spec/src/data/display-name.ts b/packages/spec/src/data/display-name.ts index 7f707876b3e..404c173ba8a 100644 --- a/packages/spec/src/data/display-name.ts +++ b/packages/spec/src/data/display-name.ts @@ -175,6 +175,13 @@ function isNameIshAffix(fieldName: string): boolean { ); } +/** The injected system columns whose type is title-eligible: today the ownership-queue column alone. */ +const INJECTED_TEXT_COLUMNS: ReadonlySet = new Set(['queue']); + +function isInjectedTextColumn(fieldName: string, def: TitleEligibleFieldDef | undefined): boolean { + return INJECTED_TEXT_COLUMNS.has(fieldName) && def?.system === true; +} + /** * Derive the title field from the object's fields when no explicit pointer is * set. Ranking (highest first), restricted to title-eligible fields: @@ -185,7 +192,13 @@ function isNameIshAffix(fieldName: string): boolean { */ function deriveDisplayField(fields: Record | undefined): string | undefined { if (!fields) return undefined; - const names = Object.keys(fields); + // [ADR-0142 D3] The platform's injected `queue` column is text, so it would + // be title-eligible — and the injection places its columns FIRST, so tier 3 + // would make a queue machine name the title of every object with no + // name-ish field. A platform column is never a derived title; an author's + // explicit `nameField` still wins above (and an author's own `queue` field, + // which carries no `system: true`, is derivable as before). + const names = Object.keys(fields).filter((fname) => !isInjectedTextColumn(fname, fields[fname])); // Tier 1: name-ish exact, in the EXACT priority order (name > title > subject > …) // so `name` wins over `title` when both exist, independent of declaration order. diff --git a/packages/spec/src/data/import-mapping-target.test.ts b/packages/spec/src/data/import-mapping-target.test.ts index aed8d7d5921..e17039aa801 100644 --- a/packages/spec/src/data/import-mapping-target.test.ts +++ b/packages/spec/src/data/import-mapping-target.test.ts @@ -40,7 +40,7 @@ describe('indexImportMappingTargets', () => { expect(index).not.toBeNull(); expect([...index!.names].sort()).toEqual([ 'account', 'created_at', 'created_by', 'email', 'full_name', 'geo', 'id', 'mailing_address', - 'organization_id', 'owner_id', 'owning_business_unit_id', 'updated_at', 'updated_by', + 'organization_id', 'owner_id', 'owning_business_unit_id', 'queue', 'updated_at', 'updated_by', ]); // Only the DECLARED definitions are carried: the compound-part arm reads a head's type here. expect([...index!.fields.keys()].sort()).toEqual(['account', 'email', 'full_name', 'geo', 'mailing_address']); diff --git a/packages/spec/src/data/injected-system-column-provenance.test.ts b/packages/spec/src/data/injected-system-column-provenance.test.ts index fa86236158a..1432d4a998b 100644 --- a/packages/spec/src/data/injected-system-column-provenance.test.ts +++ b/packages/spec/src/data/injected-system-column-provenance.test.ts @@ -39,6 +39,9 @@ const SEVEN_ANCHORS = [ 'owning_business_unit_id', ] as const; +/** Every injected anchor today: #7865's seven plus the ownership `queue` (ADR-0142 D3). */ +const INJECTED_ANCHORS = [...SEVEN_ANCHORS, 'queue'] as const; + const external = () => ({ name: 'showcase_ext_customer', external: { remoteName: 'customers' }, @@ -51,10 +54,10 @@ const local = () => ({ }); describe('provenance derivation at its spec home — where the author-time linter can reach it', () => { - it('marks all seven anchors unprovisioned on an external object, none on the local twin', () => { - expect(unprovisionedInjectedColumns(external()).sort()).toEqual([...SEVEN_ANCHORS].sort()); + it('marks every injected anchor unprovisioned on an external object, none on the local twin', () => { + expect(unprovisionedInjectedColumns(external()).sort()).toEqual([...INJECTED_ANCHORS].sort()); expect(unprovisionedInjectedColumns(local())).toEqual([]); - for (const anchor of SEVEN_ANCHORS) { + for (const anchor of INJECTED_ANCHORS) { expect(resolveInjectedColumnProvenance(external(), anchor), anchor).toBe( 'injected-unprovisioned', ); diff --git a/packages/spec/src/data/injected-system-columns.test.ts b/packages/spec/src/data/injected-system-columns.test.ts index 5989446449c..3500a0e643c 100644 --- a/packages/spec/src/data/injected-system-columns.test.ts +++ b/packages/spec/src/data/injected-system-columns.test.ts @@ -19,10 +19,10 @@ describe('resolveInjectedSystemColumns — the injected columns, so author-time it('gives a default business object every system column', () => { const plan = resolveInjectedSystemColumns(business); - expect(plan).toMatchObject({ tenant: true, audit: true, owner: true, owningBusinessUnit: true }); + expect(plan).toMatchObject({ tenant: true, audit: true, owner: true, owningBusinessUnit: true, queue: true }); expect([...plan.names].sort()).toEqual([ 'created_at', 'created_by', 'id', 'organization_id', 'owner_id', - 'owning_business_unit_id', 'updated_at', 'updated_by', + 'owning_business_unit_id', 'queue', 'updated_at', 'updated_by', ]); }); @@ -41,6 +41,9 @@ describe('resolveInjectedSystemColumns — the injected columns, so author-time expect(plan.owningBusinessUnit).toBe(false); expect(plan.names.has('owner_id')).toBe(false); expect(plan.names.has('owning_business_unit_id')).toBe(false); + // [ADR-0142 D3] The queue column rides with owner_id: no user owner, no queue. + expect(plan.queue).toBe(false); + expect(plan.names.has('queue')).toBe(false); // The tenant + audit columns are unaffected by the ownership tier. expect(plan.names.has('organization_id')).toBe(true); for (const f of AUDIT_PROVENANCE_FIELDS) expect(plan.names.has(f)).toBe(true); @@ -54,6 +57,7 @@ describe('resolveInjectedSystemColumns — the injected columns, so author-time expect(plan.owningBusinessUnit).toBe(true); expect(plan.names.has('owner_id')).toBe(false); expect(plan.names.has('owning_business_unit_id')).toBe(true); + expect(plan.names.has('queue')).toBe(false); }); it.each([ diff --git a/packages/spec/src/kernel/metadata-create-seeds.test.ts b/packages/spec/src/kernel/metadata-create-seeds.test.ts index cbccc0e0174..d8861b36228 100644 --- a/packages/spec/src/kernel/metadata-create-seeds.test.ts +++ b/packages/spec/src/kernel/metadata-create-seeds.test.ts @@ -85,6 +85,12 @@ describe('metadata create seeds validate against their spec schemas', () => { // authors its own rules through the sharing door, not `/meta`. Same // category as `capability`. 'sharing_rule', + // [ADR-0142 D1/D5] `queue` and `assignment_rule` are code-only (regime C, + // `allowRuntimeCreate: false`): an organization staffs its own queues as + // `sys_queue` rows in Setup, and assignment rules are declared only + // (merge question 11). Same category as `sharing_rule`. + 'queue', + 'assignment_rule', ]); const seeded = new Set(listMetadataCreateSeedTypes()); const missing = listMetadataTypeSchemaTypes().filter((t) => !seeded.has(t) && !KNOWN_UNSEEDED.has(t)); diff --git a/packages/spec/src/kernel/metadata-type-schemas.test.ts b/packages/spec/src/kernel/metadata-type-schemas.test.ts index c137414fedf..5326b91a723 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.test.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.test.ts @@ -653,7 +653,11 @@ describe('registered-type closure is derived, not tallied', () => { // 27 → 28 on 2026-10-19: `sharing_rule` JOINED the registry (ADR-0131 D6, // regime C), closed — `SharingRuleSchema` is `.strict()` and an authoring // surface only — so the closed count moves with it. - expect(closed.length).toBe(27); - expect(types.length).toBe(28); + // + // 28 → 30 on 2026-10-26: `queue` and `assignment_rule` JOINED the registry + // (ADR-0142 D1/D5, regime C), both closed from birth (`strictObject` + // authoring surfaces), so the closed count moves with them, 27 → 29. + expect(closed.length).toBe(29); + expect(types.length).toBe(30); }); }); diff --git a/packages/spec/src/meta-spelling/meta-url-data.generated.ts b/packages/spec/src/meta-spelling/meta-url-data.generated.ts index f499614aea0..638e08f2cc5 100644 --- a/packages/spec/src/meta-spelling/meta-url-data.generated.ts +++ b/packages/spec/src/meta-spelling/meta-url-data.generated.ts @@ -30,6 +30,8 @@ export const META_URL_TO_SINGULAR: Readonly> = Object.fre "permissions": "permission", "capabilities": "capability", "sharingRules": "sharing_rule", + "queues": "queue", + "assignmentRules": "assignment_rule", "apis": "api", "webhooks": "webhook", "agents": "agent", @@ -53,6 +55,7 @@ export const META_URL_TO_SINGULAR: Readonly> = Object.fre "translations": "translation", "email_templates": "email_template", "sharing_rules": "sharing_rule", + "assignment_rules": "assignment_rule", }); /** @@ -86,6 +89,8 @@ export const REGISTRY_DECLARED_META_TYPES: ReadonlyArray = Object.freeze "position", "capability", "sharing_rule", + "queue", + "assignment_rule", "agent", "tool", "skill", diff --git a/packages/spec/src/security/sharing.test.ts b/packages/spec/src/security/sharing.test.ts index 4f497ccbc6c..94082948964 100644 --- a/packages/spec/src/security/sharing.test.ts +++ b/packages/spec/src/security/sharing.test.ts @@ -30,7 +30,8 @@ describe('ShareRecipientType', () => { it('accepts exactly the authorable recipients', () => { // `field` (#14103) is authorable from this release; its per-record executor // is the services half (#15072) — see the `field` describe block below. - const authorable = ['user', 'team', 'position', 'unit_and_subordinates', 'business_unit', 'field']; + // `queue` was reserved until ADR-0142 D7 re-admitted it with its expander. + const authorable = ['user', 'team', 'position', 'unit_and_subordinates', 'business_unit', 'field', 'queue']; authorable.forEach((t) => { expect(() => ShareRecipientType.parse(t)).not.toThrow(); }); @@ -39,8 +40,6 @@ describe('ShareRecipientType', () => { it('rejects the retired `group` (renamed → team) and `guest` recipients', () => { expect(() => ShareRecipientType.parse('group')).toThrow(); expect(() => ShareRecipientType.parse('guest')).toThrow(); - // Reserved in the runtime contract but not authorable until implemented. - expect(() => ShareRecipientType.parse('queue')).toThrow(); }); }); diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts index 6752871e0b8..eb1ab60ebf5 100644 --- a/packages/spec/src/system/constants/platform-object-names.ts +++ b/packages/spec/src/system/constants/platform-object-names.ts @@ -110,8 +110,15 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly { return Object.fromEntries(Object.keys(injected).map((name) => [name, fields[name].label])); }; - it('starts from all seven injected columns carrying their shipped English defaults', () => { + it('starts from all eight injected columns carrying their shipped English defaults', () => { expect(Object.keys(injected).sort()).toEqual([ 'created_at', 'created_by', 'organization_id', 'owner_id', 'owning_business_unit_id', + 'queue', 'updated_at', 'updated_by', ]); @@ -3343,6 +3344,7 @@ describe('translateObject localises every platform-injected column', () => { updated_by: 'Last Modified By', owner_id: 'Owner', owning_business_unit_id: 'Owning Business Unit', + queue: 'Queue', }); }); @@ -3355,6 +3357,7 @@ describe('translateObject localises every platform-injected column', () => { updated_by: '更新人', owner_id: '所有者', owning_business_unit_id: '所属业务单元', + queue: '队列', }); }); @@ -3367,6 +3370,7 @@ describe('translateObject localises every platform-injected column', () => { updated_by: '更新者', owner_id: '所有者', owning_business_unit_id: '所属ビジネスユニット', + queue: 'キュー', }); }); @@ -3379,6 +3383,7 @@ describe('translateObject localises every platform-injected column', () => { updated_by: 'Actualizado por', owner_id: 'Propietario', owning_business_unit_id: 'Unidad de negocio propietaria', + queue: 'Cola', }); }); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 77823c09515..42ef8741fca 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -3556,7 +3556,8 @@ function lookupObjectFieldOption( * `injectedSystemColumnDefs` (`../data/injected-system-column-provenance`) * spreads onto every eligible object with English labels: the tenant scope * anchor `organization_id`, the audit family `created_*` / `updated_*`, - * `owner_id` and `owning_business_unit_id`. Custom objects carry no + * `owner_id`, `owning_business_unit_id` and the ownership `queue` (ADR-0142 + * D3). Custom objects carry no * per-object translation entries for these, so without a fallback every * localized surface — list headers, export files, import templates, the * `/meta` read exits — leaks the English default (e.g. an otherwise @@ -3593,6 +3594,7 @@ const SYSTEM_FIELD_LABELS: Record> = { 'ja-JP': '所属ビジネスユニット', 'es-ES': 'Unidad de negocio propietaria', }, + queue: { en: 'Queue', 'zh-CN': '队列', 'ja-JP': 'キュー', 'es-ES': 'Cola' }, }; /** From 1f0377d50d194f669f95e5302606ff8829b1bb93 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 14:45:51 +0000 Subject: [PATCH 04/23] feat(plugin-security): claim, release and move in the ownership gate (ADR-0142 D3/D4) Step 3.5 of the authz middleware reads the injected `queue` column: - insert naming a queue: admitted with the transfer grant or for a member; a member's insert stays owned (the stamp is unchanged) with a home queue, and only the grant parks a row ownerless in one write - claim (owner_id empty -> self, by id): admitted when the row's sharing verdict is 'allow', and the write is made conditional on owner_id being empty so a concurrent claimant gets 409 CONCURRENT_UPDATE - release (owner_id self -> empty): admitted when the record has a home queue - move (queue changed): transfer-class; a name that does not resolve in the row's organization is 422 QUEUE_NOT_FOUND - a guest supplies neither anchor Explain reports queue_membership and queue_claim on the sharing layer. Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- .../plugin-security/src/errors.test.ts | 1 + .../plugins/plugin-security/src/errors.ts | 36 ++ .../src/explain-engine.test.ts | 64 ++++ .../plugin-security/src/explain-engine.ts | 48 +++ .../src/ownership-queue-gate.test.ts | 328 ++++++++++++++++++ .../src/ownership-queue-gate.ts | 88 +++++ .../src/security-plugin-authz-middleware.ts | 241 ++++++++++--- .../src/security-plugin-explain-access.ts | 16 +- .../src/security-plugin-sharing-verdicts.ts | 10 +- .../plugin-security/src/security-plugin.ts | 27 +- .../src/ownership-queue.test.ts | 11 +- scripts/engine-double-contract.pinned.json | 25 ++ 12 files changed, 838 insertions(+), 57 deletions(-) create mode 100644 packages/plugins/plugin-security/src/ownership-queue-gate.test.ts create mode 100644 packages/plugins/plugin-security/src/ownership-queue-gate.ts diff --git a/packages/plugins/plugin-security/src/errors.test.ts b/packages/plugins/plugin-security/src/errors.test.ts index e5cdf185cf1..d52fef51b67 100644 --- a/packages/plugins/plugin-security/src/errors.test.ts +++ b/packages/plugins/plugin-security/src/errors.test.ts @@ -41,6 +41,7 @@ const FLOOR = [ 'MasterReferenceMissingError', 'MaskedValueWriteError', 'ExplainObjectNotFoundError', + 'QueueNotFoundError', ]; /** diff --git a/packages/plugins/plugin-security/src/errors.ts b/packages/plugins/plugin-security/src/errors.ts index e3c68660afe..fb47853e3fd 100644 --- a/packages/plugins/plugin-security/src/errors.ts +++ b/packages/plugins/plugin-security/src/errors.ts @@ -372,6 +372,42 @@ export class ExplainObjectNotFoundError extends Error { } } +/** + * [ADR-0142 D3/D4] A caller-supplied `queue` names no queue that resolves in + * the row's organization → `422 QUEUE_NOT_FOUND`. + * + * Raised by the ownership gate (middleware step 3.5) before the write: on an + * insert that names a queue, and on a MOVE (an update whose `queue` differs + * from the stored one). A queue resolves when a declared queue of that name is + * switched on, or the organization has a `sys_queue` row of that name; the + * column holds the machine name, so an id or a label resolves nowhere. + * + * Not a verdict: the name is wrong, and no grant would make it right. So the + * message does NOT carry the `[Security] Access denied` prefix, which is a + * MATCHER that would re-flatten this refusal into a 403 at the transports + * (see the classes above), and it carries both `status` and `statusCode`. + * An assignment rule whose target resolves nowhere is never this refusal: the + * row is written ownerless and the engine warns (ADR-0142 D5). + */ +export class QueueNotFoundError extends Error { + readonly code = 'QUEUE_NOT_FOUND'; + readonly status = 422; + readonly statusCode = 422; + readonly object: string; + readonly queue: string; + constructor(object: string, queue: unknown) { + const name = typeof queue === 'string' ? queue : JSON.stringify(queue) ?? String(queue); + super( + `No queue named '${name}' exists in this organization, so a record of '${object}' cannot be put in it. ` + + `Name a queue by its machine name (lowercase snake_case): one an installed app declares, or one of the ` + + `organization's own queues.`, + ); + this.name = 'QueueNotFoundError'; + this.object = object; + this.queue = name; + } +} + export function isPermissionDeniedError(e: unknown): e is PermissionDeniedError { if (!e || typeof e !== 'object') return false; const anyE = e as any; diff --git a/packages/plugins/plugin-security/src/explain-engine.test.ts b/packages/plugins/plugin-security/src/explain-engine.test.ts index be41fcadee0..731cfb93541 100644 --- a/packages/plugins/plugin-security/src/explain-engine.test.ts +++ b/packages/plugins/plugin-security/src/explain-engine.test.ts @@ -569,6 +569,70 @@ describe('explainAccess — record-grained (C2 / ADR-0095)', () => { expect(d.record).toMatchObject({ recordId: 'r1', visible: true }); }); + // ── [ADR-0142 D3/D4] the queue story on the sharing layer ────────────── + const QUEUED_ROW = { id: 'r1', organization_id: 'org1', owner_id: null, queue: 'support_intake' }; + const queueMember = (members: string[]) => async (userId: string, queue: string, org: string | null) => + queue === 'support_intake' && org === 'org1' && members.includes(userId); + + it('[ADR-0142 D3] a record sitting in a queue reports queue_membership — admits a member', async () => { + const d = await explainAccess( + { ...recDeps({ layered: { layer0: null, layer1: null }, record: QUEUED_ROW }), queueMember: queueMember(['u1']) }, + { object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' }, + ); + const rules = d.layers.find((l) => l.layer === 'sharing')!.record!.rules ?? []; + expect(rules).toContainEqual(expect.objectContaining({ kind: 'queue_membership', name: 'support_intake', effect: 'admits' })); + expect(ExplainDecisionSchema.safeParse(d).success).toBe(true); + }); + + it('[ADR-0142 D3] a non-member is reported neutral, an owned record reports no queue rule at all', async () => { + const outsider = await explainAccess( + { ...recDeps({ layered: { layer0: null, layer1: null }, record: QUEUED_ROW }), queueMember: queueMember(['u2']) }, + { object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' }, + ); + expect(outsider.layers.find((l) => l.layer === 'sharing')!.record!.rules) + .toContainEqual(expect.objectContaining({ kind: 'queue_membership', effect: 'neutral' })); + const owned = await explainAccess( + { ...recDeps({ layered: { layer0: null, layer1: null }, record: { ...QUEUED_ROW, owner_id: 'u9' } }), queueMember: queueMember(['u1']) }, + { object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' }, + ); + expect((owned.layers.find((l) => l.layer === 'sharing')!.record!.rules ?? []).map((r) => r.kind)).not.toContain('queue_membership'); + }); + + it('[ADR-0142 D4] a transfer of an unowned record reports queue_claim from the sharing verdict', async () => { + const asked: unknown[] = []; + const claimant = await explainAccess( + { + ...recDeps({ layered: { layer0: null, layer1: null }, record: QUEUED_ROW }), + queueMember: queueMember(['u1']), + claimVerdict: async (o, rid) => { asked.push([o, rid]); return 'allow'; }, + }, + { object: 'leave_request', operation: 'transfer', context: REC_CTX, recordId: 'r1' }, + ); + expect(asked).toEqual([['leave_request', 'r1']]); + expect(claimant.layers.find((l) => l.layer === 'sharing')!.record!.rules) + .toContainEqual(expect.objectContaining({ kind: 'queue_claim', name: 'support_intake', effect: 'admits' })); + const refused = await explainAccess( + { ...recDeps({ layered: { layer0: null, layer1: null }, record: QUEUED_ROW }), claimVerdict: async () => 'abstain' }, + { object: 'leave_request', operation: 'transfer', context: REC_CTX, recordId: 'r1' }, + ); + expect(refused.layers.find((l) => l.layer === 'sharing')!.record!.rules) + .toContainEqual(expect.objectContaining({ kind: 'queue_claim', effect: 'neutral' })); + }); + + it('[ADR-0142 D3] a delete explanation reports no queue rule — queue members never delete', async () => { + const d = await explainAccess( + { + ...recDeps({ layered: { layer0: null, layer1: null }, record: QUEUED_ROW }), + queueMember: queueMember(['u1']), + claimVerdict: async () => 'allow', + }, + { object: 'leave_request', operation: 'delete', context: REC_CTX, recordId: 'r1' }, + ); + const kinds = (d.layers.find((l) => l.layer === 'sharing')!.record!.rules ?? []).map((r) => r.kind); + expect(kinds).not.toContain('queue_membership'); + expect(kinds).not.toContain('queue_claim'); + }); + // ── [#4647] the bypass, at row granularity ────────────────────────────── // The report: `allowed: true` + a `vama_bypass` layer saying "ownership and // sharing checks are skipped", sitting next to `record: { visible: false, diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index 2cdb05342d4..c90548e22df 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -474,6 +474,21 @@ export interface ExplainEngineDeps { recordId: string, context: any, ) => Promise; + /** + * [ADR-0142 D3] Is `userId` a member of queue `queue` in `organizationId`? + * The queue service's holder check — the question the sharing service's + * queue basis asks of a record that sits in a queue (`owner_id` empty, + * `queue` set). Optional: absent, a record's queue is not reported. + */ + queueMember?: (userId: string, queue: string, organizationId: string | null) => Promise; + /** + * [ADR-0142 D4] The tri-state sharing write verdict for the record, the one + * the ownership gate reads to admit a CLAIM (an update of an unowned + * record's `owner_id` to the principal) without the transfer grant: only + * `allow` admits it. Asked for a `transfer` explanation of an unowned + * record. Optional: absent, no claim is reported. + */ + claimVerdict?: (object: string, recordId: string, context: any) => Promise<'allow' | 'deny' | 'abstain' | string>; /** * [#21771] Enforcement's own read question for an addressed by-id write: * would the read door, asked by this principal, NOT return the record? @@ -1813,6 +1828,39 @@ async function applyRecordAttribution( effect: recipMatchesUser ? 'admits' : 'neutral', }; }); + // [ADR-0142 D3/D4] The queue story, attributed on this layer beside the + // shares, because the sharing service decides it: a record that SITS in a + // queue (`owner_id` empty, `queue` set) is read and edited by the queue's + // members — never deleted, so a delete explanation reports nothing — and a + // `transfer` of an unowned record by the principal is a CLAIM when the + // sharing model positively admits them, which needs no transfer grant. + if (recordExists && engineOp !== 'delete' && hasOwnerField && (ownerRaw == null || ownerRaw === '') && context?.userId != null) { + const row = record as Record; + const sittingIn = typeof row.queue === 'string' && row.queue !== '' ? row.queue : null; + if (sittingIn && deps.queueMember) { + const organizationId = row.organization_id != null && row.organization_id !== '' + ? String(row.organization_id) + : (context?.tenantId != null ? String(context.tenantId) : null); + const member = await deps.queueMember(String(context.userId), sittingIn, organizationId).catch(() => false); + shareRules.push({ + kind: 'queue_membership', + name: sittingIn, + via: member ? 'member of the queue the record sits in' : 'not a member of the queue the record sits in', + effect: member ? 'admits' : 'neutral', + }); + } + if (engineOp === 'transfer' && deps.claimVerdict) { + const verdict = await deps.claimVerdict(object, recordId, context).catch(() => 'deny'); + shareRules.push({ + kind: 'queue_claim', + name: sittingIn ?? OWNER_FIELD, + via: verdict === 'allow' + ? 'a claim: the record is unowned and the sharing model admits the principal, so taking it needs no transfer grant' + : 'not a claim: the sharing model does not positively admit the principal to this unowned record', + effect: verdict === 'allow' ? 'admits' : 'neutral', + }); + } + } // A rejected read filter is not a `null` one: the matcher reads `null` as // "no filter" and admits the row, while the find it explains fails. const sharingFilterOrFault = deps.sharingReadFilter && recordExists diff --git a/packages/plugins/plugin-security/src/ownership-queue-gate.test.ts b/packages/plugins/plugin-security/src/ownership-queue-gate.test.ts new file mode 100644 index 00000000000..298f5c35e63 --- /dev/null +++ b/packages/plugins/plugin-security/src/ownership-queue-gate.test.ts @@ -0,0 +1,328 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0142 D3/D4] The ownership gate (authz middleware step 3.5) over the + * `queue` anchor: every admission and every refusal of the table in + * `ownership-queue-gate.ts`, driven through the registered middleware. + * + * The sharing service and the ownership-queue service are doubles with the + * real contracts' shapes; what is under test is what the gate does with their + * answers. The engine half of the claim — the write made conditional on the + * owner still being empty, and 409 for the loser — is pinned in + * `packages/objectql/src/ownership-queue.test.ts`; here the pin is that the + * gate ASKS for it (`opCtx.writePrecondition`). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; +import { QueueNotFoundError } from './errors.js'; +import { CLAIM_REFUSAL, INSERT_QUEUE_REFUSAL, MOVE_REFUSAL, RELEASE_REFUSAL } from './ownership-queue-gate.js'; + +const runEngineWriteBody = async (opCtx: any): Promise => { + const seam = opCtx?.postHookWriteImageCheck; + if (!seam) return; + seam.honoured = true; + if (opCtx.operation === 'update') { + await seam.evaluate([]); + return; + } + await seam.evaluate(Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]); +}; + +const memberSet: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, +} as any; +const transferSet: PermissionSet = { + name: 'can_transfer', + label: 'Transfer', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowTransfer: true } }, +} as any; + +/** The queues the double knows, per organization, with their members. */ +const QUEUES: Record> = { + 'org-1': { support_intake: ['u1', 'u2'], billing: ['u9'] }, + 'org-2': { support_intake: ['u7'] }, +}; + +interface Harness { + run: (opCtx: any) => Promise; + checkEdit: ReturnType; +} + +const boot = async (opts: { + sets: PermissionSet[]; + /** The stored row every by-id read returns (`null` = the caller cannot read it). */ + row?: Record | null; + /** The sharing service's tri-state write verdict for the row. */ + verdict?: 'allow' | 'deny' | 'abstain'; + /** Register the ownership-queue service (default true). */ + queues?: boolean; + /** Object field names (default: a user-owned object, so `queue` is present). */ + fields?: string[]; +}): Promise => { + const fields: Record = {}; + for (const f of opts.fields ?? ['id', 'organization_id', 'owner_id', 'queue', 'name']) fields[f] = { name: f }; + const schema: any = { name: 'case', fields }; + let middleware: any; + const findOne = vi.fn(async (object: string, query: any) => { + assertEngineFindOnePredicate(object, query as EngineFindOneQueryInput); + return opts.row === undefined ? null : opts.row ? { ...opts.row } : null; + }); + const ql = { + registerMiddleware: (mw: any) => { if (!middleware) middleware = mw; }, + getSchema: () => schema, + findOne, + find: vi.fn(async () => [] as Record[]), + }; + const checkEdit = vi.fn(async () => opts.verdict ?? 'abstain'); + const services: Record = { + manifest: { register: vi.fn() }, + objectql: ql, + metadata: { get: async () => schema, list: async () => opts.sets }, + sharing: { checkEdit, checkDelete: vi.fn(async () => 'abstain') }, + }; + if (opts.queues !== false) { + services['ownership-queue'] = { + resolveQueue: vi.fn(async (name: string, org: string | null) => + org && QUEUES[org]?.[name] ? { name, label: name, source: 'authored', organizationId: org, members: [] } : null), + isMember: vi.fn(async (userId: string, name: string, org: string | null) => + !!(org && QUEUES[org]?.[name]?.includes(userId))), + }; + } + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx); + await plugin.start(ctx); + return { + checkEdit, + run: async (opCtx: any) => { + await middleware(opCtx, () => runEngineWriteBody(opCtx)); + return opCtx; + }, + }; +}; + +const as = (userId: string, extra: Record = {}) => + ({ userId, tenantId: 'org-1', positions: [], permissions: [], ...extra }); +const update = (data: Record, context = as('u1')) => + ({ object: 'case', operation: 'update', data: { id: 'c1', ...data }, context }); +const insert = (data: Record | Record[], context = as('u1')) => + ({ object: 'case', operation: 'insert', data, context }); + +const SITTING = { id: 'c1', owner_id: null, queue: 'support_intake', organization_id: 'org-1' }; +const OWNED_BY_U1 = { id: 'c1', owner_id: 'u1', queue: 'support_intake', organization_id: 'org-1' }; + +describe('[ADR-0142 D4] claim — an unowned record, to the caller, by id, where sharing admits', () => { + it('is admitted without the transfer grant, and the write is made conditional on the owner still being empty', async () => { + const h = await boot({ sets: [memberSet], row: SITTING, verdict: 'allow' }); + const opCtx = await h.run(update({ owner_id: 'u1' })); + expect(opCtx.data.owner_id).toBe('u1'); + expect(opCtx.writePrecondition).toEqual({ owner_id: null }); + }); + + it('is admitted on a record with no queue at all, when sharing positively admits (an edit share, Modify All)', async () => { + const h = await boot({ sets: [memberSet], row: { ...SITTING, queue: null }, verdict: 'allow' }); + const opCtx = await h.run(update({ owner_id: 'u1' })); + expect(opCtx.writePrecondition).toEqual({ owner_id: null }); + }); + + it.each(['abstain', 'deny'] as const)('is refused when the sharing verdict is %s — mere passage is not a basis', async (verdict) => { + const h = await boot({ sets: [memberSet], row: SITTING, verdict }); + await expect(h.run(update({ owner_id: 'u1' }))).rejects.toThrow(CLAIM_REFUSAL); + }); + + it('claims for the caller only: naming another user is a transfer', async () => { + const h = await boot({ sets: [memberSet], row: SITTING, verdict: 'allow' }); + await expect(h.run(update({ owner_id: 'u2' }))).rejects.toThrow(/changing record ownership on update requires the transfer grant/); + }); + + it('is refused on an owned record (that is a transfer, not a claim)', async () => { + const h = await boot({ sets: [memberSet], row: { ...SITTING, owner_id: 'u2' }, verdict: 'allow' }); + await expect(h.run(update({ owner_id: 'u1' }))).rejects.toThrow(/changing record ownership/); + }); + + it('is refused on a predicate (bulk) update: no single pre-image, no one-owner precondition', async () => { + const h = await boot({ sets: [memberSet], row: SITTING, verdict: 'allow' }); + const opCtx = { + object: 'case', operation: 'update', data: { owner_id: 'u1' }, + options: { where: { queue: 'support_intake' }, multi: true }, context: as('u1'), + }; + await expect(h.run(opCtx)).rejects.toThrow(/system-managed/); + }); + + it('is refused for a row the caller cannot read', async () => { + const h = await boot({ sets: [memberSet], row: null, verdict: 'allow' }); + await expect(h.run(update({ owner_id: 'u1' }))).rejects.toThrow(); + }); + + it('a claim that also changes the queue is a move, refused without the grant', async () => { + const h = await boot({ sets: [memberSet], row: SITTING, verdict: 'allow' }); + await expect(h.run(update({ owner_id: 'u1', queue: 'billing' }))).rejects.toThrow(MOVE_REFUSAL); + }); + + it('a claim that echoes the stored queue is still a claim', async () => { + const h = await boot({ sets: [memberSet], row: SITTING, verdict: 'allow' }); + const opCtx = await h.run(update({ owner_id: 'u1', queue: 'support_intake' })); + expect(opCtx.writePrecondition).toEqual({ owner_id: null }); + }); +}); + +describe('[ADR-0142 D4] release — the owner, back to the home queue', () => { + it('is admitted for the owner of a record with a home queue', async () => { + const h = await boot({ sets: [memberSet], row: OWNED_BY_U1 }); + const opCtx = await h.run(update({ owner_id: null })); + expect(opCtx.data.owner_id).toBeNull(); + expect(opCtx.writePrecondition).toBeUndefined(); + }); + + it('is refused for a record with no home queue (that is a disown, which needs the grant)', async () => { + const h = await boot({ sets: [memberSet], row: { ...OWNED_BY_U1, queue: null } }); + await expect(h.run(update({ owner_id: null }))).rejects.toThrow(RELEASE_REFUSAL); + }); + + it('is refused for a non-owner', async () => { + const h = await boot({ sets: [memberSet], row: { ...OWNED_BY_U1, owner_id: 'u2' } }); + await expect(h.run(update({ owner_id: null }))).rejects.toThrow(/changing record ownership/); + }); + + it('a release into ANOTHER queue is a move, refused without the grant', async () => { + const h = await boot({ sets: [memberSet], row: OWNED_BY_U1 }); + await expect(h.run(update({ owner_id: null, queue: 'billing' }))).rejects.toThrow(MOVE_REFUSAL); + }); +}); + +describe('[ADR-0142 D3] move — a changed queue is transfer-class', () => { + it('is refused without the grant, even for a member of both queues', async () => { + const h = await boot({ sets: [memberSet], row: SITTING }); + await expect(h.run(update({ queue: 'billing' }))).rejects.toThrow(MOVE_REFUSAL); + }); + + it('clearing the queue is a move too', async () => { + const h = await boot({ sets: [memberSet], row: SITTING }); + await expect(h.run(update({ queue: null }))).rejects.toThrow(MOVE_REFUSAL); + }); + + it('an echo of the stored queue is not a move', async () => { + const h = await boot({ sets: [memberSet], row: OWNED_BY_U1 }); + await expect(h.run(update({ name: 'x', queue: 'support_intake' }))).resolves.toBeTruthy(); + }); + + it('is admitted under the grant when the queue resolves in the row\'s organization', async () => { + const h = await boot({ sets: [memberSet, transferSet], row: SITTING }); + const opCtx = await h.run(update({ queue: 'billing' }, as('u1', { permissions: ['can_transfer'] }))); + expect(opCtx.data.queue).toBe('billing'); + }); + + it('a dispatcher parks an owned record in one write under the grant', async () => { + const h = await boot({ sets: [memberSet, transferSet], row: OWNED_BY_U1 }); + const opCtx = await h.run(update({ owner_id: null, queue: 'billing' }, as('u5', { permissions: ['can_transfer'] }))); + expect(opCtx.data).toMatchObject({ owner_id: null, queue: 'billing' }); + }); + + it('QUEUE_NOT_FOUND: under the grant, a name that resolves nowhere in the row\'s organization is refused (422)', async () => { + const h = await boot({ sets: [memberSet, transferSet], row: SITTING }); + const err = await h.run(update({ queue: 'no_such_queue' }, as('u1', { permissions: ['can_transfer'] }))).catch((e) => e); + expect(err).toBeInstanceOf(QueueNotFoundError); + expect(err).toMatchObject({ code: 'QUEUE_NOT_FOUND', status: 422, statusCode: 422 }); + expect(err.message).not.toMatch(/^\[Security\] Access denied/); + }); + + it('a predicate (bulk) update writing queue fails closed without the grant', async () => { + const h = await boot({ sets: [memberSet], row: SITTING }); + const opCtx = { + object: 'case', operation: 'update', data: { queue: 'billing' }, + options: { where: { status: 'open' }, multi: true }, context: as('u1'), + }; + await expect(h.run(opCtx)).rejects.toThrow(MOVE_REFUSAL); + }); +}); + +describe('[ADR-0142 D3] insert naming a queue', () => { + it('a member\'s insert is an OWNED record with a home queue — the stamp does not stand down', async () => { + const h = await boot({ sets: [memberSet] }); + const opCtx = await h.run(insert({ name: 'A', queue: 'support_intake', organization_id: 'org-1' })); + expect(opCtx.data).toMatchObject({ owner_id: 'u1', queue: 'support_intake' }); + }); + + it('a member supplying an empty owner beside the queue is still stamped (parking needs the grant)', async () => { + const h = await boot({ sets: [memberSet] }); + const opCtx = await h.run(insert({ name: 'A', owner_id: null, queue: 'support_intake' })); + expect(opCtx.data.owner_id).toBe('u1'); + }); + + it('a non-member is refused without the grant', async () => { + const h = await boot({ sets: [memberSet] }); + await expect(h.run(insert({ name: 'A', queue: 'billing' }))).rejects.toThrow(INSERT_QUEUE_REFUSAL); + }); + + it('membership is asked in the row\'s organization, not another one', async () => { + const h = await boot({ sets: [memberSet] }); + await expect(h.run(insert({ name: 'A', queue: 'support_intake', organization_id: 'org-2' }))).rejects.toThrow(INSERT_QUEUE_REFUSAL); + }); + + it('a grant holder parks a new record in one write: owner supplied empty beside the queue', async () => { + const h = await boot({ sets: [memberSet, transferSet] }); + const opCtx = await h.run(insert({ name: 'A', owner_id: null, queue: 'billing' }, as('u5', { permissions: ['can_transfer'] }))); + expect(opCtx.data).toMatchObject({ owner_id: null, queue: 'billing' }); + }); + + it('a grant holder who leaves owner out is stamped as before — only an explicit empty owner parks', async () => { + const h = await boot({ sets: [memberSet, transferSet] }); + const opCtx = await h.run(insert({ name: 'A', queue: 'billing' }, as('u5', { permissions: ['can_transfer'] }))); + expect(opCtx.data.owner_id).toBe('u5'); + }); + + it('a foreign owner beside the queue still needs the grant (the forge rule)', async () => { + const h = await boot({ sets: [memberSet] }); + await expect(h.run(insert({ name: 'A', owner_id: 'u2', queue: 'support_intake' }))).rejects.toThrow(/creating a record owned by another user/); + }); + + it('QUEUE_NOT_FOUND for a name that resolves nowhere, member or not', async () => { + const h = await boot({ sets: [memberSet] }); + await expect(h.run(insert({ name: 'A', queue: 'Support Intake' }))).rejects.toBeInstanceOf(QueueNotFoundError); + }); + + it('QUEUE_NOT_FOUND for a value that is not a name', async () => { + const h = await boot({ sets: [memberSet] }); + await expect(h.run(insert({ name: 'A', queue: 42 }))).rejects.toBeInstanceOf(QueueNotFoundError); + }); + + it('QUEUE_NOT_FOUND with no ownership-queue service registered — nothing resolves, fail closed', async () => { + const h = await boot({ sets: [memberSet, transferSet], queues: false }); + await expect(h.run(insert({ name: 'A', queue: 'support_intake' }, as('u1', { permissions: ['can_transfer'] })))) + .rejects.toBeInstanceOf(QueueNotFoundError); + }); + + it('a batch insert is judged per row', async () => { + const h = await boot({ sets: [memberSet] }); + await expect(h.run(insert([{ name: 'a', queue: 'support_intake' }, { name: 'b', queue: 'billing' }]))) + .rejects.toThrow(INSERT_QUEUE_REFUSAL); + }); + + it('an object without the queue column is untouched by the queue gate', async () => { + const h = await boot({ sets: [memberSet], fields: ['id', 'organization_id', 'owner_id', 'name'] }); + const opCtx = await h.run(insert({ name: 'A' })); + expect(opCtx.data.owner_id).toBe('u1'); + }); +}); + +describe('[ADR-0142 D3 / ADR-0138 D1] the guest never supplies the queue', () => { + const guest = { userId: undefined, tenantId: 'org-1', positions: [], permissions: [], principalKind: 'guest' }; + + it('refuses a guest insert naming a queue, whatever it is granted', async () => { + const h = await boot({ sets: [memberSet, transferSet] }); + await expect(h.run(insert({ name: 'A', queue: 'support_intake' }, { ...guest, permissions: ['can_transfer'] } as any))) + .rejects.toThrow(/'queue' on 'case' cannot be set by an anonymous caller/); + }); +}); diff --git a/packages/plugins/plugin-security/src/ownership-queue-gate.ts b/packages/plugins/plugin-security/src/ownership-queue-gate.ts new file mode 100644 index 00000000000..fcb7292be06 --- /dev/null +++ b/packages/plugins/plugin-security/src/ownership-queue-gate.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0142 D3/D4] The pieces of the ownership gate (authz middleware step + * 3.5) that read the `queue` column: what counts as a supplied queue, which + * organization a queue is resolved in, the `QUEUE_NOT_FOUND` check, and the + * three refusal sentences. + * + * The admissions themselves stay in the step, beside the `owner_id` rules they + * extend — a claim, a release and a move are each a reading of one supplied + * change-set against one pre-image, and splitting the reading across two + * files would let the two anchors drift. + * + * | write | admitted when | + * |-----------------------------------------|------------------------------------------------------------| + * | insert naming a queue | the transfer grant, or the caller is a member of the queue | + * | insert, `owner_id` empty, queue named | parks only under the grant; otherwise the stamp stands | + * | claim — `owner_id`: empty → self | by id, and the sharing verdict for the row is `allow` | + * | release — `owner_id`: self → empty | the record has a home queue, and `queue` is unchanged | + * | move — `queue` changed | the transfer grant | + * + * ⛔ The guest supplies neither anchor, whatever it is granted. + */ + +import type { IOwnershipQueueService } from '@objectstack/spec/contracts'; +import { QueueNotFoundError } from './errors.js'; + +/** The injected ownership-queue column (`applySystemFields`, ADR-0142 D3). */ +export const QUEUE_FIELD = 'queue'; + +/** The queue-service slice the gate reads. */ +export type OwnershipQueueGateService = Pick; + +/** Refusal: a claim the step did not admit (ADR-0142 D4 wording). */ +export const CLAIM_REFUSAL = 'claim only an unowned record the sharing model admits you to'; +/** Refusal: a release the step did not admit. */ +export const RELEASE_REFUSAL = 'release only your own record, back to its queue'; +/** Refusal: a change of `queue` without the grant. */ +export const MOVE_REFUSAL = 'moving a record between queues requires the transfer grant'; +/** Refusal: an insert naming a queue the caller is not a member of. */ +export const INSERT_QUEUE_REFUSAL = + 'creating a record in a queue you are not a member of requires the transfer grant'; + +/** A non-empty string value, or `null` (absent, `null`, `''` and every non-string are "no queue"). */ +export function queueName(value: unknown): string | null { + return typeof value === 'string' && value !== '' ? value : null; +} + +/** + * Does `row` carry `queue` as an own property — the change-set SUPPLIES the + * anchor? Own-property only, for the reason the owner test gives: a polluted + * prototype must not make an ordinary update look like an anchor write. + */ +export function writesQueue(row: unknown): row is Record { + return !!row && typeof row === 'object' && !Array.isArray(row) && Object.prototype.hasOwnProperty.call(row, QUEUE_FIELD); +} + +/** + * The organization a queue is resolved in for this row: the row's own + * `organization_id` (the stored one, for an update), else the request's + * active organization. A queue is an organization's (ADR-0142 D1), so a name + * that resolves in another organization does not resolve here. + */ +export function queueOrganizationOf(row: Record | null | undefined, context: any): string | null { + const own = row?.organization_id; + if (own != null && own !== '') return String(own); + return context?.tenantId != null && context.tenantId !== '' ? String(context.tenantId) : null; +} + +/** + * Refuse with `QUEUE_NOT_FOUND` unless `value` names a queue that resolves in + * `organizationId`. A value that is not a non-empty string names no queue. + * With no queue service registered nothing resolves — the fail-closed answer. + * A resolution that THROWS propagates: a store fault is not evidence that the + * queue is absent. + */ +export async function assertQueueResolves( + queues: OwnershipQueueGateService | null, + object: string, + value: unknown, + organizationId: string | null, +): Promise { + const name = queueName(value); + if (name === null || !queues) throw new QueueNotFoundError(object, value); + const resolved = await queues.resolveQueue(name, organizationId); + if (!resolved) throw new QueueNotFoundError(object, name); + return name; +} diff --git a/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts b/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts index 7cc992c887d..f1fb82f6ede 100644 --- a/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts +++ b/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts @@ -87,7 +87,7 @@ import { renderOperationMessage } from '@objectstack/spec/system'; // [#19989] The engine's own update-dispatch predicate, asked rather than // re-derived: step 3.6 needs to know which row the ENGINE will write. import { resolveEngineUpdateDispatch, type EngineUpdateDispatchData } from '@objectstack/metadata-core'; -import type { II18nService, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; +import type { II18nService, IMetadataService, IObjectQLEngine, SharingWriteVerdict } from '@objectstack/spec/contracts'; import { crossFieldClassRefusalCarriedBy, findCrossFieldClassRefusal } from '@objectstack/formula'; import type { SecurityPlugin } from './security-plugin.js'; import { isGuestEnvelope, requiredCapsForOperation } from './security-plugin.js'; @@ -106,6 +106,17 @@ import { } from './unresolved-posture.js'; import { assertReadableQueryFields } from './predicate-guard.js'; import { PermissionDeniedError, MaskedValueWriteError } from './errors.js'; +import { + QUEUE_FIELD, + CLAIM_REFUSAL, + INSERT_QUEUE_REFUSAL, + MOVE_REFUSAL, + RELEASE_REFUSAL, + assertQueueResolves, + queueName, + queueOrganizationOf, + writesQueue, +} from './ownership-queue-gate.js'; import { assertEngineOwnedWriteAllowed } from './system-write-guard.js'; import { grantRowPolicySpeaks, stampExternalGrant } from './external-owd-grant-stamp.js'; @@ -242,18 +253,20 @@ export interface AuthzMiddlewareHost { getCallerPreImage: SecurityPlugin['getCallerPreImage']; getObjectFieldNames: SecurityPlugin['getObjectFieldNames']; maskOperationResult: SecurityPlugin['maskOperationResult']; + ownershipQueues: SecurityPlugin['ownershipQueues']; raiseWriteWallRefusal: SecurityPlugin['raiseWriteWallRefusal']; readableRowsScopeForPredicateWrite: SecurityPlugin['readableRowsScopeForPredicateWrite']; resolveCallerPosture: SecurityPlugin['resolveCallerPosture']; resolveOperationPrincipals: SecurityPlugin['resolveOperationPrincipals']; resolvePermissionSetsForContext: SecurityPlugin['resolvePermissionSetsForContext']; resolvePreImageFloorDrop: SecurityPlugin['resolvePreImageFloorDrop']; + resolveSharingWriteVerdict: SecurityPlugin['resolveSharingWriteVerdict']; writeCheckFieldOptions: SecurityPlugin['writeCheckFieldOptions']; } /** The authorization middleware registration `SecurityPlugin#start` runs; the comment above its call there is its contract. */ export function registerAuthzMiddleware( - this: Pick, + this: Pick, ctx: PluginContext, ql: IObjectQLEngine, metadata: IMetadataService, @@ -491,6 +504,10 @@ export function registerAuthzMiddleware( // the ordinary path and until step 2.7 runs; step 3.6 reads it so the // post-image is held to the delegator's own check as well. let delegatorPreImageFloorOpts: RlsFilterOptions | null = null; + // [ADR-0142 D4] The sharing write verdict step 2.7 obtained for the row it + // judged, where it asked one (the floor in play, not delegated). Step 3.5's + // claim admission reads it rather than asking a second time. + let preImageSharingVerdict: { id: string; verdict: SharingWriteVerdict } | null = null; // [ADR-0066 D2/D3] Resolve the object's security posture (private flag, // platform-global flag, capability contract) once for the checks below. @@ -963,6 +980,9 @@ export function registerAuthzMiddleware( opCtx.context, permissionSets, !!delegatorSets, + (verdict) => { + if (rlsOperation === 'update') preImageSharingVerdict = { id: String(targetId), verdict }; + }, ); // [commit 6feac910b] The COVERAGE VOUCH for ADR-0055 details. This gate and the // master gate (step 2.8, immediately below) run on the same middleware @@ -1319,6 +1339,15 @@ export function registerAuthzMiddleware( // back) is tolerated by comparing against the pre-image; a bulk // change-set carrying `owner_id` has no pre-image to compare and // fails CLOSED. + // • QUEUE — [ADR-0142 D3/D4] the ownership-queue column is the second + // anchor, owned the same way. An insert naming a queue is admitted for + // a member of it or under the grant (and must resolve: QUEUE_NOT_FOUND + // otherwise); an update that changes it is a MOVE, grant only. Two + // owner changes are admitted without the grant: the CLAIM (an unowned + // record, to the caller, by id, where the sharing verdict is `allow`; + // made conditional on the owner still being empty) and the RELEASE + // (the owner, back to the record's home queue). The full table is + // `ownership-queue-gate.ts`. // • GUEST — [ADR-0138 D1] the guest is a principal, never an owner. For // the anonymous principal ({@link isGuestEnvelope}) the step stamps // nothing: there is no user id to stamp, and nothing stands in for @@ -1372,19 +1401,42 @@ export function registerAuthzMiddleware( const isScalarId = (v: unknown): v is string | number => (typeof v === 'string' && v !== '') || (typeof v === 'number' && Number.isFinite(v)); - // Cheap pre-check: does any row actually WRITE owner_id? On update this - // skips the whole guard (and the field-set resolution) for the common - // path whose change-set never mentions the anchor. Insert always runs — - // it must stamp an absent owner. - if (isInsert || rows.some(writesOwner)) { + // Cheap pre-check: does any row actually WRITE an anchor (`owner_id`, or + // [ADR-0142 D3] `queue`)? On update this skips the whole guard (and the + // field-set resolution) for the common path whose change-set mentions + // neither. Insert always runs — it must stamp an absent owner. + if (isInsert || rows.some(writesOwner) || rows.some(writesQueue)) { const fields = await this.getObjectFieldNames(metadata, opCtx.object, ql); if (fields?.has('owner_id')) { const userId = opCtx.context?.userId; + // [ADR-0142 D3] The ownership-queue column rides with `owner_id` — it + // is injected on exactly the objects the owner is — and this step + // owns it the way it owns the owner. The admissions are tabled in + // `ownership-queue-gate.ts`. + const hasQueue = fields.has(QUEUE_FIELD); + const queues = hasQueue ? this.ownershipQueues() : null; + const refusalDetails = { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets }; const denyOwnerWrite = (action: string): never => { throw new PermissionDeniedError( `[Security] Access denied: 'owner_id' on '${opCtx.object}' is system-managed — ` + `${action} requires the transfer grant (allowTransfer or modifyAllRecords)`, - { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets }, + refusalDetails, + ); + }; + // [ADR-0142 D4] A claim or a release the step did not admit names the + // condition that failed, and still names the grant for everything else. + const denyOwnershipChange = (condition: string): never => { + throw new PermissionDeniedError( + `[Security] Access denied: 'owner_id' on '${opCtx.object}' is system-managed — ${condition}; any ` + + 'other change of ownership requires the transfer grant (allowTransfer or modifyAllRecords)', + refusalDetails, + ); + }; + const denyQueueWrite = (condition: string): never => { + throw new PermissionDeniedError( + `[Security] Access denied: 'queue' on '${opCtx.object}' is system-managed — ${condition} ` + + '(allowTransfer or modifyAllRecords)', + refusalDetails, ); }; // [ADR-0138 D1] The guest branch (see the step's header). Its refusal @@ -1396,7 +1448,17 @@ export function registerAuthzMiddleware( `a guest never owns a record and never assigns one, whatever it is granted. Leave 'owner_id' out ` + `of the request: the owner of a row a guest writes is set by the object's own hooks or ` + `record-change flows, and stays unset when none sets it (ADR-0138 D1)`, - { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets }, + refusalDetails, + ); + }; + // [ADR-0142 D3] The queue is an ownership anchor too: a guest never + // routes a row, and no grant changes that. + const denyGuestQueueWrite = (): never => { + throw new PermissionDeniedError( + `[Security] Access denied: 'queue' on '${opCtx.object}' cannot be set by an anonymous caller — a ` + + `guest never puts a record in a queue, whatever it is granted. Leave 'queue' out of the request: ` + + `the object's assignment rules or its own hooks route a row a guest writes`, + refusalDetails, ); }; // Lazily evaluated + memoized (incl. a `false` result): the common @@ -1411,15 +1473,41 @@ export function registerAuthzMiddleware( } return transferGrant; }; + // A supplied queue is any own `queue` that is not empty: a value that + // is not a non-empty string names no queue and is refused as one. + const suppliesQueue = (r: Record): boolean => + hasQueue && writesQueue(r) && r.queue != null && r.queue !== ''; if (isInsert) { for (const row of rows) { if (!row || typeof row !== 'object' || Array.isArray(row)) continue; + // [ADR-0142 D3] A supplied queue is resolved in the row's + // organization, then admitted under the grant or for a member of + // it. The stamp below is unchanged, so a member's insert naming a + // queue is an OWNED record with a home queue; parking a new row in + // one write (`owner_id` supplied empty beside the queue) is the + // grant holder's alone. A supplied owner beside the queue meets + // the forge rule below as before: the caller's own id is what the + // stamp would write, any other id needs the grant. + let park = false; + if (hasQueue && writesQueue(row) && guest) { + if (suppliesQueue(row)) denyGuestQueueWrite(); + if (row.queue === '') row.queue = null; + } else if (suppliesQueue(row)) { + const organizationId = queueOrganizationOf(row, opCtx.context); + const name = await assertQueueResolves(queues, opCtx.object, row.queue, organizationId); + if (hasTransferGrant()) { + park = writesOwner(row) && (row.owner_id == null || row.owner_id === ''); + } else if (!(userId != null && queues && (await queues.isMember(String(userId), name, organizationId)))) { + denyQueueWrite(INSERT_QUEUE_REFUSAL); + } + } if (!writesOwner(row) || row.owner_id == null || row.owner_id === '') { + if (park) row.owner_id = null; // Auto-stamp the acting user (batch rows included — the // single-record-only stamp left bulk-inserted rows NULL-owned // and invisible to their creator). - if (userId) row.owner_id = userId; + else if (userId) row.owner_id = userId; // [ADR-0138 D1 item 5] A guest is stamped with nothing, and // its empty owner is left UNSET: an empty string would land // in the anchor as a value that is neither an owner nor none. @@ -1436,47 +1524,108 @@ export function registerAuthzMiddleware( } } } else { - // UPDATE — a supplied owner_id is a transfer/disown. Only the - // single-id no-op echo (a form save resending the UNCHANGED owner) - // is tolerated; an array / bulk change-set has no single pre-image - // to compare and fails CLOSED. + // UPDATE — a supplied owner_id is a transfer/disown, and a supplied + // queue that differs from the stored one is a MOVE (ADR-0142 D3). + // Tolerated without the grant: the single-id no-op echo (a form + // save resending the UNCHANGED anchor), and [ADR-0142 D4] the + // claim and the release. An array / bulk change-set has no single + // pre-image to compare and fails CLOSED. const single = !Array.isArray(opCtx.data); + const targetId = single ? this.extractSingleId(opCtx) : null; + // [ADR-0142 D4] A claim is a BY-ID write, by the engine's own + // dispatch: only that branch honours the one-owner precondition. + const claimable = targetId != null && + resolveEngineUpdateDispatch(opCtx.data as EngineUpdateDispatchData, opCtx.options).kind === 'by-id'; + // Read the pre-image under the CALLER's context (NOT isSystem): a + // form echo, a claim and a release only make sense for a row the + // caller can already see. This threads the caller's open + // transaction (an in-tx echo isn't spuriously denied) AND closes + // the owner-enumeration oracle a system read would open (a caller + // who can't read the row gets null → deny, indistinguishable from + // a non-owner). + // + // [#7505] A throw here is NOT evidence about ownership, so it + // propagates (the write is still refused — this throws before + // `next()`): the caller learns the store is down instead of that + // it tried to steal a record. The oracle is untouched: an absent + // row, and a row the caller cannot read, both come back `null` and + // still deny below. + const preImage = async (): Promise | null> => + targetId != null && this.ql ? this.getCallerPreImage(opCtx, targetId) : null; + // [ADR-0142 D4] The claim's sharing verdict: the one step 2.7 asked + // for this row where it asked one, else asked here. Under + // delegation both principals must be positively admitted. + const claimAdmitted = async (): Promise => { + const recordId = String(targetId); + const own = preImageSharingVerdict && preImageSharingVerdict.id === recordId + ? preImageSharingVerdict.verdict + : await this.resolveSharingWriteVerdict('update', opCtx.object, recordId, opCtx.context, permissionSets); + if (own !== 'allow') return false; + if (!delegatorSets) return true; + return ( + await this.resolveSharingWriteVerdict('update', opCtx.object, recordId, delegatorContext, delegatorSets) + ) === 'allow'; + }; for (const row of rows) { - if (!writesOwner(row)) continue; + const ownerWritten = writesOwner(row); + const queueWritten = hasQueue && writesQueue(row); + if (!ownerWritten && !queueWritten) continue; // [ADR-0138 D1] A guest assigns no ownership — not a transfer, - // not a disown, not an echo — and no grant changes that. - if (guest) denyGuestOwnerWrite(); - if (hasTransferGrant()) break; // authorized transfer — allow every row - let unchanged = false; - if (single && isScalarId(row.owner_id)) { - const targetId = this.extractSingleId(opCtx); - if (targetId != null && this.ql) { - // Read the pre-image under the CALLER's context (NOT - // isSystem): a form echo only makes sense for a row the - // caller can already see. This threads the caller's open - // transaction (an in-tx echo isn't spuriously denied) AND - // closes the owner-enumeration oracle a system read would - // open (a caller who can't read the row gets null → deny, - // indistinguishable from a non-owner). - // - // [#7505] A throw here is NOT evidence about ownership, so - // it no longer collapses into `unchanged = false`. That - // catch predates the probe distinguishing "row absent" - // from "could not be read": with both facts arriving as - // `null` it was the only available fail-closed answer, and - // it answered a store outage with `403 changing record - // ownership` — a refusal an SDK also treats as terminal. - // The fault now propagates (the write is still refused — - // this throws before `next()`), so the caller learns the - // store is down instead of that it tried to steal a - // record. The oracle is untouched: an absent row, and a - // row the caller cannot read, both still come back `null` - // and still deny below. - const pre = await this.getCallerPreImage(opCtx, targetId); - unchanged = !!pre && pre.owner_id != null && String(pre.owner_id) === String(row.owner_id); + // not a disown, not an echo, not a queue — and no grant changes that. + if (guest) { + if (ownerWritten) denyGuestOwnerWrite(); + denyGuestQueueWrite(); + } + if (hasTransferGrant()) { + // An authorized transfer or move. [ADR-0142 D3] A move still + // names a queue that resolves in the row's organization; an + // echo of the stored value is not a move. + if (queueWritten && suppliesQueue(row)) { + const pre = await preImage(); + if (!(pre && queueName(pre.queue) === row.queue)) { + await assertQueueResolves(queues, opCtx.object, row.queue, queueOrganizationOf(pre, opCtx.context)); + } } + continue; + } + const pre = await preImage(); + // [ADR-0142 D3] A changed queue is a move, whatever else the + // change-set does: denied without the grant. + if (queueWritten) { + const echo = !!pre && (row.queue == null || typeof row.queue === 'string') && + queueName(row.queue) === queueName(pre.queue); + if (!echo) denyQueueWrite(MOVE_REFUSAL); + } + if (!ownerWritten) continue; + const storedOwner = pre?.owner_id; + // The echo: the unchanged owner resent. + if (pre && isScalarId(row.owner_id) && storedOwner != null && String(storedOwner) === String(row.owner_id)) continue; + const self = userId != null ? String(userId) : null; + // [ADR-0142 D4] CLAIM — an unowned record, to the caller's own id, + // by id, on a row the sharing model positively admits the caller + // to (a queue member through the queue basis, an `edit` share, + // Modify All). The write is made conditional on the owner still + // being empty, so two claimants produce exactly one owner; the + // loser's write answers 409 CONCURRENT_UPDATE. + if ( + pre && (storedOwner == null || storedOwner === '') && + self !== null && isScalarId(row.owner_id) && String(row.owner_id) === self + ) { + if (claimable && (await claimAdmitted())) { + opCtx.writePrecondition = { ...(opCtx.writePrecondition ?? {}), owner_id: null }; + continue; + } + denyOwnershipChange(CLAIM_REFUSAL); + } + // [ADR-0142 D4] RELEASE — the owner hands the record back to its + // home queue (the queue is unchanged: a change was refused above), + // whether or not the owner is still a member of it. With no home + // queue it stays what it was: a disown, which needs the grant. + if (pre && (row.owner_id == null || row.owner_id === '') && self !== null && storedOwner != null && String(storedOwner) === self) { + if (hasQueue && queueName(pre.queue) !== null) continue; + denyOwnershipChange(RELEASE_REFUSAL); } - if (!unchanged) denyOwnerWrite(`changing record ownership on ${opCtx.operation}`); + denyOwnerWrite(`changing record ownership on ${opCtx.operation}`); } } } diff --git a/packages/plugins/plugin-security/src/security-plugin-explain-access.ts b/packages/plugins/plugin-security/src/security-plugin-explain-access.ts index cf880ce726f..f0834be70fe 100644 --- a/packages/plugins/plugin-security/src/security-plugin-explain-access.ts +++ b/packages/plugins/plugin-security/src/security-plugin-explain-access.ts @@ -68,7 +68,7 @@ * Nothing re-exports this module, so the published surface does not move. */ import { vetOrganizationClaim } from '@objectstack/core'; -import type { AuthoredRowWriteOperation } from '@objectstack/spec/contracts'; +import { OWNERSHIP_QUEUE_SERVICE, type AuthoredRowWriteOperation, type IOwnershipQueueService } from '@objectstack/spec/contracts'; import type { ExplainDecision, ExplainOperation, PermissionSet } from '@objectstack/spec/security'; import type { SecurityPlugin } from './security-plugin.js'; import { absentUnderCallerRead, requiredCapsForOperation, unreadableUnderCallerRead } from './security-plugin.js'; @@ -109,6 +109,7 @@ export interface ExplainAccessHost { resolvePermissionSetsForContext: SecurityPlugin['resolvePermissionSetsForContext']; resolvePreImageFloorDrop: SecurityPlugin['resolvePreImageFloorDrop']; resolveSharingReadFilter: SecurityPlugin['resolveSharingReadFilter']; + resolveSharingWriteVerdict: SecurityPlugin['resolveSharingWriteVerdict']; resolveWriteScopeForSharing: SecurityPlugin['resolveWriteScopeForSharing']; } @@ -121,7 +122,7 @@ type RlsFilterOptions = NonNullable, + this: Pick, request: { object: string; operation: string; userId?: string; recordId?: string }, callerContext?: any, ): Promise { @@ -201,6 +202,11 @@ export async function explainAccessForCaller( // sharing attribution. Resolved lazily; absent (no plugin-sharing) → the // record path still works, the sharing layer simply reports not_evaluated. const sharing = recordId ? (this.resolveKernelService?.('sharing') as any) : undefined; + // [ADR-0142 D3] The ownership-queue service, for the record's queue story. + const queueSvc = recordId + ? (this.resolveKernelService?.(OWNERSHIP_QUEUE_SERVICE) as Pick | undefined) + : undefined; + const ownershipQueues = queueSvc && typeof queueSvc.isMember === 'function' ? queueSvc : undefined; // [#19963] A record-grained WRITE verdict is computed from the by-id write // path's own inputs, so `record.visible` for `update` / `delete` is the @@ -429,6 +435,12 @@ export async function explainAccessForCaller( // above: the two go together. checkControlledByParentWrite: (o: string, rid: string, c: any) => this.checkControlledByParentWrite(o, rid, c), + // [ADR-0142 D3/D4] The queue story's two questions, asked of the same + // authorities the doors ask: the queue service's holder check (the + // access path's queue basis), and the tri-state sharing write verdict + // the ownership gate's claim admission reads. + ...(ownershipQueues ? { queueMember: (u: string, q: string, org: string | null) => ownershipQueues.isMember(u, q, org) } : {}), + claimVerdict: (o: string, rid: string, c: any) => this.resolveSharingWriteVerdict('update', o, rid, c), }, { object, operation, context: targetContext, recordId }, // The asker: the caller, whose read of the record decides whether this diff --git a/packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts b/packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts index 2abcb3521b3..94889331e7c 100644 --- a/packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts +++ b/packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts @@ -280,6 +280,7 @@ export async function resolvePreImageFloorDrop( context: any, permissionSets: PermissionSet[], delegated: boolean, + onVerdict?: (verdict: SharingWriteVerdict) => void, ): Promise { if (delegated) return false; const floorApplies = this.collectRLSPolicies( @@ -290,10 +291,11 @@ export async function resolvePreImageFloorDrop( context, ).some(isPlatformOwnershipFloorPolicy); if (!floorApplies) return false; - return ( - (await this.resolveSharingWriteVerdict(rlsOperation, object, recordId, context, permissionSets)) === - 'allow' - ); + const verdict = await this.resolveSharingWriteVerdict(rlsOperation, object, recordId, context, permissionSets); + // [ADR-0142 D4] Handed back to a caller that reads the verdict itself (the + // ownership gate's claim admission), so it is asked once per write. + onVerdict?.(verdict); + return verdict === 'allow'; } /** The body of `SecurityPlugin#delegatorOwnWriteFloorOptions`; the method's docblock there is its contract. */ diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index d10a363f1df..478d84787c9 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -275,6 +275,8 @@ import { } from '@objectstack/spec/security'; import { RLS_MEMBERSHIP_RESOLVER_SERVICE, + OWNERSHIP_QUEUE_SERVICE, + type IOwnershipQueueService, type IRlsMembershipResolver, type SharingWriteVerdict, type AuthoredRowWriteVerdict, @@ -2134,7 +2136,7 @@ export class SecurityPlugin implements Plugin { const readLogger = () => this.logger; const readPermissionEvaluator = () => this.permissionEvaluator; const readQl = () => this.ql; - const host: Pick = { + const host: Pick = { addressedByIdWriteId: (...a) => this.addressedByIdWriteId(...a), addressedPredicateWrite: (...a) => this.addressedPredicateWrite(...a), assertControlledByParentWrite: (...a) => this.assertControlledByParentWrite(...a), @@ -2158,6 +2160,7 @@ export class SecurityPlugin implements Plugin { getObjectFieldNames: (...a) => this.getObjectFieldNames(...a), get logger() { return readLogger(); }, maskOperationResult: (...a) => this.maskOperationResult(...a), + ownershipQueues: (...a) => this.ownershipQueues(...a), get permissionEvaluator() { return readPermissionEvaluator(); }, get ql() { return readQl(); }, raiseWriteWallRefusal: (...a) => this.raiseWriteWallRefusal(...a), @@ -2166,6 +2169,7 @@ export class SecurityPlugin implements Plugin { resolveOperationPrincipals: (...a) => this.resolveOperationPrincipals(...a), resolvePermissionSetsForContext: (...a) => this.resolvePermissionSetsForContext(...a), resolvePreImageFloorDrop: (...a) => this.resolvePreImageFloorDrop(...a), + resolveSharingWriteVerdict: (...a) => this.resolveSharingWriteVerdict(...a), writeCheckFieldOptions: (...a) => this.writeCheckFieldOptions(...a), }; registerAuthzMiddleware.call(host, ctx, ql, metadata); @@ -2338,7 +2342,7 @@ export class SecurityPlugin implements Plugin { const readQl = () => this.ql; const readResolveKernelService = () => this.resolveKernelService; const readTenancyPosture = () => this.tenancyPosture; - const host: Pick = { + const host: Pick = { baselinePermissionSets: this.baselinePermissionSets, checkControlledByParentWrite: (...a) => this.checkControlledByParentWrite(...a), collectRLSPolicies: (...a) => this.collectRLSPolicies(...a), @@ -2355,6 +2359,7 @@ export class SecurityPlugin implements Plugin { resolvePermissionSetsForContext: (...a) => this.resolvePermissionSetsForContext(...a), resolvePreImageFloorDrop: (...a) => this.resolvePreImageFloorDrop(...a), resolveSharingReadFilter: (...a) => this.resolveSharingReadFilter(...a), + resolveSharingWriteVerdict: (...a) => this.resolveSharingWriteVerdict(...a), resolveWriteScopeForSharing: (...a) => this.resolveWriteScopeForSharing(...a), get tenancyPosture() { return readTenancyPosture(); }, }; @@ -2565,13 +2570,29 @@ export class SecurityPlugin implements Plugin { context: any, permissionSets: PermissionSet[], delegated: boolean, + onVerdict?: (verdict: SharingWriteVerdict) => void, ): Promise { // [#25223 cut 10] The body moved to `security-plugin-sharing-verdicts.ts` (see `SharingVerdictsHost`). const host: Pick = { collectRLSPolicies: (...a) => this.collectRLSPolicies(...a), resolveSharingWriteVerdict: (...a) => this.resolveSharingWriteVerdict(...a), }; - return resolvePreImageFloorDrop.call(host, rlsOperation, object, recordId, context, permissionSets, delegated); + return resolvePreImageFloorDrop.call(host, rlsOperation, object, recordId, context, permissionSets, delegated, onVerdict); + } + + /** + * [ADR-0142 D3/D4] The ownership-queue service (`plugin-sharing`), resolved + * at use — or `null` when no plugin registers it. The ownership gate reads + * it to resolve a supplied `queue` and to test a member; with none, every + * supplied queue resolves nowhere (`QUEUE_NOT_FOUND`), the fail-closed + * answer. A stack that DECLARES queues cannot boot without it (the engine's + * `kernel:ready` refusal), so `null` here means no queue was ever declared. + */ + private ownershipQueues(): Pick | null { + const svc = this.resolveKernelService?.(OWNERSHIP_QUEUE_SERVICE) as + | Pick + | undefined; + return svc && typeof svc.resolveQueue === 'function' && typeof svc.isMember === 'function' ? svc : null; } /** diff --git a/packages/plugins/plugin-sharing/src/ownership-queue.test.ts b/packages/plugins/plugin-sharing/src/ownership-queue.test.ts index 26de972b76a..c05b9c8a7b8 100644 --- a/packages/plugins/plugin-sharing/src/ownership-queue.test.ts +++ b/packages/plugins/plugin-sharing/src/ownership-queue.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { QUEUE_FIELD_DEF } from '@objectstack/spec/data'; import { METADATA_ACTIVATION_TABLE } from '@objectstack/core'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { OwnershipQueueService, specsReadBusinessUnits } from './ownership-queue-service.js'; import { SharingService } from './sharing-service.js'; import { @@ -67,8 +68,14 @@ function makeEngine(opts: { (tables[object] ??= []).push({ ...data }); return data; }, - async update() { return {}; }, - async delete() { return {}; }, + async update(_object: string, data: any, options?: any) { + assertEngineUpdateDispatch(data, options); + return {}; + }, + async delete(_object: string, options?: any) { + assertEngineDeleteDispatch(options); + return {}; + }, registerHook(event: string, handler: (ctx: any) => any, o?: { object?: string; packageId?: string }) { hooks.push({ event, object: String(o?.object), handler, packageId: o?.packageId }); }, diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 07cf3b05c6c..161dd73e92d 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3591,6 +3591,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/plugins/plugin-security/src/ownership-queue-gate.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts", "verb": "delete", @@ -3831,6 +3836,26 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-sharing/src/ownership-queue.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-sharing/src/ownership-queue.test.ts", + "verb": "update", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-sharing/src/queue-recipient.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-sharing/src/queue-recipient.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-sharing/src/recipient-width.test.ts", "verb": "delete", From 95b3e6efe5e53b7fb03d6706bcc469934b9a709a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 15:15:41 +0000 Subject: [PATCH 05/23] feat(approvals,lint): queue approvers and assignment-rule checks (ADR-0142 D5/D6/D7) - plugin-approvals: a `queue` approver resolves to the queue's members at node entry through the ownership-queue service (addressing purpose), in the directory organization and bounded by the request org's readers. An empty queue warns and adds no slot; the `queue:` literal is never written again. - lint: `queue` joins the group-routed set of the approver empty-slate check, the org-axis red-line table admits the queue recipient, and two advisory assignment-rule checks report a user-id target and a target queue declared with no member. - ui: the flow designer stops warning that queue approvers resolve to nobody. - dogfood: the authz matrix gains the ownership-queue row and re-syncs the sharing-rules recipient list. Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- content/docs/automation/approvals.mdx | 20 ++-- packages/lint/src/authoring-rules.ts | 16 +++ packages/lint/src/index.ts | 7 ++ packages/lint/src/rule-explanations.ts | 46 ++++++- .../src/validate-approval-approvers.test.ts | 37 ++++-- .../lint/src/validate-approval-approvers.ts | 24 ++-- .../src/validate-assignment-rules.test.ts | 60 ++++++++++ .../lint/src/validate-assignment-rules.ts | 113 ++++++++++++++++++ .../src/validate-org-axis-red-lines.test.ts | 2 +- .../lint/src/validate-org-axis-red-lines.ts | 20 ++-- .../src/approval-service.test.ts | 79 +++++++++--- .../plugin-approvals/src/approval-service.ts | 84 +++++++++++-- .../plugin-approvals/src/approvals-plugin.ts | 13 ++ .../approver-cross-org.integration.test.ts | 26 ++++ .../plugin-approvals/test-typecheck-debt.json | 2 +- .../dogfood/test/authz-conformance.matrix.ts | 9 +- .../src/utils/approverIdentity.test.ts | 2 +- .../src/views/metadata-admin/i18n.ts | 4 +- .../FlowReferenceField.lookup.test.tsx | 7 +- .../inspectors/FlowReferenceField.tsx | 22 ++-- 20 files changed, 501 insertions(+), 92 deletions(-) create mode 100644 packages/lint/src/validate-assignment-rules.test.ts create mode 100644 packages/lint/src/validate-assignment-rules.ts diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx index d0635e052cc..dea305a6320 100644 --- a/content/docs/automation/approvals.mdx +++ b/content/docs/automation/approvals.mdx @@ -380,28 +380,28 @@ Only `approvers` is required on the node; everything else has a default (`behavior: 'first_response'`, `lockRecord: true`, `maxRevisions: 3`). Approver entries resolve by kind — `position`, `user`, `field`, `manager`, -`team`, `department`, `org_membership_level`, and `expression` +`team`, `department`, `org_membership_level`, `queue`, and `expression` (described in the [callout above](#3-the-approval-node) and in [Dynamic approvers](#dynamic-approvers-3447)); `org_membership_level` is the one that silently resolves to nobody when it's mistaken for a business -hierarchy. `queue` still parses so stored flows keep loading, but it is **not -implemented** by the runtime and is no longer offered for authoring (#3508) — -a queue entry resolves to nobody. Route to a `team`, `department`, or -`position` instead. `field`, `manager`, and `expression` resolve against the record's +hierarchy. A `queue` entry names an ownership queue by its machine name — one +an installed app declares, or one of the organization's own — and resolves to +the queue's members at node entry, through the same expansion that decides who +may claim the queue's records (ADR-0142 D6). `field`, `manager`, and `expression` resolve against the record's **live** state at node entry (#3447); `manager` additionally depends on `sys_user.manager_id`, which a platform admin sets from **Setup → Users → ⋯ → Set Manager** — see [the `manager` callout above](#3-the-approval-node). `position`, `department`, -`org_membership_level` and `expression` may additionally name which +`org_membership_level`, `queue` and `expression` may additionally name which organization's directory resolves them — see [Approving across organizations](#cross-org-approvers). What an entry that resolves to nobody leaves on the slate depends on its kind: -- **A person entry** (`manager`, `field`) adds no slot. Nor does an - `expression` that yields no ids. A `user` entry never resolves to nobody: its - value is the slot. +- **A person entry** (`manager`, `field`) adds no slot. Nor does a `queue` + with no members, or an `expression` that yields no ids. A `user` entry never + resolves to nobody: its value is the slot. - **A group entry** (`position`, `team`, `department`, `org_membership_level`) - keeps its `:` literal as a slot, and so do a `queue` entry and an + keeps its `:` literal as a slot, and so does an `expression` value that `resolveAs` re-expands into a group with nobody in it. A `position:` slot is decided by whoever is staffed into that position later. No person ever holds any other literal, so only an administrator can diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index c90051c1c63..4377304557e 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -130,6 +130,7 @@ import { validateSemanticRoles } from './validate-semantic-roles.js'; import { validateFormLayout } from './validate-form-layout.js'; import { validatePublicFormFileFields } from './validate-public-form-file-fields.js'; import { validateSeedReplaySafety } from './validate-seed-replay-safety.js'; +import { validateAssignmentRules } from './validate-assignment-rules.js'; import { validateSeedStateMachine } from './validate-seed-state-machine.js'; import { validateVisibilityPredicates } from './validate-visibility-predicates.js'; import { validatePredicatePathRefs } from './validate-predicate-path-refs.js'; @@ -1607,6 +1608,21 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT, run: (stack) => validateActionLocations(stack), }, + // [ADR-0142 D5] An assignment rule naming a user id carries one + // organization's data in package metadata, and one targeting a declared queue + // with no member parks every row it matches. Advisory: the engine never + // refuses an insert on a rule's account. CLI-only — the queue check reads the + // stack's `queues` beside its `assignmentRules`. + { + name: 'validateAssignmentRules', + tier: 'advisory', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-assignment-rules.ts', + surfaces: CLI_ONLY, + surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT, + run: (stack) => validateAssignmentRules(stack), + }, // framework#3434 — seeds replay on every boot, so a `mode: 'insert'` dataset // duplicates its table on every restart. { diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 6ca352cc6df..8b8cfc32d72 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -314,6 +314,13 @@ export { } from './validate-approval-approvers.js'; export type { ApprovalApproverFinding, ApprovalApproverSeverity } from './validate-approval-approvers.js'; +export { + validateAssignmentRules, + ASSIGNMENT_RULE_USER_TARGET, + ASSIGNMENT_RULE_QUEUE_UNSTAFFED, +} from './validate-assignment-rules.js'; +export type { AssignmentRuleFinding, AssignmentRuleSeverity } from './validate-assignment-rules.js'; + export { validateSeedReplaySafety, SEED_INSERT_MODE_DUPLICATES_ON_REPLAY, diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 6a2fa5fba76..a1e085b11fa 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -2699,7 +2699,7 @@ const APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY_EXPLANATION: RuleExplanation = { paragraphs: [ 'An approval node opens its request on the people its approvers expand to at runtime. When ' + 'EVERY approver on the node routes to a group whose membership is runtime data — a ' + - '`position`, a `team` or a `department` — and none is staffed, no person the node names can ' + + '`position`, a `team`, a `department` or a `queue` — and none is staffed, no person the node names can ' + 'act. Under the default `onEmptyApprovers: \'admin_rescue\'` the request then opens on the ' + "organization's administrators (the people holding its organization-admin capability) " + 'instead, and under the default `lockRecord: true` the record stays locked until one of them ' + @@ -4616,8 +4616,10 @@ const ORG_AXIS_CROSS_ORG_BU_GRANT_EXPLANATION: RuleExplanation = { 'column to scope against: the grant reaches every organization\'s rows, the ' + 'cross-organization business-unit tree the ADR rejected, arrived at by accident.', 'The other recipients are the sanctioned way to share a platform-global object (ADR-0066): ' + - '`user`, `team` and `position` expand flat, and `field` reads the users a column on the record ' + - 'itself names, so none of them walks a business-unit tree. A platform-global catalog everyone ' + + '`user`, `team` and `position` expand flat, `field` reads the users a column on the record ' + + 'itself names, and a `queue` is a work-distribution list whose declared members are positions, ' + + 'so none of them walks a business-unit tree here (a queue an organization staffs with a unit ' + + 'resolves that unit through the same org-predicated resolver at runtime). A platform-global catalog everyone ' + 'should read wants an organization-wide default of `public_read`, not a business-unit grant. ' + 'An error: it mirrors a real enforcement property, the org-predicated business-unit resolver, ' + 'so the failure moves from a silent wrong answer to an authoring fix.', @@ -4654,6 +4656,42 @@ const PERMISSION_RETIRED_LIFECYCLE_RESIDUE_EXPLANATION: RuleExplanation = { // ── Seed datasets (`validate-seed-replay-safety.ts`, `validate-seed-state-machine.ts`) ── +// ── Assignment rules (`validate-assignment-rules.ts`) ── + +const ASSIGNMENT_RULE_USER_TARGET_EXPLANATION: RuleExplanation = { + rule: 'assignment-rule-user-target', + covers: 'why an assignment rule should not name a user id', + paragraphs: [ + 'An assignment rule routes each inserted row of its object that is still unowned after the ' + + '`beforeInsert` hooks have run (ADR-0142 D5). An entry whose `assignTo` is `{ type: \'user\' }` ' + + 'writes that user id as the row\'s owner. A user id is one organization\'s data, and a ' + + 'package declares metadata for every organization it is installed into (ADR-0086 D1): in any ' + + 'other organization the id names nobody, and the row it routes is owned by no member there.', + 'Route to a queue instead. A declared queue\'s members are positions, which each organization ' + + 'staffs with its own people, and the entry can pick one of them (`round_robin`, ' + + '`least_loaded`) or park the row in the queue for a member to claim.', + 'A warning, not a refusal: the rule loads, and in the one organization the id belongs to it ' + + 'routes as written.', + ], +}; + +const ASSIGNMENT_RULE_QUEUE_UNSTAFFED_EXPLANATION: RuleExplanation = { + rule: 'assignment-rule-queue-unstaffed', + covers: 'why an empty declared queue parks every row it is sent', + paragraphs: [ + 'An entry that targets a queue picks from the queue\'s members in the row\'s organization, or ' + + 'parks the row in the queue when it declares no strategy. A queue this stack declares with ' + + 'no member at all is staffed only by an organization\'s own `sys_queue_member` rows, which ' + + 'are runtime data: until an organization adds members, every row the entry matches is parked ' + + 'unowned, and the engine logs the empty pool once per rule.', + 'Declare at least one member on the queue (`{ type: \'position\', value: \'\' }`). ' + + 'The rule cannot read runtime membership, so a queue that organizations staff themselves may be ' + + 'intended; a target this stack does not declare is an organization\'s own queue and is not judged.', + 'Advisory (`info`): the shape mirrors `approval-approvers-may-resolve-empty`, and the engine never ' + + 'refuses an insert on a rule\'s account.', + ], +}; + const SEED_INSERT_MODE_DUPLICATES_ON_REPLAY_EXPLANATION: RuleExplanation = { rule: 'seed-insert-mode-duplicates-on-replay', covers: 'why a seed dataset must be safe to replay', @@ -5133,6 +5171,8 @@ export const RULE_EXPLANATIONS: Readonly> = Obje [ORG_AXIS_PERMISSION_INHERITANCE_EXPLANATION.rule]: ORG_AXIS_PERMISSION_INHERITANCE_EXPLANATION, [ORG_AXIS_CROSS_ORG_BU_GRANT_EXPLANATION.rule]: ORG_AXIS_CROSS_ORG_BU_GRANT_EXPLANATION, [PERMISSION_RETIRED_LIFECYCLE_RESIDUE_EXPLANATION.rule]: PERMISSION_RETIRED_LIFECYCLE_RESIDUE_EXPLANATION, + [ASSIGNMENT_RULE_USER_TARGET_EXPLANATION.rule]: ASSIGNMENT_RULE_USER_TARGET_EXPLANATION, + [ASSIGNMENT_RULE_QUEUE_UNSTAFFED_EXPLANATION.rule]: ASSIGNMENT_RULE_QUEUE_UNSTAFFED_EXPLANATION, [SEED_INSERT_MODE_DUPLICATES_ON_REPLAY_EXPLANATION.rule]: SEED_INSERT_MODE_DUPLICATES_ON_REPLAY_EXPLANATION, [SEED_VALUE_OUTSIDE_STATE_MACHINE_EXPLANATION.rule]: SEED_VALUE_OUTSIDE_STATE_MACHINE_EXPLANATION, [SEMANTIC_ROLE_FIELD_UNPROVISIONED_EXPLANATION.rule]: SEMANTIC_ROLE_FIELD_UNPROVISIONED_EXPLANATION, diff --git a/packages/lint/src/validate-approval-approvers.test.ts b/packages/lint/src/validate-approval-approvers.test.ts index 7c0cc026f64..dfb7a83de1a 100644 --- a/packages/lint/src/validate-approval-approvers.test.ts +++ b/packages/lint/src/validate-approval-approvers.test.ts @@ -85,18 +85,32 @@ describe('validateApprovalApprovers', () => { expect(findings[0].hint).toContain("type: 'position'"); }); - // ── queue: declared-but-unenforced (#3508) ──────────────────────────────── + // ── queue: re-admitted (ADR-0142 D6) ────────────────────────────────────── - it('flags a queue approver as unsupported — the runtime resolves it to nobody', () => { + it('accepts a queue approver beside a person — the runtime expands it to the queue\'s members', () => { const findings = validateApprovalApprovers(stackWithApprovers([ - { type: 'queue', value: 'q_west' }, + { type: 'queue', value: 'support_tier1' }, { type: 'user', value: 'u1' }, // a real route keeps the node off the empty-slate rule ])); + expect(findings).toEqual([]); + expect(findings.some((f) => f.rule === APPROVAL_APPROVER_TYPE_UNSUPPORTED)).toBe(false); + }); + + it('a queue-only node is reported may-resolve-empty, like a position-only one', () => { + const findings = validateApprovalApprovers(stackWithApprovers([ + { type: 'queue', value: 'support_tier1' }, + ])); expect(findings).toHaveLength(1); - expect(findings[0].rule).toBe(APPROVAL_APPROVER_TYPE_UNSUPPORTED); - expect(findings[0].severity).toBe('warning'); - expect(findings[0].path).toBe('flows[0].nodes[1].config.approvers[0].type'); - expect(findings[0].message).toContain('it was deprecated rather than built'); + expect(findings[0].rule).toBe(APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY); + expect(findings[0].message).toContain('routes to a group (position/team/department/queue)'); + }); + + it('admits `organization` on a queue approver — a queue is an organization\'s', () => { + const findings = validateApprovalApprovers(stackWithApprovers([ + { type: 'queue', value: 'group_finance', organization: '$root' }, + { type: 'user', value: 'u1' }, + ])); + expect(findings).toEqual([]); }); // ── the deprecated `role` spelling (ADR-0090 D3, #3133) ────────────────── @@ -126,10 +140,11 @@ describe('validateApprovalApprovers', () => { }); it('accepts the position approver type and the other RESOLVABLE spec types silently', () => { - // `queue` is deliberately absent: it parses but the runtime never resolves - // it, so it now draws APPROVAL_APPROVER_TYPE_UNSUPPORTED (#3508). + // `queue` is among them since ADR-0142 D6 re-admitted it (it drew + // APPROVAL_APPROVER_TYPE_UNSUPPORTED while the runtime had no queue, #3508). const findings = validateApprovalApprovers(stackWithApprovers([ { type: 'position', value: 'sales_manager' }, + { type: 'queue', value: 'support_tier1' }, { type: 'user', value: 'u1' }, { type: 'manager' }, { type: 'department', value: 'bu_sales' }, @@ -473,7 +488,7 @@ describe('unset-manager dead-end (#16748)', () => { { type: 'position', value: 'exec' }, ])); expect(positionOnly).toHaveLength(1); - expect(positionOnly[0].message).toContain('routes to a group (position/team/department)'); + expect(positionOnly[0].message).toContain('routes to a group (position/team/department/queue)'); expect(positionOnly[0].message).not.toContain('manager_id'); // The mixed slate this package has always pinned as silent stays silent — @@ -753,7 +768,7 @@ describe('[#22161] one-line verdicts — approval-approvers-may-resolve-empty, a it('the two empty-slate arms read as one sentence each, sharing the lock clause', () => { const [group] = validateApprovalApprovers(stackWithApprovers([{ type: 'position', value: 'exec' }])); expect(group.message).toBe( - 'every approver on this node routes to a group (position/team/department), so if none is ' + + 'every approver on this node routes to a group (position/team/department/queue), so if none is ' + "staffed the request opens on the organization's administrators, and (lockRecord) the record " + 'stays locked', ); diff --git a/packages/lint/src/validate-approval-approvers.ts b/packages/lint/src/validate-approval-approvers.ts index 9ea908ba601..a51069beb0a 100644 --- a/packages/lint/src/validate-approval-approvers.ts +++ b/packages/lint/src/validate-approval-approvers.ts @@ -81,21 +81,25 @@ const RESERVED_OUTPUT_KEYS = new Set(['decision', 'requestId']); * be empty (an unstaffed position, an empty team/department). When EVERY * approver on a node is one of these, the node can resolve to an empty slate at * runtime — the framework#3424 dead-end. Individually-routed types - * (`user`/`field`/`manager`), the guaranteed-staffed `org_membership_level` - * tiers, and the opaque `queue` are deliberately excluded: any of them present - * signals the author has a non-group route, so the node isn't purely - * group-gated. + * (`user`/`field`/`manager`) and the guaranteed-staffed `org_membership_level` + * tiers are deliberately excluded: any of them present signals the author has + * a non-group route, so the node isn't purely group-gated. + * + * `queue` is IN (ADR-0142 D6): a queue's members are runtime data — its + * positions' holders, or an organization's `sys_queue_member` rows — and an + * empty queue adds no slot, so a queue-only node reaches the same empty slate a + * position-only one does. * * ⛔ `manager` stays OUT of this set and is judged by its own arm below * ({@link MANAGER_ONLY_FIX}), for two reasons that are not interchangeable: - * the group message ("routes to a group (position/team/department)") would be + * the group message ("routes to a group (position/team/department/queue)") would be * false about it, and the REMEDY differs — an unstaffed position is staffed * in-product, an unset `sys_user.manager_id` is not (#16748). Folding `manager` * in here would also change a `position` verdict this package pins: a * `[position, manager]` node is silent today, and that pin is the existing * arm's contract, not an oversight to sweep up. */ -const GROUP_ROUTED_TYPES = new Set(['position', 'team', 'department']); +const GROUP_ROUTED_TYPES = new Set(['position', 'team', 'department', 'queue']); /** * [#22161] The closing clause both empty-slate verdicts share under the @@ -491,9 +495,7 @@ export function validateApprovalApprovers(stack: AnyRec): ApprovalApproverFindin `approver type '${type}' is declared but not implemented by the runtime: it was deprecated ` + `rather than built, and is no longer offered for authoring — the slot resolves to nobody and ` + `the request stalls.`, - hint: - `Route to people the engine can expand: { type: 'team' | 'department' | 'position', ... }. ` + - `Queue approvers need a real ownership-queue implementation before they take effect.`, + hint: `Route to people the engine can expand: { type: 'team' | 'department' | 'position' | 'queue', ... }.`, }); } @@ -518,7 +520,7 @@ export function validateApprovalApprovers(stack: AnyRec): ApprovalApproverFindin `'organization: ${declaredOrg}' has no effect (ADR-0105 D9) — the runtime refuses it.`, hint: `Drop 'organization' here. Cross-organization targeting applies to ` + - `'position', 'org_membership_level', 'department' and 'expression' approvers.`, + `'position', 'org_membership_level', 'department', 'queue' and 'expression' approvers.`, }); } } @@ -547,7 +549,7 @@ export function validateApprovalApprovers(stack: AnyRec): ApprovalApproverFindin // [#22161] One verdict sentence; why staffing is unreadable here and // who decides an empty slate is `os explain` text. message: - `every approver on this node routes to a group (position/team/department), so if ` + + `every approver on this node routes to a group (position/team/department/queue), so if ` + `none is staffed the request opens on the organization's administrators` + (locks ? STAYS_LOCKED : ''), hint: GROUP_ONLY_FIX, diff --git a/packages/lint/src/validate-assignment-rules.test.ts b/packages/lint/src/validate-assignment-rules.test.ts new file mode 100644 index 00000000000..8500e42a4fa --- /dev/null +++ b/packages/lint/src/validate-assignment-rules.test.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + validateAssignmentRules, + ASSIGNMENT_RULE_USER_TARGET, + ASSIGNMENT_RULE_QUEUE_UNSTAFFED, +} from './validate-assignment-rules.js'; +import { explainRule } from './rule-explanations.js'; + +const rule = (entries: unknown[]) => ({ name: 'case_intake', object: 'crm_case', entries }); + +describe('validateAssignmentRules (ADR-0142 D5)', () => { + it('is silent on a stack with no assignment rules', () => { + expect(validateAssignmentRules({})).toEqual([]); + expect(validateAssignmentRules({ assignmentRules: 'nonsense' })).toEqual([]); + }); + + it('warns on a user-id target — organization data in package metadata', () => { + const findings = validateAssignmentRules({ + assignmentRules: [rule([{ name: 'vip', assignTo: { type: 'user', value: 'usr_123' } }])], + }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'warning', + rule: ASSIGNMENT_RULE_USER_TARGET, + where: 'assignment rule "case_intake" · entry "vip"', + path: 'assignmentRules[0].entries[0].assignTo', + }); + expect(findings[0].message).toContain("'usr_123'"); + expect(findings[0].hint).toContain("type: 'queue'"); + }); + + it('reports a target queue the stack declares with no member', () => { + const findings = validateAssignmentRules({ + queues: [{ name: 'support_intake', label: 'Support intake', members: [] }], + assignmentRules: [rule([{ name: 'default', assignTo: { type: 'queue', value: 'support_intake' }, strategy: 'round_robin' }])], + }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ severity: 'info', rule: ASSIGNMENT_RULE_QUEUE_UNSTAFFED }); + expect(findings[0].message).toContain("queue 'support_intake'"); + }); + + it('is silent on a staffed declared queue and on a queue the stack does not declare', () => { + expect(validateAssignmentRules({ + queues: [{ name: 'support_intake', label: 'Support intake', members: [{ type: 'position', value: 'service_agent' }] }], + assignmentRules: [rule([ + { name: 'declared', assignTo: { type: 'queue', value: 'support_intake' }, strategy: 'least_loaded', load: 'true' }, + // An organization's own queue: runtime data, not judged. + { name: 'authored', assignTo: { type: 'queue', value: 'billing' } }, + ])], + })).toEqual([]); + }); + + it('every rule it emits has an `os explain` entry', () => { + for (const id of [ASSIGNMENT_RULE_USER_TARGET, ASSIGNMENT_RULE_QUEUE_UNSTAFFED]) { + expect(explainRule(id), id).toBeTruthy(); + } + }); +}); diff --git a/packages/lint/src/validate-assignment-rules.ts b/packages/lint/src/validate-assignment-rules.ts new file mode 100644 index 00000000000..f4708babdc6 --- /dev/null +++ b/packages/lint/src/validate-assignment-rules.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0142 D5] Authoring-time checks on the stack's `assignmentRules`. +// +// A pure `(stack) => Finding[]` rule (ADR-0019). The engine routes an inserted +// row through these declarations at runtime and never refuses the insert on +// their account — an unstaffed queue parks the row, an unresolved target leaves +// it ownerless, each logged once — so what a linter can add is the two shapes +// visible in the declaration itself: +// +// | rule | severity | shape | +// |-----------------------------------|----------|--------------------------------------------------------------| +// | assignment-rule-user-target | warning | `assignTo: { type: 'user' }` — a user id in package metadata | +// | assignment-rule-queue-unstaffed | info | the target is a queue this stack declares with no member | +// +// The second is the `approval-approvers-may-resolve-empty` shape: a declared +// queue's members are positions, and one that names none is staffed only by an +// organization's own `sys_queue_member` rows — runtime data this rule cannot +// read, so it reports the shape and asserts no runtime fact. A target this +// stack does not declare may be an organization's own queue and is not judged. + +import { recordsOf } from './object-graph.js'; + +export type AssignmentRuleSeverity = 'warning' | 'info'; + +export interface AssignmentRuleFinding { + severity: AssignmentRuleSeverity; + rule: string; + /** Human-readable location, e.g. `assignment rule "case_intake" · entry "default"`. */ + where: string; + /** Config path, e.g. `assignmentRules[0].entries[1].assignTo`. */ + path: string; + message: string; + hint: string; +} + +// Rule ids (registry entries). +export const ASSIGNMENT_RULE_USER_TARGET = 'assignment-rule-user-target'; +export const ASSIGNMENT_RULE_QUEUE_UNSTAFFED = 'assignment-rule-queue-unstaffed'; + +type AnyRec = Record; + +function str(v: unknown): string { + return typeof v === 'string' ? v : ''; +} + +/** + * Report the user-id targets and the unstaffed declared-queue targets of every + * assignment rule entry. Reads `stack.assignmentRules` and `stack.queues`; safe + * on any shape — a stack with neither yields no findings. + */ +export function validateAssignmentRules(stack: AnyRec): AssignmentRuleFinding[] { + const out: AssignmentRuleFinding[] = []; + const declaredQueues = new Map(); + for (const q of recordsOf(stack.queues)) { + const name = str(q.name); + if (name) declaredQueues.set(name, q); + } + + const rules = Array.isArray(stack.assignmentRules) ? (stack.assignmentRules as unknown[]) : []; + rules.forEach((raw, i) => { + if (!raw || typeof raw !== 'object') return; + const rule = raw as AnyRec; + const ruleName = str(rule.name) || `assignmentRules[${i}]`; + const entries = Array.isArray(rule.entries) ? (rule.entries as unknown[]) : []; + entries.forEach((rawEntry, j) => { + if (!rawEntry || typeof rawEntry !== 'object') return; + const entry = rawEntry as AnyRec; + const target = entry.assignTo as AnyRec | undefined; + if (!target || typeof target !== 'object') return; + const value = str(target.value); + if (!value) return; + const where = `assignment rule "${ruleName}" · entry "${str(entry.name) || j}"`; + const path = `assignmentRules[${i}].entries[${j}].assignTo`; + + if (target.type === 'user') { + out.push({ + severity: 'warning', + rule: ASSIGNMENT_RULE_USER_TARGET, + where, + path, + // [#22161] One verdict sentence; why a user id is organization data + // (ADR-0086 D1) is `os explain assignment-rule-user-target`. + message: + `\`assignTo\` names the user id '${value}', which is one organization's data written into ` + + `package metadata, so the rule routes to nobody wherever that id does not exist`, + hint: + "Route to a queue instead ({ type: 'queue', value: '' }) whose members are " + + 'positions, so each organization staffs it', + }); + return; + } + + if (target.type === 'queue') { + const declared = declaredQueues.get(value); + if (!declared) return; + if (recordsOf(declared.members).length > 0) return; + out.push({ + severity: 'info', + rule: ASSIGNMENT_RULE_QUEUE_UNSTAFFED, + where, + path, + message: + `queue '${value}' is declared with no position member, so every row this entry matches ` + + `parks unowned unless an organization adds members to it`, + hint: `Declare at least one member on queue '${value}' ({ type: 'position', value: '' })`, + }); + } + }); + }); + + return out; +} diff --git a/packages/lint/src/validate-org-axis-red-lines.test.ts b/packages/lint/src/validate-org-axis-red-lines.test.ts index 37b81b92ff1..cfa911e8a13 100644 --- a/packages/lint/src/validate-org-axis-red-lines.test.ts +++ b/packages/lint/src/validate-org-axis-red-lines.test.ts @@ -523,7 +523,7 @@ describe('validateOrgAxisRedLines — undeclared keys are the schema’s job, no * whose absence is #4991. */ const BU_TREE_RECIPIENTS = ['business_unit', 'unit_and_subordinates'] as const; -const FLAT_RECIPIENTS = ['user', 'team', 'position', 'field'] as const; +const FLAT_RECIPIENTS = ['user', 'team', 'position', 'field', 'queue'] as const; describe('validateOrgAxisRedLines — ② business-unit trees stay org-internal', () => { const platformGlobalStack = ( diff --git a/packages/lint/src/validate-org-axis-red-lines.ts b/packages/lint/src/validate-org-axis-red-lines.ts index 0fc9309d6aa..f23932016f9 100644 --- a/packages/lint/src/validate-org-axis-red-lines.ts +++ b/packages/lint/src/validate-org-axis-red-lines.ts @@ -124,7 +124,7 @@ const ORG_PARENT_FIELD = 'parent_organization_id'; * Cross-checked word-for-word against the authoring enum `ShareRecipientType` * (`@objectstack/spec/security`, `sharing.zod.ts`) — the only vocabulary an * author can write, since `sharedWith` is `.strict()` and rejects everything - * else by name. That enum has SIX members; this list intercepts two, and the + * else by name. That enum has SEVEN members; this list intercepts two, and the * difference is deliberate, not an oversight (it is exactly the oversight * #4991 was filed for — ② shipped naming only `business_unit` while ADR-0105 * D6 ②'s own text names `unit_and_subordinates`): @@ -137,20 +137,18 @@ const ORG_PARENT_FIELD = 'parent_organization_id'; * | `team` | — | `sys_team` is a FLAT collaboration grouping (ADR-0090 D3 renamed `group` → `team`); `TeamGraphService`, not the BU graph. | * | `position` | — | Flat holder expansion (ADR-0090 D3 finalized the retirement of the position hierarchy); `PositionGraphService`, not the BU graph. The BU *depth scopes* D6 ② also names are a SCOPE mechanism, not a sharing-rule recipient. | * | `field` | — | RECORD-RELATIVE (#14103, maintainer ruling 2026-09-02): the user or users a user-typed column on the matched record names — read off the row itself, no tree walked, so no organization needed to resolve one in. Per-record expansion is `plugin-sharing`'s (#15072). | + * | `queue` | — | Re-decided here as ADR-0142 D7 directs: a work-distribution list, not a BU node. A DECLARED queue's members are positions (D1), a flat kind this table already admits. An organization's own queue (`sys_queue_member`) is not readable at lint time; one whose members include a BU kind expands them through the same org-predicated BU graph a direct BU recipient does, so on a global object (no organization) the runtime refuses them by that predicate, read through the queue. | * - * The four allowed recipients are the sanctioned way to share a + * The five allowed recipients are the sanctioned way to share a * platform-global object (ADR-0066): naming a user, a flat team, a flat - * position audience, or the users a column on the record itself names grants - * those people the catalog, which is the entire point of - * `tenancy.enabled: false`. What ② forbids is not "sharing a global object" + * position audience, the users a column on the record itself names, or a + * queue's people grants those people the catalog, which is the entire point + * of `tenancy.enabled: false`. What ② forbids is not "sharing a global object" * but "resolving a BU SUBTREE with no organization to resolve it within". * - * The runtime contract `SharingRuleRecipientType` - * (`spec/contracts/sharing-service.ts`) additionally carries `queue`; it is - * deliberately NOT authorable ("no `sys_queue` yet") and `expandRecipient` - * returns `[]` for it, so no author can reach it and it grants nothing. If it - * ever becomes authorable it is a work-distribution list, not a BU node — but - * this table is the place to re-decide that, in `ShareRecipientType` order. + * The authoring enum and the runtime contract `SharingRuleRecipientType` + * (`spec/contracts/sharing-service.ts`) are the same seven members since + * ADR-0142 D7 made `queue` authorable. */ const BU_TREE_RECIPIENT_TYPES = new Set(['business_unit', 'unit_and_subordinates']); diff --git a/packages/plugins/plugin-approvals/src/approval-service.test.ts b/packages/plugins/plugin-approvals/src/approval-service.test.ts index 68b56bebc08..64d2a4119c5 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.test.ts @@ -3772,28 +3772,79 @@ describe('ApprovalService — listActions attachments mapping (#3266)', () => { }); }); -// #3508: `queue` is declared-but-unenforced — resolveApproverSpec has no queue -// branch, so the spec value falls through to the dead `queue:` literal. -// The engine must at least WARN so operators can see the silent dead slot; -// the spec marks the type non-authorable so designers stop offering it. -describe('ApprovalService — queue approver is unresolved (#3508)', () => { - it('falls back to the dead literal and warns', async () => { - const engine = makeFakeEngine(); +// [ADR-0142 D6] `queue` approvers are re-admitted: the slate is the queue's +// members at node entry, read through the `ownership-queue` service on the +// ADDRESSING purpose; an empty expansion warns as a graph type does and adds no +// slot, as a person type does — the `queue:` literal is never written. +describe('ApprovalService — a queue approver routes to the queue\'s members (ADR-0142 D6)', () => { + const svcWith = (engine: any, queues?: Record>) => { const warnings: any[] = []; + const asked: unknown[] = []; let n = 0; const svc = new ApprovalService({ - engine: engine as any, + engine, clock: { now: () => new Date(1757000000000 + (n++) * 1000) }, logger: { warn: (msg: any, meta: any) => warnings.push([msg, meta]) }, + ownershipQueues: queues + ? () => ({ + expandMembers: async (name: string, org: string | null, options?: { purpose?: string }) => { + asked.push([name, org, options]); + return queues[String(org)]?.[name] ?? []; + }, + }) + : undefined, }); + return { svc, warnings, asked }; + }; + const queueInput = (value: string) => ({ + ...openInput([]), + config: { approvers: [{ type: 'queue' as const, value }], behavior: 'first_response' as const, lockRecord: false }, + }); + + it('opens on the queue\'s members in the request\'s organization, asked for ADDRESSING', async () => { + const engine = makeFakeEngine(); + const { svc, warnings, asked } = svcWith(engine, { t1: { billing: ['u7', 'u8'] }, t2: { billing: ['u_other'] } }); + const req = await svc.openNodeRequest(queueInput('billing'), CTX) as any; + + expect(req.pending_approvers).toEqual(['u7', 'u8']); + expect(asked).toEqual([['billing', 't1', { purpose: 'addressing' }]]); + expect(warnings.filter(([msg]) => String(msg).includes('expanded to nobody'))).toEqual([]); + // A member decides it under their own id. + const decided = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u8' }, SYS); + expect(decided.finalized).toBe(true); + }); + + it('an empty queue adds NO slot — never the `queue:` literal — and warns', async () => { + const engine = makeFakeEngine(); + const { svc, warnings } = svcWith(engine, { t1: { billing: [] } }); + const req = await svc.openNodeRequest(queueInput('billing'), CTX) as any; + + expect(req.pending_approvers).not.toContain('queue:billing'); + expect(req.pending_approvers.filter((s: string) => s.startsWith('queue:'))).toEqual([]); + const hit = warnings.find(([msg]) => String(msg).includes("approver 'queue:billing' expanded to nobody")); + expect(hit, 'no warning for the empty queue').toBeTruthy(); + expect(String(hit[0])).toContain('onEmptyApprovers'); + expect(hit[1]).toMatchObject({ type: 'queue', value: 'billing', organizationId: 't1' }); + }); + + it('beside a person, the queue adds nothing and the person carries the request', async () => { + const engine = makeFakeEngine(); + const { svc } = svcWith(engine, { t1: {} }); const req = await svc.openNodeRequest( - { ...openInput([]), config: { approvers: [{ type: 'queue', value: 'q_west' }], behavior: 'first_response', lockRecord: false } }, + { ...openInput([]), config: { approvers: [{ type: 'queue', value: 'nowhere' }, { type: 'user', value: 'u5' }], behavior: 'first_response', lockRecord: false } }, CTX, - ); - // No queue expansion exists: the slot is the raw `type:value` literal, - // which matches no real user id — the request routes to nobody. - expect(req.pending_approvers).toEqual(['queue:q_west']); - expect(warnings.some(([msg]) => String(msg).includes("'queue'") && String(msg).includes('no ownership queue to expand it from'))).toBe(true); + ) as any; + expect(req.pending_approvers).toEqual(['u5']); + }); + + it('with no ownership-queue service registered, the warning names that as the cause', async () => { + const engine = makeFakeEngine(); + const { svc, warnings } = svcWith(engine); + const req = await svc.openNodeRequest(queueInput('billing'), CTX) as any; + + expect(req.pending_approvers.filter((s: string) => s.startsWith('queue:'))).toEqual([]); + const hit = warnings.find(([msg]) => String(msg).includes("approver 'queue:billing' expanded to nobody")); + expect(String(hit?.[0])).toContain('no ownership-queue service is registered'); }); }); diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index b8865b66f24..c88adcc2202 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -58,6 +58,7 @@ import type { // fields the caller had already supplied. import type { ExecutionContext } from '@objectstack/spec/kernel'; import { RESUME_AUTHORITY_SERVICE } from '@objectstack/spec/contracts'; +import type { IOwnershipQueueService } from '@objectstack/spec/contracts'; import { canServeApiOperation, isFileIdToken, referenceTargetOf, SINGLE_OPTION_TYPES, MULTI_OPTION_TYPES, } from '@objectstack/spec/data'; @@ -1479,8 +1480,22 @@ export interface ApprovalServiceOptions { * authored text the request snapshotted when it opened. */ i18nServiceSource?: () => ReaderLabelI18n | undefined; + /** + * [ADR-0142 D6] The `ownership-queue` service, through which a `queue` + * approver expands to the queue's members at node entry. Resolved LAZILY + * per expansion, for the reason {@link ApprovalServiceOptions.tenancyPosture} + * is: plugin-sharing registers it and may start after this plugin. + * + * Absent (or resolving to `undefined`), a `queue` approver expands to nobody + * and says so — it contributes no slot, and the node's `onEmptyApprovers` + * decides a slate it leaves empty. + */ + ownershipQueues?: () => ApproverQueueSource | undefined; } +/** [ADR-0142 D6] The slice of the queue service a `queue` approver reads. */ +export type ApproverQueueSource = Pick; + /** * The stored row request `requestId` names inside `tenantOrg` (every * organization when `null`), read as the system — the one read a by-id serve @@ -1580,6 +1595,8 @@ export class ApprovalService implements IApprovalService { private messageTranslator?: () => ValidationMessageTranslator | undefined; /** [#24863] Lazily-resolved `i18n` service for the reader-locale display labels. */ private i18nServiceSource?: () => ReaderLabelI18n | undefined; + /** [ADR-0142 D6] Lazily-resolved `ownership-queue` service a `queue` approver expands through. */ + private ownershipQueues?: () => ApproverQueueSource | undefined; /** * [#8652] The HOST-named object set for the record-reader visibility tier * (the constructor option). EMPTY is the default, and the tier is then off @@ -1609,6 +1626,7 @@ export class ApprovalService implements IApprovalService { this.fieldVisibility = opts.fieldVisibility; this.messageTranslator = opts.messageTranslator; this.i18nServiceSource = opts.i18nServiceSource; + this.ownershipQueues = opts.ownershipQueues; this.recordReaderVisibleObjects = new Set( (Array.isArray(opts.recordReaderVisibleObjects) ? opts.recordReaderVisibleObjects : []) .map((n) => String(n ?? '').trim()) @@ -2182,6 +2200,9 @@ export class ApprovalService implements IApprovalService { * → users with `sys_member.role = value` in tenant — the * better-auth MEMBERSHIP TIER (owner/admin/member), not a * position; author `position` for org positions + * - `queue` → the queue's members at node entry, through the + * `ownership-queue` service (ADR-0142 D6); an empty + * expansion adds no slot, like a PERSON type * - `manager` → `sys_user.manager_id` of `record[value] ?? record.owner_id` * - `field` → literal user id stored in `record[value]` * - `user` → literal value @@ -2358,6 +2379,13 @@ export class ApprovalService implements IApprovalService { } else if (type === 'org_membership_level') { const users = await bounded(await this.expandMembershipTierUsers(String(a.value), directoryOrg)); if (users.length) return users; + } else if (type === 'queue') { + // [ADR-0142 D6] The queue's members at node entry, in the directory + // organization (a queue is an organization's: APPROVER_ORG_SCOPED), on + // the ADDRESSING purpose — the slate routes work, so the holders of a + // switched-off position stay on it (D2). + const users = await bounded(await this.expandQueueUsers(String(a.value), directoryOrg)); + if (users.length) return users; } else if (type === 'manager' && record) { const subject = (record as any)[a.value] ?? (record as any).owner_id; managerSubject = subject; @@ -2400,20 +2428,25 @@ export class ApprovalService implements IApprovalService { } return []; } - // #3508: `queue` is declared-but-unenforced — there is no queue branch - // above, so a queue approver always lands here and the `queue:` slot - // routes to nobody. The spec marks it non-authorable - // (NON_AUTHORABLE_APPROVER_TYPES) so designers stop offering it; warn for - // the stored flows that still carry one, so the silent dead slot is at - // least visible to operators. + // [ADR-0142 D6] A queue that expanded to nobody warns as the graph types + // do and then contributes NO slot, as the person types do — a combination + // of its own. The `queue:` literal is never written: no acting path + // admits a caller under it (`approver-address.ts`), and the queue's members + // are read at node entry, so a member who joins later is not owed this + // request. A `queue:` slot a stored request already holds stays as stored, + // decidable by the override (the ADR-0090 D3 `role:` precedent). if (type === 'queue') { this.logger?.warn?.( - `[approvals] approver type 'queue' is not implemented — the slot resolves to nobody, because the ` - + `platform has no ownership queue to expand it from (the type is deprecated and no longer offered ` - + `for authoring). Route this step to a team, department or position instead.`, - { value: a.value }, + `[approvals] approver 'queue:${a.value}' expanded to nobody — ` + + (this.queueSource() + ? `the queue has no members in this organization, or no queue of that name exists there` + : `no ownership-queue service is registered, so no queue can be expanded`) + + `. It adds no slot to this request; a slate left naming no one is decided by the node's onEmptyApprovers.`, + { type, value: a.value ?? null, organizationId: directoryOrg ?? null }, ); - } else if (GRAPH_APPROVER_TYPES.has(type)) { + return []; + } + if (GRAPH_APPROVER_TYPES.has(type)) { // #3807 follow-up — every OTHER way to land here is a graph type whose // lookup produced nobody, and the literal below is a slot no user can // ever act on. That silence is what let #3807 hide: a `department` @@ -2941,6 +2974,35 @@ export class ApprovalService implements IApprovalService { return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean))); } + /** + * [ADR-0142 D6] The live `ownership-queue` service, or `undefined` when none + * is registered (or the registered one has no `expandMembers`). + */ + private queueSource(): ApproverQueueSource | undefined { + try { + const svc = this.ownershipQueues?.(); + return svc && typeof svc.expandMembers === 'function' ? svc : undefined; + } catch { + return undefined; + } + } + + /** + * [ADR-0142 D6] A `queue` approver's slate: the members of the queue named + * `queueName` in `organizationId`, through the queue service's one expander + * on the ADDRESSING purpose (D2 — the approver slate routes work, so the + * holders of a switched-off position are kept; dropping them would be + * fail-open). The service resolves the name registry-first, then the + * organization's own `sys_queue` row, and answers `[]` for a name that + * resolves nowhere. With no service registered there is nothing to expand. + */ + private async expandQueueUsers(queueName: string, organizationId?: string | null): Promise { + if (!queueName) return []; + const queues = this.queueSource(); + if (!queues) return []; + return queues.expandMembers(queueName, organizationId ?? null, { purpose: 'addressing' }); + } + /** * Position holders (ADR-0090 D3): `sys_user_position` is the platform-owned * assignment table, keyed by the position's machine name (ADR-0057 D4), diff --git a/packages/plugins/plugin-approvals/src/approvals-plugin.ts b/packages/plugins/plugin-approvals/src/approvals-plugin.ts index 8435b6453ad..17497bb8866 100644 --- a/packages/plugins/plugin-approvals/src/approvals-plugin.ts +++ b/packages/plugins/plugin-approvals/src/approvals-plugin.ts @@ -7,6 +7,7 @@ import type { IJobService, IObjectQLEngine, } from '@objectstack/spec/contracts'; +import { OWNERSHIP_QUEUE_SERVICE } from '@objectstack/spec/contracts'; import { SysApprovalRequest } from './sys-approval-request.object.js'; import { SysApprovalAction } from './sys-approval-action.object.js'; import { SysApprovalApprover } from './sys-approval-approver.object.js'; @@ -22,6 +23,7 @@ import { ESCALATION_SCAN_INTERVAL_MS, type ApprovalEngine, type ApprovalMessagingSurface, + type ApproverQueueSource, type ObjectReadAdmission, } from './approval-service.js'; import { @@ -271,6 +273,17 @@ export class ApprovalsServicePlugin implements Plugin { // object labels in the reader's locale (`reader-locale-labels.ts`). // Without one, the door serves the labels as stored. i18nServiceSource: i18nService, + // [ADR-0142 D6] The queue service a `queue` approver expands through. + // Resolved LAZILY for the reason the providers above are: plugin-sharing + // registers it and may start after this one. Absent, a queue approver + // expands to nobody and the warning says why. + ownershipQueues: () => { + try { + return ctx.getService(OWNERSHIP_QUEUE_SERVICE); + } catch { + return undefined; + } + }, }); // [#10749] Field-visibility authority for payload-snapshot redaction. diff --git a/packages/plugins/plugin-approvals/src/approver-cross-org.integration.test.ts b/packages/plugins/plugin-approvals/src/approver-cross-org.integration.test.ts index 43b94d620f6..1eca20c7a42 100644 --- a/packages/plugins/plugin-approvals/src/approver-cross-org.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/approver-cross-org.integration.test.ts @@ -162,6 +162,32 @@ describe('ADR-0105 D9 — cross-org approver targeting through ApprovalService', expect(req.pending_approvers).toEqual(['position:cfo']); }); + // [ADR-0142 D6] A queue is an organization's (APPROVER_ORG_SCOPED.queue), so + // `organization` retargets its expansion like a position's, and the D2 wall + // still drops a member who could not read the request. + it('a `queue` approver expands in the TARGETED organization, bounded by the request org', async () => { + const asked: unknown[] = []; + const queueSvc = new ApprovalService({ + engine: engine as any, + clock: { now: () => new Date(1767000000000 + (n++) * 1000) }, + tenancyPosture: () => 'group', + ownershipQueues: () => ({ + expandMembers: async (name: string, org: string | null, options?: { purpose?: string }) => { + asked.push([name, org, options?.purpose]); + return name === 'group_finance' && org === 'o_group' ? ['u_cfo', 'u_group_only'] : []; + }, + }), + }); + const req = await queueSvc.openNodeRequest( + openInput([{ type: 'queue', value: 'group_finance', organization: '$root' }]), + CTX, + ) as any; + expect(asked).toEqual([['group_finance', 'o_group', 'addressing']]); + // `u_group_only` holds no Plant A membership, so the wall would hide the + // request from them: dropped. The CFO is kept. + expect(req.pending_approvers).toEqual(['u_cfo']); + }); + // Regression: `user` / `field` / `manager` return EARLY in // `resolveApproverSpec`, before the graph-expansion branch. D9's resolution // originally sat after those returns, so the declaration on a directory-less diff --git a/packages/plugins/plugin-approvals/test-typecheck-debt.json b/packages/plugins/plugin-approvals/test-typecheck-debt.json index b91311a66f0..27e52f9a927 100644 --- a/packages/plugins/plugin-approvals/test-typecheck-debt.json +++ b/packages/plugins/plugin-approvals/test-typecheck-debt.json @@ -27,7 +27,7 @@ "TS2339: Property 'flow_run_id' does not exist on type '…'.": 1, "TS2339: Property 'id' does not exist on type '…'.": 182, "TS2339: Property 'lock_record' does not exist on type '…'.": 3, - "TS2339: Property 'pending_approvers' does not exist on type '…'.": 40, + "TS2339: Property 'pending_approvers' does not exist on type '…'.": 39, "TS2339: Property 'process_label' does not exist on type '…'.": 2, "TS2339: Property 'process_name' does not exist on type '…'.": 1, "TS2339: Property 'sla_due_at' does not exist on type '…'.": 2, diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index b5a52edd331..b6ece2fdc5f 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -527,10 +527,13 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ // comment and out of the row's STRINGS: a ledger string reaches readers who // cannot resolve a tracker id (check:doc-authoring, whose baseline is // shrink-only), while the reader who can resolve one is already in the source. - { id: 'sharing-rules', summary: 'criteria sharing rules (recipients: user/team/position/unit_and_subordinates/business_unit/field — `field` expands per RECORD)', state: 'enforced', - enforcement: 'plugin-sharing/sharing-rule-service.ts (materialized into sys_record_share). The five RULE-WIDE recipients expand in expandRecipient; `field` deliberately does NOT — it has no rule-wide expansion, so expandRecipient THROWS for it rather than answering [] (answering [] is how a rule-wide caller turns "per record" into "nobody": reconcile would then revoke every grant, and revokeRuleGrantsForRetiredRecipients would retire every recipient). Its pairs are derived per record in desiredGrantsForRule (whole-rule pass) and expandRecipientForRecord (caller holding a record), re-materialising on the record\'s own write. The never-enforced owner-type rules + group/guest recipients were removed from the authoring spec (ADR-0078; group renamed → team)', + { id: 'sharing-rules', summary: 'criteria sharing rules (recipients: user/team/position/unit_and_subordinates/business_unit/queue/field — `field` expands per RECORD)', state: 'enforced', + enforcement: 'plugin-sharing/sharing-rule-service.ts (materialized into sys_record_share). The six RULE-WIDE recipients expand in expandRecipient (`queue` through the ownership-queue service\'s one expander, re-materialised by queue-hooks.ts when the queue\'s membership moves); `field` deliberately does NOT — it has no rule-wide expansion, so expandRecipient THROWS for it rather than answering [] (answering [] is how a rule-wide caller turns "per record" into "nobody": reconcile would then revoke every grant, and revokeRuleGrantsForRetiredRecipients would retire every recipient). Its pairs are derived per record in desiredGrantsForRule (whole-rule pass) and expandRecipientForRecord (caller holding a record), re-materialising on the record\'s own write. The never-enforced owner-type rules + group/guest recipients were removed from the authoring spec (ADR-0078; group renamed → team)', proof: 'showcase-bu-hierarchy-sharing.dogfood.test.ts', - note: 'Recipient list re-synced when the `field` kind became enforced. ⭐ These summaries are EXHAUSTIVE, not indicative — recorded here so the gap is not re-filed a third time. The parenthetical list is an order-faithful transcription of the AUTHORABLE enum `ShareRecipientType` (spec/security/sharing.zod.ts), and the file backs that reading: of the 49 `summary` strings, 0 hedge (none carries an `e.g.`, a `such as`, an `etc` or an ellipsis), and the closest structural twin — `scope-depth` — transcribes `ObjectAccessScopeSchema` member-for-member in declaration order. So the drift was a defect, not a stylistic gap, and declaring these summaries INDICATIVE was considered and REFUSED: it would retro-weaken the 16 rows that enumerate, to buy one row a caveat it does not need. `queue` is the one runtime `SharingRuleRecipientType` member deliberately absent: it is reserved (no `sys_queue`), expands to [] and is NOT authorable, so naming it on an `enforced` row would make this row false in the OTHER direction. ⚠️ Nothing mechanical reads `summary` / `enforcement` / `note` — this row is hand-maintained, so re-check it against `ShareRecipientType` whenever that enum moves.' }, + note: 'Recipient list re-synced when the `field` kind became enforced. ⭐ These summaries are EXHAUSTIVE, not indicative — recorded here so the gap is not re-filed a third time. The parenthetical list is an order-faithful transcription of the AUTHORABLE enum `ShareRecipientType` (spec/security/sharing.zod.ts), and the file backs that reading: of the 49 `summary` strings, 0 hedge (none carries an `e.g.`, a `such as`, an `etc` or an ellipsis), and the closest structural twin — `scope-depth` — transcribes `ObjectAccessScopeSchema` member-for-member in declaration order. So the drift was a defect, not a stylistic gap, and declaring these summaries INDICATIVE was considered and REFUSED: it would retro-weaken the 16 rows that enumerate, to buy one row a caveat it does not need. `queue` joined the list when it became authorable (ADR-0142 D7): the authorable enum and the runtime `SharingRuleRecipientType` are now the same seven members, and the recipient grants the matched records to the queue\'s people — it confers no sitting-record access, which is the `ownership-queue` row\'s path. ⚠️ Nothing mechanical reads `summary` / `enforcement` / `note` — this row is hand-maintained, so re-check it against `ShareRecipientType` whenever that enum moves.' }, + { id: 'ownership-queue', summary: 'ownership queue — a record sitting in a queue (owner_id empty, queue set) is read and edited by the queue\'s members; claim, release and move are admissions of the owner gate', state: 'enforced', + enforcement: 'plugin-sharing/sharing-service.ts buildReadFilter / buildWriteFilter update arm / checkEdit / explain carry the sitting-record disjunct through plugin-sharing/ownership-queue-service.ts (one expander, ADR-0142 D2/D3); plugin-security/security-plugin-authz-middleware.ts step 3.5 admits a claim (owner_id empty → self, by id, sharing verdict allow, written under the objectql/engine.ts writePrecondition so a lost race is 409 CONCURRENT_UPDATE), a release (self → empty with a home queue) and a move (transfer grant; 422 QUEUE_NOT_FOUND); objectql/registry.ts injects the queue column on ownership:user objects', + note: 'Unit-proven in plugin-security/ownership-queue-gate.test.ts and plugin-sharing/ownership-queue.test.ts; the claim precondition and its readback in objectql/ownership-queue.test.ts. Delete is never conferred by sitting in a queue.' }, { id: 'hierarchy-widening', summary: 'hierarchy widening — a unit + its subordinate units gain access', state: 'enforced', enforcement: 'plugin-sharing/business-unit-graph.ts BusinessUnitGraphService.expandUsers subtree (unit_and_subordinates recipient) — ADR-0057 D5 re-homed off the never-existent sys_position.parent. The narrower business_unit recipient resolves through expandUnitMembers (exactly one unit, no descent) and is pinned by the same proof file: the runtime was narrowed to the declaration after both kinds shared one subtree walk', proof: 'showcase-bu-hierarchy-sharing.dogfood.test.ts' }, diff --git a/ui/packages/app-shell/src/utils/approverIdentity.test.ts b/ui/packages/app-shell/src/utils/approverIdentity.test.ts index 8daa3daa6fc..bcf2bb51133 100644 --- a/ui/packages/app-shell/src/utils/approverIdentity.test.ts +++ b/ui/packages/app-shell/src/utils/approverIdentity.test.ts @@ -90,7 +90,7 @@ describe('APPROVER_DIRECTORY_BINDINGS is derived from the spec, not restated', ( }); it('skips the kinds whose source is not data rather than throwing', () => { - // `manager` (auto), `queue` (unsupported), `role` (enum) have no directory + // `manager` (auto), `queue` (registry and data, ADR-0142 D6), `role` (enum) have no directory // rows, and `position` (registry) has its own arm. A module-level throw // over a vocabulary change would white-screen the record page. for (const kind of ['manager', 'queue', 'role', 'field', 'expression', 'position']) { diff --git a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts index c257b0c0815..4117ba12b8c 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1306,7 +1306,7 @@ const ENGINE_STRINGS_EN: Record = { 'engine.inspector.reference.enterManually': 'Enter value manually', 'engine.inspector.reference.managerPlaceholder': 'Resolved automatically', 'engine.inspector.reference.managerHint': "Resolved at runtime from the submitter's manager — no value needed.", - 'engine.inspector.reference.queueUnsupported': 'Queue approvers are not supported by the runtime yet — this slot resolves to nobody.', + 'engine.inspector.reference.queueHint': "The queue's machine name. Its members when the step opens are the approvers; an empty queue adds nobody.", 'engine.inspector.reference.fieldsOf': 'Fields of {object}.', 'engine.inspector.reference.setTriggerObject': 'Set the flow’s trigger object (on the Start node) to list fields.', 'engine.inspector.reference.actionsOf': 'Actions of {connector}.', @@ -5000,7 +5000,7 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.inspector.reference.enterManually': '手动输入值', 'engine.inspector.reference.managerPlaceholder': '自动解析', 'engine.inspector.reference.managerHint': '运行时根据提交人的上级自动解析 —— 无需填写。', - 'engine.inspector.reference.queueUnsupported': '运行时暂不支持队列审批人 —— 该位置不会解析到任何人。', + 'engine.inspector.reference.queueHint': '队列的机器名。步骤开启时队列的成员即为审批人;空队列不会添加任何人。', 'engine.inspector.reference.fieldsOf': '{object} 的字段。', 'engine.inspector.reference.setTriggerObject': '请先在开始节点上设置流程的触发对象,才能列出字段。', 'engine.inspector.reference.actionsOf': '{connector} 的动作。', diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.lookup.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.lookup.test.tsx index 7006f42dfb2..f6c7c9418ad 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.lookup.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.lookup.test.tsx @@ -203,11 +203,12 @@ describe('ReferenceCombobox — manager is auto-resolved', () => { }); }); -describe('ReferenceCombobox — queue warns (declared-but-unenforced)', () => { - it('keeps the stored value editable but warns that it resolves to nobody', () => { +describe('ReferenceCombobox — queue names a queue (ADR-0142 D6)', () => { + it('keeps the value editable, says what it names, and no longer warns that it resolves to nobody', () => { const onCommit = vi.fn(); renderRef('queue', { value: 'q_west', onCommit }); - expect(screen.getByText(/not supported by the runtime/i)).toBeInTheDocument(); + expect(screen.queryByText(/not supported by the runtime/i)).not.toBeInTheDocument(); + expect(screen.getByText(/queue's machine name/i)).toBeInTheDocument(); fireEvent.change(screen.getByDisplayValue('q_west'), { target: { value: 'q_east' } }); expect(onCommit).toHaveBeenCalledWith('q_east'); expect(state.metaList).not.toHaveBeenCalled(); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx index ce0de4f4a37..4f54c749178 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx @@ -18,7 +18,7 @@ * `position`) render a record lookup against the data API (with a manual-entry * escape hatch, so the never-trap rule still holds), `org-membership-level` is * a strict select over a closed enum, `manager` is auto-resolved (disabled - * cell), and `queue` warns that the runtime doesn't resolve it. A kind whose + * cell), and `queue` is free text naming the queue (ADR-0142 D6). A kind whose * published binding is the REGISTRY instead (`{ source: 'registry', type }`, * objectstack#22710 — `position` on a server carrying it) renders a select over * that metadata type's items and commits the item's name (objectui#7611 Q3b), @@ -793,8 +793,8 @@ export interface ReferenceComboboxProps { * directory-backed kinds render a record lookup ({@link RecordLookupCell}), * registry-bound kinds a select over the registry ({@link RegistryPickCell}), * `org-membership-level` a strict select (closed enum), `manager` a disabled - * auto-resolved cell, and `queue` free text with a not-supported warning - * (objectstack-ai/objectstack#3508). Hooks are called unconditionally (kind-gated args) so the + * auto-resolved cell, and `queue` free text naming the queue (ADR-0142 D6). + * Hooks are called unconditionally (kind-gated args) so the * component is safe to use in a repeater where the kind changes per row. */ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, onSelect, disabled, placeholder, context, showHint = true, ariaLabel }: ReferenceComboboxProps) { @@ -961,10 +961,10 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, onSelect, ); } - // Declared-but-unenforced (objectstack-ai/objectstack#3508): the runtime has no queue - // resolution, so a stored value keeps its free-text cell but carries a - // warning — rendered regardless of `showHint` because the slot silently - // routes to nobody. New rows can no longer choose this type. + // [ADR-0142 D6] A queue approver resolves to the queue's members when the + // step opens. Its value is a queue machine name with TWO sources — a declared + // queue and an organization's own `sys_queue` row — so the cell stays free + // text until the console's two-source queue picker lands. if (kind === 'queue') { return (
@@ -976,9 +976,11 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, onSelect, disabled={disabled} className="h-8 text-sm" /> -

- {t('engine.inspector.reference.queueUnsupported', locale)} -

+ {showHint && ( +

+ {t('engine.inspector.reference.queueHint', locale)} +

+ )}
); } From f940a05808e665cbdc3b35e44b3edd88f976a602 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 15:36:28 +0000 Subject: [PATCH 06/23] feat(runtime,sharing): switch declared queues and assignment rules (ADR-0142 D1/D5) - runtime: the catalog activation door takes `queue` (resolved by the ownership-queue service) and `assignment_rule` (resolved in the engine's metadata registry), with their own refusal wording. - plugin-sharing: `declaredQueueNamed` answers a switched-off queue too, so the door can switch it back on; a ledger write for a queue recomputes the queue-recipient sharing rules naming it. - i18n: the queue recipient option and the two metadata types' display pairs, translated in zh-CN, ja-JP and es-ES. Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- .../bare-type-display-echo-decisions.test.ts | 26 +++--- .../en.metadata-forms.generated.ts | 8 ++ .../es-ES.metadata-forms.generated.ts | 8 ++ .../ja-JP.metadata-forms.generated.ts | 8 ++ ...ect-lifecycle-panel-echo-decisions.test.ts | 4 +- .../zh-CN.metadata-forms.generated.ts | 8 ++ .../system/sys-metadata-activation.object.ts | 2 +- .../src/ownership-queue-service.ts | 9 +- .../src/ownership-queue.test.ts | 21 ++++- .../plugins/plugin-sharing/src/queue-hooks.ts | 30 ++++++- .../src/translations/en.objects.generated.ts | 5 +- .../translations/es-ES.objects.generated.ts | 5 +- .../translations/ja-JP.objects.generated.ts | 5 +- .../translations/zh-CN.objects.generated.ts | 5 +- .../runtime/src/domains/activation-gate.ts | 23 +++++ .../domains/catalog-activation-door.test.ts | 80 ++++++++++++++++++ .../runtime/src/domains/catalog-activation.ts | 84 ++++++++++++++++--- packages/runtime/src/route-ledger.ts | 5 +- .../src/contracts/ownership-queue-service.ts | 9 +- 19 files changed, 301 insertions(+), 44 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/bare-type-display-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/bare-type-display-echo-decisions.test.ts index fa9609a802e..4bc2e243b56 100644 --- a/packages/platform-objects/src/apps/translations/bare-type-display-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/bare-type-display-echo-decisions.test.ts @@ -791,30 +791,32 @@ describe('#19403 round 7 — the provenance table agrees these leaves are now au }); describe('#19403 round 7 — the population, DERIVED from the registry and a shape', () => { - it('⭐ the registry and the catalog describe the same 29 metadata types', () => { + it('⭐ the registry and the catalog describe the same 31 metadata types', () => { // The ratchet's anchor. The catalog is GENERATED from this registry, so a // new metadata type arrives in both at once — which is what makes the bare // class below pick up a future unauthored display pair without anybody // adding it to a list. // 27 → 28: `picklist` joined the registry, a bare type (no form) with a description. // 28 → 29: `sharing_rule` joined it (ADR-0131 D6), bare with a description too. - expect(REGISTRY_TYPES.length).toBe(29); + // 29 → 31: `queue` and `assignment_rule` joined it (ADR-0142 D1/D5), both bare + // with a description, and both authored in the three locales on arrival. + expect(REGISTRY_TYPES.length).toBe(31); expect([...REGISTRY_TYPES].sort()).toEqual(Object.keys(enMetadataForms as Record).sort()); }); it('the BARE predicate splits that population, and is read off the SHAPE not a name list', () => { // Lit. - expect(BARE_TYPES.length).toBe(12); + expect(BARE_TYPES.length).toBe(14); expect(PANEL_TYPES.length).toBe(17); expect(BARE_TYPES.length + PANEL_TYPES.length).toBe(REGISTRY_TYPES.length); - for (const type of ['seed', 'mapping', 'api', 'doc', 'book', 'capability', 'picklist', 'sharing_rule']) { + for (const type of ['seed', 'mapping', 'api', 'doc', 'book', 'capability', 'picklist', 'sharing_rule', 'queue', 'assignment_rule']) { expect(BARE_TYPES, `${type} is bare`).toContain(type); } for (const type of AUTHORED_BARE_TYPES) expect(BARE_TYPES, `${type} is bare`).toContain(type); - // 20 leaves: twelve labels, and a description on the eight that carry one. - expect(BARE_LEAVES.length).toBe(20); - expect(BARE_LEAVES.filter((l) => l.prop === 'label').length).toBe(12); - expect(BARE_LEAVES.filter((l) => l.prop === 'description').length).toBe(8); + // 24 leaves: fourteen labels, and a description on the ten that carry one. + expect(BARE_LEAVES.length).toBe(24); + expect(BARE_LEAVES.filter((l) => l.prop === 'label').length).toBe(14); + expect(BARE_LEAVES.filter((l) => l.prop === 'description').length).toBe(10); expect(BARE_LEAVES.every((l) => ['label', 'description'].includes(l.prop))).toBe(true); }); @@ -833,8 +835,8 @@ describe('#19403 round 7 — the population, DERIVED from the registry and a sha expect(registryEntry('dataset')?.description, 'dataset carries a registry description like the six').toBeTruthy(); expect(isBare('dataset')).toBe(false); const described = REGISTRY_TYPES.filter((t) => typeof registryEntry(t)?.description === 'string'); - expect([...described].sort()).toEqual(['api', 'book', 'capability', 'dataset', 'doc', 'mapping', 'picklist', 'seed', 'sharing_rule']); - expect(described.filter(isBare).sort()).toEqual(['api', 'book', 'capability', 'doc', 'mapping', 'picklist', 'seed', 'sharing_rule']); + expect([...described].sort()).toEqual(['api', 'assignment_rule', 'book', 'capability', 'dataset', 'doc', 'mapping', 'picklist', 'queue', 'seed', 'sharing_rule']); + expect(described.filter(isBare).sort()).toEqual(['api', 'assignment_rule', 'book', 'capability', 'doc', 'mapping', 'picklist', 'queue', 'seed', 'sharing_rule']); // And no panel type's own display pair echoes — the exclusion removes only // leaves that are already decided elsewhere or already authored. const panelEchoes: string[] = []; @@ -895,13 +897,13 @@ describe('#19403 round 7 — the population, DERIVED from the registry and a sha expect(flagged.length).toBe(BARE_LEAVES.length); }); - it('⭐ this class is now DONE — zero of its 20 leaves echoes in all three locales', () => { + it('⭐ this class is now DONE — zero of its 24 leaves echoes in all three locales', () => { const echoing = BARE_LEAVES.filter((l) => TRANSLATED_LOCALES.every(([, forms]) => forms[l.type]?.[l.prop] === l.en), ); expect(echoing.map((l) => `${l.type}.${l.prop}`)).toEqual([]); // Lit — and it really walked the class, which a zero alone would not show. - expect(BARE_LEAVES.length).toBe(20); + expect(BARE_LEAVES.length).toBe(24); }); }); diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 8a8bd3d8db4..5708d65a4ca 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -2417,6 +2417,14 @@ export const enMetadataForms: NonNullable = { label: "Sharing Rule", description: "A rule a package declares to share records; organizations author their own in Setup" }, + 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" + }, + assignment_rule: { + label: "Assignment Rule", + description: "A rule a package declares to route new records to a queue or a person" + }, agent: { label: "AI Agent", sections: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 409018c8da6..51d1cb0fec4 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -2417,6 +2417,14 @@ export const esESMetadataForms: NonNullable = label: "Regla de Compartición", description: "Una regla que un paquete declara para compartir registros; las organizaciones crean las suyas en Configuración" }, + queue: { + label: "Cola", + description: "Un conjunto de personas con nombre donde los registros esperan hasta que una de ellas los toma; las organizaciones preparan las suyas en Configuración" + }, + assignment_rule: { + label: "Regla de Asignación", + description: "Una regla que un paquete declara para dirigir los registros nuevos a una cola o a una persona" + }, agent: { label: "Agente de IA", sections: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 1e23c195d45..ae5538ceec9 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -2417,6 +2417,14 @@ export const jaJPMetadataForms: NonNullable = label: "共有ルール", description: "パッケージが宣言するレコード共有ルール。組織は自身のルールを設定で作成します" }, + queue: { + label: "キュー", + description: "名前付きの担当者の集まり。レコードはその中の誰かが引き受けるまでここに置かれます。組織は自身のキューを設定で用意します" + }, + assignment_rule: { + label: "割り当てルール", + description: "パッケージが宣言するルール。新しいレコードをキューまたは担当者に振り分けます" + }, agent: { label: "AI エージェント", sections: { diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 04d0e0c837c..18801346ad0 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1220,7 +1220,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 680 since the dataset form's measures repeater offers `duration` (the // time between two datetime fields) — one row label, authored in all // three locales. - expect(translated.length, `${locale} positive control`).toBe(680); + // 682 since ADR-0142: `queue` and `assignment_rule` joined the registry, + // each with its type-level display pair, authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(682); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index bddb993a46a..3e4d2bcb9ab 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -2417,6 +2417,14 @@ export const zhCNMetadataForms: NonNullable = label: "共享规则", description: "软件包声明的记录共享规则;组织在设置中编写自己的规则" }, + queue: { + label: "队列", + description: "一组具名的人员,记录可停留其中,直到其中一人认领;组织在设置中配置自己的队列" + }, + assignment_rule: { + label: "分配规则", + description: "软件包声明的规则,把新记录分配到某个队列或某个人" + }, agent: { label: "AI 代理", sections: { diff --git a/packages/platform-objects/src/system/sys-metadata-activation.object.ts b/packages/platform-objects/src/system/sys-metadata-activation.object.ts index 29b73b51f17..6f0513f04af 100644 --- a/packages/platform-objects/src/system/sys-metadata-activation.object.ts +++ b/packages/platform-objects/src/system/sys-metadata-activation.object.ts @@ -122,7 +122,7 @@ export const SysMetadataActivation = ObjectSchema.create({ required: true, searchable: true, maxLength: 100, - description: "The artifact's registry type ('flow', 'action', 'position', 'permission', 'sharing_rule', 'webhook').", + description: "The artifact's registry type ('flow', 'action', 'position', 'permission', 'sharing_rule', 'webhook', 'queue', 'assignment_rule').", group: 'Identity', }), diff --git a/packages/plugins/plugin-sharing/src/ownership-queue-service.ts b/packages/plugins/plugin-sharing/src/ownership-queue-service.ts index d6808f5f8c3..278b8597842 100644 --- a/packages/plugins/plugin-sharing/src/ownership-queue-service.ts +++ b/packages/plugins/plugin-sharing/src/ownership-queue-service.ts @@ -209,12 +209,13 @@ export class OwnershipQueueService implements IOwnershipQueueService { // ── Helpers the plugin's other modules read ──────────────────────────────── /** - * The declared queue named `name`, unless the activation ledger switches it - * off — the reader the activation door looks a `queue` row's subject up - * through, and the first half of {@link resolveQueue}. + * The declared queue named `name`, switched on or off — the reader the + * activation door looks a `queue` row's subject up through. It ignores the + * ledger on purpose: a queue switched off must stay addressable, or the door + * could never switch it back on. {@link resolveQueue} reads the ledger. */ async declaredQueueNamed(name: string): Promise<{ name: string; packageId?: string } | null> { - const item = await this.declaredQueueItem(name); + const item = this.declaredItems().find((q) => q.name === name); if (!item) return null; const packageId = str(item._packageId ?? item.packageId); return { name: item.name, ...(packageId ? { packageId } : {}) }; diff --git a/packages/plugins/plugin-sharing/src/ownership-queue.test.ts b/packages/plugins/plugin-sharing/src/ownership-queue.test.ts index c05b9c8a7b8..cad2f0c0576 100644 --- a/packages/plugins/plugin-sharing/src/ownership-queue.test.ts +++ b/packages/plugins/plugin-sharing/src/ownership-queue.test.ts @@ -141,7 +141,8 @@ describe('[ADR-0142 D1/D2] OwnershipQueueService — resolution', () => { const svc = service(makeEngine({ declared: SUPPORT_DECLARED, tables })); expect(await svc.resolveQueue('support_tier1', 'org_a')).toBeNull(); expect(await svc.expandMembers('support_tier1', 'org_a')).toEqual([]); - expect(await svc.declaredQueueNamed('support_tier1')).toBeNull(); + // Still addressable by the activation door, so it can be switched back on. + expect(await svc.declaredQueueNamed('support_tier1')).toEqual({ name: 'support_tier1', packageId: 'com.acme.support' }); // The name stays reserved: the authored-row guard still refuses it. expect(svc.isDeclaredQueueName('support_tier1')).toBe(true); }); @@ -430,4 +431,22 @@ describe('[ADR-0142 D7] queue-recipient rules recompute on a queue-membership wr await expect(engine.fire('afterDelete', 'sys_queue', { previous: { name: 'billing' } })).resolves.toBeUndefined(); expect(warn).toHaveBeenCalled(); }); + + it('a declared queue switched in the activation ledger recomputes the rules naming it; other ledger rows do not', async () => { + const engine = makeEngine(); + const svc = ruleService(); + bindQueueRecipientRecompute(engine as any, svc); + // The update patch carries no type or name: they come from the pre-image. + await engine.fire('afterUpdate', METADATA_ACTIVATION_TABLE, { + previous: { id: 'a1', metadata_type: 'queue', name: 'support_tier1', active: true }, + result: { id: 'a1', active: false }, + input: { data: { active: false } }, + }); + expect(svc.revokeRuleGrantsForRetiredRecipients.mock.calls.map((c: any[]) => c[0].id)).toEqual(['r1']); + await engine.fire('afterInsert', METADATA_ACTIVATION_TABLE, { + result: { metadata_type: 'sharing_rule', name: 'support_tier1', active: false }, + }); + expect(svc.revokeRuleGrantsForRetiredRecipients).toHaveBeenCalledTimes(1); + await ruleRegrantQueue.whenIdle(); + }); }); diff --git a/packages/plugins/plugin-sharing/src/queue-hooks.ts b/packages/plugins/plugin-sharing/src/queue-hooks.ts index 5fb61dc314b..e44319cfcd1 100644 --- a/packages/plugins/plugin-sharing/src/queue-hooks.ts +++ b/packages/plugins/plugin-sharing/src/queue-hooks.ts @@ -40,7 +40,13 @@ import type { SharingRuleRow } from '@objectstack/spec/contracts'; import type { OptionalSharingLogger } from './logger-shapes.js'; import { ruleRegrantQueue } from './rule-hooks.js'; -import { QUEUE_MEMBER_OBJECT, QUEUE_OBJECT, type OwnershipQueueService } from './ownership-queue-service.js'; +import { METADATA_ACTIVATION_TABLE } from '@objectstack/core'; +import { + QUEUE_ACTIVATION_TYPE, + QUEUE_MEMBER_OBJECT, + QUEUE_OBJECT, + type OwnershipQueueService, +} from './ownership-queue-service.js'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; @@ -241,7 +247,7 @@ function queueNamesOf(object: string, hookCtx: any): Set { return out; } -/** Bind the recompute on the queue's own tables. */ +/** Bind the recompute on the queue's own tables and on the activation ledger's `queue` rows. */ export function bindQueueRecipientRecompute( engine: MinimalEngine, service: QueueRecipientRuleService, @@ -267,4 +273,24 @@ export function bindQueueRecipientRecompute( ); } } + // A declared queue switched on or off in the activation ledger changes whom + // it reaches (off: nobody), so the queue-recipient rules naming it are + // recomputed on the ledger write. Not caught here: as the declared-sharing- + // rule switch's reconcile, a failure is the write's, so the activation door + // never answers 200 over a switched-off queue's grants still standing. + for (const event of ['afterInsert', 'afterUpdate', 'afterDelete']) { + engine.registerHook( + event, + async (hookCtx: any) => { + // The update patch carries `{ id, active, … }`; type and name come from + // the pre-image, an insert's from the row itself. + const row = { ...(hookCtx?.previous ?? {}), ...(hookCtx?.result ?? hookCtx?.input?.data ?? {}) }; + if (row.metadata_type !== QUEUE_ACTIVATION_TYPE) return; + const name = present(row.name); + if (!name) return; + await recomputeQueueRecipientRules(service, new Set([name]), logger, { object: METADATA_ACTIVATION_TABLE, event }); + }, + { object: METADATA_ACTIVATION_TABLE, packageId: QUEUE_RECIPIENT_RECOMPUTE_PACKAGE, priority: 190 }, + ); + } } diff --git a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts index 33c1b4adbe5..6ecafec3d25 100644 --- a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts @@ -135,19 +135,20 @@ export const enObjects: NonNullable = { }, recipient_type: { label: "Recipient Type", - help: "Who receives access. Picking a team, business unit or position gives access to everyone in it. \"Business unit and subordinates\" also covers every unit below the one you pick. \"Field\" shares each record with the user or users held in one of its own fields — name that field as the recipient.", + help: "Who receives access. Picking a team, business unit, position or queue gives access to everyone in it. \"Business unit and subordinates\" also covers every unit below the one you pick. \"Field\" shares each record with the user or users held in one of its own fields — name that field as the recipient.", options: { user: "User", team: "Team", business_unit: "Business unit", position: "Position", unit_and_subordinates: "Business unit and subordinates", + queue: "Queue", field: "Field" } }, recipient_id: { label: "Recipient", - help: "The specific user, team, business unit or position that receives access — or, for the \"Field\" recipient type, the name of the record field that holds the user or users to share with." + help: "The specific user, team, business unit, position or queue that receives access — or, for the \"Field\" recipient type, the name of the record field that holds the user or users to share with." }, access_level: { label: "Access Level", diff --git a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts index 8c6ecb28d92..1e28a7da3cf 100644 --- a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts @@ -135,19 +135,20 @@ export const esESObjects: NonNullable = { }, recipient_type: { label: "Tipo de destinatario", - help: "Quién recibe el acceso. Al elegir un equipo, una unidad de negocio o un puesto, lo reciben todas las personas que lo integran. «Unidad de negocio y subordinadas» abarca además todas las unidades por debajo de la elegida. «Campo» comparte cada registro con el usuario o los usuarios guardados en uno de sus propios campos: indique ese campo como destinatario.", + help: "Quién recibe el acceso. Al elegir un equipo, una unidad de negocio, un puesto o una cola, lo reciben todas las personas que lo integran. «Unidad de negocio y subordinadas» abarca además todas las unidades por debajo de la elegida. «Campo» comparte cada registro con el usuario o los usuarios guardados en uno de sus propios campos: indique ese campo como destinatario.", options: { user: "Usuario", team: "Equipo", business_unit: "Unidad de negocio", position: "Puesto", unit_and_subordinates: "Unidad de negocio y subordinados", + queue: "Cola", field: "Campo" } }, recipient_id: { label: "Destinatario", - help: "El usuario, equipo, unidad de negocio o puesto concreto que recibe el acceso o, para el tipo de destinatario «Campo», el nombre del campo del registro que guarda el usuario o los usuarios con quienes compartir." + help: "El usuario, equipo, unidad de negocio, puesto o cola concreto que recibe el acceso o, para el tipo de destinatario «Campo», el nombre del campo del registro que guarda el usuario o los usuarios con quienes compartir." }, access_level: { label: "Nivel de acceso", diff --git a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts index 4fd6262e64e..71ba391cf05 100644 --- a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts @@ -135,19 +135,20 @@ export const jaJPObjects: NonNullable = { }, recipient_type: { label: "受信者タイプ", - help: "誰にアクセスを与えるか。チーム・事業単位・役職を選ぶと、そこに属する全員がアクセスできます。「事業単位と下位組織」を選ぶと、その配下のすべての単位も対象になります。「フィールド」を選ぶと、各レコードはそのレコード自身のフィールドに保持されているユーザーに共有されます。そのフィールドを受信者として指定してください。", + help: "誰にアクセスを与えるか。チーム・事業単位・役職・キューを選ぶと、そこに属する全員がアクセスできます。「事業単位と下位組織」を選ぶと、その配下のすべての単位も対象になります。「フィールド」を選ぶと、各レコードはそのレコード自身のフィールドに保持されているユーザーに共有されます。そのフィールドを受信者として指定してください。", options: { user: "ユーザー", team: "チーム", business_unit: "ビジネスユニット", position: "ポジション", unit_and_subordinates: "ビジネスユニットと下位階層", + queue: "キュー", field: "フィールド" } }, recipient_id: { label: "受信者", - help: "アクセスを与える具体的なユーザー・チーム・事業単位・役職。受信者種別が「フィールド」の場合は、共有先のユーザーを保持しているレコードのフィールド名。" + help: "アクセスを与える具体的なユーザー・チーム・事業単位・役職・キュー。受信者種別が「フィールド」の場合は、共有先のユーザーを保持しているレコードのフィールド名。" }, access_level: { label: "アクセスレベル", diff --git a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts index 4fca0674744..95724e90670 100644 --- a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts @@ -135,19 +135,20 @@ export const zhCNObjects: NonNullable = { }, recipient_type: { label: "接收方类型", - help: "把访问权限给谁。选择团队、业务单元或岗位时,其中的每个人都会获得访问权限;选择「业务单元及下级」还会包含该单元下属的所有单元;选择「字段」时,每条记录会共享给该记录自身某个字段里保存的用户——把那个字段填作接收方即可。", + help: "把访问权限给谁。选择团队、业务单元、岗位或队列时,其中的每个人都会获得访问权限;选择「业务单元及下级」还会包含该单元下属的所有单元;选择「字段」时,每条记录会共享给该记录自身某个字段里保存的用户——把那个字段填作接收方即可。", options: { user: "用户", team: "团队", business_unit: "业务单元", position: "岗位", unit_and_subordinates: "业务单元及下级", + queue: "队列", field: "字段" } }, recipient_id: { label: "接收方", - help: "具体把访问权限给哪一个用户、团队、业务单元或岗位;接收方类型为「字段」时,填写保存共享对象用户的那个记录字段的名称。" + help: "具体把访问权限给哪一个用户、团队、业务单元、岗位或队列;接收方类型为「字段」时,填写保存共享对象用户的那个记录字段的名称。" }, access_level: { label: "访问级别", diff --git a/packages/runtime/src/domains/activation-gate.ts b/packages/runtime/src/domains/activation-gate.ts index 0831d4b0807..e1d19b15942 100644 --- a/packages/runtime/src/domains/activation-gate.ts +++ b/packages/runtime/src/domains/activation-gate.ts @@ -205,6 +205,29 @@ export const WEBHOOK_ACTIVATION_SUBJECT: ActivationSubject = { 'organization authors under names of its own stay yours to create and switch.', }; +/** + * [ADR-0142 D1, ADR-0131 D6 regime C] The declared-queue door's wording. A + * declared queue is switched off for the installation; an organization still + * authors queues of its own (`sys_queue`), so the remedy names that half. + */ +export const QUEUE_ACTIVATION_SUBJECT: ActivationSubject = { + subject: 'a declared queue', + remedy: + 'To switch a declared queue on or off for this installation, ask your platform operator; queues your ' + + 'organization authors under names of its own stay yours to create and staff.', +}; + +/** + * [ADR-0142 D5] The declared-assignment-rule door's wording. Assignment rules + * are declared only (an organization authors none, ADR-0142 merge question + * 11), so the switch is the one lever and it is the operator's. + */ +export const ASSIGNMENT_RULE_ACTIVATION_SUBJECT: ActivationSubject = { + subject: 'a declared assignment rule', + remedy: + 'To switch a declared assignment rule on or off for this installation, ask your platform operator.', +}; + /** * The activation body every `_activation` door reads: `{ enabled?: boolean }`, * unknown keys refused, `enabled` defaulting to `true`. diff --git a/packages/runtime/src/domains/catalog-activation-door.test.ts b/packages/runtime/src/domains/catalog-activation-door.test.ts index 04bf5653bfe..1b92c5c0517 100644 --- a/packages/runtime/src/domains/catalog-activation-door.test.ts +++ b/packages/runtime/src/domains/catalog-activation-door.test.ts @@ -61,6 +61,8 @@ interface EngineOptions { unbound?: boolean; /** Throw this from every ledger write — a hook refusal or a store failure. */ writeError?: Error; + /** The metadata registry's declared items by type; absent, the registry cannot list. */ + registryItems?: Record; } /** A table-backed engine double: the store's keyed find/insert/update and the resolver's `$in` read. */ @@ -89,6 +91,9 @@ function makeEngine(opts: EngineOptions = {}) { const engine: any = { registry: { getObject: (name: string) => (name === LEDGER && !opts.omitLedger ? { name: LEDGER } : undefined), + ...(opts.registryItems + ? { listItems: (type: string) => opts.registryItems![type] ?? [] } + : {}), }, async find(object: string, options: any) { const rows = (tables[object] ?? []).filter((r) => matches(r, options?.where)); @@ -729,3 +734,78 @@ describe('the catalog activation door — a declared webhook', () => { expect(h.tables[LEDGER][0].package_id ?? null).toBeNull(); }); }); + +// [ADR-0142 D1] A DECLARED ownership queue is switched through the same door and +// the same ledger: `:type` `queue`, the name resolved by the `ownership-queue` +// service's `declaredQueueNamed`, which answers a switched-off queue too so the +// switch can be turned back on. The effect on resolution and on queue-recipient +// grants is pinned beside that service (plugin-sharing `ownership-queue.test.ts`). +describe('the catalog activation door — a declared queue', () => { + const QUEUE = 'support_intake'; + const ownershipQueue = (declared: Record = { [QUEUE]: { packageId: 'crm' } }) => ({ + 'ownership-queue': { + async declaredQueueNamed(name: string) { + return declared[name] ? { name, ...declared[name] } : null; + }, + }, + }); + + it('`single`: a tenant admin holding `manage_metadata` switches it off — one ledger row of type queue, its package recorded', async () => { + const h = boot('single', {}, ownershipQueue()); + const res = await flip(h, TENANT_ADMIN(), `/_activation/queue/${QUEUE}`); + expect(statusOf(res)).toBe(200); + expect(res.response?.body?.data).toEqual({ type: 'queue', name: QUEUE, enabled: false }); + expect(h.tables[LEDGER]).toEqual([ + expect.objectContaining({ metadata_type: 'queue', name: QUEUE, package_id: 'crm', active: false }), + ]); + }); + + it('`group`: the switch is the platform operator\'s — a tenant admin is refused 403 and nothing is written', async () => { + const h = boot('group', {}, ownershipQueue()); + const refused = await flip(h, TENANT_ADMIN(), `/_activation/queue/${QUEUE}`); + expect(statusOf(refused)).toBe(403); + expect(messageOf(refused)).toContain('a declared queue'); + expect(h.writes).toEqual([]); + expect(statusOf(await flip(h, PLATFORM_OPERATOR(), `/_activation/queue/${QUEUE}`))).toBe(200); + }); + + it('an organization\'s own queue is 404 and writes nothing; no queue service is 503', async () => { + const h = boot('single', {}, ownershipQueue()); + const unknown = await flip(h, TENANT_ADMIN(), '/_activation/queue/our_own_queue'); + expect(statusOf(unknown)).toBe(404); + expect(messageOf(unknown)).toContain('sys_queue'); + const bare = boot('single'); + const unavailable = await flip(bare, TENANT_ADMIN(), `/_activation/queue/${QUEUE}`); + expect(statusOf(unavailable)).toBe(503); + expect(codeOf(unavailable)).toBe('SERVICE_UNAVAILABLE'); + expect([...h.writes, ...bare.writes]).toEqual([]); + }); +}); + +// [ADR-0142 D5] A DECLARED assignment rule is switched through the same door: +// `:type` `assignment_rule`, the name resolved in the engine's metadata registry, +// the one the engine's evaluator lists on every insert and skips a switched-off +// rule from (objectql `assignment-rules.ts`). +describe('the catalog activation door — a declared assignment rule', () => { + const RULE = 'case_intake'; + const registryItems = { assignment_rule: [{ name: RULE, object: 'crm_case', _packageId: 'crm' }] }; + + it('`single`: a tenant admin holding `manage_metadata` switches it off — one ledger row, its package recorded', async () => { + const h = boot('single', { registryItems }); + const res = await flip(h, TENANT_ADMIN(), `/_activation/assignment_rule/${RULE}`); + expect(statusOf(res)).toBe(200); + expect(res.response?.body?.data).toEqual({ type: 'assignment_rule', name: RULE, enabled: false }); + expect(h.tables[LEDGER]).toEqual([ + expect.objectContaining({ metadata_type: 'assignment_rule', name: RULE, package_id: 'crm', active: false }), + ]); + }); + + it('an undeclared name is 404; an engine whose registry cannot list is 503 — never a write', async () => { + const h = boot('single', { registryItems }); + expect(statusOf(await flip(h, TENANT_ADMIN(), '/_activation/assignment_rule/nowhere'))).toBe(404); + const bare = boot('single'); + const unavailable = await flip(bare, TENANT_ADMIN(), `/_activation/assignment_rule/${RULE}`); + expect(statusOf(unavailable)).toBe(503); + expect([...h.writes, ...bare.writes]).toEqual([]); + }); +}); diff --git a/packages/runtime/src/domains/catalog-activation.ts b/packages/runtime/src/domains/catalog-activation.ts index 35233444cb4..eb863cfb9c4 100644 --- a/packages/runtime/src/domains/catalog-activation.ts +++ b/packages/runtime/src/domains/catalog-activation.ts @@ -9,7 +9,10 @@ * rather than the security catalog; and `webhook`, a webhook a stack declares * (`defineStack({ webhooks })`), read from the declared-webhook reader * (`declaredWebhooks`, plugin-webhooks) under the same regime (ADR-0131 D2/D6, - * the ruling that retired the declared-webhook seeder). + * the ruling that retired the declared-webhook seeder); and, since ADR-0142, + * `queue`, a queue a stack declares (`queues`), read from the `ownership-queue` + * service, and `assignment_rule`, a rule a stack declares (`assignmentRules`), + * read from the engine's metadata registry, where the evaluator reads it. * * ## Why it exists * @@ -53,7 +56,7 @@ */ import { AUDIENCE_ANCHOR_POSITIONS, GUEST_POSITION } from '@objectstack/spec/identity'; -import type { ISharingRuleService } from '@objectstack/spec/contracts'; +import { OWNERSHIP_QUEUE_SERVICE, type IOwnershipQueueService, type ISharingRuleService } from '@objectstack/spec/contracts'; import { METADATA_ACTIVATION_TABLE, ObjectStoreMetadataActivationStore, @@ -68,6 +71,8 @@ import { PERMISSION_SET_ACTIVATION_SUBJECT, SHARING_RULE_ACTIVATION_SUBJECT, WEBHOOK_ACTIVATION_SUBJECT, + QUEUE_ACTIVATION_SUBJECT, + ASSIGNMENT_RULE_ACTIVATION_SUBJECT, type ActivationSubject, } from './activation-gate.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; @@ -81,7 +86,10 @@ export const CATALOG_ACTIVATION_SEGMENT = '_activation'; * types the resolver reads from the ledger; `capability` is code-only on the * regime and has no switch. */ -const CATALOG_ACTIVATION_SUBJECTS: Readonly> = { +const CATALOG_ACTIVATION_SUBJECTS: Readonly> = { position: POSITION_ACTIVATION_SUBJECT, permission: PERMISSION_SET_ACTIVATION_SUBJECT, // [ADR-0131 D6 regime C] A declared sharing rule — read from the sharing-rule @@ -90,6 +98,12 @@ const CATALOG_ACTIVATION_SUBJECTS: Readonly> = { permission: 'Permission set', sharing_rule: 'Declared sharing rule', webhook: 'Declared webhook', + queue: 'Declared queue', + assignment_rule: 'Declared assignment rule', }; /** @@ -141,15 +157,50 @@ function declaredWebhookReaderOf(service: unknown): DeclaredWebhookReader | unde } /** - * The service-backed types — declarations a plugin owns, not catalog entries — - * each with how its declaration is looked up and how it is named in a refusal. + * [ADR-0142 D1] The declaration a `queue` row addresses, as the queue service + * (`ownership-queue`, plugin-sharing) reads it: declared in the code, whether + * or not the ledger has it switched off. A queue an organization authored is a + * `sys_queue` row and is never addressed here. */ -const DECLARATION_LOOKUPS: Readonly>; + +function declaredQueueReaderOf(service: unknown): DeclaredQueueReader | undefined { + const candidate = service as Partial | null | undefined; + return typeof candidate?.declaredQueueNamed === 'function' ? (candidate as DeclaredQueueReader) : undefined; +} + +/** + * [ADR-0142 D5] The declaration an `assignment_rule` row addresses: the item of + * that type in the engine's metadata registry — the one the engine's evaluator + * lists on every insert — whether or not the ledger has it switched off. + * `undefined` when the engine carries no registry to ask. + */ +function declaredAssignmentRuleNamed( + ql: unknown, + name: string, +): Promise<{ name: string; packageId?: string } | null> | undefined { + const registry = (ql as { registry?: { listItems?: (type: string) => unknown } } | null | undefined)?.registry; + if (typeof registry?.listItems !== 'function') return undefined; + return Promise.resolve(registry.listItems('assignment_rule')).then((items) => { + const item = (Array.isArray(items) ? items : []) + .find((i: any) => i && typeof i === 'object' && i.name === name) as Record | undefined; + if (!item) return null; + const packageId = item._packageId ?? item.packageId; + return { name, ...(typeof packageId === 'string' && packageId ? { packageId } : {}) }; + }); +} + +/** + * The declaration-backed types — declarations a plugin or the engine owns, not + * catalog entries — each with how its declaration is looked up and how it is + * named in a refusal. `service` absent means the lookup reads the engine. + */ +const DECLARATION_LOOKUPS: Readonly | undefined; + lookup(service: unknown, name: string, ql: unknown): Promise<{ name: string; packageId?: string } | null> | undefined; }>> = { sharing_rule: { service: 'sharingRules', @@ -172,6 +223,19 @@ const DECLARATION_LOOKUPS: Readonly declaredQueueReaderOf(service)?.declaredQueueNamed(name), + }, + assignment_rule: { + noun: 'assignment rule', + serviceNoun: 'metadata registry', + authoredRemedy: 'Assignment rules are declared by the application only; an organization authors none.', + lookup: (_service, name, ql) => declaredAssignmentRuleNamed(ql, name), + }, }; function isDeclarationType(type: CatalogActivationType): type is keyof typeof DECLARATION_LOOKUPS { @@ -290,10 +354,10 @@ export async function handleCatalogActivationWrite( let entry: { name: string; packageId?: string } | undefined; if (isDeclarationType(type)) { const declaration = DECLARATION_LOOKUPS[type]; - const service = await deps.resolveService(context, declaration.service); + const service = declaration.service ? await deps.resolveService(context, declaration.service) : undefined; let pending: Promise<{ name: string; packageId?: string } | null> | undefined; try { - pending = declaration.lookup(service, name); + pending = declaration.lookup(service, name, ql); } catch (err) { pending = Promise.reject(err); // a reader that throws before its promise is still a failed read } diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 46d8d419a16..deb3bedcf03 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -379,7 +379,10 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ note: 'ADR-0126 §3 regime C, as ADR-0131 D6 amends it — switch ONE position (`:type` = `position`), permission set ' + '(`:type` = `permission`) or declared sharing rule (`:type` = `sharing_rule`, resolved by the `sharingRules` service; ' + 'its grants reconcile on the ledger write) or declared webhook (`:type` = `webhook`, resolved by the `declaredWebhooks` ' - + 'reader; the webhook dispatcher refreshes its subscriptions on the ledger write) on/off for this deployment. Writes one `sys_metadata_activation` row through the engine and ' + + 'reader; the webhook dispatcher refreshes its subscriptions on the ledger write) or declared ownership queue (`:type` = `queue`, ' + + 'resolved by the `ownership-queue` service; the queue-recipient sharing rules naming it recompute on the ledger write) or ' + + 'declared assignment rule (`:type` = `assignment_rule`, resolved in the engine\'s metadata registry, which the insert-time ' + + 'evaluator reads) on/off for this deployment. Writes one `sys_metadata_activation` row through the engine and ' + 'nothing else (⛔ no definition write, ⛔ no catalog row `active`: ADR-0131 D3 moved the switch into the ledger, and ' + '`resolveUserAuthzGrants` reads only the ledger for these two types). Body `{ enabled?: boolean }`, the same reader as ' + 'the action door. The same two authority tiers as `POST /automation/:name/toggle` and `POST /actions/_activation/:object/:action`: ' diff --git a/packages/spec/src/contracts/ownership-queue-service.ts b/packages/spec/src/contracts/ownership-queue-service.ts index 4f2185e41e5..513167a14fd 100644 --- a/packages/spec/src/contracts/ownership-queue-service.ts +++ b/packages/spec/src/contracts/ownership-queue-service.ts @@ -110,10 +110,11 @@ export interface IOwnershipQueueService { queuesOf(userId: string, organizationId: string | null): Promise; /** - * The declared queue named `name` — `null` when no package declares it or - * the activation ledger switches it off. Optional: the activation door looks - * a `queue` row's subject up through it, and answers 503 when the - * registered service does not carry it. + * The declared queue named `name`, whether or not the activation ledger + * switches it off — `null` when no package declares it. Optional: the + * activation door looks a `queue` row's subject up through it (a queue + * switched off must stay addressable to be switched back on), and answers + * 503 when the registered service does not carry it. */ declaredQueueNamed?(name: string): Promise<{ name: string; packageId?: string } | null>; } From 11a6513240cb972cc7d3a96e4ea643213a00547f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 15:37:30 +0000 Subject: [PATCH 07/23] chore(changeset): ownership queues and assignment rules (ADR-0142) Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- .changeset/25769-ownership-queue.md | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .changeset/25769-ownership-queue.md diff --git a/.changeset/25769-ownership-queue.md b/.changeset/25769-ownership-queue.md new file mode 100644 index 00000000000..53a2af9db76 --- /dev/null +++ b/.changeset/25769-ownership-queue.md @@ -0,0 +1,32 @@ +--- +'@objectstack/spec': minor +'@objectstack/objectql': minor +'@objectstack/metadata-core': minor +'@objectstack/plugin-security': minor +'@objectstack/plugin-sharing': minor +'@objectstack/plugin-approvals': minor +'@objectstack/lint': minor +'@objectstack/runtime': minor +'@objectstack/platform-objects': minor +'@object-ui/app-shell': minor +--- + +feat: ownership queues: records wait in a queue until a member claims them, and assignment rules route new records (ADR-0142) + +Clause-②: yes +Metadata-protocol: added + +Two enum additions: `ShareRecipientType` gains `queue` (a sharing rule can now name a queue as its recipient), and the `queue` approver type is authorable again (it leaves `NON_AUTHORABLE_APPROVER_TYPES`, so it is no longer marked deprecated). Two metadata types are new: `queue` and `assignment_rule`. + +- **`@objectstack/spec`**: new `queue` and `assignment_rule` metadata types (`QueueSchema`, `AssignmentRuleSchema`) and the stack keys `queues` and `assignmentRules`. A declared queue's members are positions. New `IOwnershipQueueService` contract, registered under `ownership-queue`. `queue` joins the injected system columns of `ownership: 'user'` objects and `PUBLIC_FORM_SERVER_MANAGED_FIELDS`. `APPROVER_VALUE_BINDINGS.queue` reads both a declared queue and an organization's `sys_queue` row, and `APPROVER_ORG_SCOPED.queue` is `true`. The explain rule kinds `queue_membership` and `queue_claim` are new. `QUEUE_NOT_FOUND` (422) is registered under plugin-security. +- **`@objectstack/objectql`**: objects with `ownership: 'user'` get a system-managed `queue` column beside `owner_id`. Declaring your own `queue` field on one is refused at registration (`INVALID_METADATA`). An update can carry a write precondition: when it no longer matches, the update answers `409 CONCURRENT_UPDATE`. Declared assignment rules run once for each inserted row that has no owner and no queue after the `beforeInsert` hooks. A rule can park the row in a queue, or pick a member by `round_robin` or `least_loaded`. Assignment never refuses an insert. `kernel:ready` refuses to boot a stack that declares queues or assignment rules when no `ownership-queue` service is registered. +- **`@objectstack/metadata-core`**: `queue` is classified as an injected system column. +- **`@objectstack/plugin-sharing`**: three new objects, `sys_queue`, `sys_queue_member` and `sys_assignment_cursor`, and the `ownership-queue` service. The service resolves a queue name from the registry first, then from the organization's own rows, and has one member expander. A record with no owner that sits in a queue can be read and edited by the queue's members, never deleted. The `queue` sharing recipient grants matched records to the queue's members, and its grants are recomputed when membership changes or the queue is switched on or off. +- **`@objectstack/plugin-security`**: the owner gate handles `queue`. A **claim** sets `owner_id` from empty to yourself, by id. It is admitted when the sharing model allows you the record, and only one claimant wins: the loser gets `409`. A **release** gives your own record back to its home queue. A **move** to another queue needs the transfer grant, and a queue name that does not resolve is refused with `422 QUEUE_NOT_FOUND`. An insert that names a queue needs the transfer grant or membership of that queue. A guest can supply neither. Explain reports the queue reasons on the sharing layer. +- **`@objectstack/plugin-approvals`**: a `queue` approver resolves to the queue's members when the step opens. An empty queue adds nobody and logs a warning. The `queue:` placeholder is never written again. +- **`@objectstack/lint`**: a queue-only approval node is reported `approval-approvers-may-resolve-empty`, and the org-axis red line admits the queue recipient. Two new advisory rules come with `os explain` entries: `assignment-rule-user-target` (warning) and `assignment-rule-queue-unstaffed` (info). +- **`@objectstack/runtime`**: `POST /security/_activation/:type/:name` also switches a declared `queue` or `assignment_rule` on and off. +- **`@objectstack/platform-objects`**: the two new metadata types' labels, and the queue recipient option, are translated in zh-CN, ja-JP and es-ES. +- **`@object-ui/app-shell`**: the flow designer no longer warns that a queue approver resolves to nobody. + +What to do: nothing for existing stacks, which behave as before. To use queues, declare `queues` and `assignmentRules` in your stack, and make sure plugin-sharing is installed: it registers the `ownership-queue` service. From cbd25482f41b8376d68ae5b19fe3acd00f521066 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 16:16:19 +0000 Subject: [PATCH 08/23] fix(gates): census, empty-state, llms.txt, field codes and transcripts for queues - Regenerate the platform-object tenancy census for sys_queue, sys_queue_member and sys_assignment_cursor. - Classify the assignment-rule entry `criteria` empty state as scope. - Count the queue and assignment-rule schema modules in llms.txt. - Queue row refusals carry closed-catalog field codes: a declared-name clash is `invalid_value` (`duplicate` is not a FieldErrorCode member). - Docs transcripts print 54 author-time rules. Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- content/docs/deployment/cli.mdx | 2 +- .../docs/deployment/validating-metadata.mdx | 2 +- .../build-with-claude-code.mdx | 2 +- content/docs/ui/react-pages.mdx | 2 +- .../src/ownership-queue.test.ts | 2 +- .../plugins/plugin-sharing/src/queue-hooks.ts | 43 ++++++++++--------- packages/spec/llms.txt | 6 +-- .../scripts/liveness/empty-state-registry.mts | 8 ++++ scripts/platform-object-tenancy-census.json | 25 ++++++++++- 9 files changed, 62 insertions(+), 30 deletions(-) diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index e462c172f19..44841bf5ebc 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -758,7 +758,7 @@ os compile --json # JSON output for CI pipelines → Normalizing stack definition... → Lowering inline handlers... → Validating protocol compliance... - → Running author-time rules (53)... + → Running author-time rules (54)... → Checking that every required capability has a provider installable in this edition... → Collecting package docs (ADR-0046)... 0 collected → Writing artifact... diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index d42583e5b0e..e673791984b 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -752,7 +752,7 @@ A clean run walks the registry and reports timing: Config: /path/to/support-desk/objectstack.config.ts Load time: 21ms → Validating against ObjectStack Protocol... - → Running author-time rules (53)... + → Running author-time rules (54)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index bf832a5d41e..c545ed7395e 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -305,7 +305,7 @@ visible: 'status != "resolved"' ◆ Validate ──────────────────────────────────────── → Validating against ObjectStack Protocol... - → Running author-time rules (53)... + → Running author-time rules (54)... ✗ Author-time rules failed (1 issue) • stack · action 'resolve_ticket' visible: bare reference `status` — a diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index e37ff6aa264..6b500416a86 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -397,7 +397,7 @@ objectstack validate ──────────────────────────────────────── → Loading configuration... → Validating against ObjectStack Protocol... - → Running author-time rules (53)... + → Running author-time rules (54)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... diff --git a/packages/plugins/plugin-sharing/src/ownership-queue.test.ts b/packages/plugins/plugin-sharing/src/ownership-queue.test.ts index cad2f0c0576..e97e7f545b4 100644 --- a/packages/plugins/plugin-sharing/src/ownership-queue.test.ts +++ b/packages/plugins/plugin-sharing/src/ownership-queue.test.ts @@ -351,7 +351,7 @@ describe('[ADR-0142 D1] sys_queue / sys_queue_member guards', () => { await expect(engine.fire('beforeInsert', 'sys_queue', { input: { data: { name: 'Billing Team' } } })).rejects.toBeInstanceOf(QueueValidationError); await expect(engine.fire('beforeInsert', 'sys_queue', { input: { data: { name: 'support_tier1' } } })).rejects.toMatchObject({ code: 'VALIDATION_FAILED', - fields: [{ field: 'name', code: 'duplicate' }], + fields: [{ field: 'name', code: 'invalid_value' }], }); await expect(engine.fire('beforeInsert', 'sys_queue', { input: { data: { name: 'billing' } } })).resolves.toBeUndefined(); }); diff --git a/packages/plugins/plugin-sharing/src/queue-hooks.ts b/packages/plugins/plugin-sharing/src/queue-hooks.ts index e44319cfcd1..76b79ec1eae 100644 --- a/packages/plugins/plugin-sharing/src/queue-hooks.ts +++ b/packages/plugins/plugin-sharing/src/queue-hooks.ts @@ -38,6 +38,7 @@ */ import type { SharingRuleRow } from '@objectstack/spec/contracts'; +import type { FieldErrorCode } from '@objectstack/spec/api'; import type { OptionalSharingLogger } from './logger-shapes.js'; import { ruleRegrantQueue } from './rule-hooks.js'; import { METADATA_ACTIVATION_TABLE } from '@objectstack/core'; @@ -71,15 +72,16 @@ interface MinimalEngine { * Field-level refusal for a queue row, on the data-API path. The same shape * `SharingCriteriaValidationError` uses (`code: 'VALIDATION_FAILED'`, a * `fields[]` entry), so REST answers it as a validation failure naming the - * field. + * field. The entry's code is a member of the closed field-level catalog + * (`FieldErrorCode`). */ export class QueueValidationError extends Error { readonly code = 'VALIDATION_FAILED'; - readonly fields: Array<{ field: string; code: string; message: string }>; - constructor(field: string, code: string, message: string) { - super(message); + readonly fields: Array<{ field: string; code: FieldErrorCode; message: string }>; + constructor(issue: { field: string; code: FieldErrorCode; message: string }) { + super(issue.message); this.name = 'ValidationError'; - this.fields = [{ field, code, message }]; + this.fields = [issue]; } } @@ -104,19 +106,20 @@ export function bindQueueGuards(engine: MinimalEngine, queues: Pick/` — the sources that ship in this tarball (`files` includes `src/**/*.zod.ts`), so every number @@ -90,12 +90,12 @@ here is verifiable from the installed package. | data | 31 | Object, Field, Picklist, Query, Filter, Driver (SQL/NoSQL/Memory/Mongo/Postgres), Cube | | api | 31 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage) | | ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Animation | -| automation | 14 | Flow, Approval, BPMN Interop, Control Flow, Webhook, Schedule Organization | +| automation | 15 | Flow, Approval, Assignment Rule, BPMN Interop, Control Flow, Webhook, Schedule Organization | | shared | 16 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy, Conditional Formatting, Value Domain, Epoch Instant (EpochMs), Duration (DurationMs / DurationSeconds) | | ai | 12 | Agent, Build Progress, Conversation, Knowledge Source/Document, Model Registry, MCP, Skill, Tool | | identity | 5 | Identity, Organization, Position, SCIM, Eval User | | marketplace | 4 | Marketplace, Package, Package Version, Template Manifest | -| security | 4 | Permission, RLS, Sharing, Explain | +| security | 5 | Permission, RLS, Sharing, Queue, Explain | | studio | 3 | Flow Builder, Object Designer, Studio Plugin | | integration | 1 | Connector | | qa | 1 | Testing | diff --git a/packages/spec/scripts/liveness/empty-state-registry.mts b/packages/spec/scripts/liveness/empty-state-registry.mts index 7259176ef32..cbf6ddf5ace 100644 --- a/packages/spec/scripts/liveness/empty-state-registry.mts +++ b/packages/spec/scripts/liveness/empty-state-registry.mts @@ -178,6 +178,14 @@ export const EMPTY_STATE_REGISTRY: EmptyStateEntry[] = [ 'Which rows of a `mirror-deletion` (ADR-0131 D10 fate 2) table are copies of code, as the platform inventory\'s own `mirror` reads: absent = every row. It selects work for the ceremony\'s mirror step, not access, and the fate itself is the declaration that the rows are re-derivable. The step deletes nothing before the verified id-to-name rewrite, and it always keeps every row a named category claims and, on a table that records provenance, every row no seeder wrote.', evidence: 'packages/cli/src/utils/organization-ownership-apply.ts (deletableWhere — the mirror selector minus every claimed row)', }, + { + file: 'packages/spec/src/automation/assignment-rule.zod.ts', + property: 'criteria', + semantics: 'scope', + rationale: + "[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/spec/src/automation/flow.zod.ts', property: 'errorCode', diff --git a/scripts/platform-object-tenancy-census.json b/scripts/platform-object-tenancy-census.json index 269ec093e36..65173976f70 100644 --- a/scripts/platform-object-tenancy-census.json +++ b/scripts/platform-object-tenancy-census.json @@ -26,8 +26,8 @@ "predicate": "resolveTenantFieldName(registered schema) !== null, where the registered schema is the authored schema plus the columns resolveInjectedSystemColumns says the registration injects (the applySystemFields pass). Both functions are loaded from source and executed; neither is re-spelled here.", "population": "every object registered by a tracked packages/**/*.object.ts module whose name carries a platform prefix (sys_ / cloud_ / ai_). The cloud repository's own cloud_ objects are not in this tree and so not in this census.", "totals": { - "registered": 84, - "inReach": 41, + "registered": 87, + "inReach": 44, "outOfReach": 43 }, "reasonTotals": { @@ -98,6 +98,13 @@ "tenantField": "organization_id", "reasons": [] }, + { + "name": "sys_assignment_cursor", + "file": "packages/plugins/plugin-sharing/src/objects/sys-assignment-cursor.object.ts", + "reach": "in", + "tenantField": "organization_id", + "reasons": [] + }, { "name": "sys_attachment", "file": "packages/platform-objects/src/audit/sys-attachment.object.ts", @@ -506,6 +513,20 @@ "systemFields.tenant: false" ] }, + { + "name": "sys_queue", + "file": "packages/plugins/plugin-sharing/src/objects/sys-queue.object.ts", + "reach": "in", + "tenantField": "organization_id", + "reasons": [] + }, + { + "name": "sys_queue_member", + "file": "packages/plugins/plugin-sharing/src/objects/sys-queue-member.object.ts", + "reach": "in", + "tenantField": "organization_id", + "reasons": [] + }, { "name": "sys_record_share", "file": "packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts", From c98a59afc17740015533091a14fe2aebc20ba14a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 16:54:13 +0000 Subject: [PATCH 09/23] fix(gates): logger receiver, double bound, typed driver options, docs counts and checklist waivers - The shadowed-queue report keeps its logger receiver. - The queue test's engine double applies the caller's limit by presence and no longer writes into its row source. - The by-id precondition write passes driver options typed against IDataDriver instead of erasing them to any. - Docs: the overlay page counts 31 registry types, the quick reference counts 15 automation and 6 security reference pages. - Platform checklist: queue and assignment_rule are waived until an app the checklist drives declares them. Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- content/docs/concepts/metadata-lifecycle.mdx | 2 +- content/docs/getting-started/quick-reference.mdx | 4 ++-- docs/qa/platform-checklist/coverage.json | 6 ++++++ packages/objectql/src/engine.ts | 10 +++++----- .../plugin-sharing/src/ownership-queue.test.ts | 14 ++++++-------- packages/plugins/plugin-sharing/src/queue-hooks.ts | 3 ++- 6 files changed, 22 insertions(+), 17 deletions(-) diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index a562fd41b0d..3de05a7f277 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -120,7 +120,7 @@ later ships. See [ADR-0070](https://github.com/objectstack-ai/objectstack/blob/m | `job` | ❌ | **Also `allowRuntimeCreate: false` since protocol 17** (#4509). `JobSchema.handler` names a function in the compiled bundle's function table, which a runtime writer has no way to reach — so a job created in Studio or through `PUT /meta` parsed, saved, reported success and was never scheduled. The door is closed rather than bridged: `job` stays first-class through `*.job.ts` / `defineStack({ jobs, functions })`, where every schedule shape, `retryPolicy` and `timeout` does reach the scheduler. Existing rows are untouched — they were never scheduled — and `migrateStoredMetadata` reports them `skipped`. | | `sharing_rule` | ❌ | **Also `allowRuntimeCreate: false` since protocol 18** (ADR-0131 D6, regime C). A sharing rule has two sources: a package declares it (the stack's `sharingRules`, switched off through the activation ledger), or an organization authors its own through `POST /api/v1/sharing/rules`, behind the same-name guard and the organization wall. A runtime `sys_metadata` item was a third source that bypassed both, so `PUT /meta/sharing_rule/:name` now answers `403 NOT_CREATABLE` (`NOT_OVERRIDABLE` over a declared rule) and names the two sanctioned paths: author an organization rule, or clone the declared one and switch it off. Items stored before the change are still served until deleted. | -Those five are the **complete** `allowEnvironmentOverlay: true` set: of the 29 types in `DEFAULT_METADATA_TYPE_REGISTRY`, every other one is `false`. The ❌ rows above are the `false` types whose *second* tier (`allowRuntimeCreate`) is worth calling out; any type not listed is `allowEnvironmentOverlay: false`. +Those five are the **complete** `allowEnvironmentOverlay: true` set: of the 31 types in `DEFAULT_METADATA_TYPE_REGISTRY`, every other one is `false`. The ❌ rows above are the `false` types whose *second* tier (`allowRuntimeCreate`) is worth calling out; any type not listed is `allowEnvironmentOverlay: false`. There is no `workflow` metadata type (per [ADR-0020](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0020-state-machine-converge-and-enforce.md), record state machines are a `state_machine` validation). Nor is there a standalone `validation` type any more — it was retired in protocol 17 under [ADR-0088](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0088-metadata-kind-admission-and-retirement.md) because `ValidationRuleSchema` carries no object-binding key, so a rule authored through that door could never say what it protected; author rules in the object's own `validations[]` instead. The runtime gate is implemented in `OVERLAY_ALLOWED_TYPES` (derived from the registry) and enforced by `SysMetadataRepository.put()`. diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index 85db14ba0af..b4ca0fe8b62 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -150,7 +150,7 @@ REST endpoints, real-time subscriptions, and discovery. | **[Metadata](/docs/references/api/metadata)** | `metadata.zod.ts` | Metadata | API metadata endpoints | | **[Storage](/docs/references/api/storage)** | `storage.zod.ts` | Storage | API storage operations | -## Automation Protocol (3 of 14 schemas) +## Automation Protocol (3 of 15 schemas) Flows, approvals, and integrations. A record's lifecycle transitions are a `state_machine` validation rule on the object (see [State Machine](/docs/protocol/objectql/state-machine)). @@ -160,7 +160,7 @@ Flows, approvals, and integrations. A record's lifecycle transitions are a `stat | **[Approval](/docs/references/automation/approval)** | `approval.zod.ts` | ApprovalNodeConfig | Flow approval-node config | | **[Webhook](/docs/references/automation/webhook)** | `webhook.zod.ts` | Webhook | Outbound webhooks | -## Security Protocol (3 of 5 schemas) +## Security Protocol (3 of 6 schemas) Access control, permissions, and row-level security. diff --git a/docs/qa/platform-checklist/coverage.json b/docs/qa/platform-checklist/coverage.json index 328a7687ca7..97d46c28fa1 100644 --- a/docs/qa/platform-checklist/coverage.json +++ b/docs/qa/platform-checklist/coverage.json @@ -43,6 +43,9 @@ "platform-core.marketplace-console-honesty" ] }, + "assignment_rule": { + "waived": "WAIVED 2026-10-26 — no fixture declares one yet, measured rather than asserted. The runtime landed with ADR-0142 phase 2 (#25769): the engine routes each inserted row with no owner and no queue through the object's declared `assignmentRules` (park in a queue, `round_robin` or `least_loaded`), and `POST /security/_activation/assignment_rule/:name` switches one off. It is proven by unit and integration suites (packages/objectql/src/ownership-queue.test.ts, the runtime activation-door test), but no app this checklist drives (the showcase, the dev apps) declares an assignment rule, so an item written to RUN today has nothing to route. WHAT RETIRES THIS WAIVER — an app the checklist drives declares a queue and an assignment rule (the HotCRM adoption phase of #25769); an item then inserts a matching record and reads where it went (parked, or owned by the member the strategy chose), and the same edit replaces this waiver with `items` naming it. ⚠️ Re-audit it every sweep, as SWEEP.md asks of every waiver." + }, "batch_endpoints": { "items": [ "api-backend.rest-batch-config-contract", @@ -251,6 +254,9 @@ "api-backend.aggregate-contract-matrix" ] }, + "queue": { + "waived": "WAIVED 2026-10-26 — no fixture declares one yet, measured rather than asserted. The runtime landed with ADR-0142 phase 2 (#25769): the `queue` column on `ownership: 'user'` objects, the sitting-record read/edit path for queue members, claim / release / move through the owner gate (409 for the losing claimant, 422 QUEUE_NOT_FOUND), the `queue` sharing recipient and the `queue` approver. It is proven by unit and integration suites (plugin-sharing ownership-queue tests, the plugin-security queue gate and explain tests, the approvals queue tests), but no app this checklist drives (the showcase, the dev apps) declares a queue, and the console has no queue surface yet, so an item written to RUN today has no queue to claim from. WHAT RETIRES THIS WAIVER — an app the checklist drives declares a staffed queue (the HotCRM adoption phase of #25769); items then drive claim (two members race, one wins), release, move and the member-only read across two personas, and the same edit replaces this waiver with `items` naming them. ⚠️ Re-audit it every sweep, as SWEEP.md asks of every waiver." + }, "realtime_subscription": { "waived": "WAIVED 2026-09-04 — there is no runtime behaviour to drive, measured rather than asserted. SubscriptionSchema (packages/spec/src/api/realtime.zod.ts) is a TRANSPORT-PROTOCOL declaration with zero runtime readers: its liveness ledger records every property dead at a same-day census, nothing outside packages/spec imports SubscriptionSchema / SubscriptionEventSchema / RealtimeConfigSchema, and the shipped in-memory adapter reads a different type entirely (RealtimeSubscriptionOptions, packages/spec/src/contracts/realtime-service.ts). There is also nothing for a client to connect to: measured on a stock showcase boot for #14646, GET /api/v1/discovery reports realtime enabled:true but status degraded, handlerReady:false, and no realtime entry in the routes map — #14646 is the open decision on whether the open framework mounts an HTTP/WS/SSE realtime surface at all. An item authored today could only assert absence, which belongs in FOLLOW-UPS.md (§10) and on #14646, not in a coverage entry. WHAT RETIRES THIS WAIVER — either the #14646 decision mounts a realtime transport that parses this schema, in which case author the subscribe / unsubscribe / event-delivery contract and flip this entry to items; or the ADR-0049 enforce-or-remove call retires the schema and its ledger, and the ratchet flags this entry as an ORPHAN to delete. ⚠️ Re-audit it every sweep: 6 of 6 waivers ever written in this file turned out stale (SWEEP.md), which is why this one is grounded in the census and the discovery reading rather than in the schema's own prose." }, diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 8be75217eba..941429975d7 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -15935,7 +15935,7 @@ export class ObjectQL implements IObjectQLEngine { id: unknown, data: Record, expect: Readonly>, - options: unknown, + options: Parameters[2], ): Promise { if (typeof driver.updateMany !== 'function') { if (!this.preconditionDegradedWarned) { @@ -15947,11 +15947,11 @@ export class ObjectQL implements IObjectQLEngine { { object }, ); } - return driver.update(object, id as string, data, options as any); + return driver.update(object, id as string, data, options); } - const affected = await driver.updateMany(object, { object, where: { ...expect, id } } as QueryAST, data, options as any); + const affected = await driver.updateMany(object, { object, where: { ...expect, id } } as QueryAST, data, options); if (!(Number(affected) > 0)) throw new ConcurrentUpdateError(object, String(id)); - return driver.findOne(object, { object, where: { id }, limit: 1 } as QueryAST, options as any); + return driver.findOne(object, { object, where: { id }, limit: 1 } as QueryAST, options); } /** [ADR-0142 D4] Set once the precondition's no-`updateMany` degradation has been logged. */ @@ -18754,7 +18754,7 @@ export class ObjectQL implements IObjectQLEngine { // `OperationContext.writePrecondition`. const precondition = opCtx.writePrecondition; result = precondition && Object.keys(precondition).length > 0 - ? await this.updateByIdExpecting(driver, object, hookContext.input.id, hookContext.input.data as Record, precondition, hookContext.input.options) + ? await this.updateByIdExpecting(driver, object, hookContext.input.id, hookContext.input.data as Record, precondition, hookContext.input.options as Parameters[2]) : await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record, hookContext.input.options as any); } catch (driverError) { throw envelopeUniqueViolation(driverError, object, this.getSchema(object)); diff --git a/packages/plugins/plugin-sharing/src/ownership-queue.test.ts b/packages/plugins/plugin-sharing/src/ownership-queue.test.ts index e97e7f545b4..d2f1f131a9e 100644 --- a/packages/plugins/plugin-sharing/src/ownership-queue.test.ts +++ b/packages/plugins/plugin-sharing/src/ownership-queue.test.ts @@ -44,14 +44,12 @@ function makeEngine(opts: { declared?: Row[]; tables?: Record; schemas?: Record; - failOn?: Set; + failOn?: string[]; } = {}) { const tables: Record = { ...(opts.tables ?? {}) }; - const reads: string[] = []; const hooks: Array<{ event: string; object: string; handler: (ctx: any) => any; packageId?: string }> = []; const engine = { _tables: tables, - reads, hooks, registry: { listItems: (type: string) => (type === 'queue' ? (opts.declared ?? []) : []), @@ -59,10 +57,10 @@ function makeEngine(opts: { }, getSchema: (name: string) => opts.schemas?.[name], async find(object: string, options?: any) { - reads.push(object); - if (opts.failOn?.has(object)) throw new Error(`${object} unavailable`); + if (opts.failOn?.includes(object)) throw new Error(`${object} unavailable`); const filter = options?.filter ?? options?.where; - return (tables[object] ?? []).filter((r) => matches(r, filter)).slice(0, options?.limit ?? 1000); + const rows = (tables[object] ?? []).filter((r) => matches(r, filter)); + return typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows; }, async insert(object: string, data: any) { (tables[object] ??= []).push({ ...data }); @@ -179,7 +177,7 @@ describe('[ADR-0142 D2] OwnershipQueueService — the one expander', () => { it('a failed member-table read contributes nobody and is logged once', async () => { const warn = vi.fn(); - const svc = service(makeEngine({ tables: BASE_TABLES(), failOn: new Set(['sys_queue_member']) }), { warn }); + const svc = service(makeEngine({ tables: BASE_TABLES(), failOn: ['sys_queue_member'] }), { warn }); expect(await svc.expandMembers('billing', 'org_a')).toEqual([]); expect(await svc.expandMembers('billing', 'org_a')).toEqual([]); expect(warn).toHaveBeenCalledTimes(1); @@ -188,7 +186,7 @@ describe('[ADR-0142 D2] OwnershipQueueService — the one expander', () => { it('a failed ledger read leaves the declared default — on — and says so', async () => { const warn = vi.fn(); - const svc = service(makeEngine({ declared: SUPPORT_DECLARED, tables: BASE_TABLES(), failOn: new Set([METADATA_ACTIVATION_TABLE]) }), { warn }); + const svc = service(makeEngine({ declared: SUPPORT_DECLARED, tables: BASE_TABLES(), failOn: [METADATA_ACTIVATION_TABLE] }), { warn }); expect(await svc.expandMembers('support_tier1', 'org_a')).toEqual(['u_a', 'u_b', 'u_d']); expect(warn).toHaveBeenCalled(); }); diff --git a/packages/plugins/plugin-sharing/src/queue-hooks.ts b/packages/plugins/plugin-sharing/src/queue-hooks.ts index 76b79ec1eae..59ad76bc691 100644 --- a/packages/plugins/plugin-sharing/src/queue-hooks.ts +++ b/packages/plugins/plugin-sharing/src/queue-hooks.ts @@ -166,7 +166,8 @@ export async function reportShadowedAuthoredQueues( for (const row of Array.isArray(rows) ? rows : []) { const name = present(row?.name); if (!name || !queues.isDeclaredQueueName(name)) continue; - (logger?.error ?? logger?.warn)?.( + (logger?.error ?? logger?.warn)?.call( + logger, `[queue] an organization's queue '${name}' is shadowed by a queue an installed app declares under the same ` + 'name — the declared queue answers for it. Rename the organization\'s queue to keep it.', { queue: name, organization: present(row?.organization_id) }, From 0388a83381af95ed10021b0520257e195e5efc36 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 17:05:24 +0000 Subject: [PATCH 10/23] fix(gates): artifact door maps queues, parsed aliases, showcase and skill keys - The metadata artifact door registers declared `queues` and `assignmentRules` (beside `sharingRules`), so an artifact-only boot serves them; the stack-collection map waivers record the other sites. - `AssignmentRuleEntryParsed` names the parsed entry; the four isomorphic queue and assignment-rule aliases are pinned (789 -> 793). - The showcase waives `queue` and `assignment_rule` until an app adopts them. - The platform skill lists the two new top-level keys; the two ADR citations in the next paragraph pay for them (488 lines either side). Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- examples/app-showcase/src/coverage.ts | 16 ++++++++++++ packages/metadata/src/plugin.ts | 4 +++ .../src/automation/assignment-rule.zod.ts | 2 ++ .../src/type-alias-convention.pin.test.ts | 25 ++++++++++++++++--- scripts/check-stack-collection-maps.mjs | 11 +++++--- skills/objectstack-platform/SKILL.md | 8 +++--- 6 files changed, 56 insertions(+), 10 deletions(-) diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index 74fdefe7d8f..63366d3efba 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -174,6 +174,22 @@ export const KIND_COVERAGE: Record = { notes: 'Regime C (ADR-0131 D6): package-declared rules (`sharingRules` in objectstack.config.ts), served from the registry and switched off through the activation ledger (POST /api/v1/security/_activation/sharing_rule/:name). Organizations author their own through POST /api/v1/sharing/rules. Code-only kind: PUT /meta/sharing_rule/:name answers 403 NOT_CREATABLE (NOT_OVERRIDABLE over a declared rule), which runtime meta-sharing-rule-door.test.ts pins.', }, + // [ADR-0142 D1/D5] Ownership queues and the assignment rules that route inserted + // rows into them joined the registry as code-only kinds. Waived until a fixture + // app adopts them: an assignment rule changes who owns a newly inserted row, so + // adding one to the showcase is fixture work with its own review. + queue: { + status: 'waived', + reason: + 'Not yet demonstrated. The runtime is proven by suites (plugin-sharing ownership-queue, the plugin-security queue gate, the approvals queue approver), but no showcase object is routed through a queue yet; the adoption phase of ADR-0142 (#25769) brings the first app that declares one.', + issue: 'https://github.com/objectstack-ai/objectstack/issues/25769', + }, + assignment_rule: { + status: 'waived', + reason: + 'Not yet demonstrated, for the same reason as `queue`: a declared assignment rule routes every matching insert that arrives with no owner, so it lands with the app that adopts queues (ADR-0142 adoption phase, #25769) rather than inside the showcase\'s existing objects.', + issue: 'https://github.com/objectstack-ai/objectstack/issues/25769', + }, // ── ai ── agent: { diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index d49307d1f45..94e90635a8f 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -168,6 +168,10 @@ const ARTIFACT_FIELD_TO_TYPE: Record = { // explicitly permits, and #12878's pins are what keep it visible. capabilities: 'capability', sharingRules: 'sharing_rule', + // [ADR-0142 D1/D5] Declared ownership queues and assignment rules, beside the + // sharing rules that can name a queue as their recipient. + queues: 'queue', + assignmentRules: 'assignment_rule', // `policies: 'policy'` removed at #12894: the stack schema is a // `strictObject` that declares no top-level `policies` key, so a // definition carrying one is refused by the strict parse a few lines diff --git a/packages/spec/src/automation/assignment-rule.zod.ts b/packages/spec/src/automation/assignment-rule.zod.ts index 6fd31a6da02..17d3bed3162 100644 --- a/packages/spec/src/automation/assignment-rule.zod.ts +++ b/packages/spec/src/automation/assignment-rule.zod.ts @@ -182,6 +182,8 @@ export type AssignmentRule = z.input; /** Post-parse shape of {@link AssignmentRule} — defaults applied, transforms run (ADR-0122). */ export type AssignmentRuleParsed = z.infer; export type AssignmentRuleEntry = z.input; +/** The parsed entry: `criteria` and `load` arrive as their evaluated-expression form. */ +export type AssignmentRuleEntryParsed = z.infer; export type AssignmentTarget = z.input; /** diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 40c6c20ac41..5f952bbc152 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -278,9 +278,12 @@ import type * as M190 from './kernel/execution-context.zod.js'; import type * as M191 from './automation/api-trigger-secret-ref.zod.js'; // [#25761] The one column apply-on vocabulary both write doors read -- new module, next free index. import type * as M192 from './data/column-apply-on.zod.js'; +// [ADR-0142] Ownership queues and assignment rules -- new modules, next free indices. +import type * as M193 from './automation/assignment-rule.zod.js'; +import type * as M194 from './security/queue.zod.js'; // --------------------------------------------------------------------------- -// 789 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 793 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -722,6 +725,10 @@ export type Iso_automation_approval__ApprovalNodeApproverSchema = Assert, z.infer< typeof M37.ApproverType > >>; export type Iso_automation_approval__DecisionOutputDefSchema = Assert, z.infer< typeof M37.DecisionOutputDefSchema > >>; +// automation/assignment-rule.zod.ts +export type Iso_automation_assignmentRule__AssignmentStrategy = Assert, z.infer< typeof M193.AssignmentStrategy > >>; +export type Iso_automation_assignmentRule__AssignmentTargetSchema = Assert, z.infer< typeof M193.AssignmentTargetSchema > >>; + // automation/bpmn-interop.zod.ts export type Iso_automation_bpmnInterop__BpmnDiagnosticSchema = Assert, z.infer< typeof M38.BpmnDiagnosticSchema > >>; export type Iso_automation_bpmnInterop__BpmnUnmappedStrategySchema = Assert, z.infer< typeof M38.BpmnUnmappedStrategySchema > >>; @@ -1133,6 +1140,10 @@ export type Iso_security_explain__ExplainRequestSchema = Assert, z.infer< typeof M107.EffectiveObjectPermissionSchema > >>; export type Iso_security_permission__ObjectAccessScopeSchema = Assert, z.infer< typeof M107.ObjectAccessScopeSchema > >>; +// security/queue.zod.ts +export type Iso_security_queue__DeclaredQueueMemberSchema = Assert, z.infer< typeof M194.DeclaredQueueMemberSchema > >>; +export type Iso_security_queue__QueueMemberType = Assert, z.infer< typeof M194.QueueMemberType > >>; + // security/rls.zod.ts export type Iso_security_rls__RLSEvaluationResultSchema = Assert, z.infer< typeof M108.RLSEvaluationResultSchema > >>; export type Iso_security_rls__RLSOperation = Assert, z.infer< typeof M108.RLSOperation > >>; @@ -1710,7 +1721,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 789 isomorphic pins', () => { + it('still declares all 793 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2600,7 +2611,15 @@ describe('ADR-0122 type-alias convention', () => { // (Iso_ui_dataset__DatasetMeasureDurationSchema, // Iso_ui_dataset__DatasetDurationUnit) rather than given `…Parsed` // synonyms. +2 added. - expect(pins).toHaveLength(789); + // + // 789 -> 793 is ADR-0142 phase 2 (#25769): the ownership queue's + // `QueueMemberType` enum and `DeclaredQueueMemberSchema`, and the assignment + // rule's `AssignmentStrategy` enum and `AssignmentTargetSchema`, pinned here + // (Iso_security_queue__QueueMemberType, Iso_security_queue__DeclaredQueueMemberSchema, + // Iso_automation_assignmentRule__AssignmentStrategy, + // Iso_automation_assignmentRule__AssignmentTargetSchema). The rule's entry + // schema has two shapes and carries `AssignmentRuleEntryParsed` instead. +4 added. + expect(pins).toHaveLength(793); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either diff --git a/scripts/check-stack-collection-maps.mjs b/scripts/check-stack-collection-maps.mjs index 9b99b51af87..bef853003d7 100644 --- a/scripts/check-stack-collection-maps.mjs +++ b/scripts/check-stack-collection-maps.mjs @@ -569,12 +569,13 @@ const SITES = [ 'datasources', 'datasourceMapping', 'objectExtensions', 'apps', 'jobs', 'emailTemplates', 'docs', 'books', 'positions', 'capabilities', 'sharingRules', 'webhooks', 'tools', 'skills', 'hooks', 'mappings', 'analyticsCubes', 'connectors', 'data', 'picklists', 'picklistExtensions', + 'queues', 'assignmentRules', ], reason: 'DRIFT in the opposite direction, under the same acceptance constraint: ADDING a member widens ' + 'what an artifact may declare and presumes a packaging layout (one subdirectory per category) ' + 'that does not exist for these. The omissions are inert today because this enum is not the ' - + 'artifact ingest path — `ARTIFACT_FIELD_TO_TYPE` below is — but 21 of 33 collections absent is ' + + 'artifact ingest path — `ARTIFACT_FIELD_TO_TYPE` below is — but 23 of 35 collections absent is ' + 'the measurement that says so out loud (#6242 row 5).', }, ], @@ -714,7 +715,7 @@ const SITES = [ keys: [ 'objectExtensions', 'datasourceMapping', 'datasources', 'jobs', 'apis', 'webhooks', 'hooks', 'mappings', 'analyticsCubes', 'connectors', 'capabilities', 'datasets', - 'picklists', 'picklistExtensions', + 'picklists', 'picklistExtensions', 'queues', 'assignmentRules', ], reason: 'DELIBERATE, and load-bearing. This is a HEURISTIC, not a registration list: it answers "did the ' @@ -753,6 +754,7 @@ const SITES = [ 'views', 'pages', 'dashboards', 'reports', 'datasets', 'actions', 'flows', 'jobs', 'emailTemplates', 'docs', 'books', 'apis', 'webhooks', 'agents', 'tools', 'skills', 'hooks', 'mappings', 'analyticsCubes', 'connectors', 'data', 'picklists', 'picklistExtensions', + 'queues', 'assignmentRules', ], reason: 'DELIBERATE — a four-collection SUBSET, not an enumeration of the collection set. This block ' @@ -766,7 +768,10 @@ const SITES = [ + 'reaches it as of #12892 step 1, so this block is no longer that collection\'s sole registrar — ' + 'it is the SECOND one, on the same boot path, which is the interim state the ruling permits ' + 'while step 2 (this block stops registering the five on the ARTIFACT path only, after a census ' - + 'of the non-artifact boots that depend on it) lands.', + + 'of the non-artifact boots that depend on it) lands. `queues` and `assignmentRules` (ADR-0142) ' + + 'were considered here and left out: nothing reads them through `list(...)` — the ownership-queue ' + + 'service and the assignment seam read the ObjectQL registry that `METADATA_ARRAY_KEYS` stamps, ' + + 'and the artifact door maps both — so a third registrar would only add a copy.', }, ], }, diff --git a/skills/objectstack-platform/SKILL.md b/skills/objectstack-platform/SKILL.md index 4979af2ac36..b1e5e70aea1 100644 --- a/skills/objectstack-platform/SKILL.md +++ b/skills/objectstack-platform/SKILL.md @@ -45,16 +45,16 @@ are `manifest`, `packages`, `objects`, `objectExtensions`, `picklists`, `picklistExtensions`, `views`, `apps`, `pages`, `dashboards`, `reports`, `datasets`, `actions`, `flows`, `jobs`, `emailTemplates`, `docs`, `books`, `positions`, `permissions`, -`capabilities`, `sharingRules`, `apis`, `webhooks`, `api`, `server`, +`capabilities`, `sharingRules`, `queues`, `assignmentRules`, `apis`, `webhooks`, `api`, `server`, `agents`, `tools`, `skills`, `hooks`, `functions`, `mappings`, `analyticsCubes`, `connectors`, `data` (seed), `datasources`, `datasourceMapping`, `translations`, `i18n`, `plugins`, `devPlugins`, `devHint`, `devLogins`, `demoStaff`, `requires`, `tiers`, `onEnable`, `appName`, `email`, `sms`. There is deliberately **no** top-level `workflows` or `approvals` collection: -an approval is authored as a flow with Approval nodes (ADR-0019), and record -state machines are a `state_machine` validation rule on each object -(ADR-0020). A phantom key like `roles:` or `policies:` is **not** a silent +an approval is authored as a flow with Approval nodes, and record +state machines are a `state_machine` validation rule on each object. +A phantom key like `roles:` or `policies:` is **not** a silent no-op — the top level refuses it and the stack fails to load: ``` From 7fa35c5c867273247ff330d336c442649b5dfe0b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 17:10:47 +0000 Subject: [PATCH 11/23] fix(gates): the queue test matcher refuses what it does not implement; changeset names metadata and skills Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- .changeset/25769-ownership-queue.md | 4 ++++ .../src/ownership-queue.test.ts | 20 +++++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.changeset/25769-ownership-queue.md b/.changeset/25769-ownership-queue.md index 53a2af9db76..60c10e3ee01 100644 --- a/.changeset/25769-ownership-queue.md +++ b/.changeset/25769-ownership-queue.md @@ -2,6 +2,7 @@ '@objectstack/spec': minor '@objectstack/objectql': minor '@objectstack/metadata-core': minor +'@objectstack/metadata': minor '@objectstack/plugin-security': minor '@objectstack/plugin-sharing': minor '@objectstack/plugin-approvals': minor @@ -9,6 +10,7 @@ '@objectstack/runtime': minor '@objectstack/platform-objects': minor '@object-ui/app-shell': minor +'@objectstack/skills': minor --- feat: ownership queues: records wait in a queue until a member claims them, and assignment rules route new records (ADR-0142) @@ -21,6 +23,7 @@ Two enum additions: `ShareRecipientType` gains `queue` (a sharing rule can now n - **`@objectstack/spec`**: new `queue` and `assignment_rule` metadata types (`QueueSchema`, `AssignmentRuleSchema`) and the stack keys `queues` and `assignmentRules`. A declared queue's members are positions. New `IOwnershipQueueService` contract, registered under `ownership-queue`. `queue` joins the injected system columns of `ownership: 'user'` objects and `PUBLIC_FORM_SERVER_MANAGED_FIELDS`. `APPROVER_VALUE_BINDINGS.queue` reads both a declared queue and an organization's `sys_queue` row, and `APPROVER_ORG_SCOPED.queue` is `true`. The explain rule kinds `queue_membership` and `queue_claim` are new. `QUEUE_NOT_FOUND` (422) is registered under plugin-security. - **`@objectstack/objectql`**: objects with `ownership: 'user'` get a system-managed `queue` column beside `owner_id`. Declaring your own `queue` field on one is refused at registration (`INVALID_METADATA`). An update can carry a write precondition: when it no longer matches, the update answers `409 CONCURRENT_UPDATE`. Declared assignment rules run once for each inserted row that has no owner and no queue after the `beforeInsert` hooks. A rule can park the row in a queue, or pick a member by `round_robin` or `least_loaded`. Assignment never refuses an insert. `kernel:ready` refuses to boot a stack that declares queues or assignment rules when no `ownership-queue` service is registered. - **`@objectstack/metadata-core`**: `queue` is classified as an injected system column. +- **`@objectstack/metadata`**: the artifact door registers a stack's `queues` and `assignmentRules`, so an artifact-only runtime serves them. - **`@objectstack/plugin-sharing`**: three new objects, `sys_queue`, `sys_queue_member` and `sys_assignment_cursor`, and the `ownership-queue` service. The service resolves a queue name from the registry first, then from the organization's own rows, and has one member expander. A record with no owner that sits in a queue can be read and edited by the queue's members, never deleted. The `queue` sharing recipient grants matched records to the queue's members, and its grants are recomputed when membership changes or the queue is switched on or off. - **`@objectstack/plugin-security`**: the owner gate handles `queue`. A **claim** sets `owner_id` from empty to yourself, by id. It is admitted when the sharing model allows you the record, and only one claimant wins: the loser gets `409`. A **release** gives your own record back to its home queue. A **move** to another queue needs the transfer grant, and a queue name that does not resolve is refused with `422 QUEUE_NOT_FOUND`. An insert that names a queue needs the transfer grant or membership of that queue. A guest can supply neither. Explain reports the queue reasons on the sharing layer. - **`@objectstack/plugin-approvals`**: a `queue` approver resolves to the queue's members when the step opens. An empty queue adds nobody and logs a warning. The `queue:` placeholder is never written again. @@ -28,5 +31,6 @@ Two enum additions: `ShareRecipientType` gains `queue` (a sharing rule can now n - **`@objectstack/runtime`**: `POST /security/_activation/:type/:name` also switches a declared `queue` or `assignment_rule` on and off. - **`@objectstack/platform-objects`**: the two new metadata types' labels, and the queue recipient option, are translated in zh-CN, ja-JP and es-ES. - **`@object-ui/app-shell`**: the flow designer no longer warns that a queue approver resolves to nobody. +- **`@objectstack/skills`**: the platform skill lists `queues` and `assignmentRules` among the stack's top-level keys. What to do: nothing for existing stacks, which behave as before. To use queues, declare `queues` and `assignmentRules` in your stack, and make sure plugin-sharing is installed: it registers the `ownership-queue` service. diff --git a/packages/plugins/plugin-sharing/src/ownership-queue.test.ts b/packages/plugins/plugin-sharing/src/ownership-queue.test.ts index d2f1f131a9e..df2ac2751a5 100644 --- a/packages/plugins/plugin-sharing/src/ownership-queue.test.ts +++ b/packages/plugins/plugin-sharing/src/ownership-queue.test.ts @@ -24,14 +24,26 @@ import { ruleRegrantQueue } from './rule-hooks.js'; type Row = Record; -/** Equality, `$in` and `$or` — every predicate shape the queue reads send. */ +/** + * Equality, `$in`, `$or` and `$and` — every predicate shape the queue reads + * send. Any other combinator or operator is refused rather than misread. + */ function matches(row: Row, filter: any): boolean { if (!filter || typeof filter !== 'object') return true; - if (Array.isArray(filter.$or) && !filter.$or.some((f: any) => matches(row, f))) return false; for (const [k, v] of Object.entries(filter)) { - if (k === '$or') continue; + if (k === '$or') { + if (!(v as any[]).some((f) => matches(row, f))) return false; + continue; + } + if (k === '$and') { + if (!(v as any[]).every((f) => matches(row, f))) return false; + continue; + } + if (k.startsWith('$')) throw new Error(`test double: filter combinator '${k}' is not implemented`); const rv = row[k] ?? null; - if (v != null && typeof v === 'object' && '$in' in (v as any)) { + if (v != null && typeof v === 'object') { + const ops = Object.keys(v as object); + if (ops.length !== 1 || ops[0] !== '$in') throw new Error(`test double: operator ${ops.join(',')} on '${k}' is not implemented`); if (!(v as any).$in.includes(rv)) return false; continue; } From adb860172d4a2ff76d73316beb1ae8bbb4164476 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 17:34:16 +0000 Subject: [PATCH 12/23] chore(spec): record AssignmentRuleEntryParsed in the api-surface and export-origins snapshots Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- packages/spec/api-surface/automation.json | 1 + packages/spec/export-origins/automation.json | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 50e00457995..e35779d71dc 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -46,6 +46,7 @@ "AssignmentExpressionValueSchema (const)", "AssignmentRule (type)", "AssignmentRuleEntry (type)", + "AssignmentRuleEntryParsed (type)", "AssignmentRuleEntrySchema (const)", "AssignmentRuleParsed (type)", "AssignmentRuleSchema (const)", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 45bef6a8854..1c19c58c296 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -44,6 +44,7 @@ "AssignmentExpressionValueSchema": "src/automation/builtin-node-config.zod.ts#AssignmentExpressionValueSchema (const)", "AssignmentRule": "src/automation/assignment-rule.zod.ts#AssignmentRule (type)", "AssignmentRuleEntry": "src/automation/assignment-rule.zod.ts#AssignmentRuleEntry (type)", + "AssignmentRuleEntryParsed": "src/automation/assignment-rule.zod.ts#AssignmentRuleEntryParsed (type)", "AssignmentRuleEntrySchema": "src/automation/assignment-rule.zod.ts#AssignmentRuleEntrySchema (const)", "AssignmentRuleParsed": "src/automation/assignment-rule.zod.ts#AssignmentRuleParsed (type)", "AssignmentRuleSchema": "src/automation/assignment-rule.zod.ts#AssignmentRuleSchema (const)", From 9bc508a8a777728a029f9197efa1ad3f3d44b8fc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 21:55:11 +0000 Subject: [PATCH 13/23] fix(plugin-sharing): the shadowed-queue report declares a required warn channel check:optional-error-sink refuses a sink whose optional `error` has no guaranteed fallback. The boot report now types its logger with a required `warn` and calls `error` when present, `warn` otherwise. Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- .../plugins/plugin-sharing/src/queue-hooks.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/plugins/plugin-sharing/src/queue-hooks.ts b/packages/plugins/plugin-sharing/src/queue-hooks.ts index 59ad76bc691..15309bf4b28 100644 --- a/packages/plugins/plugin-sharing/src/queue-hooks.ts +++ b/packages/plugins/plugin-sharing/src/queue-hooks.ts @@ -154,7 +154,10 @@ export function bindQueueGuards(engine: MinimalEngine, queues: Pick, - logger?: OptionalSharingLogger & { error?: (msg: string, meta?: Record) => void }, + logger?: { + warn: (msg: string, meta?: Record) => void; + error?: (msg: string, meta?: Record) => void; + }, ): Promise { if (typeof engine.find !== 'function') return; let rows: any[] = []; @@ -166,12 +169,12 @@ export async function reportShadowedAuthoredQueues( for (const row of Array.isArray(rows) ? rows : []) { const name = present(row?.name); if (!name || !queues.isDeclaredQueueName(name)) continue; - (logger?.error ?? logger?.warn)?.call( - logger, + const msg = `[queue] an organization's queue '${name}' is shadowed by a queue an installed app declares under the same ` + - 'name — the declared queue answers for it. Rename the organization\'s queue to keep it.', - { queue: name, organization: present(row?.organization_id) }, - ); + 'name — the declared queue answers for it. Rename the organization\'s queue to keep it.'; + const meta = { queue: name, organization: present(row?.organization_id) }; + if (logger?.error) logger.error(msg, meta); + else logger?.warn(msg, meta); } } From 6f98217859bef648b6efee8b09308a85f189ac70 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 21:56:28 +0000 Subject: [PATCH 14/23] feat(spec)!: declare the reserved queue column as a narrowing (ADR-0142 D3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An object whose records have a user owner can no longer declare its own `queue` field: registration refuses it. The changeset now declares `Clause-②: yes (narrowing)` and `Metadata-protocol: narrowed`, and the upgrade guide carries the D3 entry `object-queue-field-reserved` with the remedy. Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- .changeset/25769-ownership-queue.md | 14 +++--- .../18.object-queue-field-reserved.ts | 44 +++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.object-queue-field-reserved.ts diff --git a/.changeset/25769-ownership-queue.md b/.changeset/25769-ownership-queue.md index 60c10e3ee01..257135a9437 100644 --- a/.changeset/25769-ownership-queue.md +++ b/.changeset/25769-ownership-queue.md @@ -13,12 +13,16 @@ '@objectstack/skills': minor --- -feat: ownership queues: records wait in a queue until a member claims them, and assignment rules route new records (ADR-0142) +feat!: ownership queues: records wait in a queue until a member claims them, and assignment rules route new records (ADR-0142) -Clause-②: yes -Metadata-protocol: added +Clause-②: yes (narrowing) +Metadata-protocol: narrowed -Two enum additions: `ShareRecipientType` gains `queue` (a sharing rule can now name a queue as its recipient), and the `queue` approver type is authorable again (it leaves `NON_AUTHORABLE_APPROVER_TYPES`, so it is no longer marked deprecated). Two metadata types are new: `queue` and `assignment_rule`. + + +**BREAKING** for one shape only: an object whose records have a user owner (`ownership: 'user'`, the default) can no longer declare its own field named `queue`. The platform now injects a `queue` column on every such object, and `registerObject` refuses the declaration with `422 INVALID_METADATA` (`ObjectQueueColumnRefusedError`), naming the object. The remedy: rename the business field (for example `support_queue`) and copy its data across, or, when the object's records have no user owner, declare `ownership: 'business_unit'`, `'org'` or `'none'`. The upgrade guide entry is `object-queue-field-reserved`. + +Beside that, two enum additions: `ShareRecipientType` gains `queue` (a sharing rule can now name a queue as its recipient), and the `queue` approver type is authorable again (it leaves `NON_AUTHORABLE_APPROVER_TYPES`, so it is no longer marked deprecated). Two metadata types are new: `queue` and `assignment_rule`. - **`@objectstack/spec`**: new `queue` and `assignment_rule` metadata types (`QueueSchema`, `AssignmentRuleSchema`) and the stack keys `queues` and `assignmentRules`. A declared queue's members are positions. New `IOwnershipQueueService` contract, registered under `ownership-queue`. `queue` joins the injected system columns of `ownership: 'user'` objects and `PUBLIC_FORM_SERVER_MANAGED_FIELDS`. `APPROVER_VALUE_BINDINGS.queue` reads both a declared queue and an organization's `sys_queue` row, and `APPROVER_ORG_SCOPED.queue` is `true`. The explain rule kinds `queue_membership` and `queue_claim` are new. `QUEUE_NOT_FOUND` (422) is registered under plugin-security. - **`@objectstack/objectql`**: objects with `ownership: 'user'` get a system-managed `queue` column beside `owner_id`. Declaring your own `queue` field on one is refused at registration (`INVALID_METADATA`). An update can carry a write precondition: when it no longer matches, the update answers `409 CONCURRENT_UPDATE`. Declared assignment rules run once for each inserted row that has no owner and no queue after the `beforeInsert` hooks. A rule can park the row in a queue, or pick a member by `round_robin` or `least_loaded`. Assignment never refuses an insert. `kernel:ready` refuses to boot a stack that declares queues or assignment rules when no `ownership-queue` service is registered. @@ -33,4 +37,4 @@ Two enum additions: `ShareRecipientType` gains `queue` (a sharing rule can now n - **`@object-ui/app-shell`**: the flow designer no longer warns that a queue approver resolves to nobody. - **`@objectstack/skills`**: the platform skill lists `queues` and `assignmentRules` among the stack's top-level keys. -What to do: nothing for existing stacks, which behave as before. To use queues, declare `queues` and `assignmentRules` in your stack, and make sure plugin-sharing is installed: it registers the `ownership-queue` service. +What to do: rename any `queue` field on a user-owned object, as above. Every other existing stack behaves as before. To use queues, declare `queues` and `assignmentRules` in your stack, and make sure plugin-sharing is installed: it registers the `ownership-queue` service. diff --git a/packages/spec/src/migrations/entries/semantic/18.object-queue-field-reserved.ts b/packages/spec/src/migrations/entries/semantic/18.object-queue-field-reserved.ts new file mode 100644 index 00000000000..a74a4536a82 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.object-queue-field-reserved.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The D3 entry for ADR-0142's reserved `queue` column (merge question 15). An +// object whose records have a user owner now carries a platform-injected +// `queue` column, and a declared field of that name is refused at +// registration. It narrows the field names such an object accepts; no key is +// removed, so there is no tombstone and no RETIRED_KEYS_BY_MAJOR row. There is +// no D2 conversion either: the new name of the business field, and the data +// move behind it, are the author's judgment. +// +// No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it +// inside a code span and a table cell. +export const entry: SemanticMigration = { + id: 'object-queue-field-reserved', + surface: + 'an object field named queue on an object whose records have a user owner (ownership user, the ' + + 'default). Reachable wherever an object is registered: defineStack({ objects }) sources, a ' + + 'package installed at boot, an object saved from Studio or the metadata API, and an object row ' + + 'already sitting in sys_metadata', + replacement: + 'a business field under another name (for example `support_queue`), with its data copied across; ' + + 'or, when the object\'s records have no user owner, `ownership: \'business_unit\'`, `\'org\'` or ' + + '`\'none\'`, on which the platform injects no `queue` column', + reason: + 'ADR-0142 adds the ownership queue: a record with no owner sits in the queue its `queue` column ' + + 'names, and the sitting-record access path, the claim, the release, the move and the assignment ' + + 'rules all read that column by name. The platform injects it on every object whose records have ' + + 'a user owner. The `owner_id` rule (a declared field wins over the injected one) is deliberately ' + + 'not copied: a business field under the same name would make every queue admission read the ' + + 'author\'s value, silently. So `registerObject` refuses the declaration with `422 ' + + 'INVALID_METADATA` (`ObjectQueueColumnRefusedError`), naming the object and the remedy. A body ' + + 'that carries the platform\'s own definition (a served document coming back) passes. An object ' + + 'with another ownership tier keeps any `queue` field it declares. No D2 conversion: the platform ' + + 'cannot choose the new name or move the rows. Ships at once, no grace window. ADR-0087, ADR-0142.', + acceptanceCriteria: + 'Search every object your stacks declare for a field named `queue` on an object with no ' + + '`ownership` or `ownership: \'user\'`. Rename each one and copy its data to the new column. Two ' + + 'proofs. (1) Boot the stack and confirm each object REGISTERS: no `ObjectQueueColumnRefusedError` ' + + 'for it. (2) Read a record of each renamed object and confirm the business value is under the new ' + + 'name and its `queue` column is empty unless a queue holds it. An object without such a field ' + + 'registers byte-identically to before, apart from the injected column.', +}; From 4028072daea3c3bd31aad2e54c0edb2f709db9c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 22:44:01 +0000 Subject: [PATCH 15/23] fix(cli): give the three ownership-queue tables an organization-ownership inventory row sys_queue, sys_queue_member and sys_assignment_cursor carry organization_id, so the ADR-0131 D10 plan refused them as uninventoried. Each is written under its organization; a member and a cursor name their queue or rule by name, so no parent anchors them (fate 3, the Default Organization under single). Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- packages/cli/src/utils/organization-ownership-inventory.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index d43e359fe31..354ee2064f7 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -758,6 +758,13 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ { object: 'sys_mailbox', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] }, { 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. + { 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_import_job', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] }, { object: 'sys_user_preference', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] }, From 0f2b6ac15833a8ba7fb9aea07d1fafe31872e7a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 22:44:02 +0000 Subject: [PATCH 16/23] fix(platform-objects): label the Queues and Queue Members setup navigation in every locale plugin-sharing contributes nav_queues and nav_queue_members to the setup app's access-control group; check:app-nav-i18n needs a label for each in en, zh-CN, ja-JP and es-ES. Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- packages/platform-objects/src/apps/translations/en.ts | 2 ++ packages/platform-objects/src/apps/translations/es-ES.ts | 2 ++ packages/platform-objects/src/apps/translations/ja-JP.ts | 2 ++ packages/platform-objects/src/apps/translations/zh-CN.ts | 2 ++ 4 files changed, 8 insertions(+) diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 2ae5c517d8b..038d5223433 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -96,6 +96,8 @@ export const en: TranslationData = { nav_sharing_rules: { label: 'Sharing Rules' }, nav_declared_sharing_rules: { label: 'Declared Sharing Rules' }, nav_record_shares: { label: 'Record Shares' }, + nav_queues: { label: 'Queues' }, + nav_queue_members: { label: 'Queue Members' }, // `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 1377d47c794..6fea2e06981 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -66,6 +66,8 @@ export const esES: TranslationData = { nav_sharing_rules: { label: 'Reglas de Compartición' }, nav_declared_sharing_rules: { label: 'Reglas de Compartición Declaradas' }, nav_record_shares: { label: 'Registros Compartidos' }, + nav_queues: { label: 'Colas' }, + nav_queue_members: { label: 'Miembros de Colas' }, // 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 8bc382e8ef9..da18e58ad85 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -66,6 +66,8 @@ export const jaJP: TranslationData = { nav_sharing_rules: { label: '共有ルール' }, nav_declared_sharing_rules: { label: '宣言済みの共有ルール' }, nav_record_shares: { label: 'レコード共有' }, + nav_queues: { label: 'キュー' }, + nav_queue_members: { 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 3acab283f50..7c350a2f433 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -69,6 +69,8 @@ export const zhCN: TranslationData = { nav_sharing_rules: { label: '共享规则' }, nav_declared_sharing_rules: { label: '已声明的共享规则' }, nav_record_shares: { label: '记录共享' }, + nav_queues: { label: '队列' }, + nav_queue_members: { 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 From 2448c0035a655723046761383156a13f5f80fdd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 22:44:02 +0000 Subject: [PATCH 17/23] test: teach the injected queue column and the two code-only queue types to their pins The injected-column pins (metadata-protocol, rest, the ListView filter fixture) gain the hidden ownership queue column; the code-only type pins and the CLI schema gate classify queue and assignment_rule; the flow designer's en literal is the queue hint that replaced the not-supported warning; the objectql queue test types its mock-call filter parameter. Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- .../test/metadata-type-schema-gate.test.ts | 5 ++++ .../src/protocol.code-only-types.test.ts | 24 ++++++++++++++++++- .../protocol.injected-system-columns.test.ts | 16 +++++++++---- ...tocol.recovery-doors-emit-mutation.test.ts | 3 ++- packages/objectql/src/ownership-queue.test.ts | 4 ++-- ...-object-extension-property-classes.test.ts | 2 +- .../meta-object-injected-column-i18n.test.ts | 7 ++++-- ...meta-object-overlay-extension-fold.test.ts | 2 +- .../inspectorStrings.i18n-10586.test.tsx | 3 ++- .../ListView.filterFieldList-11810.test.tsx | 9 +++---- 10 files changed, 57 insertions(+), 18 deletions(-) diff --git a/packages/cli/test/metadata-type-schema-gate.test.ts b/packages/cli/test/metadata-type-schema-gate.test.ts index 9f575b05b0a..8daa4dc66ac 100644 --- a/packages/cli/test/metadata-type-schema-gate.test.ts +++ b/packages/cli/test/metadata-type-schema-gate.test.ts @@ -99,6 +99,11 @@ const GATED_AT: Readonly> = { // shape: the stack authors it at `sharingRules:` as a flat array of the // registry's own `.strict()` `SharingRuleSchema`, so it is gated from the start. sharing_rule: 'sharingRules', + // [ADR-0142 D1/D5] `queue` and `assignment_rule` joined the same way: code-only, + // CLOSED from their first day, authored at `queues:` / `assignmentRules:` as + // flat arrays of the registry's own `strictObject` shapes. + queue: 'queues', + assignment_rule: 'assignmentRules', agent: 'agents', tool: 'tools', skill: 'skills', diff --git a/packages/metadata-protocol/src/protocol.code-only-types.test.ts b/packages/metadata-protocol/src/protocol.code-only-types.test.ts index 43324805ad4..42eb1b97fd6 100644 --- a/packages/metadata-protocol/src/protocol.code-only-types.test.ts +++ b/packages/metadata-protocol/src/protocol.code-only-types.test.ts @@ -186,6 +186,26 @@ const PROBES: Record }> = active: true, }, }, + // [ADR-0142 D1/D5] `queue` and `assignment_rule`, regime C like + // `sharing_rule`: a stack's declaration, or (for a queue) an + // organization's `sys_queue` row, never a runtime `sys_metadata` item. + queue: { + name: 'rc3_queue_probe', + item: { + name: 'rc3_queue_probe', + label: 'Probe', + members: [{ type: 'position', value: 'probe_position' }], + }, + }, + assignment_rule: { + name: 'rc3_assignment_rule_probe', + item: { + name: 'rc3_assignment_rule_probe', + label: 'Probe', + object: 'task', + entries: [{ name: 'all_rows', assignTo: { type: 'queue', value: 'rc3_queue_probe' } }], + }, + }, }; function makeStubEngine(artifacts: Array<{ type: string; name: string }> = []) { @@ -302,7 +322,9 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => { // auto-enrolment is the point of deriving the set instead of listing it // (Prime Directive #8). expect(CODE_ONLY_TYPES.length).toBeGreaterThan(0); - expect([...CODE_ONLY_TYPES].sort()).toEqual(['agent', 'api', 'capability', 'field', 'job', 'picklist', 'sharing_rule']); + expect([...CODE_ONLY_TYPES].sort()).toEqual([ + 'agent', 'api', 'assignment_rule', 'capability', 'field', 'job', 'picklist', 'queue', 'sharing_rule', + ]); for (const type of CODE_ONLY_TYPES) { expect(PROBES[type], `no probe payload for code-only type '${type}'`).toBeDefined(); } diff --git a/packages/metadata-protocol/src/protocol.injected-system-columns.test.ts b/packages/metadata-protocol/src/protocol.injected-system-columns.test.ts index 598bba29e56..04db68c7875 100644 --- a/packages/metadata-protocol/src/protocol.injected-system-columns.test.ts +++ b/packages/metadata-protocol/src/protocol.injected-system-columns.test.ts @@ -153,6 +153,8 @@ const authored = (name: string) => ({ const INJECTED = [ 'created_at', 'created_by', 'updated_at', 'updated_by', 'organization_id', 'owner_id', 'owning_business_unit_id', + // [ADR-0142 D3] the ownership queue rides with `owner_id`. + 'queue', ] as const; async function seed(name: string, item: Record = authored(name), mode?: 'draft') { @@ -235,9 +237,11 @@ describe('[#6562] every /meta object read exit serves the effective schema', () } expect(served!.fields.organization_id).toMatchObject({ readonly: true, system: true }); expect(served!.fields.owning_business_unit_id).toMatchObject({ readonly: true, system: true }); - // Ownership is TRANSFERABLE — `owner_id` is the one injected - // column that is `system` but deliberately NOT `readonly`. + // Ownership is TRANSFERABLE — `owner_id` and its ownership queue + // (ADR-0142 D3) are the injected columns that are `system` but + // deliberately NOT `readonly`. expect(served!.fields.owner_id).toMatchObject({ readonly: false, system: true }); + expect(served!.fields.queue).toMatchObject({ readonly: false, system: true }); }); it('leaves the author’s own fields exactly as declared', async () => { @@ -308,13 +312,15 @@ describe('[#6562] the opt-out rows are the spec derivation’s, re-derived nowhe { managedBy: 'better-auth' }, []], ['systemFields.audit: false — no audit family', { systemFields: { audit: false } }, - ['organization_id', 'owner_id', 'owning_business_unit_id']], + ['organization_id', 'owner_id', 'owning_business_unit_id', 'queue']], ['systemFields.tenant: false — no tenant anchor', { systemFields: { tenant: false } }, - ['created_at', 'created_by', 'updated_at', 'updated_by', 'owner_id', 'owning_business_unit_id']], + ['created_at', 'created_by', 'updated_at', 'updated_by', 'owner_id', 'owning_business_unit_id', + 'queue']], ['tenancy.enabled: false — the schema-level shared-catalog declaration', { tenancy: { enabled: false } }, - ['created_at', 'created_by', 'updated_at', 'updated_by', 'owner_id', 'owning_business_unit_id']], + ['created_at', 'created_by', 'updated_at', 'updated_by', 'owner_id', 'owning_business_unit_id', + 'queue']], ['ownership: org — no per-record ownership anchor, either tier', { ownership: 'org' }, ['created_at', 'created_by', 'updated_at', 'updated_by', 'organization_id']], diff --git a/packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts b/packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts index 1a359f0f739..a513417a800 100644 --- a/packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts +++ b/packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts @@ -542,7 +542,8 @@ describe('[#14179] reachability: the legacy exit is confined to bootstrap mode', // Non-empty: the branch is not dead code. expect(codeOnly.length).toBeGreaterThan(0); expect(codeOnly.map((e) => e.type).sort()) - .toEqual(['agent', 'api', 'capability', 'field', 'job', 'picklist', 'sharing_rule']); + .toEqual(['agent', 'api', 'assignment_rule', 'capability', 'field', 'job', 'picklist', 'queue', + 'sharing_rule']); // …and the #6960 carve-out reaches none of them, which is what makes // the 403 below total rather than incidental. expect(codeOnly.filter((e) => e.supportsOverlay)).toEqual([]); diff --git a/packages/objectql/src/ownership-queue.test.ts b/packages/objectql/src/ownership-queue.test.ts index b5e5e3a1403..be109a129f5 100644 --- a/packages/objectql/src/ownership-queue.test.ts +++ b/packages/objectql/src/ownership-queue.test.ts @@ -307,7 +307,7 @@ describe('[ADR-0142 D5] the assignment seam', () => { await insert({ subject: 'B' }); expect(a).toMatchObject({ queue: 'support' }); expect(a.owner_id ?? null).toBeNull(); - expect(warn.mock.calls.filter((c) => /has no members/.test(String(c[0])))).toHaveLength(1); + expect(warn.mock.calls.filter((c: unknown[]) => /has no members/.test(String(c[0])))).toHaveLength(1); }); it('a target that resolves nowhere in the row\'s organization leaves it ownerless, in no queue, and warns', async () => { @@ -315,7 +315,7 @@ describe('[ADR-0142 D5] the assignment seam', () => { const row = await engine.insert('ticket', { subject: 'A', organization_id: 'org_b' }, { context: { ...SYS, tenantId: 'org_b' } }); expect(row.owner_id ?? null).toBeNull(); expect(row.queue ?? null).toBeNull(); - expect(warn.mock.calls.some((c) => /does not resolve in organization 'org_b'/.test(String(c[0])))).toBe(true); + expect(warn.mock.calls.some((c: unknown[]) => /does not resolve in organization 'org_b'/.test(String(c[0])))).toBe(true); }); it('a row that arrives with an owner or a queue is not evaluated', async () => { diff --git a/packages/rest/src/meta-object-extension-property-classes.test.ts b/packages/rest/src/meta-object-extension-property-classes.test.ts index 4e1b68ff913..fce6c2ef8ce 100644 --- a/packages/rest/src/meta-object-extension-property-classes.test.ts +++ b/packages/rest/src/meta-object-extension-property-classes.test.ts @@ -152,7 +152,7 @@ function fieldNamesOf(item: unknown): string[] { */ const PLATFORM_COLUMNS: readonly string[] = [ 'id', 'created_at', 'created_by', 'updated_at', 'updated_by', - 'organization_id', 'owner_id', 'owning_business_unit_id', + 'organization_id', 'owner_id', 'owning_business_unit_id', 'queue', ]; function declaredFieldsOf(item: unknown): string[] { diff --git a/packages/rest/src/meta-object-injected-column-i18n.test.ts b/packages/rest/src/meta-object-injected-column-i18n.test.ts index 659bf626433..35bc8f18a81 100644 --- a/packages/rest/src/meta-object-injected-column-i18n.test.ts +++ b/packages/rest/src/meta-object-injected-column-i18n.test.ts @@ -146,6 +146,7 @@ const ZH_CN = { updated_by: '更新人', owner_id: '所有者', owning_business_unit_id: '所属业务单元', + queue: '队列', }; function labelsOf(fields: Record): Record { @@ -157,6 +158,7 @@ function labelsOf(fields: Record): Record { updated_by: fields.updated_by?.label, owner_id: fields.owner_id?.label, owning_business_unit_id: fields.owning_business_unit_id?.label, + queue: fields.queue?.label, }; } @@ -165,10 +167,10 @@ function labelsOf(fields: Record): Record { // --------------------------------------------------------------------------- describe('#14972 — injected system columns reach the /meta/object reads localised', () => { - it('the fixture spreads all seven injected columns with their shipped English labels', () => { + it('the fixture spreads all eight injected columns with their shipped English labels', () => { expect(Object.keys(INJECTED).sort()).toEqual([ 'created_at', 'created_by', 'organization_id', 'owner_id', - 'owning_business_unit_id', 'updated_at', 'updated_by', + 'owning_business_unit_id', 'queue', 'updated_at', 'updated_by', ]); expect((SERVED.fields as any).organization_id.label).toBe('Organization'); }); @@ -196,6 +198,7 @@ describe('#14972 — injected system columns reach the /meta/object reads locali updated_by: 'Last Modified By', owner_id: 'Owner', owning_business_unit_id: 'Owning Business Unit', + queue: 'Queue', }); } }); diff --git a/packages/rest/src/meta-object-overlay-extension-fold.test.ts b/packages/rest/src/meta-object-overlay-extension-fold.test.ts index b8c87c8842c..3b491fee976 100644 --- a/packages/rest/src/meta-object-overlay-extension-fold.test.ts +++ b/packages/rest/src/meta-object-overlay-extension-fold.test.ts @@ -141,7 +141,7 @@ function fieldNamesOf(item: unknown): string[] { */ const PLATFORM_COLUMNS: readonly string[] = [ 'id', 'created_at', 'created_by', 'updated_at', 'updated_by', - 'organization_id', 'owner_id', 'owning_business_unit_id', + 'organization_id', 'owner_id', 'owning_business_unit_id', 'queue', ]; /** Served field names minus {@link PLATFORM_COLUMNS}. */ diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.i18n-10586.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.i18n-10586.test.tsx index c8afa419915..9f956407bab 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.i18n-10586.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.i18n-10586.test.tsx @@ -377,7 +377,8 @@ const extra = new Set(); const REFERENCE_EN = [ 'Resolved automatically', "Resolved at runtime from the submitter's manager — no value needed.", - 'Queue approvers are not supported by the runtime yet — this slot resolves to nobody.', + // ADR-0142 D6 replaced the "not supported" warning with what the value names. + "The queue's machine name. Its members when the step opens are the approvers; an empty queue adds nobody.", 'Fields of account.', 'Set the flow’s trigger object (on the Start node) to list fields.', 'Actions of slack.', diff --git a/ui/packages/plugin-list/src/__tests__/ListView.filterFieldList-11810.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.filterFieldList-11810.test.tsx index 55960254669..5fa48128054 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.filterFieldList-11810.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.filterFieldList-11810.test.tsx @@ -23,7 +23,8 @@ * order the report observed them ahead of the declared fields; the declared * fields are `task.object.ts`'s, with the author's own `created_at`; the * companion is the `hidden` + `system` text column the engine provisions. Its - * size is the report's 25 — pinned, so a drift in either source is loud. + * size is the report's 25 plus the hidden ownership `queue` column injected + * since (ADR-0142 D3) — pinned, so a drift in either source is loud. */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render, screen, fireEvent, waitFor, within } from '@testing-library/react'; @@ -87,7 +88,7 @@ function servedTaskFields(): Record> { /** The showcase Tasks default list's columns (`task.view.ts`). */ const TASK_COLUMNS = ['title', 'project', 'assignee', 'status', 'priority', 'due_date', 'progress']; -const HIDDEN = ['organization_id', 'owning_business_unit_id', '__search']; +const HIDDEN = ['organization_id', 'owning_business_unit_id', 'queue', '__search']; const BUSINESS_REST = ['estimate_hours', 'done', 'start_date', 'end_date', 'location', 'cover', 'labels', 'notes', 'sync_status', 'sync_error']; const SYSTEM = ['created_by', 'updated_at', 'updated_by', 'owner_id', 'created_at']; @@ -136,9 +137,9 @@ afterEach(() => { }); describe('objectui#11810 — the filter field list: no hidden field, the view’s columns first, system fields last', () => { - it('the fixture is the reported shape: 25 fields, hidden system columns first (lit control)', () => { + it('the fixture is the reported shape: 25 fields plus `queue`, hidden system columns first (lit control)', () => { const served = servedTaskFields(); - expect(Object.keys(served)).toHaveLength(25); + expect(Object.keys(served)).toHaveLength(26); expect(Object.keys(served)[0]).toBe('organization_id'); for (const name of HIDDEN) expect(served[name]?.hidden, name).toBe(true); }); From 9ada3a920aef790b881575cd38d70616de043249 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:00:07 +0000 Subject: [PATCH 18/23] test(driver-sql): admit the text-typed queue anchor in the sort-anchor premise pin The ownership `queue` column (ADR-0142 D3) is a platform-injected `text` column on every `ownership: 'user'` object, so the sort-anchor premise pin now admits `text` beside `datetime` and `lookup`. Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- .../src/sql-driver-external-unprovisioned-sort-anchor.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/drivers/driver-sql/src/sql-driver-external-unprovisioned-sort-anchor.test.ts b/packages/drivers/driver-sql/src/sql-driver-external-unprovisioned-sort-anchor.test.ts index 623ed806e1e..d913169e45d 100644 --- a/packages/drivers/driver-sql/src/sql-driver-external-unprovisioned-sort-anchor.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-external-unprovisioned-sort-anchor.test.ts @@ -262,7 +262,8 @@ describe('SORT over an unprovisioned injected anchor on a federated object (prem for (const anchor of ANCHORS) { const type = String((ANCHOR_DEFS as any)[anchor]?.type ?? ''); expect(type).not.toBe('formula'); - expect(['datetime', 'lookup']).toContain(type); + // `text`: the ownership `queue` anchor (ADR-0142 D3), a queue machine name. + expect(['datetime', 'lookup', 'text']).toContain(type); } }); From 564ef7a63e5db0ac8c11a37a00c1b20d18939dc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:33:19 +0000 Subject: [PATCH 19/23] test(objectql): pin the assignment criteria and load fail paths A `criteria` that cannot be evaluated does not match, warns once, and the next entry decides; a `load` the compiler cannot lower parks the row in the queue and warns, never counting every row. The comment above the load branch no longer claims an authoring gate that does not exist. Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- packages/objectql/src/assignment-rules.ts | 7 ++++--- packages/objectql/src/ownership-queue.test.ts | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/objectql/src/assignment-rules.ts b/packages/objectql/src/assignment-rules.ts index a98126503f5..875320a211a 100644 --- a/packages/objectql/src/assignment-rules.ts +++ b/packages/objectql/src/assignment-rules.ts @@ -328,9 +328,10 @@ export class AssignmentRuleEvaluator { if (!carriesQueueColumn(this.host.getSchema(object))) return null; const compiled = entry.load ? compileCelToFilter(entry.load, { variables: {} }) : null; if (!compiled || !compiled.ok) { - // The schema refuses `least_loaded` without `load`, and the authoring - // gate refuses a `load` the compiler cannot lower — so this is a body - // that skipped both. Never "count everything". + // The schema refuses `least_loaded` without `load`, but nothing before + // this point asks whether the compiler can lower it: a body that skipped + // the schema, or a `load` the compiler cannot lower, lands here. Never + // "count everything". this.warnOnce(`load::${rule.name}::${entry.name}`, `[assignment] rule '${rule.name}' entry '${entry.name}' has no \`load\` the platform can count by — the row parks in the queue`, { rule: rule.name, entry: entry.name, ...(compiled && !compiled.ok ? { reason: compiled.reason, detail: compiled.detail } : {}), }); diff --git a/packages/objectql/src/ownership-queue.test.ts b/packages/objectql/src/ownership-queue.test.ts index be109a129f5..03aa961743d 100644 --- a/packages/objectql/src/ownership-queue.test.ts +++ b/packages/objectql/src/ownership-queue.test.ts @@ -336,6 +336,26 @@ describe('[ADR-0142 D5] the assignment seam', () => { expect(await insert({ subject: 'B', priority: 'low' })).toMatchObject({ queue: 'support' }); }); + it('a `criteria` that cannot be evaluated does not match, warns once, and the next entry decides', async () => { + await boot([rule([ + { name: 'vip', criteria: 'record.priority == (', assignTo: { type: 'user', value: 'u_lead' } }, + { name: 'rest', assignTo: { type: 'queue', value: 'support' } }, + ])]); + const a = await insert({ subject: 'A', priority: 'high' }); + await insert({ subject: 'B', priority: 'high' }); + expect(a.owner_id ?? null).toBeNull(); + expect(a).toMatchObject({ queue: 'support' }); + expect(warn.mock.calls.filter((c: unknown[]) => /criteria of rule 'ticket_intake' entry 'vip' could not be evaluated/.test(String(c[0])))).toHaveLength(1); + }); + + it('a `load` the compiler cannot lower parks the row in the queue and warns; it never counts every row', async () => { + await boot([rule([{ name: 'all', assignTo: { type: 'queue', value: 'support' }, strategy: 'least_loaded', load: 'size(record.subject) > 3' }])]); + const row = await insert({ subject: 'A' }); + expect(row).toMatchObject({ queue: 'support' }); + expect(row.owner_id ?? null).toBeNull(); + expect(warn.mock.calls.filter((c: unknown[]) => /has no `load` the platform can count by/.test(String(c[0])))).toHaveLength(1); + }); + it('a rule switched off — `active: false`, or in the activation ledger — routes nothing', async () => { await boot([rule([{ name: 'all', assignTo: { type: 'queue', value: 'support' } }], { active: false })]); expect((await insert({ subject: 'A' })).queue ?? null).toBeNull(); From e45133760b7b95682a42b174a193d627ed5f996b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:33:19 +0000 Subject: [PATCH 20/23] test(dogfood): classify the assignment-rule expressions and count the queue anchor The expression conformance ledger classifies the two new expression slots of an assignment rule entry: `load` (compile, fail-closed) and `criteria` (interpret, fail-soft-log). The federated anchor provenance pin counts the ownership `queue` column (ADR-0142 D3) among the injected, unprovisioned anchors of the federated showcase object. Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- .../test/expression-conformance.ledger.ts | 17 +++++++++++++ ...ederated-anchor-provenance.dogfood.test.ts | 24 ++++++++++++------- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 801bfa9ee23..ab7ab58828c 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -147,6 +147,15 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ covers: ['security/sharing.zod.ts:CriteriaSharingRuleSchema.condition'], proof: 'packages/plugins/plugin-sharing/src/sharing-rule.test.ts', }, + { + id: 'assignment-load', + summary: 'assignment-rule entry `load` → the filter a `least_loaded` member\'s open rows are counted by (ADR-0142 D5)', + dialect: 'cel', mode: 'compile', state: 'enforced', failPolicy: 'fail-closed', + enforcement: 'objectql/assignment-rules.ts route → @objectstack/formula compileCelToFilter, ANDed with the member\'s owner_id and the row\'s organization_id in the engine count', + covers: ['automation/assignment-rule.zod.ts:AssignmentRuleEntrySchema.load'], + proof: 'packages/objectql/src/ownership-queue.test.ts', + note: 'Not a security surface: it decides who is handed a row, never who may read one. It is COMPILE because the count is a pushdown query, and `fail-closed` describes what a `load` the compiler cannot lower does: the row parks in the queue with a warning, never a count over every row. Nothing before the runtime asks whether the compiler can lower it (the schema only requires `load` beside `least_loaded`).', + }, // ── INTERPRET (per-record) — classified, fail-soft per ADR-0058 D5 ── { @@ -163,6 +172,14 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ enforcement: '@objectstack/formula celEngine (interpret) via the hook runner', covers: ['data/hook.zod.ts:HookSchema.condition'], }, + { + id: 'cel-assignment-criteria', + summary: 'assignment-rule entry `criteria`, matched against the row being inserted (ADR-0142 D5)', + dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log', + enforcement: '@objectstack/formula ExpressionEngine.evaluate (interpret) over `record`, in objectql/assignment-rules.ts matches; a fault is warned once per entry and the entry does not match', + covers: ['automation/assignment-rule.zod.ts:AssignmentRuleEntrySchema.criteria'], + proof: 'packages/objectql/src/ownership-queue.test.ts', + }, { id: 'cel-formula', summary: 'computed / formula field expressions', diff --git a/packages/qa/dogfood/test/federated-anchor-provenance.dogfood.test.ts b/packages/qa/dogfood/test/federated-anchor-provenance.dogfood.test.ts index 5ee092a6ed3..945bcfe96b2 100644 --- a/packages/qa/dogfood/test/federated-anchor-provenance.dogfood.test.ts +++ b/packages/qa/dogfood/test/federated-anchor-provenance.dogfood.test.ts @@ -16,11 +16,12 @@ * What this file proves, against the registry a deployed app would really run: * * 1. BEFORE-PICTURE PRESERVED — direction B keeps injecting: the federated - * `showcase_ext_customer` still registers all 7 platform anchors over the + * `showcase_ext_customer` still registers all 8 platform anchors (the + * card's 7 plus the ownership `queue`, ADR-0142 D3) over the * 4-column remote table, byte-identical to the shipped definition tables * (no `provisioned` key in any served or registered field definition — * the #7865 fence: the marker changes what no consumer accepts). - * 2. THE MARKER — the 7 anchors, and only they, answer + * 2. THE MARKER — the 8 anchors, and only they, answer * `'injected-unprovisioned'`; the local control answers * `'injected-provisioned'`; declared columns answer `'author'`. * 3. GUARD PARITY — the marker agrees with the #7859 Layer-0 guard's live @@ -44,6 +45,7 @@ import { TENANT_SCOPE_FIELD_DEF, OWNER_FIELD_DEF, OWNING_BUSINESS_UNIT_FIELD_DEF, + QUEUE_FIELD_DEF, platformProvisionsStorage, resolveInjectedColumnProvenance, unprovisionedInjectedColumns, @@ -65,6 +67,9 @@ const SEVEN_ANCHORS = [ 'owning_business_unit_id', ] as const; +/** Every injected anchor today: the measured seven plus the ownership `queue` (ADR-0142 D3). */ +const INJECTED_ANCHORS = [...SEVEN_ANCHORS, 'queue'] as const; + /** The remote table's real columns, as the showcase declares them. */ const DECLARED = ['name', 'email', 'region', 'lifetime_value'] as const; @@ -72,6 +77,7 @@ const SHIPPED_DEFS: Record>> = { organization_id: TENANT_SCOPE_FIELD_DEF, owner_id: OWNER_FIELD_DEF, owning_business_unit_id: OWNING_BUSINESS_UNIT_FIELD_DEF, + queue: QUEUE_FIELD_DEF, ...AUDIT_FIELD_DEFS, }; @@ -119,24 +125,24 @@ describe('[#7865] injected-anchor provenance on a real showcase boot', () => { await stack?.stop?.(); }); - it('BEFORE-PICTURE: the registry still injects all 7 anchors into the federated object (direction B keeps injecting)', () => { + it('BEFORE-PICTURE: the registry still injects all 8 anchors into the federated object (direction B keeps injecting)', () => { const names = Object.keys(fieldMap(federated)); - for (const anchor of SEVEN_ANCHORS) expect(names, 'anchors').toContain(anchor); + for (const anchor of INJECTED_ANCHORS) expect(names, 'anchors').toContain(anchor); for (const declared of DECLARED) expect(names, 'declared').toContain(declared); }); it('FENCE: every registered anchor definition is byte-identical to the shipped table — the marker adds nothing to the data', () => { const fields = fieldMap(federated); - for (const anchor of SEVEN_ANCHORS) { + for (const anchor of INJECTED_ANCHORS) { expect(fields[anchor], anchor).toEqual({ ...SHIPPED_DEFS[anchor] }); expect('provisioned' in (fields[anchor] ?? {}), `${anchor} must not carry a provisioned key`).toBe(false); } }); - it('THE MARKER: the 7 anchors — and only they — are unprovisioned on the federated object', () => { + it('THE MARKER: the 8 anchors — and only they — are unprovisioned on the federated object', () => { expect(platformProvisionsStorage(federated)).toBe(false); - expect(unprovisionedInjectedColumns(federated).sort()).toEqual([...SEVEN_ANCHORS].sort()); - for (const anchor of SEVEN_ANCHORS) { + expect(unprovisionedInjectedColumns(federated).sort()).toEqual([...INJECTED_ANCHORS].sort()); + for (const anchor of INJECTED_ANCHORS) { expect(resolveInjectedColumnProvenance(federated, anchor), anchor).toBe( 'injected-unprovisioned', ); @@ -182,7 +188,7 @@ describe('[#7865] injected-anchor provenance on a real showcase boot', () => { // Same field-name set as the registered schema — the #6562 invariant. expect(Object.keys(served).sort()).toEqual(Object.keys(fieldMap(federated)).sort()); // The served anchors carry the shipped bytes and no marker key. - for (const anchor of SEVEN_ANCHORS) { + for (const anchor of INJECTED_ANCHORS) { expect(served[anchor], anchor).toEqual({ ...SHIPPED_DEFS[anchor] }); } // And the platform files no defect report about its own document (#6810's From 45558e25559043f69dc32b3dbde238d4da720539 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 02:02:29 +0000 Subject: [PATCH 21/23] fix(objectql): an extension's own queue field never replaces the injected column (ADR-0142 D3) The registration door refuses a declared `queue` on a user-owned base layer, but an `extend` body is never planned on its own, so an objectExtensions entry declaring `queue` replaced the injected column in the fold: provenance read `author`, and the access path, the claim and the assignment seam switched off on that object while the gate kept judging the extender's business column. The fold now governs `queue` as it governs the audit family: where the base plans the column, an extender's own `queue` is dropped, the platform's column stands, and a warning names the extension once per object and package. Order-independent, like the rest of the fold. The D3 migration entry and the changeset name the extension case, and the changeset grades @objectstack/cli, whose ownership inventory moved. Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- .changeset/25769-ownership-queue.md | 8 ++-- packages/objectql/src/ownership-queue.test.ts | 28 +++++++++++ packages/objectql/src/registry.ts | 47 ++++++++++++++++++- .../18.object-queue-field-reserved.ts | 15 +++--- 4 files changed, 87 insertions(+), 11 deletions(-) diff --git a/.changeset/25769-ownership-queue.md b/.changeset/25769-ownership-queue.md index 257135a9437..266843c5f9b 100644 --- a/.changeset/25769-ownership-queue.md +++ b/.changeset/25769-ownership-queue.md @@ -11,6 +11,7 @@ '@objectstack/platform-objects': minor '@object-ui/app-shell': minor '@objectstack/skills': minor +'@objectstack/cli': minor --- feat!: ownership queues: records wait in a queue until a member claims them, and assignment rules route new records (ADR-0142) @@ -20,21 +21,22 @@ Metadata-protocol: narrowed -**BREAKING** for one shape only: an object whose records have a user owner (`ownership: 'user'`, the default) can no longer declare its own field named `queue`. The platform now injects a `queue` column on every such object, and `registerObject` refuses the declaration with `422 INVALID_METADATA` (`ObjectQueueColumnRefusedError`), naming the object. The remedy: rename the business field (for example `support_queue`) and copy its data across, or, when the object's records have no user owner, declare `ownership: 'business_unit'`, `'org'` or `'none'`. The upgrade guide entry is `object-queue-field-reserved`. +**BREAKING** for one shape only: an object whose records have a user owner (`ownership: 'user'`, the default) can no longer declare its own field named `queue`. The platform now injects a `queue` column on every such object, and `registerObject` refuses the declaration with `422 INVALID_METADATA` (`ObjectQueueColumnRefusedError`), naming the object. An `objectExtensions` entry that declares `queue` on such an object is not refused, because an extension is folded over its object at read time: its `queue` is ignored, the platform's column stands, and a warning names the extension. The remedy: rename the business field (for example `support_queue`) and copy its data across, or, when the object's records have no user owner, declare `ownership: 'business_unit'`, `'org'` or `'none'`. The upgrade guide entry is `object-queue-field-reserved`. Beside that, two enum additions: `ShareRecipientType` gains `queue` (a sharing rule can now name a queue as its recipient), and the `queue` approver type is authorable again (it leaves `NON_AUTHORABLE_APPROVER_TYPES`, so it is no longer marked deprecated). Two metadata types are new: `queue` and `assignment_rule`. - **`@objectstack/spec`**: new `queue` and `assignment_rule` metadata types (`QueueSchema`, `AssignmentRuleSchema`) and the stack keys `queues` and `assignmentRules`. A declared queue's members are positions. New `IOwnershipQueueService` contract, registered under `ownership-queue`. `queue` joins the injected system columns of `ownership: 'user'` objects and `PUBLIC_FORM_SERVER_MANAGED_FIELDS`. `APPROVER_VALUE_BINDINGS.queue` reads both a declared queue and an organization's `sys_queue` row, and `APPROVER_ORG_SCOPED.queue` is `true`. The explain rule kinds `queue_membership` and `queue_claim` are new. `QUEUE_NOT_FOUND` (422) is registered under plugin-security. -- **`@objectstack/objectql`**: objects with `ownership: 'user'` get a system-managed `queue` column beside `owner_id`. Declaring your own `queue` field on one is refused at registration (`INVALID_METADATA`). An update can carry a write precondition: when it no longer matches, the update answers `409 CONCURRENT_UPDATE`. Declared assignment rules run once for each inserted row that has no owner and no queue after the `beforeInsert` hooks. A rule can park the row in a queue, or pick a member by `round_robin` or `least_loaded`. Assignment never refuses an insert. `kernel:ready` refuses to boot a stack that declares queues or assignment rules when no `ownership-queue` service is registered. +- **`@objectstack/objectql`**: objects with `ownership: 'user'` get a system-managed `queue` column beside `owner_id`. Declaring your own `queue` field on one is refused at registration (`INVALID_METADATA`), and an extension's own `queue` field on one is ignored with a warning. An update can carry a write precondition: when it no longer matches, the update answers `409 CONCURRENT_UPDATE`. Declared assignment rules run once for each inserted row that has no owner and no queue after the `beforeInsert` hooks. A rule can park the row in a queue, or pick a member by `round_robin` or `least_loaded`. Assignment never refuses an insert. `kernel:ready` refuses to boot a stack that declares queues or assignment rules when no `ownership-queue` service is registered. - **`@objectstack/metadata-core`**: `queue` is classified as an injected system column. - **`@objectstack/metadata`**: the artifact door registers a stack's `queues` and `assignmentRules`, so an artifact-only runtime serves them. - **`@objectstack/plugin-sharing`**: three new objects, `sys_queue`, `sys_queue_member` and `sys_assignment_cursor`, and the `ownership-queue` service. The service resolves a queue name from the registry first, then from the organization's own rows, and has one member expander. A record with no owner that sits in a queue can be read and edited by the queue's members, never deleted. The `queue` sharing recipient grants matched records to the queue's members, and its grants are recomputed when membership changes or the queue is switched on or off. - **`@objectstack/plugin-security`**: the owner gate handles `queue`. A **claim** sets `owner_id` from empty to yourself, by id. It is admitted when the sharing model allows you the record, and only one claimant wins: the loser gets `409`. A **release** gives your own record back to its home queue. A **move** to another queue needs the transfer grant, and a queue name that does not resolve is refused with `422 QUEUE_NOT_FOUND`. An insert that names a queue needs the transfer grant or membership of that queue. A guest can supply neither. Explain reports the queue reasons on the sharing layer. - **`@objectstack/plugin-approvals`**: a `queue` approver resolves to the queue's members when the step opens. An empty queue adds nobody and logs a warning. The `queue:` placeholder is never written again. - **`@objectstack/lint`**: a queue-only approval node is reported `approval-approvers-may-resolve-empty`, and the org-axis red line admits the queue recipient. Two new advisory rules come with `os explain` entries: `assignment-rule-user-target` (warning) and `assignment-rule-queue-unstaffed` (info). +- **`@objectstack/cli`**: the organization-ownership inventory lists `sys_queue`, `sys_queue_member` and `sys_assignment_cursor`, so `os migrate organization-ownership` plans them instead of refusing. - **`@objectstack/runtime`**: `POST /security/_activation/:type/:name` also switches a declared `queue` or `assignment_rule` on and off. - **`@objectstack/platform-objects`**: the two new metadata types' labels, and the queue recipient option, are translated in zh-CN, ja-JP and es-ES. - **`@object-ui/app-shell`**: the flow designer no longer warns that a queue approver resolves to nobody. - **`@objectstack/skills`**: the platform skill lists `queues` and `assignmentRules` among the stack's top-level keys. -What to do: rename any `queue` field on a user-owned object, as above. Every other existing stack behaves as before. To use queues, declare `queues` and `assignmentRules` in your stack, and make sure plugin-sharing is installed: it registers the `ownership-queue` service. +What to do: rename any `queue` field on a user-owned object, or in an extension of one, as above. Every other existing stack behaves as before. To use queues, declare `queues` and `assignmentRules` in your stack, and make sure plugin-sharing is installed: it registers the `ownership-queue` service. diff --git a/packages/objectql/src/ownership-queue.test.ts b/packages/objectql/src/ownership-queue.test.ts index 03aa961743d..169f08a76f3 100644 --- a/packages/objectql/src/ownership-queue.test.ts +++ b/packages/objectql/src/ownership-queue.test.ts @@ -162,6 +162,34 @@ describe('[ADR-0142 D3] the injected `queue` column', () => { expect(objectQueueColumnRefusal({ name: 'catalog', ownership: 'org', fields: { queue: { type: 'text' } } })).toBeNull(); expect(objectQueueColumnRefusal({ name: 'sys_job_queue', fields: { queue: { type: 'text' } } })).toBeNull(); }); + + it('an extension\'s own `queue` never replaces the injected column, in either registration order, and is named once', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const extension = { name: 'ticket', fields: { queue: { type: 'text', label: 'Print queue' }, region: { type: 'text', label: 'Region' } } }; + for (const extendFirst of [false, true]) { + const engine = new ObjectQL(); + const register = { + base: () => engine.registry.registerObject(TICKET as any, 'pkg'), + ext: () => engine.registry.registerObject(extension as any, 'com.ext', undefined, 'extend'), + }; + if (extendFirst) { register.ext(); register.base(); } else { register.base(); register.ext(); } + const served = engine.registry.getObject('ticket') as any; + expect(served.fields.queue).toEqual(QUEUE_FIELD_DEF); + expect(served.fields.region).toMatchObject({ label: 'Region' }); + engine.registry.getObject('ticket'); + } + const named = warn.mock.calls.filter((c: unknown[]) => /An extension of object "ticket" from package "com\.ext" declares a field named `queue`/.test(String(c[0]))); + expect(named).toHaveLength(2); // once per registry, never once per read + + const catalog = new ObjectQL(); + catalog.registry.registerObject({ name: 'catalog', ownership: 'org', fields: {} } as any, 'pkg'); + catalog.registry.registerObject({ name: 'catalog', fields: { queue: { type: 'text', label: 'Print queue' } } } as any, 'com.ext', undefined, 'extend'); + expect((catalog.registry.getObject('catalog') as any).fields.queue).toEqual({ type: 'text', label: 'Print queue' }); + } finally { + warn.mockRestore(); + } + }); }); // ─── D4: the by-id write precondition ────────────────────────────────────── diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index ce4815c5501..5c63cd3d85f 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -3600,7 +3600,9 @@ export class SchemaRegistry { // [ADR-0142 D3] Beside it, for its reason: an eligible object's own // `queue` field is refused before the injection could be skipped for it — // see {@link ObjectQueueColumnRefusedError}. A base layer only: an `extend` - // body is never planned on its own (#24314, below). + // body is never planned on its own (#24314, below), so its `queue` is + // governed in the fold instead ({@link withoutExtensionQueueField}), + // whichever order the layers register in. if (ownership !== 'extend') { const queueRefusal = objectQueueColumnRefusal(schema); if (queueRefusal) throw queueRefusal; @@ -3917,10 +3919,19 @@ export class SchemaRegistry { // base carries the audit family, a `created_at` an extender declares cannot // replace the platform's read-only definition with a writable one (#4447). const governAudit = resolveInjectedSystemColumns(baseDefinition).audit; + // [ADR-0142 D3] The ownership-queue column, for the same reason: the + // registration door's refusal ({@link objectQueueColumnRefusal}) never sees + // an `extend` body, so where the base plans `queue` an extender's own `queue` + // field is dropped here and the platform's column stands. Letting it replace + // the injected definition would read as `author` provenance and switch the + // access path, the claim and the assignment seam off on this object, while + // the gate kept judging the extender's business column. + const governQueue = resolveInjectedSystemColumns(baseDefinition).queue; let merged = { ...baseDefinition }; for (const contrib of contributors) { if (contrib.ownership === 'extend') { - const extension = governAudit ? applyAuditFieldGovernance(contrib.definition) : contrib.definition; + let extension = governAudit ? applyAuditFieldGovernance(contrib.definition) : contrib.definition; + if (governQueue) extension = this.withoutExtensionQueueField(extension, contrib.packageId); merged = mergeObjectDefinitions(merged, extension, tenantAuthored); } } @@ -3930,6 +3941,38 @@ export class SchemaRegistry { return this.finishServedObject(merged); } + /** + * Extension `queue` declarations already named, keyed `object|package`. Per + * registry, not module-level, for the reason {@link navGroupDiagnostics} + * gives: the fold runs on every read, and a second registry must still say it. + */ + private shadowedExtensionQueueWarned = new Set(); + + /** + * [ADR-0142 D3] An extender's body without its own `queue` field, for a base + * that plans the column; the body itself when it declares none or carries + * the platform's own definition (a served document coming back). The first + * drop per object and package is named once, with the remedy the + * registration door's refusal prescribes. + */ + private withoutExtensionQueueField(extension: ServiceObject, packageId: string | undefined): ServiceObject { + const fields = extension.fields as Record | undefined; + const declared = fields?.[QUEUE_FIELD]; + if (declared === undefined || isInjectedColumnDefinition(declared, QUEUE_FIELD_DEF)) return extension; + const { [QUEUE_FIELD]: _dropped, ...rest } = fields!; + const key = `${extension.name}|${packageId ?? ''}`; + if (!this.shadowedExtensionQueueWarned.has(key)) { + this.shadowedExtensionQueueWarned.add(key); + console.warn( + `[Registry] An extension of object "${extension.name}"${packageId ? ` from package "${packageId}"` : ''} ` + + 'declares a field named `queue`, which the platform reserves on an object whose records have a user ' + + 'owner: it holds the ownership queue a record without an owner sits in. The extension\'s `queue` is ' + + 'ignored and the platform\'s column stands. Rename the business field (for example `support_queue`).', + ); + } + return { ...extension, fields: rest } as ServiceObject; + } + /** * The tail every object read ends in — {@link resolveObject} (through * {@link foldExtendersOntoDefinition}), the code layer, and diff --git a/packages/spec/src/migrations/entries/semantic/18.object-queue-field-reserved.ts b/packages/spec/src/migrations/entries/semantic/18.object-queue-field-reserved.ts index a74a4536a82..e2d99965b24 100644 --- a/packages/spec/src/migrations/entries/semantic/18.object-queue-field-reserved.ts +++ b/packages/spec/src/migrations/entries/semantic/18.object-queue-field-reserved.ts @@ -18,7 +18,9 @@ export const entry: SemanticMigration = { 'an object field named queue on an object whose records have a user owner (ownership user, the ' + 'default). Reachable wherever an object is registered: defineStack({ objects }) sources, a ' + 'package installed at boot, an object saved from Studio or the metadata API, and an object row ' - + 'already sitting in sys_metadata', + + 'already sitting in sys_metadata. An objectExtensions entry that declares queue on such an object is ' + + 'not refused, because an extension is folded over its object at read time in either registration ' + + 'order: its queue is ignored, the platform column stands, and a warning names the extension', replacement: 'a business field under another name (for example `support_queue`), with its data copied across; ' + 'or, when the object\'s records have no user owner, `ownership: \'business_unit\'`, `\'org\'` or ' @@ -35,10 +37,11 @@ export const entry: SemanticMigration = { + 'with another ownership tier keeps any `queue` field it declares. No D2 conversion: the platform ' + 'cannot choose the new name or move the rows. Ships at once, no grace window. ADR-0087, ADR-0142.', acceptanceCriteria: - 'Search every object your stacks declare for a field named `queue` on an object with no ' - + '`ownership` or `ownership: \'user\'`. Rename each one and copy its data to the new column. Two ' - + 'proofs. (1) Boot the stack and confirm each object REGISTERS: no `ObjectQueueColumnRefusedError` ' - + 'for it. (2) Read a record of each renamed object and confirm the business value is under the new ' - + 'name and its `queue` column is empty unless a queue holds it. An object without such a field ' + 'Search every object your stacks declare, and every `objectExtensions` entry, for a field named ' + + '`queue` on an object with no `ownership` or `ownership: \'user\'`. Rename each one and copy its ' + + 'data to the new column. Two proofs. (1) Boot the stack and confirm each object REGISTERS: no ' + + '`ObjectQueueColumnRefusedError` for it, and no warning that an extension\'s `queue` is ignored. ' + + '(2) Read a record of each renamed object and confirm the business value is under the new name ' + + 'and its `queue` column is empty unless a queue holds it. An object without such a field ' + 'registers byte-identically to before, apart from the injected column.', }; From 797aeb46849cba487ba0a43df4d042cbfeb4327d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 02:58:06 +0000 Subject: [PATCH 22/23] docs(skills): the queue approver type resolves to the queue's members The automation skill's approver table still told authors a `queue` approver routes to nobody and must not be authored. ADR-0142 D6 makes it resolve to the ownership queue's members at node entry, so the row now says what it resolves to, that an empty queue adds nobody with a warning, and which lint rule reports a queue-only node. Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- .../references/state-machines-and-approvals.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/objectstack-automation/references/state-machines-and-approvals.md b/skills/objectstack-automation/references/state-machines-and-approvals.md index f592cf93766..3e33b85e8b7 100644 --- a/skills/objectstack-automation/references/state-machines-and-approvals.md +++ b/skills/objectstack-automation/references/state-machines-and-approvals.md @@ -230,7 +230,7 @@ still missing. | `department` | A department + all descendant departments | | `manager` | The submitter's manager (`sys_user.manager_id`) | | `field` | User id read from a record field (`value` = field name). Resolved against the record's **live** state at node entry, so a field written mid-flow routes correctly; a multi-select user field fans out into one approver per user | -| `queue` | ⛔ Declared but never resolved — the slot routes to nobody. Do not author | +| `queue` | Members of an ownership queue (`value` = the queue's machine name, declared or the organization's own), resolved at node entry (ADR-0142 D6). An empty queue adds nobody and logs a warning; a node with only `queue` approvers is linted `approval-approvers-may-resolve-empty` | | `expression` | A **CEL expression** resolved at node entry (`value` = the expression) — see **Dynamic approvers** below. Only `current.*` / `trigger.*` / `vars.*` roots are available; the optional `resolveAs: 'user'(default) \| 'department' \| 'position' \| 'team'` re-expands each resolved id through the graph | ### Dynamic approvers (`type: 'expression'`) From aea6b12d872a03a14cdf764353b06ce6684b5ec3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 03:18:05 +0000 Subject: [PATCH 23/23] docs(skills): fit the queue approver row under the reference's token ceiling The published skills token ratchet caps this reference at 5569 tokens. The row keeps what an author needs (the members of the named ownership queue, resolved at node entry; an empty queue adds nobody) and leaves the warning and the lint rule to the approvals guide. Claude-Session: https://claude.ai/code/session_01GTntg542RUmmRRbMY6wMZD Co-authored-by: Claude --- .../references/state-machines-and-approvals.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/objectstack-automation/references/state-machines-and-approvals.md b/skills/objectstack-automation/references/state-machines-and-approvals.md index 3e33b85e8b7..dea3ec0c6a7 100644 --- a/skills/objectstack-automation/references/state-machines-and-approvals.md +++ b/skills/objectstack-automation/references/state-machines-and-approvals.md @@ -230,7 +230,7 @@ still missing. | `department` | A department + all descendant departments | | `manager` | The submitter's manager (`sys_user.manager_id`) | | `field` | User id read from a record field (`value` = field name). Resolved against the record's **live** state at node entry, so a field written mid-flow routes correctly; a multi-select user field fans out into one approver per user | -| `queue` | Members of an ownership queue (`value` = the queue's machine name, declared or the organization's own), resolved at node entry (ADR-0142 D6). An empty queue adds nobody and logs a warning; a node with only `queue` approvers is linted `approval-approvers-may-resolve-empty` | +| `queue` | Members of the ownership queue named by `value`, resolved at node entry (ADR-0142 D6); an empty queue adds nobody | | `expression` | A **CEL expression** resolved at node entry (`value` = the expression) — see **Dynamic approvers** below. Only `current.*` / `trigger.*` / `vars.*` roots are available; the optional `resolveAs: 'user'(default) \| 'department' \| 'position' \| 'team'` re-expands each resolved id through the graph | ### Dynamic approvers (`type: 'expression'`)