@onlineapps/conn-orch-validator 9.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +546 -0
  2. package/README.md +337 -19
  3. package/docs/DESIGN.md +32 -9
  4. package/manifests/biz-service.manifest.json +56 -6
  5. package/package.json +3 -2
  6. package/src/CookbookTestRunner.js +134 -22
  7. package/src/ValidationOrchestrator.js +312 -73
  8. package/src/cli/biz-ci-gate.js +191 -15
  9. package/src/cli/oa-sync-template.js +23 -8
  10. package/src/cli/oa-validate.js +70 -2
  11. package/src/index.js +21 -13
  12. package/src/lint/scripts/lintScripts.js +65 -18
  13. package/src/manifest/checks/composeRunnerBlock.js +37 -20
  14. package/src/manifest/checks/discoveryOrphan.js +2 -1
  15. package/src/manifest/checks/docsLintBridge.js +79 -21
  16. package/src/manifest/checks/gitTracked.js +14 -28
  17. package/src/manifest/checks/libraryPackage.js +3 -1
  18. package/src/manifest/checks/libraryWorkspace.js +18 -3
  19. package/src/manifest/checks/readmeRegion.js +9 -1
  20. package/src/manifest/checks/serviceConfig.js +29 -12
  21. package/src/manifest/checks/serviceDb.js +176 -7
  22. package/src/manifest/checks/serviceFiles.js +34 -7
  23. package/src/manifest/checks/serviceIdentityRows.js +3 -1
  24. package/src/manifest/checks/serviceRuntime.js +126 -1
  25. package/src/manifest/discovery.js +25 -7
  26. package/src/manifest/gitCheckout.js +84 -0
  27. package/src/manifest/runManifest.js +58 -7
  28. package/src/manifest/workspaceRoot.js +91 -5
  29. package/src/sync/serviceTemplate.js +76 -7
  30. package/src/sync/sharedEnv.js +11 -4
  31. package/src/sync/uniformFiles.js +91 -21
  32. package/src/utils/bizCiGateContract.js +25 -1
  33. package/src/utils/dbAccountGrants.js +126 -0
  34. package/src/utils/envContract.js +36 -6
  35. package/src/utils/envReads.js +102 -0
  36. package/src/utils/installContract.js +46 -5
  37. package/src/utils/libCompat.js +39 -19
  38. package/src/utils/preValidation.js +56 -11
  39. package/src/utils/stepFailure.js +106 -19
  40. package/src/utils/stepReferences.js +278 -0
  41. package/src/utils/testCoverageContract.js +60 -2
  42. package/src/utils/throwawaySchema.js +92 -7
  43. package/src/validatorIdentity.js +31 -0
  44. package/src/validators/ServiceStructureValidator.js +47 -15
  45. package/src/validators/ValidationProofGenerator.js +73 -34
  46. package/templates/business-service/.dockerignore +9 -1
  47. package/templates/business-service/.gitlab-ci.yml +199 -35
  48. package/templates/business-service/Dockerfile +49 -16
  49. package/templates/business-service/README.md +56 -9
  50. package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +17 -5
  51. package/templates/business-service/config/env-templates/shared.env +8 -2
  52. package/templates/business-service/docker-compose.production.yml +9 -0
  53. package/templates/business-service/docker-compose.yml +17 -0
  54. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +1 -1
  55. package/templates/business-service/docs/80-setup/VALIDATION.md +1 -1
  56. package/templates/business-service/jest.config.js +9 -1
  57. package/templates/business-service/package.json.template +1 -1
  58. package/src/mocks/MockStorage.js +0 -188
@@ -24,19 +24,24 @@
24
24
  * (here) — one concern, one rail (`.claude/rules/change-discipline.md` § One rail
25
25
  * per concern). Everything else in the block is compared byte for byte.
26
26
  *
