@onlineapps/conn-orch-validator 9.0.0 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +546 -0
  2. package/README.md +337 -19
  3. package/docs/DESIGN.md +32 -9
  4. package/manifests/biz-service.manifest.json +56 -6
  5. package/package.json +3 -2
  6. package/src/CookbookTestRunner.js +134 -22
  7. package/src/ValidationOrchestrator.js +312 -73
  8. package/src/cli/biz-ci-gate.js +191 -15
  9. package/src/cli/oa-sync-template.js +23 -8
  10. package/src/cli/oa-validate.js +70 -2
  11. package/src/index.js +21 -13
  12. package/src/lint/scripts/lintScripts.js +65 -18
  13. package/src/manifest/checks/composeRunnerBlock.js +37 -20
  14. package/src/manifest/checks/discoveryOrphan.js +2 -1
  15. package/src/manifest/checks/docsLintBridge.js +79 -21
  16. package/src/manifest/checks/gitTracked.js +14 -28
  17. package/src/manifest/checks/libraryPackage.js +3 -1
  18. package/src/manifest/checks/libraryWorkspace.js +18 -3
  19. package/src/manifest/checks/readmeRegion.js +9 -1
  20. package/src/manifest/checks/serviceConfig.js +29 -12
  21. package/src/manifest/checks/serviceDb.js +176 -7
  22. package/src/manifest/checks/serviceFiles.js +34 -7
  23. package/src/manifest/checks/serviceIdentityRows.js +3 -1
  24. package/src/manifest/checks/serviceRuntime.js +126 -1
  25. package/src/manifest/discovery.js +25 -7
  26. package/src/manifest/gitCheckout.js +84 -0
  27. package/src/manifest/runManifest.js +58 -7
  28. package/src/manifest/workspaceRoot.js +91 -5
  29. package/src/sync/serviceTemplate.js +76 -7
  30. package/src/sync/sharedEnv.js +11 -4
  31. package/src/sync/uniformFiles.js +91 -21
  32. package/src/utils/bizCiGateContract.js +25 -1
  33. package/src/utils/dbAccountGrants.js +126 -0
  34. package/src/utils/envContract.js +36 -6
  35. package/src/utils/envReads.js +102 -0
  36. package/src/utils/installContract.js +46 -5
  37. package/src/utils/libCompat.js +39 -19
  38. package/src/utils/preValidation.js +56 -11
  39. package/src/utils/stepFailure.js +106 -19
  40. package/src/utils/stepReferences.js +278 -0
  41. package/src/utils/testCoverageContract.js +60 -2
  42. package/src/utils/throwawaySchema.js +92 -7
  43. package/src/validatorIdentity.js +31 -0
  44. package/src/validators/ServiceStructureValidator.js +47 -15
  45. package/src/validators/ValidationProofGenerator.js +73 -34
  46. package/templates/business-service/.dockerignore +9 -1
  47. package/templates/business-service/.gitlab-ci.yml +199 -35
  48. package/templates/business-service/Dockerfile +49 -16
  49. package/templates/business-service/README.md +56 -9
  50. package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +17 -5
  51. package/templates/business-service/config/env-templates/shared.env +8 -2
  52. package/templates/business-service/docker-compose.production.yml +9 -0
  53. package/templates/business-service/docker-compose.yml +17 -0
  54. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +1 -1
  55. package/templates/business-service/docs/80-setup/VALIDATION.md +1 -1
  56. package/templates/business-service/jest.config.js +9 -1
  57. package/templates/business-service/package.json.template +1 -1
  58. package/src/mocks/MockStorage.js +0 -188
@@ -2,19 +2,28 @@
2
2
  # Written by @onlineapps/conn-orch-validator (row G-CI of
3
3
  # manifests/biz-service.manifest.json). The PLATFORM half of this pipeline: which
4
4
  # pipelines run at all, how a service is built, how its image is identified (R5),
