@onlineapps/conn-orch-validator 4.0.0 → 5.0.0-rc.1

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,96 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
7
7
  > ⚠ Neither this entry nor the connector-step work of `dcddb862` is published.
8
8
  > The last version on NPM is 3.3.2, so **no biz CI run sees any of it yet**.
9
9
 
10
+ ### BREAKING — a cookbook step spelling its identifier `id` is refused (DÁVKA 77 A3)
11
+
12
+ `format.md` § Steps has banned `id` since v2.1; the code accepted it anyway, which
13
+ is the fallback `architecture-principles.md` §3 forbids. The whole tree was
14
+ measured compliant first (`automation-gates.md` §3 — the gate arrives with the
15
+ compliance, never ahead of it): 79 cookbook files, 78 pass, one does not.
16
+
17
+ ```
18
+ [CookbookFormat] step "<step_id|#N>" carries "id" - Expected: "step_id" only
19
+ (api/docs/biz/40-cookbooks/format.md § Steps). Fix: rename "id" to "step_id"
20
+ ```
21
+
22
+ - `normalizeCookbookSteps` throws on any step carrying `id`, with or without a
23
+ `step_id` beside it, and the V2 object shape no longer writes `id` back onto
24
+ the step it normalises.
25
+ - `CookbookTestUtils.validateCookbook` answers with a list rather than an
26
+ exception, so the refusal arrives there as one more entry in `errors`.
27
+ - `stepIdentityOf` reads `step_id` only.
28
+ - `createServiceReadinessTests` synthesised steps with `id`, so the helper
29
+ rejected its own output; it now emits `step_id`.
30
+
31
+ The single file that fails today is `api_biz/meta/tests/cookbooks/list-tenants.json`
32
+ (step `test_list_tenants`, carries both). This package is not published until it
33
+ is fixed.
34
+
35
+ ### BREAKING — a step result carries `step_id` only (DÁVKA 77 A)
36
+
37
+ `CookbookTestRunner` emitted both `id` and `step_id` on every step result. Two
38
+ names for one thing means every reader has to know both, and the one that was
39
+ `undefined` for conforming cookbooks is what reached a production log as
40
+ `Step undefined`. Measured before removing it: no run artefact carries a step
41
+ identifier at all — the validation proof, `conn-runtime/validation-failure.json`
42
+ and the integration-run artefact hold counts only — and the sole readers of
43
+ `result.id` in the workspace were inside this package.
44
+
45
+ - `result.id` is gone. Read `result.step_id`.
46
+ - `runPreValidation`'s `failedSteps[]` entries carry `step_id`, not `id`; the
47
+ `biz-ci-gate run-prevalidation` failure line reads it from there.
48
+ - `describeStepIdentity` no longer falls back to `id`: a step that carries one
49
+ is refused upstream, so reading it would only put the retired name back into
50
+ the diagnostics. Such a step is now named by position.
51
+
52
+ ### Changed (minor) — `service.port` is no longer required (DÁVKA 75 B)
53
+
54
+ ADR 0005 removed the HTTP surface from biz containers. Nothing listens, so the
55
+ port has no consumer — yet `ServiceStructureValidator` step 1 failed a service
56
+ that did not declare one, forcing every repo to keep a number nobody reads. That
57
+ is the inverse of `change-discipline.md` § "Removing something removes its
58
+ declaration".
59
+
60
+ - `service.port` is dropped from `validateConfigStructure`'s required fields. A
61
+ config without it now passes step 1.
62
+ - A config that still declares one gets a **warning**, `ORPHANED_CONFIG_FIELD`,
63
+ naming ADR 0005 and the fix (delete the field). Silence would leave the eight
64
+ stale declarations recorded in `api/docs/biz/00-model/service-shape.md`
65
+ invisible, which `automation-gates.md` §5 calls a defect. It never fails a
66
+ build.
67
+ - The conformant fixture no longer declares a port, and its README says why.
68
+
69
+ A relaxation, so **minor**: a config that passed before still passes.
70
+
71
+ ### Changed (minor) — one name for a step: `step_id` (DÁVKA 75 A)
72
+
73
+ `api/docs/biz/40-cookbooks/format.md` requires `step_id` and bans `id`. Measured
74
+ 2026-08-30 across the whole workspace — 101 steps in 77 cookbook files: 94 carry
75
+ `step_id` only, 6 carry `id` only, 1 carries both. All six `id`-only steps were
76
+ this package's own fixtures; the one carrying both is
77
+ `api_biz/meta/tests/cookbooks/list-tenants.json`, another repo's file.
78
+
79
+ - The four fixture cookbooks and the inline cookbooks in four suites now use
80
+ `step_id`, and the assertions read `result.step_id`.
81
+ - `CookbookTestUtils.validateCookbook` **rejected 100 % of the real cookbooks**:
82
+ it demanded `id`, and it knew only the array shape, so every V2 object-shaped
83
+ cookbook came back `"Steps array is required"`. It is reachable from
84
+ `ServiceReadinessValidator.checkCookbookExecution`, so a service using the
85
+ readiness wrapper was told its correct cookbooks were broken. Raw proof before
86
+ the fix: `tester-helpers.json` → `["Step 0: id is required", …×5]`,
87
+ `property/list-statements.json` → `["Steps array is required"]`.
88
+ - Both shapes now go through one owner, `utils/cookbookFormat.normalizeCookbookSteps`,
89
+ which `CookbookTestRunner.normalizeSteps` also delegates to — the two entry
90
+ points can no longer disagree about what a cookbook's steps are.
91
+ - `stepIdentityOf()` reads `step_id`, then `id`. **`id` is still accepted**;
92
+ rejecting it is a separate decision for the owner.
93
+ - What this package synthesises (`generateTestCookbook`, `createWorkflowMessage`,
94
+ `createControlFlowStep`) emits `step_id` only, and `compareCookbooks` reports
95
+ differences as `steps[i].step_id`.
96
+
97
+ `result.id` is still emitted by `CookbookTestRunner` alongside `result.step_id`.
98
+ Whether it survives is a result-contract change and is the owner's call.
99
+
10
100
  ### Fixed — validator findings pointed at an archived document (DÁVKA 51)
