[object Object]

← back to Whatsmystyle

tick 6: E2E validation + resale guardrails + debates UI + onboarding triage

05b41bf45f87bb668e9fb2df2e91a64d56038680 · 2026-05-11 21:01:58 -0700 · Steve Abrams

scripts/e2e.js — 25-check fresh-session validator covering every meaningful
endpoint. Run with `node scripts/e2e.js`. Per the 2026-05-12 validation-loop
rule, this is the post-edit gate before any tick claims done. Green on
this tick.

Resale guardrails (acts on the prior debate's HOLD verdict):
- scripts/resale-guardrails.js — three checks any future resale UX MUST
  pass through:
  * Circularity: nudge eligible only if item is ≥90d old OR user confirms
    worn-5+/no-longer-fits/won't-wear-again
  * Repair/recycle FIRST: tailor + cobbler search links + 6 brand-aware
    take-back programs (Patagonia Worn Wear, Eileen Fisher Renew, Levi's
    SecondHand, For Days, TerraCycle, Goodwill) — surfaced BEFORE any
    sell CTA
  * Privacy limits: resale events log to data/resale-events.jsonl only,
    NEVER to the taste vector / duels / outfit_picks tables
- GET  /api/resale/options/:closet_id  — returns repair-first + takeback
  programs + boolean sell_nudge.allowed (with reason)
- POST /api/resale/event { closet_id, action } — ephemeral log only

Debates UI:
- New /debates screen reads /api/debates and renders one card per panel
  verdict (topic, headline, full text in <details>, timestamp). Footer
  link added to /about + /privacy + /debates so first-time visitors find
  all three.

Onboarding triage (per onboarding-length debate, verdict: modify):
- 8 of 11 questions marked optional={true} (body-shape + budget +
  city/geolocation stay mandatory per the panel's core-pillar call)
- Skip button enables/disables based on optional flag; required questions
  show 'Can't skip — required' instead of 'Skip'

Smoke-tested live: resale endpoint returns the expected guardrail shape
on a fresh closet item (sell_nudge.allowed=false at 0d). E2E re-ran
post-edit, 25/25 still green.

Files touched

Diff

commit 05b41bf45f87bb668e9fb2df2e91a64d56038680
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 11 21:01:58 2026 -0700

    tick 6: E2E validation + resale guardrails + debates UI + onboarding triage
    
    scripts/e2e.js — 25-check fresh-session validator covering every meaningful
    endpoint. Run with `node scripts/e2e.js`. Per the 2026-05-12 validation-loop
    rule, this is the post-edit gate before any tick claims done. Green on
    this tick.
    
    Resale guardrails (acts on the prior debate's HOLD verdict):
    - scripts/resale-guardrails.js — three checks any future resale UX MUST
      pass through:
      * Circularity: nudge eligible only if item is ≥90d old OR user confirms
        worn-5+/no-longer-fits/won't-wear-again
      * Repair/recycle FIRST: tailor + cobbler search links + 6 brand-aware
        take-back programs (Patagonia Worn Wear, Eileen Fisher Renew, Levi's
        SecondHand, For Days, TerraCycle, Goodwill) — surfaced BEFORE any
        sell CTA
      * Privacy limits: resale events log to data/resale-events.jsonl only,
        NEVER to the taste vector / duels / outfit_picks tables
    - GET  /api/resale/options/:closet_id  — returns repair-first + takeback
      programs + boolean sell_nudge.allowed (with reason)
    - POST /api/resale/event { closet_id, action } — ephemeral log only
    
    Debates UI:
    - New /debates screen reads /api/debates and renders one card per panel
      verdict (topic, headline, full text in <details>, timestamp). Footer
      link added to /about + /privacy + /debates so first-time visitors find
      all three.
    
    Onboarding triage (per onboarding-length debate, verdict: modify):
    - 8 of 11 questions marked optional={true} (body-shape + budget +
      city/geolocation stay mandatory per the panel's core-pillar call)
    - Skip button enables/disables based on optional flag; required questions
      show 'Can't skip — required' instead of 'Skip'
    
    Smoke-tested live: resale endpoint returns the expected guardrail shape
    on a fresh closet item (sell_nudge.allowed=false at 0d). E2E re-ran
    post-edit, 25/25 still green.
---
 data/waitlist.csv            |   2 +
 data/whatsmystyle.db-shm     | Bin 32768 -> 32768 bytes
 data/whatsmystyle.db-wal     | Bin 527392 -> 877592 bytes
 public/css/app.css           |   9 +++
 public/index.html            |  11 +++-
 public/js/app.js             |  46 +++++++++++---
 scripts/e2e.js               | 142 +++++++++++++++++++++++++++++++++++++++++++
 scripts/resale-guardrails.js |  93 ++++++++++++++++++++++++++++
 server.js                    |  25 ++++++++
 9 files changed, 318 insertions(+), 10 deletions(-)

diff --git a/data/waitlist.csv b/data/waitlist.csv
index 7432645..628462e 100644
--- a/data/waitlist.csv
+++ b/data/waitlist.csv
@@ -1,2 +1,4 @@
 email,source,created_at
 steve@designerwallcoverings.com,smoke,2026-05-12T03:45:26.941Z
+e2e-1778557939546@example.com,e2e,2026-05-12T03:52:19.577Z
+e2e-1778558413111@example.com,e2e,2026-05-12T04:00:13.114Z
diff --git a/data/whatsmystyle.db-shm b/data/whatsmystyle.db-shm
index 8ef4809..46f8207 100644
Binary files a/data/whatsmystyle.db-shm and b/data/whatsmystyle.db-shm differ
diff --git a/data/whatsmystyle.db-wal b/data/whatsmystyle.db-wal
index 6a8e3aa..ced2f82 100644
Binary files a/data/whatsmystyle.db-wal and b/data/whatsmystyle.db-wal differ
diff --git a/public/css/app.css b/public/css/app.css
index b724aac..cc2dc44 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -188,6 +188,15 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
 .pitch-waitlist input { padding: 14px 18px; border: 1.5px solid var(--ink); border-radius: 999px; font-size: 16px; min-width: 260px; }
 .pitch-waitlist button { padding: 14px 24px; border: 0; border-radius: 999px; background: var(--ink); color: var(--paper); font-size: 16px; font-weight: 600; cursor: pointer; }
 
+/* debates */
+.debate { padding: 22px; border: 1px solid var(--line); border-radius: 18px; background: var(--paper); margin-bottom: 14px; }
+.debate h3 { margin: 0 0 12px; font-family: var(--font-display); font-size: 22px; }
+.debate .verdict { font-size: 16px; line-height: 1.5; padding: 12px 14px; background: var(--paper-2); border-left: 4px solid var(--accent); border-radius: 0 12px 12px 0; }
+.debate details { margin-top: 12px; }
+.debate details summary { cursor: pointer; color: var(--ink-2); }
+.debate pre { white-space: pre-wrap; font: inherit; background: var(--paper-2); padding: 14px; border-radius: 10px; margin-top: 8px; }
+.debate .small { font-size: 12px; margin-top: 8px; }
+
 /* outfit-builder */
 .outfit-card { padding: 18px; border: 1px solid var(--line); border-radius: 22px; background: var(--paper); margin-bottom: 16px; }
 .outfit-card h4 { margin: 0 0 12px; font-family: var(--font-display); font-size: 22px; }
diff --git a/public/index.html b/public/index.html
index 398ce2e..33c56f4 100644
--- a/public/index.html
+++ b/public/index.html
@@ -214,6 +214,13 @@
     <div id="tryons-grid" class="grid"></div>
   </section>
 
+  <!-- Debates — how product decisions get made -->
+  <section id="debates" class="screen" hidden>
+    <h2>How decisions get made</h2>
+    <p class="muted">Every meaningful product decision goes through a three-model adversarial debate before it ships. Verdicts below are the raw output — what shipped is downstream of these.</p>
+    <div id="debates-list"></div>
+  </section>
+
   <!-- Privacy -->
   <section id="privacy" class="screen" hidden>
     <h2>Your data, plainly</h2>
@@ -261,7 +268,9 @@
 </main>
 
 <footer class="footer" role="contentinfo">
-  <a href="#" data-screen="privacy">Privacy & how we use your data</a> ·
+  <a href="#" data-screen="about">About</a> ·
+  <a href="#" data-screen="privacy">Privacy</a> ·
+  <a href="#" data-screen="debates">How decisions get made</a> ·
   <button class="link-btn" onclick="forgetMe()" aria-label="Delete all my data">Delete my account + everything</button>
 </footer>
 
diff --git a/public/js/app.js b/public/js/app.js
index 472a671..af5c0e8 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -48,12 +48,14 @@ $$('.menu a').forEach(a => a.addEventListener('click', e => {
 }));
 
 // ---------- Onboarding -----------------------------------------------------
+// Per the 2026-05-12 onboarding-length debate (verdict: modify) — mandatory
+// = body-shape, budget, geolocation. Other 8 are optional (skippable).
 const onbQuestions = [
-  { key: 'shopping_for', q: 'Who are you shopping for?',
+  { key: 'shopping_for', optional: true, q: 'Who are you shopping for?',
     pills: [['Myself','self'],['Someone else','other'],['Both','both']] },
-  { key: 'gender_expr', q: 'What section of the store feels right?',
+  { key: 'gender_expr', optional: true, q: 'What section of the store feels right?',
     pills: [["Women's",'women'],["Men's",'men'],['Unisex','unisex'],['Fluid — show me everything','fluid']] },
-  { key: 'height_cm', q: 'How tall are you?', kind: 'slider',
+  { key: 'height_cm', optional: true, q: 'How tall are you?', kind: 'slider',
     min: 140, max: 210, default: 170, unit: 'cm' },
   { key: 'body_shape', q: 'Which silhouette feels closest?',
     pills: [
@@ -61,7 +63,7 @@ const onbQuestions = [
       ['Apple','apple'],['Inverted triangle','inv-triangle'],['Athletic','athletic'],
       ['Skip — I find this question annoying','skip']
     ]},
-  { key: 'hair', q: 'Your hair, right now?',
+  { key: 'hair', optional: true, q: 'Your hair, right now?',
     pills: [
       ['Black, short','black-short'],['Black, long','black-long'],
       ['Brown, short','brown-short'],['Brown, long','brown-long'],
@@ -69,9 +71,9 @@ const onbQuestions = [
       ['Red','red'],['Gray / silver','gray'],
       ['Shaved / very short','shaved'],['Other','other'],
     ]},
-  { key: 'age_band', q: 'Age band — for fit + cut only, never displayed.',
+  { key: 'age_band', optional: true, q: 'Age band — for fit + cut only, never displayed.',
     pills: [['Under 25','u25'],['25–34','25-34'],['35–44','35-44'],['45–54','45-54'],['55–64','55-64'],['65+','65p']] },
-  { key: 'undertone', q: 'Your skin undertone?',
+  { key: 'undertone', optional: true, q: 'Your skin undertone?',
     pills: [
       ['Warm — gold jewelry suits me','warm'],
       ['Cool — silver jewelry suits me','cool'],
@@ -85,9 +87,9 @@ const onbQuestions = [
       ['$150–500','150-500'],['$500–1500','500-1500'],['$1500+','1500p'],
       ['No cap','nocap'],
     ]},
-  { key: 'sustainability', q: 'How much should sustainability matter?',
+  { key: 'sustainability', optional: true, q: 'How much should sustainability matter?',
     pills: [['Top priority','top'],['Nice to have','nice'],["Doesn't matter to me",'no']] },
-  { key: '_connect', q: 'Want me to learn faster? (You can skip any of these.)', kind: 'connect' },
+  { key: '_connect', optional: true, q: 'Want me to learn faster? (You can skip any of these.)', kind: 'connect' },
 ];
 let onbI = 0;
 const onbAnswers = {};
@@ -97,7 +99,13 @@ function onbRender() {
   $('#onb-bar').style.width = `${Math.round((onbI / total) * 100)}%`;
   const q = onbQuestions[onbI];
   if (!q) return finishOnboarding();
-  $('#onb-q').textContent = q.q;
+  // Mark optional questions with a hint so the user knows they can skip
+  $('#onb-q').textContent = q.q + (q.optional ? '' : '');
+  const opt = q.optional ? ' (optional)' : ' (we need this to dress you well)';
+  // Hint shown to the side of the Skip button
+  const skipBtn = document.querySelector('.onb-foot .ghost:nth-child(2)');
+  if (skipBtn) skipBtn.textContent = q.optional ? 'Skip' : "Can't skip — required";
+  if (skipBtn) skipBtn.disabled = !q.optional;
   const pills = $('#onb-pills');
   pills.innerHTML = '';
   if (q.pills) {
@@ -559,6 +567,25 @@ $('#waitlist-form').addEventListener('submit', async e => {
 });
 
 // ---------- Router ---------------------------------------------------------
+async function loadDebates() {
+  const r = await fetch('/api/debates').then(r => r.json());
+  const list = $('#debates-list');
+  list.innerHTML = '';
+  (r.debates || []).forEach(d => {
+    const div = document.createElement('article');
+    div.className = 'debate';
+    // strip the "VERDICT: " prefix for a cleaner card; keep full text in detail
+    const headline = (d.verdict || '').split('\n')[0].replace(/^VERDICT:\s*/i, '');
+    div.innerHTML = `
+      <h3>${d.topic}</h3>
+      <div class="verdict">${headline}</div>
+      <details><summary>Full panel verdict</summary><pre>${(d.verdict || '').replace(/</g,'&lt;')}</pre></details>
+      <div class="muted small">${d.created_at}</div>`;
+    list.appendChild(div);
+  });
+  if (!r.debates?.length) list.innerHTML = '<p class="muted">No debates yet.</p>';
+}
+
 function route(name) {
   show(name);
   if (name === 'home') loadDuel();
@@ -568,6 +595,7 @@ function route(name) {
   if (name === 'avatar') pollAvatar();
   if (name === 'timetravel') loadTimeTravel();
   if (name === 'tryons') loadTryons();
+  if (name === 'debates') loadDebates();
   if (name === 'connect') fetch('/api/connect').then(r => r.json()).then(c => $('#connect-state').textContent = JSON.stringify(c.connections, null, 2));
 }
 
diff --git a/scripts/e2e.js b/scripts/e2e.js
new file mode 100644
index 0000000..b4eda8d
--- /dev/null
+++ b/scripts/e2e.js
@@ -0,0 +1,142 @@
+/**
+ * End-to-end validation — fresh session through every meaningful state.
+ *
+ * Per the 2026-05-12 validation-loop rule: a feature is not "done" until
+ * the same review panel passes on the post-edit version. For an end-user
+ * app the equivalent is "the full happy path completes from a cold start."
+ *
+ * This script:
+ *   1. Hits /api/health
+ *   2. Creates a fresh session (cookie jar)
+ *   3. /api/me                       — session-user auto-created
+ *   4. /api/onboard                  — full profile post
+ *   5. /api/avatar/photos            — upload a few sample photos
+ *   6. /api/avatar/build             — kick the builder (worker may stub)
+ *   7. /api/closet/photo             — upload a closet photo
+ *   8. /api/duel × 5 + pick A/B/A/B/skip
+ *   9. /api/recommend                — recs grid endpoint
+ *   10. /api/outfits                 — outfit-builder (item_id anchor)
+ *   11. /api/outfits/:id/pick        — pick outfit 0
+ *   12. /api/waitlist                — join the waitlist
+ *   13. /api/debates                 — read panel verdicts
+ *   14. /api/stripe-fc/status        — capability probe
+ *   15. /api/me/forget               — wipe everything
+ *
+ * Exits 0 on full pass; 1 on first failure (with the failing step name).
+ */
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const FormData = require('form-data');
+
+const BASE = process.env.E2E_BASE || 'http://127.0.0.1:9777';
+
+let cookies = '';
+
+function setCookies(res) {
+  const sc = res.headers['set-cookie'];
+  if (!sc) return;
+  // simple: keep just k=v from the first cookie line
+  const first = (Array.isArray(sc) ? sc[0] : sc).split(';')[0];
+  cookies = first;
+}
+
+function req(method, p, body, extraHeaders) {
+  return new Promise((resolve, reject) => {
+    const url = new URL(p, BASE);
+    const headers = { ...(extraHeaders || {}), ...(cookies ? { cookie: cookies } : {}) };
+    let postBody;
+    if (body instanceof FormData) {
+      Object.assign(headers, body.getHeaders());
+    } else if (body != null) {
+      postBody = typeof body === 'string' ? body : JSON.stringify(body);
+      if (!headers['content-type']) headers['content-type'] = 'application/json';
+      headers['content-length'] = Buffer.byteLength(postBody);
+    }
+    const r = http.request({ hostname: url.hostname, port: url.port, path: url.pathname + url.search, method, headers }, res => {
+      setCookies(res);
+      let buf = '';
+      res.on('data', c => buf += c);
+      res.on('end', () => {
+        let data = buf;
+        try { data = JSON.parse(buf); } catch {}
+        resolve({ status: res.statusCode, data });
+      });
+    });
+    r.on('error', reject);
+    if (body instanceof FormData) body.pipe(r);
+    else if (postBody) { r.write(postBody); r.end(); }
+    else r.end();
+  });
+}
+
+async function expect(name, fn) {
+  process.stdout.write(`  ${name.padEnd(38, ' ')} `);
+  try {
+    const r = await fn();
+    if (r.ok === false || r.status >= 400) {
+      console.log(`✗ FAIL (status ${r.status}, ${JSON.stringify(r.data).slice(0, 200)})`);
+      throw new Error(name);
+    }
+    console.log('✓');
+    return r;
+  } catch (e) {
+    console.log(`✗ ERROR (${e.message})`);
+    throw e;
+  }
+}
+
+// generate a tiny PNG (1x1 red pixel) for sample-photo uploads
+const TINY_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', 'base64');
+
+async function main() {
+  console.log(`E2E validation against ${BASE}\n`);
+
+  await expect('health', () => req('GET', '/api/health'));
+  await expect('me (creates session)', () => req('GET', '/api/me'));
+  await expect('onboard', () => req('POST', '/api/onboard', {
+    display_name: 'E2E Tester',
+    gender_expr: 'fluid',
+    age_band: '35-44',
+    height_cm: 170,
+    body_shape: 'hourglass',
+    hair: 'brown-long',
+    undertone: 'warm',
+    city: 'Sherman Oaks',
+    budget_band: '50-150',
+    sustainability: 'top',
+  }));
+  // avatar photos
+  {
+    const fd = new FormData();
+    fd.append('photos', TINY_PNG, { filename: 'a.png', contentType: 'image/png' });
+    fd.append('photos', TINY_PNG, { filename: 'b.png', contentType: 'image/png' });
+    await expect('avatar/photos upload', () => req('POST', '/api/avatar/photos', fd));
+  }
+  await expect('avatar/build kick', () => req('POST', '/api/avatar/build'));
+  await expect('avatar status', () => req('GET', '/api/avatar'));
+  // closet
+  {
+    const fd = new FormData();
+    fd.append('photo', TINY_PNG, { filename: 'closet.png', contentType: 'image/png' });
+    await expect('closet/photo upload', () => req('POST', '/api/closet/photo', fd));
+  }
+  await expect('closet list', () => req('GET', '/api/closet'));
+  // 5 duels
+  for (let i = 0; i < 5; i++) {
+    const d = await expect(`duel ${i+1} get`, () => req('GET', '/api/duel'));
+    const choice = ['A','B','A','B','skip'][i];
+    await expect(`duel ${i+1} pick ${choice}`, () => req('POST', '/api/duel', { duel_id: d.data.duel_id, picked: choice }));
+  }
+  await expect('recommend', () => req('GET', '/api/recommend?limit=10'));
+  const o = await expect('outfits (item anchor)', () => req('POST', '/api/outfits', { item_id: 11 }));
+  await expect('outfit pick', () => req('POST', `/api/outfits/${o.data.pick_id}/pick`, { index: 0 }));
+  await expect('waitlist join', () => req('POST', '/api/waitlist', { email: `e2e-${Date.now()}@example.com`, source: 'e2e' }));
+  await expect('debates list', () => req('GET', '/api/debates'));
+  await expect('stripe-fc/status', () => req('GET', '/api/stripe-fc/status'));
+  await expect('me/forget', () => req('POST', '/api/me/forget'));
+
+  console.log('\n✓ E2E passed.');
+}
+
+main().catch(() => { console.error('\n✗ E2E failed.'); process.exit(1); });
diff --git a/scripts/resale-guardrails.js b/scripts/resale-guardrails.js
new file mode 100644
index 0000000..be36302
--- /dev/null
+++ b/scripts/resale-guardrails.js
@@ -0,0 +1,93 @@
+/**
+ * Resale guardrails — scaffold only, no resale UX shipped yet.
+ *
+ * Per the 2026-05-12 dream-team verdict on "should WhatsMyStyle help
+ * users resell closet items": HOLD until three guardrails are in place.
+ *
+ *   1. Circularity check — an item is eligible for a resale nudge only if:
+ *        - it's been in the closet ≥ 90 days, OR
+ *        - the user has confirmed "I've worn this >5 times", OR
+ *        - the user has confirmed "this no longer fits/I will not wear again"
+ *      Goal: prevent the app from accelerating fast-fashion churn.
+ *
+ *   2. Repair / recycle FIRST — before showing any sell-this CTA, the user
+ *      must dismiss two options:
+ *        a) repair_link  — link to a local tailor/cobbler (Google Maps
+ *           Places query "tailor near me" or "cobbler near me")
+ *        b) recycle_link — link to a textile take-back program
+ *           (Patagonia Worn Wear, Eileen Fisher Renew, For Days Take-Back,
+ *           local Goodwill / textile recycle dropoff)
+ *      Only after the user dismisses BOTH do we surface the sell CTA.
+ *
+ *   3. Privacy limits — resale events are EPHEMERAL. We don't write to
+ *      outfit_picks, we don't write to duels, we don't update the taste
+ *      vector. We log only the local-time + closet_id + action verb to
+ *      data/resale-events.jsonl for the user's own export — never the
+ *      server's analytical use.
+ *
+ * Until the UX ships, this module just exposes the eligibility check +
+ * resource links so the rest of the codebase can reason about it.
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+const DATA_DIR = path.join(__dirname, '..', 'data');
+const EPHEMERAL_LOG = path.join(DATA_DIR, 'resale-events.jsonl');
+
+// --- Guardrail 1: circularity ---------------------------------------------
+function isEligibleForResaleNudge(closetItem, userConfirmation = {}) {
+  const ageMs = Date.now() - Date.parse(closetItem.created_at || closetItem.acquired_at || 0);
+  const ageDays = ageMs / (24 * 60 * 60 * 1000);
+  if (ageDays >= 90) return { ok: true, reason: 'age>=90d' };
+  if (userConfirmation.worn_more_than_5) return { ok: true, reason: 'user-confirmed worn 5+' };
+  if (userConfirmation.no_longer_fits) return { ok: true, reason: 'user-confirmed no longer fits' };
+  if (userConfirmation.never_will_wear) return { ok: true, reason: 'user-confirmed will not wear' };
+  return { ok: false, reason: `${Math.round(ageDays)}d old + no user confirmation; nudge blocked` };
+}
+
+// --- Guardrail 2: repair / recycle ----------------------------------------
+// Hand-curated take-back programs. Add to this list as we vet new ones —
+// each one MUST be a real program that accepts garments from the public.
+const TAKEBACK_PROGRAMS = [
+  { name: 'Patagonia Worn Wear', url: 'https://wornwear.patagonia.com/', accepts: 'Patagonia only', kind: 'repair+resell' },
+  { name: 'Eileen Fisher Renew',  url: 'https://www.eileenfisherrenew.com/', accepts: 'Eileen Fisher only', kind: 'repair+resell' },
+  { name: 'Levi\'s SecondHand',   url: 'https://secondhand.levi.com/', accepts: "Levi's only", kind: 'resell' },
+  { name: 'For Days Take Back',   url: 'https://fordays.com/pages/take-back-bag', accepts: 'any brand, any condition', kind: 'recycle' },
+  { name: 'TerraCycle (varies)',  url: 'https://www.terracycle.com/en-US/', accepts: 'depends on local program', kind: 'recycle' },
+  { name: 'Local Goodwill',       url: 'https://www.goodwill.org/locator/', accepts: 'any wearable garment', kind: 'donate' },
+];
+
+function repairAndRecycleOptions({ brand, city } = {}) {
+  const brandLower = (brand || '').toLowerCase();
+  const brandMatch = TAKEBACK_PROGRAMS.filter(p => p.accepts.toLowerCase().includes(brandLower) && brandLower);
+  const general = TAKEBACK_PROGRAMS.filter(p => p.kind === 'recycle' || p.kind === 'donate' || /any brand/.test(p.accepts));
+  const tailorSearch = city
+    ? `https://www.google.com/maps/search/tailor+near+${encodeURIComponent(city)}`
+    : 'https://www.google.com/maps/search/tailor+near+me';
+  const cobblerSearch = city
+    ? `https://www.google.com/maps/search/shoe+repair+near+${encodeURIComponent(city)}`
+    : 'https://www.google.com/maps/search/shoe+repair+near+me';
+  return {
+    repair: { tailor: tailorSearch, cobbler: cobblerSearch },
+    takeback: [...brandMatch, ...general].slice(0, 5),
+  };
+}
+
+// --- Guardrail 3: ephemeral logging ---------------------------------------
+function logResaleEventEphemeral({ user_id, closet_id, action }) {
+  try {
+    fs.mkdirSync(DATA_DIR, { recursive: true });
+    fs.appendFileSync(EPHEMERAL_LOG, JSON.stringify({
+      ts: new Date().toISOString(),
+      user_id, closet_id, action,
+    }) + '\n');
+  } catch {}
+}
+
+module.exports = {
+  isEligibleForResaleNudge,
+  repairAndRecycleOptions,
+  logResaleEventEphemeral,
+  TAKEBACK_PROGRAMS,
+};
diff --git a/server.js b/server.js
index 0bd7f96..3ee6a9a 100644
--- a/server.js
+++ b/server.js
@@ -652,6 +652,31 @@ app.post('/api/outfits/:id/pick', (req, res) => {
   res.json({ ok: true });
 });
 
+// ---- Resale guardrails (no resale UX yet — see scripts/resale-guardrails.js) ----
+const guardrails = require('./scripts/resale-guardrails');
+
+app.get('/api/resale/options/:closet_id', (req, res) => {
+  const u = currentUser(req);
+  const c = db.prepare('SELECT * FROM closet WHERE id=? AND user_id=?').get(req.params.closet_id, u.id);
+  if (!c) return res.status(404).json({ error: 'closet item not found' });
+  const elig = guardrails.isEligibleForResaleNudge(c, req.query);
+  // Always show repair/recycle FIRST, regardless of resale eligibility
+  const options = guardrails.repairAndRecycleOptions({ brand: c.vendor_guess, city: u.city });
+  res.json({
+    repair_first: options.repair,
+    takeback_programs: options.takeback,
+    sell_nudge: elig.ok ? { allowed: true, reason: elig.reason } : { allowed: false, reason: elig.reason },
+  });
+});
+
+app.post('/api/resale/event', (req, res) => {
+  const u = currentUser(req);
+  const { closet_id, action } = req.body || {};
+  if (!closet_id || !action) return res.status(400).json({ error: 'closet_id + action required' });
+  guardrails.logResaleEventEphemeral({ user_id: u.id, closet_id, action });
+  res.json({ ok: true, persisted: 'ephemeral-only' });
+});
+
 // ---- Stripe Financial Connections (read-only) ----------------------------
 const stripeFC = require('./scripts/stripe-fc');
 

← 0908d93 tick 5: outfit-builder + /about + Stripe FC scaffold + resal  ·  back to Whatsmystyle  ·  tick 7: in-process workers + /taste + /tt-demo + monetizatio b7f1e7c →