diff --git a/.changeset/25598-escalation-dead-target-refused.md b/.changeset/25598-escalation-dead-target-refused.md new file mode 100644 index 00000000000..a73b1a53d78 --- /dev/null +++ b/.changeset/25598-escalation-dead-target-refused.md @@ -0,0 +1,21 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: an SLA escalation's `escalateTo` naming a `team:`, `department:`, `org_membership_level:` or `role:` address is refused at registration + +Clause-②: no (narrowing) — `ApprovalEscalationSchema.escalateTo` refuses values it used to accept. Nothing is widened. + +Metadata-protocol: narrowed (an approval node's `escalation.escalateTo` written as a `team:`, `department:`, `org_membership_level:` or `role:` address is refused) + + + +`escalateTo` was a bare string. The approvals service resolves it as a position machine name and falls back to the value as written, and a person acts on a pending slot only under their user id, their email or `position:` for a position they hold. So a `team:`, `department:` or `org_membership_level:` literal, or the `role:` spelling ADR-0090 D3 retired, names nobody under any escalation action: a `reassign` on timeout would hand the request to no one (the runtime now keeps the slate and notifies it instead), and a `notify` reaches no one through it. The schema accepted it and every build door exited 0. + +`ApprovalEscalationSchema` now refuses those four prefixes at `escalateTo`, under every action and with surrounding whitespace trimmed as the runtime trims it. The refusal names the value and the shapes that are accepted. It reaches every door that judges an approval node's config: `FlowSchema.parse` (`defineStack`, `defineFlow`, `objectstack validate` / `compile`, the metadata save door) and `AutomationEngine.registerFlow`. A user id, an email, a position machine name and `position:` stay accepted; whether they resolve is directory state, judged by the runtime on timeout. The D3 entry `approval-escalation-dead-target-refused` carries the judgment for `os migrate meta`; there is no D2 conversion, because only the author knows which position or person was meant. + +**BREAKING** for an author whose approval node escalates to a team, department, membership tier or `role:` address. + +- **Before.** `escalation: { timeoutHours: 24, action: 'reassign', escalateTo: 'team:night_shift' }` parsed, and on timeout the request was handed to no one. +- **Now.** The flow is refused at `nodes.N.config.escalation.escalateTo`, with a message naming the value and what to write instead. +- **Fix.** Name a target someone acts under: `escalateTo: 'night_shift_lead'` (a position its people hold), `'position:night_shift_lead'`, or a person's user id or email. Write `role:` as `position:`. diff --git a/content/docs/references/automation/approval.mdx b/content/docs/references/automation/approval.mdx index d8129c9d603..130b2586c15 100644 --- a/content/docs/references/automation/approval.mdx +++ b/content/docs/references/automation/approval.mdx @@ -41,7 +41,7 @@ const result = ApprovalDecision.parse(data); | **enabled** | `boolean` | optional (default: `true`) | SLA escalation switch. Defaults to true: an escalation block carrying timeoutHours is live unless this is explicitly false — the feature-level switch is whether the escalation block exists at all | | **timeoutHours** | `number` | ✅ | Calendar (wall-clock) hours before escalation triggers — nights, weekends and holidays count. The platform ships no business-hours calendar: a request opened at 17:00 on a Friday with timeoutHours 4 escalates at 21:00 that same Friday | | **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout | -| **escalateTo** | `string` | optional | User id or position machine name to escalate to | +| **escalateTo** | `string` | optional | User id, email, position machine name or 'position:``' to escalate to. A 'team:', 'department:', 'org_membership_level:' or 'role:' address is refused: no approver acts under one | | **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation | @@ -117,7 +117,7 @@ const result = ApprovalDecision.parse(data); | **enabled** | `boolean` | optional (default: `true`) | SLA escalation switch. Defaults to true: an escalation block carrying timeoutHours is live unless this is explicitly false — the feature-level switch is whether the escalation block exists at all | | **timeoutHours** | `number` | ✅ | Calendar (wall-clock) hours before escalation triggers — nights, weekends and holidays count. The platform ships no business-hours calendar: a request opened at 17:00 on a Friday with timeoutHours 4 escalates at 21:00 that same Friday | | **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout | -| **escalateTo** | `string` | optional | User id or position machine name to escalate to | +| **escalateTo** | `string` | optional | User id, email, position machine name or 'position:``' to escalate to. A 'team:', 'department:', 'org_membership_level:' or 'role:' address is refused: no approver acts under one | | **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation | diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 23014d0a90e..def04af723f 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": 245, + "droppedRefinementSites": 840, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -528,9 +528,15 @@ "data.nodes.element.in.waitEventConfig" ] }, + "automation/ApprovalEscalation": { + "sites": [ + "escalateTo" + ] + }, "automation/ApprovalNodeConfig": { "sites": [ - "" + "", + "escalation.escalateTo" ] }, "automation/AssignmentConfig": { diff --git a/packages/spec/src/automation/approval-escalation-dead-target.test.ts b/packages/spec/src/automation/approval-escalation-dead-target.test.ts new file mode 100644 index 00000000000..de569086462 --- /dev/null +++ b/packages/spec/src/automation/approval-escalation-dead-target.test.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25598] An SLA escalation's `escalateTo` that no approver could ever act + * under is refused at registration — the contract half of the card whose + * runtime half keeps a stranded slate from being written on timeout. + * + * A person acts on a slot under their user id, their email or + * `position:

