@onlineapps/conn-orch-validator 10.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.
@@ -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
+ };
@@ -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. Fix: add tests/cookbooks/<op>.json',
916
+ + 'validates operations by these recipes.',
911
917
  fix: 'add tests/cookbooks/<op>.json'
912
918
  });
913
919
  return;
@@ -288,6 +288,44 @@ deploy-production:
288
288
  # (api/docs/setup/INSTALL.md, applying a business schema).
289
289
  BIZ_DB_MIGRATIONS="$(dirname "$BIZ_DB_MIGRATIONS")"
290
290
  fi
291
+ # WHICH SEEDS, IF ANY. The declaration says which files the installer
292
+ # applies; the file's own `-- Dataset-Class:` header says what class each
293
+ # one is (api/docs/standards/repository-installation-sql-contract.md §4).
294
+ # Neither fact is copied here, and the box is handed the ANSWER: it parses
295
+ # no JSON, reads no header and decides no class.
296
+ #
297
+ # Only PRODUCTION_LIKE reaches a production database. A service may declare
298
+ # a TEST_ONLY seed in the same list - converter does - and applying the
299
+ # declaration verbatim would write synthetic fixtures into a live schema.
300
+ BIZ_DB_SEEDS=""
301
+ for seed in $(jq -r '.database.seeds // [] | .[]' "$CONTRACT"); do
302
+ seed_file="$CI_PROJECT_DIR/$seed"
303
+ if [ ! -f "$seed_file" ]; then
304
+ echo "[deploy] FATAL: $CONTRACT declares database.seeds \"$seed\" and this checkout does not carry it. Expected: the file, at that path. Fix: add it, or correct the declaration (uniform row D-DB-PACKAGE reports the same gap)." >&2
305
+ exit 1
306
+ fi
307
+ seed_class="$(sed -n 's/^-- Dataset-Class:[[:space:]]*\([^[:space:]]*\).*/\1/p' "$seed_file" | head -n 1)"
308
+ case "$seed_class" in
309
+ PRODUCTION_LIKE)
310
+ # This deploy applies every PRODUCTION_LIKE seed on EVERY run and
311
+ # keeps no tracker (see the step on the box for why). A file that
312
+ # does not converge on a replay would duplicate rows or fail, so the
313
+ # header that licenses the replay is a precondition of the deploy,
314
+ # not a comment in the file.
315
+ seed_idem="$(sed -n 's/^-- Idempotency:[[:space:]]*\([^[:space:]]*\).*/\1/p' "$seed_file" | head -n 1)"
316
+ if [ "$seed_idem" != "yes" ]; then
317
+ echo "[deploy] FATAL: $seed declares \"-- Idempotency: $seed_idem\" and this deploy applies every PRODUCTION_LIKE seed on every run, with no tracker. Expected: Idempotency: yes. Fix: make the seed converge on a replay (INSERT ... ON DUPLICATE KEY UPDATE), or move the part that does not into a migration." >&2
318
+ exit 1
319
+ fi
320
+ BIZ_DB_SEEDS="$BIZ_DB_SEEDS $seed" ;;
321
+ TEST_ONLY)
322
+ echo "[deploy] seed $seed NOT APPLIED (Dataset-Class: TEST_ONLY) - test fixtures never reach a production database." ;;
323
+ *)
324
+ echo "[deploy] FATAL: $seed declares \"-- Dataset-Class: $seed_class\" and database.seeds admits PRODUCTION_LIKE and TEST_ONLY only. Fix: correct the header (uniform row D-DB-HEADERS owns it), or remove the file from database.seeds." >&2
325
+ exit 1 ;;
326
+ esac
327
+ done
328
+ BIZ_DB_SEEDS="${BIZ_DB_SEEDS# }"
291
329
  # DEPLOY_PATH / DEPLOY_HOST / DEPLOY_USER are CI variables: the box decides
292
330
  # where its checkout lives, and no path is baked into this file.
293
331
  #
@@ -314,7 +352,7 @@ deploy-production:
314
352
  printf '%s\n' "set -euo pipefail"
315
353
  cat "$API_CHECKOUT/scripts/lib/mariadb-migrations.sh"
