@onlineapps/conn-orch-validator 9.0.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 (49) hide show
  1. package/CHANGELOG.md +373 -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 +2 -2
  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 +91 -25
  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/PLATFORM_MATRIX.md +1 -1
  46. package/templates/business-service/docs/80-setup/VALIDATION.md +1 -1
  47. package/templates/business-service/jest.config.js +9 -1
  48. package/templates/business-service/package.json.template +1 -1
  49. package/src/mocks/MockStorage.js +0 -188
@@ -49,6 +49,7 @@
49
49
  "check": "template-block",
50
50
  "path": "init.sh",
51
51
  "block": "oa-deps-guard v1",
52
+ "insert_after_function": "oa_npm_install",
52
53
  "from": { "package": "templates/business-service/init.sh", "text": true },
53
54
  "severity": "deploy",
54
55
  "owner": "BIZ-general",
@@ -100,7 +101,7 @@
100
101
  "from": { "package": "templates/business-service/gitignore", "text": true },
101
102
  "severity": "deploy",
102
103
  "owner": "BIZ-general",
103
- "why": "Dockerfile line COPY . . takes everything .dockerignore does not exclude, and being ignored by git excludes nothing: measured by BIZ-hello 2026-09-11, a LOCAL production image of hello came to 6.6 GB - 5.2 GB of it logs/ - and carried config/env-active/*.env with DB_PASSWORD, JWT_SECRET, the MinIO keys and RABBITMQ_URL. A CI image is clean by accident, because a fresh checkout has no ignored file to copy, so the defect is invisible exactly where the image is built. The entries are DERIVED from the declaration .gitignore already reads rather than written a second time (change-discipline.md \u00a7 One rail per concern), and translated: a slash-less .gitignore pattern matches at every depth while a .dockerignore one is matched from the context root, so *.log there would catch debug.log and not logs/debug.log. The criterion is a comparison and never a list of directories - the files in /app of an image built from the working tree, node_modules aside, are the files of an image built from git archive HEAD",
104
+ "why": "Dockerfile line COPY . . takes everything .dockerignore does not exclude, and being ignored by git excludes nothing: measured by BIZ-hello 2026-09-11, a LOCAL production image of hello came to 6.6 GB - 5.2 GB of it logs/ - and carried config/env-active/*.env with DB_PASSWORD, JWT_SECRET, the MinIO keys and RABBITMQ_URL. A CI image is clean by accident, because a fresh checkout has no ignored file to copy, so the defect is invisible exactly where the image is built. The entries are DERIVED from the declaration .gitignore already reads rather than written a second time (change-discipline.md \u00a7 One rail per concern), and translated: a slash-less .gitignore pattern matches at every depth while a .dockerignore one is matched from the context root, so *.log there would catch debug.log and not logs/debug.log. The criterion is a comparison and never a list of directories - the files in /app of an image built from the working tree, node_modules aside, are the files of an image built from git archive HEAD MINUS the test tree (d.560): tests belong in the repository and never in the artefact that runs, which is the decision the library uniform makes one manifest over as L-PACK-TESTS. The single exception is tests/cookbooks, and it is not a test at all but a declaration the RUNTIME reads - Tier-1 of the boot runs those cookbooks at phase 0.2, and an image without the directory measures zero of them, so ValidationProofGenerator refuses the proof as NO_TESTS and the service never starts",
104
105
  "fix": "npx oa-sync-template .dockerignore --target . - it writes the derived entries under one labelled block and rewrites nothing this repository wrote",
105
106
  "doc": "api/docs/governance/confirmations/biz-service-manifest.md \u00a72"
106
107
  }
@@ -138,9 +139,9 @@
138
139
  "from": { "package": "templates/business-service/.gitlab-ci.yml", "text": true },
139
140
  "severity": "deploy",
140
141
  "owner": "BIZ-general",
141
- "why": "the platform half of a biz pipeline belongs to the platform, not to the service: it builds the same way, identifies the image by the FULL commit sha (R5), hands the deploy the digest CI itself pushed (R1/R2), and since confirmation 008 it runs the uniform gate before the SSH step - a service that edits any of that edits the gate that judges it. It is a BLOCK and not the whole file because the other half is genuinely the service's. Measured over the eight repositories on 2026-09-11: the template is 151 lines and 7 root keys, the services 176 to 198 lines and 9, and every difference is theirs - the test job that stands up MariaDB, Redis and RabbitMQ as service containers, runs five ci:gate:* steps and publishes ci/integration-signal.json, plus workflow: and verify-installation-contract:. Which database and which gates an integration needs is a fact about the service. A whole-file fix would have deleted all of it and left eight integration tiers running against no database, which is a finding nobody can carry out (automation-gates.md §3, last bullet; controller finding 20)",
142
+ "why": "the platform half of a biz pipeline belongs to the platform, not to the service: it builds the same way, identifies the image by the FULL commit sha (R5), hands the deploy the digest CI itself pushed (R1/R2), and runs the uniform twice - continuously, in job validate-uniform on every pipeline including the devel mirror (confirmation 010), and again before the SSH step as the binding last instance (confirmation 008). A service that edits any of that edits the gate that judges it. It is a BLOCK and not the whole file because the other half is genuinely the service's: which database, which ci:gate:* steps and which artefacts its integration needs is a fact about the service, and so - since the block cannot own it - is whether that job's own rules name the devel branch the block's jobs now name. Measured over the eight repositories on 2026-09-11: the template was 151 lines and 7 root keys, the services 176 to 198 lines and 9, and every difference was theirs - the test job that stands up MariaDB, Redis and RabbitMQ as service containers, runs five ci:gate:* steps and publishes ci/integration-signal.json, plus workflow: and verify-installation-contract:. Of those two the first became the block's in d.251b; the second is the template's no longer - d.470 stopped declaring it, its work moved into the rows G-SETUP, D-DB-PACKAGE and D-DB-HEADERS that the uniform gate runs, and each repository keeps its own copy until its release retires it (d.320). A whole-file fix would have deleted all of it and left eight integration tiers running against no database, which is a finding nobody can carry out (automation-gates.md §3, last bullet; controller finding 20)",
142
143
  "fix": "npx oa-sync-template .gitlab-ci.yml --target .",