27
- * The service's own identity is the other normalization: the runner's key is
28
- * `<container>_tests`, and the container name is the one thing that is per
29
- * repository by construction. It is substituted back to the template's
30
- * placeholders before the comparison, so the block is read as the template
31
- * wrote it.
27
+ * The service's own identity is the other half, and it is settled in ONE
28
+ * direction: the reference is RENDERED with this repository's names and the
29
+ * result compared against the file, never the file read back into placeholders
30
+ * (d.531). The runner's key is `<container>_tests` and the container name is per
31
+ * repository by construction, so a render is exact; reading a name back is not,
32
+ * because a name is also an ordinary word — for `api_biz/meta` it rewrote the
33
+ * template's own citation of that path and reported a conformant block as
34
+ * drifted. `renderRunnerIdentity` below is that one direction, and both the
35
+ * check and the generator go through it.
32
36
  *
33
37
  * ## The same definition renders
34
38
  *
35
- * `renderRunnerBlock` performs exactly the inverse: the template's block with
36
- * this repository's identity in it and this repository's three shared
37
- * declarations grafted in. That is what makes `npx oa-sync-template
38
- * docker-compose.yml` produce a block the check then finds clean — a generator
39
- * whose output its own check rejects is the defect this module exists to avoid.
39
+ * `renderRunnerBlock` is the generator's half: the same identity render, plus
40
+ * this repository's three shared declarations grafted in. That is what makes
41
+ * `npx oa-sync-template docker-compose.yml` produce a block the check then finds
42
+ * clean — a generator whose output its own check rejects is the defect this
43
+ * module exists to avoid, and it is measured over a service called `meta`
44
+ * (`tests/unit/manifestServiceFiles.test.js`).
40
45
  *
41
46
  * @see api/docs/governance/confirmations/biz-service-manifest.md §2, §3.2
42
47
  * @see api/docs/governance/confirmations/biz-test-container.md
@@ -156,8 +161,22 @@ function stripDeclarations(lines, keys) {
156
161
  }
157
162
 
158
163
  /**
159
- * A repository's runner block as the template wrote it: its identity replaced by
160
- * the placeholders, and the three shared declarations removed.
164
+ * The template's block with one repository's identity in it — the ONE direction
165
+ * this module substitutes (d.531).
166
+ *
167
+ * The other direction, reading a repository's block back into placeholders, was
168
+ * a second rail under the same rule and it could not be made right: a
169
+ * substitution of the service's NAME rewrites every occurrence of that string,
170
+ * including one in the template's own prose. Measured over `api_biz/meta`: the
171
+ * block cites `api_biz/meta/docker-compose.yml` as the file the memory numbers
172
+ * were taken in, so that service's conformant block was normalized into
173
+ * `api_biz/__SERVICE_NAME__/docker-compose.yml` and reported as drifted, while
174
+ * `oa-sync-template docker-compose.yml --check` — which renders — called the
175
+ * same file unchanged.
176
+ *
177
+ * A placeholder is a name nothing else can be, so rendering is total; a name is
178
+ * a word, and reading it back is a guess. One rail, and it is this one
179
+ * (`.claude/rules/change-discipline.md` § One rail per concern).
161
180
  *
162
181
  * The container name is substituted BEFORE the service name on purpose: the
163
182
  * container name normally contains the service name (`api_service_converter`
@@ -167,10 +186,10 @@ function stripDeclarations(lines, keys) {
167
186
  * @param {{lines: string[], containerName: string, serviceName: string}} args
168
187
  * @returns {string[]}
169
188
  */