` (`approver-address.ts` in `plugin-approvals`), so a `team:`, + * `department:` or `org_membership_level:` literal, or the retired `role:` + * spelling, names nobody under any escalation action. The shape alone settles + * it, so it is judged here; whether a user id or a position resolves is + * directory state, so every other shape stays accepted. + * + * - §1 each refused prefix is refused at `escalateTo`, under every action, + * padded or not; + * - §2 each accepted shape parses, and an omitted target still parses; + * - §3 the refusal reaches the door authors meet: `FlowSchema` and the build + * judge `flowNodeConfigRefusals`, anchored at the key. + */ + +import { describe, expect, it } from 'vitest'; + +import { ApprovalEscalationSchema } from './approval.zod'; +import { flowNodeConfigRefusals } from './flow-node-config-refusals'; +import { FlowSchema } from './flow.zod'; + +const REFUSED = ['team:night_shift', 'department:sales', 'org_membership_level:admin', 'role:sales_manager']; +const ACCEPTED = ['usr_7f3a', 'vp@example.com', 'vp_operations', 'position:vp_operations']; +const ACTIONS = ['reassign', 'notify', 'auto_approve', 'auto_reject'] as const; + +function issues(value: unknown): { code: string; path: string }[] { + const result = ApprovalEscalationSchema.safeParse(value); + if (result.success) return []; + return result.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') })); +} + +function flowWith(escalation: Record) { + return { + name: 'expense_approval', + label: 'Expense approval', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'gate', type: 'approval', label: 'Finance', + config: { approvers: [{ type: 'position', value: 'finance_manager' }], escalation }, + }, + { id: 'approved', type: 'end', label: 'Approved' }, + { id: 'rejected', type: 'end', label: 'Rejected' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'gate' }, + { id: 'e2', source: 'gate', target: 'approved', label: 'approve' }, + { id: 'e3', source: 'gate', target: 'rejected', label: 'reject' }, + ], + }; +} + +describe('§1 a target no approver acts under is refused at escalateTo', () => { + it.each(REFUSED)('%s is refused under every action', (escalateTo) => { + for (const action of ACTIONS) { + expect(issues({ timeoutHours: 24, action, escalateTo })).toEqual([{ code: 'custom', path: 'escalateTo' }]); + } + }); + + it.each(REFUSED)('%s is refused with surrounding whitespace, as the runtime trims it', (escalateTo) => { + expect(issues({ timeoutHours: 24, action: 'reassign', escalateTo: ` ${escalateTo} ` })) + .toEqual([{ code: 'custom', path: 'escalateTo' }]); + }); + + it('names the value it refused and the shapes it accepts', () => { + const result = ApprovalEscalationSchema.safeParse({ timeoutHours: 24, action: 'reassign', escalateTo: 'team:ops' }); + expect(result.success).toBe(false); + const message = result.success ? '' : result.error.issues[0]!.message; + expect(message).toContain("'team:ops'"); + expect(message).toContain("'position:'"); + }); +}); + +describe('§2 a user id, an email and a position stay accepted', () => { + it.each(ACCEPTED)('%s parses, whole schema green', (escalateTo) => { + expect(issues({ timeoutHours: 24, action: 'reassign', escalateTo })).toEqual([]); + const parsed = ApprovalEscalationSchema.parse({ timeoutHours: 24, action: 'reassign', escalateTo }); + expect(parsed.escalateTo).toBe(escalateTo); + }); + + it('an omitted target still parses and stays absent', () => { + expect(issues({ timeoutHours: 24 })).toEqual([]); + expect(ApprovalEscalationSchema.parse({ timeoutHours: 24 }).escalateTo).toBeUndefined(); + }); +}); + +describe('§3 the refusal reaches the flow door', () => { + it.each(REFUSED)('FlowSchema refuses %s at the node config key', (escalateTo) => { + const result = FlowSchema.safeParse(flowWith({ timeoutHours: 24, action: 'reassign', escalateTo })); + expect(result.success).toBe(false); + const paths = result.success ? [] : result.error.issues.map((i) => i.path.join('.')); + expect(paths).toContain('nodes.1.config.escalation.escalateTo'); + }); + + it.each(REFUSED)('the build judge reports %s as node-config-refused-by-contract', (escalateTo) => { + const config = { + approvers: [{ type: 'position', value: 'finance_manager' }], + escalation: { timeoutHours: 24, action: 'reassign', escalateTo }, + }; + expect(flowNodeConfigRefusals('approval', config).map(({ code, path }) => ({ code, path }))).toEqual([ + { code: 'node-config-refused-by-contract', path: 'escalation.escalateTo' }, + ]); + }); + + it.each(ACCEPTED)('FlowSchema accepts %s', (escalateTo) => { + expect(FlowSchema.safeParse(flowWith({ timeoutHours: 24, action: 'reassign', escalateTo })).success).toBe(true); + }); +}); diff --git a/packages/spec/src/automation/approval.zod.ts b/packages/spec/src/automation/approval.zod.ts index c860b8b8f34..585bcb51b95 100644 --- a/packages/spec/src/automation/approval.zod.ts +++ b/packages/spec/src/automation/approval.zod.ts @@ -690,6 +690,49 @@ export function normalizeDecisionOutputs( return out; } +/** + * `escalateTo` literals no approver can ever act under, refused at + * registration — prefix → the remedy the refusal names. + * + * The approvals service resolves `escalateTo` as a position machine name + * first and falls back to the value as written. A person acts on a slot only + * under their user id, their email or `position:

` for a position they hold + * (`approver-address.ts` in `plugin-approvals` is the one source of that + * set), so a `team:` / `department:` / `org_membership_level:` literal, or + * the `role:` spelling ADR-0090 D3 retired, names nobody under any action: + * a `reassign` would move the request onto a slate no one can decide (the + * runtime keeps the slate and notifies instead), and a `notify` would reach + * no one. The shape alone settles it, so the contract refuses it at + * registration rather than leaving the SLA sweep to find it after the fact. + * + * Only the SHAPE is judged here. Whether a user id names an account, or a + * position has a holder, is directory state the runtime judges on timeout. + */ +const DEAD_ESCALATION_TARGET_PREFIXES: Readonly> = { + 'team:': + 'A team is not an escalation target: name a position its members hold, or a person.', + 'department:': + 'A department is not an escalation target: name a position its head or approvers hold, or a person.', + 'org_membership_level:': + 'An organization membership tier is not an escalation target: name a position, or a person.', + 'role:': + "'role:' is the retired spelling of a position address: write 'position:', or the bare position name.", +}; + +function checkEscalationTargetShape(value: string, ctx: z.RefinementCtx): void { + const target = value.trim(); + const prefix = Object.keys(DEAD_ESCALATION_TARGET_PREFIXES).find((p) => target.startsWith(p)); + if (prefix === undefined) return; + ctx.addIssue({ + code: 'custom', + message: + `escalateTo '${target}' is a '${prefix}' address, and no approver acts under one, so an ` + + 'escalation to it would hand the request to no one. ' + + `${DEAD_ESCALATION_TARGET_PREFIXES[prefix]} ` + + "escalateTo takes a user id, an email, a position machine name or 'position:'.", + }); +} + export const ApprovalEscalationSchema = lazySchema(() => strictObject( /** * Per-node SLA escalation — carried on the Approval node itself, so each @@ -735,8 +778,12 @@ export const ApprovalEscalationSchema = lazySchema(() => strictObject( // position machine name to its holders via `sys_user_position` (ADR-0090 // D3) and falls back to treating the value as a user id when nobody holds // it. NOT a better-auth membership tier — same contract as ApproverType. - escalateTo: z.string().optional().meta({ - description: 'User id or position machine name to escalate to', + // A literal no approver acts under is refused by its shape + // (DEAD_ESCALATION_TARGET_PREFIXES); whether the rest resolve is runtime state. + escalateTo: z.string().superRefine(checkEscalationTargetShape).optional().meta({ + description: + "User id, email, position machine name or 'position:' to escalate to. A 'team:', " + + "'department:', 'org_membership_level:' or 'role:' address is refused: no approver acts under one", xRef: { kind: 'position' }, }), notifySubmitter: z.boolean().default(true).describe('Notify the original submitter on escalation'), diff --git a/packages/spec/src/migrations/entries/semantic/18.approval-escalation-dead-target-refused.ts b/packages/spec/src/migrations/entries/semantic/18.approval-escalation-dead-target-refused.ts new file mode 100644 index 00000000000..5550dff9b32 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.approval-escalation-dead-target-refused.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The D3 entry for `ApprovalEscalationSchema.escalateTo` refusing, by shape, a +// target no approver acts under. It narrows a value accept set; no key is +// removed, so there is no tombstone and no RETIRED_KEYS_BY_MAJOR row. There is +// no D2 conversion either: which position or person a team, department or +// membership tier was meant to stand for is 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: 'approval-escalation-dead-target-refused', + surface: + 'an approval flow node escalation.escalateTo written as a team:, department:, ' + + 'org_membership_level: or role: address (ApprovalEscalationSchema). Reachable wherever a flow ' + + 'is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed ' + + 'to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and ' + + 'a flow row already sitting in sys_metadata', + replacement: + 'a target someone can act under: the user id or email of a person, a position machine name, or ' + + '`position:`. For a team, department or membership tier, name a position its people hold ' + + '(or create one and assign them). `role:` is the retired spelling of a position address, ' + + 'so write `position:` or the bare position name', + reason: + 'The approvals service resolves `escalateTo` as a position machine name and falls back to the ' + + 'value as written. A person acts on a pending slot only under their user id, their email or ' + + '`position:

