@onlineapps/conn-orch-validator 7.0.0 → 8.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 (102) hide show
  1. package/CHANGELOG.md +2558 -2
  2. package/README.md +1038 -4
  3. package/docs/DESIGN.md +3 -1
  4. package/manifests/biz-service.manifest.json +658 -0
  5. package/manifests/library.manifest.json +324 -0
  6. package/package.json +12 -6
  7. package/src/CookbookTestRunner.js +408 -101
  8. package/src/CookbookTestUtils.js +7 -8
  9. package/src/ServiceReadinessValidator.js +10 -35
  10. package/src/ValidationOrchestrator.js +219 -71
  11. package/src/cli/biz-ci-gate.js +176 -33
  12. package/src/cli/oa-lint-scripts.js +221 -0
  13. package/src/cli/oa-sync-template.js +1020 -0
  14. package/src/cli/oa-validate.js +474 -0
  15. package/src/helpers/README.md +2 -1
  16. package/src/helpers/createServiceReadinessTests.js +60 -4
  17. package/src/index.js +33 -3
  18. package/src/lint/scripts/lintScripts.js +298 -0
  19. package/src/manifest/checks/composeRunnerBlock.js +222 -0
  20. package/src/manifest/checks/composeShape.js +165 -0
  21. package/src/manifest/checks/contractBridge.js +181 -0
  22. package/src/manifest/checks/discoveryOrphan.js +50 -0
  23. package/src/manifest/checks/docsLintBridge.js +553 -0
  24. package/src/manifest/checks/fileAbsent.js +35 -0
  25. package/src/manifest/checks/gitTracked.js +204 -0
  26. package/src/manifest/checks/index.js +111 -0
  27. package/src/manifest/checks/libraryContext.js +226 -0
  28. package/src/manifest/checks/libraryDocs.js +75 -0
  29. package/src/manifest/checks/libraryPackage.js +272 -0
  30. package/src/manifest/checks/librarySource.js +274 -0
  31. package/src/manifest/checks/libraryTests.js +121 -0
  32. package/src/manifest/checks/libraryWorkspace.js +293 -0
  33. package/src/manifest/checks/readmeRegion.js +135 -0
  34. package/src/manifest/checks/scriptHeaders.js +79 -0
  35. package/src/manifest/checks/serviceConfig.js +390 -0
  36. package/src/manifest/checks/serviceConnectors.js +81 -0
  37. package/src/manifest/checks/serviceDb.js +388 -0
  38. package/src/manifest/checks/serviceFiles.js +754 -0
  39. package/src/manifest/checks/serviceIdentityRows.js +351 -0
  40. package/src/manifest/checks/serviceRuntime.js +295 -0
  41. package/src/manifest/checks/serviceScripts.js +213 -0
  42. package/src/manifest/deployabilitySignal.js +121 -0
  43. package/src/manifest/discovery.js +386 -0
  44. package/src/manifest/loadManifest.js +62 -0
  45. package/src/manifest/manifestShape.js +446 -0
  46. package/src/manifest/report.js +245 -0
  47. package/src/manifest/runManifest.js +449 -0
  48. package/src/manifest/serviceIdentity.js +140 -0
  49. package/src/manifest/walk.js +74 -0
  50. package/src/manifest/workspaceRoot.js +242 -0
  51. package/src/mocks/MockMQClient.js +13 -30
  52. package/src/mocks/MockRegistry.js +4 -2
  53. package/src/mocks/MockStorage.js +4 -2
  54. package/src/sync/docsRegion.js +463 -0
  55. package/src/sync/generatedRegion.js +228 -0
  56. package/src/sync/readmeLocation.js +182 -0
  57. package/src/sync/readmePointer.js +477 -0
  58. package/src/sync/serviceTemplate.js +583 -0
  59. package/src/sync/sharedEnv.js +162 -0
  60. package/src/sync/uniformFiles.js +474 -0
  61. package/src/utils/bizCiGateContract.js +131 -7
  62. package/src/utils/connectorContract.js +97 -7
  63. package/src/utils/cookbookFormat.js +81 -40
  64. package/src/utils/deployContract.js +140 -9
  65. package/src/utils/envContract.js +57 -1
  66. package/src/utils/handlerRef.js +181 -0
  67. package/src/utils/installContract.js +287 -41
  68. package/src/utils/libCompat.js +29 -7
  69. package/src/utils/migrationOrder.js +163 -0
  70. package/src/utils/preValidation.js +20 -7
  71. package/src/utils/setupDatabase.js +194 -13
  72. package/src/utils/testCoverageContract.js +539 -0
  73. package/src/utils/testNamespace.js +247 -23
  74. package/src/utils/throwawaySchema.js +207 -0
  75. package/src/validators/ServiceStructureValidator.js +2 -1
  76. package/templates/business-service/.dockerignore +42 -0
  77. package/templates/business-service/.gitlab-ci.yml +290 -0
  78. package/templates/business-service/Dockerfile +27 -0
  79. package/templates/business-service/README.md +213 -0
  80. package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
  81. package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +4 -0
  82. package/templates/business-service/config/env-templates/shared.env +65 -0
  83. package/templates/business-service/config/service/config.json +14 -0
  84. package/templates/business-service/config/service/integration-contract.json +12 -0
  85. package/templates/business-service/config/service/operations.json +41 -0
  86. package/templates/business-service/docker-compose.production.yml +60 -0
  87. package/templates/business-service/docker-compose.yml +93 -0
  88. package/templates/business-service/docs/80-setup/INSTALL.md +101 -0
  89. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
  90. package/templates/business-service/docs/80-setup/README.md +18 -0
  91. package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
  92. package/templates/business-service/docs/README.md +18 -0
  93. package/templates/business-service/gitignore +42 -0
  94. package/templates/business-service/index.js +10 -0
  95. package/templates/business-service/init.sh +54 -0
  96. package/templates/business-service/jest.config.js +6 -0
  97. package/templates/business-service/package.json.template +31 -0
  98. package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
  99. package/templates/business-service/src/handlers/v3/echo.js +39 -0
  100. package/templates/business-service/tests/cookbooks/echo.json +36 -0
  101. package/templates/business-service/tests/unit/handler.test.js +78 -0
  102. package/src/WorkflowTestRunner.js +0 -402
package/README.md CHANGED
@@ -1,3 +1,14 @@
1
+ > Status: current
2
+ > Owns: the validation a service runs over its own readiness — the uniform manifests, the cookbook test runner, and the mocks that stand in for infrastructure
3
+
4
+ <!-- BEGIN GENERATED: library-uniform — regenerate: npx oa-sync-template readme-uniform --all -->
5
+ Uniform: [library/orchestration](./manifests/library.manifest.json)
6
+
7
+ Duty sections that apply:
8
+
9
+ - `all`: L-MAIN, L-ENGINES, L-TESTS, L-TEST-SCRIPT, L-PACK-TESTS, L-PINS, L-NO-FILE-RANGE, L-CHANGELOG, L-README, L-README-REGION, L-CONSUMER
10
+ <!-- END GENERATED: library-uniform -->
11
+
1
12
  # @onlineapps/conn-orch-validator
2
13
 
3
14
  **Validation Orchestrator for OA Drive Microservices**
@@ -9,7 +20,7 @@ Coordinates validation across ALL layers (base, infra, orch, business) to ensure
9
20
  This is **NOT** a development testing tool. This is a **production validation orchestrator** that:
10
21
 
11
22
  1. **Validates service structure** - directories, files, configuration
12
- 2. **Validates configuration** - `config.json`, `operations.json` compliance
23
+ 2. **Validates configuration** - `config.json`, `operations.json` and `integration-contract.json`, through the manifest rows that own them
13
24
  3. **Validates the environment contract** - every `env.required` name is set
14
25
  4. **Validates operations** - the v3 handler-registry rules
15
26
  5. **Validates business logic** - cookbook tests with mocked infrastructure
@@ -38,16 +49,21 @@ runs Tier-1 validation in phase 0.2 — before MQ connects, before registration.
38
49
 
39
50
  ---
40
51
 
41
- ## Validation Process (6 Steps)
52
+ ## Validation Process (7 Steps)
42
53
 
43
54
  1. **Service Structure** - directories and files exist
44
- 2. **Config Files** - valid JSON, required fields
55
+ 2. **Config Files** - rows `C-SERVICE`, `C-OPS` and `C-CONTRACT` of the uniform manifest, run here so a
56
+ broken configuration stops the boot before steps 3-6; step 7 leaves them out because they are already
57
+ answered. The step decides nothing of its own — until d.224 it re-implemented two of those rules with
58
+ sentences of its own, and the same defect was reported twice
45
59
  3. **Environment Contract** - every variable the contract declares `env.required` is set
46
60
  4. **Operations Compliance** - every operation declares `handler`, `bundle_scope`, `input`, `output`, and none carries a retired v2 field (`endpoint`, `method`, `path`); a missing `description` is a warning
47
61
  5. **Cookbook Tests** - business logic + integration (MOCKED infra)
48
62
  6. **Connector Integration** - the connector declarations agree and the environment backs them
63
+ 7. **Manifest conformance** - the repository against the uniform manifest shipped in this package (see below)
49
64
 
50
- **Output:** Validation proof saved to `conn-runtime/validation-proof.json`
65
+ **Output:** Validation proof saved to `conn-runtime/validation-proof.json`, deployability
66
+ signal saved to `ci/deployability.json`
51
67
 
52
68
  > The list said "Service Readiness — HTTP API works" until 2026-08-27. That step
53
69
  > was removed on 2026-08-22 under ADR 0005 and this README kept describing it.
@@ -90,6 +106,970 @@ legitimate state until every repo adopts the declaration, and never silent.
90
106
 
91
107
  ---
92
108
 
