ruvnet-brain 4.3.34 → 4.3.36

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.
@@ -16,11 +16,15 @@ import { buildCoverage, observeSourceUniverse, renderMarkdown } from './source-c
16
16
  import { promoteArtifactSet } from '../kb/incremental-refresh.mjs';
17
17
  import { rebuildCorpusAggregates } from './corpus-aggregates.mjs';
18
18
  import { assertCapabilityOnlyStore, isCapabilityOnly, CAPABILITY_RETIRED_SUFFIXES } from '../kb/capability-only.mjs';
19
- import { fileIdentity } from '../plugin/scripts/coverage-integrity.mjs';
19
+ import { eligibleRepositoryStanding, fileIdentity, validateCoverageLedger } from '../plugin/scripts/coverage-integrity.mjs';
20
+ import {
21
+ CORPUS_QA_FAILED_EXIT, FAILURE_CLASS, StoreWorkerError, degradedBound, degradedPublication, failureReason, isRetryable,
22
+ } from './corpus-store-failure.mjs';
20
23
  import { readDiagnosticAccuracyReport } from './oracle/retrieval-accuracy.mjs';
21
24
  import { storeRoot } from '../kb/store-root.mjs';
22
25
  import { captureGistSources } from './gist-receipts.mjs';
23
- import { projectSourceStore } from './rvf-generation.mjs';
26
+ import { projectSourceStore, RUNTIME_LEDGER_KIND } from './rvf-generation.mjs';
27
+ import { compareKnowledgeInputs, fromCoverage as knowledgeFromCoverage, fromSeed as knowledgeFromSeed } from './knowledge-input-digest.mjs';
24
28
 
25
29
  export { rebuildCorpusAggregates };
26
30
 
@@ -119,6 +123,44 @@ function filesNamed(root, wanted) {
119
123
  return found;
120
124
  }
121
125
 
