harness-everything 0.36.0 → 0.36.2

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,22 @@ 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
+ // P2(r4321077631): reads the own digest exactly once. `artifact()` inspects `entry.digest` up
76
+ // to three times (typeof, length, value), so a stateful accessor could validate one value and
77
+ // have the captured destination snapshot carry another. Only the applicable rename-destination
78
+ // snapshot uses this helper; non-baseline and inapplicable lanes keep the legacy `artifact()`
79
+ // reads for their audit identity. A throwing digest getter propagates to the caller's guarded
80
+ // block and fails the inspection closed there. This is the single authoritative own-digest
81
+ // helper; nothing else in this module reads a destination's own digest.
82
+ function ownDigestOnce(entry) {
83
+ if (!isPlainObject(entry)) return '';
84
+ if (!hasOwnEntry(entry, 'digest')) return '';
85
+ const digest = entry.digest;
86
+ return typeof digest === 'string' && digest.length > 0 ? digest : '';
87
+ }
88
+
39
89
  function provenanceText(record) {
40
90
  return nonEmptyString(record.provenance) ? record.provenance : `record ${record.id}`;
41
91
  }
@@ -86,8 +136,36 @@ function prdSetDigest(artifactMap) {
86
136
  function checkDocObligations({ obligations, artifacts, baseline, executionRecords, reviewRecords } = {}) {
87
137
  const diagnostics = [];
88
138
  const results = [];
139
+ // P3: distinguish a caller-supplied plain-object or null-prototype inventory (including an
140
+ // explicit {}) from an absent, malformed, or object-like value that the pre-existing
141
+ // normalizer would coerce to (or treat as) an empty map. The provenance check is deferred to
142
+ // applicable obligations that actually declare baseline paths, so non-baseline and
143
+ // inapplicable callers never read the candidate inventory's prototype (a throwing Proxy trap
144
+ // must not abort an unrelated obligation). It is resolved at most once so every obligation
145
+ // sees the same classification. `artifactMap` keeps the original `isPlainObject` fallback for
146
+ // non-baseline callers.
89
147
  const artifactMap = isPlainObject(artifacts) ? artifacts : {};
90
- const baselineDigest = isPlainObject(baseline) && nonEmptyString(baseline.digest) ? baseline.digest : '';
148
+ let candidateInventoryKnown;
149
+ const isCandidateInventoryKnown = () => {
150
+ if (candidateInventoryKnown === undefined) {
151
+ candidateInventoryKnown = isCandidateInventory(artifacts);
152
+ }
153
+ return candidateInventoryKnown;
154
+ };
155
+ // P2(r4321077638): the whole-baseline identity is read exactly once and that same scalar is
156
+ // validated, so a stateful getter cannot pass validation as A and have every later comparison
157
+ // use B. A baseline identity whose inspection throws is unusable input: the captured digest
158
+ // stays '', so baseline-bound evaluations fail closed through `baseline-identity-missing`
159
+ // instead of escaping the checker, and non-baseline callers keep the legacy '' shape.
160
+ let baselineDigest = '';
161
+ if (isPlainObject(baseline)) {
162
+ try {
163
+ const declaredBaselineDigest = baseline.digest;
164
+ if (nonEmptyString(declaredBaselineDigest)) baselineDigest = declaredBaselineDigest;
165
+ } catch {
166
+ baselineDigest = '';
167
+ }
168
+ }
91
169
  const executionList = Array.isArray(executionRecords) ? executionRecords.filter(isRecord) : [];
92
170
  const reviewList = Array.isArray(reviewRecords) ? reviewRecords.filter(isRecord) : [];
93
171
 
@@ -105,21 +183,324 @@ function checkDocObligations({ obligations, artifacts, baseline, executionRecord
105
183
 
106
184
  for (const obligation of obligationList) {
107
185
  results.push(evaluateObligation(obligation, {
108
- artifactMap, baselineDigest, executionList, reviewList, diagnostics
186
+ artifactMap, isCandidateInventoryKnown, baseline, baselineDigest, executionList, reviewList, diagnostics
109
187
  }));
110
188
  }
111
189
  return { diagnostics, results };
112
190
  }
113
191
 
114
192
  function evaluateObligation(obligation, context) {
115
- const { artifactMap, baselineDigest, executionList, reviewList, diagnostics } = context;
193
+ const { artifactMap, isCandidateInventoryKnown, baseline, baselineDigest, executionList, reviewList, diagnostics } = context;
116
194
  const capability = typeof obligation.capability === 'string' ? obligation.capability : '';
117
195
  const obligationId = nonEmptyString(obligation.id) ? obligation.id : capability;
118
196
  const scopePaths = scopePathsOf(obligation);
119
197
  const applicability = obligation.applicability;
120
198
 
199
+ // #335: baseline-only paths are declared by the obligation. Every declared entry is validated
200
+ // before any outcome is decided; a malformed or contradictory annotation fails the whole
201
+ // obligation closed instead of being silently ignored.
202
+ const scopeObject = isPlainObject(obligation.scope) ? obligation.scope : null;
203
+ const declaresBaselinePaths = Boolean(scopeObject && Object.prototype.hasOwnProperty.call(scopeObject, 'baselinePaths'));
204
+ const declaredBaselinePaths = declaresBaselinePaths ? scopeObject.baselinePaths : undefined;
205
+ const baselineArtifactsMap = isPlainObject(baseline) && isPlainObject(baseline.artifacts) ? baseline.artifacts : null;
206
+ const baselineEntries = new Map();
207
+ // #340: every accepted entry sharing a rename destination must validate the same own digest,
208
+ // or the shared-destination snapshot is contradictory and the obligation fails closed.
209
+ const validatedDestinationDigestMap = new Map();
210
+ // #342 P3: a rename destination whose own-digest read throws or returns an unusable value is
211
+ // still a visited snapshot. Its captured ('') value is recorded here so the ordinary capture
212
+ // and the audit identity never re-read that destination entry: the single metadata read stays
213
+ // the only digest read for that declaring entry, and a stateful getter cannot ship a later
214
+ // non-empty value into the audit key even though its entry was rejected. A poisoned
215
+ // destination is honored inside the metadata loop (a later declaring entry fails closed
216
+ // without reading it) and keeps precedence over a previously seeded successful snapshot;
217
+ // multiple accepted entries still read the destination once each by design, because their
218
+ // agreement check detects shared-destination contradictions.
219
+ const failedDestinationDigests = new Map();
220
+ // #340 P2(r4320818749): the validated per-path baseline digest is captured once during
221
+ // metadata validation. The audit mirror, the stale-baseline diagnostics, and baselineBound()
222
+ // reuse that captured value instead of re-reading the caller map, so a stateful own getter
223
+ // cannot pass evidence against a value it started returning only after validation, and a
224
+ // later-throwing read cannot escape the checker.
225
+ const validatedBaselineDigests = new Map();
226
+ let baselineMetadataError = false;
227
+ let candidateInventoryUnknown = false;
228
+ // #335 P3: a candidate inventory can pass the prototype check and still throw from an
229
+ // own-property descriptor trap or a nested digest accessor. Any such failure is treated as
230
+ // unknown inventory rather than escaping the checker; the diagnostic is emitted only for
231
+ // applicable obligations that declare baseline-only paths.
232
+ let candidateInventoryInspectionFailed = false;
233
+
234
+ const baselineMetadataDiagnostic = (code, path, message) => {
235
+ baselineMetadataError = true;
236
+ diagnostics.push({ code, obligationId, ...(path ? { path } : {}), message });
237
+ };
238
+
239
+ if (declaresBaselinePaths) {
240
+ if (!Array.isArray(declaredBaselinePaths)) {
241
+ baselineMetadataDiagnostic('baseline-path-invalid', undefined,
242
+ 'scope.baselinePaths is present but not an array; baseline-bound evidence cannot be evaluated');
243
+ } else if (declaredBaselinePaths.length) {
244
+ if (!nonEmptyString(baselineDigest)) {
245
+ baselineMetadataDiagnostic('baseline-identity-missing', undefined,
246
+ 'baselinePaths are declared but the baseline carries no whole-baseline digest; baseline-bound evidence cannot be trusted');
247
+ }
248
+ if (!baselineArtifactsMap) {
249
+ baselineMetadataDiagnostic('baseline-artifacts-missing', undefined,
250
+ 'baselinePaths are declared but the baseline carries no artifacts map; per-path baseline digests are unavailable');
251
+ }
252
+ const seen = new Set();
253
+ for (const rawEntry of declaredBaselinePaths) {
254
+ const invalid = (path, reason) => baselineMetadataDiagnostic('baseline-path-invalid', path,
255
+ `baselinePaths entry ${path ? JSON.stringify(path) : '(malformed)'} is invalid: ${reason}; the path fails closed`);
256
+ if (!isPlainObject(rawEntry)) {
257
+ invalid(undefined, 'it is not an object with a non-empty path');
258
+ continue;
259
+ }
260
+ // #340: entry fields are snapshotted through one guarded inspection. A Proxy entry
261
+ // whose `path`, `reason`, or `renameTo` getter or descriptor trap throws must not
262
+ // escape the checker; the inspection failure itself fails the entry closed as a
263
+ // metadata error, and each field is carried exactly once so a stateful accessor
264
+ // cannot make validation and capture disagree.
265
+ let snapshot = null;
266
+ try {
267
+ snapshot = {
268
+ path: rawEntry.path,
269
+ reason: rawEntry.reason,
270
+ hasOwnRenameTo: Object.prototype.hasOwnProperty.call(rawEntry, 'renameTo'),
271
+ renameTo: rawEntry.renameTo
272
+ };
273
+ } catch {
274
+ snapshot = null;
275
+ }
276
+ if (!snapshot) {
277
+ invalid(undefined, 'its entry fields could not be inspected safely (a getter or descriptor trap threw); the entry fails closed');
278
+ continue;
279
+ }
280
+ if (!nonEmptyString(snapshot.path)) {
281
+ invalid(undefined, 'it is not an object with a non-empty path');
282
+ continue;
283
+ }
284
+ const entryPath = snapshot.path;
285
+ if (seen.has(entryPath)) {
286
+ invalid(entryPath, 'the same path is declared more than once');
287
+ continue;
288
+ }
289
+ seen.add(entryPath);
290
+ if (!scopePaths.includes(entryPath)) {
291
+ invalid(entryPath, 'it is not a member of scope.paths');
292
+ continue;
293
+ }
294
+ const entryReason = snapshot.reason;
295
+ if (entryReason !== 'deleted' && entryReason !== 'rename-from') {
296
+ invalid(entryPath, `reason ${JSON.stringify(entryReason)} is not deleted or rename-from`);
297
+ continue;
298
+ }
299
+ if (entryReason === 'deleted' && snapshot.hasOwnRenameTo) {
300
+ invalid(entryPath, 'a deleted entry must not carry renameTo');
301
+ continue;
302
+ }
303
+ // #340: the validated rename destination digest is carried on the entry; the
304
+ // destination candidate map is not read again after metadata validation.
305
+ let renameDestinationDigest = '';
306
+ if (entryReason === 'rename-from') {
307
+ // #340: the destination annotation is snapshotted once; a stateful getter cannot
308
+ // make the two validation reads disagree.
309
+ const entryRenameTo = snapshot.renameTo;
310
+ if (!nonEmptyString(entryRenameTo) || entryRenameTo === entryPath) {
311
+ invalid(entryPath, 'a rename-from entry needs a distinct non-empty renameTo');
312
+ continue;
313
+ }
314
+ if (!scopePaths.includes(entryRenameTo)) {
315
+ invalid(entryPath, `its rename destination ${JSON.stringify(entryRenameTo)} is not a member of scope.paths`);
316
+ continue;
317
+ }
318
+ // #340: destination candidate inspection stays applicability-gated. N/A and unknown
319
+ // obligations never bind evidence, so consuming their destination digest getter
320
+ // during metadata validation would shift the legacy audit identity (a stateful
321
+ // getter reports a later value on the second artifact read). Their audit identity
322
+ // keeps the un-gated legacy candidate read; only applicable obligations capture and
323
+ // validate the destination snapshot.
324
+ if (applicability === 'applicable') {
325
+ // #342 review P3: a destination poisoned by an earlier entry's failed snapshot is
326
+ // honored inside the metadata loop; the failed visited snapshot is never read again
327
+ // and this entry fails closed with it, so a stateful getter's later successful
328
+ // value cannot repair a shared destination or enter the audit.
329
+ if (failedDestinationDigests.has(entryRenameTo)) {
330
+ invalid(entryPath, `its rename destination ${JSON.stringify(entryRenameTo)} failed an earlier snapshot validation for this obligation; the failed destination is not read again and this entry fails closed with it`);
331
+ continue;
332
+ }
333
+ let destinationDigest = '';
334
+ try {
335
+ if (hasOwnEntry(artifactMap, entryRenameTo)) {
336
+ // P2(r4321077631): read the destination's own digest exactly once; the captured
337
+ // value is the one validated below and the one that flows to the metadata cache,
338
+ // binding, the stale diagnostics, and the audit identity. A stateful destination
339
+ // getter cannot be validated as B while a record carrying only its later C passes.
340
+ destinationDigest = ownDigestOnce(artifactMap[entryRenameTo]);
341
+ }
342
+ } catch {
343
+ candidateInventoryInspectionFailed = true;
344
+ // #342 P3: the destination is a visited snapshot even when its digest read threw;
345
+ // record it so the capture and audit lanes never re-read it.
346
+ failedDestinationDigests.set(entryRenameTo, '');
347
+ continue;
348
+ }
349
+ // #340: the captured own value must be a usable non-empty string; an unusable result
350
+ // is not carried or seeded.
351
+ if (!nonEmptyString(destinationDigest)) {
352
+ invalid(entryPath, `its rename destination ${JSON.stringify(entryRenameTo)} has no candidate artifact`);
353
+ // #342 P3: the destination is a visited snapshot even when its own digest is
354
+ // unusable; record it so the capture and audit lanes never re-read it and cannot
355
+ // describe a stateful getter's later non-empty value.
356
+ failedDestinationDigests.set(entryRenameTo, '');
357
+ continue;
358
+ }
359
+ // #340: entries that share one rename destination must agree on the validated own
360
+ // digest. A conflicting validated value is a contradictory snapshot and fails the
361
+ // whole obligation closed before any binding; equal values are allowed.
362
+ const sharedDestinationDigest = validatedDestinationDigestMap.get(entryRenameTo);
363
+ if (sharedDestinationDigest === undefined) {
364
+ validatedDestinationDigestMap.set(entryRenameTo, destinationDigest);
365
+ } else if (sharedDestinationDigest !== destinationDigest) {
366
+ 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`);
367
+ continue;
368
+ }
369
+ renameDestinationDigest = destinationDigest;
370
+ }
371
+ }
372
+ let candidatePathPresent = false;
373
+ try {
374
+ candidatePathPresent = hasOwnEntry(artifactMap, entryPath);
375
+ } catch {
376
+ candidateInventoryInspectionFailed = true;
377
+ continue;
378
+ }
379
+ if (candidatePathPresent) {
380
+ baselineMetadataDiagnostic('baseline-path-candidate-present', entryPath,
381
+ `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`);
382
+ continue;
383
+ }
384
+ if (baselineArtifactsMap) {
385
+ let trusted;
386
+ try {
387
+ trusted = hasOwnEntry(baselineArtifactsMap, entryPath) ? baselineArtifactsMap[entryPath] : undefined;
388
+ } catch {
389
+ // #340 P2: a throwing baseline inspection is a metadata error, never an escape.
390
+ baselineMetadataDiagnostic('baseline-artifact-invalid', entryPath,
391
+ `the baseline digest for ${entryPath} could not be inspected safely (a getter or descriptor trap threw); the path fails closed`);
392
+ continue;
393
+ }
394
+ if (trusted === undefined) {
395
+ baselineMetadataDiagnostic('baseline-artifact-missing', entryPath,
396
+ `the baseline carries no per-path digest for declared baseline path ${entryPath}; the path fails closed`);
397
+ continue;
398
+ }
399
+ if (!isSha256(trusted)) {
400
+ baselineMetadataDiagnostic('baseline-artifact-invalid', entryPath,
401
+ `the baseline digest for ${entryPath} is not a SHA256 value; the path fails closed`);
402
+ continue;
403
+ }
404
+ validatedBaselineDigests.set(entryPath, trusted);
405
+ }
406
+ baselineEntries.set(entryPath, {
407
+ path: entryPath,
408
+ reason: entryReason,
409
+ // #340: the metadata check above confirmed this path has no own-key candidate entry;
410
+ // carry that validated source-absence proof explicitly (never spread from rawEntry) so
411
+ // binding and the audit identity reuse it instead of re-inspecting the candidate map.
412
+ candidatePathPresent: false,
413
+ // #340 P2: carry the exact validated SHA256 so binding never re-reads the
414
+ // caller-owned baseline map.
415
+ trustedBaselineDigest: validatedBaselineDigests.get(entryPath),
416
+ ...(entryReason === 'rename-from' ? { renameTo: snapshot.renameTo, renameDestinationDigest } : {})
417
+ });
418
+ }
419
+ }
420
+ }
421
+
422
+ // #335 P3: a mixed scope combines baseline-only paths with ordinary candidate paths. Capture
423
+ // every ordinary scoped candidate digest before evidence binding, so a throwing accessor in
424
+ // the legacy candidate lane fails the inventory closed instead of aborting the obligation.
425
+ const candidateDigestCache = new Map();
426
+ if (applicability === 'applicable' && Array.isArray(declaredBaselinePaths)
427
+ && declaredBaselinePaths.length > 0) {
428
+ // #340: a rename destination was already validated as an own-key candidate entry carrying
429
+ // its own digest during metadata. Seed the captured snapshot from that validated value and
430
+ // never read the destination through the raw candidate map again, so a destination accessor
431
+ // that throws or changes after validation cannot make capture/binding/audit disagree. The
432
+ // seeded value is the validated own digest, never a caller-asserted rawEntry field. A
433
+ // rejected destination is still visited: its failed capture (see failedDestinationDigests)
434
+ // suppresses the ordinary read below, so no destination entry is ever read twice.
435
+ for (const entry of baselineEntries.values()) {
436
+ if (entry.reason === 'rename-from' && nonEmptyString(entry.renameDestinationDigest)
437
+ && !candidateDigestCache.has(entry.renameTo)) {
438
+ candidateDigestCache.set(entry.renameTo, entry.renameDestinationDigest);
439
+ }
440
+ }
441
+ for (const path of scopePaths) {
442
+ if (baselineEntries.has(path) || candidateDigestCache.has(path)
443
+ || failedDestinationDigests.has(path)) continue;
444
+ try {
445
+ candidateDigestCache.set(path, artifact(artifactMap[path]));
446
+ } catch {
447
+ candidateInventoryInspectionFailed = true;
448
+ }
449
+ }
450
+ }
451
+
452
+ // P3: an applicable obligation that actually declares baseline-only paths needs a known
453
+ // candidate inventory. Explicit {} is known and remains the legitimate empty deletion
454
+ // inventory; omitted/undefined/null/array/string/other malformed values are unknown input.
455
+ // The prototype read happens here, not up front, so unrelated obligations never touch it.
456
+ if (applicability === 'applicable' && Array.isArray(declaredBaselinePaths)
457
+ && declaredBaselinePaths.length > 0
458
+ && (candidateInventoryInspectionFailed || !isCandidateInventoryKnown())) {
459
+ candidateInventoryUnknown = true;
460
+ diagnostics.push({
461
+ code: 'candidate-artifacts-invalid',
462
+ obligationId,
463
+ 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'
464
+ });
465
+ }
466
+
467
+ const baselineAudit = {};
468
+ // #340 P2(r4320941809): the audit mirror is built from the metadata-validated snapshot
469
+ // (`validatedBaselineDigests`, populated during the per-entry guard), never from a re-read
470
+ // of the raw entry path or a fallback inspection of the live baseline map. A stateful
471
+ // `path` accessor therefore cannot make a passing result describe a different baseline
472
+ // identity than the one that validated and bound the evidence, and each declared path
473
+ // contributes its snapshot exactly once in validation order.
474
+ for (const [baselineAuditPath, baselineAuditDigest] of validatedBaselineDigests) {
475
+ baselineAudit[baselineAuditPath] = baselineAuditDigest;
476
+ }
477
+
478
+ // An uninspectable candidate entry must not abort an N/A or unknown obligation while the
479
+ // audit key is built; the baseline-bound lanes fail closed through the inventory diagnostic.
480
+ // When a baseline-annotated obligation captured the ordinary candidate digests before binding
481
+ // (candidateDigestCache), the audit identity and stale diagnostics use that same snapshot, so a
482
+ // nested digest accessor that only later throws or changes can never make the audit key or the
483
+ // stale verdict disagree with the digest that actually bound evidence. Untouched paths and
484
+ // unannotated callers keep the legacy read.
485
+ const candidateDigestOf = (path) => {
486
+ // #342 P3: poisoned state takes precedence over the capture cache. A rejected rename
487
+ // destination keeps its visited '' capture even when an earlier accepted entry seeded a
488
+ // successful value, so the audit never describes a value the getter shipped after a failed
489
+ // snapshot made the obligation fail closed; the caller map is never read for it again.
490
+ if (failedDestinationDigests.has(path)) return '';
491
+ if (candidateDigestCache.has(path)) return candidateDigestCache.get(path);
492
+ // #340: only an applicable baseline-annotated obligation validated the source absence as
493
+ // part of its captured snapshot. `N/A` and `unknown` obligations never bind evidence, so they
494
+ // keep the legacy candidate read for their audit identity and stay `outcome: 'none'`.
495
+ if (applicability === 'applicable' && baselineEntries.has(path)) return '';
496
+ try {
497
+ return artifact(artifactMap[path]);
498
+ } catch {
499
+ return '';
500
+ }
501
+ };
121
502
  const artifactDigestKey = scopePaths.length
122
- ? scopePaths.map(path => `${path}=${artifact(artifactMap[path]) || 'missing'}`).join('|')
503
+ ? scopePaths.map(path => `${path}=${candidateDigestOf(path) || 'missing'}`).join('|')
123
504
  : (baselineDigest || 'no-scope-paths');
124
505
  const baseDigestKey = baselineDigest || 'no-baseline';
125
506
  const result = (observation, outcome, provenance, recordRefs) => ({
@@ -128,6 +509,7 @@ function evaluateObligation(obligation, context) {
128
509
  outcome,
129
510
  artifactDigest: artifactDigestKey,
130
511
  baseDigest: baseDigestKey,
512
+ ...(declaresBaselinePaths ? { baselineArtifacts: { ...baselineAudit } } : {}),
131
513
  provenance,
132
514
  recordRefs: [...recordRefs]
133
515
  });
@@ -141,10 +523,65 @@ function evaluateObligation(obligation, context) {
141
523
  }
142
524
 
143
525
  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);
526
+ const candidateBound = (item, path) => {
527
+ if (!nonEmptyString(item.baseDigest) || item.baseDigest !== baselineDigest) return false;
528
+ if (!isPlainObject(item.artifactDigests)) return false;
529
+ if (candidateDigestCache.has(path)) {
530
+ // Baseline-annotated obligations use the digest captured before binding, so a mixed
531
+ // delete+edit scope never re-reads a throwing accessor from the legacy lane.
532
+ const digest = candidateDigestCache.get(path);
533
+ return digest.length > 0
534
+ && item.artifactDigests[path] === digest
535
+ && Array.isArray(item.paths) && item.paths.includes(path);
536
+ }
537
+ return artifact(artifactMap[path]).length > 0
538
+ && item.artifactDigests[path] === artifactMap[path].digest
539
+ && Array.isArray(item.paths) && item.paths.includes(path);
540
+ };
541
+
542
+ // A baseline-only path is satisfied only by a record bound to the trusted baseline digest.
543
+ // A rename-from path additionally needs the same single record to carry the current
544
+ // destination candidate digest, so destination-only, source-only and split records fail.
545
+ const baselineBound = (item, entry) => {
546
+ if (!nonEmptyString(item.baseDigest) || item.baseDigest !== baselineDigest) return false;
547
+ if (!Array.isArray(item.paths) || !item.paths.includes(entry.path)) return false;
548
+ // #340: metadata validation confirmed this baseline-only path has no own-key candidate
549
+ // entry. Reuse that carried absence proof instead of re-inspecting the candidate map, so a
550
+ // descriptor trap that only throws on a later inspection cannot abort the check. A path
551
+ // whose metadata was malformed never reaches here; it fails closed earlier.
552
+ if (entry.candidatePathPresent !== false) return false;
553
+ if (isPlainObject(item.artifactDigests)
554
+ && Object.prototype.hasOwnProperty.call(item.artifactDigests, entry.path)) return false;
555
+ // #340 P2: binding compares the validated baseline digest captured during metadata, never
556
+ // a fresh read of the caller-owned baseline map; a stateful getter that started returning
557
+ // a different value after validation cannot make a stale record bind.
558
+ const trusted = entry.trustedBaselineDigest;
559
+ if (!isSha256(trusted)) return false;
560
+ if (!isPlainObject(item.baselineArtifacts)
561
+ || !hasOwnEntry(item.baselineArtifacts, entry.path)
562
+ || item.baselineArtifacts[entry.path] !== trusted) return false;
563
+ if (entry.reason === 'rename-from') {
564
+ if (!item.paths.includes(entry.renameTo)) return false;
565
+ if (!isPlainObject(item.artifactDigests)
566
+ || !hasOwnEntry(item.artifactDigests, entry.renameTo)
567
+ || !nonEmptyString(item.artifactDigests[entry.renameTo])) return false;
568
+ // #340 P3: the destination binds to the same captured candidate digest that the ordinary
569
+ // candidate lane, the audit identity, and the stale diagnostics use, so a stateful nested
570
+ // accessor cannot make binding disagree with them. Metadata validation already required the
571
+ // destination to be an own-key candidate inventory entry carrying its own digest
572
+ // (ownDigestOnce), so a non-empty captured digest is required here and the accessor is
573
+ // never re-read; a missing destination skips capture and lands on this guard.
574
+ const capturedDestination = candidateDigestCache.get(entry.renameTo);
575
+ if (!nonEmptyString(capturedDestination)) return false;
576
+ if (item.artifactDigests[entry.renameTo] !== capturedDestination) return false;
577
+ }
578
+ return true;
579
+ };
580
+
581
+ const boundToScopePath = (item, path) => {
582
+ const entry = baselineEntries.get(path);
583
+ return entry ? baselineBound(item, entry) : candidateBound(item, path);
584
+ };
148
585
 
149
586
  for (const item of matching) {
150
587
  if (executionList.includes(item) && !completeExecution(item)) {
@@ -166,8 +603,8 @@ function evaluateObligation(obligation, context) {
166
603
  continue;
167
604
  }
168
605
  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);
606
+ const mismatch = item.paths.find(path => candidateDigestOf(path).length > 0
607
+ && nonEmptyString(item.artifactDigests[path]) && item.artifactDigests[path] !== candidateDigestOf(path));
171
608
  if (mismatch) {
172
609
  diagnostics.push({
173
610
  code: 'stale-artifact-digest',
@@ -178,12 +615,65 @@ function evaluateObligation(obligation, context) {
178
615
  }
179
616
  }
180
617
 
618
+ // #335: a record that names a baseline-only path but carries no per-path baseline digest is
619
+ // visible as an attempt, and a record whose per-path digest disagrees with the trusted value
620
+ // is stale. Both fail closed through missing-path-coverage and stale-baseline-artifact-digest.
621
+ for (const item of matching) {
622
+ const baselineNamed = Array.isArray(item.paths) ? item.paths.filter(path => baselineEntries.has(path)) : [];
623
+ if (isPlainObject(item.baselineArtifacts)) {
624
+ for (const [path, value] of Object.entries(item.baselineArtifacts)) {
625
+ if (!baselineEntries.has(path)) continue;
626
+ // #340 P2: the stale comparison uses the baseline digest captured during metadata,
627
+ // never a fresh read of the caller-owned map.
628
+ const trusted = validatedBaselineDigests.has(path)
629
+ ? validatedBaselineDigests.get(path)
630
+ : undefined;
631
+ if (!isSha256(trusted)) continue;
632
+ if (nonEmptyString(value) && value !== trusted) {
633
+ diagnostics.push({
634
+ code: 'stale-baseline-artifact-digest',
635
+ obligationId,
636
+ message: `record ${item.id} carries a baseline digest for path ${path} that differs from the trusted baseline value`
637
+ });
638
+ }
639
+ }
640
+ }
641
+ for (const path of baselineNamed) {
642
+ const bound = isPlainObject(item.baselineArtifacts)
643
+ && hasOwnEntry(item.baselineArtifacts, path)
644
+ && nonEmptyString(item.baselineArtifacts[path]);
645
+ if (!bound) {
646
+ diagnostics.push({
647
+ code: 'baseline-binding-missing',
648
+ obligationId,
649
+ message: `record ${item.id} names baseline path ${path} but carries no trusted baseline digest for it`
650
+ });
651
+ }
652
+ if (isPlainObject(item.artifactDigests)
653
+ && Object.prototype.hasOwnProperty.call(item.artifactDigests, path)) {
654
+ diagnostics.push({
655
+ code: 'baseline-path-candidate-digest',
656
+ obligationId,
657
+ message: `record ${item.id} carries a candidate artifactDigests entry for baseline-only path ${path}; the contradictory record cannot bind that path`
658
+ });
659
+ }
660
+ }
661
+ }
662
+
663
+ if (baselineMetadataError || candidateInventoryUnknown) {
664
+ return result('unresolved', 'none',
665
+ candidateInventoryUnknown && !baselineMetadataError
666
+ ? 'the top-level candidate artifacts inventory is absent or malformed; baseline-bound evidence fails closed'
667
+ : 'baseline path metadata is missing, invalid, or contradictory; baseline-bound evidence fails closed',
668
+ []);
669
+ }
670
+
181
671
  const usedExec = matching.filter(item => executionList.includes(item)
182
672
  && completeExecution(item)
183
- && scopePaths.some(path => bound(item, path)));
673
+ && scopePaths.some(path => boundToScopePath(item, path)));
184
674
  const failingExec = usedExec.filter(item => item.exitCode !== 0);
185
675
  const boundReviews = matching.filter(item => reviewList.includes(item) && item.kind === 'review'
186
- && scopePaths.some(path => bound(item, path)));
676
+ && scopePaths.some(path => boundToScopePath(item, path)));
187
677
  const validReviews = boundReviews.filter(item => item.verdict === 'approve' && nonEmptyString(item.summary));
188
678
  const invalidReviews = boundReviews.filter(item => !validReviews.includes(item));
189
679
  const oddKindRecords = matching.filter(item => reviewList.includes(item)
@@ -204,13 +694,16 @@ function evaluateObligation(obligation, context) {
204
694
  });
205
695
  }
206
696
 
207
- const boundAny = (path) => matching.some(item => bound(item, path));
697
+ const boundAny = (path) => matching.some(item => boundToScopePath(item, path));
208
698
  const unmetPaths = scopePaths.filter(path => !boundAny(path));
209
699
  for (const path of unmetPaths) {
700
+ const entry = baselineEntries.get(path);
210
701
  diagnostics.push({
211
702
  code: 'missing-path-coverage',
212
703
  obligationId,
213
- message: `no observed record is bound to path ${path} with the current candidate and baseline digests`
704
+ message: entry
705
+ ? `no observed record is bound to baseline path ${path} (${entry.reason}${entry.renameTo ? ` -> ${entry.renameTo}` : ''}) with the trusted baseline digest`
706
+ : `no observed record is bound to path ${path} with the current candidate and baseline digests`
214
707
  });
215
708
  }
216
709
 
@@ -219,7 +712,7 @@ function evaluateObligation(obligation, context) {
219
712
  const target = recordIndex.get(item);
220
713
  return Boolean(target) && target.obligationId === obligationId
221
714
  && executionList.includes(target) && completeExecution(target) && target.exitCode === 0
222
- && scopePaths.some(path => bound(target, path));
715
+ && scopePaths.some(path => boundToScopePath(target, path));
223
716
  };
224
717
  const waiverCandidates = reviewList.filter(item => item.kind === 'waiver'
225
718
  && item.obligationId === obligationId);
@@ -321,14 +814,14 @@ function evaluateObligation(obligation, context) {
321
814
  []);
322
815
  }
323
816
 
324
- const waiverSelfBound = item => scopePaths.length > 0 && scopePaths.every(path => bound(item, path));
817
+ const waiverSelfBound = item => scopePaths.length > 0 && scopePaths.every(path => boundToScopePath(item, path));
325
818
  const citationsCoverScope = item => {
326
819
  const covered = new Set();
327
820
  for (const id of Array.isArray(item.citedRecordIds) ? item.citedRecordIds : []) {
328
821
  const target = recordIndex.get(id);
329
822
  if (target && target.obligationId === obligationId && target.exitCode === 0
330
823
  && completeExecution(target) && !failingExec.includes(target)) {
331
- for (const path of scopePaths) if (bound(target, path)) covered.add(path);
824
+ for (const path of scopePaths) if (boundToScopePath(target, path)) covered.add(path);
332
825
  }
333
826
  }
334
827
  return covered.size === scopePaths.length;
@@ -362,7 +855,7 @@ function evaluateObligation(obligation, context) {
362
855
 
363
856
  const passingExec = usedExec.filter(item => item.exitCode === 0);
364
857
  const execCoveredAll = scopePaths.length > 0
365
- && scopePaths.every(path => passingExec.some(item => bound(item, path)));
858
+ && scopePaths.every(path => passingExec.some(item => boundToScopePath(item, path)));
366
859
  // S3 (§11): identity data gates every per-REQ coverage claim. A waiver lane pass requires a
367
860
  // known non-empty cited identity list, known update data, and coverage of every uncovered
368
861
  // missing REQ by a listing valid waiver. Absent or empty cited data, or an absent
@@ -439,7 +932,7 @@ function evaluateObligation(obligation, context) {
439
932
  const reviewsRequired = capability === 'quality-review';
440
933
  if (reviewsRequired) {
441
934
  const reviewCovers = (path) => matching.some(item => reviewList.includes(item) && item.kind === 'review'
442
- && item.verdict === 'approve' && nonEmptyString(item.summary) && bound(item, path));
935
+ && item.verdict === 'approve' && nonEmptyString(item.summary) && boundToScopePath(item, path));
443
936
  if (!invalidReviews.length && !oddKindRecords.length
444
937
  && validReviews.length && scopePaths.every(reviewCovers)) {
445
938
  const contributors = [...validReviews, ...usedExec.filter(item => !failingExec.includes(item))];
@@ -454,7 +947,7 @@ function evaluateObligation(obligation, context) {
454
947
 
455
948
  const execCovers = (path) => matching.some(item => executionList.includes(item)
456
949
  && completeExecution(item)
457
- && !failingExec.includes(item) && bound(item, path));
950
+ && !failingExec.includes(item) && boundToScopePath(item, path));
458
951
  const execCovered = scopePaths.every(execCovers);
459
952
  if (usedExec.length && execCovered) {
460
953
  const contributors = [...usedExec.filter(item => !failingExec.includes(item)), ...validWaivers];