@dogfood-lab/findings 1.9.0 → 1.10.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/advise/query.js CHANGED
@@ -113,6 +113,26 @@ export function queryRecommendations(rootDir, scope = {}) {
113
113
 
114
114
  /**
115
115
  * Query accepted doctrine by scope.
116
+ *
117
+ * F-d022c023: the surface-scoping filter below used to end in a bare `true`
118
+ * — since doctrine.transfer_scope is a closed 3-value enum
119
+ * (packages/schemas/src/json/dogfood-doctrine.schema.json: org_wide,
120
+ * execution_mode, surface_archetype) and the first two branches already
121
+ * matched two of the three values explicitly, the trailing `true` made the
122
+ * WHOLE expression unconditionally true for every doctrine record — a
123
+ * structural no-op covering 100% of the enum, not a rare edge case.
124
+ * Replacing that `true` with a third literal `transfer_scope ===
125
+ * 'surface_archetype'` comparison would be an equally-complete no-op (the
126
+ * three literal branches would still exhaust the closed enum and always be
127
+ * true) — so it does NOT satisfy this fix; it would just move the dead code
128
+ * one line over. doctrine itself carries NO per-record surface field to
129
+ * compare against (the schema's `additionalProperties: false` rules one out
130
+ * without a schema/version bump), so a REAL surface_archetype check has to
131
+ * reach through `based_on_pattern_ids` to the patterns' own
132
+ * `dimensions.product_surfaces` — the same signal `queryPatterns` above
133
+ * already uses, and the literal thing this function's own (correct) comment
134
+ * about org_wide/execution_mode always says: "surface_archetype applies if
135
+ * pattern surfaces match."
116
136
  */
117
137
  export function queryDoctrine(rootDir, scope = {}) {
118
138
  const all = loadDoctrines(rootDir);
@@ -121,13 +141,22 @@ export function queryDoctrine(rootDir, scope = {}) {
121
141
  let results = all.filter(d => d.status === 'accepted');
122
142
 
123
143
  if (scope.surface) {
124
- // Doctrine applies if its scope is broad enough or matches the surface
125
- // org_wide always applies; surface_archetype applies if pattern surfaces match
126
- results = results.filter(d =>
127
- d.transfer_scope === 'org_wide' ||
128
- d.transfer_scope === 'execution_mode' ||
129
- true // surface_archetype applies broadly patterns already scoped it
144
+ // org_wide and execution_mode are surface-agnostic BY DEFINITION (their
145
+ // whole point is applying beyond any single surface), so they always
146
+ // pass once ANY surface is requested — that part of the original intent
147
+ // was correct and is unchanged. surface_archetype is the one scope tied
148
+ // to a specific surface lineage: does the requested surface appear in
149
+ // the dimensions.product_surfaces of AT LEAST ONE pattern this doctrine
150
+ // is based_on_pattern_ids-derived from?
151
+ const patternSurfacesById = new Map(
152
+ loadPatterns(rootDir).map(p => [p.pattern_id, p.dimensions?.product_surfaces || []])
130
153
  );
154
+ results = results.filter(d => {
155
+ if (d.transfer_scope === 'org_wide' || d.transfer_scope === 'execution_mode') return true;
156
+ return (d.based_on_pattern_ids || []).some(
157
+ pid => patternSurfacesById.get(pid)?.includes(scope.surface)
158
+ );
159
+ });
131
160
  }
132
161
 
133
162
  results.sort((a, b) => {
package/derive/ids.js CHANGED
@@ -38,17 +38,35 @@ export function generateFindingId(repoSlug, lessonSlug) {
38
38
  * Compute a dedupe key for collision detection.
39
39
  * Two findings with the same dedupe key are considered the same lesson.
40
40
  *
41
+ * F-112c88a4: defense-in-depth sibling of generateFindingId's own
42
+ * boundary-collision fix (findings-A-002, above). A plain `'::'.join()` here
43
+ * would let a delimiter-shaped VALUE in one field forge a false boundary
44
+ * with its neighbor — e.g. (issue_kind='x::y', root_cause_kind='z') and
45
+ * (issue_kind='x', root_cause_kind='y::z') both flatten to the same joined
46
+ * string. Routed through the same NUL-delimited boundaryHash() helper
47
+ * generateFindingId uses, so no component value can forge a boundary
48
+ * regardless of what it contains.
49
+ *
50
+ * Today's five fields are each enum- or pattern-constrained upstream (see
51
+ * dogfood-finding.schema.json) such that none can actually contain '::', so
52
+ * this specific collision is not reachable through the intended pipeline —
53
+ * but that safety lives entirely in the CALLER's schema, not in this
54
+ * function's own construction. boundaryHash() makes computeDedupeKey safe on
55
+ * its own terms, so a future caller (or a future schema relaxation on any of
56
+ * these fields) cannot unknowingly reopen the collision generateFindingId
57
+ * was explicitly fixed to close.
58
+ *
41
59
  * @param {{ repo: string, issue_kind: string, root_cause_kind: string, journey_stage: string, slug: string }} fields
42
60
  * @returns {string}
43
61
  */
44
62
  export function computeDedupeKey(fields) {
45
- return [
63
+ return boundaryHash([
46
64
  fields.repo,
47
65
  fields.issue_kind,
48
66
  fields.root_cause_kind,
49
67
  fields.journey_stage,
50
68
  fields.slug
51
- ].join('::');
69
+ ]);
52
70
  }
53
71
 
54
72
  /**
@@ -33,7 +33,17 @@ export function loadRecordsForRepo(rootDir, repoKey) {
33
33
  * @returns {{ entries: Array<{ record: object, rejected: boolean, path: string }>, skipped: Array<{ path: string, error: string }> }}
34
34
  */
35
35
  export function loadRecordsForRepoWithSkips(rootDir, repoKey) {
36
- const [org, repo] = repoKey.split('/');
36
+ // F-60a31e29: two-segment contract (F-916867-005 family) — mirrors
37
+ // persist.js:51 and load-context.js:138/333, which both enforce
38
+ // `segments.length !== 2` ahead of the traversal guard below. This site
39
+ // was the odd one out: a bare 2-way destructure silently DROPPED any
40
+ // third segment, so `loadRecordsForRepoWithSkips(root, 'a/b/c')` silently
41
+ // loaded records for the DIFFERENT repo 'a/b' instead of failing closed.
42
+ const segments = repoKey.split('/');
43
+ if (segments.length !== 2) {
44
+ return { entries: [], skipped: [] };
45
+ }
46
+ const [org, repo] = segments;
37
47
  // Path-traversal guard: F-916867-005. Mirrors persist.js + load-context.js
38
48
  // via the central helper at @dogfood-lab/ingest/lib/unsafe-segment.js.
39
49
  // A malformed repoKey (`..` or path-separator) would otherwise resolve
package/derive/rules.js CHANGED
@@ -13,6 +13,21 @@
13
13
  * repoSlug — e.g. "repo-crawler-mcp"
14
14
  */
15
15
 
16
+ // F-e1d45d27 (wave 12, LOW): ruleSchemaRejection/rulePolicyRejection below used
17
+ // to hand-roll `/^schema:/` / `/^policy:/` against raw rejection_reasons
18
+ // strings — exactly the drift parse-rejection.js's own file header exists to
19
+ // end ("every operator discriminated failure class with hand-rolled
20
+ // .startsWith() chains ... a fresh drift source the moment a prefix is
21
+ // added"). `@dogfood-lab/verify` was already a TRANSITIVE dependency here (via
22
+ // @dogfood-lab/ingest) and depends on nothing beyond @dogfood-lab/schemas +
23
+ // js-yaml, so importing it directly adds no new workspace cycle. Routing
24
+ // through the authoritative classifier (`.prefix === 'schema:'` / `'policy:'`)
25
+ // is behaviorally identical today — see derive.test.js's "Regression: rules.js
26
+ // schema:/policy: routes through parseRejectionReason (F-e1d45d27)" describe
27
+ // block for the differential proof — and stays correct automatically if the
28
+ // taxonomy ever grows or reorders.
29
+ import { parseRejectionReason } from '@dogfood-lab/verify';
30
+
16
31
  // ─── Helpers ────────────────────────────────────────────────
17
32
 
18
33
  function hasRejectionMatching(record, pattern) {
@@ -46,6 +61,33 @@ function scenarioVerdict(record) {
46
61
  return record.scenario_results?.[0]?.verdict || null;
47
62
  }
48
63
 
64
+ // F-88fb37ff: a scenario's step_results is the ONLY corroborating evidence
65
+ // for a self-reported "blocked" (or "fail") verdict — a verifier fix closed
66
+ // this same gap in @dogfood-lab/verify's validateStepResults, but the derive
67
+ // pipeline reads records from BOTH records/ and records/_rejected/ (see
68
+ // load-records.js) and never consults verification.status, so a
69
+ // self-contradictory record still reaches every rule here with
70
+ // scenario_results untouched. ruleScenarioStepFailure already gates on
71
+ // failedSteps(record).length > 0 for the "fail" verdict; this mirrors that
72
+ // same evidentiary floor for a single scenario object (rule-blocked-scenario
73
+ // iterates scenarios directly rather than through the record-level,
74
+ // index-0-only helpers above).
75
+ //
76
+ // F-e42e8f80 (wave 20, amends F-88fb37ff): renamed from hasFailOrBlockedStep
77
+ // and its condition widened — the original "at least one step actively
78
+ // fail/blocked" bar had the identical blind spot the verifier-level fix
79
+ // (validateStepResults/validateRequiredSteps) closed: a genuinely blocked
80
+ // scenario whose steps honestly report 'skip' (never ran) has ZERO
81
+ // fail/blocked steps, so the old bar silently dropped the
82
+ // 'verification_gap'/'missing_precondition' finding for exactly the class of
83
+ // record most likely to deserve it. Only an ACTIVE 'pass' is a contradiction
84
+ // of a blocked verdict; 'skip'/'partial' are neutral, like a step that never
85
+ // ran. Mirrors the verifier-level fix's "not all pass" bar exactly.
86
+ function hasNonPassStepEvidence(scenario) {
87
+ const results = scenario.step_results || [];
88
+ return results.some(s => s != null && s.status !== 'pass');
89
+ }
90
+
49
91
  // ─── Rule 1: Surface/interface misclassification ────────────
50
92
 
51
93
  const ruleSurfaceMisclassification = {
@@ -228,7 +270,7 @@ const ruleScenarioStepFailure = {
228
270
  transfer_scope: 'surface_local',
229
271
  journey_stage: 'first_run',
230
272
  product_surface: surface,
231
- slug: `${repoSlug}-step-failure-${stepIds[0]}`,
273
+ slug: `${repoSlug}-step-failure-${sid}-${stepIds[0]}`,
232
274
  title: `Scenario step(s) failed: ${stepIds.join(', ')} — ${surface} entrypoint or build output may be wrong`,
233
275
  summary: `The scenario failed at step(s): ${stepIds.join(', ')}. This typically indicates the entrypoint, build output path, or invocation contract differs from what the scenario expected. The scenario or build configuration needs correction.`,
234
276
  rationale: `scenario_results[0].verdict is "fail" with ${failed.length} failed step(s): ${stepIds.join(', ')}.`,
@@ -247,12 +289,16 @@ const ruleBlockedScenario = {
247
289
  description: 'Detects scenarios that were blocked entirely, typically indicating a missing precondition or infrastructure gap.',
248
290
 
249
291
  applies(ctx) {
250
- return ctx.record.scenario_results?.some(s => s.verdict === 'blocked');
292
+ // F-88fb37ff: require step evidence backing the self-reported verdict
293
+ // otherwise a self-contradictory record (blocked verdict, every step
294
+ // reports pass) synthesizes a plausible-sounding but false
295
+ // "infrastructure gap" finding the record's own evidence contradicts.
296
+ return ctx.record.scenario_results?.some(s => s.verdict === 'blocked' && hasNonPassStepEvidence(s));
251
297
  },
252
298
 
253
299
  derive(ctx) {
254
300
  const { record, repoSlug } = ctx;
255
- const blocked = record.scenario_results.filter(s => s.verdict === 'blocked');
301
+ const blocked = record.scenario_results.filter(s => s.verdict === 'blocked' && hasNonPassStepEvidence(s));
256
302
 
257
303
  return blocked.map(scenario => {
258
304
  const reason = scenario.blocking_reason || 'No blocking reason provided';
@@ -330,13 +376,13 @@ const ruleSchemaRejection = {
330
376
  // Only fire if not already covered by surface misclassification
331
377
  const reasons = ctx.record.verification?.rejection_reasons || [];
332
378
  const hasSurfaceIssue = reasons.some(r => /product_surface/.test(r));
333
- const hasSchemaIssue = reasons.some(r => /^schema:/.test(r));
379
+ const hasSchemaIssue = reasons.some(r => parseRejectionReason(r).prefix === 'schema:');
334
380
  return hasSchemaIssue && !hasSurfaceIssue;
335
381
  },
336
382
 
337
383
  derive(ctx) {
338
384
  const { record, repoSlug } = ctx;
339
- const reasons = record.verification?.rejection_reasons?.filter(r => /^schema:/.test(r)) || [];
385
+ const reasons = record.verification?.rejection_reasons?.filter(r => parseRejectionReason(r).prefix === 'schema:') || [];
340
386
  const surface = safeRecordSurface(record, repoSlug);
341
387
 
342
388
  return [{
@@ -369,13 +415,13 @@ const rulePolicyRejection = {
369
415
  // Only fire if not already covered by evidence policy mismatch
370
416
  const reasons = ctx.record.verification?.rejection_reasons || [];
371
417
  const hasEvidenceIssue = reasons.some(r => /evidence/.test(r));
372
- const hasPolicyIssue = reasons.some(r => /^policy:/.test(r));
418
+ const hasPolicyIssue = reasons.some(r => parseRejectionReason(r).prefix === 'policy:');
373
419
  return hasPolicyIssue && !hasEvidenceIssue;
374
420
  },
375
421
 
376
422
  derive(ctx) {
377
423
  const { record, repoSlug } = ctx;
378
- const reasons = record.verification?.rejection_reasons?.filter(r => /^policy:/.test(r)) || [];
424
+ const reasons = record.verification?.rejection_reasons?.filter(r => parseRejectionReason(r).prefix === 'policy:') || [];
379
425
  const surface = safeRecordSurface(record, repoSlug);
380
426
 
381
427
  return [{
@@ -397,6 +443,66 @@ const rulePolicyRejection = {
397
443
  }
398
444
  };
399
445
 
446
+ // ─── Rule 9: Partial scenario with contradicting evidence ───
447
+
448
+ const rulePartialScenarioEvidence = {
449
+ ruleId: 'rule-partial-scenario-evidence',
450
+ description: 'Detects scenarios with a self-reported "partial" verdict backed by non-pass step evidence — the derivation-layer half of the F-cc198701 fix, so a partial verdict is no longer invisible to every rule in this file.',
451
+
452
+ applies(ctx) {
453
+ // F-cc198701 (wave 22, confirming audit of F-88fb37ff/F-e42e8f80): this
454
+ // rule's two closest siblings each enumerate exactly ONE of the four
455
+ // legal scenario_results[].verdict enum values (dogfood-record-
456
+ // submission.schema.json: ["pass","fail","blocked","partial"]) —
457
+ // ruleBlockedScenario checks only 'blocked' (line ~296 above) and
458
+ // ruleScenarioStepFailure checks only 'fail' (line ~240 above). Neither
459
+ // ever checks 'partial', so a 'partial'-verdict scenario_result was
460
+ // invisible to EVERY rule in this file regardless of its step evidence:
461
+ // a self-contradictory record (verdict:'partial' with every step actively
462
+ // 'fail' — strictly worse evidence than what the other two rules already
463
+ // catch) generated zero corrective/diagnostic finding anywhere in the
464
+ // intelligence layer, on top of being a false-rejection gap at the
465
+ // verifier layer (see steps.js's matching F-cc198701 fix, same wave).
466
+ //
467
+ // A dedicated sibling rule — rather than widening ruleBlockedScenario's
468
+ // own condition, which the finding's fix note offered as an alternative —
469
+ // keeps that rule's "was blocked: <reason>" title/summary text honest:
470
+ // blocking_reason is schema-documented as "Required when verdict is
471
+ // blocked," so reusing 'blocked' wording for a genuinely 'partial'
472
+ // scenario would misdescribe it to a human reviewer.
473
+ return ctx.record.scenario_results?.some(s => s.verdict === 'partial' && hasNonPassStepEvidence(s));
474
+ },
475
+
476
+ derive(ctx) {
477
+ const { record, repoSlug } = ctx;
478
+ const partial = record.scenario_results.filter(s => s.verdict === 'partial' && hasNonPassStepEvidence(s));
479
+
480
+ return partial.map(scenario => {
481
+ const surface = mapToValidSurface(scenario.product_surface || 'cli', repoSlug);
482
+ const nonPassIds = (scenario.step_results || [])
483
+ .filter(s => s != null && s.status !== 'pass')
484
+ .map(s => s.step_id);
485
+
486
+ return {
487
+ issue_kind: 'verification_gap',
488
+ root_cause_kind: 'contract_drift',
489
+ remediation_kind: 'scenario_change',
490
+ transfer_scope: 'surface_local',
491
+ journey_stage: 'verification',
492
+ product_surface: surface,
493
+ slug: `${repoSlug}-partial-${scenario.scenario_id}`,
494
+ title: `Scenario "${scenario.scenario_id}" reported "partial" backed by non-pass step evidence`,
495
+ summary: `The scenario "${scenario.scenario_id}" self-reported a "partial" verdict, corroborated by non-pass step(s): ${nonPassIds.join(', ')}. A partial verdict backed by non-pass evidence may indicate an incomplete run, a scenario whose success criteria need review, or a genuinely partial pass worth surfacing to the portfolio layer.`,
496
+ rationale: `scenario_results contains a scenario with verdict "partial" and non-pass step evidence: [${nonPassIds.join(', ')}].`,
497
+ evidence: [
498
+ { evidence_kind: 'scenario_result', record_id: record.run_id, scenario_id: scenario.scenario_id, note: `Partial verdict corroborated by non-pass steps: ${nonPassIds.join(', ')}` },
499
+ { evidence_kind: 'record', record_id: record.run_id, note: 'Scenario verdict is partial with corroborating non-pass step evidence.' }
500
+ ]
501
+ };
502
+ });
503
+ }
504
+ };
505
+
400
506
  // ─── Export all rules ───────────────────────────────────────
401
507
 
402
508
  export const RULES = [
@@ -407,7 +513,8 @@ export const RULES = [
407
513
  ruleBlockedScenario,
408
514
  ruleExecutionModeGap,
409
515
  ruleSchemaRejection,
410
- rulePolicyRejection
516
+ rulePolicyRejection,
517
+ rulePartialScenarioEvidence
411
518
  ];
412
519
 
413
520
  export function getRuleById(ruleId) {
package/lib/file-lock.js CHANGED
@@ -134,10 +134,11 @@ function atomicCreateLock(lockPath) {
134
134
  }
135
135
 
136
136
  /**
137
- * Attempt to acquire an exclusive file-lock on `lockPath`. Returns true if
138
- * the lock was acquired; false if it is held by another live process OR
139
- * by a process whose lock file is unreadable / pid-empty (treated as live
140
- * with bounded mtime patience — see below).
137
+ * Attempt to acquire an exclusive file-lock on `lockPath`. Returns
138
+ * `{ acquired: true }` if the lock was acquired; `{ acquired: false, reason }`
139
+ * if it is held by another live process OR by a process whose lock file is
140
+ * unreadable / pid-empty (treated as live with bounded mtime patience — see
141
+ * below).
141
142
  *
142
143
  * Stale recovery: a holder's PID file remains until its release `unlink`
143
144
  * runs. If the holder PROCESS is gone (process.kill ESRCH), the lock is
@@ -149,14 +150,23 @@ function atomicCreateLock(lockPath) {
149
150
  * safer guard: we only reclaim if the lock file is ALSO older than
150
151
  * `staleAfterMs`, the same boundary `proper-lockfile` uses.
151
152
  *
153
+ * F-3a7c4d67: `reason` is threaded all the way out to `withFileLock`'s
154
+ * ELOCKTIMEOUT error message (the `lastErrCtx` variable there was declared
155
+ * and read but never assigned anywhere — confirmed by grep before this fix:
156
+ * exactly two occurrences of the identifier in the whole file, declaration +
157
+ * the dead read). Every real lock-timeout previously rendered the bare
158
+ * "timed out after Nms waiting for X" with the promised "(last error: ...)"
159
+ * clause permanently absent.
160
+ *
152
161
  * @param {string} lockPath
153
162
  * @param {{ staleAfterMs?: number }} opts
154
- * @returns {boolean}
163
+ * @returns {{ acquired: boolean, reason?: string }} `reason` is present only
164
+ * when `acquired` is false.
155
165
  */
156
166
  function tryAcquire(lockPath, opts = {}) {
157
167
  const { staleAfterMs = DEFAULT_STALE_AFTER_MS } = opts;
158
168
 
159
- if (atomicCreateLock(lockPath)) return true;
169
+ if (atomicCreateLock(lockPath)) return { acquired: true };
160
170
 
161
171
  // Lock exists — test for staleness.
162
172
  let pidRaw = null;
@@ -175,7 +185,9 @@ function tryAcquire(lockPath, opts = {}) {
175
185
  // PID-known case: trust process.kill to decide alive-vs-dead. This is the
176
186
  // common case and gives fast crash recovery (sub-100ms typical).
177
187
  if (pidRaw && pidRaw !== '') {
178
- if (isProcessAlive(pidRaw)) return false;
188
+ if (isProcessAlive(pidRaw)) {
189
+ return { acquired: false, reason: `held by live process pid=${pidRaw}` };
190
+ }
179
191
 
180
192
  // PID-dead reclaim via "rename to graveyard" — atomic claim that exactly
181
193
  // one reclaimer wins. Without this, a sequence like:
@@ -208,7 +220,10 @@ function tryAcquire(lockPath, opts = {}) {
208
220
  }
209
221
 
210
222
  // Treat as live; the holder owns the lock and will release it.
211
- return false;
223
+ return {
224
+ acquired: false,
225
+ reason: `lock file present with no readable pid; age below staleAfterMs=${staleAfterMs}ms`,
226
+ };
212
227
  }
213
228
 
214
229
  /**
@@ -232,7 +247,10 @@ function release(lockPath) {
232
247
  *
233
248
  * @param {string} lockPath
234
249
  * @param {string} expectedDeadPid - The PID we observed and confirmed dead.
235
- * @returns {boolean}
250
+ * @returns {{ acquired: boolean, reason?: string }} F-3a7c4d67: mirrors
251
+ * tryAcquire's return shape so the caller can thread a real cause into
252
+ * withFileLock's ELOCKTIMEOUT message instead of a permanently-null
253
+ * lastErrCtx.
236
254
  */
237
255
  function reclaimViaGraveyard(lockPath, expectedDeadPid) {
238
256
  const graveyardPath = `${lockPath}.gy.${process.pid}.${randomBytes(4).toString('hex')}`;
@@ -240,7 +258,7 @@ function reclaimViaGraveyard(lockPath, expectedDeadPid) {
240
258
  renameSync(lockPath, graveyardPath);
241
259
  } catch (err) {
242
260
  // Another reclaimer beat us — let the retry loop sort it out.
243
- return false;
261
+ return { acquired: false, reason: `stale-reclaim raced: another process already renamed ${lockPath} first` };
244
262
  }
245
263
 
246
264
  // We won the rename. Verify content.
@@ -262,7 +280,7 @@ function reclaimViaGraveyard(lockPath, expectedDeadPid) {
262
280
  } catch {
263
281
  try { unlinkSync(graveyardPath); } catch { /* drop */ }
264
282
  }
265
- return false;
283
+ return { acquired: false, reason: `stale-reclaim raced: lock was freshly re-acquired by pid=${actualPid} during reclaim` };
266
284
  }
267
285
 
268
286
  if (process.env.FILE_LOCK_DEBUG) {
@@ -270,7 +288,9 @@ function reclaimViaGraveyard(lockPath, expectedDeadPid) {
270
288
  }
271
289
 
272
290
  try { unlinkSync(graveyardPath); } catch { /* drop */ }
273
- return atomicCreateLock(lockPath);
291
+ return atomicCreateLock(lockPath)
292
+ ? { acquired: true }
293
+ : { acquired: false, reason: 'stale-reclaim raced: a fresh lock was created immediately after graveyard cleanup' };
274
294
  }
275
295
 
276
296
  /**
@@ -281,9 +301,23 @@ function reclaimViaGraveyard(lockPath, expectedDeadPid) {
281
301
  * cascade through six callers and break the back-compat contract on
282
302
  * `appendEvent` that review-engine relies on.
283
303
  *
304
+ * F-3a7c4d67: guards its documented sibling `packages/ingest/lib/sleep-
305
+ * sync.js` already carries ("we don't trust caller arithmetic to never
306
+ * produce negative deltas if a system clock skews") were missing HERE.
307
+ * Independently re-confirmed empirically (isolated Node one-liner, no
308
+ * repo writes): `Atomics.wait(view, 0, 0, NaN)` never returns — it hangs
309
+ * the calling thread indefinitely, with no bound, no error, no log line,
310
+ * the worst possible outcome for a bounded-retry primitive. A negative
311
+ * value, by contrast, correctly clamps to an instant 'timed-out'. Reachable
312
+ * via `withFileLock(path, fn, { retryIntervalMs: NaN })` — e.g. a config
313
+ * value computed as `x / y` with `y === 0`, or `Number(process.env.X)` on
314
+ * an unset var — because the destructuring default on `retryIntervalMs`
315
+ * below only applies when the option is `undefined`, never when it is NaN.
316
+ *
284
317
  * @param {number} ms
285
318
  */
286
319
  function sleepSync(ms) {
320
+ if (!Number.isFinite(ms) || ms <= 0) return;
287
321
  const sab = new SharedArrayBuffer(4);
288
322
  const view = new Int32Array(sab);
289
323
  Atomics.wait(view, 0, 0, ms);
@@ -326,10 +360,15 @@ export function withFileLock(targetPath, fn, options = {}) {
326
360
  let lastErrCtx = null;
327
361
 
328
362
  while (Date.now() < deadline) {
329
- if (tryAcquire(lockPath, { staleAfterMs })) {
363
+ // F-3a7c4d67: tryAcquire now returns { acquired, reason } instead of a
364
+ // bare boolean specifically so lastErrCtx below stops being permanently
365
+ // null — see tryAcquire's own doc block for the full defect history.
366
+ const attempt = tryAcquire(lockPath, { staleAfterMs });
367
+ if (attempt.acquired) {
330
368
  acquired = true;
331
369
  break;
332
370
  }
371
+ if (attempt.reason) lastErrCtx = attempt.reason;
333
372
  sleepSync(retryIntervalMs);
334
373
  }
335
374
 
@@ -35,21 +35,46 @@ import { renameSync } from 'node:fs';
35
35
  /**
36
36
  * Sleep synchronously for `ms` milliseconds via `Atomics.wait` on a tiny
37
37
  * SharedArrayBuffer. Yields the thread (does not spin). Mirrors the
38
- * `sleepSync` in `file-lock.js:286` — the contract is identical; the
38
+ * `sleepSync` in `file-lock.js` — the contract is identical; the
39
39
  * helper is duplicated here because rename-with-retry must not import the
40
40
  * larger file-lock module (cyclic-import risk: atomic-write imports
41
41
  * rename-with-retry, and file-lock imports atomic-write transitively
42
42
  * through the lock-event write path).
43
43
  *
44
+ * F-3a7c4d67 (sibling sweep): this file's own `ms <= 0` guard does NOT
45
+ * catch `ms = NaN` — every comparison with NaN is `false` in JS, so
46
+ * `NaN <= 0` is `false` and execution falls through to
47
+ * `Atomics.wait(view, 0, 0, NaN)`, independently confirmed (isolated
48
+ * one-liner, no repo writes) to hang the calling thread indefinitely, no
49
+ * bound, no error, no log line — the same defect class F-3a7c4d67 named in
50
+ * file-lock.js's sibling `sleepSync`, found here via that finding's own
51
+ * "sweep for every sibling" discipline, not named in the finding's own
52
+ * text. Reachable via `renameWithRetry(tmp, dest, { baseMs: NaN })` or
53
+ * `{ maxMs: NaN }` — `Math.min(NaN * 2**i, maxMs)` and
54
+ * `Math.min(baseMs * 2**i, NaN)` both evaluate to NaN, and the only current
55
+ * production caller (`atomic-write.js`) calls with zero options today, so
56
+ * this is latent-but-real against the function's own public option surface,
57
+ * not a live bug. Widened to the same `Number.isFinite` check
58
+ * `packages/ingest/lib/sleep-sync.js` already carries.
59
+ *
44
60
  * @param {number} ms
45
61
  */
46
62
  function sleepSync(ms) {
47
- if (ms <= 0) return;
63
+ if (!Number.isFinite(ms) || ms <= 0) return;
48
64
  const sab = new SharedArrayBuffer(4);
49
65
  const view = new Int32Array(sab);
50
66
  Atomics.wait(view, 0, 0, ms);
51
67
  }
52
68
 
69
+ /**
70
+ * Test-only: exercise the private `sleepSync` guard directly, without
71
+ * forcing a real EPERM/EBUSY filesystem race through `renameWithRetry`.
72
+ * Mirrors `file-lock.js`'s own `isLocked` test-only export precedent.
73
+ */
74
+ export function __testSleepSync(ms) {
75
+ return sleepSync(ms);
76
+ }
77
+
53
78
  export function renameWithRetry(tmp, dest, { retries = 10, baseMs = 15, maxMs = 200 } = {}) {
54
79
  for (let i = 0; i <= retries; i++) {
55
80
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogfood-lab/findings",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "type": "module",
5
5
  "description": "Finding contract spine for testing-os. Validates, reads, lists, and queries evidence-bound findings — the fourth contract alongside record, scenario, and policy.",
6
6
  "main": "index.js",
@@ -41,6 +41,7 @@
41
41
  "dependencies": {
42
42
  "@dogfood-lab/ingest": "^1.2.0",
43
43
  "@dogfood-lab/schemas": "^1.2.0",
44
+ "@dogfood-lab/verify": "^1.2.0",
44
45
  "js-yaml": "^4.1.0"
45
46
  },
46
47
  "engines": {
@@ -21,11 +21,14 @@ import { atomicWriteFileSync } from '../lib/atomic-write.js';
21
21
  * contract: free-text prose and the four classification enums. Identity,
22
22
  * lineage, and evidence fields (finding_id, schema_version, source_record_ids,
23
23
  * evidence, status, …) are excluded — they are set by the pipeline, not edited
24
- * in place. The downstream write gate (dogfood-finding.schema.json's
25
- * additionalProperties:false + enum checks) is the enforcement boundary; this
26
- * list is what the CLI help renders so the documented set and the gate's
27
- * intent share one source. Editing an unlisted field is still refused post-hoc
28
- * by the schema gate this constant narrows the help, not the enforcement.
24
+ * in place. This constant IS the enforcement boundary: performAction's edit
25
+ * loop refuses any `--set` field not listed here (fvr-002). The write-side
26
+ * schema gate (dogfood-finding.schema.json's additionalProperties:false + enum
27
+ * checks) is the SECOND line it validates the VALUES of allowed fields — but
28
+ * it cannot be the identity gate, because a valid finding_id swapped for
29
+ * another valid finding_id is schema-valid yet breaks the id↔path invariant.
30
+ * The same list drives the CLI help so the documented set and the enforced set
31
+ * share one source.
29
32
  */
30
33
  export const EDITABLE_FIELDS = Object.freeze([
31
34
  'title',
@@ -111,6 +114,20 @@ export function performAction(rootDir, params) {
111
114
  if (field === '__proto__' || field === 'constructor' || field === 'prototype') {
112
115
  return { success: false, error: `Field "${field}" is not editable (reserved/unsafe key)` };
113
116
  }
117
+ // fvr-002 — allowlist enforcement, not just help text. `finding_id` (and
118
+ // every other identity/lineage/provenance field) is a VALID schema field,
119
+ // so mutating it to another well-formed value sails through the write-side
120
+ // schema gate while re-homing the finding's identity off its file path
121
+ // (findById matches the id INSIDE the file; the file still lands at the
122
+ // OLD path). The schema gate can only catch unknown/enum-invalid values,
123
+ // never a valid identity swapped for another valid one — so the allowlist
124
+ // is the enforcement boundary here, refusing anything `edit` is not for.
125
+ if (!EDITABLE_FIELDS.includes(field)) {
126
+ return {
127
+ success: false,
128
+ error: `Field "${field}" is not editable. Editable fields: ${EDITABLE_FIELDS.join(', ')}`
129
+ };
130
+ }
114
131
  const oldValue = finding[field];
115
132
  if (oldValue !== newValue) {
116
133
  fieldChanges[field] = { from: oldValue, to: newValue };
@@ -13,12 +13,121 @@
13
13
  * any status -> candidate (except initial creation)
14
14
  */
15
15
 
16
- const TRANSITIONS = {
16
+ /**
17
+ * F-271b4661 (wave 39, feature-pass integration finding) — cross-reference:
18
+ * this package's reopen/close guard vs. the swarm control-plane's C1/C2 verbs.
19
+ *
20
+ * docs/trajectory-and-closure.dispatch.md's Feature 2 (Closure Verbs) designs
21
+ * a FRESH guarded-reopen-with-mandatory-reason mechanism for the swarm's own
22
+ * per-run findings table: `swarm reopen` (C1) moves fixed|deferred|rejected
23
+ * -> recurring, requiring a non-empty reason AND evidence; `swarm close` (C2)
24
+ * closes a finding as fixed|rejected|deferred with a required `verified_how`.
25
+ * That is the SAME shape of guard this file already enforces, one package
26
+ * over, for a DIFFERENT 'finding':
27
+ *
28
+ * - REQUIRES_CLOSED (below) ~ C1's precondition that reopen is only
29
+ * legal from a closed status (accepted/rejected here; fixed/deferred/
30
+ * rejected on the control-plane side).
31
+ * - REASON_REQUIRED (below) ~ C2's mandatory `--reason` on `swarm
32
+ * close`. NOT C1: `swarm close`'s closing dispositions are the local
33
+ * analogue of this file's reject/invalidate/merge/supersede —
34
+ * REASON_REQUIRED's actual members — but `swarm reopen` (C1) mandates
35
+ * `--reason` AND `--evidence` while this file's own `reopen` action
36
+ * carries NEITHER. That asymmetry is real, not a documentation gap; see
37
+ * "NOT SHARED" below rather than assuming symmetry from this line alone.
38
+ * - ACTION_TARGET_STATUS.reopen ~ C1's `recurring` target status — both
39
+ * land a reopened item back in an amendable, non-terminal state
40
+ * (`reviewed` here, `recurring` there), never in the original pre-review
41
+ * state.
42
+ * - lineage.superseded_by (dogfood-finding.schema.json) ~ T5's 'versions
43
+ * supersede, nothing rewrites' — the same non-destructive-history
44
+ * principle, expressed as a schema field here and as roadmap sequence
45
+ * numbers + immutable finding_events rows on the control-plane side.
46
+ *
47
+ * SHARED DISCIPLINE (the same principle, independently earned in each place):
48
+ * - a closed/terminal state is a PRECONDITION for reopening, never a target
49
+ * (REQUIRES_CLOSED here; C1's own source-status check on the
50
+ * control-plane side);
51
+ * - history is additive — nothing here mutates a prior review-event-log
52
+ * entry, nothing on the control-plane side edits a prior finding_events
53
+ * row or a prior roadmap sequence; every reopen/close is itself a new,
54
+ * evidence-bearing record layered on top of what came before.
55
+ *
56
+ * NOT SHARED, DISCLOSED (F-a50d8106 — wave-41 rider, canonicalized from the
57
+ * collect-swallowed local label B40-002 at the wave-43 merge; this cross-reference
58
+ * comment previously listed the next line as shared discipline, which
59
+ * overclaimed): the control-plane's C1 `swarm reopen` requires a non-empty
60
+ * `--reason` (and `--evidence`) as a hard CLI-layer precondition — cli.js's
61
+ * cmdReopen refuses before any DB write (packages/dogfood-swarm/cli.js).
62
+ * THIS file's own `reopen` action carries no such requirement:
63
+ * REASON_REQUIRED (above) is {reject, invalidate, merge, supersede} —
64
+ * 'reopen' is deliberately absent, so performAction (review-engine.js) and
65
+ * reviewArtifact (review-artifacts.js, which reuses the same REASON_REQUIRED
66
+ * set) both accept a reopen with no reason at all. review.test.js's own
67
+ * 'Review actions: reopen' suite is live evidence of the gap: its
68
+ * 'requires reason' case calls performAction with action:'reopen' and NO
69
+ * reason field, and asserts success — the test's name promised the opposite
70
+ * of what its body proves, which is exactly the kind of drift this note
71
+ * exists to stop happening again at the comment level. Silently-reopenable
72
+ * was never a deliberate design choice on this side, and this note does not
73
+ * argue it should stay that way — only that a cross-reference asserting
74
+ * false symmetry is worse than one naming the gap outright, per this repo's
75
+ * own honesty-is-a-feature ethos (swarms/CLAUDE.md). Adding 'reopen' to
76
+ * REASON_REQUIRED is a behavior change out of scope for a comment-only fix
77
+ * (F-271b4661's own precedent) and belongs to a follow-up finding.
78
+ *
79
+ * DELIBERATELY DISTINCT (do not converge these — they answer different
80
+ * questions for two different concepts that both happen to be named
81
+ * 'finding'):
82
+ * - Granularity. This file's 'finding' is a distilled, reviewed,
83
+ * cross-repo lesson (dogfood-finding.schema.json) — the synthesis
84
+ * layer's output. The control-plane's 'finding' is a single dogfood-swarm
85
+ * wave's raw defect row, scoped to one run. Merging the two vocabularies
86
+ * would blur a reusable lesson with a per-run defect ticket.
87
+ * - Status vocabulary. candidate/reviewed/accepted/rejected (TRANSITIONS,
88
+ * below) vs. open/deferred/approved/fixed/rejected/recurring
89
+ * (packages/dogfood-swarm/lib/finding-status.js). No 1:1 mapping is
90
+ * attempted — reopen here targets 'reviewed' (an in-review state), C1
91
+ * targets 'recurring' (an explicitly-reopened state distinct from a
92
+ * fresh 'open' finding), because the two systems track different things
93
+ * at different points in their lifecycle.
94
+ * - Evidence shape. This file's REASON_REQUIRED actions attach a free-text
95
+ * `decision_reason` (event-log.js) plus, for reject, a structured
96
+ * `review.reject_reason` enum. C2 additionally requires `verified_how`
97
+ * (independent|self_attested|operator_evidence) — a THIRD 'how do we
98
+ * know' axis alongside this schema's own evidence[].evidence_kind (see
99
+ * that property's own cross-reference note, F-936c83f4) and
100
+ * packages/verify's --provenance=stub|github (see
101
+ * validators/provenance.js's file header). Not the same question:
102
+ * verified_how asks how a CLOSURE was verified; evidence_kind asks what
103
+ * backs a distilled lesson; --provenance asks whether a submitted CI run
104
+ * really happened.
105
+ *
106
+ * NO BEHAVIOR CHANGE. This is a comment-only fix (F-271b4661's recommendation
107
+ * option (a)) — it exists so a reader of either mechanism can find the other
108
+ * without re-deriving the comparison, and so the two vocabularies stay
109
+ * deliberately-distinct BY DECISION rather than accidentally-distinct because
110
+ * nobody had looked. C1/C2 are implemented in the swarm control plane
111
+ * (packages/dogfood-swarm — core/verbs domain, out of this package's scope);
112
+ * this file's own TRANSITIONS/REQUIRES_CLOSED/REASON_REQUIRED machinery is
113
+ * unchanged by this note.
114
+ */
115
+
116
+ // F-937733ee: family sibling of F-2965699b/F-7ce07baa/verdict.js's
117
+ // VERDICT_RANK. Object.create(null) removes the prototype chain, so
118
+ // `TRANSITIONS['constructor']` is `undefined` rather than
119
+ // `Object.prototype.constructor` (a truthy function) — the `if (!allowed)`
120
+ // / `if (!TRANSITIONS[from])` unknown-status guards below fire correctly for
121
+ // every Object.prototype key instead of going on to call `.has()` on a
122
+ // built-in method and throwing a raw TypeError. `from` is currently always a
123
+ // schema-enum-constrained finding status, so this is defense-in-depth for a
124
+ // caller that skips that gate, not a live vector.
125
+ const TRANSITIONS = Object.assign(Object.create(null), {
17
126
  candidate: new Set(['reviewed', 'accepted', 'rejected']),
18
127
  reviewed: new Set(['accepted', 'rejected']),
19
128
  accepted: new Set(['reviewed', 'rejected']),
20
129
  rejected: new Set(['reviewed'])
21
- };
130
+ });
22
131
 
23
132
  /**
24
133
  * Check if a status transition is lawful.
@@ -95,8 +95,24 @@ export function applyRecommendation(rootDir, params) {
95
95
  // org/repo carrying `..` or a separator would escape the policies tree on
96
96
  // BOTH the dry-run (path leaked in preview) and write (file touched) paths,
97
97
  // so reject here before either branch resolves a path.
98
+ //
99
+ // F-853dbce9: the two-segment repo contract (F-54e5fde7 / V2-CROSS-BO-003)
100
+ // was NOT enforced here — the bare 2-way destructure silently DROPS every
101
+ // segment past the second, so `--policy group/subgroup/project` yielded
102
+ // pOrg='group', pRepo='subgroup' and resolved a DIFFERENT repo's policy
103
+ // file than the operator named, with no error on either the dry-run
104
+ // preview or the --write path. Matches persist.js:51's `segments.length
105
+ // !== 2` guard verbatim so the family reads identically.
98
106
  if (params.policyRepo) {
99
- const [pOrg, pRepo] = String(params.policyRepo).split('/');
107
+ const segments = String(params.policyRepo).split('/');
108
+ if (segments.length !== 2) {
109
+ return structuredError(
110
+ 'RECOMMENDATION_UNSAFE_POLICY',
111
+ `policy repo "${params.policyRepo}" is not a two-segment org/repo slug`,
112
+ 'Nested GitLab subgroups are unsupported — pass --policy <org>/<repo>.'
113
+ );
114
+ }
115
+ const [pOrg, pRepo] = segments;
100
116
  if (!pOrg || !pRepo || isUnsafeSegment(pOrg) || isUnsafeSegment(pRepo)) {
101
117
  return structuredError(
102
118
  'RECOMMENDATION_UNSAFE_POLICY',
@@ -196,7 +212,18 @@ export function applyRecommendation(rootDir, params) {
196
212
  }
197
213
 
198
214
  // Apply the structured intent: add target to surfaces.<surface>.required_scenarios.
199
- if (!policy.surfaces) policy.surfaces = {};
215
+ //
216
+ // F-5dfddcb5: policy.surfaces is a dynamic-key container (`[surface]`,
217
+ // below) built from a value this module does not itself constrain to a
218
+ // safe enum — Object.create(null) so a `surface` value of '__proto__'
219
+ // reassigns an own data property instead of the object's [[Prototype]].
220
+ // Mirrors the identical fix already shipped in this domain for the same
221
+ // assignment shape: packages/ingest/rebuild-indexes.js (F-89b7dcd5),
222
+ // packages/portfolio/lib/compute-trends.js (F-a853fcaa). Not reachable
223
+ // today — `surface` comes from `rec.applies_to.product_surfaces`, which is
224
+ // schema-enum-constrained — but this is the same defense-in-depth posture
225
+ // the sibling fixes already established for this pattern.
226
+ if (!policy.surfaces) policy.surfaces = Object.create(null);
200
227
  if (!policy.surfaces[surface]) policy.surfaces[surface] = {};
201
228
  if (!Array.isArray(policy.surfaces[surface].required_scenarios)) {
202
229
  policy.surfaces[surface].required_scenarios = [];
@@ -209,7 +236,28 @@ export function applyRecommendation(rootDir, params) {
209
236
  // logic field. Stored under a dedicated `applied_recommendations` map keyed by
210
237
  // the scenario id so it is auditable but never interpreted by the policy
211
238
  // engine. The free-text details live here as a human note, never as a rule.
212
- if (!policy.applied_recommendations) policy.applied_recommendations = {};
239
+ //
240
+ // F-5dfddcb5: Object.create(null), not {} — action.target is FREE TEXT
241
+ // (≤100 chars, no enum constraint, per this file's own header docstring)
242
+ // used as the dynamic key immediately below. On a plain {}, the bracket
243
+ // assignment `container['__proto__'] = provenance` does not create an own
244
+ // '__proto__' data property — it invokes Object.prototype's __proto__
245
+ // SETTER, which reassigns the CONTAINER's own [[Prototype]] to the
246
+ // provenance object. The write silently vanishes (Object.keys() stays [])
247
+ // and the container starts inheriting provenance's own properties instead
248
+ // — confirmed with a standalone probe matching this exact single-assignment
249
+ // shape. (This is scoped to the one container object, not a write onto the
250
+ // shared, global Object.prototype — that stronger form needs a read-then-
251
+ // write through an unguarded lookup, which is a different pattern.)
252
+ // Object.create(null) removes the [[Prototype]] chain entirely, so
253
+ // '__proto__' is just an ordinary key. NOT reachable from untrusted input
254
+ // today — the only current producer (recommendation-derivation.js's
255
+ // selectTemplate()) emits hardcoded literal targets — but a future
256
+ // template letting action.target reflect free-form scenario-id-shaped text
257
+ // must not silently reopen this class. Mirrors the identical fix already
258
+ // shipped for this pattern: packages/ingest/rebuild-indexes.js
259
+ // (F-89b7dcd5), packages/portfolio/lib/compute-trends.js (F-a853fcaa).
260
+ if (!policy.applied_recommendations) policy.applied_recommendations = Object.create(null);
213
261
  const provenance = {
214
262
  recommendation_id: id,
215
263
  action_type: action.type,
@@ -252,8 +300,23 @@ export function applyRecommendation(rootDir, params) {
252
300
  };
253
301
  }
254
302
 
255
- /** Resolve the on-disk path for a repo policy under rootDir. */
303
+ /**
304
+ * Resolve the on-disk path for a repo policy under rootDir.
305
+ *
306
+ * F-853dbce9: fails closed on the same two-segment invariant the guard above
307
+ * enforces, rather than trusting every caller to have checked first —
308
+ * mirrors persist.js's computeRecordPath, which owns this same invariant at
309
+ * the write layer instead of trusting its own callers. Both of this
310
+ * function's current callers (the dry-run preview and the --write path) are
311
+ * already gated by the guard above, so this throw is defense-in-depth, not
312
+ * the primary enforcement point — but a future call site that skips the
313
+ * guard must fail loud here, not silently resolve a different repo's file.
314
+ */
256
315
  function policyPathFor(rootDir, orgRepo) {
257
- const [org, repo] = orgRepo.split('/');
316
+ const segments = String(orgRepo).split('/');
317
+ if (segments.length !== 2 || !segments[0] || !segments[1]) {
318
+ throw new Error(`policy repo "${orgRepo}" is not a two-segment org/repo slug`);
319
+ }
320
+ const [org, repo] = segments;
258
321
  return resolve(rootDir, 'policies', 'repos', org, `${repo}.yaml`);
259
322
  }