@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,6 +6,7 @@ const net = require('net');
6
6
  const { execSync } = require('child_process');
7
7
  const { readIntegrationRunArtefact } = require('./integrationRun');
8
8
  const { normalizeEnvDeclaration, collectEnvCoverage } = require('./envContract');
9
+ const { normalizeStackTiersDeclaration } = require('./testCoverageContract');
9
10
 
10
11
  const CONNECTOR_KEYS = ['db', 'redis', 'mq', 'minio'];
11
12
  const DEFAULT_CONTRACT_RELATIVE_PATH = path.join('config', 'service', 'integration-contract.json');
@@ -47,6 +48,25 @@ function assertBoolean(value, fieldName) {
47
48
  const SCHEMA_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
48
49
  const ENGINE_DECLARATION = /^(mariadb|mysql):[0-9][0-9.]*$/;
49
50
 
51
+ /**
52
+ * The collation a service declares its schema is created with — owner decision
53
+ * 2026-09-14, `db-collation-declaration` 001, written into
54
+ * `docs/biz/70-contracts/database-contract.md` §1.
55
+ *
56
+ * Which utf8mb4 collation is the SERVICE's choice and this file sets none;
57
+ * what it holds is that the value is one, because it is interpolated into
58
+ * `CREATE DATABASE … COLLATE` the way `schema` is interpolated into the name,
59
+ * and because the platform speaks utf8mb4 end to end — the client is invoked
60
+ * with `--default-character-set=utf8mb4` (§3) and every permanent table of the
61
+ * eight repositories declares a utf8mb4 collation (measured 2026-09-14: 122 of
62
+ * 123, the exception being a table whose own thread has the finding).
63
+ *
64
+ * The key's PRESENCE is not this rail's question: a repository that declares
65
+ * none is reported by uniform row `D-DB-COLLATION`, and refused by
66
+ * `setupDatabase.buildSchema` before it builds anything.
67
+ */
68
+ const COLLATION_DECLARATION = /^utf8mb4_[a-z0-9_]+$/;
69
+
50
70
  /** A declared path must stay inside the repository — it is resolved against the service root. */
51
71
  function assertRepoRelativePath(value, fieldName) {
52
72
  if (typeof value !== 'string' || value.trim() === '') {
@@ -85,6 +105,15 @@ function normalizeDatabaseDeclaration(database, connectors) {
85
105
  + 'Fix: use the real schema name, e.g. "oagen_emailer"; it is interpolated into CREATE DATABASE and USE.');
86
106
  }
87
107
 
108
+ if (database.collation !== undefined && !COLLATION_DECLARATION.test(
109
+ typeof database.collation === 'string' ? database.collation : ''
110
+ )) {
111
+ throw new Error(`[BizCiGate] Invalid database.collation - ${JSON.stringify(database.collation)} is not a `
112
+ + 'utf8mb4 collation name. Fix: declare the collation this service\'s schema is created with, e.g. '
113
+ + '"utf8mb4_bin" — the value is interpolated into CREATE DATABASE … COLLATE, and the platform speaks '
114
+ + 'utf8mb4 (api/docs/biz/70-contracts/database-contract.md §1).');
115
+ }
116
+
88
117
  assertRepoRelativePath(database.migrations, 'database.migrations');
89
118
 
90
119
  const seeds = database.seeds ?? [];
@@ -97,6 +126,7 @@ function normalizeDatabaseDeclaration(database, connectors) {
97
126
  return {
98
127
  engine: database.engine,
99
128
  schema: database.schema,
129
+ collation: database.collation,
100
130
  migrations: database.migrations,
101
131
  seeds
102
132
  };
@@ -139,6 +169,10 @@ function normalizeIntegrationContract(rawContract, contractPath, options = {}) {
139
169
 
140
170
  const database = normalizeDatabaseDeclaration(rawContract.database, normalizedConnectors);
141
171
  const env = normalizeEnvDeclaration(rawContract.env, { coverage: options.envCoverage });
172
+ // Which suites legitimately stand outside test:all, and why. The rules that
173
+ // read it live in utils/testCoverageContract.js; only the shape is decided here,
174
+ // beside the other declarations, so one file still answers "what may a contract say".
175
+ const stackTiers = normalizeStackTiersDeclaration(rawContract.stackTiers);
142
176
 
143
177
  return {
144
178
  serviceName: rawContract.serviceName || null,
@@ -148,6 +182,7 @@ function normalizeIntegrationContract(rawContract, contractPath, options = {}) {
148
182
  },
149
183
  database,
150
184
  env,
185
+ stackTiers,
151
186
  setup: rawContract.setup || {},
152
187
  raw: rawContract,
153
188
  };
@@ -240,9 +275,12 @@ function validatePackageScriptsForIntegrationMinimum(serviceRoot) {
240
275
  throw new Error('[BizCiGate] Missing package script - test:all is required');
241
276
  }
242
277
 
243
- if (!/test:integration/.test(testAll)) {
244
- throw new Error('[BizCiGate] Invalid test:all - must include test:integration');
245
- }
278
+ // Whether test:all actually REACHES the integration tests is not asked here.
279
+ // It used to be, as a substring match on the script text — a rule about a name,
280
+ // which passed for `echo test:integration` and failed for a chain reaching the
281
+ // same files through another script. utils/testCoverageContract.js measures the
282
+ // fact instead: every file the jest config matches must be run by the test:all
283
+ // chain or declared as a stack tier (change-discipline.md § One rail per concern).
246
284
 
247
285
  return {
248
286
  packageJsonPath,
@@ -300,18 +338,100 @@ function formatEnvironmentOutput(env, format) {
300
338
  throw new Error(`[BizCiGate] Unsupported output format - ${format}`);
301
339
  }
302
340
 
341
+ /**
342
+ * What the artefact says when a run declares a failure and names no step.
343
+ *
344
+ * It is not a filler string: it is the whole point of the change that put it
345
+ * here. "I do not know which step failed" is a true sentence somebody can act
346
+ * on; "integration minimum satisfied" beside a failing verdict is neither
347
+ * (`.claude/rules/truth-over-agreement.md` §4 — uncertainty is not certainty).
348
+ */
349
+ const UNKNOWN_STEP_REASON = '[BizCiGate] unknown step failed - the run declared a failing verdict and '
350
+ + 'named no step, and the integration minimum (the only check this summary runs itself) is satisfied, '
351
+ + 'so nothing here can say what failed. Fix: pass the step from the CI job - '
352
+ + 'npm run ci:gate:summary -- --gate-verdict fail --gate-reason "<the step that exited non-zero>".';
353
+
354
+ /**
355
+ * What the artefact says when a run declares `pass` over a check this summary
356
+ * measured as failing.
357
+ *
358
+ * It names the refusal rather than quietly correcting the verdict: the CI job
359
+ * asked for something it may not have, and whoever wrote that job is the only
360
+ * person who can stop asking. Format per
361
+ * `.claude/rules/architecture-principles.md` §5 - `[Context] Problem - Fix`.
362
+ *
363
+ * @param {string} measuredFailure the failing check's own message
364
+ * @returns {string}
365
+ */
366
+ function refusedOverrideReason(measuredFailure) {
367
+ return '[BizCiGate] A measured failure cannot be declared passing - the integration minimum failed '
368
+ + `(${measuredFailure}) and the run passed --gate-verdict pass. The verdict stays fail: the override `
369
+ + 'exists to report a failure this summary cannot see for itself, never to withdraw one it can. '
370
+ + 'Fix: drop --gate-verdict pass from the ci:gate:summary call and make the integration minimum pass, '
371
+ + 'or correct integrationMinimum.minTestFiles in the integration contract if the requirement is wrong.';
372
+ }
373
+
303
374
  function buildIntegrationSignalSummary(options) {
304
375
  const contractInfo = loadAndValidateIntegrationContract(options.serviceRoot, options.contractPath);
305
376
  const unitTestFiles = getUnitTestFiles(contractInfo.serviceRoot);
306
377
  const integrationTestFiles = getIntegrationTestFiles(contractInfo.serviceRoot);
307
378
 
308
- let gateVerdict = options.gateVerdict || 'pass';
309
- let gateReason = options.gateReason || 'integration minimum satisfied';
379
+ // The reason comes from the step that FAILED, or it admits it does not know
380
+ // which one did. Three sources, in this order, and no fourth:
381
+ //
382
+ // 1. the caller — the CI job watched the chain of ci:gate:* steps run and is
383
+ // the only party that knows which of them exited non-zero;
384
+ // 2. the integration minimum, the one step this function runs itself, when
385
+ // that is what failed;
386
+ // 3. nothing — and then the artefact says so.
387
+ //
388
+ // What it may never do is attribute somebody else's failure to the last check
389
+ // in the row. A job declaring `--gate-verdict fail` without a reason used to
390
+ // get "integration minimum satisfied" written beside a failing verdict, which
391
+ // is both untrue about the cause and an outright contradiction of the verdict
392
+ // beside it (reported twice by BIZ-ingest on 2026-09-08; the failing step had
393
+ // been setup-db both times).
394
+ // What this summary can establish ITSELF, before anybody declares anything.
395
+ let minimumFailure = null;
310
396
  try {
311
397
  verifyIntegrationMinimum(contractInfo.serviceRoot, contractInfo.contractPath);
312
398
  } catch (error) {
313
- gateVerdict = options.gateVerdict || 'fail';
314
- gateReason = options.gateReason || error.message;
399
+ minimumFailure = error.message;
400
+ }
401
+ const measuredVerdict = minimumFailure === null ? 'pass' : 'fail';
402
+
403
+ // `--gate-verdict` may only make the verdict WORSE. It exists so a job can
404
+ // report a failure this summary cannot see for itself - a step further up the
405
+ // chain that exited non-zero. Read the other way it is a bypass, and a gate
406
+ // has exactly one path and no flag that turns it off
407
+ // (`.claude/rules/automation-gates.md` §1 requirement 5).
408
+ const overrideRefused = options.gateVerdict === 'pass' && measuredVerdict === 'fail';
409
+ const gateVerdict = measuredVerdict === 'fail' || options.gateVerdict === 'fail' ? 'fail' : 'pass';
410
+
411
+ // The reason comes from the step that FAILED, or it admits it does not know
412
+ // which one did. Four sources, in this order, and no fifth:
413
+ //
414
+ // 0. the refusal above - a declared pass the measurement contradicts is
415
+ // itself the thing the reader has to see, and neither the caller's own
416
+ // reason nor the failure's message may stand in its place: "fail" beside
417
+ // "test:all passed" is the same contradiction one step further on;
418
+ // 1. the caller - the CI job watched the chain of ci:gate:* steps run and is
419
+ // the only party that knows which of them exited non-zero;
420
+ // 2. the integration minimum, the one step this function runs itself, when
421
+ // that is what failed;
422
+ // 3. nothing - and then the artefact says so.
423
+ //
424
+ // What it may never do is attribute somebody else's failure to the last check
425
+ // in the row. A job declaring `--gate-verdict fail` without a reason used to
426
+ // get "integration minimum satisfied" written beside a failing verdict, which
427
+ // is both untrue about the cause and an outright contradiction of the verdict
428
+ // beside it (reported twice by BIZ-ingest on 2026-09-08; the failing step had
429
+ // been setup-db both times).
430
+ let gateReason = overrideRefused
431
+ ? refusedOverrideReason(minimumFailure)
432
+ : options.gateReason || minimumFailure;
433
+ if (!gateReason) {
434
+ gateReason = gateVerdict === 'pass' ? 'integration minimum satisfied' : UNKNOWN_STEP_REASON;
315
435
  }
316
436
 
317
437
  // Executed figures come from the run artefact or from nowhere. The previous
@@ -507,6 +627,10 @@ module.exports = {
507
627
  DEFAULT_CONTRACT_RELATIVE_PATH,
508
628
  loadAndValidateIntegrationContract,
509
629
  normalizeIntegrationContract,
630
+ // Exported so the consumers of a normalized `database` block can VERIFY they
631
+ // were handed one, against this definition and not a second copy of the shape
632
+ // (utils/installContract.js).
633
+ normalizeDatabaseDeclaration,
510
634
  getIntegrationTestFiles,
511
635
  getUnitTestFiles,
512
636
  verifyIntegrationMinimum,
@@ -50,6 +50,96 @@ const CONNECTORS = {
50
50
  minio: { configSections: [], env: ['MINIO_ENDPOINT', 'MINIO_ACTUAL_HOST'] }
51
51
  };
52
52
 
53
+ /** Where each of the two declarations lives, relative to the service root. */
54
+ const CONFIG_PATH = 'config/service/config.json';
55
+ const CONTRACT_PATH = 'config/service/integration-contract.json';
56
+
57
+ /**
58
+ * Do the two declarations agree? — the half of the contract a CHECKOUT can
59
+ * answer, and therefore the half the uniform row `C-CONNECTORS` asks
60
+ * (confirmation `connector-contract-check` 001).
61
+ *
62
+ * It is separated from the environment half below for one reason: an
63
+ * environment is a property of a running container, not of a repository. The
64
+ * row runs from the api checkout and in CI, where no service's `REDIS_URL` is
65
+ * set and none should be — asking that question there would fail all eight
66
+ * services for a configuration none of them is running in, which is a gate
67
+ * whose violations have no fix (`automation-gates.md` §3).
68
+ *
69
+ * The RULE is stated once, in `mismatches()` below; this function and
70
+ * `verifyConnectorContract` are two phrasings of the same answer, for two
71
+ * surfaces (`change-discipline.md` § One rail per concern).
72
+ *
73
+ * @param {object} args
74
+ * @param {object} args.config parsed config/service/config.json
75
+ * @param {object} args.requiredConnectors from the integration contract
76
+ * @returns {{valid: boolean, checked: string[], findings: Array<{connector: string, where: string, what: string}>}}
77
+ * `where` is the file that must change, `what` the contradiction —
78
+ * the shape a manifest row reports, whose `fix` the row itself carries.
79
+ */
80
+ function verifyConnectorDeclarations({ config, requiredConnectors }) {
81
+ const findings = mismatches({ config, requiredConnectors }).map((mismatch) => ({
82
+ connector: mismatch.connector,
83
+ where: mismatch.kind === 'required-not-configured' ? CONFIG_PATH : CONTRACT_PATH,
84
+ what: mismatch.kind === 'required-not-configured'
85
+ ? `requiredConnectors.${mismatch.connector} is true and no `
86
+ + `${mismatch.sections.map((section) => `wrapper.${section}`).join('/')} section configures it `
87
+ + '— the two declarations describe the same fact and one of them is wrong'
88
+ : `${CONFIG_PATH} configures wrapper.${mismatch.section} and requiredConnectors.${mismatch.connector} `
89
+ + 'is false — while they disagree, ci:gate:wait does not wait for this connector and the suite races it'
90
+ }));
91
+
92
+ return {
93
+ valid: findings.length === 0,
94
+ checked: requiredNames(requiredConnectors),
95
+ findings
96
+ };
97
+ }
98
+
99
+ /**
100
+ * The names the contract declares as required — what either half reports as
101
+ * "checked", so the two can never count differently.
102
+ *
103
+ * @param {object} requiredConnectors
104
+ * @returns {string[]}
105
+ */
106
+ function requiredNames(requiredConnectors) {
107
+ return Object.keys(CONNECTORS).filter((name) => requiredConnectors?.[name] === true);
108
+ }
109
+
110
+ /**
111
+ * THE RULE, stated once: where the two declarations contradict each other.
112
+ *
113
+ * A connector with no `configSections` (db, minio) can contradict nothing here —
114
+ * no service on the platform declares a wrapper section for either, so both
115
+ * directions would be this module inventing a requirement. Their evidence is the
116
+ * environment, and that is the other half.
117
+ *
118
+ * @param {object} args
119
+ * @param {object} args.config parsed config/service/config.json
120
+ * @param {object} args.requiredConnectors from the integration contract
121
+ * @returns {Array<{connector: string, kind: string, sections?: string[], section?: string}>}
122
+ */
123
+ function mismatches({ config, requiredConnectors }) {
124
+ const wrapper = config?.wrapper ?? {};
125
+ const found = [];
126
+
127
+ for (const [name, spec] of Object.entries(CONNECTORS)) {
128
+ if (spec.configSections.length === 0) continue;
129
+
130
+ const required = requiredConnectors?.[name] === true;
131
+ const section = spec.configSections.find((candidate) => wrapper[candidate] !== undefined);
132
+
133
+ if (required && section === undefined) {
134
+ found.push({ connector: name, kind: 'required-not-configured', sections: spec.configSections });
135
+ } else if (!required && section !== undefined) {
136
+ found.push({ connector: name, kind: 'configured-not-required', section });
137
+ }
138
+ }
139
+
140
+ return found;
141
+ }
142
+
53
143
  /**
54
144
  * @param {object} args
55
145
  * @param {object} args.config parsed config/service/config.json
@@ -60,18 +150,18 @@ const CONNECTORS = {
60
150
  function verifyConnectorContract({ config, requiredConnectors, env }) {
61
151
  const errors = [];
62
152
  const checked = [];
63
- const wrapper = config?.wrapper ?? {};
153
+ const byConnector = new Map(mismatches({ config, requiredConnectors }).map((m) => [m.connector, m]));
64
154
 
65
155
  for (const [name, spec] of Object.entries(CONNECTORS)) {
66
156
  const required = requiredConnectors?.[name] === true;
67
- const configured = spec.configSections.some((section) => wrapper[section] !== undefined);
157
+ const mismatch = byConnector.get(name);
68
158
 
69
159
  if (required) {
70
160
  checked.push(name);
71
161
 
72
- if (spec.configSections.length > 0 && !configured) {
162
+ if (mismatch !== undefined) {
73
163
  errors.push(`Connector "${name}" is required by the integration contract but no `
74
- + `wrapper.${spec.configSections.join('/wrapper.')} section configures it in config/service/config.json.\n`
164
+ + `wrapper.${spec.configSections.join('/wrapper.')} section configures it in ${CONFIG_PATH}.\n`
75
165
  + ` Fix: configure it, or set requiredConnectors.${name} to false if the service does not use it.`);
76
166
  }
77
167
 
@@ -81,8 +171,8 @@ function verifyConnectorContract({ config, requiredConnectors, env }) {
81
171
  + ' Fix: set it in the CI job or config/env-active/*.env — a required connector without its '
82
172
  + 'endpoint fails later, inside the connector, where the cause is harder to see.');
83
173
  }
84
- } else if (configured) {
85
- errors.push(`config/service/config.json configures wrapper.${spec.configSections.find((s) => wrapper[s] !== undefined)} `
174
+ } else if (mismatch !== undefined) {
175
+ errors.push(`${CONFIG_PATH} configures wrapper.${mismatch.section} `
86
176
  + `but the integration contract says "${name}" is not required.\n`
87
177
  + ' The two declarations describe the same fact and must agree; while they disagree, '
88
178
  + 'ci:gate:wait does not wait for this connector and the suite races it.\n'
@@ -93,4 +183,4 @@ function verifyConnectorContract({ config, requiredConnectors, env }) {
93
183
  return { valid: errors.length === 0, checked, errors };
94
184
  }
95
185
 
96
- module.exports = { verifyConnectorContract, CONNECTORS };
186
+ module.exports = { verifyConnectorContract, verifyConnectorDeclarations, CONNECTORS, CONFIG_PATH, CONTRACT_PATH };
@@ -26,11 +26,32 @@
26
26
  /** Minimum accepted cookbook format version — format.md § Required fields. */
27
27
  const MIN_COOKBOOK_FORMAT_VERSION = '2.1.0';
28
28
 
29
- /** `major[.minor[.patch]]`, digits only — the shape format.md documents. */
30
- const VERSION_PATTERN = /^\d+(\.\d+){0,2}$/;
29
+ /**
30
+ * `major.minor.patch`, digits only, all three components required.
31
+ *
32
+ * The machine SSOT is `shared/cookbook/cookbook-core/schemas/cookbook.v2.schema.json:16`
33
+ * — `"pattern": "^2\\.\\d+\\.\\d+$"`. Until 2026-09-05 this gate accepted 1 to 3
34
+ * components, so `"version": "2.1"` passed Tier-1 here and failed schema
35
+ * validation there: two rails, two answers, the offline one lenient. The major
36
+ * is deliberately NOT pinned to 2 here — that ceiling belongs to the schema; the
37
+ * floor this module owns is `MIN_COOKBOOK_FORMAT_VERSION`.
38
+ */
39
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
31
40
 
32
41
  const DOC_REFERENCE = 'api/docs/biz/40-cookbooks/format.md § Required fields';
33
42
 
43
+ /**
44
+ * The same node, § The array is the only accepted shape — the section that owns
45
+ * both halves of the rule below: `steps` is an array, and a step identifies
46
+ * itself with `step_id`. The node has no section called `Steps`; this constant
47
+ * named one until 2026-09-14, and `L008` never noticed, because it checks the
48
+ * document and not the section behind `§`.
49
+ */
50
+ const STEPS_DOC_REFERENCE = 'api/docs/biz/40-cookbooks/format.md § The array is the only accepted shape';
51
+
52
+ /** `2.1` — how the format version is spoken about in prose, derived, never typed twice. */
53
+ const MIN_FORMAT_MAJOR_MINOR = MIN_COOKBOOK_FORMAT_VERSION.split('.').slice(0, 2).join('.');
54
+
34
55
  /**
35
56
  * @param {string} version numeric version string, already pattern-checked
36
57
  * @returns {number[]} exactly three components, missing ones are 0
@@ -89,65 +110,86 @@ function checkCookbookFormatVersion(version) {
89
110
  }
90
111
 
91
112
  /**
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`.
113
+ * @param {*} steps a `steps` value that is neither an array nor absent
114
+ * @returns {string} the shape, worded for the refusal message
115
+ */
116
+ function describeStepsShape(steps) {
117
+ return typeof steps === 'object' ? 'an object' : `a ${typeof steps}`;
118
+ }
119
+
120
+ /**
121
+ * Read a cookbook's `steps` in the ONE shape the format allows — an array of
122
+ * step objects, each carrying `step_id` — and refuse everything else.
95
123
  *
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.
124
+ * Two refusals, one rule each:
102
125
  *
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`.
126
+ * 1. **Any shape but an array.** Owner decision 2026-09-05, fully authoritative
127
+ * (`api/docs/governance/confirmations/cookbook-steps-shape.md` 001): `steps`
128
+ * is an ARRAY, the only allowed shape, and the object variant "must not be
129
+ * permitted or used anywhere". It supersedes the 2026-08-17 object
130
+ * directive, which never landed in any runtime. The reason is order:
131
+ * JavaScript reorders numeric object keys and Postgres `jsonb` does not
132
+ * preserve key order either (measured M9,
133
+ * `infra/api_monitoring/src/consumer/cookbookSplit.js:32-37`), so an
134
+ * object-keyed cookbook has no defined execution order at all. Arrays keep
135
+ * theirs, because a reordered step list IS a different cookbook.
106
136
  *
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.
137
+ * Until 2026-09-05 this function converted the object into an array. That
138
+ * was a FALSE GREEN and the reason the tolerance is gone: the offline Tier-1
139
+ * gate accepted a cookbook the production WorkflowOrchestrator — which only
140
+ * ever read arrays — threw on.
141
+ *
142
+ * 2. **A step spelling its identifier `id`.** The node bans it outright
143
+ * (format.md § The array is the only accepted shape). Silently reading `id`
144
+ * is the fallback
145
+ * `architecture-principles.md` §3 forbids, and it hides an authoring mistake
146
+ * until something downstream reports `Step undefined`. There is no `id`
147
+ * anywhere any more: the adapter that wrote `id = step_id` went with
148
+ * `@onlineapps/cookbook-executor` (deleted 2026-08-31), and
149
+ * WorkflowOrchestrator — the single owner of cookbook execution — reads
150
+ * `step_id`.
151
+ *
152
+ * An absent `steps` is NOT a shape problem: the caller owns that message,
153
+ * because only it knows whether it is reading a file or an object handed to it.
113
154
  *
114
155
  * @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`
156
+ * @returns {object[]} the steps, unchanged; `[]` when `steps` is absent
157
+ * @throws {Error} when `steps` is present but not an array, or a step carries `id`
117
158
  */
118
- function normalizeCookbookSteps(steps) {
119
- if (!steps) {
159
+ function readCookbookSteps(steps) {
160
+ if (steps === undefined || steps === null) {
120
161
  return [];
121
162
  }
122
163
 
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 [];
164
+ if (!Array.isArray(steps)) {
165
+ throw new Error(
166
+ `[CookbookFormat] cookbook "steps" is ${describeStepsShape(steps)}, not an array - `
167
+ + 'Expected an ARRAY of step objects, each carrying its own "step_id" '
168
+ + `(${STEPS_DOC_REFERENCE}, cookbook format v${MIN_FORMAT_MAJOR_MINOR}; `
169
+ + 'owner confirmation cookbook-steps-shape 001 of 2026-09-05: the array is the only '
170
+ + 'allowed shape, because neither JS nor Postgres jsonb preserves object key order). '
171
+ + 'Fix: rewrite {"fetch": {...}, "send": {...}} as '
172
+ + '[{"step_id": "fetch", ...}, {"step_id": "send", ...}] — each key becomes that step\'s '
173
+ + '"step_id" and the array order becomes the execution order.'
174
+ );
133
175
  }
134
176
 
135
- normalized.forEach((step, index) => {
177
+ steps.forEach((step, index) => {
136
178
  if (step && typeof step === 'object' && 'id' in step) {
137
179
  const label = stepIdentityOf(step) || `#${index + 1}`;
138
180
  throw new Error(
139
181
  `[CookbookFormat] step "${label}" carries "id" - Expected: "step_id" only `
140
- + `(${DOC_REFERENCE.replace('§ Required fields', '§ Steps')}). Fix: rename "id" to "step_id"`
182
+ + `(${STEPS_DOC_REFERENCE}). Fix: rename "id" to "step_id"`
141
183
  );
142
184
  }
143
185
  });
144
186
 
145
- return normalized;
187
+ return steps;
146
188
  }
147
189
 
148
190
  /**
149
191
  * The identifier a step declares. `step_id` is the ONLY spelling the format
150
- * allows; `normalizeCookbookSteps` refuses a step that carries `id`, so nothing
192
+ * allows; `readCookbookSteps` refuses a step that carries `id`, so nothing
151
193
  * downstream needs to know that name ever existed.
152
194
  *
153
195
  * @param {object} step one normalised step
@@ -166,7 +208,6 @@ function stepIdentityOf(step) {
166
208
  module.exports = {
167
209
  MIN_COOKBOOK_FORMAT_VERSION,
168
210
  checkCookbookFormatVersion,
169
- compareVersions,
170
- normalizeCookbookSteps,
211
+ readCookbookSteps,
171
212
  stepIdentityOf
172
213
  };