170
- function normalizeRunnerBlock({ lines, containerName, serviceName }) {
171
- return stripDeclarations(lines, SHARED_DECLARATIONS).map((line) => line
172
- .split(containerName).join(CONTAINER_PLACEHOLDER)
173
- .split(serviceName).join(SERVICE_PLACEHOLDER));
189
+ function renderRunnerIdentity({ lines, containerName, serviceName }) {
190
+ return lines.map((line) => line
191
+ .split(CONTAINER_PLACEHOLDER).join(containerName)
192
+ .split(SERVICE_PLACEHOLDER).join(serviceName));
174
193
  }
175
194
 
176
195
  /**
@@ -188,9 +207,7 @@ function normalizeRunnerBlock({ lines, containerName, serviceName }) {
188
207
  * @returns {string[]} the block's lines, markers included
189
208
  */
190
209
  function renderRunnerBlock({ reference, composeText, containerName, serviceName }) {
191
- let rendered = reference.map((line) => line
192
- .split(CONTAINER_PLACEHOLDER).join(containerName)
193
- .split(SERVICE_PLACEHOLDER).join(serviceName));
210
+ let rendered = renderRunnerIdentity({ lines: reference, containerName, serviceName });
194
211
 
195
212
  const service = serviceLines(composeText, containerName);
196
213
 
@@ -217,6 +234,6 @@ module.exports = {
217
234
  serviceLines,
218
235
  replaceServiceNode,
219
236
  stripDeclarations,
220
- normalizeRunnerBlock,
237
+ renderRunnerIdentity,
221
238
  renderRunnerBlock
222
239
  };
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  const { discoverBearers, rootOfPattern } = require('../discovery');
21
+ const { describeWorkspaceFix } = require('../workspaceRoot');
21
22
 
22
23
  const check = Object.freeze({
23
24
  scope: 'workspace',
@@ -31,7 +32,7 @@ const check = Object.freeze({
31
32
  /** What the run prints instead of a verdict when the workspace is unreachable. */
32
33
  describeNotRun({ block }) {
33
34
  const owner = block && block.from ? block.from.path : 'the referenced SSOT';
34
- return `the workspace root is not reachable, so ${owner} cannot be read`;
35
+ return `the workspace root is not reachable, so ${owner} cannot be read. ${describeWorkspaceFix(owner)}`;
35
36
  },
36
37
 
37
38
  /**
@@ -54,7 +54,7 @@ const path = require('path');
54
54
  const { spawnSync } = require('child_process');
55
55
 
56
56
  const { whereOf } = require('./libraryContext');
57
- const { resolveWorkspacePath } = require('../workspaceRoot');
57
+ const { resolveWorkspacePath, describeWorkspaceFix, AS_THE_REASON_NAMES } = require('../workspaceRoot');
58
58
 
59
59
  /** The documentation lint, by the path the manifest writes every api path in. */
60
60
  const LINT_SCRIPT = 'api/scripts/ci/lint-biz-docs.mjs';
@@ -210,6 +210,55 @@ const ERROR_SEVERITY = 'error';
210
210
  */
211
211
  const errorGraded = (findings) => findings.filter((finding) => finding.severity === ERROR_SEVERITY);
212
212
 
213
+ /**
214
+ * The NOT RUN sentence of a row the linter could not decide — the rules it could
215
+ * not evaluate, the linter's own reason for the first of them, and the command
216
+ * that turns this checkout into one where the question has an answer.
217
+ *
218
+ * Three things, and each has exactly one owner. The rule ids and the reason are
219
+ * the LINTER'S: this module cites the tool, it does not restate it, which is the
220
+ * whole point of the bridge (docblock at the top of this file). The remedy is
221
+ * `workspaceRoot.js` § describeWorkspaceFix, shared with
222
+ * `runManifest.js` § describeMissingRoots — the other place a row goes NOT RUN
223
+ * because a sibling was not there. Until d.516 this end of it stopped at the
224
+ * linter's reason: measured 2026-09-15 over a `git archive HEAD` export placed
225
+ * beside no `api_biz`, the run printed
226
+ *
227
+ * NOT RUN D-PORT — F002:http-ports could not be decided — api_biz/*&#47;docker-compose.yml
228
+ * lives in a sibling checkout this run does not have, so the probe could not
229
+ * be evaluated
230
+ *
231
+ * while `U-ORPHAN`, in the SAME run and about the SAME absent directory, named
232
+ * the command. True, and nothing the reader could act on
233
+ * (`.claude/rules/automation-gates.md` §1 requirement 4).
234
+ *
235
+ * What the fix asks the reader to carry is left as the phrase pointing back at
236
+ * the reason, rather than a root resolved here. The linter's skipped channel
237
+ * names the absent THING — a probe target, a scan root, a manifest — and it
238
+ * carries no structured root to read (`api/scripts/ci/lint-biz-docs.mjs`
239
+ * § markSkipped: `{ rule, reason }` and nothing else). Deriving one would mean
240
+ * parsing that sentence, which is the private dialect between row and tool this
241
+ * bridge exists to avoid; naming the siblings here instead would make this
242
+ * module a second owner of the lint's own probe configuration.
243
+ *
244
+ * Collapsed into one reason per row: a checkout without siblings makes a dozen
245
+ * bans undecidable at once, and twelve identical sentences say nothing the first
246
+ * one does not.
247
+ *
248
+ * @param {Array<{rule: string, reason: string}>} undecided at least one entry
249
+ * @returns {string}
250
+ */
251
+ function describeUndecided(undecided) {
252
+ if (!Array.isArray(undecided) || undecided.length === 0) {
253
+ throw new Error('[DocsLintBridge] Undecided rules are required - describeUndecided() was given '
254
+ + `${JSON.stringify(undecided)}, and a row with nothing undecided is not NOT RUN. `
255
+ + 'Fix: call it only when the linter reported at least one rule it could not evaluate.');
256
+ }
257
+
258
+ return `${undecided.map((entry) => entry.rule).join(', ')} could not be decided — `
259
+ + `${undecided[0].reason}. ${describeWorkspaceFix(AS_THE_REASON_NAMES)}`;
260
+ }
261
+
213
262
  /**
214
263
  * Which row owns a lint finding: the one whose citation is the most specific.
215
264
  *
@@ -250,7 +299,11 @@ function claimantOf(ruleId, rows) {
250
299
  * @returns {{findings: Array<object>, skipped: Array<object>}|{problem: string}}
251
300
  */
252
301
  function lintOnce({ lintScript, docsDir, treeConfig }) {
253
- const key = [lintScript, docsDir, treeConfig].join('');
302
+ // `\0` as the escape, never the raw byte: written literally it makes the
303
+ // whole file binary to grep, diff and every reader that asks what changed
304
+ // (measured 2026-09-16 — `grep -n` answered "Binary file … matches" and
305
+ // printed nothing). The value is identical; the source stays readable.
306
+ const key = [lintScript, docsDir, treeConfig].join('\0');
254
307
  if (answers.has(key)) return answers.get(key);
255
308
 
256
309
  const answer = runLint({ lintScript, docsDir, treeConfig });
@@ -394,15 +447,31 @@ function askLint({ block, serviceRoot, workspaceRoot }) {
394
447
  return { answer };
395
448
  }
396
449
 
450
+ /**
451
+ * The absence BOTH documentation rows answer, in one sentence.
452
+ *
453
+ * `describeMissingRoots` answers "the workspace is there and the root is not";
454
+ * this one answers "there is no workspace at all". Until d.516b it stopped at
455
+ * the absence — the defect `.claude/rules/automation-gates.md` §1 requirement 4
456
+ * names, one channel over from the one d.516 closed. Until d.523 the sentence
457
+ * was then written TWICE, once per row, byte for byte: two rails for one
458
+ * concern (`.claude/rules/change-discipline.md` § One rail per concern), and
459
+ * the shape in which the first of the two would one day be improved alone.
460
+ *
461
+ * @returns {string}
462
+ */
463
+ function describeLintNotRun() {
464
+ return `the workspace root is not reachable, so ${LINT_SCRIPT} cannot be run. `
465
+ + describeWorkspaceFix(LINT_SCRIPT);
466
+ }
467
+
397
468
  const docsLint = Object.freeze({
398
469
  scope: 'bearer',
399
470
  requires: Object.freeze(['rule']),
400
471
 
401
472
  requiresSiblings: () => [LINT_DIR],
402
473
 
403
- describeNotRun() {
404
- return `the workspace root is not reachable, so ${LINT_SCRIPT} cannot be run`;
405
- },
474
+ describeNotRun: describeLintNotRun,
406
475
 
407
476
  /**
408
477
  * @param {{ row: object, block: object, serviceRoot: string, workspaceRoot: string }} params
@@ -446,20 +515,15 @@ const docsLint = Object.freeze({
446
515
  // is `answer.problem` above, and the absent tree config before it, both of
447
516
  // which stay findings with a fix somebody can carry out.
448
517
  //
449
- // Collapsed into one reason per row: a checkout without siblings makes a
450
- // dozen bans undecidable at once, and twelve identical sentences say nothing
451
- // the first one does not.
518
+ // The sentence itself is `describeUndecided` above — shared with `D-LINT`,
519
+ // which reports the same absence about the rules no row claims.
452
520
  const undecided = answer.skipped
453
521
  .filter((entry) => citations.some((citation) => covers(citation, entry.rule)))
454
522
  .filter((entry) => claimantOf(entry.rule, rows) === row.id);
455
523
 
456
524
  if (undecided.length === 0) return found;
457
525
 
458
- return {
459
- findings: found,
460
- notRun: `${undecided.map((entry) => entry.rule).join(', ')} could not be decided — `
461
- + `${undecided[0].reason}`
462
- };
526
+ return { findings: found, notRun: describeUndecided(undecided) };
463
527
  }
464
528
  });
465
529
 
@@ -497,9 +561,7 @@ const docsLintClean = Object.freeze({
497
561
 
498
562
  requiresSiblings: () => [LINT_DIR],
499
563
 
500
- describeNotRun() {
501
- return `the workspace root is not reachable, so ${LINT_SCRIPT} cannot be run`;
502
- },
564
+ describeNotRun: describeLintNotRun,
503
565
 
504
566
  /**
505
567
  * @param {{ block: object, serviceRoot: string, workspaceRoot: string }} params
@@ -531,11 +593,7 @@ const docsLintClean = Object.freeze({
531
593
  const undecided = answer.skipped.filter((entry) => claimantOf(entry.rule, rows) === null);
532
594
  if (undecided.length === 0) return found;
533
595
 
534
- return {
535
- findings: found,
536
- notRun: `${undecided.map((entry) => entry.rule).join(', ')} could not be decided — `
537
- + `${undecided[0].reason}`
538
- };
596
+ return { findings: found, notRun: describeUndecided(undecided) };
539
597
  }
540
598
  });
541
599
 
@@ -45,7 +45,7 @@
45
45
 
46
46
  const fs = require('fs');
47
47
  const path = require('path');
48
- const { execFileSync } = require('child_process');
48
+ const { gitCommand, listTrackedFiles, NOT_A_CHECKOUT } = require('../gitCheckout');
49
49
 
50
50
  /** Never walked: neither is part of a repository's declared shape. */
51
51
  const NEVER_WALKED = Object.freeze(['node_modules', '.git']);
@@ -53,29 +53,6 @@ const NEVER_WALKED = Object.freeze(['node_modules', '.git']);
53
53
  /** The file classes whose rows REQUIRE a path to be there. */
54
54
  const REQUIRING_CLASSES = Object.freeze(['identical', 'contains', 'generated']);
55
55
 
56
- /**
57
- * Run one git command in the repository, or return null when git itself could
58
- * not answer. Nothing here guesses: the caller turns a null into NOT RUN.
59
- *
60
- * @param {string} serviceRoot
61
- * @param {string[]} args
62
- * @param {string} [input]
63
- * @returns {string|null}
64
- */
65
- function git(serviceRoot, args, input = undefined) {
66
- try {
67
- return execFileSync('git', ['-C', serviceRoot, ...args], {
68
- encoding: 'utf8',
69
- input,
70
- stdio: ['pipe', 'pipe', 'pipe']
71
- });
72
- } catch (error) {
73
- // check-ignore exits 1 when nothing matched, which is an ANSWER.
74
- if (error.status === 1 && typeof error.stdout === 'string') return error.stdout;
75
- return null;
76
- }
77
- }
78
-
79
56
  /**
80
57
  * Does the path exist under EXACTLY this spelling?
81
58
  *
@@ -161,12 +138,21 @@ const check = Object.freeze({
161
138
  * @returns {{findings: Array<{where: string, what: string}>, notRun: string|null}}
162
139
  */
163
140
  run({ block, serviceRoot }) {
164
- const listed = git(serviceRoot, ['ls-files', '-z']);
141
+ const listed = listTrackedFiles(serviceRoot);
165
142
  if (listed === null) {
166
143
  return {
167
144
  findings: [],
168
- notRun: 'this tree is not a git checkout (git ls-files could not answer), so what a clone '
169
- + 'would receive cannot be read — a container and an exported tarball are in exactly this state'
145
+ // The remedy of this row is NOT the one every other NOT RUN of this
146
+ // package carries. `workspaceRoot.js` § describeWorkspaceFix names
147
+ // `--workspace`, which points at a tree of checkouts; no value of it
148
+ // turns an export into a checkout, and this row asks git, not the
149
+ // workspace. So the sentence is this row's own — one remedy, one owner,
150
+ // and neither borrowed from the other
151
+ // (`.claude/rules/change-discipline.md` § One rail per concern).
152
+ // Until d.518 it stopped at the absence, which is the dead end
153
+ // `.claude/rules/automation-gates.md` §1 requirement 4 forbids.
154
+ notRun: `${NOT_A_CHECKOUT} Fix: run this row over a git checkout — a clone of the repository, `
155
+ + 'never an export, a tarball or a built image.'
170
156
  };
171
157
  }
172
158
 
@@ -180,7 +166,7 @@ const check = Object.freeze({
180
166
 
181
167
  // Only now, and only for those: which of them a .gitignore rule covers is
182
168
  // what turns the finding into an edit the reader can make.
183
- const answered = git(serviceRoot, ['check-ignore', '--stdin', '-z'], missing.map(({ file }) => file).join('\0'));
169
+ const answered = gitCommand(serviceRoot, ['check-ignore', '--stdin', '-z'], missing.map(({ file }) => file).join('\0'));
184
170
  const ignored = new Set((answered === null ? '' : answered).split('\0').filter((entry) => entry.length > 0));
185
171
 
186
172
  return {
@@ -17,6 +17,7 @@ const fs = require('fs');
17
17
  const path = require('path');
18
18
 
19
19
  const { resolveFromValue } = require('../discovery');
20
+ const { describeWorkspaceFix } = require('../workspaceRoot');
20
21
  const {
21
22
  readPackage, whereOf, appliesTo, allDeps, scopedDeps, nodeMajorOf
22
23
  } = require('./libraryContext');
@@ -91,7 +92,8 @@ const libraryEngines = Object.freeze({
91
92
  requires: Object.freeze(['from']),
92
93
 
93
94
  describeNotRun({ row }) {
94
- return `the workspace root is not reachable, so ${row.from.path} cannot be read`;
95
+ return `the workspace root is not reachable, so ${row.from.path} cannot be read. `
96
+ + describeWorkspaceFix(row.from.path);
95
97
  },
96
98
 
97
99
  run({ row, block, serviceRoot, workspaceRoot }) {
@@ -28,7 +28,7 @@ const path = require('path');
28
28
  const {
29
29
  discoverBearers, resolveFromReference, resolveFromMap, expandPattern, rootOfPattern
30
30
  } = require('../discovery');
31
- const { resolveWorkspacePath } = require('../workspaceRoot');
31
+ const { resolveWorkspacePath, describeWorkspaceFix, AS_THE_REASON_NAMES } = require('../workspaceRoot');
32
32
  const {
33
33
  readJson, readPackage, whereOf, declaredCategory, appliesTo, scopedDeps, SCOPE
34
34
  } = require('./libraryContext');
@@ -39,8 +39,23 @@ const RUNTIME_SECTIONS = Object.freeze(['dependencies']);
39
39
  /** The sections `scripts/ci/verify-manifest-pins.mjs` compares against the SSOT. */
40
40
  const PINNED_SECTIONS = Object.freeze(['dependencies', 'devDependencies']);
41
41
 
42
- /** Why a workspace-scoped row prints nothing instead of a verdict. */
43
- const notRunBecause = (what) => `the workspace root is not reachable, so ${what} cannot be read`;
42
+ /**
43
+ * Why a workspace-scoped row prints nothing instead of a verdict — and, since
44
+ * d.518, what to run so that it can.
45
+ *
46
+ * Five rows of the library uniform are reported through this one line
47
+ * (`U-ORPHAN`, `U-MISMATCH`, `L-PINS`, `L-CONSUMER`, `L-TOOLING`), so the
48
+ * command belongs here rather than at each of them: one remedy, one sentence,
49
+ * one owner (`workspaceRoot.js` § describeWorkspaceFix,
50
+ * `.claude/rules/change-discipline.md` § One rail per concern). Until d.518 all
51
+ * five stopped at the absence — true, and nothing a reader of a CI log can act
52
+ * on (`.claude/rules/automation-gates.md` §1 requirement 4).
53
+ *
54
+ * WHAT the checkout has to carry is already named by `what`, immediately before
55
+ * the fix, so the fix points back at it instead of repeating it in other words.
56
+ */
57
+ const notRunBecause = (what) => `the workspace root is not reachable, so ${what} cannot be read. `
58
+ + describeWorkspaceFix(AS_THE_REASON_NAMES);
44
59
 
45
60
  /**
46
61
  * Every package the uniform's discovery pattern finds, indexed by the name its
@@ -32,6 +32,7 @@ const {
32
32
  readReadme, serviceRegion, libraryRegion, PACKAGE_IN_WORKSPACE
33
33
  } = require('../../sync/readmeLocation');
34
34
  const { whereOf, declaredCategory, readPackage } = require('./libraryContext');
35
+ const { describeWorkspaceFix, AS_THE_REASON_NAMES } = require('../workspaceRoot');
35
36
 
36
37
  /**
37
38
  * The first line at which the region on disk stops being the rendered one, and
@@ -102,8 +103,15 @@ const libraryReadmeRegion = Object.freeze({
102
103
  // holds a copy of this package.
103
104
  requiresSiblings: () => (PACKAGE_IN_WORKSPACE === null ? [] : [PACKAGE_IN_WORKSPACE]),
104
105
 
106
+ // The absence, and then the command that removes it, from the one owner of
107
+ // that command (`workspaceRoot.js` § describeWorkspaceFix). What the checkout
108
+ // must carry is named by the sentence in front of it, so the fix points back
109
+ // at it rather than saying it a second time in other words — which is what
110
+ // `AS_THE_REASON_NAMES` is for. Until d.518 this ended at the absence
111
+ // (`.claude/rules/automation-gates.md` §1 requirement 4).
105
112
  describeNotRun() {
106
- return 'the workspace root is not reachable, so the manifest the region links at cannot be located';
113
+ return 'the workspace root is not reachable, so the manifest the region links at cannot be located. '
114
+ + describeWorkspaceFix(AS_THE_REASON_NAMES);
107
115
  },
108
116
 
109
117
  run({ row, serviceRoot, workspaceRoot }) {
@@ -25,9 +25,10 @@
25
25
  const fs = require('fs');
26
26
  const path = require('path');
27
27
 
28
- const { readReferencedFile, isPackageReference, referenceOwner } = require('../discovery');
28
+ const { resolveFromDocument, isPackageReference, referenceOwner } = require('../discovery');
29
+ const { describeWorkspaceFix } = require('../workspaceRoot');
29
30
  const { whereOf } = require('./libraryContext');
30
- const { diffAgainst } = require('../../sync/sharedEnv');
31
+ const { diffAgainst, renderSharedEnv } = require('../../sync/sharedEnv');
31
32
  const { loadAndValidateIntegrationContract } = require('../../utils/bizCiGateContract');
32
33
 
33
34
  /** The generated platform file every service carries a copy of. */
@@ -333,14 +334,29 @@ const envTemplates = Object.freeze({
333
334
  });
334
335
 
335
336
  /**
336
- * `shared.env` is rendered from `api/config/shared-env.json`, and this row reads
337
- * the RENDER the package carries (d.229): the platform manifest stays the owner
338
- * of the key set, `templates/business-service/config/env-templates/shared.env`
339
- * is its output, and `sharedEnvTemplate.test.js` is what keeps the two equal.
340
- * Reading the render rather than the manifest is what lets the row run inside a
341
- * service container, where `api/config/` is not there at all — and that is the
342
- * only place a service's own `shared.env` is ever wrong for a reader who cannot
343
- * fix it from the workspace.
337
+ * `shared.env` is generated, never written, and this row reads the file that
338
+ * OWNS the key set — `api/config/shared-env.json` in the workspace — through the
339
+ * same renderer `npx oa-sync-template shared-env` writes with (`src/sync/sharedEnv.js`).
340
+ *
341
+ * Until d.536 it read the render this package carries (d.229), so the verdict
342
+ * depended on the day the validator was published rather than on the SSOT: the
343
+ * three services synced after the 9.0.0 publish were told "10 line(s) differ" by
344
+ * a row whose own fix command had just written the file, because the SSOT had
345
+ * gained `LOG_MAX_SIZE_BYTES`, `LOG_MAX_FILES` and a comment since. One fact
346
+ * cannot be read off two files (`.claude/rules/change-discipline.md` § One rail
347
+ * per concern), and a gate whose answer moves with a publication date is not
348
+ * predictable (`.claude/rules/automation-gates.md` §1.1).
349
+ *
350
+ * The reason d.229 gave for the packaged copy — "a service container has no
351
+ * `api/config/`" — no longer buys anything the row needs: the deploy job the
352
+ * uniform runs in clones the api checkout (confirmation `biz-service-manifest`
353
+ * 008), and where the workspace really is out of reach the row says NOT RUN and
354
+ * names the command, which is what every other workspace-reading row does. A
355
+ * silent pass measured against yesterday's key set is the false guarantee
356
+ * `automation-gates.md` §5 calls a defect.
357
+ *
358
+ * `templates/business-service/config/env-templates/shared.env` stays as the
359
+ * scaffold's own output, and `sharedEnvTemplate.test.js` stays the gate on it.
344
360
  */
345
361
  const sharedEnvGenerated = Object.freeze({
346
362
  scope: 'bearer',
@@ -349,7 +365,8 @@ const sharedEnvGenerated = Object.freeze({
349
365
  needsWorkspace: ({ row }) => !isPackageReference(row.from),
350
366
 
351
367
  describeNotRun({ row }) {
352
- return `the workspace root is not reachable, so ${referenceOwner(row.from)} cannot be read`;
368
+ const owner = referenceOwner(row.from);
369
+ return `the workspace root is not reachable, so ${owner} cannot be read. ${describeWorkspaceFix(owner)}`;
353
370
  },
354
371
 
355
372
  run({ row, serviceRoot, workspaceRoot }) {
@@ -360,7 +377,7 @@ const sharedEnvGenerated = Object.freeze({
360
377
  return [{ where, what: `absent — it is rendered from ${owner}` }];
361
378
  }
362
379
 
363
- const rendered = readReferencedFile({ from: row.from, workspaceRoot });
380
+ const rendered = renderSharedEnv(resolveFromDocument({ from: row.from, workspaceRoot }));
364
381
  if (rendered === text) return [];
365
382
 
366
383
  const differing = diffAgainst(rendered, text).split('\n').filter((line) => /^[-+]/.test(line)).length - 2;