109
+ ## Test-coverage contract (`stackTiers` block)
110
+
111
+ Every test file the service's jest config matches must be RUN somewhere: by the
112
+ `test:all` chain, or by a script the contract declares as a stack tier with the
113
+ reason it cannot run in `test:all`.
114
+
115
+ ```
116
+ oa-biz-ci-gate verify-test-coverage [--service-root <path>]
117
+ ```
118
+
119
+ Also folded into `verify-contract`, so a version bump activates it.
120
+
121
+ | Set | What | How it is measured |
122
+ |---|---|---|
123
+ | T | files the service jest config matches | `jest --listTests` |
124
+ | A | files the `test:all` chain runs | the chain decomposed `npm run …` → jest, each invocation asked `--listTests` |
125
+ | B | files the declared stack tiers run | the same, per declared script |
126
+
127
+ The question is always put to **jest**, and to the service's own jest resolved
128
+ from its `node_modules` — a parse of `jest --config jest.config.js tests/unit`
129
+ answers with the shape of the command, and a different jest version resolves a
130
+ different `testMatch`. The gate therefore needs the dependencies installed:
131
+ run it after `npm ci`.
132
+
133
+ A file in T that is in neither A nor B is the defect: a suite that runs nowhere
134
+ fails nowhere, so it looks like coverage and proves nothing.
135
+
136
+ ### Declaring a stack tier
137
+
138
+ ```json
139
+ "stackTiers": [
140
+ { "script": "test:e2e", "requires": "needs a live biz-hello consumer on biz-hello.workflow; CI has no service runtime" }
141
+ ]
142
+ ```
143
+
144
+ `requires` is mandatory — a tier nobody can justify is the one nobody dares
145
+ delete, and a reader of a pipeline that does not contain the suite must be able
146
+ to see why. There is no exemption by directory name: `tests/e2e/**` is not
147
+ self-evidently un-runnable, and a name cannot carry a reason.
148
+
149
+ | Violation | Reported as |
150
+ |---|---|
151
+ | a matched file that neither `test:all` nor a declaration reaches | `TEST_RUNS_NOWHERE` |
152
+ | a declared script `package.json` does not define | `STACK_TIER_SCRIPT` |
153
+ | a declared script that lists 0 test files | `STACK_TIER_EMPTY` |
154
+ | a declared script whose tests `test:all` already runs | `STACK_TIER_IN_TEST_ALL` |
155
+ | a step in the chain that is neither `npm run <script>` nor a jest command | `TEST_ALL_CHAIN` |
156
+ | an entry without `requires` | refused when the contract loads |
157
+
158
+ **Measurement boundary, printed on every run:** the gate never checks whether a
159
+ declared stack tier is actually RUN anywhere. The declaration says why the suite
160
+ cannot run in `test:all`; it does not claim something else runs it.
161
+
162
+ A contract with no `stackTiers` block is the ordinary case — it means every test
163
+ file is reachable from `test:all`.
164
+
165
+ ---
166
+
167
+ ## Manifest uniformy (`oa-validate`)
168
+
169
+ `manifests/biz-service.manifest.json` is the machine-readable declaration of what a
170
+ biz service is: sections by concern, one row per rule, each row carrying `id`,
171
+ `severity`, `owner`, `fix`, `doc` and the name of the `check` that decides it. The
172
+ manifest ships inside this package, so **the manifest version is the validator
173
+ version** — a service pins the validator exactly and thereby pins the shape it is
174
+ measured against. There is no version field in the file.
175
+
176
+ ```bash
177
+ npx oa-validate # the current directory, workspace derived from this package
178
+ npx oa-validate ../converter --workspace /path/to/workspace
179
+ npx oa-validate . --json # the same run as data
180
+ ```
181
+
182
+ One table (`id · severity · where · what · fix · owner`), the `doc` pointer of every
183
+ reported row, and exit `0` / `1`; `2` when the run could not start. Rows that resolve
184
+ a `from:` reference into the workspace (`api/config/services.json`, `api/.nvmrc`) are
185
+ printed `NOT RUN` where that reference cannot be read, and never counted as passing.
186
+
187
+ **A service run also RECORDS its verdict**, in `<serviceRoot>/ci/deployability.json` —
188
+ the machine half described under [Step 7 and the deployability signal](#step-7-and-the-deployability-signal),
189
+ written by the same builder and the same writer that step uses. That is what makes the
190
+ run from the api checkout usable by a deploy gate: until d.232 the only writer was step 7,
191
+ which runs inside the service container where the workspace SSOTs are absent, so every
192
+ signal that existed said `complete: false` — and a gate asking for `deployable && complete`
193
+ could never be satisfied, because the run that IS complete left no file behind. The
194
+ workspace mode and `--library` write nothing: the signal is a verdict about one service
195
+ tree, and neither run has one. An unwritable service root ends the run with exit `2`,
196
+ after the table has been printed.
197
+
198
+ **A rendered template directory is compared minus what the template ignores.** The
199
+ template's own `.gitignore` is the one place saying which paths of a service tree are
200
+ generated output, and `oa-sync-template template --check` reads it on both sides
201
+ (`sync/serviceTemplate.js` § `listOutputFiles`). `oa-validate` records its verdict in the
202
+ tree it measured (d.232), so a run over `api/templates/business-service` leaves
203
+ `ci/deployability.json` there; git does not see it, and since d.237 neither does the
204
+ mirror check.
205
+
206
+ **A reference into this package runs everywhere.** The template ships INSIDE the
207
+ validator (001 §2, §5), so the rows comparing a service against it reference the packaged
208
+ file — `from: { "package": "templates/business-service/jest.config.js", "text": true }` —
209
+ and resolve it from the package's own location. Those rows therefore answer in a service
210
+ container and in a service's own CI checkout, which is exactly where the uniform is for.
211
+ Measured over `api_biz/converter` on 2026-09-09: a run with no workspace used to report 13
212
+ rows `NOT RUN`, F-RUNNER and G-SHARED-ENV among them, and raised neither of the two real
213
+ findings they carry; it now reports the same findings as the workspace run, with
214
+ `U-ORPHAN` and the five `D-*` rows the only ones left `NOT RUN`. What still needs the
215
+ workspace is what reads something outside this package. `R-NODE` left that list in d.238:
216
+ the platform Node major now comes from this package's own `engines.node`, which the
217
+ library uniform's `L-ENGINES` holds equal to `api/.nvmrc`, so it is a checked projection
218
+ of the SSOT that travels with the pin rather than a second copy of the number
219
+ (confirmation `biz-service-manifest` 006 point 2).
220
+
221
+ **Where the workspace comes from.** The run derives it from where THIS package sits:
222
+ the checkout carrying `config/services.json` is the api checkout, and the workspace is
223
+ its parent. What that checkout directory is CALLED does not matter — a CI job that
224
+ clones into `infra-mono` resolves the same workspace as a developer with `api`.
225
+ `--workspace` is the explicit override and always wins. The copy installed in a
226
+ service's `node_modules` sits inside that service rather than beside the api checkout,
227
+ so it resolves no workspace of its own and the rows reading the SSOT print `NOT RUN`
228
+ with the reason — which is what a run inside a service container does.
229
+
230
+ Rows, categories and discovery patterns are the manifest's own facts; every other
231
+ fact is a `from:` reference to the file that owns it, and the suite
232
+ `tests/unit/manifestShape.test.js` fails the build on a copied list.
233
+
234
+ The service uniform is declared in eight sections, and what each one is for — with the
235
+ reason every row exists — is confirmation
236
+ [`biz-service-manifest`](../../../docs/governance/confirmations/biz-service-manifest.md) §2
237
+ and §19:
238
+ `discovery` (which directories wear the uniform, and who says they exist),
239
+ `files` (byte-identical to the template, generated from it, carrying the platform's
240
+ entries among its own, or forbidden outright),
241
+ `scripts` (the bodies a suite is run with, and what may never run without being named),
242
+ `tooling` (the repository's own `scripts/`, and what a reader can learn about one before
243
+ running it), `config` (the files a service is configured by), `runtime` (the Node major,
244
+ the memory norm and the ports it may not publish), `db` (whether it has a database, what
245
+ its SQL package is called and which account reaches it) and `docs` (what the service's own
246
+ documentation tree may not say). A row that cites a checker this package already runs — the deploy contract, the
247
+ installation contract, the environment contract, the documentation lint — names its
248
+ `requirement` or its `rule` and adds no rule of its own: the manifest cites, it never
249
+ restates (confirmation 004 point 4).
250
+
251
+ Beside the sections sits `guidance`: a duty the confirmations name for which nothing
252
+ can decide the answer yet. It carries a `why` and a `doc` and never a `check` or a
253
+ `severity`, because it produces no finding — "no check, so not a rule" (001 §6), said
254
+ out loud rather than left as a row that reports nothing and reads as a pass.
255
+
256
+ #### The script rows (`tooling`)
257
+
258
+ *(The word is used twice in this package for two different things, and they never meet:
259
+ here it is a SECTION of the service uniform, and in the library uniform below it is a
260
+ `category` a package declares. Different manifests, different axes.)*
261
+
262
+ `S-SCRIPTS` runs the SCRIPTS-STANDARD header rules over `scripts/**` of the repository:
263
+ the five fields in order with the right comment marker, the closed `Status` and `Shell`
264
+ vocabularies, the blank comment line, every `@see` target existing, and the
265
+ `sourced`/`required` words in the two library directories. What an `Owns:` sentence SAYS
266
+ is true no linter can check; content accuracy stays review, said plainly.
267
+
268
+ Which of those two words a library owes is decided by its EXTENSION, not by which of the
269
+ two directories it lies in (`S007`, d.249). The directory says one thing only — "a library,
270
+ not an entry point", so the `Usage:` line carries a word instead of a command line — and
271
+ `.sh` is `sourced` while a Node module is `required`. Until d.249 the rule read
272
+ `scripts/lib/` as shell and `scripts/ci/lib/` as Node, which is what the tree looked like
273
+ when it was written and is not what the tree is: BIZ-ingest measured a Node library under
274
+ `scripts/lib/` in their repository and seven in meta's, none of which could go green
275
+ without writing `sourced` about a file nothing sources. A rule satisfied only by an untrue
276
+ sentence is the one `architecture-principles.md` §10 forbids weakening a test to meet, so
277
+ the rule moved and the files stayed. Measured 2026-09-11, before and after: ingest 1
278
+ finding → 0, meta 7 → 0, `api/scripts` 0 → 0 across 105 files.
279
+
280
+ The rules ship in this package — `src/lint/scripts/lintScripts.js`, with
281
+ `oa-lint-scripts` as its command — and that is the whole reason the row can exist.
282
+ They used to be a file of the `api` checkout, and a file of one checkout cannot decide a
283
+ rule about eight repositories: a service has no `api/` directory in its own CI and none
284
+ at all inside its image. `api/scripts/ci/lint-scripts.mjs` now calls this module and holds
285
+ no rule of its own, so `npm run scripts:lint`, the CI job `validate-scripts` and this row
286
+ are one implementation (confirmation `biz-service-manifest` 003 §19).
287
+
288
+ One thing in those rules needs a second root, and only when it is used: a script may cite
289
+ `api/docs/…`, which names the api checkout beside the repository rather than a `docs/` of
290
+ its own. With the workspace reachable the citation is resolved there; without it the lint
291
+ returns the target as UNRESOLVED and the row is `NOT RUN` with the targets named — a run
292
+ that could not look must not report a pass. Measured 2026-09-10 over the eight services:
293
+ 47 `@see` lines under their scripts directories, 4 of them with that prefix — and 86
294
+ scripts of which not one carried a header.
295
+
296
+ #### The SQL rows (`db`)
297
+
298
+ Beside `D-DB-CONSISTENT` (does the repository agree with what it declared) and the two
299
+ rows citing the installation contract, three rows say what the package is CALLED and who
300
+ reaches it. All three ask their question only of a repository that DECLARES a database, so
301
+ a stateless service is silent — `pdfgen` is the measured case.
302
+
303
+ * **`D-DB-NAMING`** — `migrations/*.sql` reads the shape
304
+ `api/docs/biz/70-contracts/database-contract.md` §5 prescribes: three digits, an
305
+ underscore, a lowercase description. The row reads that node back rather than inventing
306
+ a stricter rule of its own, so `000_baseline.sql` — §5's own example, and an applied
307
+ migration by §8 — is a name and not a finding. No leading verb is demanded either,
308
+ because the measured counter-example is right (`009_cnv_batch_drop_pending_status.sql`
309
+ opens with a table prefix), and the three digits are not decoration (`999a_` sorts after
310
+ `999_` only by accident of the byte after the digits). The convention reaches one level
311
+ and stops: what `BASELINE/` and `SEED/**` must CARRY is the installation contract's §4,
312
+ which `D-DB-HEADERS` already checks, and that contract names no shape for their file
313
+ names at all.
314
+ * **`D-DB-README`** — the installation contract §3 asks a repository with a database for
315
+ four things and `SQL_PACKAGE` checks three of them. The fourth is `migrations/README.md`,
316
+ the documented execution order that reproduces a working instance, and until d.218
317
+ nothing looked for it.
318
+ * **`D-DB-ACCOUNT`** — `DB_USER` in this service's env template reads
319
+ `oagen_<shortname>`, from the same derivation `C-IDENTITY` holds `database.schema` to
320
+ (confirmation `db-accounts-per-service` 001: root stops being an operational identity —
321
+ it was the identity of nine containers across eight repositories). A repository that
322
+ declares a database and no `DB_USER` is a finding and not a silence.
323
+
324
+ One more row about the database sits outside this section on purpose. **`X-DB-CONFIG`**
325
+ is in `files.forbidden`, beside `X-HOOKS` and `X-PREVAL`, and it forbids one path:
326
+ `src/config/database.js`. Seven of the eight services carried that file — a module
327
+ singleton building its own Sequelize — beside `@onlineapps/conn-base-db`, whose
328
+ `createSequelize` exists to be the one connection factory
329
+ (`.claude/rules/change-discipline.md` § One rail per concern); the 8.0.0 cascade commit of
330
+ each service switches its importers and deletes the file, and this row is what keeps it
331
+ deleted (confirmation `biz-service-manifest` 009). Unlike the three rows above it does NOT
332
+ ask `requiredConnectors.db` first: 009 names the path with no condition on it, and a
333
+ service that declared no database has even less business carrying a connection factory. A
334
+ repository without the file is silent either way — `pdfgen` is the measured case here too.
335
+
336
+ #### What `.gitignore` may NOT hide: the `tracked` class
337
+
338
+ `F-GITIGNORE` above says what the file must CONTAIN. **`X-IGNORED`** (`files.tracked`,
339
+ check `git-tracked`) says what it may not cover, and the two together are the answer to the
340
+ owner's sentence: a biz service has no right to ignore something the uniform requires.
341
+
342
+ Every file row reads the WORKING TREE. What a clone, a CI checkout and the production image
343
+ receive is what git TRACKS, and those are not the same thing — measured 2026-09-10 on a copy
344
+ of converter outside the repository:
345
+
346
+ | run | result |
347
+ |---|---|
348
+ | `init.sh` and `jest.config.js` in `.gitignore`, both files on disk | `F-INIT`, `F-JEST`: **no findings** |
349
+ | the same two files deleted — what a clean clone has | both fire, NOT DEPLOYABLE |
350
+
351
+ Developer green, CI and image red. The row reads **no list of its own**: the paths are the
352
+ ones `files.identical`, `files.contains` and `files.generated` already name, read from the
353
+ block it sits in, so a new file row extends this one too (004 point 2). A directory among
354
+ them covers its whole tree — `scripts/` is the case that matters, because `S-SCRIPTS` lints
355
+ the headers of scripts the clone may never receive. `files.forbidden` is excluded on
356
+ purpose (demanding a banned file be tracked is a contradiction), and so is `files.own`.
357
+
358
+ The question is TRACKED, not IGNORED: a tracked file matching an ignore line is harmless,
359
+ an untracked one is gone from the clone whether a rule covers it or not. `git check-ignore`
360
+ is asked only about the files that already failed, because that is what turns the finding
361
+ into an edit. Outside a git checkout — a container, an exported tarball — the row is NOT RUN
362
+ with that reason, never a pass.
363
+
364
+ #### The template's own completeness: the `own` class
365
+
366
+ `files.own` is not an exemption list; it is where the manifest says, for each file the
367
+ template carries, that the SERVICE decides its content. `tests/unit/templateCoverage.test.js`
368
+ walks `templates/business-service/**` and demands, for every file, either a row naming its
369
+ path or membership of that class. Adding a file to the template is therefore a decision
370
+ about who guards it, taken in the manifest — and the list is in the manifest rather than in
371
+ the test precisely so that the next file cannot be exempted by editing a test
372
+ (`.claude/rules/change-discipline.md` § One rail per concern). It was the missing check
373
+ behind two files that had no row at all: `gitignore` (now `F-GITIGNORE`) and
374
+ `.gitlab-ci.yml` (now `G-CI`).
375
+
376
+ #### `.gitignore`: the `contains` class
377
+
378
+ `F-GITIGNORE` compares the ignore ENTRIES the packaged template declares against the
379
+ entries the repository declares — any order, every other line left alone. The class exists
380
+ because this file is neither of the other two: it cannot be `identical` (a repository
381
+ ignores its own scratch directories, and nine different files were measured across the
382
+ eight services), and nothing renders it from a declaration. What must be true of it is a
383
+ SET: every path this platform WRITES into a repository is a path git must not see, and
384
+ `ci/deployability.json` is the measured case — `oa-validate` and the pre-push hook of
385
+ confirmation 006 both write it, and in four of the eight repositories every run left it
386
+ untracked. A narrower pattern does not satisfy the entry it narrows:
387
+ `config/env-active/*.env` leaves every non-`.env` file in a directory of live secrets
388
+ committable.
389
+
390
+ The generator still writes a labelled `# --- oa-ignore v1` block, so a reader can tell the
391
+ platform's lines from the repository's own — but the block is the FIX's shape and never
392
+ the check's, and the renderer removes an existing one before it computes what is missing,
393
+ so a second run adds no second block.
394
+
395
+ #### The documentation rows (`docs`)
396
+
397
+ The rules of `<service>/docs/**` already exist and already have an owner:
398
+ `api/scripts/ci/lint-biz-docs.mjs`, held by BIZ-DOCS. So the rows here decide nothing —
399
+ each citing row names the lint rule it stands for, in the lint's own `--rules` syntax,
400
+ and `src/manifest/checks/docsLintBridge.js` runs the lint ONCE per service and hands each
401
+ row the findings of the rules it cites, with the lint's own `file:line`:
402
+
403
+ | row | cites | what it stands for |
404
+ |---|---|---|
405
+ | `D-PORT` | `F002:http-ports` | no document sends an operator to a port nothing listens on |
406
+ | `D-NPM` | `L016` | a cited npm script is one this repository declares |
407
+ | `D-SCRIPT` | `L007` | a cited script path exists in this repository |
408
+ | `D-RETIRED` | `F002` | no live document teaches a retired concept |
409
+ | `D-HEADER` | `S001,S002` | every node opens with `Parent:` and `Owns:` |
410
+ | `D-LINT` | — | the lint reports nothing else over this tree |
411
+
412
+ A finding lands on exactly ONE row: the most specific citation wins, so the ban on
413
+ localhost ports goes to `D-PORT` rather than twice, and two rows citing one rule
414
+ equally are a fail-fast.
415
+
416
+ `D-LINT` is the catch-all and cites nothing — deliberately. What the rows above buy is
417
+ their own `fix` sentence, not coverage: measured 2026-09-09 over the eight repositories,
418
+ 80 lint findings of which 16 fell to a citing row, so a tree the documentation gate called
419
+ broken came back DEPLOYABLE here. A citation list on the catch-all would have to be held
420
+ equal to the linter's and would fall behind it the first rule BIZ-DOCS wrote, so the row
421
+ names none and takes every error-graded finding the rows above did not claim.
422
+
423
+ It counts what the LINT graded `error` — the set its own `--severity error` prints. That
424
+ is the whole of the safeguard: grading belongs to BIZ-DOCS
425
+ (`api/config/biz-docs-lint.json` § severities), so a rule they add at `warn` is visible in
426
+ their run and blocks no deploy until they raise it on their own dated transition
427
+ (`automation-gates.md` §3). The documentation gate itself is unchanged and still lands in
428
+ each repository's CI at zero findings (confirmation
429
+ [`biz-docs-foreign-tree-gate`](../../../docs/governance/confirmations/biz-docs-foreign-tree-gate.md)).
430
+
431
+ The rows are `bearer`-scoped and declare `api/scripts/ci` as a root they read, so in a
432
+ service image and in a service's own CI — where there is no api checkout — the runner
433
+ reports them `NOT RUN` with that reason. `C-LINT`, the row owning
434
+ `config/biz-docs-lint.tree.json`, lives in this block too: the rows are INVOKED with the
435
+ budget that file declares, so the path is written once, on the row, and the block names
436
+ the row (`tree_config_row`) rather than repeating the path. A tree with no such file is
437
+ reported as undecided rather than clean; an empty `docs/` raises nothing, because the
438
+ absence of the tree is `G-SETUP`'s finding, not a second one with a different fix.
439
+
440
+ **A rule the lint could not EVALUATE is `NOT RUN`, not a finding.** The linter has its own
441
+ NOT RUN channel — that is what `--allow-missing-siblings` fills — and a ban whose evidence
442
+ probe reads `api_biz/*` is undecidable in a checkout that has no siblings. Measured
443
+ 2026-09-10 over `git archive HEAD` into such a directory: the template mirror came back
444
+ `NOT DEPLOYABLE` on `D-PORT` and `D-RETIRED`, whose bans nobody had violated, while
445
+ `U-ORPHAN` in the same run said the honest thing. The two channels of the lint now map onto
446
+ the two of this uniform: its `findings` are findings, its `skipped` list is `NOT RUN`. A
447
+ tree the lint REFUSES outright (no tree config, a broken configuration) stays a finding,
448
+ because that one has a fix somebody can carry out.
449
+
450
+ `D-NPM` was guidance until 2026-09-10, and the guidance was true while it stood: no rule of
451
+ the lint raised it, and a row citing a rule nobody raises reports nothing and reads as a
452
+ clean tree (001 §6). BIZ-DOCS wrote `L016`, which resolves a cited `npm run <name>` against
453
+ the `package.json` above the tree — exactly one file for a service repository — so the row
454
+ exists now.
455
+
456
+ #### The three configuration files, and who decides them
457
+
458
+ Confirmation
459
+ [`biz-service-manifest`](../../../docs/governance/confirmations/biz-service-manifest.md)
460
+ §18 asks that `config/service/config.json`, `operations.json` and
461
+ `integration-contract.json` be decided by schemas shipped with the validator. Two of
462
+ the three already had an owner, so no second notation was introduced for them
463
+ (`change-discipline.md` § One rail per concern):
464
+
465
+ | file | row | decided by |
466
+ |---|---|---|
467
+ | `operations.json` | `C-OPS` | `service-validator-core` `validateOperationsSchema` — the mirror of the fifteen rules the Registry applies on registration |
468
+ | `integration-contract.json` | `C-CONTRACT` | `utils/bizCiGateContract.js` — the validator the CI gate already runs over this document, so the gate and the table can never disagree about it |
469
+ | `config.json` | `C-SERVICE` | `src/manifest/checks/serviceConfig.js`, which had no owner before |
470
+
471
+ `C-IDENTITY` is the row about the NAME rather than about one file. `naming.md` derives
472
+ every spelling of a biz service from a single short name, and says why that is not
473
+ tidiness: the gateway compares `service_code` from the entitlement data against the
474
+ service name in a cookbook step character by character, so a second spelling is a request
475
+ that gets refused. The row takes the short name from `service.name` and holds the npm
476
+ `name`, `container_name` in both compose files, `config/env-templates/<shortname>.env`
477
+ with the `env_file` entries loading it, and `database.schema` where the repository
478
+ declares one, to that one name. It reads nothing outside the repository, so it answers in
479
+ a container too. Its workspace half is `U-IDENTITY`, beside `U-ORPHAN`: `directory`,
480
+ `repo` (last path segment) and `container` in `api/config/services.json` against the same
481
+ short name. Measured 2026-09-10 over the eight live services: seven wear one name each and
482
+ `hello-service` wore four.
483
+
484
+ `C-SERVICE` demands the three keys that were **measured** to have a reader:
485
+ `service.name` (the identity the wrapper registers under), `service.workspaceScoped`
486
+ (an explicit boolean — the wrapper refuses to boot without one, and it decides whether
487
+ `list-workspaces` must exist) and `service.specificationEndpoint`. Everything else the
488
+ file carries is the service's own business and passes untouched.
489
+
490
+ The same measurement found declarations with **no reader anywhere**, and
491
+ `C-SERVICE-DEAD` (severity `deploy`) refuses them rather than letting each new service
492
+ copy them forward: `service.version` (`ConfigLoader` overwrites it from `package.json`
493
+ on every load), `service.port` (a biz service binds none), `wrapper.registry.url`
494
+ (registration travels over MQ to `registry.register`), `wrapper.tenantContext` (its only
495
+ consumer `createTenantContextMiddleware` and the runtime default were deleted
496
+ together, confirmation
497
+ [`wrapper-tenant-middleware`](../../../docs/governance/confirmations/wrapper-tenant-middleware.md)
498
+ 001) and `contractVersion` in the integration contract. The keys themselves are listed
499
+ once, in `DEAD_KEYS` (`src/manifest/checks/serviceConfig.js`), and the manifest row's
500
+ `why` is held equal to that list by its own test — so this paragraph names the class,
501
+ never the count. The fix is always the same sentence — delete the key.
502
+
503
+ `service.url` is the one entry carrying a **date**: it is dead from wrapper **8.0.0**,
504
+ not today. The published 7.0.0 still throws
505
+ `Missing configuration - service.url is required`, so the key travels WITH the pin, which
506
+ is what confirmation
507
+ [`biz-service-port-url`](../../../docs/governance/confirmations/biz-service-port-url.md)
508
+ 001 already said. A rule claiming the key has no reader today would send a service pinned
509
+ to 7.0.0 into a failed boot; the row names the work the cascade carries, and its `deploy`
510
+ severity means it never stops one.
511
+
512
+ ### Step 7 and the deployability signal
513
+
514
+ The same check is step **7/7** of the boot validation (`ValidationOrchestrator`), so a
515
+ service measures itself at every start against the manifest its pin selected. The two
516
+ blocking severities have different consequences, and that difference is the point
517
+ (confirmation `biz-service-manifest` 001 §4):
518
+
519
+ | Severity | Boot | `results` | What the operator sees |
520
+ |---|---|---|---|
521
+ | `boot` | refused | `success: false`, the finding in `errors` | the service does not start |
522
+ | `deploy` | **succeeds** | `success: true`, `deployable: false`, the finding in `deployFindings` | the banner below, at every start, until it is gone |
523
+ | `warn` | succeeds | table only | the row in the banner's table |
524
+
525
+ ```
526
+ [ValidationOrchestrator] NOT DEPLOYABLE — 2 finding(s)
527
+ [ValidationOrchestrator] id severity where what … fix owner
528
+ [ValidationOrchestrator] X-HOOKS deploy hooks/pre-commit … rm -f hooks/pre-commit BIZ-general
529
+ [ValidationOrchestrator] X-PREVAL deploy scripts/run-pre-validation.js … rm -f scripts/run-pre-validation.js BIZ-general
530
+ ```
531
+
532
+ A clean run says so in one line, and says what it could not look at, because silence is
533
+ the one outcome a reader cannot interpret and `no findings` alone reads as a proven clean
534
+ bill of health over rows nobody opened. There are three of those lines, and which one is
535
+ printed is decided by WHAT did not run (`report.js` § `describeClearOutcome`, the one
536
+ owner this banner, the `oa-validate` report and the library report all use):
537
+
538
+ ```
539
+ DEPLOYABLE — manifest conformance: no findings
540
+ DEPLOYABLE — manifest conformance: no findings (1 row(s) not run)
541
+ DEPLOYABLE — manifest conformance: no findings, INCOMPLETE: 6 deploy row(s) not run
542
+ (U-ORPHAN, U-IDENTITY, D-PORT, D-SCRIPT, D-RETIRED, D-HEADER). Fix: run the uniform from
543
+ the api checkout — npx oa-validate --workspace <workspace root>
544
+ ```
545
+
546
+ The first is a run that answered every row. The second is a run that missed a row which
547
+ could not have blocked anything (`warn`). The third is a run that missed a row which
548
+ COULD have — and then the verdict is true only of the half that ran. The sentence itself
549
+ belongs to the uniform (`verdict.incomplete` in the manifest, with `{count}` and `{ids}`
550
+ in it), never to the renderer: a library says `publish row(s)` there, and
551
+ `PUBLISHABLE — no findings` where nothing was skipped.
552
+
553
+ The same verdict is written, in the same step, to **`ci/deployability.json`** of the
554
+ service root, which `deploy-production` reads and refuses on a single row. The `oa-validate`
555
+ CLI writes the same file, through the same builder and writer
556
+ (`src/manifest/deployabilitySignal.js`), for the service run it just printed — one file
557
+ shape with two producers, never two shapes:
558
+
559
+ ```json
560
+ {
561
+ "validator": "@onlineapps/conn-orch-validator",
562
+ "validatorVersion": "<the version this service pins>",
563
+ "uniform": "biz-service",
564
+ "serviceRoot": "/srv/biz-hello",
565
+ "deployable": false,
566
+ "complete": false,
567
+ "findings": [{ "id": "X-HOOKS", "severity": "deploy", "where": "…", "what": "…", "fix": "…", "owner": "…", "doc": "…" }],
568
+ "notRun": [{ "id": "U-ORPHAN", "severity": "deploy", "reason": "…" }]
569
+ }
570
+ ```
571
+
572
+ **Two claims, not one.** `deployable` says nothing blocked among the rows that RAN;
573
+ `complete` says no row that CAN block this bearer was skipped. Inside a service container
574
+ the rows reading the workspace cannot run at all, so a signal is routinely deployable and
575
+ incomplete at once — and a gate reading only the first was honouring a `--skip` nobody had
576
+ flagged (`automation-gates.md` §1.5). **The deploy gate takes a signal only when both are
577
+ true**; the complete run is produced from the api checkout with
578
+ `npx oa-validate --workspace <root> <service root>`, and since d.232 that run writes the
579
+ file it is asked for. A service root OUTSIDE the workspace root cannot produce a complete
580
+ signal at all — the rows reading the SSOTs report `NOT RUN` for exactly that reason, so
581
+ the gate's complete run measures the checkout, never a copy of it. Neither field changes an exit code: a partial run in
582
+ the container is the intent, and completeness is the gate's question, not the boot's.
583
+
584
+ It is the shape of `ci/integration-signal.json` with three deliberate differences:
585
+ no `generatedAt` (an unchanged tree must produce an unchanged file, or nothing can tell
586
+ a re-run from a change), `validatorVersion` instead of a version of its own (the
587
+ manifest version IS the validator version), and `deployable` instead of a
588
+ `gate: {verdict, reason}` block (one fact, one owner). `notRun` is carried on purpose:
589
+ rows that could not look are not passes, and each entry carries the `severity` of the row
590
+ that did not run — which is what decides `complete`.
591
+
592
+ There is no switch that skips the step and none that moves the file — one path,
593
+ unbypassable.
594
+
595
+ ### Two run modes, three scopes
596
+
597
+ Every check declares what it needs in order to look, and that word alone decides where
598
+ the runner runs it. There are three of them (`src/manifest/manifestShape.js`
599
+ § `CHECK_SCOPES`):
600
+
601
+ | Scope | What the check needs | Example row |
602
+ |---|---|---|
603
+ | `service` | the bearer's own root — all there is inside a container | `S-TEST`, `R-MEM` |
604
+ | `workspace` | the workspace, because the check is ABOUT it | `U-ORPHAN` |
605
+ | `bearer` | the bearer's root, plus whatever its `from:` reference names — the workspace (`L-PINS` reads `api/config/libraries.json`) or this package itself (`F-JEST` reads the packaged template, `R-NODE` its own `package.json`) | `F-JEST`, `R-NODE`, `L-PINS` |
606
+
607
+ | Mode | Command | `service` rows | `workspace` rows (`U-ORPHAN`) | `bearer` rows |
608
+ |---|---|---|---|---|
609
+ | service | `oa-validate [serviceRoot]` | run | only findings under that service's root | run, for that one bearer |
610
+ | workspace | `oa-validate --workspace <root>` (no service root) | `NOT RUN` | **all** of them | run, **once per discovered bearer** |
611
+
612
+ A neighbour's orphan is the neighbour's blocker, so it does not appear in this service's
613
+ table; the complete list is the workspace run, which is that check's home.
614
+
615
+ `bearer` exists because `F-JEST` is neither of the other two: it is a fact of ONE service,
616
+ and it reaches the template only through the workspace. Made `workspace` — which it was
617
+ until 7.0.0 — it had to answer "no findings" in workspace mode, and that answer is
618
+ indistinguishable from a clean workspace. Measured over the `service-workspace` fixture on
619
+ 2026-09-09: the workspace run printed `DEPLOYABLE — no findings` while two of its four
620
+ services violated four of those rows between them. Who the bearers are is the manifest's
621
+ own `discovery` block, never a second walk.
622
+
623
+ **A checkout that does not carry its siblings.** CI checks out `api/` alone, so
624
+ `<workspace>/api_biz` is not there. A check that reads it — `L-CONSUMER` asks who pins this
625
+ package, `L-TOOLING` which service carries it, `U-ORPHAN` which directory nobody declared —
626
+ would walk an absent directory and report the empty set as an answer. So a check declares
627
+ the directories it walks (`requiresSiblings({ row, block })`, derived from the row's own
628
+ patterns), and the runner verifies them before running it:
629
+
630
+ ```
631
+ NOT RUN L-CONSUMER — sibling root api_biz is not present in this checkout
632
+ ```
633
+
634
+ Measured against a `git archive` export of this repository on 2026-09-09: without the gate,
635
+ `--library --all` raised a blocking `L-CONSUMER` on `conn-base-db` about files nobody
636
+ opened. Never a finding, never a pass (`automation-gates.md` §5).
637
+
638
+ One case is neither mode: a service root that does not lie under the given workspace root.
639
+ Filtering there would drop every finding and the empty table would read as "the row
640
+ looked and found nothing", so each row that needs the workspace — `workspace` and `bearer`
641
+ alike — is reported `NOT RUN — service root is outside the workspace root <root>` instead,
642
+ in the banner and in the signal alike.
643
+
644
+ ### Uniforma knihoven (`oa-validate --library`)
645
+
646
+ `manifests/library.manifest.json` is the same concept for the shared libraries, in the
647
+ same package, carrying the same version — so the two shapes cannot drift apart
648
+ (confirmation 002 §12). A library has no boot, so its rows carry one severity:
649
+ `publish`. The choke point is `scripts/publish-library.sh`, and what fails there never
650
+ becomes an artefact and therefore never reaches a service.
651
+
652
+ ```bash
653
+ npx oa-validate --library ../../logger-contract # one package
654
+ npx oa-validate --library --all # every package the uniform finds
655
+ npx oa-validate --library --all --workspace /path/to/workspace --json
656
+ ```
657
+
658
+ One table (`id · category · where · what · fix`), the `doc` pointer of every reported
659
+ row, exit `0` / `1`; `2` when the run could not start. `--library` with neither a
660
+ package directory nor `--all` is a usage error — nothing is guessed.
661
+
662
+ **Who wears the uniform** is discovery, not a list: `api/shared/**/package.json` minus
663
+ `**/node_modules/**` and `**/tests/fixtures/**`, bound to `api/config/libraries.json`.
664
+ A package the SSOT does not know, and a name in the SSOT with no package, are both
665
+ `U-ORPHAN`. No library name appears in the manifest (004 point 3); the exclusion of the
666
+ validator's own fixtures is a row of the manifest, never a branch in the code.
667
+
668
+ **The category** is the one fact about a library that cannot be derived, so it is
669
+ declared — `"oa": { "category": "core" }` — and checked against the dependency graph
670
+ (005 point 3). A missing declaration and a declaration the graph contradicts are both
671
+ `U-MISMATCH`.
672
+
673
+ | category | layer | may depend on |
674
+ |---|---|---|
675
+ | `core` | L1 | nothing under `@onlineapps` |
676
+ | `connector` | L2 | `core`, `connector` |
677
+ | `orchestration` | L3 | `core`, `connector`, `orchestration` |
678
+ | `runtime` | L4 | `core`, `connector`, `orchestration`, `runtime` |
679
+ | `tooling` | — | any of the above |
680
+
681
+ Within a layer is allowed; upward is not (`architecture-principles.md` §7 forbids a
682
+ reverse dependency, not a neighbouring one). The list a category may depend on includes
683
+ itself, explicitly, so no rule is implied anywhere.
684
+
685
+ **The rows.** `all`: `L-MAIN`, `L-ENGINES` (the platform major, referenced from
686
+ `api/.nvmrc`), `L-TESTS`, `L-TEST-SCRIPT`, `L-PACK-TESTS`, `L-PINS`, `L-NO-FILE-RANGE`,
687
+ `L-CHANGELOG`, `L-README`, `L-README-REGION`, `L-CONSUMER`. Per category: `L-CORE-DEPS`, `L-CORE-EXPORTS`,
688
+ `L-CONNECTOR-ENV`, `L-RUNTIME-CLIENT`, `L-TOOLING`. `orchestration` needs no row of its
689
+ own — "depends only downward" is exactly what `U-MISMATCH` decides, and a second row
690
+ would be a second owner of one rule.
691
+
692
+ `L-CONNECTOR-ENV` asks its question of CODE. It reads each `src/**.js` with comments, the
693
+ text of string and template literals, and regular-expression literals blanked out
694
+ (`checks/librarySource.js` § `codeOnly`); what a template literal interpolates is code
695
+ again, because `${process.env.X}` really does read the environment. Until d.239 it matched
696
+ the raw file text, so a module documenting the duty it obeys — "this module reads no
697
+ `process.env`" — was reported for the sentence in its own header.
698
+
699
+ `L-PACK-TESTS` measures the **effect**, not the file: eight packages keep `tests/` out of
700
+ the tarball with a `files` allowlist and no `.npmignore` at all, and a row checking for
701
+ the file would report eight packages that are already right.
702
+
703
+ **What is deliberately NOT a row** is written down as such, in the manifest's `guidance`
704
+ block, with the reason and the decision it rests on — a duty nothing can decide is
705
+ guidance, never a rule (001 §6). Today: `L-LINT` (002 §14 — one shared config or none,
706
+ and "leave as is" is not an option), the logger/error contract, the boot-phase messages,
707
+ "a removed export bumps the major", and the canonical body of the `test` script. The
708
+ shape check fails the build if a guidance entry grows a `check`.
709
+
710
+ Decision and specification: [`api/docs/governance/confirmations/biz-service-manifest.md`](../../../docs/governance/confirmations/biz-service-manifest.md)
711
+ (entries 001–005).
712
+
713
+ ---
714
+
715
+ ## Generated files (`oa-sync-template`)
716
+
717
+ ### The business-service template lives here
718
+
719
+ `templates/business-service/**` ships **inside this package**, so the shape a service is
720
+ created from is pinned by the same version that pins the manifest it is measured against
721
+ (confirmation 001 §2). `api/templates/business-service` is no longer that source: it is
722
+ this generator's output, committed for reading (§5), and the acceptance §9 asks for —
723
+ "differs from the generator's output by nothing" — is measured by
724
+ `tests/unit/templateMirror.test.js`, file by file, in both directions.
725
+
726
+ Two files are packed under a different name than they are written under, and the map is
727
+ in `src/sync/serviceTemplate.js` (`PACKED_NAMES`). `gitignore` has no dot because npm
728
+ never packs a file called `.gitignore`. `package.json.template` carries a suffix because a
729
+ `package.json` inside a package is a second package to every tool that walks a package
730
+ tree: measured 2026-09-09 under its written name, `oa-validate --library --all` reported
731
+ eight findings about the template and `api/scripts/ci/verify-manifest-pins.mjs` — step 1
732
+ of the pre-push hook — reported two more, all false. The template's pins are deliberately
733
+ NOT the platform's; the generator writes the SSOT's versions when it creates a service.
734
+ Every other file is packed and written under the name it has.
735
+
736
+ `F-DOCKERIGNORE` is `F-GITIGNORE`'s row one step further along, over the SAME
737
+ declaration. `Dockerfile` line `COPY . .` takes everything `.dockerignore` does not
738
+ exclude, and being ignored by git excludes nothing at all: measured by BIZ-hello on
739
+ 2026-09-11, a LOCAL production image of hello came to 6.6 GB — 5.2 GB of it `logs/` — and
740
+ carried `config/env-active/*.env` with `DB_PASSWORD`, `JWT_SECRET`, the MinIO keys and
741
+ `RABBITMQ_URL`. The image CI builds is clean by accident, because a fresh checkout has no
742
+ ignored file to copy, so the defect is invisible exactly where the image is built.
743
+
744
+ The entries are DERIVED from `templates/business-service/gitignore` rather than written a
745
+ second time, and the derivation is a translation, not a copy: a slash-less `.gitignore`
746
+ pattern matches at every depth while a `.dockerignore` one is matched from the context
747
+ root, so `*.log` there catches `debug.log` and not `logs/debug.log`. Every slash-less entry
748
+ is therefore emitted in both shapes, an entry carrying a slash travels unchanged, a
749
+ negation keeps its `!` in front and its place in the order, and `.git` is added — the one
750
+ exclusion git can never declare about itself. What the derivation cannot do is exclude
751
+ something `.gitignore` does not: `tests/` is the trap on that side, because Tier-1 of the
752
+ boot reads `tests/cookbooks` and an image without it fails at phase 0.2.
753
+
754
+ The criterion is a comparison and never a list of directories, which is how hello found it:
755
+ the files in `/app` of an image built from the WORKING TREE, `node_modules` aside, are the
756
+ files of an image built from `git archive HEAD`. `tests/unit/dockerignoreBuildContext.integration.test.js`
757
+ measures exactly that through docker itself — `FROM scratch` + `COPY . /app` exported
758
+ locally, so the matcher under test is BuildKit's own — and says NOT RUN with the reason
759
+ when there is no daemon to ask.
760
+
761
+ `G-CI` holds ONE delimited block of `.gitlab-ci.yml` — `oa-ci v1` — to the packaged
762
+ template, rendered for this service. The block carries `include:`, `workflow:`, `stages:`,
763
+ `variables:`, `verify-installation-contract:`, `build:`, `secret_detection:` and
764
+ `deploy-production:`: the pipeline's platform half, which decides WHICH pipelines run at
765
+ all, builds the same way everywhere, holds the installation document and the migrations to
766
+ each other, pins the deploy to the digest CI pushed (`R1`, `R2` and `R5` read this file)
767
+ and carries the step below. It is RENDERED rather than identical because that half names
768
+ the service once, in `GIT_CLONE_PATH`.
769
+
770
+ One key is outside the markers, and the sync never rewrites a line of it: `test:`. What
771
+ separates it from the rest is not where it happened to sit but who decides it — which
772
+ database an integration needs, which `ci:gate:*` steps run and which artefact it publishes
773
+ are facts about the service. `workflow:` and `verify-installation-contract:` looked like
774
+ the same kind of thing until they were measured: on 2026-09-11 all eight repositories
775
+ carried each of them BYTE identical, one variant apiece, and what they say is the
776
+ platform's, so they joined the block (d.251b). `test:` was the only key that actually
777
+ differed, in all eight.
778
+
779
+ The one thing the migration does move is position: a repository whose platform keys sit
780
+ between its own keys — all eight, today — gets the block where the first platform key
781
+ stood, so `test:` ends up after it, TEXT UNCHANGED. YAML mappings are unordered, and the
782
+ alternative would be a block that is not one region. The reason the row is a block at all
783
+ is a measurement and a rule: on 2026-09-11 the template was 151 lines and 7 root keys while
784
+ the eight services carried 176 to 198 lines and 9, and the difference that mattered was the
785
+ `test` job standing up MariaDB, Redis and RabbitMQ as service containers, five `ci:gate:*`
786
+ steps and the `ci/integration-signal.json` artefact. A whole-file row would have had a fix
787
+ that deleted eight integration tiers — a finding nobody can carry out
788
+ (`.claude/rules/automation-gates.md` §3). `stages:` stays in the block and declares all
789
+ four stages, the service's own jobs included; no biz service declares a fifth.
790
+
791
+ The fix is therefore `npx oa-sync-template .gitlab-ci.yml --target .`, in three states: a
792
+ file carrying the block is rewritten between its markers; a file carrying the platform's
793
+ keys UNMARKED — all eight, today — has exactly those keys replaced by the block where the
794
+ first of them stood, so no file ends up with two `build:` keys; a file that is not there is
795
+ created whole, which is what `--new` writes. Which keys the block claims is read from the
796
+ block itself (`src/sync/serviceTemplate.js` § `topLevelKeys`), so adding a job to the
797
+ platform's half is one edit to the template.
798
+
799
+ The template's `.gitlab-ci.yml` runs the conformance check once more in its
800
+ `deploy-production` job, before the SSH step — deploy is confirmation, never discovery
801
+ (confirmation 006 point 3, placed by 008). The step is one file,
802
+ `scripts/verify-deploy-uniform.sh`, called with three arguments: the checkout GitLab made,
803
+ the api repository URL carrying the job token, and the api ref production follows. It
804
+ clones `api` read-only beside the checkout, runs `oa-validate <serviceRoot> --workspace
805
+ <root>` with the engine this repository's pin installed, and refuses the deploy on one
806
+ blocking finding or on `complete: false`. Nothing turns it off.
807
+
808
+ The layout is the whole of why it can be complete: the rows reading a platform SSOT answer
809
+ only for a service at `<root>/api_biz/<service>` beside `<root>/api`. Measured 2026-09-11
810
+ over a converter export — in that layout `complete: true` in 1.1 s; as unrelated siblings,
811
+ seven blocking rows NOT RUN and `complete: false`. So the job sets `GIT_CLONE_PATH` to put
812
+ its own checkout there (no symlink, no second copy) and the script refuses any other
813
+ layout by name. Whether a real pipeline reaches `complete: true` is not measured here: it
814
+ also needs the runner's custom build directory and the api project's job-token allowlist,
815
+ which is the owner's step (008 point 2).
816
+
817
+ The same job carries the OTHER end of that sentence: after the SSH step it runs the
818
+ platform's post-deploy gate over the target it declares —
819
+ `DEPLOY_GATE_TARGETS: "biz:<registry name>"`, because the gate has no default and the job
820
+ says which checks judge it (confirmation `deploy-gate-targets` 002). The gate is
821
+ `api/scripts/run-post-deploy-gate.sh` out of the read-only `api` clone the uniform step
822
+ already made, run in REMOTE mode: the container and its `RestartCount` are read on the biz
823
+ box, the heartbeat projection Registry writes into Redis on the infra box, because those
824
+ two facts live on two machines and this runner is a third (confirmation
825
+ `deploy-gate-targets` 003, `server-topology` 004/005). The infra box is reached as the
826
+ gate-reader account — `GATE_INFRA_HOST`, `GATE_INFRA_USER`, `GATE_INFRA_SSH_PRIVATE_KEY`
827
+ as a second SSH identity — and the job REFUSES before it installs or deploys anything when
828
+ that identity is not configured, naming the missing variables: a service deployed and then
829
+ not verified is the false guarantee `automation-gates.md` §5 is about. A service whose
830
+ contract declares `workflowChecks` needs its `DEPLOY_SMOKE_*` variables too; the gate says
831
+ which one is missing.
832
+
833
+ The template's `Dockerfile` runs the conformance check in its **production stage**, after
834
+ the sources arrive and before the image is finished (confirmation 001 §3.1). It is this
835
+ package's own CLI, and every biz service declares this library under `dependencies`
836
+ (measured across all eight on 2026-09-09), so the `npm ci --omit=dev` above it installs
837
+ what the line calls. An image has no workspace above it, so the rows reading a platform
838
+ file print `NOT RUN` by name there and everything the repository answers for itself runs:
839
+ one finding of severity `boot` or `deploy` exits non-zero and the image is never built.
840
+
841
+ ### `oa-sync-template --new` — a service
842
+
843
+ ```bash
844
+ npx oa-sync-template --new <name> --into <serviceRoot> [--description <text>] [--workspace <root>]
845
+ ```
846
+
847
+ `<name>` is the DIRECTORY name under `api_biz/`; everything else is derived from it and
848
+ substituted — `__SERVICE_NAME__`, `__CONTAINER_NAME__` (`api_service_<name>`),
849
+ `__REPO_NAME__` and `__REGISTRY_NAME__` (both `biz-<name>`). Three things are added to
850
+ the render, and each is a fact about ONE service rather than about the shape: the env
851
+ template is named after the service, `config/env-templates/shared.env` comes from the
852
+ platform env manifest, and every `@onlineapps` pin comes from
853
+ `api/config/libraries.json` — a dependency the SSOT does not know fails the run by name.
854
+
855
+ `--description` is optional. Without it `__SERVICE_DESCRIPTION__` is **left standing**
856
+ and the files carrying it are listed: this run has no description, and writing a
857
+ plausible one would put a fact in the repository that nobody stated. `--into` must not
858
+ exist yet, so nothing anybody else wrote is overwritten.
859
+
860
+ `api/scripts/add-service.sh --scaffold` runs exactly this. What stays in that script is
861
+ what is not the shape of a service: the entry in `api/config/services.json` and the port.
862
+
863
+ ### `oa-sync-template [path…]` — an existing service
864
+
865
+ ```bash
866
+ npx oa-sync-template [path...] --target <serviceRoot> [--workspace <root>] [--check]
867
+ ```
868
+
869
+ Rewrites the files the manifest declares in the classes `identical`, `generated` and
870
+ `contains`, from the same `from:` reference those rows already point the conformance CHECK
871
+ at — one definition, read twice. With no paths it plans every such row. Files of the `own`
872
+ class — `src/**`, `tests/**`, the content of `docs/**` — are never written.
873
+
874
+ **No workspace is required of the run itself** — only of the ROWS that read a workspace
875
+ file, and since d.229 most of them read a file this package carries. The predicate is the
876
+ one the manifest run uses (`rowNeedsWorkspace`), so the `fix` command of `F-INIT`,
877
+ `F-JEST` and `F-RUNNER` can be typed where their finding is now raised: inside the service
878
+ image. A row that does need one is printed `NOT RUN` by name, never skipped in silence.
879
+
880
+ A row the manifest does not make renderable is printed `NOT RUN` with the reason instead
881
+ of being filled from a guess. Today that is `G-PROD-IMAGE` and `G-SETUP`: they declare no
882
+ `from:` reference, so nothing says what their content is rendered from — the first is a
883
+ requirement ABOUT a file, the second a directory that must exist. A row whose file
884
+ blocks the write — an `init.sh` carrying no block marker, where inserting one would mean
885
+ choosing a position among somebody's own install steps; an installation document missing a
886
+ section, where writing one would mean rewriting somebody's prose — is printed `BLOCKED`
887
+ and exits 1.
888
+
889
+ Three rows need more than a splice, and the module that owns their CHECK renders them, so
890
+ the generator cannot produce a file its own check rejects:
891
+
892
+ * **`F-RUNNER`** — the `oa-test-runner v1` block is the template's, except for `build`,
893
+ `env_file` and `networks`, which the same row requires to equal the service the runner
894
+ tests. The check removes those three before comparing against the template; the sync
895
+ grafts this repository's own into the rendered block. Seven of the eight biz services
896
+ reach a second network in both nodes on 2026-09-09 — that is the rule being kept, not
897
+ broken, so neither half may report it.
898
+
899
+ Unlike `init.sh`, a compose file is never `BLOCKED`, because a mapping has one place its
900
+ entries can start. **Three states**: a file carrying the markers is rewritten between
901
+ them; a file carrying a runner but NO markers has that runner's declaration REPLACED
902
+ where it stands — measured 2026-09-10, seven of the eight repositories are in that state
903
+ (d.220), and a run that only inserted gave them a second `<container>_tests:` key, which
904
+ compose either refuses or resolves by taking the last one; a file carrying neither gets
905
+ the block inserted under `services:`. The replacement spans the KEY to the next key at
906
+ the same level, so a comment above it — in `api_biz/converter` the memory measurement
907
+ taken inside that container — survives untouched.
908
+ * **`G-PROD`** — `docker-compose.production.yml` is the template rendered with this
909
+ repository's DECLARED name (`config/service/config.json` → `service.name`), whole file
910
+ and nothing else. The class is `generated` rather than `identical` because the file
911
+ carries an identity — the container, the image repository, the service's own env file —
912
+ and the render is what puts it there; owner decision `server-topology` 005, on the
913
+ measurement that the seven old-shape copies were byte-identical once the name was
914
+ normalised. There is no prose to protect here, so an existing file is REWRITTEN rather
915
+ than spliced or blocked, and the R1 bridge `G-PROD-IMAGE` keeps answering about the pin
916
+ beside it.
917
+ * **`G-README`** — the generated uniform pointer of `README.md`, rendered from the packaged
918
+ manifest and applied to the region between the markers; everything outside them is left
919
+ as it was. The file itself is never created: a README is prose somebody wrote, and an
920
+ absent one is what the row reports.
921
+ * **`G-SETUP-INSTALL` / `-MATRIX` / `-VALIDATION`** — `skeleton_only`: the check compares
922
+ the set of `##` headings against the template document and never the prose. A missing
923
+ document is created from the skeleton (header, title, sections, no prose); an existing
924
+ one is never rewritten, and a missing section is `BLOCKED` with the heading named. That
925
+ a document is absent at all stays `G-SETUP`'s finding, through the installation
926
+ contract's `SETUP_DOCS`, so one defect keeps one owner.
927
+
928
+ `--check` writes nothing and exits 1, naming the first line that differs and what the run
929
+ would put there — both sides, because one of them alone lies about half the runs: an
930
+ INSERTION is not described by what the file already said at that line. Measured over the
931
+ eight biz repositories on 2026-09-09, the set of files this run would change is exactly the
932
+ set of `F-INIT`/`F-JEST` findings `oa-validate` reports on the same trees, which is what
933
+ "one definition" has to mean to be worth anything. On the same trees `F-RUNNER` used to
934
+ report `BLOCKED`, and the row's `fix` accordingly read "paste the block once by hand" —
935
+ advice, where confirmation 001 §3.2 asks for a command. It is a command now.
936
+
937
+ ### `oa-sync-template template` — the committed template directory
938
+
939
+ ```bash
940
+ npx oa-sync-template template --target <dir> [--check]
941
+ ```
942
+
943
+ Renders the packaged template with its own placeholders as the parameters, which is what
944
+ `api/templates/business-service` is. `--check` is the gate: it exits 1 both on a file that
945
+ differs and on a file the packaged template has no source for.
946
+
947
+ ### `oa-sync-template shared-env`
948
+
949
+ `config/env-templates/shared.env` is generated, never edited, never edited. The platform env manifest
950
+ `api/config/shared-env.json` owns the shared key set — name, template value, why the
951
+ platform shares it — and every bearer's copy is rendered from it, so a service can only
952
+ add keys of its own in `service.env`.
953
+
954
+ ```bash
955
+ npx oa-sync-template shared-env --target <bearer dir> [--workspace <root>] [--check]
956
+ ```
957
+
958
+ `--target` is the bearer: a service root, `api/` itself, or the business-service
959
+ template; `--workspace` is the directory holding `api/` and `api_biz/`, found upwards
960
+ from the target when it is not given. `--check` writes nothing and exits 1 with the diff
961
+ when the file on disk is not what the manifest renders; a run that cannot start at all
962
+ exits 2. There is no flag pointing the run at another manifest — the shared key set has
963
+ one owner.
964
+
965
+ Decision and specification: [`api/docs/governance/confirmations/biz-service-manifest.md`](../../../docs/governance/confirmations/biz-service-manifest.md) §18.
966
+
967
+ ### `oa-sync-template readme-uniform`
968
+
969
+ Discoverability of a uniform is one rendered pointer per repository, not a header per
970
+ file: a generated region of the repository's `README.md`, everything in it read from the
971
+ manifest that ships in this package — the link included, which is computed from the two
972
+ paths rather than written down. Nothing outside the markers is touched.
973
+
974
+ ```bash
975
+ npx oa-sync-template readme-uniform (--target <dir> | --all) [--workspace <root>] [--check]
976
+ ```
977
+
978
+ `--target` reads the KIND from the disk, because the kind is a fact of the directory and a
979
+ `--kind` flag would be a second way to state it:
980
+
981
+ | the root carries | kind | the region between markers |
982
+ |---|---|---|
983
+ | `config/service/operations.json` | service | `biz-service-uniform` — `Uniform: [biz-service](<link>)`, which repositories wear the uniform (the `discovery` block and its SSOT), the row ids grouped by the manifest section that declares them, and which path falls under which row |
984
+ | `package.json` (and no such file) | library | `library-uniform` — `Uniform: [library/<category>](<link>)` and the duty sections the declared category wears, with the ids of their rows |
985
+ | neither | — | fail-fast naming both files it looked for |
986
+
987
+ `--all` is the library run and stays one: the eight service repositories are separate
988
+ checkouts, not siblings under one workspace, so there is no list to walk — a service is
989
+ written one `--target` at a time. A service needs no workspace above it either; its region
990
+ is the packaged manifest and its link is the copy npm installs into its own
991
+ `node_modules`, which a reader resolves without a network.
992
+
993
+ A service region renders a row's path, and a row taking its value from elsewhere renders
994
+ the path that OWNS the value rather than the value (`` `api/.nvmrc` ``, not `Node 24`).
995
+ Never a `why` or a `fix` — that prose has an owner, and the region is not it. A FORBIDDEN
996
+ row contributes its id and not its path: `hooks/pre-commit` and
997
+ `scripts/run-pre-validation.js` may not occur in a service repository as text at all
998
+ (`api/tests/scripts/add-service.bats` greps the template for them, because `init.sh` used
999
+ to install the hook), so a region naming them would write a retired path back into every
1000
+ service.
1001
+
1002
+ The region carries no date, no version and no count. The version a README is checked
1003
+ against IS the pin on this package, because the manifest ships inside it; a date would
1004
+ make two runs render different bytes, and a generator whose output varies cannot be a
1005
+ gate. A library that declares no `oa.category` is reported `NOT RUN` under `--all` and
1006
+ counted out of the coverage line — its missing declaration is already the blocking
1007
+ finding `U-MISMATCH` of `oa-validate --library`, and this run never guesses one. Asked
1008
+ about such a package by name with `--target`, the run fails fast instead. A service
1009
+ declares nothing of the sort: the uniform it wears follows from its location (005 point 1).
1010
+
1011
+ **The region is gated, not only generated** (owner decision 2026-09-09). A `generated` class
1012
+ is a generator PLUS a row carrying its `--check` (001 §2, the precedent being
1013
+ `G-SHARED-ENV`), and until d.215e this one had the generator alone: a README whose pointer
1014
+ had gone stale was reported by nothing anybody runs on a repository. Two rows say it now —
1015
+ `G-README` of the biz-service uniform (severity `deploy`) and `L-README-REGION` of the
1016
+ library one (severity `publish`) — and they render through this very module and compare
1017
+ through the same `checkUniformRegion` the `--check` run calls, so a row and the command
1018
+ cannot disagree about one file. A missing region, a stale one and an absent README are three
1019
+ sentences with three fixes; the row names the first line that differs, and the full diff is
1020
+ one command away, which is the row's own `fix`.
1021
+
1022
+ `L-README` is untouched beside it: it asks whether the node header is there, which a human
1023
+ writes, while `L-README-REGION` asks whether the rendered half still says what the manifest
1024
+ says. Two writers, two fixes, two rows. A library declaring no category raises neither —
1025
+ that is `U-MISMATCH`, blocking, with its own fix — and a checkout holding no copy of this
1026
+ package reports `L-README-REGION` `NOT RUN` with that reason, because the link the region
1027
+ carries points at the manifest as it lies in the checkout being read.
1028
+
1029
+ Decision and specification: [`api/docs/governance/confirmations/biz-service-manifest.md`](../../../docs/governance/confirmations/biz-service-manifest.md) § Confirmation 20260909-biz-service-manifest-005, point 2.
1030
+
1031
+ ### `oa-sync-template docs-region`
1032
+
1033
+ The documentation tree copied the same descriptive lists by hand: the repository shape in
1034
+ three nodes, the `package.json` scripts in two, the runtime numbers in several. A copy is
1035
+ kept true by review only, which is what `.claude/rules/doc-code-binding.md` §1 forbids for
1036
+ a descriptive fact. Five regions render those lists from the two packaged manifests:
1037
+
1038
+ | id | renders |
1039
+ |---|---|
1040
+ | `biz-service-tree` | the packaged template's files, each annotated with the row that owns the path; the forbidden paths, and a path a row owns that the template does not carry |
1041
+ | `biz-service-scripts` | `scripts.required` with the body of each row, and `scripts.forbidden` |
1042
+ | `biz-service-runtime` | the runtime rows and every row carrying a memory limit, with their own parameters |
1043
+ | `biz-service-alignment` | every row of the service manifest — id, severity, what it checks |
1044
+ | `library-duties` | the library categories with their layer and `may_depend_on`, the duty sections by row id, and the guidance entries marked apart as named non-rules |
1045
+
1046
+ ```bash
1047
+ npx oa-sync-template docs-region (--id <id> | --all) --file <document> [--workspace <root>] [--check]
1048
+ npx oa-sync-template docs-region --list
1049
+ ```
1050
+
1051
+ What a region carries is a row's observable — its id, its check, the paths and values its
1052
+ own parameters name. Never its `why` or `fix`: that prose has an owner, and a copy of it in
1053
+ five documents is the duplication the mechanism exists to remove. A `from:` reference
1054
+ renders as the path that OWNS the value, never as the value itself, so `` `api/.nvmrc` ``
1055
+ stays true when the platform moves to the next Node major. `${runner}` stays unexpanded,
1056
+ because the name of the one-shot test runner is per repository.
1057
+
1058
+ The run REPLACES a region and never inserts one. These documents live in `api/docs/**`, a
1059
+ tree owned by other threads: where a section belongs in them is their owner's decision, and
1060
+ a generator writing into somebody else's document is the unsafe side effect
1061
+ `.claude/rules/automation-gates.md` §1 requirement 3 forbids. A document carrying no
1062
+ markers is therefore a failure that prints the two lines to paste; `--all` covers exactly
1063
+ the regions a document already declares. Like every region in this package, the output
1064
+ carries no date, no version and no count.
1065
+
1066
+ Finding, replacing, extracting and diffing a marked region is `src/sync/generatedRegion.js`,
1067
+ shared with `readme-uniform` — one implementation of the marker shape in the package.
1068
+
1069
+ Decision and specification: [`api/docs/governance/confirmations/biz-service-manifest.md`](../../../docs/governance/confirmations/biz-service-manifest.md) § Confirmation 20260909-biz-service-manifest-002, §16.2.
1070
+
1071
+ ---
1072
+
93
1073
  ## Runtime Directory Structure
