@onlineapps/conn-orch-validator 3.3.2 → 4.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.
@@ -2,35 +2,45 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { verifyConnectorContract } = require('./utils/connectorContract');
6
+ const { normalizeEnvDeclaration, collectEnvCoverage, verifyEnvPresence } = require('./utils/envContract');
5
7
  const { ValidationProofCodec, ValidationProofVerifier } = require('@onlineapps/service-validator-core');
6
8
  const FingerprintUtils = require('@onlineapps/service-validator-core').FingerprintUtils;
7
9
  const { ServiceStructureValidator } = require('./validators/ServiceStructureValidator');
8
- const ServiceReadinessValidator = require('./ServiceReadinessValidator');
10
+ const { describeStepFailureWithContext } = require('./utils/stepFailure');
9
11
  const CookbookTestRunner = require('./CookbookTestRunner');
10
12
 
11
13
  /**
12
14
  * ValidationOrchestrator
13
15
  *
14
16
  * Orchestrates complete service validation (Tier 1 Pre-Validation)
15
- * Runs 6-step validation process and generates validation proof.
17
+ * Runs a 6-step validation process and generates validation proof.
16
18
  *
17
19
  * Called automatically by ServiceWrapper during initialization.
20
+ *
21
+ * The steps are not the same six it ran before 2026-08-22. The readiness step
22
+ * is gone — its removal, and why nothing was lost with it, is explained above
23
+ * runFullValidation() — and the environment contract (step 3, F16) took a
24
+ * different place: a service now fails on a variable it declared it needs
25
+ * before any handler runs, instead of several layers later inside the wrapper.
26
+ *
27
+ * `options.serviceUrl` is accepted-and-ignored: existing callers
28
+ * (ServiceWrapper, the biz `scripts/run-pre-validation.js`) still pass one.
29
+ * It is not stored and not required. It fed the readiness step's
30
+ * `fetch(url + '/health')`, retired in 3.3.1 under ADR 0005; after that it was
31
+ * a *required* constructor option no line of code read, so a service whose
32
+ * SERVICE_URL was unset failed to boot on account of a value nobody wanted.
18
33
  */
