@onlineapps/conn-orch-validator 12.1.1 → 13.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +607 -0
  2. package/README.md +126 -19
  3. package/manifests/biz-service.manifest.json +15 -2
  4. package/manifests/library.manifest.json +4 -4
  5. package/package.json +11 -3
  6. package/src/CookbookTestRunner.js +275 -105
  7. package/src/CookbookTestUtils.js +79 -68
  8. package/src/ServiceReadinessValidator.js +42 -52
  9. package/src/ValidationOrchestrator.js +65 -44
  10. package/src/cli/biz-ci-gate.js +2 -2
  11. package/src/cli/oa-sync-template.js +97 -47
  12. package/src/cli/oa-validate.js +44 -10
  13. package/src/helpers/README.md +6 -6
  14. package/src/helpers/createServiceReadinessTests.js +87 -33
  15. package/src/index.js +14 -5
  16. package/src/lint/scripts/lintScripts.js +11 -4
  17. package/src/manifest/checks/libraryContext.js +6 -3
  18. package/src/manifest/checks/libraryDocs.js +174 -4
  19. package/src/manifest/checks/libraryTests.js +200 -19
  20. package/src/manifest/checks/scriptHeaders.js +6 -13
  21. package/src/manifest/checks/serviceConfig.js +36 -16
  22. package/src/manifest/checks/serviceConnectors.js +180 -2
  23. package/src/manifest/checks/serviceDb.js +0 -3
  24. package/src/manifest/checks/serviceScripts.js +3 -20
  25. package/src/manifest/runManifest.js +90 -13
  26. package/src/manifest/workspaceRoot.js +133 -4
  27. package/src/mocks/MockMQClient.js +2 -2
  28. package/src/sync/docsRegion.js +2 -2
  29. package/src/sync/readmeFile.js +30 -0
  30. package/src/sync/readmeLocation.js +2 -12
  31. package/src/sync/readmePointer.js +10 -4
  32. package/src/sync/serviceTemplate.js +9 -11
  33. package/src/sync/sharedEnv.js +59 -3
  34. package/src/sync/uniformFiles.js +81 -8
  35. package/src/utils/bizCiGateContract.js +2 -2
  36. package/src/utils/connectorContract.js +54 -2
  37. package/src/utils/cookbookFormat.js +25 -115
  38. package/src/utils/dbAccountGrants.js +5 -3
  39. package/src/utils/deployContract.js +153 -28
  40. package/src/utils/envContract.js +2 -2
  41. package/src/utils/handlerRef.js +8 -10
  42. package/src/utils/integrationRun.js +1 -1
  43. package/src/utils/operationsDocumentRules.js +242 -0
  44. package/src/utils/operationsRules.js +157 -0
  45. package/src/utils/resolveHeaders.js +12 -1
  46. package/src/utils/setupDatabase.js +1 -1
  47. package/src/utils/stepFailure.js +3 -3
  48. package/src/utils/stepReferences.js +28 -87
  49. package/src/utils/throwawaySchema.js +1 -1
  50. package/src/utils/yamlTopLevel.js +105 -0
  51. package/src/validators/ServiceStructureValidator.js +67 -152
  52. package/templates/business-service/.gitlab-ci.yml +203 -37
  53. package/templates/business-service/README.md +7 -4
  54. package/templates/business-service/config/env-templates/shared.env +1 -0
  55. package/templates/business-service/docs/80-setup/INSTALL.md +31 -3
  56. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +5 -2
  57. package/templates/business-service/src/config/index.js +15 -0
  58. package/TESTING_STRATEGY.md +0 -92
  59. package/jest.config.js +0 -37
@@ -29,12 +29,10 @@ const path = require('path');
29
29
 
30
30
  const { loadManifest, DEFAULT_MANIFEST_PATH, LIBRARY_MANIFEST_PATH } = require('../manifest/loadManifest');