126
+ // ADR-0091 D4 -- the ONE seed property that cannot be judged before download. corpus-next-seed.mjs
127
+ // judges a published generation's embedding model and recall report from small files, but the
128
+ // generation ledger lives only inside the archive, so its schema is checked here, right after
129
+ // extraction and BEFORE anything is moved. A mismatch is not a corrupt seed: it is a seed this runtime
130
+ // cannot consume (a ledger-schema change is a code-release event). It is thrown as a distinct error
131
+ // and main() exits SEED_LEDGER_INCOMPATIBLE_EXIT with the assets directory untouched, so
132
+ // corpus-seed.yml can re-run seed extraction ONCE from the committed bootstrap in the same job.
133
+ export const SEED_LEDGER_SCHEMA_VERSION = 2;
134
+ export const SEED_LEDGER_INCOMPATIBLE_EXIT = 3;
135
+ // ADR-0091 D5 + D10: the generation was built and SEALED with carried/missing stores, but degraded
136
+ // publication is not yet allowed (no soaked tolerant-validator transition). It must not be published;
137
+ // the next night re-plans the carried stores automatically because their sourceCommit still differs.
138
+ export const DEGRADED_UNPUBLISHED_EXIT = 4;
139
+
140
+ export class SeedLedgerIncompatibleError extends Error {
141
+ constructor(reason) {
142
+ super(`[corpus-reconcile] seed ledger is incompatible with this runtime: ${reason}`);
143
+ this.name = 'SeedLedgerIncompatibleError';
144
+ this.reason = reason;
145
+ }
146
+ }
147
+
148
+ /** null when this runtime can consume the ledger, else the reason it cannot. */
149
+ export function seedLedgerIncompatibility(ledger) {
150
+ if (!ledger || typeof ledger !== 'object' || Array.isArray(ledger)) return 'RVF-GENERATIONS.json is not an object';
151
+ if (ledger.schemaVersion !== SEED_LEDGER_SCHEMA_VERSION || ledger.kind !== RUNTIME_LEDGER_KIND) {
152
+ return `RVF-GENERATIONS.json is schemaVersion ${JSON.stringify(ledger.schemaVersion ?? null)} kind ${JSON.stringify(ledger.kind ?? null)}; `
153
+ + `this runtime reads schemaVersion ${SEED_LEDGER_SCHEMA_VERSION} kind ${RUNTIME_LEDGER_KIND}`;
154
+ }
155
+ if (!ledger.stores || typeof ledger.stores !== 'object' || Array.isArray(ledger.stores)) return 'RVF-GENERATIONS.json has no stores object';
156
+ return null;
157
+ }
158
+
159
+ // Moves the corpus root's TOP-LEVEL entries only (directories such as keys/, primer/ and l2/ move
160
+ // whole). Nothing is filtered out: a seed's own runtime files (.mjs, package.json) are harmless here,
161
+ // because build-bundle.mjs copies only named store files and the sealed prose from --assets and takes
162
+ // every runtime module from the checkout (ADR-0091 D4 withdrew the 0.1.0 "strip" step for that reason,
163
+ // and because source-coverage.mjs hard-reads capability-cards.md from these assets).
122
164
  export function normalizeExtractedCorpus({ extractedDir, assetsDir }) {
123
165
  const extracted = path.resolve(extractedDir || '');
124
166
  const assets = path.resolve(assetsDir || '');
@@ -128,6 +170,11 @@ export function normalizeExtractedCorpus({ extractedDir, assetsDir }) {
128
170
  if (fs.existsSync(assets) && fs.readdirSync(assets).length) fail(`bootstrap assets directory is not empty (${assets})`);
129
171
  const ledgers = filesNamed(extracted, 'RVF-GENERATIONS.json');
130
172
  if (ledgers.length !== 1) fail(`seed archive must contain exactly one RVF-GENERATIONS.json; found ${ledgers.length}`);
173
+ let seedLedger;
174
+ try { seedLedger = JSON.parse(fs.readFileSync(ledgers[0], 'utf8')); }
175
+ catch (error) { throw new SeedLedgerIncompatibleError(`RVF-GENERATIONS.json is unreadable (${error.message})`); }
176
+ const incompatibility = seedLedgerIncompatibility(seedLedger);
177
+ if (incompatibility) throw new SeedLedgerIncompatibleError(incompatibility);
131
178
  const corpusRoot = path.dirname(ledgers[0]);
132
179
  // A published seed's own PRIVATE-STORES.json is AUTHENTICATED HISTORICAL EVIDENCE of what that
133
180
  // prior round excluded — never the current builder's live policy. Keep it under a distinct name
@@ -252,7 +299,7 @@ async function measureFreshness({ closingObservation, observation }) {
252
299
  * generation; `latest` is never substituted, and an exhausted partial generation is never accepted.
253
300
  */
254
301
  export async function acquireSealedGeneration({ maxAttempts = 3, assetsDir = null, observe, build,
255
- readLedger: currentLedger, execute, prune, rebuild, preflight = null, closingObservation = null } = {}) {
302
+ readLedger: currentLedger, execute, prune, rebuild, preflight = null, closingObservation = null, unchanged = null } = {}) {
256
303
  if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 10
257
304
  || [observe, build, currentLedger, execute, prune, rebuild].some((fn) => typeof fn !== 'function')) {
258
305
  fail('bounded acquisition configuration is invalid');
@@ -260,15 +307,33 @@ export async function acquireSealedGeneration({ maxAttempts = 3, assetsDir = nul
260
307
  // ONE discovery pass. This observation is the sealed manifest every later step consumes; it is never
261
308
  // re-taken, so upstream churn cannot restart or invalidate the generation.
262
309
  const observation = await observe();
310
+ // NO-CHANGE, DECIDED BEFORE ANYTHING IS BUILT (2026-09-29 nightly redesign). When every knowledge
311
+ // input equals the seed's (scripts/knowledge-input-digest.mjs), the night ends here: no gist
312
+ // preflight, no clone, no embedding, no aggregate rebuild, nothing sealed or published.
313
+ if (typeof unchanged === 'function') {
314
+ const knowledgeInput = await unchanged(observation);
315
+ if (knowledgeInput?.unchanged === true) {
316
+ return { noChange: true, observation, attempts: [], consistencyModel: CONSISTENCY_MODEL, knowledgeInput };
317
+ }
318
+ }
263
319
  // Validate/fetch the source most likely to fail late (gist detail/raw access) before any expensive
264
320
  // repository clone and embedding work. Its verified bodies are the existing capture cache consumed
265
321
  // by the later aggregate build, so preflight does not double-fetch or weaken source binding.
266
322
  const preflightResult = typeof preflight === 'function' ? await preflight(observation) : null;
267
323
  const attempts = [];
324
+ // ADR-0091 D5: stores whose refresh FAILED this generation, keyed by folded store name, with what
325
+ // they became -- { carry } | { failure } | { integrity }. They are never re-executed by a later
326
+ // attempt of this loop and never count as remaining/unresolved: before D5 one stuck store cost all
327
+ // three attempts (each re-running a ~22-minute aggregate rebuild) and then failed the night anyway.
328
+ // The loop now re-attempts only for what it was built for: a gist revision that moved mid-fetch.
329
+ const storeOutcomes = {};
330
+ const recorded = (store) => Object.hasOwn(storeOutcomes, String(store || '').toLowerCase());
268
331
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
269
- const coverage = await build(observation);
270
- const plan = planReconciliation({ coverage, ledger: currentLedger(), assetsDir });
332
+ const coverage = await build(observation, storeOutcomes);
333
+ const plan = planReconciliation({ coverage, ledger: currentLedger(), assetsDir }).filter((item) => !recorded(item.store));
271
334
  const reconciliation = await execute(plan, attempt);
335
+ recordStoreOutcomes(storeOutcomes, reconciliation);
336
+ assertIsolatedFailures({ coverage, storeOutcomes });
272
337
  const pruning = await prune(coverage, attempt);
273
338
  let aggregates;
274
339
  try {
@@ -288,14 +353,17 @@ export async function acquireSealedGeneration({ maxAttempts = 3, assetsDir = nul
288
353
  // row still pinned the PRE-rebuild ruv-gists digest and build-bundle refused the candidate with
289
354
  // "coverage row gist:... was measured against different ruv-gists RVF bytes than this corpus
290
355
  // carries", 56 minutes into an otherwise complete run.
291
- const settled = await build(observation);
292
- const remaining = planReconciliation({ coverage: settled, ledger: currentLedger(), assetsDir });
293
- const unresolved = settled.rows.filter((row) => row.disposition === 'eligible' && row.status !== 'CURRENT');
356
+ const settled = await build(observation, storeOutcomes);
357
+ const remaining = planReconciliation({ coverage: settled, ledger: currentLedger(), assetsDir })
358
+ .filter((item) => !recorded(item.store));
359
+ const unresolved = settled.rows.filter((row) => row.disposition === 'eligible' && row.status !== 'CURRENT'
360
+ && !(row.kind === 'repository' && recorded(row.artifact?.store)));
294
361
  attempts.push({ attempt, plan, ...reconciliation, ...pruning, ...aggregates,
295
362
  remainingArtifacts: remaining.length, unresolvedSources: unresolved.length });
296
363
  if (!remaining.length && !unresolved.length) {
297
364
  return {
298
365
  observation, coverage: settled, attempts, consistencyModel: CONSISTENCY_MODEL,
366
+ degraded: degradedSummary(storeOutcomes),
299
367
  freshness: await measureFreshness({ closingObservation, observation }),
300
368
  };
301
369
  }
@@ -306,6 +374,78 @@ export async function acquireSealedGeneration({ maxAttempts = 3, assetsDir = nul
306
374
  + 'eligible source(s) remain unresolved against the sealed manifest');
307
375
  }
308
376
 
377
+ function recordStoreOutcomes(storeOutcomes, reconciliation) {
378
+ for (const { store, carry } of reconciliation?.carried || []) storeOutcomes[store.toLowerCase()] = { carry };
379
+ for (const { store, failure } of reconciliation?.missing || []) storeOutcomes[store.toLowerCase()] = { failure };
380
+ for (const { store, integrity } of reconciliation?.integrityFailures || []) storeOutcomes[store.toLowerCase()] = { integrity };
381
+ }
382
+
383
+ export function degradedSummary(storeOutcomes) {
384
+ const entries = Object.entries(storeOutcomes || {}).sort(([a], [b]) => a.localeCompare(b));
385
+ return {
386
+ carried: entries.filter(([, outcome]) => outcome.carry).map(([store, outcome]) => ({ store, ...outcome.carry })),
387
+ missing: entries.filter(([, outcome]) => outcome.failure).map(([store, outcome]) => ({ store, ...outcome.failure })),
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Fail the generation the moment isolation stops being the right answer, BEFORE pruning and the
393
+ * ~22-minute aggregate rebuild are spent on it:
394
+ * - any integrity failure (carried bytes that no longer match the seed ledger, or a same-commit
395
+ * rebuild that failed) -- such a row is FAILED, and FAILED is never shippable, so no bound can
396
+ * admit it;
397
+ * - more carried + missing stores than max(3, 5% of eligible) -- the failure is systemic, and
398
+ * publishing around it would hide a forge or network regression.
399
+ */
400
+ export function assertIsolatedFailures({ coverage, storeOutcomes }) {
401
+ const outcomes = Object.entries(storeOutcomes || {});
402
+ const integrity = outcomes.filter(([, outcome]) => outcome.integrity);
403
+ if (integrity.length) {
404
+ fail(`integrity failure in ${integrity.length} store(s): `
405
+ + `${integrity.map(([store, outcome]) => `${store} (${outcome.integrity})`).join('; ')} -- the generation fails`);
406
+ }
407
+ const eligible = (coverage?.rows || []).filter((row) => row.kind === 'repository' && row.disposition === 'eligible').length;
408
+ const isolated = outcomes.length;
409
+ const bound = degradedBound(eligible);
410
+ if (isolated > bound) {
411
+ fail(`systemic failure: ${isolated} of ${eligible} eligible store(s) failed to refresh `
412
+ + `(${outcomes.map(([store]) => store).join(', ')}); the bound is max(3, 5% of eligible) = ${bound} -- the generation fails`);
413
+ }
414
+ }
415
+
416
+ /**
417
+ * The ONE reader of an acquisition result's per-attempt history (ADR-0091 D1).
418
+ *
419
+ * WHY THIS EXISTS. cd0f032f renamed the loop's history from `rounds` to `attempts` but left two
420
+ * independent readers behind: main() (`reconciliation.rounds.flatMap(...)`) and the local rehearsal
421
+ * (scripts/rehearse-corpus-pipeline.mjs). Both threw "Cannot read properties of undefined (reading
422
+ * 'flatMap')" AFTER the whole generation had been acquired, so every corpus-publish run died at its
423
+ * last line and the rehearsal meant to catch that died at the same place. Every reader now goes
424
+ * through here, and a result without an `attempts` array fails by name instead of by TypeError.
425
+ */
426
+ export function summarizeReconciliation(reconciliation) {
427
+ const attempts = reconciliation?.attempts;
428
+ if (!Array.isArray(attempts)) {
429
+ fail(`reconciliation result has no attempts array (keys: ${Object.keys(reconciliation || {}).join(', ') || 'none'}); `
430
+ + 'acquireSealedGeneration returns { observation, coverage, attempts, ... }');
431
+ }
432
+ const across = (field) => attempts.flatMap((attempt) => attempt?.[field] || []);
433
+ return {
434
+ attempts: attempts.length,
435
+ observationSha256: reconciliation.observation?.observationSha256 ?? null,
436
+ plan: across('plan'),
437
+ refreshed: across('refreshed'),
438
+ pruned: across('pruned'),
439
+ rebuilt: across('rebuilt'),
440
+ // ADR-0091 D5: the stores this generation carries (STALE) or lacks (MISSING) after an isolated
441
+ // refresh failure. Empty lists on an all-CURRENT generation.
442
+ degraded: {
443
+ carried: reconciliation.degraded?.carried || [],
444
+ missing: reconciliation.degraded?.missing || [],
445
+ },
446
+ };
447
+ }
448
+
309
449
  function defaultRun(command, args, options = {}) {
310
450
  return spawnSync(command, args, { encoding: 'utf8', ...options });
311
451
  }
@@ -319,7 +459,7 @@ function checked(run, command, args, options = {}) {
319
459
  return result;
320
460
  }
321
461
 
322
- function defaultRunAsync(command, args, options = {}) {
462
+ export function defaultRunAsync(command, args, options = {}) {
323
463
  return new Promise((resolve) => {
324
464
  const inherited = options.stdio === 'inherit';
325
465
  const child = spawn(command, args, { ...options, encoding: undefined,
@@ -335,15 +475,6 @@ function defaultRunAsync(command, args, options = {}) {
335
475
  });
336
476
  }
337
477
 
338
- async function checkedAsync(run, command, args, options = {}) {
339
- const result = await run(command, args, options) || {};
340
- if (result.error || result.status !== 0) {
341
- const detail = String(result.stderr || result.stdout || result.error?.message || `exit ${result.status}`).trim();
342
- fail(`${command} ${args.join(' ')} failed${detail ? ` (${detail})` : ''}`);
343
- }
344
- return result;
345
- }
346
-
347
478
  const storeArtifacts = (store) => STORE_ARTIFACT_SUFFIXES.map((suffix) => `${store}${suffix}`);
348
479
 
349
480
  function writeJsonAtomic(file, value) {
@@ -439,6 +570,81 @@ function validateWorkerOutput({ output, item }) {
439
570
  return { ...payload, receiptSha256: crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex'), output };
440
571
  }
441
572
 
573
+ // ADR-0091 D5: the commit date of the bytes a carried store keeps, when the PREVIOUS generation's
574
+ // sealed coverage proves it. A row whose observed upstream SHA IS the carried commit dates that exact
575
+ // commit; a row that was itself carried passes its own carriedCommittedAt on. Anything else -- no
576
+ // prior coverage (the bootstrap lineage), an invalid one, a different commit -- is null, never
577
+ // estimated (D7.1 reads this for `oldestCarried`).
578
+ export function readPriorCoverage(assetsDir) {
579
+ const file = path.join(path.resolve(assetsDir || ''), 'CORPUS-COVERAGE.json');
580
+ if (!fs.existsSync(file)) return null;
581
+ try {
582
+ const coverage = JSON.parse(fs.readFileSync(file, 'utf8'));
583
+ return coverage?.kind === 'ruvnet-brain-corpus-coverage' && validateCoverageLedger(coverage).valid ? coverage : null;
584
+ } catch {
585
+ return null;
586
+ }
587
+ }
588
+
589
+ export function carriedCommittedAt({ priorCoverage, store, sourceCommit }) {
590
+ const folded = String(store || '').toLowerCase();
591
+ const commit = String(sourceCommit || '').toLowerCase();
592
+ const row = (priorCoverage?.rows || []).find((candidate) => candidate?.kind === 'repository'
593
+ && String(candidate?.artifact?.store || '').toLowerCase() === folded);
594
+ const iso = (value) => (typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null);
595
+ if (!row || !HEX40.test(commit)) return null;
596
+ if (String(row.upstream?.sha || '').toLowerCase() === commit) return iso(row.upstream?.committedAt);
597
+ if (String(row.carry?.carriedSourceCommit || '').toLowerCase() === commit) return iso(row.carry?.carriedCommittedAt);
598
+ return null;
599
+ }
600
+
601
+ // ADR-0091 D5: what a store whose refresh FAILED becomes. planReconciliation's byte check never
602
+ // covers a store it plans (it runs only when sourceCommit already equals upstream), so the seed
603
+ // bytes are re-hashed HERE before they may stand in for the missed refresh. The ledger binds one file
604
+ // per store (the .big.rvf: file, bytes, sha256); the rest of the family must be present as regular
605
+ // files. The seed archive itself was digest-verified on download (assertBootstrapIdentity).
606
+ export function dispositionForFailedStore({ assetsDir, ledger, item, attempts, reason, priorCoverage = null }) {
607
+ const assets = path.resolve(assetsDir || '');
608
+ const generation = Object.entries(ledger?.stores || {})
609
+ .find(([name]) => name.toLowerCase() === item.store.toLowerCase())?.[1] || null;
610
+ if (!generation) return { store: item.store, failure: { reason, attempts } };
611
+ const carried = String(generation.sourceCommit || '').toLowerCase();
612
+ if (!HEX40.test(carried)) {
613
+ return { store: item.store, integrity: 'carried bytes have no exact 40-hex ledger sourceCommit to carry' };
614
+ }
615
+ if (carried === item.upstreamSha) {
616
+ // Planned for a policy or receipt reason at the SAME commit (a capability-only clean rebuild, or
617
+ // seed bytes that already failed the receipt check): the seed bytes are exactly what the rebuild
618
+ // was meant to replace, so they cannot stand in for it.
619
+ return { store: item.store, integrity: 'the refresh was a same-commit rebuild the seed bytes cannot stand in for' };
620
+ }
621
+ const regular = (name) => {
622
+ try { const stat = fs.lstatSync(path.join(assets, name)); return stat.isFile() && !stat.isSymbolicLink(); }
623
+ catch { return false; }
624
+ };
625
+ const rvf = `${item.store}.big.rvf`;
626
+ const bound = generation.file === rvf && regular(rvf)
627
+ && generation.bytes === fs.statSync(path.join(assets, rvf)).size
628
+ && generation.sha256 === sha256File(path.join(assets, rvf))
629
+ && REQUIRED_STORE_ARTIFACT_SUFFIXES.every((suffix) => regular(`${item.store}${suffix}`));
630
+ if (!bound) return { store: item.store, integrity: 'carried bytes differ from the seed generation ledger' };
631
+ return { store: item.store, carry: {
632
+ reason,
633
+ carriedSourceCommit: carried,
634
+ missedUpstream: item.upstreamSha,
635
+ attempts,
636
+ carriedCommittedAt: carriedCommittedAt({ priorCoverage, store: item.store, sourceCommit: carried }),
637
+ } };
638
+ }
639
+
640
+ // Only a TRANSIENT failure is retried, exactly once, in a fresh directory: `<store>-retry1`. The first
641
+ // attempt's directory is never reused -- a clone or a half-written worker output from the failed
642
+ // attempt must not be mistaken for the retry's own (the pre-D5 path collided).
643
+ export function workerRootFor(workspace, store, retry) {
644
+ return path.join(workspace, 'workers', retry === 0 ? store : `${store}-retry${retry}`);
645
+ }
646
+ export const MAX_TRANSIENT_RETRIES = 1;
647
+
442
648
  export async function executeReconciliation({
443
649
  plan,
444
650
  assetsDir,
@@ -447,6 +653,8 @@ export async function executeReconciliation({
447
653
  run = defaultRunAsync,
448
654
  concurrency = 5,
449
655
  signal,
656
+ priorCoverage = null,
657
+ log = (line) => console.log(line),
450
658
  }) {
451
659
  if (!Array.isArray(plan) || !Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 10) {
452
660
  fail('reconciliation plan or worker concurrency is invalid');
@@ -466,50 +674,94 @@ export async function executeReconciliation({
466
674
  const lowerStores = orderedPlan.map(({ store }) => store.toLowerCase());
467
675
  if (new Set(lowerStores).size !== lowerStores.length) fail('reconciliation plan has duplicate or case-fold-colliding stores');
468
676
 
469
- // Step 4, required proof 4 (2026-09-13): every worker in this pool shares ONE internal
470
- // AbortController. Before this, `Promise.all` over the fixed-size worker pool below rejected as
471
- // soon as ANY lane's `worker()` threw -- but the OTHER lanes kept running their own `while` loop
472
- // completely unobserved: still cloning, still spawning forge-refresh, with nobody left awaiting
473
- // them once the outer Promise.all had already settled. A later failure (or success) in one of
474
- // those orphaned lanes could then surface as an unhandled rejection, or simply keep doing
475
- // unnecessary work after the round was already lost. Now: the first failure aborts the shared
476
- // signal, every lane observes it (both at its own loop-top and via the signal threaded into every
477
- // child-process spawn below) and returns promptly, and `Promise.all` -- which no lane's promise
478
- // ever rejects out of directly -- only resolves once every lane has actually stopped. Only then do
479
- // we throw the FIRST real error (an aborted sibling's own error is discarded, never overwrites it).
480
- // An externally supplied `signal` (a caller discarding this whole round) aborts the same
481
- // controller, so both cancellation paths join through the one place.
677
+ // ADR-0091 D5: ONE store failing no longer aborts the round. Before D5 the first worker error
678
+ // aborted every sibling (2 of 9 corpus runs died that way: one deterministic `ruvector` QA miss
679
+ // threw away 93 other stores' refreshes). Now each store's failure is recorded and its lane moves
680
+ // on; the shared AbortController below exists ONLY for an externally supplied `signal` -- a caller
681
+ // discarding the whole round -- which still stops and joins every lane (required proof 4).
482
682
  const controller = new AbortController();
483
683
  if (signal) {
484
684
  if (signal.aborted) controller.abort(signal.reason);
485
685
  else signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true });
486
686
  }
487
687
 
488
- const worker = async (item) => {
688
+ const stage = async (item, name, classify, command, args, options = {}) => {
689
+ const result = await run(command, args, { ...options, signal: controller.signal }) || {};
690
+ if (controller.signal.aborted) throw abortError(controller.signal);
691
+ if (result.error || result.status !== 0) {
692
+ const detail = String(result.stderr || result.stdout || result.error?.message || `exit ${result.status}`).trim().slice(0, 400);
693
+ throw new StoreWorkerError({ store: item.store, stage: name, failureClass: classify(result), detail });
694
+ }
695
+ return result;
696
+ };
697
+ const transient = () => FAILURE_CLASS.TRANSIENT;
698
+ // forge-refresh's exit status is the structured reason: CORPUS_QA_FAILED_EXIT means corpus-qa refused
699
+ // the candidate (deterministic, never retried); a spawn error is runner I/O (transient); any other
700
+ // non-zero exit is a build failure (not retried: a retry is a full re-embed with no reason to differ).
701
+ const forgeClass = (result) => (result.status === CORPUS_QA_FAILED_EXIT ? FAILURE_CLASS.QA
702
+ : result.error ? FAILURE_CLASS.TRANSIENT : FAILURE_CLASS.BUILD);
703
+
704
+ const worker = async (item, retry) => {
489
705
  if (controller.signal.aborted) throw abortError(controller.signal);
490
706
  if (!SAFE_STORE.test(item.store) || !HEX40.test(item.upstreamSha) || !repositorySlug(item.url)) {
491
- fail(`unsafe reconciliation item for ${item?.store || item?.name || 'unknown store'}`);
707
+ throw new StoreWorkerError({ store: item?.store || item?.name || 'unknown store', stage: 'plan item',
708
+ failureClass: FAILURE_CLASS.INTEGRITY, detail: 'unsafe reconciliation item' });
709
+ }
710
+ const workerRoot = workerRootFor(workspace, item.store, retry);
711
+ if (fs.existsSync(workerRoot)) {
712
+ throw new StoreWorkerError({ store: item.store, stage: 'worker directory', failureClass: FAILURE_CLASS.INTEGRITY,
713
+ detail: 'a fresh worker directory already exists' });
492
714
  }
493
- const workerRoot = path.join(workspace, 'workers', item.store);
494
715
  const cloneDir = path.join(workerRoot, 'clone');
495
716
  const output = path.join(workerRoot, 'assets');
496
- fs.mkdirSync(workerRoot, { recursive: true });
497
- seedWorkerAssets({ assets, output, store: item.store, ledger: canonicalLedger, source: canonicalSource });
498
- await checkedAsync(run, 'git', ['clone', '--no-checkout', '--filter=blob:none', item.url, cloneDir], { signal: controller.signal });
499
- await checkedAsync(run, 'git', ['-C', cloneDir, 'fetch', '--depth=1', 'origin', item.upstreamSha], { signal: controller.signal });
500
- await checkedAsync(run, 'git', ['-C', cloneDir, 'checkout', '--detach', 'FETCH_HEAD'], { signal: controller.signal });
501
- const head = await checkedAsync(run, 'git', ['-C', cloneDir, 'rev-parse', 'HEAD'], { signal: controller.signal });
717
+ try {
718
+ fs.mkdirSync(workerRoot, { recursive: true });
719
+ seedWorkerAssets({ assets, output, store: item.store, ledger: canonicalLedger, source: canonicalSource });
720
+ } catch (error) {
721
+ // An errno (disk, file table) is runner I/O; our own refusal is an integrity failure.
722
+ throw new StoreWorkerError({ store: item.store, stage: 'worker seed copy',
723
+ failureClass: typeof error?.code === 'string' ? FAILURE_CLASS.TRANSIENT : FAILURE_CLASS.INTEGRITY, detail: error.message });
724
+ }
725
+ await stage(item, 'git clone', transient, 'git', ['clone', '--no-checkout', '--filter=blob:none', item.url, cloneDir]);
726
+ await stage(item, 'git fetch', transient, 'git', ['-C', cloneDir, 'fetch', '--depth=1', 'origin', item.upstreamSha]);
727
+ await stage(item, 'git checkout', transient, 'git', ['-C', cloneDir, 'checkout', '--detach', 'FETCH_HEAD']);
728
+ const head = await stage(item, 'git rev-parse', transient, 'git', ['-C', cloneDir, 'rev-parse', 'HEAD']);
502
729
  if (String(head.stdout || '').trim().toLowerCase() !== item.upstreamSha) {
503
- fail(`${item.store}: fresh clone did not resolve the exact upstream SHA`);
730
+ throw new StoreWorkerError({ store: item.store, stage: 'exact-sha checkout', failureClass: FAILURE_CLASS.INTEGRITY,
731
+ detail: 'fresh clone did not resolve the exact upstream SHA' });
504
732
  }
505
- await checkedAsync(run, process.execPath, [forge, '--repo', cloneDir, '--out', output, '--name', item.store,
733
+ await stage(item, 'forge-refresh', forgeClass, process.execPath, [forge, '--repo', cloneDir, '--out', output, '--name', item.store,
506
734
  ...(FULL_HINTS[item.store] ? ['--full', FULL_HINTS[item.store]] : []),
507
735
  ...(KEEP_DIRS[item.store] ? ['--keep', KEEP_DIRS[item.store]] : []),
508
- ], { stdio: 'inherit', env: { ...process.env, RUVNET_BIG_SHARDS: '1' }, signal: controller.signal });
509
- return validateWorkerOutput({ output, item });
736
+ ], { stdio: 'inherit', env: { ...process.env, RUVNET_BIG_SHARDS: '1' } });
737
+ try {
738
+ return validateWorkerOutput({ output, item });
739
+ } catch (error) {
740
+ throw new StoreWorkerError({ store: item.store, stage: 'worker output validation', failureClass: FAILURE_CLASS.INTEGRITY,
741
+ detail: error.message });
742
+ }
510
743
  };
511
744
 
512
- const results = new Array(orderedPlan.length);
745
+ const runStore = async (item) => {
746
+ let lastError = null;
747
+ let attempts = 0;
748
+ for (let retry = 0; retry <= MAX_TRANSIENT_RETRIES; retry += 1) {
749
+ attempts += 1;
750
+ try {
751
+ return { ok: true, attempts, result: await worker(item, retry) };
752
+ } catch (error) {
753
+ if (controller.signal.aborted) throw error;
754
+ lastError = error;
755
+ const retrying = isRetryable(error) && retry < MAX_TRANSIENT_RETRIES;
756
+ log(`[corpus-reconcile] ${item.store}: attempt ${attempts} failed -- ${error.message}`
757
+ + (retrying ? '; retrying once in a fresh worker directory' : '; not retried'));
758
+ if (!retrying) break;
759
+ }
760
+ }
761
+ return { ok: false, attempts, error: lastError };
762
+ };
763
+
764
+ const outcomes = new Array(orderedPlan.length);
513
765
  let next = 0;
514
766
  let firstError = null;
515
767
  await Promise.all(Array.from({ length: Math.min(concurrency, orderedPlan.length) }, async () => {
@@ -517,17 +769,35 @@ export async function executeReconciliation({
517
769
  if (controller.signal.aborted) return;
518
770
  const index = next++;
519
771
  try {
520
- results[index] = await worker(orderedPlan[index]);
772
+ outcomes[index] = await runStore(orderedPlan[index]);
521
773
  } catch (error) {
774
+ // Only an external cancellation reaches here; every lane is already observing the same signal.
522
775
  if (!firstError) firstError = error;
523
- controller.abort(error);
524
776
  return;
525
777
  }
526
778
  }
527
779
  }));
528
780
  if (!firstError && controller.signal.aborted) firstError = abortError(controller.signal);
529
781
  if (firstError) throw firstError;
530
- if (!results.length) return { refreshed: [], workers: [] };
782
+
783
+ const carried = [];
784
+ const missing = [];
785
+ const integrityFailures = [];
786
+ outcomes.forEach((outcome, index) => {
787
+ if (!outcome || outcome.ok) return;
788
+ const disposition = dispositionForFailedStore({ assetsDir: assets, ledger: canonicalLedger, item: orderedPlan[index],
789
+ attempts: outcome.attempts, reason: failureReason(outcome.error), priorCoverage });
790
+ if (disposition.carry) carried.push(disposition);
791
+ else if (disposition.failure) missing.push(disposition);
792
+ else integrityFailures.push(disposition);
793
+ log(`[corpus-reconcile] ${orderedPlan[index].store}: ${disposition.carry ? 'CARRIED at its verified seed bytes (STALE)'
794
+ : disposition.failure ? 'MISSING (no prior bytes)' : `INTEGRITY FAILURE (${disposition.integrity})`}`);
795
+ });
796
+ // The merge reads SUCCESSFUL results only. `outcomes` is indexed by plan position, so a failed
797
+ // store leaves a slot with no worker result; the pre-D5 merge read `result.files` off every slot.
798
+ const results = outcomes.filter((outcome) => outcome?.ok).map((outcome) => outcome.result);
799
+ const failed = { carried, missing, integrityFailures };
800
+ if (!results.length) return { refreshed: [], workers: [], ...failed };
531
801
 
532
802
  const merge = path.join(workspace, 'merge-candidate');
533
803
  fs.mkdirSync(merge);
@@ -563,7 +833,7 @@ export async function executeReconciliation({
563
833
  assertCapabilityOnlyStore(assets, store);
564
834
  }
565
835
  return { refreshed: results.map(({ store }) => store),
566
- workers: results.map(({ output: _output, ...receipt }) => receipt) };
836
+ workers: results.map(({ output: _output, ...receipt }) => receipt), ...failed };
567
837
  }
568
838
 
569
839
  // syncCorpusInputs — Step 3 (2026-09-13): this used to ALSO sync public-prose inputs
@@ -611,7 +881,8 @@ async function observeSourceOnly({ owner, assetsDir }) {
611
881
  export async function acquireCorpusGeneration({ owner = 'ruvnet', assetsDir, workspaceDir,
612
882
  root = DEFAULT_ROOT, maxAttempts = 3, closingObservation = null,
613
883
  observe = null,
614
- build = (observation) => buildCoverage({ owner, kbDir: assetsDir, policyDir: assetsDir, observation }),
884
+ build = (observation, storeOutcomes = null) => buildCoverage({ owner, kbDir: assetsDir, policyDir: assetsDir, observation,
885
+ storeOutcomes }),
615
886
  readLedger = () => readJson(path.join(path.resolve(assetsDir || ''), 'RVF-GENERATIONS.json'),
616
887
  'RVF generation ledger'),
617
888
  execute = executeReconciliation,
@@ -630,6 +901,14 @@ export async function acquireCorpusGeneration({ owner = 'ruvnet', assetsDir, wor
630
901
  rebuild = (coverage, observation, _attempt, capturedGists) => rebuildCorpusAggregates({
631
902
  assetsDir, observation, coverage, root, cache: capturedGists,
632
903
  }),
904
+ // The seed's knowledge inputs come from the evidence it carries (read FIRST: a seed without it --
905
+ // the pre-contract bootstrap -- always builds, and costs no second coverage measurement); tonight's
906
+ // from the coverage `build` measures off the sealed observation, plus this checkout's public prose.
907
+ unchanged = async (observation) => {
908
+ const seed = knowledgeFromSeed(assetsDir);
909
+ if (!seed) return { unchanged: false, reason: 'the seed carries no knowledge-input evidence' };
910
+ return compareKnowledgeInputs({ seed, tonight: await knowledgeFromCoverage(await build(observation, {}), root) });
911
+ },
633
912
  } = {}) {
634
913
  if (!assetsDir || !workspaceDir) fail('stable reconciliation requires explicit assets and workspace directories');
635
914
  const workspace = path.resolve(workspaceDir || '');
@@ -638,6 +917,8 @@ export async function acquireCorpusGeneration({ owner = 'ruvnet', assetsDir, wor
638
917
  assertPathNotOverlapping('reconciliation workspace directory', workspace, forbidden);
639
918
  assertPathNotOverlapping('reconciliation workspace directory', workspace,
640
919
  [{ label: 'the assets directory', dir: assetsDir }]);
920
+ // Read ONCE, before anything is rebuilt: the seed's own sealed coverage dates a carried store's bytes.
921
+ const priorCoverage = readPriorCoverage(assetsDir);
641
922
  return acquireSealedGeneration({
642
923
  maxAttempts,
643
924
  closingObservation,
@@ -646,11 +927,12 @@ export async function acquireCorpusGeneration({ owner = 'ruvnet', assetsDir, wor
646
927
  build,
647
928
  readLedger,
648
929
  execute: (plan, attempt) => execute({
649
- plan, assetsDir, workspaceDir: path.join(workspace, `attempt-${attempt}`), root,
930
+ plan, assetsDir, workspaceDir: path.join(workspace, `attempt-${attempt}`), root, priorCoverage,
650
931
  }),
651
932
  prune,
652
933
  rebuild,
653
934
  preflight,
935
+ unchanged,
654
936
  });
655
937
  }
656
938
 
@@ -668,9 +950,12 @@ export async function reconcileAndPrepareCorpusCandidate({ assetsDir, workspaceD
668
950
  owner = 'ruvnet', builderSha, candidateDir, receiptFile, coverageFile, bootstrapIdentity = null, maxAttempts = 3,
669
951
  reconcile = (options) => acquireCorpusGeneration(options),
670
952
  normalizeUpdaters = normalizeUpdaterManifest,
671
- accuracyOracleFile = null, accuracyStores = null, accuracySample = null, accuracyTimeoutMs = null,
953
+ accuracyOracleFile = null, accuracyStores = null, accuracySample = null, accuracySamplePerPartition = null,
954
+ accuracyTimeoutMs = null,
672
955
  prepare = prepareCorpusCandidate } = {}) {
673
956
  const finalized = await reconcile({ owner, assetsDir, workspaceDir, root, maxAttempts });
957
+ // Nothing the corpus is built from changed since the seed: nothing to normalize, seal or measure.
958
+ if (finalized?.noChange === true) return { reconciliation: finalized, noChange: true, updaters: null, candidate: null };
674
959
  // Every shipped repository store needs a complete updater entry, and a seed that predates the
675
960
  // convention leaves inherited stores without one -- measured 2026-09-15: 100 of 194 repository
676
961
  // stores, none of them refreshed that run, which build-bundle rightly refused to ship. Normalize
@@ -680,7 +965,7 @@ export async function reconcileAndPrepareCorpusCandidate({ assetsDir, workspaceD
680
965
  const updaters = normalizeUpdaters({
681
966
  assetsDir,
682
967
  coverage: finalized.coverage,
683
- refreshedStores: (finalized.attempts || []).flatMap((a) => (a.refreshed || []).map((r) => r?.store || r)).filter(Boolean),
968
+ refreshedStores: summarizeReconciliation(finalized).refreshed.map((r) => r?.store || r).filter(Boolean),
684
969
  seedIdentity: bootstrapIdentity,
685
970
  });
686
971
  if (updaters.missing?.length) {
@@ -691,7 +976,7 @@ export async function reconcileAndPrepareCorpusCandidate({ assetsDir, workspaceD
691
976
  const candidate = await prepare({
692
977
  root, assetsDir, builderSha, candidateDir, receiptFile, coverageFile, bootstrapIdentity,
693
978
  coverage: finalized.coverage,
694
- accuracyOracleFile, accuracyStores, accuracySample, accuracyTimeoutMs,
979
+ accuracyOracleFile, accuracyStores, accuracySample, accuracySamplePerPartition, accuracyTimeoutMs,
695
980
  });
696
981
  return { reconciliation: finalized, updaters, candidate };
697
982
  }
@@ -707,7 +992,11 @@ export function prepareCorpusCandidate({
707
992
  coverage,
708
993
  accuracyOracleFile = null,
709
994
  accuracyStores = null,
995
+ // ADR-0091 D2: a whole-oracle, deterministic question sample (retrieval-accuracy.mjs
996
+ // --sample-questions). `accuracySamplePerPartition` is the older first-k-per-partition bound; its
997
+ // floor is one question per partition (196 x 2 queries, ~27 min hosted), so it cannot meet D2.
710
998
  accuracySample = null,
999
+ accuracySamplePerPartition = null,
711
1000
  accuracyTimeoutMs = null,
712
1001
  run = defaultRun,
713
1002
  }) {
@@ -732,8 +1021,7 @@ export function prepareCorpusCandidate({
732
1021
  if (!coverage || coverage.kind !== 'ruvnet-brain-corpus-coverage' || !Array.isArray(coverage.rows)) {
733
1022
  fail('prepareCorpusCandidate requires an already-measured coverage object; it never re-observes live sources');
734
1023
  }
735
- const blockers = coverage.rows.filter((row) => row.disposition === 'eligible' && row.status !== 'CURRENT');
736
- if (blockers.length) fail(`strict coverage: ${blockers.length} eligible row(s) are not CURRENT`);
1024
+ const degraded = assessCandidateCoverage(coverage);
737
1025
  assertPathNotOverlapping('candidate output directory', candidate, forbiddenOutputRoots(sourceRoot));
738
1026
  const buildScript = path.join(sourceRoot, 'scripts', 'build-bundle.mjs');
739
1027
  const receiptScript = path.join(sourceRoot, 'scripts', 'corpus-candidate.mjs');
@@ -770,9 +1058,14 @@ export function prepareCorpusCandidate({
770
1058
  const bundleFile = path.join(path.dirname(candidate), `${path.basename(candidate)}.zip`);
771
1059
  // ADR-086 Step 15: the benchmark runs HERE — after single-pass assembly and before the seal —
772
1060
  // against the EXTRACTED final archive through the customer query path, never against `assets`.
773
- // The report is written detached, beside the archive, and the seal below binds its digest. A
774
- // bounded run (--stores/--sample) still writes a report, but it marks itself incomplete and the
775
- // seal refuses it, so a bounded measurement can never be presented as a corpus-wide pass.
1061
+ // The report is written detached, beside the archive, and the receipt below binds its digest.
1062
+ // C3 is a non-blocking diagnostic (ADR-086 amendment 2026-09-15): every reader of this report
1063
+ // (corpus-candidate.mjs, release.mjs, corpus-seed.yml) uses readDiagnosticAccuracyReport, which
1064
+ // checks only its schema and its binding to this archive, oracle and generator -- never whether
1065
+ // coverage is complete. So a bounded run (--stores/--sample/--sample-questions) seals exactly like
1066
+ // a full one; it marks itself `coverage.complete: false`, and only the retained strict reader
1067
+ // (validateAccuracyReport, the re-arm path) would refuse it. corpus-seed.yml runs a question
1068
+ // sample (ADR-0091 D2) because the full run cost 82 minutes on a hosted runner.
776
1069
  const accuracyReportFile = `${bundleFile}.accuracy.json`;
777
1070
  // A stale leftover report from a prior run must never be mistaken for a fresh measurement of
778
1071
  // THIS bundle -- delete it before invoking the script so only a report the script just wrote
@@ -781,7 +1074,8 @@ export function prepareCorpusCandidate({
781
1074
  const accuracyResult = run(process.execPath, [accuracyScript, '--bundle', bundleFile,
782
1075
  '--oracle', accuracyOracle, '--out', accuracyReportFile,
783
1076
  ...(accuracyStores != null ? ['--stores', String(accuracyStores)] : []),
784
- ...(accuracySample != null ? ['--sample', String(accuracySample)] : []),
1077
+ ...(accuracySample != null ? ['--sample-questions', String(accuracySample)] : []),
1078
+ ...(accuracySamplePerPartition != null ? ['--sample', String(accuracySamplePerPartition)] : []),
785
1079
  ...(accuracyTimeoutMs != null ? ['--timeout-ms', String(accuracyTimeoutMs)] : [])],
786
1080
  { stdio: 'inherit' }) || {};
787
1081
  // C3 was demoted to a non-blocking diagnostic on 2026-09-15 (commit a20727b7, ADR-086
@@ -804,10 +1098,12 @@ export function prepareCorpusCandidate({
804
1098
  // THE BLOCKING RETRIEVAL GATE (ADR-086 amendment 2026-09-15). Same placement and same discipline
805
1099
  // as the C3 run above — the EXTRACTED final archive through the customer query path — but this is
806
1100
  // the measurement that can refuse a candidate. It asks the 194 frozen human questions, one per
807
- // repository, and fails on any error, any repository that returns nothing of its own, or any
808
- // exact-file Hit@5 below the committed ratchet floor.
1101
+ // repository, and fails on any error or any repository that returns nothing of its own. The
1102
+ // exact-file Hit@5 floor is RECORDED in the report and never fails the CLI (ADR-0091 D7.6).
1103
+ // `--coverage` is this candidate's sealed observation: a fixture repository with no row in it is
1104
+ // retired (D7.2) instead of asked a question it has no store to answer.
809
1105
  const recallReportFile = `${bundleFile}.recall.json`;
810
- checked(run, process.execPath, [recallScript, '--bundle', bundleFile, '--out', recallReportFile],
1106
+ checked(run, process.execPath, [recallScript, '--bundle', bundleFile, '--out', recallReportFile, '--coverage', policy],
811
1107
  { stdio: 'inherit' });
812
1108
  // The candidate receipt is derived ENTIRELY from the sealed bundle's own bytes plus the detached,
813
1109
  // digest-bound reports — the separate assets/policy directory used to build it is no longer an
@@ -817,23 +1113,59 @@ export function prepareCorpusCandidate({
817
1113
  : [];
818
1114
  checked(run, process.execPath, [receiptScript, '--bundle', bundleFile,
819
1115
  '--receipt', receipt, '--builder-source-sha', builderSha,
820
- '--accuracy-report', accuracyReportFile, '--recall-report', recallReportFile,
1116
+ '--accuracy-report', accuracyReportFile, '--recall-report', recallReportFile, '--coverage', policy,
821
1117
  ...bootstrapArgs], { stdio: 'inherit' });
822
1118
  checked(run, process.execPath, [receiptScript, '--verify', '--bundle', bundleFile,
823
1119
  '--receipt', receipt, '--accuracy-report', accuracyReportFile,
824
- '--recall-report', recallReportFile], { stdio: 'inherit' });
1120
+ '--recall-report', recallReportFile, '--coverage', policy], { stdio: 'inherit' });
825
1121
  return {
826
1122
  bundleFile, receiptFile: receipt, coverageFile: policy,
827
- accuracyReportFile, accuracyOracleFile: accuracyOracle, recallReportFile,
1123
+ accuracyReportFile, accuracyOracleFile: accuracyOracle, recallReportFile, degraded,
828
1124
  };
829
1125
  }
830
1126
 
1127
+ /**
1128
+ * ADR-0091 D5 -- the gate that replaced "every eligible row is CURRENT". An eligible row passes when
1129
+ * it is CURRENT, or when it is a repository row the shipped validator itself accepts
1130
+ * (eligibleRepositoryStanding: STALE with a verified `carry`, MISSING with a `failure`). Everything
1131
+ * else -- a STALE/MISSING row with no record, FAILED, UNVERIFIED, any non-CURRENT gist -- still fails
1132
+ * closed, and so does a count of carried + missing stores above max(3, 5% of eligible).
1133
+ */
1134
+ export function assessCandidateCoverage(coverage) {
1135
+ const eligible = coverage.rows.filter((row) => row.disposition === 'eligible');
1136
+ const carried = [];
1137
+ const missing = [];
1138
+ const blockers = [];
1139
+ for (const row of eligible) {
1140
+ const standing = row.kind === 'repository' ? eligibleRepositoryStanding(row)
1141
+ : row.status === 'CURRENT' && row.carry === undefined && row.failure === undefined ? 'shipped' : null;
1142
+ if (standing === null) blockers.push(row);
1143
+ else if (row.carry) carried.push({ store: row.artifact.store, ...row.carry });
1144
+ else if (row.failure) missing.push({ store: row.artifact.store, ...row.failure });
1145
+ }
1146
+ if (blockers.length) {
1147
+ fail(`strict coverage: ${blockers.length} eligible row(s) are not CURRENT and carry no verified carry/failure record `
1148
+ + `(${blockers.slice(0, 5).map((row) => `${row.artifact?.store || row.key}:${row.status}`).join(', ')}`
1149
+ + `${blockers.length > 5 ? ', ...' : ''})`);
1150
+ }
1151
+ const repositories = eligible.filter((row) => row.kind === 'repository').length;
1152
+ const bound = degradedBound(repositories);
1153
+ if (carried.length + missing.length > bound) {
1154
+ fail(`degraded coverage: ${carried.length} carried + ${missing.length} missing store(s) exceed `
1155
+ + `max(3, 5% of ${repositories} eligible) = ${bound}`);
1156
+ }
1157
+ return { carried, missing, bound, eligibleRepositories: repositories };
1158
+ }
1159
+
831
1160
  function arg(argv, name, fallback = null) {
832
1161
  const index = argv.indexOf(name);
833
1162
  return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
834
1163
  }
835
1164
 
836
- export async function main(argv = process.argv.slice(2)) {
1165
+ // The two injectable seams exist so a test can drive main() end to end (ADR-0091 D1): nothing
1166
+ // called main() before, which is how its last line stayed broken for weeks. Production passes neither.
1167
+ export async function main(argv = process.argv.slice(2), {
1168
+ reconcileAndPrepare = reconcileAndPrepareCorpusCandidate, stdout = process.stdout, stderr = process.stderr } = {}) {
837
1169
  const root = path.resolve(arg(argv, '--root', DEFAULT_ROOT));
838
1170
  const archiveFile = path.resolve(arg(argv, '--seed-archive', ''));
839
1171
  const seedTag = arg(argv, '--seed-tag');
@@ -846,30 +1178,71 @@ export async function main(argv = process.argv.slice(2)) {
846
1178
  const builderSha = String(arg(argv, '--builder-sha', '')).toLowerCase();
847
1179
  const owner = arg(argv, '--owner', 'ruvnet');
848
1180
  const accuracyOracleFile = path.resolve(arg(argv, '--accuracy-oracle', path.join(root, 'data', 'retrieval-accuracy-oracle.json')));
849
- // Bounded measurement is explicit and opt-in. It never yields a sealable candidate — the seal
850
- // refuses an incomplete report — so these flags exist for measuring, not for shipping.
1181
+ // Bounded measurement is explicit and opt-in; omit every flag below for the full C3 audit.
1182
+ // `--accuracy-sample <n>` measures n oracle questions in total (both query modes), chosen
1183
+ // deterministically -- what corpus-seed.yml passes (ADR-0091 D2). A bounded report still seals,
1184
+ // because C3 is a diagnostic and its readers check binding, not completeness.
851
1185
  const accuracyStores = arg(argv, '--accuracy-stores') ? Number(arg(argv, '--accuracy-stores')) : null;
852
1186
  const accuracySample = arg(argv, '--accuracy-sample') ? Number(arg(argv, '--accuracy-sample')) : null;
1187
+ const accuracySamplePerPartition = arg(argv, '--accuracy-sample-per-partition')
1188
+ ? Number(arg(argv, '--accuracy-sample-per-partition')) : null;
853
1189
  const accuracyTimeoutMs = arg(argv, '--accuracy-timeout-ms') ? Number(arg(argv, '--accuracy-timeout-ms')) : null;
854
1190
 
855
- const bootstrap = assertBootstrapIdentity({ archiveFile, tag: seedTag, sha256: seedSha256, allowPinnedTag: process.argv.includes('--allow-pinned-seed-tag') });
1191
+ // `--no-change-out <file>`: always written (true or false) once reconciliation returns, so
1192
+ // corpus-seed.yml never has to infer a no-change night from a missing file.
1193
+ const noChangeOut = arg(argv, '--no-change-out');
1194
+ // The argv this main() was HANDED, never process.argv: an injected invocation must mean what it says.
1195
+ const bootstrap = assertBootstrapIdentity({ archiveFile, tag: seedTag, sha256: seedSha256, allowPinnedTag: argv.includes('--allow-pinned-seed-tag') });
856
1196
  if (fs.existsSync(assetsDir) && fs.readdirSync(assetsDir).length) fail(`bootstrap assets directory is not empty (${assetsDir})`);
857
1197
  fs.mkdirSync(path.dirname(assetsDir), { recursive: true });
858
1198
  const extractParent = fs.mkdtempSync(path.join(path.dirname(assetsDir), '.corpus-seed-extract-'));
859
1199
  await extractZip(archiveFile, extractParent);
860
- normalizeExtractedCorpus({ extractedDir: extractParent, assetsDir });
1200
+ try {
1201
+ normalizeExtractedCorpus({ extractedDir: extractParent, assetsDir });
1202
+ } catch (error) {
1203
+ if (!(error instanceof SeedLedgerIncompatibleError)) throw error;
1204
+ // Nothing was moved; leave --assets exactly as absent/empty as it was so the one bootstrap retry
1205
+ // in corpus-seed.yml can reuse the same path. A distinct exit code, never a generic failure.
1206
+ fs.rmSync(extractParent, { recursive: true, force: true });
1207
+ stderr.write(`${error.message}\n[corpus-reconcile] seed ${seedTag} cannot be consumed by this runtime; `
1208
+ + `exiting ${SEED_LEDGER_INCOMPATIBLE_EXIT} so the caller can fall back to the committed bootstrap seed\n`);
1209
+ return SEED_LEDGER_INCOMPATIBLE_EXIT;
1210
+ }
861
1211
  const privateFence = path.join(root, 'kb', 'PRIVATE-STORES.json');
862
1212
  if (!fs.existsSync(privateFence)) fail(`canonical private-store fence missing (${privateFence})`);
863
1213
  fs.copyFileSync(privateFence, path.join(assetsDir, 'PRIVATE-STORES.json'), fs.constants.COPYFILE_EXCL);
864
1214
  fs.rmSync(extractParent, { recursive: true, force: true });
865
1215
  syncCorpusInputs({ root, assetsDir });
866
1216
  const bootstrapIdentity = { tag: bootstrap.tag, sha256: bootstrap.sha256, privateFenceEvidence: seedPrivateFenceEvidence(assetsDir) };
867
- const { reconciliation, candidate } = await reconcileAndPrepareCorpusCandidate({
1217
+ const { reconciliation, candidate, noChange = false } = await reconcileAndPrepare({
868
1218
  assetsDir, workspaceDir, root, owner, builderSha, candidateDir, receiptFile, coverageFile, bootstrapIdentity,
869
- accuracyOracleFile, accuracyStores, accuracySample, accuracyTimeoutMs,
1219
+ accuracyOracleFile, accuracyStores, accuracySample, accuracySamplePerPartition, accuracyTimeoutMs,
870
1220
  });
871
- const plan = reconciliation.rounds.flatMap((round) => round.plan);
872
- process.stdout.write(`${JSON.stringify({ ok: true, seedTag, seedSha256, plan, reconciliation, ...candidate }, null, 2)}\n`);
1221
+ if (noChangeOut) {
1222
+ fs.mkdirSync(path.dirname(path.resolve(noChangeOut)), { recursive: true });
1223
+ fs.writeFileSync(path.resolve(noChangeOut), `${JSON.stringify({
1224
+ noChange: noChange === true,
1225
+ knowledgeInputSha256: noChange === true ? reconciliation?.knowledgeInput?.tonightSha256 ?? null : null,
1226
+ observationSha256: reconciliation?.observation?.observationSha256 ?? null,
1227
+ })}\n`);
1228
+ }
1229
+ if (noChange === true) {
1230
+ stdout.write(`${JSON.stringify({ ok: true, noChange: true, seedTag, seedSha256,
1231
+ knowledgeInput: reconciliation?.knowledgeInput ?? null }, null, 2)}\n`);
1232
+ return 0;
1233
+ }
1234
+ const { plan } = summarizeReconciliation(reconciliation);
1235
+ const degraded = candidate.degraded || { carried: [], missing: [] };
1236
+ const isDegraded = degraded.carried.length + degraded.missing.length > 0;
1237
+ const publication = isDegraded ? degradedPublication() : { allowed: true, reason: 'every eligible row is CURRENT' };
1238
+ stdout.write(`${JSON.stringify({ ok: publication.allowed, seedTag, seedSha256, plan, reconciliation, ...candidate,
1239
+ degraded: { ...degraded, publishable: publication.allowed, reason: publication.reason } }, null, 2)}\n`);
1240
+ if (!publication.allowed) {
1241
+ stderr.write(`::warning title=Degraded corpus generation sealed, not published::${degraded.carried.length} carried `
1242
+ + `(${degraded.carried.map((row) => row.store).join(', ') || 'none'}), ${degraded.missing.length} missing `
1243
+ + `(${degraded.missing.map((row) => row.store).join(', ') || 'none'}); ${publication.reason}\n`);
1244
+ return DEGRADED_UNPUBLISHED_EXIT;
1245
+ }
873
1246
  return 0;
874
1247
  }
875
1248