[object Object]

← back to AbramsEgo

Spend → Reviews: bind approval to content (anti-drift seal)

ddc28d6a76322b7f4988cdf5f9e7e408ef34ff04 · 2026-09-10 13:53:16 -0700 · Steve

Codex red-team caught the key correctness hole: approval wasn't bound to content, so
a regenerated draft or changed seller address could be executed differing from what a
human approved (stale-approval replay). Fix: approve now seals a sha1 of the exact
executable payload; the executor refuses + re-gates to 'draft' if content drifted; and
resolve/draft/seller-email-to changes invalidate a stale seal. Proven on :9773
(matching content passes, tampered draft blocked). TK-11433.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QA2Se1HgCv8KSZbUQD6w2p

Files touched

Diff

commit ddc28d6a76322b7f4988cdf5f9e7e408ef34ff04
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Sep 10 13:53:16 2026 -0700

    Spend → Reviews: bind approval to content (anti-drift seal)
    
    Codex red-team caught the key correctness hole: approval wasn't bound to content, so
    a regenerated draft or changed seller address could be executed differing from what a
    human approved (stale-approval replay). Fix: approve now seals a sha1 of the exact
    executable payload; the executor refuses + re-gates to 'draft' if content drifted; and
    resolve/draft/seller-email-to changes invalidate a stale seal. Proven on :9773
    (matching content passes, tampered draft blocked). TK-11433.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QA2Se1HgCv8KSZbUQD6w2p
---
 lib/spend-reviews/executors.js | 11 +++++++++++
 lib/spend-reviews/router.js    | 32 ++++++++++++++++++++++++++++----
 lib/spend-reviews/store.js     | 21 ++++++++++++++++++++-
 3 files changed, 59 insertions(+), 5 deletions(-)

