@onlineapps/conn-orch-validator 6.0.1 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
7
7
  > ⚠ Neither this entry nor the connector-step work of `dcddb862` is published.
8
8
  > The last version on NPM is 3.3.2, so **no biz CI run sees any of it yet**.
9
9
 
10
+ ### BREAKING — standard level v1.1 "Multitenancy Standard" is removed (DÁVKA 88j)
11
+
12
+ **Major.** `getStandardLevels()` now returns three levels, `v1.0, v1.2, v1.3`, and
13
+ `determineStandardLevel().details` has one entry fewer. Anything indexing that
14
+ array by position, or expecting a `tenant_context` check, reads something else now.
15
+
16
+ Owner decision: `api/docs/governance/confirmations/service-shape-v11-retirement.md`.
17
+
18
+ v1.1 held a single check, `tenant_context`, and it asserted nothing but the
19
+ EXISTENCE of `wrapper.tenantContext` in `config/service/config.json`. Nothing
20
+ reads that key: the wrapper runtime reads `cache`, `infrastructureGate`,
21
+ `health`, `validation`, `mq`, `registry`, `monitoring`, `state`, `secrets` and
22
+ `heartbeat`; `createTenantContextMiddleware` had had no caller since ADR 0005
23
+ removed the HTTP surface (and was itself deleted on 2026-09-05,
24
+ `docs/governance/confirmations/wrapper-tenant-middleware.md`); at the time,
25
+ `service-wrapper/config/runtime-defaults.json` still supplied the key regardless
26
+ of what a service declared. The gate was enforcing a
27
+ dead declaration — `change-discipline.md` § *Removing something removes its
28
+ declaration*.
29
+
30
+ - **Removed** the level, not its checks. An empty level with `checks: () => []`
31
+ would pass for every service while asserting nothing, which is the false
32
+ guarantee `automation-gates.md` §5 names.
33
+ - **The number is not reused and the gap stays.** v1.2 and v1.3 keep their names,
34
+ so a level recorded in an earlier proof still means what it meant. Cumulative
35
+ now reads *"v1.2 requires v1.0"*.
36
+ - **A service is free to drop `wrapper.tenantContext`** and reach the same level
37
+ it reaches with it. Measured over `api_biz/*` (8 services, all of which still
38
+ declare the key): every verdict is `v1.3` before the change and `v1.3` after —
39
+ the removal takes a requirement away without moving a single live verdict.
40
+ - The package's own conformant fixture (`tests/fixtures/v3-cookbook-service`)
41
+ drops the key in the same change; it carried it only to satisfy v1.1.
42
+
10
43
  ### BREAKING — a cookbook step spelling its identifier `id` is refused (DÁVKA 77 A3)
11
44
 
12
45
  `format.md` § Steps has banned `id` since v2.1; the code accepted it anyway, which
package/README.md CHANGED
@@ -134,7 +134,14 @@ services/my-service/
134
134
  - **Fingerprint:** SHA256 over (service version + operations + `@onlineapps/*`
135
135
  dependencies + config + infra files + cookbooks + seeds), stored in the proof
136
136
  as `contractFingerprint`. Nothing reads it back to skip work; it records what
137
- the run covered.
137
+ the run covered. The **service version comes from the service `package.json`,
138
+ and from nowhere else** — the same single source of truth the readiness
139
+ section below describes. It used to be `config.service?.version ||
140
+ pkg.version`, which for a service carrying `"${npm_package_version}"` in
141
+ `config/service/config.json` hashed that literal and never consulted
142
+ package.json at all, so a version bump moved no bit of the fingerprint. A
143
+ missing, unparseable or version-less package.json now fails the run instead of
144
+ falling back.
138
145
  - **Registry:** the proof travels in the registration payload
139
146
  (`ServiceWrapper.js`, `validationProof` field) — a service that fails
140
147
  validation never reaches registration, because phase 0.2 throws.