143
- "doc": "api/docs/governance/confirmations/biz-service-manifest.md \u00a7 Confirmation 20260910-biz-service-manifest-008"
144
+ "doc": "api/docs/governance/confirmations/biz-service-manifest.md \u00a7 Confirmation 20260916-biz-service-manifest-010"
144
145
  },
145
146
  {
146
147
  "id": "G-PROD-IMAGE",
@@ -293,6 +294,17 @@
293
294
  "fix": "add scripts[\"test:integration:container\"] wrapping the runner this repository declares",
294
295
  "doc": "api/docs/governance/confirmations/biz-test-container.md"
295
296
  },
297
+ {
298
+ "id": "S-UNIT-C",
299
+ "check": "script-body",
300
+ "name": "test:unit:container",
301
+ "body": "docker compose run --rm ${runner} npm run test:unit",
302
+ "severity": "deploy",
303
+ "owner": "BIZ-general",
304
+ "why": "the unit suite is the measured reason the runner exists (biz-test-container 001: unit 341 MiB beside a 96 MiB service in a 384 MB budget, SIGKILL for both), so it has a wrapper like every other suite; the NAME says which suite runs, the way test:all:container and test:integration:container do, and the five repositories reaching it through test:container were a second spelling of one rail that script-body cannot see",
305
+ "fix": "rename scripts[\"test:container\"] to scripts[\"test:unit:container\"], wrapping the runner this repository declares",
306
+ "doc": "api/docs/governance/confirmations/biz-test-container.md"
307
+ },
296
308
  {
297
309
  "id": "S-COOKBOOKS",
298
310
  "check": "script-body",
@@ -304,6 +316,17 @@
304
316
  "why": "the cookbook run is the library's command; a per-service copy of it drifts from the library that owns the dispatch",
305
317
  "fix": "set scripts[\"test:cookbooks\"] to the library command and delete the per-service script",
306
318
  "doc": "api/docs/governance/confirmations/biz-service-manifest.md §5"
319
+ },
320
+ {
321
+ "id": "S-COOK-C",
322
+ "check": "script-body",
323
+ "name": "test:cookbooks:container",
324
+ "body": "docker compose run --rm ${runner} npm run test:cookbooks",
325
+ "severity": "deploy",
326
+ "owner": "BIZ-general",
327
+ "why": "the cookbook run dispatches real handlers and reads TESTING_TENANT_ID / TESTING_WORKSPACE_ID, which only the runner has from env_file; without the wrapper the documented path to it is a host command that fails on missing env rather than on the cookbook",
328
+ "fix": "add scripts[\"test:cookbooks:container\"] wrapping the runner this repository declares",
329
+ "doc": "api/docs/governance/confirmations/biz-test-container.md"
307
330
  }
308
331
  ],
