@shipfox/api-definitions 10.2.0 → 12.0.0

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.
Files changed (150) hide show
  1. package/.turbo/turbo-build.log +13 -12
  2. package/CHANGELOG.md +91 -0
  3. package/dist/core/entities/index.d.ts +1 -0
  4. package/dist/core/entities/index.d.ts.map +1 -1
  5. package/dist/core/entities/index.js +1 -0
  6. package/dist/core/entities/index.js.map +1 -1
  7. package/dist/core/entities/sync-state.d.ts +4 -0
  8. package/dist/core/entities/sync-state.d.ts.map +1 -1
  9. package/dist/core/entities/sync-state.js +10 -0
  10. package/dist/core/entities/sync-state.js.map +1 -1
  11. package/dist/core/entities/validation-warning.d.ts +6 -0
  12. package/dist/core/entities/validation-warning.d.ts.map +1 -0
  13. package/dist/core/entities/validation-warning.js +3 -0
  14. package/dist/core/entities/validation-warning.js.map +1 -0
  15. package/dist/core/parse-definition.d.ts +9 -7
  16. package/dist/core/parse-definition.d.ts.map +1 -1
  17. package/dist/core/parse-definition.js +14 -2
  18. package/dist/core/parse-definition.js.map +1 -1
  19. package/dist/core/sync-definitions.d.ts +2 -0
  20. package/dist/core/sync-definitions.d.ts.map +1 -1
  21. package/dist/core/sync-definitions.js +6 -5
  22. package/dist/core/sync-definitions.js.map +1 -1
  23. package/dist/core/validate-definition.d.ts +9 -5
  24. package/dist/core/validate-definition.d.ts.map +1 -1
  25. package/dist/core/validate-definition.js +23 -2
  26. package/dist/core/validate-definition.js.map +1 -1
  27. package/dist/core/workflow-model/index.d.ts +1 -1
  28. package/dist/core/workflow-model/index.d.ts.map +1 -1
  29. package/dist/core/workflow-model/index.js.map +1 -1
  30. package/dist/core/workflow-model/invalid-workflow-model-error.d.ts +3 -1
  31. package/dist/core/workflow-model/invalid-workflow-model-error.d.ts.map +1 -1
  32. package/dist/core/workflow-model/invalid-workflow-model-error.js.map +1 -1
  33. package/dist/core/workflow-model/normalize-if-condition.d.ts +0 -1
  34. package/dist/core/workflow-model/normalize-if-condition.d.ts.map +1 -1
  35. package/dist/core/workflow-model/normalize-if-condition.js +0 -1
  36. package/dist/core/workflow-model/normalize-if-condition.js.map +1 -1
  37. package/dist/core/workflow-model/normalize-job-checkout.d.ts +15 -3
  38. package/dist/core/workflow-model/normalize-job-checkout.d.ts.map +1 -1
  39. package/dist/core/workflow-model/normalize-job-checkout.js +83 -4
  40. package/dist/core/workflow-model/normalize-job-checkout.js.map +1 -1
  41. package/dist/core/workflow-model/normalize-job-listening.js +0 -1
  42. package/dist/core/workflow-model/normalize-job-listening.js.map +1 -1
  43. package/dist/core/workflow-model/normalize-job-success.d.ts.map +1 -1
  44. package/dist/core/workflow-model/normalize-job-success.js +0 -1
  45. package/dist/core/workflow-model/normalize-job-success.js.map +1 -1
  46. package/dist/core/workflow-model/normalize-jobs.d.ts.map +1 -1
  47. package/dist/core/workflow-model/normalize-jobs.js +310 -45
  48. package/dist/core/workflow-model/normalize-jobs.js.map +1 -1
  49. package/dist/core/workflow-model/normalize-step-gate.js +0 -1
  50. package/dist/core/workflow-model/normalize-step-gate.js.map +1 -1
  51. package/dist/core/workflow-model/normalize-triggers.d.ts.map +1 -1
  52. package/dist/core/workflow-model/normalize-triggers.js +0 -1
  53. package/dist/core/workflow-model/normalize-triggers.js.map +1 -1
  54. package/dist/core/workflow-model/normalize-workflow-document.d.ts +3 -0
  55. package/dist/core/workflow-model/normalize-workflow-document.d.ts.map +1 -1
  56. package/dist/core/workflow-model/normalize-workflow-document.js +34 -4
  57. package/dist/core/workflow-model/normalize-workflow-document.js.map +1 -1
  58. package/dist/core/workflow-model/parse-interpolation-field.d.ts +1 -1
  59. package/dist/core/workflow-model/parse-interpolation-field.d.ts.map +1 -1
  60. package/dist/core/workflow-model/parse-interpolation-field.js +27 -57
  61. package/dist/core/workflow-model/parse-interpolation-field.js.map +1 -1
  62. package/dist/core/workflow-model/validate-literal-name.d.ts +12 -0
  63. package/dist/core/workflow-model/validate-literal-name.d.ts.map +1 -0
  64. package/dist/core/workflow-model/validate-literal-name.js +36 -0
  65. package/dist/core/workflow-model/validate-literal-name.js.map +1 -0
  66. package/dist/core/workflow-model/validate-predicate-expression.d.ts +1 -2
  67. package/dist/core/workflow-model/validate-predicate-expression.d.ts.map +1 -1
  68. package/dist/core/workflow-model/validate-predicate-expression.js +31 -90
  69. package/dist/core/workflow-model/validate-predicate-expression.js.map +1 -1
  70. package/dist/core/workflow-model/validation-issue.d.ts +2 -1
  71. package/dist/core/workflow-model/validation-issue.d.ts.map +1 -1
  72. package/dist/core/workflow-model/validation-issue.js +8 -2
  73. package/dist/core/workflow-model/validation-issue.js.map +1 -1
  74. package/dist/core/workflow-model/workflow-field-label.d.ts.map +1 -1
  75. package/dist/core/workflow-model/workflow-field-label.js +16 -2
  76. package/dist/core/workflow-model/workflow-field-label.js.map +1 -1
  77. package/dist/db/db.d.ts +38 -0
  78. package/dist/db/db.d.ts.map +1 -1
  79. package/dist/db/schema/sync-states.d.ts +19 -0
  80. package/dist/db/schema/sync-states.d.ts.map +1 -1
  81. package/dist/db/schema/sync-states.js +3 -1
  82. package/dist/db/schema/sync-states.js.map +1 -1
  83. package/dist/db/sync-states.d.ts +2 -1
  84. package/dist/db/sync-states.d.ts.map +1 -1
  85. package/dist/db/sync-states.js +4 -0
  86. package/dist/db/sync-states.js.map +1 -1
  87. package/dist/presentation/dto/definition.d.ts.map +1 -1
  88. package/dist/presentation/dto/definition.js +2 -1
  89. package/dist/presentation/dto/definition.js.map +1 -1
  90. package/dist/presentation/routes/project-access.d.ts +1 -0
  91. package/dist/presentation/routes/project-access.d.ts.map +1 -1
  92. package/dist/presentation/routes/validate-definition.d.ts.map +1 -1
  93. package/dist/presentation/routes/validate-definition.js +5 -3
  94. package/dist/presentation/routes/validate-definition.js.map +1 -1
  95. package/dist/temporal/activities/sync-activities.d.ts +6 -2
  96. package/dist/temporal/activities/sync-activities.d.ts.map +1 -1
  97. package/dist/temporal/activities/sync-activities.js +8 -1
  98. package/dist/temporal/activities/sync-activities.js.map +1 -1
  99. package/dist/temporal/workflows/definition-sync.js +4 -1
  100. package/dist/temporal/workflows/definition-sync.js.map +1 -1
  101. package/dist/temporal/workflows/index.bundle.js +9029 -3
  102. package/dist/tsconfig.test.tsbuildinfo +1 -1
  103. package/drizzle/0002_lumpy_ben_parker.sql +1 -0
  104. package/drizzle/meta/0002_snapshot.json +534 -0
  105. package/drizzle/meta/_journal.json +7 -0
  106. package/package.json +14 -14
  107. package/src/core/entities/index.ts +1 -0
  108. package/src/core/entities/sync-state.ts +23 -0
  109. package/src/core/entities/validation-warning.ts +5 -0
  110. package/src/core/parse-definition-warnings.test.ts +33 -0
  111. package/src/core/parse-definition.test.ts +7 -1
  112. package/src/core/parse-definition.ts +28 -10
  113. package/src/core/sync-definitions.test.ts +39 -0
  114. package/src/core/sync-definitions.ts +14 -4
  115. package/src/core/validate-definition.test.ts +128 -0
  116. package/src/core/validate-definition.ts +39 -8
  117. package/src/core/workflow-model/README.md +2 -2
  118. package/src/core/workflow-model/index.ts +1 -0
  119. package/src/core/workflow-model/invalid-workflow-model-error.ts +8 -2
  120. package/src/core/workflow-model/normalize-if-condition.ts +0 -2
  121. package/src/core/workflow-model/normalize-job-checkout.ts +105 -8
  122. package/src/core/workflow-model/normalize-job-listening.ts +0 -1
  123. package/src/core/workflow-model/normalize-job-success.ts +0 -1
  124. package/src/core/workflow-model/normalize-jobs.ts +330 -49
  125. package/src/core/workflow-model/normalize-step-gate.ts +0 -1
  126. package/src/core/workflow-model/normalize-triggers.ts +0 -1
  127. package/src/core/workflow-model/normalize-workflow-document.test.ts +759 -185
  128. package/src/core/workflow-model/normalize-workflow-document.ts +29 -4
  129. package/src/core/workflow-model/parse-interpolation-field.ts +50 -79
  130. package/src/core/workflow-model/validate-literal-name.ts +64 -0
  131. package/src/core/workflow-model/validate-predicate-expression.test.ts +153 -15
  132. package/src/core/workflow-model/validate-predicate-expression.ts +52 -120
  133. package/src/core/workflow-model/validation-issue.ts +4 -0
  134. package/src/core/workflow-model/workflow-field-label.ts +16 -2
  135. package/src/core/workflow-yaml/README.md +2 -2
  136. package/src/db/schema/sync-states.ts +4 -1
  137. package/src/db/sync-states.test.ts +57 -0
  138. package/src/db/sync-states.ts +6 -0
  139. package/src/presentation/dto/definition.ts +1 -0
  140. package/src/presentation/routes/create-definition.test.ts +3 -3
  141. package/src/presentation/routes/get-definition.test.ts +1 -1
  142. package/src/presentation/routes/list-definitions.test.ts +2 -1
  143. package/src/presentation/routes/validate-definition.test.ts +1 -0
  144. package/src/presentation/routes/validate-definition.ts +7 -1
  145. package/src/temporal/activities/sync-activities.test.ts +16 -0
  146. package/src/temporal/activities/sync-activities.ts +18 -3
  147. package/src/temporal/workflows/definition-sync.ts +1 -1
  148. package/test/agent-validation-catalog.ts +17 -0
  149. package/test/fixtures/valid-listening-job.yml +2 -1
  150. package/tsconfig.build.tsbuildinfo +1 -1
