@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
@@ -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}
@@ -11,12 +11,19 @@
11
11
  * points the check at. A second list here would be a second owner of the shape,
12
12
  * and the two would diverge exactly the way the nine copies of `init.sh` did.
13
13
  *
14
- * The corollary is that a row the manifest does not make renderable is NOT
15
- * rendered. `G-PROD-IMAGE` names no reference — it is a requirement about a
16
- * file, not a file — so it is reported NOT RUN, with the reason, rather than
17
- * being filled from something this module decided
18
- * (`.claude/rules/automation-gates.md` §5: silence is a defect, and so is a
19
- * mechanism that quietly covers less than it claims).
14
+ * The corollary is that a row the manifest does not make renderable is not a
15
+ * row of this run at all. `G-PROD-IMAGE` and `G-SETUP` name no reference — the
16
+ * first is a requirement about a pin inside a file, the second about a directory
17
+ * existing — so the MANIFEST run answers them and the sync does not plan them
18
+ * (`syncRows` below). Until d.507 the sync planned them and printed NOT RUN: a
19
+ * line every `--check` in every repository ended on, naming a state its reader
20
+ * could do nothing about, which is the false guarantee
21
+ * `.claude/rules/automation-gates.md` §5 calls a defect and the `Fix`-less
22
+ * message its §1 requirement 4 forbids.
23
+ *
24
+ * NOT RUN stays for the other case, which is the one it was made for: a row the
25
+ * sync MUST render and cannot reach the reference of — named, with the reason
26
+ * and the command that makes it reachable, never skipped in silence.
20
27
  *
21
28
  * WHERE A ROW NEEDS MORE THAN A SPLICE, THE MODULE THAT OWNS THE RULE RENDERS
22
29
  * IT. Two rows are not "the reference, verbatim": the runner block carries three
@@ -39,10 +46,11 @@ const {
39
46
  withoutBlock, IGNORE_BLOCK, DOCKERIGNORE_BLOCK
40
47
  } = require('../manifest/checks/serviceFiles');
41
48
  const {
42
- spliceBlock, insertBlockUnder, topLevelKeys, replaceTopLevelKeys
49
+ spliceBlock, insertBlockUnder, insertBlockAfterFunction, topLevelKeys, replaceTopLevelKeys
43
50
  } = require('./serviceTemplate');
44
51
  const { requireIdentity } = require('../manifest/serviceIdentity');
45
52
  const { rowNeedsWorkspace } = require('../manifest/manifestShape');
53
+ const { describeWorkspaceFix } = require('../manifest/workspaceRoot');
46
54
  const { KINDS, applyUniformRegion } = require('./readmePointer');
47
55
  const { serviceRegion } = require('./readmeLocation');
48
56
  const { CHECK_REGISTRY } = require('../manifest/checks');
