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,138 @@
1
+ #!/usr/bin/env node
2
+ // scripts/corpus-dispatch-decision.mjs — the ONE decision the nightly corpus dispatcher makes.
3
+ //
4
+ // WHY A MODULE (2026-09-29 nightly redesign). The decision used to live as bash in
5
+ // corpus-nightly-dispatch.yml and compared the approved runtime against main HEAD: whenever main
6
+ // carried a commit newer than the newest install-verified release -- which under "every main commit
7
+ // is a release" is exactly the window between a code release landing and its verification finishing,
8
+ // and forever if that verification failed -- the nightly stood down, silently. The corpus is built at
9
+ // the APPROVED runtime's own source, so main HEAD is irrelevant to it and is no longer consulted.
10
+ //
11
+ // decide() is pure and returns one of three verdicts:
12
+ // arm dispatch protected-release mode=corpus at the approved release's sourceSha
13
+ // stand-down a correct, quiet non-event (kill switch off, newest release not yet verified, or an
14
+ // approved runtime older than the first release whose pipeline understands this workflow)
15
+ // fail evidence is present but does not hold -- loud, never a silent skip
16
+ //
17
+ // minPipelineVersion: the dispatched workflow text is always main's, but the scripts it runs come from
18
+ // the approved release's checkout. A runtime older than MIN_PIPELINE_VERSION does not know the flags
19
+ // the new workflow passes (--approved-tag, --no-change-out), so the nightly stands down rather than
20
+ // let new workflow text drive old scripts.
21
+
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { pathToFileURL } from 'node:url';
25
+
26
+ export const MIN_PIPELINE_VERSION = '4.3.36'; // sync-version-ignore: a fixed pipeline floor, not the current version
27
+ const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
28
+ const HEX40 = /^[0-9a-f]{40}$/;
29
+ const CODE_TAG = /^v\d+\.\d+\.\d+$/;
30
+ // scripts/approved-runtime.mjs names the release it refused: "code release vX.Y.Z (the newest) carries ...".
31
+ const REFUSED_NEWEST = /code release (v\d+\.\d+\.\d+) \(the newest\)/;
32
+
33
+ /** -1 / 0 / 1; throws on anything that is not plain x.y.z. */
34
+ export function compareVersions(left, right) {
35
+ const a = SEMVER.exec(String(left || ''));
36
+ const b = SEMVER.exec(String(right || ''));
37
+ if (!a || !b) throw new Error(`cannot compare versions ${JSON.stringify(left)} and ${JSON.stringify(right)}`);
38
+ for (let i = 1; i <= 3; i += 1) {
39
+ const delta = Number(a[i]) - Number(b[i]);
40
+ if (delta) return Math.sign(delta);
41
+ }
42
+ return 0;
43
+ }
44
+
45
+ /** The owner's kill switch: CORPUS_NIGHTLY reading `off` (any case, surrounding space ignored). */
46
+ export function killSwitchOff(nightlyVar) {
47
+ return String(nightlyVar ?? '').trim().toLowerCase() === 'off';
48
+ }
49
+
50
+ /**
51
+ * @param nightlyVar repository variable CORPUS_NIGHTLY (armed unless it reads `off`, any case; the owner's
52
+ * approval of the code release is the consent)
53
+ * @param resolution { status: 'resolved', release: {tag, version, sourceSha} }
54
+ * | { status: 'not-yet-verified' } | { status: 'invalid', reason }
55
+ */
56
+ export function decide({ nightlyVar, resolution, minPipelineVersion = MIN_PIPELINE_VERSION } = {}) {
57
+ if (killSwitchOff(nightlyVar)) {
58
+ return { verdict: 'stand-down', reason: 'repository variable CORPUS_NIGHTLY is off (the owner kill switch)' };
59
+ }
60
+ if (resolution?.status === 'not-yet-verified') {
61
+ return { verdict: 'stand-down', reason: 'the newest code release has not reached install-verified yet (no aggregate)' };
62
+ }
63
+ if (resolution?.status === 'invalid' && CODE_TAG.test(String(resolution.tag || ''))
64
+ && compareVersions(resolution.tag.slice(1), minPipelineVersion) < 0) {
65
+ // VERSION SKEW, not tampering (found 2026-09-29): this checkout's verifier is a newer generation
66
+ // than the newest release's aggregate (e.g. D7's retired-fixture denominator), so it cannot judge
67
+ // it. A runtime below the floor would stand down even if it verified, so standing down loses
68
+ // nothing -- and an aggregate that claims a runtime AT or above the floor still fails loud below.
69
+ return { verdict: 'stand-down', reason: `the newest code release ${resolution.tag} predates ${minPipelineVersion}; `
70
+ + `its install evidence is not judged by this pipeline (${resolution.reason || 'unverifiable here'})` };
71
+ }
72
+ if (resolution?.status !== 'resolved') {
73
+ return { verdict: 'fail', reason: `the newest code release carries install-verification evidence that does not hold: ${resolution?.reason || 'unknown resolution'}` };
74
+ }
75
+ const { tag, version, sourceSha } = resolution.release || {};
76
+ if (!SEMVER.test(String(version || '')) || tag !== `v${version}` || !HEX40.test(String(sourceSha || ''))) {
77
+ return { verdict: 'fail', reason: `resolved release identity is malformed (${JSON.stringify(resolution.release || null)})` };
78
+ }
79
+ if (compareVersions(version, minPipelineVersion) < 0) {
80
+ return { verdict: 'stand-down', reason: `approved runtime ${tag} predates ${minPipelineVersion}, the first release whose pipeline this workflow can drive` };
81
+ }
82
+ return { verdict: 'arm', reason: `building the corpus at approved runtime ${tag} @ ${sourceSha}`, tag, version, sourceSha };
83
+ }
84
+
85
+ /** approved-runtime.mjs --resolve's exit status + stdout (+ stderr, naming a refused release) -> a resolution. */
86
+ export function resolutionFrom({ status, stdout, stderr = '' }) {
87
+ if (String(status) === '3') return { status: 'not-yet-verified' };
88
+ if (String(status) !== '0') {
89
+ const tag = REFUSED_NEWEST.exec(String(stderr))?.[1] ?? null;
90
+ const detail = String(stderr).split(': ').slice(-1)[0].trim().slice(0, 300);
91
+ return { status: 'invalid', tag, reason: `approved-runtime.mjs --resolve exited ${status}${detail ? ` (${detail})` : ''}` };
92
+ }
93
+ try {
94
+ const release = JSON.parse(String(stdout || ''));
95
+ return { status: 'resolved', release };
96
+ } catch (error) {
97
+ return { status: 'invalid', reason: `approved-runtime.mjs --resolve printed no release (${error.message})` };
98
+ }
99
+ }
100
+
101
+ const arg = (argv, name) => {
102
+ const index = argv.indexOf(name);
103
+ return index >= 0 ? argv[index + 1] : undefined;
104
+ };
105
+
106
+ /**
107
+ * CLI: --nightly <value> --resolve-status <n|skipped> --release <file> [--resolve-stderr <file>]
108
+ * --github-output <file>. Exit 1 only on `fail`.
109
+ * --is-off <value> prints true|false (lets the workflow skip the ~555 MB resolve when the switch is off)
110
+ */
111
+ export function main(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
112
+ if (argv.includes('--is-off')) {
113
+ stdout.write(`${killSwitchOff(arg(argv, '--is-off'))}\n`);
114
+ return 0;
115
+ }
116
+ const nightlyVar = arg(argv, '--nightly') ?? '';
117
+ const status = arg(argv, '--resolve-status');
118
+ const releaseFile = arg(argv, '--release');
119
+ const stderrFile = arg(argv, '--resolve-stderr');
120
+ const githubOutput = arg(argv, '--github-output');
121
+ const resolution = status === 'skipped' ? null
122
+ : resolutionFrom({ status,
123
+ stdout: releaseFile && fs.existsSync(releaseFile) ? fs.readFileSync(releaseFile, 'utf8') : '',
124
+ stderr: stderrFile && fs.existsSync(stderrFile) ? fs.readFileSync(stderrFile, 'utf8') : '' });
125
+ const decision = decide({ nightlyVar, resolution });
126
+ const lines = decision.verdict === 'arm'
127
+ ? ['armed=true', `approved_sha=${decision.sourceSha}`, `approved_version=${decision.version}`, `approved_tag=${decision.tag}`]
128
+ : ['armed=false'];
129
+ lines.push(`verdict=${decision.verdict}`);
130
+ if (githubOutput) fs.appendFileSync(path.resolve(githubOutput), `${lines.join('\n')}\n`);
131
+ const annotation = decision.verdict === 'fail' ? '::error::' : '::notice::';
132
+ stdout.write(`${annotation}corpus-nightly-dispatch ${decision.verdict}: ${decision.reason}\n`);
133
+ return decision.verdict === 'fail' ? 1 : 0;
134
+ }
135
+
136
+ if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
137
+ process.exitCode = main();
138
+ }
@@ -11,27 +11,56 @@
11
11
  // unsigned or half-uploaded release — it falls back to the committed data/corpus-seed.json, which is