309
332
  "forbidden": [
@@ -420,10 +443,10 @@
420
443
  "id": "G-SHARED-ENV",
421
444
  "check": "shared-env-generated",
422
445
  "path": "config/env-templates/shared.env",
423
- "from": { "package": "templates/business-service/config/env-templates/shared.env", "text": true },
446
+ "from": { "path": "api/config/shared-env.json", "text": true },
424
447
  "severity": "deploy",
425
448
  "owner": "BIZ-general",
426
- "why": "the shared key set is one fact with one owner; measured on 2026-09-09, none of the nine copies matched the platform file and one key existed in no copy at all. The owner is still api/config/shared-env.json: what this row reads is the render this package carries, kept equal to that manifest by the packaged template's own gate, so the row also answers inside a service container, where api/config/ is not there",
449
+ "why": "the shared key set is one fact with one owner; measured on 2026-09-09, none of the nine copies matched the platform file and one key existed in no copy at all. The owner is api/config/shared-env.json and this row renders from it with the renderer the sync command writes with, so a service cannot be synced and reported drifted in the same hour; a run that cannot reach the workspace says NOT RUN and names the command, because a render published with this package answers about the day it was published, not about the SSOT",
427
450
  "fix": "npx oa-sync-template shared-env --target .",
428
451
  "doc": "api/docs/governance/confirmations/biz-service-manifest.md \u00a718"
429
452
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/conn-orch-validator",
3
- "version": "9.0.0",
3
+ "version": "10.0.0",
4
4
  "description": "Validation orchestrator for OA Drive microservices - coordinates validation across all layers (base, infra, orch, business)",
5
5
  "oa": {
6
6
  "category": "orchestration"
@@ -29,7 +29,7 @@
29
29
  "license": "PROPRIETARY",
30
30
  "dependencies": {
31
31
  "@onlineapps/logger-contract": "2.0.0",
32
- "@onlineapps/service-validator-core": "2.0.1"
32
+ "@onlineapps/service-validator-core": "2.1.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "jest": "^29.5.0"
@@ -3,12 +3,12 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const crypto = require('crypto');
6
- const MockMQClient = require('./mocks/MockMQClient');
7
- const MockRegistry = require('./mocks/MockRegistry');
8
6
  const { resolveHeaders } = require('./utils/resolveHeaders');
9
7
  const { checkCookbookFormatVersion, readCookbookSteps } = require('./utils/cookbookFormat');
10
8
  const { getTestNamespace, assertWorkspaceId } = require('./utils/testNamespace');
11
- const { describeStepFailure, describeStepIdentity } = require('./utils/stepFailure');
9
+ const {
10
+ describeStepFailure, describeStepIdentity, KIND_STEP, KIND_COOKBOOK_LOAD_FAILURE
11
+ } = require('./utils/stepFailure');
12
12
  const { parseHandlerRef, resolveHandlerModule } = require('./utils/handlerRef');
13
13
  const { assertLogger } = require('@onlineapps/logger-contract');
14
14
 
@@ -76,7 +76,14 @@ function resolveOutputField(container, fieldPath) {
76
76
  }
77
77
 
78
78
  /**
79
- * CookbookTestRunner — executes cookbook tests offline with mocked infrastructure.
79
+ * CookbookTestRunner — executes cookbook tests in this process, without a transport.
80
+ *
81
+ * There is no infrastructure double. `mockInfrastructure` built a MockMQClient
82
+ * and a MockRegistry for the days when a step was dispatched through a
83
+ * transport; since v3 in-process dispatch there is nothing for them to stand in
84
+ * for, nothing read them, and the database a handler reaches is the SERVICE's
85
+ * real one. The option is retired and refused by name in the constructor and in
86
+ * a cookbook's `test` block — see README § Pre-validation runs in-process.
80
87
  *
81
88
  * Dispatch is exclusively v3 handler-registry: each operation MUST declare
82
89
  * `handler: "path#exportName"` in operations.json. The runner loads the
@@ -109,9 +116,20 @@ class CookbookTestRunner {
109
116
  // docs/governance/confirmations/connector-logger-contract.md 001.
110
117
  assertLogger('CookbookTestRunner', options.logger, 'the runner writes where the service writes');
111
118
 
119
+ // A RETIRED option is refused, never ignored: a caller that still writes it
120
+ // believes it buys something, and silently doing nothing is the implicit
121
+ // behaviour `.claude/rules/architecture-principles.md` §8 forbids. Both
122
+ // values are refused — `false` asked for the same absent mechanism as
123
+ // `true`. Why the concept went: `tests/unit/mockInfrastructureRetired.test.js`.
124
+ if (options.mockInfrastructure !== undefined) {
125
+ throw new Error('[CookbookTestRunner] mockInfrastructure is not an option - a cookbook step is '
126
+ + 'dispatched in-process against the service\'s own v3 handler, with no transport to '
127
+ + 'stand in for; the database a handler reaches is the real one. '
128
+ + 'Fix: drop mockInfrastructure from the constructor options.');
129
+ }
130
+
112
131
  this.serviceName = options.serviceName;
113
132
  this.servicePath = options.servicePath;
114
- this.mockInfrastructure = options.mockInfrastructure !== false;
115
133
  this.timeout = options.timeout;
116
134
  if (!this.timeout || typeof this.timeout !== 'number' || this.timeout <= 0) {
117
135
  throw new Error('[CookbookTestRunner] timeout is required — Expected positive number (ms)');
@@ -122,12 +140,6 @@ class CookbookTestRunner {
122
140
  this._servicePathByName.set(this.serviceName, this.servicePath);
123
141
  }
124
142
 
125
- // Initialize mocked infrastructure
126
- if (this.mockInfrastructure) {
127
- this.mqClient = new MockMQClient();
128
- this.registry = new MockRegistry();
129
- }
130
-
131
143
  // Test results
132
144
  this.results = this._emptyResults();
133
145
  }
@@ -278,6 +290,13 @@ class CookbookTestRunner {
278
290
  this.results.cookbooks.total += 1;
279
291
  this.results.cookbooks.failed += 1;
280
292
  this.results.steps.push({
293
+ // WHAT this record is, said here rather than inferred downstream from
294
+ // the absence of `step_id`. It is not a step: this cookbook produced
295
+ // none. Every consumer of `results.steps` reads the field
296
+ // (`utils/stepFailure.js`), and until d.516b the aggregate error list
297
+ // called this `step "step #1"` — a step that does not exist
298
+ // (`.claude/rules/architecture-principles.md` §8).
299
+ kind: KIND_COOKBOOK_LOAD_FAILURE,
281
300
  cookbook: file,
282
301
  passed: false,
283
302
  error: error.message
@@ -380,6 +399,9 @@ class CookbookTestRunner {
380
399
  this.logger.info(`Executing step: ${stepLabel} (${step.operation})`);
381
400
 
382
401
  const result = {
402
+ // The other half of the pair: this record IS a step, and says so beside
403
+ // the cookbook-load failure that is not (d.516b).
404
+ kind: KIND_STEP,
383
405
  // The identifier the format requires, plus the position, so the aggregate
384
406
  // error list can name the step without re-deriving anything. `id` was
385
407
  // emitted alongside it until 2026-08-30; one thing, one name.
@@ -556,6 +578,18 @@ class CookbookTestRunner {
556
578
  correlation_id: correlationId,
557
579
  person_id: personId,
558
580
  operation_name: step.operation,
581
+ // WHICH STEP is running, not merely which operation. Production
582
+ // `OperationContext` carries it among its 16 fields
583
+ // (`@onlineapps/service-wrapper` src/OperationContext.js), and a handler
584
+ // that stores a file cannot do without it:
585
+ // `@onlineapps/conn-orch-content-resolver` keys every stored object on
586
+ // `content/<workflow_id>/<step_id>` and refuses to invent either half.
587
+ // Tier-1 left it out, so a service moving off `operation_name` onto
588
+ // `ctx.step_id` passed in production and died in ServiceWrapper phase 0.2
589
+ // (BIZ-pdfgen, measured 2026-09-16 against 9.0.0 and HEAD). It is per
590
+ // STEP on purpose: one cookbook may call the same operation twice, and
591
+ // those two runs must not share a storage prefix.
592
+ step_id: step.step_id,
559
593
  service_name: step.service,
560
594
  db: null,
561
595
  cache: null,
@@ -591,7 +625,8 @@ class CookbookTestRunner {
591
625
  workspace_id: ctx.workspace_id,
592
626
  workflow_id: ctx.workflow_id,
593
627
  correlation_id: ctx.correlation_id,
594
- person_id: ctx.person_id
628
+ person_id: ctx.person_id,
629
+ step_id: ctx.step_id
595
630
  }
596
631
  };
597
632
  result.request = request;
@@ -1041,6 +1076,22 @@ class CookbookTestRunner {
1041
1076
  throw new Error(`[CookbookTestRunner] ${versionProblem}`);
1042
1077
  }
1043
1078
 
1079
+ // The retired option, in the other place a caller could still write it. The
1080
+ // cookbook schema admits additional properties inside `test`, so nothing
1081
+ // refuses the key at load time — and a key nobody reads taught every reader
1082
+ // that a cookbook chooses its own infrastructure, which it never did
1083
+ // (`change-discipline.md` § Removing something removes its declaration).
1084
+ // Refused BY NAME rather than dropped in silence, for the reason the
1085
+ // constructor refuses it one screen up.
1086
+ if (cookbook.test && typeof cookbook.test === 'object'
1087
+ && cookbook.test.mockInfrastructure !== undefined) {
1088
+ throw new Error('[CookbookTestRunner] Cookbook declares test.mockInfrastructure - the option is '
1089
+ + 'retired; a cookbook never chose its own infrastructure and the runner dispatches '
1090
+ + 'in-process against the real service. '
1091
+ + 'Fix: remove "mockInfrastructure" from the cookbook\'s "test" block '
1092
+ + '(api/docs/biz/40-cookbooks/format.md § Optional top-level fields).');
1093
+ }
1094
+
1044
1095
  if (cookbook.steps === undefined || cookbook.steps === null) {
1045
1096
  throw new Error('[CookbookTestRunner] Cookbook has no steps - Expected "steps": [ … ] with at '
1046
1097
  + 'least one step object carrying "step_id". '
@@ -1055,18 +1106,35 @@ class CookbookTestRunner {
1055
1106
  + 'steps array. Fix: add at least one step (api/docs/biz/40-cookbooks/format.md § Required fields).');
1056
1107
  }
1057
1108
 
1058
- for (const step of stepsArray) {
1109
+ // The three fields `@onlineapps/cookbook-core` 5.0.0 requires of a task step
1110
+ // beyond `type` (`schemas/cookbook.v2.schema.json`
1111
+ // definitions.TaskStep.required). `step_id` comes FIRST, because it is what
1112
+ // the other two messages name the step by: until d.516b this guard checked
1113
+ // only the last two and wrote `Step ${step.step_id || 'unknown'}`, so a
1114
+ // cookbook missing the field the whole format addresses steps by was
1115
+ // accepted here and its diagnostics said `Step unknown`. A stand-in name is
1116
+ // the fallback `.claude/rules/architecture-principles.md` §3 forbids; with
1117
+ // the field required, the case it stood in for cannot occur.
1118
+ stepsArray.forEach((step, index) => {
1119
+ const position = index + 1;
1120
+ if (typeof step.step_id !== 'string' || step.step_id.length === 0) {
1121
+ throw new Error(`[CookbookTestRunner] Step #${position} has no step_id - every step of a v2.1 `
1122
+ + 'cookbook is addressed by its own step_id (@onlineapps/cookbook-core '
1123
+ + 'schemas/cookbook.v2.schema.json definitions.TaskStep.required). '
1124
+ + `Fix: add "step_id" to step #${position} `
1125
+ + '(api/docs/biz/40-cookbooks/format.md § Required fields).');
1126
+ }
1059
1127
  if (!step.service) {
1060
- throw new Error(`[CookbookTestRunner] Step ${step.step_id || 'unknown'} must have service - `
1128
+ throw new Error(`[CookbookTestRunner] Step ${step.step_id} must have service - `
1061
1129
  + 'Expected the name of the service that runs the step. Fix: add "service" to that step '
1062
1130
  + '(api/docs/biz/40-cookbooks/format.md § Step definition).');
1063
1131
  }
1064
1132
  if (!step.operation) {
1065
- throw new Error(`[CookbookTestRunner] Step ${step.step_id || 'unknown'} must have operation - `
1133
+ throw new Error(`[CookbookTestRunner] Step ${step.step_id} must have operation - `
1066
1134
  + 'Expected the operation that service exposes. Fix: add "operation" to that step '
1067
1135
  + '(api/docs/biz/40-cookbooks/format.md § Step definition).');
1068
1136
  }
1069
- }
1137
+ });
1070
1138
 
1071
1139
  return true;
1072
1140
  }
@@ -8,10 +8,13 @@ const { normalizeEnvDeclaration, collectEnvCoverage, verifyEnvPresence } = requi
8
8
  // called. It verified a cached proof before booting; the proof cache was
9
9
  // removed (see validate()) and the verifier's last call site went with it,
10
10
  // leaving a binding whose only remaining effect was to tell a reader that this
11
- // class still checks proofs. `ValidationProofCodec` stays — finalizeResults()
12
- // encodes the proof it writes.
13
- const { ValidationProofCodec } = require('@onlineapps/service-validator-core');
11
+ // class still checks proofs. `ValidationProofCodec` went the same way in d.524:
12
+ // finalizeResults() no longer encodes a proof of its own — it asks
13
+ // `ValidationProofGenerator`, which is the one author of one, and the codec is
14
+ // that generator's business.
14
15
  const FingerprintUtils = require('@onlineapps/service-validator-core').FingerprintUtils;
16
+ const ValidationProofGenerator = require('./validators/ValidationProofGenerator');
17
+ const { VALIDATOR_VERSION } = require('./validatorIdentity');
15
18
  const { ServiceStructureValidator } = require('./validators/ServiceStructureValidator');
16
19
  const { describeStepFailureWithContext } = require('./utils/stepFailure');
17
20
  const CookbookTestRunner = require('./CookbookTestRunner');
@@ -38,6 +41,41 @@ const { buildDeployabilitySignal, writeDeployabilitySignal } = require('./manife
38
41
  */
39
42
  const CONFIG_STEP_ROWS = Object.freeze(['C-SERVICE', 'C-OPS', 'C-CONTRACT']);
40
43
 
44
+ /**
45
+ * One number a step MEASURED — or a refusal.
46
+ *
47
+ * The run's `totalTests`/`passedTests`/`failedTests` used to be summed with
48
+ * `|| 0`, so a step that measured nothing contributed a zero and the aggregate
49
+ * could not tell "no tests ran" from "the step did not say". The proof's author
50
+ * refuses an unmeasured field (`ValidationProofGenerator.readMeasurement`,
51
+ * d.524), but it never saw one: the fallback had already turned it into a
52
+ * number. That is `.claude/rules/architecture-principles.md` §3 (No Fallbacks)
53
+ * standing exactly where §4 wants a refusal, and the far end paid for it —
54
+ * `ValidationProofCodec.decode()` rejects the resulting proof as NO_TESTS, days
55
+ * later and nowhere near the cause.
56
+ *
57
+ * A measured 0 is untouched: zero failures is the ordinary case. What is
58
+ * refused is the ABSENCE of a measurement.
59
+ *
60
+ * @param {Object} step the step result the aggregate is summing
61
+ * @param {string} field `total`, `passed` or `failed`
62
+ * @param {string} stepName the step's name, for the refusal
63
+ * @returns {number} the measured value
64
+ */
65
+ function readStepMeasurement(step, field, stepName) {
66
+ const value = step === null || step === undefined ? undefined : step[field];
67
+
68
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
69
+ throw new Error(`[ValidationOrchestrator] Missing measurement - step "${stepName}" reported `
70
+ + `${JSON.stringify(value) === undefined ? 'undefined' : JSON.stringify(value)} for "${field}", `
71
+ + 'and the run counts measured values only - a zero standing in for an absent measurement is '
72
+ + 'what makes a proof of nothing look like a passing validation. '
73
+ + `Fix: return a finite number as "${field}" from step "${stepName}".`);
74
+ }
75
+
76
+ return value;
77
+ }
78
+
41
79
  /**
42
80
  * ValidationOrchestrator
43
81
  *
@@ -391,9 +429,11 @@ class ValidationOrchestrator {
391
429
  // Step 5: Cookbook Tests
392
430
  this.logger.info('[ValidationOrchestrator] Step 5/7: Cookbook Tests');
393
431
  results.steps.cookbooks = await this.runCookbookTests();
394
- results.totalTests += results.steps.cookbooks.total || 0;
395
- results.passedTests += results.steps.cookbooks.passed || 0;
396
- results.failedTests += results.steps.cookbooks.failed || 0;
432
+ // Measured values only — see readStepMeasurement() for why `|| 0` here
433
+ // was the reason the generator's refusal never fired.
434
+ results.totalTests += readStepMeasurement(results.steps.cookbooks, 'total', 'cookbooks');
435
+ results.passedTests += readStepMeasurement(results.steps.cookbooks, 'passed', 'cookbooks');
436
+ results.failedTests += readStepMeasurement(results.steps.cookbooks, 'failed', 'cookbooks');
397
437
  if (!results.steps.cookbooks.success) {
398
438
  results.success = false;
399
439
  results.errors.push(...(results.steps.cookbooks.errors || []));
@@ -773,7 +813,7 @@ class ValidationOrchestrator {
773
813
  serviceRoot: this.serviceRoot,
774
814
  signal: buildDeployabilitySignal({
775
815
  result,
776
- validatorVersion: require('../package.json').version
816
+ validatorVersion: VALIDATOR_VERSION
777
817
  })
778
818
  });
779
819
 
@@ -825,21 +865,34 @@ class ValidationOrchestrator {
825
865
  try {
826
866
  // Generate validation proof
827
867
  const fingerprint = await this.calculateFingerprint();
828
- const proof = {
868
+
869
+ // Through the generator, which is the ONE author of a proof. Built here
870
+ // by hand and handed straight to the codec, this proof escaped both of
871
+ // the generator's refusals: an unmeasured field (`readMeasurement`) and
872
+ // a run that executed nothing
873
+ // (`.claude/rules/change-discipline.md` § One rail per concern).
874
+ //
875
+ // The numbers reaching it are measured ones: the aggregate above sums
876
+ // through `readStepMeasurement()`, so an absent measurement fails the
877
+ // run here rather than arriving as a zero the generator cannot tell
878
+ // from a real one (d.576b).
879
+ const generator = new ValidationProofGenerator({
829
880
  serviceName: this.serviceName,
830
- version: this.serviceVersion,
831
- contractFingerprint: fingerprint, // Store contract fingerprint for subsequent starts
832
- validator: '@onlineapps/conn-orch-validator',
833
- validatorVersion: require('../package.json').version,
834
- validatedAt: new Date().toISOString(),
835
- testsRun: results.totalTests,
836
- testsPassed: results.passedTests,
837
- testsFailed: results.failedTests,
838
- durationMs: duration
839
- };
881
+ serviceVersion: this.serviceVersion
882
+ });
840
883
 
841
- // Encode proof using ValidationProofCodec
842
- const encodedProof = ValidationProofCodec.encode(proof);
884
+ const pkg = this.readServicePackageJson(path.join(this.serviceRoot, 'package.json'));
885
+
886
+ const encodedProof = generator.generateProof(
887
+ {
888
+ total: results.totalTests,
889
+ passed: results.passedTests,
890
+ failed: results.failedTests,
891
+ duration
892
+ },
893
+ pkg.dependencies || {},
894
+ { contractFingerprint: fingerprint }
895
+ );
843
896
 
844
897
  // Save proof to conn-runtime/
845
898
  await this.saveProof(encodedProof);
@@ -33,6 +33,7 @@ const {
33
33
  PLATFORM_TEST_FILE_PATTERN,
34
34
  } = require('../utils/testCoverageContract');
35
35
  const { runPreValidation } = require('../utils/preValidation');
36
+ const { describeRecordIdentity } = require('../utils/stepFailure');
36
37
  const { checkLibCompat, loadLibrarySet } = require('../utils/libCompat');
37
38
  const { buildSchema } = require('../utils/setupDatabase');
38
39
 
@@ -89,7 +90,7 @@ Commands:
89
90
  contract's closed vocabularies on every live migrations/**/*.sql
90
91
  (archive/ and superseded/ excluded), and each BASELINE manifest
91
92
  naming exactly the files the installer applies, in that order.
92
- run-prevalidation Run the service's cookbooks offline against mocked infrastructure
93
+ run-prevalidation Run the service's cookbooks in-process (no transport, real database)
93
94
  and write conn-runtime/validation-proof.json. Replaces the
94
95
  per-service scripts/run-pre-validation.js.
95
96
  verify-lib-compat Compare @onlineapps/* pins against the platform library SSOT (R6).
@@ -382,18 +383,6 @@ async function runVerifyContract(options) {
382
383
  }
383
384
 
384
385
 
385
- /**
386
- * Running cookbooks needs `tests/cookbooks/`, `operations.json` and the handler
387
- * modules — nothing else. This command used to resolve the service's own
388
- * `@onlineapps/service-wrapper`, run `ConfigLoader.loadAll()` and pass
389
- * `service.url` to the runner, which never read it (`this.serviceUrl` appears
390
- * nowhere in CookbookTestRunner). `ConfigLoader.loadAll` no longer returns that
391
- * field either — the owner removed `port` and `url` from the service shape
392
- * (docs/governance/confirmations/biz-service-port-url.md 001), so the value had
393
- * been `undefined` for every service on the platform. What the load still did
394
- * was make a readable config, an installed wrapper and a resolvable `${ENV}`
395
- * placeholder preconditions of a cookbook run that depends on none of them.
396
- */
397
386
  /**
398
387
  * End the process with this code, once everything written has left the buffers.
399
388
  *
@@ -434,6 +423,27 @@ function exitWhenFlushed(code) {
434
423
  done();
435
424
  }
436
425
 
426
+ /**
427
+ * What a cookbook run reads, and what it does NOT.
428
+ *
429
+ * It reads `tests/cookbooks/`, `config/service/operations.json` and the
430
+ * service's `package.json`, and it dispatches the service's own v3 handler
431
+ * modules IN THIS PROCESS. So it needs whatever those handlers need: a handler
432
+ * that reaches its schema opens the service's real pool here, which is the
433
+ * subject of `exitWhenFlushed` above. "Nothing else" is what this sentence used
434
+ * to say, and the paragraph below it said the opposite (d.523).
435
+ *
436
+ * What it does not do is load the service CONFIG. This command used to resolve
437
+ * the service's own `@onlineapps/service-wrapper`, run `ConfigLoader.loadAll()`
438
+ * and pass `service.url` to the runner, which never read it (`this.serviceUrl`
439
+ * appears nowhere in CookbookTestRunner). `ConfigLoader.loadAll` no longer
440
+ * returns that field either — the owner removed `port` and `url` from the
441
+ * service shape (docs/governance/confirmations/biz-service-port-url.md 001), so
442
+ * the value had been `undefined` for every service on the platform. What the
443
+ * load still did was make a readable config, an installed wrapper and a
444
+ * resolvable `${ENV}` placeholder preconditions of a cookbook run that depends
445
+ * on none of them.
446
+ */
437
447
  async function runRunPreValidation(options) {
438
448
  const outcome = await runPreValidation({
439
449
  serviceRoot: path.resolve(options.serviceRoot),
@@ -451,7 +461,11 @@ async function runRunPreValidation(options) {
451
461
 
452
462
  if (!outcome.ok) {
453
463
  for (const step of outcome.failedSteps) {
454
- process.stderr.write(`[BizCiGate] FAIL run-prevalidation — ${step.step_id}: ${step.error}\n`);
464
+ // The record names itself: `results.steps` carries two kinds and this
465
+ // line printed `step_id` for both, so a rejected cookbook read as
466
+ // `undefined` (d.523). The identity comes from the one owner of it,
467
+ // through `runPreValidation`'s outcome.
468
+ process.stderr.write(`[BizCiGate] FAIL run-prevalidation — ${describeRecordIdentity(step)}: ${step.error}\n`);
455
469
  for (const validationError of step.validationErrors) {
456
470
  process.stderr.write(`[BizCiGate] * ${validationError}\n`);
457
471
  }
@@ -11,8 +11,9 @@
11
11
  * * bare paths - the uniform sync of confirmation 001 §3.2. It writes the
12
12
  * files of the manifest classes `identical` and `generated` from the very
13
13
  * `from:` reference those rows already point the conformance CHECK at, and
14
- * never touches a file of the `own` class. A row the manifest does not make
15
- * renderable is reported NOT RUN by name, never filled from a guess.
14
+ * never touches a file of the `own` class. A row that names no `from:`
15
+ * reference is not a row of this run — it is a requirement about the
16
+ * repository, and `npx oa-validate` is what answers it (d.507).
16
17
  * * `--new <name>` - the whole service tree, from the template that ships
17
18
  * inside this package (001 §2). It is what `api/scripts/add-service.sh
18
19
  * --scaffold` runs; that script keeps the platform facts (the registry
@@ -48,7 +49,7 @@ const {
48
49
  SSOT_PIN,
49
50
  TEMPLATE_ROOT
50
51
  } = require('../sync/serviceTemplate');
51
- const { planSync, uniformRows } = require('../sync/uniformFiles');
52
+ const { planSync, uniformRows, isSyncRow } = require('../sync/uniformFiles');
52
53
  const { loadManifest: loadUniformManifest, DEFAULT_MANIFEST_PATH } = require('../manifest/loadManifest');
53
54
  const {
54
55
  applyUniformRegion,
@@ -107,10 +108,11 @@ Usage:
107
108
  is: generated output, committed for reading (001 §5). --check is the gate.
108
109
 
109
110
  With bare paths (or none, meaning all of them) the run rewrites the files the
110
- manifest declares in the classes "identical" and "generated", from the same
111
- reference the conformance check reads. Files of the "own" class - src/**,
112
- tests/**, the content of docs/** - are never written. A row the manifest does
113
- not make renderable is printed NOT RUN with the reason.
111
+ manifest declares in the classes "identical" and "generated" that name a
112
+ "from" reference, from that same reference the conformance check reads. Files
113
+ of the "own" class - src/**, tests/**, the content of docs/** - are never
114
+ written. A row naming no reference is a requirement about the repository
115
+ rather than a file, and npx oa-validate <serviceRoot> is what answers it.
114
116
 
115
117
  --new <name> create a service tree from the packaged template
116
118
  --into <dir> where that tree is written; it must not exist yet
@@ -721,9 +723,22 @@ function runSync(options) {
721
723
  // The positionals are judged FIRST, against the manifest, and before anything
722
724
  // that touches the disk: a mistyped subcommand must be named as what it is,
723
725
  // not reported as a missing workspace three steps later.
724
- const declared = uniformRows(loadUniformManifest(DEFAULT_MANIFEST_PATH)).map((row) => row.path);
726
+ const rows = uniformRows(loadUniformManifest(DEFAULT_MANIFEST_PATH));
727
+ const declared = rows.filter(isSyncRow).map((row) => row.path);
725
728
  for (const wanted of options.paths) {
726
729
  if (declared.includes(wanted)) continue;
730
+
731
+ // A path the uniform DOES declare, as a requirement rather than as a file
732
+ // this run renders, is refused by what it is and not as a typo: the reader
733
+ // named a real row, and what they need is the run that answers it.
734
+ const requirement = rows.find((row) => row.path === wanted);
735
+ if (requirement !== undefined) {
736
+ throw new Error(`[oa-sync-template] "${wanted}" is declared by ${requirement.id}, which names no "from" `
737
+ + 'reference - it is a requirement about the repository, not a file this run renders. '
738
+ + 'Fix: check it with npx oa-validate <serviceRoot>; the paths this run writes are '
739
+ + `(${declared.join(', ')}).`);
740
+ }
741
+
727
742
  throw new Error(`[oa-sync-template] Unknown argument "${wanted}" - it is neither a subcommand this run `
728
743
  + `has (${SUBCOMMAND_NAMES.join(', ')}) nor a path the uniform declares (${declared.join(', ')}). `
729
744
  + 'Fix: name one of those, or leave the paths out to write them all.');
@@ -433,7 +433,13 @@ function main(argv) {
433
433
  }
434
434
  const serviceRoot = canonicalRoot(given);
435
435
 
436
- const workspaceRoot = resolveWorkspaceRoot({ explicit: options.workspace });
436
+ // The workspace this SERVICE lies in, not the one this package lies in
437
+ // (d.540): the copy a service runs is the one it installed, and an installed
438
+ // copy is part of no checkout — `oa-validate .` inside a complete workspace
439
+ // printed NOT RESOLVED and reported every workspace row NOT RUN. The root is
440
+ // the one the caller named, never `process.cwd()`
441
+ // (`workspaceRoot.js` § The service a run is ABOUT).
442
+ const workspaceRoot = resolveWorkspaceRoot({ explicit: options.workspace, serviceRoot });
437
443
  const result = runManifest({ manifest, serviceRoot, workspaceRoot });
438
444
 
439
445
  process.stdout.write(options.json ? renderJson(result) : renderReport(result));
package/src/index.js CHANGED
@@ -12,10 +12,6 @@
12
12
  * @see /api/docs/architecture/validator.md (validator architecture)
13
13
  */
14
14
 
15
- const MockMQClient = require('./mocks/MockMQClient');
16
- const MockRegistry = require('./mocks/MockRegistry');
17
- const MockStorage = require('./mocks/MockStorage');
18
-
19
15
  const CookbookTestUtils = require('./CookbookTestUtils');
20
16
  const { ServiceStructureValidator } = require('./validators/ServiceStructureValidator');
21
17
  const ValidationProofGenerator = require('./validators/ValidationProofGenerator');
@@ -34,11 +30,16 @@ const ValidationOrchestrator = require('./ValidationOrchestrator');
34
30
 
35
31
  const { createServiceReadinessTests } = require('./helpers/createServiceReadinessTests');
36
32
 
33
+ // No infrastructure doubles are published. `MockMQClient`, `MockRegistry` and
34
+ // `MockStorage` were the furniture of `mockInfrastructure`, retired in d.576:
35
+ // pre-validation dispatches a step in-process against the service's own v3
36
+ // handler, so there is no transport to stand in for, and the database a handler
37
+ // reaches is the real one. `MockRegistry` stays inside the package as the
38
+ // registry stand-in of `helpers/createServiceReadinessTests`, which is a
39
+ // different concern and keeps its own consumer; `MockMQClient` as the double of
40
+ // `tests/unit/RegistrationFlow.test.js`. `MockStorage` had neither, so d.583
41
+ // deleted the file as well as the export (`tests/unit/mockInfrastructureRetired.test.js`).
37
42
  module.exports = {
38
- get MockMQClient() { return MockMQClient; },
39
- get MockRegistry() { return MockRegistry; },
40
- get MockStorage() { return MockStorage; },
41
-
42
43
  get CookbookTestUtils() { return CookbookTestUtils; },
43
44
  get ServiceReadinessValidator() { return ServiceReadinessValidator; },
44
45
  get CookbookTestRunner() { return CookbookTestRunner; },
@@ -82,11 +83,18 @@ module.exports = {
82
83
  get parseHandlerRef() { return require('./utils/handlerRef').parseHandlerRef; },
83
84
  get resolveHandlerModule() { return require('./utils/handlerRef').resolveHandlerModule; },
84
85
 
86
+ // The other half of what a service's integration harness needs to build its
87
+ // throwaway schema: which .sql files make up the set, and in which order.
88
+ // `createThrowawaySchema` above resolves the plan itself; a harness that
89
+ // builds its own schema step by step reads the plan directly, and had to
90
+ // reach into `src/utils/setupDatabase` to get it — a path inside the package
91
+ // is not a contract, and every move of `src/utils/` would break a consumer
92
+ // that never agreed to one. Exported as the identical function, not a
93
+ // wrapper: one definition, one behaviour (`change-discipline.md` § One rail
94
+ // per concern).
95
+ get resolveMigrationPlan() { return require('./utils/setupDatabase').resolveMigrationPlan; },
96
+
85
97
  get createServiceReadinessTests() { return createServiceReadinessTests; },
86
98
  get BizCiGateContract() { return BizCiGateContract; },
87
- get IntegrationRun() { return require('./utils/integrationRun'); },
88
-
89
- createMockMQ: () => new MockMQClient(),
90
- createMockRegistry: () => new MockRegistry(),
91
- createMockStorage: () => new MockStorage()
99
+ get IntegrationRun() { return require('./utils/integrationRun'); }
92
100
  };