@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/CHANGELOG.md CHANGED
@@ -4,8 +4,2564 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
- > ⚠ Neither this entry nor the connector-step work of `dcddb862` is published.
8
- > The last version on NPM is 3.3.2, so **no biz CI run sees any of it yet**.
7
+ ### Added — zahazovací testovací schéma je export balíčku (d.407)
8
+
9
+ `createThrowawaySchema` (`src/utils/throwawaySchema.js`, na indexu balíčku) staví schéma, které
10
+ integrační sada vlastní: z deklarace `database` v `config/service/integration-contract.json`, se
11
+ stejným rozhodnutím jako brána CI. Sdílí se **všechno kromě jednoho**: sada migrací a její pořadí
12
+ (`resolveMigrationPlan`, `migrationOrder.js`), požadavek na collation (`requireCollation`), příkaz
13
+ `CREATE DATABASE … COLLATE` (`createDatabaseSql`) i volání klienta (`defaultExec`) mají jedinou
14
+ definici, v `setupDatabase.js`. Liší se ta jediná věc, kvůli které mají dvě jména: `buildSchema`
15
+ staví OSTRÉ schéma služby, a proto odmítne cíl, který nezaložil; `createThrowawaySchema` staví
16
+ schéma testu, a proto ho zahodí a založí znovu — a odmítá jediné jméno, to deklarované.
17
+
18
+ Tvar vychází z toho, co si dnes staví služby samy (biz-meta `tests/helpers/schemaFixture.js`, tři
19
+ sady): `before` zastaví před jmenovanou migrací (stav, na který se ta migrace aplikuje),
20
+ `applyMigration(name)` pustí jednu, `readMigration(name)` vrátí její SQL, `dispose()` schéma zahodí,
21
+ `seeds: true` doplní deklarované seedy (bez toho se neaplikují). Exekutor je injektovaný a smí být
22
+ asynchronní — služba, jejíž testovací obraz nemá klienta `mysql/mariadb`, si předá vlastní.
23
+
24
+ **Schéma vybírá volající, nikdy soubor.** Migrace psaná pro instalační běžec začíná
25
+ ``USE `oagen_<služba>`;``, tedy jménem ostrého schématu; aplikovaná doslova pošle celý soubor tam.
26
+ Tyto řádky se proto odstraňují a výběr schématu, který strip přežije, běh zastaví. Změřeno na živé
27
+ MariaDB: bez stripu vznikne tabulka v deklarovaném schématu a další migrace spadne na
28
+ `Table '…_throwaway.probe' doesn't exist`.
29
+
30
+ ### Changed — R8 čte import helperu, ne řetězec `CREATE DATABASE` (d.407)
31
+
32
+ Třetí propustka R8 (`fec9100a`) pouštěla soubor, který obsahuje `CREATE DATABASE` a přesměrování
33
+ `DB_NAME`. To je textová heuristika: přečte dva řetězce a z nich usuzuje, kam dotazy dopadnou.
34
+ Jakmile služba přesune stavbu schématu do sdíleného helperu — přesně to udělala biz-meta —
35
+ propustka zanikne, ačkoli soubor je bezpečnější než předtím. R8 proto nově pouští soubor, který na
36
+ vykonávaném řádku **importuje `createThrowawaySchema` z `@onlineapps/conn-orch-validator`**: kam
37
+ dotazy dopadnou, tam rozhoduje kód (odmítnuté deklarované schéma, jméno zahazovacího schématu u
38
+ každého příkazu, setřené výběry schématu v migracích), ne domněnka z textu.
39
+
40
+ Obě poloviny se musí zasloužit na vykonávaném řádku a repozitářový modul stejného jména nezíská nic
41
+ — důvěřuje se buildu tohoto balíčku, ničemu jinému. Měřeno nad `api_biz/meta`: dnes 3 hlášené
42
+ soubory (`migration013MeditestMove`, `migration014MeditestRefsLeave100`, `platformIdentitySeed`),
43
+ po záměně jejich importu za knihovní helper 0; táž kopie proti původnímu kódu R8 dál 3.
44
+
45
+ ### Changed — VALIDATION.md šablony nese hlášku chybějící collation (d.407)
46
+
47
+ `templates/business-service/docs/80-setup/VALIDATION.md` (a jeho zrcadlo v `api/templates`) má v
48
+ § Failure signatures položku `[SetupDatabase] Missing database.collation …` a co s ní: doplnit klíč
49
+ do bloku `database`. Blok `database` v šabloně nadále není — služba ho píše, až když databázi má.
50
+
51
+
52
+ ### BREAKING — `database.collation` je deklarace služby: brána s ní schéma zakládá a cizí collation odmítá (d.434)
53
+
54
+ Rozhodnutí vlastníka 2026-09-14 (`api/docs/governance/confirmations/db-collation-declaration.md` 001,
55
+ `api/docs/biz/70-contracts/database-contract.md` §1): collation, se kterou se schéma služby zakládá,
56
+ deklaruje služba jedním klíčem v bloku `database`, a čte ho CI brána i produkční runbook. Žádný
57
+ default (princip 3).
58
+
59
+ `buildSchema` proto (1) bez klíče odmítne stavět a pojmenuje ho i s opravou, (2) **měří**
60
+ `information_schema.SCHEMATA.DEFAULT_COLLATION_NAME` cílového schématu a (3) zakládá
61
+ `CREATE DATABASE <schema> COLLATE <collation>` — nikdy `IF NOT EXISTS`. Ta tichá varianta je to,
62
+ co tu dosud bylo, a nad existující databází NIC nezmění (změřeno BIZ-DOCS 2026-09-14 v jednorázovém
63
+ `mariadb:10.5.29`): šest biz pipeline si nechá schéma založit sidecarem z `MARIADB_DATABASE` ještě
64
+ před jobem, takže přesně v prostředí, kde brána běží, databáze už existuje — s defaultem obrazu
65
+ `utf8mb4_general_ci`. Existující schéma s jinou collation tedy nově běh zastaví a pojmenuje obě
66
+ collation, server i opravu (`MARIADB_COLLATION` v sidecaru, nebo `ALTER DATABASE`); se stejnou
67
+ collation se nezakládá nic a migrace běží.
68
+
69
+ Kontrakt (`bizCiGateContract.js`) validuje TVAR hodnoty, když je uvedena (utf8mb4 collation —
70
+ interpoluje se do `CREATE DATABASE … COLLATE`, a klient i všechny tabulky platformy mluví utf8mb4),
71
+ a nese ji v normalizovaném bloku dál. Řádek uniformy **`D-DB-COLLATION`** (`db`, severity `deploy`)
72
+ hlásí repozitář s blokem `database`, který klíč nedeklaruje; služba bez bloku mlčí. Tři otázky, tři
73
+ místa, žádná kopie: tvar = `C-CONTRACT`, přítomnost = `D-DB-COLLATION`, „bez hodnoty nestavím" =
74
+ `setupDatabase`.
75
+
76
+ **BREAKING pro služby**: `biz-ci-gate setup-db` selže každé službě s databází, která `collation`
77
+ nedeklaruje. Na HEAD 2026-09-14 blok `database` deklaruje sedm repozitářů a klíč z nich má jediný
78
+ (`property`, `utf8mb4_bin`); `converter`, `emailer`, `hello-service`, `ingest`, `invoicing` a `meta`
79
+ ho doplní ve své kaskádě 8.0.0 (`pdfgen` databázi nemá a mlčí).
80
+
81
+ RED → GREEN (jednotkově): `npx jest tests/unit/databaseDeclaration.test.js` 6 failed / 20 passed →
82
+ 26/26; `tests/unit/setupDatabase.test.js` 11 failed / 28 passed → 39/39; `tests/unit/manifestDbRows.test.js`
83
+ 3 failed / 26 passed → 29/29. Integračně proti živé MariaDB 10.5 (`setupDatabaseLive.integration.test.js`,
84
+ sonda `tests/helpers/setupDatabaseProbe.js` v kontejneru nad `gen_mariadb10.5`): 3 failed / 6 passed →
85
+ 9/9 — schéma neexistuje → založeno a zpětně změřeno `utf8mb4_bin`; existuje prázdné se správnou →
86
+ postaveno (4 migrace, 1 tabulka); existuje prázdné s `utf8mb4_general_ci` → odmítnuto, a po odmítnutí
87
+ má 0 tabulek a pořád svou původní collation. Kontrolní případy beze změny: populované schéma dál
88
+ padá na počtu tabulek dřív než na collation, služba bez bloku `database` dál hlásí
89
+ `NOT APPLICABLE setup-db`, a řádek `D-DB-COLLATION` mlčí nad službou bez databáze i nad hodnotou
90
+ špatného charsetu (ta patří `C-CONTRACT`).
91
+
92
+ ### Added — test drží, že `doc` každého řádku manifestu vede na existující dokument a sekci (d.438)
93
+
94
+ `manifestShape.js` odmítá řádek BEZ `doc` (konfirmace `biz-service-manifest` 004 bod 1), ale nikdo
95
+ se neptal, jestli ten ukazatel někam vede: přejmenovaný nebo přečíslovaný dokument udělá ze všech
96
+ řádků, které ho citují, slepou uličku, a sada zůstane zelená — falešná záruka
97
+ (`.claude/rules/automation-gates.md` §5), a totéž hnití, které `L008` chytá v opačném směru.
98
+ `tests/unit/manifestDocPointers.test.js` prochází OBA manifesty a každý `doc`, kdekoli sedí: cesta
99
+ se řeší stejným `resolveWorkspacePath` jako `from:` odkazy a musí existovat; `§ N` musí v dokumentu
100
+ najít nadpis — a hlídají se obě reálné podoby, `## N.` ve `docs/biz/**` i `### §N` v konfirmacích,
101
+ protože kontrola znající jen jednu by druhou hlásila jako mrtvou. Nečíselná citace
102
+ (`§ Version pinning`) se řeší jako text.
103
+
104
+ Nad HEAD je mrtvých odkazů nula (85 ukazatelů), takže RED se dokládá zasazením: `doc` řádku
105
+ `D-DB-NAMING` přepsán na `database-contract.md § 42` → 1 failed se jménem řádku i chybějící sekce,
106
+ po vrácení 7/7. Kontrolní případy, které to drží poctivé: neexistující dokument je hlášen, obě
107
+ podoby nadpisu projdou, vymyšlená pojmenovaná sekce je hlášena, a kopie balíčku mimo checkout
108
+ řekne NOT RUN místo tichého průchodu.
109
+
110
+ ### Fixed — `D-DB-NAMING` drží `database-contract.md` §5, takže `000_baseline.sql` je platné jméno (d.433)
111
+
112
+ Regex řádku žádal „aspoň dvě slova" (`(?:_[a-z0-9]+)+`), ale pojmenování migrací vlastní
113
+ normativní `api/docs/biz/70-contracts/database-contract.md` §5 a ten předepisuje
114
+ `NNN_snake_case.sql` — tři číslice, podtržítko, popis malými písmeny — a `000_baseline.sql`
115
+ uvádí jako svůj vlastní příklad; §8 z regenerované baseline dělá aplikovanou migraci. Řádek
116
+ byl tedy přísnější než kontrakt a odmítal jeho příklad: `meta` i `emailer` (a `invoicing`)
117
+ mají `migrations/000_baseline.sql` aplikovaný a dostávaly nález blokující deploy. Kvantifikátor
118
+ je `*` místo `+`, `why`/`fix` cituje vlastníka pravidla místo vlastní formulace a pole `doc`
119
+ míří na `database-contract.md § 5` — dosavadní `repository-installation-sql-contract.md`
120
+ pojmenování souborů nedefinuje vůbec (definuje obsah `BASELINE/` a `SEED/**`, §4, což hlídá
121
+ `D-DB-HEADERS`).
122
+
123
+ RED (`npx jest tests/unit/manifestDbRows.test.js`): 1 failed / 22 passed — kontrolní případ
124
+ `000_baseline.sql` dostal nález „three digits, then at least two snake_case words". GREEN:
125
+ 23/23. Kontrolní případy, které se nesměly hnout a nehnuly: `010_core_tables.sql` (druhý
126
+ příklad §5) mlčí, `10_x.sql` (dvě číslice), `010_CamelCase.sql` (velká písmena), `010_.sql`
127
+ (prázdný popis), `999a_diary_header_alias.sql` (přípona u pořadí) i soubor bez pořadí dál
128
+ nález jsou; `BASELINE/`, `SEED/**` a služba bez databáze dál mlčí. Přes CLI
129
+ (`node src/cli/oa-validate.js <repo> --json`) `meta` i `emailer` před opravou hlásily
130
+ `D-DB-NAMING` na `migrations/000_baseline.sql`, po opravě nic; nad zbylými šesti biz
131
+ repozitáři je řádek beze změny tichý.
132
+
133
+ ### Fixed — R6 odmítne pin na `@onlineapps/*` balíček, který SSOT nezná v žádném seznamu (d.413)
134
+
135
+ Zúžení na `infraConsumed` pokrývá balíček, který SSOT DEKLARUJE a žádná infra služba ho
136
+ neinstaluje — tam není s čím verzi porovnávat. Pin na `@onlineapps/*` balíček, který SSOT
137
+ nedeklaruje ANI v `libraries`, ANI v `infraConsumed`, padal do téhož `notGated`, takže
138
+ R6 hlásil OK pro balíček, který žádný platformní kontrakt nekryje (změřeno 2026-09-14,
139
+ nález W401, `src/utils/libCompat.js:88` před opravou). Pravidlo R6 je, že služba pinuje přesně
140
+ to, co SSOT deklaruje (`.claude/rules/architecture-principles.md` § Version pinning; kanál dat
141
+ brány = SSOT, konfirmace `libraries-ssot-channel` 001) — u nedeklarovaného balíčku žádná
142
+ taková verze neexistuje, takže se odmítá, ne zužuje (jinak je to fallback na „not gated",
143
+ princip 3). `notGated` zůstává pro deklarovaný balíček, který infra neinstaluje; balíčky mimo
144
+ scope `@onlineapps/*` se do něj nikdy nedostaly (filtr `SCOPE` v `checkLibCompat`).
145
+
146
+ RED (`npx jest tests/unit/libCompat.test.js tests/unit/bizCiGateCli.libCompat.integration.test.js`):
147
+ 4 failed / 32 passed — jednotkově `ok` bylo `true` místo `false`, přes CLI prázdný `stderr` a
148
+ exit 0. GREEN: 36/36. Kontrolní případy, které se nesměly hnout: balíček v `infraConsumed`
149
+ chybějící v `libraries` dál hlásí svou větev (`not present in the infra library set`), shodný
150
+ pin dál prochází, týž repozitář s toutéž pinovanou verzí při deklaraci v SSOT je dál `not
151
+ gated`. Fixtura `api/tests/scripts/lib-compat.bats` (smoke obálky `verify-lib-compat`)
152
+ deklarovala balíček pro případ „narrowing skipped" v žádném seznamu; po opravě ho SSOT fixtury
153
+ deklaruje, takže případ dál popisuje to, k čemu byl napsán (5/5).
154
+
155
+ ### Added — R6 má sadu, která drží, že se na něj `verify-contract` DOSTANE (d.401, `436095a7`)
156
+
157
+ Ze čtyř sousedních poskládaných kontraktů má každý svou `bizCiGateCli.*.integration.test.js`;
158
+ R6 byl jediný bez ní — a je to ten, který biz repozitáře pouštějí každou pipeline.
159
+ `bizCiGateCli.libCompat.integration.test.js` měří akceptační kritérium R6 přes CLI, které
160
+ pipeline skutečně spouští (`ci:gate:contract` = `biz-ci-gate verify-contract`, 8/8 repozitářů;
161
+ samostatný `verify-lib-compat` v žádném `.gitlab-ci.yml` není): neshodný pin → exit 1 s oběma
162
+ verzemi, caret odmítnut, devDependency stejně jako dependency, balíček mimo `infraConsumed`
163
+ hlášen jako not gated, nenakonfigurovaný nebo nečitelný SSOT odmítnut místo tichého průchodu,
164
+ a shoda verdiktu se samostatným subcommandem. RED s odstraněným fold-inem: 8 failed / 2 passed
165
+ (projdou právě dva testy samostatné koleje — kontrolní případ); GREEN 10/10.
166
+
167
+ ### Changed — konformní fixtura biz repozitáře má jedno místo (d.401, `436095a7`)
168
+
169
+ `tests/helpers/conformantBizRepo.js`: repozitář, který projde deploy, installation, env
170
+ a test-coverage kontraktem, takže exit kód poskládaného běhu je přiřaditelný. Dvě sady
171
+ potřebují totéž; definice opsaná dvakrát se rozejde, jakmile kterýkoli z kontraktů dostane
172
+ pravidlo (`change-discipline.md` § One rail per concern).
173
+
174
+ ### Fixed — `checkSetupDocs` and the manifest check ask for the exact spelling (d.408)
175
+
176
+ `fs.existsSync` answers true for `install.md` when the contract spells `INSTALL.md` on a case-insensitive filesystem (macOS APFS), so the install-contract gate passed on a developer machine and failed in `node:24-alpine` (BIZ-PROPERTY, 2026-09-14: 361 findings vs 363). Both places now use `existsExactly` from the git-tracked check — one rail for the trap, exported rather than copied. RED on macOS: a setup doc renamed to lower case passed; GREEN: it is reported as missing on every filesystem.
177
+
178
+ ### Fixed — the contract gates verify their input on the way in (d.400, `98cbb2ab`)
179
+
180
+ `verifyEnvCompleteness` and `verifyInstallContract` returned `ok: true` over a loader envelope (`{contract, source, …}`) instead of the normalized contract — a gate passing over a declaration it never checked (`automation-gates.md` §5). They now refuse anything but the normalized shape with `[Context] Problem - Expected/Fix`, through the one normalizer the CLI already uses.
181
+
182
+ ### Fixed — `write-summary` cannot be talked out of a failure it measured (d.392b, `3b445f05`)
183
+
184
+ `--gate-verdict pass` no longer withdraws a failing integration minimum: the measured verdict wins, `--gate-verdict` may only make the verdict worse, and the artefact's reason names the refused override in `[Context] Problem - Fix` form — neither the caller's `--gate-reason` nor the failure's own message stands in its place (`automation-gates.md` §1.5). The CLI help for `--gate-verdict` says what the switch does now. A backtick inside the help template literal took the whole CLI down during the change (`SyntaxError`), caught by the suite; `node --check src/cli/biz-ci-gate.js` is part of the gate from here.
185
+
186
+ ### Changed — the service template stops stating library versions by hand (d.392, `e7943612` … `a8d3c281`)
187
+
188
+ - `templates/business-service/package.json` pins every `@onlineapps/*` dependency with the `__SSOT_PIN__` placeholder; `oa-sync-template --new` resolves it from `api/config/libraries.json` and refuses a placeholder it cannot resolve. The template used to claim `service-wrapper 2.1.119` and `service-common 1.1.1` while the SSOT said 7.0.0 / 2.0.1 — a number written by hand is a lie with a commencement date. Existing services keep getting their pins from the publish cascade, never from the template. (d.392/1)
189
+ - `docker-compose.production.yml` of the template no longer declares `DEPLOY_ENV` — zero readers anywhere in the workspace (`process.env.DEPLOY_ENV`, `$DEPLOY_ENV`: none); `NODE_ENV` from the shared env manifest owns that fact. Nine manifest fixtures follow (`G-PROD` is `template-render`). (d.392/3, 3b)
190
+ - `biz-ci-gate setup-db` over a service with no `database` block reports `NOT APPLICABLE` with the contract path, instead of `OK` for work it never did. The template's README and `docs/80-setup/INSTALL.md` state that the one-shot test runner is outside a plain `docker compose build` and name `docker compose --profile test build` (measured with `--dry-run`: one image vs two). (d.392/6)
191
+
192
+ ### Fixed — `write-summary` names the step that failed (d.392/5, `47f9114c`)
193
+
194
+ `gate.reason` comes from the step that failed — the caller's `--gate-reason`, or the integration minimum when that is what failed, or `unknown step failed`. A declared failure with no reason no longer reports "integration minimum satisfied".
195
+
196
+ ### Added — manifest self-test over the template's npm scripts (d.392/2, `4473b956`)
197
+
198
+ `tests/unit/templateScriptSelftest.test.js` checks the packaged template's scripts against the manifest in both directions, with the deliberately-free list and its reasons (`test:container` stays as a declared exception; `test:integration:container` is `only_with: tests/integration`, which the template does not ship). The CI `test` job of a service is the service's own — the `oa-ci v1` block does not render it (confirmation `biz-service-manifest` 008, d.251) — so the template carries no conditional `mariadb-client`/`XDG_RUNTIME_DIR` rendering; that finding is closed by the recorded decision, not by code.
199
+
200
+ ### Fixed — every `format.md §` citation in the package names a section that exists (d.393b, `631d0cce`)
201
+
202
+ `CookbookTestRunner` sent cookbook authors to `format.md § Steps` from five messages. The node has no such section, and the five do not even share one owner: a missing `config/service/operations.json` is owned by `30-operations/schema-v3.md § File shape` — `format.md` decides nothing about that file, so the citation named the wrong NODE, not merely the wrong section — while the absent/empty `steps` belong to § Required fields and a step missing `service`/`operation` to § Step definition. All five now say where the answer is. `tests/unit/cookbookFormatDocReference.test.js` no longer checks a single module: it scans every `format.md §` citation under `src/` against the node's headings, carries its own case for the relocated operations.json citation, and says NOT RUN out loud when the node is absent from the checkout. `L008` cannot catch this class — it verifies the document behind a citation, never the section behind `§`.
203
+
204
+ ### Fixed — every fixture cookbook obeys the `step_id` pattern, and a gate now holds it (d.393b)
205
+
206
+ The last six fixture steps spelling `step_id` with a hyphen (`v3-cookbook-service/**`, `v3-service-undeclared-handler/**`) are on the underscore spelling the schema requires (`^[a-zA-Z0-9_]+$`, `cookbook.v2.schema.json`, every step type); their six assertions prove the same outcomes under the new names. With compliance in the same commit — never ahead of it — `tests/unit/fixtureCookbookStepIds.test.js` widens from two fixture workspaces to the whole `tests/fixtures/**` tree, with no exception list. Files that are not valid JSON are counted rather than silently skipped, and a control case names the only one that is meant to be broken.
207
+
208
+ ### Fixed — the cookbook-format refusals cite a section the node actually has (d.393, `c5611383`)
209
+
210
+ `readCookbookSteps()` sent a cookbook author to `api/docs/biz/40-cookbooks/format.md § Steps`. The node has no such section: both halves of the rule — `steps` is an array, and a step identifies itself with `step_id` — are owned by § The array is the only accepted shape. `L008` cannot see this class of defect; it checks the document behind a citation, never the section behind `§`. Both messages now name the real section, and `tests/unit/cookbookFormatDocReference.test.js` extracts the citation from the message the module actually throws and matches it against the node's headings (NOT RUN, loudly, when the node is absent from the checkout).
211
+
212
+ ### Fixed — fixture cookbooks obey the `step_id` pattern the schema enforces (d.393)
213
+
214
+ Seven fixture steps spelled their `step_id` with a hyphen, which `cookbook-core/schemas/cookbook.v2.schema.json` refuses on every step type (`^[a-zA-Z0-9_]+$`) — a shape production rejects and the offline Tier-1 runner accepts, because the runner checks no pattern at all. The six cookbooks now use the underscore spelling, their suites assert the same outcomes under the new step names, and `tests/unit/fixtureCookbookStepIds.test.js` reads the pattern from the schema instead of restating it. Four fixture files under `v3-cookbook-service/**` and `v3-service-undeclared-handler/**` still carry the defect and are deliberately outside this test's scope — an open finding (d.393b), not a silent pass.
215
+
216
+ ### Security — the biz CI template stops putting the registry password on a command line (d.389, `ccde5b17`)
217
+
218
+ `templates/business-service/.gitlab-ci.yml` logged in to the registry with `-p $CI_REGISTRY_PASSWORD` on the runner, and handed the same password to the deploy host as an argument of the remote `bash -s` — visible in `ps` on both machines for the length of the job (INFRA-DOCS finding, 2026-09-14). The password now travels on stdin only: the build logs in with `--password-stdin`, and the remote stream carries the password as its first line, which the remote shell reads and exports before `bash -s` gets the rest. The remote positional arguments are six and none of them is the secret. Proved by `tests/scripts/biz-deploy-secret-handling.bats`, which runs the template's own shell with doubles that record argv and stdin on both sides.
219
+
220
+ ### Security — both boxes the deploy job talks to are pinned (d.389)
221
+
222
+ The job's `~/.ssh/config` said `StrictHostKeyChecking no`, so the deploy key, the registry password and the gate-reader key went to whatever answered on the address. The catch-all now carries `StrictHostKeyChecking yes` and `UserKnownHostsFile ~/.ssh/known_hosts`, written from the new CI variables `DEPLOY_HOST_KEY` and `GATE_INFRA_HOST_KEY` (one `ssh-keyscan -t ed25519` line each); the policy lives in `ssh_config` rather than on the command line because `run-post-deploy-gate.sh` opens its own ssh connection and takes no switches from the template — one rail for both. A missing pin fails the job before the first connection, naming the variable and the command that produces its value — never a fallback.
223
+
224
+ ### Changed — `installContract.js` enforces the whole contract, not three directories and four keys (d.320, `d6321fd2`)
225
+
226
+ BREAKING for any repository whose live SQL lay outside `migrations/{BASELINE,SEED}`: the loop is now `migrations/**/*.sql` minus `archive/` and `superseded/` (confirmation `installation-sql-contract-scope` 001). Measured on HEAD: invoicing 5 files and property 90 files carried no header at all and passed. The header VOCABULARY is checked, not the presence of the key (§4): `Dataset-Class`, `Safe-For-Production` and `Idempotency` accept only the values the contract defines, the value is the first token and free text may follow it; `Target-DB` stays presence-only — the contract calls it a name, not a vocabulary, and comparing it against `database.schema` would make the header a second copy of that fact. Test-only data must carry its visible `TEST-ONLY` comment marker, and `Dataset-Class: TEST_ONLY` anywhere implies `Safe-For-Production: no` (the rule follows the class, not the directory). A manifest package is complete in BOTH directions (§5, §7): every file an `-- Applies:` line names exists, every `.sql` the installer applies is named, and the order is the installer's own (`migrationOrder.compareVersion`, shared with `setupDatabase.js` and the operator's `sort -V`); a file is a manifest exactly when it carries `-- Applies:`. The module cited ADR 0006, where the rule does not live — `Rules:` now names `api/docs/standards/repository-installation-sql-contract.md`, so L008 checks it. RED 14/31 → GREEN 31/31.
227
+
228
+ ### Changed — the delay test states WHEN, not how long the machine took (d.379)
229
+
230
+ `MockMQClient.test.js` § Delay Simulation measured `process.hrtime` against a 2 ms
231
+ tolerance and a 500 ms ceiling, and went red on roughly one run in four under concurrent
232
+ load. A verdict that depends on how busy the machine is fails the first requirement
233
+ `automation-gates.md` §1.1 puts on any automatic mechanism, and a flake teaches everyone to
234
+ re-run instead of to read. Fake timers replace the stopwatch: the promise is pending at
235
+ `DELAY_MS - 1`, settled at `DELAY_MS`, and no timer is left behind. The three claims are
236
+ unchanged — it waited, it waited once, it waited in the right scale — and each was
237
+ confirmed by breaking `simulateDelay` those three ways and watching the suite go red.
238
+
239
+ ### Removed — three declarations with no consumer (d.378)
240
+
241
+ - **`operation.headers`.** The dispatch descriptor carried `headers: operation.headers || {}`
242
+ and nothing read it: ctx.headers is built from `step.headers`, which is what the contract
243
+ prescribes (api/docs/biz/10-invocation/operation-context.md § ctx.headers names the STEP).
244
+ The operations.json rules in `service-validator-core` know no `headers` field, and not one
245
+ of the eight live `config/service/operations.json` declares it. A field nobody reads, in a
246
+ shape nobody may declare, taught readers that an operation could carry headers.
247
+ - **`mockInfrastructure` in seven cookbook fixtures.** The runner mocks on
248
+ `options.mockInfrastructure`, which `utils/preValidation.js` passes; the cookbook format
249
+ carries no such key (`cookbook-core/schemas/cookbook.v2.schema.json` calls `test`
250
+ acceptance-test metadata, `docs/biz/40-cookbooks/format.md` never names it). One
251
+ declaration, in the options. The schema admits additional properties at both levels, so
252
+ nothing can refuse the key at load time — `cookbookMockInfrastructure.test.js` is what
253
+ keeps it from growing back.
254
+ - **The second copy of the service fixture.** `tests/fixtures/cookbook-counts/service`
255
+ duplicated `v3-test-service` and had already drifted apart from it — the `returns-nothing`
256
+ operation and its handler existed in one copy and not the other — while
257
+ `cookbook-counts/two-cookbooks` held a byte-identical copy of that service's own cookbooks.
258
+ One fixture now, read by all three suites (`change-discipline.md` § One rail per concern).
259
+
260
+ ### Fixed — the step aggregate adds up: `passed + failed === total` (d.378)
261
+
262
+ A cookbook that threw on load pushed one entry into `results.steps` and incremented
263
+ `failed`, and nothing incremented `total` — so a run could report `total 2 / failed 3`
264
+ (BIZ-converter, 2026-08-26): an aggregate whose own arithmetic contradicts itself, read by
265
+ `utils/preValidation.js` to decide whether a proof is written and folded by
266
+ `ValidationOrchestrator` into the run's totals. One definition now: everything in
267
+ `results.steps` is counted in `total` exactly once — a real step, or the single entry
268
+ standing for a cookbook that never produced any. Two suites had encoded the old arithmetic
269
+ (`total: 1` against `failed: 2`, and seven failures out of a total of zero) and now state
270
+ the rule instead.
271
+
272
+ ### Fixed — `expect.error` is checked when the step SUCCEEDS too (d.378)
273
+
274
+ `validateExpectations` guarded the error block with `if (expect.error && result.error)`,
275
+ the sister defect of the `expect.output` guard one block up (d.365). A cookbook declaring
276
+ `expect.error` over a step whose handler returned normally skipped the block entirely, and
277
+ since `expect.status` is optional, a step declaring only `expect.error` came back with
278
+ `validationErrors: []` — green, over an expectation nothing evaluated
279
+ (`automation-gates.md` §5). The absence of the error is now the violation and says so in
280
+ the `[Context] Problem - Expected/Fix` form; a step that genuinely throws is judged exactly
281
+ as before.
282
+
283
+ ### Fixed — the Tier-1 runner reads the workspace the cookbook declares (d.354)
284
+
285
+ `CookbookTestRunner` built its ctx from `getTestNamespace()` alone, so
286
+ `defaults.workspace_id` and the per-step override the format defines were fields
287
+ nothing read — six emailer cookbooks carried a value the boot probe ignored. Owner
288
+ confirmation `tier1-runner-workspace` 001 (2026-09-14): the runner reads the cookbook.
289
+
290
+ Per-step wins over `defaults`, and a cookbook that declares neither keeps the platform
291
+ namespace from the environment — 32 of 44 cookbooks in the eight live biz repositories
292
+ declare no workspace, so refusing there would fail six services' boot on day one
293
+ (`automation-gates.md` §3). The TENANT does not move: it stays `getTestNamespace()`'s, so
294
+ no cookbook can walk the probe out of an allowed environment class. A declared workspace
295
+ passes the same rail as the environment value — `assertWorkspaceId` in
296
+ `utils/testNamespace.js`, new and exported from that module — so a value that is not an id
297
+ is refused naming the cookbook key, never swapped for the env value.
298
+
299
+ ### Added — `C-CONNECTORS`: the two connector declarations must agree (d.353)
300
+
301
+ `verifyConnectorContract` has compared `config/service/config.json` → `wrapper.<connector>`
302
+ against `integration-contract.json` → `requiredConnectors` since F12, and nothing acted on
303
+ the answer: at boot it went into `results.warnings` and the service started, and no CI job
304
+ ran it. Owner confirmation `connector-contract-check` 001 (2026-09-14): a uniform row,
305
+ severity `deploy`. The boot is unchanged — it still reports and still starts.
306
+
307
+ The row asks only the declaration half; the environment half (`REDIS_URL` and friends)
308
+ stays at boot, where an environment exists. The rule itself stays in one place —
309
+ `utils/connectorContract.js` → `mismatches()` — with `verifyConnectorDeclarations` and
310
+ `verifyConnectorContract` as its two phrasings. Measured against all eight services before
311
+ the row landed: zero findings.
312
+
313
+ ### Fixed — cookbooks and steps are counted apart, and `expect.output` is always checked (d.365)
314
+
315
+ - `Cookbook tests: X/Y` counted STEPS while saying cookbooks, so three one-step cookbooks
316
+ read as "3/3" and the day one gained a second step the number rose with no cookbook added
317
+ (BIZ-converter, 2026-08-27). The aggregate now carries `cookbooks: {total, passed, failed}`
318
+ beside the step counts, and both lines name their unit: `Cookbooks: 2/2 passed (3/3 steps)`
319
+ at boot, `cookbooks — 2/2 passed, steps 3/3 passed, 0 failed` from the CLI. A cookbook that
320
+ cannot be loaded counts as a failed cookbook, never as one nobody counted.
321
+ - `expect.output` was guarded by `if (expect.output && result.actual)`, so a step whose
322
+ handler returned nothing skipped every field assertion and passed on `status` alone
323
+ (BIZ-pdfgen, 2026-08-29). The guard is gone: `validateOutput` already answers correctly
324
+ for an absent output — a required field is missing, and an `exists: false` assertion is
325
+ satisfied by no output at all.
326
+
327
+ ### Added — `run-prevalidation --operation <regex>` (d.365)
328
+
329
+ Only the cookbooks holding a step whose operation matches are run, and they run WHOLE — a
330
+ later step reads what an earlier one wrote. A filtered run writes NO validation proof: a
331
+ proof is the claim that the whole cookbook set passed. An expression matching nothing is an
332
+ error naming the operations the directory does run, never an empty pass
333
+ (`automation-gates.md` §5). This is what lets BIZ-emailer delete
334
+ `tests/cookbooks/run-all.js`, the second way of running cookbooks their
335
+ `COOKBOOK_OPERATION_FILTER` kept alive (`change-discipline.md` § One rail per concern).
336
+
337
+ ### Fixed — `run-prevalidation` ends instead of hanging on the service's own pool (d.365)
338
+
339
+ Measured 2026-09-14: with a handler that leaves one live handle behind — which is what a
340
+ service's module-level Sequelize pool is — the command printed every line, wrote the proof,
341
+ and then hung until a 15 s kill. The connectors are NOT closed, deliberately: at boot the
342
+ same dispatch runs inside `ServiceWrapper` phase 0.2 and that pool serves the service for
343
+ the rest of its life. What differs is the lifetime, so the one-shot command states its exit
344
+ code and ends, after flushing both streams (`process.exit` can truncate a pipe).
345
+
346
+ The finding that led here (BIZ-ingest, 2026-09-07) named a per-service
347
+ `scripts/run-pre-validation.js` masking the pool with `process.exit(0)`. That script is
348
+ retired — `X-PREVAL` forbids it and the template does not carry it — so nothing masked the
349
+ leak any more, and the hang is what was left.
350
+
351
+
352
+ ### Fixed — a marker inside a fence is an example, and `--all` stops matching markers by hand (d.319)
353
+
354
+ Two modules carry the same marker shape: `api/scripts/ci/lib/generatedRegion.js`, which the
355
+ biz-facts and infra-queue generators render through, and this one, which `docsRegion.js`
356
+ and `readmePointer.js` render through. Only the first knew that a marker shown inside a
357
+ fenced code block is an EXAMPLE. The api side learned it the hard way on 2026-09-09, hours
358
+ after the queue generator landed: `api/docs/standards/INFRA-DOC-STANDARD.md` documents the
359
+ marker pair inside a fence — it still does, at the section on generated regions — and the
360
+ generator took its own documentation for a region naming a queue family that does not
361
+ exist, a gate red on the tree it had just been added to (`automation-gates.md` §3).
362
+
363
+ This module writes into the same tree, and the documents it writes into are precisely the
364
+ ones that TEACH the shape (`docs/biz/60-templates/service-template.md`,
365
+ `docs/guides/library-publishing-process.md`, `docs/guides/DEVELOPMENT.md` — the last already
366
+ shows the markers in prose at `library-publishing-process.md`). One input, two answers,
367
+ depending on which module met it, is what `automation-gates.md` §1.1 calls unpredictable.
368
+ `findRegion`, `extractRegion` and everything built on them now skip fenced markers, so a
369
+ document that only shows the pair declares no region and is never written into.
370
+
371
+ What did NOT change, and is covered by its own case: an UNFENCED lone marker is still
372
+ damage and still throws, and an inverted pair still throws. Fence-awareness must not turn a
373
+ half-written region into silence.
374
+
375
+ `generatedRegion.hasRegion()` is new, and `docsRegion.regionIdsIn()` — the question `--all`
376
+ asks before it renders anything — now goes through it. It was the one place in the package
377
+ that matched a marker string by hand, which is a second and fence-blind copy of this
378
+ module's own rule (`change-discipline.md` § One rail per concern).
379
+
380
+ **What d.319 deliberately did not do**, with the reason on record rather than as a silence:
381
+
382
+ - **`docs-region` is not retired.** Confirmation `biz-service-manifest` 002 §16.2 and 003
383
+ §3.3 require the descriptive lists of those three documents to be a rendered region fed by
384
+ the MANIFESTS; `sync-biz-facts.mjs` does not read a manifest (zero matches on `manifest` in
385
+ that file), so retiring this renderer would leave the confirmation unimplementable.
386
+ - **No `D-REGION` row over the biz repositories.** Measured 2026-09-14:
387
+ `grep -rn "BEGIN GENERATED" api_biz` returns nothing — not one of the eight trees carries a
388
+ region today, and these five regions live in `api/docs/**` in any case. A row checking
389
+ eight repositories for something none of them bears, and none of them should, is the gate
390
+ that matches nothing of `automation-gates.md` §5.
391
+ - **The two modules are still two.** They can become one the day this package is published:
392
+ the api gate runs `npm ci` and would import the RELEASED version, so importing it today
393
+ would tie a running gate to unreleased code. BIZ-DOCS recorded the same measurement and
394
+ offered the wiring (`api/shared/TODO.md`, their 2026-09-11 entry); this change makes the
395
+ merge possible by giving this module the behaviour the api one has. The divergence that
396
+ remains after that is the error contract — this module throws where the api one returns
397
+ `{error}` — and it is the caller's to absorb, in the change that deletes the copy.
398
+
399
+ ### Fixed — `ci:gate:setup` builds the operator's schema, and refuses anybody's database (d.338)
400
+
401
+ Three defects in one path, all of them reported from services and none of them visible
402
+ from inside the gate's own green run.
403
+
404
+ **The order was a second definition of itself.** The real installation is built by
405
+ `api/scripts/apply-oagen-meta-install-sql.sh` with
406
+ `find … -name '*.sql' | LC_ALL=C sort -V`; `setupDatabase.js` used JavaScript `Array#sort`,
407
+ which is lexicographic. BIZ-META reported it on 2026-08-29 as latent, on the reasoning that
408
+ three-digit zero-padded prefixes sort identically either way. It is not latent: measured
409
+ 2026-09-14 over `api_biz/property/migrations` (87 files), `999_layer_tick.sql` is applied
410
+ LAST by the operator and FIRST of the `999*` group by CI, because `_` (0x5F) precedes `a`
411
+ (0x61) byte for byte while version order compares the digit run first. That is exactly the
412
+ shape `.claude/rules/architecture-principles.md` § Version pinning names — CI proving one
413
+ artefact while the deployment runs another — and with migrations the order IS semantics.
414
+
415
+ `migrationOrder.js` is a port of what the tool actually does (GNU coreutils `filevercmp`
416
+ over gnulib `verrevcmp`), not an approximation that happens to agree on today's names, and
417
+ its expectations come from the tool's own output: the `@integration` case re-runs
418
+ `LC_ALL=C sort -V` during the suite and compares. The alternative — demanding the
419
+ `NNN_` convention `D-DB-NAMING` declares — was rejected because the nine `999a_`…`999i_`
420
+ files in property do not follow it, and a gate lands with compliance, never ahead of it
421
+ (`automation-gates.md` §3).
422
+
423
+ **A populated target was accepted.** `database.schema` is the REAL name (`oagen_emailer`,
424
+ the same string on the dev server and in production), so nothing about the name
425
+ distinguishes the CI sidecar from live data, and "does it already exist" cannot be the
426
+ criterion either — every biz `.gitlab-ci.yml` has the sidecar create it empty through
427
+ `MARIADB_DATABASE`. BIZ-emailer reported on 2026-09-04 that they had found nothing
428
+ blocking a `npm run ci:gate:setup` against dev; there was nothing. The criterion is the
429
+ premise the build already rests on: the set is proved by applying it to an EMPTY schema
430
+ (ADR 0006 §3). A target holding tables breaks it twice — the run proves nothing, because
431
+ what it would create is already there, and a declared seed carries no `IF NOT EXISTS`. So
432
+ the build now asks `information_schema` first and refuses, naming the schema, the server,
433
+ the count and how to get a legitimate target. A probe that cannot run, or answers nothing
434
+ countable, is a failure and never a pass.
435
+
436
+ **The charset flag had no test.** `--default-character-set=utf8mb4` has been on the client
437
+ invocation since the gate was written, and BIZ-invoicing confirmed on 2026-09-05 that the
438
+ installer is the clean path — but nothing held it there. `setupDatabaseLive.integration.test.js`
439
+ runs the real build against the dev MariaDB 10.5.17 in a throwaway schema it creates and
440
+ drops, and compares `HEX(note)` with the UTF-8 bytes of the source, so the answer does not
441
+ depend on the reading client either. Its control writes the same text through a latin1
442
+ client and requires the bytes to differ: without that, the case could pass on a leniently
443
+ configured server and prove nothing about the flag.
444
+
445
+ The executor contract gains one field, `capture`, which asks for the client's stdout in a
446
+ bare shape (`--batch --skip-column-names`). Only the emptiness probe uses it; a migration
447
+ produces nothing to read.
448
+
449
+ **And the message when no client is on PATH said the wrong reason.** It read "required
450
+ because migrations use DELIMITER"; BIZ-hello met it on 2026-09-14 in the first hello
451
+ pipeline to reach `ci:gate:setup` (job 2845685968) with no `DELIMITER` anywhere in its
452
+ migrations. The requirement and the fix were right, the reason was not — and a service
453
+ without triggers reads it as "this does not apply to me" and goes looking for a
454
+ `DELIMITER` that is not there. The client is required unconditionally, because every
455
+ statement goes through it, the first `CREATE DATABASE` included, and there is no driver
456
+ path; the message now says that (`architecture-principles.md` §5), with a case over its
457
+ text and a control that it no longer blames `DELIMITER`. Why the CLI rather than a driver
458
+ IS the DELIMITER story, and that stays where it belongs: the file's own header.
459
+
460
+ ### Added — `F-DOCKERIGNORE`: a local production build stops copying live secrets into the image (d.316)
461
+
462
+ `templates/business-service/Dockerfile` line 6 is `COPY . .` and the template carried no
463
+ `.dockerignore` at all, so every service scaffolded from it was born with the defect
464
+ BIZ-hello measured on 2026-09-11: a LOCAL production image of hello came to **6.6 GB**,
465
+ 5.2 GB of it `logs/`, and carried `config/env-active/*.env` with `DB_PASSWORD`,
466
+ `JWT_SECRET`, the MinIO keys and `RABBITMQ_URL`. Being ignored by git excludes nothing
467
+ from a docker context. The image CI builds is clean by accident — a fresh checkout has no
468
+ ignored file to copy — which is why the defect is invisible exactly where images are built.
469
+
470
+ The row is `F-GITIGNORE`'s shape (`contains`, severity `deploy`, fix = the sync) over the
471
+ SAME declaration, `templates/business-service/gitignore`: one list, two consumers
472
+ (`change-discipline.md` § One rail per concern). The derivation TRANSLATES rather than
473
+ copies, because the two formats disagree about the same line — a slash-less `.gitignore`
474
+ pattern matches at every depth, a `.dockerignore` one is matched from the context root, so
475
+ `*.log` there would catch `debug.log` and not `logs/debug.log`. Slash-less entries are
476
+ emitted in both shapes, anchored ones travel unchanged, a negation keeps its `!` in front
477
+ and its place, and `.git` is added — the one exclusion git never has to declare about
478
+ itself. The other trap hello named is avoided by construction: the derivation can never
479
+ exclude `tests/`, which Tier-1 of the boot reads at phase 0.2, because `.gitignore` does
480
+ not exclude it either.
481
+
482
+ The criterion is hello's comparison, not a list of directories, and it is now a test:
483
+ `dockerignoreBuildContext.integration.test.js` builds the template twice through docker
484
+ itself — `FROM scratch` + `COPY . /app` exported locally, so the matcher under test is
485
+ BuildKit's own — from a working tree carrying `logs/`, `config/env-active/`, `ci/` and
486
+ `node_modules/`, and from `git archive HEAD` of the same commit, and asserts the two file
487
+ lists are equal. Its failure path removes the file and asserts the secrets and logs come
488
+ back. With no docker daemon it says NOT RUN with the reason rather than passing.
489
+
490
+ Compliance landed with the row: the packaged template, the committed mirror
491
+ `api/templates/business-service` and every fixture carry the generated file.
492
+
493
+ RED→GREEN: `manifestDockerignore.test.js` 9 failed → 9 passed (99 suites, 1593 tests);
494
+ `dockerignoreBuildContext.integration.test.js` 2 passed against a live daemon;
495
+ `api/tests/scripts/add-service.bats` asserts a scaffolded service is born with both ignore
496
+ files, 68/68.
497
+
498
+ ### Added — `R-PID1`: the process a biz container runs AS is part of the uniform (d.315)
499
+
500
+ Confirmation `biz-compose-pid1` 001 settled the substance on 2026-09-05 — `command:
501
+ ["npm","start"]` makes npm process 1, and npm does not forward SIGTERM, so a stop is ten
502
+ seconds of waiting followed by SIGKILL through a service that never closed a channel — and
503
+ 002 (2026-09-13) settled where the decision belongs: *"zaroven by to asi melo byt u vsech
504
+ biz servis reseno jednotne a tudiz by to melo byt soucasti uniformy"*. Until now it
505
+ travelled by being remembered: measured 2026-09-14 over the eight dev composes, **six still
506
+ say `npm`, two say `node`**, eight days after the decision.
507
+
508
+ `R-PID1` (section `runtime`, severity `deploy`, `doc` → `biz-compose-pid1.md`) reads the
509
+ service block of `docker-compose.yml` and reports anything that is not `node index.js`. It
510
+ accepts both spellings compose allows — `["node", "index.js"]` and `node index.js` — because
511
+ they are one decision written two ways, and a row grading the spelling would report a
512
+ service that is already right. An unreadable bracket form is a finding, not a pass. The
513
+ one-shot test runner is outside the row: it declares no command because `docker compose
514
+ run` supplies one, and its shape is `F-RUNNER`'s.
515
+
516
+ **Compliance landed with the row, not after it** (`automation-gates.md` §3): the packaged
517
+ template's own `docker-compose.yml` said `["npm","start"]` — every service
518
+ `add-service.sh --scaffold` had created since would have been born with the defect — and
519
+ now says `["node", "index.js"]`, with the reason beside it. The committed mirror and the
520
+ fixtures are regenerated in the same change. The eight live repositories switch in their
521
+ own 8.0.0 cascade commit, citing 001, the way confirmation `biz-service-manifest` 009
522
+ sequences `X-DB-CONFIG`; `R-PID1` ships in the same unreleased version, never ahead of it.
523
+
524
+ RED→GREEN: `tests/unit/manifestServiceConfig.test.js` 3 failed → 1580/1580 in 97 suites,
525
+ covering the finding, the service that declares no command at all, both spellings, the
526
+ unreadable form, and the CONTROL that the one-shot runner is never reported.
527
+
528
+ ### Added — the biz `deploy-production` job runs the post-deploy gate of the target it declares (d.306)
529
+
530
+ Confirmation `deploy-gate-targets` 003 point 2. Until now the biz target of
531
+ `api/scripts/run-post-deploy-gate.sh` was called by nobody: measured 2026-09-10 over all
532
+ eight repositories and over this template, 0 callers. A deploy that ends at "the SSH step
533
+ returned 0" has confirmed that compose was told to start something, not that the service
534
+ came up — which is the false guarantee `automation-gates.md` §5 names, and it would have
535
+ made the `deploy-gate-restart-count` 001 decision (a restart loop is a FAIL) unreachable.
536
+
537
+ The `oa-ci v1` block's `deploy-production` job therefore:
538
+
539
+ - **declares its target** — `DEPLOY_GATE_TARGETS: "biz:__REGISTRY_NAME__"`. The gate has
540
+ no default; the job says which checks judge it (002). The name is the registry identity,
541
+ which is the key `api/config/services.json` and the contract file both use;
542
+ - **runs the gate after the SSH step**, in REMOTE mode
543
+ (`--biz-host "$DEPLOY_USER@$DEPLOY_HOST" --infra-host "$GATE_INFRA_USER@$GATE_INFRA_HOST"`),
544
+ out of the read-only `api` clone the uniform step already made beside the checkout. That
545
+ path is derived from `CI_PROJECT_DIR` rather than spelled a second time, so the layout
546
+ stays declared once, in `GIT_CLONE_PATH`;
547
+ - **carries a second SSH identity** — the gate-reader key for the infra box, in its own
548
+ `Host` block with `IdentitiesOnly yes`, so neither key is offered to the other box;
549
+ - **refuses BEFORE it installs or deploys anything** when `GATE_INFRA_HOST`,
550
+ `GATE_INFRA_USER` or `GATE_INFRA_SSH_PRIVATE_KEY` is empty, naming the missing ones and
551
+ the fix (`architecture-principles.md` §5). There is no branch that skips the gate
552
+ (`automation-gates.md` §1 requirement 5);
553
+ - **installs what the gate needs** on an alpine runner: `bash` (the script declares
554
+ `bash>=4` for `declare -A`), `jq`, `curl`. No `docker` — in remote mode every docker call
555
+ is carried to the box that holds the fact.
556
+
557
+ RED→GREEN: `api/tests/scripts/biz-deploy-uniform-gate.bats` 2 failed → 11/11, including
558
+ the failure path (no identity → exit 1 naming all three variables), the single-variable
559
+ case (the message names THAT one) and the CONTROL that a fully configured check says
560
+ nothing and gets out of the way. The 13 fixtures and the committed mirror
561
+ `api/templates/business-service` are regenerated in the same change.
562
+
563
+ ### Added — `D-LINT`: the documentation tree is clean, not merely clean of five classes (d.303)
564
+
565
+ The documentation block named five classes — a localhost port, a dead script citation, a
566
+ missing npm script, a retired concept, a missing header — and stayed silent about every
567
+ other rule the lint raises. Measured 2026-09-09 over the eight repositories: **80 lint
568
+ findings, 16 of which fell to a row here.** A tree the documentation gate called broken
569
+ came back DEPLOYABLE from this uniform, which is the false guarantee `automation-gates.md`
570
+ §5 is about.
571
+
572
+ `D-LINT` (section `docs`, severity `deploy`, `doc` → `api/docs/biz/DOC-STANDARD.md`) says
573
+ the lint reports nothing over this service's tree. It **cites no rule id**: a citation
574
+ list here would have to be held equal to the linter's and would fall behind it the first
575
+ time BIZ-DOCS wrote a rule (confirmation `biz-service-manifest` 004 point 2 — the manifest
576
+ cites, it never restates). The five rows above keep their findings and their `fix`
577
+ sentences; one finding still lands on exactly one row, and this one takes what is left.
578
+
579
+ **The safeguard that lets it land before every tree is clean**: it counts only what the
580
+ LINT graded `error` — the set its own `--severity error` prints, read from the one run the
581
+ block already makes rather than bought with a second process. Grading belongs to BIZ-DOCS
582
+ (`api/config/biz-docs-lint.json` § severities), so a rule they add at `warn` is visible in
583
+ their run and blocks no deploy until they raise it on their own dated transition. Proved
584
+ with the real lint over the real fixture, in both directions: the same finding, graded
585
+ `error`, is counted; graded `warn`, it is not.
586
+
587
+ **Compliance landed with the gate, not after it** (`automation-gates.md` §3). The packaged
588
+ template's own documentation tree failed the lint on nine findings — no `docs/README.md`
589
+ and no `docs/80-setup/README.md`, so all three installation documents were orphans (S008,
590
+ L006 ×3), and all three `Owns:` lines claimed the same backticked `__REPO_NAME__` (M002 ×2,
591
+ M004 ×3), which is not the fact any of them owns. Every service `add-service.sh --scaffold`
592
+ had created since would have carried those nine. The template now ships both maps, the
593
+ three `Owns:` lines name what each node actually owns, and the tree measures 0 findings;
594
+ the committed mirror `api/templates/business-service` is regenerated in the same change.
595
+ `docs/README.md` joins the manifest's `own` class — the map of a tree is the service's, and
596
+ what the platform holds the tree to is `D-LINT`, not the template's prose.
597
+
598
+ Today's run from the api checkout over the eight services: **174 findings → 238**, of which
599
+ 59 are `D-LINT` (invoicing 28, pdfgen 6, five services 5 each, property 0). The row is
600
+ therefore red for seven of eight and lands with the 8.0.0 cascade; the code is here now.
601
+ 97 suites, 1577 tests green.
602
+
603
+ ### Fixed — `C-SERVICE-DEAD`: `service.url` is dead FROM 8.0.0, and `wrapper.tenantContext` joins the list (d.304)
604
+
605
+ Two sentences in one row, both measured against the artefact a service is actually pinned
606
+ to rather than against this working tree.
607
+
608
+ - **`service.url` carries a date now.** The row's `why` said the key had "no reader
609
+ anywhere in the platform". The guard left the SOURCE in `c7ee2256`, but the PUBLISHED
610
+ `@onlineapps/service-wrapper@7.0.0` — every live service's pin — still throws
611
+ `Missing configuration - service.url is required` (measured 2026-09-14 in
612
+ `api_biz/invoicing/node_modules/@onlineapps/service-wrapper`). A rule telling an author
613
+ the key has no reader would have sent a service pinned to 7.0.0 into a failed boot
614
+ (BIZ-invoicing 2026-09-11 point 1). The finding now says it is dead from wrapper 8.0.0
615
+ and that the key travels WITH the pin — which is what confirmation `biz-service-port-url`
616
+ 001 said all along ("v rámci pin kaskády"). Severity is unchanged: `deploy` names the
617
+ work the cascade carries and never stops a boot.
618
+ - **`wrapper.tenantContext` is a dead key the row did not know.** It was retired with
619
+ service shape v1.1 (`docs/biz/RETIRED-VOCABULARY.md`, confirmation
620
+ `service-shape-v11-retirement` 001) and its runtime defaults went in `b5e36431`;
621
+ `grep tenantContext api/shared/connector/service-wrapper/src` = 0 across 12 files. The
622
+ key stayed in five of eight live configs and in the packaged template, so the check
623
+ claiming "dead keys are refused" claimed more than it did.
624
+ - **The template no longer carries it.** `templates/business-service/config/service/config.json`
625
+ shipped `tenantContext.enabled` plus `requirePathPrefixes: ["/api/"]` and
626
+ `excludePathPrefixes: ["/health", "/metrics"]` — HTTP prefixes in a service with no HTTP
627
+ listener (ADR 0005), copied forward by every `add-service.sh --scaffold`. The block is
628
+ gone and the committed mirror `api/templates/business-service` is regenerated in the same
629
+ change (`oa-sync-template template --check` in sync).
630
+
631
+ The row's `why` and `DEAD_KEYS` are held equal by the test that already guards that pair,
632
+ so the enumeration cannot fall behind the list again. 97 suites, 1567 tests green.
633
+
634
+ ### Added — `assertAllowedTenant()`: the tenant whitelist answers about any id, not only about the env (d.255)
635
+
636
+ `ALLOWED_TENANT_CLASSES` had exactly one consumer, inside its own file. A caller that
637
+ needed the other half of the same question — "may this script touch tenant X?" — had
638
+ nothing to call, so biz-property kept its own guard: `PLATFORM_LIVE_TENANT_ID`, one
639
+ refused id, every customer tenant from 101 up (101 = Meditest) through it. That is the
640
+ hole the whitelist replaced, surviving beside the whitelist because the whitelist was
641
+ unreachable (`api/shared/TODO.md`, "Od BIZ-PROPERTY (2026-09-11)").
642
+
643
+ ```javascript
644
+ const target = assertAllowedTenant(args.tenant, { purpose: '--to-tenant', setBy: 'on the command line' });
645
+ ```
646
+
647
+ - **Returns the id normalized** to an integer — a number and a string holding one both
648
+ arrive that way, the rail the env reader already used. Nothing else is coerced: `[99]`
649
+ stringifies to `"99"` and would have passed a bare `Number(String(x))`, so a type that
650
+ is neither number nor string is now refused by name.
651
+ - **One refusal, one place.** The sentence naming the allowed classes was written inline
652
+ in `getTestNamespace()`; it is rendered from one function now and `getTestNamespace()`
653
+ calls it. Writing it twice is how `97` stayed in the list for months after the
654
+ allocation retired it.
655
+ - **`purpose` / `setBy` belong to the caller**, because the fix does: an operator who
656
+ typed `--to-tenant 101` must not be sent to `config/env-active/shared.env`. The env
657
+ reader keeps its own "missing environment variable" sentence — six suites assert it
658
+ verbatim — while the empty-value condition, the integer check and the class refusal
659
+ stay one rail.
660
+ - **The class list is not exported.** A copy of a boundary is a second boundary; the
661
+ refusal names the classes so a caller never has to.
662
+
663
+ `getTestNamespace()` and `getForeignTestNamespace()` are unchanged, asserted by the
664
+ existing suite plus control cases in the new one: 97 suites, 1566 tests green.
665
+
666
+ ### Fixed — a test that writes gets a directory of its own, outside the repository (d.233)
667
+
668
+ Two jest processes over this package shared one tree, so a suite went red without a line
669
+ of it changing. Measured 2026-09-11, before the change: `manifestServiceFiles` built
670
+ `tests/fixtures/manifest/service-workspace/api_biz/tmp-alpha-0` and a `manifestRun` running
671
+ beside it aggregated `F-RUNNER@api_biz/tmp-alpha-0/docker-compose.yml` into the union it
672
+ asserts; a second full run raised `[DeployContract] Service root not found -
673
+ …/api_biz/tmp-prod-onebyte` when one suite's `afterAll` removed what the other was reading;
674
+ `StandardLevels` and the two `serviceStructure*` suites wrote under fixed paths in `/tmp`
675
+ and lost them the same way; and a `.tmp-test-coverage-*` directory left by a killed run
676
+ crashed the jest-haste-map of every later run in the package. Two full runs side by side
677
+ ended 33 and 36 tests red.
678
+
679
+ - **One place a suite gets a writable directory**: `tests/helpers/tmpWorkspace.js` —
680
+ `mkdtemp` under `realpath(os.tmpdir())` (the engine compares roots through `realpath`,
681
+ d.227), a copy of a fixture workspace or of one fixture directory, and removal in
682
+ `afterAll`, which jest runs whether the cases passed or failed.
683
+ - **A temporary service repository can carry an installed jest**: `makeResolvableRoot`
684
+ writes `node_modules/jest` whose manifest names this package's real jest binary. The
685
+ test-coverage gate resolves the SERVICE's own jest from the repository root, and a
686
+ fixture is never `npm ci`-ed; the two alternatives are measured blind alleys, written out
687
+ where the function is.
688
+ - **Zero writes under `tests/fixtures/**`, measured** — `find tests/fixtures -newer <marker>`
689
+ answers nothing after two full runs side by side. The last of them was
690
+ `oaValidateCli.integration`, whose every root is now a copy that brings its own git
691
+ checkout: `X-IGNORED` asks git what a clone would receive, and a fixture inside the `api`
692
+ checkout borrows that answer from the repository it happens to sit in.
693
+ - **The completeness case changed its subject, deliberately.** `complete: true` means every
694
+ row ran, which needs the SSOTs and the sibling `api/scripts/ci` the documentation rows
695
+ read — so the case used to run over `tests/fixtures/manifest/conforming` and quietly
696
+ borrow the real workspace around it, the ambient dependence `automation-gates.md` §1.1
697
+ forbids, over a directory that is a SIBLING of the fixture workspace rather than a service
698
+ inside one. It measures `workspace-clean/api_biz/alpha` inside a workspace the suite builds
699
+ and declares, and the sentence is now true of the right subject.
700
+
701
+ Two full runs side by side end 97 suites and 1566 tests green on both sides, and
702
+ `find tests/fixtures -name '*tmp*'` answers nothing after them.
703
+
704
+ ### `X-DB-CONFIG` cites the contract node, not the library README (d.254)
705
+
706
+ BIZ-DOCS wrote the prescriptive sentence into `docs/biz/70-contracts/database-contract.md`
707
+ §10 (`api@0b54101a`, owner decision biz-service-manifest 009), so the row's `doc` now
708
+ points at the concept that carries the rule — `doc-code-binding.md` §1.
709
+
710
+
711
+ ### Changed — `workflow:` and `verify-installation-contract:` join the `oa-ci v1` block (d.251b)
712
+
713
+ Left outside the block by d.251 because the template did not carry them, not because anyone
714
+ had decided they were the service's. Measured over the eight repositories on 2026-09-11:
715
+ each of the two is BYTE identical in all eight (one sha apiece), and what they say is the
716
+ platform's — `workflow:` decides which pipelines run at all, and
717
+ `verify-installation-contract:` holds the installation document's SQL to the migrations.
718
+ So both are now written by the block and shipped in the template; no service loses a line,
719
+ because the text the block writes is the text all eight already carry.
720
+
721
+ `test:` is the only key left outside the markers, and the rule it stands on is sharper for
722
+ it: which database an integration needs, which `ci:gate:*` steps run and which artefact it
723
+ publishes are facts about the service. It was also the only key that actually differed
724
+ across the eight.
725
+
726
+ Evidence. Simulated on a copy of `api_biz/converter` outside the repository: `workflow:`,
727
+ `verify-installation-contract:` AND `test:` all byte-identical to what they were, nine root
728
+ keys, no duplicate, YAML parses, `test:` outside the block, second run `unchanged`, `G-CI`
729
+ clean. Live over the eight services: **8 of 8**, with the eight-key sentence.
730
+
731
+ Also in this change: `api/tests/scripts/biz-deploy-uniform-gate.bats` asserted "nothing
732
+ turns the gate off" over `sed -n '/^deploy-production:/,$p'` — a range that reached EOF.
733
+ That was the deploy job alone until the block landed; since d.251 it also covered the end
734
+ marker and this repository's own `test:` job. It now reads from the key to the next line at
735
+ column 0. Falsified both ways: `allow_failure: true` inside `deploy-production` makes the
736
+ case fail, the same line inside `test:` leaves it passing.
737
+
738
+ ### Changed — `G-CI` is a platform BLOCK of `.gitlab-ci.yml`, not the whole file (d.251, finding 20)
739
+
740
+ The row landed in d.248 as `template-render` over the whole file, and its fix could not be
741
+ run: measured over all eight biz repositories on 2026-09-11, the template is 151 lines and
742
+ 7 root keys while the services carry 176 to 198 lines and 9. Every difference is theirs —
743
+ the `test` job that stands up MariaDB, Redis and RabbitMQ as service containers, runs five
744
+ `ci:gate:*` steps and publishes `ci/integration-signal.json`, plus `workflow:` and
745
+ `verify-installation-contract:`. Running the sync would have deleted all of it and left
746
+ eight integration tiers running against no database: a finding a gate reports and nobody
747
+ can carry out (`automation-gates.md` §3, last bullet).
748
+
749
+ So `.gitlab-ci.yml` is split the way `docker-compose.yml` already is (`oa-test-runner v1`,
750
+ d.215d), one level up — a delimited block of top-level keys rather than one compose service:
751
+
752
+ - **The block `oa-ci v1`** carries `include:`, `stages:`, `variables:`, `build:`,
753
+ `secret_detection:` and `deploy-production:` — how a service is built, how its image is
754
+ identified (`R5`), how the digest reaches the deploy (`R1`, `R2`) and the uniform gate of
755
+ confirmation 008 before the SSH step. `stages:` declares all four stages, the service's
756
+ own jobs included; measured 2026-09-11, no biz service declares a fifth.
757
+ - **Outside the markers is the service's**, and the sync never rewrites a line of it: which database
758
+ and which gates its integration needs is a fact about the service. The block carries no
759
+ variable of a service either, so the sync cannot put back the `REGISTRY_URL` / `PORT` /
760
+ `SERVICE_URL` that d.244 removed from the biz side.
761
+ - **New check `delimited-block-render`** (`src/manifest/checks/serviceFiles.js`): the block
762
+ as the file carries it against the template's block RENDERED for this service — the
763
+ platform half names the service once, in `GIT_CLONE_PATH`. It is `template-block` plus
764
+ the render, and `template-render` minus the rest of the file.
765
+ - **The fix is a command again**: `npx oa-sync-template .gitlab-ci.yml --target .`. Three
766
+ states, and the third is the migration — a file with the block is spliced between its
767
+ markers; a file carrying the platform's keys UNMARKED (all eight, today) has exactly
768
+ those keys REPLACED by the block where the first of them stood, so no file ends with two
769
+ `build:` or two `deploy-production:` keys; an absent file is created whole, which is what
770
+ `--new` writes. Which keys the block claims is read from the block
771
+ (`serviceTemplate.js` § `topLevelKeys`), never from a list beside it. The migration moves
772
+ the service's own keys down past the block when they sat between the platform's — their
773
+ TEXT is untouched, and a mapping is unordered; a block that stayed in several pieces
774
+ would not be one region.
775
+
776
+ Evidence. RED over a copy of `api_biz/converter/.gitlab-ci.yml` normalised to the fixture
777
+ service (`tests/fixtures/ci-legacy/`): `carries no "oa-ci v1" block`. GREEN after the sync,
778
+ with `workflow:`, `test:` and `verify-installation-contract:` byte-identical to what they
779
+ were, nine root keys and no duplicate, and `G-CI` clean on the re-run. Simulated on a copy
780
+ of converter outside the repository: YAML parses, the three service keys unchanged, `build`
781
+ differing only in a comment, `deploy-production` gaining exactly the uniform gate of 008.
782
+ Live over the eight services: `G-CI` **8 of 8**, with the new sentence.
783
+
784
+
785
+ ### Added — `G-CI`, `X-IGNORED`, and the manifest's completeness check over itself (d.248)
786
+
787
+ Three findings of the controller (`api/shared/TODO.md` 17, 18, 19) with one cause: nothing
788
+ held the manifest to the template, so the template could carry a file no row named, and a
789
+ service could `.gitignore` a file the uniform requires without a word being said.
790
+
791
+ - **The self-test (finding 18).** `tests/unit/templateCoverage.test.js` walks
792
+ `templates/business-service/**` and demands, for every file, either a row naming its path
793
+ or membership of the new class `files.own`. That list lives **in the manifest**
794
+ (`files.own.allowed`: `Dockerfile`, `index.js`, `package.json`, `src/`, `tests/`), never in
795
+ the test — an exemption written in a test is a second owner of the decision, and the next
796
+ file added would be exempted by editing the test. Adding a file to the template is now a
797
+ decision about who guards it. `Dockerfile` is the case that shows the class is not a
798
+ loophole: its one platform fact, the node major, is `R-NODE`'s row.
799
+ - **`G-CI`** (`files.generated`, check `template-render`, severity `deploy`, fix
800
+ `npx oa-sync-template .gitlab-ci.yml --target .`, doc confirmation
801
+ `biz-service-manifest` 008). Generated rather than identical because since d.246 the file
802
+ names the service once, in `GIT_CLONE_PATH`. The pipeline of a biz service is the
803
+ platform's: it runs the same suite, pins the deploy to the digest CI pushed (`R1`, `R2`,
804
+ `R5` read this file), and carries the step that measures the uniform before the SSH step —
805
+ a service that edits any of that edits the gate judging it.
806
+ - **`X-IGNORED`** (`files.tracked`, check `git-tracked`, severity `deploy`). Every file row
807
+ reads the WORKING TREE; what a clone, a CI checkout and the production image receive is
808
+ what git TRACKS. Measured 2026-09-10 on a copy of converter: `init.sh` and
809
+ `jest.config.js` in `.gitignore` and still on disk gave `F-INIT` and `F-JEST` **zero**
810
+ findings; the same two files deleted — the state a clean clone is in — gave both, NOT
811
+ DEPLOYABLE. Developer green, CI red. The row reads no list of its own: the paths are the
812
+ ones `files.identical`, `files.contains` and `files.generated` already name, a directory
813
+ among them covering its whole tree. Outside a git checkout it is NOT RUN with that reason,
814
+ never a pass — that is the state of a container and of an exported tarball.
815
+ - **`C-SERVICE-DEAD`** names `wrapper.registry.url` too (it entered `DEAD_KEYS` with d.244
816
+ and the row still listed four keys), and a test now holds the row's enumeration to the
817
+ list the code owns (`doc-code-binding.md` §1).
818
+
819
+ Evidence. Self-test RED on `.gitlab-ci.yml` (`placedBy` = `NOTHING`), GREEN 26/26 once
820
+ `G-CI` landed. `X-IGNORED` over a real git repository — a copy of the `conforming` fixture
821
+ with `init.sh` ignored: without the row `DEPLOYABLE, findings []`; with it
822
+ `NOT DEPLOYABLE, findings ["X-IGNORED@init.sh"]`. Live over the eight services on
823
+ 2026-09-11: `G-CI` **8 of 8** (every one of them differs from the template at the same
824
+ place — `workflow:` where the template has `stages:`), `X-IGNORED` **0 of 8**, so that row
825
+ lands with compliance rather than ahead of it (`automation-gates.md` §3).
826
+
827
+ A trap measured on the way: `fs.existsSync` is the wrong question on a case-insensitive
828
+ filesystem. property carries `docs/80-setup/install.md` while `G-SETUP-INSTALL` names
829
+ `INSTALL.md`; the filesystem said yes, git's index said no, and the row reported two
830
+ untracked files that are tracked — hiding the real defect, which is the name the row does
831
+ not match. Paths are now verified segment by segment through `readdir`: a spelling no row
832
+ matches is ABSENT, and absence is the file row's own finding.
833
+
834
+
835
+ ### Fixed — `S007` asks for the usage word of the LANGUAGE, not of the directory (d.249)
836
+
837
+ Reported by BIZ-ingest, 2026-09-11 (`api/shared/TODO.md`). The rule demanded `sourced` of
838
+ every file under `scripts/lib/` and `required` of every file under `scripts/ci/lib/` — the
839
+ tree those two lines were written against, not the tree there is. A Node library under
840
+ `scripts/lib/` could therefore go green only by declaring `sourced` about a file nothing
841
+ sources, which is the weakening `architecture-principles.md` §10 forbids. So the rule
842
+ moved; the files did not.
843
+
844
+ - The directory decides ONE thing: whether the question is asked at all. `scripts/lib/**`
845
+ and `scripts/ci/lib/**` hold libraries rather than entry points, so their `Usage:` line
846
+ carries a word instead of a command line. Which word is the extension's answer — `.sh`
847
+ is `sourced`, a Node module is `required`.
848
+ - The test is written as "shell, or else Node" rather than as a second list of Node
849
+ extensions beside `SCRIPT_EXTENSIONS`; a copy of that list here would drift
850
+ (`change-discipline.md` § One rail per concern). A `.cjs` is outside the scan today and
851
+ needs no edit here the day it enters.
852
+ - The message says what the file IS rather than where it lies: `A Node library declares
853
+ Usage: required, found "sourced"`. The id, the line and the `(SCRIPTS-STANDARD §2)`
854
+ citation are unchanged.
855
+
856
+ Measured 2026-09-11, the same trees before and after the change:
857
+
858
+ | tree | before | after |
859
+ |---|---|---|
860
+ | `api_biz/ingest` | 1 finding, `S007` | **0 findings** across 5 files |
861
+ | `api_biz/meta` | 7 findings, `S007` | **0 findings** across 12 files |
862
+ | `api_biz/converter` | 0 | 0 across 23 files |
863
+ | `api_biz/property` | 38 findings, none `S007` | 38, none `S007` — its scripts carry no header at all, so `S001` speaks first |
864
+ | `api/scripts` (`npm run scripts:lint`) | 0 across 105 files | 0 across 105 files |
865
+ | `templates/business-service` | 0 across 1 file | 0 across 1 file |
866
+
867
+ `api/tests/scripts/scripts-lint.bats` passes 18/18, exit 0 — including case 1, the
868
+ no-grandfathering gate over the real tree.
869
+
870
+ The row `S-SCRIPTS` of the biz uniform runs these rules, so ingest and meta stop being red
871
+ on it through no fault of their own. The standard's own §Scope rows still describe the old
872
+ split; the text for them went to INFRA-DOCS, who own `docs/standards/SCRIPTS-STANDARD.md`.
873
+
874
+ ### Changed — `registry` odchází ze šablony služby, `wrapper.registry.url` je mrtvý klíč (d.244)
875
+
876
+ Konfirmace `biz-discovery-redis` 002. `templates/business-service/config/service/config.json`
877
+ už blok `registry` nenese: `@onlineapps/service-wrapper` adresu nevyžaduje ani
878
+ nepředává a `@onlineapps/conn-orch-registry` parametr `registryUrl` nemá.
879
+
880
+ `C-SERVICE-DEAD` (`config-dead-keys`) proto nově hlásí i `wrapper.registry.url`:
881
+ „nothing reads it; registration travels over MQ to registry.register and discovery
882
+ reads the Redis projection". Živě to je **8 nálezů z 8 služeb** — všech osm ten klíč
883
+ dnes v `config/service/config.json` deklaruje (změřeno 2026-09-11); zmizí s jejich
884
+ kaskádou 8.0.0, kde blok vypustí spolu s pinem wrapperu.
885
+
886
+ ### Changed — `shared.env` šablony a fixtur přegenerován z `api/config/shared-env.json` (d.244)
887
+
888
+ Balíčková kopie i 13 fixtur pod `tests/fixtures/**` běhy `oa-sync-template shared-env`
889
+ (mechanismem, ne rukou). Zachycuje dvě změny manifestu: heslo v `REDIS_URL`
890
+ (INFRA `8d79478a`) a nové `why` u `REGISTRY_URL`. Fixtura
891
+ `service-workspace/api_biz/beta` **záměrně vynechána** — je to ručně rozjetá kopie,
892
+ na které `G-SHARED-ENV` dokazuje, že rozdíl umí najít.
893
+
894
+ ### Added — the deploy-production job of a biz repository measures the complete uniform before its SSH step (d.246)
895
+
896
+ Confirmation `biz-service-manifest` 006 point 3, placed by 008: a biz service is deployed
897
+ only by its own repository's CI (`server-topology` 004), so the last place that can refuse
898
+ an unfit deploy is that repository's own `deploy-production` job — not
899
+ `api/scripts/deploy-production.sh`, which is not the biz deploy path at all.
900
+
901
+ - **`templates/business-service/scripts/verify-deploy-uniform.sh`** is the step, as a FILE
902
+ rather than a heredoc in the YAML. A heredoc can only ever be run by GitLab, so the one
903
+ place the gate would be proven is production; this file is called by the job with three
904
+ arguments and by `api/tests/scripts/biz-deploy-uniform-gate.bats` with three others, and
905
+ both run the same bytes. It reads nothing from the environment
906
+ (`architecture-principles.md` §1): the checkout, the api repository URL carrying the job
907
+ token, and the api ref production follows all arrive as arguments.
908
+ - It refuses on the engine's own non-zero exit **and** on `complete: false` — `deployable`
909
+ says nothing blocked among the rows that RAN, `complete` says no blocking row was
910
+ skipped, and reading only the first honours a skip nobody flagged
911
+ (`automation-gates.md` §1.5). No flag, variable or branch turns it off (§1 requirement
912
+ 5), and messages print the clone URL with its userinfo cut out, so the job token never
913
+ reaches the log.
914
+ - **The layout is what makes the run complete, and the job asks GitLab for it.** Measured
915
+ 2026-09-11 over a converter export against a shallow `api` clone: with the service at
916
+ `<root>/api_biz/<service>` beside `<root>/api` the run is `complete: true` in 1.1 s;
917
+ with the same two directories as unrelated siblings, seven blocking rows come back NOT
918
+ RUN — `U-ORPHAN`, `U-IDENTITY`, `D-PORT`, `D-NPM`, `D-SCRIPT`, `D-RETIRED`, `D-HEADER` —
919
+ and the signal reads `complete: false`. That is the same seven the container simulation
920
+ of 006 measured. A symlink is forbidden (api rule: No Symlinks) and a second copy of the
921
+ repository would leave two trees with no way to say which one the verdict was about, so
922
+ the job sets `GIT_CLONE_PATH: $CI_BUILDS_DIR/oa-uniform/api_biz/__SERVICE_NAME__` and the
923
+ script verifies it got what it asked for.
924
+ - The job runs on `node:24-alpine` (the major `R3` compares against `engines.node` and the
925
+ Dockerfile), adds `git`, and runs `npm ci` — the engine is the one this repository's pin
926
+ installed, never a floating `npx`.
927
+ - `EXECUTABLE_FILES` in `src/sync/serviceTemplate.js` gains the script: a rendered copy
928
+ without the bit fails the job with "permission denied" instead of measuring anything,
929
+ which is the false guarantee `automation-gates.md` §5 names. The packaged template is
930
+ now **23** files, and `tests/unit/serviceTemplate.test.js` names the 23rd rather than
931
+ only counting it.
932
+
933
+ `api/tests/scripts/biz-deploy-uniform-gate.bats` — 9 cases, RED 8/9 against the tree
934
+ before this change (the ninth is skipped in an api-only checkout), GREEN 9/9 after: the
935
+ step stands before the ssh line; the job asks for the layout and the committed mirror
936
+ carries the same bytes; nothing turns the gate off; each precondition refuses with its fix
937
+ and without the token; THE REAL RUN over a converter export comes back `complete: true`
938
+ with `notRun` empty and its 33 findings refuse the deploy; an incomplete run is refused
939
+ even when nothing blocked; and the CONTROL case — only a signal that is `deployable` AND
940
+ `complete` passes.
941
+
942
+ **UNCERTAIN, and it cannot be measured from here:** whether a real GitLab pipeline reaches
943
+ `complete: true`. Two things this simulation cannot answer — whether the runner allows a
944
+ custom build directory (`[runners.custom_build_dir] enabled`), and whether the api
945
+ project's CI job-token allowlist admits the biz project (the owner's step, 008 point 2).
946
+ The clone step's own message names the second one when it fails.
947
+
948
+ **What this change does NOT carry:** no manifest row covers `.gitlab-ci.yml` — measured
949
+ over the manifest, zero rows name it — so the step reaches none of the eight services by
950
+ `oa-sync-template`, and none of them is reported as drifting from it. A new service
951
+ created by `oa-sync-template --new` gets it; an existing one does not.
952
+
953
+ ### Changed — the biz box's `shared.env` is rendered on the infra box, not written by role-biz (d.247)
954
+
955
+ Two sentences of the template — the host-contract comment in
956
+ `docker-compose.production.yml` and the production paragraph of `README.md` — said the one
957
+ host-level `/var/www/html/oadrive/api_biz/shared.env` is written by `role-biz`. Since
958
+ `843b0ba9` it is rendered on the INFRA box by
959
+ `api/scripts/provision/render-biz-shared-env.sh` from that box's own `env-active` and
960
+ carried over by the operator (`server-topology` 004, 005); `role-biz.sh` mentions
961
+ `shared.env` nowhere (0 occurrences) and creates only the `biz_network`, which those texts
962
+ still say and which is still true. The sentence was cascaded by the generator into the
963
+ eight fixtures that are conforming copies of the template; the four deliberately drifted
964
+ ones and `beta` were left as they are, because the sync would erase the defect they exist
965
+ to demonstrate.
966
+
967
+
968
+ ### Added — `X-DB-CONFIG`: the private connection factory is a forbidden file (d.242)
969
+
970
+ Confirmation `biz-service-manifest` 009: `@onlineapps/conn-base-db` is kept and its
971
+ cascade finished. Seven of the eight services carried their own
972
+ `src/config/database.js` — a module singleton building its own Sequelize — beside the
973
+ library whose `createSequelize` exists to be the one connection factory
974
+ (`change-discipline.md` § One rail per concern). The 8.0.0 cascade commit of each service
975
+ switches its importers and deletes the file; this row is what keeps it deleted, and it
976
+ ships in the release that does the switching, never ahead of it (`automation-gates.md`
977
+ §3).
978
+
979
+ - **`X-DB-CONFIG`** (`files.forbidden`, check `file-absent`, severity `deploy`,
980
+ `scope: service`) — path `src/config/database.js`, `doc`
981
+ `api/shared/connector/conn-base-db/README.md`. No new check and no new class: the
982
+ "this file must not exist" class is `files.forbidden`, which `X-HOOKS` and `X-PREVAL`
983
+ already wear, and `file-absent` reads nothing but the path. Like them it answers inside
984
+ a service container, with no workspace and no `api/` checkout.
985
+ - The row does **not** ask `requiredConnectors.db` first. 009 names the path with no
986
+ condition on it, and a service that declared no database has even less business carrying
987
+ a connection factory. The three `D-DB-*` rows that DO ask the declaration are a different
988
+ concern and are unchanged.
989
+
990
+ Live, 2026-09-11, `runManifest` per service against the workspace — 8 services, **7
991
+ findings**, `pdfgen` silent:
992
+
993
+ | service | `requiredConnectors.db` | `src/config/database.js` | `X-DB-CONFIG` |
994
+ |---|---|---|---|
995
+ | converter | true | present | 1 |
996
+ | emailer | true | present | 1 |
997
+ | hello-service | true | present | 1 |
998
+ | ingest | true | present | 1 |
999
+ | invoicing | true | present | 1 |
1000
+ | meta | true | present | 1 |
1001
+ | pdfgen | false | absent | 0 |
1002
+ | property | true | present | 1 |
1003
+
1004
+ `pdfgen` is the control case in both halves: it declares no database AND carries no file,
1005
+ and the row stays silent without reading the declaration at all. The mirror
1006
+ `api/templates/business-service` carries no `src/config/database.js` either, so the row
1007
+ raises nothing over it.
1008
+
1009
+ The workspace run (`oa-validate --workspace <root>`, no service root) reports the row
1010
+ `NOT RUN`, beside `X-HOOKS` and `X-PREVAL` — a service-scoped row has no service to read
1011
+ there, and NOT RUN is printed rather than counted as a pass.
1012
+
1013
+ ### Added — `S-SCRIPTS`, and the script-header rules move into this package (d.218)
1014
+
1015
+ Confirmation `biz-service-manifest` 003 §19: "`scripts/**` of every bearer passes
1016
+ `lint-scripts.mjs`, **shipped in the validator package**". The second half was the whole
1017
+ requirement. The rules lived in `api/scripts/ci/lint-scripts.mjs`, a file of ONE checkout —
1018
+ and a service has no `api/` directory in its own CI and none at all inside its image, so
1019
+ the row could only ever have been NOT RUN exactly where it has to answer
1020
+ (`automation-gates.md` §5).
1021
+
1022
+ - **`src/lint/scripts/lintScripts.js`** carries the rules. Every id, message and closed
1023
+ vocabulary is unchanged: `api/tests/scripts/scripts-lint.bats` passes 18/18, and over the
1024
+ api tree the two runs print the same line (0 findings across 104 files in scope, 97 in
1025
+ the citation scope).
1026
+ - **`oa-lint-scripts`** is the command; it owns argument parsing, the report and the exit
1027
+ code, nothing else.
1028
+ - **`api/scripts/ci/lint-scripts.mjs` is a thin caller** of the same module — the shape
1029
+ `api/scripts/ci/verify-lib-compat.mjs` already keeps. `npm run scripts:lint` and the CI
1030
+ job `validate-scripts` are unchanged.
1031
+ - **`S-SCRIPTS`** (new section `tooling`, severity `deploy`, `scope: service`) is the row;
1032
+ `src/manifest/checks/scriptHeaders.js` is the bridge to the module and decides nothing.
1033
+
1034
+ One rule changed behaviour, and it had to. `S006` checks that an `@see` target exists, and
1035
+ an `api/…` target used to be resolved by stripping the prefix and looking under the run's
1036
+ own root — correct exactly once, when the run IS the api checkout. In a service repository
1037
+ that would have measured `api/docs/standards/…` against `<service>/docs/standards/…` and
1038
+ reported a dead pointer that is not dead. The api checkout is now a PARAMETER: given, the
1039
+ target is resolved against it; absent, the lint returns it as UNRESOLVED and the row is
1040
+ NOT RUN with the targets named, never a silent pass. Measured 2026-09-10 over the eight
1041
+ services: 47 `@see` lines under their scripts directories, 4 with that prefix.
1042
+
1043
+ Live, 2026-09-10: **86 scripts, 86 findings** — not one carries a header (converter 23,
1044
+ property 38, meta 13, ingest 6, hello-service 2, invoicing 2, emailer 1, pdfgen 1). The
1045
+ mirror `api/templates/business-service` stays DEPLOYABLE with 0.
1046
+
1047
+ ### Added — the `db` section says what the SQL package is called and who reaches it (d.218)
1048
+
1049
+ §19 names three duties beside "is there a database" and leaves the naming convention to be
1050
+ chosen. All three ask their question only of a repository that DECLARES a database; a
1051
+ stateless service is silent, and `pdfgen` is the measured case.
1052
+
1053
+ - **`D-DB-NAMING`** — `migrations/*.sql` reads `NNN_<subject>.sql`: three digits, then at
1054
+ least two snake_case words (chosen by BIZ-general, d.218). No leading verb is demanded,
1055
+ because the measured counter-example is right —
1056
+ `converter/migrations/009_cnv_batch_drop_pending_status.sql` opens with a table prefix;
1057
+ and the three digits are not decoration — `999a_` sorts after `999_` only by accident of
1058
+ the byte after the digits. The convention reaches ONE level: what `BASELINE/` and
1059
+ `SEED/**` must carry is the installation contract's §4, which `D-DB-HEADERS` already
1060
+ checks, and that contract names no shape for their file names at all — measured, not
1061
+ assumed, so no regex is invented where nothing owns one.
1062
+ - **`D-DB-README`** — the installation contract §3 asks for four things and
1063
+ `SQL_PACKAGE` (`D-DB-PACKAGE`) checks three directories. The fourth is
1064
+ `migrations/README.md`, the documented execution order of §5 point 4, and nothing was
1065
+ looking for it.
1066
+ - **`D-DB-ACCOUNT`** — `DB_USER` in this service's env template reads `oagen_<shortname>`,
1067
+ from the SAME derivation `C-IDENTITY` holds `database.schema` to, so the account and the
1068
+ schema cannot drift into two spellings (confirmation `db-accounts-per-service` 001: root
1069
+ stops being an operational identity — it was the identity of nine containers across eight
1070
+ repositories, which made rotating it a cross-repo batch nobody could run). A repository
1071
+ that declares a database and no `DB_USER` is a finding, not a silence.
1072
+
1073
+ Live, 2026-09-10: `D-DB-NAMING` 12 (emailer, invoicing, meta one each — `000_baseline.sql`;
1074
+ property nine, eight of them the `999a`–`999h` ordinals plus a single-word `050_views.sql`),
1075
+ `D-DB-README` 0, `D-DB-ACCOUNT` 4 (ingest, invoicing, property still `DB_USER=root`; meta
1076
+ `oagen_meta_app`). The mirror stays DEPLOYABLE with 0.
1077
+
1078
+ ### Added — `F-GITIGNORE` and the `contains` file class (d.218, finding of d.240)
1079
+
1080
+ `ci/deployability.json` is written by `oa-validate` and by the pre-push hook of
1081
+ confirmation 006. Four of the eight services (converter, invoicing, meta, property) do not
1082
+ ignore `ci/`, so every run leaves an untracked file behind; the template has carried the
1083
+ line since d.212.
1084
+
1085
+ The class is `contains`, and the choice was measured rather than preferred. `identical` is
1086
+ impossible — a repository ignores its own scratch directories, and the eight services carry
1087
+ nine different files (d.220). A delimited `# --- oa-ignore v1` block was refused for the
1088
+ opposite reason: not one of the eight files carries a marker today and no generator ever
1089
+ wrote one, so a block row would report eight services for a defect four of them do not
1090
+ have, and would impose an ordering on lines that are legitimately theirs. What has to be
1091
+ true here is a SET of paths — every path this platform WRITES into a repository is a path
1092
+ git must not see — and a set is what the row compares: the template's entries against the
1093
+ repository's, in any order, every other line untouched.
1094
+
1095
+ A narrower pattern does not satisfy the entry it narrows, and that is not pedantry:
1096
+ property's `config/env-active/*.env` leaves every non-`.env` file in a directory of live
1097
+ secrets committable.
1098
+
1099
+ The generator still writes the labelled block — that is how a reader tells the platform's
1100
+ lines from the repository's own — but the block is the FIX's shape, never the check's. The
1101
+ renderer removes an existing block before computing what is missing, so it is idempotent:
1102
+ a second run adds nothing, and an entry the repository has since written by hand drops out
1103
+ of the block instead of standing beside it twice. `SYNCABLE_CLASSES` gains `contains`.
1104
+
1105
+ Live, 2026-09-10: 4 services, 5 findings. The mirror stays DEPLOYABLE with 0.
1106
+
1107
+ ### Changed — `C-IDENTITY` holds the sixth spelling: `serviceName` (d.218)
1108
+
1109
+ `config/service/integration-contract.json` opens with `serviceName`, and it is the first
1110
+ file every platform gate reads. The row held five spellings after d.236 and not this one.
1111
+ It is compared against what `config/service/config.json` DECLARES, character for
1112
+ character — the same reading the npm name gets, and for the same reason: whether
1113
+ `service.name` itself must read `biz-<shortname>` is a question no row owns, and this is
1114
+ not the place a rule appears as a side effect (`truth-over-agreement.md` §6). A contract
1115
+ declaring no `serviceName` is `C-CONTRACT`'s finding, which calls the validator owning
1116
+ every rule of that file.
1117
+
1118
+ Live, 2026-09-10: all eight services already agree, so no finding moved — the gate lands
1119
+ with compliance (`automation-gates.md` §3). What the row DID find is that the template
1120
+ carried two placeholders for one name: `__REGISTRY_NAME__` in `config/service/config.json`
1121
+ and `__REPO_NAME__` in the contract. Both render to `biz-<name>` for any real service, so
1122
+ nothing changes downstream, but the one bearer that is the shape of all the others wore two
1123
+ spellings; `serviceName` is the platform identity, so the contract now writes
1124
+ `__REGISTRY_NAME__`. The same correction landed in the conformant fixture, whose contract
1125
+ said `biz-v3-cookbook-service` while its `package.json`, its `config.json` and every
1126
+ cookbook step said `v3-cookbook-service`.
1127
+
1128
+ ### Fixed — a suite copying the fixture workspace no longer races the suites writing into it (d.218)
1129
+
1130
+ `manifestServiceFiles` and `manifestProductionCompose` create and delete temporary
1131
+ services under `api_biz/tmp-*` INSIDE the shared fixture workspace, and jest runs suites in
1132
+ parallel — so the three suites that copy that workspace whole walked a directory which can
1133
+ vanish between the readdir and the copy. Measured 2026-09-10: `ENOENT …
1134
+ api_biz/tmp-prod-onebyte`, in a suite that had been green twice before it appeared. The
1135
+ copy now filters those names out, and `cpSync` applies `filter` BEFORE it stats the entry
1136
+ (measured), so the window is closed rather than narrowed.
1137
+
1138
+ ### Added — one service, one name: `C-IDENTITY` and `U-IDENTITY` (d.236)
1139
+
1140
+ `api/docs/biz/60-templates/naming.md` is normative and derives every spelling of a biz
1141
+ service from one short name. It is not a matter of tidiness: the gateway compares
1142
+ `service_code` from the entitlement data against the service name in a cookbook step
1143
+ CHARACTER BY CHARACTER, so a second spelling of one service is a request that gets
1144
+ refused. Measured 2026-09-10 over the eight live services: seven wear one name each, and
1145
+ `hello-service` wore four — directory `hello-service`, repository `biz-hello-service`, env
1146
+ template `hello-service.env`, everything else `hello`. No row said a word, and the
1147
+ standard's own § Current State had recorded the divergence as a fact rather than a defect.
1148
+
1149
+ **`C-IDENTITY`** (section `config`, severity `deploy`, `scope: bearer`, no workspace
1150
+ needed) holds the spellings INSIDE the repository to the short name
1151
+ `config/service/config.json` declares: the npm `name`, `container_name` in both compose
1152
+ files, `config/env-templates/<shortname>.env` together with the `env_file` entries loading
1153
+ it, and `database.schema` = `oagen_<shortname>` where the repository declares a database.
1154
+ It reads nothing outside the repository, so it answers in a container and in the service's
1155
+ own CI too.
1156
+
1157
+ **`U-IDENTITY`** (beside `U-ORPHAN`, `scope: bearer`) holds `directory`, `repo` and
1158
+ `container` in `api/config/services.json` to the same short name; those three are facts of
1159
+ the workspace, so without one the row is NOT RUN.
1160
+
1161
+ Four decisions the row makes and states:
1162
+
1163
+ - the npm `name` is compared against the DECLARED `service.name`, not against a derived
1164
+ `biz-<shortname>` — `naming.md` gives both the same spelling, and comparing against the
1165
+ derivation would smuggle in a rule about `service.name` itself that no row owns
1166
+ (`serviceIdentity.js` § `REGISTERED_NAME`);
1167
+ - only the LAST segment of `repo` is this row's business; the organisation prefix is
1168
+ `naming.md`'s, and a repository moved between groups is not a second name;
1169
+ - the database name is read where the repository declares it —
1170
+ `config/service/integration-contract.json` → `database.schema` (7 of 8 services;
1171
+ `pdfgen` is a stateless renderer and declares none, so the row stays silent);
1172
+ - a bearer the SSOT knows under neither a name nor a directory is `U-ORPHAN`'s finding and
1173
+ `U-IDENTITY` says nothing — one defect, one owner.
1174
+
1175
+ The template is a bearer of every file row and of neither SSOT row: it declares its
1176
+ identity as the placeholder, and `api/config/services.json` lists services, of which the
1177
+ template is not one. It is held to `C-IDENTITY` and passes — which took fixing the
1178
+ template itself (d.220 decision 4): its compose files loaded
1179
+ `env-active/__SERVICE_NAME__.env` while the file was called
1180
+ `config/env-templates/service.env`, so the one bearer that is the shape of all the others
1181
+ wore two names. The source is now `__SERVICE_NAME__.env`; the `--new` rename is unchanged.
1182
+
1183
+ ### Fixed — `template --check` reads the template's own `.gitignore` (d.237)
1184
+
1185
+ `oa-validate` records its verdict in the tree it measured (d.232), so a run over the
1186
+ mirror `api/templates/business-service` leaves `ci/deployability.json` behind. The
1187
+ template gitignores `ci/`, so git does not see it — but `oa-sync-template template
1188
+ --check` did, and reported the mirror stale over the artefact of a legitimate run.
1189
+
1190
+ One rule, read from one place: what the template's `.gitignore` ignores, the comparison
1191
+ ignores too, on both sides. `compileGitignore()` reads git's own semantics in the part
1192
+ these files use — a trailing `/` covers directories only, a pattern carrying a `/` is
1193
+ anchored at the root and one without matches a name at any depth, a matched directory
1194
+ carries everything under it, and the LAST matching rule decides, which is what makes a `!`
1195
+ exception work. `listOutputFiles()` is that filter over `listTemplateFiles()`, and the
1196
+ mirror suite reads it too — it carried the same defect in its "carries exactly the files
1197
+ the packaged template renders" assertion. A file the `.gitignore` does not cover is still
1198
+ reported stale, and an ignored path never hides a real drift in a tracked file.
1199
+
1200
+ ### Changed — `R-NODE` reads the platform major from the package's own `engines` (d.238)
1201
+
1202
+ The row referenced `api/.nvmrc`, a file that exists only in a workspace, so in a service
1203
+ container and in the service's own CI — the two places a service is actually built — the
1204
+ one row that catches a service quietly living on an older major reported NOT RUN. (`R3`
1205
+ of the deploy contract compares CI, Dockerfile and `engines.node` only against each other,
1206
+ so a service consistently on Node 22 is green there.)
1207
+
1208
+ The reference is now `{ "package": "package.json", "text": true }` — the fourth reference
1209
+ shape of d.229 — and the check reads `engines.node` from it. It is not a second copy of
1210
+ the number: the library uniform's `L-ENGINES` fails any package whose `engines.node`
1211
+ disagrees with `api/.nvmrc`, so this is a checked projection of the SSOT one hop further
1212
+ along, and it travels with the pin. `api/.nvmrc` stays the owner and `L-ENGINES` still
1213
+ reads it. Owner decision: `api/docs/governance/confirmations/biz-service-manifest.md`
1214
+ § Confirmation 20260910-biz-service-manifest-006, point 2.
1215
+
1216
+ Measured over a service copy outside any checkout, with the package installed from a
1217
+ `npm pack` tarball: NOT RUN shrank to `U-ORPHAN` and the five `D-*` rows, and `R-NODE`
1218
+ reported both places the service states its major.
1219
+
1220
+ ### Fixed — `L-CONNECTOR-ENV` asks its question of code, not of prose (d.239)
1221
+
1222
+ The check matched `process.env` in the raw file text, so a module that DOCUMENTS the duty
1223
+ it obeys — "this module reads no `process.env`" — was reported for the sentence in its own
1224
+ header. Measured: `api/shared/connector/conn-base-db/src/createSequelize.js`.
1225
+
1226
+ The file is now read as source: `codeOnly()` blanks comments, the text of string and
1227
+ template literals, and regular-expression literals before the search, keeping length and
1228
+ line breaks. What a template literal interpolates is code again, because
1229
+ `${process.env.X}` really does read the environment. The tokenizer is written by hand
1230
+ rather than pulled from `acorn`: that package is not a declared dependency, and adding a
1231
+ runtime dependency for one line-level question would change what every biz service
1232
+ installs (§ Shared Packages, "a package sees only what it declares", gate rule G6).
1233
+
1234
+
1235
+ ### Changed — the production compose of a biz service is a generated file, not a shape (d.234)
1236
+
1237
+ `G-PROD` was a bridge to requirement **R1** of the deploy contract: it asked one question
1238
+ about `docker-compose.production.yml` — is the image pinned by digest — and said nothing
1239
+ about the file the rest of the platform depends on. That left the topology of the
1240
+ production compose maintained by hand in eight repositories at once, which is how seven of
1241
+ them came to name `api_network` and `gendb-network`: networks the biz box does not have.
1242
+
1243
+ The row is now a `generated` whole-file row (`check: template-render`), rendering
1244
+ `templates/business-service/docker-compose.production.yml` with the one parameter a service
1245
+ has, its DECLARED name (`config/service/config.json` → `service.name`, through
1246
+ `deriveParams`). The measurement the owner decided on is what makes that possible: on
1247
+ 2026-09-10 the seven old-shape files were byte-identical once the service name was
1248
+ normalised, apart from `memory: 384M` in ingest and property — so what looked like seven
1249
+ per-service files was one file copied seven times, and adding or removing a service is a
1250
+ scaffold plus an SSOT entry rather than a hand-written file. Severity is `deploy` from the
1251
+ start; a `warn` phase would be a gate that does not gate (`automation-gates.md` §5).
1252
+
1253
+ The R1 bridge stays, under the id **`G-PROD-IMAGE`** — a requirement about a file is not
1254
+ the file, and it keeps answering on a repository whose compose the generator has not yet
1255
+ written. `G-PROD` is the id the owner decision names for the file itself, so it is the id
1256
+ the file row wears.
1257
+
1258
+ `npx oa-sync-template docker-compose.production.yml --target .` writes it. Unlike an
1259
+ installation document, the file is REWRITTEN rather than spliced or blocked: the whole file
1260
+ is the template's, so there is no prose of the service's own to preserve. A repository that
1261
+ has not declared who it is gets the `[ServiceIdentity] … Fix: …` fail-fast instead of a
1262
+ guessed name, and the check stays silent about it — that defect is `C-SERVICE`'s.
1263
+
1264
+ Owner decision: `api/docs/governance/confirmations/server-topology.md`
1265
+ § Confirmation 20260910-server-topology-005.
1266
+
1267
+ ### Fixed — `oa-validate` records its verdict, so a deploy gate can ever see a complete one (d.232)
1268
+
1269
+ `ci/deployability.json` had exactly one writer: step **7/7** of the boot validation, which
1270
+ runs inside the service container, where the workspace SSOTs are not present. Every signal
1271
+ that could exist therefore carried `complete: false` — and the gate INFRA was handed in
1272
+ d.230 ("accept the signal only when `deployable && complete`") could never be satisfied,
1273
+ because the run that IS complete, `npx oa-validate --workspace <root> <service root>` from
1274
+ the api checkout, wrote no file at all. Measured before the fix: a copy of `api_biz/converter`
1275
+ validated by the packaged CLI printed `NOT DEPLOYABLE` and left no `ci/deployability.json`
1276
+ behind.
1277
+
1278
+ A service run now writes the file into the service root it measured, through the same
1279
+ `buildDeployabilitySignal` / `writeDeployabilitySignal` step 7 uses — one file shape with
1280
+ two producers, asserted by a control case comparing the CLI's file against what the shared
1281
+ builder produces from the same run. The report is printed BEFORE the write, as in step 7,
1282
+ so an unwritable service root still leaves the human-readable verdict; the write itself has
1283
+ no switch (`automation-gates.md` §1 requirement 5), and a root that cannot take it ends the
1284
+ run with the writer's `[DeployabilitySignal] … Fix: …` and exit `2`.
1285
+
1286
+ The workspace mode (`--workspace` with no service root) and `--library` write nothing. The
1287
+ signal is a verdict about ONE service tree: the workspace run has no service root and
1288
+ reports every per-service row `NOT RUN` by construction, and a library has no deploy of its
1289
+ own (002 §10), so a file from either would be a partial verdict standing where a complete
1290
+ one belongs.
1291
+
1292
+ This package's own version is now read lazily, at the moment the signal is built, with
1293
+ `[oa-validate] This copy of the engine carries no package.json - … Fix: …` where the file
1294
+ is missing — a planted copy of the engine used to be a `MODULE_NOT_FOUND` stack trace
1295
+ before a single row ran.
1296
+
1297
+ ### Fixed — a repository is who it DECLARES it is, not what its directory is called (d.229)
1298
+
1299
+ `F-RUNNER` and the three `G-SETUP-*` skeleton rows derived this repository's parameters
1300
+ from `path.basename(serviceRoot)`. Since d.229 those rows finally answer inside the image
1301
+ the service is built into — and that image has `WORKDIR /app`, so every service on the
1302
+ platform is called "app" there. Measured over the conforming fixture copied into a
1303
+ directory of that name: `F-RUNNER` reported a conformant compose file as drifted, quoting
1304
+ a line the substitution had mangled (`It __SERVICE_NAME__lies in dev and CI only`, because
1305
+ "app" is a substring of "applies"), and the production stage then fails the build of every
1306
+ conformant service. A directory called `My Service` did worse: the derivation threw, and
1307
+ `oa-validate` exited 2 with no table at all.
1308
+
1309
+ The identity is read from `config/service/config.json` → `service.name`, the key row
1310
+ `C-SERVICE` owns (`src/manifest/serviceIdentity.js`). Reading it back is the exact inverse
1311
+ of what `deriveParams` writes, which only ever ADDS the platform prefix — so no new rule
1312
+ about how a service must be named appears here as a side effect. A file that is absent,
1313
+ unreadable or declares no name is `C-SERVICE`'s finding and the rendered rows repeat
1314
+ nothing; a name no parameters can be derived from is reported by the row that needed it,
1315
+ because nothing else would say a word about it. The generator fails fast with
1316
+ `[ServiceIdentity] … Fix: …`.
1317
+
1318
+ ### Fixed — the uniform sync needs a workspace only where a ROW needs one (d.229)
1319
+
1320
+ `oa-sync-template [path…] --target` demanded a workspace root before it planned anything,
1321
+ so the `fix` command of `F-INIT`, `F-JEST` and `F-RUNNER` could not be typed in the one
1322
+ place their finding is now raised: inside a service container. It asks the same predicate
1323
+ the manifest run asks (`rowNeedsWorkspace`, moved to `manifestShape.js` so both read one
1324
+ definition), and a row that does need the workspace is printed `NOT RUN` by name.
1325
+ `shared-env` still refuses without one — its owner is a platform file.
1326
+
1327
+ ### Changed — `F-RUNNER`'s fix is a command: the sync writes the block (d.215d)
1328
+
1329
+ Confirmation 001 §3.2 asks that every finding's `fix` be a COMMAND. Seven of the eight biz
1330
+ repositories carry no `oa-test-runner v1` block at all, so the row's fix read "paste it in
1331
+ by hand once", which is advice. `npx oa-sync-template docker-compose.yml --target .` now
1332
+ writes it, in three states: a file carrying the markers is rewritten between them; a file
1333
+ carrying a runner but no markers has that runner's declaration REPLACED where it stands;
1334
+ a file carrying neither gets the block inserted as the first entry under `services:`.
1335
+
1336
+ The middle state is the one that matters and the one a first cut got wrong: measured
1337
+ 2026-09-10 over a copy of `api_biz/converter`, inserting gave the file a SECOND
1338
+ `api_service_converter_tests:` key — a duplicate mapping key, which compose either refuses
1339
+ or resolves by taking the last one. The replacement spans the key to the next key at the
1340
+ same level, so a comment above it (in converter, the memory measurement taken inside that
1341
+ container) survives. Where the block goes is not a guess in a compose file — a mapping has
1342
+ one place its entries can start — which is why `init.sh` stays `BLOCKED`: there the
1343
+ position IS a decision about that service's own install steps.
1344
+
1345
+ The `wrote` / `would change` line names both sides of the first difference now. Naming only
1346
+ what the file already said reported `line 2: "api_service_converter:"` for an insertion —
1347
+ the line the block was pushed past, rather than the block.
1348
+
1349
+ ### Added — the `Uniform:` region of a README has a gate, not only a generator (d.215e)
1350
+
1351
+ Owner decision 2026-09-09: that region is of the class `generated`, and 001 §2 makes such a
1352
+ class a generator PLUS a row carrying its `--check` (the precedent is `G-SHARED-ENV`).
1353
+ Until now the generator existed alone, so a README whose pointer had gone stale was reported
1354
+ by nothing anybody runs on a repository — the false guarantee of `automation-gates.md` §5.
1355
+
1356
+ * **`G-README`** (biz-service, severity `deploy`, scope `service`, `fix: npx
1357
+ oa-sync-template readme-uniform --target .`) — reads only the packaged manifest, so it
1358
+ answers in a container.
1359
+ * **`L-README-REGION`** (library, severity `publish`, scope `bearer`, `fix: npx
1360
+ oa-sync-template readme-uniform --all`) — a row of its own beside `L-README` rather than
1361
+ an extension of it, because the two halves of that file have different writers: the node
1362
+ header is written by a human, the region by a command. A checkout carrying no copy of
1363
+ this package reports it `NOT RUN` with that reason; a package declaring no category
1364
+ raises nothing, because `U-MISMATCH` already blocks it with its own fix.
1365
+
1366
+ Both render through `readmePointer.js` and compare through the same `checkUniformRegion`
1367
+ the `--check` run calls, and where each kind's region points is one module
1368
+ (`src/sync/readmeLocation.js`), read by the rows and by the CLI.
1369
+
1370
+ A forbidden row (`X-HOOKS`, `X-PREVAL`) now renders its path in the region like every other
1371
+ row. It did not, and the reason was a test rather than a reader:
1372
+ `api/tests/scripts/add-service.bats` grepped the WHOLE template for `hooks/pre-commit`, so
1373
+ a region correctly naming a banned path failed a case whose subject is whether `init.sh`
1374
+ still INSTALLS the hook. That case now asks it of the files that could.
1375
+
1376
+ ### Changed — `C-LINT` moved into the `docs` block, whose budget it declares (d.210b)
1377
+
1378
+ The literal `config/biz-docs-lint.tree.json` stood in the manifest twice: on the row and
1379
+ again as the block's `tree_config`. The block names the ROW now (`tree_config_row`) and the
1380
+ row names the path, so a rename has one place to happen. The row id is unchanged, and so is
1381
+ every verdict: the block gained `applies_to: "*"`, without which `file-present` would have
1382
+ skipped the row in silence.
1383
+
1384
+ ### Added — `D-NPM`, now that the rule it cites exists (L016)
1385
+
1386
+ Guidance until 2026-09-10, and true while it stood: 001 §2 asks that an npm script a live
1387
+ document cites still exist, and no rule of `lint-biz-docs.mjs` raised it, so a row would
1388
+ have reported nothing and read as a clean tree (001 §6). BIZ-DOCS wrote `L016`, which
1389
+ resolves a cited `npm run <name>` against the `package.json` above the tree — exactly one
1390
+ file for a service repository. Measured over the eight service trees: evaluated on seven of
1391
+ them with no finding, undecided on the eighth, which declares no tree config.
1392
+
1393
+ ### Fixed — a rule the lint could not EVALUATE is NOT RUN, not a finding
1394
+
1395
+ An evidence probe reading `api_biz/*` cannot be evaluated in a checkout that has no
1396
+ siblings — the shape of the CI job. Measured 2026-09-10 over `git archive HEAD` into such a
1397
+ directory: the template mirror came back `NOT DEPLOYABLE` on `D-PORT` and `D-RETIRED`,
1398
+ whose bans nobody had violated, while `U-ORPHAN` in the same run said the honest thing. The
1399
+ linter's two channels map onto this uniform's two now: its `findings` are findings, its
1400
+ `skipped` list is `NOT RUN`. A tree the lint REFUSES outright — no tree config, a broken
1401
+ configuration — stays a finding, because that one has a fix somebody can carry out. A check
1402
+ may therefore return `{ findings, notRun }`; until now it could only return an array, so
1403
+ "I could not decide this" had to be dressed as a finding.
1404
+
1405
+ ### Changed — `L-NO-FILE-RANGE` reads `file:` for every package, in every section (d.224d)
1406
+
1407
+ The row read `@onlineapps/*` only, so `lodash = file:../lodash` in `devDependencies` passed
1408
+ the uniform and the F10 publish gate had to keep rule **G2** of its own for exactly that
1409
+ case — one concern on two rails. A `file:` resolves to a path on the machine that wrote it
1410
+ and never reaches a published tarball, whoever owns the package, so the row now reads all
1411
+ four dependency sections for every name. RANGES (`^`, `~`, `latest`, `*`) stay scoped to
1412
+ what the SSOT owns: a caret on a third-party dependency is a judgement nothing here can
1413
+ make. G2 is deleted; the gate's rules are G4, G6 and G7.
1414
+
1415
+ ### Fixed — Step 7 reads the reasons a service is undeployable from the whole manifest run
1416
+
1417
+ A `CONFIG_STEP_ROWS` row carrying `deploy` was filtered out of `deployFindings` together
1418
+ with its report (`4dcd61a2`).
1419
+
1420
+
1421
+ ### Changed — the deploy signal says whether the run reached every blocking row (d.230)
1422
+
1423
+ `ci/deployability.json` carried one claim: `deployable`, meaning nothing blocked among the
1424
+ rows that RAN. Inside a service container the rows reading the workspace cannot run at all,
1425
+ so the file routinely said `deployable: true` about a tree whose other half nobody had
1426
+ opened — and a deploy gate reading only that key was honouring a `--skip` nobody had
1427
+ flagged (`automation-gates.md` §1.5). Confirmation 001 §4 knows one outcome, a finding
1428
+ refusing the deploy, and says nothing about NOT RUN.
1429
+
1430
+ The second half is **`complete`**: no row whose severity blocks this uniform was NOT RUN.
1431
+ It is derived in one place (`runManifest.js` § `incompleteRows`), so the verdict and the
1432
+ sentence a human reads name the same rows, and every `notRun` entry now carries the
1433
+ `severity` of the row that did not run — without it nothing can say whether a skipped row
1434
+ could have stopped anything (a `warn` row could not).
1435
+
1436
+ `deployable` is unchanged, and so is the registration contract of d.221. **No exit code
1437
+ changes**: an incomplete run must not stop a build image or a boot, because the partial run
1438
+ in the container is the intent (001 §4); completeness is the deploy gate's question, asked
1439
+ of the file. The gate takes a signal only when `deployable && complete`, and the complete
1440
+ run comes from the api checkout: `npx oa-validate --workspace <root>`.
1441
+
1442
+ The sentence a clear-but-incomplete run prints belongs to the manifest
1443
+ (`verdict.incomplete`, carrying `{count}` and `{ids}`), not to the renderer: a service says
1444
+ `deploy row(s)`, a library `publish row(s)`, and `manifestShape` refuses a manifest whose
1445
+ verdict lacks the sentence or whose sentence cannot name the rows.
1446
+
1447
+ ```
1448
+ DEPLOYABLE — manifest conformance: no findings, INCOMPLETE: 6 deploy row(s) not run
1449
+ (U-ORPHAN, R-NODE, D-PORT, D-SCRIPT, D-RETIRED, D-HEADER). Fix: run the uniform from the
1450
+ api checkout — npx oa-validate --workspace <workspace root>
1451
+ ```
1452
+
1453
+ ### Changed — the template rows reference the package, not its output (d.229)
1454
+
1455
+ `F-INIT`, `F-JEST`, `F-RUNNER`, `G-SETUP-INSTALL`, `G-SETUP-MATRIX`, `G-SETUP-VALIDATION`
1456
+ and `G-SHARED-ENV` pointed at `api/templates/business-service/…` and
1457
+ `api/config/shared-env.json`. Since d.215a the OWNER of the template is this package and
1458
+ `api/templates/business-service` is its committed output (001 §5), so those rows
1459
+ referenced the output rather than the owner — which confirmation 004 point 2 forbids.
1460
+
1461
+ The measured cost was where it matters most: with no workspace root — a service container
1462
+ at boot, and a service's own CI checkout — every one of those rows was `NOT RUN`. Over
1463
+ `api_biz/converter` on 2026-09-09 that was 13 rows skipped and two real findings
1464
+ (`F-RUNNER`, `G-SHARED-ENV`) never raised. After the change the same run raises the same
1465
+ findings as the workspace run, and only `U-ORPHAN`, `R-NODE` and the four `D-*` rows stay
1466
+ `NOT RUN` — the rows that really do read something outside this package.
1467
+
1468
+ **A fourth `from:` shape**: `{ "package": "<path inside this package>", "text": true }`,
1469
+ resolved from the package's own location, so it travels with the pin. `manifestShape`
1470
+ describes it and refuses both a packaged reference without `text: true` and one naming a
1471
+ `path` and a `package` at once (two owners for one fact).
1472
+
1473
+ **Which rows need the workspace is now the ROW's property, not only the scope's.** A check
1474
+ answers `needsWorkspace({ row })`; the ones that read nothing but their own reference
1475
+ answer from the reference. Scope `bearer` stays — making these rows `service` would have
1476
+ stopped the workspace run from checking them across every service, the regression d.223
1477
+ removed. With no workspace there is one bearer and nothing to tell apart, so a finding is
1478
+ located the way the repository knows it (`init.sh`, not `../../elsewhere/init.sh`).
1479
+
1480
+ `G-SHARED-ENV` reads the rendered copy in the packaged template. The key set is still owned
1481
+ by `api/config/shared-env.json`; the two are kept equal by `tests/unit/sharedEnvTemplate.test.js`
1482
+ — an output with no gate would be a second copy of the fact (`doc-code-binding.md` §5).
1483
+
1484
+ ### Fixed — one directory, one name: both roots of a run are canonical
1485
+
1486
+ The workspace root is derived from this package's own location, which node resolves
1487
+ through symlinks; a service or package root is an argument, which `path.resolve()` leaves
1488
+ in the spelling the caller typed. On macOS `/var` is a symlink to `/private/var`, so a run
1489
+ below the system temp got one root in each spelling — and "is the service inside the
1490
+ workspace", a string prefix, then answered no about a service plainly inside it. Measured
1491
+ over a `git archive` export of this repository: six rows reported `service root is outside
1492
+ the workspace root`, a sentence that was false, and the findings that export really
1493
+ carries were never raised. Every root now passes through `workspaceRoot.canonicalRoot()`
1494
+ before it is compared or printed.
1495
+
1496
+ ### Added — the documentation rows of the biz-service uniform (`docs`)
1497
+
1498
+ The manifest gained a seventh section. Confirmation `biz-service-manifest` 001 §2 names
1499
+ five `D-*` rules about a service's own `docs/**`; four of them land as rows, and the
1500
+ fifth lands as `guidance` because the rule it needs does not exist yet.
1501
+
1502
+ Nothing here decides whether a document is wrong. The rules of `<service>/docs/**` are
1503
+ already written and already have an owner — `api/scripts/ci/lint-biz-docs.mjs`, held by
1504
+ BIZ-DOCS — so `src/manifest/checks/docsLintBridge.js` is a bridge in the same shape
1505
+ `contractBridge.js` has for the deploy and installation contracts: the row names the lint
1506
+ rule it stands for, the bridge runs the lint ONCE per service and hands each row the
1507
+ findings of the rules it cites, with the lint's own `file:line` and message.
1508
+
1509
+ `D-PORT` cites `F002:http-ports`, `D-SCRIPT` cites `L007`, `D-RETIRED` cites `F002` and
1510
+ `D-HEADER` cites `S001,S002` — written in the linter's own `--rules` syntax, so the row
1511
+ and the tool cannot grow a private dialect between them. A finding lands on exactly ONE
1512
+ row: the most specific citation wins, which is what keeps the port ban from being
1513
+ reported twice under two ids with two different `fix` sentences, and two rows claiming
1514
+ one rule equally are a fail-fast.
1515
+
1516
+ A rule NO row cites is not reported by this uniform. Measured 2026-09-09 over the eight
1517
+ repositories: the lint raises 80 findings, of which 16 fall to a row here. The rest is
1518
+ the documentation gate's own, landing in each repository's CI at zero findings
1519
+ (`biz-docs-foreign-tree-gate` 001) — said out loud in the module header rather than
1520
+ implied as coverage that is not there.
1521
+
1522
+ Where the rows do NOT answer, they say so. They are `bearer`-scoped and declare
1523
+ `api/scripts/ci` as a root they read, so a service image and a service's own CI report
1524
+ them `NOT RUN` with that reason. A tree carrying no `config/biz-docs-lint.tree.json` is
1525
+ undecided rather than clean — the file itself is row `C-LINT`, with its own fix — and a
1526
+ rule whose evidence probe reads a checkout that is not there is reported undecided too,
1527
+ never as a pass (`automation-gates.md` §5). An empty `docs/` raises nothing: the absence
1528
+ of the tree is `G-SETUP`'s finding, and a second row with a different fix would be a
1529
+ duplicate.
1530
+
1531
+ `D-NPM` is `guidance`, not a row. 001 §2 asks that an npm script a document cites still
1532
+ exists, and no rule of the lint raises it; a row citing a rule nobody raises reports
1533
+ nothing and reads as a clean tree ("no check, so not a rule", 001 §6). The interface such
1534
+ a rule would need is handed to BIZ-DOCS, and the row is one line of manifest away once it
1535
+ exists.
1536
+
1537
+ Measured on the eight repositories the day it landed: `invoicing` 17 `D-HEADER` findings,
1538
+ `ingest` 4 rows undecided for want of a tree config, the other six clean.
1539
+
1540
+ ### Changed — `readme-uniform` renders the pointer of a biz service too
1541
+
1542
+ Confirmation 005 point 2 names three bearer kinds in one sentence — `library/<category>`,
1543
+ `biz-service`, `infra-service` — so `src/sync/readmePointer.js` is parameterised by kind
1544
+ rather than copied per kind; a second renderer would be two rails under one rule. What
1545
+ differs is declared as data: the region id, the regenerate command, the manifest a failed
1546
+ `--check` names, whether the README must carry a node header, and the body.
1547
+
1548
+ A service region carries `Uniform: [biz-service](<link>)`, the sentence saying which
1549
+ repositories wear the uniform (read from the `discovery` block and its SSOT, never typed),
1550
+ the row ids grouped by the manifest section that declares them, and which path falls under
1551
+ which row. A row taking its value from elsewhere renders the path that OWNS it, never the
1552
+ value (confirmation 004). A **forbidden** row contributes its id and not its path:
1553
+ `hooks/pre-commit` and `scripts/run-pre-validation.js` may not occur in a service
1554
+ repository as text at all — `api/tests/scripts/add-service.bats` greps the whole template
1555
+ for them, because `init.sh` used to install that hook — and a region naming them would
1556
+ write a retired path back into the template every service is created from. Measured: with
1557
+ the path in the region, that suite went 55/57.
1558
+
1559
+ The link points at the manifest as it lies in the service after `npm install`
1560
+ (`node_modules/@onlineapps/conn-orch-validator/manifests/biz-service.manifest.json`),
1561
+ derived from this package's name and the manifest's position inside it. A path, not a URL:
1562
+ a reader of the repository resolves it without a network. It resolves from the pin that
1563
+ ships `manifests/`, which is this wave.
1564
+
1565
+ `readme-uniform --target` now reads the KIND from the disk — a root carrying
1566
+ `config/service/operations.json` is a service, a root carrying `package.json` is a library,
1567
+ neither is a fail-fast naming both files. There is no `--kind` flag: it would be a second
1568
+ way to say what the directory already says. `--all` stays the library run, because the
1569
+ eight service repositories are separate checkouts and there is no list to walk; a service
1570
+ also needs no workspace above it, which is only consulted for what to call the target on
1571
+ stdout.
1572
+
1573
+ `templates/business-service/README.md` and its committed mirror
1574
+ `api/templates/business-service/README.md` carry the region rendered, so a service created
1575
+ by `--new` starts in sync. The pointer is the FIRST line of a service README: nothing
1576
+ requires a node header of a service, unlike a library, where its absence stays a fail-fast.
1577
+ The 27 library regions are byte-identical.
1578
+
1579
+ Not introduced: a manifest row asserting that a README region is current. The library
1580
+ manifest carries none (`L-README` checks the node header alone), so the service manifest
1581
+ gets none either — the pointer is a sync artefact with its own `--check`, and adding the
1582
+ row is the owner's decision rather than a side effect of this change.
1583
+
1584
+
1585
+ ### Changed — `F-RUNNER` is a delimited block, and the sync renders it
1586
+
1587
+ The row named a compose file and compared it against nothing but itself, so the generator
1588
+ printed it `NOT RUN` and the runner stayed a copy kept in sync by review. The template's
1589
+ compose now carries `# --- oa-test-runner v1` … `# --- end oa-test-runner v1` (the two
1590
+ markers `F-INIT` already uses in `init.sh`, read trimmed so they may sit indented inside
1591
+ `services:`), the row declares `block:` and `from:`, and `src/manifest/checks/composeRunnerBlock.js`
1592
+ holds the one definition both halves read.
1593
+
1594
+ Normalized is not loosened: `build`, `env_file` and `networks` are what the same row
1595
+ already compares against the SERVICE the runner tests, so they are removed before the
1596
+ block is compared against the template — and grafted back from that service when the sync
1597
+ renders it. Seven of the eight biz repositories reach a second network in both nodes
1598
+ (measured 2026-09-09); that is the rule being kept, and neither half may report it as a
1599
+ difference. `where` now names the repository (`api_biz/<service>/docker-compose.yml`): the
1600
+ row runs per bearer, and five services all saying `docker-compose.yml` were five findings
1601
+ nobody could tell apart.
1602
+
1603
+ ### Added — `G-SETUP-INSTALL`, `G-SETUP-MATRIX`, `G-SETUP-VALIDATION` (`skeleton_only`)
1604
+
1605
+ The three installation documents are compared by their set of `##` headings against the
1606
+ packaged template, never by their prose (confirmation 001 §2). The sync creates a missing
1607
+ document from the skeleton — header, title, sections, and no sentence of the template's
1608
+ prose, because what a section says is this service's own fact. An existing document is
1609
+ never rewritten: a missing section is `BLOCKED` with the heading named. That a document is
1610
+ absent at all remains `G-SETUP`'s finding through the installation contract's
1611
+ `SETUP_DOCS`, so one defect keeps one owner.
1612
+
1613
+ ### Fixed — the `G-SHARED-ENV` fix sentence doubled the path
1614
+
1615
+ `npx oa-sync-template shared-env --target config/env-templates` wrote
1616
+ `config/env-templates/config/env-templates/shared.env`: the subcommand appends
1617
+ `config/env-templates/shared.env` to its target. Measured, then corrected to `--target .`
1618
+ (confirmation 001 §3.2: every `fix` sentence in the manifest is a command).
1619
+
1620
+ ### Added — the template's production stage builds only a clean image
1621
+
1622
+ `templates/business-service/Dockerfile` runs `oa-validate` in the production stage, after
1623
+ `COPY . .` (confirmation 001 §3.1). The CLI is this package's, and all eight biz services
1624
+ declare this library under `dependencies`, so `npm ci --omit=dev` installs it. Rows that
1625
+ read a platform file print `NOT RUN` there — an image has no workspace above it — and one
1626
+ finding of severity `boot` or `deploy` fails the build.
1627
+
1628
+ ### Changed — step 2 of the boot validation IS the manifest rows for the config documents
1629
+
1630
+ `ValidationOrchestrator.validateConfig()` checked `config.json`/`service.name` and a non-empty
1631
+ `operations.json` with sentences of its own, while rows `C-SERVICE`, `C-OPS` and `C-CONTRACT`
1632
+ demand the same documents with more coverage, an owner, a fix and a doc pointer. One contract,
1633
+ two rails — and the same defect was reported twice: a free sentence in step 2 and a table row in
1634
+ step 7. Step 2 keeps its place in the boot order (steps 3-6 must not run behind a broken
1635
+ configuration, and step 7 runs after them) and now runs those three rows; step 7 leaves them out
1636
+ of its table and its errors as already answered. `results.steps.config` gained `findings`, and its
1637
+ `errors` are the row messages (`[Manifest] C-SERVICE config/service/config.json - …`). The
1638
+ banner and `ci/deployability.json` still describe the whole uniform — the verdict is about the
1639
+ tree, not about which step read a row. The manifest is loaded and run ONCE per validation
1640
+ (`manifestRun()`); the wrapper builds a fresh orchestrator for every attempt, revalidation
1641
+ included (d.224).
1642
+
1643
+ ### Changed — `installContract.js` exports the requirement ids it raises
1644
+
1645
+ `INSTALL_CONTRACT_REQUIREMENTS` joins `DEPLOY_CONTRACT_REQUIREMENTS` as an export, and `add()`
1646
+ refuses an id that is not on the list. The manifest bridge
1647
+ (`src/manifest/checks/contractBridge.js`) read a hand-written copy of those three ids, with a
1648
+ comment saying it was one; the deploy contract had already paid for that pattern, its copy naming
1649
+ one requirement fewer than the module enforced. The bridge now reads the export (d.224).
1650
+
1651
+ ### Added — the documentation tree's descriptive lists are rendered, not typed
1652
+
1653
+ `oa-sync-template docs-region (--id <id> | --all) --file <document> [--check]` renders five
1654
+ regions from the two packaged manifests: the repository shape annotated with the row owning
1655
+ each path, the required and forbidden scripts with their bodies, the runtime parameters, the
1656
+ table of every row, and the library categories with their duty sections. Three documents were
1657
+ copying the repository tree by hand and two the script table — a descriptive fact kept true by
1658
+ review only. A region carries a row's observable and never its `why` or `fix`; a `from:`
1659
+ reference renders as the path that owns the value, never as the value. The run replaces a
1660
+ region and never inserts one: a document with no markers fails and prints the two lines to
1661
+ paste, because where a section belongs in another thread's document is that thread's decision.
1662
+ `--list` names the ids, `--check` exits 1 with the drift (d.226).
1663
+
1664
+ ### Changed — one implementation of the generated-region markers
1665
+
1666
+ Finding, replacing, extracting and diffing a marked region moved to `src/sync/generatedRegion.js`,
1667
+ shared by `readme-uniform` and `docs-region`. `readme-uniform` renders byte-identical output
1668
+ (27 of 28 libraries in sync, exit 0, before and after). Damaged markers are now reported by
1669
+ `[GeneratedRegion]` rather than `[ReadmePointer]` — the module that rejects them names itself,
1670
+ and the message still names the file to repair (d.226).
1671
+
1672
+ ### Fixed — the api checkout is found by where this package sits, not by the directory's name
1673
+
1674
+ The workspace root was resolved by looking upwards for a directory named `api`. A CI job
1675
+ that clones the repository under any other name — `infra-mono` — therefore resolved no
1676
+ workspace, and every row reading the platform SSOT printed `NOT RUN` in a checkout that
1677
+ had the SSOT all along. The checkout is now identified by the marker it carries
1678
+ (`config/services.json`) and located from this package's own path, so the name of the
1679
+ directory stops being part of the contract; `--workspace` remains the explicit override,
1680
+ and a copy installed in a service's `node_modules` still resolves nothing, which is the
1681
+ behaviour a run inside a service container needs (d.227).
1682
+
1683
+
1684
+ ### Added — `config/service/*.json` is decided, and the keys nobody reads are refused
1685
+
1686
+ Confirmation `biz-service-manifest` 003 §18 asks for schemas over the three configuration
1687
+ files. Measurement first (d.217b): `operations.json` is already decided by
1688
+ `service-validator-core` `validateOperationsSchema`, and `integration-contract.json` by
1689
+ `utils/bizCiGateContract.js` — the validator the CI gate runs over it, which checks the
1690
+ connector booleans, the integration minimum, the database block and its agreement with
1691
+ `requiredConnectors.db`, and the env block with a `why` per name. Writing JSON Schema
1692
+ beside either would have been a second implementation of one rule, so `C-CONTRACT` now
1693
+ CALLS that validator (`check: config-contract`) instead of only asserting the file exists,
1694
+ and its message is the validator's own sentence. One rail, one wording, in the gate and in
1695
+ the table.
1696
+
1697
+ `config.json` had no owner, and `C-SERVICE` checked one key of it. It now demands the
1698
+ three that were measured to have a reader — `service.name`, `service.workspaceScoped` (an
1699
+ explicit boolean; the wrapper refuses to boot without one) and
1700
+ `service.specificationEndpoint` — and reports a present-but-wrongly-typed value with a
1701
+ different sentence than a missing one, because the two send the reader to different places.
1702
+
1703
+ `C-SERVICE-DEAD` (new row, severity `deploy`) refuses the four declarations the same
1704
+ measurement found no reader for anywhere in the platform: `service.version` (`ConfigLoader`
1705
+ assigns it from `package.json` on every load, so the value in the file is never read),
1706
+ `service.port` and `service.url` (confirmation `biz-service-port-url` 001) and
1707
+ `contractVersion` in the integration contract. Measured over the eight biz services: 22
1708
+ findings, and none for `C-SERVICE` or `C-CONTRACT` — the shape they already have is the
1709
+ shape the rows demand. A declaration nothing consumes is not harmless; it tells the next
1710
+ author something depends on it, and every new service copies it forward
1711
+ (`change-discipline.md` § Removing something removes its declaration).
1712
+
1713
+ ### Removed — `D-DB-DECL`, whose rule `C-CONTRACT` now states
1714
+
1715
+ Once `C-CONTRACT` calls the contract validator, a missing or non-boolean
1716
+ `requiredConnectors.db` is reported by it as `[BizCiGate] Invalid contract field`.
1717
+ `D-DB-DECL` said the same thing in its own words, and two rows for one rule is the
1718
+ duplicate `change-discipline.md` § One rail per concern forbids — so the row, its
1719
+ `db-declared` check and its tests are gone in this change, not left to drift apart
1720
+ (lead decision, d.217b).
1721
+
1722
+ `D-DB-CONSISTENT` stays: `requiredConnectors.db` against a repository that has
1723
+ `migrations/` or a database client anyway is a comparison the contract validator cannot
1724
+ make, because it never looks at the repository.
1725
+
1726
+
1727
+ ### Added — the business-service template ships in this package, and `oa-sync-template` renders it
1728
+
1729
+ Confirmation `biz-service-manifest` 001 §2 named three consumers of one declaration: the
1730
+ conformance check, the generator, and the documentation. Only the first existed. Every
1731
+ `fix` sentence about a file was therefore a hand-off — "copy the block from
1732
+ api/templates/business-service/init.sh" — and a hand-off is how nine copies of one
1733
+ `init.sh` block came to differ in the first place.
1734
+
1735
+ `templates/business-service/**` moved into this package (§2), so the shape a service is
1736
+ created from is pinned by the same version that pins the manifest it is measured against.
1737
+ `api/templates/business-service` is now that template rendered with its own placeholders
1738
+ as the parameters — generated output, committed for reading (§5) — and
1739
+ `tests/unit/templateMirror.test.js` measures §9's acceptance file by file, in both
1740
+ directions.
1741
+
1742
+ Two template files are packed under a name they are not written under, and both names are
1743
+ measurements rather than taste. `gitignore` has no dot because npm never packs a file
1744
+ called `.gitignore` — 19 of the template's 22 files reached `npm pack --dry-run` before,
1745
+ 22 do now. `package.json.template` carries a suffix because a `package.json` inside a
1746
+ package is a second package to every tool that walks a package tree: under its written
1747
+ name it produced eight findings from `oa-validate --library --all` and two from
1748
+ `api/scripts/ci/verify-manifest-pins.mjs`, step 1 of the pre-push hook, all of them false
1749
+ — the template is not a published library, and its pins are deliberately not the
1750
+ platform's, because the generator writes the SSOT's versions when it creates a service.
1751
+
1752
+ Three runs joined `shared-env` and `readme-uniform`:
1753
+
1754
+ * `oa-sync-template --new <name> --into <dir>` — a whole service tree. It is what
1755
+ `api/scripts/add-service.sh --scaffold` now runs, instead of a `cp -R`, a `sed` loop
1756
+ and three `jq` calls that made the script a second writer of files the library already
1757
+ knew how to write. `--description` is optional: without it `__SERVICE_DESCRIPTION__` is
1758
+ left standing and the files carrying it are listed, because this run has no description
1759
+ and inventing one would write a fact nobody stated.
1760
+ * `oa-sync-template [path…] --target <serviceRoot>` — the uniform sync of §3.2. It walks
1761
+ the rows of classes `identical` and `generated` and renders each from the very `from:`
1762
+ reference that row points the CHECK at: one definition, read twice. It holds no list of
1763
+ files and no template path of its own. Files of the `own` class are never written.
1764
+ * `oa-sync-template template --target <dir>` — the committed template directory as output,
1765
+ with `--check` as its gate.
1766
+
1767
+ A row the manifest does not make renderable is printed `NOT RUN` with the reason, never
1768
+ filled from a guess: `F-RUNNER`, `G-PROD` and `G-SETUP` declare no `from:` reference, so
1769
+ nothing says what their content is rendered from (`.claude/rules/automation-gates.md` §5).
1770
+ Measured over the eight biz repositories on 2026-09-09, the files `--check` would change
1771
+ are exactly the `F-INIT`/`F-JEST` findings `oa-validate` reports on the same trees.
1772
+
1773
+ ### Changed — the template gained `__REGISTRY_NAME__`, and the ignore list gained `ci/`
1774
+
1775
+ The template used to spend `__SERVICE_NAME__` on two facts — the registry identity
1776
+ (`biz-reporting`) and the directory-derived paths (`config/env-active/reporting.env`) — so
1777
+ the scaffold patched the identity back in with `jq` after copying, reformatting the JSON on
1778
+ the way. Each fact now has its own placeholder, which is what makes rendering a pure text
1779
+ substitution and the identity render comparable byte for byte. `ci/` joined the template's
1780
+ ignore list: `ci/deployability.json` is written on every start.
1781
+
1782
+ The `fix` sentences of `F-INIT` and `F-JEST` are now the command that fixes them. The other
1783
+ three file rows keep their prose fix, because the command would not do what it says.
1784
+
1785
+
1786
+ ### Fixed — the workspace run stops printing "no findings" over a drifting workspace
1787
+
1788
+ A check declares what it needs in order to look, and until now the vocabulary had two
1789
+ words for three needs. `F-INIT`, `F-JEST`, `G-SHARED-ENV`, `R-NODE`, `L-ENGINES` and
1790
+ `L-PINS` are facts of ONE bearer that reach their reference file — the template,
1791
+ `api/config/shared-env.json`, `api/.nvmrc`, `api/config/libraries.json` — only through
1792
+ the workspace. There was no word for that, so they borrowed `scope: workspace`, whose
1793
+ contract is "there is no service to read", and each of them opened with
1794
+ `if (serviceRoot === null) return []`.
1795
+
1796
+ The consequence was a silent pass, which `.claude/rules/automation-gates.md` §5 counts as
1797
+ a defect of the same severity as a wrong result. Measured on 2026-09-09:
1798
+ `oa-validate --workspace <root>` over this workspace printed `DEPLOYABLE — no findings`,
1799
+ while the same eight services, checked one at a time, reported 28 findings of those four
1800
+ rows — six drifted `jest.config.js`, four drifted `init.sh`, eight stale `shared.env`, and
1801
+ five services building on Node 22 against an `api/.nvmrc` of 24.
1802
+
1803
+ `CHECK_SCOPES` now names three: `service` (the bearer's root is enough), `workspace` (the
1804
+ check reports about the workspace itself) and `bearer` (about one bearer, workspace needed
1805
+ only to read its `from:` reference). A `bearer` row runs once per bearer — the one given in
1806
+ service mode, every one the manifest's own `discovery` block finds in workspace mode. The
1807
+ service tables and `--library --all` are unchanged, byte for byte; only the workspace run
1808
+ gained what it had been quietly dropping.
1809
+
1810
+ An unknown scope is refused where the check registers, not where it would first be needed:
1811
+ a check the runner cannot place would produce a report indistinguishable from one in which
1812
+ it found nothing.
1813
+
1814
+ ### Fixed — a clean verdict names how many rows did not run
1815
+
1816
+ `DEPLOYABLE — no findings` reads as a proven clean bill of health. Over a run where five
1817
+ rows could not look it is not one, and the NOT RUN lines above it do not undo the sentence
1818
+ a reader believes. Measured on 2026-09-09: the biz-service workspace run over a checkout
1819
+ carrying `api/` alone printed `DEPLOYABLE — no findings` with 29 rows not run.
1820
+
1821
+ The clear verdict is now `DEPLOYABLE — no findings (29 row(s) not run)` — and
1822
+ `PUBLISHABLE — no findings (6 row(s) not run)` for the library uniform — with the
1823
+ parenthesis appearing only when something really did not run. The outcome half of the
1824
+ sentence has one owner, `report.js` § `describeClearOutcome`, used by the init-log banner,
1825
+ the `oa-validate` report and the library report alike; before this the three described the
1826
+ same run in three different ways and two of them stopped at `no findings`. The blocked
1827
+ verdict and `ci/deployability.json` (which already carried `notRun[]`) are unchanged.
1828
+
1829
+ ### Changed — `L-CONSUMER` and `L-TOOLING` are bearer rows
1830
+
1831
+ Both are facts of ONE package — who pins it, which service carries it — that reach the
1832
+ answer only through the workspace: the same shape as `L-PINS`. Carried as
1833
+ `scope: workspace` they read a null package root in a whole-set run and returned `[]`, so
1834
+ `runManifest({ workspaceRoot })` said nothing about them at all. As `bearer` rows they run
1835
+ once per package, and the whole-set run is now the union of what each package says: over
1836
+ the fixture workspace, four findings (`lib-bad-core` and `lib-orphan` pinned by nothing,
1837
+ `lib-tooling` carried by a service twice) where it previously reported none.
1838
+ `--library --all` is unchanged at 30, byte for byte — that path already ran them per
1839
+ package.
1840
+
1841
+ `scopeByRowId` in the CLI became `packageRelativeRowIds`: it asks the registry which rows
1842
+ report a path relative to the PACKAGE root — only `scope: service` does — instead of
1843
+ naming a scope in a comment that had not mentioned the third. Nothing there had to move
1844
+ when these two rows changed scope, which is the point.
1845
+
1846
+ ### Fixed — a checkout without its siblings says NOT RUN instead of reporting a false blocker
1847
+
1848
+ CI checks out `api/` alone, so `<workspace>/api_biz` is not on disk at all. A check reading
1849
+ it then walked an absent directory, got the empty set, and reported it as an answer.
1850
+ Measured on 2026-09-09 against a `git archive` export of this repository: `--library --all`
1851
+ raised a blocking `L-CONSUMER` on `conn-base-db` — "pinned by nothing under
1852
+ api_biz/*/package.json, api/infra/*/package.json" — about files nobody opened, and
1853
+ `L-TOOLING` quietly answered "no service carries it" over the same absence. The
1854
+ biz-service workspace run printed `DEPLOYABLE — no findings` with `U-ORPHAN` passing over
1855
+ an `api_biz` that was not there.
1856
+
1857
+ A check now declares the directories it walks — `requiresSiblings({ row, block })`,
1858
+ derived from the row's own patterns so no name is written twice — and the RUNNER verifies
1859
+ them before running it, once, for every row alike: `NOT RUN — sibling root api_biz is not
1860
+ present in this checkout` (`sibling roots …, … are not present` when several are missing).
1861
+ Same rule `lint-biz-docs --allow-missing-siblings` already follows,
1862
+ `automation-gates.md` §5. A `bearer` row in workspace mode inherits the manifest's own
1863
+ discovery roots, because enumerating no bearers is how it would otherwise pass eight
1864
+ services nobody looked at.
1865
+
1866
+ Measured after: the same export raises 16 findings instead of 17 — the false one is gone —
1867
+ and names `L-CONSUMER` and `L-TOOLING` as NOT RUN. `--library --all` over the full
1868
+ workspace is unchanged at 30, byte for byte.
1869
+
1870
+ ### Changed — the template `jest.config.js` is a clean norm, so `F-JEST` measures against one
1871
+
1872
+ `templates/business-service/jest.config.js` set `RABBITMQ_URL` and `REGISTRY_URL` from a
1873
+ `||` fallback onto a hardcoded address — two violations of
1874
+ `.claude/rules/architecture-principles.md` §2 and §3 in the file `F-JEST` holds up as the
1875
+ norm. None of the eight biz services had copied it (measured: zero `||`, zero
1876
+ `process.env` writes in all eight), and the environment those two names come from is
1877
+ already owned elsewhere — `.gitlab-ci.yml` variables and `config/env-templates/shared.env`
1878
+ — so nothing was orphaned by removing them.
1879
+
1880
+ The template is now byte-equal to `api_biz/converter/jest.config.js`, the shape two of the
1881
+ eight already share exactly and six share in substance (referential run, confirmation
1882
+ `biz-service-manifest` 001 §8.4). `F-JEST` consequently clears for `converter` and `meta`
1883
+ and keeps reporting the other six, which differ for reasons of their own.
1884
+
1885
+ ### Added — the service uniform's rows: files, scripts, config, runtime, database
1886
+
1887
+ `manifests/biz-service.manifest.json` grows from three rows to twenty-nine, in six
1888
+ sections (confirmation `biz-service-manifest` 001 §2, §8 step 2). What each row decides
1889
+ is its `check`, and the checks fall into two kinds.
1890
+
1891
+ **Rows that cite a checker this package already runs**, and add no rule of their own
1892
+ (004 point 4): `G-PROD` and `R-PORTS` name requirements `R1` and `R4` of
1893
+ `utils/deployContract.js`; `G-SETUP`, `D-DB-PACKAGE` and `D-DB-HEADERS` name
1894
+ `SETUP_DOCS`, `SQL_PACKAGE` and `SQL_HEADERS` of `utils/installContract.js`;
1895
+ `C-ENV-READS` runs the completeness half of `utils/envContract.js`. One registry entry
1896
+ per checker, a `requirement` per row, and a row naming a requirement its checker does
1897
+ not raise fails fast instead of finding nothing.
1898
+
1899
+ **Rows that decide what nothing decided before**: `F-INIT` (the `oa-deps-guard v1` block
1900
+ of `init.sh`, byte-equal to the template — the same two markers
1901
+ `api/tests/scripts/infra-init-scripts.bats` matches on, reaching the biz repositories
1902
+ that bats file says are kept in sync by review only), `F-JEST`, `F-RUNNER` (the one-shot
1903
+ test runner's shape: one `test` profile, its own `1g` budget, no `restart`, the same
1904
+ build, env and networks as the service), `S-TEST`, `S-ALL-C`, `S-INT-C`, `S-COOKBOOKS`,
1905
+ `S-HOST`, `S-HOOKS`, `C-SERVICE`, `C-OPS`, `C-CONTRACT`, `C-ENV`, `C-LINT`,
1906
+ `G-SHARED-ENV`, `R-NODE`, `R-MEM`, `R-PORTS-DEV`, `D-DB-DECL`, `D-DB-CONSISTENT`.
1907
+
1908
+ Three of those fill a measured gap rather than repeating a gate: `R-NODE` compares the
1909
+ Node major against `api/.nvmrc`, where `R3` of the deploy contract only compares CI,
1910
+ Dockerfile and `engines.node` against each other — five of the eight services are
1911
+ internally consistent on Node 22 while the platform runs 24; `R-PORTS-DEV` reads the dev
1912
+ compose, which `R4` never does; and the memory norm had no gate at all.
1913
+
1914
+ A container script's body is parameterised by `${runner}`, resolved from the compose
1915
+ service carrying `profiles: [test]`, so seven spellings of one body are one body and not
1916
+ seven findings. `S-INT-C` carries `only_with: tests/integration`: a script pointing at an
1917
+ absent suite is a declaration with nothing behind it, and jest exits 1 on a path that
1918
+ matches no test.
1919
+
1920
+ ### Changed — `templates/business-service` reconciled with the rows it is the reference for
1921
+
1922
+ The template is the reference bearer of every `identical` row, so a row that is red on it
1923
+ is a test of the row (lead decision, `api/shared/TODO.md` §0.2b-9 point 4). It gains
1924
+ `scripts["test:all:container"]`, the canonical `scripts.test` body, and
1925
+ `config/biz-docs-lint.tree.json`; its README no longer offers the bare host scripts as a
1926
+ second way to run a suite, because the runner is the only rail
1927
+ (`biz-test-container` 001). `npx oa-validate` over the template reports nothing.
1928
+
1929
+
1930
+ ### Added — `oa-sync-template readme-uniform`, the rendered uniform pointer of a library README
1931
+
1932
+ Every library's `README.md` now carries a generated region between
1933
+ `<!-- BEGIN GENERATED: library-uniform -->` markers: the line
1934
+ `Uniform: [library/<category>](<link to manifests/library.manifest.json>)` and the duty
1935
+ sections that apply to that category, with the ids of their rows. The content is read
1936
+ from the library manifest and the package's own `oa.category`, the link is computed from
1937
+ the two paths, and `--check` exits 1 with a diff when a region on disk is not what the
1938
+ manifest renders. Marker shape and idea are `scripts/ci/sync-biz-facts.mjs`, so the
1939
+ workspace keeps one shape of generated region.
1940
+
1941
+ Discoverability of the uniform was a header per file in 003 §17; 005 point 2 replaced it
1942
+ with one rendered pointer per repository, and 0 of 28 library READMEs carried one. A
1943
+ library declaring no `oa.category` is announced `NOT RUN` and never guessed at — that
1944
+ absence is the blocking finding `U-MISMATCH` of `oa-validate --library`, which owns it.
1945
+
1946
+ ### Changed — the verdict is a property of the uniform, not of the runner
1947
+
1948
+ Which severities stop a bearer, and the two words its verdict is printed with, are
1949
+ now declared by the manifest: `blocking_severities` and
1950
+ `verdict: { blocked, clear }`, required by the manifest's check on itself
1951
+ (`M-BLOCKING`, `M-VERDICT`). `runManifest` reads them instead of a global constant
1952
+ and carries them in the result; `report.js` prints them.
1953
+
1954
+ Before this, one list held `boot`, `deploy` and `publish`, so a service run ended
1955
+ with `NOT DEPLOYABLE — N finding(s) of severity boot|deploy|publish` — offering a
1956
+ consequence no service row can raise — and the library CLI kept its own second list
1957
+ to avoid the same sentence. That duplicate (`LIBRARY_BLOCKING` in
1958
+ `src/cli/oa-validate.js`) is gone. A service now reads `… of severity boot|deploy`
1959
+ and `DEPLOYABLE — no findings`; a library reads `… of severity publish` and
1960
+ `PUBLISHABLE — no findings`.
1961
+
1962
+ ### Changed — `L-CONNECTOR-ENV` exempts the config module in either shape
1963
+
1964
+ The row's parameter is now `allowed: ["src/config/", "src/config.js"]` instead of
1965
+ `allowed_dir: "src/config"`: an entry ending in `/` exempts a directory, any other
1966
+ entry exempts that one file. The duty is about the concept — env reading lives in
1967
+ one config module — and a package small enough for a single `src/config.js` wears
1968
+ the same uniform as one with a directory. Measured over the workspace: the row falls
1969
+ from 2 findings to 1, and the one that remains
1970
+ (`conn-base-db/src/createSequelize.js`) is a real finding.
1971
+
1972
+ ### Changed — `L-README` asks a library README for `> Owns:` and `> Status:`
1973
+
1974
+ The row used to demand `Parent:`, `Owns:` and a `Status` section, citing
1975
+ `api/docs/biz/DOC-STANDARD.md` — a header that standard reserves for the biz
1976
+ documentation tree, which `api/shared/**` is not part of. It now asks for the node
1977
+ header of `api/docs/standards/INFRA-DOC-STANDARD.md`: a `> Owns:` line carrying a
1978
+ value, and a `> Status:` line whose value is `current`, `draft` or `archived`.
1979
+ `Parent:` is neither required nor forbidden — a library's parent is the rendered
1980
+ category line, so a hand-written one would be a second owner of one fact.
1981
+
1982
+ ### Added — the uniform manifest and `npx oa-validate` (towards 8.0.0)
1983
+
1984
+ `manifests/biz-service.manifest.json` is the machine-readable declaration of what a
1985
+ biz service is, shipped inside this package so that the manifest version IS the
1986
+ validator version — the pin decides the shape, and no version field exists in the
1987
+ file (confirmation `biz-service-manifest` 004).
1988
+
1989
+ The core lands here; the rows that describe the whole uniform follow in the same
1990
+ major:
1991
+
1992
+ - `src/manifest/` — loader, the manifest's check on ITSELF (required row fields,
1993
+ unique ids, valid severity, a registered check with its parameters, a non-empty
1994
+ `doc`, and no copied list of names), discovery of bearers from the disk with
1995
+ `from:` references resolved against the workspace, a registry of checks, and the
1996
+ report;
1997
+ - rows `X-HOOKS` and `X-PREVAL` (`files.forbidden`, severity `deploy`) — the hook
1998
+ the owner deleted and the per-service pre-validation script the library command
1999
+ replaced;
2000
+ - row `U-ORPHAN` — a directory under the discovery pattern that matches no pattern,
2001
+ or that `api/config/services.json` does not declare;
2002
+ - `bin.oa-validate` — one table `id · severity · where · what · fix · owner`, the
2003
+ `doc` pointer per reported row, `--json`, `--workspace`, exit `0` / `1` (`2` when
2004
+ the run cannot start).
2005
+
2006
+ Where the workspace is not reachable — inside a service container it is not — the
2007
+ rows that need it print `NOT RUN` with the reason, never a pass.
2008
+
2009
+ Measured on the eight biz repositories the day it landed: converter clean, the other
2010
+ seven carrying `hooks/pre-commit`, `scripts/run-pre-validation.js` or both; zero
2011
+ `U-ORPHAN`.
2012
+
2013
+ - **Step 7/7 "Manifest conformance", the banner and `ci/deployability.json`.** The
2014
+ manifest now runs at every boot, against the service root the orchestrator was
2015
+ constructed with (never `process.cwd()`). A `boot` finding fails the validation as
2016
+ before; a `deploy` finding lets the service start and sets `results.deployable =
2017
+ false` with the rows in `results.deployFindings`, prints the banner
2018
+ `NOT DEPLOYABLE — N finding(s)` plus the table through the injected logger, and
2019
+ writes the verdict to `ci/deployability.json` for `deploy-production` to refuse on.
2020
+ A clean run says `DEPLOYABLE — manifest conformance: 0 finding(s), K not run`; the
2021
+ signal carries the NOT RUN rows too, because a row that could not look is not a
2022
+ pass. No timestamp in the file — an unchanged tree writes an unchanged file. A run
2023
+ that stops at an earlier fail-fast step reports `deployable: null`, which is
2024
+ "unmeasured", not "fine". `oa-validate --workspace <root>` without a service root is
2025
+ the workspace run: every workspace-scoped finding, and the per-service rows NOT RUN.
2026
+ In a single service's run those rows report only what lies under that service — and
2027
+ where the service root does not lie under the workspace root at all, they are NOT RUN
2028
+ with that reason rather than silently filtered to nothing.
2029
+ Measured: hello-service 2 findings and `deployable: false`, converter 0 and
2030
+ `deployable: true`.
2031
+
2032
+
2033
+ ### Added — the library uniform and `npx oa-validate --library`
2034
+
2035
+ `manifests/library.manifest.json` — the same concept for the 28 shared libraries, in
2036
+ the same package and therefore on the same version, so the two shapes cannot drift
2037
+ apart (confirmation `biz-service-manifest` 002 §10–§15). A library has no boot, so its
2038
+ rows carry one severity, `publish`: the choke point is `scripts/publish-library.sh`,
2039
+ and what fails there never becomes an artefact.
2040
+
2041
+ - **Discovery, not a list.** `api/shared/**/package.json` minus `**/node_modules/**`
2042
+ and `**/tests/fixtures/**`, bound to `api/config/libraries.json` by a `from:`
2043
+ reference. `U-ORPHAN` reports both directions: a package the SSOT does not know, and
2044
+ a name in the SSOT with no package. No library name appears in the manifest, and the
2045
+ validator's own fixtures are excluded by a manifest row rather than by a branch in
2046
+ the code (002 §13.1).
2047
+ - **Categories as definitions.** `core`, `connector`, `orchestration`, `runtime`,
2048
+ `tooling`, each with the categories it `may_depend_on` — itself included, explicitly,
2049
+ because within a layer is allowed and upward is not. The category is DECLARED
2050
+ (`"oa": { "category": … }`) and verified against the dependency graph; a missing
2051
+ declaration and a declaration the graph contradicts are both `U-MISMATCH` (005.3).
2052
+ `orchestration` gets no row of its own: "depends only downward" is what `U-MISMATCH`
2053
+ already decides.
2054
+ - **Rows.** Every library: `L-MAIN`, `L-ENGINES` (against the platform major,
2055
+ referenced from `api/.nvmrc`), `L-TESTS`, `L-TEST-SCRIPT`, `L-PACK-TESTS`, `L-PINS`,
2056
+ `L-NO-FILE-RANGE`, `L-CHANGELOG`, `L-README`, `L-CONSUMER`. Per category:
2057
+ `L-CORE-DEPS`, `L-CORE-EXPORTS`, `L-CONNECTOR-ENV`, `L-RUNTIME-CLIENT`, `L-TOOLING`.
2058
+ `L-PACK-TESTS` measures the effect (`files` allowlist, then `.npmignore`, then
2059
+ `.gitignore`), never the presence of a file — eight packages keep `tests/` out with
2060
+ an allowlist alone, and a row checking for `.npmignore` would report all eight as
2061
+ broken. Falsified against `npm pack --dry-run` over all 28: both say clean, 28/28.
2062
+ - **What is deliberately not a row** is written into the manifest's `guidance` block
2063
+ with its reason and its decision — `L-LINT` (002 §14), the logger/error contract, the
2064
+ boot-phase messages, "a removed export bumps the major", and the canonical body of
2065
+ the `test` script. A duty nothing can decide is guidance, not a rule (001 §6), and
2066
+ the shape check fails the build if a guidance entry grows a `check`.
2067
+ - **`oa-validate --library <packageDir>` and `--library --all`** — one table
2068
+ `id · category · where · what · fix`, the `doc` pointer per reported row, `--json`,
2069
+ exit `0` / `1` (`2` when the run cannot start). `--library` with neither a package
2070
+ nor `--all` is a usage error; nothing is guessed. `--all` runs every discovered
2071
+ package plus one whole-set run, because a name in the SSOT with no package on disk
2072
+ belongs to no package's root and no per-package run would ever show it.
2073
+ - `severity: publish` joins `boot` and `deploy` in the shape check; `from:` gained two
2074
+ shapes for the two SSOTs that are not arrays of objects — `names: "keys"` for an
2075
+ object map (`api/config/libraries.json`) and `text: true` for a file that is one
2076
+ value (`api/.nvmrc`). The service manifest is unchanged and its self-test unchanged.
2077
+ - **`ok` now answers for a library too.** `runManifest.BLOCKING_SEVERITIES` carries
2078
+ `publish` beside `boot` and `deploy`, so a run over a library returns `ok: false`
2079
+ when a `publish` row fires; before, `ok` was computed from the service severities
2080
+ alone and a library run reported `ok: true` with findings on the table. One fact,
2081
+ one owner — a consumer no longer has to recompute the verdict per uniform.
2082
+ `LIBRARY_MANIFEST_PATH` moved from the CLI to `src/manifest/loadManifest.js` beside
2083
+ `DEFAULT_MANIFEST_PATH`, for the same reason: where the manifest lives is a fact
2084
+ about the package, not about the CLI, and a boot step reads it without one.
2085
+
2086
+ Measured over the 28 libraries the day it landed: 119 findings — `U-MISMATCH` 28 (no
2087
+ package declares a category yet), `L-ENGINES` 28 (no package declares the platform
2088
+ major), `L-README` 28, `L-CHANGELOG` 21, `L-PINS` 13 (all one uncommitted
2089
+ `logger-contract` 1.0.0 → 1.1.0 wave, not a defect of the pins), `L-CONSUMER` 1
2090
+ (`conn-base-db`, which nothing in the workspace depends on), `U-ORPHAN` 0. Nothing in
2091
+ the libraries was changed by this entry; that is the next batch.
2092
+
2093
+ ### Added — the platform env manifest and `bin.oa-sync-template` (towards 8.0.0)
2094
+
2095
+ - `src/sync/sharedEnv.js` — `renderSharedEnv(manifest)` and `checkSharedEnv(manifest, text)`,
2096
+ both pure: the file text `api/config/shared-env.json` describes, and whether a file on
2097
+ disk is still that text. Nothing here reads the environment or a path; the caller loads,
2098
+ reads and writes. `consumers` is deliberately not rendered — who reads a key is a fact
2099
+ the code owns.
2100
+ - `bin.oa-sync-template` — `oa-sync-template shared-env --target <dir> [--workspace <root>]
2101
+ [--check]` writes `<dir>/config/env-templates/shared.env` from that manifest, or exits 1
2102
+ with the diff under `--check` (2 when the run cannot start). No flag points it at another
2103
+ manifest: the shared key set has one owner, beside the SSOT.
2104
+ - Measured on the two copies this repository owns: the platform template and the
2105
+ business-service template are now rendered output, key-for-key identical to the platform
2106
+ file they came from. The business-service copy was 4 keys/values adrift (`LOG_LEVEL`
2107
+ absent, `guest:guest` and two `minioadmin` values where the platform file says
2108
+ `CHANGE_ME`).
2109
+
2110
+ Decision: confirmation `biz-service-manifest` 003 §18, 004.
2111
+
2112
+ ### Changed — `verify-test-coverage` asks the DISK too, not only jest
2113
+
2114
+ **BREAKING.** The gate compared what jest matches against what the `test:all`
2115
+ chain and the declared stack tiers run — and all three asked the same jest. A
2116
+ `testMatch` narrowed to `tests/unit` therefore hid `tests/integration/` from
2117
+ every side at once, and the run printed `OK … 1 file(s) matched, 1 run by the
2118
+ test:all chain` with a whole suite on disk running nowhere (measured 2026-09-08).
2119
+
2120
+ `verifyTestCoverage` now also walks the repository for platform test files
2121
+ (`**/tests/**/*.test.js` — the `testMatch` `templates/business-service/jest.config.js`
2122
+ declares, used verbatim by all eight biz repositories) and reports every one the
2123
+ service's jest config does not match, as `TEST_NOT_MATCHED`. `node_modules` and
2124
+ `.git` are not walked; a file under `tests/` that is not a test file
2125
+ (`tests/helpers/*.js`, fixtures, `tests/setup-env.js`) is never reported.
2126
+ `TEST_COVERAGE_SCOPE` names the new side, so the printed scope still describes
2127
+ what the gate looked at.
2128
+
2129
+ Repositories that already keep the platform `testMatch` are unaffected: the eight
2130
+ biz repositories return exactly the verdicts they returned before (7 × OK,
2131
+ hello-service `TEST_RUNS_NOWHERE`).
2132
+
2133
+ ### Changed — every error this package throws names the context that threw it
2134
+
2135
+ Each thrown message now carries `[Context] Problem - Expected/Fix`, chained failures
2136
+ pass their origin as `cause` instead of interpolating its text, and a static guard
2137
+ (`tests/unit/error-message-contract.test.js`) fails the suite if a bare message returns.
2138
+
2139
+ ### Changed — `step.service` names a registered service, or the step fails
2140
+
2141
+ **BREAKING.** `CookbookTestRunner._resolveServiceRoot` no longer falls back to
2142
+ `servicePath` for an unregistered name, so a typo in a cookbook's `step.service`
2143
+ is refused (`Unknown step.service "<x>" - Expected one of: <registered names>`)
2144
+ instead of quietly running the runner's own handler and reporting PASS.
2145
+
2146
+ ### Removed — the integration minimum no longer checks `test:all` by NAME
2147
+
2148
+ `verifyIntegrationMinimum` required the literal substring `test:integration` in
2149
+ `test:all` — a rule about a name, which passed for `echo test:integration` and
2150
+ failed for a chain reaching the same files through another script; the fact it
2151
+ was standing in for is now measured by `verify-test-coverage`, so the second rail
2152
+ is gone. The other three checks stay: `test:integration` must exist, it must not
2153
+ carry `--passWithNoTests`, and `test:all` must exist.
2154
+
2155
+ ### Added — `verify-test-coverage`: every test runs somewhere, or says why it cannot
2156
+
2157
+ **BREAKING (the check is folded into `verify-contract`).** A new command, and a
2158
+ new optional contract field `stackTiers`. The gate compares three sets, all of
2159
+ them answered by the service's own jest rather than by parsing its scripts:
2160
+
2161
+ | | what | how |
2162
+ |---|---|---|
2163
+ | T | every file the service jest config matches | `jest --listTests` |
2164
+ | A | every file the `test:all` chain runs | the chain decomposed `npm run …` → jest, each invocation asked `--listTests` |
2165
+ | B | every file the declared stack tiers run | the same, per declared script |
2166
+
2167
+ A file in T that is in neither A nor B is a suite that runs nowhere: green
2168
+ whenever somebody runs it by hand, in no pipeline, and reported by nothing.
2169
+ Measured over the eight biz repositories on 2026-09-07 — biz-hello matched 17
2170
+ files and ran 14.
2171
+
2172
+ `stackTiers` is how a suite legitimately stands outside `test:all`:
2173
+
2174
+ ```json
2175
+ "stackTiers": [
2176
+ { "script": "test:e2e", "requires": "needs a live biz-hello consumer on biz-hello.workflow; CI has no service runtime" }
2177
+ ]
2178
+ ```
2179
+
2180
+ `requires` is mandatory, for the same reason `why` is mandatory in the `env`
2181
+ block: a tier nobody can justify is the one nobody dares delete. There is no
2182
+ exemption by directory name — `tests/e2e/**` is not self-evidently un-runnable,
2183
+ and a name cannot carry a reason.
2184
+
2185
+ Five violations, each naming the fix: a test that runs nowhere; a declared
2186
+ script `package.json` does not have; a declared script that lists no test file
2187
+ (a declaration with no consumer); a declared script whose tests `test:all`
2188
+ already runs (the declaration states something untrue); and — refused when the
2189
+ contract loads — an entry without `requires`. A step in the chain the gate
2190
+ cannot decompose is reported too, never skipped: an unreadable step would make
2191
+ every count a guess.
2192
+
2193
+ **BREAKING for repositories:** `verify-contract` now runs this check, so from
2194
+ 8.0.0 a repo with a test file outside `test:all` and outside a declaration is
2195
+ RED. At the pin, that is biz-hello and only biz-hello
2196
+ (`tests/e2e/mq-invocation.test.js`, which needs a live consumer): it declares
2197
+ the tier, and goes green. The other seven repositories are already clean —
2198
+ measured 2026-09-07: converter 66/66, emailer 28/28, ingest 24/24, invoicing
2199
+ 36/36, meta 51/51, pdfgen 17/17, property 163/163.
2200
+
2201
+ **What the gate does not measure, and says so on every run:** whether a declared
2202
+ stack tier is ever RUN. The declaration says why the suite cannot run in
2203
+ `test:all`; it never claims something else runs it.
2204
+
2205
+ ### Added — `getForeignTestNamespace()`, and R8 accepts it
2206
+
2207
+ **Minor, additive.** Beside `getTestNamespace()` the package now exports
2208
+ `getForeignTestNamespace()`: a namespace an integration test must NOT see rows
2209
+ from, for the isolation tests that prove a handler filters on tenant_id AND
2210
+ workspace_id. It returns another member of the allowed environment classes,
2211
+ chosen by position in that list and wrapping at its end — deterministic per
2212
+ environment, with the caller's own workspace, and validated by the same
2213
+ whitelist, the same refusal message and the same missing-variable message as
2214
+ `getTestNamespace()`.
2215
+
2216
+ Why the library owns it (owner decision 2026-09-07, via BIZ-hello): the two
2217
+ alternatives a repo reaches for are both what deploy-contract R8 exists to
2218
+ refuse — a literal (biz-hello asserted against tenant 101, the live Meditest
2219
+ customer, so its isolation test wrote into real data) or arithmetic
2220
+ (`tenant + 1`, which turns the platform default 99 into 100, LIVE). Reading a
2221
+ second env variable per repo was rejected as a duplicate rail.
2222
+
2223
+ R8's permit in `utils/deployContract.js` now recognises a call to either helper,
2224
+ on a line that executes; a mention in a comment still earns nothing.
2225
+
2226
+ ### Fixed — the deploy-contract gate reports the scope it actually checks
2227
+
2228
+ **Patch — messages only, no rule changed.** `verify-contract`,
2229
+ `verify-deploy-contract` and the green line
2230
+ `[BizCiGate] OK deploy-contract (<service>) — … satisfied` all announced
2231
+ `R1-R7`, hand-written in four places. R8 (one source for tenant / workspace
2232
+ identity) had been part of the run for weeks, and R6 has never been part of it —
2233
+ it is the library-compatibility gate `verify-lib-compat`. So the gate claimed one
2234
+ check it does not run and hid one it does.
2235
+
2236
+ The scope now has a single owner: `DEPLOY_CONTRACT_REQUIREMENTS` in
2237
+ `utils/deployContract.js`, the module's own list of the ids it can raise, with
2238
+ `DEPLOY_CONTRACT_SCOPE` (`R1-R5, R7-R8`) rendered from it. Both are exported for
2239
+ callers that need to name the scope. `verifyDeployContract` refuses an id that is
2240
+ not on the list, so the list and the code cannot drift apart again.
2241
+
2242
+ Reported by BIZ-hello (`api/shared/TODO.md`, "biz-ci-gate hlásí menší pokrytí,
2243
+ než jaké odbaví").
2244
+
2245
+ ### BREAKING — `SETUP_DOCS` moves from `docs/setup/` to `docs/80-setup/`
2246
+
2247
+ **Major, behavioural.** The installation contract now requires
2248
+ `docs/80-setup/{INSTALL,PLATFORM_MATRIX,VALIDATION}.md`. It required
2249
+ `docs/setup/{…}.md` before; the file names are unchanged, the branch is not.
2250
+ There is no tolerance of the old path and no fallback — a repo that still
2251
+ carries `docs/setup/` fails `verify-install-contract` (and `verify-contract`,
2252
+ which folds it in) with three `SETUP_DOCS` violations
2253
+ (`architecture-principles.md` §3, §11).
2254
+
2255
+ The contract had been the reason seven biz repos violated `docs/biz/DOC-STANDARD.md`
2256
+ rule 7, which requires numbered branches `NN-name`: converter measured that
2257
+ renaming to a numbered branch satisfied lint S006 and immediately broke the CI
2258
+ gate. The owner decided the contract yields to the standard, not the other way
2259
+ round — `api/docs/governance/confirmations/docs-setup-branch-naming.md`
2260
+ confirmation 001, verdict "B — kontrakt: SETUP_DOCS přejmenovat na `NN-setup/`".
2261
+ The number `80` is BIZ-general's: property, the only numbered biz tree today,
2262
+ already carries `docs/80-setup/`; `30` would collide with property's
2263
+ `30-recurring`; and setup is an operational appendix sitting behind the contract
2264
+ nodes, in the same band as the platform tree's `80-decisions` / `90-migration`.
2265
+
2266
+ **How a repo adopts it.** The rename and the pin of this package's new version
2267
+ land in the SAME commit of each repo (`git mv docs/setup docs/80-setup` plus the
2268
+ `@onlineapps/conn-orch-validator` bump). Between the publish and that commit the
2269
+ repo's CI is red, so the two halves are never split across commits
2270
+ (`automation-gates.md` §3 — a gate lands with its compliance, not ahead of it).
2271
+ The `Fix:` half of the violation message says so on every run: *"add
2272
+ docs/80-setup/INSTALL.md to the repository; a repo carrying the older
2273
+ docs/setup/ branch renames it to docs/80-setup/."*
2274
+
2275
+ ### Removed — the documented `url` tolerance in `ServiceReadinessValidator`
2276
+
2277
+ **Patch — no behaviour changes.** The class docblock carried a paragraph
2278
+ explaining that the retired probe's `url` may still arrive from a stale caller,
2279
+ and a test asserted that tolerance. Neither had a subject, and the paragraph said
2280
+ so itself: the only caller that ever passed a url was Tier-1's readiness step,
2281
+ retired 2026-08-22, and the one consumer left —
2282
+ `helpers/createServiceReadinessTests`, and through it every biz
2283
+ `tests/bootstrap/` suite — passes name, version, operations, testCookbook and
2284
+ registry. A tolerance nobody exercises still tells the reader that some caller
2285
+ depends on it (`change-discipline.md` § Removing something removes its
2286
+ declaration).
2287
+
2288
+ The docblock now states what `validateReadiness(service)` reads: `name`,
2289
+ `version`, `operations`, `testCookbook`, `registry`. Nothing replaced the
2290
+ tolerance — the method destructures the fields it needs and looks at no others,
2291
+ the same decision as `ValidationOrchestrator` for the same reasons (a
2292
+ per-class unknown-key rejection would be a second rail and a gate without its
2293
+ compliance sweep, `automation-gates.md` §3). A caller that still passes a `url`
2294
+ is unaffected, and the test that now stands asserts exactly that alongside the
2295
+ removal.
2296
+
2297
+ ### Removed — the documented `serviceUrl` tolerance in `ValidationOrchestrator`
2298
+
2299
+ **Patch — no behaviour changes.** The class docblock promised that
2300
+ `options.serviceUrl` is "accepted-and-ignored: existing callers (ServiceWrapper,
2301
+ the biz `scripts/run-pre-validation.js`) still pass one". That sentence had gone
2302
+ false in both halves: `ServiceWrapper._createValidationOrchestrator()` says in
2303
+ its own comment that `serviceUrl` is NOT passed, and the biz
2304
+ `scripts/run-pre-validation.js` hand theirs to `CookbookTestRunner`, never to
2305
+ this class. A tolerance nobody exercises still tells every reader that some
2306
+ caller depends on it, which is the declaration `change-discipline.md` § Removing
2307
+ something removes its declaration exists to delete. The two tests asserting the
2308
+ tolerance, and the `serviceUrl` key in two test fixtures, went with it.
2309
+
2310
+ **Nothing was added in its place, and that is a decision.** The constructor reads
2311
+ `serviceRoot`, `serviceName`, `serviceVersion` and `logger`, and looks at no
2312
+ other key — the same way `CookbookTestRunner` and every other component in this
2313
+ package treat their options. Rejecting unknown keys would put a new mechanism on
2314
+ one class only (a second rail), and would be a gate landing without the
2315
+ compliance sweep `automation-gates.md` §3 requires. A caller that still passes a
2316
+ `serviceUrl` is therefore unaffected, exactly as before.
2317
+
2318
+ ### Removed — `run-prevalidation` no longer loads the service config to fetch a URL nobody reads
2319
+
2320
+ **Minor, behavioural.** `resolveServiceUrl()` is gone from `src/cli/biz-ci-gate.js`
2321
+ and the `serviceUrl` parameter is gone from `runPreValidation`
2322
+ (`src/utils/preValidation.js`).
2323
+
2324
+ It resolved the service's own `@onlineapps/service-wrapper`, ran
2325
+ `ConfigLoader.loadAll()` and handed `config.service.url` to `CookbookTestRunner`
2326
+ — which never read it (`this.serviceUrl` appears nowhere in that class). And the
2327
+ value had stopped existing: the owner removed `port` and `url` from the service
2328
+ shape (`docs/governance/confirmations/biz-service-port-url.md` 001), so
2329
+ `loadAll()` does not return `service.url` any more. Measured against the
2330
+ installed loader with a `config.json` that still declares the field:
2331
+
2332
+ ```
2333
+ WITH url in config.json -> config.service.url = undefined
2334
+ service keys = name,version,specificationEndpoint,description,workspaceScoped,env
2335
+ ```
2336
+
2337
+ So the resolution had been yielding `undefined` for every service on the
2338
+ platform. What it still did was make an installed wrapper, a readable
2339
+ `package.json`, a declared `specificationEndpoint` and a resolvable `${ENV}`
2340
+ placeholder into preconditions of a cookbook run that depends on none of them —
2341
+ a required declaration with no consumer, which is worse than none
2342
+ (`change-discipline.md` § Removing something removes its declaration).
2343
+
2344
+ Running cookbooks needs `tests/cookbooks/`, `operations.json` and the handler
2345
+ modules. A service that ships none of the rest now runs its gate; a service that
2346
+ still declares `service.url` is unaffected, because the field is ignored rather
2347
+ than refused. The test that asserted the old refusal (`@onlineapps/service-wrapper
2348
+ is not installed`) went with the code that produced it.
2349
+
2350
+ ### Added — `utils/handlerRef`: one definition of what a handler ref is and what resolving it means
2351
+
2352
+ **Minor.** New exports on the package index: `HANDLER_REF_PATTERN`,
2353
+ `parseHandlerRef(ref)` and `resolveHandlerModule({ serviceRoot, handlerRef })`.
2354
+
2355
+ The rule lived in five places. Four were copies of the same regex literal
2356
+ checking only the SHAPE — `ServiceReadinessValidator`, `ValidationOrchestrator`,
2357
+ `ServiceStructureValidator`, `helpers/createServiceReadinessTests`. The fifth,
2358
+ `CookbookTestRunner.resolveOperation`, actually resolved a ref, and did it more
2359
+ weakly than production. Measured against the new fixture before the change:
2360
+
2361
+ ```
2362
+ double-hash -> { modulePath: '…/src/handlers/v3/good.js', exportName: 'run' }
2363
+ escapes-src -> { modulePath: '…/v3-service-broken-handler/outside.js', exportName: 'run' }
2364
+ missing-export -> { modulePath: '…/src/handlers/v3/good.js', exportName: 'nope' }
2365
+ ```
2366
+
2367
+ `handlers/v3/good#run#extra` was silently truncated and dispatched;
2368
+ `../outside#run` loaded and ran a module from OUTSIDE the service source root;
2369
+ an export that does not exist got a descriptor. Cookbooks run at every biz
2370
+ service boot, so that was the loosest resolution on the platform sitting on the
2371
+ hottest path.
2372
+
2373
+ The two functions are separate on purpose, and that is what lets
2374
+ `service-wrapper`'s `HandlerLoader` adopt them as an internal swap rather than
2375
+ an API change: `resolveHandlerModule` states exactly the rule the loader
2376
+ implements today (containment on the resolved location, `require`, the export is
2377
+ a function), while the stricter declared form — `handlers/<path>#<export>`, owned
2378
+ by `api/docs/biz/30-operations/schema-v3.md` § handler — stays in
2379
+ `parseHandlerRef`. Adopting the first narrows nothing the wrapper accepts.
2380
+
2381
+ ### Changed — the cookbook runner resolves handlers by that rule, which makes it stricter
2382
+
2383
+ **Minor, behavioural.** `CookbookTestRunner.resolveOperation()` now refuses what
2384
+ it used to accept: more than one `#`, a ref outside the declared form (including
2385
+ anything reaching out of `src/`), a module that does not load, and an export that
2386
+ is missing or is not a function. The failures arrive at resolution instead of at
2387
+ the first invocation, and each names the ref, the resolved path and the fix.
2388
+
2389
+ A service whose cookbooks pass today keeps passing — every conformant ref
2390
+ resolves exactly as before. One whose `operations.json` carries a malformed ref
2391
+ now fails its cookbook step where it previously dispatched something the author
2392
+ did not write. The descriptor gained `handler` (the resolved function), and the
2393
+ dispatch no longer requires the module a second time.
2394
+
2395
+ ### Added — the readiness suite verifies that every declared handler RESOLVES
2396
+
2397
+ **Minor.** `createServiceReadinessTests` generates one more test: *every declared
2398
+ handler resolves to an exported function*, through `resolveDeclaredHandlers`,
2399
+ which is exported from the helper module. Matching the ref against a regex said
2400
+ nothing about whether the module is in the image, so an operation naming a module
2401
+ nobody shipped passed readiness in CI and failed at boot, where
2402
+ `HandlerRegistry.validate()` resolves every declared operation and fail-fasts.
2403
+ The verdict was right in the end; it arrived one environment too late. The check
2404
+ fail-fasts on the first unusable handler and names the operation.
2405
+
2406
+ ### Changed — the `getTestNamespace()` refusals name the CI half of the fix, not only the local file
2407
+
2408
+ **Patch.** All three fail-fast messages of `utils/testNamespace` (missing value,
2409
+ non-integer value, refused tenant class) pointed at `config/env-active/shared.env`
2410
+ and nothing else. That file exists on a developer's machine and in no CI job: CI
2411
+ takes the testing pair from the pipeline variables, not from a file in the
2412
+ repository (`api/docs/standards/tenant-allocation.md` § Env variables). So the
2413
+ one place the message is read by somebody who cannot act on it — a red pipeline —
2414
+ was the one place its `Fix:` was unusable, which is the defect
2415
+ `architecture-principles.md` §5 names.
2416
+
2417
+ Each message now names both: `locally in config/env-active/shared.env`, `in CI in
2418
+ the pipeline variables`, and the section of the standard that owns the rule. The
2419
+ verdicts are untouched — the same values are accepted and refused as before, and
2420
+ only the wording moved.
2421
+
2422
+ ### Added — an `expect.output` field name is a dot path into the output
2423
+
2424
+ **Minor.** `CookbookTestRunner.validateOutput()` resolves a field name such as
2425
+ `"pdf.size"` through nested objects instead of looking up `actual["pdf.size"]`
2426
+ flat. Every matcher works over a path — `equals`, `contains`, `type`, `pattern`,
2427
+ `min`, `max`, `length`, `exists`.
2428
+
2429
+ Why it had to change: a file descriptor lives under its own key inside the
2430
+ output (`{ pdf: <descriptor> }`), never at the root
2431
+ (`api/docs/biz/40-cookbooks/variable-references.md` § "File descriptors in
2432
+ outputs"). A cookbook that may only name root keys could therefore assert
2433
+ nothing at all about a generated file: `"pdf.size": { "min": 1000 }` came back
2434
+ as `Field pdf.size: missing in output` and failed the step — and, at Tier-1, the
2435
+ boot of a service returning exactly what the norm prescribes.
2436
+
2437
+ Two rules, both decisions:
2438
+
2439
+ - **The exact key wins, at every level.** `{ "a.b": 1 }` is legal output, so
2440
+ `"a.b"` is looked up verbatim before the name is split; splitting is what
2441
+ happens when the name does not exist as written. Nothing has to be escaped.
2442
+ - **Objects only.** A segment never indexes an array — `"items.0"` does not
2443
+ reach the first element — and never reads a property off a scalar. No cookbook
2444
+ asks for indices today, and the syntax would be this library's invention
2445
+ rather than the norm's.
2446
+
2447
+ A path that cannot be resolved reports the whole path AND the first segment that
2448
+ failed, because "no `pdf` at all" and "a `pdf` without a `size`" are different
2449
+ defects: `Field pdf.size: missing in output - segment "pdf" not found. Fix: …`.
2450
+ A name without a dot keeps its original wording, unchanged.
2451
+
2452
+ Backwards compatible: a flat key resolves exactly as before, including the
2453
+ `Field <name>: missing in output` message it produced.
2454
+
2455
+ ### BREAKING — `getTestNamespace()` refuses tenant 97; the allowlist is 96 / 98 / 99
2456
+
2457
+ **Major.** `ALLOWED_TENANT_CLASSES` in `utils/testNamespace` drops **97**. A run
2458
+ configured with `TESTING_TENANT_ID=97` now throws instead of returning a
2459
+ namespace, and the refusal message lists the classes that remain.
2460
+
2461
+ 97 was the `TESTING` environment class. The allocation retired it on 2026-08-31
2462
+ and the whitelist kept it, so the guard accepted at run time a class no row of
2463
+ `api/docs/standards/tenant-allocation.md` claimed — the document itself recorded
2464
+ the gap as "a residue, not a permission", waiting for exactly this change. A
2465
+ whitelist states the allocation; it does not outlive it.
2466
+
2467
+ **Nothing in the workspace sets that value.** Every `TESTING_TENANT_ID` in `api`
2468
+ and `api_biz/*` is `99` in the env templates and `96` in CI, verified before the
2469
+ narrowing — compliance precedes the gate (`automation-gates.md` §3). Dependents
2470
+ that pin an older version keep the wider list until they bump; a repo that has
2471
+ adopted this version and still configures 97 fails fast at the first integration
2472
+ run, naming the value, the allowed classes and the document.
2473
+
2474
+ Unchanged: 96, 98 and 99 are still accepted, 100 (LIVE) and 101+ (CUSTOMER) are
2475
+ still refused, and the workspace id is still not class-checked.
2476
+
2477
+
2478
+ ### Added — R8 exempts an integration test that owns its database, and reports the half-done case
2479
+
2480
+ **Minor.** `utils/deployContract` R8 gains a third permit for `tests/integration/`:
2481
+ a file that builds its own schema (`CREATE DATABASE` / `CREATE SCHEMA`) on a line
2482
+ that executes AND redirects `DB_NAME` to it (`process.env.DB_NAME = …`, bracket
2483
+ form included) no longer has to take its namespace from the shared test-namespace
2484
+ module. It targets no shared namespace, so the helper protects nothing it could
2485
+ reach, and the only assertion it could offer instead cannot fail —
2486
+ `getTestNamespace()` returns a value from `ALLOWED_TENANT_CLASSES` and nothing
2487
+ else. Reporting a state whose only compliance is an assertion that cannot fail is
2488
+ what `automation-gates.md` §3 forbids.
2489
+
2490
+ The gate is not softened by it. A file that builds its own schema and does NOT
2491
+ redirect `DB_NAME` is still reported, now under a message of its own: its queries
2492
+ reach the live database while the file appears to own a throwaway. Both halves of
2493
+ the permit are read on lines that execute — a `CREATE DATABASE` or a redirect
2494
+ sitting in a comment earns nothing. Every file R8 reported before this change is
2495
+ still reported, and every file it reported for having no schema of its own keeps
2496
+ the message it had.
2497
+
2498
+ The permit asks for the redirect rather than banning the live database's name:
2499
+ a test may legitimately name the live schema to assert it never touches it, and
2500
+ a ban would demand the deletion of that guard — the same reason the runtime guard
2501
+ is an allowlist rather than a comparison against a declared live id.
2502
+ Rule: `api/docs/standards/tenant-allocation.md` § Enforcement.
2503
+
2504
+ ### Changed — the logger check is `@onlineapps/logger-contract`, and the message case follows it
2505
+
2506
+ `ServiceReadinessValidator`, `ValidationOrchestrator` and `CookbookTestRunner`
2507
+ each carried their own `LOGGER_METHODS` constant and their own validation loop —
2508
+ three byte-identical copies of one contract, which
2509
+ `change-discipline.md` § One rail per concern calls a defect. All three now call
2510
+ `assertLogger(context, logger, reason)` from `@onlineapps/logger-contract`
2511
+ (exact pin `1.0.0`), per owner confirmation
2512
+ `api/docs/governance/confirmations/connector-logger-contract.md` 004.
2513
+
2514
+ What a caller sees: the same two states, refused at the same point in the same
2515
+ constructor, with the same `[Context] …` prefix and the same `Fix:` line. The
2516
+ only difference is the word after the prefix — `logger is required` /
2517
+ `logger is incomplete` where these three used to write `Logger` with a capital
2518
+ L. The other four adopters (`content-resolver`, `cookbook-template-helpers`,
2519
+ `conn-orch-registry`, `error-handler-core`) already wrote it lowercase, so this
2520
+ removes a divergence rather than creating one.
2521
+
2522
+ ### BREAKING — cookbook `steps` is an ARRAY, and the object shape is refused (DÁVKA 95a)
2523
+
2524
+ **Major.** `utils/cookbookFormat.normalizeCookbookSteps` is renamed to
2525
+ `readCookbookSteps` and no longer normalises anything: a `steps` value that is
2526
+ present but not an array THROWS, with the object→array migration recipe in the
2527
+ message. `CookbookTestRunner.normalizeSteps()` is gone — the runner calls the one
2528
+ owner of the rule directly. `CookbookTestRunner.validateCookbook` throws
2529
+ `[CookbookTestRunner] Cookbook has no steps - …` where it used to throw
2530
+ `Cookbook must have steps (array or object)`.
2531
+
2532
+ Owner decision, fully authoritative:
2533
+ `api/docs/governance/confirmations/cookbook-steps-shape.md` 001 (2026-09-05) —
2534
+ `steps` is an array of step objects carrying `step_id`, the only allowed shape;
2535
+ the object variant must not be permitted or used anywhere. It supersedes the
2536
+ 2026-08-17 object directive, which never landed in any runtime.
2537
+
2538
+ Why the tolerance had to go rather than stay as a convenience: the offline runner
2539
+ converted the object into an array, so Tier-1 passed a cookbook the production
2540
+ WorkflowOrchestrator — which only ever read arrays — threw on. That is a false
2541
+ green, and it is what kept object-shaped cookbooks alive in the tree. Order is
2542
+ the reason the shape matters at all: JS reorders numeric object keys and Postgres
2543
+ `jsonb` does not preserve key order either (measured M9,
2544
+ `infra/api_monitoring/src/consumer/cookbookSplit.js:32-37`).
2545
+
2546
+ ### BREAKING — the cookbook version needs all three components (DÁVKA 95a)
2547
+
2548
+ **Major.** `checkCookbookFormatVersion` accepted 1 to 3 numeric components
2549
+ (`/^\d+(\.\d+){0,2}$/`); it now requires `major.minor.patch`
2550
+ (`/^\d+\.\d+\.\d+$/`). `"version": "2.1"` was accepted by this Tier-1 gate and
2551
+ rejected by the machine SSOT — `shared/cookbook/cookbook-core/schemas/cookbook.v2.schema.json:16`,
2552
+ `"pattern": "^2\\.\\d+\\.\\d+$"` — so the offline rail was the lenient one, which
2553
+ is the same false green as the object steps. Five fixtures in this package
2554
+ declared `"2.1"` and now declare `"2.1.0"`.
2555
+
2556
+ ### REMOVED — `WorkflowTestRunner` (DÁVKA 95a)
2557
+
2558
+ **Major.** `WorkflowTestRunner` and its export are deleted. It was a second,
2559
+ parallel cookbook-execution rail with no consumer anywhere in the workspace
2560
+ (measured 2026-09-05: `api`, `api_biz/*`, `fe_adminui`, outside this package's
2561
+ own test and export), and it read `step.id` — the spelling `format.md` § Steps
2562
+ bans — so every key it wrote into the workflow context was `undefined`. A
2563
+ duplicate rail with no written justification is a defect, not redundancy
2564
+ (`change-discipline.md` § One rail per concern).
9
2565
 
10
2566
  ### BREAKING — standard level v1.1 "Multitenancy Standard" is removed (DÁVKA 88j)
11
2567