@onlineapps/conn-orch-validator 12.1.1 → 13.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 (59) hide show
  1. package/CHANGELOG.md +607 -0
  2. package/README.md +126 -19
  3. package/manifests/biz-service.manifest.json +15 -2
  4. package/manifests/library.manifest.json +4 -4
  5. package/package.json +11 -3
  6. package/src/CookbookTestRunner.js +275 -105
  7. package/src/CookbookTestUtils.js +79 -68
  8. package/src/ServiceReadinessValidator.js +42 -52
  9. package/src/ValidationOrchestrator.js +65 -44
  10. package/src/cli/biz-ci-gate.js +2 -2
  11. package/src/cli/oa-sync-template.js +97 -47
  12. package/src/cli/oa-validate.js +44 -10
  13. package/src/helpers/README.md +6 -6
  14. package/src/helpers/createServiceReadinessTests.js +87 -33
  15. package/src/index.js +14 -5
  16. package/src/lint/scripts/lintScripts.js +11 -4
  17. package/src/manifest/checks/libraryContext.js +6 -3
  18. package/src/manifest/checks/libraryDocs.js +174 -4
  19. package/src/manifest/checks/libraryTests.js +200 -19
  20. package/src/manifest/checks/scriptHeaders.js +6 -13
  21. package/src/manifest/checks/serviceConfig.js +36 -16
  22. package/src/manifest/checks/serviceConnectors.js +180 -2
  23. package/src/manifest/checks/serviceDb.js +0 -3
  24. package/src/manifest/checks/serviceScripts.js +3 -20
  25. package/src/manifest/runManifest.js +90 -13
  26. package/src/manifest/workspaceRoot.js +133 -4
  27. package/src/mocks/MockMQClient.js +2 -2
  28. package/src/sync/docsRegion.js +2 -2
  29. package/src/sync/readmeFile.js +30 -0
  30. package/src/sync/readmeLocation.js +2 -12
  31. package/src/sync/readmePointer.js +10 -4
  32. package/src/sync/serviceTemplate.js +9 -11
  33. package/src/sync/sharedEnv.js +59 -3
  34. package/src/sync/uniformFiles.js +81 -8
  35. package/src/utils/bizCiGateContract.js +2 -2
  36. package/src/utils/connectorContract.js +54 -2
  37. package/src/utils/cookbookFormat.js +25 -115
  38. package/src/utils/dbAccountGrants.js +5 -3
  39. package/src/utils/deployContract.js +153 -28
  40. package/src/utils/envContract.js +2 -2
  41. package/src/utils/handlerRef.js +8 -10
  42. package/src/utils/integrationRun.js +1 -1
  43. package/src/utils/operationsDocumentRules.js +242 -0
  44. package/src/utils/operationsRules.js +157 -0
  45. package/src/utils/resolveHeaders.js +12 -1
  46. package/src/utils/setupDatabase.js +1 -1
  47. package/src/utils/stepFailure.js +3 -3
  48. package/src/utils/stepReferences.js +28 -87
  49. package/src/utils/throwawaySchema.js +1 -1
  50. package/src/utils/yamlTopLevel.js +105 -0
  51. package/src/validators/ServiceStructureValidator.js +67 -152
  52. package/templates/business-service/.gitlab-ci.yml +203 -37
  53. package/templates/business-service/README.md +7 -4
  54. package/templates/business-service/config/env-templates/shared.env +1 -0
  55. package/templates/business-service/docs/80-setup/INSTALL.md +31 -3
  56. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +5 -2
  57. package/templates/business-service/src/config/index.js +15 -0
  58. package/TESTING_STRATEGY.md +0 -92
  59. package/jest.config.js +0 -37
@@ -4,7 +4,7 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const crypto = require('crypto');
6
6
  const { resolveHeaders } = require('./utils/resolveHeaders');
7
- const { checkCookbookFormatVersion, readCookbookSteps } = require('./utils/cookbookFormat');
7
+ const { readCookbookSteps } = require('./utils/cookbookFormat');
8
8
  const { getTestNamespace, assertWorkspaceId } = require('./utils/testNamespace');
