@onlineapps/conn-orch-validator 8.1.0 → 10.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 (50) hide show
  1. package/CHANGELOG.md +412 -0
  2. package/README.md +83 -9
  3. package/docs/DESIGN.md +21 -7
  4. package/manifests/biz-service.manifest.json +28 -5
  5. package/package.json +3 -3
  6. package/src/CookbookTestRunner.js +84 -16
  7. package/src/ValidationOrchestrator.js +73 -20
  8. package/src/cli/biz-ci-gate.js +28 -14
  9. package/src/cli/oa-sync-template.js +23 -8
  10. package/src/cli/oa-validate.js +7 -1
  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 +12 -1
  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/serviceFiles.js +34 -7
  22. package/src/manifest/checks/serviceIdentityRows.js +3 -1
  23. package/src/manifest/checks/serviceRuntime.js +3 -1
  24. package/src/manifest/discovery.js +25 -7
  25. package/src/manifest/runManifest.js +58 -7
  26. package/src/manifest/workspaceRoot.js +91 -5
  27. package/src/sync/serviceTemplate.js +76 -7
  28. package/src/sync/sharedEnv.js +11 -4
  29. package/src/sync/uniformFiles.js +91 -21
  30. package/src/utils/bizCiGateContract.js +25 -1
  31. package/src/utils/installContract.js +46 -5
  32. package/src/utils/libCompat.js +39 -19
  33. package/src/utils/preValidation.js +56 -11
  34. package/src/utils/stepFailure.js +106 -19
  35. package/src/utils/testCoverageContract.js +60 -2
  36. package/src/utils/throwawaySchema.js +92 -7
  37. package/src/validatorIdentity.js +31 -0
  38. package/src/validators/ServiceStructureValidator.js +41 -15
  39. package/src/validators/ValidationProofGenerator.js +73 -34
  40. package/templates/business-service/.dockerignore +9 -1
  41. package/templates/business-service/.gitlab-ci.yml +97 -30
  42. package/templates/business-service/README.md +14 -5
  43. package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +17 -5
  44. package/templates/business-service/config/env-templates/shared.env +7 -1
  45. package/templates/business-service/docs/80-setup/INSTALL.md +13 -6
  46. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +1 -1
  47. package/templates/business-service/docs/80-setup/VALIDATION.md +1 -1
  48. package/templates/business-service/jest.config.js +9 -1
  49. package/templates/business-service/package.json.template +1 -1
  50. package/src/mocks/MockStorage.js +0 -188
@@ -27,9 +27,10 @@ const path = require('path');
27
27
 
28
28
  const { readReferencedFile, isPackageReference, referenceOwner } = require('../discovery');
29
29
  const { whereOf } = require('./libraryContext');
30
+ const { describeWorkspaceFix } = require('../workspaceRoot');
30
31
  const { readComposeServices, scalarAt, declares, declarationOf } = require('./composeShape');
31
32
  const {
32
- SHARED_DECLARATIONS, CONTAINER_PLACEHOLDER, SERVICE_PLACEHOLDER, normalizeRunnerBlock
33
+ SHARED_DECLARATIONS, renderRunnerIdentity, stripDeclarations
33
34
  } = require('./composeRunnerBlock');
34
35
  const { renderText, topLevelKeys } = require('../../sync/serviceTemplate');
35
36
  const { readIdentity, requireIdentity, IDENTITY_FILE } = require('../serviceIdentity');
@@ -51,7 +52,8 @@ const READS_ITS_REFERENCE_ONLY = Object.freeze({
51
52
  needsWorkspace: ({ row }) => !isPackageReference(row.from),
52
53
 
53
54
  describeNotRun({ row }) {
54
- return `the workspace root is not reachable, so ${referenceOwner(row.from)} cannot be read`;
55
+ const owner = referenceOwner(row.from);
56
+ return `the workspace root is not reachable, so ${owner} cannot be read. ${describeWorkspaceFix(owner)}`;
55
57
  }
56
58
  });
57
59
 