12
12
  // the bootstrap/recovery input and is content-addressed independently.
13
13
  //
14
- // "Compatible" is decided against the SAME approved runtime pin that gates promotion
15
- // (scripts/approved-runtime.mjs): a generation whose archive shipped a different brainVersion is a
16
- // different runtime, and seeding from it would drag unapproved executables forward through reuse.
14
+ // "Compatible" means THIS RUNTIME CAN CONSUME THE SEED (ADR-0091 D4). It is NOT "the generation
15
+ // shipped the same runtime version as the approved pin": that rule rejected every generation the
16
+ // moment a code release moved the approved runtime, so the seed chain reset to the weeks-stale
17
+ // bootstrap on every release (ADR-0091 section 3.4). The runtime's executables never come from a
18
+ // seed anyway -- build-bundle.mjs copies runtime modules from the checkout and only named store
19
+ // files from the seed, and verifyApprovedRuntime's backward check still refuses any unpinned
20
+ // executable in the candidate. So --pin is gone from this script entirely. What CAN make a seed
21
+ // unusable is judged here, from small files, BEFORE the ~500 MB archive is ever fetched:
22
+ // 1. the receipt (schema 3, as before) records every store's embedding model and dimensions, and
23
+ // they must equal what this runtime's forge build produces (kb/forge-corpus.mjs
24
+ // FORGE_BUILD_FINGERPRINT) -- vectors from another model are not searchable by this one;
25
+ // 2. the generation's detached .recall.json must be the one its receipt binds, must have been
26
+ // measured against THIS runtime's frozen fixture digest, and must pass THIS runtime's
27
+ // readRecallReport -- the same reader and arguments corpus-seed.yml's downstream seed
28
+ // re-check uses, so that re-check (which has no fallback) only ever sees a generation that
29
+ // already passed here.
30
+ // "This runtime" is --runtime-root: the source tree whose readers will consume the seed. On the
31
+ // nightly that is the approved runtime's own sourceSha (ADR-0091 D3), which usually trails main.
32
+ // It defaults to this checkout, which is right whenever the checkout IS the build source.
33
+ //
34
+ // A generation that fails any check is SKIPPED with its reason, never a failure: the walk moves to
35
+ // the next older one. At most SEARCH_BOUND (5) generations are judged, so one bad format change can
36
+ // never turn into an unbounded walk of release history; after that the committed bootstrap is used.
17
37
  //
