ruvnet-brain 4.3.35 → 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.
@@ -66,6 +66,9 @@ const recallNotes = (receipt) => {
66
66
  };
67
67
  import { verifyBundle } from './verify-bundle.mjs';
68
68
  import { CORPUS_GENERATION_FIELD, evaluateCorpusPromotion } from './corpus-promotion.mjs';
69
+ import { bindCoverageToReceipt, writeCoverageAssets } from './corpus-coverage-sidecar.mjs';
70
+ import { degradedPublication } from './corpus-store-failure.mjs';
71
+ import { assertNoNewerCorpusGeneration } from './code-release-corpus.mjs';
69
72
 
70
73
  const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
71
74
  const PUBLISH = process.argv.includes('--publish');
@@ -114,6 +117,28 @@ function corpusFailure(message) {
114
117
  throw new Error(`[corpus-seed] ${message}`);
115
118
  }
116
119
 
120
+ /**
121
+ * A newer code release was published after this corpus was built at its approved runtime. Promoting
122
+ * it now would put an OLDER runtime on releases/latest over a newer live code release (fresh installs
123
+ * then fail on a version mismatch). That is not a broken night -- the next night builds at the newer
124
+ * runtime -- so it is a distinct, typed outcome: exit CORPUS_SUPERSEDED_EXIT, recorded as `superseded`.
125
+ */
126
+ export const CORPUS_SUPERSEDED_EXIT = 4;
127
+ export class CorpusSuperseded extends Error {
128
+ constructor(message) {
129
+ super(`[corpus-seed] superseded: ${message}`);
130
+ this.name = 'CorpusSuperseded';
131
+ this.code = 'CORPUS_SUPERSEDED';
132
+ }
133
+ }
134
+
135
+ const CODE_TAG = /^v(\d+)\.(\d+)\.(\d+)$/;
136
+ const compareCodeTags = (left, right) => {
137
+ const a = CODE_TAG.exec(left).slice(1).map(Number);
138
+ const b = CODE_TAG.exec(right).slice(1).map(Number);
139
+ return Math.sign(a[0] - b[0] || a[1] - b[1] || a[2] - b[2]);
140
+ };
141
+
117
142
  export async function runProtectedCorpusSeed({
118
143
  argv = process.argv.slice(2),
119
144
  env = process.env,
@@ -135,13 +160,15 @@ export async function runProtectedCorpusSeed({
135
160
  const tag = cliArg(argv, '--corpus-tag');
136
161
  const bundleFile = cliArg(argv, '--corpus-bundle');
137
162
  const receiptFile = cliArg(argv, '--corpus-receipt');
163
+ // ADR-0091 D6.2: the generation's sealed coverage (the prepared artifact's source-coverage.json).
164
+ const coverageFile = cliArg(argv, '--corpus-coverage');
138
165
  const target = cliArg(argv, '--target');
139
166
  const repo = cliArg(argv, '--repo') || env.GITHUB_REPOSITORY;
140
167
  const digestMatch = String(tag || '').match(/^corpus-sha256-([a-f0-9]{64})$/);
141
168
  if (!digestMatch) corpusFailure('corpus tag must be corpus-sha256- followed by 64 lowercase hex characters');
142
169
  if (repo !== env.GITHUB_REPOSITORY || repo !== 'stuinfla/ruvnet-brain') corpusFailure('repository does not match the protected workflow');
143
170
 
144
- for (const [label, file] of [['bundle', bundleFile], ['receipt', receiptFile]]) {
171
+ for (const [label, file] of [['bundle', bundleFile], ['receipt', receiptFile], ['coverage', coverageFile]]) {
145
172
  if (!file || !path.isAbsolute(file)) corpusFailure(`${label} must be an absolute regular file`);
146
173
  try {
147
174
  const stat = fs.lstatSync(file);
@@ -164,14 +191,28 @@ export async function runProtectedCorpusSeed({
164
191
  } catch (error) {
165
192
  corpusFailure(`corpus receipt is unreadable/corrupt (${error.message})`);
166
193
  }
167
- // EXACT equality, never "GITHUB_SHA or an ancestor of it" (independent review of ADR-0091 D3,
168
- // 2026-09-28). Accepting an ancestor let the unattended corpus job promote an OLDER runtime over the
169
- // current live code release as `releases/latest` — fresh installs then fail on a version mismatch
170
- // and already-updated clients refuse it as incompatible. The corpus is built at the newest
171
- // install-verified release's sourceSha, and that must BE the protected main commit this run executes.
172
- // The format check runs first; the comparisons below never hand the value to a subprocess.
173
- if (!isHex(target, 40) || target !== head || target !== env.GITHUB_SHA || target !== receipt.builderSourceSha) {
174
- corpusFailure('target must exactly equal HEAD, GITHUB_SHA, and the corpus receipt builderSourceSha');
194
+ // DECOUPLED FROM main HEAD (2026-09-29 nightly redesign). The corpus is built at the APPROVED
195
+ // runtime -- the newest code release with a verified install aggregate -- whose source is on main's
196
+ // history but is usually NOT main HEAD. The old rule (target === GITHUB_SHA) stood the nightly down
197
+ // whenever main was ahead of the newest verified release. The guard that rule was protecting
198
+ // (independent review of ADR-0091 D3: never promote an OLDER runtime over the live code release)
199
+ // is now enforced directly: target must be the checkout, the receipt's builder, an ancestor of this
200
+ // protected run's GITHUB_SHA, and -- for a customer promotion -- the commit of --approved-tag, which
201
+ // must still be the NEWEST code release at publish time (below; otherwise CorpusSuperseded).
202
+ // Format checks run first; no value reaches a subprocess unvalidated.
203
+ if (!isHex(target, 40) || target !== head || target !== receipt.builderSourceSha || !isHex(env.GITHUB_SHA, 40)) {
204
+ corpusFailure('target must exactly equal HEAD and the corpus receipt builderSourceSha (and GITHUB_SHA must be a commit)');
205
+ }
206
+ const ancestry = run('git', ['merge-base', '--is-ancestor', target, env.GITHUB_SHA], {
207
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
208
+ });
209
+ if (ancestry.error || ancestry.status !== 0) corpusFailure(`target ${target} is not an ancestor of this run's GITHUB_SHA ${env.GITHUB_SHA}`);
210
+ const approvedTag = cliArg(argv, '--approved-tag');
211
+ if (promoteLatest) {
212
+ if (!CODE_TAG.test(String(approvedTag || ''))) corpusFailure('customer promotion requires --approved-tag vX.Y.Z (the approved runtime this corpus was built at)');
213
+ if (receipt.archiveManifestReleaseTag !== approvedTag) {
214
+ corpusFailure(`the archive ships runtime ${receipt.archiveManifestReleaseTag}, not the approved runtime ${approvedTag}`);
215
+ }
175
216
  }
176
217
 
177
218
  // Schema 3 (ADR-086 Step 15 / A6): the receipt binds the full provenance closure shipped INSIDE
@@ -261,11 +302,16 @@ export async function runProtectedCorpusSeed({
261
302
  || fs.statSync(recallReportFile).size !== receipt.recallReport.bytes) {
262
303
  corpusFailure('detached repo-recall report bytes do not match the corpus receipt');
263
304
  }
305
+ // ADR-0091 D7.3: a claimed retirement is recomputed from THIS generation's sealed coverage (the bytes
306
+ // published beside the archive as CORPUS-COVERAGE.json), never taken from the report's own claim.
307
+ const recallFixture = loadFixture();
264
308
  try {
265
309
  readRecallReport({
266
310
  reportFile: recallReportFile,
267
311
  archive: archiveIdentity,
268
- expectedFixtureSha256: loadFixture().fixtureSha256,
312
+ expectedFixtureSha256: recallFixture.fixtureSha256,
313
+ coverageBytes: fs.readFileSync(coverageFile),
314
+ fixtureStores: recallFixture.questions.map((question) => question.store),
269
315
  });
270
316
  } catch (error) {
271
317
  corpusFailure(`retrieval does not qualify this corpus for publication (${error.message})`);
@@ -280,12 +326,39 @@ export async function runProtectedCorpusSeed({
280
326
  // here. It runs before any `gh` call so an untrue candidate never reaches the network.
281
327
  try {
282
328
  await verifyCorpusReceipt({
283
- receiptFile, bundleFile, accuracyReportFile, recallReportFile, expectedBuilderSha: target, expectedArchiveSha256: archiveSha256,
329
+ receiptFile, bundleFile, accuracyReportFile, recallReportFile, coverageFile, expectedBuilderSha: target, expectedArchiveSha256: archiveSha256,
284
330
  });
285
331
  } catch (error) {
286
332
  corpusFailure(`corpus receipt does not verify against the sealed archive (${error.message})`);
287
333
  }
288
334
 
335
+ // ADR-0091 D6.2 + D10. The archive carries no coverage and the schema-3 receipt binds none, so this
336
+ // is the one place the publisher can SEE whether the generation is degraded. The coverage must be
337
+ // the coverage of THIS archive (bound store by store to the receipt), it is published beside the
338
+ // archive as CORPUS-COVERAGE.json + coverage-receipt.json (no receipt schema bump), and a
339
+ // generation with any carried or missing store is refused while D10 has recorded no soaked
340
+ // tolerant-validator transition -- installed clients would reject it. Local, before any network.
341
+ let coverageAssets;
342
+ try {
343
+ coverageAssets = writeCoverageAssets({
344
+ dir: fs.mkdtempSync(path.join(os.tmpdir(), 'corpus-coverage-assets-')),
345
+ coverageFile, generationTag: tag, archiveSha256, archiveBytes: archiveIdentity.bytes,
346
+ });
347
+ const degraded = bindCoverageToReceipt({
348
+ coverage: JSON.parse(fs.readFileSync(coverageAssets.coverageFile, 'utf8')), receipt,
349
+ });
350
+ if (degraded.carried.length + degraded.missing.length > 0) {
351
+ const decision = degradedPublication();
352
+ if (!decision.allowed) {
353
+ corpusFailure(`degraded generation (${degraded.carried.length} carried, ${degraded.missing.length} missing) `
354
+ + `must not be published: ${decision.reason}`);
355
+ }
356
+ }
357
+ } catch (error) {
358
+ if (String(error.message).startsWith('[corpus-seed]')) throw error;
359
+ corpusFailure(`the generation's sealed coverage does not bind this archive (${error.message})`);
360
+ }
361
+
289
362
  // EVERY local proof happens before the first network call. `gh` must never be reached by a
290
363
  // candidate that is already known to be unpublishable — that is the same discipline the deep
291
364
  // verifyCorpusReceipt above follows, and a customer release with an unusable signature is exactly
@@ -307,16 +380,48 @@ export async function runProtectedCorpusSeed({
307
380
  if (!Number.isFinite(Date.parse(generation))) corpusFailure('corpus receipt createdAt is not a readable generation timestamp');
308
381
  }
309
382
 
310
- const viewArgs = ['release', 'view', tag, '--json', 'tagName', '--repo', repo];
311
383
  const ghCommand = env.RUVNET_GH_COMMAND || 'gh';
312
384
  const ghPrefix = env.RUVNET_GH_SCRIPT ? [env.RUVNET_GH_SCRIPT] : [];
313
- const view = run(ghCommand, [...ghPrefix, ...viewArgs], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
385
+ const gh = (args) => run(ghCommand, [...ghPrefix, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
386
+ const ghJson = (args, label) => {
387
+ const result = gh(args);
388
+ if (result.error || result.status !== 0) {
389
+ corpusFailure(`cannot read ${label} (${String(result.error?.message || result.stderr || result.stdout || '').trim() || `gh exited ${result.status}`})`);
390
+ }
391
+ try { return JSON.parse(String(result.stdout || 'null')); }
392
+ catch (error) { corpusFailure(`cannot parse ${label} (${error.message})`); }
393
+ };
394
+
395
+ if (promoteLatest) {
396
+ // PUBLISH-TIME RE-RESOLVE, before anything is written. Preparation takes hours; a code release
397
+ // may have been published meanwhile. The newest code release is selected exactly as
398
+ // scripts/approved-runtime.mjs selects it (non-draft, non-prerelease vX.Y.Z, highest version).
399
+ // Its signed install aggregate was re-verified for --approved-tag by the workflow step that built
400
+ // the runtime pin moments ago; what can change after that is only WHICH release is newest.
401
+ const listed = ghJson(['release', 'list', '--repo', repo, '--limit', '200', '--json', 'tagName,isDraft,isPrerelease'], 'the code release list');
402
+ const [newest] = (Array.isArray(listed) ? listed : [])
403
+ .filter((row) => !row?.isDraft && !row?.isPrerelease && CODE_TAG.test(String(row?.tagName || '')))
404
+ .map((row) => row.tagName).sort((a, b) => compareCodeTags(b, a));
405
+ if (!newest) corpusFailure(`no published code release is listed on ${repo}; the approved runtime ${approvedTag} cannot be confirmed`);
406
+ const order = compareCodeTags(newest, approvedTag);
407
+ if (order > 0) {
408
+ throw new CorpusSuperseded(`code release ${newest} was published after this corpus was built at ${approvedTag}; `
409
+ + 'promoting it would put an older runtime over the live code release. The next night builds at the newer runtime.');
410
+ }
411
+ if (order < 0) corpusFailure(`approved runtime ${approvedTag} is newer than every published code release (newest ${newest})`);
412
+ const commit = ghJson(['api', `repos/${repo}/commits/${approvedTag}`], `the commit of ${approvedTag}`);
413
+ if (String(commit?.sha || '').toLowerCase() !== target) {
414
+ corpusFailure(`target ${target} is not the source of the approved runtime ${approvedTag} (${commit?.sha || 'unknown'})`);
415
+ }
416
+ }
417
+
418
+ const viewArgs = ['release', 'view', tag, '--json', 'tagName', '--repo', repo];
419
+ const view = gh(viewArgs);
314
420
  if (!view.error && view.status === 0) corpusFailure(`release ${tag} already exists; refusing to overwrite immutable corpus seed`);
315
421
  const viewError = String(view.error?.message || view.stderr || view.stdout || '');
316
422
  if (!/(release not found|no release found)/i.test(viewError)) corpusFailure(`cannot prove ${tag} is absent (${viewError.trim() || `gh exited ${view.status}`})`);
317
423
 
318
424
  const receiptSha256 = sha256File(receiptFile);
319
- const gh = (args) => run(ghCommand, [...ghPrefix, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
320
425
 
321
426
  if (!promoteLatest) {
322
427
  // BOOTSTRAP/RECOVERY seeds stay exactly as ADR-086's original contract left them: an immutable
@@ -344,6 +449,7 @@ export async function runProtectedCorpusSeed({
344
449
  '--title', `Immutable corpus seed ${archiveSha256.slice(0, 16)}`,
345
450
  '--notes', notes,
346
451
  bundleFile, receiptFile, accuracyReportFile, recallReportFile,
452
+ coverageAssets.coverageFile, coverageAssets.receiptFile,
347
453
  ];
348
454
  const create = gh(createArgs);
349
455
  if (create.error || create.status !== 0) {
@@ -395,7 +501,8 @@ export async function runProtectedCorpusSeed({
395
501
  // (or the next night's dispatcher) that downloads the archive must be able to reverify it against
396
502
  // the identity it was actually measured under — the blocking recall gate AND the C3 diagnostic it
397
503
  // scored 59.0% on, so nobody has to take either number on trust.
398
- const assetFiles = [bundleFile, signatureFile, digestFile, receiptFile, accuracyReportFile, recallReportFile];
504
+ const assetFiles = [bundleFile, signatureFile, digestFile, receiptFile, accuracyReportFile, recallReportFile,
505
+ coverageAssets.coverageFile, coverageAssets.receiptFile];
399
506
  const create = gh([
400
507
  'release', 'create', tag,
401
508
  '--draft',
@@ -425,13 +532,22 @@ export async function runProtectedCorpusSeed({
425
532
  corpusFailure(`corpus promotion to latest failed (${String(promote.error?.message || promote.stderr || promote.stdout || '').trim()})`);
426
533
  }
427
534
 
428
- const finalView = gh(['release', 'view', tag, '--json', 'tagName,isDraft,isLatest,isPrerelease,assets', '--repo', repo]);
535
+ // `isLatest` is NOT a `gh release view` field (gh 2.101.0: "Unknown JSON field"; it exists only on
536
+ // `gh release list`), so asking for it made this confirmation fail against the real CLI every time.
537
+ // Latest-ness is read from the one authoritative endpoint instead: releases/latest must BE this tag.
538
+ // tests/unit/gh-json-fields.test.mjs checks every --json field list against the captured real CLI.
539
+ const finalView = gh(['release', 'view', tag, '--json', 'tagName,isDraft,isPrerelease,assets', '--repo', repo]);
429
540
  if (finalView.error || finalView.status !== 0) corpusFailure('cannot confirm the promoted corpus release');
430
541
  let promoted;
431
542
  try { promoted = JSON.parse(String(finalView.stdout || 'null')); }
432
543
  catch (error) { corpusFailure(`cannot read the promoted corpus release (${error.message})`); }
544
+ const latestNow = gh(['api', `repos/${repo}/releases/latest`]);
545
+ let latestTag = null;
546
+ if (!latestNow.error && latestNow.status === 0) {
547
+ try { latestTag = JSON.parse(String(latestNow.stdout || 'null'))?.tag_name ?? null; } catch { latestTag = null; }
548
+ }
433
549
  const promotedAssets = (promoted?.assets || []).map((asset) => asset?.name).sort();
434
- if (promoted?.tagName !== tag || promoted.isDraft !== false || promoted.isLatest !== true
550
+ if (promoted?.tagName !== tag || promoted.isDraft !== false || latestTag !== tag
435
551
  || promoted.isPrerelease !== false || JSON.stringify(promotedAssets) !== JSON.stringify(expectedAssets)) {
436
552
  corpusFailure('corpus release did not reach a complete, non-draft, non-prerelease latest state');
437
553
  }
@@ -448,7 +564,13 @@ if (CORPUS_SEED) {
448
564
  console.log(JSON.stringify({ ok: true, mode: 'corpus-seed', ...result }, null, 2));
449
565
  } catch (error) {
450
566
  console.error(error.message);
451
- process.exitCode = 1;
567
+ if (error instanceof CorpusSuperseded) {
568
+ // Typed, not red: stdout carries the outcome the workflow records.
569
+ console.log(JSON.stringify({ ok: false, mode: 'corpus-seed', outcome: 'superseded', reason: error.message }));
570
+ process.exitCode = CORPUS_SUPERSEDED_EXIT;
571
+ } else {
572
+ process.exitCode = 1;
573
+ }
452
574
  }
453
575
  } else {
454
576
 
@@ -556,6 +678,22 @@ if (PUBLISH) {
556
678
  process.exit(1);
557
679
  }
558
680
  }
681
+ // ADR-0091 D6.6 — THE BACKWARD-MOVE RACE. Release QE sealed the corpus generation this bundle was
682
+ // built from; publication happens later, after owner approval. Clients always accept a code release
683
+ // and drop their corpusGeneration marker when they install one (kb/forge-update.mjs), so publishing
684
+ // a bundle built from generation G after G+1 already shipped rolls every user back one night.
685
+ // Re-resolve with the SAME resolver, before any asset upload, and refuse on any difference -- or on
686
+ // any answer that could not prove there is no newer generation.
687
+ try {
688
+ const guard = await assertNoNewerCorpusGeneration({
689
+ sealedFile: assets.corpusSeedPath, repo: 'stuinfla/ruvnet-brain', runtimeRoot: ROOT,
690
+ });
691
+ console.log(` corpus seed still current at publish time: ${guard.origin} ${guard.tag}`);
692
+ } catch (error) {
693
+ console.error(`\n${c.r('✗ GATE FAILED: corpus generation moved after release QE')} ${c.dim(error.message)}`);
694
+ console.error(`${c.r(' NOT shipped. Re-run release QE so this release is built from the newest generation.')}\n`);
695
+ process.exit(1);
696
+ }
559
697
  const bundleSha256 = fs.readFileSync(assets.bundleDigestPath, 'utf8').trim().split(/\s+/)[0];
560
698
  if (!/^[a-f0-9]{64}$/i.test(bundleSha256)) {
561
699
  console.error(`\n${c.r('✗ GATE FAILED: release digest is not a SHA-256 value')}`);
@@ -5,7 +5,8 @@ import crypto from 'node:crypto';
5
5
  import readline from 'node:readline';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { canonicalJson, digest, validateCoverageLedger } from './coverage-integrity.mjs';
8
+ import { canonicalJson, digest, eligibleRepositoryStanding, validateCoverageLedger } from './coverage-integrity.mjs';
9
+ import { fixtureDenominator } from './fixture-denominator.mjs';
9
10
 
10
11
  // Both release phases resolve against an explicit installed context, never the checkout.
11
12
  export async function resolveInstalledCanaryCitation({ kbDir, matched, expected, passageFileDigests = new Map() }) {
@@ -204,8 +205,10 @@ export function auditOracleCoverage({ coverage, queryEvidence, exemptions = null
204
205
  const checked = validateCoverageLedger(coverage);
205
206
  if (!checked.valid) throw new Error(`coverage ledger is invalid: ${checked.failures.join('; ')}`);
206
207
  validateRetrievalQueryEvidence(queryEvidence);
208
+ // ADR-0091 D5: a shipped store is CURRENT or STALE with a verified carry (its bytes ship). A
209
+ // MISSING-with-failure store ships nothing; the fixture-vs-available denominator is D6.4's change.
207
210
  const eligibleRows = coverage.rows.filter((row) => row.kind === 'repository'
208
- && row.disposition === 'eligible' && row.status === 'CURRENT');
211
+ && row.disposition === 'eligible' && eligibleRepositoryStanding(row) === 'shipped');
209
212
  const eligible = ordered(eligibleRows.map(storeOf));
210
213
  if (!eligible.length || new Set(eligible).size !== eligible.length || eligible.some((store) => !store)) {
211
214
  throw new Error('eligible coverage denominator is invalid');
@@ -310,9 +313,20 @@ export function validateRetrievalCanaryPlan(plan) {
310
313
  'legacy population denominator');
311
314
  checkedSet(plan.denominator.legacySelectedStores, plan.denominator.legacySelectedStoreSetSha256,
312
315
  'legacy selected denominator');
316
+ // ADR-0091 D6.4: `eligibleStores` is the FROZEN FIXTURE denominator (it must equal the oracle's store
317
+ // set exactly, as before). Retired fixture stores are excluded from questioning; eligible stores the
318
+ // fixture does not cover are recorded, never questioned and never blocking.
319
+ checkedSet(plan.denominator.retiredFixtureStores, plan.denominator.retiredFixtureStoreSetSha256, 'retired fixture denominator');
320
+ checkedSet(plan.denominator.unfixturedEligibleStores, plan.denominator.unfixturedEligibleStoreSetSha256,
321
+ 'unfixtured eligible record');
313
322
  if (setDigest(plan.denominator.eligibleStores) !== plan.oracle.queryStoreSetSha256) {
314
323
  throw new Error('oracle denominator differs from eligible coverage');
315
324
  }
325
+ if (plan.denominator.retiredFixtureStores.some((store) => !plan.denominator.eligibleStores.includes(store))
326
+ || plan.denominator.unfixturedEligibleStores.some((store) => plan.denominator.eligibleStores.includes(store))
327
+ || plan.denominator.unfixturedEligibleCount !== plan.denominator.unfixturedEligibleStores.length) {
328
+ throw new Error('retrieval canary fixture denominator is inconsistent');
329
+ }
316
330
  if (new Set(ids).size !== ids.length) throw new Error('retrieval canary plan has duplicate case ids');
317
331
  const hasDelta = plan.cases.some(({ cohort }) => cohort === 'delta');
318
332
  if ((!hasDelta && plan.noDelta !== true) || (hasDelta && plan.noDelta === true)
@@ -352,14 +366,18 @@ export function validatePlanAgainstCoverage(plan, coverage, { allowObservedBasel
352
366
  if (!checked.valid) throw new Error(`coverage ledger is invalid: ${checked.failures.join('; ')}`);
353
367
  const generation = coverage.kind === 'ruvnet-brain-release-coverage'
354
368
  ? coverage.releaseCoverageGeneration : coverage.coverageGeneration;
355
- const eligible = ordered(coverage.rows.filter((row) => row.kind === 'repository'
356
- && row.disposition === 'eligible' && row.status === 'CURRENT').map(storeOf));
357
- if (!eligible.length || new Set(eligible).size !== eligible.length) throw new Error('eligible coverage denominator is invalid');
369
+ // Recomputed from the coverage and the plan's own sealed fixture, never trusted from the plan.
370
+ const denominator = fixtureDenominator({ coverage, fixtureStores: Object.keys(plan.oracle.evidence.queries) });
371
+ if (denominator.blocking.length) throw new Error(`fixture store(s) neither available nor retired: ${denominator.blocking.map(({ store }) => store).join(', ')}`);
372
+ const eligible = denominator.questioned;
373
+ if (!eligible.length) throw new Error('eligible coverage denominator is invalid');
358
374
  const baseline = new Set(plan.baseline.stores);
359
375
  const delta = eligible.filter((store) => !baseline.has(store));
360
376
  const legacy = eligible.filter((store) => baseline.has(store));
361
377
  if (generation !== plan.coverage.releaseCoverageGeneration
362
- || canonicalJson(eligible) !== canonicalJson(plan.denominator.eligibleStores)
378
+ || canonicalJson(denominator.fixture) !== canonicalJson(plan.denominator.eligibleStores)
379
+ || canonicalJson(denominator.retired) !== canonicalJson(plan.denominator.retiredFixtureStores)
380
+ || canonicalJson(denominator.unfixturedEligible) !== canonicalJson(plan.denominator.unfixturedEligibleStores)
363
381
  || canonicalJson(delta) !== canonicalJson(plan.denominator.deltaStores)
364
382
  || canonicalJson(legacy) !== canonicalJson(plan.denominator.legacyPopulationStores)
365
383
  || plan.denominator.legacySelectedStores.some((store) => !legacy.includes(store))) {
@@ -417,20 +435,19 @@ export function buildRetrievalCanaryPlan({ coverage, baseline, candidate, covera
417
435
  }
418
436
  validateRetrievalQueryEvidence(queryEvidence);
419
437
  if (queryEvidence.sourceCommit === candidate.sourceSha) throw new Error('independent query source is not pre-candidate');
420
- const eligible = coverage.rows.filter((row) => row.kind === 'repository' && row.disposition === 'eligible');
421
- if (!eligible.length || eligible.some((row) => row.status !== 'CURRENT' || !storeOf(row))) {
422
- throw new Error('eligible repository coverage is incomplete');
423
- }
424
- const duplicateStores = eligible.map(storeOf).filter((store, index, stores) => stores.indexOf(store) !== index);
425
- if (duplicateStores.length) throw new Error(`eligible repository stores are duplicated: ${ordered(new Set(duplicateStores)).join(', ')}`);
426
- const eligibleStores = ordered(eligible.map(storeOf));
427
- if (queryEvidence.queryStoreSetSha256 !== setDigest(eligibleStores)
428
- || canonicalJson(ordered(Object.keys(queryEvidence.queries))) !== canonicalJson(eligibleStores)) {
429
- const oracleStores = new Set(Object.keys(queryEvidence.queries));
430
- const missing = eligibleStores.filter((store) => !oracleStores.has(store));
431
- const extra = [...oracleStores].filter((store) => !eligibleStores.includes(store)).sort();
432
- throw new Error(`independent query oracle does not cover the exact eligible store set (eligible=${eligibleStores.length}, oracle=${oracleStores.size}, missing=${missing.join(',') || 'none'}, extra=${extra.join(',') || 'none'})`);
433
- }
438
+ // ADR-0091 D6.4: fixture ⊆ available. The frozen fixture no longer has to EQUAL a living eligible
439
+ // set; every fixture store must ship (or be verified retired), and eligible stores the fixture does
440
+ // not cover are recorded as unfixturedEligible, never blocking.
441
+ let denominator;
442
+ try { denominator = fixtureDenominator({ coverage, fixtureStores: Object.keys(queryEvidence.queries) }); }
443
+ catch (error) { throw new Error(`eligible repository coverage is incomplete (${error.message})`); }
444
+ if (denominator.blocking.length) {
445
+ throw new Error(`independent query oracle names ${denominator.blocking.length} fixture store(s) that are neither shipped nor `
446
+ + `verified retired: ${denominator.blocking.map(({ store, status }) => `${store}:${status ?? 'no-row'}`).join(', ')}`);
447
+ }
448
+ const eligibleStores = denominator.fixture;
449
+ const eligible = denominator.questionedRows;
450
+ if (!eligible.length) throw new Error('eligible repository coverage is incomplete');
434
451
  const baselineStores = new Set(baseline.stores.map((name) => String(name).toLowerCase()));
435
452
  const delta = eligible.filter((row) => !baselineStores.has(storeOf(row)));
436
453
  const legacyPool = eligible.filter((row) => baselineStores.has(storeOf(row)));
@@ -502,6 +519,11 @@ export function buildRetrievalCanaryPlan({ coverage, baseline, candidate, covera
502
519
  denominator: {
503
520
  eligibleStores,
504
521
  eligibleStoreSetSha256: setDigest(eligibleStores),
522
+ retiredFixtureStores: denominator.retired,
523
+ retiredFixtureStoreSetSha256: setDigest(denominator.retired),
524
+ unfixturedEligibleStores: denominator.unfixturedEligible,
525
+ unfixturedEligibleStoreSetSha256: setDigest(denominator.unfixturedEligible),
526
+ unfixturedEligibleCount: denominator.unfixturedEligible.length,
505
527
  deltaStores: ordered(delta.map(storeOf)),
506
528
  deltaStoreSetSha256: setDigest(delta.map(storeOf)),
507
529
  legacyPopulationStores: ordered(legacyPool.map(storeOf)),
@@ -341,7 +341,13 @@ function assertExclusionEvidence(repo, exclusion, upstreamSha) {
341
341
  }
342
342
  }
343
343
 
344
- export function classifyRepository(repo, evidence, exclusion = null) {
344
+ // ADR-0091 D5: `outcome` is what reconciliation recorded for this store when its refresh FAILED this
345
+ // generation -- { carry } (previous bytes kept, already re-hashed against the ledger), { failure } (no
346
+ // prior bytes), or { integrity } (carried bytes failed that re-hash). The status token is always the
347
+ // one the evidence proves; the record is attached only where it agrees with that status, so a carry
348
+ // can never dress up a row the bytes do not support. Rows are annotated BEFORE sealing, so the
349
+ // coverage generation digest covers them.
350
+ export function classifyRepository(repo, evidence, exclusion = null, outcome = null) {
345
351
  const upstreamSha = repo.defaultBranchRef?.target?.oid || null;
346
352
  const activeExclusion = Boolean(exclusion && String(exclusion.pushedAt || '') !== ''
347
353
  && String(exclusion.pushedAt) === String(repo.pushedAt || ''));
@@ -373,6 +379,14 @@ export function classifyRepository(repo, evidence, exclusion = null) {
373
379
  else if (evidence.receipt.sourceCommit !== upstreamSha) { status = 'STALE'; reasons.push('receipt sourceCommit differs from upstream HEAD'); }
374
380
  else if (!evidence.bytesVerified) { status = 'FAILED'; reasons.push('RVF bytes do not match receipt'); }
375
381
  else if (!evidence.passagesPresent) { status = 'FAILED'; reasons.push('passage inventory is absent'); }
382
+ const disposed = isIngestibleDisposition(disposition);
383
+ if (disposed && outcome?.integrity && ['CURRENT', 'STALE'].includes(status)) {
384
+ status = 'FAILED';
385
+ reasons.push(outcome.integrity);
386
+ }
387
+ const record = {};
388
+ if (disposed && outcome?.carry && status === 'STALE') record.carry = { ...outcome.carry };
389
+ if (disposed && outcome?.failure && status === 'MISSING') record.failure = { ...outcome.failure };
376
390
  return {
377
391
  key: repo.fullName ? `repo:${repo.fullName.toLowerCase()}` : `repo:${repo.databaseId}`,
378
392
  kind: 'repository',
@@ -391,6 +405,7 @@ export function classifyRepository(repo, evidence, exclusion = null) {
391
405
  cardPresent: evidence.cardPresent },
392
406
  status,
393
407
  reasons,
408
+ ...record,
394
409
  };
395
410
  }
396
411
 
@@ -567,7 +582,7 @@ export function explainCoverageDrift(recorded, current) {
567
582
  // where they are source-controlled (`<repo>/kb`) unless the caller names both directories, as the
568
583
  // release path does with `--assets`.
569
584
  export function buildCoverage({ owner = 'ruvnet', env = process.env, home = os.homedir(), kbDir = null, policyDir = null,
570
- observation = null, gh = runGh, now = () => new Date().toISOString() } = {}) {
585
+ observation = null, gh = runGh, now = () => new Date().toISOString(), storeOutcomes = null } = {}) {
571
586
  policyDir ??= kbDir ?? path.join(ROOT, 'kb');
572
587
  kbDir ??= storeRoot(env, home);
573
588
  if (rootNeverMaterialized(kbDir)) {
@@ -597,7 +612,8 @@ export function buildCoverage({ owner = 'ruvnet', env = process.env, home = os.h
597
612
  const exclusions = fs.existsSync(exclusionsPath) ? JSON.parse(fs.readFileSync(exclusionsPath, 'utf8')) : {};
598
613
  const rows = repositories.rows.map((repo) => {
599
614
  const store = storeName(repo.storeName || repo.name);
600
- return classifyRepository(repo, artifactEvidence(kbDir, ledger, cardStores, store), exclusions[store] || null);
615
+ return classifyRepository(repo, artifactEvidence(kbDir, ledger, cardStores, store), exclusions[store] || null,
616
+ storeOutcomes?.[store.toLowerCase()] || null);
601
617
  });
602
618
  const gistEvidence = { ...artifactEvidence(kbDir, ledger, cardStores, 'ruv-gists'), sources: gistSources };
603
619
  try {