316
354
  cat
317
- } <<'REMOTE' | ssh $DEPLOY_USER@$DEPLOY_HOST "IFS= read -r REGISTRY_PASSWORD; export REGISTRY_PASSWORD; bash -s -- '$DEPLOY_PATH' '$CI_REGISTRY' '$CI_REGISTRY_USER' '$CI_PROJECT_PATH_SLUG' '$BIZ_IMAGE_DIGEST' '$CI_PROJECT_PATH' '$BIZ_DB_SCHEMA' '$BIZ_DB_MIGRATIONS'"
355
+ } <<'REMOTE' | ssh $DEPLOY_USER@$DEPLOY_HOST "IFS= read -r REGISTRY_PASSWORD; export REGISTRY_PASSWORD; bash -s -- '$DEPLOY_PATH' '$CI_REGISTRY' '$CI_REGISTRY_USER' '$CI_PROJECT_PATH_SLUG' '$BIZ_IMAGE_DIGEST' '$CI_PROJECT_PATH' '$BIZ_DB_SCHEMA' '$BIZ_DB_MIGRATIONS' '$BIZ_DB_SEEDS' '$CI_COMMIT_SHA'"
318
356
  DEPLOY_PATH="$1"
319
357
  REGISTRY="$2"
320
358
  REGISTRY_USER="$3"
@@ -325,6 +363,14 @@ deploy-production:
325
363
  # only thing that turns the migration step below on or off.
326
364
  DB_SCHEMA="$7"
327
365
  DB_MIGRATIONS_DIR="$8"
366
+ # Space-separated, repo-relative, and already filtered to PRODUCTION_LIKE on
367
+ # the runner - the box parses no JSON and decides no class. Empty when the
368
+ # contract declares no seeds, or declares only ones no production database
369
+ # may see.
370
+ DB_SEEDS="$9"
371
+ # The commit the runner MEASURED the contract at. The box is moved to it
372
+ # below, so both sides read one declaration rather than two.
373
+ COMMIT_SHA="${10}"
328
374
  # This service's own env file on the box, the per-machine half of the two
329
375
  # the compose file names (../shared.env is the host's). It carries the
330
376
  # migration account - the SAME account the service connects as.
@@ -355,12 +401,39 @@ deploy-production:
355
401
  # R2: reset, never merge. A local modification on the server would turn a
356
402
  # merge into a conflict and abort the deploy halfway.
357
403
  git fetch origin production
358
- git reset --hard origin/production
404
+ # ...and to the COMMIT THIS PIPELINE MEASURED, never to whatever
405
+ # origin/production points at by the time the ssh step opens. The runner
406
+ # read config/service/integration-contract.json at $CI_COMMIT_SHA and
407
+ # derived the migration directory and the seed list from it THERE; a push
408
+ # that lands while this job runs would otherwise leave the box applying a
409
+ # declaration nobody measured.
410
+ #
411
+ # It must still be ON the branch this box follows. A commit that is not is
412
+ # a pipeline for a different history, and resetting to it would put this
413
+ # box on code production never took.
414
+ if ! git merge-base --is-ancestor "$COMMIT_SHA" origin/production; then
415
+ echo "[deploy] FATAL: $COMMIT_SHA is not an ancestor of origin/production - this pipeline measured a commit that is not on the branch this box deploys, and resetting to it would put $PROJECT_PATH on a history production never took. Expected: the pipeline of a commit that is on production. Fix: re-run the pipeline of the current tip of production." >&2
416
+ exit 1
417
+ fi
418
+ git reset --hard "$COMMIT_SHA"
359
419
  mkdir -p "$DOCKER_CONFIG_DIR"
360
420
  echo "$REGISTRY_PASSWORD" | docker --config "$DOCKER_CONFIG_DIR" login -u "$REGISTRY_USER" --password-stdin "$REGISTRY"
361
421
  echo "[deploy] pulling $BIZ_IMAGE_DIGEST"
362
422
  docker --config "$DOCKER_CONFIG_DIR" compose -f docker-compose.production.yml pull
363
423
 