18
38
  // "Verified" is decided by evidence that is checkable without downloading 500 MB here: the tag must
19
39
  // be the content-addressed corpus-sha256-<digest> form, the release must not be a draft, and it must
20
- // carry all three of ruvnet-brain.zip, its detached .sig, and corpus-receipt.json, with the receipt's
21
- // own archive digest equal to the digest in the tag. The full byte-level proof still happens
22
- // downstream where the archive is actually fetched (corpus-seed.yml re-checks sha256 and byte length
23
- // before reconciliation, and corpus-candidate.mjs re-derives the whole candidate from the bytes).
40
+ // carry exactly one of each of ruvnet-brain.zip, its detached .sig, corpus-receipt.json and both
41
+ // detached reports, with the receipt's own archive digest equal to the digest in the tag. The full
42
+ // byte-level proof still happens downstream where the archive is actually fetched (corpus-seed.yml
43
+ // re-checks sha256 and byte length before reconciliation, corpus-reconcile.mjs checks the ledger
44
+ // schema after extraction, and corpus-candidate.mjs re-derives the whole candidate from the bytes).
45
+ //
46
+ // ADR-0091 D6: --require-coverage is the CODE-RELEASE mode (ci.yml's release-qe and warm-brain jobs,
47
+ // and release.mjs's publish-time re-check). A code release assembles single-pass from the
48
+ // generation's sealed coverage, so a generation that did not publish its coverage sidecar
49
+ // (CORPUS-COVERAGE.json + coverage-receipt.json, D6.2) -- every generation published before D6 --
50
+ // is skipped as incompatible FOR THAT PURPOSE, with its reason. It remains a valid nightly seed:
51
+ // without the flag nothing about the nightly's selection changes.
24
52
  //
25
53
  // Usage:
26
- // node scripts/corpus-next-seed.mjs --repo owner/name [--pin data/approved-runtime.json]
27
- // [--bootstrap data/corpus-seed.json] [--out <file>]
54
+ // node scripts/corpus-next-seed.mjs --repo owner/name [--runtime-root <source tree>]
55
+ // [--bootstrap data/corpus-seed.json] [--require-coverage] [--out <file>]
28
56
 
57
+ import crypto from 'node:crypto';
29
58
  import fs from 'node:fs';
30
59
  import os from 'node:os';
31
60
  import path from 'node:path';
32
61
  import { spawnSync } from 'node:child_process';
33
62
  import { fileURLToPath, pathToFileURL } from 'node:url';
34
- import { readApprovedRuntime, validateApprovedRuntime } from './approved-runtime.mjs';
63
+ import { COVERAGE_ASSET, COVERAGE_RECEIPT_ASSET, bindCoverageToReceipt, verifyCoverageSidecar } from './corpus-coverage-sidecar.mjs';
35
64
 
36
65
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
37
66
  const HEX64 = /^[0-9a-f]{64}$/;