94
1074
 
95
1075
  ```
@@ -239,6 +1219,60 @@ ServiceStructureValidator.getStandardLevels();
239
1219
 
240
1220
  **Related:** [Biz Service Canonical Shape — Implementation Standard Levels](/docs/biz/00-model/service-shape.md#implementation-standard-levels), [Biz Error Handling Contract](/docs/biz/70-contracts/error-handling.md)
241
1221
 
1222
+ ## Test namespace (`getTestNamespace`, `getForeignTestNamespace`, `assertAllowedTenant`)
1223
+
1224
+ An integration test reaches a real database, so the tenant / workspace it writes
1225
+ into is a safety boundary — never a value a test file picks for itself. Both
1226
+ helpers read the platform env (`TESTING_TENANT_ID`, `TESTING_WORKSPACE_ID`) and
1227
+ refuse anything outside the environment classes
1228
+ [tenant-allocation.md](/docs/standards/tenant-allocation.md) allocates to
1229
+ non-production; a missing or malformed value throws rather than defaulting.
1230
+
1231
+ ```javascript
1232
+ const { getTestNamespace, getForeignTestNamespace } = require('@onlineapps/conn-orch-validator');
1233
+
1234
+ const ctx = getTestNamespace(); // the namespace this test owns
1235
+ const other = getForeignTestNamespace(); // one it must NOT see rows from
1236
+ ```
1237
+
1238
+ `getForeignTestNamespace()` exists for isolation tests: a handler filters on
1239
+ tenant_id AND workspace_id, and proving it does needs a second namespace. It
1240
+ returns another of the same allowed classes, chosen by position in that list and
1241
+ wrapping at its end — deterministic for a given environment, and never a literal
1242
+ customer tenant or a derived `tenant + 1` (the platform default 99 + 1 is the
1243
+ LIVE tenant). The workspace half is the caller's own.
1244
+
1245
+ Deploy-contract R8 accepts a call to either helper as the one legitimate source
1246
+ of a namespace inside `tests/integration/`.
1247
+
1248
+ ### `assertAllowedTenant(tenantId, { purpose, setBy })`
1249
+
1250
+ The same boundary asked about an id the caller already holds. An operational
1251
+ script — clone a namespace, wipe one, backfill one — is handed a tenant on the
1252
+ command line, and the question it must answer is not "which namespace do I own"
1253
+ but "may I touch this one at all".
1254
+
1255
+ ```javascript
1256
+ const { assertAllowedTenant } = require('@onlineapps/conn-orch-validator');
1257
+
1258
+ const target = assertAllowedTenant(args.tenant, {
1259
+ purpose: '--to-tenant', // what the caller calls the value
1260
+ setBy: 'on the command line' // where that name is given a value
1261
+ });
1262
+ // 98 — an integer, whatever shape came in ("98" and 98 both arrive this way)
1263
+ ```
1264
+
1265
+ It returns the id normalized to an integer, and throws otherwise: absent, not an
1266
+ integer, or not one of the environment classes the standard allocates to
1267
+ non-production. The refusal is the sentence `getTestNamespace()` throws, rendered
1268
+ from the same place — `purpose` and `setBy` are the only parts that differ, so an
1269
+ operator who typed `--to-tenant 101` is told to change that flag rather than an
1270
+ env file they never touched.
1271
+
1272
+ The class list itself is deliberately **not** exported: a copy of a boundary is a
1273
+ second boundary, and the two drift. The refusal names the allowed classes, so a
1274
+ caller that has to show them never enumerates them itself.
1275
+
242
1276
  ## Related Documentation
243
1277
 
244
1278
  - [/docs/architecture/validator.md](/docs/architecture/validator.md)