diff --git a/lib/spend-reviews/executors.js b/lib/spend-reviews/executors.js
index f915e02b..31ef12f9 100644
--- a/lib/spend-reviews/executors.js
+++ b/lib/spend-reviews/executors.js
@@ -124,6 +124,17 @@ async function executeTarget(item, target, opts = {}) {
   if (!approved) {
     return { ok: false, gated: true, reason: 'target not per-item approved', plan };
   }
+  // GATE 1b: the approved content must be UNCHANGED since approval (anti-drift /
+  // stale-approval-replay). A regenerated draft or a changed seller address
+  // invalidates the seal → re-gate to 'draft', fire nothing.
+  const seal = item.approvals && item.approvals[target];
+  const currentHash = store.targetContentHash(item, target);
+  if (!seal || seal.contentHash !== currentHash) {
+    store.updateItem(item.id, { targets: Object.assign({}, item.targets, { [target]: 'draft' }),
+      approvals: Object.assign({}, item.approvals, { [target]: null }) });
+    store.logAction({ action: 'execute-blocked-drift', id: item.id, target });
+    return { ok: false, gated: true, reason: 'content changed since approval — re-approve before sending', plan };
+  }
   // GATE 2 + 3: live env switch AND a matching confirm token.
   if (!liveEnabled || !liveRequested) {
     store.logAction({ action: 'execute-gated', id: item.id, target, liveEnabled, liveRequested });
diff --git a/lib/spend-reviews/router.js b/lib/spend-reviews/router.js
index 9a4b9bb5..6c370145 100644
--- a/lib/spend-reviews/router.js
+++ b/lib/spend-reviews/router.js
@@ -22,15 +22,28 @@ function loadAdapter(source) {
 
 function newBudget() { return { remaining: cfg.PLACES_MAX_LOOKUPS_PER_RUN, spent: 0 }; }
 
+// Downgrade any 'approved' target whose sealed content no longer matches (drift).
+function invalidateDriftedApprovals(item, targets, approvals) {
+  for (const t of cfg.TARGETS) {
+    if (targets[t] === 'approved') {
+      const seal = approvals[t];
+      if (!seal || seal.contentHash !== store.targetContentHash(item, t)) { targets[t] = 'draft'; approvals[t] = null; }
+    }
+  }
+}
+
 async function resolveAndDraft(item, budget) {
   const resolved = await resolver.resolveItem(item, budget);
   const withResolved = Object.assign({}, item, { resolved });
   const d = drafts.generateAll(withResolved);
   const targets = Object.assign({}, item.targets);
+  const approvals = Object.assign({}, item.approvals);
   for (const t of cfg.TARGETS) {
     if (d[t] && (targets[t] === 'none' || !targets[t])) targets[t] = 'draft';
   }
-  return store.updateItem(item.id, { resolved, drafts: d, targets });
+  const next = Object.assign({}, withResolved, { drafts: d, targets, approvals });
+  invalidateDriftedApprovals(next, targets, approvals);
+  return store.updateItem(item.id, { resolved, drafts: d, targets, approvals });
 }
 
 function summary() {
@@ -111,8 +124,11 @@ router.post('/draft/:id', (req, res) => {
   if (!it) return res.status(404).json({ ok: false, error: 'not found' });
   const d = drafts.generateAll(it);
   const targets = Object.assign({}, it.targets);
+  const approvals = Object.assign({}, it.approvals);
   for (const t of cfg.TARGETS) if (d[t] && (targets[t] === 'none' || !targets[t])) targets[t] = 'draft';
-  res.json({ ok: true, item: store.updateItem(it.id, { drafts: d, targets }) });
+  const next = Object.assign({}, it, { drafts: d, targets, approvals });
+  invalidateDriftedApprovals(next, targets, approvals);
+  res.json({ ok: true, item: store.updateItem(it.id, { drafts: d, targets, approvals }) });
 });
 
 // Per-item human approval flip (the whole point — nothing posts without this).
@@ -123,8 +139,11 @@ function setTarget(req, res, status) {
   if (!it) return res.status(404).json({ ok: false, error: 'not found' });
   if (status === 'approved' && (!it.drafts || !it.drafts[target])) return res.status(400).json({ ok: false, error: 'no draft to approve for this target' });
   const targets = Object.assign({}, it.targets, { [target]: status });
+  // Seal the approved content (anti-drift). Un/skip clears the seal.
+  const approvals = Object.assign({}, it.approvals);
+  approvals[target] = status === 'approved' ? { contentHash: store.targetContentHash(it, target), approvedAt: new Date().toISOString() } : null;
   store.logAction({ action: 'set-status', id, target, status });
-  res.json({ ok: true, item: store.updateItem(id, { targets }) });
+  res.json({ ok: true, item: store.updateItem(id, { targets, approvals }) });
 }
 router.post('/approve', (req, res) => setTarget(req, res, 'approved'));
 router.post('/unapprove', (req, res) => setTarget(req, res, 'draft'));
@@ -135,7 +154,12 @@ router.post('/seller-email-to', (req, res) => {
   const { id, to } = req.body || {};
   const it = store.getItem(id);
   if (!it) return res.status(404).json({ ok: false, error: 'not found' });
-  res.json({ ok: true, item: store.updateItem(id, { seller_email_to: (to || '').toString().slice(0, 200) }) });
+  // changing the destination invalidates a prior seller_email approval seal
+  const targets = Object.assign({}, it.targets);
+  const approvals = Object.assign({}, it.approvals);
+  const next = Object.assign({}, it, { seller_email_to: (to || '').toString().slice(0, 200) });
+  invalidateDriftedApprovals(next, targets, approvals);
+  res.json({ ok: true, item: store.updateItem(id, { seller_email_to: next.seller_email_to, targets, approvals }) });
 });
 
 // THE GATED EXECUTE. Per item + per target. Returns the plan (gated) unless every
diff --git a/lib/spend-reviews/store.js b/lib/spend-reviews/store.js
index e2dbe3d9..72b2804d 100644
--- a/lib/spend-reviews/store.js
+++ b/lib/spend-reviews/store.js
@@ -101,9 +101,28 @@ function makeItem(input) {
     resolved: { google: null, amazon: null },
     drafts: { google_review: null, amazon_review: null, seller_email: null },
     targets: { google_review: 'none', amazon_review: 'none', seller_email: 'none' },
+    // per-target approval binding: {contentHash, approvedAt} snapshot of EXACTLY
+    // what a human approved, so a later content drift can't be silently executed.
+    approvals: { google_review: null, amazon_review: null, seller_email: null },
+    seller_email_to: null,
   };
 }
 
+/** Hash the EXACT executable payload for a target — the anti-drift approval seal. */
+function targetContentHash(item, target) {
+  const d = (item.drafts && item.drafts[target]) || {};
+  let payload;
+  if (target === 'seller_email') {
+    payload = [item.seller_email_to || '', d.subject || '', d.body || ''].join('');
+  } else {
+    const g = (item.resolved && item.resolved.google) || {};
+    const a = (item.resolved && item.resolved.amazon) || {};
+    const tgtUrl = target === 'google_review' ? (g.writeUrl || g.mapsUrl || '') : (a.reviewUrl || a.productUrl || '');
+    payload = [d.title || '', d.text || '', String(d.rating || ''), tgtUrl].join('');
+  }
+  return crypto.createHash('sha1').update(target + '' + payload).digest('hex');
+}
+
 function readItems() { return readJsonl(cfg.ITEMS_FILE); }
 function getItem(id) { return readItems().find((i) => i.id === id) || null; }
 
@@ -158,6 +177,6 @@ function logAction(row) {
 }
 
 module.exports = {
-  normalizeMerchant, displayMerchant, makeItem, readItems, getItem, upsertItems, updateItem,
+  normalizeMerchant, displayMerchant, makeItem, targetContentHash, readItems, getItem, upsertItems, updateItem,
   getPlace, setPlace, logAction, readJsonl,
 };

← b5451cd2 AbramsEgo: add Spend → Reviews panel (ingest → resolve → dra  ·  back to AbramsEgo  ·  auto-data-snapshot: 2026-09-10T14:13:31 (1 data files) — dat 65195041 →