@@ -39,6 +68,13 @@ const CORPUS_TAG = /^corpus-sha256-([0-9a-f]{64})$/;
39
68
  const ARCHIVE_ASSET = 'ruvnet-brain.zip';
40
69
  const SIGNATURE_ASSET = 'ruvnet-brain.zip.sig';
41
70
  const RECEIPT_ASSET = 'corpus-receipt.json';
71
+ const RECALL_ASSET = 'ruvnet-brain.zip.recall.json';
72
+ const ACCURACY_ASSET = 'ruvnet-brain.zip.accuracy.json';
73
+ // Every asset corpus-seed.yml requires of a digest-derived seed, plus the two judged here. A release
74
+ // missing one would pass this resolver and then fail the downstream step that has no fallback.
75
+ const REQUIRED_ASSETS = [ARCHIVE_ASSET, SIGNATURE_ASSET, RECEIPT_ASSET, RECALL_ASSET, ACCURACY_ASSET];
76
+ /** ADR-0091 D4: judge at most this many of the newest generations, then use the committed bootstrap. */
77
+ export const SEARCH_BOUND = 5;
42
78
 
43
79
  const defaultRun = (command, args, options) => spawnSync(command, args, { encoding: 'utf8', ...options });
44
80
 
@@ -61,103 +97,215 @@ export function validateBootstrapSeed(seed) {
61
97
  return failures;
62
98
  }
63
99
 