5
- # how the digest reaches the deploy (R1/R2), the uniform gate that runs before
6
- # the SSH step (confirmation biz-service-manifest 008) and the post-deploy gate
7
- # that runs after it (confirmation deploy-gate-targets 003). The installation
8
- # contract is checked by that uniform gate, in the manifest rows G-SETUP,
9
- # D-DB-PACKAGE and D-DB-HEADERS, and no longer by a job of its own (d.470).
10
- # npx oa-sync-template .gitlab-ci.yml --target . rewrites everything between
11
- # these two markers and nothing outside them.
5
+ # how the digest reaches the deploy (R1/R2), the uniform gate that runs on every
6
+ # pipeline (job validate-uniform, confirmation biz-service-manifest 010) and
7
+ # again before the SSH step as the binding instance (008), and the post-deploy
8
+ # gate that runs after it (confirmation deploy-gate-targets 003). The
9
+ # installation contract is checked by that uniform gate, in the manifest rows
10
+ # G-SETUP, D-DB-PACKAGE and D-DB-HEADERS, and no longer by a job of its own
11
+ # (d.470). npx oa-sync-template .gitlab-ci.yml --target . rewrites everything
12
+ # between these two markers and nothing outside them.
12
13
  #
13
14
  # Outside them is this repository's own: its test job - which database, which
14
15
  # ci:gate:* steps and which artefacts its integration needs is a fact about the
15
16
  # service - and whatever else it runs. The stages this block declares (test,
16
17
  # build, secret-detection, deploy) cover those jobs too; measured over the eight
17
18
  # biz repositories on 2026-09-11, none declares a fifth stage.
19
+ #
20
+ # One thing the platform can only RECOMMEND about that half, because it does not
21
+ # own it: give the service's own jobs the `devel` branch as well. The post-commit
22
+ # mirror pushes every commit there, and a `rules:` list naming only main,
23
+ # production and merge requests is why that branch produced no pipeline at all
24
+ # (010 point 2). The block does its side of it - validate-uniform and
25
+ # secret_detection run on devel; whether a service's own test job should too is
26
+ # the service's call.
18
27
  include:
19
28
  - template: Jobs/Secret-Detection.gitlab-ci.yml
20
29
 
@@ -33,6 +42,73 @@ variables:
33
42
  IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
34
43
  IMAGE_LATEST: $CI_REGISTRY_IMAGE:latest
35
44
 
45
+ # The layout and the runtime every uniform run needs, declared ONCE for the two
46
+ # jobs that run it: the continuous check below and the binding one in
47
+ # deploy-production (confirmations biz-service-manifest 008 and 010). A hidden
48
+ # key is a declaration and never a job - GitLab runs nothing whose name starts
49
+ # with a dot - so this adds no gate, it gives the two that exist one source.
50
+ .oa-uniform:
51
+ # The uniform runs in these jobs, so they need the runtime the pinned engine
52
+ # declares - the same major the test stage and the Dockerfile carry, which is
53
+ # what R3 of the deploy contract compares.
54
+ image: node:24-alpine
55
+ variables:
56
+ # The engine answers the rows that read the platform SSOT only for a service
57
+ # lying at <root>/api_biz/<service> beside <root>/api. GitLab decides where a
58
+ # project is checked out, so the job ASKS for the one place that IS that
59
+ # layout - no symlink (api rule: No Symlinks) and no second copy of this
60
+ # repository, which would leave two trees and no way to tell which one the
61
+ # verdict was about. Needs [runners.custom_build_dir] enabled on the runner.
62
+ #
63
+ # $CI_CONCURRENT_ID is the runner's slot number, and it is what keeps two
64
+ # pipelines of this project off each other's tree. Since 010 the uniform runs
65
+ # on EVERY pipeline, so two of them on one runner is the ordinary case: with
66
+ # one shared path the second run either refuses on an api clone it did not
67
+ # make, or measures the one the first run is still replacing. The slot sits
68
+ # ABOVE api_biz, so the sibling api/ still lands beside this checkout, which
69
+ # is the layout verify-deploy-uniform.sh checks.
70
+ GIT_CLONE_PATH: $CI_BUILDS_DIR/oa-uniform/$CI_CONCURRENT_ID/api_biz/__SERVICE_NAME__
71
+ # Where the platform SSOT is read from, and which state of it a run is
72
+ # measured against. Read access is granted by the api project's CI job-token
73
+ # allowlist - the owner's step, outside any repository (confirmation
74
+ # biz-service-manifest 008 point 2).
75
+ API_PROJECT_PATH: onlineapps/oadrive/infra-mono
76
+ API_UNIFORM_REF: production
77
+
78
+ # The uniform of THIS commit, on every pipeline - confirmation
79
+ # biz-service-manifest 010. Entry 008 places the BINDING run before the SSH step
80
+ # of a production deploy; it never said that is the only place it runs, and read
81
+ # that way it left a service unmeasured between two deploys. Measured on
82
+ # biz-converter 2026-09-16: no job ran the uniform outside the deploy job, the
83
+ # post-commit mirror pushed every commit to origin/devel and pipelines?ref=devel
84
+ # answered 0, and the last pipeline on main was 13 days old. So the same gate,
85
+ # the same bytes and the same arguments run here too, on a merge request, on
86
+ # main, on the devel mirror and on production - and the deploy job keeps its own
87
+ # run as the last, binding instance.
88
+ validate-uniform:
89
+ stage: test
90
+ extends: .oa-uniform
91
+ before_script:
92
+ # git: the gate clones api read-only beside this checkout. npm ci: the gate
93
+ # and the engine it runs are BOTH the ones this repository pinned, so the
94
+ # version measured is the version this service proved - never a floating
95
+ # download.
96
+ - apk add --no-cache git
97
+ - npm ci
98
+ script:
99
+ # The same invocation deploy-production makes. The script is not this
100
+ # repository's own file: no biz repository carries it (measured over all
101
+ # eight, 2026-09-16), and a copy generated into eight of them would be a
102
+ # second place to keep in step (change-discipline.md - One rail per
103
+ # concern). It comes from the package this repository pins, beside the
104
+ # engine it runs.
105
+ - sh node_modules/@onlineapps/conn-orch-validator/templates/business-service/scripts/verify-deploy-uniform.sh "$CI_PROJECT_DIR" "https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/${API_PROJECT_PATH}.git" "$API_UNIFORM_REF"
106
+ rules:
107
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
108
+ - if: $CI_COMMIT_BRANCH == "main"
109
+ - if: $CI_COMMIT_BRANCH == "devel"
110
+ - if: $CI_COMMIT_BRANCH == "production"
111
+
36
112
  build:
37
113
  stage: build
38
114
  image: docker:24
@@ -72,28 +148,18 @@ secret_detection:
72
148
  rules:
73
149
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"
74
150
  - if: $CI_COMMIT_BRANCH == "main"
151
+ # The mirror branch too (010 point 2): a secret pushed there is pushed.
152
+ - if: $CI_COMMIT_BRANCH == "devel"
75
153
  - if: $CI_COMMIT_BRANCH == "production"
76
154
 
77
155
  deploy-production:
78
156
  stage: deploy
79
- # The uniform runs here (confirmation biz-service-manifest 008), so the job
80
- # needs the runtime the pinned engine declares — the same major the test stage
81
- # and the Dockerfile carry, which is what R3 of the deploy contract compares.
82
- image: node:24-alpine
157
+ # The BINDING uniform run happens here, before the SSH step (confirmation
158
+ # biz-service-manifest 008). The runtime it needs and the layout it is
159
+ # measured in are the shared declaration above - the same one validate-uniform
160
+ # reads, so the two runs cannot be measuring different things.
161
+ extends: .oa-uniform
83
162
  variables:
84
- # The engine answers the rows that read the platform SSOT only for a service
85
- # lying at <root>/api_biz/<service> beside <root>/api. GitLab decides where a
86
- # project is checked out, so the job ASKS for the one place that IS that
87
- # layout — no symlink (api rule: No Symlinks) and no second copy of this
88
- # repository, which would leave two trees and no way to tell which one the
89
- # verdict was about. Needs [runners.custom_build_dir] enabled on the runner.
90
- GIT_CLONE_PATH: $CI_BUILDS_DIR/oa-uniform/api_biz/__SERVICE_NAME__
91
- # Where the platform SSOT is read from, and which state of it a production
92
- # deploy is measured against. Read access is granted by the api project's CI
93
- # job-token allowlist — the owner's step, outside any repository
94
- # (confirmation biz-service-manifest 008 point 2).
95
- API_PROJECT_PATH: onlineapps/oadrive/infra-mono
96
- API_UNIFORM_REF: production
97
163
  # Which checks judge this deploy. The gate has no default target: the job
98
164
  # declares what it is measured by (confirmation deploy-gate-targets 002),
99
165
  # and the name is the service's registry identity — the one
@@ -181,7 +247,7 @@ deploy-production:
181
247
  # not look everywhere, and nothing reaches the box. There is no flag and no
