harness-everything 0.36.0 → 0.36.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.
@@ -6,6 +6,24 @@ function isPlainObject(value) {
6
6
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
7
7
  }
8
8
 
9
+ // #335 P3: the candidate inventory provenance check is deliberately stricter than the shared
10
+ // `isPlainObject` helper. Baseline-only evidence requires a real plain object or a
11
+ // null-prototype dictionary; object-like values (Map, Date, RegExp, class instances) must not
12
+ // masquerade as an empty inventory. The shared normalizer and `artifactMap` fallback remain
13
+ // unchanged for non-baseline callers.
14
+ // A Proxy (or host object) whose `getPrototypeOf` trap throws cannot be proven to be a plain
15
+ // object, so it is classified as unknown inventory rather than aborting the whole check.
16
+ function isCandidateInventory(value) {
17
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
18
+ let prototype;
19
+ try {
20
+ prototype = Object.getPrototypeOf(value);
21
+ } catch {
22
+ return false;
23
+ }
24
+ return prototype === Object.prototype || prototype === null;
25
+ }
26
+
9
27
  function isRecord(value) {
10
28
  return isPlainObject(value)
11
29
  && typeof value.id === 'string' && value.id.length > 0
@@ -16,6 +34,22 @@ function nonEmptyString(value) {
16
34
  return typeof value === 'string' && value.trim().length > 0;
17
35
  }
18
36
 
37
+ // #335: per-path baseline digests are SHA256 values. Trust still follows the whole-baseline
38
+ // identity, so an invalid per-path value fails the baseline path closed.
39
+ const SHA256_DIGEST = /^[a-f0-9]{64}$/i;
40
+
41
+ function isSha256(value) {
42
+ return typeof value === 'string' && SHA256_DIGEST.test(value);
43
+ }
44
+
45
+ // #335 P2: per-path baseline and rename evidence must be carried explicitly. Inherited
46
+ // properties (including prototype-injected digests) are not declared evidence, so every
47
+ // trust-boundary map lookup uses own-key membership rather than bracket lookup alone.
48
+ function hasOwnEntry(map, key) {
49
+ return Boolean(map) && typeof map === 'object' && typeof key === 'string'
50
+ && Object.prototype.hasOwnProperty.call(map, key);
51
+ }
52
+
19
53
  function compareText(left, right) {
20
54
  return left < right ? -1 : left > right ? 1 : 0;
21
55
  }
@@ -36,6 +70,12 @@ function artifact(entry) {
36
70
  return entry && typeof entry.digest === 'string' && entry.digest.length > 0 ? entry.digest : '';
37
71
  }
38
72
 
73
+ // #335 P2: a baseline-bound rename destination must declare its own digest. An inherited
74
+ // `digest` (for example Object.create({ digest })) is not explicit candidate evidence.
75
+ function ownArtifactDigest(entry) {
76
+ return isPlainObject(entry) && hasOwnEntry(entry, 'digest') ? artifact(entry) : '';
77
+ }
78
+
39
79
  function provenanceText(record) {
40
80
  return nonEmptyString(record.provenance) ? record.provenance : `record ${record.id}`;
41
81
  }
@@ -86,7 +126,22 @@ function prdSetDigest(artifactMap) {
86
126
  function checkDocObligations({ obligations, artifacts, baseline, executionRecords, reviewRecords } = {}) {
87
127
  const diagnostics = [];
88
128
  const results = [];
129
+ // P3: distinguish a caller-supplied plain-object or null-prototype inventory (including an
130
+ // explicit {}) from an absent, malformed, or object-like value that the pre-existing
131
+ // normalizer would coerce to (or treat as) an empty map. The provenance check is deferred to
132
+ // applicable obligations that actually declare baseline paths, so non-baseline and
133
+ // inapplicable callers never read the candidate inventory's prototype (a throwing Proxy trap
134
+ // must not abort an unrelated obligation). It is resolved at most once so every obligation
135
+ // sees the same classification. `artifactMap` keeps the original `isPlainObject` fallback for
136
+ // non-baseline callers.
89
137
  const artifactMap = isPlainObject(artifacts) ? artifacts : {};
138
+ let candidateInventoryKnown;
139
+ const isCandidateInventoryKnown = () => {
140
+ if (candidateInventoryKnown === undefined) {
141
+ candidateInventoryKnown = isCandidateInventory(artifacts);
142
+ }
143
+ return candidateInventoryKnown;
144
+ };
90
145
  const baselineDigest = isPlainObject(baseline) && nonEmptyString(baseline.digest) ? baseline.digest : '';
91
146
  const executionList = Array.isArray(executionRecords) ? executionRecords.filter(isRecord) : [];
92
147
  const reviewList = Array.isArray(reviewRecords) ? reviewRecords.filter(isRecord) : [];
@@ -105,21 +160,287 @@ function checkDocObligations({ obligations, artifacts, baseline, executionRecord
105
160
 
106
161
  for (const obligation of obligationList) {
107
162
  results.push(evaluateObligation(obligation, {
108
- artifactMap, baselineDigest, executionList, reviewList, diagnostics
163
+ artifactMap, isCandidateInventoryKnown, baseline, baselineDigest, executionList, reviewList, diagnostics
109
164
  }));
110
165
  }
111
166
  return { diagnostics, results };
112
167
  }
113
168
 
114
169
  function evaluateObligation(obligation, context) {
115
- const { artifactMap, baselineDigest, executionList, reviewList, diagnostics } = context;
170
+ const { artifactMap, isCandidateInventoryKnown, baseline, baselineDigest, executionList, reviewList, diagnostics } = context;
116
171
  const capability = typeof obligation.capability === 'string' ? obligation.capability : '';
117
172
  const obligationId = nonEmptyString(obligation.id) ? obligation.id : capability;
118
173
  const scopePaths = scopePathsOf(obligation);
119
174
  const applicability = obligation.applicability;
120
175
 
176
+ // #335: baseline-only paths are declared by the obligation. Every declared entry is validated
177
+ // before any outcome is decided; a malformed or contradictory annotation fails the whole
178
+ // obligation closed instead of being silently ignored.
179
+ const scopeObject = isPlainObject(obligation.scope) ? obligation.scope : null;
180
+ const declaresBaselinePaths = Boolean(scopeObject && Object.prototype.hasOwnProperty.call(scopeObject, 'baselinePaths'));
181
+ const declaredBaselinePaths = declaresBaselinePaths ? scopeObject.baselinePaths : undefined;
182
+ const baselineArtifactsMap = isPlainObject(baseline) && isPlainObject(baseline.artifacts) ? baseline.artifacts : null;
183
+ const baselineEntries = new Map();
184
+ // #340: every accepted entry sharing a rename destination must validate the same own digest,
185
+ // or the shared-destination snapshot is contradictory and the obligation fails closed.
186
+ const validatedDestinationDigestMap = new Map();
187
+ // #340 P2(r4320818749): the validated per-path baseline digest is captured once during
188
+ // metadata validation. The audit mirror, the stale-baseline diagnostics, and baselineBound()
189
+ // reuse that captured value instead of re-reading the caller map, so a stateful own getter
190
+ // cannot pass evidence against a value it started returning only after validation, and a
191
+ // later-throwing read cannot escape the checker.
192
+ const validatedBaselineDigests = new Map();
193
+ let baselineMetadataError = false;
194
+ let candidateInventoryUnknown = false;
195
+ // #335 P3: a candidate inventory can pass the prototype check and still throw from an
196
+ // own-property descriptor trap or a nested digest accessor. Any such failure is treated as
197
+ // unknown inventory rather than escaping the checker; the diagnostic is emitted only for
198
+ // applicable obligations that declare baseline-only paths.
199
+ let candidateInventoryInspectionFailed = false;
200
+
201
+ const baselineMetadataDiagnostic = (code, path, message) => {
202
+ baselineMetadataError = true;
203
+ diagnostics.push({ code, obligationId, ...(path ? { path } : {}), message });
204
+ };
205
+
206
+ if (declaresBaselinePaths) {
207
+ if (!Array.isArray(declaredBaselinePaths)) {
208
+ baselineMetadataDiagnostic('baseline-path-invalid', undefined,
209
+ 'scope.baselinePaths is present but not an array; baseline-bound evidence cannot be evaluated');
210
+ } else if (declaredBaselinePaths.length) {
211
+ if (!nonEmptyString(baselineDigest)) {
212
+ baselineMetadataDiagnostic('baseline-identity-missing', undefined,
213
+ 'baselinePaths are declared but the baseline carries no whole-baseline digest; baseline-bound evidence cannot be trusted');
214
+ }
215
+ if (!baselineArtifactsMap) {
216
+ baselineMetadataDiagnostic('baseline-artifacts-missing', undefined,
217
+ 'baselinePaths are declared but the baseline carries no artifacts map; per-path baseline digests are unavailable');
218
+ }
219
+ const seen = new Set();
220
+ for (const rawEntry of declaredBaselinePaths) {
221
+ const invalid = (path, reason) => baselineMetadataDiagnostic('baseline-path-invalid', path,
222
+ `baselinePaths entry ${path ? JSON.stringify(path) : '(malformed)'} is invalid: ${reason}; the path fails closed`);
223
+ if (!isPlainObject(rawEntry)) {
224
+ invalid(undefined, 'it is not an object with a non-empty path');
225
+ continue;
226
+ }
227
+ // #340: entry fields are snapshotted through one guarded inspection. A Proxy entry
228
+ // whose `path`, `reason`, or `renameTo` getter or descriptor trap throws must not
229
+ // escape the checker; the inspection failure itself fails the entry closed as a
230
+ // metadata error, and each field is carried exactly once so a stateful accessor
231
+ // cannot make validation and capture disagree.
232
+ let snapshot = null;
233
+ try {
234
+ snapshot = {
235
+ path: rawEntry.path,
236
+ reason: rawEntry.reason,
237
+ hasOwnRenameTo: Object.prototype.hasOwnProperty.call(rawEntry, 'renameTo'),
238
+ renameTo: rawEntry.renameTo
239
+ };
240
+ } catch {
241
+ snapshot = null;
242
+ }
243
+ if (!snapshot) {
244
+ invalid(undefined, 'its entry fields could not be inspected safely (a getter or descriptor trap threw); the entry fails closed');
245
+ continue;
246
+ }
247
+ if (!nonEmptyString(snapshot.path)) {
248
+ invalid(undefined, 'it is not an object with a non-empty path');
249
+ continue;
250
+ }
251
+ const entryPath = snapshot.path;
252
+ if (seen.has(entryPath)) {
253
+ invalid(entryPath, 'the same path is declared more than once');
254
+ continue;
255
+ }
256
+ seen.add(entryPath);
257
+ if (!scopePaths.includes(entryPath)) {
258
+ invalid(entryPath, 'it is not a member of scope.paths');
259
+ continue;
260
+ }
261
+ const entryReason = snapshot.reason;
262
+ if (entryReason !== 'deleted' && entryReason !== 'rename-from') {
263
+ invalid(entryPath, `reason ${JSON.stringify(entryReason)} is not deleted or rename-from`);
264
+ continue;
265
+ }
266
+ if (entryReason === 'deleted' && snapshot.hasOwnRenameTo) {
267
+ invalid(entryPath, 'a deleted entry must not carry renameTo');
268
+ continue;
269
+ }
270
+ // #340: the validated rename destination digest is carried on the entry; the
271
+ // destination candidate map is not read again after metadata validation.
272
+ let renameDestinationDigest = '';
273
+ if (entryReason === 'rename-from') {
274
+ // #340: the destination annotation is snapshotted once; a stateful getter cannot
275
+ // make the two validation reads disagree.
276
+ const entryRenameTo = snapshot.renameTo;
277
+ if (!nonEmptyString(entryRenameTo) || entryRenameTo === entryPath) {
278
+ invalid(entryPath, 'a rename-from entry needs a distinct non-empty renameTo');
279
+ continue;
280
+ }
281
+ if (!scopePaths.includes(entryRenameTo)) {
282
+ invalid(entryPath, `its rename destination ${JSON.stringify(entryRenameTo)} is not a member of scope.paths`);
283
+ continue;
284
+ }
285
+ // #340: destination candidate inspection stays applicability-gated. N/A and unknown
286
+ // obligations never bind evidence, so consuming their destination digest getter
287
+ // during metadata validation would shift the legacy audit identity (a stateful
288
+ // getter reports a later value on the second artifact read). Their audit identity
289
+ // keeps the un-gated legacy candidate read; only applicable obligations capture and
290
+ // validate the destination snapshot.
291
+ if (applicability === 'applicable') {
292
+ let destinationDigest = '';
293
+ try {
294
+ if (hasOwnEntry(artifactMap, entryRenameTo)) {
295
+ destinationDigest = ownArtifactDigest(artifactMap[entryRenameTo]);
296
+ }
297
+ } catch {
298
+ candidateInventoryInspectionFailed = true;
299
+ continue;
300
+ }
301
+ // #340: the captured own value must be a usable non-empty string; `artifact()` can read
302
+ // a stateful accessor three times, so a non-string result is not carried or seeded.
303
+ if (!nonEmptyString(destinationDigest)) {
304
+ invalid(entryPath, `its rename destination ${JSON.stringify(entryRenameTo)} has no candidate artifact`);
305
+ continue;
306
+ }
307
+ // #340: entries that share one rename destination must agree on the validated own
308
+ // digest. A conflicting validated value is a contradictory snapshot and fails the
309
+ // whole obligation closed before any binding; equal values are allowed.
310
+ const sharedDestinationDigest = validatedDestinationDigestMap.get(entryRenameTo);
311
+ if (sharedDestinationDigest === undefined) {
312
+ validatedDestinationDigestMap.set(entryRenameTo, destinationDigest);
313
+ } else if (sharedDestinationDigest !== destinationDigest) {
314
+ invalid(entryPath, `its rename destination ${JSON.stringify(entryRenameTo)} was validated to conflicting own digests by another accepted baseline entry; the shared destination snapshot is not consistent`);
315
+ continue;
316
+ }
317
+ renameDestinationDigest = destinationDigest;
318
+ }
319
+ }
320
+ let candidatePathPresent = false;
321
+ try {
322
+ candidatePathPresent = hasOwnEntry(artifactMap, entryPath);
323
+ } catch {
324
+ candidateInventoryInspectionFailed = true;
325
+ continue;
326
+ }
327
+ if (candidatePathPresent) {
328
+ baselineMetadataDiagnostic('baseline-path-candidate-present', entryPath,
329
+ `baseline path ${entryPath} is declared baseline-only but the candidate artifact map has an entry for it (own key, regardless of digest validity); the contradictory path state fails closed`);
330
+ continue;
331
+ }
332
+ if (baselineArtifactsMap) {
333
+ let trusted;
334
+ try {
335
+ trusted = hasOwnEntry(baselineArtifactsMap, entryPath) ? baselineArtifactsMap[entryPath] : undefined;
336
+ } catch {
337
+ // #340 P2: a throwing baseline inspection is a metadata error, never an escape.
338
+ baselineMetadataDiagnostic('baseline-artifact-invalid', entryPath,
339
+ `the baseline digest for ${entryPath} could not be inspected safely (a getter or descriptor trap threw); the path fails closed`);
340
+ continue;
341
+ }
342
+ if (trusted === undefined) {
343
+ baselineMetadataDiagnostic('baseline-artifact-missing', entryPath,
344
+ `the baseline carries no per-path digest for declared baseline path ${entryPath}; the path fails closed`);
345
+ continue;
346
+ }
347
+ if (!isSha256(trusted)) {
348
+ baselineMetadataDiagnostic('baseline-artifact-invalid', entryPath,
349
+ `the baseline digest for ${entryPath} is not a SHA256 value; the path fails closed`);
350
+ continue;
351
+ }
352
+ validatedBaselineDigests.set(entryPath, trusted);
353
+ }
354
+ baselineEntries.set(entryPath, {
355
+ path: entryPath,
356
+ reason: entryReason,
357
+ // #340: the metadata check above confirmed this path has no own-key candidate entry;
358
+ // carry that validated source-absence proof explicitly (never spread from rawEntry) so
359
+ // binding and the audit identity reuse it instead of re-inspecting the candidate map.
360
+ candidatePathPresent: false,
361
+ // #340 P2: carry the exact validated SHA256 so binding never re-reads the
362
+ // caller-owned baseline map.
363
+ trustedBaselineDigest: validatedBaselineDigests.get(entryPath),
364
+ ...(entryReason === 'rename-from' ? { renameTo: snapshot.renameTo, renameDestinationDigest } : {})
365
+ });
366
+ }
367
+ }
368
+ }
369
+
370
+ // #335 P3: a mixed scope combines baseline-only paths with ordinary candidate paths. Capture
371
+ // every ordinary scoped candidate digest before evidence binding, so a throwing accessor in
372
+ // the legacy candidate lane fails the inventory closed instead of aborting the obligation.
373
+ const candidateDigestCache = new Map();
374
+ if (applicability === 'applicable' && Array.isArray(declaredBaselinePaths)
375
+ && declaredBaselinePaths.length > 0) {
376
+ // #340: a rename destination was already validated as an own-key candidate entry carrying
377
+ // its own digest during metadata. Seed the captured snapshot from that validated value and
378
+ // never read the destination through the raw candidate map again, so a destination accessor
379
+ // that throws or changes after validation cannot make capture/binding/audit disagree. The
380
+ // seeded value is the validated own digest, never a caller-asserted rawEntry field.
381
+ for (const entry of baselineEntries.values()) {
382
+ if (entry.reason === 'rename-from' && nonEmptyString(entry.renameDestinationDigest)
383
+ && !candidateDigestCache.has(entry.renameTo)) {
384
+ candidateDigestCache.set(entry.renameTo, entry.renameDestinationDigest);
385
+ }
386
+ }
387
+ for (const path of scopePaths) {
388
+ if (baselineEntries.has(path) || candidateDigestCache.has(path)) continue;
389
+ try {
390
+ candidateDigestCache.set(path, artifact(artifactMap[path]));
391
+ } catch {
392
+ candidateInventoryInspectionFailed = true;
393
+ }
394
+ }
395
+ }
396
+
397
+ // P3: an applicable obligation that actually declares baseline-only paths needs a known
398
+ // candidate inventory. Explicit {} is known and remains the legitimate empty deletion
399
+ // inventory; omitted/undefined/null/array/string/other malformed values are unknown input.
400
+ // The prototype read happens here, not up front, so unrelated obligations never touch it.
401
+ if (applicability === 'applicable' && Array.isArray(declaredBaselinePaths)
402
+ && declaredBaselinePaths.length > 0
403
+ && (candidateInventoryInspectionFailed || !isCandidateInventoryKnown())) {
404
+ candidateInventoryUnknown = true;
405
+ diagnostics.push({
406
+ code: 'candidate-artifacts-invalid',
407
+ obligationId,
408
+ message: 'this obligation declares baseline-only paths, but the top-level artifacts argument is not a plain object or cannot be inspected safely; the candidate inventory is unknown, so baseline-bound evidence fails closed'
409
+ });
410
+ }
411
+
412
+ const baselineAudit = {};
413
+ // #340 P2(r4320941809): the audit mirror is built from the metadata-validated snapshot
414
+ // (`validatedBaselineDigests`, populated during the per-entry guard), never from a re-read
415
+ // of the raw entry path or a fallback inspection of the live baseline map. A stateful
416
+ // `path` accessor therefore cannot make a passing result describe a different baseline
417
+ // identity than the one that validated and bound the evidence, and each declared path
418
+ // contributes its snapshot exactly once in validation order.
419
+ for (const [baselineAuditPath, baselineAuditDigest] of validatedBaselineDigests) {
420
+ baselineAudit[baselineAuditPath] = baselineAuditDigest;
421
+ }
422
+
423
+ // An uninspectable candidate entry must not abort an N/A or unknown obligation while the
424
+ // audit key is built; the baseline-bound lanes fail closed through the inventory diagnostic.
425
+ // When a baseline-annotated obligation captured the ordinary candidate digests before binding
426
+ // (candidateDigestCache), the audit identity and stale diagnostics use that same snapshot, so a
427
+ // nested digest accessor that only later throws or changes can never make the audit key or the
428
+ // stale verdict disagree with the digest that actually bound evidence. Untouched paths and
429
+ // unannotated callers keep the legacy read.
430
+ const candidateDigestOf = (path) => {
431
+ if (candidateDigestCache.has(path)) return candidateDigestCache.get(path);
432
+ // #340: only an applicable baseline-annotated obligation validated the source absence as
433
+ // part of its captured snapshot. `N/A` and `unknown` obligations never bind evidence, so they
434
+ // keep the legacy candidate read for their audit identity and stay `outcome: 'none'`.
435
+ if (applicability === 'applicable' && baselineEntries.has(path)) return '';
436
+ try {
437
+ return artifact(artifactMap[path]);
438
+ } catch {
439
+ return '';
440
+ }
441
+ };
121
442
  const artifactDigestKey = scopePaths.length
122
- ? scopePaths.map(path => `${path}=${artifact(artifactMap[path]) || 'missing'}`).join('|')
443
+ ? scopePaths.map(path => `${path}=${candidateDigestOf(path) || 'missing'}`).join('|')
123
444
  : (baselineDigest || 'no-scope-paths');
124
445
  const baseDigestKey = baselineDigest || 'no-baseline';
125
446
  const result = (observation, outcome, provenance, recordRefs) => ({
@@ -128,6 +449,7 @@ function evaluateObligation(obligation, context) {
128
449
  outcome,
129
450
  artifactDigest: artifactDigestKey,
130
451
  baseDigest: baseDigestKey,
452
+ ...(declaresBaselinePaths ? { baselineArtifacts: { ...baselineAudit } } : {}),
131
453
  provenance,
132
454
  recordRefs: [...recordRefs]
133
455
  });
@@ -141,10 +463,65 @@ function evaluateObligation(obligation, context) {
141
463
  }
142
464
 
143
465
  const matching = [...executionList, ...reviewList].filter(item => item.obligationId === obligationId);
144
- const bound = (item, path) => nonEmptyString(item.baseDigest) && item.baseDigest === baselineDigest
145
- && isPlainObject(item.artifactDigests) && artifact(artifactMap[path]).length > 0
146
- && item.artifactDigests[path] === artifactMap[path].digest
147
- && Array.isArray(item.paths) && item.paths.includes(path);
466
+ const candidateBound = (item, path) => {
467
+ if (!nonEmptyString(item.baseDigest) || item.baseDigest !== baselineDigest) return false;
468
+ if (!isPlainObject(item.artifactDigests)) return false;
469
+ if (candidateDigestCache.has(path)) {
470
+ // Baseline-annotated obligations use the digest captured before binding, so a mixed
471
+ // delete+edit scope never re-reads a throwing accessor from the legacy lane.
472
+ const digest = candidateDigestCache.get(path);
473
+ return digest.length > 0
474
+ && item.artifactDigests[path] === digest
475
+ && Array.isArray(item.paths) && item.paths.includes(path);
476
+ }
477
+ return artifact(artifactMap[path]).length > 0
478
+ && item.artifactDigests[path] === artifactMap[path].digest
479
+ && Array.isArray(item.paths) && item.paths.includes(path);
480
+ };
481
+
482
+ // A baseline-only path is satisfied only by a record bound to the trusted baseline digest.
483
+ // A rename-from path additionally needs the same single record to carry the current
484
+ // destination candidate digest, so destination-only, source-only and split records fail.
485
+ const baselineBound = (item, entry) => {
486
+ if (!nonEmptyString(item.baseDigest) || item.baseDigest !== baselineDigest) return false;
487
+ if (!Array.isArray(item.paths) || !item.paths.includes(entry.path)) return false;
488
+ // #340: metadata validation confirmed this baseline-only path has no own-key candidate
489
+ // entry. Reuse that carried absence proof instead of re-inspecting the candidate map, so a
490
+ // descriptor trap that only throws on a later inspection cannot abort the check. A path
491
+ // whose metadata was malformed never reaches here; it fails closed earlier.
492
+ if (entry.candidatePathPresent !== false) return false;
493
+ if (isPlainObject(item.artifactDigests)
494
+ && Object.prototype.hasOwnProperty.call(item.artifactDigests, entry.path)) return false;
495
+ // #340 P2: binding compares the validated baseline digest captured during metadata, never
496
+ // a fresh read of the caller-owned baseline map; a stateful getter that started returning
497
+ // a different value after validation cannot make a stale record bind.
498
+ const trusted = entry.trustedBaselineDigest;
499
+ if (!isSha256(trusted)) return false;
500
+ if (!isPlainObject(item.baselineArtifacts)
501
+ || !hasOwnEntry(item.baselineArtifacts, entry.path)
502
+ || item.baselineArtifacts[entry.path] !== trusted) return false;
503
+ if (entry.reason === 'rename-from') {
504
+ if (!item.paths.includes(entry.renameTo)) return false;
505
+ if (!isPlainObject(item.artifactDigests)
506
+ || !hasOwnEntry(item.artifactDigests, entry.renameTo)
507
+ || !nonEmptyString(item.artifactDigests[entry.renameTo])) return false;
508
+ // #340 P3: the destination binds to the same captured candidate digest that the ordinary
509
+ // candidate lane, the audit identity, and the stale diagnostics use, so a stateful nested
510
+ // accessor cannot make binding disagree with them. Metadata validation already required the
511
+ // destination to be an own-key candidate inventory entry carrying its own digest
512
+ // (ownArtifactDigest), so a non-empty captured digest is required here and the accessor is
513
+ // never re-read; a missing destination skips capture and lands on this guard.
514
+ const capturedDestination = candidateDigestCache.get(entry.renameTo);
515
+ if (!nonEmptyString(capturedDestination)) return false;
516
+ if (item.artifactDigests[entry.renameTo] !== capturedDestination) return false;
517
+ }
518
+ return true;
519
+ };
520
+
521
+ const boundToScopePath = (item, path) => {
522
+ const entry = baselineEntries.get(path);
523
+ return entry ? baselineBound(item, entry) : candidateBound(item, path);
524
+ };
148
525
 
149
526
  for (const item of matching) {
150
527
  if (executionList.includes(item) && !completeExecution(item)) {
@@ -166,8 +543,8 @@ function evaluateObligation(obligation, context) {
166
543
  continue;
167
544
  }
168
545
  if (Array.isArray(item.paths) && isPlainObject(item.artifactDigests)) {
169
- const mismatch = item.paths.find(path => artifact(artifactMap[path]).length > 0
170
- && nonEmptyString(item.artifactDigests[path]) && item.artifactDigests[path] !== artifactMap[path].digest);
546
+ const mismatch = item.paths.find(path => candidateDigestOf(path).length > 0
547
+ && nonEmptyString(item.artifactDigests[path]) && item.artifactDigests[path] !== candidateDigestOf(path));
171
548
  if (mismatch) {
172
549
  diagnostics.push({
173
550
  code: 'stale-artifact-digest',
@@ -178,12 +555,65 @@ function evaluateObligation(obligation, context) {
178
555
  }
179
556
  }
180
557
 
558
+ // #335: a record that names a baseline-only path but carries no per-path baseline digest is
559
+ // visible as an attempt, and a record whose per-path digest disagrees with the trusted value
560
+ // is stale. Both fail closed through missing-path-coverage and stale-baseline-artifact-digest.
561
+ for (const item of matching) {
562
+ const baselineNamed = Array.isArray(item.paths) ? item.paths.filter(path => baselineEntries.has(path)) : [];
563
+ if (isPlainObject(item.baselineArtifacts)) {
564
+ for (const [path, value] of Object.entries(item.baselineArtifacts)) {
565
+ if (!baselineEntries.has(path)) continue;
566
+ // #340 P2: the stale comparison uses the baseline digest captured during metadata,
567
+ // never a fresh read of the caller-owned map.
568
+ const trusted = validatedBaselineDigests.has(path)
569
+ ? validatedBaselineDigests.get(path)
570
+ : undefined;
571
+ if (!isSha256(trusted)) continue;
572
+ if (nonEmptyString(value) && value !== trusted) {
573
+ diagnostics.push({
574
+ code: 'stale-baseline-artifact-digest',
575
+ obligationId,
576
+ message: `record ${item.id} carries a baseline digest for path ${path} that differs from the trusted baseline value`
577
+ });
578
+ }
579
+ }
580
+ }
581
+ for (const path of baselineNamed) {
582
+ const bound = isPlainObject(item.baselineArtifacts)
583
+ && hasOwnEntry(item.baselineArtifacts, path)
584
+ && nonEmptyString(item.baselineArtifacts[path]);
585
+ if (!bound) {
586
+ diagnostics.push({
587
+ code: 'baseline-binding-missing',
588
+ obligationId,
589
+ message: `record ${item.id} names baseline path ${path} but carries no trusted baseline digest for it`
590
+ });
591
+ }
592
+ if (isPlainObject(item.artifactDigests)
593
+ && Object.prototype.hasOwnProperty.call(item.artifactDigests, path)) {
594
+ diagnostics.push({
595
+ code: 'baseline-path-candidate-digest',
596
+ obligationId,
597
+ message: `record ${item.id} carries a candidate artifactDigests entry for baseline-only path ${path}; the contradictory record cannot bind that path`
598
+ });
599
+ }
600
+ }
601
+ }
602
+
603
+ if (baselineMetadataError || candidateInventoryUnknown) {
604
+ return result('unresolved', 'none',
605
+ candidateInventoryUnknown && !baselineMetadataError
606
+ ? 'the top-level candidate artifacts inventory is absent or malformed; baseline-bound evidence fails closed'
607
+ : 'baseline path metadata is missing, invalid, or contradictory; baseline-bound evidence fails closed',
608
+ []);
609
+ }
610
+
181
611
  const usedExec = matching.filter(item => executionList.includes(item)
182
612
  && completeExecution(item)
183
- && scopePaths.some(path => bound(item, path)));
613
+ && scopePaths.some(path => boundToScopePath(item, path)));
184
614
  const failingExec = usedExec.filter(item => item.exitCode !== 0);
185
615
  const boundReviews = matching.filter(item => reviewList.includes(item) && item.kind === 'review'
186
- && scopePaths.some(path => bound(item, path)));
616
+ && scopePaths.some(path => boundToScopePath(item, path)));
187
617
  const validReviews = boundReviews.filter(item => item.verdict === 'approve' && nonEmptyString(item.summary));
188
618
  const invalidReviews = boundReviews.filter(item => !validReviews.includes(item));
189
619
  const oddKindRecords = matching.filter(item => reviewList.includes(item)
@@ -204,13 +634,16 @@ function evaluateObligation(obligation, context) {
204
634
  });
205
635
  }
206
636
 
207
- const boundAny = (path) => matching.some(item => bound(item, path));
637
+ const boundAny = (path) => matching.some(item => boundToScopePath(item, path));
208
638
  const unmetPaths = scopePaths.filter(path => !boundAny(path));
209
639
  for (const path of unmetPaths) {
640
+ const entry = baselineEntries.get(path);
210
641
  diagnostics.push({
211
642
  code: 'missing-path-coverage',
212
643
  obligationId,
213
- message: `no observed record is bound to path ${path} with the current candidate and baseline digests`
644
+ message: entry
645
+ ? `no observed record is bound to baseline path ${path} (${entry.reason}${entry.renameTo ? ` -> ${entry.renameTo}` : ''}) with the trusted baseline digest`
646
+ : `no observed record is bound to path ${path} with the current candidate and baseline digests`
214
647
  });
215
648
  }
216
649
 
@@ -219,7 +652,7 @@ function evaluateObligation(obligation, context) {
219
652
  const target = recordIndex.get(item);
220
653
  return Boolean(target) && target.obligationId === obligationId
221
654
  && executionList.includes(target) && completeExecution(target) && target.exitCode === 0
222
- && scopePaths.some(path => bound(target, path));
655
+ && scopePaths.some(path => boundToScopePath(target, path));
223
656
  };
224
657
  const waiverCandidates = reviewList.filter(item => item.kind === 'waiver'
225
658
  && item.obligationId === obligationId);
@@ -321,14 +754,14 @@ function evaluateObligation(obligation, context) {
321
754
  []);
322
755
  }
323
756
 
324
- const waiverSelfBound = item => scopePaths.length > 0 && scopePaths.every(path => bound(item, path));
757
+ const waiverSelfBound = item => scopePaths.length > 0 && scopePaths.every(path => boundToScopePath(item, path));
325
758
  const citationsCoverScope = item => {
326
759
  const covered = new Set();
327
760
  for (const id of Array.isArray(item.citedRecordIds) ? item.citedRecordIds : []) {
328
761
  const target = recordIndex.get(id);
329
762
  if (target && target.obligationId === obligationId && target.exitCode === 0
330
763
  && completeExecution(target) && !failingExec.includes(target)) {
331
- for (const path of scopePaths) if (bound(target, path)) covered.add(path);
764
+ for (const path of scopePaths) if (boundToScopePath(target, path)) covered.add(path);
332
765
  }
333
766
  }
334
767
  return covered.size === scopePaths.length;
@@ -362,7 +795,7 @@ function evaluateObligation(obligation, context) {
362
795
 
363
796
  const passingExec = usedExec.filter(item => item.exitCode === 0);
364
797
  const execCoveredAll = scopePaths.length > 0
365
- && scopePaths.every(path => passingExec.some(item => bound(item, path)));
798
+ && scopePaths.every(path => passingExec.some(item => boundToScopePath(item, path)));
366
799
  // S3 (§11): identity data gates every per-REQ coverage claim. A waiver lane pass requires a
367
800
  // known non-empty cited identity list, known update data, and coverage of every uncovered
368
801
  // missing REQ by a listing valid waiver. Absent or empty cited data, or an absent
@@ -439,7 +872,7 @@ function evaluateObligation(obligation, context) {
439
872
  const reviewsRequired = capability === 'quality-review';
440
873
  if (reviewsRequired) {
441
874
  const reviewCovers = (path) => matching.some(item => reviewList.includes(item) && item.kind === 'review'
442
- && item.verdict === 'approve' && nonEmptyString(item.summary) && bound(item, path));
875
+ && item.verdict === 'approve' && nonEmptyString(item.summary) && boundToScopePath(item, path));
443
876
  if (!invalidReviews.length && !oddKindRecords.length
444
877
  && validReviews.length && scopePaths.every(reviewCovers)) {
445
878
  const contributors = [...validReviews, ...usedExec.filter(item => !failingExec.includes(item))];
@@ -454,7 +887,7 @@ function evaluateObligation(obligation, context) {
454
887
 
455
888
  const execCovers = (path) => matching.some(item => executionList.includes(item)
456
889
  && completeExecution(item)
457
- && !failingExec.includes(item) && bound(item, path));
890
+ && !failingExec.includes(item) && boundToScopePath(item, path));
458
891
  const execCovered = scopePaths.every(execCovers);
459
892
  if (usedExec.length && execCovered) {
460
893
  const contributors = [...usedExec.filter(item => !failingExec.includes(item)), ...validWaivers];
@@ -163,6 +163,20 @@ function changePaths(change) {
163
163
  return change.kind === 'rename' ? [change.from, change.to] : [change.path];
164
164
  }
165
165
 
166
+ // #335: a path with no candidate artifact. The old endpoint of an identified rename and a
167
+ // deleted path are both baseline-only. Derivation annotates them; it never removes them from
168
+ // scope.paths, so an unannotated caller keeps the pre-existing fail-closed behavior.
169
+ function baselinePathFacts(changes) {
170
+ const entries = [];
171
+ for (const change of changes) {
172
+ if (change.kind === 'deleted') entries.push({ path: change.path, reason: 'deleted' });
173
+ else if (change.kind === 'rename') {
174
+ entries.push({ path: change.from, reason: 'rename-from', renameTo: change.to });
175
+ }
176
+ }
177
+ return entries.sort((left, right) => compareText(left.path, right.path));
178
+ }
179
+
166
180
  function uniqueSorted(values) {
167
181
  return [...new Set(values.filter(value => typeof value === 'string'))].sort(compareText);
168
182
  }
@@ -1465,6 +1479,7 @@ function deriveDocFacts({ base, candidate, config, inventory } = {}) {
1465
1479
  governedTouched,
1466
1480
  governedPaths: uniqueSorted(contextualChanges.filter(change => change.oldGoverned || change.newGoverned)
1467
1481
  .flatMap(changePaths)),
1482
+ baselinePaths: baselinePathFacts(contextualChanges),
1468
1483
  newGovernedPaths: newGovernedFiles.map(change => change.newPath).sort(compareText),
1469
1484
  derivedAffected,
1470
1485
  derivedPaths,
@@ -1540,6 +1555,13 @@ function deriveDocObligations(facts) {
1540
1555
  ...(facts.citedReqs || []).map(item => item && item.file)]) : []
1541
1556
  };
1542
1557
  const inventoryEnvelope = validFacts ? copyInventoryEnvelope(facts.inventory) : undefined;
1558
+ const baselineByPath = new Map((validFacts && Array.isArray(facts.baselinePaths) ? facts.baselinePaths : [])
1559
+ .filter(entry => entry && typeof entry.path === 'string')
1560
+ .map(entry => [entry.path, {
1561
+ path: entry.path,
1562
+ reason: entry.reason,
1563
+ ...(typeof entry.renameTo === 'string' ? { renameTo: entry.renameTo } : {})
1564
+ }]));
1543
1565
  const requirementFacts = {
1544
1566
  citedReqs: validFacts && Array.isArray(facts.citedReqs) ? facts.citedReqs : [],
1545
1567
  reqUpdates: validFacts && Array.isArray(facts.reqUpdates) ? facts.reqUpdates : [],
@@ -1558,11 +1580,16 @@ function deriveDocObligations(facts) {
1558
1580
  capability: item.capability,
1559
1581
  strength: strengths.get(item.capability) || item.strength,
1560
1582
  applicability: applicable.has(item.capability) ? 'applicable' : unknown.has(item.capability) ? 'unknown' : 'N/A',
1561
- scope: {
1562
- paths: uniqueSorted(scopePaths[item.capability] || []),
1563
- ...(item.capability === 'requirement-sync' && requirementFacts.requirementScopeUnknown
1564
- ? { requirementIds: 'unknown' } : {})
1565
- },
1583
+ scope: (() => {
1584
+ const paths = uniqueSorted(scopePaths[item.capability] || []);
1585
+ const baselinePaths = paths.filter(path => baselineByPath.has(path)).map(path => baselineByPath.get(path));
1586
+ return {
1587
+ paths,
1588
+ ...(baselinePaths.length ? { baselinePaths } : {}),
1589
+ ...(item.capability === 'requirement-sync' && requirementFacts.requirementScopeUnknown
1590
+ ? { requirementIds: 'unknown' } : {})
1591
+ };
1592
+ })(),
1566
1593
  acceptance: item.acceptance,
1567
1594
  ...(item.capability === 'requirement-sync' ? requirementFacts : {})
1568
1595
  }));