11
101
 
12
102
  `ServiceStructureValidator` sent readers of five findings to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/conn-orch-validator",
3
- "version": "4.0.0",
3
+ "version": "5.0.0-rc.1",
4
4
  "description": "Validation orchestrator for OA Drive microservices - coordinates validation across all layers (base, infra, orch, business)",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -24,12 +24,7 @@
24
24
  "author": "OnlineApps",
25
25
  "license": "PROPRIETARY",
26
26
  "dependencies": {
27
- "@onlineapps/service-validator-core": "1.0.14",
28
- "@onlineapps/runtime-config": "1.0.2",
29
- "ajv": "^8.12.0",
30
- "ajv-formats": "^2.1.1",
31
- "amqplib": "^0.10.9",
32
- "joi": "^17.9.0"
27
+ "@onlineapps/service-validator-core": "1.0.15"
33
28
  },
34
29
  "devDependencies": {
35
30
  "jest": "^29.5.0"
@@ -6,9 +6,9 @@ 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 } = require('./utils/cookbookFormat');
9
+ const { checkCookbookFormatVersion, normalizeCookbookSteps } = require('./utils/cookbookFormat');
10
10
  const { getTestNamespace } = require('./utils/testNamespace');
11
- const { describeStepFailure } = require('./utils/stepFailure');
11
+ const { describeStepFailure, describeStepIdentity } = require('./utils/stepFailure');
12
12
 
13
13
  /**
14
14
  * The step's stopwatch, spelled out. A single total hid the fact that
@@ -124,8 +124,8 @@ class CookbookTestRunner {
124
124
  // Execute steps
125
125
  const cookbookName = cookbookData.description || 'Unnamed';
126
126
  const stepResults = [];
127
- for (const step of stepsArray) {
128
- const stepResult = await this.executeStep(step, testConfig);
127
+ for (const [stepIndex, step] of stepsArray.entries()) {
128
+ const stepResult = await this.executeStep(step, testConfig, stepIndex);
129
129
  // Which cookbook this step came from. Without it a failed step in the
130
130
  // aggregate is an id with no file behind it, and a service can carry a
131
131
  // dozen cookbooks (utils/stepFailure.js).
@@ -210,13 +210,23 @@ class CookbookTestRunner {
210
210
  /**
211
211
  * Execute single step
212
212
  */
