@onlineapps/conn-orch-validator 10.0.0 → 11.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.
@@ -10,6 +10,8 @@ const {
10
10
  describeStepFailure, describeStepIdentity, KIND_STEP, KIND_COOKBOOK_LOAD_FAILURE
11
11
  } = require('./utils/stepFailure');
12
12
  const { parseHandlerRef, resolveHandlerModule } = require('./utils/handlerRef');
13
+ const { createRunContext, resolveStepInput, recordStepOutcome, checkStepInputExpressions }
14
+ = require('./utils/stepReferences');
13
15
  const { assertLogger } = require('@onlineapps/logger-contract');
14
16
 
15
17
  /**
@@ -168,6 +170,17 @@ class CookbookTestRunner {
168
170
  // along, so the catching layer still sees the original error object.
169
171
  try {
170
172
  this.validateCookbook(cookbookData);
173
+
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.
180
+ const referenceProblem = await checkStepInputExpressions(readCookbookSteps(cookbookData.steps));
181
+ if (referenceProblem !== null) {
182
+ throw new Error(`[CookbookTestRunner] ${referenceProblem}`);
183
+ }
171
184
  } catch (error) {
172
185
  if (typeof cookbook === 'string') {
173
186
  throw new Error(`${error.message} (cookbook file: ${cookbook})`, { cause: error });
@@ -190,8 +203,19 @@ class CookbookTestRunner {
190
203
  // Execute steps
191
204
  const cookbookName = cookbookData.description || 'Unnamed';
192
205
  const stepResults = [];
206
+ // What the steps of THIS cookbook have produced so far — the scope a
207
+ // `{{steps.<step_id>.output.…}}` reference resolves against
208
+ // (src/utils/stepReferences.js). It is built here, per cookbook, because
209
+ // `runCookbooks()` drives many cookbooks through one runner and a step of
210
+ // one must never see a step of another.
211
+ const runContext = createRunContext();
193
212
  for (const [stepIndex, step] of stepsArray.entries()) {
194
- const stepResult = await this.executeStep(step, testConfig, stepIndex, cookbookData.defaults);
213
+ const stepResult = await this.executeStep(step, testConfig, stepIndex, cookbookData.defaults, runContext);
214
+ // Every step is recorded — passed or failed. `recordStepOutcome` decides
215
+ // what an entry carries; a failed step keeps its status and no output, so
216
+ // a reference into it stays literal (the section "Runtime context
217
+ // (`context.steps`)" of api/docs/biz/40-cookbooks/format.md).
218
+ recordStepOutcome(runContext, step, stepResult);
195
219
  // Which cookbook this step came from. Without it a failed step in the
196
220
  // aggregate is an id with no file behind it, and a service can carry a
197
221
  // dozen cookbooks (utils/stepFailure.js).
@@ -385,9 +409,21 @@ class CookbookTestRunner {
385
409
  }
386
410
 
387
411
  /**
388
- * Execute single step
412
+ * Execute single step.
413
+ *
414
+ * @param {Object} step - the cookbook step as written
415
+ * @param {Object} testConfig - the cookbook's `test` block
416
+ * @param {number} [stepIndex] - the step's position, for messages
417
+ * @param {Object|null} [cookbookDefaults] - the cookbook's `defaults` block
418
+ * @param {{steps: Object}} [runContext] - what earlier steps of this cookbook
419
+ * produced (`utils/stepReferences`). A step executed on its own has no
420
+ * earlier steps, so the default is an EMPTY context — the accurate
421
+ * value for that case, not a stand-in for a missing one: every
422
+ * `{{steps.…}}` reference then resolves to nothing and survives as
423
+ * literal text, exactly as the format prescribes.
424
+ * @returns {Promise<Object>} the step result record
389
425
  */