9
9
  const {
10
10
  describeStepFailure, describeStepIdentity, KIND_STEP, KIND_COOKBOOK_LOAD_FAILURE
@@ -13,6 +13,40 @@ const { parseHandlerRef, resolveHandlerModule } = require('./utils/handlerRef');
13
13
  const { createRunContext, resolveStepInput, recordStepOutcome, checkStepInputExpressions }
14
14
  = require('./utils/stepReferences');
15
15
  const { assertLogger } = require('@onlineapps/logger-contract');
16
+ const { OperationContext } = require('@onlineapps/handler-contract');
17
+ const { validateCookbook: validateCookbookFormat } = require('@onlineapps/cookbook-core');
18
+ const { ErrorClassifier } = require('@onlineapps/error-handler-core');
19
+
20
+ /**
21
+ * EPILOGUE OF A RECIPE — the parked `compensate`, woken by owner decision
22
+ * api/docs/governance/confirmations/meditest-manual-trigger.md 009.
23
+ *
24
+ * A recipe may declare `global_error_handler: { strategy: 'compensate',
25
+ * compensationStep: '<last step>' }`. The runner follows the engine
26
+ * (`@onlineapps/conn-orch-orchestrator` `WorkflowOrchestrator`, d.934): the
27
+ * ordinary run never reaches that step; a run that ends unsuccessfully stops at
28
+ * the failed step and runs the epilogue ONCE, with the failure in scope; a
29
+ * failed epilogue starts no second one. The SHAPE of the declaration is
30
+ * `@onlineapps/cookbook-core`'s (`validateCookbook`, which
31
+ * `CookbookTestRunner.validateCookbook` runs first); the runner reads
32
+ * `compensationStep` and nothing else.
33
+ *
34
+ * Where the failure sits in scope — the root and key the engine writes it to,
35
+ * `{{global_error_handler._failure.<field>}}`.
36
+ */
37
+ const EPILOGUE_FAILURE_ROOT = 'global_error_handler';
38
+ const EPILOGUE_FAILURE_KEY = '_failure';
39
+
40
+ /**
41
+ * The handler's own thrown error behind a step result, kept out of the report.
42
+ *
43
+ * The step record carries `error.code` as `code || name` — a label for the
44
+ * reader of the log. The epilogue's `_failure.error_code` must be what the
45
+ * engine puts there, the thrown `code` or `null`, and `details` likewise; so
46
+ * the raw values are held here, beside the record rather than in it, and the
47
+ * report's shape does not change.
48
+ */
49
+ const THROWN_ERRORS = new WeakMap();
16
50
 
17
51
  /**
18
52
  * The step's stopwatch, spelled out. A single total hid the fact that
@@ -92,19 +126,60 @@ function resolveOutputField(container, fieldPath) {
92
126
  * module via `require()` and calls `handler(input, ctx)` directly in-process
93
127
  * (no HTTP). Operations without a `handler` field are an explicit error.
94
128
  *
95
- * The handler dispatch builds a minimal real ctx:
96
- * { logger, tenant_id, workspace_id, correlation_id, db: null, cache: null,
97
- * httpClient: null, secrets: null, state: null, stream: null,
98
- * abortSignal: null }
99
- * matching the shape ServiceWrapper.ContextBuilder produces in production
100
- * (see api/docs/biz/10-invocation/handler-dispatch.md). Handlers
101
- * that need DB access import sequelize directly from their own
102
- * src/config/database.js module (see biz/60-templates/onboarding-checklist.md §9a);
103
- * connector slots stay null and the handler surfaces its own failure if
104
- * something it genuinely needs is missing.
129
+ * The handler dispatch builds a minimal real ctx: an instance of the same
130
+ * `OperationContext` class production hands a handler, imported from
131
+ * `@onlineapps/handler-contract` (never from the wrapper, which sits a layer
132
+ * above — architecture-principles.md §7; conf handler-contract 001). So the
133
+ * frozen instance and its prototype helpers are the class's, not a mirror of
134
+ * them. Its shape is NOT written out here, meaning which slots the instance
135
+ * is filled with. A second enumeration is a copy, and a copy drifts silently
136
+ * (`.claude/rules/doc-code-binding.md` §2): this one named eleven slots while
137
+ * `_dispatchViaHandler` below built seventeen. Three places hold the slot set,
138
+ * and each of them is checked — the fields passed to the constructor in
139
+ * `_dispatchViaHandler`, the slot set asserted by value in
140
+ * tests/unit/CookbookTestRunner.ctxShape.test.js, and the contract node
141
+ * api/docs/biz/40-cookbooks/test-runner-flow.md.
142
+ *
143
+ * Connector slots stay null and the handler surfaces its own failure if
144
+ * something it genuinely needs is missing; a handler that needs DB access
145
+ * imports sequelize directly from its own src/config/database.js module
146
+ * (see api/docs/biz/60-templates/onboarding-checklist.md §9a).
105
147
  *
106
148
  * Supports unified cookbook format for both testing (with expect) and production (without expect).
107
149
  */
150
+ /**
151
+ * The recipe's epilogue step id, or `null` when it declares none.
152
+ *
153
+ * Only `compensationStep` is read: the declaration was validated by
154
+ * `@onlineapps/cookbook-core` before this runs.
155
+ *
156
+ * @param {Object} cookbook
157
+ * @returns {string|null}
158
+ */
159
+ function declaredEpilogueStepId(cookbook) {
160
+ const handler = cookbook[EPILOGUE_FAILURE_ROOT];
161
+ return handler && typeof handler === 'object' && typeof handler.compensationStep === 'string'
162
+ ? handler.compensationStep
163
+ : null;
164
+ }
165
+
166
+ /**
167
+ * What the report says about a failed epilogue: the handler's own `{ code,
168
+ * message }` when it threw, otherwise the reason the step record gives.
169
+ *
170
+ * @param {Object} result - the epilogue's step result
171
+ * @returns {{code?: string, message: string}}
172
+ */
173
+ function describeEpilogueError(result) {
174
+ if (result.error && typeof result.error === 'object') {
175
+ return { code: result.error.code, message: result.error.message };
176
+ }
177
+ if (typeof result.error === 'string') {
178
+ return { message: result.error };
179
+ }
180
+ return { message: result.validationErrors.join('; ') };
181
+ }
182
+
108
183
  class CookbookTestRunner {
109
184
  constructor(options = {}) {
110
185
  if (!options.serviceName) {
@@ -115,7 +190,7 @@ class CookbookTestRunner {
115
190
  // calls info() and error() throughout a run. A logger without them therefore
116
191
  // constructed fine and died on the first log line: delayed validation, which
117
192
  // architecture-principles.md §4 forbids. Owner confirmation:
118
- // docs/governance/confirmations/connector-logger-contract.md 001.
193
+ // api/docs/governance/confirmations/connector-logger-contract.md 001.
119
194
  assertLogger('CookbookTestRunner', options.logger, 'the runner writes where the service writes');
120
195
 
121
196
  // A RETIRED option is refused, never ignored: a caller that still writes it
@@ -142,6 +217,12 @@ class CookbookTestRunner {
142
217
  this._servicePathByName.set(this.serviceName, this.servicePath);
143
218
  }
144
219
 
220
+ // The engine's classifier, not a copy of it: the engine asks the injected
221
+ // `@onlineapps/conn-infra-error-handler` (`classifyError`), which delegates
222
+ // to this class of `@onlineapps/error-handler-core`. Stateless — one per
223
+ // runner. Read by `_runEpilogue` for `_failure.error_type`.
224
+ this.errorClassifier = new ErrorClassifier();
225
+
145
226
  // Test results
146
227
  this.results = this._emptyResults();
147
228
  }
@@ -171,12 +252,13 @@ class CookbookTestRunner {
171
252
  try {
172
253
  this.validateCookbook(cookbookData);
173
254
 
174
- // What the recipe WRITES, before anything of it runs. A helper call and a
175
- // `{{steps.<id>}}` naming no step of this cookbook are both refused here:
176
- // production evaluates the first and refuses the second at submission, so
177
- // letting either through as literal text would make a green Tier-1 say
178
- // more than production will (utils/stepReferences.js). The step values
179
- // themselves are resolved per step, later and against what ran.
255
+ // What the recipe WRITES, before anything of it runs, that only Tier-1
256
+ // must refuse: a helper call. Production evaluates it, this runner has no
257
+ // helper registry, so letting it through as literal text would make a
258
+ // green Tier-1 say more than production will (utils/stepReferences.js).
259
+ // A `{{steps.<id>}}` naming no step is cookbook-core's refusal, above.
260
+ // The step values themselves are resolved per step, later and against
261
+ // what ran.
180
262
  const referenceProblem = await checkStepInputExpressions(readCookbookSteps(cookbookData.steps));
181
263
  if (referenceProblem !== null) {
182
264
  throw new Error(`[CookbookTestRunner] ${referenceProblem}`);
@@ -198,11 +280,21 @@ class CookbookTestRunner {
198
280
  this.logger.info(`Mode: ${testConfig.mode || 'production'}, Test: ${isTestMode}`);
199
281
 
200
282
  // Already proved to be the array shape by validateCookbook above.
201
- const stepsArray = readCookbookSteps(cookbookData.steps);
283
+ const allSteps = readCookbookSteps(cookbookData.steps);
284
+
285
+ // The epilogue step, when the recipe declares one. cookbook-core has proved
286
+ // it is the LAST top-level step, so the ordinary run is every step before
287
+ // it — the runner's form of the engine's `nextStep === compensationStep`
288
+ // stop.
289
+ const epilogueStepId = declaredEpilogueStepId(cookbookData);
290
+ const stepsArray = epilogueStepId === null
291
+ ? allSteps
292
+ : allSteps.filter((step) => step.step_id !== epilogueStepId);
202
293
 
203
294
  // Execute steps
204
295
  const cookbookName = cookbookData.description || 'Unnamed';
205
296
  const stepResults = [];
297
+ let failedStep = null;
206
298
  // What the steps of THIS cookbook have produced so far — the scope a
207
299
  // `{{steps.<step_id>.output.…}}` reference resolves against
208
300
  // (src/utils/stepReferences.js). It is built here, per cookbook, because
@@ -222,6 +314,14 @@ class CookbookTestRunner {
222
314
  stepResult.cookbook = cookbookName;
223
315
  stepResults.push(stepResult);
224
316
 
317
+ // A recipe with an epilogue ends at its first failed step, as the engine
318
+ // ends the run there; the epilogue then answers that failure. Without the
319
+ // declaration nothing changes: only a failed step WITH expect stops the run.
320
+ if (!stepResult.passed && epilogueStepId !== null) {
321
+ failedStep = { step, result: stepResult };
322
+ break;
323
+ }
324
+
225
325
  // If step failed and has expect, fail immediately
226
326
  if (!stepResult.passed && step.expect) {
227
327
  break;
@@ -243,6 +343,19 @@ class CookbookTestRunner {
243
343
  steps: stepResults
244
344
  };
245
345
 
346
+ // The epilogue is reported beside the steps, never among them: it is not a
347
+ // step of the ordinary run, so it moves none of the counts above.
348
+ if (failedStep !== null) {
349
+ results.epilogue = await this._runEpilogue({
350
+ epilogueStep: allSteps.find((step) => step.step_id === epilogueStepId),
351
+ failedStep,
352
+ cookbookData,
353
+ testConfig,
354
+ runContext,
355
+ stepIndex: allSteps.length - 1
356
+ });
357
+ }
358
+
246
359
  // Update aggregate results
247
360
  this.results.total += results.total;
248
361
  this.results.passed += results.passedCount;
@@ -257,6 +370,58 @@ class CookbookTestRunner {
257
370
  return results;
258
371
  }
259
372
 
373
+ /**
374
+ * Run the recipe's epilogue once, after the step that ended the run failed.
375
+ *
376
+ * The failure goes into scope under `global_error_handler._failure` with all
377
+ * six keys of the engine's record (`@onlineapps/cookbook-core` `RunFailure`),
378
+ * `null` where the runner has no value — never an invented one:
379
+ * - `error_type` is what the engine's classifier answers for the thrown
380
+ * error (`@onlineapps/error-handler-core` `ErrorClassifier.classify`, the
381
+ * core behind the engine's `errorHandler.classifyError`) — always a
382
+ * string, as `RunFailure` requires;
383
+ * - `error_code` and `details` are the thrown error's own, or `null`.
384
+ *
385
+ * A step whose handler succeeded but whose `expect` was not met threw
386
+ * nothing. Tier-1 `expect` has no counterpart in the engine, so there is no
387
+ * engine record to mirror: `error_code` and `details` are `null`, and
388
+ * `error_type` is the classifier's answer for no error (`classify(null)`,
389
+ * `UNKNOWN`) — asked, not written here.
390
+ *
391
+ * @private
392
+ * @returns {Promise<{step_id: string, status: 'passed'|'failed', output?: *, error?: Object}>}
393
+ */
394
+ async _runEpilogue({ epilogueStep, failedStep, cookbookData, testConfig, runContext, stepIndex }) {
395
+ const thrown = THROWN_ERRORS.get(failedStep.result);
396
+ const failure = {
397
+ step_id: failedStep.step.step_id,
398
+ service: failedStep.step.service,
399
+ operation: typeof failedStep.step.operation === 'string' ? failedStep.step.operation : null,
400
+ error_type: this.errorClassifier.classify(thrown === undefined ? null : thrown),
401
+ error_code: thrown && thrown.code !== undefined && thrown.code !== null ? thrown.code : null,
402
+ details: thrown && thrown.details !== undefined && thrown.details !== null ? thrown.details : null
403
+ };
404
+
405
+ const epilogueContext = {
406
+ ...runContext,
407
+ [EPILOGUE_FAILURE_ROOT]: { ...cookbookData[EPILOGUE_FAILURE_ROOT], [EPILOGUE_FAILURE_KEY]: failure }
408
+ };
409
+
410
+ const result = await this.executeStep(epilogueStep, testConfig, stepIndex, cookbookData.defaults, epilogueContext);
411
+
412
+ if (result.passed) {
413
+ this.logger.info(`[CookbookTestRunner] Run ended unsuccessfully at step '${failure.step_id}'; `
414
+ + `epilogue step '${epilogueStep.step_id}' after step '${failure.step_id}' PASSED`);
415
+ return { step_id: epilogueStep.step_id, status: 'passed', output: result.actual };
416
+ }
417
+
418
+ const error = describeEpilogueError(result);
419
+ this.logger.error(`[CookbookTestRunner] Run ended unsuccessfully at step '${failure.step_id}', and `
420
+ + `epilogue step '${epilogueStep.step_id}' after step '${failure.step_id}' FAILED too — ${error.message}. `
421
+ + 'No second epilogue runs.');
422
+ return { step_id: epilogueStep.step_id, status: 'failed', error };
423
+ }
424
+
260
425
  /**
261
426
  * Run all cookbooks in a directory — or the ones running one operation.
262
427
  *
@@ -447,8 +612,15 @@ class CookbookTestRunner {
447
612
  passed: false,
448
613
  // Three separate numbers, because they answer different questions.
449
614
  // `duration` is the wall clock of the whole step. `setupDurationMs` is
450
- // what the RUNNER spent getting to the handler — reading operations.json
451
- // and require()ing the module, a one-off cost per process. Only
615
+ // what the RUNNER spent getting to the handler, and that is two costs of
616
+ // different shape: `resolveOperation()` below re-reads
617
+ // `config/service/operations.json` from disk on EVERY step (measured:
618
+ // five steps, five reads), while `require()` of the handler module is
619
+ // served from Node's module registry after the first step that asks for
620
+ // it. Only the second one is a one-off per process — until d.795b this
621
+ // comment said both were, which made the per-step read invisible to
622
+ // anybody reading the number. This is the Tier-1 runner at boot, so the
623
+ // repeated read is per cookbook step, never on a request path. Only
452
624
  // `handlerDurationMs` is the handler's own runtime, and only that one is
453
625
  // what `expect.duration.max` asserts about (see validateExpectations).
454
626
  duration: 0,
@@ -467,8 +639,8 @@ class CookbookTestRunner {
467
639
  // The namespace is a safety boundary, so it is resolved before anything
468
640
  // else happens — before the operation is even looked up. utils/testNamespace
469
641
  // is the single owner of that decision: it reads the TESTING_* env contract
470
- // (docs/biz/40-cookbooks/test-env-vars.md +
471
- // docs/biz/60-templates/env-conventions.md — the TESTING_ prefix marks
642
+ // (api/docs/biz/40-cookbooks/test-env-vars.md +
643
+ // api/docs/biz/60-templates/env-conventions.md — the TESTING_ prefix marks
472
644
  // values that legitimately ship to production, because this runner fires at
473
645
  // every service startup, prod included) AND it returns a namespace only for
474
646
  // the non-production environment classes — 96 CI, 97 TESTING, 98 DEVEL,
@@ -599,22 +771,62 @@ class CookbookTestRunner {
599
771
  return envWorkspaceId;
600
772
  }
601
773
 
774
+ /**
775
+ * The per-step limit, read from the ONE place a recipe may set it.
776
+ *
777
+ * The cookbook's `test.timeout` overrides the runner default
778
+ * (`api/docs/biz/40-cookbooks/testing.md` § The `test` block): absent means the
779
+ * constructor's `timeout`, which the constructor has already proved positive.
780
+ * Present, it must be a positive finite number of milliseconds and is refused
781
+ * by name otherwise. Until d.1001 this was `testConfig.timeout || this.timeout`,
782
+ * which turned a written `0`, `-1` or `NaN` silently into the runner default and
783
+ * raced the handler against a string `"5"` (`architecture-principles.md` §3).
784
+ *
785
+ * @param {Object} testConfig - the cookbook's `test` block
786
+ * @returns {number} milliseconds
787
+ */
788
+ _stepTimeout(testConfig) {
789
+ if (testConfig.timeout === undefined) {
790
+ return this.timeout;
791
+ }
792
+ const { timeout } = testConfig;
793
+ if (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout <= 0) {
794
+ const shown = typeof timeout === 'string' ? JSON.stringify(timeout) : String(timeout);
795
+ throw new Error(
796
+ `[CookbookTestRunner] test.timeout must be a positive number of milliseconds - got ${shown}. `
797
+ + 'Expected: the per-step limit the recipe sets over the runner default, or no test.timeout '
798
+ + 'at all to keep that default. '
799
+ + 'Fix: write a positive number (e.g. "timeout": 5000) in the cookbook\'s test block, or drop the key.'
800
+ );
801
+ }
802
+ return timeout;
803
+ }
804
+
602
805
  /**
603
806
  * Handler dispatch: require handler module, build minimal real ctx, call
604
- * handler(input, ctx). Mirrors the production ContextBuilder shape
605
- * (see api/docs/biz/10-invocation/handler-dispatch.md) with
606
- * all connector slots set to null — handlers that need DB access use
607
- * direct sequelize import per biz/60-templates/onboarding-checklist.md §9a.
807
+ * handler(input, ctx). The fields passed to `new OperationContext(...)` below
808
+ * are the one statement of the runner's slot set — see api/docs/biz/40-cookbooks/test-runner-flow.md and
809
+ * api/docs/biz/10-invocation/operation-context.md. All connector slots are
810
+ * null; handlers that need DB access use a direct sequelize import per
811
+ * api/docs/biz/60-templates/onboarding-checklist.md §9a.
608
812
  */
609
813
  async _dispatchViaHandler({ step, input, spec, testConfig, validationTenantId, validationWorkspaceId, result, startTime }) {
610
- const stepTimeout = testConfig.timeout || this.timeout;
611
- const correlationId = step.correlation_id || crypto.randomUUID();
612
- // Production ctx (from ContextBuilder) always carries workflow_id + person_id.
613
- // Handlers check both — mirror them here (person_id nullable per envelope).
614
- const workflowId = step.workflow_id || correlationId;
814
+ const stepTimeout = this._stepTimeout(testConfig);
815
+ // The run's identity is the RUNNER's to make, never the step's (d.1001). The
816
+ // step format (`api/docs/biz/40-cookbooks/format.md` § Task step, cookbook-core's
817
+ // `TaskStep`) declares neither `correlation_id` nor `workflow_id`, and nothing
818
+ // in production reads them off a step — so a step that still writes them is
819
+ // treated like any other undeclared key: not refused (`TaskStep` keeps
820
+ // `additionalProperties: true`, conf `parked-features` 001), and not read.
821
+ // Production ctx (from ContextBuilder) always carries workflow_id; one step
822
+ // here is one run, so the workflow is named by the step's own correlation id.
823
+ const correlationId = crypto.randomUUID();
824
+ const workflowId = correlationId;
825
+ // `person_id` is still read off the step — whether the runner carries an
826
+ // acting person, and from where, is conf `testing-tenant-identity` 001.
615
827
  const personId = step.person_id != null ? step.person_id : null;
616
828
 
617
- const ctx = {
829
+ const ctx = new OperationContext({
618
830
  logger: this.logger,
619
831
  tenant_id: validationTenantId,
620
832
  workspace_id: validationWorkspaceId,
@@ -623,8 +835,7 @@ class CookbookTestRunner {
623
835
  person_id: personId,
624
836
  operation_name: step.operation,
625
837
  // WHICH STEP is running, not merely which operation. Production
626
- // `OperationContext` carries it among its 16 fields
627
- // (`@onlineapps/service-wrapper` src/OperationContext.js), and a handler
838
+ // `OperationContext` carries it among its fields, and a handler
628
839
  // that stores a file cannot do without it:
629
840
  // `@onlineapps/conn-orch-content-resolver` keys every stored object on
630
841
  // `content/<workflow_id>/<step_id>` and refuses to invent either half.
@@ -655,7 +866,7 @@ class CookbookTestRunner {
655
866
  'x-workspace-id': String(validationWorkspaceId),
656
867
  ...resolveHeaders(step.headers)
657
868
  }
658
- };
869
+ });
659
870
 
660
871
  const request = {
661
872
  mode: 'handler',
@@ -692,7 +903,11 @@ class CookbookTestRunner {
692
903
 
693
904
  // Everything up to here was the runner getting ready: the namespace, the
694
905
  // operations.json read, require.resolve and the require() of the handler
695
- // module. That is a one-off cost per process and it is not the handler's.
906
+ // module. None of it is the handler's own time — and only the module load
907
+ // is a one-off per process (Node's module registry serves it after the
908
+ // first step). The operations.json read happens on EVERY step, because
909
+ // `resolveOperation()` opens the file each time it is called; the same
910
+ // sentence one screen up said otherwise until d.795b/d.795c.
696
911
  const handlerStart = Date.now();
697
912
  result.setupDurationMs = handlerStart - startTime;
698
913
 
@@ -705,6 +920,7 @@ class CookbookTestRunner {
705
920
  ]);
706
921
  } catch (error) {
707
922
  handlerError = error;
923
+ THROWN_ERRORS.set(result, error);
708
924
  } finally {
709
925
  clearTimeout(timeoutId);
710
926
  }
@@ -1091,34 +1307,32 @@ class CookbookTestRunner {
1091
1307
  }
1092
1308
 
1093
1309
  /**
1094
- * Validate cookbook format
1310
+ * Validate cookbook format — ONE owner, then what only the runner knows.
1095
1311
  *
1096
- * `steps` is an ARRAY of step objects — the only shape the format allows
1097
- * (owner confirmation `cookbook-steps-shape` 001 of 2026-09-05). The runner
1098
- * used to accept an object keyed by `step_id` and convert it, which made this
1099
- * gate pass cookbooks the production WorkflowOrchestrator threw on. The shape
1100
- * rule itself lives in `utils/cookbookFormat.readCookbookSteps`, the same
1101
- * owner `CookbookTestUtils.validateCookbook` reads, so the two entry points
1102
- * cannot disagree about what a cookbook's steps are.
1312
+ * FIRST, `@onlineapps/cookbook-core` `validateCookbook`: the same function the
1313
+ * receiving side throws on
1314
+ * (api/docs/governance/confirmations/cookbook-validation-placement.md 001).
1315
+ * It refuses a non-object, a missing or pre-2.1 `version`, a missing, empty
1316
+ * or non-array `steps` (the array is the only shape — owner confirmation
1317
+ * `cookbook-steps-shape` 001), a step without `step_id`/`type`/`service`/
1318
+ * `operation`, a step carrying `id` (d.983; the runner's own copy of that ban
1319
+ * went with d.985), a duplicate `step_id`, a `{{steps.<id>}}` or `depends_on`
1320
+ * naming no step, a positional step reference, a non-integer `workspace_id`,
1321
+ * and the epilogue rules (d.933, d.942). Until d.980 the runner held its own
1322
+ * copy of most of these, with its own messages, ahead of this call — one
1323
+ * concern on two rails (`.claude/rules/change-discipline.md` § One rail per
1324
+ * concern). The words a biz service reads in its Tier-1 output for these
1325
+ * cases are cookbook-core's.
1103
1326
  *
1104
- * The format-version check is FIRST and unconditional. Until 2026-08-27 it
1105
- * lived only in `CookbookTestUtils.validateCookbook`, reachable exclusively
1106
- * through the optional `tests/bootstrap/service-readiness.test.js` wrapper
1107
- * that 3 of 8 biz repos happen to have — so the rule from
1108
- * api/docs/biz/40-cookbooks/format.md § "Required fields" was, in practice,
1109
- * opt-in per repo, and 4 repos ship cookbooks with no `version` field at all.
1110
- * A check a repo can decline is not a check (automation-gates §1.5).
1327
+ * THEN, what cookbook-core accepts and the runner must not:
1328
+ * `test.mockInfrastructure` — the retired option, refused by name.
1329
+ *
1330
+ * @param {*} cookbook - the parsed cookbook
1331
+ * @returns {true}
1332
+ * @throws {Error} the first refusal, in the words of its owner
1111
1333
  */
1112
1334
  validateCookbook(cookbook) {
1113
- if (!cookbook || typeof cookbook !== 'object' || Array.isArray(cookbook)) {
1114
- throw new Error('[CookbookTestRunner] Cookbook is not a JSON object - Expected an object with "version" and "steps". '
1115
- + 'Fix: check the cookbook file content (api/docs/biz/40-cookbooks/format.md § Basic structure).');
1116
- }
1117
-
1118
- const versionProblem = checkCookbookFormatVersion(cookbook.version);
1119
- if (versionProblem) {
1120
- throw new Error(`[CookbookTestRunner] ${versionProblem}`);
1121
- }
1335
+ validateCookbookFormat(cookbook);
1122
1336
 
1123
1337
  // The retired option, in the other place a caller could still write it. The
1124
1338
  // cookbook schema admits additional properties inside `test`, so nothing
@@ -1136,50 +1350,6 @@ class CookbookTestRunner {
1136
1350
  + '(api/docs/biz/40-cookbooks/format.md § Optional top-level fields).');
1137
1351
  }
1138
1352
 
1139
- if (cookbook.steps === undefined || cookbook.steps === null) {
1140
- throw new Error('[CookbookTestRunner] Cookbook has no steps - Expected "steps": [ … ] with at '
1141
- + 'least one step object carrying "step_id". '
1142
- + 'Fix: add the steps array (api/docs/biz/40-cookbooks/format.md § Required fields).');
1143
- }
1144
-
1145
- // Throws unless `steps` is the array the format requires.
1146
- const stepsArray = readCookbookSteps(cookbook.steps);
1147
-
1148
- if (stepsArray.length === 0) {
1149
- throw new Error('[CookbookTestRunner] Cookbook must have at least one step - Expected a non-empty '
1150
- + 'steps array. Fix: add at least one step (api/docs/biz/40-cookbooks/format.md § Required fields).');
1151
- }
1152
-
1153
- // The three fields `@onlineapps/cookbook-core` 5.0.0 requires of a task step
1154
- // beyond `type` (`schemas/cookbook.v2.schema.json`
1155
- // definitions.TaskStep.required). `step_id` comes FIRST, because it is what
1156
- // the other two messages name the step by: until d.516b this guard checked
1157
- // only the last two and wrote `Step ${step.step_id || 'unknown'}`, so a
1158
- // cookbook missing the field the whole format addresses steps by was
1159
- // accepted here and its diagnostics said `Step unknown`. A stand-in name is
1160
- // the fallback `.claude/rules/architecture-principles.md` §3 forbids; with
1161
- // the field required, the case it stood in for cannot occur.
1162
- stepsArray.forEach((step, index) => {
1163
- const position = index + 1;
1164
- if (typeof step.step_id !== 'string' || step.step_id.length === 0) {
1165
- throw new Error(`[CookbookTestRunner] Step #${position} has no step_id - every step of a v2.1 `
1166
- + 'cookbook is addressed by its own step_id (@onlineapps/cookbook-core '
1167
- + 'schemas/cookbook.v2.schema.json definitions.TaskStep.required). '
1168
- + `Fix: add "step_id" to step #${position} `
1169
- + '(api/docs/biz/40-cookbooks/format.md § Required fields).');
1170
- }
1171
- if (!step.service) {
1172
- throw new Error(`[CookbookTestRunner] Step ${step.step_id} must have service - `
1173
- + 'Expected the name of the service that runs the step. Fix: add "service" to that step '
1174
- + '(api/docs/biz/40-cookbooks/format.md § Step definition).');
1175
- }
1176
- if (!step.operation) {
1177
- throw new Error(`[CookbookTestRunner] Step ${step.step_id} must have operation - `
1178
- + 'Expected the operation that service exposes. Fix: add "operation" to that step '
1179
- + '(api/docs/biz/40-cookbooks/format.md § Step definition).');
1180
- }
1181
- });
1182
-
1183
1353
  return true;
1184
1354
  }
1185
1355