@@ -159,6 +166,22 @@ a result it never independently reached. What consumes it today is
159
166
  The retired `health` check's 20 points folded into `operations` (ADR 0005), so
160
167
  the maximum is still 100.
161
168
 
169
+ **"Can register" is not truthiness.** `canRegister` requires a `name`, an
170
+ `operations` object, and a `version` matching the semver 2.0.0 grammar — the
171
+ last one because the line below it compares that value against what the registry
172
+ already holds, and comparing two strings that are not versions decides nothing.
173
+ The string that made this measurable is `"${npm_package_version}"`: every biz
174
+ `config/service/config.json` declares it under `service.version`, it is truthy,
175
+ and the gate used to accept it (measured by BIZ-ingest). Each failing part
176
+ contributes one `[Context] Problem - Expected/Fix` line to `checks.registry.errors`.
177
+
178
+ The version a caller passes comes from the service **`package.json`**, never from
179
+ `config.json`. That is the single source of truth the runtime uses:
180
+ `ConfigLoader.loadServiceConfig()` overwrites `config.service.version` from
181
+ package.json before `${VAR}` placeholders are resolved, so the config.json key
182
+ never reaches a running service. `helpers/createServiceReadinessTests` reads
183
+ package.json for exactly that reason.
184
+
162
185
  **Every evaluation writes exactly one line** through the injected logger:
163
186
  `[ServiceReadinessValidator] Readiness evaluated` with a structured object