424
+ # Both env files, for EVERY service, BEFORE anything branches on a database.
425
+ # docker-compose.production.yml names them, so a missing one is fatal whether
426
+ # or not this service has a schema - and a service without one (pdfgen, hello)
427
+ # used to learn it from compose alone, as `env file ... not found`: a line that
428
+ # names no runbook and no owner. Neither file is committed, and neither is
429
+ # created by a script; the runbook step below is what puts them on the box.
430
+ for env_file in "$HOST_ENV" "$SERVICE_ENV"; do
431
+ if [ ! -f "$env_file" ]; then
432
+ echo "[deploy] Missing env file - $DEPLOY_PATH/$env_file does not exist on this box, and docker-compose.production.yml names it: the service could not start without it. Expected: both env files that compose names, on every box - the host-level $HOST_ENV and this service's own $SERVICE_ENV. Fix: place it on the box (both are per-machine and never committed) - api/docs/operations/production-box-provisioning.md § 3 The biz box, step 3." >&2
433
+ exit 1
434
+ fi
435
+ done
436
+
364
437
  # ─── Migrations: after the pull, before the switch ───────────────
365
438
  # Owner decision api/docs/governance/confirmations/db-migrations-first-deploy.md
366
439
  # 001, phase 2. The ordering IS the safety: at this point the new image is
@@ -380,16 +453,14 @@ deploy-production:
380
453
  if [ -z "$DB_SCHEMA" ]; then
381
454
  echo "[deploy] migrations NOT APPLICABLE (no database block) - config/service/integration-contract.json declares no database, so this service has no schema to migrate."
382
455
  else
383
- # The two env files the compose names, in the order it names them: the
384
- # host-level one first, this service's own second, so the same value wins
385
- # here that wins inside the container. Reading only one of them would
456
+ # The two env files the compose names, READ in the order it names them:
457
+ # the host-level one first, this service's own second, so the same value
458
+ # wins here that wins inside the container. Reading only one of them would
386
459
  # make this step disagree with the service it migrates for, depending on
387
- # which file the box happens to carry DB_HOST in.
460
+ # which file the box happens to carry DB_HOST in. That both exist was
461
+ # settled above, for every service - checking it twice would be two rails
462
+ # over one fact (.claude/rules/change-discipline.md § One rail per concern).
388
463
  for env_file in "$HOST_ENV" "$SERVICE_ENV"; do
389
- if [ ! -f "$env_file" ]; then
390
- echo "[deploy] Missing env file - $env_file does not exist on this box, and docker-compose.production.yml names it: between the two of them they carry the endpoint and the migration account of $DB_SCHEMA. Fix: place it on the box (both are per-machine and never committed), then re-run this deploy." >&2
391
- exit 1
392
- fi
393
464
  set -a
394
465
  . "$env_file"
395
466
  set +a
@@ -403,6 +474,15 @@ deploy-production:
403
474
  echo "[deploy] Missing environment variable -$missing in $DEPLOY_PATH/$SERVICE_ENV. Expected: the migration account of $DB_SCHEMA (the same account as DB_USER, granted on that schema only) and the endpoint it is reached at. Fix: add the keys to that file on this box; config/env-templates/ declares each of them with the reason it exists." >&2
404
475
  exit 1
405
476
  fi
477
+ # The runner is carried from the api clone at API_UNIFORM_REF, so a ref
478
+ # older than the seed entry point would reach `command not found` here -
479
+ # after the migrations and before the switch, in a production deploy and
480
+ # nowhere else. Checked before the first statement instead
481
+ # (automation-gates.md §1 requirement 4).
482
+ if [ -n "$DB_SEEDS" ] && ! declare -F apply_mariadb_seeds_over_tcp > /dev/null 2>&1; then
483
+ echo "[deploy] FATAL: the migration runner carried from the api clone has no apply_mariadb_seeds_over_tcp, and $DB_SCHEMA declares PRODUCTION_LIKE seeds this deploy has to apply. Expected: that entry point in api/scripts/lib/mariadb-migrations.sh. Fix: move API_UNIFORM_REF to an api commit that carries it, and re-run this deploy." >&2
484
+ exit 1
485
+ fi
406
486
  # The precondition confirmation 001 demands before any migration: the
