diff --git a/docs/cfg-gov-deploy-notes.md b/docs/cfg-gov-deploy-notes.md index e014682..8a13571 100644 --- a/docs/cfg-gov-deploy-notes.md +++ b/docs/cfg-gov-deploy-notes.md @@ -44,5 +44,17 @@ with `--confirm-purge-scored-corpus`) **before** deploying the new runtime, so: - the fabricated-input opinions are gone while `signal_outcomes` and the raw ingest payloads (`scoring_context.rawUss`) remain for offline re-scoring. +**Owner ruling 2026-08-06 — `scoring_context` is scrubbed, not kept whole.** The +collection held both the observations *and* the worthless scores. The script now +does a field-level scrub: every document survives, `analystScore` is set to +`null`, and a self-describing `scorePurge` marker records why. Retained: +`lenses` (the persisted enriched view that makes offline re-scoring possible), +`rawUss`, `meta`, `decayParams`, `compositionRef`, `uwrResolvedSource`. + +The hourly outcomes cron is unaffected — `scripts/capture-outcomes.mjs` reads +`ctx.meta?.direction` and `ctx.meta?.symbol` (`:161`, `:176`) and never reads +`analystScore`. The scrub issues `$set` only; its document count is asserted +byte-identical before and after. + Then deploy, then apply the Atlas role separation runbook (or apply the roles first — either order works; the purge itself needs a write-capable user). diff --git a/scripts/purge-scored-corpus.mjs b/scripts/purge-scored-corpus.mjs index 5dee7b6..1965c4f 100644 --- a/scripts/purge-scored-corpus.mjs +++ b/scripts/purge-scored-corpus.mjs @@ -11,10 +11,21 @@ * - afi_scored_signal_evidence.scored_signal_evidence (canonical current) * - afi_scored_signal_evidence.scored_signal_evidence_history (canonical history) * - * WHAT IT KEEPS — the keep-list, hard-refused even if asked: + * WHAT IT SCRUBS — field-level, documents preserved (owner ruling 2026-08-06): + * - afi_signal_analytics.scoring_context — `analystScore` is set to null and a + * self-describing `scorePurge` marker is written. The score was the worthless + * part; everything that makes the row an OBSERVATION is kept: + * `lenses` the persisted enriched view — enables offline re-scoring + * `rawUss` the raw ingest payload as submitted + * `meta` instrument + declared direction (the hourly outcomes cron + * reads `meta.direction` / `meta.symbol`; it never reads + * `analystScore`, so this scrub cannot break it) + * `decayParams`, `compositionRef`, `uwrResolvedSource` provenance + * Nulling rather than unsetting is deliberate: a reader can distinguish + * "score deliberately removed" from "row never carried one". + * + * WHAT IT KEEPS UNTOUCHED — hard-refused even if asked: * - afi_signal_analytics.signal_outcomes (observations: what the market did) - * - afi_signal_analytics.scoring_context (carries the RAW INGEST PAYLOAD, - * `rawUss`, per signal — observations that enable offline re-scoring later) * Everything not named above (tssd_signals, any other db/collection) is * out of scope and never touched. * @@ -24,8 +35,10 @@ * - Refuses any attempt to name a keep-list or unknown collection. * - deleteMany({}) (never drop): collections, indexes, and the unique * signalId constraint survive; only documents are removed. + * - The scrub issues $set only — NEVER a delete. Its document count must be + * byte-identical before/after, and that is asserted. * - Prints per-collection counts BEFORE and AFTER, and verifies the - * keep-list counts are byte-identical before/after. + * keep-list and scrub-list document counts are byte-identical before/after. * * USAGE: * AFI_EVIDENCE_MONGODB_URI='mongodb+srv://…' node scripts/purge-scored-corpus.mjs @@ -44,7 +57,18 @@ const PURGE_LIST = [ const KEEP_LIST = [ { db: ANALYTICS_DB, collection: "signal_outcomes" }, - { db: ANALYTICS_DB, collection: "scoring_context" }, +]; + +// Field-level scrub: documents survive, only the named score fields are nulled. +// `filter` also defines what the dry-run counts as still-scored. +const SCRUB_LIST = [ + { + db: ANALYTICS_DB, + collection: "scoring_context", + filter: { analystScore: { $ne: null } }, + set: { analystScore: null }, + label: "analystScore", + }, ]; const CONFIRM_FLAG = "--confirm-purge-scored-corpus"; @@ -66,12 +90,21 @@ for (const arg of args) { } const act = args.includes(CONFIRM_FLAG); -// Defense in depth: if editing ever put a keep-list name into the purge set, -// refuse to start. +// Defense in depth: if editing ever put a keep-list or scrub-list name into the +// purge (deleteMany) set, refuse to start. A scrubbed collection must never be +// deleted from, and a keep-list collection must never be written at all. for (const p of PURGE_LIST) { if (KEEP_LIST.some((k) => k.db === p.db && k.collection === p.collection)) { fail(`purge list names keep-list collection ${p.db}.${p.collection}`); } + if (SCRUB_LIST.some((s) => s.db === p.db && s.collection === p.collection)) { + fail(`purge list names scrub-list collection ${p.db}.${p.collection} — scrub is $set, never delete`); + } +} +for (const s of SCRUB_LIST) { + if (KEEP_LIST.some((k) => k.db === s.db && k.collection === s.collection)) { + fail(`scrub list names keep-list collection ${s.db}.${s.collection}`); + } } const uri = process.env.AFI_EVIDENCE_MONGODB_URI; @@ -94,18 +127,46 @@ function printCounts(label, rows) { } } +async function scrubCounts() { + const out = []; + for (const { db, collection, filter, label } of SCRUB_LIST) { + const col = client.db(db).collection(collection); + out.push({ + db, + collection, + label, + documents: await col.countDocuments(), + stillScored: await col.countDocuments(filter), + }); + } + return out; +} + +function printScrub(label, rows) { + console.log(`\n${label}`); + for (const r of rows) { + console.log( + ` ${r.db}.${r.collection}: ${r.documents} document(s), ${r.stillScored} still carrying '${r.label}'` + ); + } +} + try { await client.connect(); const purgeBefore = await counts(PURGE_LIST); const keepBefore = await counts(KEEP_LIST); - printCounts("PURGE TARGETS (before):", purgeBefore); - printCounts("KEEP-LIST (never touched):", keepBefore); + const scrubBefore = await scrubCounts(); + printCounts("PURGE TARGETS — documents deleted (before):", purgeBefore); + printCounts("KEEP-LIST — never written (before):", keepBefore); + printScrub("SCRUB TARGETS — documents PRESERVED, score field nulled (before):", scrubBefore); if (!act) { console.log( - `\nDRY-RUN — no documents were removed. To purge the ${purgeBefore.reduce((n, r) => n + r.count, 0)} ` + - `scored documents above, re-run with ${CONFIRM_FLAG}.` + `\nDRY-RUN — nothing was removed or modified.` + + `\n would DELETE ${purgeBefore.reduce((n, r) => n + r.count, 0)} scored evidence document(s)` + + `\n would NULL ${scrubBefore.reduce((n, r) => n + r.stillScored, 0)} '${SCRUB_LIST.map((s) => s.label).join("/")}' field(s), keeping every document` + + `\nTo act, re-run with ${CONFIRM_FLAG}.` ); process.exit(0); } @@ -116,10 +177,33 @@ try { console.log(` deleted ${res.deletedCount} from ${db}.${collection}`); } + console.log("\nCONFIRMED — scrubbing score fields ($set only, never delete)…"); + const purgedAt = new Date().toISOString(); + for (const { db, collection, filter, set, label } of SCRUB_LIST) { + const res = await client + .db(db) + .collection(collection) + .updateMany(filter, { + $set: { + ...set, + scorePurge: { + purgedAt, + field: label, + reason: + "pre-CFG-GOV score produced by a pipeline with fabricated inputs; " + + "observations (lenses, rawUss, meta) deliberately retained for offline re-scoring", + }, + }, + }); + console.log(` nulled ${label} on ${res.modifiedCount} document(s) in ${db}.${collection}`); + } + const purgeAfter = await counts(PURGE_LIST); const keepAfter = await counts(KEEP_LIST); + const scrubAfter = await scrubCounts(); printCounts("PURGE TARGETS (after):", purgeAfter); printCounts("KEEP-LIST (after — must equal before):", keepAfter); + printScrub("SCRUB TARGETS (after — documents must equal before, stillScored must be 0):", scrubAfter); const keepMoved = keepAfter.some( (r, i) => r.count !== keepBefore[i].count @@ -128,12 +212,22 @@ try { console.error("\nINTEGRITY ALARM: a keep-list count changed. This script issued no write to those collections — investigate concurrent writers before proceeding."); process.exit(2); } + const scrubLostDocs = scrubAfter.some((r, i) => r.documents !== scrubBefore[i].documents); + if (scrubLostDocs) { + console.error("\nINTEGRITY ALARM: a scrub-target document count changed. The scrub issues $set only and must never delete — investigate before proceeding."); + process.exit(2); + } + const scrubResidue = scrubAfter.reduce((n, r) => n + r.stillScored, 0); + if (scrubResidue !== 0) { + console.error(`\nINCOMPLETE: ${scrubResidue} document(s) still carry a score field.`); + process.exit(2); + } const residue = purgeAfter.reduce((n, r) => n + r.count, 0); if (residue !== 0) { console.error(`\nINCOMPLETE: ${residue} document(s) remain in the purge targets.`); process.exit(2); } - console.log("\nDONE — scored corpus cleared; observations kept."); + console.log("\nDONE — scored corpus cleared, score fields nulled; observations kept."); } finally { await client.close(); }