31
31
  const {
32
- API_CHECKOUT_ROOT, PACKAGE_ROOT: OWN_ROOT, WORKSPACE_MARKER, resolveWorkspacePath
32
+ API_CHECKOUT_ROOT, PACKAGE_ROOT: OWN_ROOT, API_PREFIX, resolveWorkspacePath
33
33
  } = require('../manifest/workspaceRoot');
34
34
  const { KINDS, renderUniformRegion } = require('./readmePointer');
35
-
36
- /** The file the pointer is a region of. */
37
- const README_FILE = 'README.md';
35
+ const { README_FILE } = require('./readmeFile');
38
36
 
39
37
  /**
40
38
  * This package, by the name a consumer installs it under — read when a link
@@ -74,13 +72,6 @@ function serviceManifestPath(serviceRoot) {
74
72
  return path.join(serviceRoot, 'node_modules', ...selfName().split('/'), SERVICE_MANIFEST_IN_PACKAGE);
75
73
  }
76
74
 
77
- /**
78
- * The conventional prefix a workspace-relative path uses for the api checkout,
79
- * read off the marker that declares it rather than typed a second time here
80
- * (`../manifest/workspaceRoot.js`, which owns both the prefix and the marker).
81
- */
82
- const [API_PREFIX] = WORKSPACE_MARKER.split('/');
83
-
84
75
  /**
85
76
  * Where this package lies inside its own workspace, workspace-relative — or null
86
77
  * when it lies in no checkout at all (an installed copy, a service container).
@@ -190,7 +181,6 @@ function libraryRegion({ packageDir, pkg, workspaceRoot, manifest }) {
190
181
  }
191
182
 
192
183
  module.exports = {
193
- README_FILE,
194
184
  selfName,
195
185
  PACKAGE_ROOT,
196
186
  PACKAGE_IN_WORKSPACE,
@@ -42,6 +42,7 @@
42
42
  const path = require('path');
43
43
 
44
44
  const { markersFor, replaceRegion, extractRegion: extractBetween, checkRegion } = require('./generatedRegion');
45
+ const { README_FILE } = require('./readmeFile');
45
46
 
46
47
  /** The bearer kinds this package renders a pointer for. */
47
48
  const KINDS = Object.freeze({ library: 'library', service: 'service' });
@@ -57,8 +58,13 @@ const ALL_CATEGORIES = '*';
57
58
  */
58
59
  const DISCOVERY_SECTION = 'discovery';
59
60
 
60
- /** The file every error of this module is about; the prefix is generatedRegion.js's own. */
61
- const WHERE = Object.freeze({ file: 'README.md' });
61
+ /**
62
+ * The file every error of this module is about; the prefix is
63
+ * generatedRegion.js's own. The name comes from `readmeFile.js`, which owns it
64
+ * for the whole package — this module renders the region and names no file of
65
+ * its own (d.739b).
66
+ */
67
+ const WHERE = Object.freeze({ file: README_FILE });
62
68
 
63
69
  const code = (value) => `\`${value}\``;
64
70
 
@@ -392,7 +398,7 @@ function headerEnd(lines, { required }) {
392
398
  if (!required) return 0;
393
399
  throw new Error('[ReadmePointer] README has no node header - the file must open with the '
394
400
  + '"> Status:" / "> Owns:" block, and the region is placed after it. '
395
- + 'Fix: give README.md the node header (api/docs/standards/INFRA-DOC-STANDARD.md).');
401
+ + `Fix: give ${README_FILE} the node header (api/docs/standards/INFRA-DOC-STANDARD.md).`);
396
402
  }
397
403
 
398
404
  let index = 0;
@@ -402,7 +408,7 @@ function headerEnd(lines, { required }) {
402
408
  throw new Error('[ReadmePointer] Node header is not followed by a blank line - the region is placed '
403
409
  + `after it and the shape has to be unambiguous, but line ${index + 1} is `
404
410
  + `${JSON.stringify(lines[index] === undefined ? null : lines[index])}. `
405
- + 'Fix: leave one blank line between the "> " block and the rest of README.md.');
411
+ + `Fix: leave one blank line between the "> " block and the rest of ${README_FILE}.`);
406
412
  }