407
487
  # account opens the schema, or this deploy stops and names the runbook.
408
488
  if ! mariadb_migrations_can_open_over_tcp "$DB_HOST" "$DB_PORT" "$DB_SCHEMA"; then
@@ -435,6 +515,24 @@ deploy-production:
435
515
  # anywhere in a deploy job, and the gate that keeps this job free of one
436
516
  # judges by the word, not by the intent.
437
517
  echo "[deploy] migrations $DB_SCHEMA: $MARIADB_MIGRATIONS_APPLIED applied now, $(wc -l < "$TRACKER" | tr -d " ") recorded in $DEPLOY_PATH/$TRACKER"
518
+ # Seeds: after the migrations, before the switch, for the same reason the
519
+ # migrations are there - the schema they write into is the one the step
520
+ # above just brought up to date, and the old container is still serving.
521
+ #
522
+ # They are NOT tracked. The tracker answers "has this file ever been
523
+ # applied", and a PRODUCTION_LIKE seed is a regenerated reference set
524
+ # whose name never changes and whose content does - converter's system
525
+ # catalogue is written by a generator in its own repository. Tracking it
526
+ # would pin production to the first catalogue ever installed, which is the
527
+ # drift this step exists to close. What licenses the replay is the file's own
528
+ # `-- Idempotency: yes`, and the runner side refused this deploy if it did
529
+ # not say so.
530
+ if [ -z "$DB_SEEDS" ]; then
531
+ echo "[deploy] seeds NOT APPLICABLE - config/service/integration-contract.json declares no PRODUCTION_LIKE seed for $DB_SCHEMA."
532
+ else
533
+ apply_mariadb_seeds_over_tcp "$DB_HOST" "$DB_PORT" "$DB_SCHEMA" $DB_SEEDS
534
+ echo "[deploy] seeds $DB_SCHEMA: $MARIADB_SEEDS_APPLIED applied (every deploy, no tracker)"
535
+ fi
438
536
  fi
439
537
 
440
538
  docker --config "$DOCKER_CONFIG_DIR" compose -f docker-compose.production.yml up -d
@@ -1,27 +1,60 @@
1
1
  # === Production stage (build with: --target production) ===
2
2
  FROM node:24-alpine AS production
3
3
  WORKDIR /app
4
- COPY package*.json ./
5
- RUN npm ci --omit=dev
6
- COPY . .
7
- # The manifest conformance check, inside the image that would be deployed
8
- # (confirmation biz-service-manifest 001 §3.1: "The same check runs in the
9
- # Dockerfile production stage, so an image with a finding is never built").
4
+ # The identity the PROCESS runs as, and it is the same one in every environment:
5
+ # "production is dev in the rights of the process". Until d.588 this stage
6
+ # declared no USER at all, so a deployed service ran as root while its dev
7
+ # container ran as 1000:1000 — measured 2026-09-17 on an image built from this
8
+ # template, `docker run --rm <image> id` → uid=0(root) gid=0(root). Nobody had
9
+ # decided that difference; it was what the file happened to say.
10
+ #
11
+ # `node` is the account node:24-alpine ships at uid 1000, gid 1000 (measured the
12
+ # same day: `docker run --rm node:24-alpine id node` → uid=1000(node)
13
+ # gid=1000(node)), so USER node here and `user: "1000:1000"` in both compose
14
+ # files are two spellings of ONE identity, not two decisions.
10
15
  #