390
- async executeStep(step, testConfig, stepIndex = 0, cookbookDefaults = null) {
426
+ async executeStep(step, testConfig, stepIndex = 0, cookbookDefaults = null, runContext = createRunContext()) {
391
427
  const startTime = Date.now();
392
428
 
393
429
  // ONE label for this step, resolved once. `step.id` alone was undefined for
@@ -460,8 +496,16 @@ class CookbookTestRunner {
460
496
  // { modulePath, exportName } (v3 handler dispatch is the only mode).
461
497
  const spec = await this.resolveOperation(step.service, step.operation);
462
498
 
499
+ // WHAT THE HANDLER IS ACTUALLY CALLED WITH. The step's `input` is the
500
+ // cookbook's text; a `{{steps.<step_id>.output.…}}` reference in it is
501
+ // resolved HERE, against what the earlier steps of this cookbook returned.
502
+ // Inside the try, so an evaluator error becomes this step's failure and
503
+ // not the whole run's.
504
+ const input = await resolveStepInput(step, runContext);
505
+
463
506
  await this._dispatchViaHandler({
464
507
  step,
508
+ input,
465
509
  spec,
466
510
  testConfig,
467
511
  validationTenantId,
@@ -562,7 +606,7 @@ class CookbookTestRunner {
562
606
  * all connector slots set to null — handlers that need DB access use
563
607
  * direct sequelize import per biz/60-templates/onboarding-checklist.md §9a.
564
608
  */
565
- async _dispatchViaHandler({ step, spec, testConfig, validationTenantId, validationWorkspaceId, result, startTime }) {
609
+ async _dispatchViaHandler({ step, input, spec, testConfig, validationTenantId, validationWorkspaceId, result, startTime }) {
566
610
  const stepTimeout = testConfig.timeout || this.timeout;
567
611
  const correlationId = step.correlation_id || crypto.randomUUID();
568
612
  // Production ctx (from ContextBuilder) always carries workflow_id + person_id.
@@ -619,7 +663,7 @@ class CookbookTestRunner {
619
663
  operation: step.operation,
620
664
  module: spec.modulePath,
621
665
  export: spec.exportName,
622
- input: step.input,
666
+ input,
623
667
  ctx: {
624
668
  tenant_id: ctx.tenant_id,
625
669
  workspace_id: ctx.workspace_id,
@@ -656,7 +700,7 @@ class CookbookTestRunner {
656
700
  let handlerOutput = null;
657
701
  try {
658
702
  handlerOutput = await Promise.race([
659
- Promise.resolve().then(() => handlerFn(step.input || {}, ctx)),
703
+ Promise.resolve().then(() => handlerFn(input || {}, ctx)),
660
704
  timeoutPromise
661
705
  ]);
662
706
  } catch (error) {
@@ -25,6 +25,7 @@ const { runManifest } = require('./manifest/runManifest');
25
25
  const { resolveWorkspaceRoot } = require('./manifest/workspaceRoot');
26
26
  const { renderBanner, describeFinding } = require('./manifest/report');
27
27
  const { buildDeployabilitySignal, writeDeployabilitySignal } = require('./manifest/deployabilitySignal');
28
+ const { isGitCheckout, NOT_A_CHECKOUT } = require('./manifest/gitCheckout');
28
29
 
29
30
  /**
30
31
  * The manifest rows step 2 answers: the configuration documents a biz service is
@@ -76,6 +77,59 @@ function readStepMeasurement(step, field, stepName) {
76
77
  return value;
77
78
  }
78
79
 
80
+ /**
81
+ * The step that produced a finding, carried ON the finding.
82
+ *
83
+ * `results.errors` is one flat array: step 1 pushes objects
84
+ * `{ type, path, message, fix }` and steps 2-7 push sentences they have already
85
+ * written in the `[Context] Problem - Expected/Fix` shape. Nothing in it said
86
+ * WHICH step a finding came from, so the only reader that needs to know — the
87
+ * wrapper, which retries a transient step and refuses a static one — had to read
88
+ * `results.steps` instead and could say nothing about an individual finding.
89
+ *
90
+ * The marker is ADDED, never substituted: a sentence stays the same sentence and
91
+ * an object keeps every field it had, because `describeValidationFinding()` of
92
+ * `@onlineapps/service-wrapper` 9.0.x renders a string verbatim and an object
93
+ * only when it carries `type` and `message` — anything else reaches the operator
94
+ * as raw JSON. Rewriting the sentences into `{ step, message }` objects would
95
+ * therefore break the released wrapper, so the sentences keep their shape until
96
+ * a wrapper that reads `step` is published (d.602; the report to BIZ-general).
97
+ *
98
+ * The findings are COPIED rather than stamped in place: the step's own
99
+ * `results.steps.<step>.errors` is its answer and stays untouched.
100
+ *
101
+ * @param {Array<(string|object)>} findings what a step reported
102
+ * @param {string} step the step's key in `results.steps`
103
+ * @returns {Array<(string|object)>} the same findings, objects carrying `step`
104
+ */
105
+ function attributeToStep(findings, step) {
106
+ if (!Array.isArray(findings)) {
107
+ // The TypeError this replaces reported itself instead of the finding: step 6
108
+ // spread a field the step does not have and aborted the whole run with
109
+ // "results.steps.connectors.warnings is not iterable" (F12, 2026-08-22).
110
+ throw new Error(`[ValidationOrchestrator] Step "${step}" reported findings that are not an array - `
111
+ + `got ${JSON.stringify(findings)}, expected an array. `
112
+ + `Fix: return "errors" as an array from step "${step}".`);
113
+ }
114
+
115
+ return findings.map((finding) => (
116
+ finding !== null && typeof finding === 'object' ? { ...finding, step } : finding
117
+ ));
118
+ }
119
+
120
+ /**
121
+ * The finding of a step whose body THREW — an object, so that it carries `step`,
122
+ * and with `type` so the released wrapper renders it as a sentence rather than
123
+ * as JSON.
124
+ *
125
+ * @param {string} step the step's key in `results.steps`
126
+ * @param {string} message the failure, in the `[Context] Problem - Fix` shape
127
+ * @returns {{type: string, step: string, message: string}}
128
+ */
129
+ function stepThrewFinding(step, message) {
130
+ return { type: 'STEP_THREW', step, message };
131
+ }
132
+
79
133
  /**
80
134
  * ValidationOrchestrator
81
135
  *
@@ -383,22 +437,27 @@ class ValidationOrchestrator {
383
437
 
384
438
  try {
385
439
  // Step 1: Service Structure
386
- this.logger.info('[ValidationOrchestrator] Step 1/7: Service Structure');
387
- results.steps.structure = await this.validateStructure();
388
- if (!results.steps.structure.valid) {
440
+ const structureFailed = await this.runStep(results, 'structure', async () => {
441
+ this.logger.info('[ValidationOrchestrator] Step 1/7: Service Structure');
442
+ results.steps.structure = await this.validateStructure();
443
+ if (results.steps.structure.valid) return false;
444
+
389
445
  results.success = false;
390
- results.errors.push(...results.steps.structure.errors);
391
- // Fail fast - can't continue without proper structure
392
- return this.finalizeResults(results, startTime);
393
- }
446
+ results.errors.push(...attributeToStep(results.steps.structure.errors, 'structure'));
447
+ return true;
448
+ });
449
+ // Fail fast - can't continue without proper structure
450
+ if (structureFailed) return this.finalizeResults(results, startTime);
394
451
 
395
452
  // Step 2: Config Files
396
- this.logger.info('[ValidationOrchestrator] Step 2/7: Config Files');
397
- results.steps.config = await this.validateConfig();
398
- if (!results.steps.config.valid) {
453
+ await this.runStep(results, 'config', async () => {
454
+ this.logger.info('[ValidationOrchestrator] Step 2/7: Config Files');
455
+ results.steps.config = await this.validateConfig();
456
+ if (results.steps.config.valid) return;
457
+
399
458
  results.success = false;
400
- results.errors.push(...results.steps.config.errors);
401
- }
459
+ results.errors.push(...attributeToStep(results.steps.config.errors, 'config'));
460
+ });
402
461
 
403
462
  // Step 3: Environment Contract
404
463
  //
@@ -407,42 +466,53 @@ class ValidationOrchestrator {
407
466
  // start without must fail here, where its name and the reason it exists
408
467
  // are both at hand. SECRETS_MASTER_KEY used to fail after MQ
409
468
  // registration, several layers away from the declaration (defect D2).
410
- this.logger.info('[ValidationOrchestrator] Step 3/7: Environment Contract');
411
- results.steps.env = this.validateEnvContract();
412
- if (!results.steps.env.valid) {
469
+ const envFailed = await this.runStep(results, 'env', () => {
470
+ this.logger.info('[ValidationOrchestrator] Step 3/7: Environment Contract');
471
+ results.steps.env = this.validateEnvContract();
472
+ if (results.steps.env.valid) return false;
473
+
413
474
  results.success = false;
414
- results.errors.push(...results.steps.env.errors);
415
- // Fail fast — running handlers without the environment they declared
416
- // produces a second, less honest error somewhere further in.
417
- return this.finalizeResults(results, startTime);
418
- }
475
+ results.errors.push(...attributeToStep(results.steps.env.errors, 'env'));
476
+ return true;
477
+ });
478
+ // Fail fast — running handlers without the environment they declared
479
+ // produces a second, less honest error somewhere further in.
480
+ if (envFailed) return this.finalizeResults(results, startTime);
419
481
 
420
482
  // Step 4: Operations Compliance
421
- this.logger.info('[ValidationOrchestrator] Step 4/7: Operations Compliance');
422
- results.steps.operations = await this.validateOperations();
423
- results.warnings.push(...(results.steps.operations.warnings || []));
424
- if (!results.steps.operations.valid) {
483
+ await this.runStep(results, 'operations', async () => {
484
+ this.logger.info('[ValidationOrchestrator] Step 4/7: Operations Compliance');
485
+ results.steps.operations = await this.validateOperations();
486
+ results.warnings.push(...attributeToStep(results.steps.operations.warnings || [], 'operations'));
487
+ if (results.steps.operations.valid) return;
488
+
425
489
  results.success = false;
426
- results.errors.push(...results.steps.operations.errors);
427
- }
490
+ results.errors.push(...attributeToStep(results.steps.operations.errors, 'operations'));
491
+ });
428
492
 
429
493
  // Step 5: Cookbook Tests
430
- this.logger.info('[ValidationOrchestrator] Step 5/7: Cookbook Tests');
431
- results.steps.cookbooks = await this.runCookbookTests();
432
- // Measured values only — see readStepMeasurement() for why `|| 0` here
433
- // was the reason the generator's refusal never fired.
434
- results.totalTests += readStepMeasurement(results.steps.cookbooks, 'total', 'cookbooks');
435
- results.passedTests += readStepMeasurement(results.steps.cookbooks, 'passed', 'cookbooks');
436
- results.failedTests += readStepMeasurement(results.steps.cookbooks, 'failed', 'cookbooks');
437
- if (!results.steps.cookbooks.success) {
494
+ await this.runStep(results, 'cookbooks', async () => {
495
+ this.logger.info('[ValidationOrchestrator] Step 5/7: Cookbook Tests');
496
+ results.steps.cookbooks = await this.runCookbookTests();
497
+ // Measured values only — see readStepMeasurement() for why `|| 0` here
498
+ // was the reason the generator's refusal never fired. The refusal now
499
+ // fails THIS step by name instead of surfacing as a step-less
500
+ // "Validation error" (d.602).
501
+ results.totalTests += readStepMeasurement(results.steps.cookbooks, 'total', 'cookbooks');
502
+ results.passedTests += readStepMeasurement(results.steps.cookbooks, 'passed', 'cookbooks');
503
+ results.failedTests += readStepMeasurement(results.steps.cookbooks, 'failed', 'cookbooks');
504
+ if (results.steps.cookbooks.success) return;
505
+
438
506
  results.success = false;
439
- results.errors.push(...(results.steps.cookbooks.errors || []));
440
- }
507
+ results.errors.push(...attributeToStep(results.steps.cookbooks.errors || [], 'cookbooks'));
508
+ });
441
509
 
442
510
  // Step 6: Connector Integration
443
- this.logger.info('[ValidationOrchestrator] Step 6/7: Connector Integration');
444
- results.steps.connectors = this.validateConnectors();
445
- if (!results.steps.connectors.valid) {
511
+ await this.runStep(results, 'connectors', () => {
512
+ this.logger.info('[ValidationOrchestrator] Step 6/7: Connector Integration');
513
+ results.steps.connectors = this.validateConnectors();
514
+ if (results.steps.connectors.valid) return;
515
+
446
516
  // Severity is unchanged: the connector contract stays non-critical and
447
517
  // its findings are reported as warnings, not as validation errors.
448
518
  //
@@ -453,8 +523,8 @@ class ValidationOrchestrator {
453
523
  // — threw "results.steps.connectors.warnings is not iterable", aborted
454
524
  // the whole run, and reported that TypeError in place of every finding.
455
525
  // Reproduced against biz-pdfgen and biz-property on 2026-08-22.
456
- results.warnings.push(...results.steps.connectors.errors);
457
- }
526
+ results.warnings.push(...attributeToStep(results.steps.connectors.errors, 'connectors'));
527
+ });
458
528
 
459
529
  // Step 7: Manifest conformance
460
530
  //
@@ -467,15 +537,17 @@ class ValidationOrchestrator {
467
537
  // the service running and marks it undeployable — the banner says so at
468
538
  // the end of this log and `ci/deployability.json` says so to
469
539
  // `deploy-production`.
470
- this.logger.info('[ValidationOrchestrator] Step 7/7: Manifest conformance');
471
- results.steps.manifest = this.validateManifestConformance();
472
- results.deployable = results.steps.manifest.deployable;
473
- results.deployFindings = results.steps.manifest.deployFindings;
474
- results.notRun = results.steps.manifest.notRun;
475
- if (!results.steps.manifest.valid) {
540
+ await this.runStep(results, 'manifest', () => {
541
+ this.logger.info('[ValidationOrchestrator] Step 7/7: Manifest conformance');
542
+ results.steps.manifest = this.validateManifestConformance();
543
+ results.deployable = results.steps.manifest.deployable;
544
+ results.deployFindings = results.steps.manifest.deployFindings;
545
+ results.notRun = results.steps.manifest.notRun;
546
+ if (results.steps.manifest.valid) return;
547
+
476
548
  results.success = false;
477
- results.errors.push(...results.steps.manifest.errors);
478
- }
549
+ results.errors.push(...attributeToStep(results.steps.manifest.errors, 'manifest'));
550
+ });
479
551
 
480
552
  // Finalize and generate proof if successful
481
553
  return await this.finalizeResults(results, startTime);
@@ -483,11 +555,74 @@ class ValidationOrchestrator {
483
555
  } catch (error) {
484
556
  this.logger.error(`[ValidationOrchestrator] Validation failed: ${error.message}`);
485
557
  results.success = false;
486
- results.errors.push(`Validation error: ${error.message}`);
558
+ // A failure that belongs to NO step — `finalizeResults` is the only code
559
+ // left out here. Everything raised inside a step is already recorded under
560
+ // that step's name by runStep(), which marks the error on its way out, so
561
+ // adding this sentence as well would report one failure twice and the
562
+ // second copy would carry no step at all (d.602).
563
+ if (typeof error.validationStep !== 'string') {
564
+ results.errors.push(`Validation error: ${error.message}`);
565
+ }
487
566
  return this.finalizeResults(results, startTime);
488
567
  }
489
568
  }
490
569
 
570
+ /**
571
+ * Run one step of the validation and GUARANTEE its record in `results.steps`.
572
+ *
573
+ * Each step method catches what its own work raises and answers
574
+ * `{ valid: false, … }` by its own name. This is the net under all of them: an
575
+ * exception from the logger, from the aggregate that reads a step's
576
+ * measurements, or from a step's findings being something other than an array
577
+ * used to escape to the catch of runFullValidation(), which pushed
578
+ * `Validation error: <message>` and left `results.steps.<step>` ABSENT.
579
+ *
580
+ * That absence is not a cosmetic gap. `@onlineapps/service-wrapper` 9.0.x
581
+ * decides whether a failed FÁZE 0.2 may be retried by reading
582
+ * `result.steps.<step>.valid` (its `STATIC_VALIDATION_STEPS`), and a step with
583
+ * no record reads as "the validator threw" — transient — so a structural
584
+ * defect bought the full six-attempt budget: 18,5 minutes of a boot that
585
+ * cannot succeed (measured on biz-converter, W591).
586
+ *
587
+ * The run still STOPS where it stopped before: the error is re-thrown, marked,
588
+ * and the outer catch finalizes exactly as it did.
589
+ *
590
+ * @param {object} results the run being assembled
591
+ * @param {string} step the step's key in `results.steps`
592
+ * @param {function(): *} block the step, its harvest included
593
+ * @returns {Promise<*>} whatever the block returned
594
+ */
595
+ async runStep(results, step, block) {
596
+ try {
597
+ return await block();
598
+ } catch (error) {
599
+ const finding = stepThrewFinding(step,
600
+ `[ValidationOrchestrator] Step "${step}" threw - ${error.message} `
601
+ + `Fix: repair what that message names; step "${step}" reached no verdict of its own.`);
602
+
603
+ // What the step had already measured is KEPT: a throw during the harvest
604
+ // must not erase the counts or findings the step did produce.
605
+ const measured = results.steps[step];
606
+ const partial = measured !== null && typeof measured === 'object' ? measured : {};
607
+
608
+ results.steps[step] = {
609
+ ...partial,
610
+ // Both fields, because this net does not know which one the step
611
+ // answers with: steps 1-4, 6 and 7 report `valid`, step 5 reports
612
+ // `success`, and a reader of either must see the failure.
613
+ valid: false,
614
+ success: false,
615
+ threw: true,
616
+ errors: [...(Array.isArray(partial.errors) ? partial.errors : []), finding]
617
+ };
618
+ results.success = false;
619
+ results.errors.push(finding);
620
+
621
+ error.validationStep = step;
622
+ throw error;
623
+ }
624
+ }
625
+
491
626
  /**
492
627
  * Step 1: Validate service structure
493
628
  */
@@ -500,7 +635,8 @@ class ValidationOrchestrator {
500
635
  } catch (error) {
501
636
  return {
502
637
  valid: false,
503
- errors: [`Structure validation failed: ${error.message}`]
638
+ threw: true,
639
+ errors: [stepThrewFinding('structure', `Structure validation failed: ${error.message}`)]
504
640
  };
505
641
  }
506
642
  }
@@ -539,7 +675,8 @@ class ValidationOrchestrator {
539
675
  this.logger.error(`[ValidationOrchestrator] Step 2/7 could not run: ${error.message}`);
540
676
  return {
541
677
  valid: false,
542
- errors: [error.message],
678
+ threw: true,
679
+ errors: [stepThrewFinding('config', error.message)],
543
680
  findings: []
544
681
  };
545
682
  }
@@ -622,7 +759,8 @@ class ValidationOrchestrator {
622
759
  } catch (error) {
623
760
  return {
624
761
  valid: false,
625
- errors: [`Operations validation failed: ${error.message}`],
762
+ threw: true,
763
+ errors: [stepThrewFinding('operations', `Operations validation failed: ${error.message}`)],
626
764
  warnings: []
627
765
  };
628
766
  }
@@ -683,11 +821,12 @@ class ValidationOrchestrator {
683
821
  } catch (error) {
684
822
  return {
685
823
  success: false,
824
+ threw: true,
686
825
  total: 0,
687
826
  passed: 0,
688
827
  failed: 0,
689
828
  cookbooks: { total: 0, passed: 0, failed: 0 },
690
- errors: [`Cookbook tests failed: ${error.message}`]
829
+ errors: [stepThrewFinding('cookbooks', `Cookbook tests failed: ${error.message}`)]
691
830
  };
692
831
  }
693
832
  }
@@ -730,7 +869,7 @@ class ValidationOrchestrator {
730
869
  + `no ${path.relative(this.serviceRoot, contractFile)} in this service`);
731
870
  return { valid: true, skipped: true, errors: [] };
732
871
  }
733
- return { valid: false, errors: [error.message] };
872
+ return { valid: false, threw: true, errors: [stepThrewFinding('env', error.message)] };
734
873
  }
735
874
 
736
875
  if (declaration === null) {
@@ -766,7 +905,12 @@ class ValidationOrchestrator {
766
905
  config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
767
906
  requiredConnectors = JSON.parse(fs.readFileSync(contractFile, 'utf8')).requiredConnectors || {};
768
907
  } catch (error) {
769
- return { valid: false, errors: [`[ConnectorContract] Cannot read the connector declarations - ${error.message}`] };
908
+ return {
909
+ valid: false,
910
+ threw: true,
911
+ errors: [stepThrewFinding('connectors',
912
+ `[ConnectorContract] Cannot read the connector declarations - ${error.message}`)]
913
+ };
770
914
  }
771
915
 
772
916
  const result = verifyConnectorContract({ config, requiredConnectors, env: process.env });
@@ -788,18 +932,59 @@ class ValidationOrchestrator {
788
932
  * the signal carries them (`automation-gates.md` §5). There is no switch that
789
933
  * skips this step and none that moves the signal: one path, unbypassable.
790
934
  *
935
+ * OUTSIDE A GIT CHECKOUT THE STEP DOES NOT MEASURE. Owner decision 2026-09-17
936
+ * (confirmation `biz-service-manifest` 011): a production image is not a
937
+ * checkout and carries neither `docker-compose.yml` nor `README.md`, so the
938
+ * uniform measured there reports absences that say nothing about the
939
+ * repository — 12 findings for a healthy service. A verdict reached that way
940
+ * is not a strict check, it is a wrong answer, and `NOT DEPLOYABLE` printed
941
+ * from it discredits the rows that ARE true. What proves deployability is the
942
+ * CI job `validate-uniform`, from every commit, over the checkout
943
+ * (confirmation 010); a running container cannot prove it and no longer
944
+ * pretends to. The step then says NOT RUN in one line and `deployable` stays
945
+ * `null` — NOT MEASURED, which is a different statement from `false` and is
946
+ * why that field has three states.
947
+ *
791
948
  * Order inside the step is deliberate: the banner is printed BEFORE the file
792
949
  * is written, so a service root that cannot be written to still leaves the
793
950
  * human-readable verdict in the log.
794
951
  *
795
952
  * @see api/docs/governance/confirmations/biz-service-manifest.md §4
796
- * @returns {{valid: boolean, deployable: boolean, errors: string[], findings: object[],
953
+ * @returns {{valid: boolean, deployable: (boolean|null), errors: string[], findings: object[],
797
954
  * deployFindings: object[], notRun: object[], signalPath: string|null}}
798
955
  */
799
956
  validateManifestConformance() {
800
957
  try {
958
+ // The run happens first and costs nothing extra: step 2 has already asked
959
+ // for it (`manifestRun()` is computed once per validation), and a service
960
+ // root that vanished must still fail by its own name rather than be
961
+ // reported as "not a checkout".
801
962
  const result = this.manifestRun();
802
963
 
964
+ if (!isGitCheckout(this.serviceRoot)) {
965
+ // One line, and no table: naming the findings of a tree this run cannot
966
+ // read would be the false guarantee of `automation-gates.md` §5 in
967
+ // reverse — a reader acting on `README.md missing` inside a container
968
+ // would go looking for a file the image is not supposed to carry.
969
+ this.logger.info(`[ValidationOrchestrator] NOT RUN manifest conformance — ${NOT_A_CHECKOUT} `
970
+ + 'Deployability is proven per commit by the CI job `validate-uniform` over the checkout '
971
+ + '(confirmation biz-service-manifest 010).');
972
+
973
+ // No signal either: `ci/deployability.json` IS a verdict, and this run
974
+ // reached none. Writing one would hand `deploy-production` a file that
975
+ // looks like an answer. The checkout path writes it
976
+ // (`src/cli/oa-validate.js`), which is where the answer exists.
977
+ return {
978
+ valid: true,
979
+ deployable: null,
980
+ errors: [],
981
+ findings: [],
982
+ deployFindings: [],
983
+ notRun: [],
984
+ signalPath: null
985
+ };
986
+ }
987
+
803
988
  // The banner and `ci/deployability.json` describe the WHOLE uniform,
804
989
  // CONFIG_STEP_ROWS included: the verdict is about the tree, and a reader
805
990
  // (or `deploy-production`) must not conclude it is clean because the boot
@@ -844,8 +1029,9 @@ class ValidationOrchestrator {
844
1029
  this.logger.error(`[ValidationOrchestrator] Step 7/7 could not run: ${error.message}`);
845
1030
  return {
846
1031
  valid: false,
1032
+ threw: true,
847
1033
  deployable: false,
848
- errors: [error.message],
1034
+ errors: [stepThrewFinding('manifest', error.message)],
849
1035
  findings: [],
850
1036
  deployFindings: [],
851
1037
  notRun: [],