19
34
  class ValidationOrchestrator {
20
35
  constructor(options = {}) {
21
36
  this.serviceRoot = options.serviceRoot;
22
37
  this.serviceName = options.serviceName;
23
38
  this.serviceVersion = options.serviceVersion;
24
- this.serviceUrl = options.serviceUrl; // NEW: Service URL for HTTP calls
25
39
  if (!options.logger || typeof options.logger.warn !== 'function') {
26
40
  throw new Error('[ValidationOrchestrator] Logger is required — Expected object with warn() method');
27
41
  }
28
42
  this.logger = options.logger;
29
43
 
30
- if (!this.serviceUrl) {
31
- throw new Error('[ValidationOrchestrator] serviceUrl is required');
32
- }
33
-
34
44
  // Paths — prefer new config/service/ layout, fall back to legacy conn-config/
35
45
  const newConfigDir = path.join(this.serviceRoot, 'config', 'service');
36
46
  const legacyConfigDir = path.join(this.serviceRoot, 'conn-config');
@@ -41,7 +51,6 @@ class ValidationOrchestrator {
41
51
 
42
52
  // Validators
43
53
  this.structureValidator = new ServiceStructureValidator(this.serviceRoot);
44
- this.readinessValidator = new ServiceReadinessValidator({ logger: this.logger });
45
54
  this.cookbookRunner = new CookbookTestRunner({
46
55
  servicePath: this.serviceRoot,
47
56
  serviceName: this.serviceName,
@@ -58,78 +67,25 @@ class ValidationOrchestrator {
58
67
  console.log('[ValidationOrchestrator] Starting validation...');
59
68
 
60
69
  // Check if proof exists and is valid
61
- const existingProof = await this.loadExistingProof();
62
- if (existingProof && await this.isProofValid(existingProof)) {
63
- console.log('[ValidationOrchestrator] Valid proof found, skipping validation');
64
- return {
65
- success: true,
66
- proofExists: true,
67
- proof: existingProof,
68
- skipped: true
69
- };
70
- }
71
-
70
+ // No proof cache. It skipped steps 1-3 and 5-6 — measured at 6 ms in total
71
+ // against 1 ms to compute the fingerprint that decided it, so it bought five
72
+ // milliseconds. It cost a window of up to seven days in which validation
73
+ // asserted something no longer true, which is how biz-converter's
74
+ // fresh-database failure stayed hidden for days.
75
+ //
76
+ // And it never worked: FingerprintUtils.generate() serialised with a
77
+ // replacer array, so the fingerprint only ever covered serviceVersion and
78
+ // never invalidated on a change to operations.json, config.json or the
79
+ // Dockerfile either. Removing it gives up no capability.
80
+ //
81
+ // Its original justification — an HTTP probe with retries in step 5 — was
82
+ // removed in 3.3.1 under ADR 0005. Audit: docs/archive/validation-proof-audit-2026-08.md
72
83
  // Run full validation
73
84
  console.log('[ValidationOrchestrator] No valid proof, running validation...');
74
85
  return await this.runFullValidation();
75
86
  }
76
87
 
77
- /**
78
- * Load existing validation proof from conn-runtime/
79
- */
80
- async loadExistingProof() {
81
- try {
82
- if (!fs.existsSync(this.proofPath)) {
83
- return null;
84
- }
85
-
86
- const content = fs.readFileSync(this.proofPath, 'utf8');
87
- const proof = JSON.parse(content);
88
-
89
- return proof;
90
- } catch (error) {
91
- console.warn(`[ValidationOrchestrator] Failed to load proof: ${error.message}`);
92
- return null;
93
- }
94
- }
95
-
96
- /**
97
- * Check if proof is still valid
98
- * Valid = correct signature + fingerprint + not expired
99
- */
100
- async isProofValid(proof) {
101
- try {
102
- if (!proof) {
103
- return false;
104
- }
105
-
106
- // Verify proof integrity + age using the shared core verifier defaults.
107
- // This MUST match what Tier-2 validator enforces to avoid PROOF_EXPIRED drift.
108
- const verifier = new ValidationProofVerifier();
109
- const verificationResult = verifier.verifyProof(proof);
110
-
111
- if (!verificationResult.valid) {
112
- console.log(`[ValidationOrchestrator] Proof validation failed: ${verificationResult.reason}`);
113
- return false;
114
- }
115
-
116
- // Calculate current fingerprint
117
- const currentFingerprint = await this.calculateFingerprint();
118
88
 
119
- // Check if fingerprint matches (stored in validationData.contractFingerprint)
120
- if (proof.validationData?.contractFingerprint !== currentFingerprint) {
121
- console.log('[ValidationOrchestrator] Fingerprint mismatch (service changed)');
122
- return false;
123
- }
124
-
125
- return true;
126
- } catch (error) {
127
- if (this.logger && typeof this.logger.error === 'function') {
128
- this.logger.error(`[ValidationOrchestrator] Proof validation error: ${error.message}`);
129
- }
130
- return false;
131
- }
132
- }
133
89
 
134
90
  /**
135
91
  * Calculate service fingerprint
@@ -164,6 +120,34 @@ class ValidationOrchestrator {
164
120
  infra.envTemplate = fs.readFileSync(envTemplateFile, 'utf8');
165
121
  }
166
122
 
123
+ // Cookbooks and seeds are validation INPUTS. Leaving them out let a proof
124
+ // outlive the very thing it attests to: editing a cookbook, or breaking
125
+ // the fixtures one depends on, kept a cached proof valid for up to seven
126
+ // days while the service booted green. That masked biz-converter's
127
+ // fresh-database boot failure for days. Step 4 now also runs every boot
128
+ // regardless (see validate()); this covers the cached structural steps.
129
+ // A missing directory means the service has none of these, which is
130
+ // legitimate. Any other read failure is not, and must reach the caller
131
+ // rather than silently reducing what the fingerprint covers.
132
+ const hashDirectory = (dir, extension) => {
133
+ let names;
134
+ try {
135
+ names = fs.readdirSync(dir);
136
+ } catch (err) {
137
+ if (err.code === 'ENOENT') return undefined;
138
+ throw err;
139
+ }
140
+ const files = names.filter((name) => name.endsWith(extension)).sort();
141
+ if (files.length === 0) return undefined;
142
+ return files.map((name) => `${name}:${fs.readFileSync(path.join(dir, name), 'utf8')}`).join('\n');
143
+ };
144
+
145
+ const cookbooks = hashDirectory(path.join(this.serviceRoot, 'tests', 'cookbooks'), '.json');
146
+ if (cookbooks !== undefined) infra.cookbooks = cookbooks;
147
+
148
+ const seeds = hashDirectory(path.join(this.serviceRoot, 'scripts', 'seed'), '.sql');
149
+ if (seeds !== undefined) infra.seeds = seeds;
150
+
167
151
  // Extract @onlineapps/* dependencies
168
152
  const deps = {};
169
153
  if (pkg.dependencies) {
@@ -190,7 +174,40 @@ class ValidationOrchestrator {
190
174
  }
191
175
 
192
176
  /**
193
- * Run full 6-step validation process
177
+ * Run the full 6-step validation process.
178
+ *
179
+ * There were six steps until 2026-08-22. Step 5, "Service Readiness",
180
+ * delegated to `ServiceReadinessValidator.validateReadiness({name, url,
181
+ * operations})` and reported a sixth-of-the-run verdict it never
182
+ * independently reached:
183
+ *
184
+ * - Its per-operation rules were the same set as step 3's, rule for rule
185
+ * (handler presence and shape, bundle_scope, input, output, the retired
186
+ * v2 fields) — the two loops were textual copies of one another.
187
+ * - Its only two extra guards, "operations must be an object" and "no
188
+ * operations defined", are step 2's `operations.json has no operations
189
+ * defined`.
190
+ * - Run against a valid baseline plus 13 mutations covering every rule,
191
+ * its verdict equalled `step2 && step3` in all 14 cases. Against the
192
+ * eight live biz services it emitted one check, scored 80 out of a
193
+ * maxScore of 100 that this call path could never reach (the 15-point
194
+ * cookbook and 5-point registry checks need arguments the orchestrator
195
+ * never passed), and compared that 80 against a `score >= 60` threshold
196
+ * the single remaining weight had made decorative. It measured 0 ms.
197
+ *
198
+ * So it could not fail a service that steps 2 and 3 passed. A step that
199
+ * announces PASS without evaluating anything of its own is the defect the
200
+ * connector step was fixed for in F12 (`dcddb862`); this is the same defect
201
+ * one step earlier, and the honest repair is removal, not a smaller lie.
202
+ *
203
+ * Its one non-duplicate signal — a warning for an operation with no
204
+ * `description` — never reached anyone: the step returned `{valid, errors}`
205
+ * and dropped `warnings` on the floor. That signal now lives in step 3 and
206
+ * is surfaced in `results.warnings`, so removal costs nothing.
207
+ *
208
+ * `ServiceReadinessValidator` itself stays: its cookbook and registry checks
209
+ * are used by `helpers/createServiceReadinessTests`, and through it by the
210
+ * `tests/bootstrap/` suites of the biz repos.
194
211
  */
195
212
  async runFullValidation() {
196
213
  const startTime = Date.now();
@@ -223,16 +240,34 @@ class ValidationOrchestrator {
223
240
  results.errors.push(...results.steps.config.errors);
224
241
  }
225
242
 
226
- // Step 3: Operations Compliance
227
- console.log('[ValidationOrchestrator] Step 3/6: Operations Compliance');
243
+ // Step 3: Environment Contract
244
+ //
245
+ // Before the cookbook tests run a single handler and long before the
246
+ // wrapper opens a connector: a variable the service declared it cannot
247
+ // start without must fail here, where its name and the reason it exists
248
+ // are both at hand. SECRETS_MASTER_KEY used to fail after MQ
249
+ // registration, several layers away from the declaration (defect D2).
250
+ console.log('[ValidationOrchestrator] Step 3/6: Environment Contract');
251
+ results.steps.env = this.validateEnvContract();
252
+ if (!results.steps.env.valid) {
253
+ results.success = false;
254
+ results.errors.push(...results.steps.env.errors);
255
+ // Fail fast — running handlers without the environment they declared
256
+ // produces a second, less honest error somewhere further in.
257
+ return this.finalizeResults(results, startTime);
258
+ }
259
+
260
+ // Step 4: Operations Compliance
261
+ console.log('[ValidationOrchestrator] Step 4/6: Operations Compliance');
228
262
  results.steps.operations = await this.validateOperations();
263
+ results.warnings.push(...(results.steps.operations.warnings || []));
229
264
  if (!results.steps.operations.valid) {
230
265
  results.success = false;
231
266
  results.errors.push(...results.steps.operations.errors);
232
267
  }
233
268
 
234
- // Step 4: Cookbook Tests
235
- console.log('[ValidationOrchestrator] Step 4/6: Cookbook Tests');
269
+ // Step 5: Cookbook Tests
270
+ console.log('[ValidationOrchestrator] Step 5/6: Cookbook Tests');
236
271
  results.steps.cookbooks = await this.runCookbookTests();
237
272
  results.totalTests += results.steps.cookbooks.total || 0;
238
273
  results.passedTests += results.steps.cookbooks.passed || 0;
@@ -242,20 +277,21 @@ class ValidationOrchestrator {
242
277
  results.errors.push(...(results.steps.cookbooks.errors || []));
243
278
  }
244
279
 
245
- // Step 5: Service Readiness
246
- console.log('[ValidationOrchestrator] Step 5/6: Service Readiness');
247
- results.steps.readiness = await this.validateReadiness();
248
- if (!results.steps.readiness.valid) {
249
- results.success = false;
250
- results.errors.push(...results.steps.readiness.errors);
251
- }
252
-
253
280
  // Step 6: Connector Integration
254
281
  console.log('[ValidationOrchestrator] Step 6/6: Connector Integration');
255
282
  results.steps.connectors = this.validateConnectors();
256
283
  if (!results.steps.connectors.valid) {
257
- // Non-critical - just warnings
258
- results.warnings.push(...results.steps.connectors.warnings);
284
+ // Severity is unchanged: the connector contract stays non-critical and
285
+ // its findings are reported as warnings, not as validation errors.
286
+ //
287
+ // The field they are read from is what changed. F12 (`dcddb862`) gave
288
+ // this step a real check returning {valid, errors}; this line still
289
+ // spread `.warnings`, which that shape does not have. So the only case
290
+ // the step exists to catch — a service that actually fails the contract
291
+ // — threw "results.steps.connectors.warnings is not iterable", aborted
292
+ // the whole run, and reported that TypeError in place of every finding.
293
+ // Reproduced against biz-pdfgen and biz-property on 2026-08-22.
294
+ results.warnings.push(...results.steps.connectors.errors);
259
295
  }
260
296
 
261
297
  // Finalize and generate proof if successful
@@ -331,18 +367,26 @@ class ValidationOrchestrator {
331
367
  * Step 3: Validate operations compliance (v3 — handler registry dispatch).
332
368
  * Required per operation: handler ('handlers/<path>#<export>'), bundle_scope, input, output.
333
369
  * Forbidden (v2): endpoint, method, path.
370
+ * Warned about: a missing `description` — inherited from the removed
371
+ * readiness step, which was the only place that noticed it and then threw
372
+ * the notice away.
334
373
  */
335
374
  async validateOperations() {
336
375
  try {
337
376
  const operationsFile = path.join(this.configPath, 'operations.json');
338
377
  const operations = JSON.parse(fs.readFileSync(operationsFile, 'utf8'));
339
378
  const errors = [];
379
+ const warnings = [];
340
380
 
341
381
  const validScopes = ['platform', 'tenant', 'workspace'];
342
382
  const handlerPattern = /^handlers\/[a-zA-Z0-9_\/-]+#[a-zA-Z_][a-zA-Z0-9_]*$/;
343
383
  const forbiddenV2Fields = ['endpoint', 'method', 'path'];
344
384
 
345
385
  for (const [opName, opDef] of Object.entries(operations.operations || {})) {
386
+ if (!opDef.description) {
387
+ warnings.push(`Operation ${opName}: missing description`);
388
+ }
389
+
346
390
  if (!opDef.handler) {
347
391
  errors.push(`Operation ${opName}: missing handler (v3 — 'handlers/<path>#<export>')`);
348
392
  } else if (!handlerPattern.test(opDef.handler)) {
@@ -369,15 +413,18 @@ class ValidationOrchestrator {
369
413
  }
370
414
  }
371
415
 
372
- console.log(`[ValidationOrchestrator] ✓ Operations compliance: ${errors.length === 0 ? 'PASS' : 'FAIL'}`);
416
+ console.log(`[ValidationOrchestrator] ✓ Operations compliance: ${errors.length === 0 ? 'PASS' : 'FAIL'}`
417
+ + `${warnings.length > 0 ? ` (${warnings.length} warning(s))` : ''}`);
373
418
  return {
374
419
  valid: errors.length === 0,
375
- errors: errors
420
+ errors: errors,
421
+ warnings: warnings
376
422
  };
377
423
  } catch (error) {
378
424
  return {
379
425
  valid: false,
380
- errors: [`Operations validation failed: ${error.message}`]
426
+ errors: [`Operations validation failed: ${error.message}`],
427
+ warnings: []
381
428
  };
382
429
  }
383
430
  }
@@ -406,12 +453,24 @@ class ValidationOrchestrator {
406
453
  const result = await this.cookbookRunner.runCookbooks(cookbooksPath);
407
454
 
408
455
  console.log(`[ValidationOrchestrator] ✓ Cookbook tests: ${result.passed}/${result.total} passed`);
456
+
457
+ // `N cookbook test(s) failed` was the whole error list until 2026-08-29:
458
+ // the count without a single name, so `Validation failed: 1 cookbook
459
+ // test(s) failed` was all the wrapper, the registry and the reader ever
460
+ // got. Every failing step now names itself and its reason
461
+ // (utils/stepFailure.js) — automation-gates.md §5.
462
+ const failedSteps = (result.steps || []).filter((step) => step.passed === false);
463
+ const details = failedSteps.map(describeStepFailureWithContext);
464
+ for (const detail of details) {
465
+ console.error(`[ValidationOrchestrator] ✗ ${detail}`);
466
+ }
467
+
409
468
  return {
410
469
  success: result.failed === 0,
411
470
  total: result.total,
412
471
  passed: result.passed,
413
472
  failed: result.failed,
414
- errors: result.failed > 0 ? [`${result.failed} cookbook test(s) failed`] : []
473
+ errors: result.failed > 0 ? [`${result.failed} cookbook test(s) failed`, ...details] : []
415
474
  };
416
475
  } catch (error) {
417
476
  return {
@@ -425,52 +484,86 @@ class ValidationOrchestrator {
425
484
  }
426
485
 
427
486
  /**
428
- * Step 5: Validate service readiness
487
+ * Step 3: Verify the environment contract — PRESENCE.
488
+ *
489
+ * The service declares the variables it needs in the same file where it
490
+ * declares its connectors and its database; this step checks that every name
491
+ * declared `required` is actually set. CI checks the other direction —
492
+ * COMPLETENESS, that nothing the repo reads is undeclared — because that one
493
+ * needs the repository, not the running environment.
494
+ * Rules and rationale: utils/envContract.js.
495
+ *
496
+ * A contract with no `env` block is skipped OUT LOUD. That state is
497
+ * legitimate until every repo adopts the declaration, but a step that says
498
+ * nothing about what it did not check reads as a guarantee it never gave
499
+ * (automation-gates.md §5).
500
+ *
501
+ * @returns {{valid: boolean, skipped?: boolean, errors: string[]}}
429
502
  */
430
- async validateReadiness() {
431
- try {
432
- // Load operations.json
433
- const operationsFile = path.join(this.configPath, 'operations.json');
434
- const operations = JSON.parse(fs.readFileSync(operationsFile, 'utf8'));
503
+ validateEnvContract() {
504
+ const contractFile = path.join(this.serviceRoot, 'config', 'service', 'integration-contract.json');
435
505
 
436
- const result = await this.readinessValidator.validateReadiness({
437
- name: this.serviceName,
438
- url: this.serviceUrl,
439
- operations: operations.operations
506
+ let declaration;
507
+ try {
508
+ const rawContract = JSON.parse(fs.readFileSync(contractFile, 'utf8'));
509
+ declaration = normalizeEnvDeclaration(rawContract.env, {
510
+ coverage: collectEnvCoverage({
511
+ serviceRoot: this.serviceRoot,
512
+ requiredConnectors: rawContract.requiredConnectors
513
+ })
440
514
  });
441
-
442
- console.log(`[ValidationOrchestrator] ✓ Service readiness: ${result.ready ? 'PASS' : 'FAIL'}`);
443
- return {
444
- valid: result.ready,
445
- errors: result.ready ? [] : result.errors || ['Service not ready']
446
- };
447
515
  } catch (error) {
448
- return {
449
- valid: false,
450
- errors: [`Readiness validation failed: ${error.message}`]
451
- };
516
+ // A missing or unreadable contract file is already reported by the
517
+ // connector step; what is new here is a declaration that exists and is
518
+ // wrong, and that must stop the boot rather than be worked around.
519
+ if (error.code === 'ENOENT') {
520
+ console.log('[ValidationOrchestrator] ⊘ Environment contract: SKIPPED — '
521
+ + `no ${path.relative(this.serviceRoot, contractFile)} in this service`);
522
+ return { valid: true, skipped: true, errors: [] };
523
+ }
524
+ return { valid: false, errors: [error.message] };
452
525
  }
526
+
527
+ if (declaration === null) {
528
+ console.log('[ValidationOrchestrator] ⊘ Environment contract: SKIPPED — '
529
+ + 'config/service/integration-contract.json declares no "env" block '
530
+ + '(F16 adoption pending; nothing is being checked here)');
531
+ return { valid: true, skipped: true, errors: [] };
532
+ }
533
+
534
+ const result = verifyEnvPresence({ declaration, env: process.env });
535
+ console.log(`[ValidationOrchestrator] ${result.valid ? '✓' : '✗'} Environment contract: `
536
+ + `${result.valid ? 'PASS' : 'FAIL'} (${result.checked.length} required name(s) checked: `
537
+ + `${result.checked.join(', ') || 'none'})`);
538
+ return { valid: result.valid, errors: result.errors };
453
539
  }
454
540
 
455
541
  /**
456
542
  * Step 6: Validate connector integration
457
543
  */
544
+ /**
545
+ * Verify the connector contract. Tier-1 runs in phase 0.2, before the wrapper
546
+ * opens anything, so nothing live can be probed — what is checked is that the
547
+ * service's two declarations agree and that the environment backs them.
548
+ * Rules and rationale: utils/connectorContract.js.
549
+ */
458
550
  validateConnectors() {
459
- try {
460
- // This is validated implicitly through cookbook tests
461
- // which test ServiceWrapper + all connectors
551
+ const configFile = path.join(this.configPath, 'config.json');
552
+ const contractFile = path.join(this.serviceRoot, 'config', 'service', 'integration-contract.json');
462
553
 
463
- console.log('[ValidationOrchestrator] Connector integration: PASS (via cookbook tests)');
464
- return {
465
- valid: true,
466
- warnings: []
467
- };
554
+ let config = {};
555
+ let requiredConnectors = {};
556
+ try {
557
+ config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
558
+ requiredConnectors = JSON.parse(fs.readFileSync(contractFile, 'utf8')).requiredConnectors || {};
468
559
  } catch (error) {
469
- return {
470
- valid: false,
471
- warnings: [`Connector validation warning: ${error.message}`]
472
- };
560
+ return { valid: false, errors: [`[ConnectorContract] Cannot read the connector declarations - ${error.message}`] };
473
561
  }
562
+
563
+ const result = verifyConnectorContract({ config, requiredConnectors, env: process.env });
564
+ console.log(`[ValidationOrchestrator] ${result.valid ? '✓' : '✗'} Connector contract: `
565
+ + `${result.valid ? 'PASS' : 'FAIL'} (${result.checked.length} checked: ${result.checked.join(', ') || 'none'})`);
566
+ return { valid: result.valid, errors: result.errors };
474
567
  }
475
568
 
476
569
  /**
@@ -515,7 +608,11 @@ class ValidationOrchestrator {
515
608
  }
516
609
  } else {
517
610
  console.error(`[ValidationOrchestrator] ❌ Validation FAILED (${duration}ms)`);
518
- console.error(`[ValidationOrchestrator] Errors: ${JSON.stringify(results.errors, null, 2)}(', ')}`);
611
+ // The trailing `(', ')}` was a leftover from a half-finished edit to a
612
+ // template literal: it printed on every failed validation as if it were
613
+ // part of the data. Found while reading this line for the biz-hello
614
+ // diagnosis (2026-08-29).
615
+ console.error(`[ValidationOrchestrator] Errors: ${JSON.stringify(results.errors, null, 2)}`);
519
616
  }
520
617
 
521
618
  return results;