213
- async executeStep(step, testConfig) {
213
+ async executeStep(step, testConfig, stepIndex = 0) {
214
214
  const startTime = Date.now();
215
215
 
216
- this.logger.info(`Executing step: ${step.id || 'unnamed'} (${step.operation})`);
216
+ // ONE label for this step, resolved once. `step.id` alone was undefined for
217
+ // every cookbook that follows the required v2.1 shape (`step_id`), so the
218
+ // live biz-converter log read `Step undefined: FAILED …` about a cookbook
219
+ // that is correct. Owner of the rule: utils/stepFailure.js.
220
+ const stepLabel = describeStepIdentity(step, stepIndex);
221
+
222
+ this.logger.info(`Executing step: ${stepLabel} (${step.operation})`);
217
223
 
218
224
  const result = {
219
- id: step.id,
225
+ // The identifier the format requires, plus the position, so the aggregate
226
+ // error list can name the step without re-deriving anything. `id` was
227
+ // emitted alongside it until 2026-08-30; one thing, one name.
228
+ step_id: step.step_id,
229
+ stepIndex,
220
230
  operation: step.operation,
221
231
  passed: false,
222
232
  // Three separate numbers, because they answer different questions.
@@ -273,13 +283,13 @@ class CookbookTestRunner {
273
283
  });
274
284
 
275
285
  if (result.passed) {
276
- this.logger.info(`Step ${step.id}: PASSED (${describeStepTiming(result)})`);
286
+ this.logger.info(`Step ${stepLabel}: PASSED (${describeStepTiming(result)})`);
277
287
  } else {
278
288
  // A verdict without its reason is a silent gate (automation-gates.md
279
289
  // §5). Until 2026-08-29 this line said only "FAILED (1101ms)" and the
280
290
  // reason — held in result.validationErrors or result.error — reached
281
291
  // nobody, which made a failed biz-hello boot undiagnosable from logs.
282
- this.logger.error(`Step ${step.id}: FAILED (${describeStepTiming(result)}) — ${describeStepFailure(result)}`);
292
+ this.logger.error(`Step ${stepLabel}: FAILED (${describeStepTiming(result)}) — ${describeStepFailure(result)}`);
283
293
  }
284
294
 
285
295
  } catch (error) {
@@ -291,7 +301,7 @@ class CookbookTestRunner {
291
301
  result.handlerDurationMs = 0;
292
302
  result.passed = false;
293
303
 
294
- this.logger.error(`Step ${step.id}: ERROR - ${error.message}\n${error.stack}`);
304
+ this.logger.error(`Step ${stepLabel}: ERROR - ${error.message}\n${error.stack}`);
295
305
  }
296
306
 
297
307
  return result;
@@ -692,25 +702,10 @@ class CookbookTestRunner {
692
702
  * Supports V1 (steps as array) and V2 (steps as object keyed by step_id)
693
703
  */
694
704
  normalizeSteps(steps) {
695
- if (!steps) {
696
- return [];
697
- }
698
-
699
- // V1 format: steps is already an array
700
- if (Array.isArray(steps)) {
701
- return steps;
702
- }
703
-
704
- // V2 format: steps is an object keyed by step_id
705
- if (typeof steps === 'object') {
706
- return Object.entries(steps).map(([stepId, step]) => ({
707
- ...step,
708
- step_id: stepId,
709
- id: stepId // Keep both for backward compatibility
710
- }));
711
- }
712
-
713
- return [];
705
+ // The rule lives in utils/cookbookFormat — the same owner
706
+ // CookbookTestUtils.validateCookbook now reads, so the two entry points
707
+ // cannot disagree about what a cookbook's steps are.
708
+ return normalizeCookbookSteps(steps);
714
709
  }
715
710
 
716
711
  /**
@@ -748,8 +743,8 @@ class CookbookTestRunner {
748
743
  }
749
744
 
750
745
  for (const step of stepsArray) {
751
- if (!step.service) throw new Error(`Step ${step.step_id || step.id || 'unknown'} must have service`);
752
- if (!step.operation) throw new Error(`Step ${step.step_id || step.id || 'unknown'} must have operation`);
746
+ if (!step.service) throw new Error(`Step ${step.step_id || 'unknown'} must have service`);
747
+ if (!step.operation) throw new Error(`Step ${step.step_id || 'unknown'} must have operation`);
753
748
  }
754
749
 
755
750
  return true;
@@ -1,6 +1,11 @@
1
1
  'use strict';
2
2
 
3
- const { MIN_COOKBOOK_FORMAT_VERSION, checkCookbookFormatVersion } = require('./utils/cookbookFormat');
3
+ const {
4
+ MIN_COOKBOOK_FORMAT_VERSION,
5
+ checkCookbookFormatVersion,
6
+ normalizeCookbookSteps,
7
+ stepIdentityOf
8
+ } = require('./utils/cookbookFormat');
4
9
 
5
10
  /**
6
11
  * CookbookTestUtils - Utilities for cookbook testing
@@ -16,7 +21,7 @@ class CookbookTestUtils {
16
21
  version: MIN_COOKBOOK_FORMAT_VERSION,
17
22
  steps: options.steps || [
18
23
  {
19
- id: 'step1',
24
+ step_id: 'step1',
20
25
  type: 'task',
21
26
  service: options.serviceName || 'test-service',
22
27
  operation: 'testOperation',
@@ -43,7 +48,7 @@ class CookbookTestUtils {
43
48
 
44
49
  for (let i = 1; i <= stepCount; i++) {
45
50
  steps.push({
46
- id: `step${i}`,
51
+ step_id: `step${i}`,
47
52
  type: 'task',
48
53
  service: services[i % services.length],
49
54
  operation: `operation${i}`,
@@ -80,12 +85,26 @@ class CookbookTestUtils {
80
85
  errors.push(versionProblem);
81
86
  }
82
87
 
83
- if (!cookbook.steps || !Array.isArray(cookbook.steps)) {
88
+ // Both documented shapes, through the one owner of that rule. Requiring an
89
+ // array here rejected every V2 object-shaped cookbook, and requiring `id`
90
+ // rejected every cookbook that follows format.md — measured 2026-08-30,
91
+ // that was 100 % of the tree.
92
+ let steps;
93
+ try {
94
+ steps = normalizeCookbookSteps(cookbook.steps);
95
+ } catch (error) {
96
+ // The refusal is the finding. This entry point answers with a list, not
97
+ // an exception, so the message becomes one more error in that list.
98
+ errors.push(error.message);
99
+ return { valid: false, errors };
100
+ }
101
+
102
+ if (steps.length === 0) {
84
103
  errors.push('Steps array is required');
85
104
  } else {
86
- cookbook.steps.forEach((step, index) => {
87
- if (!step.id) {
88
- errors.push(`Step ${index}: id is required`);
105
+ steps.forEach((step, index) => {
106
+ if (!stepIdentityOf(step)) {
107
+ errors.push(`Step ${index}: step_id is required`);
89
108
  }
90
109
  if (!step.type) {
91
110
  errors.push(`Step ${index}: type is required`);
@@ -118,8 +137,8 @@ class CookbookTestUtils {
118
137
  }
119
138
 
120
139
  // Compare steps
121
- const steps1 = cookbook1.steps || [];
122
- const steps2 = cookbook2.steps || [];
140
+ const steps1 = normalizeCookbookSteps(cookbook1.steps);
141
+ const steps2 = normalizeCookbookSteps(cookbook2.steps);
123
142
 
124
143
  if (steps1.length !== steps2.length) {
125
144
  differences.push({
@@ -135,11 +154,11 @@ class CookbookTestUtils {
135
154
  const step1 = steps1[i];
136
155
  const step2 = steps2[i];
137
156
 
138
- if (step1.id !== step2.id) {
157
+ if (stepIdentityOf(step1) !== stepIdentityOf(step2)) {
139
158
  differences.push({
140
- field: `steps[${i}].id`,
141
- cookbook1: step1.id,
142
- cookbook2: step2.id
159
+ field: `steps[${i}].step_id`,
160
+ cookbook1: stepIdentityOf(step1),
161
+ cookbook2: stepIdentityOf(step2)
143
162
  });
144
163
  }
145
164
 
@@ -198,7 +217,7 @@ class CookbookTestUtils {
198
217
  */
199
218
  static createControlFlowStep(type, options = {}) {
200
219
  const baseStep = {
201
- id: options.id || `${type}-step-${Date.now()}`,
220
+ step_id: options.step_id || options.id || `${type}-step-${Date.now()}`,
202
221
  type
203
222
  };
204
223
 
@@ -208,7 +227,7 @@ class CookbookTestUtils {
208
227
  ...baseStep,
209
228
  items: options.items || '$api_input.items',
210
229
  body: options.body || {
211
- id: 'foreach-body',
230
+ step_id: 'foreach-body',
212
231
  type: 'task',
213
232
  service: 'test-service',
214
233
  operation: 'processItem'
@@ -220,18 +239,18 @@ class CookbookTestUtils {
220
239
  ...baseStep,
221
240
  condition: options.condition || '$api_input.type',
222
241
  cases: options.cases || [
223
- { value: 'type1', step: { id: 'case1', type: 'task', service: 'service1' } },
224
- { value: 'type2', step: { id: 'case2', type: 'task', service: 'service2' } }
242
+ { value: 'type1', step: { step_id: 'case1', type: 'task', service: 'service1' } },
243
+ { value: 'type2', step: { step_id: 'case2', type: 'task', service: 'service2' } }
225
244
  ],
226
- default: options.default || { id: 'default', type: 'task', service: 'default-service' }
245
+ default: options.default || { step_id: 'default', type: 'task', service: 'default-service' }
227
246
  };
228
247
 
229
248
  case 'fork_join':
230
249
  return {
231
250
  ...baseStep,
232
251
  branches: options.branches || [
233
- { id: 'branch1', type: 'task', service: 'service1' },
234
- { id: 'branch2', type: 'task', service: 'service2' }
252
+ { step_id: 'branch1', type: 'task', service: 'service1' },
253
+ { step_id: 'branch2', type: 'task', service: 'service2' }
235
254
  ],
236
255
  join: options.join || { strategy: 'merge' }
237
256
  };
@@ -334,7 +334,7 @@ async function runRunPreValidation(options) {
334
334
 
335
335
  if (!outcome.ok) {
336
336
  for (const step of outcome.failedSteps) {
337
- process.stderr.write(`[BizCiGate] FAIL run-prevalidation — ${step.id}: ${step.error}\n`);
337
+ process.stderr.write(`[BizCiGate] FAIL run-prevalidation — ${step.step_id}: ${step.error}\n`);
338
338
  for (const validationError of step.validationErrors) {
339
339
  process.stderr.write(`[BizCiGate] * ${validationError}\n`);
340
340
  }
@@ -103,7 +103,7 @@ function createServiceReadinessTests(testsDir, options = {}) {
103
103
  // § Required fields), otherwise this helper rejects its own output.
104
104
  version: MIN_COOKBOOK_FORMAT_VERSION,
105
105
  steps: Object.entries(operationsFlat).map(([name, op]) => ({
106
- id: `test-${name}`,
106
+ step_id: `test-${name}`,
107
107
  type: 'task',
108
108
  service: serviceName,
109
109
  operation: name,
@@ -88,8 +88,85 @@ function checkCookbookFormatVersion(version) {
88
88
  return null;
89
89
  }
90
90
 
91
+ /**
92
+ * Normalise a cookbook's `steps` into the array shape the rest of the package
93
+ * works with, whichever of the two documented shapes it arrives in, and REFUSE
94
+ * a step that spells its identifier `id`.
95
+ *
96
+ * format.md § Steps documents an array of steps, each carrying `step_id`, and
97
+ * bans `id` outright. Accepting both is how a tree ends up with two names for
98
+ * one thing: measured 2026-08-30, 101 steps in 77 files carried `step_id` and
99
+ * six carried `id`, and every reader had to know about both. The V2 object
100
+ * shape keys the same steps by their `step_id`, so normalising it writes that
101
+ * key back onto the step — and nothing else.
102
+ *
103
+ * Refusal is loud, not lenient: silently reading `id` is the fallback
104
+ * `architecture-principles.md` §3 forbids, and it hides an authoring mistake
105
+ * until something downstream reports `Step undefined`.
106
+ *
107
+ * There is no `id` anywhere any more, at authoring time or at runtime. The
108
+ * adapter that used to write `id = step_id` (`normalizeStepForExecutor`) went
109
+ * with the executor it fed, and `@onlineapps/cookbook-executor` itself was
110
+ * deleted on 2026-08-31 — WorkflowOrchestrator is the single owner of cookbook
111
+ * execution and reads `step_id`. So this refusal is the whole rule, not one
112
+ * half of it: `id` is banned by format.md § Steps, full stop.
113
+ *
114
+ * @param {*} steps the raw value of the cookbook's top-level `steps`
115
+ * @returns {object[]} the steps as an array; `[]` for anything else
116
+ * @throws {Error} when any step carries `id`
117
+ */
118
+ function normalizeCookbookSteps(steps) {
119
+ if (!steps) {
120
+ return [];
121
+ }
122
+
123
+ let normalized;
124
+ if (Array.isArray(steps)) {
125
+ normalized = steps;
126
+ } else if (typeof steps === 'object') {
127
+ normalized = Object.entries(steps).map(([stepId, step]) => ({
128
+ ...step,
129
+ step_id: stepId
130
+ }));
131
+ } else {
132
+ return [];
133
+ }
134
+
135
+ normalized.forEach((step, index) => {
136
+ if (step && typeof step === 'object' && 'id' in step) {
137
+ const label = stepIdentityOf(step) || `#${index + 1}`;
138
+ throw new Error(
139
+ `[CookbookFormat] step "${label}" carries "id" - Expected: "step_id" only `
140
+ + `(${DOC_REFERENCE.replace('§ Required fields', '§ Steps')}). Fix: rename "id" to "step_id"`
141
+ );
142
+ }
143
+ });
144
+
145
+ return normalized;
146
+ }
147
+
148
+ /**
149
+ * The identifier a step declares. `step_id` is the ONLY spelling the format
150
+ * allows; `normalizeCookbookSteps` refuses a step that carries `id`, so nothing
151
+ * downstream needs to know that name ever existed.
152
+ *
153
+ * @param {object} step one normalised step
154
+ * @returns {string|null} the declared identifier, or `null` when it declares none
155
+ */
156
+ function stepIdentityOf(step) {
157
+ if (!step || typeof step !== 'object') {
158
+ return null;
159
+ }
160
+ if (typeof step.step_id === 'string' && step.step_id.length > 0) {
161
+ return step.step_id;
162
+ }
163
+ return null;
164
+ }
165
+
91
166
  module.exports = {
92
167
  MIN_COOKBOOK_FORMAT_VERSION,
93
168
  checkCookbookFormatVersion,
94
- compareVersions
169
+ compareVersions,
170
+ normalizeCookbookSteps,
171
+ stepIdentityOf
95
172
  };
@@ -110,7 +110,7 @@ async function runPreValidation({ serviceRoot, serviceUrl, RunnerClass, ProofGen
110
110
  const failedSteps = results.steps
111
111
  .filter((step) => !step.passed)
112
112
  .map((step) => ({
113
- id: step.id || step.operation,
113
+ step_id: step.step_id || step.operation,
114
114
  error: describeStepError(step.error),
115
115
  validationErrors: step.validationErrors || []
116
116
  }));
@@ -17,6 +17,40 @@
17
17
  * (automation-gates.md §5 — silence is a defect).
18
18
  */
19
19
 
20
+ /**
21
+ * Which step this is — for a log line, an error list, anything a human reads.
22
+ *
23
+ * `api/docs/biz/40-cookbooks/format.md` § Required fields makes `step_id` THE
24
+ * identifier of a v2.1 step and bans `id` outright ("an entry carrying `id`
25
+ * instead of `step_id` throws"). CookbookTestRunner read `step.id` anyway, so
26
+ * every conforming cookbook logged `Step undefined: FAILED …` — measured in the
27
+ * live biz-converter log on 2026-08-29 against a cookbook that is correct.
28
+ *
29
+ * The position is the last resort and it is a real answer: `step #3 (operation:
30
+ * convert)` finds the step in the file, which `undefined` never did. A reader
31
+ * who cannot tell which step failed has the same problem as one who was told
32
+ * nothing (automation-gates.md §5).
33
+ *
34
+ * @param {Object} step - a cookbook step, or a step result carrying the same keys
35
+ * @param {number} index - zero-based position within the cookbook
36
+ * @returns {string} never empty, never "undefined"
37
+ */
38
+ function describeStepIdentity(step, index) {
39
+ if (!step || typeof step !== 'object') {
40
+ throw new Error('[stepFailure] step is required - Expected the cookbook step (or its result) to name in a message');
41
+ }
42
+
43
+ // `step_id` only. `normalizeCookbookSteps` refuses a step spelling it `id`,
44
+ // so a step reaching here can no longer carry the retired name.
45
+ if (typeof step.step_id === 'string' && step.step_id.length > 0) return step.step_id;
46
+
47
+ const position = Number.isInteger(index) && index >= 0 ? index + 1 : 1;
48
+ const operation = typeof step.operation === 'string' && step.operation.length > 0
49
+ ? ` (operation: ${step.operation})`
50
+ : '';
51
+ return `step #${position}${operation}`;
52
+ }
53
+
20
54
  /**
21
55
  * Why this step failed, in one line, from whichever field holds it.
22
56
  *
@@ -65,9 +99,11 @@ function describeStepFailure(stepResult) {
65
99
  function describeStepFailureWithContext(stepResult) {
66
100
  const reason = describeStepFailure(stepResult);
67
101
  const cookbook = stepResult.cookbook || 'unknown cookbook';
68
- const stepId = stepResult.id || stepResult.step_id || 'unnamed step';
102
+ // One owner for "which step": `unnamed step` used to be the answer for every
103
+ // conforming cookbook, because the result carried neither identifier.
104
+ const stepId = describeStepIdentity(stepResult, stepResult.stepIndex);
69
105
  const operation = stepResult.operation ? ` (${stepResult.operation})` : '';
70
106
  return `cookbook "${cookbook}" step "${stepId}"${operation}: ${reason}`;
71
107
  }
72
108
 
73
- module.exports = { describeStepFailure, describeStepFailureWithContext };
109
+ module.exports = { describeStepIdentity, describeStepFailure, describeStepFailureWithContext };
@@ -549,10 +549,16 @@ class ServiceStructureValidator {
549
549
  * Note: service.version is now read from package.json (Single Source of Truth)
550
550
  */
551
551
  validateConfigStructure(config) {
552
- // Required fields (version comes from package.json, not config.json)
552
+ // Required fields (version comes from package.json, not config.json).
553
+ //
554
+ // `service.port` was required here until 2026-08-30. ADR 0005 removed the
555
+ // HTTP surface from biz containers, so nothing listens and the number has
556
+ // no consumer — requiring it forced every repo to keep a declaration that
557
+ // means nothing, which is what `change-discipline.md` § "Removing something
558
+ // removes its declaration" exists to prevent. Absence is now correct;
559
+ // presence is reported below as a warning, never as a failure.
553
560
  const requiredFields = [
554
- 'service.name',
555
- 'service.port'
561
+ 'service.name'
556
562
  ];
557
563
 
558
564
  for (const field of requiredFields) {
@@ -573,6 +579,21 @@ class ServiceStructureValidator {
573
579
  }
574
580
  }
575
581
 
582
+ // A port that is still declared is a stale declaration, not a failure. Say
583
+ // so out loud: `automation-gates.md` §5 — a check that stays silent about a
584
+ // thing it can see is a false guarantee, and the eight services measured in
585
+ // api/docs/biz/00-model/service-shape.md still carry the field.
586
+ if (config.service?.port !== undefined) {
587
+ this.warnings.push({
588
+ type: 'ORPHANED_CONFIG_FIELD',
589
+ path: 'config/service/config.json',
590
+ field: 'service.port',
591
+ value: config.service.port,
592
+ message: 'service.port is declared but nothing listens: ADR 0005 removed the HTTP surface from biz containers',
593
+ fix: 'Remove "port" from the "service" section of config.json'
594
+ });
595
+ }
596
+
576
597
  // Validate service name format
577
598
  if (config.service?.name) {
578
599
  if (!/^[a-z][a-z0-9-]*$/.test(config.service.name)) {