@@ -433,9 +435,14 @@ const composeRunner = Object.freeze({
433
435
  return findings;
434
436
  }
435
437
 
438
+ // The reference is rendered for THIS repository and compared with the file
439
+ // as it stands — the direction the generator writes in, and the only one
440
+ // that is exact (`composeRunnerBlock.js` § renderRunnerIdentity, d.531).
441
+ // The three shared declarations come off both sides, because the rows above
442
+ // compare those against the service this runner tests.
436
443
  const difference = firstDifference(
437
- normalizeRunnerBlock({ lines: mine, containerName, serviceName }),
438
- normalizeRunnerBlock({ lines: reference, containerName: CONTAINER_PLACEHOLDER, serviceName: SERVICE_PLACEHOLDER })
444
+ stripDeclarations(mine, SHARED_DECLARATIONS),
445
+ stripDeclarations(renderRunnerIdentity({ lines: reference, containerName, serviceName }), SHARED_DECLARATIONS)
439
446
  );
440
447
  if (difference !== null) {
441
448
  say(`the "${row.block}" block differs from the template at block line ${difference.line}: `
@@ -600,6 +607,25 @@ const DOCKERIGNORE_BLOCK = 'oa-dockerignore v1';
600
607
  */
601
608
  const DOCKER_IMPLICIT = Object.freeze(['.git']);
602
609
 
610
+ /**
611
+ * The test tree, which `.gitignore` must NOT declare and the image must not
612
+ * carry: tests live in the repository and never in the artefact that runs. It is
613
+ * the same decision the library uniform makes one row over as `L-PACK-TESTS`,
614
+ * applied to an image instead of a tarball — the reason is one, so the sentence
615
+ * is one (`.claude/rules/change-discipline.md` § One rail per concern).
616
+ *
617
+ * The exception is the reason this is a list and not a line. `tests/cookbooks`
618
+ * is NOT a test: it is a declaration the RUNTIME reads. Tier-1 of the boot runs
619
+ * those cookbooks at phase 0.2, and an image without the directory measures zero
620
+ * of them, so `ValidationProofGenerator` refuses the proof as NO_TESTS
621
+ * (`src/validators/ValidationProofGenerator.js`) and the service never starts.
622
+ * Measured on 2026-09-16 against the wrapper's boot path.
623
+ *
624
+ * Order carries the meaning: in a `.dockerignore` a later line wins, so the
625
+ * re-inclusion stands AFTER the two exclusions and never before them.
626
+ */
627
+ const DOCKER_TEST_TREE = Object.freeze(['tests', '**/tests', '!tests/cookbooks']);
628
+
603
629
  /**
604
630
  * The exclusions a `.dockerignore` must carry, DERIVED from the very declaration
605
631
  * the `.gitignore` row reads. One list, two consumers
@@ -619,9 +645,9 @@ const DOCKER_IMPLICIT = Object.freeze(['.git']);
619
645
  * A negation keeps its `!` in front of the pattern rather than inside it, and
620
646
  * keeps its place in the order — in both formats a later line wins.
621
647
  *
622
- * What the derivation does NOT do is exclude anything `.gitignore` does not:
623
- * `tests/` is the measured trap on that side, because Tier-1 of the boot reads
624
- * `tests/cookbooks` and an image without it fails at phase 0.2.
648
+ * What the derivation adds to the declaration is the test tree, and exactly one
649
+ * exception inside it — see `DOCKER_TEST_TREE` above. Nothing else: an entry the
650
+ * `.gitignore` does not declare has no business being invented here.
625
651
  *
626
652
  * @param {string} gitignoreText the declaration both rows read
627
653
  * @returns {string[]}
@@ -640,6 +666,7 @@ function dockerignoreEntries(gitignoreText) {
640
666
  if (!pattern.includes('/')) add(mark(`**/${pattern}`));
641
667
  }
642
668
 
669
+ for (const entry of DOCKER_TEST_TREE) add(entry);
643
670
  for (const entry of DOCKER_IMPLICIT) add(entry);
644
671
  return entries;
645
672
  }
@@ -46,6 +46,7 @@ const { readComposeServices } = require('./composeShape');
46
46
  const { readIdentity, IDENTITY_FILE } = require('../serviceIdentity');
47
47
  const { PLACEHOLDERS } = require('../../sync/serviceTemplate');
48
48
  const { resolveFromMap } = require('../discovery');
49
+ const { describeWorkspaceFix } = require('../workspaceRoot');
49
50
 
50
51
  /** Where a service declares the name npm knows it by. */
51
52
  const PACKAGE_FILE = 'package.json';
@@ -271,7 +272,8 @@ const ssotIdentity = Object.freeze({
271
272
  requires: Object.freeze([]),
272
273
 
273
274
  describeNotRun({ block }) {
274
- return `the workspace root is not reachable, so ${block.from.path} cannot be read`;
275
+ return `the workspace root is not reachable, so ${block.from.path} cannot be read. `
276
+ + describeWorkspaceFix(block.from.path);
275
277
  },
276
278
 
277
279
  run({ block, serviceRoot, workspaceRoot }) {
@@ -44,6 +44,7 @@ const path = require('path');
44
44
 
45
45
  const { resolveFromValue, isPackageReference, referenceOwner } = require('../discovery');
46
46
  const { whereOf } = require('./libraryContext');
47
+ const { describeWorkspaceFix } = require('../workspaceRoot');
47
48
  const { readComposeServices, scalarAt, declares, declarationOf } = require('./composeShape');
48
49
 
49
50
  /** Where a service block states its own budget, in both compose files. */
@@ -106,7 +107,8 @@ const nodeMajor = Object.freeze({
106
107
  needsWorkspace: ({ row }) => !isPackageReference(row.from),
107
108
 
108
109
  describeNotRun({ row }) {
109
- return `the workspace root is not reachable, so ${referenceOwner(row.from)} cannot be read`;
110
+ const owner = referenceOwner(row.from);
111
+ return `the workspace root is not reachable, so ${owner} cannot be read. ${describeWorkspaceFix(owner)}`;
110
112
  },
111
113
 
112
114
  run({ row, serviceRoot, workspaceRoot }) {
@@ -237,21 +237,38 @@ function resolveFromValue({ from, workspaceRoot }) {
237
237
  }
238
238
 
239
239
  /**
240
- * The node a `from.list` path points at, as the owner file writes it.
240
+ * The whole referenced file as the document it is: `{ path, text: true }` over a
241
+ * JSON SSOT, for a row that does not take a value out of it but hands it to the
242
+ * module that owns its shape (`G-SHARED-ENV` renders `api/config/shared-env.json`
243
+ * through `src/sync/sharedEnv.js`).
241
244
  *
242
- * @param {{ from: {path: string, list: string}, workspaceRoot: string }} params
243
- * @returns {Array|object} the array or the object map the reference names
245
+ * It is the same read and the same refusal `resolveFromMap` makes, said once:
246
+ * a broken SSOT is reported as a broken SSOT wherever a row reads it, and the
247
+ * second call site is the one that would have written its own sentence
248
+ * (`.claude/rules/change-discipline.md` § One rail per concern).
249
+ *
250
+ * @param {{ from: {path: string}, workspaceRoot: string }} params
251
+ * @returns {object|Array} the parsed document
244
252
  */
245
- function resolveFromMap({ from, workspaceRoot }) {
253
+ function resolveFromDocument({ from, workspaceRoot }) {
246
254
  const raw = readReferencedFile({ from, workspaceRoot });
247
255
 
248
- let document;
249
256
  try {
250
- document = JSON.parse(raw);
257
+ return JSON.parse(raw);
251
258
  } catch (cause) {
252
- throw new Error(`[ManifestDiscovery] Referenced file is not valid JSON - ${from.path}. `
259
+ throw new Error(`[ManifestDiscovery] Referenced file is not valid JSON - ${referenceOwner(from)}. `
253
260
  + 'Fix: repair the file; it is the SSOT this row reads.', { cause });
254
261
  }
262
+ }
263
+
264
+ /**
265
+ * The node a `from.list` path points at, as the owner file writes it.
266
+ *
267
+ * @param {{ from: {path: string, list: string}, workspaceRoot: string }} params
268
+ * @returns {Array|object} the array or the object map the reference names
269
+ */
270
+ function resolveFromMap({ from, workspaceRoot }) {
271
+ const document = resolveFromDocument({ from, workspaceRoot });
255
272
 
256
273
  const node = from.list.split('.').reduce((current, key) => (current == null ? undefined : current[key]), document);
257
274
  if (node === null || node === undefined || typeof node !== 'object') {
@@ -378,6 +395,7 @@ module.exports = {
378
395
  resolveFromValue,
379
396
  resolveFromMap,
380
397
  readReferencedFile,
398
+ resolveFromDocument,
381
399
  expandPattern,
382
400
  rootOfPattern,
383
401
  containerOf,
@@ -19,7 +19,7 @@ const path = require('path');
19
19
  const { verifyManifestShape, rowNeedsWorkspace } = require('./manifestShape');
20
20
  const { collectRows } = require('./walk');
21
21
  const { discoverBearers, rootOfPattern } = require('./discovery');
22
- const { resolveWorkspacePath, canonicalRoot } = require('./workspaceRoot');
22
+ const { resolveWorkspacePath, canonicalRoot, describeWorkspaceFix } = require('./workspaceRoot');
23
23
  const { CHECK_REGISTRY } = require('./checks');
24
24
 
25
25
  /** The three scopes, by the name the runner branches on. */
@@ -164,22 +164,45 @@ function runManifest({ manifest, serviceRoot = null, workspaceRoot = null, check
164
164
  const check = checkRegistry[row.check];
165
165
  const needsWorkspace = rowNeedsWorkspace({ check, row, block });
166
166
 
167
+ // The sentence a row is reported NOT RUN with belongs to the CHECK, because
168
+ // only the check knows what it could not read. Until d.518 a check that
169
+ // declared none borrowed `the workspace root is not reachable` from here —
170
+ // a sentence about a row this file knows nothing about, with no fix in it,
171
+ // which nothing reached: every registered workspace-dependent check owns
172
+ // one, and `service-identity` declares `needsWorkspace: () => false` and
173
+ // cannot arrive here at all. A default nobody reaches is the false
174
+ // guarantee `.claude/rules/automation-gates.md` §5 calls a defect, so the
175
+ // duty is stated rather than covered for — and stated on EVERY run, not
176
+ // only on the ones without a workspace, which is where it would otherwise
177
+ // be discovered (`architecture-principles.md` §4, fail-fast).
178
+ if (needsWorkspace && typeof check.describeNotRun !== 'function') {
179
+ throw new Error(`[Manifest] Check "${row.check}" owns no NOT RUN sentence - row ${row.id} needs the `
180
+ + 'workspace, so a run without one has to say why this row was not answered and how to answer it. '
181
+ + 'Fix: add describeNotRun({ row, block }) to the check, ending it with '
182
+ + 'workspaceRoot.js § describeWorkspaceFix.');
183
+ }
184
+
167
185
  if (needsWorkspace && workspace === null) {
168
186
  notRun.push({
169
187
  id: row.id,
170
188
  severity: row.severity,
171
- reason: check.describeNotRun
172
- ? check.describeNotRun({ row, block })
173
- : 'the workspace root is not reachable'
189
+ reason: check.describeNotRun({ row, block })
174
190
  });
175
191
  continue;
176
192
  }
177
193
 
178
194
  if (needsWorkspace && outsideWorkspace) {
195
+ // A state sentence is not a remedy. This one said where the repository
196
+ // is NOT, and stopped — the same half-answer d.518 removed from the
197
+ // checks' own sentences, in the branch that reports it for them. The
198
+ // command comes from the one owner of it, and what the checkout has to
199
+ // carry is the repository under check (d.523).
179
200
  notRun.push({
180
201
  id: row.id,
181
202
  severity: row.severity,
182
- reason: `service root is outside the workspace root ${workspace}`
203
+ reason: `service root is outside the workspace root ${workspace} `
204
+ + '- a row about the workspace cannot be answered about a repository that is not in it. '
205
+ + describeWorkspaceFix(root)
183
206
  });
184
207
  continue;
185
208
  }
@@ -320,13 +343,41 @@ function missingRoots({ roots, workspaceRoot }) {
320
343
  }
321
344
 
322
345
  /**
346
+ * The NOT RUN reason for a row whose sibling root is not there, naming every
347
+ * missing one AND what to do about it.
348
+ *
349
+ * The fix half is not decoration. This line is read where the reader has no
350
+ * workspace to look at — a service's own CI log, a container — and until d.511
351
+ * it stopped at "is not present in this checkout": true, and nothing anybody
352
+ * could act on. Measured 2026-09-15 over a `git archive` export of
353
+ * `api_biz/hello-service` placed beside an api clone with no `api_biz/` around
354
+ * them: the run printed `NOT RUN U-ORPHAN — sibling root api_biz is not
355
+ * present in this checkout` and, because the report's own incomplete-run
356
+ * sentence carries its fix only on a CLEAR verdict (`report.js`
357
+ * § describeClearOutcome), a run that also had findings named no fix at all.
358
+ * `automation-gates.md` §1 requirement 4 asks for the exact command, and the
359
+ * sync half of this package already gives it (`src/sync/uniformFiles.js`
360
+ * § planRow).
361
+ *
362
+ * The command is DERIVED from the missing roots rather than written beside
363
+ * them, so a row that starts reading a new sibling needs no second edit here.
364
+ * It is also not written HERE: the sentence belongs to `workspaceRoot.js`
365
+ * § describeWorkspaceFix, because the bridge to the documentation lint reports
366
+ * the same absence through the linter's own NOT RUN channel and must offer the
367
+ * same remedy (d.516, `.claude/rules/change-discipline.md` § One rail per
368
+ * concern). This function still owns what is missing; the helper owns what to
369
+ * do about it.
370
+ *
323
371
  * @param {string[]} missing the roots that are not there
324
- * @returns {string} the NOT RUN reason, naming every one of them
372
+ * @returns {string} the NOT RUN reason, naming every one of them and the fix
325
373
  */
326
374
  function describeMissingRoots(missing) {
327
- return missing.length === 1
375
+ const found = missing.length === 1
328
376
  ? `sibling root ${missing[0]} is not present in this checkout`
329
377
  : `sibling roots ${missing.join(', ')} are not present in this checkout`;
378
+
379
+ return `${found} - an absent directory is not an empty one, so this row is not answered. `
380
+ + describeWorkspaceFix(missing.join(', '));
330
381
  }
331
382
 
332
383
  /**
@@ -37,10 +37,34 @@
37
37
  * `startDir` and get the workspace THAT TREE belongs to. It is a different
38
38
  * question, asked explicitly, never a fallback of the other one.
39
39
  *
40
+ * ## The service a run is ABOUT (d.540)
41
+ *
42
+ * A run over a service repository asks a third question, and it is as
43
+ * deterministic as the other two: the workspace is the nearest ancestor OF THE
44
+ * SERVICE ROOT that carries an api checkout — the root the caller named, never
45
+ * the directory the shell happens to stand in. `oa-validate` passes it as
46
+ * `serviceRoot`.
47
+ *
48
+ * It exists because the package question answers about the PACKAGE, and the copy
49
+ * a service runs is the one it installed: `oa-validate .` inside
50
+ * `<workspace>/api_biz/<service>` printed `Workspace root: NOT RESOLVED` and
51
+ * reported every `U-*` and `D-*` row NOT RUN, for a service lying in a complete
52
+ * workspace two directories up (measured 2026-09-16, BIZ-hello). The run knew
53
+ * where the service was and refused to look there.
54
+ *
55
+ * The three questions are asked in a stated order, and none of them reads
56
+ * ambient state: an explicit `--workspace` wins, then the tree the run is about
57
+ * (`startDir`, or `serviceRoot`), then the checkout this package is part of.
58
+ * The last one is what answers for a run whose subject lies in NO workspace —
59
+ * the service root is then reported as outside the workspace it names, which is
60
+ * true and actionable — and when neither question finds a checkout the run says
61
+ * NOT RESOLVED, exactly as before.
62
+ *
40
63
  * An INSTALLED copy (`<service>/node_modules/@onlineapps/conn-orch-validator`)
41
- * lies in no checkout, so it resolves nothing and the rows that need the SSOT
42
- * are reported NOT RUN — loudly, never as a pass (`automation-gates.md` §5).
43
- * That is what a service container has, and it is unchanged by this rule.
64
+ * lies in no checkout, so it speaks for none: the rows that read the platform
65
+ * SSOT are answered from the workspace the SERVICE lies in, or reported NOT RUN
66
+ * — loudly, never as a pass (`automation-gates.md` §5). A service container,
67
+ * whose image carries no workspace above `/app`, is unchanged by this rule.
44
68
  */
45
69
 
46
70
  const fs = require('fs');
@@ -201,11 +225,19 @@ function workspaceAbove(startDir) {
201
225
  }
202
226
 
203
227
  /**
204
- * @param {{ explicit?: string|null, startDir?: string|null }} params
228
+ * The three questions, in the order the header states them: the root the caller
229
+ * NAMED, then the tree the run is about, then the checkout this package is part
230
+ * of. `startDir` and `serviceRoot` are both "the tree this run is about" and
231
+ * differ in what happens when that tree lies in no workspace — a run that WRITES
232
+ * into a tree stops there (there is nowhere to write from), a run that MEASURES
233
+ * one lets the package answer, so the report can say which workspace the service
234
+ * root is outside of.
235
+ *
236
+ * @param {{ explicit?: string|null, startDir?: string|null, serviceRoot?: string|null }} params
205
237
  * @returns {string|null} absolute workspace root, or null when neither the named
206
238
  * tree nor this package lies in one
207
239
  */
208
- function resolveWorkspaceRoot({ explicit = null, startDir = null } = {}) {
240
+ function resolveWorkspaceRoot({ explicit = null, startDir = null, serviceRoot = null } = {}) {
209
241
  if (explicit !== null && explicit !== undefined) {
210
242
  const resolved = path.resolve(explicit);
211
243
  if (apiCheckoutOf(resolved) === null) {
@@ -226,17 +258,71 @@ function resolveWorkspaceRoot({ explicit = null, startDir = null } = {}) {
226
258
  return above === null ? null : canonicalRoot(above);
227
259
  }
228
260
 
261
+ if (serviceRoot !== null && serviceRoot !== undefined) {
262
+ if (typeof serviceRoot !== 'string' || serviceRoot.length === 0) {
263
+ throw new Error('[ManifestWorkspace] Service root is required - resolveWorkspaceRoot({ serviceRoot }) got '
264
+ + `${JSON.stringify(serviceRoot)}. Fix: pass the repository the run is about, or omit it to use this `
265
+ + 'package\'s own checkout.');
266
+ }
267
+ const above = workspaceAbove(serviceRoot);
268
+ if (above !== null) return canonicalRoot(above);
269
+ }
270
+
229
271
  return API_CHECKOUT_ROOT === null ? null : canonicalRoot(path.dirname(API_CHECKOUT_ROOT));
230
272
  }
231
273
 
274
+ /**
275
+ * The command that turns "this run had no such checkout" into a run that does —
276
+ * one sentence, one owner, wherever a row is reported NOT RUN because the tree
277
+ * it needed was not under the workspace.
278
+ *
279
+ * It exists as a function because there are two such places and they were
280
+ * drifting apart. `runManifest.js` § describeMissingRoots gained it in d.511,
281
+ * from the roots a row declares; the bridge to the documentation lint
282
+ * (`checks/docsLintBridge.js`) reports the same absence through the linter's own
283
+ * NOT RUN channel and, until d.516, ended at "the probe could not be evaluated"
284
+ * — true, and nothing anybody could act on (`.claude/rules/automation-gates.md`
285
+ * §1 requirement 4). Two sentences for one remedy is the second rail
286
+ * `.claude/rules/change-discipline.md` § One rail per concern names.
287
+ *
288
+ * What the checkout must CARRY is the caller's, because only the caller knows
289
+ * it: a row declares its roots and names them, while the bridge is handed a
290
+ * reason written by the linter — which names the probe target itself, and which
291
+ * this package neither writes nor parses (the bridge cites the lint, it does not
292
+ * restate it).
293
+ *
294
+ * @param {string} carries what a workspace has to hold for the row to be
295
+ * answered — the missing roots when the caller resolved them, otherwise the
296
+ * thing the reason in front of this sentence already named
297
+ * @returns {string} the `Fix:` sentence, ending in a full stop
298
+ */
299
+ function describeWorkspaceFix(carries) {
300
+ if (typeof carries !== 'string' || carries.length === 0) {
301
+ throw new Error('[ManifestWorkspace] Fix subject is required - describeWorkspaceFix() got '
302
+ + `${JSON.stringify(carries)}, so the sentence would tell the reader to carry nothing. `
303
+ + 'Fix: pass the missing roots, or the phrase naming what the reason before it points at.');
304
+ }
305
+ return `Fix: run with --workspace pointing at a checkout that carries ${carries}.`;
306
+ }
307
+
308
+ /**
309
+ * What the bridge to a delegated checker passes as `carries`: the absent thing
310
+ * is named by the checker's own reason, which sits immediately before this
311
+ * sentence, so the fix points back at it instead of repeating it in this
312
+ * package's words.
313
+ */
314
+ const AS_THE_REASON_NAMES = 'what this reason names';
315
+
232
316
  module.exports = {
233
317
  resolveWorkspaceRoot,
234
318
  canonicalRoot,
235
319
  workspaceAbove,
236
320
  resolveWorkspacePath,
321
+ describeWorkspaceFix,
237
322
  apiCheckoutOf,
238
323
  API_CHECKOUT_ROOT,
239
324
  API_MARKER,
325
+ AS_THE_REASON_NAMES,
240
326
  PACKAGE_ROOT,
241
327
  WORKSPACE_MARKER
242
328
  };
@@ -124,13 +124,33 @@ const PACKED_NAMES = Object.freeze({
124
124
  /**
125
125
  * The files a service is expected to EXECUTE, so the ones written executable.
126
126
  *
127
- * `init.sh` is the file a service is started through. `scripts/verify-deploy-uniform.sh`
128
- * is invoked by name from the `deploy-production` job of the rendered
129
- * `.gitlab-ci.yml`, so a copy without the bit fails that job with "permission
130
- * denied" instead of measuring the uniform — and a gate that cannot run is the
131
- * false guarantee `automation-gates.md` §5 names.
127
+ * `init.sh` is the file a service is started through — and since d.555 it is the
128
+ * only one: the deploy gate is no longer rendered into a service (see
129
+ * `PACKAGE_ONLY` below), so there is no copy of it in a repository to give a bit
130
+ * to.
132
131
  */
133
- const EXECUTABLE_FILES = Object.freeze(['init.sh', 'scripts/verify-deploy-uniform.sh']);
132
+ const EXECUTABLE_FILES = Object.freeze(['init.sh']);
133
+
134
+ /**
135
+ * Files this package carries INSIDE the template directory that are not part of
136
+ * the scaffold — the package's own, reached by the path a pin installs.
137
+ *
138
+ * `scripts/verify-deploy-uniform.sh` is the uniform gate. Until d.529 the
139
+ * rendered `.gitlab-ci.yml` ran the copy in the service repository; since then
140
+ * BOTH callers — the `validate-uniform` job and `deploy-production` — invoke
141
+ * `node_modules/@onlineapps/conn-orch-validator/templates/business-service/scripts/verify-deploy-uniform.sh`,
142
+ * the pinned one. So the rendered copy is read by nothing, and the four
143
+ * questions `change-discipline.md` § "Removing something removes its
144
+ * declaration" asks answer cleanly: it came to exist with the deploy job of
145
+ * d.421, it carried gate 008, nothing reads it because the callers moved to the
146
+ * pin, and what replaces it is MORE conceptual — one pin, one gate, versioned
147
+ * with the manifest it measures against, instead of eight copies that drift the
148
+ * day one of them is edited.
149
+ *
150
+ * It stays in the package rather than moving elsewhere in it: the path is what
151
+ * the rendered CI file names, and the file lives where that path points.
152
+ */
153
+ const PACKAGE_ONLY = Object.freeze(['scripts/verify-deploy-uniform.sh']);
134
154
 
135
155
  /**
136
156
  * The env template's name in the template, and the name it is written under.
@@ -340,7 +360,7 @@ function listTemplateFiles(root) {
340
360
  }
341
361
  };
342
362
  walk(root, '');
343
- return collected.sort();
363
+ return collected.filter((relative) => !PACKAGE_ONLY.includes(relative)).sort();
344
364
  }
345
365
 
346
366
  /**
@@ -483,6 +503,53 @@ function insertBlockUnder({ text, key, replacement }) {
483
503
  return [...lines.slice(0, at + 1), ...replacement, '', ...lines.slice(at + 1)].join('\n');
484
504
  }
485
505
 
506
+ /**
507
+ * A file with a delimited block INSERTED after a shell function's closing brace,
508
+ * and every other line of it left exactly as it was.
509
+ *
510
+ * The sibling of `insertBlockUnder`, for the file that is not a mapping. Both
511
+ * answer the same question — where does a block that is not there yet go? — and
512
+ * both answer it from something the FILE declares rather than from a judgement
513
+ * about the service. In a compose file that is the `services:` key; in an
514
+ * `init.sh` it is the end of the function the block's own text refers to
515
+ * ("A service's own install steps belong in oa_npm_install() above, everything
516
+ * else it needs goes below"), which the row names as its anchor.
517
+ *
518
+ * Measured 2026-09-16 in api_biz/invoicing: the sync refused this state with
519
+ * "paste the block from the template once", a message telling a human to
520
+ * hand-copy generated content, which is what `automation-gates.md` §1.4 calls a
521
+ * defect — the run knows the bytes and, with the anchor, the place.
522
+ *
523
+ * Without the anchor the refusal STAYS a refusal, and names the anchor rather
524
+ * than the block: where a service's own install steps end is that service's
525
+ * decision, and a generator guessing it would write the block above or below
526
+ * code that has to run on the other side.
527
+ *
528
+ * @param {{text: string, fn: string, replacement: string[]}} args the file, the
529
+ * shell function the block belongs after, and the block's lines
530
+ * @returns {string}
531
+ */
532
+ function insertBlockAfterFunction({ text, fn, replacement }) {
533
+ const lines = text.split('\n');
534
+ const opens = new RegExp(`^${fn}\\s*\\(\\)\\s*\\{`);
535
+
536
+ const start = lines.findIndex((line) => opens.test(line.trimEnd()));
537
+ if (start === -1) {
538
+ throw new Error(`[ServiceTemplate] No ${fn}() to insert the block after - the file declares no line `
539
+ + `opening "${fn}() {", so where this service's own install steps end is undefined and the block `
540
+ + `has no unambiguous place. Fix: wrap this file's install command in a ${fn}() function, then run `
541
+ + 'the sync again.');
542
+ }
543
+
544
+ const end = lines.findIndex((line, index) => index > start && line.trimEnd() === '}');
545
+ if (end === -1) {
546
+ throw new Error(`[ServiceTemplate] The ${fn}() function never closes - "${fn}() {" is there, its "}" `
547
+ + 'is not, so the end of the install steps is undefined. Fix: close the function, then run the sync again.');
548
+ }
549
+
550
+ return [...lines.slice(0, end + 1), '', ...replacement, ...lines.slice(end + 1)].join('\n');
551
+ }
552
+
486
553
  /** A top-level mapping key: a line that starts in column 0 and ends its key with a colon. */
487
554
  const TOP_LEVEL_KEY = /^([A-Za-z_][A-Za-z0-9_.-]*):/;
488
555
 
@@ -565,6 +632,7 @@ module.exports = {
565
632
  SSOT_PIN,
566
633
  PACKED_NAMES,
567
634
  EXECUTABLE_FILES,
635
+ PACKAGE_ONLY,
568
636
  ENV_TEMPLATE_SOURCE,
569
637
  envTemplateTarget,
570
638
  deriveParams,
@@ -577,6 +645,7 @@ module.exports = {
577
645
  renderText,
578
646
  renderTree,
579
647
  spliceBlock,
648
+ insertBlockAfterFunction,
580
649
  topLevelKeys,
581
650
  replaceTopLevelKeys,
582
651
  insertBlockUnder
@@ -66,10 +66,17 @@ function assertManifest(manifest) {
66
66
  * nothing that varies between two runs - the same manifest always renders the
67
67
  * same bytes, which is what makes `--check` a gate rather than a diff of noise.
68
68
  *
69
- * `consumers` is deliberately NOT rendered: who reads a key is a fact the code
70
- * owns and a check measures, and a hand-maintained copy of it inside every
71
- * service's env file would rot the moment a reader moved
72
- * (`.claude/rules/doc-code-binding.md` §1).
69
+ * `consumers` is deliberately NOT rendered: a hand-maintained copy of who reads
70
+ * a key, inside every service's env file, would rot the moment a reader moved
71
+ * (`.claude/rules/doc-code-binding.md` §1). That half is measured - the CONTROL
72
+ * case in `tests/unit/sharedEnv.test.js` moves the field and asserts the
73
+ * rendered text does not move with it. The field's own content is held by
74
+ * REVIEW: no code in this package reads it, and no gate elsewhere does either
75
+ * (`api/config/shared-env.json`, `_consumers`). The machine answer to "who
76
+ * reads this name" is per service, not per platform: the `env-contract` check
77
+ * (row `C-ENV-READS` of `manifests/biz-service.manifest.json`) measures the
78
+ * names a service's code reads against what its
79
+ * `config/service/integration-contract.json` declares.
73
80
  *
74
81
  * @param {{keys: Array<{name: string, value: string, why: string}>}} manifest
75
82
  * @returns {string}