100
+ /** "forge-corpus-v1|Xenova/bge-base-en-v1.5@<rev>:768:cls" -> { model, dimensions }. */
101
+ export function parseBuildFingerprint(fingerprint) {
102
+ const match = /\|([^@|]+)@[^:|]+:(\d+):[^|]*$/.exec(String(fingerprint || ''));
103
+ if (!match) throw new Error(`cannot read the embedding model from FORGE_BUILD_FINGERPRINT (${fingerprint})`);
104
+ return { model: match[1], dimensions: Number(match[2]) };
105
+ }
106
+
107
+ /**
108
+ * What the consuming runtime expects of a seed, read from THAT runtime's own source tree -- never
109
+ * restated here. A tree that cannot answer is a code defect, so this throws (a loud red night)
110
+ * rather than quietly seeding from the bootstrap every night, which would look exactly like "no new
111
+ * generation yet".
112
+ */
113
+ export async function loadCompatibilityProfile({ runtimeRoot = ROOT, fixtureFile = null } = {}) {
114
+ const root = path.resolve(runtimeRoot);
115
+ const load = (relative) => import(pathToFileURL(path.join(root, relative)).href);
116
+ const [recall, forge] = await Promise.all([load('scripts/oracle/repo-recall.mjs'), load('kb/forge-corpus.mjs')]);
117
+ if (typeof recall.readRecallReport !== 'function' || typeof recall.loadFixture !== 'function') {
118
+ throw new Error(`${root} has no repo-recall reader to judge a seed with`);
119
+ }
120
+ const { model, dimensions } = parseBuildFingerprint(forge.FORGE_BUILD_FINGERPRINT);
121
+ const fixture = recall.loadFixture(fixtureFile || path.join(root, recall.DEFAULT_FIXTURE_FILE));
122
+ return {
123
+ runtimeRoot: root,
124
+ model,
125
+ dimensions,
126
+ fixtureSha256: fixture.fixtureSha256,
127
+ fixtureStores: fixture.questions.map((question) => question.store),
128
+ floorValue: recall.ABSOLUTE_FLOOR,
129
+ readRecallReport: recall.readRecallReport,
130
+ };
131
+ }
132
+
133
+ function download(run, { repo, tag, pattern, dir }) {
134
+ const result = run('gh', ['release', 'download', tag, '--repo', repo, '--pattern', pattern, '--dir', dir],
135
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
136
+ const file = path.join(dir, pattern);
137
+ return !result.error && result.status === 0 && fs.existsSync(file) ? file : null;
138
+ }
139
+
140
+ // Small files only (a receipt, a recall report): read whole.
141
+ const sha256Of = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
142
+
64
143
  /**
65
144
  * One published release, judged. Returns null when it cannot serve as a seed, with the reason
66
- * recorded on `rejected` so a no-op night is explainable rather than silent.
145
+ * recorded on `rejected` so a no-op night is explainable rather than silent. Never downloads the
146
+ * archive: every check below reads the release's asset list, its receipt, or its recall report.
67
147
  */
68
- function judgeRelease({ run, repo, tag, digest, approved, rejected }) {
148
+ function judgeRelease({ run, repo, tag, digest, profile, rejected, requireCoverage = false }) {
149
+ // `indeterminate` marks a rejection that says nothing about the release itself -- the network or
150
+ // `gh` failed. A publish-time re-check (ADR-0091 D6.6) must not read one as "no newer generation".
151
+ const reject = (reason, { indeterminate = false } = {}) => {
152
+ rejected.push({ tag, reason, ...(indeterminate ? { indeterminate: true } : {}) });
153
+ return null;
154
+ };
69
155
  let view;
70
156
  try {
71
157
  view = ghJson(run, ['release', 'view', tag, '--repo', repo, '--json', 'tagName,isDraft,assets']);
72
158
  } catch (error) {
73
- rejected.push({ tag, reason: `release view failed (${error.message})` });
74
- return null;
75
- }
76
- if (view?.tagName !== tag || view.isDraft) {
77
- rejected.push({ tag, reason: 'release is a draft or names another tag' });
78
- return null;
159
+ return reject(`release view failed (${error.message})`, { indeterminate: true });
79
160
  }
161
+ if (view?.tagName !== tag || view.isDraft) return reject('release is a draft or names another tag');
80
162
  const assets = Array.isArray(view.assets) ? view.assets : [];
81
163
  const named = (name) => assets.filter((asset) => asset?.name === name);
82
- for (const name of [ARCHIVE_ASSET, SIGNATURE_ASSET, RECEIPT_ASSET]) {
83
- if (named(name).length !== 1) {
84
- rejected.push({ tag, reason: `unverified: expected exactly one ${name} asset` });
85
- return null;
86
- }
164
+ for (const name of REQUIRED_ASSETS) {
165
+ if (named(name).length !== 1) return reject(`unverified: expected exactly one ${name} asset`);
87
166
  }
88
- const archive = named(ARCHIVE_ASSET)[0];
89
- if (!Number.isSafeInteger(archive.size) || archive.size < 1) {
90
- rejected.push({ tag, reason: 'unverified: archive asset has no usable byte length' });
91
- return null;
167
+ if (requireCoverage && [COVERAGE_ASSET, COVERAGE_RECEIPT_ASSET].some((name) => named(name).length !== 1)) {
168
+ return reject(`incompatible for a code release: no coverage sidecar (${COVERAGE_ASSET} + ${COVERAGE_RECEIPT_ASSET}; `
169
+ + 'published before ADR-0091 D6.2)');
92
170
  }
171
+ const archive = named(ARCHIVE_ASSET)[0];
172
+ if (!Number.isSafeInteger(archive.size) || archive.size < 1) return reject('unverified: archive asset has no usable byte length');
93
173
 
94
174
  const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'corpus-next-seed-'));
95
175
  try {
96
- const download = run('gh', ['release', 'download', tag, '--repo', repo, '--pattern', RECEIPT_ASSET, '--dir', scratch],
97
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
98
- if (download.error || download.status !== 0) {
99
- rejected.push({ tag, reason: 'unverified: corpus receipt could not be downloaded' });
100
- return null;
101
- }
176
+ const receiptFile = download(run, { repo, tag, pattern: RECEIPT_ASSET, dir: scratch });
177
+ if (!receiptFile) return reject('unverified: corpus receipt could not be downloaded', { indeterminate: true });
102
178
  let receipt;
103
- try { receipt = JSON.parse(fs.readFileSync(path.join(scratch, RECEIPT_ASSET), 'utf8')); }
104
- catch (error) { rejected.push({ tag, reason: `unverified: corpus receipt unreadable (${error.message})` }); return null; }
179
+ try { receipt = JSON.parse(fs.readFileSync(receiptFile, 'utf8')); }
180
+ catch (error) { return reject(`unverified: corpus receipt unreadable (${error.message})`); }
105
181
 
106
182
  // ADR-086 Step 15 / A6 moved the corpus receipt to schemaVersion 3 (it now binds the detached
107
183
  // retrieval-accuracy report). This reader has to move with it: left at 2 it would reject every
108
184
  // schema-3 generation as unverified and silently fall back to the committed bootstrap seed every
109
185
  // night — a degradation that looks exactly like "no new generation yet".
110
186
  if (receipt.schemaVersion !== 3 || receipt.kind !== 'ruvnet-brain-corpus-candidate') {
111
- rejected.push({ tag, reason: 'unverified: receipt schema or kind is not a schema-3 corpus candidate' });
112
- return null;
187
+ return reject('unverified: receipt schema or kind is not a schema-3 corpus candidate');
113
188
  }
114
- if (!receipt.accuracyReport?.file || !/^[a-f0-9]{64}$/.test(String(receipt.accuracyReport.sha256 || ''))
189
+ if (!receipt.accuracyReport?.file || !HEX64.test(String(receipt.accuracyReport.sha256 || ''))
115
190
  || !Number.isSafeInteger(receipt.accuracyReport.bytes)) {
116
- rejected.push({ tag, reason: 'unverified: receipt carries no retrieval-accuracy binding' });
117
- return null;
191
+ return reject('unverified: receipt carries no retrieval-accuracy binding');
118
192
  }
119
193
  if (receipt.archive?.sha256 !== digest || receipt.archive?.bytes !== archive.size) {
120
- rejected.push({ tag, reason: 'unverified: receipt archive identity disagrees with the content-addressed tag' });
121
- return null;
194
+ return reject('unverified: receipt archive identity disagrees with the content-addressed tag');
195
+ }
196
+ if (receipt.recallReport?.file !== RECALL_ASSET || !HEX64.test(String(receipt.recallReport?.sha256 || ''))
197
+ || !Number.isSafeInteger(receipt.recallReport?.bytes)) {
198
+ return reject('unverified: receipt carries no repo-recall binding');
122
199
  }
123
- if (receipt.archiveManifestVersion !== approved.brainVersion || receipt.archiveManifestReleaseTag !== approved.releaseTag) {
124
- rejected.push({ tag, reason: `incompatible: generation shipped runtime ${receipt.archiveManifestReleaseTag}, approved runtime is ${approved.releaseTag}` });
125
- return null;
200
+
201
+ // D4 check 1 -- embedding model and dimensions, per store, from the receipt alone.
202
+ const stores = Array.isArray(receipt.stores) ? receipt.stores : [];
203
+ if (!stores.length) return reject('unverified: receipt lists no stores');
204
+ const foreign = stores.filter((row) => row?.model !== profile.model || row?.dimensions !== profile.dimensions);
205
+ if (foreign.length) {
206
+ const first = foreign[0];
207
+ return reject(`incompatible: ${foreign.length} store(s) embedded with a different model/dimensions `
208
+ + `(e.g. ${first?.name}: ${first?.model}/${first?.dimensions}); this runtime builds ${profile.model}/${profile.dimensions}`);
209
+ }
210
+
211
+ // D4 check 2 -- the recall report, the one small file that decides whether the downstream seed
212
+ // re-check can pass. Downloaded only now, after the receipt already qualified.
213
+ const recallFile = download(run, { repo, tag, pattern: RECALL_ASSET, dir: scratch });
214
+ if (!recallFile) return reject('unverified: repo-recall report could not be downloaded', { indeterminate: true });
215
+ if (sha256Of(recallFile) !== receipt.recallReport.sha256 || fs.statSync(recallFile).size !== receipt.recallReport.bytes) {
216
+ return reject('unverified: repo-recall report is not the one the receipt binds');
126
217
  }
218
+ let claimed = null;
219
+ try { claimed = JSON.parse(fs.readFileSync(recallFile, 'utf8')); } catch { /* the reader names it below */ }
220
+ const claimedFixture = claimed?.fixture?.sha256 ?? null;
221
+ if (claimedFixture !== profile.fixtureSha256) {
222
+ return reject(`incompatible: recall report was measured against fixture ${String(claimedFixture).slice(0, 12)}, `
223
+ + `this runtime's frozen fixture is ${profile.fixtureSha256.slice(0, 12)}`);
224
+ }
225
+ // ADR-0091 D7.3: a report that claims retired questions is verified against the generation's own
226
+ // sealed coverage (its D6.2 sidecar), recomputed by the reader -- in the nightly mode too, or one
227
+ // retirement would push every night onto the bootstrap. No sidecar means the claim is unverifiable,
228
+ // and the generation is skipped.
229
+ const claimsRetirement = claimed?.retirement !== undefined;
230
+ if (claimsRetirement && !requireCoverage && [COVERAGE_ASSET, COVERAGE_RECEIPT_ASSET].some((name) => named(name).length !== 1)) {
231
+ return reject('incompatible: recall report claims retired question(s) but the generation published no coverage sidecar '
232
+ + `(${COVERAGE_ASSET} + ${COVERAGE_RECEIPT_ASSET}) to verify them against`);
233
+ }
234
+
235
+ // D6.2 -- the code-release mode's extra check, from two more small files: the sidecar must name
236
+ // THIS generation, bind THESE coverage bytes, and the coverage must describe this receipt's stores.
237
+ let coverage = null;
238
+ let coverageBytes = null;
239
+ if (requireCoverage || claimsRetirement) {
240
+ const sidecarFile = download(run, { repo, tag, pattern: COVERAGE_RECEIPT_ASSET, dir: scratch });
241
+ const coverageFile = sidecarFile && download(run, { repo, tag, pattern: COVERAGE_ASSET, dir: scratch });
242
+ if (!sidecarFile || !coverageFile) return reject('unverified: coverage sidecar could not be downloaded', { indeterminate: true });
243
+ try {
244
+ coverageBytes = fs.readFileSync(coverageFile);
245
+ const verified = verifyCoverageSidecar({ sidecar: JSON.parse(fs.readFileSync(sidecarFile, 'utf8')), coverageBytes,
246
+ generationTag: tag, archiveSha256: digest, archiveBytes: archive.size });
247
+ bindCoverageToReceipt({ coverage: verified.coverage, receipt });
248
+ coverage = { asset: COVERAGE_ASSET, sha256: sha256Of(coverageFile), bytes: coverageBytes.length,
249
+ receiptAsset: COVERAGE_RECEIPT_ASSET, receiptSha256: sha256Of(sidecarFile), degraded: verified.degraded };
250
+ } catch (error) {
251
+ return reject(`unverified: coverage sidecar does not bind this generation (${error.message})`);
252
+ }
253
+ }
254
+
255
+ try {
256
+ profile.readRecallReport({
257
+ reportFile: recallFile,
258
+ archive: { file: ARCHIVE_ASSET, sha256: digest, bytes: archive.size },
259
+ expectedFixtureSha256: profile.fixtureSha256,
260
+ // The same bar corpus-seed.yml's seed re-check uses: a published seed is graded against the
261
+ // fixed ABSOLUTE_FLOOR, never re-judged by a later, higher committed floor.
262
+ floorValue: profile.floorValue,
263
+ // Ignored by a pre-D7 reader, which then fails closed on a retirement-bearing report's totals.
264
+ coverageBytes,
265
+ ...(profile.fixtureStores ? { fixtureStores: profile.fixtureStores } : {}),
266
+ });
267
+ } catch (error) {
268
+ return reject(`incompatible: recall report fails this runtime's reader (${error.message})`);
269
+ }
270
+
127
271
  return {
128
272
  origin: 'published-generation',
129
273
  tag,
130
274
  asset: ARCHIVE_ASSET,
131
275
  sha256: digest,
132
276
  bytes: archive.size,
277
+ // The nightly descriptor keeps its pre-D7 shape even when a retirement claim fetched the coverage.
278
+ ...(coverage && requireCoverage ? { coverage } : {}),
133
279
  sourceCommit: typeof receipt.builderSourceSha === 'string' ? receipt.builderSourceSha : null,
134
- brainVersion: receipt.archiveManifestVersion,
280
+ // Informational only since D4: the runtime that BUILT the seed, which may be older than the
281
+ // runtime about to consume it. Nothing gates on it.
282
+ brainVersion: receipt.archiveManifestVersion ?? null,
135
283
  };
136
284
  } finally {
137
285
  fs.rmSync(scratch, { recursive: true, force: true });
138
286
  }
139
287
  }
140
288
 
141
- export function resolveNextCorpusSeed({
142
- repo, run = defaultRun, root = ROOT, pinFile, bootstrapFile, limit = 100,
289
+ export async function resolveNextCorpusSeed({
290
+ repo, run = defaultRun, root = ROOT, runtimeRoot = null, fixtureFile = null, bootstrapFile,
291
+ limit = 100, searchBound = SEARCH_BOUND, profile = null, requireCoverage = false,
143
292
  } = {}) {
144
293
  if (!/^[^/\s]+\/[^/\s]+$/.test(String(repo || ''))) throw new Error('--repo must be owner/name');
145
-
146
- const approved = readApprovedRuntime(pinFile || path.join(root, 'data/approved-runtime.json'));
147
- const pinFailures = validateApprovedRuntime(approved);
148
- if (pinFailures.length) throw new Error(`approved runtime pin is invalid: ${pinFailures.join('; ')}`);
294
+ if (!Number.isSafeInteger(searchBound) || searchBound < 1) throw new Error('search bound must be a positive integer');
149
295
 
150
296
  const bootstrapPath = path.resolve(bootstrapFile || path.join(root, 'data/corpus-seed.json'));
151
297
  const bootstrap = JSON.parse(fs.readFileSync(bootstrapPath, 'utf8'));
152
298
  const bootstrapFailures = validateBootstrapSeed(bootstrap);
153
299
  if (bootstrapFailures.length) throw new Error(`committed bootstrap seed is invalid: ${bootstrapFailures.join('; ')}`);
154
300
 
301
+ const compatibility = profile || await loadCompatibilityProfile({ runtimeRoot: runtimeRoot || root, fixtureFile });
302
+
155
303
  const rejected = [];
156
304
  let listed = [];
157
305
  try {
158
306
  listed = ghJson(run, ['release', 'list', '--repo', repo, '--limit', String(limit), '--json', 'tagName,isDraft,createdAt']) || [];
159
307
  } catch (error) {
160
- rejected.push({ tag: null, reason: `release list failed (${error.message})` });
308
+ rejected.push({ tag: null, reason: `release list failed (${error.message})`, indeterminate: true });
161
309
  }
162
310
 
163
311
  // Every corpus-shaped tag that is NOT usable gets an explicit reason. A silently skipped row is
@@ -174,9 +322,15 @@ export function resolveNextCorpusSeed({
174
322
  }
175
323
  candidates.sort((left, right) => right.createdAt - left.createdAt);
176
324
 
177
- for (const candidate of candidates) {
178
- const resolved = judgeRelease({ run, repo, tag: candidate.tag, digest: candidate.digest, approved, rejected });
179
- if (resolved) return { seed: resolved, rejected, approvedRuntime: approved.releaseTag };
325
+ const judged = candidates.slice(0, searchBound);
326
+ for (const skipped of candidates.slice(searchBound)) {
327
+ rejected.push({ tag: skipped.tag, reason: `not judged: older than the ${searchBound} newest generations (ADR-0091 D4 search bound)` });
328
+ }
329
+ const expects = { model: compatibility.model, dimensions: compatibility.dimensions, fixtureSha256: compatibility.fixtureSha256 };
330
+ for (const candidate of judged) {
331
+ const resolved = judgeRelease({ run, repo, tag: candidate.tag, digest: candidate.digest, profile: compatibility, rejected,
332
+ requireCoverage });
333
+ if (resolved) return { seed: resolved, rejected, judged: judged.length, expects };
180
334
  }
181
335
 
182
336
  return {
@@ -190,36 +344,60 @@ export function resolveNextCorpusSeed({
190
344
  brainVersion: null,
191
345
  },
192
346
  rejected,
193
- approvedRuntime: approved.releaseTag,
347
+ judged: judged.length,
348
+ expects,
194
349
  };
195
350
  }
196
351
 
197
- const arg = (name, fallback) => {
198
- const index = process.argv.indexOf(name);
199
- return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
352
+ const arg = (argv, name, fallback) => {
353
+ const index = argv.indexOf(name);
354
+ return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
200
355
  };
201
356
 
202
- function main() {
357
+ export async function main(argv = process.argv.slice(2), { run = defaultRun, stdout = process.stdout, stderr = process.stderr } = {}) {
358
+ // Loud, not ignored: a caller still passing --pin is on the pre-D4 contract and should be told.
359
+ if (argv.includes('--pin')) {
360
+ stderr.write('[corpus-next-seed] --pin was removed by ADR-0091 D4: seed selection no longer depends on the approved runtime pin\n');
361
+ return 2;
362
+ }
203
363
  let result;
204
364
  try {
205
- result = resolveNextCorpusSeed({
206
- repo: arg('--repo', process.env.GITHUB_REPOSITORY),
207
- pinFile: arg('--pin'),
208
- bootstrapFile: arg('--bootstrap'),
365
+ result = await resolveNextCorpusSeed({
366
+ repo: arg(argv, '--repo', process.env.GITHUB_REPOSITORY),
367
+ runtimeRoot: arg(argv, '--runtime-root', null),
368
+ fixtureFile: arg(argv, '--fixture', null),
369
+ bootstrapFile: arg(argv, '--bootstrap', null),
370
+ requireCoverage: argv.includes('--require-coverage'),
371
+ run,
209
372
  });
210
373
  } catch (error) {
211
- console.error(`[corpus-next-seed] ${error.message}`);
374
+ stderr.write(`[corpus-next-seed] ${error.message}\n`);
212
375
  return 1;
213
376
  }
214
- for (const row of result.rejected) console.error(`[corpus-next-seed] skipped ${row.tag || '(list)'}: ${row.reason}`);
215
- const out = arg('--out');
377
+ for (const row of result.rejected) stderr.write(`[corpus-next-seed] skipped ${row.tag || '(list)'}: ${row.reason}\n`);
378
+ const out = arg(argv, '--out', null);
216
379
  const serialized = `${JSON.stringify(result.seed, null, 2)}\n`;
217
380
  if (out) fs.writeFileSync(path.resolve(out), serialized);
218
- process.stdout.write(serialized);
219
- console.error(`[corpus-next-seed] ${result.seed.origin} ${result.seed.tag} (approved runtime ${result.approvedRuntime})`);
381
+ stdout.write(serialized);
382
+ stderr.write(`[corpus-next-seed] ${result.seed.origin} ${result.seed.tag} (judged ${result.judged} generation(s) against `
383
+ + `${result.expects.model}/${result.expects.dimensions}, fixture ${result.expects.fixtureSha256.slice(0, 12)})\n`);
220
384
  return 0;
221
385
  }
222
386
 
223
- if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
224
- process.exitCode = main();
387
+ // REALPATH BOTH SIDES (ADR-0091 D12.2, applied here because this file is rewritten anyway): a plain
388
+ // argv[1]-vs-import.meta.url comparison is false under a symlinked path such as macOS's /var/folders
389
+ // temp directories, and the CLI then exits 0 having done nothing.
390
+ function isMain() {
391
+ try {
392
+ return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
393
+ } catch {
394
+ return false;
395
+ }
396
+ }
397
+
398
+ if (isMain()) {
399
+ main().then((code) => { process.exitCode = code; }).catch((error) => {
400
+ console.error(`[corpus-next-seed] ${error?.message || error}`);
401
+ process.exitCode = 1;
402
+ });
225
403
  }