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.
@@ -0,0 +1,234 @@
1
+ // scripts/rehearse-seed-selection.mjs — the ADR-0091 D4 half of the corpus rehearsal.
2
+ //
3
+ // Before D4 the rehearsal handed candidate N straight to generation N+1 and never exercised seed
4
+ // SELECTION at all (the audit's V4 gap). This module lets it do what the nightly does:
5
+ // 1. "publish" candidate N into a LOCAL release registry, from the exact files release.mjs tried to
6
+ // upload (the recorded `gh release create`), so nothing is invented about the asset set;
7
+ // 2. optionally cut a simulated CODE RELEASE in the disposable checkout between generations (a
8
+ // version bump through the repo's own scripts/sync-version.mjs, committed) -- the event that,
9
+ // before D4, reset the seed chain to the bootstrap (ADR-0091 section 3.4);
10
+ // 3. register newer, INCOMPATIBLE decoy generations that must be skipped before any archive
11
+ // download: one whose receipt names a different embedding model, one whose recall report was
12
+ // measured against a different frozen fixture;
13
+ // 4. answer the real corpus-next-seed.mjs resolver through a fake `gh` served from that registry,
14
+ // which REFUSES any archive download and records every call;
15
+ // 5. check every candidate's runtime surface with the real verifyApprovedRuntime against a pin
16
+ // derived from the CHECKOUT's own files, never from the candidate -- so a seed's executable that
17
+ // leaked into the archive shows up as "no approved code release pinned".
18
+ //
19
+ // It publishes nothing and calls no network: the registry is a directory, the `gh` is a function.
20
+
21
+ import crypto from 'node:crypto';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { spawnSync } from 'node:child_process';
25
+
26
+ const ARCHIVE_ASSET = 'ruvnet-brain.zip';
27
+ const SIGNATURE_ASSET = 'ruvnet-brain.zip.sig';
28
+
29
+ const sha256Text = (text) => crypto.createHash('sha256').update(text).digest('hex');
30
+ function sha256File(file) {
31
+ const hash = crypto.createHash('sha256');
32
+ const fd = fs.openSync(file, 'r');
33
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
34
+ try {
35
+ let bytes;
36
+ while ((bytes = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, bytes));
37
+ } finally { fs.closeSync(fd); }
38
+ return hash.digest('hex');
39
+ }
40
+ const clone = (from, to) => fs.copyFileSync(from, to, fs.constants.COPYFILE_FICLONE);
41
+
42
+ // ---------------------------------------------------------------------------------------------
43
+ // The local registry
44
+ // ---------------------------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Register one generation. `files` are local paths whose basenames become asset names. `declared`
48
+ * overrides an asset's listed size without writing its bytes (a decoy's archive is listed, never
49
+ * served). A `.sig` placeholder is added when the files carry none, and the release records that it
50
+ * is a placeholder: the rehearsal's publish path is the non-promoting one, which signs nothing, and
51
+ * the resolver checks the signature's PRESENCE only (verification happens in the updater).
52
+ */
53
+ export function registerGeneration({ registryDir, tag, createdAt, files = [], declared = {}, note = null }) {
54
+ const dir = path.join(registryDir, tag);
55
+ fs.mkdirSync(path.join(dir, 'assets'), { recursive: true });
56
+ const assets = [];
57
+ for (const file of files) {
58
+ const name = path.basename(file);
59
+ clone(file, path.join(dir, 'assets', name));
60
+ assets.push({ name, size: fs.statSync(file).size, state: 'uploaded' });
61
+ }
62
+ for (const [name, size] of Object.entries(declared)) {
63
+ fs.writeFileSync(path.join(dir, 'assets', name), '');
64
+ assets.push({ name, size, state: 'uploaded', declaredOnly: true });
65
+ }
66
+ let signaturePlaceholder = false;
67
+ if (!assets.some(({ name }) => name === SIGNATURE_ASSET)) {
68
+ fs.writeFileSync(path.join(dir, 'assets', SIGNATURE_ASSET), 'rehearsal placeholder: not a signature\n');
69
+ assets.push({ name: SIGNATURE_ASSET, size: fs.statSync(path.join(dir, 'assets', SIGNATURE_ASSET)).size, state: 'uploaded' });
70
+ signaturePlaceholder = true;
71
+ }
72
+ const release = { tagName: tag, isDraft: false, createdAt, assets, signaturePlaceholder, note };
73
+ fs.writeFileSync(path.join(dir, 'release.json'), `${JSON.stringify(release, null, 2)}\n`);
74
+ return release;
75
+ }
76
+
77
+ /** The files a recorded `gh release create` would have uploaded: its existing-file arguments. */
78
+ export function uploadedFilesOf(createArgs) {
79
+ return (createArgs || []).filter((value) => path.isAbsolute(String(value)) && fs.existsSync(value) && fs.statSync(value).isFile());
80
+ }
81
+
82
+ /**
83
+ * A `gh` answering the three calls corpus-next-seed.mjs makes, from the registry. An archive download
84
+ * is REFUSED and recorded, so the rehearsal can assert the resolver judged from small files only.
85
+ */
86
+ export function registryGh(registryDir) {
87
+ const calls = [];
88
+ const releases = () => fs.readdirSync(registryDir)
89
+ .map((tag) => JSON.parse(fs.readFileSync(path.join(registryDir, tag, 'release.json'), 'utf8')));
90
+ const run = (command, args) => {
91
+ const line = `${command} ${args.join(' ')}`;
92
+ calls.push(line);
93
+ if (command !== 'gh') return { status: 127, stderr: `unexpected command ${command}` };
94
+ if (args[0] === 'release' && args[1] === 'list') {
95
+ return { status: 0, stdout: JSON.stringify(releases().map(({ tagName, isDraft, createdAt }) => ({ tagName, isDraft, createdAt }))) };
96
+ }
97
+ const release = releases().find((row) => row.tagName === args[2]);
98
+ if (args[0] === 'release' && args[1] === 'view') {
99
+ if (!release) return { status: 1, stderr: 'release not found' };
100
+ return { status: 0, stdout: JSON.stringify({ tagName: release.tagName, isDraft: release.isDraft,
101
+ assets: release.assets.map(({ name, size, state }) => ({ name, size, state })) }) };
102
+ }
103
+ if (args[0] === 'release' && args[1] === 'download') {
104
+ const pattern = args[args.indexOf('--pattern') + 1];
105
+ const dir = args[args.indexOf('--dir') + 1];
106
+ if (pattern === ARCHIVE_ASSET) return { status: 1, stderr: 'rehearsal registry refuses archive downloads to the resolver' };
107
+ const asset = release?.assets.find(({ name }) => name === pattern);
108
+ if (!asset || asset.declaredOnly) return { status: 1, stderr: 'asset not found' };
109
+ fs.copyFileSync(path.join(registryDir, release.tagName, 'assets', pattern), path.join(dir, pattern));
110
+ return { status: 0, stdout: '' };
111
+ }
112
+ return { status: 1, stderr: `unsupported gh call in the rehearsal registry: ${line}` };
113
+ };
114
+ return {
115
+ run,
116
+ calls,
117
+ downloads: () => calls.filter((line) => / release download /.test(line)),
118
+ archiveDownloadAttempts: () => calls.filter((line) => / release download /.test(line) && line.includes(`--pattern ${ARCHIVE_ASSET} `)),
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Two newer generations the resolver must SKIP, each rebound to its own digest so every identity
124
+ * check that precedes the compatibility check still passes -- only the property under test differs:
125
+ * model-mismatch -- one store's receipt row names another model/dimensions (rejected from the
126
+ * receipt alone; its recall report must never be fetched);
127
+ * fixture-mismatch -- the recall report was measured against another frozen fixture (rejected
128
+ * after the small recall download; its archive must never be fetched).
129
+ */
130
+ export function registerIncompatibleDecoys({ registryDir, real, createdAtMs, scratchDir }) {
131
+ const realReceipt = JSON.parse(fs.readFileSync(real.receiptFile, 'utf8'));
132
+ const realRecall = JSON.parse(fs.readFileSync(real.recallFile, 'utf8'));
133
+ const decoys = [];
134
+ const make = (kind, offsetMs, mutate) => {
135
+ const digest = sha256Text(`rehearsal-decoy:${kind}:${real.sha256}`);
136
+ const tag = `corpus-sha256-${digest}`;
137
+ const dir = path.join(scratchDir, `decoy-${kind}`);
138
+ fs.mkdirSync(dir, { recursive: true });
139
+ const recall = { ...realRecall, archive: { ...realRecall.archive, sha256: digest, bytes: real.bytes } };
140
+ const receipt = { ...realReceipt, archive: { ...realReceipt.archive, sha256: digest, bytes: real.bytes } };
141
+ mutate({ recall, receipt });
142
+ const recallFile = path.join(dir, 'ruvnet-brain.zip.recall.json');
143
+ fs.writeFileSync(recallFile, JSON.stringify(recall));
144
+ receipt.recallReport = { file: path.basename(recallFile), sha256: sha256File(recallFile), bytes: fs.statSync(recallFile).size };
145
+ const receiptFile = path.join(dir, 'corpus-receipt.json');
146
+ fs.writeFileSync(receiptFile, JSON.stringify(receipt));
147
+ registerGeneration({
148
+ registryDir, tag, createdAt: new Date(createdAtMs + offsetMs).toISOString(),
149
+ files: [receiptFile, recallFile, real.accuracyFile], declared: { [ARCHIVE_ASSET]: real.bytes },
150
+ note: `rehearsal decoy (${kind}); its archive is listed, never served`,
151
+ });
152
+ decoys.push({ kind, tag });
153
+ };
154
+ make('fixture-mismatch', 1000, ({ recall }) => {
155
+ recall.fixture = { ...recall.fixture, sha256: sha256Text('rehearsal-decoy: a different frozen fixture') };
156
+ });
157
+ make('model-mismatch', 2000, ({ receipt }) => {
158
+ receipt.stores = receipt.stores.map((row, index) => (index === 0 ? { ...row, model: 'Xenova/all-MiniLM-L6-v2', dimensions: 384 } : row));
159
+ });
160
+ return decoys;
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------------------------
164
+ // A code release between generations
165
+ // ---------------------------------------------------------------------------------------------
166
+
167
+ export function nextPatchVersion(version) {
168
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(version || ''));
169
+ if (!match) throw new Error(`cannot bump a non x.y.z version (${version})`);
170
+ return `${match[1]}.${match[2]}.${Number(match[3]) + 1}`;
171
+ }
172
+
173
+ /**
174
+ * Cut a code release in the disposable checkout the way CONTRIBUTING.md's "set version" commit does:
175
+ * edit the ONE hand-edited number (plugin/.claude-plugin/plugin.json) and let scripts/sync-version.mjs
176
+ * propagate it (kb/package.json -- a SHIPPED runtime file -- among others). Only the paths this bump
177
+ * changed are committed; the rehearsal's own scratch edits in the checkout stay uncommitted.
178
+ */
179
+ export function simulateCodeRelease({ checkoutRoot }) {
180
+ const git = (args) => {
181
+ const result = spawnSync('git', args, { cwd: checkoutRoot, encoding: 'utf8' });
182
+ if (result.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${String(result.stderr).trim().slice(0, 400)}`);
183
+ return String(result.stdout);
184
+ };
185
+ const dirty = () => new Set(git(['status', '--porcelain']).split('\n').filter(Boolean).map((line) => line.slice(3)));
186
+ const pluginFile = path.join(checkoutRoot, 'plugin', '.claude-plugin', 'plugin.json');
187
+ const plugin = JSON.parse(fs.readFileSync(pluginFile, 'utf8'));
188
+ const from = plugin.version;
189
+ const to = nextPatchVersion(from);
190
+ const before = dirty();
191
+ plugin.version = to;
192
+ fs.writeFileSync(pluginFile, `${JSON.stringify(plugin, null, 2)}\n`);
193
+ const sync = spawnSync(process.execPath, [path.join(checkoutRoot, 'scripts', 'sync-version.mjs')], { cwd: checkoutRoot, encoding: 'utf8' });
194
+ if (sync.status !== 0) throw new Error(`sync-version.mjs failed in the disposable checkout: ${String(sync.stderr || sync.stdout).trim().slice(0, 400)}`);
195
+ const committed = [...dirty()].filter((file) => !before.has(file)).sort();
196
+ if (!committed.includes('kb/package.json')) throw new Error('the simulated code release did not change kb/package.json, the shipped runtime manifest');
197
+ git(['add', '--', ...committed]);
198
+ git(['-c', 'user.email=rehearsal@localhost', '-c', 'user.name=corpus rehearsal', 'commit', '-q', '-m', `chore(release): set version ${to} (rehearsal)`]);
199
+ return { from, to, head: git(['rev-parse', 'HEAD']).trim(), committed };
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------------------------
203
+ // The runtime surface, judged against the CHECKOUT
204
+ // ---------------------------------------------------------------------------------------------
205
+
206
+ /**
207
+ * A pin built from the checkout's own bytes, for the runtime-shaped rows of a candidate manifest.
208
+ * build-bundle.mjs copies the kb module graph and kb/package*.json from <checkout>/kb, and
209
+ * verify-bundle.mjs, the signing key and primer/ from the checkout root or scripts/. A row is pinned
210
+ * only when a checkout file at one of those places has EXACTLY its bytes; anything else (a file that
211
+ * could only have come from the seed) stays unpinned, so verifyApprovedRuntime's backward check names
212
+ * it. Never emitted from the candidate itself, which would pin whatever leaked.
213
+ */
214
+ export function checkoutRuntimePin({ checkoutRoot, manifest, approvedCodeSha, version, api }) {
215
+ const traced = [];
216
+ const untraced = [];
217
+ for (const row of manifest.files.filter((entry) => api.isRuntimeFile(entry.path))) {
218
+ const candidates = [path.join('kb', row.path), row.path, path.join('scripts', row.path)];
219
+ const source = candidates.find((relative) => {
220
+ const file = path.join(checkoutRoot, relative);
221
+ return fs.existsSync(file) && fs.statSync(file).isFile() && fs.statSync(file).size === row.bytes && sha256File(file) === row.sha256;
222
+ });
223
+ if (source) {
224
+ const file = path.join(checkoutRoot, source);
225
+ traced.push({ path: row.path, sha256: sha256File(file), bytes: fs.statSync(file).size, source });
226
+ } else untraced.push(row.path);
227
+ }
228
+ const pin = api.emitApprovedRuntime({
229
+ manifest: { schemaVersion: 1, kind: 'ruvnet-brain-archive-manifest', version, releaseTag: `v${version}`,
230
+ files: traced.map(({ path: p, sha256, bytes }) => ({ path: p, sha256, bytes })) },
231
+ approvedCodeSha,
232
+ });
233
+ return { pin, traced: traced.length, untraced };
234
+ }
@@ -60,12 +60,15 @@ const recallNotes = (receipt) => {
60
60
  + ` about themselves from their own content; ${r.exactFileTop5}/${r.questions} return the exact`
61
61
  + ` labeled file in the top 5 (floor ${r.floor}).`,
62
62
  'NOT measured: generated-answer correctness, citation support, or unscoped whole-corpus discovery.',
63
- `ADR-086 C3 was NOT met and is NOT claimed — its measurement ships as ${receipt.accuracyReport.file}`
64
- + ' for inspection.',
63
+ `ADR-086 C3 was NOT met and is NOT claimed — its diagnostic measurement (a declared question sample`
64
+ + ` when its coverage.bounded says so) ships as ${receipt.accuracyReport.file} for inspection.`,
65
65
  ];
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,8 +191,28 @@ export async function runProtectedCorpusSeed({
164
191
  } catch (error) {
165
192
  corpusFailure(`corpus receipt is unreadable/corrupt (${error.message})`);
166
193
  }
167
- if (!isHex(target, 40) || target !== head || target !== env.GITHUB_SHA || target !== receipt.builderSourceSha) {
168
- 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
+ }
169
216
  }
170
217
 
171
218
  // Schema 3 (ADR-086 Step 15 / A6): the receipt binds the full provenance closure shipped INSIDE
@@ -210,10 +257,11 @@ export async function runProtectedCorpusSeed({
210
257
 
211
258
  // ADR-086 Step 15's second binding. The detached accuracy report travels beside the archive; this
212
259
  // proves (a) the file the receipt names is the file present here, byte for byte, (b) the report
213
- // was measured against THESE archive bytes, (c) every partition in both query modes passed
214
- // 20x>=19x with no timeouts and no bounded sampling, and (d) it was produced by the committed
215
- // benchmark against the committed oracle — so a swapped oracle or a patched benchmark is caught
216
- // here even though the receipt itself carries only {file, sha256, bytes}.
260
+ // was measured against THESE archive bytes, and (c) it was produced by the committed benchmark
261
+ // against the committed oracle — so a swapped oracle or a patched benchmark is caught here even
262
+ // though the receipt itself carries only {file, sha256, bytes}. It does NOT check the score or
263
+ // coverage completeness: C3 is a diagnostic (ADR-086 amendment 2026-09-15), and the corpus
264
+ // pipeline measures a declared question sample of it (ADR-0091 D2).
217
265
  const accuracyReportFile = `${bundleFile}.accuracy.json`;
218
266
  if (receipt.accuracyReport.file !== path.basename(accuracyReportFile)) {
219
267
  corpusFailure('corpus receipt names an accuracy report that is not the one beside this archive');
@@ -254,11 +302,16 @@ export async function runProtectedCorpusSeed({
254
302
  || fs.statSync(recallReportFile).size !== receipt.recallReport.bytes) {
255
303
  corpusFailure('detached repo-recall report bytes do not match the corpus receipt');
256
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();
257
308
  try {
258
309
  readRecallReport({
259
310
  reportFile: recallReportFile,
260
311
  archive: archiveIdentity,
261
- expectedFixtureSha256: loadFixture().fixtureSha256,
312
+ expectedFixtureSha256: recallFixture.fixtureSha256,
313
+ coverageBytes: fs.readFileSync(coverageFile),
314
+ fixtureStores: recallFixture.questions.map((question) => question.store),
262
315
  });
263
316
  } catch (error) {
264
317
  corpusFailure(`retrieval does not qualify this corpus for publication (${error.message})`);
@@ -273,12 +326,39 @@ export async function runProtectedCorpusSeed({
273
326
  // here. It runs before any `gh` call so an untrue candidate never reaches the network.
274
327
  try {
275
328
  await verifyCorpusReceipt({
276
- receiptFile, bundleFile, accuracyReportFile, recallReportFile, expectedBuilderSha: target, expectedArchiveSha256: archiveSha256,
329
+ receiptFile, bundleFile, accuracyReportFile, recallReportFile, coverageFile, expectedBuilderSha: target, expectedArchiveSha256: archiveSha256,
277
330
  });
278
331
  } catch (error) {
279
332
  corpusFailure(`corpus receipt does not verify against the sealed archive (${error.message})`);
280
333
  }
281
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
+
282
362
  // EVERY local proof happens before the first network call. `gh` must never be reached by a
283
363
  // candidate that is already known to be unpublishable — that is the same discipline the deep
284
364
  // verifyCorpusReceipt above follows, and a customer release with an unusable signature is exactly
@@ -300,16 +380,48 @@ export async function runProtectedCorpusSeed({
300
380
  if (!Number.isFinite(Date.parse(generation))) corpusFailure('corpus receipt createdAt is not a readable generation timestamp');
301
381
  }
302
382
 
303
- const viewArgs = ['release', 'view', tag, '--json', 'tagName', '--repo', repo];
304
383
  const ghCommand = env.RUVNET_GH_COMMAND || 'gh';
305
384
  const ghPrefix = env.RUVNET_GH_SCRIPT ? [env.RUVNET_GH_SCRIPT] : [];
306
- 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);
307
420
  if (!view.error && view.status === 0) corpusFailure(`release ${tag} already exists; refusing to overwrite immutable corpus seed`);
308
421
  const viewError = String(view.error?.message || view.stderr || view.stdout || '');
309
422
  if (!/(release not found|no release found)/i.test(viewError)) corpusFailure(`cannot prove ${tag} is absent (${viewError.trim() || `gh exited ${view.status}`})`);
310
423
 
311
424
  const receiptSha256 = sha256File(receiptFile);
312
- const gh = (args) => run(ghCommand, [...ghPrefix, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
313
425
 
314
426
  if (!promoteLatest) {
315
427
  // BOOTSTRAP/RECOVERY seeds stay exactly as ADR-086's original contract left them: an immutable
@@ -337,6 +449,7 @@ export async function runProtectedCorpusSeed({
337
449
  '--title', `Immutable corpus seed ${archiveSha256.slice(0, 16)}`,
338
450
  '--notes', notes,
339
451
  bundleFile, receiptFile, accuracyReportFile, recallReportFile,
452
+ coverageAssets.coverageFile, coverageAssets.receiptFile,
340
453
  ];
341
454
  const create = gh(createArgs);
342
455
  if (create.error || create.status !== 0) {
@@ -388,7 +501,8 @@ export async function runProtectedCorpusSeed({
388
501
  // (or the next night's dispatcher) that downloads the archive must be able to reverify it against
389
502
  // the identity it was actually measured under — the blocking recall gate AND the C3 diagnostic it
390
503
  // scored 59.0% on, so nobody has to take either number on trust.
391
- const assetFiles = [bundleFile, signatureFile, digestFile, receiptFile, accuracyReportFile, recallReportFile];
504
+ const assetFiles = [bundleFile, signatureFile, digestFile, receiptFile, accuracyReportFile, recallReportFile,
505
+ coverageAssets.coverageFile, coverageAssets.receiptFile];
392
506
  const create = gh([
393
507
  'release', 'create', tag,
394
508
  '--draft',
@@ -418,13 +532,22 @@ export async function runProtectedCorpusSeed({
418
532
  corpusFailure(`corpus promotion to latest failed (${String(promote.error?.message || promote.stderr || promote.stdout || '').trim()})`);
419
533
  }
420
534
 
421
- 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]);
422
540
  if (finalView.error || finalView.status !== 0) corpusFailure('cannot confirm the promoted corpus release');
423
541
  let promoted;
424
542
  try { promoted = JSON.parse(String(finalView.stdout || 'null')); }
425
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
+ }
426
549
  const promotedAssets = (promoted?.assets || []).map((asset) => asset?.name).sort();
427
- if (promoted?.tagName !== tag || promoted.isDraft !== false || promoted.isLatest !== true
550
+ if (promoted?.tagName !== tag || promoted.isDraft !== false || latestTag !== tag
428
551
  || promoted.isPrerelease !== false || JSON.stringify(promotedAssets) !== JSON.stringify(expectedAssets)) {
429
552
  corpusFailure('corpus release did not reach a complete, non-draft, non-prerelease latest state');
430
553
  }
@@ -441,7 +564,13 @@ if (CORPUS_SEED) {
441
564
  console.log(JSON.stringify({ ok: true, mode: 'corpus-seed', ...result }, null, 2));
442
565
  } catch (error) {
443
566
  console.error(error.message);
444
- 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
+ }
445
574
  }
446
575
  } else {
447
576
 
@@ -549,6 +678,22 @@ if (PUBLISH) {
549
678
  process.exit(1);
550
679
  }
551
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
+ }
552
697
  const bundleSha256 = fs.readFileSync(assets.bundleDigestPath, 'utf8').trim().split(/\s+/)[0];
553
698
  if (!/^[a-f0-9]{64}$/i.test(bundleSha256)) {
554
699
  console.error(`\n${c.r('✗ GATE FAILED: release digest is not a SHA-256 value')}`);