@onlineapps/conn-orch-validator 12.1.1 → 13.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 (59) hide show
  1. package/CHANGELOG.md +607 -0
  2. package/README.md +126 -19
  3. package/manifests/biz-service.manifest.json +15 -2
  4. package/manifests/library.manifest.json +4 -4
  5. package/package.json +11 -3
  6. package/src/CookbookTestRunner.js +275 -105
  7. package/src/CookbookTestUtils.js +79 -68
  8. package/src/ServiceReadinessValidator.js +42 -52
  9. package/src/ValidationOrchestrator.js +65 -44
  10. package/src/cli/biz-ci-gate.js +2 -2
  11. package/src/cli/oa-sync-template.js +97 -47
  12. package/src/cli/oa-validate.js +44 -10
  13. package/src/helpers/README.md +6 -6
  14. package/src/helpers/createServiceReadinessTests.js +87 -33
  15. package/src/index.js +14 -5
  16. package/src/lint/scripts/lintScripts.js +11 -4
  17. package/src/manifest/checks/libraryContext.js +6 -3
  18. package/src/manifest/checks/libraryDocs.js +174 -4
  19. package/src/manifest/checks/libraryTests.js +200 -19
  20. package/src/manifest/checks/scriptHeaders.js +6 -13
  21. package/src/manifest/checks/serviceConfig.js +36 -16
  22. package/src/manifest/checks/serviceConnectors.js +180 -2
  23. package/src/manifest/checks/serviceDb.js +0 -3
  24. package/src/manifest/checks/serviceScripts.js +3 -20
  25. package/src/manifest/runManifest.js +90 -13
  26. package/src/manifest/workspaceRoot.js +133 -4
  27. package/src/mocks/MockMQClient.js +2 -2
  28. package/src/sync/docsRegion.js +2 -2
  29. package/src/sync/readmeFile.js +30 -0
  30. package/src/sync/readmeLocation.js +2 -12
  31. package/src/sync/readmePointer.js +10 -4
  32. package/src/sync/serviceTemplate.js +9 -11
  33. package/src/sync/sharedEnv.js +59 -3
  34. package/src/sync/uniformFiles.js +81 -8
  35. package/src/utils/bizCiGateContract.js +2 -2
  36. package/src/utils/connectorContract.js +54 -2
  37. package/src/utils/cookbookFormat.js +25 -115
  38. package/src/utils/dbAccountGrants.js +5 -3
  39. package/src/utils/deployContract.js +153 -28
  40. package/src/utils/envContract.js +2 -2
  41. package/src/utils/handlerRef.js +8 -10
  42. package/src/utils/integrationRun.js +1 -1
  43. package/src/utils/operationsDocumentRules.js +242 -0
  44. package/src/utils/operationsRules.js +157 -0
  45. package/src/utils/resolveHeaders.js +12 -1
  46. package/src/utils/setupDatabase.js +1 -1
  47. package/src/utils/stepFailure.js +3 -3
  48. package/src/utils/stepReferences.js +28 -87
  49. package/src/utils/throwawaySchema.js +1 -1
  50. package/src/utils/yamlTopLevel.js +105 -0
  51. package/src/validators/ServiceStructureValidator.js +67 -152
  52. package/templates/business-service/.gitlab-ci.yml +203 -37
  53. package/templates/business-service/README.md +7 -4
  54. package/templates/business-service/config/env-templates/shared.env +1 -0
  55. package/templates/business-service/docs/80-setup/INSTALL.md +31 -3
  56. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +5 -2
  57. package/templates/business-service/src/config/index.js +15 -0
  58. package/TESTING_STRATEGY.md +0 -92
  59. package/jest.config.js +0 -37
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The rules about the `config/service/operations.json` DOCUMENT — one
5
+ * definition, asked by every rail, so one question gets one answer.
6
+ *
7
+ * Three rules live here:
8
+ *
9
+ * 1. the document declares `schema_version: "3.0"` — the version of the
10
+ * operation schema it is written in;
11
+ * 2. the map it wraps declares at least one operation — a dispatch table with
12
+ * nothing in it is a service with no reason to boot;
13
+ * 3. each declaration is complete enough for a cookbook to be generated from
14
+ * it — `input` and `output` (errors), `description` (a warning).
15
+ *
16
+ * ## Why these are NOT in `@onlineapps/service-validator-core`
17
+ *
18
+ * Because the Registry never sees this document. Registration sends
19
+ * `doc.operations` alone — the map, not the file — so `schema_version` and a
20
+ * sibling key of it never reach the Registry, and the Registry has no rule
21
+ * about either. The core package holds the MIRROR of the Registry's rules
22
+ * (owner decision `api/docs/governance/confirmations/operations-schema-mirror.md`
23
+ * 001), and its own header promises it "adds no rule and drops no rule".
24
+ * Putting a rule the Registry does not have into that mirror would make the
25
+ * promise false and the mirror unreadable as a mirror.
26
+ *
27
+ * Rule 3 is the same case seen from the other side: the Registry generates no
28
+ * cookbook, so it needs none of `input`, `output`, `description`; the two rails
29
+ * that DO generate one cannot boot a service without them.
30
+ *
31
+ * These three rules belong to the tool that reads REPOSITORIES — this package —
32
+ * and their home here is the owner-approved answer, not a waypoint: do not move
33
+ * them into the core because they look like they belong beside the per-operation
34
+ * rules. `src/utils/operationsRules.js` is the adapter to those; this module is
35
+ * the owner of these. Lead decision 2026-09-20 (batch d.465b), on the measured
36
+ * alternative of putting them in the core, which no rail could have called.
37
+ *
38
+ * ## What it replaced (measured 2026-09-20)
39
+ *
40
+ * An empty operations map had FOUR answers: an error
41
+ * (`ServiceReadinessValidator`), a warning (`ServiceStructureValidator`
42
+ * `NO_OPERATIONS`), silence — a clean PASS — (`ValidationOrchestrator` step 4)
43
+ * and a finding (the uniform row `C-OPS`). A missing `schema_version` had none:
44
+ * `ServiceStructureValidator` compared the value only `if` it was there.
45
+ * Now every rail asks this module, and the answer is an error everywhere — the
46
+ * stricter of the four, because a service that dispatches nothing cannot serve
47
+ * a single cookbook step.
48
+ *
49
+ * Layer: L3 (Orchestration). No filesystem, no environment, no dependencies.
50
+ *
51
+ * @see api/docs/biz/30-operations/schema-v3.md § File shape
52
+ * @see api/docs/governance/confirmations/operations-schema-mirror.md
53
+ */
54
+
55
+ /** The one document these rules are about, named the same way in every finding. */
56
+ const OPERATIONS_FILE = 'config/service/operations.json';
57
+
58
+ /** The only operation-schema version this platform dispatches. */
59
+ const OPERATIONS_SCHEMA_VERSION = '3.0';
60
+
61
+ /** How every finding of this module says what to do about it. */
62
+ const SET_VERSION_FIX = `set "schema_version": "${OPERATIONS_SCHEMA_VERSION}" in ${OPERATIONS_FILE}`;
63
+
64
+ /** The two severities a finding of this module carries, named once. */
65
+ const SEVERITY_ERROR = 'error';
66
+ const SEVERITY_WARNING = 'warning';
67
+
68
+ /**
69
+ * Whether a value is a plain object — the shape both rules presuppose.
70
+ *
71
+ * @param {*} value
72
+ * @returns {boolean}
73
+ */
74
+ function isPlainObject(value) {
75
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
76
+ }
77
+
78
+ /**
79
+ * Rule 2, on the map alone — for the rail that never holds the document.
80
+ *
81
+ * A map that is missing, null or not a plain object is NOT this rule's business:
82
+ * that one the Registry does have, so `utils/operationsRules.js` answers it and
83
+ * this module stays silent rather than giving the same defect a second voice.
84
+ *
85
+ * @param {*} operations - the `operations` map
86
+ * @returns {Array<{field: string, what: string, fix: string}>}
87
+ */
88
+ function operationsMapFindings(operations) {
89
+ if (!isPlainObject(operations)) return [];
90
+ if (Object.keys(operations).length > 0) return [];
91
+
92
+ return [{
93
+ field: 'operations',
94
+ what: 'declares no operations — a service that dispatches nothing has no reason to boot',
95
+ fix: `declare at least one operation in ${OPERATIONS_FILE}`,
96
+ severity: SEVERITY_ERROR
97
+ }];
98
+ }
99
+
100
+ /**
101
+ * Both rules, on the parsed document — for every rail that holds the file.
102
+ *
103
+ * @param {*} document - parsed contents of `config/service/operations.json`
104
+ * @returns {Array<{field: string, what: string, fix: string}>}
105
+ */
106
+ function operationsDocumentFindings(document) {
107
+ const findings = [];
108
+ const declared = isPlainObject(document) ? document.schema_version : undefined;
109
+
110
+ if (declared === undefined || declared === null) {
111
+ findings.push({
112
+ field: 'schema_version',
113
+ what: 'absent — the document does not say which operation schema it is written in, '
114
+ + 'so nothing can tell a v3 declaration from an older one',
115
+ fix: SET_VERSION_FIX,
116
+ severity: SEVERITY_ERROR
117
+ });
118
+ } else if (declared !== OPERATIONS_SCHEMA_VERSION) {
119
+ findings.push({
120
+ field: 'schema_version',
121
+ what: `is ${JSON.stringify(declared)} — this platform dispatches operation schema `
122
+ + `${OPERATIONS_SCHEMA_VERSION} and no other`,
123
+ fix: SET_VERSION_FIX,
124
+ severity: SEVERITY_ERROR
125
+ });
126
+ }
127
+
128
+ findings.push(...operationsMapFindings(isPlainObject(document) ? document.operations : undefined));
129
+ return findings;
130
+ }
131
+
132
+ /**
133
+ * Rule 3 — is each declaration complete enough for a cookbook to be generated
134
+ * from it?
135
+ *
136
+ * This is a document rule for the same reason as the other two: the Registry
137
+ * has none of it. It never generates a cookbook, so it never needs `input`,
138
+ * `output` or `description`; the two rails that DO generate one —
139
+ * `ValidationOrchestrator` step 4 and the readiness score — need all three.
140
+ *
141
+ * The severity is not a new decision. Both rails already answered `input` and
142
+ * `output` with an error and `description` with a warning, and agreed
143
+ * (measured d.465c); what they disagreed about was every sentence — `Operation
144
+ * x: missing input schema` against `Operation 'x' missing input schema`. One
145
+ * rule stated in two texts is still two rails, because the next edit moves one
146
+ * of them and nothing notices.
147
+ *
148
+ * A value that is not an object is not this rule's business: the Registry owns
149
+ * that one, and `utils/operationsRules.js` reports it.
150
+ *
151
+ * @param {*} operations - the `operations` map
152
+ * @returns {Array<{operation: string, field: string, what: string, fix: string, severity: string}>}
153
+ */
154
+ function operationsDeclarationFindings(operations) {
155
+ if (!isPlainObject(operations)) return [];
156
+
157
+ const findings = [];
158
+ for (const [name, operation] of Object.entries(operations)) {
159
+ if (!isPlainObject(operation)) continue;
160
+
161
+ for (const field of ['input', 'output']) {
162
+ if (operation[field]) continue;
163
+ findings.push({
164
+ operation: name,
165
+ field,
166
+ what: `no ${field} schema — a cookbook step generated from this declaration `
167
+ + `has nothing to shape its ${field} by`,
168
+ fix: `add "${field}" (a JSON Schema object) to the operation in ${OPERATIONS_FILE}`,
169
+ severity: SEVERITY_ERROR
170
+ });
171
+ }
172
+
173
+ if (!operation.description) {
174
+ findings.push({
175
+ operation: name,
176
+ field: 'description',
177
+ what: 'no description — the operation catalogue and the generated cookbook step '
178
+ + 'have nothing to call it',
179
+ fix: `add "description" to the operation in ${OPERATIONS_FILE}`,
180
+ severity: SEVERITY_WARNING
181
+ });
182
+ }
183
+ }
184
+
185
+ return findings;
186
+ }
187
+
188
+ /**
189
+ * The findings of one severity, in declaration order.
190
+ *
191
+ * @param {Array<{severity: string}>} findings
192
+ * @param {string} severity - `SEVERITY_ERROR` or `SEVERITY_WARNING`
193
+ * @returns {Array<object>}
194
+ */
195
+ function ofSeverity(findings, severity) {
196
+ return findings.filter((finding) => finding.severity === severity);
197
+ }
198
+
199
+ /**
200
+ * One document finding as a single sentence, for a rail that collects strings.
201
+ *
202
+ * The `Fix:` sentence is part of it: a rail reporting strings has nowhere else
203
+ * to carry the remedy (`architecture-principles.md` §5).
204
+ *
205
+ * @param {{field: string, what: string, fix: string}} finding
206
+ * @returns {string}
207
+ */
208
+ function documentFindingSentence(finding) {
209
+ const subject = finding.operation === undefined
210
+ ? `${OPERATIONS_FILE} —`
211
+ : `${OPERATIONS_FILE} — operation '${finding.operation}',`;
212
+ return `${subject} ${finding.field}: ${finding.what}. Fix: ${finding.fix}.`;
213
+ }
214
+
215
+ /**
216
+ * One document finding as a record, for a rail that collects objects.
217
+ *
218
+ * @param {{field: string, what: string, fix: string}} finding
219
+ * @returns {{type: string, path: string, field: string, message: string, fix: string}}
220
+ */
221
+ function documentFindingRecord(finding) {
222
+ return {
223
+ type: 'INVALID_OPERATIONS_DOCUMENT',
224
+ path: OPERATIONS_FILE,
225
+ field: finding.field,
226
+ message: `${OPERATIONS_FILE} — ${finding.field}: ${finding.what}`,
227
+ fix: finding.fix
228
+ };
229
+ }
230
+
231
+ module.exports = {
232
+ OPERATIONS_FILE,
233
+ OPERATIONS_SCHEMA_VERSION,
234
+ SEVERITY_ERROR,
235
+ SEVERITY_WARNING,
236
+ ofSeverity,
237
+ operationsDeclarationFindings,
238
+ operationsMapFindings,
239
+ operationsDocumentFindings,
240
+ documentFindingSentence,
241
+ documentFindingRecord
242
+ };
@@ -0,0 +1,157 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The per-operation rules of `config/service/operations.json`, read from the one
5
+ * place that owns them, and rendered in the two shapes the rails of this package
6
+ * report in.
7
+ *
8
+ * The rules themselves are NOT here and must never be: they are the rules the
9
+ * Registry applies when a service registers, and the owner decided where they
10
+ * live — `@onlineapps/service-validator-core`, `validateOperationsSchema`
11
+ * (`api/docs/governance/confirmations/operations-schema-mirror.md` 001, which
12
+ * rejected a copy in this package in so many words). This module is the adapter
13
+ * that lets a rail here ask that one question and print the answer in its own
14
+ * voice.
15
+ *
16
+ * What it replaced (measured 2026-09-20, d.465): three rails restated the rules
17
+ * from memory — `ValidationOrchestrator.validateOperations` (boot step 4),
18
+ * `ServiceStructureValidator.validateOperation` (boot step 1) and
19
+ * `ServiceReadinessValidator.checkOperationsCompliance` (the readiness score),
20
+ * with a fourth copy in the jest suite `createServiceReadinessTests` generates
21
+ * for every biz repository. All four knew `handler`, `bundle_scope` and the
22
+ * retired v2 fields; NONE of them knew `mutates`, `resource_type` or the
23
+ * kebab-case key rule, which are the three the Registry refuses a registration
24
+ * on. An operation violating them therefore passed every local check and
25
+ * surfaced as a crash loop after restart — the defect the confirmation exists to
26
+ * remove (`change-discipline.md` § One rail per concern).
27
+ *
28
+ * Layer: L3 (Orchestration). No filesystem, no environment.
29
+ *
30
+ * @see api/docs/governance/confirmations/operations-schema-mirror.md
31
+ * @see api/docs/biz/30-operations/schema-v3.md
32
+ */
33
+
34
+ /**
35
+ * The owning definition, required at CALL time rather than at load time.
36
+ *
37
+ * `oa-validate` is started from a copy of this engine's `src/` tree — that is
38
+ * how an installed package runs, and how three acceptance suites plant one —
39
+ * and the engine's own guards (no `package.json`, no workspace root) speak
40
+ * before any row runs. A top-level `require` of a sibling package here loads
41
+ * before those guards get a chance, so a copy with nothing installed beside it
42
+ * died with `MODULE_NOT_FOUND` and a stack instead of the one actionable
43
+ * sentence `architecture-principles.md` §5 demands
44
+ * (`tests/unit/oaValidateCli.integration.test.js` § an engine copy carrying no
45
+ * package.json). Asking at call time keeps the dependency exactly as hard as it
46
+ * was — the row cannot answer without it — while leaving the order of the
47
+ * engine's messages intact. The package's own `index.js` defers for the same
48
+ * reason.
49
+ *
50
+ * @returns {Function} `validateOperationsSchema` of `@onlineapps/service-validator-core`
51
+ */
52
+ function owningDefinition() {
53
+ return require('@onlineapps/service-validator-core').validateOperationsSchema;
54
+ }
55
+
56
+ /** The one document these rules are about, named the same way in every finding. */
57
+ const OPERATIONS_FILE = 'config/service/operations.json';
58
+
59
+ /**
60
+ * Split a mirror reason into the problem and the fix it already carries.
61
+ *
62
+ * Several of the Registry's reasons end in a `Fix:` sentence and the rest do
63
+ * not. A rail that reports `{ message, fix }` needs both halves, so the halves
64
+ * are taken from the reason where it has them, and composed from the field it
65
+ * names where it does not. Nothing is invented about the RULE either way — the
66
+ * sentence that states the rule is always the Registry's own.
67
+ *
68
+ * @param {string} reason - `reason` of one mirror finding
69
+ * @returns {{ what: string, fix: string|null }}
70
+ */
71
+ function splitReason(reason) {
72
+ const marker = reason.indexOf('Fix:');
73
+ if (marker === -1) return { what: reason.trim(), fix: null };
74
+
75
+ return {
76
+ what: reason.slice(0, marker).replace(/\s*[-–—]\s*$/, '').trim(),
77
+ fix: reason.slice(marker + 'Fix:'.length).trim()
78
+ };
79
+ }
80
+
81
+ /**
82
+ * One mirror finding as a single sentence, for a rail that collects strings.
83
+ *
84
+ * @param {{operation: string, field: string, reason: string}} finding
85
+ * @returns {string}
86
+ */
87
+ function findingSentence(finding) {
88
+ if (finding.operation === '(root)') {
89
+ return `${OPERATIONS_FILE}: ${finding.reason}`;
90
+ }
91
+ return `Operation '${finding.operation}' — ${finding.field}: ${finding.reason}`;
92
+ }
93
+
94
+ /**
95
+ * One mirror finding as a record, for a rail that collects objects.
96
+ *
97
+ * `type` is the Registry's own error code for the whole verdict, not a taxonomy
98
+ * of this package: a caller reading `INVALID_OPERATIONS_SCHEMA` here and in the
99
+ * rejection message is reading the same word about the same rule.
100
+ *
101
+ * @param {{operation: string, field: string, reason: string}} finding
102
+ * @param {string} errorCode - `errorCode` of the verdict the finding came from
103
+ * @returns {{type: string, path: string, operation: string, field: string, message: string, fix: string}}
104
+ */
105
+ function findingRecord(finding, errorCode) {
106
+ const { what, fix } = splitReason(finding.reason);
107
+
108
+ return {
109
+ type: errorCode,
110
+ path: OPERATIONS_FILE,
111
+ operation: finding.operation,
112
+ field: finding.field,
113
+ message: `Operation "${finding.operation}" — ${finding.field}: ${what}`,
114
+ fix: fix === null
115
+ ? `Correct "${finding.field}" of operation "${finding.operation}" in ${OPERATIONS_FILE} `
116
+ + '— see api/docs/biz/30-operations/schema-v3.md.'
117
+ : fix
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Ask the owning definition, and get the answer as sentences.
123
+ *
124
+ * @param {object|null|undefined} operations - the `operations` map
125
+ * @returns {{ valid: boolean, errorCode: string|null, sentences: string[] }}
126
+ */
127
+ function operationsRuleSentences(operations) {
128
+ const verdict = owningDefinition()(operations);
129
+ return {
130
+ valid: verdict.valid,
131
+ errorCode: verdict.errorCode,
132
+ sentences: verdict.errors.map(findingSentence)
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Ask the owning definition, and get the answer as records.
138
+ *
139
+ * @param {object|null|undefined} operations - the `operations` map
140
+ * @returns {{ valid: boolean, errorCode: string|null, records: object[] }}
141
+ */
142
+ function operationsRuleRecords(operations) {
143
+ const verdict = owningDefinition()(operations);
144
+ return {
145
+ valid: verdict.valid,
146
+ errorCode: verdict.errorCode,
147
+ records: verdict.errors.map((finding) => findingRecord(finding, verdict.errorCode))
148
+ };
149
+ }
150
+
151
+ module.exports = {
152
+ OPERATIONS_FILE,
153
+ findingSentence,
154
+ findingRecord,
155
+ operationsRuleSentences,
156
+ operationsRuleRecords
157
+ };
@@ -26,7 +26,18 @@ function resolveHeaders(headers, options = {}) {
26
26
  const resolvedValue = valueStr.replace(/\$\{([A-Z0-9_]+)\}/g, (_match, varName) => {
27
27
  const envValue = env[varName];
28
28
  if (envValue === undefined || envValue === null || String(envValue).trim() === '') {
29
- throw new Error(`[conn-orch-validator][Headers] Missing environment variable - Expected ${varName} for header ${key}`);
29
+ // The `Fix:` half is what `architecture-principles.md` §5 asks of every
30
+ // config error, in the wording
31
+ // `api/docs/standards/ARCHITECTURE_PRINCIPLES.md` § Config Contract
32
+ // Exceptions owns: a key whose schema declares no owning env file is
33
+ // sent to `env-active/*.env`, and a location nobody declared is never
34
+ // invented. A header placeholder has no schema, so that is the location
35
+ // it gets — plus the other way out this rail really has, a literal
36
+ // value in the cookbook step.
37
+ throw new Error(
38
+ `[conn-orch-validator][Headers] Missing environment variable - Expected ${varName} for header ${key}. `
39
+ + `Fix: set ${varName} in env-active/*.env, or replace the placeholder with a literal value in the cookbook step's headers.`
40
+ );
30
41
  }
31
42
  return String(envValue);
32
43
  });
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * The service declares WHAT its database is in integration-contract.json; this
7
7
  * decides HOW it gets built, identically for all of them
8
- * (docs/biz/00-model/uniformity-principle.md).
8
+ * (api/docs/biz/00-model/uniformity-principle.md).
9
9
  *
10
10
  * It replaces six per-repo ci-setup-db.js scripts that had each solved the same
11
11
  * problem differently — 113, 69, 56, 54, 22 and 20 lines, in three strategies,
@@ -52,8 +52,8 @@ const KIND_COOKBOOK_LOAD_FAILURE = 'cookbook-load-failure';
52
52
  * alternative on the day was `undefined`. It is the wrong answer now. Since
53
53
  * `@onlineapps/cookbook-core` 5.0.0 the schema requires `step_id` on every task
54
54
  * step (`schemas/cookbook.v2.schema.json` definitions.TaskStep.required lists
55
- * step_id, type, service, operation), `readCookbookSteps` refuses a step
56
- * spelling it `id`, `CookbookTestRunner.validateCookbook` refuses a cookbook
55
+ * step_id, type, service, operation) and forbids a step key `id` (d.983),
56
+ * `CookbookTestRunner.validateCookbook` refuses a cookbook
57
57
  * whose step omits it, and the orchestrator will not run a task step that fails
58
58
  * to name its operation either (`@onlineapps/conn-orch-orchestrator`
59
59
  * § _requireStepOperation, d.460). So the only step that can still arrive here
@@ -78,7 +78,7 @@ function describeStepIdentity(step, index) {
78
78
  throw new Error('[stepFailure] step is required - Expected the cookbook step (or its result) to name in a message');
79
79
  }
80
80
 
81
- // `step_id` only. `readCookbookSteps` refuses a step spelling it `id`, so a
81
+ // `step_id` only. cookbook-core refuses a step spelling it `id` (d.983), so a
82
82
  // step reaching here can no longer carry the retired name.
83
83
  if (typeof step.step_id === 'string' && step.step_id.length > 0) return step.step_id;
84
84
 
@@ -82,33 +82,29 @@ function resolveStepInput(step, runContext) {
82
82
  }
83
83
 
84
84
  /**
85
- * WHAT A TIER-1 RECIPE MAY WRITE IN `input`, checked before the run starts.
85
+ * WHAT A TIER-1 RECIPE MAY WRITE IN `input`, checked before the run starts —
86
+ * the ONE rule of that kind the format's owner does not hold.
86
87
  *
87
88
  * Tier-1 is the service's own startup proof, so a recipe it passes must be a
88
- * recipe production would accept. Two shapes broke that symmetry, both by going
89
- * QUIET where production is loud:
90
- *
91
- * 1. a helper call — `{{webalizeString(…)}}`, `{{string2file(…)}}`. The
92
- * orchestrator resolves it through its helper registry and throws on a
93
- * name the registry does not carry
94
- * (`WorkflowOrchestrator._executeTemplateHelper`). This runner has no
95
- * registry — deliberately: a helper reaches its own runtime (ContentResolver,
96
- * files, storage), which is not what a boot probe may do. So the expression
97
- * resolved to nothing and survived as literal text, and the step ran with
98
- * `{{webalizeString(…)}}` where a value belonged.
99
- * 2. a `{{steps.<id>}}` naming a step the recipe does not define. Production
100
- * refuses such a cookbook at submission with 400 `Invalid cookbook
101
- * references` (`infra/api_gateway/shared/cookbookReferences.js`,
102
- * `api/docs/governance/confirmations/cookbook-validation-placement.md` 001
103
- * — the receiver's own throw is the safety net for the paths that bypass
104
- * the gateway, and a Tier-1 boot run is exactly such a path). Here it was a
105
- * literal too.
106
- *
107
- * The literal outcome is right for a reference whose VALUE is missing at that
108
- * moment — a step that has not run, or one that failed
109
- * (`variable-references.md` § Unresolved references), and those two stay literal
110
- * below. It is wrong for a name that does not exist in the recipe at all: no
111
- * run can ever make it resolve.
89
+ * recipe production would accept. A helper call — `{{webalizeString(…)}}`,
90
+ * `{{string2file(…)}}` — broke that symmetry by going QUIET where production is
91
+ * loud: the orchestrator resolves it through its helper registry and throws on
92
+ * a name the registry does not carry
93
+ * (`WorkflowOrchestrator._executeTemplateHelper`). This runner has no registry —
94
+ * deliberately: a helper reaches its own runtime (ContentResolver, files,
95
+ * storage), which is not what a boot probe may do. So the expression resolved
96
+ * to nothing and survived as literal text, and the step ran with
97
+ * `{{webalizeString(…)}}` where a value belonged. `@onlineapps/cookbook-core`
98
+ * accepts a helper call — it is valid in a recipe production runs — so the
99
+ * refusal is Tier-1's own and lives here.
100
+ *
101
+ * WHAT IS NOT HERE ANY MORE. A `{{steps.<id>}}` naming no step of the recipe,
102
+ * a positional `steps.0` / `steps[0]`, and a `depends_on` naming no step are
103
+ * refused by `@onlineapps/cookbook-core` `validateCookbook`, which
104
+ * `CookbookTestRunner.validateCookbook` runs FIRST. This module held its own
105
+ * copy of the first and the last with its own messages until d.980 — one
106
+ * concern on two rails (`.claude/rules/change-discipline.md` § One rail per
107
+ * concern).
112
108
  *
113
109
  * NO SECOND WALK. The traversal and the expression extraction are the same
114
110
  * `resolveReferencesWith()` that resolves the input for real one moment later,
@@ -127,8 +123,7 @@ function resolveStepInput(step, runContext) {
127
123
  * another package's manifest.
128
124
  *
129
125
  * SCOPE. `input` only, which is the single expansion site a Tier-1 cookbook has
130
- * (`resolveStepInput` above says why), plus `depends_on`, whose values are step
131
- * names by definition.
126
+ * (`resolveStepInput` above says why).
132
127
  *
133
128
  * @see api/docs/biz/40-cookbooks/variable-references.md
134
129
  * @see api/docs/governance/confirmations/cookbook-validation-placement.md
@@ -137,9 +132,6 @@ function resolveStepInput(step, runContext) {
137
132
  /** `helperName(...)` — the shape `WorkflowOrchestrator` routes to its registry. */
138
133
  const HELPER_CALL = /^([a-zA-Z_][a-zA-Z0-9_]*)\(([\s\S]*)\)$/;
139
134
 
140
- /** Root of the step namespace; the only root whose names a recipe can be checked against. */
141
- const STEPS_ROOT = 'steps';
142
-
143
135
  function helperCallProblem(stepId, helperName, expression) {
144
136
  return `Step ${stepId} input calls the template helper "${helperName}" - `
145
137
  + `Tier-1 runs no helper registry, so "{{${expression}}}" would reach the handler as literal `
@@ -149,76 +141,25 @@ function helperCallProblem(stepId, helperName, expression) {
149
141
  + 'earlier steps (api/docs/biz/40-cookbooks/variable-references.md).';
150
142
  }
151
143
 
152
- function undefinedStepProblem(stepId, referencedId, expression, knownIds) {
153
- return `Step ${stepId} references an undefined step - "{{${expression}}}" names "${referencedId}", `
154
- + 'which is not a step_id of this cookbook. '
155
- + `Expected one of: ${knownIds.join(', ')}. `
156
- + 'Fix: correct the step_id in the reference, or add the missing step '
157
- + '(api/docs/biz/40-cookbooks/variable-references.md § Unresolved references).';
158
- }
159
-
160
- function undefinedDependencyProblem(stepId, dependencyId, knownIds) {
161
- return `Step ${stepId} depends on an undefined step - "depends_on" names "${dependencyId}", `
162
- + 'which is not a step_id of this cookbook. '
163
- + `Expected one of: ${knownIds.join(', ')}. `
164
- + 'Fix: correct the step_id in "depends_on", or add the missing step '
165
- + '(api/docs/biz/40-cookbooks/format.md § Step definition).';
166
- }
167
-
168
- /**
169
- * What ONE expression is, measured against the names this cookbook defines.
170
- *
171
- * A path whose root is not `steps` — `api_input.…`, `context.…`, `current.…` —
172
- * is left alone: its value comes into being at run time and a recipe says
173
- * nothing about whether it will. The BRACKET form `steps[0]` is not a step
174
- * reference either, in this runner or at the gateway; the dotted `steps.0` is,
175
- * and it names a step no recipe can define.
176
- *
177
- * @returns {string|null} the problem, or `null` when the expression is fine
178
- */
179
- function checkExpression(expression, stepId, known, knownIds) {
180
- const helperCall = expression.match(HELPER_CALL);
181
- if (helperCall) {
182
- return helperCallProblem(stepId, helperCall[1], expression);
183
- }
184
-
185
- const parts = expression.split('.');
186
- if (parts[0] !== STEPS_ROOT || parts.length < 2) return null;
187
-
188
- const referencedId = parts[1];
189
- if (known.has(referencedId)) return null;
190
-
191
- return undefinedStepProblem(stepId, referencedId, expression, knownIds);
192
- }
193
-
194
144
  /**
195
145
  * The problem this cookbook's step inputs carry, or `null` when they carry none.
196
146
  *
197
- * Returns the FIRST problem rather than throwing, the way
198
- * `utils/cookbookFormat.checkCookbookFormatVersion` does: the caller owns the
147
+ * Returns the FIRST problem rather than throwing: the caller owns the
199
148
  * `[Context]` of the message it throws, and the caller here is the runner
200
149
  * (`.claude/rules/architecture-principles.md` §5).
201
150
  *
202
- * @param {Array<Object>} steps - the cookbook's steps, already proved to be the array shape
151
+ * @param {Array<Object>} steps - the cookbook's steps, already validated by cookbook-core
203
152
  * @returns {Promise<string|null>} the problem text without its context prefix
204
153
  */
205
154
  async function checkStepInputExpressions(steps) {
206
- const knownIds = steps
207
- .map((step) => step && step.step_id)
208
- .filter((stepId) => typeof stepId === 'string' && stepId.length > 0);
209
- const known = new Set(knownIds);
210
-
211
155
  for (const step of steps) {
212
- const dependencies = Array.isArray(step.depends_on) ? step.depends_on : [];
213
- for (const dependency of dependencies) {
214
- if (typeof dependency !== 'string' || known.has(dependency)) continue;
215
- return undefinedDependencyProblem(step.step_id, dependency, knownIds);
216
- }
217
-
218
156
  let problem = null;
219
157
  await resolveReferencesWith(step.input, (expression) => {
220
158
  if (problem === null) {
221
- problem = checkExpression(expression, step.step_id, known, knownIds);
159
+ const helperCall = expression.match(HELPER_CALL);
160
+ if (helperCall) {
161
+ problem = helperCallProblem(step.step_id, helperCall[1], expression);
162
+ }
222
163
  }
223
164
  return undefined;
224
165
  });
@@ -12,7 +12,7 @@
12
12
  * (`change-discipline.md` § One rail per concern), and the concern here is not
13
13
  * service-specific at all: the service declares WHAT its database is in
14
14
  * `config/service/integration-contract.json`, and HOW a schema gets built from
15
- * that declaration is uniform (`docs/biz/00-model/uniformity-principle.md`).
15
+ * that declaration is uniform (`api/docs/biz/00-model/uniformity-principle.md`).
16
16
  *
17
17
  * WHAT IT SHARES WITH THE CI BUILD. Everything except one decision. The
18
18
  * migration set and its order (`resolveMigrationPlan`, `migrationOrder.js`), the