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