@@ -291,7 +299,12 @@ function renderDockerignoreEntries({ row, current, workspaceRoot }) {
291
299
  `# --- ${DOCKERIGNORE_BLOCK}`,
292
300
  `# Derived from ${referenceOwner(row.from)}, the declaration .gitignore reads too —`,
293
301
  '# what a LOCAL production build must not copy into the image (being ignored by git',
294
- "# excludes nothing from COPY . .). Everything above this block is this repository's own.",
302
+ '# excludes nothing from COPY . .), PLUS the test tree: tests stay in the repository',
303
+ '# and out of the artefact that runs, the same decision the library uniform makes as',
304
+ '# L-PACK-TESTS. The one exception is tests/cookbooks, which is not a test but a',
305
+ '# declaration the runtime reads — Tier-1 of the boot runs those cookbooks at phase',
306
+ '# 0.2, and an image without them refuses its own validation proof as NO_TESTS.',
307
+ "# Everything above this block is this repository's own.",
295
308
  ...missing,
296
309
  `# --- end ${DOCKERIGNORE_BLOCK}`,
297
310
  ''
@@ -322,7 +335,11 @@ const RENDERERS = Object.freeze({
322
335
  const SYNCABLE_CLASSES = Object.freeze(['identical', 'generated', 'contains']);
323
336
 
324
337
  /**
325
- * The manifest rows this run is defined by, in manifest order.
338
+ * The manifest rows of the classes the generator owns, in manifest order.
339
+ *
340
+ * Not every one of them is a row this run renders — see `syncRows`. This is the
341
+ * full declaration, and the CLI needs it to tell a path the uniform declares as
342
+ * a REQUIREMENT from a path it has never heard of.
326
343
  *
327
344
  * @param {object} manifest
328
345
  * @returns {object[]}
@@ -332,6 +349,34 @@ function uniformRows(manifest) {
332
349
  return SYNCABLE_CLASSES.flatMap((className) => files[className] || []);
333
350
  }
334
351
 
352
+ /**
353
+ * Whether the sync is defined for this row: does it name what its content comes
354
+ * from?
355
+ *
356
+ * The predicate is the manifest's own distinction, not a list of ids: a row with
357
+ * a `from:` reference is a file rendered from that reference, a row without one
358
+ * is a requirement about the repository, and the reference is exactly what
359
+ * `desiredContent` would read. Judging the reference rather than the id is why a
360
+ * new requirement row needs no change here.
361
+ *
362
+ * @param {object} row
363
+ * @returns {boolean}
364
+ */
365
+ function isSyncRow(row) {
366
+ if (!row || !row.from) return false;
367
+ return typeof row.from.path === 'string' || isPackageReference(row.from);
368
+ }
369
+
370
+ /**
371
+ * The manifest rows this run is defined by, in manifest order.
372
+ *
373
+ * @param {object} manifest
374
+ * @returns {object[]}
375
+ */
376
+ function syncRows(manifest) {
377
+ return uniformRows(manifest).filter(isSyncRow);
378
+ }
379
+
335
380
  /**
336
381
  * Where two texts first differ, what the file says there, and what the run would
337
382
  * put there instead.
@@ -373,17 +418,14 @@ function readServiceFile(serviceRoot, relative) {
373
418
  }
374
419
 
375
420
  /**
376
- * What a row's file should contain, or why this run cannot say.
421
+ * What a row's file should contain.
377
422
  *
378
- * @returns {{desired: string}|{reason: string}}
423
+ * Only ever called for a row `isSyncRow` accepts, so the reference is there to
424
+ * be read; `planRow` is where that is checked, once, before anything reads.
425
+ *
426
+ * @returns {{desired: string}}
379
427
  */
380
428
  function desiredContent({ row, current, serviceRoot, workspaceRoot }) {
381
- if (!row.from || (typeof row.from.path !== 'string' && !isPackageReference(row.from))) {
382
- return {
383
- reason: 'the row declares no "from" reference, so nothing says what this file\'s content is rendered from'
384
- };
385
- }
386
-
387
429
  const render = RENDERERS[row.check];
388
430
  if (render !== undefined) return { desired: render({ row, current, serviceRoot, workspaceRoot }) };
389
431
 
@@ -401,6 +443,18 @@ function desiredContent({ row, current, serviceRoot, workspaceRoot }) {
401
443
  throw new Error(`[UniformSync] Reference block "${row.block}" not found in ${referenceOwner(row.from)} - the row `
402
444
  + 'names a block the template does not carry. Fix: restore the block in the template, or correct the row.');
403
445
  }
446
+
447
+ // The block is missing from a file that exists. That is "not synced", not
448
+ // "paste it in by hand": the content is generated and the run holds it, so the
449
+ // only open question is WHERE — and a row answers that by naming an anchor the
450
+ // file itself declares (`insert_after_function`). Without one the refusal
451
+ // stands, because the position would then be a decision about this service
452
+ // rather than a fact about the file (the same line `insertBlockUnder` draws
453
+ // for a compose mapping). A message that tells a human to copy generated bytes
454
+ // is the defect `.claude/rules/automation-gates.md` §1.4 names.
455
+ if (blockLines(current, row.block).length === 0 && typeof row.insert_after_function === 'string') {
456
+ return { desired: insertBlockAfterFunction({ text: current, fn: row.insert_after_function, replacement }) };
457
+ }
404
458
  return { desired: spliceBlock({ text: current, block: row.block, replacement }) };
405
459
  }
406
460
 
@@ -412,6 +466,15 @@ function desiredContent({ row, current, serviceRoot, workspaceRoot }) {
412
466
  * current: string|null, desired?: string, detail?: string, reason?: string}}
413
467
  */
414
468
  function planRow({ row, serviceRoot, workspaceRoot }) {
469
+ // Fail-fast rather than a NOT RUN line: `planSync` hands over only the rows
470
+ // `syncRows` selected, so a requirement row arriving here is a caller's
471
+ // mistake, and the caller is told which run does answer that row.
472
+ if (!isSyncRow(row)) {
473
+ throw new Error(`[UniformSync] ${row.id} declares no "from" reference - nothing says what ${row.path} `
474
+ + 'would be rendered from, so this row is a requirement about the repository rather than a file this '
475
+ + 'run writes. Fix: check it with npx oa-validate <serviceRoot>, which is the run that answers it.');
476
+ }
477
+
415
478
  const current = readServiceFile(serviceRoot, row.path);
416
479
  const base = { id: row.id, path: row.path, current };
417
480
 
@@ -425,9 +488,17 @@ function planRow({ row, serviceRoot, workspaceRoot }) {
425
488
  return {
426
489
  ...base,
427
490
  outcome: 'not-run',
491
+ // The check's own sentence, remedy included — not that sentence plus one
492
+ // of this module's. `workspaceRoot.js` § describeWorkspaceFix is the ONE
493
+ // owner of "point --workspace at a checkout that carries X" (d.516), and
494
+ // since d.523 every workspace-dependent check ends its NOT RUN sentence
495
+ // with it. Appending a second `Fix:` here produced two remedies for one
496
+ // absence, separated by a stray full stop — the second rail
497
+ // `.claude/rules/change-discipline.md` § One rail per concern names, in
498
+ // the one line a reader acts on.
428
499
  reason: check.describeNotRun
429
500
  ? check.describeNotRun({ row, block: null })
430
- : 'the workspace root is not reachable'
501
+ : `the workspace root is not reachable. ${describeWorkspaceFix('api/ and api_biz/')}`
431
502
  };
432
503
  }
433
504
 
@@ -438,7 +509,6 @@ function planRow({ row, serviceRoot, workspaceRoot }) {
438
509
  return { ...base, outcome: 'blocked', reason: error.message };
439
510
  }
440
511
 
441
- if (outcome.reason !== undefined) return { ...base, outcome: 'not-run', reason: outcome.reason };
442
512
  if (outcome.desired === current) return { ...base, outcome: 'unchanged', desired: outcome.desired };
443
513
 
444
514
  return {
@@ -456,7 +526,7 @@ function planRow({ row, serviceRoot, workspaceRoot }) {
456
526
  * @returns {object[]} one entry per row, in manifest order
457
527
  */
458
528
  function planSync({ manifest, serviceRoot, workspaceRoot, paths = [] }) {
459
- const rows = uniformRows(manifest);
529
+ const rows = syncRows(manifest);
460
530
 
461
531
  const wanted = paths.length === 0 ? rows : paths.map((wantedPath) => {
462
532
  const row = rows.find((candidate) => candidate.path === wantedPath);
@@ -471,4 +541,4 @@ function planSync({ manifest, serviceRoot, workspaceRoot, paths = [] }) {
471
541
  return wanted.map((row) => planRow({ row, serviceRoot, workspaceRoot }));
472
542
  }
473
543
 
474
- module.exports = { SYNCABLE_CLASSES, uniformRows, planRow, planSync, firstDifference };
544
+ module.exports = { SYNCABLE_CLASSES, uniformRows, isSyncRow, syncRows, planRow, planSync, firstDifference };
@@ -82,7 +82,31 @@ function assertRepoRelativePath(value, fieldName) {
82
82
  function normalizeDatabaseDeclaration(database, connectors) {
83
83
  // Absent means "this service has no database". Null rather than {} so callers
84
84
  // distinguish that from an empty declaration and skip the steps explicitly.
85
- if (database === undefined || database === null) return null;
85
+ //
86
+ // Absent is only allowed together with its connector. `db: true` with no
87
+ // block used to pass — a transition allowance while the six repositories
88
+ // still carried their own ci-setup-db.js ("F4 tightens this"). F4 landed:
89
+ // utils/setupDatabase.js replaced all six, none of the eight biz
90
+ // repositories carries one, and every repository declaring `db: true`
91
+ // declares a block (measured 2026-09-16), so the gate lands with compliance
92
+ // already in place (`automation-gates.md` §3) and the allowance goes with it
93
+ // (`architecture-principles.md` §11, no transition shims).
94
+ //
95
+ // What it cost while it stood: the combination makes `setup-db` report NOT
96
+ // APPLICABLE — correctly, there is no schema to build — while
97
+ // `wait-connectors` waits for a database and `run-prevalidation` then
98
+ // dispatches the service's own handlers against a schema nobody built. The
99
+ // DB steps must run exactly where a database is declared, and the two keys
100
+ // are one fact stated twice.
101
+ if (database === undefined || database === null) {
102
+ if (connectors.db) {
103
+ throw new Error('[BizCiGate] Contradictory contract - requiredConnectors.db is true but the contract '
104
+ + 'carries no "database" block, so setup-db has no schema to build while wait-connectors waits for '
105
+ + 'one and the cookbooks run against whatever is there. '
106
+ + 'Fix: declare the database block (engine, schema, migrations), or set requiredConnectors.db to false.');
107
+ }
108
+ return null;
109
+ }
86
110
 
87
111
  if (typeof database !== 'object' || Array.isArray(database)) {
88
112
  throw new Error('[BizCiGate] Invalid database - Expected an object with engine, schema and migrations. '