407
413
  return index + 1;
408
414
  }
@@ -23,6 +23,8 @@
23
23
  const fs = require('fs');
24
24
  const path = require('path');
25
25
 
26
+ const { rewritableTopLevelKeys } = require('../utils/yamlTopLevel');
27
+
26
28
  /** The template as it lies in this package. */
27
29
  const TEMPLATE_ROOT = path.join(__dirname, '..', '..', 'templates', 'business-service');
28
30
 
@@ -550,9 +552,6 @@ function insertBlockAfterFunction({ text, fn, replacement }) {
550
552
  return [...lines.slice(0, end + 1), '', ...replacement, ...lines.slice(end + 1)].join('\n');
551
553
  }
552
554
 
553
- /** A top-level mapping key: a line that starts in column 0 and ends its key with a colon. */
554
- const TOP_LEVEL_KEY = /^([A-Za-z_][A-Za-z0-9_.-]*):/;
555
-
556
555
  /**
557
556
  * The top-level mapping keys a run of lines declares, in the order it declares
558
557
  * them.
@@ -563,16 +562,17 @@ const TOP_LEVEL_KEY = /^([A-Za-z_][A-Za-z0-9_.-]*):/;
563
562
  * (`.claude/rules/change-discipline.md` § One rail per concern), and the day the
564
563
  * block gained a job the two would disagree about which file had drifted.
565
564
  *
566
- * Column 0 is the whole rule, and it is enough for the one file this reads:
567
- * a `.gitlab-ci.yml` job is a top-level key and everything it owns is indented
568
- * under it. Text in, text out — nothing is re-serialized, so a comment and a
569
- * quoting style survive (`.claude/rules/automation-gates.md` §1 requirement 3).
565
+ * What a top-level key IS has one owner, `src/utils/yamlTopLevel.js`, and this
566
+ * takes the REWRITABLE reading of it: hidden keys (`.oa-uniform:`) are left out,
567
+ * because the same list drives `replaceTopLevelKeys`, which DELETES what it
568
+ * matches, and a hidden key a service wrote itself is not the platform's to
569
+ * delete.
570
570
  *
571
571
  * @param {string[]} lines
572
572
  * @returns {string[]}
573
573
  */
574
574
  function topLevelKeys(lines) {
575
- return lines.map((line) => (TOP_LEVEL_KEY.exec(line) || [])[1]).filter((key) => key !== undefined);
575
+ return rewritableTopLevelKeys(lines.join('\n')).map((key) => key.name);
576
576
  }
577
577
 
578
578
  /**
@@ -597,9 +597,7 @@ function topLevelKeys(lines) {
597
597
  */