11
- # It is the CLI of @onlineapps/conn-orch-validator, which every service declares
12
- # under `dependencies` — measured across all eight biz repositories on
13
- # 2026-09-09 — so `npm ci --omit=dev` above installs it and this line needs
14
- # nothing the image does not already carry.
16
+ # The line below runs while /app is still root's, because WORKDIR created it so,
17
+ # and it does two things at once for that reason:
18
+ # * logs/ and conn-runtime/ are written at RUNTIME — @onlineapps/monitoring-core
19
+ # writes logs/app.<date>.log (its fileDirectory default) and ServiceWrapper
20
+ # writes conn-runtime/validation-proof.json on every boot. Both are excluded
21
+ # from the build context by .dockerignore, and in production /app is the
22
+ # image rather than a bind mount, so they exist only because this line
23
+ # creates them;
24
+ # * the handover of /app itself, after which every COPY carries --chown and
25
+ # nothing in the image belongs to uid 0.
26
+ RUN mkdir -p /app/logs /app/conn-runtime && chown -R node:node /app
27
+ USER node
28
+ COPY --chown=node:node package*.json ./
29
+ RUN npm ci --omit=dev
30
+ COPY --chown=node:node . .
31
+ # No conformance check here. From d.215b the stage ran `oa-validate`, on the
32
+ # sentence of confirmation biz-service-manifest 001 §3.1 ("an image with a
33
+ # finding is never built"); three later decisions took that away. 006 point 2
34
+ # calls the run inside an image "information, never a gate" — which a non-zero
35
+ # exit is not; 010 made the job `validate-uniform` run the COMPLETE uniform over
36
+ # a real checkout in every pipeline, and again before the deploy; 011 settled
37
+ # that a tree which is not a git checkout reports NOT RUN, never a verdict.
15
38
  #
16
- # The rows that read a platform file (the template, api/config/shared-env.json,
17
- # api/.nvmrc) report NOT RUN here and say so by name: an image has no workspace
18
- # above it. What runs is every row the repository itself answers, and one
19
- # finding of severity boot or deploy exits non-zero and fails the build.
20
- RUN node node_modules/@onlineapps/conn-orch-validator/src/cli/oa-validate.js .
39
+ # What was left was a gate over a tree where the uniform cannot be answered: an
40
+ # image has no workspace above it, no `.git`, and — once `.dockerignore` does its
41
+ # job — no `README.md` and no `docker-compose*.yml` either. Measured 2026-09-17
42
+ # over exactly that shape: NOT DEPLOYABLE, 10 findings, every one of them
43
+ # "absent", exit 1. The deployability verdict is CI's; this stage builds.
21
44
  CMD ["node", "index.js"]
22
45
 
23
46
  # === Development stage (default) ===
24
47
  FROM node:24-alpine
25
48
  WORKDIR /app
26
- COPY package*.json ./
49
+ # The same identity as the production stage — and here the directory is load
50
+ # bearing for a second reason. docker-compose.yml gives the one-shot test runner
51
+ # an anonymous volume at /app/conn-runtime so its validation proof never lands in
52
+ # the file the running service reads, and docker seeds such a volume from the
53
+ # IMAGE at that path. Measured 2026-09-17 with the path absent: docker created it
54
+ # root:root and the runner's write failed with "Permission denied"; with the
55
+ # directory present and owned by node, the volume inherits that ownership and the
56
+ # write succeeds.
57
+ RUN mkdir -p /app/logs /app/conn-runtime && chown -R node:node /app
58
+ USER node
59
+ COPY --chown=node:node package*.json ./
27
60
  CMD ["npm", "start"]
@@ -9,15 +9,15 @@ Duty sections that apply:
9
9
  - `scripts`: S-TEST, S-ALL-C, S-INT-C, S-UNIT-C, S-COOKBOOKS, S-COOK-C, S-HOST, S-HOOKS
10
10
  - `tooling`: S-SCRIPTS
11
11
  - `config`: C-IDENTITY, C-SERVICE, C-OPS, C-CONTRACT, C-CONNECTORS, C-SERVICE-DEAD, C-ENV, G-SHARED-ENV, C-ENV-READS
12
- - `runtime`: R-NODE, R-MEM, R-PID1, R-PORTS-DEV, R-PORTS
13
- - `db`: D-DB-CONSISTENT, D-DB-COLLATION, D-DB-PACKAGE, D-DB-NAMING, D-DB-README, D-DB-ACCOUNT, D-DB-HEADERS
12
+ - `runtime`: R-NODE, R-MEM, R-PID1, R-USER, R-PORTS-DEV, R-PORTS
13
+ - `db`: D-DB-CONSISTENT, D-DB-COLLATION, D-DB-PACKAGE, D-DB-NAMING, D-DB-README, D-DB-ACCOUNT, D-DB-CI-ACCOUNT, D-DB-HEADERS
14
14
  - `docs`: C-LINT, D-PORT, D-NPM, D-SCRIPT, D-RETIRED, D-HEADER, D-LINT
