Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/cfg-gov-deploy-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
118 changes: 106 additions & 12 deletions scripts/purge-scored-corpus.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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
Expand All @@ -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";
Expand All @@ -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;
Expand All @@ -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);
}
Expand All @@ -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
Expand All @@ -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();
}
Loading