` for a position they hold, so a `team:`, `department:` or ' + + '`org_membership_level:` literal, or the `role:` spelling ADR-0090 D3 retired, names nobody ' + + 'under any action. A `reassign` escalation used to move the request onto that literal, a slate ' + + 'only the privileged override could decide; the runtime now keeps the slate and notifies it ' + + 'instead, and a `notify` escalation reaches no one through it. The schema accepted the value ' + + 'and every build door exited 0, so the SLA the author declared never did what they meant. ' + + 'The shape alone settles it, so the contract refuses it at registration (`FlowSchema.parse`, ' + + 'the build doors and `AutomationEngine.registerFlow` share that judge); whether a user id names ' + + 'an account or a position has a holder is directory state, judged by the runtime on timeout. ' + + 'No D2 conversion: the platform cannot know which position or person the author meant. Where ' + + 'such a node already sits, the whole flow is refused like any approval node its contract ' + + 'refuses. Ships at once, no grace window. ADR-0087, ADR-0090.', + acceptanceCriteria: + 'Run `objectstack validate` over every stack authored in config files, and boot every deployed ' + + 'stack. A refused target is reported at `nodes.N.config.escalation.escalateTo`; replace it per ' + + 'the replacement. Two proofs. (1) For a stack authored in config files, `objectstack validate` ' + + 'is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` ' + + 'warn for it, the locator for a row that exists only in `sys_metadata`. An escalation whose ' + + 'target is a user id, an email, a position name or `position:` parses and registers ' + + 'byte-identically to before.', +};