Skip to content

Commit 54883e1

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-aetoxp
2 parents 37cb4a6 + fbb7599 commit 54883e1

9 files changed

Lines changed: 373 additions & 47 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: an SLA escalation's `escalateTo` naming a `team:`, `department:`, `org_membership_level:` or `role:` address is refused at registration
6+
7+
Clause-②: no (narrowing) — `ApprovalEscalationSchema.escalateTo` refuses values it used to accept. Nothing is widened.
8+
9+
Metadata-protocol: narrowed (an approval node's `escalation.escalateTo` written as a `team:`, `department:`, `org_membership_level:` or `role:` address is refused)
10+
11+
<!-- adr-0087: registered approval-escalation-dead-target-refused -->
12+
13+
`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:<name>` 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.
14+
15+
`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:<name>` 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.
16+
17+
**BREAKING** for an author whose approval node escalates to a team, department, membership tier or `role:` address.
18+
19+
- **Before.** `escalation: { timeoutHours: 24, action: 'reassign', escalateTo: 'team:night_shift' }` parsed, and on timeout the request was handed to no one.
20+
- **Now.** The flow is refused at `nodes.N.config.escalation.escalateTo`, with a message naming the value and what to write instead.
21+
- **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:<name>` as `position:<name>`.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): the "nothing in this app is available to you" screen is drawn by the shared `DataEmptyState`
6+
7+
Clause-②: no (one block swaps its own markup for the shared components; no exported type change. If a shared component needs a new prop, the thread reports first and the seat amends this line)
8+
Metadata-protocol: none
9+
10+
The screen an app's root shows when the app serves the caller no navigation, or names only objects the caller may not read (`AppNoAccessEmptyState`), now uses `DataEmptyState` from `@object-ui/components` instead of the `Empty` primitive with a hand-copied icon square. It is announced to assistive technology as `role="status"`. The text, the Open Setup link, the test ids and the `data-reason` attribute (now on the status node) are unchanged; the title takes the shared component's smaller size.

‎content/docs/references/automation/approval.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const result = ApprovalDecision.parse(data);
4141
| **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 |
4242
| **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 |
4343
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
44-
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
44+
| **escalateTo** | `string` | optional | User id, email, position machine name or 'position:`<name>`' to escalate to. A 'team:', 'department:', 'org_membership_level:' or 'role:' address is refused: no approver acts under one |
4545
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
4646

4747

@@ -117,7 +117,7 @@ const result = ApprovalDecision.parse(data);
117117
| **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 |
118118
| **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 |
119119
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
120-
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
120+
| **escalateTo** | `string` | optional | User id, email, position machine name or 'position:`<name>`' to escalate to. A 'team:', 'department:', 'org_membership_level:' or 'role:' address is refused: no approver acts under one |
121121
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
122122

123123

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"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.",
33
"measured": {
44
"zod": "4.4.3",
5-
"publishedSchemasWithDroppedRefinements": 244,
6-
"droppedRefinementSites": 838,
5+
"publishedSchemasWithDroppedRefinements": 245,
6+
"droppedRefinementSites": 840,
77
"refinementSitesThatDidProject": 369,
88
"refinementSitesWithNoJsonFormToCompare": 0
99
},
@@ -528,9 +528,15 @@
528528
"data.nodes.element.in.waitEventConfig"
529529
]
530530
},
531+
"automation/ApprovalEscalation": {
532+
"sites": [
533+
"escalateTo"
534+
]
535+
},
531536
"automation/ApprovalNodeConfig": {
532537
"sites": [
533-
""
538+
"",
539+
"escalation.escalateTo"
534540
]
535541
},
536542
"automation/AssignmentConfig": {
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25598] An SLA escalation's `escalateTo` that no approver could ever act
5+
* under is refused at registration — the contract half of the card whose
6+
* runtime half keeps a stranded slate from being written on timeout.
7+
*
8+
* A person acts on a slot under their user id, their email or
9+
* `position:<p>` (`approver-address.ts` in `plugin-approvals`), so a `team:`,
10+
* `department:` or `org_membership_level:` literal, or the retired `role:`
11+
* spelling, names nobody under any escalation action. The shape alone settles
12+
* it, so it is judged here; whether a user id or a position resolves is
13+
* directory state, so every other shape stays accepted.
14+
*
15+
* - §1 each refused prefix is refused at `escalateTo`, under every action,
16+
* padded or not;
17+
* - §2 each accepted shape parses, and an omitted target still parses;
18+
* - §3 the refusal reaches the door authors meet: `FlowSchema` and the build
19+
* judge `flowNodeConfigRefusals`, anchored at the key.
20+
*/
21+
22+
import { describe, expect, it } from 'vitest';
23+
24+
import { ApprovalEscalationSchema } from './approval.zod';
25+
import { flowNodeConfigRefusals } from './flow-node-config-refusals';
26+
import { FlowSchema } from './flow.zod';
27+
28+
const REFUSED = ['team:night_shift', 'department:sales', 'org_membership_level:admin', 'role:sales_manager'];
29+
const ACCEPTED = ['usr_7f3a', 'vp@example.com', 'vp_operations', 'position:vp_operations'];
30+
const ACTIONS = ['reassign', 'notify', 'auto_approve', 'auto_reject'] as const;
31+
32+
function issues(value: unknown): { code: string; path: string }[] {
33+
const result = ApprovalEscalationSchema.safeParse(value);
34+
if (result.success) return [];
35+
return result.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') }));
36+
}
37+
38+
function flowWith(escalation: Record<string, unknown>) {
39+
return {
40+
name: 'expense_approval',
41+
label: 'Expense approval',
42+
type: 'autolaunched',
43+
nodes: [
44+
{ id: 'start', type: 'start', label: 'Start' },
45+
{
46+
id: 'gate', type: 'approval', label: 'Finance',
47+
config: { approvers: [{ type: 'position', value: 'finance_manager' }], escalation },
48+
},
49+
{ id: 'approved', type: 'end', label: 'Approved' },
50+
{ id: 'rejected', type: 'end', label: 'Rejected' },
51+
],
52+
edges: [
53+
{ id: 'e1', source: 'start', target: 'gate' },
54+
{ id: 'e2', source: 'gate', target: 'approved', label: 'approve' },
55+
{ id: 'e3', source: 'gate', target: 'rejected', label: 'reject' },
56+
],
57+
};
58+
}
59+
60+
describe('§1 a target no approver acts under is refused at escalateTo', () => {
61+
it.each(REFUSED)('%s is refused under every action', (escalateTo) => {
62+
for (const action of ACTIONS) {
63+
expect(issues({ timeoutHours: 24, action, escalateTo })).toEqual([{ code: 'custom', path: 'escalateTo' }]);
64+
}
65+
});
66+
67+
it.each(REFUSED)('%s is refused with surrounding whitespace, as the runtime trims it', (escalateTo) => {
68+
expect(issues({ timeoutHours: 24, action: 'reassign', escalateTo: ` ${escalateTo} ` }))
69+
.toEqual([{ code: 'custom', path: 'escalateTo' }]);
70+
});
71+
72+
it('names the value it refused and the shapes it accepts', () => {
73+
const result = ApprovalEscalationSchema.safeParse({ timeoutHours: 24, action: 'reassign', escalateTo: 'team:ops' });
74+
expect(result.success).toBe(false);
75+
const message = result.success ? '' : result.error.issues[0]!.message;
76+
expect(message).toContain("'team:ops'");
77+
expect(message).toContain("'position:<name>'");
78+
});
79+
});
80+
81+
describe('§2 a user id, an email and a position stay accepted', () => {
82+
it.each(ACCEPTED)('%s parses, whole schema green', (escalateTo) => {
83+
expect(issues({ timeoutHours: 24, action: 'reassign', escalateTo })).toEqual([]);
84+
const parsed = ApprovalEscalationSchema.parse({ timeoutHours: 24, action: 'reassign', escalateTo });
85+
expect(parsed.escalateTo).toBe(escalateTo);
86+
});
87+
88+
it('an omitted target still parses and stays absent', () => {
89+
expect(issues({ timeoutHours: 24 })).toEqual([]);
90+
expect(ApprovalEscalationSchema.parse({ timeoutHours: 24 }).escalateTo).toBeUndefined();
91+
});
92+
});
93+
94+
describe('§3 the refusal reaches the flow door', () => {
95+
it.each(REFUSED)('FlowSchema refuses %s at the node config key', (escalateTo) => {
96+
const result = FlowSchema.safeParse(flowWith({ timeoutHours: 24, action: 'reassign', escalateTo }));
97+
expect(result.success).toBe(false);
98+
const paths = result.success ? [] : result.error.issues.map((i) => i.path.join('.'));
99+
expect(paths).toContain('nodes.1.config.escalation.escalateTo');
100+
});
101+
102+
it.each(REFUSED)('the build judge reports %s as node-config-refused-by-contract', (escalateTo) => {
103+
const config = {
104+
approvers: [{ type: 'position', value: 'finance_manager' }],
105+
escalation: { timeoutHours: 24, action: 'reassign', escalateTo },
106+
};
107+
expect(flowNodeConfigRefusals('approval', config).map(({ code, path }) => ({ code, path }))).toEqual([
108+
{ code: 'node-config-refused-by-contract', path: 'escalation.escalateTo' },
109+
]);
110+
});
111+
112+
it.each(ACCEPTED)('FlowSchema accepts %s', (escalateTo) => {
113+
expect(FlowSchema.safeParse(flowWith({ timeoutHours: 24, action: 'reassign', escalateTo })).success).toBe(true);
114+
});
115+
});

‎packages/spec/src/automation/approval.zod.ts‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,49 @@ export function normalizeDecisionOutputs(
690690
return out;
691691
}
692692

693+
/**
694+
* `escalateTo` literals no approver can ever act under, refused at
695+
* registration — prefix → the remedy the refusal names.
696+
*
697+
* The approvals service resolves `escalateTo` as a position machine name
698+
* first and falls back to the value as written. A person acts on a slot only
699+
* under their user id, their email or `position:<p>` for a position they hold
700+
* (`approver-address.ts` in `plugin-approvals` is the one source of that
701+
* set), so a `team:` / `department:` / `org_membership_level:` literal, or
702+
* the `role:` spelling ADR-0090 D3 retired, names nobody under any action:
703+
* a `reassign` would move the request onto a slate no one can decide (the
704+
* runtime keeps the slate and notifies instead), and a `notify` would reach
705+
* no one. The shape alone settles it, so the contract refuses it at
706+
* registration rather than leaving the SLA sweep to find it after the fact.
707+
*
708+
* Only the SHAPE is judged here. Whether a user id names an account, or a
709+
* position has a holder, is directory state the runtime judges on timeout.
710+
*/
711+
const DEAD_ESCALATION_TARGET_PREFIXES: Readonly<Record<string, string>> = {
712+
'team:':
713+
'A team is not an escalation target: name a position its members hold, or a person.',
714+
'department:':
715+
'A department is not an escalation target: name a position its head or approvers hold, or a person.',
716+
'org_membership_level:':
717+
'An organization membership tier is not an escalation target: name a position, or a person.',
718+
'role:':
719+
"'role:' is the retired spelling of a position address: write 'position:<name>', or the bare position name.",
720+
};
721+
722+
function checkEscalationTargetShape(value: string, ctx: z.RefinementCtx): void {
723+
const target = value.trim();
724+
const prefix = Object.keys(DEAD_ESCALATION_TARGET_PREFIXES).find((p) => target.startsWith(p));
725+
if (prefix === undefined) return;
726+
ctx.addIssue({
727+
code: 'custom',
728+
message:
729+
`escalateTo '${target}' is a '${prefix}' address, and no approver acts under one, so an `
730+
+ 'escalation to it would hand the request to no one. '
731+
+ `${DEAD_ESCALATION_TARGET_PREFIXES[prefix]} `
732+
+ "escalateTo takes a user id, an email, a position machine name or 'position:<name>'.",
733+
});
734+
}
735+
693736
export const ApprovalEscalationSchema = lazySchema(() => strictObject(
694737
/**
695738
* Per-node SLA escalation — carried on the Approval node itself, so each
@@ -735,8 +778,12 @@ export const ApprovalEscalationSchema = lazySchema(() => strictObject(
735778
// position machine name to its holders via `sys_user_position` (ADR-0090
736779
// D3) and falls back to treating the value as a user id when nobody holds
737780
// it. NOT a better-auth membership tier — same contract as ApproverType.
738-
escalateTo: z.string().optional().meta({
739-
description: 'User id or position machine name to escalate to',
781+
// A literal no approver acts under is refused by its shape
782+
// (DEAD_ESCALATION_TARGET_PREFIXES); whether the rest resolve is runtime state.
783+
escalateTo: z.string().superRefine(checkEscalationTargetShape).optional().meta({
784+
description:
785+
"User id, email, position machine name or 'position:<name>' to escalate to. A 'team:', "
786+
+ "'department:', 'org_membership_level:' or 'role:' address is refused: no approver acts under one",
740787
xRef: { kind: 'position' },
741788
}),
742789
notifySubmitter: z.boolean().default(true).describe('Notify the original submitter on escalation'),

0 commit comments

Comments
 (0)