champollion 0.3.3
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.
- package/LICENSE +133 -0
- package/README.md +387 -0
- package/bin/cli.js +278 -0
- package/index.js +135 -0
- package/lib/api-key.js +127 -0
- package/lib/autofix.js +432 -0
- package/lib/bridge/method_bridge.py +430 -0
- package/lib/card-source-resolution.mjs +284 -0
- package/lib/cards/cache.js +169 -0
- package/lib/cards/env.js +82 -0
- package/lib/cards/fetch-card-child.js +38 -0
- package/lib/cards/reader.js +435 -0
- package/lib/cards/refresh.js +111 -0
- package/lib/cards/remote.js +387 -0
- package/lib/cldf-export.mjs +540 -0
- package/lib/cldf-terms.mjs +62 -0
- package/lib/command-help.js +790 -0
- package/lib/commands/audit.js +49 -0
- package/lib/commands/card.js +454 -0
- package/lib/commands/doctor.js +559 -0
- package/lib/commands/fonts.js +489 -0
- package/lib/commands/help.js +91 -0
- package/lib/commands/init.js +1259 -0
- package/lib/commands/integrity.js +148 -0
- package/lib/commands/leaderboard.js +478 -0
- package/lib/commands/lint.js +30 -0
- package/lib/commands/models.js +177 -0
- package/lib/commands/plugin.js +103 -0
- package/lib/commands/provenance.js +45 -0
- package/lib/commands/recommend.js +75 -0
- package/lib/commands/register-corpus.js +678 -0
- package/lib/commands/repair-script.js +42 -0
- package/lib/commands/seal-corpus.js +355 -0
- package/lib/commands/seo.js +72 -0
- package/lib/commands/serve.js +147 -0
- package/lib/commands/status.js +265 -0
- package/lib/commands/submit.js +332 -0
- package/lib/commands/sync.js +89 -0
- package/lib/commands/tm.js +573 -0
- package/lib/commands/verify.js +39 -0
- package/lib/commands/watch.js +20 -0
- package/lib/commands/wrap.js +138 -0
- package/lib/commands/xliff.js +327 -0
- package/lib/commercial-eligibility.js +235 -0
- package/lib/concurrent.js +87 -0
- package/lib/config.js +523 -0
- package/lib/contamination-lane.js +76 -0
- package/lib/content-sync.js +731 -0
- package/lib/content.js +733 -0
- package/lib/corpus-registration.mjs +608 -0
- package/lib/cost-report.js +346 -0
- package/lib/diff.js +155 -0
- package/lib/docusaurus-sync.js +1256 -0
- package/lib/flatten.js +55 -0
- package/lib/format.js +954 -0
- package/lib/hash.js +159 -0
- package/lib/icu.js +473 -0
- package/lib/integrity.js +689 -0
- package/lib/license-gate.mjs +478 -0
- package/lib/license-identify.mjs +229 -0
- package/lib/lint.js +629 -0
- package/lib/method-manifest.js +60 -0
- package/lib/methods/anthropic.js +140 -0
- package/lib/methods/apertium.js +163 -0
- package/lib/methods/api.js +316 -0
- package/lib/methods/base.js +184 -0
- package/lib/methods/content-separator.js +45 -0
- package/lib/methods/deepl.js +426 -0
- package/lib/methods/direct-llm.js +586 -0
- package/lib/methods/external.js +332 -0
- package/lib/methods/fetch-with-retry.js +124 -0
- package/lib/methods/gemini.js +147 -0
- package/lib/methods/google-translate.js +402 -0
- package/lib/methods/http-utils.js +122 -0
- package/lib/methods/libretranslate.js +314 -0
- package/lib/methods/llm-coached.js +670 -0
- package/lib/methods/llm.js +592 -0
- package/lib/methods/local.js +76 -0
- package/lib/methods/microsoft-translator.js +331 -0
- package/lib/methods/openai.js +131 -0
- package/lib/methods/openrouter-client.js +327 -0
- package/lib/methods/openrouter-pricing.js +156 -0
- package/lib/methods/provider-env.js +115 -0
- package/lib/methods/provider-pricing.js +310 -0
- package/lib/methods/tilde.js +150 -0
- package/lib/methods/translated.js +229 -0
- package/lib/methods/translation-error.js +80 -0
- package/lib/models.js +258 -0
- package/lib/no-translate.js +233 -0
- package/lib/output.js +238 -0
- package/lib/pairs.js +547 -0
- package/lib/plugins.js +447 -0
- package/lib/provenance.js +323 -0
- package/lib/recommend.js +648 -0
- package/lib/registers.js +1185 -0
- package/lib/repair-script.js +266 -0
- package/lib/scripts.js +994 -0
- package/lib/seal.mjs +464 -0
- package/lib/sealed-qualifier.mjs +211 -0
- package/lib/security.js +59 -0
- package/lib/segment.js +369 -0
- package/lib/seo.js +275 -0
- package/lib/serve.js +854 -0
- package/lib/string-classify.js +85 -0
- package/lib/submit.mjs +344 -0
- package/lib/sync.js +969 -0
- package/lib/tags/bcp47.js +202 -0
- package/lib/tags/resolve.js +314 -0
- package/lib/terminology.js +111 -0
- package/lib/tm-seed.js +294 -0
- package/lib/tm.js +515 -0
- package/lib/translate-pair.js +197 -0
- package/lib/translate.js +203 -0
- package/lib/types.js +230 -0
- package/lib/validate.js +510 -0
- package/lib/verify.js +451 -0
- package/lib/watch.js +145 -0
- package/lib/xliff.js +184 -0
- package/package.json +93 -0
- package/shared/ATTRIBUTION.md +145 -0
- package/shared/CORPORA-CARDS.md +288 -0
- package/shared/DATA-SOVEREIGNTY.md +500 -0
- package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
- package/shared/card-lint-baseline.json +3189 -0
- package/shared/cards-fallback.json +1 -0
- package/shared/catalogue/card-config.json +6091 -0
- package/shared/catalogue/external-results.json +3888 -0
- package/shared/catalogue/gender-guidance.json +1038 -0
- package/shared/catalogue/method-coverage.json +1751 -0
- package/shared/catalogue/metric-coverage.json +170 -0
- package/shared/catalogue/metric-reliability.json +1 -0
- package/shared/catalogue/register-presets.json +3180 -0
- package/shared/catalogue/vitality-scales.json +55 -0
- package/shared/cldr-index.json +1115 -0
- package/shared/code-bridge.json +253 -0
- package/shared/corpora-cards-v1-reference.md +281 -0
- package/shared/curated-dictionary-flags.json +35 -0
- package/shared/curated-endonyms.json +35 -0
- package/shared/curated-fsts.json +51 -0
- package/shared/curated-orthography-conventions.json +26 -0
- package/shared/curated-sil-resources.json +374 -0
- package/shared/curated-tools.json +41 -0
- package/shared/docent/corpus.json +11333 -0
- package/shared/docent/faq.en.json +564 -0
- package/shared/docent/register-blocks.json +60 -0
- package/shared/docent/system-prompt.md +144 -0
- package/shared/domain-taxonomy.json +35 -0
- package/shared/explainers/glossary.json +2975 -0
- package/shared/explainers/tc-features.json +20112 -0
- package/shared/explainers/term-watchlist.json +147 -0
- package/shared/human-services.json +59 -0
- package/shared/license-corrections.json +261 -0
- package/shared/license-evidence.json +13452 -0
- package/shared/licenses.json +6781 -0
- package/shared/method-registry.json +236 -0
- package/shared/metric-registry.json +620 -0
- package/shared/model-aliases.json +7 -0
- package/shared/schemas/champollion-plugin.schema.json +206 -0
- package/shared/schemas/corpora-card.schema.json +957 -0
- package/shared/schemas/domain-taxonomy.schema.json +64 -0
- package/shared/schemas/external-results.schema.json +314 -0
- package/shared/schemas/human-services.schema.json +90 -0
- package/shared/schemas/language-card.schema.json +1308 -0
- package/shared/schemas/licenses.schema.json +155 -0
- package/shared/schemas/method-card.schema.json +412 -0
- package/shared/schemas/method-registry.schema.json +85 -0
- package/shared/schemas/metric-registry.schema.json +96 -0
- package/shared/schemas/metric-reliability.schema.json +178 -0
- package/shared/schemas/model-aliases.schema.json +27 -0
- package/shared/schemas/source-snapshot.schema.json +96 -0
package/lib/seal.mjs
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* seal.mjs — CLIENT-SIDE encryption for the SEALED exposure tier.
|
|
3
|
+
*
|
|
4
|
+
* This is the cryptographic front door for the zero-knowledge sovereign-eval
|
|
5
|
+
* PoC (docs/governance/OCAP_MULTISIG_PLAN.md, mechanism M1). A community that
|
|
6
|
+
* chooses to make a held-out set centrally evaluable-on-demand encrypts it
|
|
7
|
+
* **on their own machine, before a single byte leaves**, under a *threshold
|
|
8
|
+
* public key* the platform cannot decrypt alone. Champollion ever only receives
|
|
9
|
+
*
|
|
10
|
+
* • ciphertext (an off-git, off-allowlist artifact), and
|
|
11
|
+
* • a CONTENT-FREE metadata card (cipher id + custodian group + digest + AAD).
|
|
12
|
+
*
|
|
13
|
+
* The plaintext sentences are never read, uploaded, or hosted — exactly the L1
|
|
14
|
+
* never-host-content doctrine (the data-boundaries doctrine), strengthened: not just
|
|
15
|
+
* "no plaintext in the repo" but "no plaintext anywhere we hold, ever."
|
|
16
|
+
*
|
|
17
|
+
* ── SCHEME ────────────────────────────────────────────────────────────────
|
|
18
|
+
* A standard hybrid "sealed box" (libsodium crypto_box_seal shape), built only
|
|
19
|
+
* on Node's `node:crypto` (no third-party dep):
|
|
20
|
+
*
|
|
21
|
+
* 1. The recipient is the custodian group's THRESHOLD public key (X25519).
|
|
22
|
+
* 2. The sender (this CLI, on the author's device) generates an EPHEMERAL
|
|
23
|
+
* X25519 keypair, does ECDH against the threshold public key, and derives
|
|
24
|
+
* a one-time AES-256 key with HKDF-SHA256 (salt + an info string that
|
|
25
|
+
* binds the ephemeral key, the threshold key, and the card AAD).
|
|
26
|
+
* 3. The corpus bytes are encrypted with AES-256-GCM (the AAD authenticates
|
|
27
|
+
* the card binding). The ephemeral private key is discarded.
|
|
28
|
+
*
|
|
29
|
+
* Decryption requires the threshold PRIVATE key — which only exists as 3-of-5
|
|
30
|
+
* custodian shares. Champollion holds zero shares (M4), so it can never
|
|
31
|
+
* assemble the key and never decrypt. See the WAVE-2 SEAM notes below.
|
|
32
|
+
*
|
|
33
|
+
* ── WAVE-1 vs the shipped threshold layer (the integration seam) ──────────
|
|
34
|
+
* WAVE 1 (this module): accept/derive a PROVIDED threshold public key (a raw
|
|
35
|
+
* X25519 recipient) and encrypt to it. The matching private key is a single
|
|
36
|
+
* X25519 scalar (used by tests and the controlled-eval context to PROVE the
|
|
37
|
+
* ciphertext is real).
|
|
38
|
+
*
|
|
39
|
+
* SHIPPED (Python, arena/mt_eval_harness/sovereign/): the "threshold" is
|
|
40
|
+
* SHAMIR M-of-N secret sharing of that same X25519 scalar (shamir_gf256.py),
|
|
41
|
+
* reconstructed in the offline node's executor memory during a quorum-
|
|
42
|
+
* authorized run — NOT an aggregated FROST group key and NOT MPC/TSS (see
|
|
43
|
+
* sovereign/__init__.py HONESTY CONTRACT). This module is unchanged by that:
|
|
44
|
+
* every `WAVE-2 SEAM` marker below is a point that WOULD change only if we
|
|
45
|
+
* moved to real threshold *decryption* (partial ECDH per custodian, key never
|
|
46
|
+
* assembled) — a networked-platform upgrade that is not built. The ciphertext
|
|
47
|
+
* format and the card block do NOT change under either model; that is the
|
|
48
|
+
* whole point of the seam. Historical note: earlier docs named FROST here —
|
|
49
|
+
* FROST is threshold *signing*, the wrong primitive for this decryption path;
|
|
50
|
+
* see docs/governance/OCAP_MULTISIG_PLAN.md for the corrected framing.
|
|
51
|
+
*
|
|
52
|
+
* @module seal
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import crypto from 'node:crypto';
|
|
56
|
+
|
|
57
|
+
/** Cipher suite id stamped into every artifact + card. */
|
|
58
|
+
export const SEAL_CIPHER = 'x25519-hkdf-sha256+aes-256-gcm';
|
|
59
|
+
|
|
60
|
+
/** Sealed-artifact format version (bumped only on a breaking envelope change). */
|
|
61
|
+
export const SEAL_VERSION = '1';
|
|
62
|
+
|
|
63
|
+
/** HKDF info-string prefix — domain-separates this use of the shared secret. */
|
|
64
|
+
const HKDF_INFO_PREFIX = 'champollion-sealed-corpus';
|
|
65
|
+
|
|
66
|
+
/** AES-256-GCM parameters. */
|
|
67
|
+
const AES_KEY_BYTES = 32;
|
|
68
|
+
const GCM_IV_BYTES = 12;
|
|
69
|
+
const HKDF_SALT_BYTES = 16;
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// AAD — the card binding. Additional Authenticated Data is NOT encrypted, but
|
|
73
|
+
// it IS authenticated: a ciphertext sealed for card A / group A cannot be
|
|
74
|
+
// silently re-presented as card B / group B without breaking the GCM tag. It
|
|
75
|
+
// is metadata only (ids), never corpus content — safe to store on the card.
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Deterministic AAD string binding a ciphertext to its card + custodian group.
|
|
80
|
+
* @param {{cardId:string, custodianGroupId:string}} o
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
export function buildAad({ cardId, custodianGroupId }) {
|
|
84
|
+
const id = String(cardId || '').trim();
|
|
85
|
+
const group = String(custodianGroupId || '').trim();
|
|
86
|
+
return `champollion-sealed:v${SEAL_VERSION}|card=${id}|group=${group}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Threshold key handling. WAVE-2 SEAM: in production the public key is the
|
|
91
|
+
// aggregated FROST group key; here we accept a provided X25519 public key in
|
|
92
|
+
// the common encodings, or generate a stand-in keypair for tests/demo.
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Generate a stand-in threshold keypair (X25519).
|
|
97
|
+
*
|
|
98
|
+
* WAVE-2 SEAM: replaced by the custodian key ceremony, which produces the
|
|
99
|
+
* aggregated group PUBLIC key (the only part the author needs) while the
|
|
100
|
+
* matching secret exists solely as 3-of-5 shares. This helper exposes the full
|
|
101
|
+
* private key ONLY so tests and a future controlled-eval context can prove the
|
|
102
|
+
* ciphertext decrypts to the original — it must never run on Champollion infra
|
|
103
|
+
* in production.
|
|
104
|
+
*
|
|
105
|
+
* @returns {{publicKey:crypto.KeyObject, privateKey:crypto.KeyObject,
|
|
106
|
+
* publicKeyDerB64:string, privateKeyDerB64:string, keyId:string}}
|
|
107
|
+
*/
|
|
108
|
+
export function generateThresholdKeypair() {
|
|
109
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519');
|
|
110
|
+
return {
|
|
111
|
+
publicKey,
|
|
112
|
+
privateKey,
|
|
113
|
+
publicKeyDerB64: publicKey.export({ type: 'spki', format: 'der' }).toString('base64'),
|
|
114
|
+
privateKeyDerB64: privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64'),
|
|
115
|
+
keyId: thresholdKeyId(publicKey),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolve a provided threshold public key into a node KeyObject.
|
|
121
|
+
*
|
|
122
|
+
* Accepts: a KeyObject; an object with `{publicKeyDerB64}`; a PEM string
|
|
123
|
+
* (-----BEGIN PUBLIC KEY-----); or a base64-encoded DER SPKI string. Throws a
|
|
124
|
+
* plain-language error on anything that is not a usable X25519 public key.
|
|
125
|
+
*
|
|
126
|
+
* The command wrapper (lib/commands/seal-corpus.js readKeyMaterial) also
|
|
127
|
+
* accepts keygen JSON FILES directly — both the .pub.json and the .key.json
|
|
128
|
+
* (which embeds publicKeyDerB64 alongside the private half) work as a
|
|
129
|
+
* --threshold-pubkey source; the wrapper extracts the right field and hands
|
|
130
|
+
* the base64 DER string down to this function.
|
|
131
|
+
*
|
|
132
|
+
* @param {crypto.KeyObject|string|{publicKeyDerB64:string}} input
|
|
133
|
+
* @returns {crypto.KeyObject}
|
|
134
|
+
*/
|
|
135
|
+
export function resolveThresholdPublicKey(input) {
|
|
136
|
+
if (!input) throw new Error('A threshold public key is required to seal a corpus.');
|
|
137
|
+
if (typeof input === 'object' && input.asymmetricKeyType) return assertX25519Public(input);
|
|
138
|
+
if (typeof input === 'object' && typeof input.publicKeyDerB64 === 'string') {
|
|
139
|
+
return assertX25519Public(crypto.createPublicKey({
|
|
140
|
+
key: Buffer.from(input.publicKeyDerB64, 'base64'), format: 'der', type: 'spki',
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
const raw = String(input).trim();
|
|
144
|
+
if (!raw) throw new Error('A threshold public key is required to seal a corpus.');
|
|
145
|
+
|
|
146
|
+
// PEM (most explicit) first.
|
|
147
|
+
if (raw.includes('-----BEGIN')) {
|
|
148
|
+
try { return assertX25519Public(crypto.createPublicKey(raw)); }
|
|
149
|
+
catch (e) { throw new Error(`Threshold public key is not a valid PEM key: ${e.message}`); }
|
|
150
|
+
}
|
|
151
|
+
// Otherwise treat as base64-encoded DER SPKI.
|
|
152
|
+
let der;
|
|
153
|
+
try { der = Buffer.from(raw, 'base64'); }
|
|
154
|
+
catch { throw new Error('Threshold public key must be a PEM string or base64 DER (SPKI).'); }
|
|
155
|
+
if (!der.length) throw new Error('Threshold public key must be a PEM string or base64 DER (SPKI).');
|
|
156
|
+
try {
|
|
157
|
+
return assertX25519Public(crypto.createPublicKey({ key: der, format: 'der', type: 'spki' }));
|
|
158
|
+
} catch (e) {
|
|
159
|
+
throw new Error(`Threshold public key could not be parsed as DER SPKI: ${e.message}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Guard: only X25519 public keys may receive a sealed corpus. */
|
|
164
|
+
function assertX25519Public(keyObject) {
|
|
165
|
+
if (keyObject.type !== 'public') throw new Error('Expected a PUBLIC threshold key, got a private/secret key.');
|
|
166
|
+
if (keyObject.asymmetricKeyType !== 'x25519') {
|
|
167
|
+
throw new Error(`Threshold public key must be X25519 (got ${keyObject.asymmetricKeyType || 'unknown'}).`);
|
|
168
|
+
}
|
|
169
|
+
return keyObject;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Stable short id for a threshold public key — sha256 of its DER SPKI bytes,
|
|
174
|
+
* first 16 hex chars. Lets the card/artifact name *which* group key was used
|
|
175
|
+
* without embedding the key itself.
|
|
176
|
+
* @param {crypto.KeyObject} publicKeyObject
|
|
177
|
+
* @returns {string}
|
|
178
|
+
*/
|
|
179
|
+
export function thresholdKeyId(publicKeyObject) {
|
|
180
|
+
const der = publicKeyObject.export({ type: 'spki', format: 'der' });
|
|
181
|
+
return crypto.createHash('sha256').update(der).digest('hex').slice(0, 16);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// The encrypt path — runs on the AUTHOR'S device.
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Encrypt plaintext corpus bytes to a threshold public key (client-side).
|
|
190
|
+
*
|
|
191
|
+
* Returns a content-FREE envelope: ciphertext + the public crypto parameters
|
|
192
|
+
* needed to decrypt later (ephemeral public key, salt, IV, GCM tag) + a digest.
|
|
193
|
+
* NONE of the return value contains plaintext.
|
|
194
|
+
*
|
|
195
|
+
* @param {object} o
|
|
196
|
+
* @param {string|Buffer} o.plaintext corpus content to seal
|
|
197
|
+
* @param {crypto.KeyObject|string|object} o.thresholdPublicKey recipient key
|
|
198
|
+
* @param {string} o.aad card-binding AAD (see buildAad)
|
|
199
|
+
* @returns {{cipher:string, version:string, aad:string, thresholdKeyId:string,
|
|
200
|
+
* envelope:{ephemeralPublicKey:string, salt:string, iv:string, authTag:string},
|
|
201
|
+
* ciphertext:string, ciphertextDigest:string}}
|
|
202
|
+
*/
|
|
203
|
+
export function sealPlaintext({ plaintext, thresholdPublicKey, aad }) {
|
|
204
|
+
if (plaintext === undefined || plaintext === null) throw new Error('Nothing to seal — plaintext is empty.');
|
|
205
|
+
const pt = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(String(plaintext), 'utf-8');
|
|
206
|
+
if (!pt.length) throw new Error('Nothing to seal — plaintext is empty.');
|
|
207
|
+
if (typeof aad !== 'string' || !aad) throw new Error('A binding AAD string is required (see buildAad).');
|
|
208
|
+
|
|
209
|
+
const recipient = resolveThresholdPublicKey(thresholdPublicKey);
|
|
210
|
+
|
|
211
|
+
// Ephemeral keypair → ECDH against the threshold key → one-time AES key.
|
|
212
|
+
const ephemeral = crypto.generateKeyPairSync('x25519');
|
|
213
|
+
const sharedSecret = crypto.diffieHellman({ privateKey: ephemeral.privateKey, publicKey: recipient });
|
|
214
|
+
const ephemeralPubDer = ephemeral.publicKey.export({ type: 'spki', format: 'der' });
|
|
215
|
+
const recipientDer = recipient.export({ type: 'spki', format: 'der' });
|
|
216
|
+
const salt = crypto.randomBytes(HKDF_SALT_BYTES);
|
|
217
|
+
|
|
218
|
+
// Bind the derived key to both public keys + the card AAD so the key cannot
|
|
219
|
+
// be reused across recipients or transplanted to another card.
|
|
220
|
+
const info = Buffer.concat([
|
|
221
|
+
Buffer.from(HKDF_INFO_PREFIX, 'utf-8'),
|
|
222
|
+
ephemeralPubDer,
|
|
223
|
+
recipientDer,
|
|
224
|
+
Buffer.from(aad, 'utf-8'),
|
|
225
|
+
]);
|
|
226
|
+
const aesKey = Buffer.from(crypto.hkdfSync('sha256', sharedSecret, salt, info, AES_KEY_BYTES));
|
|
227
|
+
|
|
228
|
+
const iv = crypto.randomBytes(GCM_IV_BYTES);
|
|
229
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv);
|
|
230
|
+
cipher.setAAD(Buffer.from(aad, 'utf-8'));
|
|
231
|
+
const ciphertext = Buffer.concat([cipher.update(pt), cipher.final()]);
|
|
232
|
+
const authTag = cipher.getAuthTag();
|
|
233
|
+
|
|
234
|
+
// Scrub the derived key from memory as soon as we're done with it.
|
|
235
|
+
aesKey.fill(0);
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
cipher: SEAL_CIPHER,
|
|
239
|
+
version: SEAL_VERSION,
|
|
240
|
+
aad,
|
|
241
|
+
thresholdKeyId: thresholdKeyId(recipient),
|
|
242
|
+
envelope: {
|
|
243
|
+
ephemeralPublicKey: ephemeralPubDer.toString('base64'),
|
|
244
|
+
salt: salt.toString('base64'),
|
|
245
|
+
iv: iv.toString('base64'),
|
|
246
|
+
authTag: authTag.toString('base64'),
|
|
247
|
+
},
|
|
248
|
+
ciphertext: ciphertext.toString('base64'),
|
|
249
|
+
ciphertextDigest: crypto.createHash('sha256').update(ciphertext).digest('hex'),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Assemble the full sealed ARTIFACT — the ciphertext-only blob that would be
|
|
255
|
+
* stored in the off-git, off-allowlist object store (never the repo). It is
|
|
256
|
+
* exactly the `sealPlaintext` envelope plus the ids that route it. It contains
|
|
257
|
+
* NO plaintext.
|
|
258
|
+
*
|
|
259
|
+
* @param {object} o
|
|
260
|
+
* @param {object} o.sealed a sealPlaintext() result
|
|
261
|
+
* @param {string} o.cardId
|
|
262
|
+
* @param {string} o.custodianGroupId
|
|
263
|
+
* @param {string} o.createdAt ISO timestamp (injected for determinism)
|
|
264
|
+
* @returns {object} ciphertext-only artifact
|
|
265
|
+
*/
|
|
266
|
+
export function buildSealedArtifact({ sealed, cardId, custodianGroupId, createdAt }) {
|
|
267
|
+
return {
|
|
268
|
+
champollionSealed: SEAL_VERSION,
|
|
269
|
+
cardId,
|
|
270
|
+
custodianGroupId,
|
|
271
|
+
cipher: sealed.cipher,
|
|
272
|
+
aad: sealed.aad,
|
|
273
|
+
thresholdKeyId: sealed.thresholdKeyId,
|
|
274
|
+
envelope: sealed.envelope,
|
|
275
|
+
ciphertext: sealed.ciphertext,
|
|
276
|
+
ciphertextDigest: sealed.ciphertextDigest,
|
|
277
|
+
createdAt: createdAt || null,
|
|
278
|
+
_note:
|
|
279
|
+
'Ciphertext-only sealed corpus. Decryptable ONLY by the custodian group\'s ' +
|
|
280
|
+
'threshold key (3-of-5 in Wave 2). Champollion holds zero shares and cannot ' +
|
|
281
|
+
'decrypt this. Never tracked in git or hosted in plaintext.',
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The content-free `sealed` block written onto the corpus card. This is the
|
|
287
|
+
* shape the card builder records — cipher id, custodian group, digest, AAD.
|
|
288
|
+
* (Plus the key id + qualifier reference, all metadata.)
|
|
289
|
+
*
|
|
290
|
+
* @param {object} o
|
|
291
|
+
* @param {object} o.sealed a sealPlaintext() result
|
|
292
|
+
* @param {string} o.custodianGroupId
|
|
293
|
+
* @param {string} [o.keyScheme] e.g. 'TSS-3-of-5' (Wave 2)
|
|
294
|
+
* @param {string|null} [o.qualifierId] paired public qualifier card id
|
|
295
|
+
* @param {number|null} [o.qualifierThreshold]
|
|
296
|
+
* @param {string|null} [o.artifactRef] off-git pointer to the ciphertext store
|
|
297
|
+
* @returns {{cipher:string, custodianGroupId:string, ciphertextDigest:string, aad:string,
|
|
298
|
+
* thresholdKeyId:string, keyScheme:(string|null), qualifierId:(string|null),
|
|
299
|
+
* qualifierThreshold:(number|null), artifactRef:(string|null)}}
|
|
300
|
+
*/
|
|
301
|
+
export function buildSealedCardBlock({
|
|
302
|
+
sealed, custodianGroupId, keyScheme = null,
|
|
303
|
+
qualifierId = null, qualifierThreshold = null, artifactRef = null,
|
|
304
|
+
}) {
|
|
305
|
+
return {
|
|
306
|
+
cipher: sealed.cipher,
|
|
307
|
+
custodianGroupId,
|
|
308
|
+
ciphertextDigest: sealed.ciphertextDigest,
|
|
309
|
+
aad: sealed.aad,
|
|
310
|
+
thresholdKeyId: sealed.thresholdKeyId,
|
|
311
|
+
keyScheme,
|
|
312
|
+
qualifierId,
|
|
313
|
+
qualifierThreshold,
|
|
314
|
+
artifactRef,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// The decrypt path. WAVE-2 SEAM: in production this runs ONLY inside the
|
|
320
|
+
// controlled eval context (sandbox-evaluation-spec.md §6) after an M-of-N grant,
|
|
321
|
+
// and the single private key below is replaced by a threshold ECDH over the
|
|
322
|
+
// 3-of-5 shares. Here it proves a sealed corpus is genuinely recoverable —
|
|
323
|
+
// i.e. the encryption is real, not a no-op.
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Decrypt a sealed artifact (or a raw sealPlaintext envelope) with the
|
|
328
|
+
* threshold private key. Verifies the ciphertext digest and the GCM tag (which
|
|
329
|
+
* also authenticates the AAD binding).
|
|
330
|
+
*
|
|
331
|
+
* @param {object} o
|
|
332
|
+
* @param {object} o.artifact buildSealedArtifact()/sealPlaintext() shape
|
|
333
|
+
* @param {crypto.KeyObject|string} o.thresholdPrivateKey
|
|
334
|
+
* @param {string} [o.aad] expected AAD (defaults to artifact.aad)
|
|
335
|
+
* @returns {Buffer} the recovered plaintext
|
|
336
|
+
*/
|
|
337
|
+
export function openSealed({ artifact, thresholdPrivateKey, aad }) {
|
|
338
|
+
if (!artifact || !artifact.envelope) throw new Error('Not a sealed artifact (missing envelope).');
|
|
339
|
+
const expectAad = aad !== undefined ? aad : artifact.aad;
|
|
340
|
+
if (typeof expectAad !== 'string' || !expectAad) throw new Error('A binding AAD is required to open a sealed artifact.');
|
|
341
|
+
|
|
342
|
+
const privateKey = typeof thresholdPrivateKey === 'object' && thresholdPrivateKey.asymmetricKeyType
|
|
343
|
+
? thresholdPrivateKey
|
|
344
|
+
: crypto.createPrivateKey(
|
|
345
|
+
typeof thresholdPrivateKey === 'string' && thresholdPrivateKey.includes('-----BEGIN')
|
|
346
|
+
? thresholdPrivateKey
|
|
347
|
+
: { key: Buffer.from(String(thresholdPrivateKey), 'base64'), format: 'der', type: 'pkcs8' },
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const ciphertext = Buffer.from(artifact.ciphertext, 'base64');
|
|
351
|
+
const digest = crypto.createHash('sha256').update(ciphertext).digest('hex');
|
|
352
|
+
if (artifact.ciphertextDigest && digest !== artifact.ciphertextDigest) {
|
|
353
|
+
throw new Error('Sealed artifact failed integrity check (ciphertext digest mismatch).');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const ephemeralPub = crypto.createPublicKey({
|
|
357
|
+
key: Buffer.from(artifact.envelope.ephemeralPublicKey, 'base64'), format: 'der', type: 'spki',
|
|
358
|
+
});
|
|
359
|
+
const sharedSecret = crypto.diffieHellman({ privateKey, publicKey: ephemeralPub });
|
|
360
|
+
const recipientDer = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'der' });
|
|
361
|
+
const ephemeralPubDer = ephemeralPub.export({ type: 'spki', format: 'der' });
|
|
362
|
+
const info = Buffer.concat([
|
|
363
|
+
Buffer.from(HKDF_INFO_PREFIX, 'utf-8'),
|
|
364
|
+
ephemeralPubDer,
|
|
365
|
+
recipientDer,
|
|
366
|
+
Buffer.from(expectAad, 'utf-8'),
|
|
367
|
+
]);
|
|
368
|
+
const aesKey = Buffer.from(crypto.hkdfSync(
|
|
369
|
+
'sha256', sharedSecret, Buffer.from(artifact.envelope.salt, 'base64'), info, AES_KEY_BYTES,
|
|
370
|
+
));
|
|
371
|
+
|
|
372
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, Buffer.from(artifact.envelope.iv, 'base64'));
|
|
373
|
+
decipher.setAAD(Buffer.from(expectAad, 'utf-8'));
|
|
374
|
+
decipher.setAuthTag(Buffer.from(artifact.envelope.authTag, 'base64'));
|
|
375
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
376
|
+
aesKey.fill(0);
|
|
377
|
+
return plaintext;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// Score-bundle signing (Phase B airgap transport) — Ed25519, node:crypto only.
|
|
382
|
+
//
|
|
383
|
+
// The AIRGAPPED scoring node signs each exported score bundle so the
|
|
384
|
+
// connected relay can verify the bytes it publishes are exactly the bytes the
|
|
385
|
+
// sandbox produced (arena/docs/sandbox-evaluation-spec.md §8 step 4 "audit
|
|
386
|
+
// log sealed"/§9). Honesty label: this is a SINGLE node signing key — it
|
|
387
|
+
// authenticates the machine's output, it is NOT the M-of-N steward custody of
|
|
388
|
+
// §12 (Wave 2). The canonical signed payload is the file's exact bytes; the
|
|
389
|
+
// signature block records the payload sha256 alongside so tampering is
|
|
390
|
+
// doubly evident.
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
/** Signature scheme label stamped into every signature block. */
|
|
394
|
+
export const SIGN_SCHEME = 'ed25519-single-node-wave1';
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Generate an Ed25519 signing keypair for a scoring node.
|
|
398
|
+
* @returns {{publicKeyDerB64:string, privateKeyDerB64:string, keyId:string}}
|
|
399
|
+
*/
|
|
400
|
+
export function generateSigningKeypair() {
|
|
401
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
402
|
+
return {
|
|
403
|
+
publicKeyDerB64: publicKey.export({ type: 'spki', format: 'der' }).toString('base64'),
|
|
404
|
+
privateKeyDerB64: privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64'),
|
|
405
|
+
keyId: thresholdKeyId(publicKey), // same stable-id recipe: sha256(SPKI)[:16]
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function resolveSigningKey(input, { wantPublic }) {
|
|
410
|
+
const label = wantPublic ? 'verify (public)' : 'signing (private)';
|
|
411
|
+
if (!input) throw new Error(`A ${label} key is required.`);
|
|
412
|
+
if (typeof input === 'object' && input.asymmetricKeyType) return input;
|
|
413
|
+
if (typeof input === 'object') {
|
|
414
|
+
const der = input.publicKeyDerB64 || input.privateKeyDerB64;
|
|
415
|
+
if (typeof der === 'string') {
|
|
416
|
+
return wantPublic
|
|
417
|
+
? crypto.createPublicKey({ key: Buffer.from(der, 'base64'), format: 'der', type: 'spki' })
|
|
418
|
+
: crypto.createPrivateKey({ key: Buffer.from(der, 'base64'), format: 'der', type: 'pkcs8' });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const raw = String(input).trim();
|
|
422
|
+
if (raw.includes('-----BEGIN')) {
|
|
423
|
+
return wantPublic ? crypto.createPublicKey(raw) : crypto.createPrivateKey(raw);
|
|
424
|
+
}
|
|
425
|
+
const der = Buffer.from(raw, 'base64');
|
|
426
|
+
return wantPublic
|
|
427
|
+
? crypto.createPublicKey({ key: der, format: 'der', type: 'spki' })
|
|
428
|
+
: crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Sign a payload (Buffer) with an Ed25519 private key.
|
|
433
|
+
* @returns {{scheme:string, keyId:string, payloadSha256:string, signatureB64:string}}
|
|
434
|
+
*/
|
|
435
|
+
export function signPayload({ payload, signingPrivateKey }) {
|
|
436
|
+
if (!Buffer.isBuffer(payload) || !payload.length) {
|
|
437
|
+
throw new Error('signPayload needs a non-empty Buffer payload.');
|
|
438
|
+
}
|
|
439
|
+
const privateKey = resolveSigningKey(signingPrivateKey, { wantPublic: false });
|
|
440
|
+
if (privateKey.asymmetricKeyType !== 'ed25519') {
|
|
441
|
+
throw new Error(`Signing key must be Ed25519 (got ${privateKey.asymmetricKeyType || 'unknown'}).`);
|
|
442
|
+
}
|
|
443
|
+
const publicKey = crypto.createPublicKey(privateKey);
|
|
444
|
+
return {
|
|
445
|
+
scheme: SIGN_SCHEME,
|
|
446
|
+
keyId: thresholdKeyId(publicKey),
|
|
447
|
+
payloadSha256: crypto.createHash('sha256').update(payload).digest('hex'),
|
|
448
|
+
signatureB64: crypto.sign(null, payload, privateKey).toString('base64'),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Verify a payload (Buffer) against a signature block and an Ed25519 public
|
|
454
|
+
* key. Returns true/false — the CALLER decides what a failure means (always
|
|
455
|
+
* a refusal; never a warning).
|
|
456
|
+
*/
|
|
457
|
+
export function verifyPayload({ payload, signatureB64, verifyPublicKey }) {
|
|
458
|
+
if (!Buffer.isBuffer(payload)) throw new Error('verifyPayload needs a Buffer payload.');
|
|
459
|
+
const publicKey = resolveSigningKey(verifyPublicKey, { wantPublic: true });
|
|
460
|
+
if (publicKey.asymmetricKeyType !== 'ed25519') {
|
|
461
|
+
throw new Error(`Verify key must be Ed25519 (got ${publicKey.asymmetricKeyType || 'unknown'}).`);
|
|
462
|
+
}
|
|
463
|
+
return crypto.verify(null, payload, publicKey, Buffer.from(String(signatureB64), 'base64'));
|
|
464
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sealed-qualifier.mjs — the PUBLIC QUALIFIER paired to a sealed test set.
|
|
3
|
+
*
|
|
4
|
+
* A sealed (community-controlled secret) test set is precious: every evaluation
|
|
5
|
+
* against it leaks a little signal, and custodian approval is a real,
|
|
6
|
+
* low-frequency act (M-of-N, docs/governance/OCAP_MULTISIG_PLAN.md M2). So a
|
|
7
|
+
* method must EARN the right to even *propose* a sealed run by first clearing a
|
|
8
|
+
* disjoint, fully public twin — the **qualifier**.
|
|
9
|
+
*
|
|
10
|
+
* The qualifier is:
|
|
11
|
+
* • PUBLIC-tier (CC-BY / CC0 / share-alike) — admitted through the SAME gate
|
|
12
|
+
* as any public corpus (gatePublicRegistration, corpus-registration.mjs).
|
|
13
|
+
* A non-redistributable or unconfirmed license can never be a qualifier.
|
|
14
|
+
* • DISJOINT from the sealed set (no shared entries) — drawn from the same
|
|
15
|
+
* distribution so clearing it is meaningful, but sharing no sentences so it
|
|
16
|
+
* leaks nothing about the sealed content. Author-attested (we never see
|
|
17
|
+
* either side's content to verify), recorded on the card.
|
|
18
|
+
* • YEARLY-ROTATED (vYYYY). Each year a fresh qualifier supersedes the last;
|
|
19
|
+
* the prior year is FROZEN for history (immutable, kept so old results stay
|
|
20
|
+
* interpretable). A qualifier whose vintage is behind the current year is
|
|
21
|
+
* STALE — it has been public long enough that contamination/overfitting
|
|
22
|
+
* risk rises, so we surface a contamination-risk badge.
|
|
23
|
+
*
|
|
24
|
+
* The load-bearing rule is `isEligibleForSealedRun`: below the qualifier
|
|
25
|
+
* threshold (or no qualifier cleared at all) ⇒ NOT eligible to request a sealed
|
|
26
|
+
* run. Pure functions, no I/O — dates/years are injected so the logic is
|
|
27
|
+
* deterministic and testable.
|
|
28
|
+
*
|
|
29
|
+
* @module sealed-qualifier
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { gatePublicRegistration } from './corpus-registration.mjs';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Default qualifier clearance threshold. Expressed on the public leaderboard's
|
|
36
|
+
* composite 0–100 scale; communities/contests may override per qualifier. This
|
|
37
|
+
* is a floor placeholder, not a claim about any specific pair's difficulty —
|
|
38
|
+
* callers should set a calibrated threshold for real qualifiers.
|
|
39
|
+
*/
|
|
40
|
+
export const DEFAULT_QUALIFIER_THRESHOLD = 30;
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Versioning — vYYYY yearly rotation.
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/** `2026` → `'v2026'`. */
|
|
47
|
+
export function qualifierVersionTag(year) {
|
|
48
|
+
const y = parseInt(year, 10);
|
|
49
|
+
if (!Number.isInteger(y) || y < 2000 || y > 9999) {
|
|
50
|
+
throw new Error(`Qualifier year must be a 4-digit year (got ${year}).`);
|
|
51
|
+
}
|
|
52
|
+
return `v${y}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `'v2026'` (or an id ending in `-v2026`) → `2026`, else null. */
|
|
56
|
+
export function parseQualifierYear(input) {
|
|
57
|
+
const m = String(input || '').match(/v(\d{4})(?:$|[^0-9])/);
|
|
58
|
+
return m ? parseInt(m[1], 10) : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Derive a qualifier card id paired to a sealed set.
|
|
63
|
+
* Shape: eval-<src>-<tgt>-<slug>-qualifier-vYYYY (public-tier, runnable).
|
|
64
|
+
*
|
|
65
|
+
* @param {{source:string, target:string, slug?:string, year:number}} o
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
export function buildQualifierId({ source, target, slug, year }) {
|
|
69
|
+
const tag = slug && String(slug).length ? slug : 'sealed';
|
|
70
|
+
return `eval-${source}-${target}-${tag}-qualifier-${qualifierVersionTag(year)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// The license gate — a qualifier MUST be public-eligible (it is itself public).
|
|
75
|
+
// Reuses the one public-registration gate; no parallel rule.
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* May this license back a PUBLIC qualifier? (Same admission as any public
|
|
80
|
+
* corpus.) NC / ND / unconfirmed licenses are blocked.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} licenseOption a resolveLicense() result
|
|
83
|
+
* @returns {{allowed:boolean, tier:string, reason:string}}
|
|
84
|
+
*/
|
|
85
|
+
export function gateQualifier(licenseOption) {
|
|
86
|
+
const gate = gatePublicRegistration(licenseOption);
|
|
87
|
+
if (gate.allowed) return gate;
|
|
88
|
+
return {
|
|
89
|
+
allowed: false,
|
|
90
|
+
tier: gate.tier,
|
|
91
|
+
reason: `A qualifier is public by definition, so it needs a redistribution-cleared (CC-BY/CC0/share-alike) license. ${gate.reason}`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Eligibility — the gate every sealed-run proposal passes BEFORE custodians are
|
|
97
|
+
// ever bothered. Fail-safe: anything unconfirmed ⇒ NOT eligible.
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Is a method eligible to PROPOSE a run against the sealed set?
|
|
102
|
+
*
|
|
103
|
+
* Requires a paired qualifier, a recorded score on it, and that score meeting
|
|
104
|
+
* the threshold. A stale qualifier still gates (you must clear the *current*
|
|
105
|
+
* qualifier), so we also report staleness for the caller to surface.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} o
|
|
108
|
+
* @param {string|null} [o.qualifierId] the paired public qualifier (required)
|
|
109
|
+
* @param {number|null} [o.score] the method's score on that qualifier
|
|
110
|
+
* @param {number} [o.threshold] clearance threshold (default DEFAULT_QUALIFIER_THRESHOLD)
|
|
111
|
+
* @param {number|null} [o.qualifierYear] vintage year of the qualifier the score was earned on
|
|
112
|
+
* @param {number|null} [o.currentYear] the active qualifier year (for staleness)
|
|
113
|
+
* @returns {{eligible:boolean, reason:string, threshold:number, score:(number|null),
|
|
114
|
+
* stale:boolean, badge:(object|null)}}
|
|
115
|
+
*/
|
|
116
|
+
export function isEligibleForSealedRun({
|
|
117
|
+
qualifierId = null, score = null, threshold = DEFAULT_QUALIFIER_THRESHOLD,
|
|
118
|
+
qualifierYear = null, currentYear = null,
|
|
119
|
+
} = {}) {
|
|
120
|
+
const thr = Number(threshold);
|
|
121
|
+
const badge = (qualifierYear != null && currentYear != null)
|
|
122
|
+
? qualifierContaminationBadge({ qualifierYear, currentYear })
|
|
123
|
+
: null;
|
|
124
|
+
const stale = !!(badge && badge.stale);
|
|
125
|
+
|
|
126
|
+
if (!qualifierId) {
|
|
127
|
+
return { eligible: false, reason: 'No public qualifier is paired with this sealed set — a sealed run cannot be proposed without one.', threshold: thr, score: null, stale, badge };
|
|
128
|
+
}
|
|
129
|
+
if (score === null || score === undefined || Number.isNaN(Number(score))) {
|
|
130
|
+
return { eligible: false, reason: `Method has not cleared the public qualifier (${qualifierId}) yet — run it on the qualifier first.`, threshold: thr, score: null, stale, badge };
|
|
131
|
+
}
|
|
132
|
+
const s = Number(score);
|
|
133
|
+
if (s < thr) {
|
|
134
|
+
return { eligible: false, reason: `Method scored ${s} on the public qualifier (${qualifierId}); the sealed-run threshold is ${thr}. Not eligible to propose a sealed run.`, threshold: thr, score: s, stale, badge };
|
|
135
|
+
}
|
|
136
|
+
return { eligible: true, reason: `Method cleared the public qualifier (${qualifierId}) at ${s} ≥ ${thr} — eligible to PROPOSE a sealed run (still requires M-of-N custodian approval).`, threshold: thr, score: s, stale, badge };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Staleness → contamination-risk badge.
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Contamination-risk badge for a qualifier given its vintage vs the active year.
|
|
145
|
+
* A current-year qualifier is fresh (no badge). Each year behind raises risk:
|
|
146
|
+
* 1 year ⇒ MEDIUM, 2+ ⇒ HIGH — it has been public long enough that training-set
|
|
147
|
+
* contamination and overfitting to the public twin become real.
|
|
148
|
+
*
|
|
149
|
+
* @param {{qualifierYear:number, currentYear:number}} o
|
|
150
|
+
* @returns {{stale:boolean, ageYears:number, risk:('NONE'|'LOW'|'MEDIUM'|'HIGH'),
|
|
151
|
+
* badge:(string|null), reason:string}}
|
|
152
|
+
*/
|
|
153
|
+
export function qualifierContaminationBadge({ qualifierYear, currentYear }) {
|
|
154
|
+
const qy = parseInt(qualifierYear, 10);
|
|
155
|
+
const cy = parseInt(currentYear, 10);
|
|
156
|
+
if (!Number.isInteger(qy) || !Number.isInteger(cy)) {
|
|
157
|
+
throw new Error('qualifierContaminationBadge needs integer years.');
|
|
158
|
+
}
|
|
159
|
+
const age = cy - qy;
|
|
160
|
+
if (age <= 0) {
|
|
161
|
+
return { stale: false, ageYears: Math.max(0, age), risk: 'NONE', badge: null, reason: `Qualifier v${qy} is current — fresh.` };
|
|
162
|
+
}
|
|
163
|
+
const risk = age >= 2 ? 'HIGH' : 'MEDIUM';
|
|
164
|
+
return {
|
|
165
|
+
stale: true,
|
|
166
|
+
ageYears: age,
|
|
167
|
+
risk,
|
|
168
|
+
badge: `⚠ STALE QUALIFIER (v${qy}, ${age} yr${age === 1 ? '' : 's'} old)`,
|
|
169
|
+
reason: `Qualifier v${qy} is ${age} year${age === 1 ? '' : 's'} behind the active v${cy}; public exposure raises contamination/overfitting risk (${risk}). Rotate to v${cy}.`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Rotation — supersede with a fresh year; FREEZE the prior for history.
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Rotate a qualifier to a new year. The previously-active qualifier is FROZEN
|
|
179
|
+
* (marked immutable, retained for history) and the new year becomes active.
|
|
180
|
+
*
|
|
181
|
+
* @param {object} o
|
|
182
|
+
* @param {{source:string, target:string, slug?:string}} o.pair
|
|
183
|
+
* @param {number} o.newYear
|
|
184
|
+
* @param {object|null} [o.current] the currently-active qualifier descriptor (or null for first-ever)
|
|
185
|
+
* @param {object[]} [o.frozen] already-frozen prior qualifiers
|
|
186
|
+
* @param {number} [o.threshold]
|
|
187
|
+
* @returns {{active:object, frozen:object[]}}
|
|
188
|
+
*/
|
|
189
|
+
export function rotateQualifier({ pair, newYear, current = null, frozen = [], threshold = DEFAULT_QUALIFIER_THRESHOLD }) {
|
|
190
|
+
const ny = parseInt(newYear, 10);
|
|
191
|
+
if (current) {
|
|
192
|
+
const currentYear = current.year ?? parseQualifierYear(current.id);
|
|
193
|
+
if (currentYear != null && ny <= currentYear) {
|
|
194
|
+
throw new Error(`Cannot rotate to v${ny}: it is not newer than the active qualifier v${currentYear}.`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const nextFrozen = [...frozen];
|
|
198
|
+
if (current) {
|
|
199
|
+
nextFrozen.push({ ...current, status: 'frozen', frozen: true });
|
|
200
|
+
}
|
|
201
|
+
const active = {
|
|
202
|
+
id: buildQualifierId({ source: pair.source, target: pair.target, slug: pair.slug, year: ny }),
|
|
203
|
+
year: ny,
|
|
204
|
+
version: qualifierVersionTag(ny),
|
|
205
|
+
tier: 'public',
|
|
206
|
+
status: 'active',
|
|
207
|
+
frozen: false,
|
|
208
|
+
threshold: Number(threshold),
|
|
209
|
+
};
|
|
210
|
+
return { active, frozen: nextFrozen };
|
|
211
|
+
}
|