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.
@@ -17,9 +17,17 @@
17
17
  // scripts/corpus-candidate.mjs --verify (CLI) and verifySeedBaseline
18
18
  // scripts/release.mjs --corpus-seed (CLI, the real future product-consumption path)
19
19
  //
20
- // WHAT IS BOUNDED, AND SAID OUT LOUD: `--repos N` (default 2) and `--gists M` (default 3). A
20
+ // WHAT IS BOUNDED, AND SAID OUT LOUD: `--repos N` (default 2) and `--gists M` (default 3). The C3
21
+ // diagnostic measures the same deterministic question sample corpus-seed.yml passes
22
+ // (`--accuracy-sample`, default C3_DIAGNOSTIC_SAMPLE_QUESTIONS; `--accuracy-sample full` for the
23
+ // whole oracle, which is ~1,164 queries against the full seed and runs well over an hour). A
21
24
  // rehearsal receipt ALWAYS carries `bounds` and the banner below so nobody can mistake a 2-store
22
- // rehearsal for a 194-store corpus build. Everything else is the production code path.
25
+ // rehearsal for a 194-store corpus build. Two further declared bounds keep the unsampled seed stores
26
+ // from being pruned (which would fail the repo-recall gate for every one of them, ADR-0091 V1): seed
27
+ // stores with a ledger sourceCommit stay in the observation FROZEN at that commit (not rebuilt, not
28
+ // pruned), and the repo-recall fixture in the disposable checkout is scoped to the repositories the
29
+ // candidate carries (seed stores with no sourceCommit cannot be frozen and are out of scope). Both are
30
+ // counted in the receipt. Everything else is the production code path.
23
31
  //
24
32
  // SAFETY — publication mutations are RECORDED, NEVER EXECUTED, and the interception is PROVEN:
25
33
  // 1. The explicit seam the code offers (`RUVNET_GH_COMMAND` / `RUVNET_GH_SCRIPT`, read by
@@ -35,7 +43,21 @@
35
43
  //
36
44
  // Usage:
37
45
  // node scripts/rehearse-corpus-pipeline.mjs [--repos 2] [--gists 3] [--generations 2]
38
- // [--receipt <file>] [--seed <local ruvnet-brain.zip>] [--keep] [--tamper <mode>]
46
+ // [--accuracy-sample <n>|full] [--receipt <file>] [--seed <local ruvnet-brain.zip>] [--keep] [--tamper <mode>]
47
+ // [--no-seed-selection] [--no-code-release-between] [--no-code-release-consumption]
48
+ // [--inject-store-failure transient|qa] [--inject-generation <n>] [--recall-fixture scoped|committed]
49
+ //
50
+ // ADR-0091 D7: `--recall-fixture committed` keeps the COMMITTED 182-question fixture unedited instead of
51
+ // scoping it. The fixture repositories the bounded observation drops then have no row in a complete
52
+ // sealed observation, so the production repo-recall gate RETIRES them (D7.2) -- the rehearsal asserts it
53
+ // retired exactly those and asked the rest, and every reader re-verifies the claim from coverage (D7.3).
54
+ // Every candidate's manifest.json `corpus` block (D7.1) is checked against its sealed coverage in both modes.
55
+ //
56
+ // ADR-0091 D4 (V4): from generation 2 on, the seed is chosen by the REAL scripts/corpus-next-seed.mjs
57
+ // resolver from a local registry holding generation N (exactly the files release.mjs tried to upload)
58
+ // and two newer INCOMPATIBLE decoys, after a simulated code release has moved the checkout's runtime
59
+ // version. Every candidate's runtime surface is then checked by the real verifyApprovedRuntime against
60
+ // a pin traced to the checkout's own bytes. See scripts/rehearse-seed-selection.mjs.
39
61
  //
40
62
  // Done is an exit code, not an opinion: FAIL exits non-zero. A phase that could not run is a SKIP
41
63
  // with a stated reason, never a silent pass.
@@ -52,6 +74,13 @@ export const REAL_ROOT = path.resolve(HERE, '..');
52
74
  const BANNER = 'BOUNDED REHEARSAL — a small explicitly-bounded subset, NOT a full corpus build';
53
75
  const HEX64 = /^[a-f0-9]{64}$/;
54
76
  const TAMPER_MODES = new Set(['none', 'extracted-byte', 'archive-byte', 'bypass-interception']);
77
+ // ADR-0091 D5 (V5): inject ONE store failure into a real reconciliation. `transient` fails the target's
78
+ // first `git clone` (it must be retried once, in a fresh worker directory, and recover); `qa` answers the
79
+ // target's forge-refresh with corpus-qa's refusal status without running it (it must NOT be retried, and
80
+ // the store must be carried at its re-hashed seed bytes as STALE + carry). Both are synthetic, declared
81
+ // in the receipt, and applied at the process-runner seam executeReconciliation already exposes.
82
+ const INJECTION_MODES = new Set(['none', 'transient', 'qa']);
83
+ const RECALL_FIXTURE_MODES = new Set(['scoped', 'committed']);
55
84
  const PROBE_ARG = '__rehearsal_probe__';
56
85
 
57
86
  // Every `gh`/`npm` invocation that would MUTATE anything outside this process. Matched against the
