@contractspec/lib.workflow-composer 3.0.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,45 +2,56 @@
2
2
 
3
3
  Website: https://contractspec.io/
4
4
 
5
+ Compose base `WorkflowSpec` definitions with tenant-, role-, and device-scoped extensions.
5
6
 
6
- Compose base WorkflowSpecs with tenant-, role-, or device-specific extensions. The composer lets teams inject steps, hide portions of a workflow, and attach tenant-scoped metadata without duplicating base definitions.
7
+ This package is designed for deterministic workflow personalization without duplicating core specs.
7
8
 
8
9
  ## Highlights
9
10
 
10
- - Type-safe `extendWorkflow` helper.
11
- - Registry for extension templates (tenant, role, device scopes).
12
- - Validation to ensure injected steps reference valid anchors.
13
- - Merge utilities to combine overlays coming from tenant + user.
14
-
15
- Refer to `docs/tech/personalization/workflow-composition.md` for more.
16
-
17
-
18
-
19
-
20
-
21
-
22
-
23
-
24
-
25
-
26
-
27
-
28
-
29
-
30
-
31
-
32
-
33
-
34
-
35
-
36
-
37
-
38
-
39
-
40
-
41
-
42
-
43
-
44
-
45
-
46
-
11
+ - Deterministic extension merge ordering with stable tie-breaking.
12
+ - Strict extension validation for anchors, transition endpoints, and duplicate injected step IDs.
13
+ - Safe hidden-step handling with entry-step protections.
14
+ - Metadata and annotations merge support on composed workflow outputs.
15
+ - Post-composition validation using `validateWorkflowSpec` from `@contractspec/lib.contracts-spec/workflow`.
16
+
17
+ ## Usage
18
+
19
+ ```ts
20
+ import { WorkflowComposer, approvalStepTemplate } from '@contractspec/lib.workflow-composer';
21
+
22
+ const composer = new WorkflowComposer();
23
+
24
+ composer.register({
25
+ workflow: 'billing.invoiceApproval',
26
+ tenantId: 'acme',
27
+ priority: 10,
28
+ metadata: {
29
+ tenant: 'acme',
30
+ },
31
+ annotations: {
32
+ source: 'tenant-extension',
33
+ },
34
+ customSteps: [
35
+ {
36
+ after: 'validate-invoice',
37
+ inject: approvalStepTemplate({
38
+ id: 'acme-legal-review',
39
+ label: 'Legal Review (ACME)',
40
+ description: 'Tenant-specific compliance step.',
41
+ }),
42
+ transitionTo: 'final-approval',
43
+ },
44
+ ],
45
+ });
46
+
47
+ const runtimeSpec = composer.compose({
48
+ base: BaseInvoiceWorkflow,
49
+ tenantId: 'acme',
50
+ });
51
+ ```
52
+
53
+ ## Notes
54
+
55
+ - Extensions are additive and validated before application.
56
+ - Composition throws with explicit error codes/messages when extension constraints are violated.
57
+ - Runtime behavior remains controlled by `@contractspec/lib.contracts-spec/workflow` runner and validation.
@@ -12,16 +12,63 @@ function approvalStepTemplate(options) {
12
12
  } : undefined
13
13
  };
14
14
  }
15
+ // src/injector.ts
16
+ import {
17
+ validateWorkflowSpec
18
+ } from "@contractspec/lib.contracts-spec/workflow";
19
+
15
20
  // src/validator.ts