182
248
  # variable that turns this off (automation-gates.md §1 requirement 5).
183
249
  - npm ci
184
- - scripts/verify-deploy-uniform.sh "$CI_PROJECT_DIR" "https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/${API_PROJECT_PATH}.git" "$API_UNIFORM_REF"
250
+ - sh node_modules/@onlineapps/conn-orch-validator/templates/business-service/scripts/verify-deploy-uniform.sh "$CI_PROJECT_DIR" "https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/${API_PROJECT_PATH}.git" "$API_UNIFORM_REF"
185
251
  # R1: refuse to deploy without the immutable target the build stage published.
186
252
  - |
187
253
  if [ -z "${BIZ_IMAGE_DIGEST:-}" ]; then
@@ -222,6 +288,44 @@ deploy-production:
222
288
  # (api/docs/setup/INSTALL.md, applying a business schema).
223
289
  BIZ_DB_MIGRATIONS="$(dirname "$BIZ_DB_MIGRATIONS")"
224
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# }"
225
329
  # DEPLOY_PATH / DEPLOY_HOST / DEPLOY_USER are CI variables: the box decides
226
330
  # where its checkout lives, and no path is baked into this file.
227
331
  #
@@ -248,7 +352,7 @@ deploy-production:
248
352
  printf '%s\n' "set -euo pipefail"
249
353
  cat "$API_CHECKOUT/scripts/lib/mariadb-migrations.sh"
250
354
  cat
251
- } <<'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'"
252
356
  DEPLOY_PATH="$1"
253
357
  REGISTRY="$2"
254
358
  REGISTRY_USER="$3"
@@ -259,6 +363,14 @@ deploy-production:
259
363
  # only thing that turns the migration step below on or off.
260
364
  DB_SCHEMA="$7"
261
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}"
262
374
  # This service's own env file on the box, the per-machine half of the two
263
375
  # the compose file names (../shared.env is the host's). It carries the
264
376
  # migration account - the SAME account the service connects as.
@@ -289,12 +401,39 @@ deploy-production:
289
401
  # R2: reset, never merge. A local modification on the server would turn a
290
402
  # merge into a conflict and abort the deploy halfway.
291
403
  git fetch origin production
292
- 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"
293
419
  mkdir -p "$DOCKER_CONFIG_DIR"
294
420
  echo "$REGISTRY_PASSWORD" | docker --config "$DOCKER_CONFIG_DIR" login -u "$REGISTRY_USER" --password-stdin "$REGISTRY"
295
421
  echo "[deploy] pulling $BIZ_IMAGE_DIGEST"
296
422
  docker --config "$DOCKER_CONFIG_DIR" compose -f docker-compose.production.yml pull
297
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
+
298
437
  # ─── Migrations: after the pull, before the switch ───────────────
299
438
  # Owner decision api/docs/governance/confirmations/db-migrations-first-deploy.md
300
439
  # 001, phase 2. The ordering IS the safety: at this point the new image is
@@ -314,16 +453,14 @@ deploy-production:
314
453
  if [ -z "$DB_SCHEMA" ]; then
315
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."
316
455
  else
317
- # The two env files the compose names, in the order it names them: the
318
- # host-level one first, this service's own second, so the same value wins
319
- # 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
320
459
  # make this step disagree with the service it migrates for, depending on
321
- # 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).
322
463
  for env_file in "$HOST_ENV" "$SERVICE_ENV"; do
323
- if [ ! -f "$env_file" ]; then
324
- 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
325
- exit 1
326
- fi
327
464
  set -a
328
465
  . "$env_file"
329
466
  set +a
@@ -337,6 +474,15 @@ deploy-production:
337
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
338
475
  exit 1
339
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
340
486
  # The precondition confirmation 001 demands before any migration: the
341
487
  # account opens the schema, or this deploy stops and names the runbook.
342
488
  if ! mariadb_migrations_can_open_over_tcp "$DB_HOST" "$DB_PORT" "$DB_SCHEMA"; then
@@ -369,6 +515,24 @@ deploy-production:
369
515
  # anywhere in a deploy job, and the gate that keeps this job free of one
370
516
  # judges by the word, not by the intent.
371
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
372
536
  fi
373
537
 
