@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
@@ -37,6 +37,7 @@ const path = require('path');
37
37
 
38
38
  const { readIdentity, IDENTITY_FILE } = require('../serviceIdentity');
39
39
  const { DATABASE_PREFIX } = require('./serviceIdentityRows');
40
+ const { blockLines } = require('./serviceFiles');
40
41
 
41
42
  /** Where the fact lives, and the key that carries it. */
42
43
  const CONTRACT_PATH = 'config/service/integration-contract.json';
@@ -200,6 +201,39 @@ function ownEnvTemplate(serviceRoot, shortName) {
200
201
  return { relative: only, text: fs.readFileSync(path.join(serviceRoot, ...only.split('/')), 'utf8') };
201
202
  }
202
203
 
204
+ /**
205
+ * WHICH account this repository declares, and where it said so.
206
+ *
207
+ * Exported because `D-DB-ACCOUNT` is not the only reader: `biz-ci-gate
208
+ * setup-db-account` CREATES that account in CI, and it must create the one the
209
+ * repository declares rather than one derived a second way. Which file carries
210
+ * the fact and under which key is therefore said ONCE
211
+ * (`change-discipline.md` § One rail per concern); the row below and the CLI
212
+ * command each phrase their own finding from the same reading.
213
+ *
214
+ * It is deliberately NOT the derived `oagen_<shortname>` — that is the row's
215
+ * comparison, and a caller that wanted the derivation instead of the
216
+ * declaration would be reading past the repository. Measured reason: the meta
217
+ * runbook names `oagen_meta_app` where the repository declares `oagen_meta`
218
+ * (`api/docs/setup/INSTALL.md`, open finding with an INFRA owner), so the two
219
+ * answers are not interchangeable today.
220
+ *
221
+ * @param {string} serviceRoot repository root
222
+ * @returns {{shortName: string|null, relative: string|null, declared: string|null}}
223
+ * `shortName` null when the repository does not say who it is (`C-SERVICE`'s
224
+ * finding), `relative` null when it owns no env template, `declared` null when
225
+ * that template carries no such key
226
+ */
227
+ function readDeclaredAccount(serviceRoot, key = ACCOUNT_KEY) {
228
+ const shortName = shortNameOf(serviceRoot);
229
+ if (shortName === null) return { shortName: null, relative: null, declared: null };
230
+
231
+ const template = ownEnvTemplate(serviceRoot, shortName);
232
+ if (template === null) return { shortName, relative: null, declared: null };
233
+
234
+ return { shortName, relative: template.relative, declared: declaredValue(template.text, key) };
235
+ }
236
+
203
237
  /**
204
238
  * The three rows below ask their question only of a repository that DECLARES a
205
239
  * database, and say nothing about a declaration they could not read — that one
@@ -293,12 +327,11 @@ const dbAccount = Object.freeze({
293
327
  run({ row, serviceRoot }) {
294
328
  if (!hasDatabase(serviceRoot)) return [];
295
329
 
296
- const shortName = shortNameOf(serviceRoot);
330
+ const { shortName, relative, declared } = readDeclaredAccount(serviceRoot, row.key);
297
331
  if (shortName === null) return [];
298
332
 
299
333
  const expected = `${DATABASE_PREFIX}${shortName}`;
300
- const template = ownEnvTemplate(serviceRoot, shortName);
301
- if (template === null) {
334
+ if (relative === null) {
302
335
  return [{
303
336
  where: row.path,
304
337
  what: `no env template of this service's own declares ${row.key}, and this service's account is `
@@ -306,23 +339,155 @@ const dbAccount = Object.freeze({
306
339
  }];
307
340
  }
308
341
 
309
- const declared = declaredValue(template.text, row.key);
310
342
  if (declared === null) {
311
343
  return [{
312
- where: template.relative,
344
+ where: relative,
313
345
  what: `declares no ${row.key}, and this service's account is ${JSON.stringify(expected)}`
314
346
  }];
315
347
  }
316
348
  if (declared === expected) return [];
317
349
 
318
350
  return [{
319
- where: template.relative,
351
+ where: relative,
320
352
  what: `${row.key} is ${JSON.stringify(declared)}, and the account derived from ${IDENTITY_FILE} `
321
353
  + `is ${JSON.stringify(expected)}`
322
354
  }];
323
355
  }
324
356
  });
325
357
 
358
+ /**
359
+ * `D-DB-CI-ACCOUNT` — the pipeline reaches the database as the SERVICE.
360
+ *
361
+ * `D-DB-ACCOUNT` one row up measures the DECLARATION: `DB_USER` in the env
362
+ * template, the account production installs. Nothing measured the ONE
363
+ * environment where that migration set is applied every day. Measured
364
+ * 2026-09-16 over the eight biz repositories: seven set `DB_USER: "root"` in
365
+ * their `test` job while declaring `oagen_<service>` in the template, so CI
366
+ * proved the set under rights no production box grants — the gap
367
+ * `db-migrations-first-deploy` 001 :32 names ("migrace nikdy pod rootem") in the
368
+ * only place it can be tried before a deploy.
369
+ *
370
+ * A SEPARATE row rather than a wider `D-DB-ACCOUNT`: another file, another fix,
371
+ * and one row with two fixes is two mechanisms under one name
372
+ * (`automation-gates.md` §1.2).
373
+ *
374
+ * ## What it reads, and what it leaves alone
375
+ *
376
+ * Only the lines OUTSIDE the `oa-ci v1` block. That block is the platform's and
377
+ * `G-CI` compares it to the template byte for byte; a second row reporting an
378
+ * edit there would be the duplicate `change-discipline.md` § One rail per
379
+ * concern forbids. Reading a REGION and not the whole file is also what keeps it
380
+ * clear of the defect that had the whole-file row over `.gitlab-ci.yml`
381
+ * withdrawn after three days: the other half of this file genuinely is the
382
+ * service's (`serviceFiles.js`, `delimited-block-render`).
383
+ *
384
+ * It asks WHO the database steps run as. It does NOT ask whether a repository
385
+ * runs them: a job that builds no schema has no account for the step to create,
386
+ * and demanding one would be this row inventing a rule
387
+ * (`truth-over-agreement.md` §6). Said out loud because a boundary nobody states
388
+ * is read as coverage (`automation-gates.md` §5).
389
+ *
390
+ * Silent for a service with no `database` block, by construction — `pdfgen` is
391
+ * the live case.
392
+ *
393
+ * @see api/docs/governance/confirmations/db-accounts-per-service.md
394
+ * @see api/docs/governance/confirmations/db-migrations-first-deploy.md
395
+ */
396
+
397
+ /**
398
+ * The step that CREATES the account, and the step that first connects as it.
399
+ *
400
+ * Both are read on lines that are not comments. Measured in `api_biz/meta`: the
401
+ * `before_script` opens with a comment explaining why the job installs
402
+ * mariadb-client, and that comment NAMES `ci:gate:setup` — so a finding would
403
+ * have pointed the reader at a sentence instead of at the step, and a comment
404
+ * naming `setup-db-account` would have counted as running it.
405
+ */
406
+ const ACCOUNT_STEP = /\bsetup-db-account\b/;
407
+ const SCHEMA_STEP = /\bci:gate:setup(?![\w:-])/;
408
+ const COMMENT = /^\s*#/;
409
+
410
+ /** `KEY: value`, as a YAML mapping line reads it — a `#` line declares nothing. */
411
+ const DECLARATION = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/;
412
+
413
+ const unquoted = (value) => value.trim().replace(/\s+#.*$/, '').replace(/^["']|["']$/g, '');
414
+
415
+ /**
416
+ * The other spelling of the same thing: `DB_USER=root` as a shell prefix on the
417
+ * step itself, so root is never left standing in the job environment.
418
+ *
419
+ * Measured shape, `api_biz/hello-service`:
420
+ * `- DB_USER=root DB_PASSWORD="$MYSQL_ROOT_PASSWORD" npm run ci:gate:setup`
421
+ * The intent is the better one, and the migration set is still applied by the
422
+ * superuser — which is the whole of what this row is about. A check reading only
423
+ * the `variables:` block would call that green, and a gate that is silent where
424
+ * it should speak is the false guarantee `automation-gates.md` §5 names.
425
+ *
426
+ * @param {string} line one line of the file
427
+ * @param {string} key the variable name the row measures
428
+ * @returns {string|null} the value, or null when the line assigns nothing
429
+ */
430
+ function inlineAssignment(line, key) {
431
+ if (/^\s*#/.test(line)) return null;
432
+ const match = line.match(new RegExp(`(?:^|[\\s;&|])${key}=(\\S*)`));
433
+ return match === null ? null : match[1].replace(/^["']|["']$/g, '');
434
+ }
435
+
436
+ const dbCiAccount = Object.freeze({
437
+ scope: 'service',
438
+ requires: Object.freeze(['path', 'block', 'key', 'account']),
439
+
440
+ run({ row, serviceRoot }) {
441
+ if (!hasDatabase(serviceRoot)) return [];
442
+
443
+ const file = path.join(serviceRoot, ...row.path.split('/'));
444
+ // Whether the file is there at all is `G-CI`'s finding, with its own fix.
445
+ if (!fs.existsSync(file)) return [];
446
+
447
+ const lines = fs.readFileSync(file, 'utf8').split('\n');
448
+ const block = blockLines(fs.readFileSync(file, 'utf8'), row.block);
449
+ const blockStart = block.length === 0 ? -1 : lines.indexOf(block[0]);
450
+ const inBlock = (index) => blockStart !== -1 && index >= blockStart && index < blockStart + block.length;
451
+
452
+ const shortName = shortNameOf(serviceRoot);
453
+ const account = shortName === null ? null : `${DATABASE_PREFIX}${shortName}`;
454
+ const findings = [];
455
+
456
+ let accountStepAt = -1;
457
+ let schemaStepAt = -1;
458
+
459
+ lines.forEach((line, index) => {
460
+ if (inBlock(index)) return;
461
+ const isStep = !COMMENT.test(line);
462
+ if (isStep && accountStepAt === -1 && ACCOUNT_STEP.test(line)) accountStepAt = index;
463
+ if (isStep && schemaStepAt === -1 && SCHEMA_STEP.test(line)) schemaStepAt = index;
464
+
465
+ const declaration = line.match(DECLARATION);
466
+ const declared = declaration !== null && declaration[1] === row.key
467
+ ? unquoted(declaration[2])
468
+ : inlineAssignment(line, row.key);
469
+ if (declared !== row.account) return;
470
+
471
+ findings.push({
472
+ where: `${row.path}:${index + 1}`,
473
+ what: `${row.key} is ${JSON.stringify(row.account)} — this pipeline applies the migration set as `
474
+ + 'the database superuser, and production applies it as '
475
+ + `${account === null ? 'the service account' : `the service account ${JSON.stringify(account)}`}`
476
+ });
477
+ });
478
+
479
+ if (schemaStepAt !== -1 && (accountStepAt === -1 || accountStepAt > schemaStepAt)) {
480
+ findings.push({
481
+ where: `${row.path}:${schemaStepAt + 1}`,
482
+ what: `ci:gate:setup connects as ${row.key}, and no setup-db-account step runs before it — the `
483
+ + 'account it connects as is created by nothing'
484
+ });
485
+ }
486
+
487
+ return findings;
488
+ }
489
+ });
490
+
326
491
  /**
327
492
  * `D-DB-COLLATION` — the schema is created with a collation this service CHOSE.
328
493
  *
@@ -378,11 +543,15 @@ const dbCollation = Object.freeze({
378
543
  });
379
544
 
380
545
  module.exports = {
546
+ readDeclaredAccount,
547
+ ACCOUNT_KEY,
548
+ ENV_TEMPLATE_DIR,
381
549
  checks: [
382
550
  { name: 'db-consistent', check: dbConsistent },
383
551
  { name: 'db-collation', check: dbCollation },
384
552
  { name: 'db-migration-naming', check: dbMigrationNaming },
385
553
  { name: 'db-readme', check: dbReadme },
386
- { name: 'db-account', check: dbAccount }
554
+ { name: 'db-account', check: dbAccount },
555
+ { name: 'db-ci-account', check: dbCiAccount }
387
556
  ]
388
557
  };
@@ -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. */
@@ -52,6 +53,9 @@ const SERVICE_MEMORY_PATH = 'deploy.resources.limits.memory';
52
53
  /** The profile that marks the test runner — its budget is F-RUNNER's row, not R-MEM's. */
53
54
  const TEST_PROFILE = 'test';
54
55
 
56
+ /** How a compose service names the identity its container runs as. */
57
+ const COMPOSE_USER_KEY = 'user';
58
+
55
59
  const readOrNull = (serviceRoot, relative) => {
56
60
  const target = path.join(serviceRoot, ...relative.split('/'));
57
61
  return fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
@@ -106,7 +110,8 @@ const nodeMajor = Object.freeze({
106
110
  needsWorkspace: ({ row }) => !isPackageReference(row.from),
107
111
 
108
112
  describeNotRun({ row }) {
109
- return `the workspace root is not reachable, so ${referenceOwner(row.from)} cannot be read`;
113
+ const owner = referenceOwner(row.from);
114
+ return `the workspace root is not reachable, so ${owner} cannot be read. ${describeWorkspaceFix(owner)}`;
110
115
  },
111
116
 
112
117
  run({ row, serviceRoot, workspaceRoot }) {
@@ -267,6 +272,125 @@ const composeCommand = Object.freeze({
267
272
  }
268
273
  });
269
274
 
275
+ /**
276
+ * The instructions of ONE `FROM … AS <stage>` block, up to the next `FROM`.
277
+ *
278
+ * Comments are dropped, because the comment beside the identity names the very
279
+ * words this check reads and a comment is not an instruction.
280
+ *
281
+ * @param {string} dockerfile
282
+ * @param {string} stage the name after `AS`
283
+ * @returns {string[]|null} null when the file declares no such stage
284
+ */
285
+ function stageInstructions(dockerfile, stage) {
286
+ const lines = dockerfile.split('\n').map((line) => line.trim());
287
+ const starts = lines
288
+ .map((line, index) => ({ line, index }))
289
+ .filter((entry) => /^FROM\s/i.test(entry.line));
290
+
291
+ const wanted = starts.find((entry) => new RegExp(`\\sAS\\s+${stage}\\s*$`, 'i').test(entry.line));
292
+ if (wanted === undefined) return null;
293
+
294
+ const after = starts.find((entry) => entry.index > wanted.index);
295
+ return lines
296
+ .slice(wanted.index, after === undefined ? lines.length : after.index)
297
+ .filter((line) => line !== '' && !line.startsWith('#'));
298
+ }
299
+
300
+ /**
301
+ * `R-USER` — WHICH USER the service process is.
302
+ *
303
+ * Measured 2026-09-17 on a production image built from the platform template:
304
+ * `docker run --rm <image> id` answered `uid=0(root) gid=0(root)`, while the dev
305
+ * container beside it ran as `1000:1000` because `docker-compose.yml` pins it.
306
+ * Nobody had decided that difference — the production stage declared no `USER`
307
+ * at all — and nothing in the uniform could say so, because `Dockerfile` is a
308
+ * file of the `own` class. This row is the SECOND platform fact carved out of
309
+ * that class, for the same reason as the first (`R-NODE`, the node major): what
310
+ * a service BUILDS is its own, what it RUNS AS is the platform's.
311
+ *
312
+ * Two facts, and they are the two nothing else holds:
313
+ *
314
+ * * the `production` stage of the `Dockerfile` declares a non-root `USER` —
315
+ * that is the stage CI builds (`docker build --target production`), and the
316
+ * one the defect lived in;
317
+ * * the SERVICE node of the dev compose pins the same identity numerically.
318
+ *
319
+ * The other two places it appears already have an owner, and a second rail over
320
+ * them would report one fact twice (`change-discipline.md` § One rail per
321
+ * concern): the runner's `user:` sits inside the block `F-RUNNER` holds byte for
322
+ * byte, and the production compose is `G-PROD`'s whole-file render.
323
+ *
324
+ * The two spellings are one identity, and the row carries both because no
325
+ * machine can resolve an account NAME to a uid without the image: `node` is uid
326
+ * 1000, gid 1000 in the node images this platform builds on (measured
327
+ * `docker run --rm node:24-alpine id node`), which is the `1000:1000` the compose
328
+ * files pin. Same shape as `R-MEM` and `R-PID1`, which carry their values for
329
+ * the same reason.
330
+ *
331
+ * @see api/docs/biz/00-model/service-shape.md
332
+ */
333
+ const processIdentity = Object.freeze({
334
+ scope: 'service',
335
+ requires: Object.freeze(['path', 'image_path', 'image_stage', 'service_user', 'service_uid']),
336
+
337
+ run({ row, serviceRoot, workspaceRoot }) {
338
+ const findings = [];
339
+ const identity = `the platform identity is ${JSON.stringify(row.service_user)} `
340
+ + '(uid 1000, gid 1000 in the node images this platform builds on)';
341
+ const imageWhere = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.image_path });
342
+
343
+ const dockerfile = readOrNull(serviceRoot, row.image_path);
344
+ if (dockerfile === null) {
345
+ findings.push({ where: imageWhere, what: 'absent — the image this service runs as is built from it' });
346
+ } else {
347
+ const stage = stageInstructions(dockerfile, row.image_stage);
348
+ if (stage === null) {
349
+ findings.push({
350
+ where: imageWhere,
351
+ what: `declares no stage "AS ${row.image_stage}" — that is the stage CI builds, so nothing here `
352
+ + 'says what the deployed container runs as'
353
+ });
354
+ } else {
355
+ // The LAST one wins, the way docker reads it: a stage may switch twice,
356
+ // and what the container runs as is whatever stood at the end.
357
+ const declared = stage.filter((line) => /^USER\s/i.test(line)).pop();
358
+ if (declared === undefined) {
359
+ findings.push({
360
+ where: imageWhere,
361
+ what: `the ${row.image_stage} stage declares no USER, so the container runs as root — ${identity}`
362
+ });
363
+ } else if (declared.replace(/^USER\s+/i, '').trim() !== row.service_user) {
364
+ findings.push({
365
+ where: imageWhere,
366
+ what: `the ${row.image_stage} stage runs as ${JSON.stringify(declared.replace(/^USER\s+/i, '').trim())} — ${identity}`
367
+ });
368
+ }
369
+ }
370
+ }
371
+
372
+ const composeWhere = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.path });
373
+ const compose = readOrNull(serviceRoot, row.path);
374
+ if (compose === null) {
375
+ findings.push({ where: composeWhere, what: 'absent — the service is run from it' });
376
+ return findings;
377
+ }
378
+
379
+ for (const [name, node] of runtimeServices(compose)) {
380
+ const declared = declarationOf(node, COMPOSE_USER_KEY).trim().replace(/^["']|["']$/g, '');
381
+ if (declared === row.service_uid) continue;
382
+ findings.push({
383
+ where: composeWhere,
384
+ what: declared === ''
385
+ ? `service ${name} declares no user — the platform identity is ${JSON.stringify(row.service_uid)}`
386
+ : `service ${name} runs as ${JSON.stringify(declared)} — the platform identity is ${JSON.stringify(row.service_uid)}`
387
+ });
388
+ }
389
+
390
+ return findings;
391
+ }
392
+ });
393
+
270
394
  const composeNoPorts = Object.freeze({
271
395
  scope: 'service',
272
396
  requires: Object.freeze(['path']),
@@ -289,6 +413,7 @@ module.exports = {
289
413
  { name: 'node-major', check: nodeMajor },
290
414
  { name: 'memory-limit', check: memoryLimit },
291
415
  { name: 'compose-command', check: composeCommand },
416
+ { name: 'process-identity', check: processIdentity },
292
417
  { name: 'compose-no-ports', check: composeNoPorts }
293
418
  ],
294
419
  majorOf
@@ -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,
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The one probe that answers "is this tree a git checkout" — and the one command
5
+ * that asks it.
6
+ *
7
+ * Two places need the answer and they must never disagree:
8
+ *
9
+ * - the `git-tracked` row (`checks/gitTracked.js`), which asks what a clone
10
+ * would receive and reports NOT RUN where git cannot say;
11
+ * - step 7 of `ValidationOrchestrator`, which measures the whole uniform and,
12
+ * outside a checkout, is NOT RUN for the same reason (owner decision
13
+ * 2026-09-17, confirmation `api/docs/governance/confirmations/biz-service-manifest.md`
14
+ * 011).
15
+ *
16
+ * A second copy of `git ls-files` would be a second rail for one concern
17
+ * (`.claude/rules/change-discipline.md` § One rail per concern) and, worse, two
18
+ * rails that can answer differently: one of them would eventually grow a
19
+ * `.git` existence check, which is not the same question — a worktree, a
20
+ * submodule and a `$GIT_DIR` elsewhere are checkouts without a `.git`
21
+ * DIRECTORY, and a directory called `.git` inside an exported tarball is not a
22
+ * checkout. What decides is whether git itself can answer.
23
+ *
24
+ * Nothing here guesses: a command git could not answer returns `null`, and the
25
+ * caller turns that into NOT RUN. What a negative answer MEANS is stated here
26
+ * once, in `NOT_A_CHECKOUT`; the remedy is the caller's own, because a row and a
27
+ * whole step do not fix the same way.
28
+ */
29
+
30
+ const { execFileSync } = require('child_process');
31
+
32
+ /**
33
+ * What a negative answer means — the half both callers state identically, so it
34
+ * cannot drift into two versions of one fact. Each of them appends its own
35
+ * remedy sentence after it.
36
+ */
37
+ const NOT_A_CHECKOUT = 'this tree is not a git checkout (git ls-files could not answer), so what a clone '
38
+ + 'would receive cannot be read — a container and an exported tarball are in exactly this state.';
39
+
40
+ /**
41
+ * Run one git command in the tree, or return null when git itself could not
42
+ * answer.
43
+ *
44
+ * @param {string} root the directory to run in
45
+ * @param {string[]} args the git arguments, without `-C <root>`
46
+ * @param {string} [input] stdin for commands that read it
47
+ * @returns {string|null} stdout, or null when git could not answer
48
+ */
49
+ function gitCommand(root, args, input = undefined) {
50
+ try {
51
+ return execFileSync('git', ['-C', root, ...args], {
52
+ encoding: 'utf8',
53
+ input,
54
+ stdio: ['pipe', 'pipe', 'pipe']
55
+ });
56
+ } catch (error) {
57
+ // check-ignore exits 1 when nothing matched, which is an ANSWER.
58
+ if (error.status === 1 && typeof error.stdout === 'string') return error.stdout;
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * What git tracks in this tree, NUL-separated exactly as `git ls-files -z`
65
+ * printed it — or null when this is not a checkout.
66
+ *
67
+ * @param {string} root
68
+ * @returns {string|null}
69
+ */
70
+ function listTrackedFiles(root) {
71
+ return gitCommand(root, ['ls-files', '-z']);
72
+ }
73
+
74
+ /**
75
+ * Is this tree a git checkout?
76
+ *
77
+ * @param {string} root
78
+ * @returns {boolean}
79
+ */
80
+ function isGitCheckout(root) {
81
+ return listTrackedFiles(root) !== null;
82
+ }
83
+
84
+ module.exports = { gitCommand, listTrackedFiles, isGitCheckout, NOT_A_CHECKOUT };