@onlineapps/conn-orch-validator 7.0.0 → 8.1.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 (102) hide show
  1. package/CHANGELOG.md +2582 -2
  2. package/README.md +1038 -4
  3. package/docs/DESIGN.md +3 -1
  4. package/manifests/biz-service.manifest.json +658 -0
  5. package/manifests/library.manifest.json +324 -0
  6. package/package.json +12 -6
  7. package/src/CookbookTestRunner.js +408 -101
  8. package/src/CookbookTestUtils.js +7 -8
  9. package/src/ServiceReadinessValidator.js +10 -35
  10. package/src/ValidationOrchestrator.js +219 -71
  11. package/src/cli/biz-ci-gate.js +176 -33
  12. package/src/cli/oa-lint-scripts.js +221 -0
  13. package/src/cli/oa-sync-template.js +1020 -0
  14. package/src/cli/oa-validate.js +474 -0
  15. package/src/helpers/README.md +2 -1
  16. package/src/helpers/createServiceReadinessTests.js +60 -4
  17. package/src/index.js +33 -3
  18. package/src/lint/scripts/lintScripts.js +298 -0
  19. package/src/manifest/checks/composeRunnerBlock.js +222 -0
  20. package/src/manifest/checks/composeShape.js +165 -0
  21. package/src/manifest/checks/contractBridge.js +181 -0
  22. package/src/manifest/checks/discoveryOrphan.js +50 -0
  23. package/src/manifest/checks/docsLintBridge.js +553 -0
  24. package/src/manifest/checks/fileAbsent.js +35 -0
  25. package/src/manifest/checks/gitTracked.js +204 -0
  26. package/src/manifest/checks/index.js +111 -0
  27. package/src/manifest/checks/libraryContext.js +226 -0
  28. package/src/manifest/checks/libraryDocs.js +75 -0
  29. package/src/manifest/checks/libraryPackage.js +272 -0
  30. package/src/manifest/checks/librarySource.js +274 -0
  31. package/src/manifest/checks/libraryTests.js +121 -0
  32. package/src/manifest/checks/libraryWorkspace.js +293 -0
  33. package/src/manifest/checks/readmeRegion.js +135 -0
  34. package/src/manifest/checks/scriptHeaders.js +79 -0
  35. package/src/manifest/checks/serviceConfig.js +390 -0
  36. package/src/manifest/checks/serviceConnectors.js +81 -0
  37. package/src/manifest/checks/serviceDb.js +388 -0
  38. package/src/manifest/checks/serviceFiles.js +754 -0
  39. package/src/manifest/checks/serviceIdentityRows.js +351 -0
  40. package/src/manifest/checks/serviceRuntime.js +295 -0
  41. package/src/manifest/checks/serviceScripts.js +213 -0
  42. package/src/manifest/deployabilitySignal.js +121 -0
  43. package/src/manifest/discovery.js +386 -0
  44. package/src/manifest/loadManifest.js +62 -0
  45. package/src/manifest/manifestShape.js +446 -0
  46. package/src/manifest/report.js +245 -0
  47. package/src/manifest/runManifest.js +449 -0
  48. package/src/manifest/serviceIdentity.js +140 -0
  49. package/src/manifest/walk.js +74 -0
  50. package/src/manifest/workspaceRoot.js +242 -0
  51. package/src/mocks/MockMQClient.js +13 -30
  52. package/src/mocks/MockRegistry.js +4 -2
  53. package/src/mocks/MockStorage.js +4 -2
  54. package/src/sync/docsRegion.js +463 -0
  55. package/src/sync/generatedRegion.js +228 -0
  56. package/src/sync/readmeLocation.js +182 -0
  57. package/src/sync/readmePointer.js +477 -0
  58. package/src/sync/serviceTemplate.js +583 -0
  59. package/src/sync/sharedEnv.js +162 -0
  60. package/src/sync/uniformFiles.js +474 -0
  61. package/src/utils/bizCiGateContract.js +131 -7
  62. package/src/utils/connectorContract.js +97 -7
  63. package/src/utils/cookbookFormat.js +81 -40
  64. package/src/utils/deployContract.js +140 -9
  65. package/src/utils/envContract.js +57 -1
  66. package/src/utils/handlerRef.js +181 -0
  67. package/src/utils/installContract.js +287 -41
  68. package/src/utils/libCompat.js +29 -7
  69. package/src/utils/migrationOrder.js +163 -0
  70. package/src/utils/preValidation.js +20 -7
  71. package/src/utils/setupDatabase.js +194 -13
  72. package/src/utils/testCoverageContract.js +539 -0
  73. package/src/utils/testNamespace.js +247 -23
  74. package/src/utils/throwawaySchema.js +207 -0
  75. package/src/validators/ServiceStructureValidator.js +2 -1
  76. package/templates/business-service/.dockerignore +42 -0
  77. package/templates/business-service/.gitlab-ci.yml +409 -0
  78. package/templates/business-service/Dockerfile +27 -0
  79. package/templates/business-service/README.md +213 -0
  80. package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
  81. package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +22 -0
  82. package/templates/business-service/config/env-templates/shared.env +65 -0
  83. package/templates/business-service/config/service/config.json +14 -0
  84. package/templates/business-service/config/service/integration-contract.json +12 -0
  85. package/templates/business-service/config/service/operations.json +41 -0
  86. package/templates/business-service/docker-compose.production.yml +60 -0
  87. package/templates/business-service/docker-compose.yml +93 -0
  88. package/templates/business-service/docs/80-setup/INSTALL.md +123 -0
  89. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
  90. package/templates/business-service/docs/80-setup/README.md +18 -0
  91. package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
  92. package/templates/business-service/docs/README.md +18 -0
  93. package/templates/business-service/gitignore +42 -0
  94. package/templates/business-service/index.js +10 -0
  95. package/templates/business-service/init.sh +54 -0
  96. package/templates/business-service/jest.config.js +6 -0
  97. package/templates/business-service/package.json.template +31 -0
  98. package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
  99. package/templates/business-service/src/handlers/v3/echo.js +39 -0
  100. package/templates/business-service/tests/cookbooks/echo.json +36 -0
  101. package/templates/business-service/tests/unit/handler.test.js +78 -0
  102. package/src/WorkflowTestRunner.js +0 -402
