@onlineapps/conn-orch-validator 12.1.0 → 12.1.1

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,88 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [12.1.1] — 2026-09-25
8
+
9
+ ### Fixed — `verify-deploy-uniform.sh`: rada při checkoutu mimo `api_biz/` vede na cestu, kterou šablona skutečně deklaruje (d.899)
10
+
11
+ Hláška radila `GIT_CLONE_PATH: $CI_BUILDS_DIR/oa-uniform/api_biz/<service>` bez `$CI_CONCURRENT_ID`,
12
+ zatímco `.oa-uniform` v šabloně `.gitlab-ci.yml` deklaruje `$CI_BUILDS_DIR/oa-uniform/$CI_CONCURRENT_ID/api_biz/<service>`.
13
+ Nově radí „make this job extend .oa-uniform" a cituje cestu šablony; `biz-deploy-uniform-gate.bats` čte
14
+ očekávanou cestu ze šablony, takže se hláška a šablona nemohou rozejít.
15
+
16
+ ### Fixed — `verify-deploy-uniform.sh` smaže jen klon, který sám vytvořil; cizí `api/` odmítne a nechá netknutý (d.885)
17
+
18
+ Šablonový skript brány nasazení odvozuje `API_CHECKOUT="<workspace>/api"` a existující adresář před
19
+ klonem maže, aby runner, který znovu používá svůj builds adresář, neměřil proti zastaralému SSOT. Za
20
+ „svůj" ale uznal každý adresář s `.git` a `config/services.json`, a ty nese KAŽDÝ checkout api
21
+ repozitáře. 2026-09-24 skript spustil vývojář z `api_biz/emailer` na pracovní stanici,
22
+ `<workspace>/api` byl skutečný pracovní strom a skript ho smazal (639 sledovaných souborů; obnoveno).
23
+
24
+ Skript teď po vlastním `git clone` zapíše do klonu značku `.verify-deploy-uniform.clone`
25
+ (`created-by verify-deploy-uniform.sh <ISO čas> <pid>`). Mazat smí jen adresář, který nese `.git`,
26
+ `config/services.json` A tu značku. Bez značky končí `die` (exit 2): „exists and carries no marker …
27
+ Expected: a clone this script made. Fix: remove it yourself, or run this script only from a CI
28
+ checkout where <workspace>/api is free" a na adresář nesáhne. Přepínač, který by kontrolu vypnul,
29
+ neexistuje (`automation-gates.md` §1 požadavky 3 a 5). URL `file://` se nezakazuje: ochranu nese
30
+ značka a druhé pravidlo pro tutéž věc by bylo druhou kolejí.
31
+
32
+ RED (`api/tests/scripts/biz-deploy-uniform-gate.bats`, tři nové případy): `not ok 1 an api checkout
33
+ the gate did not make is refused and left exactly as it was` (`[ "$status" -eq 2 ]` failed: skript
34
+ cizí checkout smazal a naklonoval znovu), `not ok 2` a `not ok 3` (značka neexistuje). GREEN: celý
35
+ soubor `1..20`, 20× `ok`, včetně případů se skutečným enginem, které čtou klon se značkou.
36
+
37
+ ### Fixed — konfigurační řádek se severitou `deploy` už neshodí boot služby; start blokuje jen `boot` (d.870)
38
+
39
+ Krok 2 boot validace (`validateConfig`, řádky `C-SERVICE`, `C-OPS`, `C-CONTRACT`) soudil start podle
40
+ `blocking_severities` uniformy. Ten seznam je ale **verdikt nasaditelnosti**: `runManifest` z něj počítá
41
+ `ok`, tištěné jako DEPLOYABLE / NOT DEPLOYABLE, a pro službu zní `["boot", "deploy"]`. Krok 7 přitom
42
+ soudil start jen podle `boot`. Konfigurační řádek se severitou `deploy` by tedy odmítl start služby, kterou
43
+ konfirmace `biz-service-manifest` 001 §4 nechává běžet („`deploy` — the service **starts**") a jen ji označí
44
+ za nenasaditelnou. Dnes to nikdo neviděl, protože všechny tři řádky kroku 2 mají `boot`. Chyba by se ukázala
45
+ v den, kdy jednomu z nich manifest dá `deploy`.
46
+
47
+ Otázku „blokuje tento nález start?" teď oba kroky kladou jedné funkci `blocksBoot` nad konstantou
48
+ `BOOT_SEVERITY` (`src/manifest/manifestShape.js`, vedle `SEVERITIES`). `blocking_severities` zůstává tím,
49
+ čím je: seznamem verdiktu uniformy. Tvar výsledku kroků (`valid`, `errors`, `findings`, `deployable`,
50
+ `deployFindings`) se nemění.
51
+
52
+ RED (`tests/unit/ValidationOrchestrator.manifestStep.test.js`): nový případ „a config row of severity deploy
53
+ does not refuse the boot" `Expected: true / Received: false` na `step2.valid`, `1 failed, 15 passed`. Kontrolní
54
+ případ (týž řádek s `boot` → `valid: false` a přesně jedna chybová zpráva) byl zelený před opravou i po ní.
55
+
56
+ ### Documentation — čísla kroků a citace zrušené hlášky v docblocích (d.870)
57
+
58
+ - Docblock `runCookbookTests()` říkal „Step 4", ale běh ho loguje jako `Step 5/7`; docblock
59
+ `validateOperations()` říkal „Step 3" (tedy číslo kroku prostředí), ale běh ho loguje jako `Step 4/7`.
60
+ - Odstavec o zrušeném kroku Readiness (a týž komentář v `tests/unit/ValidationOrchestrator.unit.test.js`)
61
+ citoval jako přítomnou hlášku kroku 2 `operations.json has no operations defined`, kterou žádný kód nevydává.
62
+ Teď cituje řádek uniformy `C-OPS`, který tu otázku zodpovídá. Odkaz na řádek nezestárne jako opsaný text.
63
+
64
+ ### Fixed — R6 porovná se SSOT KAŽDÝ pin `@onlineapps/*`, i u knihoven, které infra neinstaluje (d.654, d.654b)
65
+
66
+ Zúžení na `infraConsumed` přeskakovalo i porovnání ROVNOSTI pinu se SSOT, ne jen otázku
67
+ kompatibility s infra vydáním. Pravidlo R6 přitom žádnou infra klauzuli nemá: služba pinuje
68
+ přesně to, co SSOT deklaruje (`.claude/rules/architecture-principles.md` § Version pinning).
69
+ `notGated` proto **už není výjimka z porovnání, jen informace** o tom, kterou VĚTU nález
70
+ dostane — infra balíček „biz pins X, infra ships Y", balíček bez infra konzumenta „service
71
+ <jméno> pins X, the platform library SSOT declares Y … Fix: npm install <pkg>@Y --save-exact".
72
+ Zelený řádek navíc říká, kolik pinů se doopravdy porovnalo, aby se coverage brány nedala
73
+ odhadnout z názvu druhého seznamu.
74
+
75
+ **Nahrazuje chování popsané u d.413** („`notGated` zůstává pro deklarovaný balíček, který
76
+ infra neinstaluje" — tam byl `notGated` výjimka z porovnání; od d.654 už není). Položka
77
+ vydané verze zůstává, jak byla: popisuje, co ta verze dělala.
78
+
79
+ Změřeno nad `api_biz/ingest` (13 pinů `@onlineapps/*`, SSOT deklaruje 28 knihoven a
80
+ `infraConsumed` 7) — PŘED (vydaný validátor 12.1.0): `not gated (no infra service installs
81
+ these): …8 balíčků…` + `OK verify-lib-compat — 5 pin(s) match the platform library set`;
82
+ PO: `no infra comparison (no infra service installs these; their SSOT pin is compared all
83
+ the same): …8 balíčků…` + `OK verify-lib-compat — 13 pin(s) match the platform library
84
+ SSOT, 5 of them also compared against the infra library set`. Falzifikace na kopii
85
+ `package.json` s `@onlineapps/service-wrapper` 10.1.2 proti SSOT 10.1.1 (balíček, který
86
+ žádná infra služba neinstaluje): 12.1.0 exit 0 „5 pin(s) match", nový validátor exit 1
87
+ s jmenovaným nálezem a opravou. Nedotčená kopie dál exit 0.
88
+
7
89
  ## [12.1.0] — 2026-09-18
8
90
 
9
91
  ### Fixed — lint, který se odmítl spustit, je NOT RUN, ne šest nálezů o dokumentaci (d.632, W632)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/conn-orch-validator",
3
- "version": "12.1.0",
3
+ "version": "12.1.1",
4
4
  "description": "Validation orchestrator for OA Drive microservices - coordinates validation across all layers (base, infra, orch, business)",
5
5
  "oa": {
6
6
  "category": "orchestration"
@@ -24,6 +24,21 @@ const { loadManifest, DEFAULT_MANIFEST_PATH } = require('./manifest/loadManifest
24
24
  const { runManifest } = require('./manifest/runManifest');
25
25
  const { resolveWorkspaceRoot } = require('./manifest/workspaceRoot');
26
26
  const { renderBanner, describeFinding } = require('./manifest/report');
27
+ const { BOOT_SEVERITY } = require('./manifest/manifestShape');
28
+
29
+ /**
30
+ * Whether a finding refuses the service's start — the one question steps 2 and
31
+ * 7 both ask, so they ask it here (`.claude/rules/change-discipline.md` § One
32
+ * rail per concern). Until d.870 step 2 answered it with the uniform's
33
+ * `blocking_severities`, which is the deployability verdict and names `deploy`
34
+ * as well, while step 7 answered `boot` alone: a config row given `deploy`
35
+ * would have refused a start that confirmation `biz-service-manifest` 001 §4
36
+ * says goes ahead.
37
+ *
38
+ * @param {{severity: string}} finding
39
+ * @returns {boolean}
40
+ */
41
+ const blocksBoot = (finding) => finding.severity === BOOT_SEVERITY;
27
42
  const { buildDeployabilitySignal, writeDeployabilitySignal } = require('./manifest/deployabilitySignal');
28
43
  const { isGitCheckout, NOT_A_CHECKOUT } = require('./manifest/gitCheckout');
29
44
 
@@ -392,8 +407,8 @@ class ValidationOrchestrator {
392
407
  * (handler presence and shape, bundle_scope, input, output, the retired
393
408
  * v2 fields) — the two loops were textual copies of one another.
394
409
  * - Its only two extra guards, "operations must be an object" and "no
395
- * operations defined", are step 2's `operations.json has no operations
396
- * defined`.
410
+ * operations defined", are answered by step 2 through the uniform row
411
+ * `C-OPS`.
397
412
  * - Run against a valid baseline plus 13 mutations covering every rule,
398
413
  * its verdict equalled `step2 && step3` in all 14 cases. Against the
399
414
  * eight live biz services it emitted one check, scored 80 out of a
@@ -651,9 +666,13 @@ class ValidationOrchestrator {
651
666
  * leaves them out, because they are answered, not because they stopped
652
667
  * mattering.
653
668
  *
654
- * Which severity fails the boot is the uniform's own list, never a constant
655
- * here — a service raises `boot`, a library `publish` (`api/shared/TODO.md`
656
- * §0.2b-15).
669
+ * Only a `boot` finding fails the start (`blocksBoot`, the same question
670
+ * step 7 asks). The uniform's `blocking_severities` is a different list: the
671
+ * deployability verdict, `["boot", "deploy"]` for a service — a `deploy` row
672
+ * here is reported, and the service starts undeployable (confirmation
673
+ * `biz-service-manifest` 001 §4; d.870). This step only ever runs the service
674
+ * uniform (`manifestRun()` loads `DEFAULT_MANIFEST_PATH`), so no library
675
+ * severity reaches it.
657
676
  *
658
677
  * @returns {{valid: boolean, errors: string[], findings: object[]}}
659
678
  */
@@ -661,7 +680,7 @@ class ValidationOrchestrator {
661
680
  try {
662
681
  const run = this.manifestRun();
663
682
  const findings = run.findings.filter((finding) => CONFIG_STEP_ROWS.includes(finding.id));
664
- const blocking = findings.filter((finding) => run.blockingSeverities.includes(finding.severity));
683
+ const blocking = findings.filter(blocksBoot);
665
684
 
666
685
  this.logger.info(`[ValidationOrchestrator] ✓ Config files: ${blocking.length === 0 ? 'PASS' : 'FAIL'}`);
667
686
  return {
@@ -701,7 +720,7 @@ class ValidationOrchestrator {
701
720
  }
702
721
 
703
722
  /**
704
- * Step 3: Validate operations compliance (v3 — handler registry dispatch).
723
+ * Step 4: Validate operations compliance (v3 — handler registry dispatch).
705
724
  * Required per operation: handler ('handlers/<path>#<export>'), bundle_scope, input, output.
706
725
  * Forbidden (v2): endpoint, method, path.
707
726
  * Warned about: a missing `description` — inherited from the removed
@@ -767,7 +786,7 @@ class ValidationOrchestrator {
767
786
  }
768
787
 
769
788
  /**
770
- * Step 4: Run cookbook tests via CookbookTestRunner (v3 handler dispatch).
789
+ * Step 5: Run cookbook tests via CookbookTestRunner (v3 handler dispatch).
771
790
  * Operations must declare a `handler` in operations.json; the runner
772
791
  * loads the module via `require()` and calls the exported function
773
792
  * in-process. Failures fail this validation step.
@@ -1003,7 +1022,7 @@ class ValidationOrchestrator {
1003
1022
  });
1004
1023
 
1005
1024
  const findings = result.findings.filter((finding) => !CONFIG_STEP_ROWS.includes(finding.id));
1006
- const bootFindings = findings.filter((finding) => finding.severity === 'boot');
1025
+ const bootFindings = findings.filter(blocksBoot);
1007
1026
 
1008
1027
  return {
1009
1028
  valid: bootFindings.length === 0,
@@ -467,8 +467,13 @@ async function runVerifyLibCompat(options) {
467
467
  const librarySet = await loadLibrarySet(options.libraries);
468
468
  const result = checkLibCompat(pkg, librarySet);
469
469
 
470
+ // What is skipped for these is the INFRA comparison, and nothing else: since
471
+ // d.654 their pin is held against the SSOT like every other. The line used to
472
+ // read "not gated", which after d.654 told the reader the opposite of what
473
+ // the run did.
470
474
  if (result.notGated.length > 0) {
471
- process.stdout.write(`[BizCiGate] not gated (no infra service installs these): ${result.notGated.join(', ')}\n`);
475
+ process.stdout.write('[BizCiGate] no infra comparison (no infra service installs these; their SSOT pin '
476
+ + `is compared all the same): ${result.notGated.join(', ')}\n`);
472
477
  }
473
478
  if (!result.ok) {
474
479
  for (const violation of result.violations) {
@@ -476,7 +481,14 @@ async function runVerifyLibCompat(options) {
476
481
  }
477
482
  process.exit(1);
478
483
  }
479
- process.stdout.write(`[BizCiGate] OK verify-lib-compat — ${result.gated.length} pin(s) match the platform library set\n`);
484
+ // Two numbers, because the run answers two questions and a single count
485
+ // could only ever be true of one of them: how many pins were held against the
486
+ // SSOT (all of them, since d.654), and how many of those infra also installs,
487
+ // which is the compatibility half. Both come from the rule module's own
488
+ // lists, never counted again here (`.claude/rules/automation-gates.md` §5 —
489
+ // a coverage sentence smaller than the coverage is a false signal).
490
+ process.stdout.write(`[BizCiGate] OK verify-lib-compat — ${result.compared.length} pin(s) match the platform `
491
+ + `library SSOT, ${result.gated.length} of them also compared against the infra library set\n`);
480
492
  }
481
493
 
482
494
  async function runVerifyContract(options) {
@@ -21,16 +21,26 @@
21
21
 
22
22
  const { walkManifest, collectRows, isPlainObject } = require('./walk');
23
23
 
24
+ /**
25
+ * The one severity that refuses a service's start (001 §4). Every other
26
+ * consequence leaves the service running: `deploy` makes it undeployable,
27
+ * `publish` belongs to a library, which has no start at all, and `warn` stops
28
+ * nothing. It is NOT the uniform's `blocking_severities` — that list is the
29
+ * VERDICT (DEPLOYABLE / PUBLISHABLE, `runManifest` turns it into `ok`), and for
30
+ * a service it names `deploy` too (d.870).
31
+ */
32
+ const BOOT_SEVERITY = 'boot';
33
+
34
+ /** The one severity that stops nothing: it is printed in the table and nowhere else. */
35
+ const WARN_SEVERITY = 'warn';
36
+
24
37
  /**
25
38
  * The consequences a row may carry, and no others. `boot` and `deploy` are the
26
39
  * service uniform's (001 §4); `publish` is the library uniform's — a library has
27
40
  * no boot, and the choke point nothing reaches a service past is
28
41
  * `scripts/publish-library.sh` (002 §10).
29
42
  */
30
- const SEVERITIES = Object.freeze(['boot', 'deploy', 'publish', 'warn']);
31
-
32
- /** The one severity that stops nothing: it is printed in the table and nowhere else. */
33
- const WARN_SEVERITY = 'warn';
43
+ const SEVERITIES = Object.freeze([BOOT_SEVERITY, 'deploy', 'publish', WARN_SEVERITY]);
34
44
 
35
45
  /** Which severities a uniform may declare as blocking — every consequence except `warn`. */
36
46
  const BLOCKING_CANDIDATES = Object.freeze(SEVERITIES.filter((severity) => severity !== WARN_SEVERITY));
@@ -435,6 +445,7 @@ module.exports = {
435
445
  rowNeedsWorkspace,
436
446
  describeFromProblem,
437
447
  SEVERITIES,
448
+ BOOT_SEVERITY,
438
449
  CHECK_SCOPES,
439
450
  WORKSPACE_DEPENDENT_SCOPES,
440
451
  BLOCKING_CANDIDATES,
@@ -7,26 +7,34 @@
7
7
  * matches the version the platform declares. Mismatch means the service was
8
8
  * built against a different platform contract than the one it will run on.
9
9
  *
10
- * Only packages at least one infra service actually installs are compared: the
11
- * SSOT lists biz-only packages too, and for those there is no infra version to
12
- * diverge from, so asking "does this match what infra ships" has no answer.
13
- * The SSOT carries that set as `infraConsumed`. A bare version map has no such
14
- * list — a platform-release entry records what was deployed, so everything in
15
- * it is by definition shipped and must match.
10
+ * EVERY package the SSOT declares and the service pins is compared against the
11
+ * declared version: R6's rule is that a service pins exactly what the SSOT
12
+ * declares (`.claude/rules/architecture-principles.md` § Version pinning), and
13
+ * that sentence carries no infra clause. What the `infraConsumed` list decides
14
+ * is which SENTENCE a stale pin gets, because the two say different things:
16
15
  *
17
- * That narrowing covers a package the SSOT DECLARES and infra does not install.
18
- * A package the SSOT declares in NEITHER list is a different thing: not
19
- * biz-only, but unknown to the platform. R6's rule is that a service pins
20
- * exactly what the SSOT declares (`.claude/rules/architecture-principles.md`
21
- * § Version pinning), and for an undeclared package there is no such version at
22
- * all — so it is refused, never narrowed away. Passing it would be a fallback
23
- * to "not gated" (principle 3), and it would let a typo or a retired package
24
- * name travel into an image with the gate reporting OK (measured 2026-09-14).
16
+ * - infra installs the package → biz and infra run different code against
17
+ * one contract, and the fix may be to deploy the matching infra release;
18
+ * - no infra service installs it → there is no infra version to diverge
19
+ * from, so the only statement left is the SSOT one: this pin is not what
20
+ * the platform declares, and the fix is to re-pin.
25
21
  *
26
- * The narrowing decides one thing only: which packages are COMPARED against an
27
- * infra version. It never relaxes the exact-pin rule, which `^`, `~` and
28
- * `latest` break in EVERY `@onlineapps/*` dependency, gated or not
29
- * (`.claude/rules/architecture-principles.md` § Version pinning) — a floating
22
+ * Until 2026-09-18 (W651) `infraConsumed` decided more than that: a package no
23
+ * infra service installs skipped the version comparison altogether. Measured
24
+ * then: 20 of the 28 SSOT packages are biz-only, so for most of a service's
25
+ * pins a biz pipeline compared nothing at all, and a service could ship months
26
+ * behind the platform with a green gate. A bare version map has no such list —
27
+ * a platform-release entry records what was deployed, so everything in it is by
28
+ * definition shipped, and every mismatch is a compatibility one.
29
+ *
30
+ * A package the SSOT declares in NEITHER list is a different thing again: not
31
+ * biz-only, but unknown to the platform — there is no version to pin against at
32
+ * all, so it is refused, never narrowed away. Passing it would be a fallback to
33
+ * "not gated" (principle 3), and it would let a typo or a retired package name
34
+ * travel into an image with the gate reporting OK (measured 2026-09-14).
35
+ *
36
+ * The narrowing never relaxes the exact-pin rule either, which `^`, `~` and
37
+ * `latest` break in EVERY `@onlineapps/*` dependency, gated or not — a floating
30
38
  * range makes the same commit install different code on different days whether
31
39
  * or not infra happens to ship that package too. Until 2026-09-15 (W413) the
32
40
  * exactness test sat inside the gated branch, so a biz-only pin could float
@@ -76,12 +84,39 @@ function normalizeLibrarySet(raw) {
76
84
  return { versions: raw, infraConsumed: null };
77
85
  }
78
86
 
87
+ /**
88
+ * The service a finding belongs to, taken from its package.json.
89
+ *
90
+ * The SSOT-pin finding names the service, because the cross-repository audit
91
+ * path (`api/scripts/ci/verify-lib-compat.mjs`) reports several repositories in
92
+ * one run. A package.json without a name is invalid input — npm requires the
93
+ * field — so it is refused with an actionable message rather than rendered as
94
+ * "service undefined".
95
+ */
96
+ function serviceNameOf(pkg) {
97
+ const name = pkg?.name;
98
+ if (typeof name !== 'string' || name.length === 0) {
99
+ throw new Error('[LibCompat] Service package.json declares no "name" - a pin finding must say which '
100
+ + 'service carries the pin. Fix: add a "name" field to the service package.json.');
101
+ }
102
+ return name;
103
+ }
104
+
79
105
  /**
80
106
  * Compare a package.json against the platform library set.
81
107
  *
108
+ * `compared` is what the gate may claim as coverage: the packages whose
109
+ * version was actually held against the declared one. It is appended by the
110
+ * comparing loop itself, never recomputed from the other two lists by a
111
+ * renderer — a second derivation of one fact is the drift
112
+ * `.claude/rules/change-discipline.md` § One rail per concern forbids, and
113
+ * `gated ∪ notGated` is NOT the same set: a caret pin and a package the SSOT
114
+ * declares nowhere are classified and then refused before any comparison
115
+ * happens.
116
+ *
82
117
  * @param {object} pkg parsed package.json of the biz service
83
118
  * @param {object} librarySet committed SSOT shape or bare version map
84
- * @returns {{ok: boolean, gated: string[], notGated: string[], violations: Array<{package: string, message: string}>}}
119
+ * @returns {{ok: boolean, gated: string[], notGated: string[], compared: string[], violations: Array<{package: string, message: string}>}}
85
120
  */
86
121
  function checkLibCompat(pkg, librarySet) {
87
122
  const { versions, infraConsumed } = normalizeLibrarySet(librarySet);
@@ -91,6 +126,7 @@ function checkLibCompat(pkg, librarySet) {
91
126
 
92
127
  const gated = [];
93
128
  const notGated = [];
129
+ const compared = [];
94
130
  const violations = [];
95
131
 
96
132
  for (const [name, version] of ours) {
@@ -133,13 +169,23 @@ function checkLibCompat(pkg, librarySet) {
133
169
  continue;
134
170
  }
135
171
 
136
- if (bizOnly) continue;
172
+ // The equality comparison is unconditional, for the same reason the
173
+ // exactness test above is: R6 proves a service pins exactly what the SSOT
174
+ // declares, and the SSOT declares a version for the biz-only packages too.
175
+ // Only the sentence differs — there is no infra release to deploy for a
176
+ // package no infra service installs, so naming one would send the reader
177
+ // after a fix that does not exist.
178
+ compared.push(name);
137
179
 
138
180
  if (declared !== version) {
139
181
  violations.push({
140
182
  package: name,
141
- message: `${name}: biz pins ${version}, infra ships ${declared} — `
142
- + 'rebuild the service against the current platform, or deploy the matching infra release first.'
183
+ message: bizOnly
184
+ ? `${name}: service ${serviceNameOf(pkg)} pins ${version}, the platform library SSOT declares `
185
+ + `${declared} — a service pins exactly what the SSOT declares, whether or not an infra service `
186
+ + `installs the package. Fix: npm install ${name}@${declared} --save-exact.`
187
+ : `${name}: biz pins ${version}, infra ships ${declared} — `
188
+ + 'rebuild the service against the current platform, or deploy the matching infra release first.'
143
189
  });
144
190
  }
145
191
  }
@@ -148,6 +194,7 @@ function checkLibCompat(pkg, librarySet) {
148
194
  ok: violations.length === 0,
149
195
  gated,
150
196
  notGated,
197
+ compared,
151
198
  violations
152
199
  };
153
200
  }
@@ -79,7 +79,7 @@ WORKSPACE_ROOT=$(dirname "$CONTAINER_DIR")
79
79
  API_CHECKOUT="$WORKSPACE_ROOT/api"
80
80
 
81
81
  if [ "$(basename "$CONTAINER_DIR")" != "api_biz" ]; then
82
- die "The checkout is not in the layout the uniform needs - $SERVICE_ROOT lies in '$(basename "$CONTAINER_DIR")', and the engine answers the rows that read the platform SSOT only for a service under <root>/api_biz/ beside <root>/api. Expected: the job checks this project out there. Fix: in .gitlab-ci.yml set the job variable GIT_CLONE_PATH: \$CI_BUILDS_DIR/oa-uniform/api_biz/<service>, and confirm the runner allows a custom build directory ([runners.custom_build_dir] enabled)." 2
82
+ die "The checkout is not in the layout the uniform needs - $SERVICE_ROOT lies in '$(basename "$CONTAINER_DIR")', and the engine answers the rows that read the platform SSOT only for a service under <root>/api_biz/ beside <root>/api. Expected: the job checks this project out there. Fix: make this job extend .oa-uniform in .gitlab-ci.yml (it sets GIT_CLONE_PATH: \$CI_BUILDS_DIR/oa-uniform/\$CI_CONCURRENT_ID/api_biz/<service>), and confirm the runner allows a custom build directory ([runners.custom_build_dir] enabled)." 2
83
83
  fi
84
84
 
85
85
  command -v git >/dev/null 2>&1 || die "Missing tool - 'git' does not run, and the api checkout this run reads cannot be fetched without it. Fix: install git in the job image (apk add --no-cache git)." 2
@@ -90,12 +90,24 @@ command -v node >/dev/null 2>&1 || die "Missing tool - 'node' does not run, and
90
90
  # Read-only and shallow: the uniform reads files, never history. A runner reuses
91
91
  # its builds directory between pipelines, so a checkout left by an earlier run
92
92
  # would silently measure this deploy against a stale SSOT — it is removed and
93
- # fetched again, and only when it is recognisably this script's own artefact.
93
+ # fetched again, and ONLY when this script can prove it made it.
94
+ #
95
+ # The proof is the marker the clone step below writes. `.git` plus
96
+ # `config/services.json` is no proof at all: every checkout of the api repository
97
+ # carries both. On 2026-09-24 the script was run from api_biz/emailer on a
98
+ # workstation, <workspace>/api was the developer's real working tree, it passed
99
+ # that test and was deleted (639 tracked files; d.885). A directory without the
100
+ # marker is somebody's work, so the script refuses and touches nothing
101
+ # (`automation-gates.md` §1 requirement 3, Safe). There is no flag that skips
102
+ # the check: the one systemic path is a free place or a clone this script made.
103
+ CLONE_MARKER=".verify-deploy-uniform.clone"
94
104
  if [ -e "$API_CHECKOUT" ]; then
95
- if [ -d "$API_CHECKOUT/.git" ] && [ -f "$API_CHECKOUT/config/services.json" ]; then
105
+ if [ -d "$API_CHECKOUT/.git" ] && [ -f "$API_CHECKOUT/config/services.json" ] \
106
+ && [ -f "$API_CHECKOUT/$CLONE_MARKER" ] \
107
+ && head -n 1 "$API_CHECKOUT/$CLONE_MARKER" | grep -q '^created-by verify-deploy-uniform.sh '; then
96
108
  rm -rf "$API_CHECKOUT"
97
109
  else
98
- die "Cannot place the api checkout - $API_CHECKOUT already exists and is not an api clone this script made (no .git and config/services.json). Expected: that path free, or an earlier clone of the api repository. Fix: remove it, or point the job's GIT_CLONE_PATH at a build directory this pipeline owns." 2
110
+ die "Cannot place the api checkout - $API_CHECKOUT exists and carries no marker $CLONE_MARKER, so it is not a clone this script made, and deleting it could destroy somebody's work. Expected: a clone this script made. Fix: remove it yourself, or run this script only from a CI checkout where <workspace>/api is free (the job's GIT_CLONE_PATH points at a build directory this pipeline owns)." 2
99
111
  fi
100
112
  fi
101
113
 
@@ -106,6 +118,11 @@ if ! git clone --depth 1 --single-branch --branch "$API_REF" "$API_URL" "$API_CH
106
118
  die "The api checkout could not be cloned - git clone --branch $API_REF $SAFE_URL failed, so api/config/services.json cannot be read and the uniform would answer a fraction of its rows. A partial answer is not a deploy permit, so this refuses rather than continues. Expected: read access for this pipeline's job token. Fix: in GitLab open the api project, Settings > CI/CD > Job token permissions, and add this project to the allowlist (owner's step, confirmation biz-service-manifest 008 point 2); check also that the ref '$API_REF' exists there." 2
107
119
  fi
108
120
 
121
+ # The marker is what lets the NEXT run delete this clone, so it is written the
122
+ # moment the clone exists and before anything reads it.
123
+ printf 'created-by verify-deploy-uniform.sh %s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" > "$API_CHECKOUT/$CLONE_MARKER" \
124
+ || die "Cannot mark the api checkout - writing $API_CHECKOUT/$CLONE_MARKER failed, and without it the next run would refuse to replace this clone. Expected: a writable build directory. Fix: check the permissions of $WORKSPACE_ROOT on the runner." 2
125
+
109
126
  # --- the engine -------------------------------------------------------------
110
127
  #
111
128
  # The pin decides which uniform this service is measured against, so the engine