598
598
  function replaceTopLevelKeys({ text, keys, replacement }) {
599
599
  const lines = text.split('\n');
600
- const starts = lines
601
- .map((line, index) => ({ key: (TOP_LEVEL_KEY.exec(line) || [])[1], index }))
602
- .filter((entry) => entry.key !== undefined);
600
+ const starts = rewritableTopLevelKeys(text).map(({ name, line }) => ({ key: name, index: line }));
603
601
 
604
602
  const doomed = new Set();
605
603
  for (let i = 0; i < starts.length; i += 1) {
@@ -26,6 +26,49 @@ const DIFF_HEADER = Object.freeze([
26
26
  '+++ on disk'
27
27
  ]);
28
28
 
29
+ /**
30
+ * The opening of the per-machine marker line, and the only field its
31
+ * declaration carries. A template says with it that the value of a key is a
32
+ * property of the box rather than of the platform, so the drift gate must not
33
+ * judge it.
34
+ *
35
+ * @see api/scripts/lib/env-template-markers.sh - the reader of this marker
36
+ * @see api/scripts/validate-env.sh - the gate that consults the reader (--check-drift)
37
+ */
38
+ const PER_MACHINE_PREFIX = '# @per-machine — ';
39
+ const PER_MACHINE_FIELDS = Object.freeze(['reason']);
40
+
41
+ function assertPerMachine(name, perMachine) {
42
+ if (typeof perMachine !== 'object' || perMachine === null || Array.isArray(perMachine)) {
43
+ throw new Error(`[SharedEnv] Key ${name} has a "perMachine" that is not an object - expected `
44
+ + '{ "reason": "<why this machine\'s value differs>" }, because a marker with no reason '
45
+ + 'exempts a key from the drift gate without saying who decided it. Fix: write "perMachine": '
46
+ + '{ "reason": "..." } for that key in api/config/shared-env.json.');
47
+ }
48
+
49
+ Object.keys(perMachine).forEach((field) => {
50
+ if (!PER_MACHINE_FIELDS.includes(field)) {
51
+ throw new Error(`[SharedEnv] Key ${name} has an unknown "perMachine" field `
52
+ + `${JSON.stringify(field)} - expected "reason" and nothing else, so a field nobody renders `
53
+ + 'cannot look like a declaration. Fix: remove it from that key in '
54
+ + 'api/config/shared-env.json.');
55
+ }
56
+ });
57
+
58
+ if (typeof perMachine.reason !== 'string' || perMachine.reason.trim() === '') {
59
+ throw new Error(`[SharedEnv] Key ${name} has a "perMachine" with no "reason" - the marker line `
60
+ + 'states why THIS machine differs, and the drift gate quotes it back to the operator. '
61
+ + 'Fix: add "reason" to that key\'s "perMachine" in api/config/shared-env.json.');
62
+ }
63
+
64
+ if (/[\r\n]/.test(perMachine.reason)) {
65
+ throw new Error(`[SharedEnv] Key ${name} has a multi-line "perMachine.reason" - the generated `
66
+ + 'file carries one marker line per key, and the reader holds a marker only inside the '
67
+ + 'comment block immediately above the key. Fix: write it as a single sentence in '
68
+ + 'api/config/shared-env.json.');
69
+ }
70
+ }
71
+
29
72
  function assertManifest(manifest) {
30
73
  const keys = manifest && manifest.keys;
31
74
  if (!Array.isArray(keys) || keys.length === 0) {
@@ -46,7 +89,7 @@ function assertManifest(manifest) {
46
89
  }
47
90
  if (typeof key.why !== 'string' || key.why.trim() === '') {
48
91
  throw new Error(`[SharedEnv] Key ${name} has no "why" - every shared key states why the platform `
49
- + 'shares it (docs/biz/70-contracts/env-contract.md). Fix: add "why" to that key in '
92
+ + 'shares it (api/docs/biz/70-contracts/env-contract.md). Fix: add "why" to that key in '
50
93
  + 'api/config/shared-env.json.');
51
94
  }
52
95
  if (/[\r\n]/.test(key.why)) {
@@ -57,6 +100,9 @@ function assertManifest(manifest) {
57
100
  throw new Error(`[SharedEnv] Key ${name} has a multi-line "value" - an env file carries one line `
58
101
  + 'per key. Fix: correct the value in api/config/shared-env.json.');
59
102
  }
103
+ if (key.perMachine !== undefined) {
104
+ assertPerMachine(name, key.perMachine);
105
+ }
60
106
  });
61
107
  }
62
108
 
@@ -78,7 +124,13 @@ function assertManifest(manifest) {
78
124
  * names a service's code reads against what its
79
125
  * `config/service/integration-contract.json` declares.
80
126
  *
81
- * @param {{keys: Array<{name: string, value: string, why: string}>}} manifest
127
+ * A key that declares `perMachine` renders a second comment line under its
128
+ * `why`, immediately above `KEY=`: the marker of
129
+ * `api/scripts/lib/env-template-markers.sh`, which holds only inside the
130
+ * contiguous comment block above the key it belongs to.
131
+ *
132
+ * @param {{keys: Array<{name: string, value: string, why: string,
133
+ * perMachine?: {reason: string}}>}} manifest
82
134
  * @returns {string}
83
135
  */
84
136
  function renderSharedEnv(manifest) {
@@ -86,7 +138,11 @@ function renderSharedEnv(manifest) {
86
138
 
87
139
  const lines = [...HEADER];
88
140
  manifest.keys.forEach((key) => {
89
- lines.push('', `# ${key.why}`, `${key.name}=${key.value}`);
141
+ lines.push('', `# ${key.why}`);
142
+ if (key.perMachine !== undefined) {
143
+ lines.push(`${PER_MACHINE_PREFIX}${key.perMachine.reason}`);
144
+ }
145
+ lines.push(`${key.name}=${key.value}`);
90
146
  });
91
147
 
92
148
  return `${lines.join('\n')}\n`;
@@ -38,7 +38,11 @@
38
38
  const fs = require('fs');
39
39
  const path = require('path');
40
40
 
41
- const { readReferencedFile, isPackageReference, referenceOwner } = require('../manifest/discovery');
41
+ const {
42
+ readReferencedFile, isPackageReference, referenceOwner, resolveFromDocument
43
+ } = require('../manifest/discovery');
44
+ const { renderSharedEnv } = require('./sharedEnv');
45
+ const { collectRows } = require('../manifest/walk');
42
46
  const { readComposeServices } = require('../manifest/checks/composeShape');
43
47
  const { renderRunnerBlock, replaceServiceNode } = require('../manifest/checks/composeRunnerBlock');
44
48
  const {
@@ -175,6 +179,24 @@ function renderReadme({ row, current, serviceRoot }) {
175
179
  return applyUniformRegion(current, serviceRegion(serviceRoot), { kind: KINDS.service });
176
180
  }
177
181
 
182
+ /**
183
+ * `config/env-templates/shared.env` from the platform env manifest.
184
+ *
185
+ * The same expression the row's own check evaluates
186
+ * (`../manifest/checks/serviceConfig.js` § sharedEnvGenerated): the referenced
187
+ * document, handed to the module that owns the shared key set. Nothing here
188
+ * knows a key, a format or a path to the SSOT — the row names the owner and
189
+ * `sharedEnv.js` renders it, which is why the `shared-env` subcommand and this
190
+ * run cannot produce two different files (`.claude/rules/change-discipline.md`
191
+ * § One rail per concern).
192
+ *
193
+ * @param {{row: object, workspaceRoot: string}} args
194
+ * @returns {string}
195
+ */
196
+ function renderSharedEnvFile({ row, workspaceRoot }) {
197
+ return renderSharedEnv(resolveFromDocument({ from: row.from, workspaceRoot }));
198
+ }
199
+
178
200
  /**
179
201
  * A whole file rendered for THIS repository: the template with the one parameter
180
202
  * a service has in it, its declared name.
@@ -325,7 +347,8 @@ const RENDERERS = Object.freeze({
325
347
  'template-render': renderRendered,
326
348
  'delimited-block-render': renderDelimitedBlock,
327
349
  'template-skeleton': renderSkeleton,
328
- 'readme-uniform-current': renderReadme
350
+ 'readme-uniform-current': renderReadme,
351
+ 'shared-env-generated': renderSharedEnvFile
329
352
  });
330
353
 
331
354
  /**
@@ -337,7 +360,28 @@ const SYNCABLE_CLASSES = Object.freeze(['identical', 'generated', 'contains']);
337
360
  /**
338
361
  * The manifest rows of the classes the generator owns, in manifest order.
339
362
  *
340
- * Not every one of them is a row this run renders — see `syncRows`. This is the
363
+ * Two ways in, and the second one is why this is not one flatMap:
364
+ *
365
+ * * a row of `files.<class>` wears the class of the array it sits in;
366
+ * * a row ANYWHERE else wears the class it DECLARES, in a `class` field of
367
+ * its own.
368
+ *
369
+ * The second exists because a generated file is not always reported under
370
+ * `files`. `G-SHARED-ENV` is filed under `config`, where the manifest reports
371
+ * about a service's configuration, and until d.710 that placement decided
372
+ * something it was never meant to decide: the sync's row set was the `files`
373
+ * section, so `oa-validate` measured `config/env-templates/shared.env` and the
374
+ * sync wrote nothing — instruction 3 of the cascade ("sync every row") was four
375
+ * commands each of the eight repositories assembled for itself, and BIZ-ingest
376
+ * escalated that the bare run does not include that row. A section is a place
377
+ * to report from; what a file IS belongs to the row.
378
+ *
379
+ * A row joins by SAYING so, never by being in a section somebody read as
380
+ * generated: `files.own` and `files.forbidden` cannot drift into this run, and
381
+ * neither can a future row of any other section (`automation-gates.md` §1
382
+ * requirement 1 — no dependence on state nobody declared).
383
+ *
384
+ * Not every row here is a row this run renders — see `syncRows`. This is the
341
385
  * full declaration, and the CLI needs it to tell a path the uniform declares as
342
386
  * a REQUIREMENT from a path it has never heard of.
343
387
  *
@@ -346,12 +390,18 @@ const SYNCABLE_CLASSES = Object.freeze(['identical', 'generated', 'contains']);
346
390
  */
347
391
  function uniformRows(manifest) {
348
392
  const files = (manifest && manifest.files) || {};
349
- return SYNCABLE_CLASSES.flatMap((className) => files[className] || []);
393
+ const inFiles = SYNCABLE_CLASSES.flatMap((className) => files[className] || []);
394
+
395
+ const declared = collectRows(manifest || {})
396
+ .map((entry) => entry.row)
397
+ .filter((row) => SYNCABLE_CLASSES.includes(row.class) && !inFiles.includes(row));
398
+
399
+ return [...inFiles, ...declared];
350
400
  }
351
401
 
352
402
  /**
353
- * Whether the sync is defined for this row: does it name what its content comes
354
- * from?
403
+ * Whether the sync is defined for this row: does it name a file, and what its
404
+ * content comes from?
355
405
  *
356
406
  * The predicate is the manifest's own distinction, not a list of ids: a row with
357
407
  * a `from:` reference is a file rendered from that reference, a row without one
@@ -359,11 +409,16 @@ function uniformRows(manifest) {
359
409
  * `desiredContent` would read. Judging the reference rather than the id is why a
360
410
  * new requirement row needs no change here.
361
411
  *
412
+ * The `path` is the other half, and it is not a formality: `R-NODE` names a
413
+ * reference and no path, because what it compares is `engines.node` — a FIELD of
414
+ * a file the service owns. There is no file to write from that reference, so a
415
+ * reference alone does not make a row renderable.
416
+ *
362
417
  * @param {object} row
363
418
  * @returns {boolean}
364
419
  */
365
420
  function isSyncRow(row) {
366
- if (!row || !row.from) return false;
421
+ if (!row || !row.from || typeof row.path !== 'string') return false;
367
422
  return typeof row.from.path === 'string' || isPackageReference(row.from);
368
423
  }
369
424
 
@@ -461,9 +516,24 @@ function desiredContent({ row, current, serviceRoot, workspaceRoot }) {
461
516
  /**
462
517
  * One row's plan.
463
518
  *
519
+ * `block` is what this entry actually compared, and it is why a reader can
520
+ * trust the word `unchanged`. For a block row over a file that EXISTS, the
521
+ * desired text is the file's own with the markers spliced (`desiredContent`
522
+ * below), so the two sides can differ only inside them — everything else is
523
+ * this service's own and was never looked at. A line reporting that as a fact
524
+ * about the FILE is the false guarantee `.claude/rules/automation-gates.md` §5
525
+ * calls a defect, and it was measured: a `find` over node_modules with an
526
+ * `rm -rf` loop sat below `oa-deps-guard v1` in api_biz/hello for months while
527
+ * every run printed `unchanged init.sh` (d.42, hello `7d14d07`).
528
+ *
529
+ * It is absent where no splice happened — a whole-file row, and a block row
530
+ * whose file is not there, which is created entire. The name comes from the
531
+ * manifest row and from nowhere else, so this module still holds no list of
532
+ * blocks of its own.
533
+ *
464
534
  * @param {{row: object, serviceRoot: string, workspaceRoot: string}} args
465
535
  * @returns {{id: string, path: string, outcome: 'unchanged'|'change'|'not-run'|'blocked',
466
- * current: string|null, desired?: string, detail?: string, reason?: string}}
536
+ * current: string|null, block?: string, desired?: string, detail?: string, reason?: string}}
467
537
  */
468
538
  function planRow({ row, serviceRoot, workspaceRoot }) {
469
539
  // Fail-fast rather than a NOT RUN line: `planSync` hands over only the rows
@@ -477,6 +547,9 @@ function planRow({ row, serviceRoot, workspaceRoot }) {
477
547
 
478
548
  const current = readServiceFile(serviceRoot, row.path);
479
549
  const base = { id: row.id, path: row.path, current };
550
+ // The same predicate `desiredContent` decides the splice by, asked once here
551
+ // so the reported scope and the rendered scope cannot drift apart.
552
+ if (typeof row.block === 'string' && current !== null) base.block = row.block;
480
553
 
481
554
  // The same question the manifest run asks of the same row, answered by the
482
555
  // same predicate: does THIS row need the workspace? Since d.229 most of them
@@ -41,7 +41,7 @@ function assertBoolean(value, fieldName) {
41
41
  }
42
42
 
43
43
  // The service declares WHAT its database is; the library implements HOW it is
44
- // built (see docs/biz/00-model/uniformity-principle.md). Everything the six
44
+ // built (see api/docs/biz/00-model/uniformity-principle.md). Everything the six
45
45
  // per-repo ci-setup-db.js scripts used to decide for themselves — client,
46
46
  // ordering, foreign-key handling — is a platform property and lives in the
47
47
  // library; only these four values legitimately differ per service.
@@ -51,7 +51,7 @@ const ENGINE_DECLARATION = /^(mariadb|mysql):[0-9][0-9.]*$/;
51
51
  /**
52
52
  * The collation a service declares its schema is created with — owner decision
53
53
  * 2026-09-14, `db-collation-declaration` 001, written into
54
- * `docs/biz/70-contracts/database-contract.md` §1.
54
+ * `api/docs/biz/70-contracts/database-contract.md` §1.
55
55
  *
56
56
  * Which utf8mb4 collation is the SERVICE's choice and this file sets none;
57
57
  * what it holds is that the value is one, because it is interpolated into
@@ -38,6 +38,49 @@
38
38
  * services: an assumed wrapper.storage section for MinIO exists nowhere, and
39
39
  * guessing it would have failed emailer and pdfgen for a configuration neither
40
40
  * was ever meant to have.
41
+ *
42
+ * ## What `env` IS — and the three questions asked of it
43
+ *
44
+ * `env` is the set of environment names the connector's own library resolves
45
+ * WITHOUT a default: the names that decide whether the connector opens at all.
46
+ * It is measured against that library's runtime-config schema, never guessed:
47
+ *
48
+ * db — no shared library resolves a `DB_*` name; a service reaches its
49
+ * schema itself, so `DB_HOST` is the endpoint of that reach
50
+ * redis — `@onlineapps/conn-base-cache/src/config.js` `REDIS_URL`. The
51
+ * `REDIS_HOST`/`REDIS_PORT` of `conn-base-state` are NOT here: the
52
+ * wrapper passes both explicitly, parsed out of `REDIS_URL`
53
+ * (`ServiceWrapper.js` `stateConfig?.host || parsed.host`), so an
54
+ * environment without them is no gap
55
+ * mq — `@onlineapps/mq-client-core/src/config.js` `RABBITMQ_URL`
56
+ * minio — `@onlineapps/conn-base-storage/src/config.js`, the five keys its
57
+ * schema marks `required: true` with no default. `MINIO_ACTUAL_HOST`
58
+ * is not among them: it is the OPTIONAL proxy-host override
59
+ * (`config.js` `actualHost`, no `required`), so a row demanding it
60
+ * would be this module inventing a requirement, and the services that
61
+ * do route through a proxy declare it where they read it — the
62
+ * `${MINIO_ACTUAL_HOST}` placeholder of `config/service/config.json`,
63
+ * which is M1 coverage (`utils/envContract.js`)
64
+ *
65
+ * Three mechanisms read this set, and they ask three different questions of it —
66
+ * said out loud, because a list read three ways is where a silent drift starts:
67
+ *
68
+ * 1. `verifyConnectorContract` below, at boot: is at least ONE of them set —
69
+ * evidence that the environment knows this connector at all. Deliberately
70
+ * not "all of them": the check runs in phase 0.2 of every start, it is a
71
+ * warning and not a stop, and the complete answer is question 3's;
72
+ * 2. `collectEnvCoverage` (`utils/envContract.js`), M2: these names are covered
73
+ * by the connector declaration, so the `env` block of the integration
74
+ * contract must not declare them a second time, and `--env-reads` lists them;
75
+ * 3. the uniform row `C-CI-CONNECTOR-ENV`
76
+ * (`manifest/checks/serviceConnectors.js`): EVERY one of them is set in the
77
+ * `variables:` of the job that runs the suite. That is the question the
78
+ * other two never asked, and the gap it closes was measured on 2026-09-18 —
79
+ * `MINIO_USE_SSL` missing from one CI job, failing three emailer tests on a
80
+ * name no line of the service reads.
81
+ *
82
+ * The minio set grew from two names to these five on the owner's decision
83
+ * (`api/docs/governance/confirmations/biz-service-manifest.md` 013).
41
84
  */
42
85
  const CONNECTORS = {
43
86
  db: { configSections: [], env: ['DB_HOST'] },
@@ -47,7 +90,10 @@ const CONNECTORS = {
47
90
  // @onlineapps/conn-base-storage directly. Verified 2026-08-22 — no service on
48
91
  // the platform declares wrapper.storage. So it is declared by the contract and
49
92
  // evidenced only by the environment.
50
- minio: { configSections: [], env: ['MINIO_ENDPOINT', 'MINIO_ACTUAL_HOST'] }
93
+ minio: {
94
+ configSections: [],
95
+ env: ['MINIO_ENDPOINT', 'MINIO_PORT', 'MINIO_USE_SSL', 'MINIO_ACCESS_KEY', 'MINIO_SECRET_KEY']
96
+ }
51
97
  };
52
98
 
53
99
  /** Where each of the two declarations lives, relative to the service root. */
@@ -165,9 +211,15 @@ function verifyConnectorContract({ config, requiredConnectors, env }) {
165
211
  + ` Fix: configure it, or set requiredConnectors.${name} to false if the service does not use it.`);
166
212
  }
167
213
 
214
+ // ONE of them, deliberately — question 1 of the three the header names.
215
+ // The message says which question it asked, so a reader does not take the
216
+ // silence of this step for the complete answer (`automation-gates.md` §5).
168
217
  const satisfied = spec.env.some((key) => env?.[key]);
169
218
  if (!satisfied) {
170
- errors.push(`Connector "${name}" is required but ${spec.env.join(' / ')} is not set.\n`
219
+ const names = spec.env.length === 1
220
+ ? `${spec.env[0]} is not set`
221
+ : `none of ${spec.env.join(' / ')} is set`;
222
+ errors.push(`Connector "${name}" is required but ${names}.\n`
171
223
  + ' Fix: set it in the CI job or config/env-active/*.env — a required connector without its '
172
224
  + 'endpoint fails later, inside the connector, where the cause is harder to see.');
173
225
  }