@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
@@ -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,14 +101,14 @@
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
  }
107
108
  ],
108
109
  "own": {
109
110
  "concern": "the files of the template whose CONTENT the service decides, and the reason each is not held to the platform's copy",
110
- "why": "a file added to the template is a decision about who guards it, and this class is the decision written down: no row holds these to the template, because their content IS the service. Dockerfile is the exception that proves the rule - its one platform fact, the node major, is R-NODE's row, and everything else in it is this service's build. docs/README.md is the same decision for the map of the documentation tree: which branches a service has is the service's, and what the platform holds the tree to is not that prose but D-LINT, which asks the documentation lint whether the tree is clean. It is the map alone, not docs/ - the three installation documents under docs/80-setup/ ARE held to a skeleton, and a class covering them would exempt what a row requires. The class exists so that the completeness check of finding 18 has an answer other than silence for every file the template carries (automation-gates.md \u00a75)",
111
+ "why": "a file added to the template is a decision about who guards it, and this class is the decision written down: no row holds these to the template, because their content IS the service. Dockerfile is the exception that proves the rule - its TWO platform facts are rows of their own, and everything else in it is this service's build: the node major is R-NODE's row, and WHICH USER the process runs as is R-USER's (added d.588c, after a production image built from the template was measured running as uid 0 while the dev container beside it ran as 1000). What a service BUILDS is its own; what it RUNS AS is the platform's. docs/README.md is the same decision for the map of the documentation tree: which branches a service has is the service's, and what the platform holds the tree to is not that prose but D-LINT, which asks the documentation lint whether the tree is clean. It is the map alone, not docs/ - the three installation documents under docs/80-setup/ ARE held to a skeleton, and a class covering them would exempt what a row requires. The class exists so that the completeness check of finding 18 has an answer other than silence for every file the template carries (automation-gates.md \u00a75)",
111
112
  "allowed": [
112
113
  "Dockerfile",
113
114
  "index.js",
@@ -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
  },
@@ -474,6 +497,20 @@
474
497
  "fix": "in docker-compose.yml set the service block's command to [\"node\", \"index.js\"] (the entrypoint init.sh execs it, so node becomes PID 1), then recreate the container and smoke it",
475
498
  "doc": "api/docs/governance/confirmations/biz-compose-pid1.md"
476
499
  },
500
+ {
501
+ "id": "R-USER",
502
+ "check": "process-identity",
503
+ "image_path": "Dockerfile",
504
+ "image_stage": "production",
505
+ "path": "docker-compose.yml",
506
+ "service_user": "node",
507
+ "service_uid": "1000:1000",
508
+ "severity": "deploy",
509
+ "owner": "BIZ-general",
510
+ "why": "WHICH USER the process is, which is a platform fact and not a service build. Measured 2026-09-17 on a production image built from the platform template: `docker run --rm <image> id` answered uid=0(root) gid=0(root), while the dev container beside it ran as 1000:1000 because docker-compose.yml pins it - nobody had decided that difference, the production stage simply declared no USER, and nothing in the uniform could say so because Dockerfile is a file of the own class. This row is the SECOND platform fact carved out of that class, for the same reason as the first (R-NODE, the node major): what a service BUILDS is its own, what it RUNS AS is the platform. It measures the two places nothing else holds - the production stage of the Dockerfile, which is the stage CI builds and the one the defect lived in, and the SERVICE node of the dev compose. The runner user: sits inside the block F-RUNNER holds byte for byte, and the production compose is the whole-file render of G-PROD, so neither is repeated here (change-discipline.md, One rail per concern). The two spellings are ONE identity, and the row carries both because no machine resolves an account name to a uid without the image: node is uid 1000, gid 1000 in the node images this platform builds on, measured docker run --rm node:24-alpine id node",
511
+ "fix": "in the production stage of Dockerfile put RUN mkdir -p /app/logs /app/conn-runtime && chown -R node:node /app, then USER node before npm ci, and --chown=node:node on both COPY lines; in docker-compose.yml give the service node the line user: \"1000:1000\"",
512
+ "doc": "api/docs/biz/00-model/service-shape.md"
513
+ },
477
514
  {
478
515
  "id": "R-PORTS-DEV",
479
516
  "check": "compose-no-ports",
@@ -564,6 +601,19 @@
564
601
  "fix": "set DB_USER in this service's env template to oagen_<shortname>, where <shortname> is what config/service/config.json declares; the account and its grants are created by the operator installing the schema",
565
602
  "doc": "api/docs/governance/confirmations/db-accounts-per-service.md"
566
603
  },