15
15
 
16
16
  Paths a duty owns:
17
17
 
18
18
  - `.dockerignore`: F-DOCKERIGNORE (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/gitignore`)
19
19
  - `.gitignore`: F-GITIGNORE (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/gitignore`)
20
- - `.gitlab-ci.yml`: G-CI (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/.gitlab-ci.yml`)
20
+ - `.gitlab-ci.yml`: G-CI (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/.gitlab-ci.yml`), D-DB-CI-ACCOUNT
21
21
  - `README.md`: G-README (from `./node_modules/@onlineapps/conn-orch-validator/manifests/biz-service.manifest.json`)
22
22
  - `config/biz-docs-lint.tree.json`: C-LINT
23
23
  - `config/env-templates`: C-ENV, D-DB-ACCOUNT
@@ -26,7 +26,7 @@ Paths a duty owns:
26
26
  - `config/service/integration-contract.json`: C-CONTRACT, D-DB-COLLATION
27
27
  - `config/service/operations.json`: C-OPS
28
28
  - `docker-compose.production.yml`: G-PROD (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/docker-compose.production.yml`), G-PROD-IMAGE, R-PORTS
29
- - `docker-compose.yml`: F-RUNNER (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/docker-compose.yml`), C-IDENTITY, R-MEM, R-PID1, R-PORTS-DEV
29
+ - `docker-compose.yml`: F-RUNNER (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/docker-compose.yml`), C-IDENTITY, R-MEM, R-PID1, R-USER, R-PORTS-DEV
30
30
  - `docs/80-setup/`: G-SETUP
31
31
  - `docs/80-setup/INSTALL.md`: G-SETUP-INSTALL (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/docs/80-setup/INSTALL.md`)