16
21
  function validateExtension(extension, base) {
17
22
  const issues = [];
18
- const stepIds = new Set(base.definition.steps.map((step) => step.id));
23
+ const baseStepIds = new Set(base.definition.steps.map((step) => step.id));
24
+ if (!extension.workflow.trim()) {
25
+ issues.push({
26
+ code: "workflow.extension.workflow",
27
+ message: "workflow is required"
28
+ });
29
+ }
30
+ if (extension.workflow !== base.meta.key) {
31
+ issues.push({
32
+ code: "workflow.extension.workflow.mismatch",
33
+ message: `extension targets "${extension.workflow}" but base workflow is "${base.meta.key}"`
34
+ });
35
+ }
36
+ const hiddenSteps = new Set(extension.hiddenSteps ?? []);
37
+ hiddenSteps.forEach((stepId) => {
38
+ if (!baseStepIds.has(stepId)) {
39
+ issues.push({
40
+ code: "workflow.extension.hidden-step",
41
+ message: `hidden step "${stepId}" does not exist`
42
+ });
43
+ }
44
+ });
45
+ const availableAnchorSteps = new Set([...baseStepIds].filter((stepId) => !hiddenSteps.has(stepId)));
19
46
  extension.customSteps?.forEach((injection, idx) => {
20
- if (!injection.inject.id) {
47
+ const injectionId = injection.inject.id?.trim();
48
+ if (!injectionId) {
21
49
  issues.push({
22
50
  code: "workflow.extension.step.id",
23
51
  message: `customSteps[${idx}] is missing an id`
24
52
  });
53
+ return;
54
+ }
55
+ if (injection.id && injection.id !== injectionId) {
56
+ issues.push({
57
+ code: "workflow.extension.step.id-mismatch",
58
+ message: `customSteps[${idx}] has mismatched id (id="${injection.id}", inject.id="${injectionId}")`
59
+ });
60
+ }
61
+ if (availableAnchorSteps.has(injectionId)) {
62
+ issues.push({
63
+ code: "workflow.extension.step.id.duplicate",
64
+ message: `customSteps[${idx}] injects duplicate step id "${injectionId}"`
65
+ });
66
+ }
67
+ if (injection.after && injection.before) {
68
+ issues.push({
69
+ code: "workflow.extension.step.anchor.multiple",
70
+ message: `customSteps[${idx}] cannot set both after and before`
71
+ });
25
72
  }
26
73
  if (!injection.after && !injection.before) {
27
74
  issues.push({
@@ -29,27 +76,39 @@ function validateExtension(extension, base) {
29
76
  message: `customSteps[${idx}] must set after or before`
30
77
  });
31
78
  }
32
- if (injection.after && !stepIds.has(injection.after)) {
79
+ if (injection.after && !availableAnchorSteps.has(injection.after)) {
33
80
  issues.push({
34
81
  code: "workflow.extension.step.after",
35
82
  message: `customSteps[${idx}] references unknown step "${injection.after}"`
36
83
  });
37
84
  }
38
- if (injection.before && !stepIds.has(injection.before)) {
85
+ if (injection.before && !availableAnchorSteps.has(injection.before)) {
39
86
  issues.push({
40
87
  code: "workflow.extension.step.before",
41
88
  message: `customSteps[${idx}] references unknown step "${injection.before}"`
42
89
  });
43
90
  }
44
- });
45
- extension.hiddenSteps?.forEach((stepId) => {
46
- if (!stepIds.has(stepId)) {
91
+ if (injection.transitionFrom && !availableAnchorSteps.has(injection.transitionFrom)) {
47
92
  issues.push({
48
- code: "workflow.extension.hidden-step",
49
- message: `hidden step "${stepId}" does not exist`
93
+ code: "workflow.extension.step.transition-from",
94
+ message: `customSteps[${idx}] references unknown transitionFrom step "${injection.transitionFrom}"`
95
+ });
96
+ }
97
+ if (injection.transitionTo && !availableAnchorSteps.has(injection.transitionTo)) {
98
+ issues.push({
99
+ code: "workflow.extension.step.transition-to",
100
+ message: `customSteps[${idx}] references unknown transitionTo step "${injection.transitionTo}"`
50
101
  });
51
102
  }
103
+ availableAnchorSteps.add(injectionId);
52
104
  });
105
+ const entryStepId = base.definition.entryStepId ?? base.definition.steps[0]?.id;
106
+ if (entryStepId && hiddenSteps.has(entryStepId)) {
107
+ issues.push({
108
+ code: "workflow.extension.hidden-step.entry",
109
+ message: `hiddenSteps removes the entry step "${entryStepId}"`
110
+ });
111
+ }
53
112
  if (issues.length) {
54
113
  const reason = issues.map((issue) => `${issue.code}: ${issue.message}`).join("; ");
55
114
  throw new Error(`Invalid workflow extension for ${extension.workflow}: ${reason}`);
@@ -78,10 +137,13 @@ function applyWorkflowExtension(base, extension) {
78
137
  });
79
138
  spec.definition.steps = steps;
80
139
  spec.definition.transitions = dedupeTransitions(transitions);
81
- spec.meta = {
82
- ...spec.meta,
83
- version: spec.meta.version
84
- };
140
+ spec.metadata = mergeRecords(spec.metadata, extension.metadata);
141
+ spec.annotations = mergeRecords(spec.annotations, extension.annotations);
142
+ const issues = validateWorkflowSpec(spec);
143
+ const blockingIssues = issues.filter((issue) => issue.level === "error");
144
+ if (blockingIssues.length > 0) {
145
+ throw new Error(`Invalid composed workflow ${spec.meta.key}.v${spec.meta.version}: ${blockingIssues.map((issue) => issue.message).join("; ")}`);
146
+ }
85
147
  return spec;
86
148
  }
87
149
  function insertStep(steps, injection) {
@@ -135,9 +197,41 @@ function dedupeTransitions(transitions) {
135
197
  function cloneWorkflowSpec(spec) {
136
198
  return JSON.parse(JSON.stringify(spec));
137
199
  }
200
+ function mergeRecords(base, patch) {
201
+ if (!base && !patch) {
202
+ return;
203
+ }
204
+ return {
205
+ ...base ?? {},
206
+ ...patch ?? {}
207
+ };
208
+ }
138
209
  // src/composer.ts
139
210
  import { satisfies } from "compare-versions";
140
211
 
212
+ // src/merger.ts
213
+ function mergeExtensions(extensions) {
214
+ return [...extensions].sort((a, b) => {
215
+ const priorityOrder = (a.priority ?? 0) - (b.priority ?? 0);
216
+ if (priorityOrder !== 0)
217
+ return priorityOrder;
218
+ const workflowOrder = a.workflow.localeCompare(b.workflow);
219
+ if (workflowOrder !== 0)
220
+ return workflowOrder;
221
+ const tenantOrder = (a.tenantId ?? "").localeCompare(b.tenantId ?? "");
222
+ if (tenantOrder !== 0)
223
+ return tenantOrder;
224
+ const roleOrder = (a.role ?? "").localeCompare(b.role ?? "");
225
+ if (roleOrder !== 0)
226
+ return roleOrder;
227
+ const deviceOrder = (a.device ?? "").localeCompare(b.device ?? "");
228
+ if (deviceOrder !== 0)
229
+ return deviceOrder;
230
+ return (a.baseVersion ?? "").localeCompare(b.baseVersion ?? "");
231
+ });
232
+ }
233
+
234
+ // src/composer.ts
141
235
  class WorkflowComposer {
142
236
  extensions = [];
143
237
  register(extension) {
@@ -145,11 +239,13 @@ class WorkflowComposer {
145
239
  return this;
146
240
  }
147
241
  registerMany(extensions) {
148
- extensions.forEach((extension) => this.register(extension));
242
+ extensions.forEach((extension) => {
243
+ this.register(extension);
244
+ });
149
245
  return this;
150
246
  }
151
247
  compose(params) {
152
- const applicable = this.extensions.filter((extension) => matches(params, extension)).sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
248
+ const applicable = mergeExtensions(this.extensions.filter((extension) => matches(params, extension)));
153
249
  return applicable.reduce((acc, extension) => applyWorkflowExtension(acc, extension), params.base);
154
250
  }
155
251
  }
@@ -170,10 +266,6 @@ function matches(params, extension) {
170
266
  }
171
267
  return true;
172
268
  }
173
- // src/merger.ts
174
- function mergeExtensions(extensions) {
175
- return extensions.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
176
- }
177
269
  export {
178
270
  validateExtension,
179
271
  mergeExtensions,
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js CHANGED
@@ -13,16 +13,63 @@ function approvalStepTemplate(options) {
13
13
  } : undefined
14
14
  };
15
15
  }
16
+ // src/injector.ts
17
+ import {
18
+ validateWorkflowSpec
19
+ } from "@contractspec/lib.contracts-spec/workflow";
20
+
16
21
  // src/validator.ts
17
22
  function validateExtension(extension, base) {
18
23
  const issues = [];
19
- const stepIds = new Set(base.definition.steps.map((step) => step.id));
24
+ const baseStepIds = new Set(base.definition.steps.map((step) => step.id));
25
+ if (!extension.workflow.trim()) {
26
+ issues.push({
27
+ code: "workflow.extension.workflow",
28
+ message: "workflow is required"
29
+ });
30
+ }
31
+ if (extension.workflow !== base.meta.key) {
32
+ issues.push({
33
+ code: "workflow.extension.workflow.mismatch",
34
+ message: `extension targets "${extension.workflow}" but base workflow is "${base.meta.key}"`
35
+ });
36
+ }
37
+ const hiddenSteps = new Set(extension.hiddenSteps ?? []);
38
+ hiddenSteps.forEach((stepId) => {
39
+ if (!baseStepIds.has(stepId)) {
40
+ issues.push({
41
+ code: "workflow.extension.hidden-step",
42
+ message: `hidden step "${stepId}" does not exist`
43
+ });
44
+ }
45
+ });
46
+ const availableAnchorSteps = new Set([...baseStepIds].filter((stepId) => !hiddenSteps.has(stepId)));
20
47
  extension.customSteps?.forEach((injection, idx) => {
21
- if (!injection.inject.id) {
48
+ const injectionId = injection.inject.id?.trim();
49
+ if (!injectionId) {
22
50
  issues.push({
23
51
  code: "workflow.extension.step.id",
24
52
  message: `customSteps[${idx}] is missing an id`
25
53
  });
54
+ return;
55
+ }
56
+ if (injection.id && injection.id !== injectionId) {
57
+ issues.push({
58
+ code: "workflow.extension.step.id-mismatch",
59
+ message: `customSteps[${idx}] has mismatched id (id="${injection.id}", inject.id="${injectionId}")`
60
+ });
61
+ }
62
+ if (availableAnchorSteps.has(injectionId)) {
63
+ issues.push({
64
+ code: "workflow.extension.step.id.duplicate",
65
+ message: `customSteps[${idx}] injects duplicate step id "${injectionId}"`
66
+ });
67
+ }
68
+ if (injection.after && injection.before) {
69
+ issues.push({
70
+ code: "workflow.extension.step.anchor.multiple",
71
+ message: `customSteps[${idx}] cannot set both after and before`
72
+ });
26
73
  }
27
74
  if (!injection.after && !injection.before) {
28
75
  issues.push({
@@ -30,27 +77,39 @@ function validateExtension(extension, base) {
30
77
  message: `customSteps[${idx}] must set after or before`
31
78
  });
32
79
  }
33
- if (injection.after && !stepIds.has(injection.after)) {
80
+ if (injection.after && !availableAnchorSteps.has(injection.after)) {
34
81
  issues.push({
35
82
  code: "workflow.extension.step.after",
36
83
  message: `customSteps[${idx}] references unknown step "${injection.after}"`
37
84
  });
38
85
  }
39
- if (injection.before && !stepIds.has(injection.before)) {
86
+ if (injection.before && !availableAnchorSteps.has(injection.before)) {
40
87
  issues.push({
41
88
  code: "workflow.extension.step.before",
42
89
  message: `customSteps[${idx}] references unknown step "${injection.before}"`
43
90
  });
44
91
  }
45
- });
46
- extension.hiddenSteps?.forEach((stepId) => {
47
- if (!stepIds.has(stepId)) {
92
+ if (injection.transitionFrom && !availableAnchorSteps.has(injection.transitionFrom)) {
48
93
  issues.push({
49
- code: "workflow.extension.hidden-step",
50
- message: `hidden step "${stepId}" does not exist`
94
+ code: "workflow.extension.step.transition-from",
95
+ message: `customSteps[${idx}] references unknown transitionFrom step "${injection.transitionFrom}"`
96
+ });
97
+ }
98
+ if (injection.transitionTo && !availableAnchorSteps.has(injection.transitionTo)) {
99
+ issues.push({
100
+ code: "workflow.extension.step.transition-to",
101
+ message: `customSteps[${idx}] references unknown transitionTo step "${injection.transitionTo}"`
51
102
  });
52
103
  }
104
+ availableAnchorSteps.add(injectionId);
53
105
  });
106
+ const entryStepId = base.definition.entryStepId ?? base.definition.steps[0]?.id;
107
+ if (entryStepId && hiddenSteps.has(entryStepId)) {
108
+ issues.push({
109
+ code: "workflow.extension.hidden-step.entry",
110
+ message: `hiddenSteps removes the entry step "${entryStepId}"`
111
+ });
112
+ }
54
113
  if (issues.length) {
55
114
  const reason = issues.map((issue) => `${issue.code}: ${issue.message}`).join("; ");
56
115
  throw new Error(`Invalid workflow extension for ${extension.workflow}: ${reason}`);
@@ -79,10 +138,13 @@ function applyWorkflowExtension(base, extension) {
79
138
  });
80
139
  spec.definition.steps = steps;
81
140
  spec.definition.transitions = dedupeTransitions(transitions);
82
- spec.meta = {
83
- ...spec.meta,
84
- version: spec.meta.version
85
- };
141
+ spec.metadata = mergeRecords(spec.metadata, extension.metadata);
142
+ spec.annotations = mergeRecords(spec.annotations, extension.annotations);
143
+ const issues = validateWorkflowSpec(spec);
144
+ const blockingIssues = issues.filter((issue) => issue.level === "error");
145
+ if (blockingIssues.length > 0) {
146
+ throw new Error(`Invalid composed workflow ${spec.meta.key}.v${spec.meta.version}: ${blockingIssues.map((issue) => issue.message).join("; ")}`);
147
+ }
86
148
  return spec;
87
149
  }
88
150
  function insertStep(steps, injection) {
@@ -136,9 +198,41 @@ function dedupeTransitions(transitions) {
136
198
  function cloneWorkflowSpec(spec) {
137
199
  return JSON.parse(JSON.stringify(spec));
138
200
  }
201
+ function mergeRecords(base, patch) {
202
+ if (!base && !patch) {
203
+ return;
204
+ }
205
+ return {
206
+ ...base ?? {},
207
+ ...patch ?? {}
208
+ };
209
+ }
139
210
  // src/composer.ts
140
211
  import { satisfies } from "compare-versions";
141
212
 
213
+ // src/merger.ts
214
+ function mergeExtensions(extensions) {
215
+ return [...extensions].sort((a, b) => {
216
+ const priorityOrder = (a.priority ?? 0) - (b.priority ?? 0);
217
+ if (priorityOrder !== 0)
218
+ return priorityOrder;
219
+ const workflowOrder = a.workflow.localeCompare(b.workflow);
220
+ if (workflowOrder !== 0)
221
+ return workflowOrder;
222
+ const tenantOrder = (a.tenantId ?? "").localeCompare(b.tenantId ?? "");
223
+ if (tenantOrder !== 0)
224
+ return tenantOrder;
225
+ const roleOrder = (a.role ?? "").localeCompare(b.role ?? "");
226
+ if (roleOrder !== 0)
227
+ return roleOrder;
228
+ const deviceOrder = (a.device ?? "").localeCompare(b.device ?? "");
229
+ if (deviceOrder !== 0)
230
+ return deviceOrder;
231
+ return (a.baseVersion ?? "").localeCompare(b.baseVersion ?? "");
232
+ });
233
+ }
234
+
235
+ // src/composer.ts
142
236
  class WorkflowComposer {
143
237
  extensions = [];
144
238
  register(extension) {
@@ -146,11 +240,13 @@ class WorkflowComposer {
146
240
  return this;
147
241
  }
148
242
  registerMany(extensions) {
149
- extensions.forEach((extension) => this.register(extension));
243
+ extensions.forEach((extension) => {
244
+ this.register(extension);
245
+ });
150
246
  return this;
151
247
  }
152
248
  compose(params) {
153
- const applicable = this.extensions.filter((extension) => matches(params, extension)).sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
249
+ const applicable = mergeExtensions(this.extensions.filter((extension) => matches(params, extension)));
154
250
  return applicable.reduce((acc, extension) => applyWorkflowExtension(acc, extension), params.base);
155
251
  }
156
252
  }
@@ -171,10 +267,6 @@ function matches(params, extension) {
171
267
  }
172
268
  return true;
173
269
  }
174
- // src/merger.ts
175
- function mergeExtensions(extensions) {
176
- return extensions.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
177
- }
178
270
  export {
179
271
  validateExtension,
180
272
  mergeExtensions,
@@ -1,3 +1,3 @@
1
- import type { WorkflowSpec } from '@contractspec/lib.contracts-spec/workflow';
1
+ import { type WorkflowSpec } from '@contractspec/lib.contracts-spec/workflow';
2
2
  import type { WorkflowExtension } from './types';
3
3
  export declare function applyWorkflowExtension(base: WorkflowSpec, extension: WorkflowExtension): WorkflowSpec;
@@ -12,16 +12,63 @@ function approvalStepTemplate(options) {
12
12
  } : undefined
13
13
  };
14
14
  }
15
+ // src/injector.ts
16
+ import {
17
+ validateWorkflowSpec
18
+ } from "@contractspec/lib.contracts-spec/workflow";
19
+
15
20
  // src/validator.ts
16
21
  function validateExtension(extension, base) {
17
22
  const issues = [];
18
- const stepIds = new Set(base.definition.steps.map((step) => step.id));
23
+ const baseStepIds = new Set(base.definition.steps.map((step) => step.id));
24
+ if (!extension.workflow.trim()) {
25
+ issues.push({
26
+ code: "workflow.extension.workflow",
27
+ message: "workflow is required"
28
+ });
29
+ }
30
+ if (extension.workflow !== base.meta.key) {
31
+ issues.push({
32
+ code: "workflow.extension.workflow.mismatch",
33
+ message: `extension targets "${extension.workflow}" but base workflow is "${base.meta.key}"`
34
+ });
35
+ }
36
+ const hiddenSteps = new Set(extension.hiddenSteps ?? []);
37
+ hiddenSteps.forEach((stepId) => {
38
+ if (!baseStepIds.has(stepId)) {
39
+ issues.push({
40
+ code: "workflow.extension.hidden-step",
41
+ message: `hidden step "${stepId}" does not exist`
42
+ });
43
+ }
44
+ });
45
+ const availableAnchorSteps = new Set([...baseStepIds].filter((stepId) => !hiddenSteps.has(stepId)));
19
46
  extension.customSteps?.forEach((injection, idx) => {
20
- if (!injection.inject.id) {
47
+ const injectionId = injection.inject.id?.trim();
48
+ if (!injectionId) {
21
49
  issues.push({
22
50
  code: "workflow.extension.step.id",
23
51
  message: `customSteps[${idx}] is missing an id`
24
52
  });
53
+ return;
54
+ }
55
+ if (injection.id && injection.id !== injectionId) {
56
+ issues.push({
57
+ code: "workflow.extension.step.id-mismatch",
58
+ message: `customSteps[${idx}] has mismatched id (id="${injection.id}", inject.id="${injectionId}")`
59
+ });
60
+ }
61
+ if (availableAnchorSteps.has(injectionId)) {
62
+ issues.push({
63
+ code: "workflow.extension.step.id.duplicate",
64
+ message: `customSteps[${idx}] injects duplicate step id "${injectionId}"`
65
+ });
66
+ }
67
+ if (injection.after && injection.before) {
68
+ issues.push({
69
+ code: "workflow.extension.step.anchor.multiple",
70
+ message: `customSteps[${idx}] cannot set both after and before`
71
+ });
25
72
  }
26
73
  if (!injection.after && !injection.before) {
27
74
  issues.push({
@@ -29,27 +76,39 @@ function validateExtension(extension, base) {
29
76
  message: `customSteps[${idx}] must set after or before`
30
77
  });
31
78
  }
32
- if (injection.after && !stepIds.has(injection.after)) {
79
+ if (injection.after && !availableAnchorSteps.has(injection.after)) {
33
80
  issues.push({
34
81
  code: "workflow.extension.step.after",
35
82
  message: `customSteps[${idx}] references unknown step "${injection.after}"`
36
83
  });
37
84
  }
38
- if (injection.before && !stepIds.has(injection.before)) {
85
+ if (injection.before && !availableAnchorSteps.has(injection.before)) {
39
86
  issues.push({
40
87
  code: "workflow.extension.step.before",
41
88
  message: `customSteps[${idx}] references unknown step "${injection.before}"`
42
89
  });
43
90
  }
44
- });
45
- extension.hiddenSteps?.forEach((stepId) => {
46
- if (!stepIds.has(stepId)) {
91
+ if (injection.transitionFrom && !availableAnchorSteps.has(injection.transitionFrom)) {
47
92
  issues.push({
48
- code: "workflow.extension.hidden-step",
49
- message: `hidden step "${stepId}" does not exist`
93
+ code: "workflow.extension.step.transition-from",
94
+ message: `customSteps[${idx}] references unknown transitionFrom step "${injection.transitionFrom}"`
95
+ });
96
+ }
97
+ if (injection.transitionTo && !availableAnchorSteps.has(injection.transitionTo)) {
98
+ issues.push({
99
+ code: "workflow.extension.step.transition-to",
100
+ message: `customSteps[${idx}] references unknown transitionTo step "${injection.transitionTo}"`
50
101
  });
51
102
  }
103
+ availableAnchorSteps.add(injectionId);
52
104
  });
105
+ const entryStepId = base.definition.entryStepId ?? base.definition.steps[0]?.id;
106
+ if (entryStepId && hiddenSteps.has(entryStepId)) {
107
+ issues.push({
108
+ code: "workflow.extension.hidden-step.entry",
109
+ message: `hiddenSteps removes the entry step "${entryStepId}"`
110
+ });
111
+ }
53
112
  if (issues.length) {
54
113
  const reason = issues.map((issue) => `${issue.code}: ${issue.message}`).join("; ");
55
114
  throw new Error(`Invalid workflow extension for ${extension.workflow}: ${reason}`);
@@ -78,10 +137,13 @@ function applyWorkflowExtension(base, extension) {
78
137
  });
79
138
  spec.definition.steps = steps;
80
139
  spec.definition.transitions = dedupeTransitions(transitions);
81
- spec.meta = {
82
- ...spec.meta,
83
- version: spec.meta.version
84
- };
140
+ spec.metadata = mergeRecords(spec.metadata, extension.metadata);
141
+ spec.annotations = mergeRecords(spec.annotations, extension.annotations);
142
+ const issues = validateWorkflowSpec(spec);
143
+ const blockingIssues = issues.filter((issue) => issue.level === "error");
144
+ if (blockingIssues.length > 0) {
145
+ throw new Error(`Invalid composed workflow ${spec.meta.key}.v${spec.meta.version}: ${blockingIssues.map((issue) => issue.message).join("; ")}`);
146
+ }
85
147
  return spec;
86
148
  }
87
149
  function insertStep(steps, injection) {
@@ -135,9 +197,41 @@ function dedupeTransitions(transitions) {
135
197
  function cloneWorkflowSpec(spec) {
136
198
  return JSON.parse(JSON.stringify(spec));
137
199
  }
200
+ function mergeRecords(base, patch) {
201
+ if (!base && !patch) {
202
+ return;
203
+ }
204
+ return {
205
+ ...base ?? {},
206
+ ...patch ?? {}
207
+ };
208
+ }
138
209
  // src/composer.ts
139
210
  import { satisfies } from "compare-versions";
140
211
 
212
+ // src/merger.ts
213
+ function mergeExtensions(extensions) {
214
+ return [...extensions].sort((a, b) => {
215
+ const priorityOrder = (a.priority ?? 0) - (b.priority ?? 0);
216
+ if (priorityOrder !== 0)
217
+ return priorityOrder;
218
+ const workflowOrder = a.workflow.localeCompare(b.workflow);
219
+ if (workflowOrder !== 0)
220
+ return workflowOrder;
221
+ const tenantOrder = (a.tenantId ?? "").localeCompare(b.tenantId ?? "");
222
+ if (tenantOrder !== 0)
223
+ return tenantOrder;
224
+ const roleOrder = (a.role ?? "").localeCompare(b.role ?? "");
225
+ if (roleOrder !== 0)
226
+ return roleOrder;
227
+ const deviceOrder = (a.device ?? "").localeCompare(b.device ?? "");
228
+ if (deviceOrder !== 0)
229
+ return deviceOrder;
230
+ return (a.baseVersion ?? "").localeCompare(b.baseVersion ?? "");
231
+ });
232
+ }
233
+
234
+ // src/composer.ts
141
235
  class WorkflowComposer {
142
236
  extensions = [];
143
237
  register(extension) {
@@ -145,11 +239,13 @@ class WorkflowComposer {
145
239
  return this;
146
240
  }
147
241
  registerMany(extensions) {
148
- extensions.forEach((extension) => this.register(extension));
242
+ extensions.forEach((extension) => {
243
+ this.register(extension);
244
+ });
149
245
  return this;
150
246
  }
151
247
  compose(params) {
152
- const applicable = this.extensions.filter((extension) => matches(params, extension)).sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
248
+ const applicable = mergeExtensions(this.extensions.filter((extension) => matches(params, extension)));
153
249
  return applicable.reduce((acc, extension) => applyWorkflowExtension(acc, extension), params.base);
154
250
  }
155
251
  }
@@ -170,10 +266,6 @@ function matches(params, extension) {
170
266
  }
171
267
  return true;
172
268
  }
173
- // src/merger.ts
174
- function mergeExtensions(extensions) {
175
- return extensions.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
176
- }
177
269
  export {
178
270
  validateExtension,
179
271
  mergeExtensions,
package/dist/types.d.ts CHANGED
@@ -1,4 +1,18 @@
1
- import type { Step, WorkflowSpec } from '@contractspec/lib.contracts-spec/workflow';
1
+ import type { Step, StepModelHints, WorkflowSpec } from '@contractspec/lib.contracts-spec/workflow';
2
+ import type { ModelSelector } from '@contractspec/lib.ai-providers/selector-types';
3
+ /**
4
+ * Context provided to operation executors during workflow step execution.
5
+ * Includes an optional ranking-driven model selector for AI-powered steps.
6
+ */
7
+ export interface OperationExecutorContext {
8
+ tenantId?: string;
9
+ role?: string;
10
+ /** Ranking-driven model selector; executors should use this when selecting AI models. */
11
+ modelSelector?: ModelSelector;
12
+ /** Model hints from the current step, if any. */
13
+ stepModelHints?: StepModelHints;
14
+ metadata?: Record<string, unknown>;
15
+ }
2
16
  export interface WorkflowExtensionScope {
3
17
  tenantId?: string;
4
18
  role?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractspec/lib.workflow-composer",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
4
4
  "description": "Tenant-aware workflow composition helpers for ContractSpec.",
5
5
  "keywords": [
6
6
  "contractspec",
@@ -31,13 +31,14 @@
31
31
  "typecheck": "tsc --noEmit"
32
32
  },
33
33
  "dependencies": {
34
- "@contractspec/lib.contracts-spec": "3.0.0",
34
+ "@contractspec/lib.contracts-spec": "3.1.1",
35
+ "@contractspec/lib.ai-providers": "3.1.1",
35
36
  "compare-versions": "^6.1.1"
36
37
  },
37
38
  "devDependencies": {
38
- "@contractspec/tool.typescript": "3.0.0",
39
+ "@contractspec/tool.typescript": "3.1.0",
39
40
  "typescript": "^5.9.3",
40
- "@contractspec/tool.bun": "3.0.0"
41
+ "@contractspec/tool.bun": "3.1.0"
41
42
  },
42
43
  "exports": {
43
44
  ".": {