← back to Dw Rotation Activator
lib/settlement-gate.js
283 lines
'use strict';
/*
* settlement-gate.js — per-product SETTLEMENT gate for the rotation activator.
*
* The activator flips DRAFT→ACTIVE at ~500/day. WITHOUT this gate it would
* publish tropical/botanical products (Gracie chinoiserie, Zuber scenics,
* Fromental florals, Madagascar "Real Banana Leaf") unchecked — a hard-rule
* violation of the Designer Wallcoverings Settlement Agreement. This module
* closes that hole. It runs ALONGSIDE the existing 5-field gate: a product must
* pass BOTH the 5-field gate AND settlement before it can activate.
*
* Binding rule (from ~/.claude/skills/settlement/binding/SETTLEMENT-BINDING-TEXT.md):
* A design is PROHIBITED iff BOTH
* Part A fully (ALL of: directional-variation foliage + open space between
* leaves + >1 ink color), AND
* Part B (≥1 of: bananas/banana-pods, grapes, birds, butterflies).
* The defendant-favorable reading is the only reading; BORDERLINE / UNKNOWN
* cases must be ruled BLOCK by default.
*
* Three outcomes per product:
* PASS — settlement clear → activator may proceed to ACTIVE (if 5-field also ok).
* BLOCK — full Part A + a Part B element → leave DRAFT + hold metafield + log.
* HELD — borderline / vision-unavailable / unknown → leave DRAFT + hold + log.
* (Treated identically to BLOCK by the caller: never activates.)
*
* Two tiers of check:
* (0) CHEAP AUTO-PASS ($0, no vision): a plain natural-texture material whose
* TITLE carries no foliage/motif keyword is settlement-MOOT (a solid
* grasscloth/cork/silk with no leaves can't satisfy Part A). This covers
* the entire tier-0 textures block (the first ~10 days of the rotation),
* so the loop is settlement-safe immediately at ~$0/run.
* (1) VISION GATE (Gemini 2.5-flash, ~$0.0006/image): everything else — every
* motif/print/botanical product that is NOT an auto-pass texture. Downloads
* the primary image and asks the model the Part A / Part B / Acceptable
* booleans, then applies the binding verdict locally.
*
* FAIL-CLOSED: lock.sh (settlement SHA verifier) runs once at construction. If it
* fails, no motif/vision product may activate this run (they all return HELD);
* plain auto-pass textures still pass (they never depended on the vision path).
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const https = require('https');
const { execFileSync } = require('child_process');
// ── texture material lexicon (auto-pass tier) ────────────────────────────────
// Prompt-specified plain-texture set. A product is a candidate auto-pass only if
// its material (metafield OR title-derived) is one of these solid naturals.
const TEXTURE_MATERIALS = [
'silk', 'grasscloth', 'cork', 'sisal', 'raffia', 'abaca', 'jute', 'linen',
'hemp', 'paperweave', 'grass', 'seagrass', 'leather', 'suede', 'wool', 'mohair',
];
const TEXTURE_MAT_RE = new RegExp('\\b(' + TEXTURE_MATERIALS.join('|') + ')\\b', 'i');
// 'grass' would match 'grasscloth' anyway; 'paperweave' also spelled 'paper weave'.
const TEXTURE_MAT_RE2 = /paper\s*weave/i;
// Motif / foliage keywords. If ANY appears in the title, the product is NOT a
// solid texture — it carries a motif and must be vision-gated (never auto-passed).
const MOTIF_RE =
/floral|flower|leaf|leaves|palm|frond|banana|bird|butterfly|tropical|scenic|toile|chinoiserie|bloom|vine|jungle|garden|grape|peacock|foliage|botanic/i;
// ── orchestrator forensic log (shared with the settlement skill) ─────────────
const ORCH_LOG = path.join(os.homedir(),
'Projects/wallco-ai/data/settlement-audit/orchestrator-log.jsonl');
function appendOrchestrator(entry) {
try {
fs.mkdirSync(path.dirname(ORCH_LOG), { recursive: true });
fs.appendFileSync(ORCH_LOG, JSON.stringify(entry) + '\n');
} catch (_) { /* forensic log best-effort; never block a run on it */ }
}
// ── cost logging (cost-tracker) ──────────────────────────────────────────────
const COST_LOG = path.join(os.homedir(), '.claude/skills/cost-tracker/scripts/log.js');
// Gemini 2.5-flash vision: a settlement image call is ~1 image (~258 vision
// tokens/tile) + a short prompt + a tiny JSON answer. Bill a flat conservative
// estimate so the ledger stays honest without token accounting per call.
const GEMINI_INPUT_TOKENS_PER_CALL = 800; // prompt + ~258 image tokens, rounded up
const GEMINI_OUTPUT_TOKENS_PER_CALL = 120; // small JSON verdict
function logVisionCost() {
try {
execFileSync('node', [COST_LOG,
'--api', 'gemini_2_5_flash',
'--units', `${GEMINI_INPUT_TOKENS_PER_CALL}:input_token,${GEMINI_OUTPUT_TOKENS_PER_CALL}:output_token`,
'--app', 'dw-rotation-activator',
'--note', 'settlement vision gate'], { encoding: 'utf8', stdio: 'ignore' });
} catch (_) { /* ledger best-effort */ }
// computed dollar value for inline display + return
return GEMINI_INPUT_TOKENS_PER_CALL * 7.5e-8 + GEMINI_OUTPUT_TOKENS_PER_CALL * 3e-7;
}
// ── the gate ─────────────────────────────────────────────────────────────────
class SettlementGate {
constructor() {
// FAIL-CLOSED lock check — verify the settlement binding SHAs once per run.
this.lockOk = false;
this.lockMsg = '';
try {
execFileSync('bash', [path.join(os.homedir(), '.claude/skills/settlement/lock.sh')],
{ encoding: 'utf8', stdio: 'pipe' });
this.lockOk = true;
} catch (e) {
this.lockMsg = (e.stdout || e.stderr || e.message || 'lock.sh failed').toString().slice(0, 200);
}
// Gemini keys — keep BOTH so a per-key quota cap (429 "monthly spending cap")
// on one key falls through to the other instead of HELD-ing every tier-1
// product. Order: generic GEMINI_API_KEY first (its own cap), then wallco.
const env = fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8');
const clean = (v) => (v || '').replace(/['"]/g, '').trim();
const kGen = clean((env.match(/^GEMINI_API_KEY=(.*)$/m) || [])[1]);
const kWallco = clean((env.match(/^GEMINI_API_KEY_WALLCO=(.*)$/m) || [])[1]);
this.geminiKeys = [kGen, kWallco].filter((k, i, a) => k && a.indexOf(k) === i);
this.geminiKey = this.geminiKeys[0] || ''; // primary (back-compat)
this.visionCalls = 0;
this.visionCostTotal = 0;
this.autoPassCount = 0;
}
lockStatus() { return { ok: this.lockOk, msg: this.lockMsg }; }
stats() {
return { visionCalls: this.visionCalls, visionCostTotal: this.visionCostTotal,
autoPassCount: this.autoPassCount };
}
// Cheap auto-pass test: solid texture material + NO motif keyword in title.
isAutoPassTexture(title, material) {
const t = String(title || '');
if (MOTIF_RE.test(t)) return false; // any motif keyword → NOT solid texture
const matStr = `${material || ''} ${t}`; // material metafield OR title-derived
return TEXTURE_MAT_RE.test(matStr) || TEXTURE_MAT_RE2.test(matStr);
}
// Fetch an image URL to a base64 buffer (follows one redirect).
fetchImageB64(url, depth = 0) {
return new Promise((resolve) => {
if (!url || depth > 3) return resolve(null);
https.get(url, (r) => {
if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location) {
r.resume();
return resolve(this.fetchImageB64(r.headers.location, depth + 1));
}
if (r.statusCode !== 200) { r.resume(); return resolve(null); }
const chunks = [];
r.on('data', (c) => chunks.push(c));
r.on('end', () => resolve(Buffer.concat(chunks).toString('base64')));
}).on('error', () => resolve(null));
});
}
// Gemini 2.5-flash: return the settlement booleans for the produced image.
// Iterates over available keys, falling through a 429 / quota-cap to the next.
// Returns { ok:true, partA, partB, acceptable } or { ok:false, why }.
async visionDetect(b64, mime) {
const keys = this.geminiKeys && this.geminiKeys.length ? this.geminiKeys
: (this.geminiKey ? [this.geminiKey] : []);
if (!keys.length) return { ok: false, why: 'no-gemini-key' };
let last = { ok: false, why: 'no-attempt' };
for (const key of keys) {
const r = await this._visionCall(b64, mime, key);
if (r.ok) return r;
last = r;
// only fall through on rate/quota; a hard error (parse) shouldn't loop keys.
if (!/^vision-http-429$/.test(r.why || '')) break;
}
return last;
}
async _visionCall(b64, mime, key) {
const prompt =
'You are a legal-compliance image classifier for a wallcovering catalog. Look ONLY at what is ' +
'visibly depicted in this pattern image. Answer STRICTLY as JSON with these boolean keys:\n' +
'"a1_directional_foliage": true if the design shows a REPEATING pattern of leaves, palm fronds, or ' +
'similar foliage with DIRECTIONAL VARIATION (leaves point in more than one direction).\n' +
'"a2_open_space": true if there is visible OPEN / negative space between the leaves (not edge-to-edge coverage).\n' +
'"a3_multiple_ink_colors": true if the foliage/leaf layer uses MORE THAN ONE color (excluding the background).\n' +
'"partB_element": true if ANY of these specific elements is visibly depicted: bananas or banana pods, ' +
'grapes, birds (any species), or butterflies (any species).\n' +
'"acceptable_element": true if the design shows tree trunks, clearly represented branches, OR fruit/animal ' +
'elements OTHER THAN bananas/banana-pods/grapes/birds/butterflies.\n' +
'Report only what is actually visible. Return ONLY the JSON object, no prose.';
const body = {
contents: [{ parts: [
{ inline_data: { mime_type: mime || 'image/jpeg', data: b64 } },
{ text: prompt },
] }],
generationConfig: { temperature: 0, responseMimeType: 'application/json' },
};
const data = JSON.stringify(body);
const res = await new Promise((resolve) => {
const req = https.request({
host: 'generativelanguage.googleapis.com',
path: `/v1beta/models/gemini-2.5-flash:generateContent?key=${key}`,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
}, (r) => { let d = ''; r.on('data', (c) => d += c);
r.on('end', () => resolve({ status: r.statusCode, body: d })); });
req.on('error', (e) => resolve({ status: 0, err: e.message }));
req.write(data); req.end();
});
if (res.status !== 200) return { ok: false, why: `vision-http-${res.status}` };
let j;
try { j = JSON.parse(res.body); } catch { return { ok: false, why: 'vision-parse' }; }
let obj;
try { obj = JSON.parse(j.candidates?.[0]?.content?.parts?.[0]?.text || '{}'); }
catch { return { ok: false, why: 'verdict-parse' }; }
// require the 5 expected keys — a partial answer is UNKNOWN (fail-closed).
const keys = ['a1_directional_foliage', 'a2_open_space', 'a3_multiple_ink_colors',
'partB_element', 'acceptable_element'];
if (!keys.every((k) => typeof obj[k] === 'boolean')) return { ok: false, why: 'incomplete-verdict', obj };
const partA = obj.a1_directional_foliage && obj.a2_open_space && obj.a3_multiple_ink_colors;
return { ok: true, partA, partB: obj.partB_element, acceptable: obj.acceptable_element, raw: obj };
}
// Apply the BINDING verdict rule to the vision booleans.
// PROHIBITED iff (full Part A) AND (Part B). Everything else PASS.
verdictFromVision(v) {
if (v.partA && v.partB) {
return { verdict: 'BLOCK', reason: `fullPartA+partB(${JSON.stringify(v.raw)})` };
}
return { verdict: 'PASS', reason: v.partA ? 'partA-but-no-partB' : 'partA-not-fully-satisfied' };
}
/*
* evaluate(product) — the per-product entry point.
* product = { shopify_id, title, vendor, dw_sku, material, imageUrl }
* Returns { verdict:'PASS'|'BLOCK'|'HELD', tier:'auto-pass-texture'|'vision', reason, cost }.
* Caller activates ONLY on verdict === 'PASS'.
*/
async evaluate(product) {
const { shopify_id, title, vendor, dw_sku, material, imageUrl } = product;
// (0) CHEAP AUTO-PASS — solid texture, no foliage motif → settlement MOOT.
if (this.isAutoPassTexture(title, material)) {
this.autoPassCount++;
return { verdict: 'PASS', tier: 'auto-pass-texture',
reason: 'solid-texture-no-motif', cost: 0 };
}
// (1) VISION GATE for everything else.
// Fail-closed on lock failure: no motif/vision product activates this run.
if (!this.lockOk) {
const rec = { verdict: 'HELD', tier: 'vision', reason: 'lock-failed:' + this.lockMsg, cost: 0 };
appendOrchestrator({ ts: new Date().toISOString().slice(0, 10), skus: dw_sku || shopify_id,
pattern: title, verdict: 'NEEDS REVIEW', reason: 'settlement-lock-failed',
source: 'dw-rotation-activator' });
return rec;
}
const b64 = await this.fetchImageB64(imageUrl);
if (!b64) {
// No image to check → cannot clear settlement → HELD (fail-closed). (The
// 5-field gate independently requires an image, so this is belt-and-braces.)
appendOrchestrator({ ts: new Date().toISOString().slice(0, 10), skus: dw_sku || shopify_id,
pattern: title, verdict: 'NEEDS REVIEW', reason: 'no-image-for-settlement',
source: 'dw-rotation-activator' });
return { verdict: 'HELD', tier: 'vision', reason: 'no-image', cost: 0 };
}
const mime = /\.png(\?|$)/i.test(imageUrl || '') ? 'image/png' : 'image/jpeg';
const v = await this.visionDetect(b64, mime);
this.visionCalls++;
const cost = logVisionCost();
this.visionCostTotal += cost;
if (!v.ok) {
// vision unavailable / incomplete → BORDERLINE → HELD by default.
appendOrchestrator({ ts: new Date().toISOString().slice(0, 10), skus: dw_sku || shopify_id,
pattern: title, verdict: 'NEEDS REVIEW', reason: 'vision-' + (v.why || 'error'),
source: 'dw-rotation-activator' });
return { verdict: 'HELD', tier: 'vision', reason: 'vision-' + (v.why || 'error'), cost };
}
const dec = this.verdictFromVision(v);
if (dec.verdict === 'BLOCK') {
appendOrchestrator({ ts: new Date().toISOString().slice(0, 10), skus: dw_sku || shopify_id,
pattern: title, verdict: 'BLOCK', reason: dec.reason, source: 'dw-rotation-activator' });
return { verdict: 'BLOCK', tier: 'vision', reason: dec.reason, cost };
}
return { verdict: 'PASS', tier: 'vision', reason: dec.reason, cost };
}
}
module.exports = { SettlementGate, TEXTURE_MATERIALS, MOTIF_RE };