604
+ {
605
+ "id": "D-DB-CI-ACCOUNT",
606
+ "check": "db-ci-account",
607
+ "path": ".gitlab-ci.yml",
608
+ "block": "oa-ci v1",
609
+ "key": "DB_USER",
610
+ "account": "root",
611
+ "severity": "deploy",
612
+ "owner": "BIZ-general",
613
+ "why": "D-DB-ACCOUNT one row up measures the DECLARATION - the account production installs. Nothing measured the ONE environment where that migration set is applied every day. Measured 2026-09-16 over the eight biz repositories: seven set DB_USER: \"root\" in their test job while declaring oagen_<service> in the env template, so CI proved the set under rights no production box grants - the gap db-migrations-first-deploy 001 names (\"migrace nikdy pod rootem\") in the only place a migration can be tried before a deploy. A separate row and not a wider D-DB-ACCOUNT: another file, another fix, and one row with two fixes is two mechanisms under one name (automation-gates.md §1.2). It reads the lines OUTSIDE the oa-ci v1 block, because that block is the platform's and G-CI compares it byte for byte; reading a region and not the whole file is also what keeps it clear of the defect that had the whole-file row over .gitlab-ci.yml withdrawn after three days. It asks WHO the database steps run as and deliberately not WHETHER a repository runs them: a job that builds no schema has no account for the step to create, and demanding one would be this row inventing a rule (truth-over-agreement.md §6). Silent for a service with no database block by construction - pdfgen is the live case",
614
+ "fix": "in the test job of .gitlab-ci.yml set DB_USER to the account config/env-templates/<service>.env declares, give DB_PASSWORD a throwaway value of the job, name the sidecar's root credential CI_DB_ROOT_USER / CI_DB_ROOT_PASSWORD, and run `npx oa-biz-ci-gate setup-db-account` as the first before_script step, before ci:gate:setup",
615
+ "doc": "api/docs/governance/confirmations/db-accounts-per-service.md"
616
+ },
567
617
  {
568
618
  "id": "D-DB-HEADERS",
569
619
  "check": "install-contract",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/conn-orch-validator",
3
- "version": "9.0.0",
3
+ "version": "11.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"
@@ -28,8 +28,9 @@
28
28
  "author": "OnlineApps",
29
29
  "license": "PROPRIETARY",
30
30
  "dependencies": {
31
+ "@onlineapps/cookbook-core": "6.0.0",
31
32
  "@onlineapps/logger-contract": "2.0.0",
32
- "@onlineapps/service-validator-core": "2.0.1"
33
+ "@onlineapps/service-validator-core": "2.1.0"
33
34
  },
34
35
  "devDependencies": {
35
36
  "jest": "^29.5.0"
@@ -3,13 +3,15 @@
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
+ const { createRunContext, resolveStepInput, recordStepOutcome, checkStepInputExpressions }
14
+ = require('./utils/stepReferences');
13
15
  const { assertLogger } = require('@onlineapps/logger-contract');
14
16
 
15
17
  /**
@@ -76,7 +78,14 @@ function resolveOutputField(container, fieldPath) {
76
78
  }
77
79
 
78
80
  /**
79
- * CookbookTestRunner — executes cookbook tests offline with mocked infrastructure.
81
+ * CookbookTestRunner — executes cookbook tests in this process, without a transport.
82
+ *
83
+ * There is no infrastructure double. `mockInfrastructure` built a MockMQClient
84
+ * and a MockRegistry for the days when a step was dispatched through a
85
+ * transport; since v3 in-process dispatch there is nothing for them to stand in
86
+ * for, nothing read them, and the database a handler reaches is the SERVICE's
87
+ * real one. The option is retired and refused by name in the constructor and in
88
+ * a cookbook's `test` block — see README § Pre-validation runs in-process.
80
89
  *
81
90
  * Dispatch is exclusively v3 handler-registry: each operation MUST declare
82
91
  * `handler: "path#exportName"` in operations.json. The runner loads the
@@ -109,9 +118,20 @@ class CookbookTestRunner {
109
118
  // docs/governance/confirmations/connector-logger-contract.md 001.
110
119
  assertLogger('CookbookTestRunner', options.logger, 'the runner writes where the service writes');
111
120
 
121
+ // A RETIRED option is refused, never ignored: a caller that still writes it
122
+ // believes it buys something, and silently doing nothing is the implicit
123
+ // behaviour `.claude/rules/architecture-principles.md` §8 forbids. Both
124
+ // values are refused — `false` asked for the same absent mechanism as
125
+ // `true`. Why the concept went: `tests/unit/mockInfrastructureRetired.test.js`.
126
+ if (options.mockInfrastructure !== undefined) {
127
+ throw new Error('[CookbookTestRunner] mockInfrastructure is not an option - a cookbook step is '
128
+ + 'dispatched in-process against the service\'s own v3 handler, with no transport to '
129
+ + 'stand in for; the database a handler reaches is the real one. '
130
+ + 'Fix: drop mockInfrastructure from the constructor options.');
131
+ }
132
+
112
133
  this.serviceName = options.serviceName;
113
134
  this.servicePath = options.servicePath;
114
- this.mockInfrastructure = options.mockInfrastructure !== false;
115
135
  this.timeout = options.timeout;
116
136
  if (!this.timeout || typeof this.timeout !== 'number' || this.timeout <= 0) {
117
137
  throw new Error('[CookbookTestRunner] timeout is required — Expected positive number (ms)');
@@ -122,12 +142,6 @@ class CookbookTestRunner {
122
142
  this._servicePathByName.set(this.serviceName, this.servicePath);
123
143
  }
124
144
 
125
- // Initialize mocked infrastructure
126
- if (this.mockInfrastructure) {
127
- this.mqClient = new MockMQClient();
128
- this.registry = new MockRegistry();
129
- }
130
-
131
145
  // Test results
132
146
  this.results = this._emptyResults();
133
147
  }
@@ -156,6 +170,17 @@ class CookbookTestRunner {
156
170
  // along, so the catching layer still sees the original error object.
157
171
  try {
158
172
  this.validateCookbook(cookbookData);
173
+
174
+ // What the recipe WRITES, before anything of it runs. A helper call and a
175
+ // `{{steps.<id>}}` naming no step of this cookbook are both refused here:
176
+ // production evaluates the first and refuses the second at submission, so
177
+ // letting either through as literal text would make a green Tier-1 say
178
+ // more than production will (utils/stepReferences.js). The step values
179
+ // themselves are resolved per step, later and against what ran.
180
+ const referenceProblem = await checkStepInputExpressions(readCookbookSteps(cookbookData.steps));
181
+ if (referenceProblem !== null) {
182
+ throw new Error(`[CookbookTestRunner] ${referenceProblem}`);
183
+ }
159
184
  } catch (error) {
160
185
  if (typeof cookbook === 'string') {
161
186
  throw new Error(`${error.message} (cookbook file: ${cookbook})`, { cause: error });
@@ -178,8 +203,19 @@ class CookbookTestRunner {
178
203
  // Execute steps
179
204
  const cookbookName = cookbookData.description || 'Unnamed';
180
205
  const stepResults = [];
206
+ // What the steps of THIS cookbook have produced so far — the scope a
207
+ // `{{steps.<step_id>.output.…}}` reference resolves against
208
+ // (src/utils/stepReferences.js). It is built here, per cookbook, because
209
+ // `runCookbooks()` drives many cookbooks through one runner and a step of
210
+ // one must never see a step of another.
211
+ const runContext = createRunContext();
181
212
  for (const [stepIndex, step] of stepsArray.entries()) {
182
- const stepResult = await this.executeStep(step, testConfig, stepIndex, cookbookData.defaults);
213
+ const stepResult = await this.executeStep(step, testConfig, stepIndex, cookbookData.defaults, runContext);
214
+ // Every step is recorded — passed or failed. `recordStepOutcome` decides
215
+ // what an entry carries; a failed step keeps its status and no output, so
216
+ // a reference into it stays literal (the section "Runtime context
217
+ // (`context.steps`)" of api/docs/biz/40-cookbooks/format.md).
218
+ recordStepOutcome(runContext, step, stepResult);
183
219
  // Which cookbook this step came from. Without it a failed step in the
184
220
  // aggregate is an id with no file behind it, and a service can carry a
185
221
  // dozen cookbooks (utils/stepFailure.js).
@@ -278,6 +314,13 @@ class CookbookTestRunner {
278
314
  this.results.cookbooks.total += 1;
279
315
  this.results.cookbooks.failed += 1;
280
316
  this.results.steps.push({
317
+ // WHAT this record is, said here rather than inferred downstream from
318
+ // the absence of `step_id`. It is not a step: this cookbook produced
319
+ // none. Every consumer of `results.steps` reads the field
320
+ // (`utils/stepFailure.js`), and until d.516b the aggregate error list
321
+ // called this `step "step #1"` — a step that does not exist
322
+ // (`.claude/rules/architecture-principles.md` §8).
323
+ kind: KIND_COOKBOOK_LOAD_FAILURE,
281
324
  cookbook: file,
282
325
  passed: false,
283
326
  error: error.message
@@ -366,9 +409,21 @@ class CookbookTestRunner {
366
409
  }
367
410
 
368
411
  /**
369
- * Execute single step
412
+ * Execute single step.
413
+ *
414
+ * @param {Object} step - the cookbook step as written
415
+ * @param {Object} testConfig - the cookbook's `test` block
416
+ * @param {number} [stepIndex] - the step's position, for messages
417
+ * @param {Object|null} [cookbookDefaults] - the cookbook's `defaults` block
418
+ * @param {{steps: Object}} [runContext] - what earlier steps of this cookbook
419
+ * produced (`utils/stepReferences`). A step executed on its own has no
420
+ * earlier steps, so the default is an EMPTY context — the accurate
421
+ * value for that case, not a stand-in for a missing one: every
422
+ * `{{steps.…}}` reference then resolves to nothing and survives as
423
+ * literal text, exactly as the format prescribes.
424
+ * @returns {Promise<Object>} the step result record
370
425
  */
371
- async executeStep(step, testConfig, stepIndex = 0, cookbookDefaults = null) {
426
+ async executeStep(step, testConfig, stepIndex = 0, cookbookDefaults = null, runContext = createRunContext()) {
372
427
  const startTime = Date.now();
373
428
 
374
429
  // ONE label for this step, resolved once. `step.id` alone was undefined for
@@ -380,6 +435,9 @@ class CookbookTestRunner {
380
435
  this.logger.info(`Executing step: ${stepLabel} (${step.operation})`);
381
436
 
382
437
  const result = {
438
+ // The other half of the pair: this record IS a step, and says so beside
439
+ // the cookbook-load failure that is not (d.516b).
440
+ kind: KIND_STEP,
383
441
  // The identifier the format requires, plus the position, so the aggregate
384
442
  // error list can name the step without re-deriving anything. `id` was
385
443
  // emitted alongside it until 2026-08-30; one thing, one name.
@@ -438,8 +496,16 @@ class CookbookTestRunner {
438
496
  // { modulePath, exportName } (v3 handler dispatch is the only mode).
439
497
  const spec = await this.resolveOperation(step.service, step.operation);
440
498
 
499
+ // WHAT THE HANDLER IS ACTUALLY CALLED WITH. The step's `input` is the
500
+ // cookbook's text; a `{{steps.<step_id>.output.…}}` reference in it is
501
+ // resolved HERE, against what the earlier steps of this cookbook returned.
502
+ // Inside the try, so an evaluator error becomes this step's failure and
503
+ // not the whole run's.
504
+ const input = await resolveStepInput(step, runContext);
505
+
441
506
  await this._dispatchViaHandler({
442
507
  step,
508
+ input,
443
509
  spec,
444
510
  testConfig,
445
511
  validationTenantId,
@@ -540,7 +606,7 @@ class CookbookTestRunner {
540
606
  * all connector slots set to null — handlers that need DB access use
541
607
  * direct sequelize import per biz/60-templates/onboarding-checklist.md §9a.
542
608
  */
543
- async _dispatchViaHandler({ step, spec, testConfig, validationTenantId, validationWorkspaceId, result, startTime }) {
609
+ async _dispatchViaHandler({ step, input, spec, testConfig, validationTenantId, validationWorkspaceId, result, startTime }) {
544
610
  const stepTimeout = testConfig.timeout || this.timeout;
545
611
  const correlationId = step.correlation_id || crypto.randomUUID();
546
612
  // Production ctx (from ContextBuilder) always carries workflow_id + person_id.
@@ -556,6 +622,18 @@ class CookbookTestRunner {
556
622
  correlation_id: correlationId,
557
623
  person_id: personId,
558
624
  operation_name: step.operation,
625
+ // WHICH STEP is running, not merely which operation. Production
626
+ // `OperationContext` carries it among its 16 fields
627
+ // (`@onlineapps/service-wrapper` src/OperationContext.js), and a handler
628
+ // that stores a file cannot do without it:
629
+ // `@onlineapps/conn-orch-content-resolver` keys every stored object on
630
+ // `content/<workflow_id>/<step_id>` and refuses to invent either half.
631
+ // Tier-1 left it out, so a service moving off `operation_name` onto
632
+ // `ctx.step_id` passed in production and died in ServiceWrapper phase 0.2
633
+ // (BIZ-pdfgen, measured 2026-09-16 against 9.0.0 and HEAD). It is per
634
+ // STEP on purpose: one cookbook may call the same operation twice, and
635
+ // those two runs must not share a storage prefix.
636
+ step_id: step.step_id,
559
637
  service_name: step.service,
560
638
  db: null,
561
639
  cache: null,
@@ -585,13 +663,14 @@ class CookbookTestRunner {
585
663
  operation: step.operation,
586
664
  module: spec.modulePath,
587
665
  export: spec.exportName,
588
- input: step.input,
666
+ input,
589
667
  ctx: {
590
668
  tenant_id: ctx.tenant_id,
591
669
  workspace_id: ctx.workspace_id,
592
670
  workflow_id: ctx.workflow_id,
593
671
  correlation_id: ctx.correlation_id,
594
- person_id: ctx.person_id
672
+ person_id: ctx.person_id,
673
+ step_id: ctx.step_id
595
674
  }
596
675
  };
597
676
  result.request = request;
@@ -621,7 +700,7 @@ class CookbookTestRunner {
621
700
  let handlerOutput = null;
622
701
  try {
623
702
  handlerOutput = await Promise.race([
624
- Promise.resolve().then(() => handlerFn(step.input || {}, ctx)),
703
+ Promise.resolve().then(() => handlerFn(input || {}, ctx)),
625
704
  timeoutPromise
626
705
  ]);
627
706
  } catch (error) {
@@ -1041,6 +1120,22 @@ class CookbookTestRunner {
1041
1120
  throw new Error(`[CookbookTestRunner] ${versionProblem}`);
1042
1121
  }
1043
1122
 
1123
+ // The retired option, in the other place a caller could still write it. The
1124
+ // cookbook schema admits additional properties inside `test`, so nothing
1125
+ // refuses the key at load time — and a key nobody reads taught every reader
1126
+ // that a cookbook chooses its own infrastructure, which it never did
1127
+ // (`change-discipline.md` § Removing something removes its declaration).
1128
+ // Refused BY NAME rather than dropped in silence, for the reason the
1129
+ // constructor refuses it one screen up.
1130
+ if (cookbook.test && typeof cookbook.test === 'object'
1131
+ && cookbook.test.mockInfrastructure !== undefined) {
1132
+ throw new Error('[CookbookTestRunner] Cookbook declares test.mockInfrastructure - the option is '
1133
+ + 'retired; a cookbook never chose its own infrastructure and the runner dispatches '
1134
+ + 'in-process against the real service. '
1135
+ + 'Fix: remove "mockInfrastructure" from the cookbook\'s "test" block '
1136
+ + '(api/docs/biz/40-cookbooks/format.md § Optional top-level fields).');
1137
+ }
1138
+
1044
1139
  if (cookbook.steps === undefined || cookbook.steps === null) {
1045
1140
  throw new Error('[CookbookTestRunner] Cookbook has no steps - Expected "steps": [ … ] with at '
1046
1141
  + 'least one step object carrying "step_id". '
@@ -1055,18 +1150,35 @@ class CookbookTestRunner {
1055
1150
  + 'steps array. Fix: add at least one step (api/docs/biz/40-cookbooks/format.md § Required fields).');
1056
1151
  }
1057
1152
 
1058
- for (const step of stepsArray) {
1153
+ // The three fields `@onlineapps/cookbook-core` 5.0.0 requires of a task step
1154
+ // beyond `type` (`schemas/cookbook.v2.schema.json`
1155
+ // definitions.TaskStep.required). `step_id` comes FIRST, because it is what
1156
+ // the other two messages name the step by: until d.516b this guard checked
1157
+ // only the last two and wrote `Step ${step.step_id || 'unknown'}`, so a
1158
+ // cookbook missing the field the whole format addresses steps by was
1159
+ // accepted here and its diagnostics said `Step unknown`. A stand-in name is
1160
+ // the fallback `.claude/rules/architecture-principles.md` §3 forbids; with
1161
+ // the field required, the case it stood in for cannot occur.
1162
+ stepsArray.forEach((step, index) => {
1163
+ const position = index + 1;
1164
+ if (typeof step.step_id !== 'string' || step.step_id.length === 0) {
1165
+ throw new Error(`[CookbookTestRunner] Step #${position} has no step_id - every step of a v2.1 `
1166
+ + 'cookbook is addressed by its own step_id (@onlineapps/cookbook-core '
1167
+ + 'schemas/cookbook.v2.schema.json definitions.TaskStep.required). '
1168
+ + `Fix: add "step_id" to step #${position} `
1169
+ + '(api/docs/biz/40-cookbooks/format.md § Required fields).');
1170
+ }
1059
1171
  if (!step.service) {
1060
- throw new Error(`[CookbookTestRunner] Step ${step.step_id || 'unknown'} must have service - `
1172
+ throw new Error(`[CookbookTestRunner] Step ${step.step_id} must have service - `
1061
1173
  + 'Expected the name of the service that runs the step. Fix: add "service" to that step '
1062
1174
  + '(api/docs/biz/40-cookbooks/format.md § Step definition).');
1063
1175
  }
1064
1176
  if (!step.operation) {
1065
- throw new Error(`[CookbookTestRunner] Step ${step.step_id || 'unknown'} must have operation - `
1177
+ throw new Error(`[CookbookTestRunner] Step ${step.step_id} must have operation - `
1066
1178
  + 'Expected the operation that service exposes. Fix: add "operation" to that step '
1067
1179
  + '(api/docs/biz/40-cookbooks/format.md § Step definition).');
1068
1180
  }
1069
- }
1181
+ });
1070
1182
 
1071
1183
  return true;
1072
1184
  }