374
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"]
@@ -6,27 +6,27 @@ Every `api_biz/*/package.json` that `api/config/services.json` lists wears it (`
6
6
  Duty sections that apply:
7
7
 
8
8
  - `files`: F-INIT, F-JEST, F-RUNNER, F-GITIGNORE, F-DOCKERIGNORE, G-PROD, G-CI, G-PROD-IMAGE, G-README, G-SETUP, G-SETUP-INSTALL, G-SETUP-MATRIX, G-SETUP-VALIDATION, X-IGNORED, X-HOOKS, X-PREVAL, X-DB-CONFIG
9
- - `scripts`: S-TEST, S-ALL-C, S-INT-C, S-COOKBOOKS, S-HOST, S-HOOKS
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
24
- - `config/env-templates/shared.env`: G-SHARED-ENV (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/config/env-templates/shared.env`)
24
+ - `config/env-templates/shared.env`: G-SHARED-ENV (from `api/config/shared-env.json`)
25
25
  - `config/service/config.json`: C-SERVICE
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`)
@@ -125,7 +125,7 @@ own memory budget, so a heavy suite can no longer be killed together with the
125
125
  service, and the `test` profile keeps it out of a plain `docker compose up`.
126
126
 
127
127
  ```bash
128
- npm run test:container # unit suite, in the runner
128
+ npm run test:unit:container # unit suite, in the runner
129
129
  npm run test:all:container # everything test:all chains, in the runner
130
130
  ```
131
131
 
@@ -141,14 +141,23 @@ the `Dockerfile` or to the dependencies:
141
141
  docker compose --profile test build
142
142
  ```
143
143
 
144
- Without it the next `npm run test:container` runs the image the runner was last
144
+ Without it the next `npm run test:unit:container` runs the image the runner was last
145
145
  built from, and a suite then passes or fails on code that is no longer in this
146
146
  repository — a green run that says nothing about what is committed.
147
147
 
148
+ `jest.config.js` is held byte for byte by row `F-JEST`, so a local
149
+ `setupFiles` entry pointing at a `tests/setup-env.js` of your own does not
150
+ survive a sync — and does not need to. What a suite needs from the environment it
151
+ gets from the runner's `env_file`, the same files the service itself loads, which
152
+ is why the runner exists at all; a setup file that put those names into
153
+ `process.env` would be a second, quieter source for them
154
+ (`.claude/rules/change-discipline.md` § One rail per concern). A suite that needs
155
+ a value nothing declares says so by failing on the name, which is the point.
156
+
148
157
  `npm test` and `npm run test:unit` are what runs INSIDE that container; the
149
158
  runner is the one rail a suite runs on (owner decision
150
159
  `api/docs/governance/confirmations/biz-test-container.md` 001, and manifest rows
151
- `S-ALL-C` and `S-HOST`). Running them on the host is not a second, quicker way
160
+ `S-UNIT-C`, `S-ALL-C`, `S-COOK-C` and `S-HOST`). Running them on the host is not a second, quicker way
152
161
  to the same answer: the host has neither the service's `env_file` nor its
153
162
  network, so what fails there says nothing about the service — which is why no
154
163
  `test:host` script exists and why the uniform forbids one.
@@ -211,3 +220,41 @@ cgroup.
211
220
  Revise the service limit when this service exceeds 60% of it in operation,
212
221
  measured the way the norm was: cgroup `memory.stat:anon`, sampled inside the
213
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.
@@ -1,7 +1,12 @@
1
1
  # __SERVICE_NAME__ environment
2
2
  # Copy to config/env-active/__SERVICE_NAME__.env and adjust values.
3
-
4
- SERVICE_NAME=__SERVICE_NAME__
3
+ #
4
+ # The service's own name is NOT here. config/service/config.json declares it and
5
+ # ServiceWrapper._serviceName() reads it there, passing it on to monitoring and
6
+ # to the MQ client; an env key repeating it would be a second bearer of one fact,
7
+ # and the copies measured on 2026-09-16 had already drifted from it (the template
8
+ # rendered the short name, every live file the registry name). Row C-IDENTITY
9
+ # holds every spelling in this repository to config/service/config.json.
5
10
 
6
11
  # Schema migrations at deploy time. The deploy job (block `oa-ci v1` of
7
12
  # .gitlab-ci.yml) runs api/scripts/lib/mariadb-migrations.sh between `compose
@@ -15,8 +20,15 @@ SERVICE_NAME=__SERVICE_NAME__
15
20
  #
16
21
  # Commented out because this scaffold has no database: its