@@ -6,13 +6,11 @@ const crypto = require('crypto');
6
6
  const MockMQClient = require('./mocks/MockMQClient');
7
7
  const MockRegistry = require('./mocks/MockRegistry');
8
8
  const { resolveHeaders } = require('./utils/resolveHeaders');
9
- const { checkCookbookFormatVersion, normalizeCookbookSteps } = require('./utils/cookbookFormat');
10
- const { getTestNamespace } = require('./utils/testNamespace');
9
+ const { checkCookbookFormatVersion, readCookbookSteps } = require('./utils/cookbookFormat');
10
+ const { getTestNamespace, assertWorkspaceId } = require('./utils/testNamespace');
11
11
  const { describeStepFailure, describeStepIdentity } = require('./utils/stepFailure');
12
-
13
- // The logger methods this runner requires — the order is the order the error
14
- // message lists them in.
15
- const LOGGER_METHODS = ['info', 'warn', 'error', 'debug'];
12
+ const { parseHandlerRef, resolveHandlerModule } = require('./utils/handlerRef');
13
+ const { assertLogger } = require('@onlineapps/logger-contract');
16
14
 
17
15
  /**
18
16
  * The step's stopwatch, spelled out. A single total hid the fact that
@@ -28,6 +26,55 @@ function describeStepTiming(result) {
28
26
  + ` = ${result.duration}ms total`;
29
27
  }
30
28
 
29
+ /**
30
+ * Resolve one `expect.output` field name against a step's output.
31
+ *
32
+ * The name is a DOT PATH into nested objects, because that is the only way a
33
+ * cookbook can assert anything about a file descriptor: a descriptor lives
34
+ * under its own key inside the output (`{ pdf: <descriptor> }`), never at the
35
+ * root — api/docs/biz/40-cookbooks/variable-references.md § "File descriptors
36
+ * in outputs". A flat `actual[field]` lookup answered `undefined` for
37
+ * `"pdf.size"` and reported it as missing, which fails the step and, in
38
+ * Tier-1, the boot of a service returning exactly what the norm prescribes.
39
+ *
40
+ * Two rules, both decisions rather than consequences:
41
+ *
42
+ * 1. **The exact key wins, at every level.** `{ "a.b": 1 }` is legal output
43
+ * and `"a.b"` is the name its author typed, so it is looked up verbatim
44
+ * before the name is split. Splitting is what happens when the name does
45
+ * not exist as written — nobody has to escape anything.
46
+ * 2. **Objects only.** A segment never indexes an array (`items.0` does not
47
+ * reach the first element) and never reads a property off a scalar. No
48
+ * cookbook asks for indices today; inventing the syntax here would make
49
+ * it this library's invention rather than the norm's.
50
+ *
51
+ * @param {*} container - the output object, or any nested value during the walk
52
+ * @param {string} fieldPath - the field name as the cookbook wrote it
53
+ * @returns {{found: boolean, value?: *, missingSegment?: string}} `missingSegment`
54
+ * is the FIRST segment that could not be resolved, so the caller can
55
+ * say whether `pdf` was absent or `pdf` was there without a `size`.
56
+ */
57
+ function resolveOutputField(container, fieldPath) {
58
+ const traversable = container !== null && typeof container === 'object' && !Array.isArray(container);
59
+
60
+ if (traversable && Object.prototype.hasOwnProperty.call(container, fieldPath)) {
61
+ return { found: true, value: container[fieldPath] };
62
+ }
63
+
64
+ const separator = fieldPath.indexOf('.');
65
+ if (separator === -1) {
66
+ return { found: false, missingSegment: fieldPath };
67
+ }
68
+
69
+ const head = fieldPath.slice(0, separator);
70
+ const rest = fieldPath.slice(separator + 1);
71
+ if (!traversable || !Object.prototype.hasOwnProperty.call(container, head)) {
72
+ return { found: false, missingSegment: head };
73
+ }
74
+
75
+ return resolveOutputField(container[head], rest);
76
+ }
77
+
31
78
  /**
32
79
  * CookbookTestRunner — executes cookbook tests offline with mocked infrastructure.
33
80
  *
@@ -60,24 +107,8 @@ class CookbookTestRunner {
60
107
  // constructed fine and died on the first log line: delayed validation, which
61
108
  // architecture-principles.md §4 forbids. Owner confirmation:
62
109
  // docs/governance/confirmations/connector-logger-contract.md 001.
63
- if (!options.logger) {
64
- throw new Error(
65
- '[CookbookTestRunner] Logger is required - Expected: a logger with info/warn/error/debug, '
66
- + 'so the runner writes where the service writes. '
67
- + 'Fix: pass options.logger (e.g. the logger your service already built).'
68
- );
69
- }
70
-
71
- const missingLoggerMethods = LOGGER_METHODS.filter(
72
- (method) => typeof options.logger[method] !== 'function'
73
- );
74
- if (missingLoggerMethods.length > 0) {
75
- throw new Error(
76
- '[CookbookTestRunner] Logger is incomplete - Expected: info, warn, error, debug as functions; '
77
- + `missing: ${missingLoggerMethods.join(', ')}. `
78
- + 'Fix: pass a logger implementing all four.'
79
- );
80
- }
110
+ assertLogger('CookbookTestRunner', options.logger, 'the runner writes where the service writes');
111
+
81
112
  this.serviceName = options.serviceName;
82
113
  this.servicePath = options.servicePath;
83
114
  this.mockInfrastructure = options.mockInfrastructure !== false;
@@ -98,13 +129,7 @@ class CookbookTestRunner {
98
129
  }
99
130
 
100
131
  // Test results
101
- this.results = {
102
- total: 0,
103
- passed: 0,
104
- failed: 0,
105
- duration: 0,
106
- steps: []
107
- };
132
+ this.results = this._emptyResults();
108
133
  }
109
134
 
110
135
  /**
@@ -125,6 +150,10 @@ class CookbookTestRunner {
125
150
  // path is appended to whatever the validation rejected — the reader of a
126
151
  // boot failure otherwise learns that "a" cookbook is malformed but not
127
152
  // which one, and a service can carry a dozen of them.
153
+ //
154
+ // The message opens with the cause's own message, so it INHERITS that
155
+ // error's `[Context]` rather than prefixing a second one; the cause travels
156
+ // along, so the catching layer still sees the original error object.
128
157
  try {
129
158
  this.validateCookbook(cookbookData);
130
159
  } catch (error) {
@@ -143,14 +172,14 @@ class CookbookTestRunner {
143
172
  this.logger.info(`Running cookbook: ${cookbookData.description || 'Unnamed'}`);
144
173
  this.logger.info(`Mode: ${testConfig.mode || 'production'}, Test: ${isTestMode}`);
145
174
 
146
- // Convert steps to array format (support both V1 array and V2 object formats)
147
- const stepsArray = this.normalizeSteps(cookbookData.steps);
175
+ // Already proved to be the array shape by validateCookbook above.
176
+ const stepsArray = readCookbookSteps(cookbookData.steps);
148
177
 
149
178
  // Execute steps
150
179
  const cookbookName = cookbookData.description || 'Unnamed';
151
180
  const stepResults = [];
152
181
  for (const [stepIndex, step] of stepsArray.entries()) {
153
- const stepResult = await this.executeStep(step, testConfig, stepIndex);
182
+ const stepResult = await this.executeStep(step, testConfig, stepIndex, cookbookData.defaults);
154
183
  // Which cookbook this step came from. Without it a failed step in the
155
184
  // aggregate is an id with no file behind it, and a service can carry a
156
185
  // dozen cookbooks (utils/stepFailure.js).
@@ -185,16 +214,24 @@ class CookbookTestRunner {
185
214
  this.results.duration += results.duration;
186
215
  this.results.steps.push(...results.steps);
187
216
 
217
+ // One cookbook, counted as one — whatever its number of steps.
218
+ this.results.cookbooks.total += 1;
219
+ this.results.cookbooks[results.passed ? 'passed' : 'failed'] += 1;
220
+
188
221
  return results;
189
222
  }
190
223
 
191
224
  /**
192
- * Run all cookbooks in a directory
225
+ * Run all cookbooks in a directory — or the ones running one operation.
193
226
  *
194
227
  * @param {string} cookbooksDir - Path to cookbooks directory
228
+ * @param {{operation?: string}} [options] - `operation` is a JavaScript
229
+ * regular expression; only cookbooks containing a step whose
230
+ * `operation` matches it are run. See `_selectCookbooks` for what
231
+ * "selected" means and why the selection is per cookbook.
195
232
  * @returns {Object} Aggregate results
196
233
  */