@@ -79,6 +79,199 @@ const integrationValidationContext = {
79
79
  } satisfies IntegrationValidationContext;
80
80
 
81
81
  describe('normalizeWorkflowDocument', () => {
82
+ it('rejects interpolated workflow names in favor of run_name', () => {
83
+ const error = expectInvalid({
84
+ name: interpolation('inputs.environment'),
85
+ jobs: {build: {steps: [{run: 'build'}]}},
86
+ });
87
+
88
+ expect(error.issues).toEqual([
89
+ expect.objectContaining({
90
+ code: 'invalid-interpolation-template',
91
+ message: 'Workflow name must be literal. Move runtime interpolation to run_name.',
92
+ path: ['name'],
93
+ }),
94
+ ]);
95
+ });
96
+
97
+ it('rejects unterminated workflow-name templates with the migration guidance', () => {
98
+ const error = expectInvalid({
99
+ name: '$' + '{{ unterminated',
100
+ jobs: {build: {steps: [{run: 'build'}]}},
101
+ });
102
+
103
+ expect(error.issues).toEqual([
104
+ expect.objectContaining({
105
+ code: 'invalid-interpolation-template',
106
+ message: 'Workflow name must be literal. Move runtime interpolation to run_name.',
107
+ path: ['name'],
108
+ details: expect.objectContaining({reason: 'unterminated opener'}),
109
+ }),
110
+ ]);
111
+ });
112
+
113
+ it('rejects unterminated job-name templates with the migration guidance', () => {
114
+ const error = expectInvalid({
115
+ name: 'Deploy',
116
+ jobs: {
117
+ build: {name: '$' + '{{ unterminated', steps: [{run: 'build'}]},
118
+ },
119
+ });
120
+
121
+ expect(error.issues).toEqual([
122
+ expect.objectContaining({
123
+ code: 'invalid-interpolation-template',
124
+ message: 'Job name must be literal. Move runtime interpolation to execution_name.',
125
+ path: ['jobs', 'build', 'name'],
126
+ details: expect.objectContaining({reason: 'unterminated opener'}),
127
+ }),
128
+ ]);
129
+ });
130
+
131
+ it('normalizes workflow run and job execution name templates', () => {
132
+ const model = normalizeWorkflowDocument({
133
+ name: 'Deploy application',
134
+ run_name: `Deploy ${interpolation('inputs.environment')}`,
135
+ jobs: {
136
+ deploy: {
137
+ name: 'Deploy',
138
+ execution_name: `Deploy ${interpolation('event.environment')}`,
139
+ steps: [{run: 'deploy'}],
140
+ },
141
+ },
142
+ });
143
+
144
+ expect(model.runName).toMatchObject([
145
+ {kind: 'literal', value: 'Deploy '},
146
+ {kind: 'deferred', roots: ['inputs'], fillTarget: 'run-creation'},
147
+ ]);
148
+ expect(model.jobs[0]?.executionName).toMatchObject([
149
+ {kind: 'literal', value: 'Deploy '},
150
+ {kind: 'deferred', roots: ['event'], fillTarget: 'execution-creation'},
151
+ ]);
152
+ });
153
+
154
+ it('preserves literal workflow run and job execution names', () => {
155
+ const model = normalizeWorkflowDocument({
156
+ name: 'Workflow',
157
+ run_name: 'Static run name',
158
+ jobs: {
159
+ build: {
160
+ execution_name: 'Static execution name',
161
+ steps: [{run: 'build'}],
162
+ },
163
+ },
164
+ });
165
+
166
+ expect(model.runName).toEqual([{kind: 'literal', value: 'Static run name'}]);
167
+ expect(model.jobs[0]?.executionName).toEqual([
168
+ {kind: 'literal', value: 'Static execution name'},
169
+ ]);
170
+ });
171
+
172
+ it('unescapes literal workflow and job names', () => {
173
+ const escapedName = `$${interpolation('inputs.environment')}`;
174
+ const model = normalizeWorkflowDocument({
175
+ name: escapedName,
176
+ jobs: {
177
+ build: {name: escapedName, steps: [{run: 'build'}]},
178
+ },
179
+ });
180
+
181
+ expect(model.name).toBe(interpolation('inputs.environment'));
182
+ expect(model.jobs[0]?.name).toBe(interpolation('inputs.environment'));
183
+ });
184
+
185
+ it.each([
186
+ [
187
+ 'run_name',
188
+ {
189
+ name: 'Workflow',
190
+ run_name: interpolation('steps.build.outputs.name'),
191
+ jobs: {build: {steps: [{run: 'build'}]}},
192
+ },
193
+ ],
194
+ [
195
+ 'execution_name',
196
+ {
197
+ name: 'Workflow',
198
+ jobs: {
199
+ build: {
200
+ execution_name: interpolation('steps.build.outputs.name'),
201
+ steps: [{run: 'build'}],
202
+ },
203
+ },
204
+ },
205
+ ],
206
+ ] as const)('rejects unavailable context in %s', (_field, document) => {
207
+ const error = expectInvalid(document as unknown as WorkflowDocument);
208
+ expect(error.issues).toEqual([
209
+ expect.objectContaining({code: 'context-unavailable-at-fill-site'}),
210
+ ]);
211
+ });
212
+
213
+ it('allows computed access to unrelated dynamic-name context fields', () => {
214
+ const model = normalizeWorkflowDocument({
215
+ name: 'Workflow',
216
+ run_name: interpolation('run["id"]'),
217
+ jobs: {
218
+ build: {
219
+ execution_name: interpolation('execution["status"]'),
220
+ steps: [{run: 'build'}],
221
+ },
222
+ },
223
+ });
224
+
225
+ expect(model.runName).toMatchObject([{kind: 'deferred', roots: ['run']}]);
226
+ expect(model.jobs[0]?.executionName).toMatchObject([{kind: 'deferred', roots: ['execution']}]);
227
+ });
228
+
229
+ it.each([
230
+ [
231
+ 'run_name',
232
+ {
233
+ name: 'Workflow',
234
+ run_name: interpolation('run.name'),
235
+ jobs: {build: {steps: [{run: 'build'}]}},
236
+ },
237
+ ],
238
+ [
239
+ 'run_name bracket access',
240
+ {
241
+ name: 'Workflow',
242
+ run_name: interpolation('run["name"]'),
243
+ jobs: {build: {steps: [{run: 'build'}]}},
244
+ },
245
+ ],
246
+ [
247
+ 'execution_name',
248
+ {
249
+ name: 'Workflow',
250
+ jobs: {
251
+ build: {
252
+ execution_name: interpolation('execution.name'),
253
+ steps: [{run: 'build'}],
254
+ },
255
+ },
256
+ },
257
+ ],
258
+ [
259
+ 'execution_name bracket access',
260
+ {
261
+ name: 'Workflow',
262
+ jobs: {
263
+ build: {
264
+ execution_name: interpolation('execution["name"]'),
265
+ steps: [{run: 'build'}],
266
+ },
267
+ },
268
+ },
269
+ ],
270
+ ] as const)('rejects dynamic-name self-reference in %s', (_field, document) => {
271
+ const error = expectInvalid(document as unknown as WorkflowDocument);
272
+ expect(error.issues).toEqual([expect.objectContaining({code: 'dynamic-name-self-reference'})]);
273
+ });
274
+
82
275
  it('normalizes a workflow document into a WorkflowModel', () => {
83
276
  const document: WorkflowDocument = {
84
277
  name: 'simple build',
@@ -154,7 +347,7 @@ describe('normalizeWorkflowDocument', () => {
154
347
  {
155
348
  key: 'review',
156
349
  harness: 'claude',
157
- model: 'gpt-5.5-pro',
350
+ model: 'claude-opus-4-8',
158
351
  provider: 'anthropic',
159
352
  prompt: 'Review the fix.',
160
353
  thinking: 'low',
@@ -538,6 +731,7 @@ describe('normalizeWorkflowDocument', () => {
538
731
  {
539
732
  harness: 'pi',
540
733
  provider: 'workspace-openai-compatible',
734
+ model: 'workspace-model',
541
735
  prompt: 'Fix it.',
542
736
  },
543
737
  ],
@@ -551,6 +745,80 @@ describe('normalizeWorkflowDocument', () => {
551
745
  kind: 'agent',
552
746
  harness: 'pi',
553
747
  provider: 'workspace-openai-compatible',
748
+ model: 'workspace-model',
749
+ });
750
+ });
751
+
752
+ it('reports unsupported literal models', () => {
753
+ const document: WorkflowDocument = {
754
+ name: 'agent build',
755
+ jobs: {
756
+ fix: {
757
+ steps: [{harness: 'pi', provider: 'anthropic', model: 'not-a-model', prompt: 'Fix it.'}],
758
+ },
759
+ },
760
+ };
761
+
762
+ const error = expectInvalid(document);
763
+
764
+ expect(error.issues).toEqual([
765
+ {
766
+ code: 'invalid-model',
767
+ message:
768
+ 'Agent model "not-a-model" is not available for harness "pi" and provider "anthropic".',
769
+ path: ['jobs', 'fix', 'steps', 0, 'model'],
770
+ details: {harness: 'pi', provider: 'anthropic', model: 'not-a-model'},
771
+ },
772
+ ]);
773
+ });
774
+
775
+ it('allows custom providers without an explicit harness', () => {
776
+ const document: WorkflowDocument = {
777
+ name: 'agent build',
778
+ jobs: {
779
+ fix: {
780
+ steps: [
781
+ {
782
+ provider: 'workspace-openai-compatible',
783
+ model: 'workspace-model',
784
+ prompt: 'Fix it.',
785
+ },
786
+ ],
787
+ },
788
+ },
789
+ };
790
+
791
+ const model = normalizeWorkflowDocument(document);
792
+
793
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
794
+ kind: 'agent',
795
+ provider: 'workspace-openai-compatible',
796
+ model: 'workspace-model',
797
+ });
798
+ });
799
+
800
+ it('defers harness-specific validation when harness is omitted', () => {
801
+ const document: WorkflowDocument = {
802
+ name: 'workspace-default harness',
803
+ jobs: {
804
+ fix: {
805
+ steps: [
806
+ {
807
+ provider: 'anthropic',
808
+ model: 'claude-sonnet-4-6',
809
+ prompt: 'Fix it.',
810
+ },
811
+ ],
812
+ },
813
+ },
814
+ };
815
+
816
+ const model = normalizeWorkflowDocument(document);
817
+
818
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
819
+ kind: 'agent',
820
+ provider: 'anthropic',
821
+ model: 'claude-sonnet-4-6',
554
822
  });
555
823
  });
556
824
 
@@ -743,6 +1011,29 @@ describe('normalizeWorkflowDocument', () => {
743
1011
  });
744
1012
  });
745
1013
 
1014
+ it('defers an interpolated thinking level to dispatch', () => {
1015
+ const document: WorkflowDocument = {
1016
+ name: 'agent build',
1017
+ jobs: {
1018
+ fix: {
1019
+ steps: [
1020
+ {
1021
+ harness: 'claude',
1022
+ prompt: 'Fix it.',
1023
+ thinking: workflowInterpolation('vars.THINKING'),
1024
+ },
1025
+ ],
1026
+ },
1027
+ },
1028
+ };
1029
+
1030
+ const model = normalizeWorkflowDocument(document);
1031
+ const step = model.jobs[0]?.steps[0];
1032
+
1033
+ if (step?.kind !== 'agent') throw new Error('Expected an agent step');
1034
+ expect(step.templates?.thinking).toMatchObject([{kind: 'deferred', roots: ['vars']}]);
1035
+ });
1036
+
746
1037
  it('normalizes job success expressions and execution timeouts', () => {
747
1038
  const document: WorkflowDocument = {
748
1039
  name: 'job controls',
@@ -831,6 +1122,144 @@ describe('normalizeWorkflowDocument', () => {
831
1122
  expect(model.jobs[0]?.checkout).toEqual(DEFAULT_JOB_CHECKOUT);
832
1123
  });
833
1124
 
1125
+ it('preserves the job checkout opt-out in the model', () => {
1126
+ const model = normalizeWorkflowDocument({
1127
+ name: 'checkout disabled',
1128
+ jobs: {
1129
+ build: {
1130
+ checkout: false,
1131
+ steps: [{run: 'npm test'}],
1132
+ },
1133
+ },
1134
+ });
1135
+
1136
+ expect(model.jobs[0]?.checkout).toBe(false);
1137
+ });
1138
+
1139
+ it('normalizes checkout-step targets from earlier step outputs', () => {
1140
+ const model = normalizeWorkflowDocument({
1141
+ name: 'templated checkout step',
1142
+ jobs: {
1143
+ build: {
1144
+ steps: [
1145
+ {
1146
+ key: 'route',
1147
+ run: 'echo route',
1148
+ outputs: {
1149
+ connection: {type: 'string'},
1150
+ repository: {type: 'string'},
1151
+ ref: {type: 'string'},
1152
+ path: {type: 'string'},
1153
+ },
1154
+ },
1155
+ {
1156
+ key: 'checkout',
1157
+ checkout: {
1158
+ connection: interpolation('inputs.connection'),
1159
+ repository: interpolation('steps.route.outputs.repository'),
1160
+ ref: interpolation('steps.route.outputs.ref'),
1161
+ path: interpolation('steps.route.outputs.path'),
1162
+ },
1163
+ },
1164
+ ],
1165
+ },
1166
+ },
1167
+ });
1168
+
1169
+ expect(model.jobs[0]?.steps[1]).toMatchObject({
1170
+ kind: 'checkout',
1171
+ checkout: {
1172
+ connection: interpolation('inputs.connection'),
1173
+ repository: interpolation('steps.route.outputs.repository'),
1174
+ ref: interpolation('steps.route.outputs.ref'),
1175
+ path: interpolation('steps.route.outputs.path'),
1176
+ fetchDepth: 1,
1177
+ permissions: {contents: 'read'},
1178
+ persistCredentials: true,
1179
+ templates: {
1180
+ connection: [{kind: 'deferred', roots: ['inputs'], fillTarget: 'run-creation'}],
1181
+ repository: [{kind: 'deferred', roots: ['steps'], fillTarget: 'step-dispatch'}],
1182
+ ref: [{kind: 'deferred', roots: ['steps'], fillTarget: 'step-dispatch'}],
1183
+ path: [{kind: 'deferred', roots: ['steps'], fillTarget: 'step-dispatch'}],
1184
+ },
1185
+ },
1186
+ });
1187
+ });
1188
+
1189
+ it('reports invalid checkout target combinations from model normalization', () => {
1190
+ const error = expectInvalid({
1191
+ name: 'invalid checkout target',
1192
+ jobs: {
1193
+ build: {
1194
+ steps: [{checkout: {project: 'project-id', repository: 'acme/api'}}],
1195
+ },
1196
+ },
1197
+ });
1198
+
1199
+ expect(error.issues).toContainEqual(
1200
+ expect.objectContaining({
1201
+ code: 'checkout-target-invalid',
1202
+ path: ['jobs', 'build', 'steps', 0, 'checkout', 'repository'],
1203
+ }),
1204
+ );
1205
+
1206
+ const missingRepository = expectInvalid({
1207
+ name: 'missing checkout repository',
1208
+ jobs: {
1209
+ build: {
1210
+ steps: [{checkout: {connection: 'github'}}],
1211
+ },
1212
+ },
1213
+ });
1214
+
1215
+ expect(missingRepository.issues).toContainEqual(
1216
+ expect.objectContaining({
1217
+ code: 'checkout-target-invalid',
1218
+ path: ['jobs', 'build', 'steps', 0, 'checkout', 'repository'],
1219
+ details: {fields: ['connection', 'repository']},
1220
+ }),
1221
+ );
1222
+ });
1223
+
1224
+ it('normalizes checkout steps and their static defaults', () => {
1225
+ const model = normalizeWorkflowDocument({
1226
+ name: 'checkout step',
1227
+ jobs: {
1228
+ build: {
1229
+ steps: [
1230
+ {
1231
+ key: 'fetch-target',
1232
+ checkout: {
1233
+ repository: 'acme/api',
1234
+ ref: 'refs/heads/main',
1235
+ 'fetch-depth': 0,
1236
+ path: 'target',
1237
+ permissions: {contents: 'write'},
1238
+ 'persist-credentials': false,
1239
+ force: true,
1240
+ },
1241
+ },
1242
+ ],
1243
+ },
1244
+ },
1245
+ });
1246
+
1247
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
1248
+ id: expect.stringContaining('fetch-target'),
1249
+ key: 'fetch-target',
1250
+ kind: 'checkout',
1251
+ checkout: {
1252
+ repository: 'acme/api',
1253
+ ref: 'refs/heads/main',
1254
+ fetchDepth: 0,
1255
+ path: 'target',
1256
+ permissions: {contents: 'write'},
1257
+ persistCredentials: false,
1258
+ force: true,
1259
+ },
1260
+ });
1261
+ });
1262
+
834
1263
  it('normalizes checkout contents write and defaults persisted credentials', () => {
835
1264
  const document: WorkflowDocument = {
836
1265
  name: 'write checkout',
@@ -880,7 +1309,7 @@ describe('normalizeWorkflowDocument', () => {
880
1309
  name: 'listen for reviews',
881
1310
  jobs: {
882
1311
  review: {
883
- name: displayName,
1312
+ execution_name: displayName,
884
1313
  listening: {
885
1314
  on: [
886
1315
  {
@@ -915,7 +1344,7 @@ describe('normalizeWorkflowDocument', () => {
915
1344
  onResolve: 'cancel',
916
1345
  },
917
1346
  });
918
- expect(model.jobs[0]?.name?.[1]).toMatchObject({
1347
+ expect(model.jobs[0]?.executionName?.[1]).toMatchObject({
919
1348
  kind: 'deferred',
920
1349
  expression: {source: 'execution.index', check: 'typed'},
921
1350
  });
@@ -968,7 +1397,7 @@ describe('normalizeWorkflowDocument', () => {
968
1397
  ]);
969
1398
  });
970
1399
 
971
- it('validates listener filters against job-activation roots', () => {
1400
+ it('validates listener filters against their persisted snapshot roots', () => {
972
1401
  const document: WorkflowDocument = {
973
1402
  name: 'listener filter roots',
974
1403
  jobs: {
@@ -983,7 +1412,7 @@ describe('normalizeWorkflowDocument', () => {
983
1412
  source: 'github',
984
1413
  event: 'pull_request_review',
985
1414
  filter:
986
- 'run.id != "" && inputs.target == event.issue.number && job.key == "review" && executions.all(execution, execution.status != "") && matrix.os == "linux" && jobs.build.outputs.pr_number == event.issue.number',
1415
+ 'run.id != "" && trigger.event == "pull_request_review" && inputs.target == event.issue.number && vars.ENABLED == "true" && job.key == "review" && jobs.build.outputs.pr_number == event.issue.number',
987
1416
  },
988
1417
  ],
989
1418
  until: [
@@ -1002,7 +1431,7 @@ describe('normalizeWorkflowDocument', () => {
1002
1431
  const model = normalizeWorkflowDocument(document);
1003
1432
 
1004
1433
  expect(model.jobs[1]?.listening?.on[0]?.filter).toBe(
1005
- 'run.id != "" && inputs.target == event.issue.number && job.key == "review" && executions.all(execution, execution.status != "") && matrix.os == "linux" && jobs.build.outputs.pr_number == event.issue.number',
1434
+ 'run.id != "" && trigger.event == "pull_request_review" && inputs.target == event.issue.number && vars.ENABLED == "true" && job.key == "review" && jobs.build.outputs.pr_number == event.issue.number',
1006
1435
  );
1007
1436
  expect(model.jobs[1]?.listening?.until?.[0]?.filter).toBe('event.action == "closed"');
1008
1437
  });
@@ -1010,6 +1439,8 @@ describe('normalizeWorkflowDocument', () => {
1010
1439
  it.each([
1011
1440
  ['step root', 'step.status == "succeeded"', 'context-unavailable-at-predicate-site'],
1012
1441
  ['steps root', 'steps.build.outputs.sha == "abc"', 'context-unavailable-at-predicate-site'],
1442
+ ['executions root', 'executions.size() > 0', 'context-unavailable-at-predicate-site'],
1443
+ ['matrix root', 'matrix.os == "linux"', 'context-unavailable-at-predicate-site'],
1013
1444
  ['runner root', 'runner.os == "linux"', 'runner-context-in-server-predicate'],
1014
1445
  ['non-boolean source', 'event.action', 'invalid-listener-filter'],
1015
1446
  ] as const)('reports invalid listener on filters for %s', (_label, filter, code) => {
@@ -1114,12 +1545,12 @@ describe('normalizeWorkflowDocument', () => {
1114
1545
  ]);
1115
1546
  });
1116
1547
 
1117
- it('preserves explicit model ids even when the seed catalog only knows provider defaults', () => {
1548
+ it('accepts explicit model ids present in the harness catalog', () => {
1118
1549
  const document: WorkflowDocument = {
1119
1550
  name: 'agent build',
1120
1551
  jobs: {
1121
1552
  fix: {
1122
- steps: [{provider: 'openai', model: 'gpt-4.1', prompt: 'Fix it.'}],
1553
+ steps: [{harness: 'pi', provider: 'openai', model: 'gpt-4.1', prompt: 'Fix it.'}],
1123
1554
  },
1124
1555
  },
1125
1556
  };
@@ -1602,7 +2033,7 @@ describe('normalizeWorkflowDocument', () => {
1602
2033
  });
1603
2034
  });
1604
2035
 
1605
- it('accepts server roots available at step reporting in gate success', () => {
2036
+ it('rejects server roots omitted from the gate success context', () => {
1606
2037
  const document: WorkflowDocument = {
1607
2038
  name: 'server-context gate',
1608
2039
  jobs: {
@@ -1610,14 +2041,21 @@ describe('normalizeWorkflowDocument', () => {
1610
2041
  },
1611
2042
  };
1612
2043
 
1613
- const model = normalizeWorkflowDocument(document);
2044
+ const error = expectInvalid(document);
1614
2045
 
1615
- expect(model.jobs[0]?.steps[0]?.gate?.success).toEqual({
1616
- language: 'cel',
1617
- source: 'run.id != ""',
1618
- check: 'typed',
1619
- resultType: 'bool',
1620
- });
2046
+ expect(error.issues).toEqual([
2047
+ expect.objectContaining({
2048
+ code: 'context-unavailable-at-predicate-site',
2049
+ message: expect.stringContaining('Step gate success references context "run"'),
2050
+ path: ['jobs', 'build', 'steps', 0, 'gate', 'success'],
2051
+ details: expect.objectContaining({
2052
+ field: 'step.success',
2053
+ source: 'run.id != ""',
2054
+ unavailableRoots: ['run'],
2055
+ site: 'step-report',
2056
+ }),
2057
+ }),
2058
+ ]);
1621
2059
  });
1622
2060
 
1623
2061
  it('rejects runner-host roots in gate success with a server-predicate issue', () => {
@@ -1647,7 +2085,7 @@ describe('normalizeWorkflowDocument', () => {
1647
2085
  ]);
1648
2086
  });
1649
2087
 
1650
- it('rejects vars in gate success with a server-predicate issue', () => {
2088
+ it('accepts vars in gate success', () => {
1651
2089
  const document: WorkflowDocument = {
1652
2090
  name: 'vars-context gate',
1653
2091
  jobs: {
@@ -1657,24 +2095,15 @@ describe('normalizeWorkflowDocument', () => {
1657
2095
  },
1658
2096
  };
1659
2097
 
1660
- const error = expectInvalid(document);
2098
+ const model = normalizeWorkflowDocument(document);
1661
2099
 
1662
- expect(error.issues).toEqual([
1663
- expect.objectContaining({
1664
- code: 'vars-context-in-server-predicate',
1665
- message: expect.stringContaining('cannot reference vars'),
1666
- path: ['jobs', 'build', 'steps', 0, 'gate', 'success'],
1667
- details: expect.objectContaining({
1668
- field: 'step.success',
1669
- source: 'vars.REQUIRED == "true"',
1670
- rejectedRoots: ['vars'],
1671
- site: 'step-report',
1672
- }),
1673
- }),
1674
- ]);
2100
+ expect(model.jobs[0]?.steps[0]?.gate?.success).toMatchObject({
2101
+ source: 'vars.REQUIRED == "true"',
2102
+ check: 'syntax',
2103
+ });
1675
2104
  });
1676
2105
 
1677
- it('accepts jobs root references at the gate predicate site', () => {
2106
+ it('rejects jobs root references omitted from the gate success context', () => {
1678
2107
  const document: WorkflowDocument = {
1679
2108
  name: 'jobs-context gate',
1680
2109
  jobs: {
@@ -1688,13 +2117,21 @@ describe('normalizeWorkflowDocument', () => {
1688
2117
  },
1689
2118
  };
1690
2119
 
1691
- const model = normalizeWorkflowDocument(document);
2120
+ const error = expectInvalid(document);
1692
2121
 
1693
- expect(model.jobs[1]?.steps[0]?.gate?.success).toEqual({
1694
- language: 'cel',
1695
- source: 'jobs.build.status == "succeeded"',
1696
- check: 'syntax',
1697
- });
2122
+ expect(error.issues).toEqual([
2123
+ expect.objectContaining({
2124
+ code: 'context-unavailable-at-predicate-site',
2125
+ message: expect.stringContaining('Step gate success references context "jobs"'),
2126
+ path: ['jobs', 'deploy', 'steps', 0, 'gate', 'success'],
2127
+ details: expect.objectContaining({
2128
+ field: 'step.success',
2129
+ source: 'jobs.build.status == "succeeded"',
2130
+ unavailableRoots: ['jobs'],
2131
+ site: 'step-report',
2132
+ }),
2133
+ }),
2134
+ ]);
1698
2135
  });
1699
2136
 
1700
2137
  it('parses job output mappings at execution resolution', () => {
@@ -1954,7 +2391,34 @@ describe('normalizeWorkflowDocument', () => {
1954
2391
  });
1955
2392
  });
1956
2393
 
1957
- it('rejects untrusted step outputs in trusted-only run fields', () => {
2394
+ it('allows integer equality literals for number step outputs in gate success expressions', () => {
2395
+ const document: WorkflowDocument = {
2396
+ name: 'number output equality',
2397
+ jobs: {
2398
+ build: {
2399
+ steps: [
2400
+ {
2401
+ key: 'collect',
2402
+ run: 'npm run collect',
2403
+ outputs: {issue_number: {type: 'number'}},
2404
+ gate: {success: 'step.outputs.issue_number == 1'},
2405
+ },
2406
+ ],
2407
+ },
2408
+ },
2409
+ };
2410
+
2411
+ const model = normalizeWorkflowDocument(document);
2412
+
2413
+ expect(model.jobs[0]?.steps[0]?.gate?.success).toEqual({
2414
+ language: 'cel',
2415
+ source: 'step.outputs.issue_number == 1',
2416
+ check: 'typed',
2417
+ resultType: 'bool',
2418
+ });
2419
+ });
2420
+
2421
+ it('allows step outputs in run fields', () => {
1958
2422
  const document: WorkflowDocument = {
1959
2423
  name: 'untrusted step output',
1960
2424
  jobs: {
@@ -1967,15 +2431,17 @@ describe('normalizeWorkflowDocument', () => {
1967
2431
  },
1968
2432
  };
1969
2433
 
1970
- const error = expectInvalid(document);
2434
+ const model = normalizeWorkflowDocument(document);
1971
2435
 
1972
- expect(error.issues).toEqual([
1973
- expect.objectContaining({
1974
- code: 'untrusted-context-in-field',
1975
- path: ['jobs', 'build', 'steps', 1, 'run'],
1976
- details: expect.objectContaining({field: 'run', rejectedRoots: ['steps']}),
1977
- }),
1978
- ]);
2436
+ expect(model.jobs[0]?.steps[1]).toMatchObject({
2437
+ kind: 'run',
2438
+ templates: {
2439
+ command: [
2440
+ {kind: 'literal', value: 'echo '},
2441
+ {kind: 'deferred', roots: ['steps']},
2442
+ ],
2443
+ },
2444
+ });
1979
2445
  });
1980
2446
 
1981
2447
  it('infers typed job output scalars for downstream job overlays', () => {
@@ -2291,29 +2757,29 @@ describe('normalizeWorkflowDocument', () => {
2291
2757
  );
2292
2758
  });
2293
2759
 
2294
- it('rejects vars roots in if predicates', () => {
2760
+ it('accepts vars in job and step if predicates', () => {
2295
2761
  const document: WorkflowDocument = {
2296
2762
  name: 'vars condition',
2297
2763
  jobs: {
2298
2764
  build: {
2299
2765
  if: interpolation('vars.REQUIRED == "true"'),
2300
- steps: [{run: 'npm run build'}],
2766
+ steps: [{if: interpolation('vars.REQUIRED == "true"'), run: 'npm run build'}],
2301
2767
  },
2302
2768
  },
2303
2769
  };
2304
2770
 
2305
- const error = expectInvalid(document);
2771
+ const model = normalizeWorkflowDocument(document);
2306
2772
 
2307
- expect(error.issues).toEqual([
2308
- expect.objectContaining({
2309
- code: 'vars-context-in-server-predicate',
2310
- path: ['jobs', 'build', 'if'],
2311
- details: expect.objectContaining({
2312
- field: 'job.if',
2313
- rejectedRoots: ['vars'],
2314
- }),
2315
- }),
2316
- ]);
2773
+ expect(model.jobs[0]?.if).toMatchObject({
2774
+ source: 'vars.REQUIRED == "true"',
2775
+ check: 'typed',
2776
+ resultType: 'bool',
2777
+ });
2778
+ expect(model.jobs[0]?.steps[0]?.if).toMatchObject({
2779
+ source: 'vars.REQUIRED == "true"',
2780
+ check: 'typed',
2781
+ resultType: 'bool',
2782
+ });
2317
2783
  });
2318
2784
 
2319
2785
  it('rejects if job references without a direct needs edge', () => {
@@ -2415,7 +2881,7 @@ describe('normalizeWorkflowDocument', () => {
2415
2881
  path: ['jobs', 'build', 'if'],
2416
2882
  details: expect.objectContaining({
2417
2883
  field: 'job.if',
2418
- unavailableRoots: ['execution.failed'],
2884
+ unavailableRoots: ['execution'],
2419
2885
  }),
2420
2886
  }),
2421
2887
  ]);
@@ -2546,7 +3012,7 @@ describe('normalizeWorkflowDocument', () => {
2546
3012
  ]);
2547
3013
  });
2548
3014
 
2549
- it('rejects vars in job success with a server-predicate issue', () => {
3015
+ it('accepts vars in job success', () => {
2550
3016
  const document: WorkflowDocument = {
2551
3017
  name: 'vars-context job success',
2552
3018
  jobs: {
@@ -2557,21 +3023,9 @@ describe('normalizeWorkflowDocument', () => {
2557
3023
  },
2558
3024
  };
2559
3025
 
2560
- const error = expectInvalid(document);
3026
+ const model = normalizeWorkflowDocument(document);
2561
3027
 
2562
- expect(error.issues).toEqual([
2563
- expect.objectContaining({
2564
- code: 'vars-context-in-server-predicate',
2565
- message: expect.stringContaining('cannot reference vars'),
2566
- path: ['jobs', 'build', 'success'],
2567
- details: expect.objectContaining({
2568
- field: 'job.success',
2569
- source: 'vars.ENVIRONMENT == "prod"',
2570
- rejectedRoots: ['vars'],
2571
- site: 'job-resolution',
2572
- }),
2573
- }),
2574
- ]);
3028
+ expect(model.jobs[0]?.success).toBe('vars.ENVIRONMENT == "prod"');
2575
3029
  });
2576
3030
 
2577
3031
  it('reports malformed job execution timeouts', () => {
@@ -3084,12 +3538,12 @@ describe('normalizeWorkflowDocument', () => {
3084
3538
  [
3085
3539
  'vars root',
3086
3540
  'vars.environment == "prod"',
3087
- 'vars-context-in-server-predicate',
3541
+ 'context-unavailable-at-predicate-site',
3088
3542
  {
3089
3543
  field: 'trigger.filter',
3090
3544
  source: 'vars.environment == "prod"',
3091
3545
  contextRoots: ['vars'],
3092
- rejectedRoots: ['vars'],
3546
+ unavailableRoots: ['vars'],
3093
3547
  site: 'ingest',
3094
3548
  },
3095
3549
  ],
@@ -3524,11 +3978,13 @@ describe('normalizeWorkflowDocument', () => {
3524
3978
  steps: [
3525
3979
  {
3526
3980
  name: `deploy ${interpolation('event.action')}`,
3981
+ working_directory: 'packages/api',
3527
3982
  run: `deploy ${interpolation('run.id')}`,
3528
3983
  env: {PR_TITLE: interpolation('event.pull_request.title'), DEBUG: false},
3529
3984
  },
3530
3985
  {
3531
3986
  name: `review ${interpolation('inputs.topic')}`,
3987
+ working_directory: `packages/${interpolation('inputs.topic')}`,
3532
3988
  provider: 'openai',
3533
3989
  prompt: `Review ${interpolation('event.pull_request.title')}`,
3534
3990
  },
@@ -3575,6 +4031,7 @@ describe('normalizeWorkflowDocument', () => {
3575
4031
  expect(model.jobs[0]?.steps[0]).toMatchObject({
3576
4032
  kind: 'run',
3577
4033
  command: {value: `deploy ${interpolation('run.id')}`},
4034
+ workingDirectory: 'packages/api',
3578
4035
  templates: {
3579
4036
  command: [
3580
4037
  {kind: 'literal', value: 'deploy '},
@@ -3609,7 +4066,16 @@ describe('normalizeWorkflowDocument', () => {
3609
4066
  });
3610
4067
  expect(model.jobs[0]?.steps[1]).toMatchObject({
3611
4068
  kind: 'agent',
4069
+ workingDirectory: `packages/${interpolation('inputs.topic')}`,
3612
4070
  templates: {
4071
+ workingDirectory: [
4072
+ {kind: 'literal', value: 'packages/'},
4073
+ {
4074
+ kind: 'deferred',
4075
+ expression: {source: 'inputs.topic'},
4076
+ roots: ['inputs'],
4077
+ },
4078
+ ],
3613
4079
  prompt: [
3614
4080
  {kind: 'literal', value: 'Review '},
3615
4081
  {
@@ -3651,7 +4117,7 @@ describe('normalizeWorkflowDocument', () => {
3651
4117
  expect(model).not.toHaveProperty('templates');
3652
4118
  expect(model.jobs[0]?.steps[0]).not.toHaveProperty('templates');
3653
4119
  expect(model.env).toEqual({VALUE: '$${{ event.ref }}'});
3654
- expect(model.jobs[0]?.name).toEqual([{kind: 'literal', value: 'Build app'}]);
4120
+ expect(model.jobs[0]?.name).toBe('Build app');
3655
4121
  expect(model.jobs[0]?.steps[0]).toMatchObject({
3656
4122
  kind: 'run',
3657
4123
  command: {value: 'echo $${{ event.ref }}'},
@@ -3710,30 +4176,32 @@ describe('normalizeWorkflowDocument', () => {
3710
4176
 
3711
4177
  it.each([
3712
4178
  ['event payload', 'event.pull_request.title', ['event']],
3713
- ['job outputs', 'jobs.build.outputs.sha', ['jobs']],
3714
- ] as const)('rejects untrusted %s context in run commands with the env fix-it message', (_label, source, rejectedRoots) => {
4179
+ ['job status', 'jobs.build.status', ['jobs']],
4180
+ ] as const)('allows %s context in run commands', (_label, source, roots) => {
3715
4181
  const document: WorkflowDocument = {
3716
4182
  name: 'unsafe run',
3717
4183
  jobs: {
3718
4184
  build: {
4185
+ steps: [{run: 'echo build'}],
4186
+ },
4187
+ deploy: {
4188
+ needs: 'build',
3719
4189
  steps: [{run: `echo ${interpolation(source)}`}],
3720
4190
  },
3721
4191
  },
3722
4192
  };
3723
4193
 
3724
- const error = expectInvalid(document);
4194
+ const model = normalizeWorkflowDocument(document);
3725
4195
 
3726
- expect(error.issues).toEqual([
3727
- expect.objectContaining({
3728
- code: 'untrusted-context-in-field',
3729
- path: ['jobs', 'build', 'steps', 0, 'run'],
3730
- message: expect.stringContaining('Bind untrusted values to env'),
3731
- details: expect.objectContaining({
3732
- field: 'run',
3733
- rejectedRoots,
3734
- }),
3735
- }),
3736
- ]);
4196
+ expect(model.jobs[1]?.steps[0]).toMatchObject({
4197
+ kind: 'run',
4198
+ templates: {
4199
+ command: [
4200
+ {kind: 'literal', value: 'echo '},
4201
+ {kind: 'deferred', roots},
4202
+ ],
4203
+ },
4204
+ });
3737
4205
  });
3738
4206
 
3739
4207
  it('rejects secrets in agent fields', () => {
@@ -3805,8 +4273,7 @@ describe('normalizeWorkflowDocument', () => {
3805
4273
  it.each([
3806
4274
  'execution.events[0].data.body',
3807
4275
  'execution["events"][0].data.body',
3808
- 'execution[x]',
3809
- ])('rejects untrusted execution sub-paths in run commands: %s', (source) => {
4276
+ ])('allows execution event sub-paths in run commands: %s', (source) => {
3810
4277
  const document: WorkflowDocument = {
3811
4278
  name: 'unsafe execution run',
3812
4279
  jobs: {
@@ -3816,42 +4283,17 @@ describe('normalizeWorkflowDocument', () => {
3816
4283
  },
3817
4284
  };
3818
4285
 
3819
- const error = expectInvalid(document);
3820
-
3821
- expect(error.issues).toEqual([
3822
- expect.objectContaining({
3823
- code: 'untrusted-context-in-field',
3824
- path: ['jobs', 'build', 'steps', 0, 'run'],
3825
- details: expect.objectContaining({
3826
- field: 'run',
3827
- rejectedRoots: ['execution'],
3828
- }),
3829
- }),
3830
- ]);
3831
- });
4286
+ const model = normalizeWorkflowDocument(document);
3832
4287
 
3833
- it('rejects execution event access through CEL comprehension bindings in run commands', () => {
3834
- const document: WorkflowDocument = {
3835
- name: 'unsafe execution map run',
3836
- jobs: {
3837
- build: {
3838
- steps: [{run: `echo ${interpolation('executions.map(e, e.events[0].data.body)')}`}],
3839
- },
4288
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
4289
+ kind: 'run',
4290
+ templates: {
4291
+ command: [
4292
+ {kind: 'literal', value: 'echo '},
4293
+ {kind: 'deferred', roots: ['execution']},
4294
+ ],
3840
4295
  },
3841
- };
3842
-
3843
- const error = expectInvalid(document);
3844
-
3845
- expect(error.issues).toEqual([
3846
- expect.objectContaining({
3847
- code: 'untrusted-context-in-field',
3848
- path: ['jobs', 'build', 'steps', 0, 'run'],
3849
- details: expect.objectContaining({
3850
- field: 'run',
3851
- rejectedRoots: ['executions'],
3852
- }),
3853
- }),
3854
- ]);
4296
+ });
3855
4297
  });
3856
4298
 
3857
4299
  it.each([
@@ -3967,7 +4409,7 @@ describe('normalizeWorkflowDocument', () => {
3967
4409
  });
3968
4410
  });
3969
4411
 
3970
- it('does not apply availability checks to job names', () => {
4412
+ it('rejects dynamic job names in favor of execution_name', () => {
3971
4413
  const document: WorkflowDocument = {
3972
4414
  name: 'job display context',
3973
4415
  jobs: {
@@ -3983,16 +4425,19 @@ describe('normalizeWorkflowDocument', () => {
3983
4425
  },
3984
4426
  };
3985
4427
 
3986
- const model = normalizeWorkflowDocument(document);
3987
-
3988
- expect(model.jobs[0]?.name?.[0]).toMatchObject({
3989
- kind: 'deferred',
3990
- roots: ['execution'],
3991
- });
3992
- expect(model.jobs[1]?.name?.[0]).toMatchObject({
3993
- kind: 'deferred',
3994
- roots: ['execution'],
3995
- });
4428
+ const error = expectInvalid(document);
4429
+ expect(error.issues).toEqual([
4430
+ expect.objectContaining({
4431
+ code: 'invalid-interpolation-template',
4432
+ message: 'Job name must be literal. Move runtime interpolation to execution_name.',
4433
+ path: ['jobs', 'build', 'name'],
4434
+ }),
4435
+ expect.objectContaining({
4436
+ code: 'invalid-interpolation-template',
4437
+ message: 'Job name must be literal. Move runtime interpolation to execution_name.',
4438
+ path: ['jobs', 'review', 'name'],
4439
+ }),
4440
+ ]);
3996
4441
  });
3997
4442
 
3998
4443
  it('does not apply availability checks to workflow-level or job-level env', () => {
@@ -4122,7 +4567,7 @@ describe('normalizeWorkflowDocument', () => {
4122
4567
  });
4123
4568
  });
4124
4569
 
4125
- it('allows execution events in untrusted-capable fields', () => {
4570
+ it('rejects execution events in static job names', () => {
4126
4571
  const document: WorkflowDocument = {
4127
4572
  name: 'execution events allowed',
4128
4573
  jobs: {
@@ -4134,25 +4579,13 @@ describe('normalizeWorkflowDocument', () => {
4134
4579
  },
4135
4580
  };
4136
4581
 
4137
- const model = normalizeWorkflowDocument(document);
4138
-
4139
- expect(model.jobs[0]?.name?.[1]).toMatchObject({
4140
- kind: 'deferred',
4141
- expression: {check: 'typed'},
4142
- roots: ['execution'],
4143
- });
4144
- expect(model.jobs[0]?.steps[0]).toMatchObject({
4145
- kind: 'agent',
4146
- templates: {
4147
- prompt: [
4148
- {
4149
- kind: 'deferred',
4150
- expression: {check: 'typed'},
4151
- roots: ['execution'],
4152
- },
4153
- ],
4154
- },
4155
- });
4582
+ const error = expectInvalid(document);
4583
+ expect(error.issues).toEqual([
4584
+ expect.objectContaining({
4585
+ code: 'invalid-interpolation-template',
4586
+ path: ['jobs', 'build', 'name'],
4587
+ }),
4588
+ ]);
4156
4589
  });
4157
4590
 
4158
4591
  it('allows untrusted context in env, prompt, and step names', () => {
@@ -4189,9 +4622,9 @@ describe('normalizeWorkflowDocument', () => {
4189
4622
  it.each([
4190
4623
  ['model', {model: interpolation('event.model'), prompt: 'Fix it.'}, 'event'],
4191
4624
  ['provider', {provider: interpolation('inputs.provider'), prompt: 'Fix it.'}, 'inputs'],
4192
- ] as const)('rejects untrusted agent %s interpolation', (_field, step, root) => {
4625
+ ] as const)('allows external context in agent %s interpolation', (field, step, root) => {
4193
4626
  const document: WorkflowDocument = {
4194
- name: 'unsafe agent field',
4627
+ name: 'external agent field',
4195
4628
  jobs: {
4196
4629
  fix: {
4197
4630
  steps: [step],
@@ -4199,14 +4632,77 @@ describe('normalizeWorkflowDocument', () => {
4199
4632
  },
4200
4633
  };
4201
4634
 
4202
- const error = expectInvalid(document);
4635
+ const model = normalizeWorkflowDocument(document);
4203
4636
 
4204
- expect(error.issues).toEqual([
4205
- expect.objectContaining({
4206
- code: 'untrusted-context-in-field',
4207
- details: expect.objectContaining({rejectedRoots: [root]}),
4208
- }),
4209
- ]);
4637
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
4638
+ kind: 'agent',
4639
+ templates: {
4640
+ [field]: [{kind: 'deferred', roots: [root]}],
4641
+ },
4642
+ });
4643
+ });
4644
+
4645
+ it.each([
4646
+ ['model', {model: interpolation('steps.collect.outputs.value'), prompt: 'Fix it.'}, 'steps'],
4647
+ [
4648
+ 'provider',
4649
+ {provider: interpolation('steps.collect.outputs.value'), prompt: 'Fix it.'},
4650
+ 'steps',
4651
+ ],
4652
+ [
4653
+ 'model',
4654
+ {
4655
+ model: interpolation('steps.filter(s, true).map(s, s.outputs.value)[0]'),
4656
+ prompt: 'Fix it.',
4657
+ },
4658
+ 'steps',
4659
+ ],
4660
+ [
4661
+ 'provider',
4662
+ {
4663
+ provider: interpolation('steps.filter(s, true).map(s, s.outputs.value)[0]'),
4664
+ prompt: 'Fix it.',
4665
+ },
4666
+ 'steps',
4667
+ ],
4668
+ [
4669
+ 'model',
4670
+ {model: interpolation('execution.events[0].data.body'), prompt: 'Fix it.'},
4671
+ 'execution',
4672
+ ],
4673
+ [
4674
+ 'provider',
4675
+ {provider: interpolation('execution.events[0].data.body'), prompt: 'Fix it.'},
4676
+ 'execution',
4677
+ ],
4678
+ [
4679
+ 'model',
4680
+ {model: interpolation('execution["events"][0].data.body'), prompt: 'Fix it.'},
4681
+ 'execution',
4682
+ ],
4683
+ [
4684
+ 'provider',
4685
+ {provider: interpolation('execution["events"][0].data.body'), prompt: 'Fix it.'},
4686
+ 'execution',
4687
+ ],
4688
+ ] as const)('allows path-specific external context in agent %s interpolation', (field, step, root) => {
4689
+ const document: WorkflowDocument = {
4690
+ name: 'path-specific external agent field',
4691
+ jobs: {
4692
+ fix: {
4693
+ steps: [{key: 'collect', run: 'collect'}, step],
4694
+ },
4695
+ },
4696
+ };
4697
+
4698
+ const model = normalizeWorkflowDocument(document);
4699
+
4700
+ expect(model.jobs[0]?.steps[1]).toMatchObject({
4701
+ kind: 'agent',
4702
+ templates: {
4703
+ [field]: [{kind: 'deferred', roots: [root]}],
4704
+ },
4705
+ });
4210
4706
  });
4211
4707
 
4212
4708
  it.each([
@@ -4271,6 +4767,77 @@ describe('normalizeWorkflowDocument', () => {
4271
4767
  });
4272
4768
  });
4273
4769
 
4770
+ it('does not add provider catalog issues for invalid provider interpolation', () => {
4771
+ const document: WorkflowDocument = {
4772
+ name: 'invalid provider interpolation',
4773
+ jobs: {
4774
+ fix: {
4775
+ steps: [{harness: 'claude', provider: interpolation('foo.bar'), prompt: 'Fix it.'}],
4776
+ },
4777
+ },
4778
+ };
4779
+
4780
+ const error = expectInvalid(document);
4781
+
4782
+ expect(error.issues).toEqual([
4783
+ expect.objectContaining({
4784
+ code: 'unknown-interpolation-context',
4785
+ path: ['jobs', 'fix', 'steps', 0, 'provider'],
4786
+ }),
4787
+ ]);
4788
+ });
4789
+
4790
+ it('skips static model catalog validation when model is interpolated', () => {
4791
+ const document: WorkflowDocument = {
4792
+ name: 'templated model',
4793
+ jobs: {
4794
+ fix: {
4795
+ steps: [
4796
+ {
4797
+ provider: 'anthropic',
4798
+ model: interpolation('run.name'),
4799
+ prompt: 'Fix it.',
4800
+ },
4801
+ ],
4802
+ },
4803
+ },
4804
+ };
4805
+
4806
+ const model = normalizeWorkflowDocument(document);
4807
+
4808
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
4809
+ kind: 'agent',
4810
+ templates: {model: [{kind: 'deferred', roots: ['run']}]},
4811
+ });
4812
+ });
4813
+
4814
+ it('does not add model catalog issues for invalid model interpolation', () => {
4815
+ const document: WorkflowDocument = {
4816
+ name: 'invalid model interpolation',
4817
+ jobs: {
4818
+ fix: {
4819
+ steps: [
4820
+ {
4821
+ harness: 'pi',
4822
+ provider: 'anthropic',
4823
+ model: interpolation('foo.bar'),
4824
+ prompt: 'Fix it.',
4825
+ },
4826
+ ],
4827
+ },
4828
+ },
4829
+ };
4830
+
4831
+ const error = expectInvalid(document);
4832
+
4833
+ expect(error.issues).toEqual([
4834
+ expect.objectContaining({
4835
+ code: 'unknown-interpolation-context',
4836
+ path: ['jobs', 'fix', 'steps', 0, 'model'],
4837
+ }),
4838
+ ]);
4839
+ });
4840
+
4274
4841
  it('still validates literal providers through the catalog', () => {
4275
4842
  const document: WorkflowDocument = {
4276
4843
  name: 'literal provider',
@@ -4400,7 +4967,7 @@ describe('normalizeWorkflowDocument', () => {
4400
4967
  ]);
4401
4968
  });
4402
4969
 
4403
- it('uses syntax mode for mixed open contexts but still enforces minimum trust', () => {
4970
+ it('uses syntax mode for mixed open contexts without a trust restriction', () => {
4404
4971
  const envDocument: WorkflowDocument = {
4405
4972
  name: 'mixed env',
4406
4973
  env: {MIXED: interpolation('run.nope + event.x')},
@@ -4420,22 +4987,25 @@ describe('normalizeWorkflowDocument', () => {
4420
4987
  };
4421
4988
 
4422
4989
  const model = normalizeWorkflowDocument(envDocument);
4423
- const error = expectInvalid(runDocument);
4990
+ const runModel = normalizeWorkflowDocument(runDocument);
4424
4991
 
4425
4992
  expect(model.templates?.env?.MIXED?.[0]).toMatchObject({
4426
4993
  kind: 'deferred',
4427
4994
  expression: {check: 'syntax'},
4428
4995
  roots: expect.arrayContaining(['run', 'event']),
4429
4996
  });
4430
- expect(error.issues).toEqual([
4431
- expect.objectContaining({
4432
- code: 'untrusted-context-in-field',
4433
- path: ['jobs', 'build', 'steps', 0, 'run'],
4434
- }),
4435
- ]);
4997
+ expect(runModel.jobs[0]?.steps[0]).toMatchObject({
4998
+ kind: 'run',
4999
+ templates: {
5000
+ command: [
5001
+ {kind: 'literal', value: 'echo '},
5002
+ {kind: 'deferred', roots: expect.arrayContaining(['run', 'event'])},
5003
+ ],
5004
+ },
5005
+ });
4436
5006
  });
4437
5007
 
4438
- it('reports one trust issue for a multi-segment run field with one untrusted segment', () => {
5008
+ it('allows multi-segment run fields with external context', () => {
4439
5009
  const document: WorkflowDocument = {
4440
5010
  name: 'multi segment run',
4441
5011
  jobs: {
@@ -4445,14 +5015,18 @@ describe('normalizeWorkflowDocument', () => {
4445
5015
  },
4446
5016
  };
4447
5017
 
4448
- const error = expectInvalid(document);
5018
+ const model = normalizeWorkflowDocument(document);
4449
5019
 
4450
- expect(error.issues).toEqual([
4451
- expect.objectContaining({
4452
- code: 'untrusted-context-in-field',
4453
- details: expect.objectContaining({rejectedRoots: ['event']}),
4454
- }),
4455
- ]);
5020
+ expect(model.jobs[0]?.steps[0]).toMatchObject({
5021
+ kind: 'run',
5022
+ templates: {
5023
+ command: [
5024
+ {kind: 'deferred', roots: ['run']},
5025
+ {kind: 'literal', value: '-'},
5026
+ {kind: 'deferred', roots: ['event']},
5027
+ ],
5028
+ },
5029
+ });
4456
5030
  });
4457
5031
 
4458
5032
  it('does not parse templates in non-string env values', () => {