17
22
  # config/service/integration-contract.json declares no `database` block, the
18
- # deploy step prints NOT APPLICABLE and reads neither name. A service that
19
- # declares that block uncomments both here and fills the values in
20
- # config/env-active/ only - per machine, never in git.
23
+ # deploy step prints NOT APPLICABLE and reads neither name.
24
+ #
25
+ # Where the values live is the host contract, not a choice: the deploy job reads
26
+ # config/env-active/<service>.env ON THE BOX, so a service that declares a
27
+ # `database` block uncomments both names HERE (the template, in git, values
28
+ # empty) and fills them in config/env-active/ on each machine - never in git. The
29
+ # account itself is not created by any of this: on a first deployment the owner
30
+ # of the box creates the schema and the service account by hand from the runbook,
31
+ # and the migration runner then runs under that account (db-migrations-first-deploy
32
+ # 001, phase 1).
21
33
  # MARIADB_MIGRATION_USER=
22
34
  # MARIADB_MIGRATION_PASSWORD=
@@ -4,10 +4,16 @@
4
4
  # The runtime environment name every platform library resolves through its own runtime-config schema; without it a library cannot say which environment it is reporting from.
5
5
  NODE_ENV=development
6
6
 
7
- # The log level an infra service's config/logging.json resolves through ${LOG_LEVEL}; one platform value so a stack does not log at four different levels.
7
+ # The log level an infrastructure service's config/logging.json resolves through ${LOG_LEVEL}; one platform value so a stack does not log at a different level in every service. In the business chain the key is carried, not read: no library in the business chain reads it - @onlineapps/monitoring-core reads no environment variable at all (principle 1) and takes its level from the config the service hands it - so setting it on a business service changes nothing. 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.
8
8
  LOG_LEVEL=info
9
9
 
10
- # 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).
10
+ # How large one log file may grow before @onlineapps/monitoring-core rotates it (src/logger.js REQUIRED_FILE_BOUNDS, src/config.js logMaxSize). From monitoring-core 3.0.0 the bound has no default in code, so a service that declares it nowhere fails at logger construction by design, and the value has to be declared - it is shared because the ceiling is a property of the box every service writes onto, not a preference of one service: the age bound LOG_MAX_DAYS alone let a single day's file fill the disk (hello wrote 2.4 GB/day before d.383). The platform value is 50 MB, the same in dev and production (owner decision docs/governance/confirmations/log-file-bounds.md 001).
11
+ LOG_MAX_SIZE_BYTES=52428800
12
+
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
+ LOG_MAX_FILES=10
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). 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.
11
17
  JWT_SECRET=CHANGE_ME
12
18
 
13
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:
@@ -80,6 +80,23 @@ services:
80
80
  mem_limit: 1g
81
81
  volumes:
82
82
  - .:/app
83
+ # The runner's OWN conn-runtime, and the reason it is a volume rather than
84
+ # a second bind mount. `conn-runtime/validation-proof.json` is an assertion
85
+ # about ONE running instance
86
+ # (docs/biz/50-lifecycle/validation-responsibilities.md § Data Flow), and
87
+ # with `.:/app` alone the runner writes it into the directory the SERVICE
88
+ # reads — measured in api_biz/converter on 2026-09-16, where a proof
89
+ # produced by a probe schema stood in the place of the service's own.
90
+ #
91
+ # ANONYMOUS on purpose: F-RUNNER holds this block and
92
+ # `oa-sync-template docker-compose.yml` writes this block and nothing else,
93
+ # so a named volume would reach every service without the top-level
94
+ # `volumes:` key that declares it and `docker compose config` would refuse
95
+ # the document. Docker seeds the volume from the IMAGE at that path, which
96
+ # is why the Dockerfile's development stage creates /app/conn-runtime and
97
+ # gives it to uid 1000 — without that, docker creates it root-owned and the
98
+ # runner cannot write it at all.
99
+ - /app/conn-runtime
83
100
  env_file:
84
101
  - ./config/env-active/shared.env
85
102
  - ./config/env-active/__SERVICE_NAME__.env
@@ -24,7 +24,7 @@ the service.
24
24
 
25
25
  ```bash
26
26
  docker compose up -d --build
27
- npm run test:container
27
+ npm run test:unit:container
28
28
  npm run test:cookbooks:container
29
29
  ```
30
30