164
187
  (`serviceName`, `score`, `maxScore`, `ready`, `checks` as `{name: {passed,
@@ -177,12 +200,23 @@ The validator evaluates each service against cumulative implementation standards
177
200
  | Level | Name | Checks | Since |
178
201
  |-------|------|--------|-------|
179
202
  | **v1.0** | Base Service Standard | `config/service/`, `src/`, `tests/`, `src/handlers/` (v3 handler modules — ADR 0005 replaced `src/app.js`), `index.js`, `config.json` with `service.name`, `operations.json` with an `operations` object, `@onlineapps/service-wrapper` dep | 2025-06 |
180
- | **v1.1** | Multitenancy Standard | `wrapper.tenantContext` configured in `config.json` | 2026-03 |
181
203
  | **v1.2** | Business Error Handling Standard | `business_error_contract` (some file under `src/**` imports a `BusinessError` family from `@onlineapps/service-wrapper` or declares the brand `onlineapps.businessError`) + `no_retired_error_hierarchy` (no error class imported from `@onlineapps/service-common` anywhere under `src/`) | 2026-03 |
182
204
  | **v1.3** | Zero-HTTP Shape (ADR 0005) | `src/app.js` absent, `src/routes/` absent, `src/middlewares/` absent, `express` not in dependencies | 2026-08 |
183
205
 
206
+ **v1.1 "Multitenancy Standard" was retired on 2026-09-04** (owner decision,
207
+ `/docs/governance/confirmations/service-shape-v11-retirement.md`). Its only check
208
+ asserted that `wrapper.tenantContext` was present in `config.json`, and nothing
209
+ reads that key — the wrapper runtime never looks it up,
210
+ `createTenantContextMiddleware` was deleted outright on 2026-09-05
211
+ (`/docs/governance/confirmations/wrapper-tenant-middleware.md`), and
212
+ `runtime-defaults.json` no longer carries the key either. The level is removed,
213
+ not emptied: a level with no checks passes for everyone while asserting nothing
214
+ (`automation-gates.md` §5). The number is not reused and the gap in the numbering
215
+ stays, so a level recorded earlier still means what it meant.
216
+
184
217
  **Key properties:**
185
- - **Cumulative** — v1.2 requires v1.0 + v1.1 to also pass
218
+ - **Cumulative** — each level requires every level listed above it (so v1.2
219
+ requires v1.0); the table is the authority, not the version number
186
220
  - **Baked into validator** — older validator versions naturally know fewer levels (backward compatible)
187
221
  - **Warnings** — next unsatisfied level generates `STANDARD_LEVEL_GAP` warnings with specific missing checks
188
222
  - **In validation results** — `ServiceStructureValidator.validate()` returns
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/conn-orch-validator",
3
- "version": "6.0.1",
3
+ "version": "7.0.0",
4
4
  "description": "Validation orchestrator for OA Drive microservices - coordinates validation across all layers (base, infra, orch, business)",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -441,11 +441,22 @@ class CookbookTestRunner {
441
441
  // Synthesize an HTTP-like response so existing expect validators
442
442
  // continue to work (expect.status: 'error', expect.error.code, ...).
443
443
  const errorCode = handlerError.code || handlerError.name || 'HANDLER_ERROR';
444
- const statusCode = typeof handlerError.statusCode === 'number'
445
- ? handlerError.statusCode
444
+ // `status` is the platform's ONE name for the number a thrown error
445
+ // carries: `BusinessError` sets it, `ErrorMapper` reads it
446
+ // (@onlineapps/service-wrapper, src/ErrorMapper.js), and a service that
447
+ // throws its own class declares the same shape.
448
+ // @see api/docs/biz/70-contracts/error-handling.md §1
449
+ //
450
+ // No `statusCode || status` chain. The retired second name was read here
451
+ // and nowhere else, which is why three biz services mirrored the value
452
+ // under both names to satisfy this line; a fallback would keep both alive
453
+ // and let them drift. An error carrying no usable status is a 500 — the
454
+ // handler declared no outcome, so it gets the server fault it earned.
455
+ const status = typeof handlerError.status === 'number'
456
+ ? handlerError.status
446
457
  : 500;
447
458
  result.response = {
448
- status: statusCode,
459
+ status,
449
460
  statusText: errorCode,
450
461
  data: { code: errorCode, message: handlerError.message }
451
462
  };
@@ -6,6 +6,19 @@ const CookbookTestUtils = require('./CookbookTestUtils');
6
6
  // error message lists them in.
7
7
  const LOGGER_METHODS = ['info', 'warn', 'error', 'debug'];
8
8
 
9
+ /**
10
+ * The semver 2.0.0 grammar, verbatim from semver.org's own published regular
11
+ * expression (anchored, no leading `v`, no surrounding whitespace, no leading
12
+ * zeroes in a numeric identifier).
13
+ *
14
+ * Written out rather than pulled from the `semver` package: this is the only
15
+ * version rule in the package, one anchored pattern expresses it completely,
16
+ * and a dependency added for a single `test()` call is a supply chain the gate
17
+ * does not need. If a second version rule ever appears here, that trade changes
18
+ * — and the change is then visible, because it is this comment that has to go.
19
+ */
20
+ const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
21
+
9
22
  /**
10
23
  * ServiceReadinessValidator - Orchestrates complete service validation.
11
24
  *
@@ -311,16 +324,63 @@ class ServiceReadinessValidator {
311
324
  }
312
325
 
313
326
  /**
314
- * Check registry compatibility
327
+ * Check registry compatibility.
328
+ *
329
+ * `canRegister` used to be `!!(name && version && operations)`. Truthiness is
330
+ * the wrong question about a version: the line below it compares this value
331
+ * against what the registry already holds, and a comparison between two
332
+ * strings that are not versions decides nothing. Measured (BIZ-ingest): every
333
+ * biz `config/service/config.json` declares
334
+ * `"service": { "version": "${npm_package_version}" }`, the placeholder was
335
+ * handed here unexpanded, and — being a non-empty string — it passed. The
336
+ * gate reported `canRegister: true` about a value that is not a version.
337
+ *
338
+ * The grammar is semver 2.0.0, checked here and nowhere else: this is the one
339
+ * place that decides `canRegister`. Callers supply the value (the `version`
340
+ * field of the service package.json, which is what ConfigLoader reads at
341
+ * runtime); they do not re-check it.
315
342
  */
316
343
  async checkRegistryCompatibility(service, registry) {
317
344
  try {
318
- // Check if service can be registered
319
- const canRegister = !!(
320
- service.name &&
321
- service.version &&
322
- service.operations
323
- );
345
+ const errors = [];
346
+
347
+ if (!service.name) {
348
+ errors.push(
349
+ '[ServiceReadinessValidator] Service name is missing - '
350
+ + 'validateReadiness() received no `name`. '
351
+ + 'Expected: the service name, e.g. biz-ingest. '
352
+ + 'Fix: pass it in the object handed to validateReadiness().'
353
+ );
354
+ }
355
+
356
+ if (service.version === undefined || service.version === null) {
357
+ errors.push(
358
+ '[ServiceReadinessValidator] Service version is missing - '
359
+ + 'validateReadiness() received no `version`. '
360
+ + 'Expected: the `version` field of the service package.json, e.g. 1.4.2. '
361
+ + 'Fix: pass it in the object handed to validateReadiness().'
362
+ );
363
+ } else if (!SEMVER_PATTERN.test(service.version)) {
364
+ errors.push(
365
+ '[ServiceReadinessValidator] Service version is not a semantic version - '
366
+ + `received "${service.version}". `
367
+ + 'Expected: MAJOR.MINOR.PATCH per semver 2.0.0, e.g. 1.4.2 or 1.4.2-rc.1. '
368
+ + 'Fix: pass the `version` field of the service package.json — the single source of '
369
+ + 'truth ConfigLoader reads at runtime. An unresolved "${...}" means the value came '
370
+ + 'from config.json instead, where the key is never expanded.'
371
+ );
372
+ }
373
+
374
+ if (!service.operations) {
375
+ errors.push(
376
+ '[ServiceReadinessValidator] Operations are missing - '
377
+ + 'validateReadiness() received no `operations`. '
378
+ + 'Expected: the flat operations object from config/service/operations.json. '
379
+ + 'Fix: pass it in the object handed to validateReadiness().'
380
+ );
381
+ }
382
+
383
+ const canRegister = errors.length === 0;
324
384
 
325
385
  // Check if service operations match registry expectations
326
386
  const registeredService = registry.getService(service.name);
@@ -331,7 +391,8 @@ class ServiceReadinessValidator {
331
391
  passed: canRegister && compatible,
332
392
  canRegister,
333
393
  compatible,
334
- existingVersion: registeredService?.version
394
+ existingVersion: registeredService?.version,
395
+ errors
335
396
  };
336
397
  } catch (error) {
337
398
  return {
@@ -4,7 +4,13 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { verifyConnectorContract } = require('./utils/connectorContract');
6
6
  const { normalizeEnvDeclaration, collectEnvCoverage, verifyEnvPresence } = require('./utils/envContract');
7
- const { ValidationProofCodec, ValidationProofVerifier } = require('@onlineapps/service-validator-core');
7
+ // `ValidationProofVerifier` was destructured here alongside the codec and never
8
+ // called. It verified a cached proof before booting; the proof cache was
9
+ // removed (see validate()) and the verifier's last call site went with it,
10
+ // leaving a binding whose only remaining effect was to tell a reader that this
11
+ // class still checks proofs. `ValidationProofCodec` stays — finalizeResults()
12
+ // encodes the proof it writes.
13
+ const { ValidationProofCodec } = require('@onlineapps/service-validator-core');
8
14
  const FingerprintUtils = require('@onlineapps/service-validator-core').FingerprintUtils;
9
15
  const { ServiceStructureValidator } = require('./validators/ServiceStructureValidator');
10
16
  const { describeStepFailureWithContext } = require('./utils/stepFailure');
@@ -115,15 +121,72 @@ class ValidationOrchestrator {
115
121
 
116
122
 
117
123
 
124
+ /**
125
+ * Read and parse the service's package.json.
126
+ *
127
+ * It is the single source of truth for the service version — the same one
128
+ * validateConfig() names below, and the same one the biz pre-validation entry
129
+ * point (`src/utils/preValidation.js`) already reads. `config/service/config.json`
130
+ * cannot supply it: the wrapper's ConfigLoader
131
+ * (`api/shared/connector/service-wrapper/src/ConfigLoader.js`) assigns
132
+ * `config.service.version` from package.json unconditionally on every load, so
133
+ * the key in the file is dead — and where it is declared at all, biz services
134
+ * declare it as the unresolved literal `${npm_package_version}`.
135
+ *
136
+ * A missing, unreadable, unparseable or version-less package.json is a contract
137
+ * violation, not a computation hiccup, so it is raised here in full §5 shape
138
+ * rather than through calculateFingerprint()'s generic wrapper.
139
+ *
140
+ * @param {string} packageFile absolute path to the service's package.json
141
+ * @returns {object} the parsed package.json, `version` guaranteed non-empty
142
+ */
143
+ readServicePackageJson(packageFile) {
144
+ let raw;
145
+ try {
146
+ raw = fs.readFileSync(packageFile, 'utf8');
147
+ } catch (error) {
148
+ throw new Error(
149
+ `[ValidationOrchestrator] Service package.json is unreadable - Expected: a readable ${packageFile}, `
150
+ + 'the single source of truth for the service version. '
151
+ + `Read failed with: ${error.code || error.message}. `
152
+ + 'Fix: point options.serviceRoot at the service directory that contains package.json.'
153
+ );
154
+ }
155
+
156
+ let pkg;
157
+ try {
158
+ pkg = JSON.parse(raw);
159
+ } catch (error) {
160
+ throw new Error(
161
+ `[ValidationOrchestrator] Service package.json is not valid JSON - Expected: parseable JSON at ${packageFile}. `
162
+ + `Parse failed with: ${error.message}. `
163
+ + 'Fix: repair the file.'
164
+ );
165
+ }
166
+
167
+ if (typeof pkg.version !== 'string' || pkg.version.length === 0) {
168
+ throw new Error(
169
+ `[ValidationOrchestrator] Service package.json has no version - Expected: a non-empty "version" string in ${packageFile}, `
170
+ + 'the single source of truth for the service version. '
171
+ + `Got: ${JSON.stringify(pkg.version)}. `
172
+ + 'Fix: set "version" in the service package.json.'
173
+ );
174
+ }
175
+
176
+ return pkg;
177
+ }
178
+
118
179
  /**
119
180
  * Calculate service fingerprint
120
181
  * Based on: version + operations.json + dependencies + config
121
182
  */
122
183
  async calculateFingerprint() {
184
+ const packageFile = path.join(this.serviceRoot, 'package.json');
185
+ const pkg = this.readServicePackageJson(packageFile);
186
+
123
187
  try {
124
188
  const configFile = path.join(this.configPath, 'config.json');
125
189
  const operationsFile = path.join(this.configPath, 'operations.json');
126
- const packageFile = path.join(this.serviceRoot, 'package.json');
127
190
  const dockerFile = path.join(this.serviceRoot, 'Dockerfile');
128
191
  const dockerComposeFile = path.join(this.serviceRoot, 'docker-compose.yml');
129
192
  const envTemplateFile = path.join(this.serviceRoot, '..', '..', 'config', 'env-templates', `${path.basename(this.serviceRoot)}.env`);
@@ -134,7 +197,6 @@ class ValidationOrchestrator {
134
197
 
135
198
  const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
136
199
  const operations = JSON.parse(fs.readFileSync(operationsFile, 'utf8'));
137
- const pkg = JSON.parse(fs.readFileSync(packageFile, 'utf8'));
138
200
 
139
201
  // Include infra file contents in fingerprint to detect changes in environment/setup
140
202
  const infra = {};
@@ -187,8 +249,21 @@ class ValidationOrchestrator {
187
249
  }
188
250
 
189
251
  // Generate fingerprint using FingerprintUtils (handles deep sorting)
252
+ //
253
+ // The version comes from package.json alone. It used to read
254
+ // `config.service?.version || pkg.version`, which hashed a value that was
255
+ // not a version at all: `config.service.version` is overwritten by the
256
+ // wrapper's ConfigLoader on every load (see readServicePackageJson above),
257
+ // and the services that declare it leave the unresolved literal
258
+ // `${npm_package_version}` in that key. The `||` then hid it — for those
259
+ // services the package.json branch was never reached, so publishing a new
260
+ // version of one did not move its fingerprint by one bit
261
+ // (package.json is otherwise represented here only by its @onlineapps
262
+ // dependency pins). config.json's own content is still hashed below, so
263
+ // editing that key is still detected — as a config change, which is what
264
+ // it is.
190
265
  const fingerprintData = {
191
- serviceVersion: config.service?.version || pkg.version,
266
+ serviceVersion: pkg.version,
192
267
  operations: operations,
193
268
  dependencies: deps,
194
269
  config: config,
@@ -126,7 +126,16 @@ const operations = JSON.parse(fs.readFileSync(path.join(serviceRoot, 'config/ser
126
126
 
127
127
  // Extract metadata
128
128
  const serviceName = config.service.name;
129
- const serviceVersion = config.service.version;
129
+
130
+ // The version comes from package.json, NEVER from config.json. ConfigLoader
131
+ // (shared/connector/service-wrapper/src/ConfigLoader.js) assigns
132
+ // `config.service.version` from package.json unconditionally on every load, and
133
+ // does it BEFORE `${VAR}` placeholders are resolved — so `service.version` in
134
+ // config.json never reaches a running service under any value, and reading it
135
+ // here yielded the literal string `${npm_package_version}`.
136
+ const serviceVersion = JSON.parse(
137
+ fs.readFileSync(path.join(serviceRoot, 'package.json'), 'utf-8')
138
+ ).version;
130
139
  ```
131
140
 
132
141
  Nothing loads `src/app.js` and nothing reads a health endpoint: both belonged to
@@ -84,9 +84,22 @@ function createServiceReadinessTests(testsDir, options = {}) {
84
84
  const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
85
85
  const operations = JSON.parse(fs.readFileSync(operationsPath, 'utf-8'));
86
86
 
87
- // Extract metadata from config
88
87
  const serviceName = config.service.name;
89
- const serviceVersion = config.service.version;
88
+
89
+ // The version comes from package.json, never from config.json — the same
90
+ // single source of truth the runtime uses:
91
+ //
92
+ // shared/connector/service-wrapper/src/ConfigLoader.js:239
93
+ // config.service.version = this.loadPackageVersion(basePath);
94
+ //
95
+ // That assignment is unconditional and happens BEFORE `${VAR}` placeholders
96
+ // are resolved, so `service.version` in config.json never reaches a running
97
+ // service under any value. Reading it here was reading a dead key, and the
98
+ // value it yielded was the literal string `${npm_package_version}`: the
99
+ // placeholder that no expansion pass ever touches, handed to
100
+ // ServiceReadinessValidator, which used to accept it for being truthy.
101
+ const packagePath = path.join(serviceRoot, 'package.json');
102
+ const serviceVersion = JSON.parse(fs.readFileSync(packagePath, 'utf-8')).version;
90
103
 
91
104
  describe(`${serviceName} Service Readiness @integration`, () => {
92
105
  test('service passes readiness validation', async () => {
@@ -331,6 +331,77 @@ function isCommentLine(line) {
331
331
  return trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*');
332
332
  }
333
333
 
334
+ /**
335
+ * The other half of the same distinction. A comment is prose because of WHERE
336
+ * it sits; an error message is prose because of WHAT IT SAYS.
337
+ *
338
+ * "Inside quotes" alone is NOT the test, and must never become it: a query is
339
+ * a string too, and a query is exactly what reaches a database —
340
+ * `SELECT 1 FROM t WHERE tenant_id = 100` has to keep failing. What separates
341
+ * the two is the verb. Prose makes a CLAIM about the identifier ("workspace_id
342
+ * = 0 IS NOT a value"); code assigns a VALUE to it, and nothing follows the
343
+ * number but syntax.
344
+ *
345
+ * So the permit is granted only when the number is followed, inside the same
346
+ * string literal, by a finite verb from the closed list below. Everything else
347
+ * — end of string, `,`, `)`, ` AND `, ` order by ` — stays reported. The list
348
+ * is deliberately short and enumerated rather than clever: a gate whose verdict
349
+ * cannot be predicted from reading it is not a gate (`automation-gates.md` §1).
350
+ *
351
+ * Both conditions fail CLOSED. A string that spans several lines looks to this
352
+ * line-local scanner like no string at all, so its literal keeps its report.
353
+ */
354
+ const PROSE_PREDICATE =
355
+ /^[\s,;:—-]*\b(is|are|was|were|has|have|had|means|meant|carries|carry|cannot|must|never|does|do|would|will|remains|stays)\b/i;
356
+
357
+ /**
358
+ * The end offset of the CLOSED string literal containing `index`, or -1 when
359
+ * that position is code. Line-local and escape-aware; a quote opened on an
360
+ * earlier line is invisible here, which is the safe direction (no permit).
361
+ */
362
+ function enclosingStringEnd(line, index) {
363
+ let quote = null;
364
+ let start = -1;
365
+ for (let i = 0; i < line.length; i += 1) {
366
+ const ch = line[i];
367
+ if (quote !== null && ch === '\\') { i += 1; continue; }
368
+ if (quote === null) {
369
+ if (ch === "'" || ch === '"' || ch === '`') { quote = ch; start = i; }
370
+ } else if (ch === quote) {
371
+ if (index > start && index < i) return i;
372
+ quote = null;
373
+ start = -1;
374
+ }
375
+ }
376
+ return -1;
377
+ }
378
+
379
+ /**
380
+ * True when the literal at `index` is quoted inside an English sentence that
381
+ * talks ABOUT the identifier, rather than handing it a value.
382
+ */
383
+ function isProseLiteral(line, index, matchText) {
384
+ const end = enclosingStringEnd(line, index);
385
+ if (end === -1) return false;
386
+ return PROSE_PREDICATE.test(line.slice(index + matchText.length, end));
387
+ }
388
+
389
+ /**
390
+ * A tenant / workspace identity frozen into this line of executable code.
391
+ *
392
+ * One rail for both scan sites (src+scripts, and the shared test-namespace
393
+ * module), so the two permits — comment, prose — cannot drift apart.
394
+ */
395
+ function freezesNamespaceIdentity(line) {
396
+ if (isCommentLine(line)) return false;
397
+ for (const pattern of [LITERAL_NAMESPACE, LITERAL_NAMESPACE_CONST]) {
398
+ const match = pattern.exec(line);
399
+ if (!match) continue;
400
+ if (!isProseLiteral(line, match.index, match[0])) return true;
401
+ }
402
+ return false;
403
+ }
404
+
334
405
  function walkFiles(dir, base, out) {
335
406
  let entries;
336
407
  try {
@@ -384,8 +455,7 @@ function checkNamespaceLiterals(serviceRoot, add) {
384
455
  if (text === null) continue;
385
456
  const lines = text.split('\n');
386
457
  for (let i = 0; i < lines.length; i += 1) {
387
- if (isCommentLine(lines[i])) continue;
388
- if (!LITERAL_NAMESPACE.test(lines[i]) && !LITERAL_NAMESPACE_CONST.test(lines[i])) continue;
458
+ if (!freezesNamespaceIdentity(lines[i])) continue;
389
459
  add('R8', `${file.relative}:${i + 1} freezes a tenant / workspace id into code:\n`
390
460
  + ` ${lines[i].trim()}\n`
391
461
  + ' Fix: take it from ctx (handlers), from a CLI argument (scripts),\n'
@@ -409,8 +479,7 @@ function checkIntegrationNamespaceSource(serviceRoot, add) {
409
479
  if (isNamespaceModule(file.relative)) {
410
480
  const lines = text.split('\n');
411
481
  for (let i = 0; i < lines.length; i += 1) {
412
- if (isCommentLine(lines[i])) continue;
413
- if (!LITERAL_NAMESPACE.test(lines[i]) && !LITERAL_NAMESPACE_CONST.test(lines[i])) continue;
482
+ if (!freezesNamespaceIdentity(lines[i])) continue;
414
483
  add('R8', `${file.relative}:${i + 1} freezes the test namespace into a literal:\n`
415
484
  + ` ${lines[i].trim()}\n`
416
485
  + ' This module exists so the namespace has one source; that source must\n'
@@ -4,7 +4,10 @@
4
4
  * Validates that a business service has correct directory structure,
5
5
  * configuration files, and follows OA Drive standards.
6
6
  *
7
- * Each standard level is cumulative — v1.2 requires all checks from v1.0 + v1.1 + v1.2.
7
+ * Each standard level is cumulative — a level requires all checks from every level
8
+ * declared before it in STANDARD_LEVELS, in that order. The list is the authority,
9
+ * not the version number: v1.1 was retired on 2026-09-04 and its number is not
10
+ * reused, so today "v1.2" means v1.0 + v1.2.
8
11
  * Older validator versions naturally support fewer levels (they don't know about newer ones).
9
12
  *
10
13
  * @module validators/ServiceStructureValidator
@@ -268,24 +271,21 @@ const STANDARD_LEVELS = [
268
271
  return results;
269
272
  }
270
273
  },
271
- {
272
- level: 'v1.1',
273
- name: 'Multitenancy Standard',
274
- since: '2026-03-01',
275
- checks: (root) => {
276
- const configRaw = readConfigFile(root, 'config.json');
277
- let hasTenantContext = false;
278
- if (configRaw) {
279
- try {
280
- const cfg = JSON.parse(configRaw);
281
- hasTenantContext = cfg.wrapper?.tenantContext !== undefined;
282
- } catch { /* invalid JSON */ }
283
- }
284
- return [
285
- { passed: hasTenantContext, id: 'tenant_context', message: 'wrapper.tenantContext configured in config.json' }
286
- ];
287
- }
288
- },
274
+ // v1.1 "Multitenancy Standard" was retired on 2026-09-04 (owner decision,
275
+ // api/docs/governance/confirmations/service-shape-v11-retirement.md). Its single
276
+ // check `tenant_context` asserted only that `wrapper.tenantContext` EXISTED in
277
+ // config.json. Nothing reads that key: the wrapper runtime reads cache,
278
+ // infrastructureGate, health, validation, mq, registry, monitoring, state,
279
+ // secrets and heartbeat — not tenantContext; `createTenantContextMiddleware`
280
+ // was deleted on 2026-09-05 together with the runtime-defaults key
281
+ // (docs/governance/confirmations/wrapper-tenant-middleware.md). A
282
+ // level enforcing a dead declaration is the false guarantee automation-gates.md
283
+ // §5 names, so the level is GONE rather than emptied — an empty level would
284
+ // pass for every service while asserting nothing.
285
+ //
286
+ // The number is deliberately not reused and the gap is not closed up: v1.2 and
287
+ // v1.3 keep their names so that a level recorded in an older proof still means
288
+ // what it meant. Cumulative now reads "v1.2 requires v1.0".
289
289
  {
290
290
  level: 'v1.2',
291
291
  name: 'Business Error Handling Standard',