32
32
  - `docs/80-setup/PLATFORM_MATRIX.md`: G-SETUP-MATRIX (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md`)
@@ -220,3 +220,41 @@ cgroup.
220
220
  Revise the service limit when this service exceeds 60% of it in operation,
221
221
  measured the way the norm was: cgroup `memory.stat:anon`, sampled inside the
222
222
  running container.
223
+
224
+ ## Process identity
225
+
226
+ One identity, in every environment: the service process is **uid 1000, gid
227
+ 1000** — the `node` account of `node:24-alpine` — and it is never root.
228
+
229
+ | Where | What says it |
230
+ |---|---|
231
+ | the image, both stages | `USER node` in `Dockerfile` |
232
+ | dev container and test runner | `user: "1000:1000"` in `docker-compose.yml` |
233
+ | production container | `user: "1000:1000"` in `docker-compose.production.yml` |
234
+
235
+ Row `R-USER` of the uniform holds the two halves nothing else reaches — the
236
+ production stage of the `Dockerfile` and the service node of `docker-compose.yml`.
237
+ The runner line is inside the block `F-RUNNER` holds, and the production compose is
238
+ `G-PROD`'s whole-file render, so neither is measured twice.
239
+
240
+ Both halves are stated on purpose. The `Dockerfile` decides what the IMAGE is,
241
+ the compose file decides what THIS deployment runs, and a rebuild that lost the
242
+ `USER` would otherwise reach the box unnoticed. Until 2026-09-17 neither half
243
+ existed for production: a `docker run --rm <image> id` on an image built from
244
+ this template answered `uid=0(root)`, while the dev container beside it ran as
245
+ 1000 — a difference nobody had decided.
246
+
247
+ The two directories the process writes at runtime, `logs/` (the log files
248
+ `@onlineapps/monitoring-core` rotates) and `conn-runtime/` (the validation
249
+ proof), are created in the image and belong to that account. In dev they are
250
+ covered by the bind mount and belong to whoever owns the checkout; in production
251
+ `/app` IS the image, so a directory nothing created is a directory nothing can
252
+ write.
253
+
254
+ The one-shot test runner gets a `conn-runtime/` of its own — an anonymous volume
255
+ at `/app/conn-runtime` in its compose node. A validation proof is an assertion
256
+ about ONE running instance, and with the plain `.:/app` mount the runner wrote
257
+ its proof into the file the live service reads (measured in `api_biz/converter`,
258
+ 2026-09-16). Nothing else is separated: the runner keeps the same build, the
259
+ same `env_file` and the same networks as the service, because that is what makes
260
+ a suite measure what the service sees.
@@ -13,7 +13,7 @@ LOG_MAX_SIZE_BYTES=52428800
13
13
  # How many rotated files of one log row the directory keeps - the other half of the size bound, and equally without a default in @onlineapps/monitoring-core 3.0.0 (src/logger.js REQUIRED_FILE_BOUNDS, src/config.js logMaxFiles). Ten files of LOG_MAX_SIZE_BYTES is the 500 MB per service the platform declares as its ceiling, so the two keys are read together and travel together (owner decision docs/governance/confirmations/log-file-bounds.md 001).
14
14
  LOG_MAX_FILES=10
15
15
 
16
- # The HMAC-SHA256 secret the gateway and auth sign and verify access tokens with; it is shared because both ends of one token must hold the same value (docs/standards/JWT_AUTH.md).
16
+ # The HMAC-SHA256 secret the gateway and auth sign and verify access tokens with; it is shared because both ends of one token must hold the same value (docs/standards/JWT_AUTH.md). Four infrastructure services read the name, each at boot: api_gateway (infra/api_gateway/config/jwtSecret.js, assertJwtSecret), api_auth (infra/api_auth/config/bootEnv.js, assertJwtSecret), api_meta_reader and api_delivery_endpoint (both requireEnv('JWT_SECRET', ...) in src/config.js). In the business chain the key is carried, not read: no business service reads the name, and @onlineapps/service-common 3.0.0 takes the secret as an INPUT of verifyAccessToken(token, secret) and createJwtValidator({ secret }) and reads no environment variable at all (principle 1, CHANGELOG 3.0.0), so a business service could not use the value even if it were filled in. It reaches every bearer because the shared key set is ONE file whose every copy is byte-identical to the platform template (confirmation biz-service-manifest 003 §18), never because each bearer reads every key - which is why CHANGE_ME here is a real placeholder only on an infrastructure target.
17
17
  JWT_SECRET=CHANGE_ME
18
18
 
19
19
  # The ordered list of SecretBox master keys every process that resolves a secret reference decrypts with - entries of <key_id>:<base64 32-byte key> separated by commas, newest FIRST: the first entry seals every new write, every entry opens what it sealed (selected by the blob's own key_id), and a blob naming a key outside the list is refused rather than guessed at (owner decision docs/governance/confirmations/secretbox-master-keys.md 001). It is shared because one keyring has to reach every bearer at once - a rotation is a switch of this one list, and a service left on yesterday's list stops reading what its neighbour has already re-encrypted; the measured cost of the single key it replaces is six of ten rows of oagen_meta.secret unreadable for two weeks after the August 2026 rotation. The value is per-machine and lives only in config/env-active/shared.env. @onlineapps/conn-infra-secrets owns the name and the format and never reads the environment itself (principle 1): the bearer reads this key and hands the string to the connector.
@@ -33,6 +33,15 @@ services:
33
33
  # carries a digest, and an image built here would in any case not be the one
34
34
  # CI proved. Build locally through docker-compose.yml instead.
35
35
  container_name: __CONTAINER_NAME__
36
+ # The same uid:gid the dev compose pins, and the Dockerfile's production
37
+ # stage declares the same identity as USER node (node:24-alpine ships that
38
+ # account at uid 1000, gid 1000). Until d.588 neither file said anything
39
+ # here, so the deployed process was the one on the platform running as root
40
+ # — measured 2026-09-17, `docker run --rm <image> id` → uid=0(root).
41
+ # Both halves are stated on purpose: the Dockerfile decides what the image
42
+ # is, this line decides what THIS deployment runs, and a rebuild that lost
43
+ # the USER would otherwise reach the box unnoticed.
44
+ user: "1000:1000"
36
45
  deploy:
37
46
  resources:
38
47
  limits: