@onlineapps/conn-orch-validator 10.0.0 → 12.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.
- package/CHANGELOG.md +225 -0
- package/README.md +254 -10
- package/docs/DESIGN.md +11 -2
- package/manifests/biz-service.manifest.json +29 -2
- package/package.json +2 -1
- package/src/CookbookTestRunner.js +50 -6
- package/src/ValidationOrchestrator.js +244 -58
- package/src/cli/biz-ci-gate.js +163 -1
- package/src/cli/oa-validate.js +63 -1
- package/src/manifest/checks/gitTracked.js +5 -30
- package/src/manifest/checks/serviceDb.js +176 -7
- package/src/manifest/checks/serviceRuntime.js +123 -0
- package/src/manifest/discovery.js +42 -3
- package/src/manifest/gitCheckout.js +84 -0
- package/src/utils/dbAccountGrants.js +126 -0
- package/src/utils/deployContract.js +116 -6
- package/src/utils/envContract.js +36 -6
- package/src/utils/envReads.js +102 -0
- package/src/utils/stepReferences.js +278 -0
- package/src/utils/testNamespace.js +30 -3
- package/src/validators/ServiceStructureValidator.js +7 -1
- package/templates/business-service/.gitlab-ci.yml +127 -17
- package/templates/business-service/Dockerfile +49 -16
- package/templates/business-service/README.md +42 -4
- package/templates/business-service/config/env-templates/shared.env +1 -1
- package/templates/business-service/docker-compose.production.yml +9 -0
- package/templates/business-service/docker-compose.yml +17 -0
- package/templates/business-service/scripts/verify-deploy-uniform.sh +3 -2
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which environment names a service's image reads — asked by a deploy gate,
|
|
5
|
+
* answered by the contract (d.533c).
|
|
6
|
+
*
|
|
7
|
+
* ## The defect this ends
|
|
8
|
+
*
|
|
9
|
+
* `api/scripts/validate-env.sh` refuses a deploy when a key in the target's
|
|
10
|
+
* `.env` still says `CHANGE_ME`. Pointed at a biz service it refused on
|
|
11
|
+
* `JWT_SECRET`, a key the gateway reads and no biz image ever opens: the gate
|
|
12
|
+
* was judging the platform's whole key set against one service. The fix is not
|
|
13
|
+
* a list of exceptions in bash — it is the service saying what it reads.
|
|
14
|
+
*
|
|
15
|
+
* ## Why not a grep for `process.env`
|
|
16
|
+
*
|
|
17
|
+
* Because most of the platform's environment is read inside installed
|
|
18
|
+
* libraries: `@onlineapps/service-wrapper` reads `SECRETS_MASTER_KEYS`,
|
|
19
|
+
* `@onlineapps/conn-base-cache` reads `REDIS_URL`. A grep over `src/` sees none
|
|
20
|
+
* of them and would answer a set too small — which in a gate means a key
|
|
21
|
+
* silently unchecked. That blind spot is stated, not discovered:
|
|
22
|
+
* `utils/envContract.js` § Measurability boundary.
|
|
23
|
+
*
|
|
24
|
+
* ## The set is the uniform's, not a second definition
|
|
25
|
+
*
|
|
26
|
+
* The answer is `collectDeclaredEnvNames` — the contract's `env` block, the
|
|
27
|
+
* `${VAR}` placeholders of `config/service/*.json`, and the endpoint variables
|
|
28
|
+
* of the connectors the contract declares required. That is exactly the set row
|
|
29
|
+
* `C-ENV-READS` of the biz-service uniform measures the repository against, so
|
|
30
|
+
* the two can never disagree about what a service reads
|
|
31
|
+
* (`change-discipline.md` § One rail per concern).
|
|
32
|
+
*
|
|
33
|
+
* The consequence is deliberate: a name the SOURCE reads and the contract does
|
|
34
|
+
* not declare is absent from this answer. It is a blocking finding of
|
|
35
|
+
* `C-ENV-READS`, with its own fix, and a gate must not demand a value for a key
|
|
36
|
+
* nobody owns.
|
|
37
|
+
*
|
|
38
|
+
* The messages carry the context `[oa-validate]` because the subcommand is this
|
|
39
|
+
* module's only caller and the message goes to a bash gate reading stderr; there
|
|
40
|
+
* is no wrapper re-wording them, which is how they stay the exact sentence the
|
|
41
|
+
* test asserts (`automation-gates.md` §2).
|
|
42
|
+
*
|
|
43
|
+
* @see api/docs/biz/70-contracts/env-contract.md
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const fs = require('fs');
|
|
47
|
+
const path = require('path');
|
|
48
|
+
|
|
49
|
+
const { loadAndValidateIntegrationContract } = require('./bizCiGateContract');
|
|
50
|
+
const { collectDeclaredEnvNames } = require('./envContract');
|
|
51
|
+
|
|
52
|
+
/** Where every biz service declares what it integrates with. */
|
|
53
|
+
const CONTRACT_RELATIVE_PATH = path.join('config', 'service', 'integration-contract.json');
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every environment name the service at `serviceRoot` reads, as the uniform
|
|
57
|
+
* measures it.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} serviceRoot repository root of the service; the caller
|
|
60
|
+
* resolves its own default, so an empty value is refused rather than silently
|
|
61
|
+
* answered about `process.cwd()`
|
|
62
|
+
* @returns {string[]} the names, sorted, without duplicates
|
|
63
|
+
* @throws {Error} when the root or the contract cannot be read — a gate that
|
|
64
|
+
* cannot get the set must stop, never continue with an empty one
|
|
65
|
+
*/
|
|
66
|
+
function listEnvReads(serviceRoot) {
|
|
67
|
+
if (typeof serviceRoot !== 'string' || serviceRoot.trim() === '') {
|
|
68
|
+
throw new Error('[oa-validate] No service root to answer about - --env-reads was given '
|
|
69
|
+
+ `${typeof serviceRoot === 'string' ? 'an empty path' : typeof serviceRoot}. `
|
|
70
|
+
+ 'Fix: oa-validate --env-reads <serviceRoot>, or run it inside the service repository.');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const root = path.resolve(serviceRoot);
|
|
74
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
75
|
+
throw new Error(`[oa-validate] Service root not found - ${root}. `
|
|
76
|
+
+ 'Fix: pass an existing repository directory, or run the command inside the service repository.');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const contractPath = path.join(root, CONTRACT_RELATIVE_PATH);
|
|
80
|
+
if (!fs.existsSync(contractPath)) {
|
|
81
|
+
throw new Error(`[oa-validate] No environment contract - ${contractPath} does not exist, so nothing `
|
|
82
|
+
+ 'declares which environment names this service reads and the answer would be an empty set. '
|
|
83
|
+
+ 'Fix: add config/service/integration-contract.json with requiredConnectors and, where the '
|
|
84
|
+
+ 'service reads a name no placeholder and no connector covers, an "env" block.');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let contract;
|
|
88
|
+
try {
|
|
89
|
+
contract = loadAndValidateIntegrationContract(root).contract;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new Error(`[oa-validate] Unusable environment contract - ${contractPath}: `
|
|
92
|
+
+ `${String(error.message).split('\n')[0].trim()} `
|
|
93
|
+
+ 'Fix: correct the contract, then run npx oa-validate to see the row that judges it.');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return collectDeclaredEnvNames({ serviceRoot: root, contract }).names;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = {
|
|
100
|
+
CONTRACT_RELATIVE_PATH,
|
|
101
|
+
listEnvReads
|
|
102
|
+
};
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { resolveReferencesWith, resolveReferencePath } = require('@onlineapps/cookbook-core');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The run context a Tier-1 cookbook resolves its `{{…}}` references against.
|
|
7
|
+
*
|
|
8
|
+
* WHY IT EXISTS. Until 2026-09-17 the runner executed every step independently:
|
|
9
|
+
* `stepResults` only counted, and nothing a step returned could reach the next
|
|
10
|
+
* one. A recipe therefore could not build the data it needs, so biz-converter's
|
|
11
|
+
* boot recipe depended on a row only a TEST_ONLY seed supplies — and on
|
|
12
|
+
* production Tier-1 answered `NOT_CONVERTIBLE`, the boot exited 1 after ~18
|
|
13
|
+
* minutes of backoff and the container restarted in a loop (measured
|
|
14
|
+
* 2026-09-16). The owner's decision was that the recipe stays as written and the
|
|
15
|
+
* RUNNER learns what the orchestrator already does
|
|
16
|
+
* (`api/docs/governance/confirmations/converter-boot-cookbook.md` 001).
|
|
17
|
+
*
|
|
18
|
+
* ONE RAIL, AND IT IS NOT THIS PACKAGE'S. The syntax, the type rule for a whole
|
|
19
|
+
* expression, the ban on addressing a step by position and the fate of an
|
|
20
|
+
* unresolved reference all belong to the cookbook format —
|
|
21
|
+
* `api/docs/biz/40-cookbooks/variable-references.md`. The traversal of the value
|
|
22
|
+
* and the walk of one path belong to `@onlineapps/cookbook-core`:
|
|
23
|
+
* `resolveReferencesWith()` and `resolveReferencePath()`, the same two functions
|
|
24
|
+
* `WorkflowOrchestrator._resolveInputReferencesAsync` /
|
|
25
|
+
* `_resolveTemplateExpression` call. This module writes no parser; it supplies
|
|
26
|
+
* the SCOPE, which is the only part Tier-1 owns.
|
|
27
|
+
*
|
|
28
|
+
* THE SCOPE IS THE ORCHESTRATOR'S, SPELLED THE SAME WAY. A completed entry is
|
|
29
|
+
* "the step definition unchanged, plus two added fields: `output` and
|
|
30
|
+
* `_execution`" — the section "Runtime context (`context.steps`)" of
|
|
31
|
+
* api/docs/biz/40-cookbooks/format.md — and a reference addresses it by
|
|
32
|
+
* `step_id`, which is what
|
|
33
|
+
* `WorkflowOrchestrator.referenceScope()` puts under the root `steps` (its
|
|
34
|
+
* `steps_by_id` map). A second spelling here, such as a bare `status` beside
|
|
35
|
+
* `output`, would resolve at boot and stay literal in production: precisely the
|
|
36
|
+
* divergence class that broke the converter's boot.
|
|
37
|
+
*
|
|
38
|
+
* WHAT TIER-1 DELIBERATELY DOES NOT CARRY. The orchestrator's context also holds
|
|
39
|
+
* `api_input`, `delivery` and the tenancy keys. A Tier-1 run has no workflow
|
|
40
|
+
* input and no delivery at all, so those roots are absent and a reference to
|
|
41
|
+
* them stays literal — the norm's documented outcome for an unresolvable
|
|
42
|
+
* reference, not a silent substitution.
|
|
43
|
+
*
|
|
44
|
+
* @see api/docs/biz/40-cookbooks/variable-references.md
|
|
45
|
+
* @see api/docs/biz/40-cookbooks/format.md
|
|
46
|
+
* @module utils/stepReferences
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The context one cookbook run starts from: no step has produced anything yet.
|
|
51
|
+
*
|
|
52
|
+
* It is per COOKBOOK, never per runner — `runCookbooks()` executes many
|
|
53
|
+
* cookbooks through one runner and a step of one must not see a step of
|
|
54
|
+
* another.
|
|
55
|
+
*
|
|
56
|
+
* @returns {{steps: Object}} the scope `resolveStepInput` resolves against
|
|
57
|
+
*/
|
|
58
|
+
function createRunContext() {
|
|
59
|
+
return { steps: {} };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the `{{…}}` references in ONE step's input.
|
|
64
|
+
*
|
|
65
|
+
* `input` is the only field expanded. The format names three expansion sites —
|
|
66
|
+
* `step.input`, a `foreach` iterator and a `switch` expression
|
|
67
|
+
* (`variable-references.md` § Limitations) — and a Tier-1 cookbook has only the
|
|
68
|
+
* first: `CookbookTestRunner.validateCookbook` requires `service` and
|
|
69
|
+
* `operation` of every step, so a `foreach` or `switch` step never reaches this
|
|
70
|
+
* runner. `expect` is not on that list and is therefore taken literally, the
|
|
71
|
+
* same as `service`, `operation` and `depends_on`.
|
|
72
|
+
*
|
|
73
|
+
* @param {Object} step - the cookbook step as written
|
|
74
|
+
* @param {{steps: Object}} runContext - what earlier steps produced
|
|
75
|
+
* @returns {Promise<*>} the input the handler is called with
|
|
76
|
+
*/
|
|
77
|
+
function resolveStepInput(step, runContext) {
|
|
78
|
+
return resolveReferencesWith(
|
|
79
|
+
step.input,
|
|
80
|
+
(expression) => resolveReferencePath(expression, runContext)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* WHAT A TIER-1 RECIPE MAY WRITE IN `input`, checked before the run starts.
|
|
86
|
+
*
|
|
87
|
+
* 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.
|
|
112
|
+
*
|
|
113
|
+
* NO SECOND WALK. The traversal and the expression extraction are the same
|
|
114
|
+
* `resolveReferencesWith()` that resolves the input for real one moment later,
|
|
115
|
+
* so this check sees exactly the expressions the resolver will see. The
|
|
116
|
+
* evaluator returns `undefined` for everything: nothing is substituted here,
|
|
117
|
+
* only read.
|
|
118
|
+
*
|
|
119
|
+
* WHERE THE HELPER-CALL SHAPE OUGHT TO LIVE. `@onlineapps/cookbook-core` owns
|
|
120
|
+
* the format and exports the traversal and the path walk, but no definition of
|
|
121
|
+
* "this expression is a helper call" — the only one on the platform is the
|
|
122
|
+
* private regex in `WorkflowOrchestrator._resolveTemplateExpression`, and this
|
|
123
|
+
* module now holds a second copy of it. That is one concern on two rails
|
|
124
|
+
* (`.claude/rules/change-discipline.md` § One rail per concern) and it belongs
|
|
125
|
+
* beside `resolveReferencePath()` in cookbook-core, where both callers could
|
|
126
|
+
* read it. Recorded for the owner rather than fixed here: cookbook-core is
|
|
127
|
+
* another package's manifest.
|
|
128
|
+
*
|
|
129
|
+
* 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.
|
|
132
|
+
*
|
|
133
|
+
* @see api/docs/biz/40-cookbooks/variable-references.md
|
|
134
|
+
* @see api/docs/governance/confirmations/cookbook-validation-placement.md
|
|
135
|
+
*/
|
|
136
|
+
|
|
137
|
+
/** `helperName(...)` — the shape `WorkflowOrchestrator` routes to its registry. */
|
|
138
|
+
const HELPER_CALL = /^([a-zA-Z_][a-zA-Z0-9_]*)\(([\s\S]*)\)$/;
|
|
139
|
+
|
|
140
|
+
/** Root of the step namespace; the only root whose names a recipe can be checked against. */
|
|
141
|
+
const STEPS_ROOT = 'steps';
|
|
142
|
+
|
|
143
|
+
function helperCallProblem(stepId, helperName, expression) {
|
|
144
|
+
return `Step ${stepId} input calls the template helper "${helperName}" - `
|
|
145
|
+
+ `Tier-1 runs no helper registry, so "{{${expression}}}" would reach the handler as literal `
|
|
146
|
+
+ 'text while the production orchestrator evaluates it (and throws when the helper name is '
|
|
147
|
+
+ 'unknown). '
|
|
148
|
+
+ 'Fix: remove the helper call from the step input; a Tier-1 recipe builds its values from '
|
|
149
|
+
+ 'earlier steps (api/docs/biz/40-cookbooks/variable-references.md).';
|
|
150
|
+
}
|
|
151
|
+
|
|
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
|
+
/**
|
|
195
|
+
* The problem this cookbook's step inputs carry, or `null` when they carry none.
|
|
196
|
+
*
|
|
197
|
+
* Returns the FIRST problem rather than throwing, the way
|
|
198
|
+
* `utils/cookbookFormat.checkCookbookFormatVersion` does: the caller owns the
|
|
199
|
+
* `[Context]` of the message it throws, and the caller here is the runner
|
|
200
|
+
* (`.claude/rules/architecture-principles.md` §5).
|
|
201
|
+
*
|
|
202
|
+
* @param {Array<Object>} steps - the cookbook's steps, already proved to be the array shape
|
|
203
|
+
* @returns {Promise<string|null>} the problem text without its context prefix
|
|
204
|
+
*/
|
|
205
|
+
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
|
+
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
|
+
let problem = null;
|
|
219
|
+
await resolveReferencesWith(step.input, (expression) => {
|
|
220
|
+
if (problem === null) {
|
|
221
|
+
problem = checkExpression(expression, step.step_id, known, knownIds);
|
|
222
|
+
}
|
|
223
|
+
return undefined;
|
|
224
|
+
});
|
|
225
|
+
if (problem !== null) return problem;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Write one finished step into the run context, under its own name.
|
|
233
|
+
*
|
|
234
|
+
* `output` appears ONLY on a step that passed — "an entry carries `output` and
|
|
235
|
+
* `_execution` once it has completed, not before"
|
|
236
|
+
* (the same "Runtime context (`context.steps`)" section). A step that failed is
|
|
237
|
+
* recorded with its status and no output, so a reference into it resolves to
|
|
238
|
+
* nothing and survives as literal text: the cookbook author sees
|
|
239
|
+
* `{{steps.x.output.id}}` sitting in the failing step's input, which names the
|
|
240
|
+
* real cause.
|
|
241
|
+
*
|
|
242
|
+
* The `step_id` is REQUIRED here rather than assumed. `validateCookbook`
|
|
243
|
+
* already refuses a cookbook whose step has none, so in a run this cannot
|
|
244
|
+
* happen; the check is at the entry of this function because the alternative is
|
|
245
|
+
* a silent `steps[undefined]` — a nameless step that a later reference could
|
|
246
|
+
* never address on purpose (architecture-principles.md §4).
|
|
247
|
+
*
|
|
248
|
+
* @param {{steps: Object}} runContext - the context to write into
|
|
249
|
+
* @param {Object} step - the cookbook step as written
|
|
250
|
+
* @param {{passed: boolean, actual: *}} result - the step's result record
|
|
251
|
+
* @returns {void}
|
|
252
|
+
*/
|
|
253
|
+
function recordStepOutcome(runContext, step, result) {
|
|
254
|
+
if (typeof step.step_id !== 'string' || step.step_id.length === 0) {
|
|
255
|
+
throw new Error('[stepReferences] A step with no step_id cannot enter the run context - Expected a '
|
|
256
|
+
+ 'non-empty "step_id"; a step is addressed by its name and never by its position '
|
|
257
|
+
+ '(api/docs/biz/40-cookbooks/variable-references.md § Rules). '
|
|
258
|
+
+ 'Fix: add "step_id" to the step.');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const entry = {
|
|
262
|
+
...step,
|
|
263
|
+
_execution: { status: result.passed ? 'completed' : 'failed' }
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
if (result.passed) {
|
|
267
|
+
entry.output = result.actual;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
runContext.steps[step.step_id] = entry;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = {
|
|
274
|
+
createRunContext,
|
|
275
|
+
resolveStepInput,
|
|
276
|
+
recordStepOutcome,
|
|
277
|
+
checkStepInputExpressions
|
|
278
|
+
};
|
|
@@ -202,6 +202,16 @@ function assertAllowedTenant(tenantId, options) {
|
|
|
202
202
|
* fallback there would hide exactly the misconfiguration the boundary exists to
|
|
203
203
|
* surface (architecture-principles.md §3).
|
|
204
204
|
*
|
|
205
|
+
* What it DOES hold is the shape of a workspace id: a POSITIVE integer. The
|
|
206
|
+
* recorded decision names it (api/docs/governance/confirmations/tier1-runner-workspace.md,
|
|
207
|
+
* entry 002) and `.claude/rules/workspace-architecture.md` says `workspace_id` 0
|
|
208
|
+
* is not a value; the guard checked `Number.isInteger` only, so `0` and `-5` came
|
|
209
|
+
* back normalized and a cookbook could send the boot probe into a workspace that
|
|
210
|
+
* cannot exist. The check lives here rather than in `normalizeNamespaceId()`
|
|
211
|
+
* because the tenant rail already refuses both — by the class allowlist, for a
|
|
212
|
+
* different reason and with a different fix — and one message per boundary is
|
|
213
|
+
* what keeps the refusal actionable.
|
|
214
|
+
*
|
|
205
215
|
* There is deliberately NO class allowlist here, and that is not an omission:
|
|
206
216
|
*
|
|
207
217
|
* - `api/docs/standards/tenant-allocation.md` allocates environment classes to
|
|
@@ -222,7 +232,7 @@ function assertAllowedTenant(tenantId, options) {
|
|
|
222
232
|
* `purpose` is the name the caller knows the id by (here, the cookbook key
|
|
223
233
|
* that carries it), `setBy` where that name is given a value.
|
|
224
234
|
* @returns {number} the normalized id.
|
|
225
|
-
* @throws {Error} when the id is absent
|
|
235
|
+
* @throws {Error} when the id is absent, not an integer, or not positive.
|
|
226
236
|
*/
|
|
227
237
|
function assertWorkspaceId(workspaceId, options) {
|
|
228
238
|
if (options === null || typeof options !== 'object') {
|
|
@@ -232,7 +242,14 @@ function assertWorkspaceId(workspaceId, options) {
|
|
|
232
242
|
requireOriginText(purpose, 'purpose');
|
|
233
243
|
requireOriginText(setBy, 'setBy');
|
|
234
244
|
|
|
235
|
-
|
|
245
|
+
const value = normalizeNamespaceId(workspaceId, { purpose, setBy });
|
|
246
|
+
if (value <= 0) {
|
|
247
|
+
throw new Error(`[TestNamespace] Invalid ${purpose}=${value} - Expected a positive integer `
|
|
248
|
+
+ 'workspace id (1 or greater); 0 identifies no workspace. '
|
|
249
|
+
+ `Fix: set ${purpose} to the workspace the run owns - ${setBy} - `
|
|
250
|
+
+ 'see api/docs/standards/tenant-allocation.md § The allocation.');
|
|
251
|
+
}
|
|
252
|
+
return value;
|
|
236
253
|
}
|
|
237
254
|
|
|
238
255
|
/**
|
|
@@ -275,9 +292,19 @@ function getTestNamespace() {
|
|
|
275
292
|
setBy: ENV_SET_BY
|
|
276
293
|
});
|
|
277
294
|
|
|
295
|
+
// The workspace takes the same shape check a cookbook's declared value takes,
|
|
296
|
+
// under the same function and the same sentence. It did not, and that made the
|
|
297
|
+
// boundary half a boundary: the positivity rule held for a value a cookbook
|
|
298
|
+
// declares and not for the variable every integration test and every boot
|
|
299
|
+
// probe actually runs under (measured: TESTING_WORKSPACE_ID=0 returned
|
|
300
|
+
// workspace_id 0). The tenant above shows the shape — the env is one caller of
|
|
301
|
+
// a boundary, never its owner.
|
|
278
302
|
return {
|
|
279
303
|
tenant_id: tenantId,
|
|
280
|
-
workspace_id: readNamespaceId('TESTING_WORKSPACE_ID')
|
|
304
|
+
workspace_id: assertWorkspaceId(readNamespaceId('TESTING_WORKSPACE_ID'), {
|
|
305
|
+
purpose: 'TESTING_WORKSPACE_ID',
|
|
306
|
+
setBy: ENV_SET_BY
|
|
307
|
+
})
|
|
281
308
|
};
|
|
282
309
|
}
|
|
283
310
|
|
|
@@ -906,8 +906,14 @@ class ServiceStructureValidator {
|
|
|
906
906
|
this.errors.push({
|
|
907
907
|
type: 'MISSING_COOKBOOKS',
|
|
908
908
|
path: 'tests/cookbooks',
|
|
909
|
+
// The problem only. The remedy is `fix`, and the printer composes the
|
|
910
|
+
// two — `formatResult` below writes a `Fix:` line of its own, the
|
|
911
|
+
// service wrapper composes `message (Fix: fix)` inline. A message that
|
|
912
|
+
// carries `Fix:` as well prints the remedy twice, which reads as two
|
|
913
|
+
// instructions; this was the only one of the validator's findings that
|
|
914
|
+
// did it (d.599).
|
|
909
915
|
message: '[ServiceStructure] tests/cookbooks is missing or empty - Tier-1 boot '
|
|
910
|
-
+ 'validates operations by these recipes.
|
|
916
|
+
+ 'validates operations by these recipes.',
|
|
911
917
|
fix: 'add tests/cookbooks/<op>.json'
|
|
912
918
|
});
|
|
913
919
|
return;
|