197
- async runCookbooks(cookbooksDir) {
234
+ async runCookbooks(cookbooksDir, options = {}) {
198
235
  // Every run reports ITS OWN run. `this.results` only ever accumulated, and
199
236
  // `ValidationOrchestrator` builds one runner in its constructor while
200
237
  // `ServiceWrapper._ensureValidationProof` reuses one orchestrator across
@@ -210,9 +247,13 @@ class CookbookTestRunner {
210
247
  // This is that caller.
211
248
  this.resetResults();
212
249
 
213
- const files = fs.readdirSync(cookbooksDir).filter(f => f.endsWith('.json'));
250
+ const all = fs.readdirSync(cookbooksDir).filter(f => f.endsWith('.json'));
251
+ const files = this._selectCookbooks(cookbooksDir, all, options.operation);
214
252
 
215
- this.logger.info(`Found ${files.length} cookbook(s) in ${cookbooksDir}`);
253
+ this.logger.info(options.operation === undefined
254
+ ? `Found ${files.length} cookbook(s) in ${cookbooksDir}`
255
+ : `Found ${files.length} of ${all.length} cookbook(s) in ${cookbooksDir}`
256
+ + ` matching operation /${options.operation}/`);
216
257
 
217
258
  for (const file of files) {
218
259
  const cookbookPath = path.join(cookbooksDir, file);
@@ -220,7 +261,22 @@ class CookbookTestRunner {
220
261
  await this.runCookbook(cookbookPath);
221
262
  } catch (error) {
222
263
  this.logger.error(`Failed to run cookbook ${file}:`, error.message);
264
+ // ONE definition for the step counters: everything in `results.steps` is
265
+ // counted in `total` exactly once — a real step, or the single entry
266
+ // below standing for a cookbook that never produced any. `failed++`
267
+ // without the matching `total++` is how the aggregate came to report
268
+ // `total 2 / failed 3` (BIZ-converter, 2026-08-26): numbers that do not
269
+ // add up, in the object `utils/preValidation.js` reads to decide whether
270
+ // to write a proof and `ValidationOrchestrator` folds into the run's
271
+ // totals. The invariant is `passed + failed === total === steps.length`.
272
+ this.results.total++;
223
273
  this.results.failed++;
274
+ // A cookbook that could not be loaded or validated is a FAILED cookbook,
275
+ // never a cookbook nobody counted: the file is there, it is part of the
276
+ // service's declared coverage, and a total that quietly excludes it
277
+ // reports a smaller run as a complete one.
278
+ this.results.cookbooks.total += 1;
279
+ this.results.cookbooks.failed += 1;
224
280
  this.results.steps.push({
225
281
  cookbook: file,
226
282
  passed: false,
@@ -232,10 +288,87 @@ class CookbookTestRunner {
232
288
  return this.results;
233
289
  }
234
290
 
291
+ /**
292
+ * Which cookbook files this run executes.
293
+ *
294
+ * Without a filter: all of them, in the order the directory lists them.
295
+ *
296
+ * With one, three rules, each a decision:
297
+ *
298
+ * 1. **A cookbook is selected WHOLE.** The expression is matched against
299
+ * the `operation` of every step, and a cookbook holding one match runs
300
+ * entirely. Running only the matching steps would be a different
301
+ * cookbook: a later step reads what an earlier one wrote, and the format
302
+ * makes that explicit through `depends_on`
303
+ * (api/docs/biz/40-cookbooks/variable-references.md).
304
+ * 2. **A file this method cannot read is never filtered OUT.** Selection
305
+ * reads JSON; a file that does not parse, or whose steps are not the
306
+ * shape the format requires, is kept and fails in the run where the
307
+ * format gate reports it by name. A filter that quietly dropped
308
+ * unreadable cookbooks would be a way to walk past that gate
309
+ * (`automation-gates.md` §1.5).
310
+ * 3. **A filter matching nothing is an error**, not an empty pass. A run
311
+ * that executes zero cookbooks and reports success is the silent gate of
312
+ * `automation-gates.md` §5; the message names the operations the
313
+ * directory actually runs, so the fix needs no second command.
314
+ *
315
+ * @param {string} cookbooksDir directory being run
316
+ * @param {string[]} files every `.json` in it
317
+ * @param {string|undefined} operation the regular expression, or undefined
318
+ * @returns {string[]} the files to run
319
+ */
320
+ _selectCookbooks(cookbooksDir, files, operation) {
321
+ if (operation === undefined) return files;
322
+
323
+ let pattern;
324
+ try {
325
+ pattern = new RegExp(operation);
326
+ } catch (error) {
327
+ throw new Error(`[CookbookTestRunner] Invalid --operation expression ${JSON.stringify(operation)} - `
328
+ + `Expected a JavaScript regular expression. Fix: correct it (${error.message}).`, { cause: error });
329
+ }
330
+
331
+ const operationsSeen = new Set();
332
+ const selected = files.filter((file) => {
333
+ const operations = this._operationsOf(path.join(cookbooksDir, file));
334
+ if (operations === null) return true;
335
+ for (const name of operations) operationsSeen.add(name);
336
+ return operations.some((name) => pattern.test(name));
337
+ });
338
+
339
+ if (selected.length === 0) {
340
+ const known = [...operationsSeen].sort().join(', ') || '(none declared)';
341
+ throw new Error(`[CookbookTestRunner] No cookbook matches --operation ${JSON.stringify(operation)} - `
342
+ + `Expected a JavaScript regular expression matching one of the operations these cookbooks run: `
343
+ + `${known}. Fix: correct the expression, or run without --operation.`);
344
+ }
345
+
346
+ return selected;
347
+ }
348
+
349
+ /**
350
+ * The operations one cookbook file runs, or `null` when the file cannot be
351
+ * read as a cookbook at all — which is rule 2 above: unreadable is not
352
+ * "does not match", it is "runs and reports itself".
353
+ *
354
+ * @param {string} cookbookPath absolute path
355
+ * @returns {string[]|null}
356
+ */
357
+ _operationsOf(cookbookPath) {
358
+ try {
359
+ const cookbook = JSON.parse(fs.readFileSync(cookbookPath, 'utf8'));
360
+ return readCookbookSteps(cookbook.steps)
361
+ .map((step) => step && step.operation)
362
+ .filter((name) => typeof name === 'string' && name.length > 0);
363
+ } catch {
364
+ return null;
365
+ }
366
+ }
367
+
235
368
  /**
236
369
  * Execute single step
237
370
  */
238
- async executeStep(step, testConfig, stepIndex = 0) {
371
+ async executeStep(step, testConfig, stepIndex = 0, cookbookDefaults = null) {
239
372
  const startTime = Date.now();
240
373
 
241
374
  // ONE label for this step, resolved once. `step.id` alone was undefined for
@@ -291,7 +424,15 @@ class CookbookTestRunner {
291
424
  // biz-property incident wrote into the live tenant at every restart.
292
425
  // Values arrive already validated as integers, so ctx matches the
293
426
  // production ContextBuilder for handlers checking Number.isInteger().
294
- const { tenant_id: validationTenantId, workspace_id: validationWorkspaceId } = getTestNamespace();
427
+ const { tenant_id: validationTenantId, workspace_id: envWorkspaceId } = getTestNamespace();
428
+
429
+ // The WORKSPACE, unlike the tenant, is the cookbook author's to choose.
430
+ const validationWorkspaceId = this._resolveStepWorkspaceId({
431
+ step,
432
+ stepLabel,
433
+ cookbookDefaults,
434
+ envWorkspaceId
435
+ });
295
436
 
296
437
  // Resolve operation spec from operations.json — always returns
297
438
  // { modulePath, exportName } (v3 handler dispatch is the only mode).
@@ -332,6 +473,66 @@ class CookbookTestRunner {
332
473
  return result;
333
474
  }
334
475
 
476
+ /**
477
+ * The workspace THIS step runs in.
478
+ *
479
+ * TWO LEVELS, and the rule that orders them is not this file's: it belongs to
480
+ * `.claude/rules/workspace-architecture.md` and its single implementation on
481
+ * the invocation path is
482
+ * `@onlineapps/conn-orch-orchestrator` → `WorkflowOrchestrator._resolveStepWorkspaceId`
483
+ * (d.332). A per-step `workspace_id` overrides `defaults.workspace_id` for that
484
+ * step and for no other; it is read here, per step, so it can never leak into
485
+ * the next one. This runner CITES that rule rather than importing it: the
486
+ * package depends on `@onlineapps/logger-contract` and
487
+ * `@onlineapps/service-validator-core` and on nothing that owns cookbook
488
+ * semantics, and the third term below is Tier-1's alone, so a shared body would
489
+ * have to be parameterised to say less than either caller means.
490
+ *
491
+ * THE THIRD TERM — the platform namespace from the environment — is what a
492
+ * cookbook that declares no workspace gets, which is exactly what every such
493
+ * cookbook got before this method existed. It is a precedence chain over two
494
+ * DECLARED sources, not a fallback for a missing one: the env value is itself
495
+ * required (`getTestNamespace()` throws when it is absent), and 32 of the 44
496
+ * cookbooks in the eight live biz repositories declare no workspace at all
497
+ * (measured 2026-09-14), so refusing here would fail the boot of six services
498
+ * the day the rule lands — `automation-gates.md` §3, a gate lands WITH
499
+ * compliance. The orchestrator reached the same conclusion for the same reason
500
+ * (d.332): "missing both → error" holds where the scope of the operation is
501
+ * known, and at boot it is not.
502
+ *
503
+ * WHAT A DECLARED VALUE STILL PASSES: `assertWorkspaceId`, the boundary owner
504
+ * in `utils/testNamespace.js`. A cookbook value that is not an id is refused,
505
+ * naming the key that carries it — never swapped for the environment value,
506
+ * which would hide the misconfiguration (architecture-principles.md §3). The
507
+ * tenant is not resolved here at all: it stays `getTestNamespace()`'s, so no
508
+ * cookbook can move the boot probe out of an allowed environment class
509
+ * (api/docs/standards/tenant-allocation.md § Enforcement).
510
+ *
511
+ * @param {Object} params
512
+ * @param {Object} params.step - the cookbook step, possibly carrying `workspace_id`
513
+ * @param {string} params.stepLabel - the step's name for messages (utils/stepFailure)
514
+ * @param {Object|null} params.cookbookDefaults - the cookbook's `defaults` block
515
+ * @param {number} params.envWorkspaceId - the platform namespace workspace
516
+ * @returns {number} the workspace id for this step
517
+ */
518
+ _resolveStepWorkspaceId({ step, stepLabel, cookbookDefaults, envWorkspaceId }) {
519
+ if (step && step.workspace_id !== undefined) {
520
+ return assertWorkspaceId(step.workspace_id, {
521
+ purpose: `cookbook step "${stepLabel}" workspace_id`,
522
+ setBy: 'in the cookbook'
523
+ });
524
+ }
525
+
526
+ if (cookbookDefaults && cookbookDefaults.workspace_id !== undefined) {
527
+ return assertWorkspaceId(cookbookDefaults.workspace_id, {
528
+ purpose: 'cookbook defaults.workspace_id',
529
+ setBy: 'in the cookbook'
530
+ });
531
+ }
532
+
533
+ return envWorkspaceId;
534
+ }
535
+
335
536
  /**
336
537
  * Handler dispatch: require handler module, build minimal real ctx, call
337
538
  * handler(input, ctx). Mirrors the production ContextBuilder shape
@@ -395,16 +596,12 @@ class CookbookTestRunner {
395
596
  };
396
597
  result.request = request;
397
598
 
398
- let handlerFn;
399
- try {
400
- const mod = require(spec.modulePath);
401
- handlerFn = mod[spec.exportName];
402
- if (typeof handlerFn !== 'function') {
403
- throw new Error(`[CookbookTestRunner] Handler ${spec.exportName} not exported from ${spec.modulePath}`);
404
- }
405
- } catch (error) {
406
- throw new Error(`[CookbookTestRunner] Failed to load handler ${spec.exportName} from ${spec.modulePath}: ${error.message}`);
407
- }
599
+ // The module was required and the export checked by `resolveOperation`,
600
+ // through `utils/handlerRef` — the same rule production dispatches by.
601
+ // Requiring it a second time here was the other half of the duplicated
602
+ // resolution, and it reported a failure the resolution had already ruled
603
+ // out.
604
+ const handlerFn = spec.handler;
408
605
 
409
606
  let timeoutId;
410
607
  const timeoutPromise = new Promise((_, reject) => {
@@ -526,16 +723,45 @@ class CookbookTestRunner {
526
723
  }
527
724
  }
528
725
 
529
- // Validate output
530
- if (expect.output && result.actual) {
726
+ // Validate output — whenever the cookbook declares expectations, whether or
727
+ // not the handler produced anything.
728
+ //
729
+ // The guard used to read `expect.output && result.actual`, so a step whose
730
+ // handler returned nothing skipped every field assertion and was judged on
731
+ // `status` alone: `validationErrors: []`, a green step over unchecked
732
+ // expectations (measured by BIZ-pdfgen 2026-08-29; `automation-gates.md`
733
+ // §5). `validateOutput` already answers correctly for an absent output — a
734
+ // required field is "missing in output", and an `exists: false` assertion is
735
+ // SATISFIED by no output — so the guard was what made those two cases
736
+ // indistinguishable.
737
+ if (expect.output) {
531
738
  const outputErrors = this.validateOutput(expect.output, result.actual);
532
739
  errors.push(...outputErrors);
533
740
  }
534
741
 
535
- // Validate error
536
- if (expect.error && result.error) {
537
- const errorErrors = this.validateError(expect.error, result.error);
538
- errors.push(...errorErrors);
742
+ // Validate error — whenever the cookbook declares one, whether or not the
743
+ // step actually failed.
744
+ //
745
+ // The sister defect of the `expect.output` guard one block up, and the same
746
+ // shape: `if (expect.error && result.error)` meant a step declaring
747
+ // `expect.error` over a handler that returned NORMALLY skipped the whole
748
+ // block. `expect.status` is optional, so a step declaring only `expect.error`
749
+ // came back with `validationErrors: []` — green, over an expectation nothing
750
+ // evaluated (`automation-gates.md` §5).
751
+ //
752
+ // Here the guard alone cannot be the fix: `validateError` reads `code` and
753
+ // `message` off the error, and in this case there is no error. The absence
754
+ // IS the violation, so it gets its own sentence rather than a comparison
755
+ // against undefined.
756
+ if (expect.error) {
757
+ if (result.error) {
758
+ errors.push(...this.validateError(expect.error, result.error));
759
+ } else {
760
+ const declared = expect.error.code === undefined ? '(any)' : expect.error.code;
761
+ errors.push(`[CookbookTestRunner] Expected error ${declared}, step succeeded - `
762
+ + 'Expected the handler to throw the declared error; it returned a result instead. '
763
+ + 'Fix: drive the step to the failing path it declares, or drop expect.error from the step.');
764
+ }
539
765
  }
540
766
 
541
767
  return {
@@ -545,13 +771,18 @@ class CookbookTestRunner {
545
771
  }
546
772
 
547
773
  /**
548
- * Validate output fields
774
+ * Validate output fields.
775
+ *
776
+ * A field name is a dot path into the output — see `resolveOutputField`
777
+ * above for the two rules it follows (exact key first, objects only) and
778
+ * for why a descriptor cannot be asserted any other way.
549
779
  */
550
780
  validateOutput(expected, actual) {
551
781
  const errors = [];
552
782
 
553
783
  for (const [field, expectation] of Object.entries(expected)) {
554
- const actualValue = actual[field];
784
+ const resolved = resolveOutputField(actual, field);
785
+ const actualValue = resolved.found ? resolved.value : undefined;
555
786
 
556
787
  // Check existence
557
788
  if (expectation.exists !== undefined) {
@@ -568,7 +799,16 @@ class CookbookTestRunner {
568
799
  // If field doesn't exist and we're not explicitly checking existence, error
569
800
  if (actualValue === undefined || actualValue === null) {
570
801
  if (expectation.exists === undefined) {
571
- errors.push(`Field ${field}: missing in output`);
802
+ // A path miss names the first segment that could not be resolved:
803
+ // "no pdf at all" and "a pdf without a size" are different defects
804
+ // and get different fixes. A name without a dot keeps the original
805
+ // wording — its first missing segment is the name itself, and
806
+ // repeating it would be noise.
807
+ errors.push(field.includes('.') && resolved.missingSegment !== undefined
808
+ ? `Field ${field}: missing in output - segment "${resolved.missingSegment}" not found. `
809
+ + 'Fix: assert a path that exists in the step output '
810
+ + '(a dot path walks nested objects, not array indices).'
811
+ : `Field ${field}: missing in output`);
572
812
  }
573
813
  continue;
574
814
  }
@@ -652,8 +892,24 @@ class CookbookTestRunner {
652
892
 
653
893
  /**
654
894
  * Resolve operation spec from operations.json. Returns the handler
655
- * dispatch descriptor: { modulePath, exportName, headers }.
656
- * Throws if the operation does not declare a v3 `handler`.
895
+ * dispatch descriptor: { modulePath, exportName, handler } — exactly what the
896
+ * dispatch reads, and nothing else. It carried `headers: operation.headers ||
897
+ * {}` until 2026-09-14, which no caller ever read: ctx.headers is built from
898
+ * `step.headers` (see `_dispatchViaHandler`), which is what the contract
899
+ * prescribes — api/docs/biz/10-invocation/operation-context.md § ctx.headers
900
+ * names the STEP, the operations.json rules in `service-validator-core` know
901
+ * no `headers` field, and not one of the eight live services declares it.
902
+ * Throws if the operation does not declare a v3 `handler`, or if that
903
+ * handler does not resolve.
904
+ *
905
+ * The ref is read by the shared rule in `utils/handlerRef` — the same one
906
+ * every shape check in this package uses. This method used to carry its own,
907
+ * weaker copy: it split on `#` without counting the separators, so
908
+ * `handlers/v3/good#run#extra` dispatched as `#run`, and it joined the path
909
+ * onto `<serviceRoot>/src` with no containment check, so `../outside#run`
910
+ * loaded and ran a module from outside the service source root. Cookbooks run
911
+ * at every biz service boot, which made that the loosest resolution on the
912
+ * platform sitting on the hottest path.
657
913
  */
658
914
  async resolveOperation(serviceName, operationName) {
659
915
  const serviceRoot = this._resolveServiceRoot(serviceName);
@@ -662,46 +918,67 @@ class CookbookTestRunner {
662
918
  const operationsPath = path.join(serviceRoot, 'config', 'service', 'operations.json');
663
919
 
664
920
  if (!fs.existsSync(operationsPath)) {
665
- throw new Error(`Operations file not found for service: ${serviceName} (expected config/service/operations.json)`);
921
+ throw new Error(`[CookbookTestRunner] Operations file not found for service: ${serviceName} `
922
+ + `(expected config/service/operations.json) - Expected the declaration every v3 service ships. `
923
+ + `Fix: create ${operationsPath} (api/docs/biz/30-operations/schema-v3.md § File shape).`);
666
924
  }
667
925
 
668
926
  const operations = JSON.parse(fs.readFileSync(operationsPath, 'utf8'));
669
927
  const operation = operations.operations && operations.operations[operationName];
670
928
 
671
929
  if (!operation) {
672
- throw new Error(`Operation not found: ${operationName}`);
930
+ throw new Error(`[CookbookTestRunner] Operation not found: ${operationName} - Expected a key of `
931
+ + `that name under "operations" in ${operationsPath}. Fix: declare the operation there, or `
932
+ + 'correct the operation name the cookbook step asks for.');
673
933
  }
674
934
 
675
935
  if (!operation.handler) {
676
936
  throw new Error(`[CookbookTestRunner] Operation ${serviceName}.${operationName} in ${operationsPath} is missing required v3 field "handler" ("path#exportName"). Fix: declare handler in operations.json per RFC §5.3.`);
677
937
  }
678
938
 
679
- const [modRel, exportName] = String(operation.handler).split('#');
680
- if (!modRel || !exportName) {
681
- throw new Error(`[CookbookTestRunner] Invalid handler spec "${operation.handler}" for ${serviceName}.${operationName}; expected "path#exportName"`);
682
- }
683
- const modulePath = require.resolve(path.join(serviceRoot, 'src', modRel));
939
+ parseHandlerRef(operation.handler);
940
+ const { modulePath, exportName, handler } = resolveHandlerModule({
941
+ serviceRoot,
942
+ handlerRef: operation.handler
943
+ });
944
+
684
945
  return {
685
946
  modulePath,
686
947
  exportName,
687
- headers: operation.headers || {}
948
+ handler
688
949
  };
689
950
  }
690
951
 
691
952
  /**
692
- * Resolve filesystem root for a service. The default resolver assumes
693
- * every cookbook step targets the runner's own service (this.servicePath).
694
- * Callers that need cross-service dispatch can subclass and override.
953
+ * Resolve the filesystem root for a `step.service`.
954
+ *
955
+ * `_servicePathByName` is the ONLY source: the constructor registers the
956
+ * primary service under the name it was constructed with, and
957
+ * `setServicePath` adds every other one. A name that is in neither is an
958
+ * error, never a guess.
959
+ *
960
+ * Until 2026-09-08 an unknown name fell back to `this.servicePath` on the
961
+ * comment "cookbooks may reference this.serviceName under a different
962
+ * alias". No such alias exists in the data — measured across the eight live
963
+ * business repositories, all 37 cookbook steps name their own
964
+ * `package.json` name, which is exactly what `scripts/run-pre-validation.js`
965
+ * passes as `serviceName`. What the fallback actually absorbed was a typo:
966
+ * `"servcie": "biz-hello"` ran biz-hello's handler and reported PASS, so
967
+ * Tier-1 validated a cookbook the production WorkflowOrchestrator would
968
+ * route somewhere else entirely. That is a fallback masking bad input
969
+ * (architecture-principles.md §3), resolved late instead of refused at the
970
+ * gate (§4).
695
971
  */
696
972
  _resolveServiceRoot(serviceName) {
697
973
  if (this._servicePathByName.has(serviceName)) {
698
974
  return this._servicePathByName.get(serviceName);
699
975
  }
700
- if (!this.servicePath) {
701
- throw new Error(`[CookbookTestRunner] No servicePath known for ${serviceName}; construct with servicePath or register via setServicePath(name, path)`);
702
- }
703
- // Cookbooks may reference this.serviceName under a different alias.
704
- return this.servicePath;
976
+
977
+ const registered = [...this._servicePathByName.keys()];
978
+ const known = registered.length > 0 ? registered.join(', ') : '(none registered)';
979
+ throw new Error(`[CookbookTestRunner] Unknown step.service "${serviceName}" - Expected one of: `
980
+ + `${known}. Fix: fix the cookbook's step.service, or register the service `
981
+ + 'via setServicePath(name, path).');
705
982
  }
706
983
 
707
984
  /**
@@ -720,7 +997,8 @@ class CookbookTestRunner {
720
997
  */
721
998
  loadCookbook(cookbookPath) {
722
999
  if (!fs.existsSync(cookbookPath)) {
723
- throw new Error(`Cookbook not found: ${cookbookPath}`);
1000
+ throw new Error(`[CookbookTestRunner] Cookbook not found: ${cookbookPath} - Expected a readable `
1001
+ + 'cookbook file at that path. Fix: create it, or correct the path the caller passed.');
724
1002
  }
725
1003
 
726
1004
  const content = fs.readFileSync(cookbookPath, 'utf8');
@@ -733,20 +1011,16 @@ class CookbookTestRunner {
733
1011
  }
734
1012
  }
735
1013
 
736
- /**
737
- * Normalize steps to array format
738
- * Supports V1 (steps as array) and V2 (steps as object keyed by step_id)
739
- */
740
- normalizeSteps(steps) {
741
- // The rule lives in utils/cookbookFormat — the same owner
742
- // CookbookTestUtils.validateCookbook now reads, so the two entry points
743
- // cannot disagree about what a cookbook's steps are.
744
- return normalizeCookbookSteps(steps);
745
- }
746
-
747
1014
  /**
748
1015
  * Validate cookbook format
749
- * Supports both V1 (steps as array) and V2 (steps as object) formats
1016
+ *
1017
+ * `steps` is an ARRAY of step objects — the only shape the format allows
1018
+ * (owner confirmation `cookbook-steps-shape` 001 of 2026-09-05). The runner
1019
+ * used to accept an object keyed by `step_id` and convert it, which made this
1020
+ * gate pass cookbooks the production WorkflowOrchestrator threw on. The shape
1021
+ * rule itself lives in `utils/cookbookFormat.readCookbookSteps`, the same
1022
+ * owner `CookbookTestUtils.validateCookbook` reads, so the two entry points
1023
+ * cannot disagree about what a cookbook's steps are.
750
1024
  *
751
1025
  * The format-version check is FIRST and unconditional. Until 2026-08-27 it
752
1026
  * lived only in `CookbookTestUtils.validateCookbook`, reachable exclusively
@@ -767,20 +1041,31 @@ class CookbookTestRunner {
767
1041
  throw new Error(`[CookbookTestRunner] ${versionProblem}`);
768
1042
  }
769
1043
 
770
- if (!cookbook.steps || (typeof cookbook.steps !== 'object' && !Array.isArray(cookbook.steps))) {
771
- throw new Error('Cookbook must have steps (array or object)');
1044
+ if (cookbook.steps === undefined || cookbook.steps === null) {
1045
+ throw new Error('[CookbookTestRunner] Cookbook has no steps - Expected "steps": [ … ] with at '
1046
+ + 'least one step object carrying "step_id". '
1047
+ + 'Fix: add the steps array (api/docs/biz/40-cookbooks/format.md § Required fields).');
772
1048
  }
773
1049
 
774
- // Normalize steps to array for validation
775
- const stepsArray = this.normalizeSteps(cookbook.steps);
1050
+ // Throws unless `steps` is the array the format requires.
1051
+ const stepsArray = readCookbookSteps(cookbook.steps);
776
1052
 
777
1053
  if (stepsArray.length === 0) {
778
- throw new Error('Cookbook must have at least one step');
1054
+ throw new Error('[CookbookTestRunner] Cookbook must have at least one step - Expected a non-empty '
1055
+ + 'steps array. Fix: add at least one step (api/docs/biz/40-cookbooks/format.md § Required fields).');
779
1056
  }
780
1057
 
781
1058
  for (const step of stepsArray) {
782
- if (!step.service) throw new Error(`Step ${step.step_id || 'unknown'} must have service`);
783
- if (!step.operation) throw new Error(`Step ${step.step_id || 'unknown'} must have operation`);
1059
+ if (!step.service) {
1060
+ throw new Error(`[CookbookTestRunner] Step ${step.step_id || 'unknown'} must have service - `
1061
+ + 'Expected the name of the service that runs the step. Fix: add "service" to that step '
1062
+ + '(api/docs/biz/40-cookbooks/format.md § Step definition).');
1063
+ }
1064
+ if (!step.operation) {
1065
+ throw new Error(`[CookbookTestRunner] Step ${step.step_id || 'unknown'} must have operation - `
1066
+ + 'Expected the operation that service exposes. Fix: add "operation" to that step '
1067
+ + '(api/docs/biz/40-cookbooks/format.md § Step definition).');
1068
+ }
784
1069
  }
785
1070
 
786
1071
  return true;
@@ -788,10 +1073,9 @@ class CookbookTestRunner {
788
1073
 
789
1074
  /**
790
1075
  * Check if cookbook has expect clauses
791
- * Supports both V1 (array) and V2 (object) step formats
792
1076
  */
793
1077
  hasExpectClauses(cookbook) {
794
- const stepsArray = this.normalizeSteps(cookbook.steps);
1078
+ const stepsArray = readCookbookSteps(cookbook.steps);
795
1079
  return stepsArray.some(step => step.expect !== undefined);
796
1080
  }
797
1081
 
@@ -806,12 +1090,35 @@ class CookbookTestRunner {
806
1090
  * Reset results
807
1091
  */
808
1092
  resetResults() {
809
- this.results = {
1093
+ this.results = this._emptyResults();
1094
+ }
1095
+
1096
+ /**
1097
+ * The aggregate a run starts from — one definition, read by the constructor
1098
+ * and by `resetResults()`. Two copies is how `cookbooks` would end up counted
1099
+ * in one of them and not the other.
1100
+ *
1101
+ * `total`/`passed`/`failed` count STEPS and always did. `cookbooks` counts
1102
+ * cookbooks, and it exists because the boot line said "Cookbook tests: X/Y"
1103
+ * about step numbers: three cookbooks of one step each read as "3/3", so the
1104
+ * day one of them gained a second step the number rose without a cookbook
1105
+ * being added (measured by BIZ-converter 2026-08-27). Both facts are real and
1106
+ * they answer different questions — how much was exercised, and how much
1107
+ * passed — so both are counted and the line names which is which
1108
+ * (`automation-gates.md` §5: a label that disagrees with its measurement is a
1109
+ * false guarantee, not untidiness).
1110
+ *
1111
+ * @returns {{total: number, passed: number, failed: number, duration: number,
1112
+ * steps: Array, cookbooks: {total: number, passed: number, failed: number}}}
1113
+ */
1114
+ _emptyResults() {
1115
+ return {
810
1116
  total: 0,
811
1117
  passed: 0,
812
1118
  failed: 0,
813
1119
  duration: 0,
814
- steps: []
1120
+ steps: [],
1121
+ cookbooks: { total: 0, passed: 0, failed: 0 }
815
1122
  };
816
1123
  }
817
1124
  }