@@ -407,10 +436,33 @@ export function acquireSeed({ descriptor, repo, downloadDir, env, localCandidate
407
436
  * downstream — planning, cloning, embedding, indexing, pruning, aggregation, assembly, sealing —
408
437
  * is the production code path over that smaller universe.
409
438
  */
410
- export function boundObservation({ observation, api, repoStores, gistCount }) {
411
- const chosen = repoStores.length
412
- ? observation.repositories.rows.filter((row) => repoStores.includes(String(row.storeName || row.name).toLowerCase()))
439
+ export function boundObservation({ observation, api, repoStores, gistCount, frozenSourceCommits = null }) {
440
+ const storeOf = (row) => String(row.storeName || row.name).toLowerCase();
441
+ const sampled = repoStores.length
442
+ ? observation.repositories.rows.filter((row) => repoStores.includes(storeOf(row)))
443
+ : [];
444
+ // FROZEN AT SEED (2026-09-28, ADR-0091 V1). Observing ONLY the sampled repositories makes every
445
+ // other seed store "not observed this round", and the production prune (correctly, for a real
446
+ // nightly that always observes everything) deletes it -- after which the repo-recall gate, which
447
+ // asks one frozen question of every fixture repository, fails 180/182 with `rvf not found`. So the
448
+ // rehearsal keeps every OTHER repository the seed already carries in the observation, with its
449
+ // head pinned to the seed's own ledger sourceCommit: planReconciliation sees it CURRENT (nothing to
450
+ // rebuild) and pruneIneligibleStores sees it eligible (nothing to delete). Repositories the seed does
451
+ // not carry are dropped, so a brand-new upstream repo can never turn a minutes-long rehearsal into a
452
+ // full clone-and-embed. This rewrite is a declared rehearsal bound, recorded in the receipt; the
453
+ // pipeline's own observation, planning and pruning code is untouched.
454
+ const frozen = frozenSourceCommits
455
+ ? observation.repositories.rows
456
+ .filter((row) => !repoStores.includes(storeOf(row)) && frozenSourceCommits[storeOf(row)])
457
+ .map((row) => ({
458
+ ...row,
459
+ defaultBranchRef: {
460
+ name: row.defaultBranchRef?.name || 'main',
461
+ target: { oid: frozenSourceCommits[storeOf(row)], committedDate: null },
462
+ },
463
+ }))
413
464
  : [];
465
+ const chosen = [...sampled, ...frozen];
414
466
  const gistRows = observation.gists.rows.slice(0, gistCount);
415
467
  return api.canonicalSourceObservation({
416
468
  schemaVersion: observation.schemaVersion,
@@ -422,6 +474,41 @@ export function boundObservation({ observation, api, repoStores, gistCount }) {
422
474
  });
423
475
  }
424
476
 
477
+ /** The seed ledger's own sourceCommit for every repository store OTHER than the sampled ones —
478
+ * the commits boundObservation pins those stores to. Aggregates (ruv-gists, concepts) are rebuilt
479
+ * from nothing every round and are never repository rows, so they are excluded. */
480
+ export function frozenSeedCommits(assetsDir, repoStores) {
481
+ const ledger = JSON.parse(fs.readFileSync(path.join(assetsDir, 'RVF-GENERATIONS.json'), 'utf8'));
482
+ const out = {};
483
+ for (const [store, row] of Object.entries(ledger.stores || {})) {
484
+ const folded = store.toLowerCase();
485
+ if (['ruv-gists', 'concepts'].includes(folded) || repoStores.includes(folded)) continue;
486
+ const sha = String(row?.sourceCommit || '').toLowerCase();
487
+ if (/^[a-f0-9]{40}$/.test(sha)) out[folded] = sha;
488
+ }
489
+ return out;
490
+ }
491
+
492
+ /**
493
+ * Scope the frozen repo-recall fixture to the repositories THIS bounded candidate carries (ADR-0091
494
+ * V1). A seed store whose ledger sourceCommit is null (57 of 182 in the v4.3.26 seed) cannot be frozen
495
+ * at a commit: the real nightly rebuilds it from upstream, which is hours, not minutes. Out of the
496
+ * rehearsal's scope, it is pruned, and asking the fixture question of it could only report
497
+ * `rvf not found`. Every in-scope question is kept byte for byte. Returns the scoped fixture document
498
+ * plus exactly what was excluded, so the receipt says it out loud.
499
+ */
500
+ export function scopeRecallFixture({ fixture, inScopeStores }) {
501
+ const scope = new Set([...inScopeStores].map((store) => String(store).toLowerCase()));
502
+ const kept = {};
503
+ const excluded = [];
504
+ for (const [store, row] of Object.entries(fixture.queries || {})) {
505
+ if (scope.has(store.toLowerCase())) kept[store] = row;
506
+ else excluded.push(store);
507
+ }
508
+ if (!Object.keys(kept).length) fail('scoping the repo-recall fixture left no questions; the bounded scope carries no fixture repository');
509
+ return { fixture: { ...fixture, queries: kept }, kept: Object.keys(kept).length, excluded: excluded.sort() };
510
+ }
511
+
425
512
  /** Deterministically pick the `count` smallest ELIGIBLE repositories, measured by the observation's
426
513
  * own diskUsage, so a rehearsal clones and embeds real upstream source in minutes. Eligibility is
427
514
  * decided by the production classifier (`buildCoverage`), never by a guess here. */
@@ -548,6 +635,56 @@ export async function verifyExtractedBytesWithoutOriginals({
548
635
  // rehearse publication with the recorder, and emit candidate N as the seed for generation N+1.
549
636
  // ---------------------------------------------------------------------------------------------
550
637
 
638
+ // The rehearsal's record of one generation's reconciliation. It never reads the acquisition history
639
+ // itself: it uses the disposable checkout's own summarizeReconciliation, the same reader main() uses
640
+ // (ADR-0091 D1). Its private copy of the old `rounds` read crashed exactly where main() did.
641
+ export function recordReconciliation({ summarize, reconciliation, durationMs }) {
642
+ const summary = summarize(reconciliation);
643
+ return {
644
+ attempts: summary.attempts,
645
+ observationSha256: summary.observationSha256,
646
+ refreshed: summary.refreshed,
647
+ pruned: summary.pruned.length,
648
+ rebuilt: summary.rebuilt,
649
+ durationMs,
650
+ };
651
+ }
652
+
653
+ /**
654
+ * ADR-0091 D7, read from the REAL sealed artifacts of one generation (never recomputed from inputs):
655
+ * D7.1 the assembled manifest.json `corpus` block names the sealed observation's observedAt (not the
656
+ * assembly time) and counts exactly the sealed coverage's eligible repository rows;
657
+ * D7.2 in `committed` mode the recall report retired EXACTLY the fixture repositories the bounded
658
+ * observation dropped, asked every other one, and bound the claim to the sealed coverage bytes.
659
+ */
660
+ export function checkGenerationCurrency({ checkoutRoot, candidateDir, bundleFile, committedFixture, expectedRetired, expectedAsked }) {
661
+ const coverageFile = path.join(checkoutRoot, 'data', 'source-coverage.json');
662
+ const coverage = JSON.parse(fs.readFileSync(coverageFile, 'utf8'));
663
+ const corpus = JSON.parse(fs.readFileSync(path.join(candidateDir, 'manifest.json'), 'utf8')).corpus;
664
+ const eligible = coverage.rows.filter((row) => row.kind === 'repository' && row.disposition === 'eligible');
665
+ const count = (status) => eligible.filter((row) => row.status === status).length;
666
+ const problems = [];
667
+ if (!corpus || corpus.basis !== 'sealed-observation') problems.push(`manifest.corpus basis is ${corpus?.basis}`);
668
+ if (corpus?.observedAt !== coverage.observedAt) problems.push(`manifest.corpus.observedAt ${corpus?.observedAt} is not the sealed observation's ${coverage.observedAt}`);
669
+ const counts = corpus?.counts || {};
670
+ for (const [key, status] of [['current', 'CURRENT'], ['stale', 'STALE'], ['missing', 'MISSING'], ['unverified', 'UNVERIFIED']]) {
671
+ if (counts[key] !== count(status)) problems.push(`manifest.corpus.counts.${key}=${counts[key]}, sealed coverage has ${count(status)}`);
672
+ }
673
+ if (counts.eligible !== eligible.length) problems.push(`manifest.corpus.counts.eligible=${counts.eligible}, sealed coverage has ${eligible.length}`);
674
+ const report = JSON.parse(fs.readFileSync(`${bundleFile}.recall.json`, 'utf8'));
675
+ const want = [...new Set(expectedRetired.map((store) => store.toLowerCase()))].sort();
676
+ const got = report.retirement?.stores ?? [];
677
+ if (committedFixture) {
678
+ if (JSON.stringify(got) !== JSON.stringify(want)) problems.push(`recall retired [${got.join(', ')}], expected exactly [${want.join(', ')}]`);
679
+ if (report.totals.questions !== expectedAsked) problems.push(`recall asked ${report.totals.questions}, expected ${expectedAsked}`);
680
+ if (want.length && report.retirement?.coverageSha256 !== sha256File(coverageFile)) problems.push('recall retirement is not bound to the sealed coverage bytes');
681
+ if (counts.retired !== want.length) problems.push(`manifest.corpus.counts.retired=${counts.retired}, expected ${want.length}`);
682
+ } else if (got.length) problems.push(`a scoped fixture retired [${got.join(', ')}]; nothing should retire`);
683
+ if (problems.length) fail(`ADR-0091 D7 generation checks failed: ${problems.join('; ')}`);
684
+ return { manifestCorpus: corpus, recall: { questions: report.totals.questions, retired: report.totals.retired ?? 0,
685
+ retiredStores: got, coverageSha256: report.retirement?.coverageSha256 ?? null } };
686
+ }
687
+
551
688
  async function runGeneration({ index, api, checkoutRoot, workRoot, seed, bounds, recorder, tamper, log }) {
552
689
  const generation = { index, startedAt: new Date().toISOString(), seed: { tag: seed.tag, sha256: seed.sha256, bytes: seed.bytes, channel: seed.channel } };
553
690
  const assetsDir = path.join(workRoot, `assets-gen${index}`);
@@ -571,8 +708,11 @@ async function runGeneration({ index, api, checkoutRoot, workRoot, seed, bounds,
571
708
  api.syncCorpusInputs({ root: checkoutRoot, assetsDir });
572
709
  const bootstrapIdentity = { tag: bootstrap.tag, sha256: bootstrap.sha256,
573
710
  privateFenceEvidence: api.seedPrivateFenceEvidence(assetsDir) };
574
- generation.seedImport = { assetsDir, storesInSeed: Object.keys(
575
- JSON.parse(fs.readFileSync(path.join(assetsDir, 'RVF-GENERATIONS.json'), 'utf8')).stores || {}).length };
711
+ const seedLedger = JSON.parse(fs.readFileSync(path.join(assetsDir, 'RVF-GENERATIONS.json'), 'utf8'));
712
+ generation.seedImport = { assetsDir, storesInSeed: Object.keys(seedLedger.stores || {}).length,
713
+ // normalizeExtractedCorpus refused the import unless this runtime can consume the ledger (ADR-0091 D4).
714
+ ledgerSchema: { schemaVersion: seedLedger.schemaVersion, kind: seedLedger.kind, brainVersion: seedLedger.brainVersion ?? null,
715
+ verdict: 'consumable by this runtime' } };
576
716
  log(`[gen ${index}] seed imported: ${generation.seedImport.storesInSeed} stores in the ledger`);
577
717
 
578
718
  // --- bounded observation over the REAL live source universe ---------------------------------
@@ -591,40 +731,128 @@ async function runGeneration({ index, api, checkoutRoot, workRoot, seed, bounds,
591
731
  // seed RVF bytes so planReconciliation must rebuild them from upstream. Without this a seed whose
592
732
  // stores are already CURRENT would exercise no acquisition at all, and the rehearsal would prove
593
733
  // nothing about the expensive half of the pipeline.
734
+ const injection = bounds.injectStoreFailure !== 'none' && index === bounds.injectGeneration ? bounds.injectStoreFailure : 'none';
735
+ let injectTarget = null;
736
+ if (injection !== 'none') {
737
+ const upstreamOf = (store) => full.repositories.rows.find((row) => String(row.storeName || row.name).toLowerCase() === store)
738
+ ?.defaultBranchRef?.target?.oid?.toLowerCase() || null;
739
+ const seedCommitOf = (store) => String(Object.entries(seedLedger.stores || {})
740
+ .find(([name]) => name.toLowerCase() === store)?.[1]?.sourceCommit || '').toLowerCase();
741
+ // A carry needs seed bytes at a commit that DIFFERS from upstream; a transient retry needs neither.
742
+ const carryable = (store) => /^[0-9a-f]{40}$/.test(seedCommitOf(store)) && upstreamOf(store) && seedCommitOf(store) !== upstreamOf(store);
743
+ injectTarget = injection === 'transient' ? repoStores[0] : repoStores.find(carryable) || null;
744
+ if (!injectTarget && injection === 'qa') {
745
+ // The smallest bounded stores are often unchanged since the seed. A QA-injected store is never
746
+ // embedded (its forge-refresh is answered synthetically), so adding the smallest carryable seed
747
+ // store to the bounded set costs one clone. Declared in the receipt.
748
+ const extra = [...full.repositories.rows]
749
+ .sort((a, b) => (a.diskUsage ?? Number.MAX_SAFE_INTEGER) - (b.diskUsage ?? Number.MAX_SAFE_INTEGER))
750
+ .map((row) => String(row.storeName || row.name).toLowerCase())
751
+ .find((store) => !repoStores.includes(store) && carryable(store)) || null;
752
+ if (extra) {
753
+ repoStores.push(extra);
754
+ generation.bounded.repositories = repoStores;
755
+ generation.bounded.addedForInjection = extra;
756
+ injectTarget = extra;
757
+ }
758
+ }
759
+ if (!injectTarget) {
760
+ fail(`--inject-store-failure ${injection}: no observed store has seed bytes at a commit that differs from upstream, `
761
+ + 'so nothing could be carried');
762
+ }
763
+ generation.injection = { mode: injection, target: injectTarget, synthetic: true, events: [] };
764
+ log(`[gen ${index}] INJECTING a synthetic ${injection} failure into store ${injectTarget}`);
765
+ }
594
766
  if (bounds.forceRebuild) {
595
- for (const store of repoStores) fs.rmSync(path.join(assetsDir, `${store}.big.rvf`), { force: true });
596
- generation.forcedRebuild = repoStores;
767
+ // A QA-injected store keeps its seed bytes: they are what the carry must re-hash and keep.
768
+ const forced = repoStores.filter((store) => !(injection === 'qa' && store === injectTarget));
769
+ for (const store of forced) fs.rmSync(path.join(assetsDir, `${store}.big.rvf`), { force: true });
770
+ generation.forcedRebuild = forced;
597
771
  }
598
772
 
599
- const boundedObserve = () => boundObservation({ observation: observeFull(), api, repoStores, gistCount: bounds.gists });
773
+ // Every other seed store stays in scope FROZEN at its seed sourceCommit (see boundObservation), so
774
+ // the production prune leaves it alone and the repo-recall gate can ask its fixture question.
775
+ const frozenSourceCommits = frozenSeedCommits(assetsDir, repoStores);
776
+ generation.bounded.frozenAtSeed = Object.keys(frozenSourceCommits).length;
777
+ log(`[gen ${index}] ${generation.bounded.frozenAtSeed} other seed store(s) kept in scope frozen at their seed sourceCommit (not rebuilt, not pruned)`);
778
+ const boundedObserve = () => boundObservation({
779
+ observation: observeFull(), api, repoStores, gistCount: bounds.gists, frozenSourceCommits });
780
+
781
+ // The repo-recall gate asks one question per fixture repository. Scope it -- in the DISPOSABLE
782
+ // checkout only, where repo-recall, corpus-candidate --verify and release.mjs all read the same file
783
+ // -- to the repositories this bounded observation keeps. The committed original is preserved beside
784
+ // it so every generation scopes from the same source.
785
+ const fixtureFile = path.join(checkoutRoot, 'data', 'retrieval-query-evidence.json');
786
+ const originalFixtureFile = path.join(workRoot, 'retrieval-query-evidence.committed.json');
787
+ if (!fs.existsSync(originalFixtureFile)) fs.copyFileSync(fixtureFile, originalFixtureFile);
788
+ const inScope = boundObservation({ observation: full, api, repoStores, gistCount: 0, frozenSourceCommits })
789
+ .repositories.rows.map((row) => String(row.storeName || row.name));
790
+ const scoped = scopeRecallFixture({
791
+ fixture: JSON.parse(fs.readFileSync(originalFixtureFile, 'utf8')), inScopeStores: inScope });
792
+ const committedFixture = bounds.recallFixture === 'committed';
793
+ // ADR-0091 D7: in `committed` mode the fixture is NOT edited; the out-of-scope repositories are
794
+ // retired by the production gate instead, which the checks after sealing hold to exactly this list.
795
+ if (!committedFixture) fs.writeFileSync(fixtureFile, `${JSON.stringify(scoped.fixture, null, 2)}\n`);
796
+ generation.bounded.recallFixture = { mode: bounds.recallFixture, questions: scoped.kept, excluded: scoped.excluded.length,
797
+ excludedStores: scoped.excluded,
798
+ reason: 'no seed sourceCommit to freeze at (the real nightly rebuilds these from upstream) or not observed upstream' };
799
+ log(committedFixture
800
+ ? `[gen ${index}] repo-recall fixture kept COMMITTED and unedited: ${scoped.excluded.length} out-of-scope repositories must be RETIRED by the gate (ADR-0091 D7), ${scoped.kept} asked`
801
+ : `[gen ${index}] repo-recall fixture scoped to ${scoped.kept} in-scope repositories (${scoped.excluded.length} out of scope, listed in the receipt)`);
600
802
 
601
803
  // --- reconcile + assemble ONCE --------------------------------------------------------------
602
804
  const invocations = [];
603
805
  const recordingRun = (command, args, options = {}) => {
604
- invocations.push({ command, script: path.basename(String(args?.[0] || '')), args: args.map(String) });
605
- return spawnSync(command, args, { encoding: 'utf8', ...options });
806
+ const row = { command, script: path.basename(String(args?.[0] || '')), args: args.map(String) };
807
+ invocations.push(row);
808
+ const began = Date.now();
809
+ const result = spawnSync(command, args, { encoding: 'utf8', ...options });
810
+ row.durationMs = Date.now() - began;
811
+ row.exit = result.status ?? null;
812
+ return result;
813
+ };
814
+ const injectingRun = (command, args, options) => {
815
+ const events = generation.injection?.events;
816
+ const target = generation.injection?.target;
817
+ const clone = command === 'git' && args[0] === 'clone';
818
+ const cloneDir = clone ? String(args.at(-1)) : '';
819
+ if (target && clone && path.basename(path.dirname(cloneDir)) === target && injection === 'transient') {
820
+ events.push({ event: 'git clone', dir: path.relative(workRoot, cloneDir), injected: 'transient failure (exit 128)' });
821
+ return Promise.resolve({ status: 128, stdout: '', stderr: '[rehearsal] injected transient failure: connection reset' });
822
+ }
823
+ if (target && clone && path.basename(path.dirname(cloneDir)).startsWith(`${target}-retry`)) {
824
+ events.push({ event: 'git clone', dir: path.relative(workRoot, cloneDir), injected: null });
825
+ }
826
+ const forge = command === process.execPath && String(args[0]).endsWith(`${path.sep}forge-refresh.mjs`);
827
+ if (target && forge && args[args.indexOf('--name') + 1] === target) {
828
+ if (injection === 'qa') {
829
+ events.push({ event: 'forge-refresh', out: path.relative(workRoot, args[args.indexOf('--out') + 1]),
830
+ injected: `corpus-qa refusal (exit ${api.CORPUS_QA_FAILED_EXIT}), forge not run` });
831
+ return Promise.resolve({ status: api.CORPUS_QA_FAILED_EXIT, stdout: '', stderr: '[rehearsal] injected corpus-qa refusal' });
832
+ }
833
+ events.push({ event: 'forge-refresh', out: path.relative(workRoot, args[args.indexOf('--out') + 1]), injected: null });
834
+ }
835
+ return api.defaultRunAsync(command, args, options);
606
836
  };
607
837
  const reconcileStart = Date.now();
608
838
  const { reconciliation, candidate } = await api.reconcileAndPrepareCorpusCandidate({
609
839
  assetsDir, workspaceDir, root: checkoutRoot, owner: bounds.owner, builderSha: bounds.builderSha,
610
840
  candidateDir, receiptFile, coverageFile: path.join(checkoutRoot, 'data', 'source-coverage.json'),
611
- bootstrapIdentity, maxRounds: bounds.maxRounds,
612
- reconcile: (options) => api.acquireCorpusGeneration({ ...options, observe: boundedObserve }),
841
+ // reconcileAndPrepareCorpusCandidate reads `maxAttempts`; `maxRounds` was silently ignored after
842
+ // cd0f032f, so --max-rounds never reached the acquisition loop.
843
+ bootstrapIdentity, maxAttempts: bounds.maxRounds, accuracySample: bounds.accuracyQuestions,
844
+ reconcile: (options) => api.acquireCorpusGeneration({ ...options, observe: boundedObserve,
845
+ ...(injection !== 'none' ? { execute: (executeOptions) => api.executeReconciliation({ ...executeOptions, run: injectingRun }) } : {}) }),
613
846
  prepare: (options) => api.prepareCorpusCandidate({ ...options, run: recordingRun }),
614
847
  });
615
- generation.reconciliation = {
616
- rounds: reconciliation.rounds.length,
617
- observationSha256: reconciliation.observation.observationSha256,
618
- refreshed: reconciliation.rounds.flatMap((round) => round.refreshed || []),
619
- pruned: reconciliation.rounds.flatMap((round) => round.pruned || []).length,
620
- rebuilt: reconciliation.rounds.flatMap((round) => round.rebuilt || []),
621
- durationMs: Date.now() - reconcileStart,
622
- };
848
+ generation.reconciliation = recordReconciliation({
849
+ summarize: api.summarizeReconciliation, reconciliation, durationMs: Date.now() - reconcileStart });
623
850
  const assemblyInvocations = invocations.filter((row) => row.script === 'build-bundle.mjs');
624
851
  generation.assembly = {
625
852
  assembleBundleInvocations: assemblyInvocations.length,
626
853
  receiptInvocations: invocations.filter((row) => row.script === 'corpus-candidate.mjs').length,
627
854
  bundleFile: candidate.bundleFile,
855
+ steps: invocations.map(({ script, durationMs, exit }) => ({ script, durationMs, exit })),
628
856
  };
629
857
  if (assemblyInvocations.length !== 1) {
630
858
  fail(`assembly must happen exactly once per candidate; build-bundle.mjs ran ${assemblyInvocations.length} time(s)`);
@@ -649,6 +877,38 @@ async function runGeneration({ index, api, checkoutRoot, workRoot, seed, bounds,
649
877
  generation.candidate.storeCount = receipt.storeCount;
650
878
  generation.candidate.stores = receipt.stores.map(({ name, kind }) => ({ name, kind }));
651
879
  log(`[gen ${index}] sealed candidate ${archiveSha256.slice(0, 16)} — ${receipt.storeCount} stores, ${archiveBytes} bytes`);
880
+ generation.degraded = candidate.degraded || { carried: [], missing: [] };
881
+ if (injection !== 'none') {
882
+ const sealedCoverage = JSON.parse(fs.readFileSync(path.join(checkoutRoot, 'data', 'source-coverage.json'), 'utf8'));
883
+ const row = sealedCoverage.rows.find((entry) => entry.kind === 'repository' && String(entry.artifact?.store).toLowerCase() === injectTarget);
884
+ generation.injection.sealedRow = row ? { status: row.status, carry: row.carry ?? null, failure: row.failure ?? null,
885
+ artifactSourceCommit: row.artifact?.sourceCommit ?? null, upstreamSha: row.upstream?.sha ?? null } : null;
886
+ const events = generation.injection.events;
887
+ const problems = [];
888
+ if (injection === 'qa') {
889
+ const carried = generation.degraded.carried.find((entry) => entry.store === injectTarget);
890
+ if (!carried || carried.attempts !== 1 || !/^qa: /.test(carried.reason)) problems.push(`${injectTarget} was not carried once as a QA failure (${JSON.stringify(carried || null)})`);
891
+ if (events.filter((entry) => entry.event === 'forge-refresh').length !== 1) problems.push('a QA failure was retried');
892
+ if (row?.status !== 'STALE' || !row?.carry) problems.push(`the sealed coverage row is ${row?.status || 'absent'}${row?.carry ? '' : ' with no carry record'}, not STALE + carry`);
893
+ } else {
894
+ if (!generation.reconciliation.refreshed.includes(injectTarget)) problems.push(`${injectTarget} did not recover on its retry`);
895
+ const clones = events.filter((entry) => entry.event === 'git clone');
896
+ if (clones.length !== 2 || !clones[1].dir.includes(`${injectTarget}-retry1`)) problems.push(`expected one failed clone then one retry in ${injectTarget}-retry1 (${JSON.stringify(clones)})`);
897
+ if (generation.degraded.carried.length || generation.degraded.missing.length) problems.push('a recovered transient failure left the generation degraded');
898
+ if (row?.status !== 'CURRENT') problems.push(`the recovered store's sealed row is ${row?.status}, not CURRENT`);
899
+ }
900
+ generation.injection.verdict = problems.length ? 'FAIL' : 'PASS';
901
+ if (problems.length) fail(`injected ${injection} failure did not behave as ADR-0091 D5 requires: ${problems.join('; ')}`);
902
+ log(`[gen ${index}] injected ${injection} failure in ${injectTarget} behaved as required: `
903
+ + `${injection === 'qa' ? 'not retried, carried at its seed bytes, sealed row STALE + carry' : 'retried once in a fresh directory and recovered'}`);
904
+ }
905
+
906
+ // --- ADR-0091 D7: the manifest's currency block and the recall gate's retirement, from real bytes --
907
+ generation.d7 = checkGenerationCurrency({ checkoutRoot, candidateDir, bundleFile,
908
+ committedFixture, expectedRetired: scoped.excluded, expectedAsked: scoped.kept });
909
+ log(`[gen ${index}] ADR-0091 D7: manifest.corpus observedAt=${generation.d7.manifestCorpus.observedAt} `
910
+ + `counts=${JSON.stringify(generation.d7.manifestCorpus.counts)}; recall asked ${generation.d7.recall.questions}, `
911
+ + `retired ${generation.d7.recall.retired}`);
652
912
 
653
913
  // --- remove access to the originals, then verify the extracted bytes ------------------------
654
914
  generation.extractedByteVerification = await verifyExtractedBytesWithoutOriginals({
@@ -657,8 +917,20 @@ async function runGeneration({ index, api, checkoutRoot, workRoot, seed, bounds,
657
917
  });
658
918
  log(`[gen ${index}] extracted-byte verification passed with staging revoked (${generation.extractedByteVerification.files} files)`);
659
919
 
660
- // --- rehearse the real product-consumption path, publication RECORDED not executed -----------
920
+ // --- ADR-0091 D5 + D10: a degraded generation is sealed but not published until D10 allows it ---
661
921
  const corpusTag = `corpus-sha256-${archiveSha256}`;
922
+ if (generation.degraded.carried.length || generation.degraded.missing.length) {
923
+ const decision = api.degradedPublication();
924
+ if (!decision.allowed) {
925
+ generation.publication = { status: 'WITHHELD-DEGRADED', corpusTag, reason: decision.reason,
926
+ carried: generation.degraded.carried.map((entry) => entry.store), missing: generation.degraded.missing.map((entry) => entry.store) };
927
+ generation.finishedAt = new Date().toISOString();
928
+ log(`[gen ${index}] degraded generation sealed and WITHHELD from publication: ${decision.reason}`);
929
+ return { generation, withheld: true, nextSeed: null, published: null };
930
+ }
931
+ }
932
+
933
+ // --- rehearse the real product-consumption path, publication RECORDED not executed -----------
662
934
  const proof = recorder.proveIntercepting();
663
935
  if (!proof.ok) {
664
936
  generation.publication = { status: 'FAIL', reason: 'command interception could not be proven; refusing to run a publication path that might reach the network', proof };
@@ -677,8 +949,13 @@ async function runGeneration({ index, api, checkoutRoot, workRoot, seed, bounds,
677
949
  GH_TOKEN: 'rehearsal-invalid-token-do-not-use',
678
950
  GITHUB_TOKEN: 'rehearsal-invalid-token-do-not-use',
679
951
  };
952
+ // ADR-0091 D6.2: the sealed coverage travels to the publisher the way corpus-seed.yml stages it
953
+ // (a copy of <checkout>/data/source-coverage.json beside the archive), and is published with it.
954
+ const stagedCoverage = path.join(path.dirname(bundleFile), 'source-coverage.json');
955
+ fs.copyFileSync(path.join(checkoutRoot, 'data', 'source-coverage.json'), stagedCoverage);
680
956
  const publish = run(process.execPath, [path.join(checkoutRoot, 'scripts', 'release.mjs'), '--corpus-seed',
681
957
  '--corpus-tag', corpusTag, '--corpus-bundle', bundleFile, '--corpus-receipt', candidate.receiptFile,
958
+ '--corpus-coverage', stagedCoverage,
682
959
  '--target', bounds.builderSha, '--repo', 'stuinfla/ruvnet-brain'], { cwd: checkoutRoot, env: publishEnv });
683
960
  const recordedNow = recorder.recorded().filter((row) => row.classification === 'publication-mutation');
684
961
  generation.publication = {
@@ -705,16 +982,244 @@ async function runGeneration({ index, api, checkoutRoot, workRoot, seed, bounds,
705
982
  const nextSeedFile = path.join(workRoot, `seed-gen${index + 1}`, path.basename(bundleFile));
706
983
  fs.mkdirSync(path.dirname(nextSeedFile), { recursive: true });
707
984
  fs.copyFileSync(bundleFile, nextSeedFile);
985
+ // The detached C3 and repo-recall reports travel WITH the archive, exactly as release.mjs publishes
986
+ // them (zip, sig, digest, receipt, .accuracy.json, .recall.json). verifySeedBaseline re-reads both
987
+ // beside the seed, so handing over the zip alone failed every generation-2 import ("detached
988
+ // repo-recall report missing") -- unseen until 2026-09-28 because no rehearsal had reached gen 2.
989
+ for (const suffix of ['.accuracy.json', '.recall.json']) {
990
+ const report = `${bundleFile}${suffix}`;
991
+ if (!fs.existsSync(report)) fail(`candidate ${index} has no detached ${suffix} report to hand to generation ${index + 1}`);
992
+ fs.copyFileSync(report, `${nextSeedFile}${suffix}`);
993
+ }
994
+ // ADR-0091 D7.3: the generation's sealed coverage travels with it, so the next import can verify a
995
+ // retirement its recall report claims (the published CORPUS-COVERAGE.json is these exact bytes).
996
+ const nextSeedCoverage = path.join(path.dirname(nextSeedFile), 'CORPUS-COVERAGE.json');
997
+ fs.copyFileSync(stagedCoverage, nextSeedCoverage);
708
998
  generation.nextSeed = { tag: corpusTag, sha256: archiveSha256, bytes: archiveBytes, file: nextSeedFile,
709
999
  contentAddressed: true };
710
1000
  generation.finishedAt = new Date().toISOString();
1001
+ const createRow = recordedNow.find((row) => row.args[0] === 'release' && row.args[1] === 'create' && row.args[2] === corpusTag);
711
1002
  return {
712
1003
  generation,
713
1004
  nextSeed: { file: nextSeedFile, tag: corpusTag, sha256: archiveSha256, bytes: archiveBytes,
714
- channel: `candidate-generation-${index}`, allowPinnedTag: false, receiptFile: candidate.receiptFile },
1005
+ channel: `candidate-generation-${index}`, allowPinnedTag: false, receiptFile: candidate.receiptFile, coverageFile: nextSeedCoverage },
1006
+ // ADR-0091 D4: exactly what release.mjs tried to upload, so the local registry invents nothing.
1007
+ published: { tag: corpusTag, createArgs: createRow ? createRow.args : [], receiptFile: candidate.receiptFile,
1008
+ recallFile: `${bundleFile}.recall.json`, accuracyFile: `${bundleFile}.accuracy.json`, sha256: archiveSha256, bytes: archiveBytes },
715
1009
  };
716
1010
  }
717
1011
 
1012
+ // ---------------------------------------------------------------------------------------------
1013
+ // ADR-0091 D4: seed selection through the real resolver (see scripts/rehearse-seed-selection.mjs)
1014
+ // ---------------------------------------------------------------------------------------------
1015
+
1016
+ /** Candidate N, "published" into the local registry from release.mjs's own upload list, plus two
1017
+ * newer incompatible decoys the resolver must walk past. */
1018
+ function publishLocally({ selectionMod, registryDir, published, workRoot, index }) {
1019
+ const files = selectionMod.uploadedFilesOf(published.createArgs);
1020
+ const names = files.map((file) => path.basename(file)).sort();
1021
+ const expectedNames = ['CORPUS-COVERAGE.json', 'corpus-receipt.json', 'coverage-receipt.json', 'ruvnet-brain.zip',
1022
+ 'ruvnet-brain.zip.accuracy.json', 'ruvnet-brain.zip.recall.json'];
1023
+ if (JSON.stringify(names) !== JSON.stringify(expectedNames)) {
1024
+ fail(`release.mjs's recorded upload for generation ${index} is [${names.join(', ')}], expected [${expectedNames.join(', ')}]`);
1025
+ }
1026
+ const createdAtMs = Date.now();
1027
+ selectionMod.registerGeneration({ registryDir, tag: published.tag, createdAt: new Date(createdAtMs).toISOString(), files,
1028
+ note: `generation ${index}, from the files release.mjs --corpus-seed tried to upload` });
1029
+ const decoys = selectionMod.registerIncompatibleDecoys({ registryDir, createdAtMs,
1030
+ scratchDir: path.join(workRoot, `decoys-gen${index}`),
1031
+ real: { sha256: published.sha256, bytes: published.bytes, receiptFile: published.receiptFile,
1032
+ recallFile: published.recallFile, accuracyFile: published.accuracyFile } });
1033
+ return { ...published, decoys };
1034
+ }
1035
+
1036
+ /**
1037
+ * What corpus-identity + corpus-seed.yml do on the nightly, against the local registry: resolve with
1038
+ * the REAL corpus-next-seed resolver (judged by this checkout's readers), prove it skipped both decoys
1039
+ * before any archive download, then "download" the chosen archive and re-check it exactly as
1040
+ * corpus-seed.yml does (sha256 + bytes, repo-recall at ABSOLUTE_FLOOR, C3 binding).
1041
+ */
1042
+ async function selectPublishedSeed({ index, api, selectionMod, checkoutRoot, workRoot, registryDir, repoSlug, expected, receipt, phase }) {
1043
+ const gh = selectionMod.registryGh(registryDir);
1044
+ const selection = await api.resolveNextCorpusSeed({ repo: repoSlug, root: checkoutRoot, run: gh.run, runtimeRoot: checkoutRoot });
1045
+ const consumingVersion = JSON.parse(fs.readFileSync(path.join(checkoutRoot, 'plugin', '.claude-plugin', 'plugin.json'), 'utf8')).version;
1046
+ const publishedReceipt = JSON.parse(fs.readFileSync(expected.receiptFile, 'utf8'));
1047
+ const record = {
1048
+ generation: index,
1049
+ selected: { origin: selection.seed.origin, tag: selection.seed.tag, builtUnder: selection.seed.brainVersion },
1050
+ consumingRuntime: consumingVersion,
1051
+ judged: selection.judged,
1052
+ expects: selection.expects,
1053
+ rejected: selection.rejected,
1054
+ ghCalls: gh.calls,
1055
+ archiveDownloadAttempts: gh.archiveDownloadAttempts().length,
1056
+ // The rule D4 removed, evaluated on the generation it would have judged. Recorded, not enforced.
1057
+ preD4RuntimeRule: publishedReceipt.archiveManifestVersion === consumingVersion ? 'would-accept'
1058
+ : `would-REJECT (generation shipped v${publishedReceipt.archiveManifestVersion}, consuming runtime is v${consumingVersion}) and reset to the bootstrap`,
1059
+ };
1060
+ receipt.seedSelection.push(record);
1061
+ const problems = [];
1062
+ if (selection.seed.origin !== 'published-generation' || selection.seed.tag !== expected.tag) {
1063
+ problems.push(`selected ${selection.seed.origin} ${selection.seed.tag}, expected the published generation ${expected.tag}`);
1064
+ }
1065
+ if (record.archiveDownloadAttempts !== 0) problems.push(`the resolver attempted ${record.archiveDownloadAttempts} archive download(s)`);
1066
+ for (const decoy of expected.decoys) {
1067
+ const row = selection.rejected.find((entry) => entry.tag === decoy.tag);
1068
+ const wanted = decoy.kind === 'model-mismatch' ? /^incompatible: .*different model\/dimensions/ : /^incompatible: recall report was measured against fixture/;
1069
+ if (!row || !wanted.test(row.reason)) problems.push(`decoy ${decoy.kind} was not skipped for its own reason (${row ? row.reason : 'not judged'})`);
1070
+ const recallFetched = gh.downloads().some((line) => line.includes(decoy.tag) && line.includes('--pattern ruvnet-brain.zip.recall.json '));
1071
+ if (decoy.kind === 'model-mismatch' && recallFetched) problems.push('the model-mismatch decoy was not rejected from its receipt alone');
1072
+ if (decoy.kind === 'fixture-mismatch' && !recallFetched) problems.push('the fixture-mismatch decoy was rejected without reading its recall report');
1073
+ }
1074
+ if (problems.length) {
1075
+ phase(`generation-${index}-seed-selection`, 'FAIL', { reason: problems.join('; ') });
1076
+ fail(`seed selection for generation ${index} failed: ${problems.join('; ')}`);
1077
+ }
1078
+
1079
+ // The download corpus-seed.yml performs, then its defence-in-depth re-check -- which by construction
1080
+ // now only ever sees a generation the resolver already passed.
1081
+ const dir = path.join(workRoot, `seed-gen${index}-selected`);
1082
+ fs.mkdirSync(dir, { recursive: true });
1083
+ for (const name of ['ruvnet-brain.zip', 'corpus-receipt.json', 'ruvnet-brain.zip.recall.json', 'ruvnet-brain.zip.accuracy.json',
1084
+ 'CORPUS-COVERAGE.json', 'coverage-receipt.json']) {
1085
+ fs.copyFileSync(path.join(registryDir, selection.seed.tag, 'assets', name), path.join(dir, name), fs.constants.COPYFILE_FICLONE);
1086
+ }
1087
+ const file = path.join(dir, 'ruvnet-brain.zip');
1088
+ const sha256 = sha256File(file);
1089
+ const bytes = fs.statSync(file).size;
1090
+ if (sha256 !== selection.seed.sha256 || bytes !== selection.seed.bytes) fail(`selected seed download does not match its descriptor (${sha256}/${bytes})`);
1091
+ const archive = { file: 'ruvnet-brain.zip', sha256, bytes };
1092
+ api.readDiagnosticAccuracyReport({ reportFile: `${file}.accuracy.json`, archive });
1093
+ // corpus-seed.yml's seed-recall-verify block: the seed's own sidecar coverage, verified, then handed to
1094
+ // the reader so a claimed retirement is recomputed rather than trusted (ADR-0091 D7.3).
1095
+ const coverageBytes = fs.readFileSync(path.join(dir, 'CORPUS-COVERAGE.json'));
1096
+ api.verifyCoverageSidecar({ sidecar: JSON.parse(fs.readFileSync(path.join(dir, 'coverage-receipt.json'), 'utf8')), coverageBytes,
1097
+ generationTag: selection.seed.tag, archiveSha256: sha256, archiveBytes: bytes });
1098
+ const fixture = api.loadFixture();
1099
+ const { report } = api.readRecallReport({ reportFile: `${file}.recall.json`, archive,
1100
+ expectedFixtureSha256: fixture.fixtureSha256, floorValue: 0, coverageBytes,
1101
+ fixtureStores: fixture.questions.map((question) => question.store) });
1102
+ record.downstreamRecheck = { repoCoverage: report.totals.repoCoverage, questions: report.totals.questions, hitTop5: report.totals.hitTop5,
1103
+ retired: report.totals.retired ?? 0 };
1104
+ phase(`generation-${index}-seed-selection`, 'PASS', {
1105
+ selected: selection.seed.tag, builtUnder: `v${selection.seed.brainVersion}`, consumingRuntime: `v${consumingVersion}`,
1106
+ skipped: selection.rejected.map((row) => `${row.tag.slice(0, 26)}...: ${row.reason}`),
1107
+ archiveDownloadAttempts: 0, preD4RuntimeRule: record.preD4RuntimeRule,
1108
+ reason: `the real corpus-next-seed resolver walked past ${expected.decoys.length} newer incompatible generation(s) without downloading an archive and chose generation ${index - 1}, built under an older runtime` });
1109
+ return { file, tag: selection.seed.tag, sha256, bytes, channel: 'published-generation-selected', allowPinnedTag: false,
1110
+ receiptFile: path.join(dir, 'corpus-receipt.json'), builtUnder: selection.seed.brainVersion,
1111
+ coverageFile: path.join(dir, 'CORPUS-COVERAGE.json') };
1112
+ }
1113
+
1114
+ // ---------------------------------------------------------------------------------------------
1115
+ // ADR-0091 D6 (V6a/V6c): a CODE RELEASE consumes the published generation
1116
+ // ---------------------------------------------------------------------------------------------
1117
+
1118
+ /**
1119
+ * What ci.yml's release-qe does after D6, against the local registry: resolve in code-release mode
1120
+ * (the real resolver, --require-coverage: decoys without a coverage sidecar are skipped), download the
1121
+ * generation plus its sidecar, and assemble through the REAL orchestrator on the single-pass path --
1122
+ * a real build-bundle run with the generation's sealed coverage and seed identity, which had never
1123
+ * been exercised against a generation before. V6a: the generation directory is byte-unchanged and the
1124
+ * archive's store bytes equal the generation's. D6.4: the frozen fixture is judged against the live
1125
+ * eligible set by the shared denominator.
1126
+ */
1127
+ async function rehearseCodeRelease({ index, api, selectionMod, checkoutRoot, workRoot, registryDir, repoSlug, expected, receipt, phase, log }) {
1128
+ const name = `code-release-from-generation-${index}`;
1129
+ const gh = selectionMod.registryGh(registryDir);
1130
+ const selection = await api.resolveNextCorpusSeed({ repo: repoSlug, root: checkoutRoot, run: gh.run, runtimeRoot: checkoutRoot,
1131
+ requireCoverage: true });
1132
+ const record = { generation: index, selected: { origin: selection.seed.origin, tag: selection.seed.tag },
1133
+ rejected: selection.rejected, archiveDownloadAttempts: gh.archiveDownloadAttempts().length };
1134
+ receipt.codeReleaseConsumption = [...(receipt.codeReleaseConsumption || []), record];
1135
+ if (selection.seed.origin !== 'published-generation' || selection.seed.tag !== expected.tag || !selection.seed.coverage) {
1136
+ phase(name, 'FAIL', { reason: `code-release resolution chose ${selection.seed.origin} ${selection.seed.tag}, expected ${expected.tag} with its coverage` });
1137
+ fail(`${name}: the code-release resolver did not choose the published generation`);
1138
+ }
1139
+ const dir = path.join(workRoot, `code-release-gen${index}`);
1140
+ const evidence = path.join(dir, 'release-evidence');
1141
+ const extracted = path.join(dir, 'extracted');
1142
+ fs.mkdirSync(evidence, { recursive: true });
1143
+ for (const asset of ['ruvnet-brain.zip', 'CORPUS-COVERAGE.json', 'coverage-receipt.json']) {
1144
+ fs.copyFileSync(path.join(registryDir, selection.seed.tag, 'assets', asset), path.join(dir, asset), fs.constants.COPYFILE_FICLONE);
1145
+ }
1146
+ const zip = path.join(dir, 'ruvnet-brain.zip');
1147
+ if (sha256File(zip) !== selection.seed.sha256) fail(`${name}: downloaded generation does not match its descriptor`);
1148
+ await api.extractZip(zip, extracted);
1149
+ const descriptorFile = path.join(evidence, 'corpus-seed.json');
1150
+ fs.writeFileSync(descriptorFile, `${JSON.stringify(selection.seed, null, 2)}\n`);
1151
+ const outputs = [];
1152
+ const capture = (command, args, options = {}) => {
1153
+ const result = spawnSync(command, args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, ...options });
1154
+ outputs.push({ script: path.basename(String(args?.[0] || command)), exit: result.status,
1155
+ tail: result.status === 0 ? undefined : String(result.stderr || result.stdout || '').trim().slice(-800) });
1156
+ return result;
1157
+ };
1158
+ const started = Date.now();
1159
+ let assembled;
1160
+ try {
1161
+ assembled = api.assembleCodeReleaseCorpus({ root: checkoutRoot, descriptor: selection.seed, seedBundle: zip, assetsDir: extracted,
1162
+ evidenceDir: evidence, coverageFile: path.join(dir, 'CORPUS-COVERAGE.json'),
1163
+ coverageReceiptFile: path.join(dir, 'coverage-receipt.json'), run: capture, env: { ...process.env } });
1164
+ } catch (error) {
1165
+ record.steps = outputs;
1166
+ phase(name, 'FAIL', { reason: error.message, steps: outputs });
1167
+ throw error;
1168
+ }
1169
+ record.assembly = { mode: assembled.mode, durationMs: Date.now() - started, steps: outputs, unmutated: assembled.unmutated,
1170
+ seedTree: assembled.seedTree };
1171
+ const releaseCoverage = JSON.parse(fs.readFileSync(path.join(checkoutRoot, 'dist', 'ruvnet-brain', 'COVERAGE.json'), 'utf8'));
1172
+ // ADR-0091 D7.1: a code release built FROM a generation names it, and dates itself by its observation.
1173
+ const releaseCorpus = JSON.parse(fs.readFileSync(path.join(checkoutRoot, 'dist', 'ruvnet-brain', 'manifest.json'), 'utf8')).corpus;
1174
+ const generationCoverage = JSON.parse(fs.readFileSync(path.join(dir, 'CORPUS-COVERAGE.json'), 'utf8'));
1175
+ record.manifestCorpus = releaseCorpus;
1176
+ if (releaseCorpus?.basis !== 'sealed-observation' || releaseCorpus.generationTag !== selection.seed.tag
1177
+ || releaseCorpus.observedAt !== generationCoverage.observedAt) {
1178
+ phase(name, 'FAIL', { reason: `code-release manifest.corpus does not name its generation and observation: ${JSON.stringify(releaseCorpus)}` });
1179
+ fail(`${name}: manifest.corpus is ${JSON.stringify(releaseCorpus)}`);
1180
+ }
1181
+ const fixture = JSON.parse(fs.readFileSync(path.join(checkoutRoot, 'data', 'retrieval-query-evidence.json'), 'utf8'));
1182
+ const denominator = api.fixtureDenominator({ coverage: releaseCoverage, fixtureStores: Object.keys(fixture.queries) });
1183
+ record.canaryDenominator = { fixture: denominator.fixture.length, questioned: denominator.questioned.length,
1184
+ retired: denominator.retired, blocking: denominator.blocking, unfixturedEligible: denominator.unfixturedEligible.length,
1185
+ corpusSeed: releaseCoverage.corpusSeed };
1186
+ if (assembled.mode !== 'single-pass' || denominator.blocking.length || releaseCoverage.corpusSeed?.tag !== selection.seed.tag) {
1187
+ phase(name, 'FAIL', { reason: `mode=${assembled.mode}, blocking=${JSON.stringify(denominator.blocking)}, coverage seed=${releaseCoverage.corpusSeed?.tag}` });
1188
+ fail(`${name}: the code release did not assemble single-pass from the generation with an admissible canary denominator`);
1189
+ }
1190
+ const guard = api.checkNoNewerCorpusGeneration({ sealed: selection.seed,
1191
+ resolution: await api.resolveNextCorpusSeed({ repo: repoSlug, root: checkoutRoot, run: selectionMod.registryGh(registryDir).run,
1192
+ runtimeRoot: checkoutRoot, requireCoverage: true }) });
1193
+ record.publishGuardBeforeNextGeneration = guard;
1194
+ phase(name, 'PASS', { mode: assembled.mode, durationMs: record.assembly.durationMs, unmutated: assembled.unmutated,
1195
+ canary: `${denominator.questioned.length}/${denominator.fixture.length} fixture stores questioned, ${denominator.retired.length} retired, `
1196
+ + `${denominator.unfixturedEligible.length} unfixtured eligible recorded`,
1197
+ reason: `release built single-pass from ${selection.seed.tag.slice(0, 26)}...: ${assembled.unmutated.files} store files byte-equal to the generation, `
1198
+ + 'generation directory unchanged, no capability-only refresh or index repair; the publish guard passes while no newer generation exists' });
1199
+ log(`[${name}] single-pass code release assembled in ${record.assembly.durationMs} ms`);
1200
+ return { sealed: selection.seed, record };
1201
+ }
1202
+
1203
+ /** V6c: once generation N+1 is published, a release QE'd from generation N must be refused at publish. */
1204
+ async function rehearsePublishGuard({ sealed, index, api, selectionMod, checkoutRoot, registryDir, repoSlug, record, phase }) {
1205
+ const name = `publish-guard-after-generation-${index}`;
1206
+ const resolution = await api.resolveNextCorpusSeed({ repo: repoSlug, root: checkoutRoot, run: selectionMod.registryGh(registryDir).run,
1207
+ runtimeRoot: checkoutRoot, requireCoverage: true });
1208
+ try {
1209
+ api.checkNoNewerCorpusGeneration({ sealed, resolution });
1210
+ } catch (error) {
1211
+ record.publishGuardAfterNextGeneration = { refused: true, reason: error.message, newest: resolution.seed.tag };
1212
+ if (!/re-run release QE/.test(error.message)) {
1213
+ phase(name, 'FAIL', { reason: `refused for the wrong reason: ${error.message}` });
1214
+ fail(`${name}: the guard refused for the wrong reason`);
1215
+ }
1216
+ phase(name, 'PASS', { reason: `publication of the release QE'd from ${sealed.tag.slice(0, 26)}... was REFUSED: ${error.message}` });
1217
+ return;
1218
+ }
1219
+ phase(name, 'FAIL', { reason: `generation ${index} was published after QE, yet the publish guard allowed the older release` });
1220
+ fail(`${name}: the backward-move guard did not refuse`);
1221
+ }
1222
+
718
1223
  // ---------------------------------------------------------------------------------------------
719
1224
  // The rehearsal
720
1225
  // ---------------------------------------------------------------------------------------------
@@ -728,14 +1233,34 @@ export async function rehearseCorpusPipeline({
728
1233
  owner = 'ruvnet',
729
1234
  tamper = 'none',
730
1235
  tamperGeneration = 1,
1236
+ injectStoreFailure = 'none',
1237
+ injectGeneration = 1,
1238
+ recallFixture = 'scoped',
731
1239
  forceRebuild = true,
732
1240
  seedOverride = null,
1241
+ // ADR-0091 D4. seedSelection: generation N+1's seed is chosen by the real corpus-next-seed resolver
1242
+ // from a local registry of published generations (plus incompatible decoys), not handed over
1243
+ // directly. codeReleaseBetween: cut a version bump in the disposable checkout between generations,
1244
+ // so the chosen seed was built under an OLDER runtime than the one consuming it.
1245
+ seedSelection = true,
1246
+ codeReleaseBetween = true,
1247
+ // ADR-0091 D6: after generation N is published locally, a code release consumes it (single-pass,
1248
+ // real build) and, once generation N+1 is published, the publish-time guard must refuse that release.
1249
+ codeReleaseConsumption = true,
1250
+ // null = the checkout's own C3_DIAGNOSTIC_SAMPLE_QUESTIONS (what CI runs); 'full' = no sample.
1251
+ accuracySample = null,
733
1252
  keep = false,
734
1253
  workRootParent = null,
735
1254
  installedBrainDir = path.join(os.homedir(), '.cache', 'ruvnet-brain', 'kb'),
736
1255
  log = (line) => process.stderr.write(`${line}\n`),
737
1256
  } = {}) {
738
1257
  if (!TAMPER_MODES.has(tamper)) fail(`unknown --tamper mode ${tamper} (expected: ${[...TAMPER_MODES].join(', ')})`);
1258
+ if (!RECALL_FIXTURE_MODES.has(recallFixture)) {
1259
+ fail(`unknown --recall-fixture mode ${recallFixture} (expected: ${[...RECALL_FIXTURE_MODES].join(', ')})`);
1260
+ }
1261
+ if (!INJECTION_MODES.has(injectStoreFailure)) {
1262
+ fail(`unknown --inject-store-failure mode ${injectStoreFailure} (expected: ${[...INJECTION_MODES].join(', ')})`);
1263
+ }
739
1264
  const startedAt = new Date().toISOString();
740
1265
  const started = Date.now();
741
1266
  const receipt = {
@@ -744,9 +1269,12 @@ export async function rehearseCorpusPipeline({
744
1269
  banner: BANNER,
745
1270
  startedAt,
746
1271
  host: { node: process.version, platform: `${process.platform}/${process.arch}` },
747
- bounds: { repos, gists, generations, maxRounds, owner, forceRebuild, tamper, tamperGeneration },
1272
+ bounds: { repos, gists, generations, maxRounds, owner, forceRebuild, tamper, tamperGeneration, accuracySample,
1273
+ seedSelection, codeReleaseBetween, codeReleaseConsumption, injectStoreFailure, injectGeneration, recallFixture },
748
1274
  phases: [],
749
1275
  generations: [],
1276
+ seedSelection: [],
1277
+ approvedRuntime: [],
750
1278
  verdict: 'FAIL',
751
1279
  };
752
1280
  const phase = (name, status, detail = {}) => {
@@ -817,10 +1345,21 @@ export async function rehearseCorpusPipeline({
817
1345
 
818
1346
  // ---- phase 4: load the disposable checkout's own modules ---------------------------------
819
1347
  const load = (relative) => import(pathToFileURL(path.join(checkoutRoot, relative)).href);
820
- const [reconcileMod, coverageMod, candidateMod, zipMod] = await Promise.all([
1348
+ const [reconcileMod, coverageMod, candidateMod, zipMod, accuracyMod, nextSeedMod, approvedMod, recallMod, selectionMod, failureMod] = await Promise.all([
821
1349
  load('scripts/corpus-reconcile.mjs'), load('scripts/source-coverage.mjs'),
822
- load('scripts/corpus-candidate.mjs'), load('kb/zip-extract.mjs'),
1350
+ load('scripts/corpus-candidate.mjs'), load('kb/zip-extract.mjs'), load('scripts/oracle/retrieval-accuracy.mjs'),
1351
+ load('scripts/corpus-next-seed.mjs'), load('scripts/approved-runtime.mjs'), load('scripts/oracle/repo-recall.mjs'),
1352
+ load('scripts/rehearse-seed-selection.mjs'), load('scripts/corpus-store-failure.mjs'),
823
1353
  ]);
1354
+ const [codeReleaseMod, denominatorMod, sidecarMod] = await Promise.all([
1355
+ load('scripts/code-release-corpus.mjs'), load('scripts/fixture-denominator.mjs'), load('scripts/corpus-coverage-sidecar.mjs')]);
1356
+ // The same C3 question sample CI runs (ADR-0091 D2), unless the caller asked for the full audit.
1357
+ const accuracyQuestions = accuracySample === 'full' ? null
1358
+ : accuracySample == null ? accuracyMod.C3_DIAGNOSTIC_SAMPLE_QUESTIONS : Number(accuracySample);
1359
+ if (accuracyQuestions != null && (!Number.isSafeInteger(accuracyQuestions) || accuracyQuestions <= 0)) {
1360
+ fail(`--accuracy-sample must be a positive integer or 'full' (got ${accuracySample})`);
1361
+ }
1362
+ receipt.bounds.accuracySample = accuracyQuestions ?? 'full';
824
1363
  const api = {
825
1364
  assertBootstrapIdentity: reconcileMod.assertBootstrapIdentity,
826
1365
  normalizeExtractedCorpus: reconcileMod.normalizeExtractedCorpus,
@@ -828,16 +1367,33 @@ export async function rehearseCorpusPipeline({
828
1367
  syncCorpusInputs: reconcileMod.syncCorpusInputs,
829
1368
  acquireCorpusGeneration: reconcileMod.acquireCorpusGeneration,
830
1369
  reconcileAndPrepareCorpusCandidate: reconcileMod.reconcileAndPrepareCorpusCandidate,
1370
+ summarizeReconciliation: reconcileMod.summarizeReconciliation,
831
1371
  prepareCorpusCandidate: reconcileMod.prepareCorpusCandidate,
1372
+ executeReconciliation: reconcileMod.executeReconciliation,
1373
+ defaultRunAsync: reconcileMod.defaultRunAsync,
1374
+ degradedPublication: failureMod.degradedPublication,
832
1375
  observeSourceUniverse: coverageMod.observeSourceUniverse,
833
1376
  canonicalSourceObservation: coverageMod.canonicalSourceObservation,
834
1377
  buildCoverage: coverageMod.buildCoverage,
835
1378
  verifySeedBaseline: candidateMod.verifySeedBaseline,
836
1379
  extractZip: zipMod.extractZip,
1380
+ resolveNextCorpusSeed: nextSeedMod.resolveNextCorpusSeed,
1381
+ verifyApprovedRuntime: approvedMod.verifyApprovedRuntime,
1382
+ emitApprovedRuntime: approvedMod.emitApprovedRuntime,
1383
+ readArchiveManifestFromZip: approvedMod.readArchiveManifestFromZip,
1384
+ isRuntimeFile: approvedMod.isRuntimeFile,
1385
+ readRecallReport: recallMod.readRecallReport,
1386
+ loadFixture: recallMod.loadFixture,
1387
+ readDiagnosticAccuracyReport: accuracyMod.readDiagnosticAccuracyReport,
1388
+ assembleCodeReleaseCorpus: codeReleaseMod.assembleCodeReleaseCorpus,
1389
+ checkNoNewerCorpusGeneration: codeReleaseMod.checkNoNewerCorpusGeneration,
1390
+ fixtureDenominator: denominatorMod.fixtureDenominator,
1391
+ verifyCoverageSidecar: sidecarMod.verifyCoverageSidecar,
837
1392
  };
838
1393
  for (const [name, value] of Object.entries(api)) {
839
1394
  if (typeof value !== 'function') fail(`the disposable checkout does not export ${name}`);
840
1395
  }
1396
+ api.CORPUS_QA_FAILED_EXIT = failureMod.CORPUS_QA_FAILED_EXIT;
841
1397
  phase('load-pipeline-entrypoints', 'PASS', { entrypoints: Object.keys(api).length });
842
1398
 
843
1399
  // ---- phase 5: real seed acquisition (or an explicit SKIP) --------------------------------
@@ -864,14 +1420,31 @@ export async function rehearseCorpusPipeline({
864
1420
 
865
1421
  // ---- phase 6..N: the generations ---------------------------------------------------------
866
1422
  let seed = { file: acquisition.file, tag: descriptor.tag, sha256: descriptor.sha256, bytes: descriptor.bytes,
867
- channel: acquisition.channel, allowPinnedTag: true };
1423
+ channel: acquisition.channel, allowPinnedTag: true, builtUnder: String(descriptor.tag).replace(/^v/, '') };
1424
+ let builderSha = checkout.head;
1425
+ const registryDir = path.join(workRoot, 'release-registry');
1426
+ fs.mkdirSync(registryDir, { recursive: true });
1427
+ let lastPublished = null;
1428
+ let codeRelease = null;
868
1429
  for (let index = 1; index <= generations; index += 1) {
1430
+ if (index > 1 && codeReleaseBetween) {
1431
+ const release = selectionMod.simulateCodeRelease({ checkoutRoot });
1432
+ builderSha = release.head;
1433
+ receipt.codeReleases = [...(receipt.codeReleases || []), { beforeGeneration: index, ...release }];
1434
+ phase(`code-release-before-generation-${index}`, 'PASS', { from: release.from, to: release.to, head: release.head,
1435
+ committed: release.committed,
1436
+ reason: `a code release (v${release.from} -> v${release.to}) landed between generations, the event that reset the pre-D4 seed chain` });
1437
+ }
1438
+ if (index > 1 && seedSelection) {
1439
+ seed = await selectPublishedSeed({ index, api, selectionMod, checkoutRoot, workRoot, registryDir, repoSlug,
1440
+ expected: lastPublished, receipt, phase });
1441
+ }
869
1442
  if (index > 1) {
870
1443
  // Import candidate N as seed N+1 through the SAME baseline verifier the future consumer
871
1444
  // uses — the content-addressed tag, the archive bytes, and the schema-2 receipt together.
872
1445
  const baseline = await api.verifySeedBaseline({
873
1446
  seedDescriptor: { tag: seed.tag, sha256: seed.sha256, bytes: seed.bytes },
874
- bundleFile: seed.file, receiptFile: seed.receiptFile,
1447
+ bundleFile: seed.file, receiptFile: seed.receiptFile, coverageFile: seed.coverageFile ?? null,
875
1448
  });
876
1449
  phase(`generation-${index}-seed-baseline`, 'PASS', {
877
1450
  tag: baseline.tag, sha256: baseline.sha256, stores: baseline.receipt.storeCount,
@@ -879,13 +1452,53 @@ export async function rehearseCorpusPipeline({
879
1452
  }
880
1453
  const result = await runGeneration({
881
1454
  index, api, checkoutRoot, workRoot, seed, recorder, tamper, log,
882
- bounds: { repos, gists, owner, maxRounds, builderSha: checkout.head, forceRebuild, tamperGeneration },
1455
+ bounds: { repos, gists, owner, maxRounds, builderSha, forceRebuild, tamperGeneration, accuracyQuestions,
1456
+ injectStoreFailure, injectGeneration, recallFixture },
883
1457
  });
884
1458
  receipt.generations.push(result.generation);
885
1459
  phase(`generation-${index}`, 'PASS', {
886
1460
  candidateSha256: result.generation.candidate.sha256,
887
1461
  stores: result.generation.candidate.storeCount,
888
1462
  durationMs: result.generation.reconciliation.durationMs });
1463
+
1464
+ // ADR-0091 D4 / V4: the candidate's runtime surface, judged by the real verifyApprovedRuntime
1465
+ // against a pin traced to the CHECKOUT's own bytes (never emitted from the candidate).
1466
+ const manifest = api.readArchiveManifestFromZip(result.generation.candidate.bundleFile);
1467
+ const version = JSON.parse(fs.readFileSync(path.join(checkoutRoot, 'plugin', '.claude-plugin', 'plugin.json'), 'utf8')).version;
1468
+ const { pin, traced, untraced } = selectionMod.checkoutRuntimePin({
1469
+ checkoutRoot, manifest, approvedCodeSha: builderSha, version, api });
1470
+ const verdict = api.verifyApprovedRuntime({ manifest, pin });
1471
+ const runtimeCheck = { generation: index, seedTag: seed.tag, seedBuiltUnder: seed.builtUnder ?? null,
1472
+ consumingRuntime: `v${version}`, pinnedFromCheckout: traced, untraced, verdict: verdict.verdict,
1473
+ checked: verdict.checked, failures: verdict.failures };
1474
+ receipt.approvedRuntime.push(runtimeCheck);
1475
+ if (verdict.verdict !== 'PASS') {
1476
+ phase(`generation-${index}-approved-runtime`, 'FAIL', { ...runtimeCheck, reason: verdict.failures.slice(0, 5).join('; ') });
1477
+ fail(`generation ${index}'s candidate fails verifyApprovedRuntime against the checkout: ${verdict.failures.slice(0, 5).join('; ')}`);
1478
+ }
1479
+ phase(`generation-${index}-approved-runtime`, 'PASS', { ...runtimeCheck,
1480
+ reason: `verifyApprovedRuntime PASS: ${verdict.checked} runtime files equal the checkout's, none unpinned, seed ${seed.tag} did not leak an executable` });
1481
+
1482
+ if (result.withheld) {
1483
+ phase(`generation-${index}-publication`, 'PASS', { status: 'WITHHELD-DEGRADED', ...result.generation.publication,
1484
+ reason: `degraded generation sealed but not published (ADR-0091 D5/D10): ${result.generation.publication.reason}` });
1485
+ if (index < generations) {
1486
+ phase('remaining-generations', 'SKIP', { reason: `generation ${index} was withheld, so there is no published generation ${index} to seed from` });
1487
+ }
1488
+ break;
1489
+ }
1490
+ const consumeNow = seedSelection && codeReleaseConsumption && !codeRelease;
1491
+ if (seedSelection && (index < generations || codeRelease)) {
1492
+ lastPublished = publishLocally({ selectionMod, registryDir, published: result.published, workRoot, index });
1493
+ }
1494
+ if (codeRelease) {
1495
+ await rehearsePublishGuard({ sealed: codeRelease.sealed, index, api, selectionMod, checkoutRoot, registryDir, repoSlug,
1496
+ record: codeRelease.record, phase });
1497
+ codeRelease = null;
1498
+ } else if (consumeNow && index < generations) {
1499
+ codeRelease = await rehearseCodeRelease({ index, api, selectionMod, checkoutRoot, workRoot, registryDir, repoSlug,
1500
+ expected: lastPublished, receipt, phase, log });
1501
+ }
889
1502
  seed = result.nextSeed;
890
1503
  }
891
1504
 
@@ -972,8 +1585,15 @@ export async function main(argv = process.argv.slice(2)) {
972
1585
  owner: arg(argv, '--owner', 'ruvnet'),
973
1586
  tamper: arg(argv, '--tamper', 'none'),
974
1587
  tamperGeneration: Number(arg(argv, '--tamper-generation', 1)),
1588
+ injectStoreFailure: arg(argv, '--inject-store-failure', 'none'),
1589
+ injectGeneration: Number(arg(argv, '--inject-generation', 1)),
1590
+ recallFixture: arg(argv, '--recall-fixture', 'scoped'),
975
1591
  forceRebuild: !argv.includes('--no-force-rebuild'),
976
1592
  seedOverride: arg(argv, '--seed', null),
1593
+ seedSelection: !argv.includes('--no-seed-selection'),
1594
+ codeReleaseBetween: !argv.includes('--no-code-release-between'),
1595
+ codeReleaseConsumption: !argv.includes('--no-code-release-consumption'),
1596
+ accuracySample: arg(argv, '--accuracy-sample', null),
977
1597
  keep: argv.includes('--keep'),
978
1598
  workRootParent: arg(argv, '--work-root', null),
979
1599
  });