[object Object]

← back to Marketing Command Center

engine: contrarian fixes — YT approve defaults private, edit routed through CAS (double-post guard), TZ pinned PT, productUrl in outbound content (+bluesky budget), LLM claim-grounding

0210a80432978ecccece9c45a644fc05c594617f · 2026-07-21 15:02:09 -0700 · Steve

Files touched

Diff

commit 0210a80432978ecccece9c45a644fc05c594617f
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 21 15:02:09 2026 -0700

    engine: contrarian fixes — YT approve defaults private, edit routed through CAS (double-post guard), TZ pinned PT, productUrl in outbound content (+bluesky budget), LLM claim-grounding
---
 modules/engine/config.js    |  4 ++++
 modules/engine/index.js     | 12 ++++++++++--
 modules/engine/llm.js       |  3 +++
 modules/engine/scheduler.js | 24 +++++++++++++++++++-----
 public/panels/engine.js     |  4 ++--
 5 files changed, 38 insertions(+), 9 deletions(-)

diff --git a/modules/engine/config.js b/modules/engine/config.js
index 474e411..1d05928 100644
--- a/modules/engine/config.js
+++ b/modules/engine/config.js
@@ -8,6 +8,10 @@ const CONFIG_FILE = path.join(__dirname, '..', '..', 'data', 'engine-config.json
 
 // Per-channel default posting slots (local HH:MM). Approve defaults an item to
 // today at its suggestedSlot (drawn from these).
+// TIMEZONE: slots are authored as PACIFIC (Steve's audience-time intent). The
+// process must run with TZ=America/Los_Angeles (.env pins it; server.js loadEnv
+// applies it before any Date math) — on a UTC box without it, every slot fires
+// 7-8h off. Verified: runtime TZ set re-anchors Date correctly.
 const DEFAULTS = {
   slots: {
     instagram: ['11:00', '17:00'],
diff --git a/modules/engine/index.js b/modules/engine/index.js
index f132a92..ca1b7e9 100644
--- a/modules/engine/index.js
+++ b/modules/engine/index.js
@@ -112,7 +112,10 @@ module.exports = {
 
       const patch = {};
       patch.scheduledAt = scheduledAt || slotToISO(today(), it.suggestedSlot, { rollIfPast: true });
-      if (it.channel === 'youtube') patch.privacy = privacy || 'public';
+      // YouTube defaults PRIVATE on approve — an unread LLM-written title going
+      // public is the one irreversible mistake in this system. Promote to public
+      // deliberately via /api/channels/youtube/visibility after review.
+      if (it.channel === 'youtube') patch.privacy = privacy || 'private';
       else if (privacy != null) patch.privacy = privacy;
 
       const updated = store.transition(id, 'suggested', 'approved', patch);
@@ -154,7 +157,12 @@ module.exports = {
       if (hashtags != null) patch.hashtags = Array.isArray(hashtags) ? hashtags : it.hashtags;
       if (mediaUrl != null) patch.mediaUrl = mediaUrl;
       if (scheduledAt != null) patch.scheduledAt = scheduledAt;
-      const updated = store.upsert({ ...it, ...patch });
+      // Same-status CAS (not upsert): if the scheduler moved this item to
+      // posting/posted between our read and this write, the transition returns
+      // null instead of stomping the live status back — which would re-arm a
+      // double post. Edit never changes status; it only survives if status held.
+      const updated = store.transition(id, it.status, it.status, patch);
+      if (!updated) return res.status(409).json({ ok: false, error: 'item changed state while editing — refresh and retry' });
       res.json({ ok: true, item: updated });
     });
 
diff --git a/modules/engine/llm.js b/modules/engine/llm.js
index d6ce64f..e0202e2 100644
--- a/modules/engine/llm.js
+++ b/modules/engine/llm.js
@@ -19,6 +19,9 @@ Voice rules (HARD):
 - Say "wallcovering" — never "wallpaper" — when speaking generically.
 - No generic superlatives: never "best", "amazing", "stunning", "gorgeous", "must-have".
 - Use sensory, material language: texture, light, movement, hand, depth, sheen.
+- Describe ONLY qualities inferable from the given title, type, and tags. NEVER
+  invent materials, colorway names, origins, or construction details not present
+  in the input.
 - NEVER mention price, cost, sale, or discount.
 Per-channel voice:
 - instagram: polished and editorial, 5-8 hashtags.
diff --git a/modules/engine/scheduler.js b/modules/engine/scheduler.js
index dddd8a6..fa3c45d 100644
--- a/modules/engine/scheduler.js
+++ b/modules/engine/scheduler.js
@@ -59,19 +59,33 @@ function _sweep() {
 // ── content mapping ────────────────────────────────────────────────────────────
 // Build the channels.postLive `content` object from a queue item.
 // Caption = the caption text, then (only if hashtags exist) a blank line + the
-// hashtags joined by a single space.
+// hashtags joined by a single space. The product link is the conversion path:
+// link-friendly channels carry it in the caption (Bluesky auto-facets it into a
+// tappable link); YouTube carries it in the description. IG/TikTok captions don't
+// render clickable links, so it's omitted there rather than shipped dead.
+const LINK_CHANNELS = new Set(['bluesky', 'facebook', 'threads']);
 function buildContent(item) {
   const tags = Array.isArray(item.hashtags) ? item.hashtags.filter(Boolean) : [];
-  const caption = tags.length
-    ? [item.caption, tags.join(' ')].filter(Boolean).join('\n\n')
-    : (item.caption || '');
+  const url = item.product && item.product.productUrl;
+  // Bluesky hard-slices at 300 chars — budget the caption so the link never gets chopped.
+  let cap = item.caption || '';
+  if (item.channel === 'bluesky' && url) {
+    const budget = 300 - (url.length + 2 + (tags.length ? tags.join(' ').length + 2 : 0));
+    if (cap.length > budget) cap = cap.slice(0, Math.max(0, budget - 1)).replace(/\s+\S*$/, '') + '…';
+  }
+  const parts = [cap];
+  if (url && LINK_CHANNELS.has(item.channel)) parts.push(url);
+  if (tags.length) parts.push(tags.join(' '));
+  const caption = parts.filter(Boolean).join('\n\n');
+  let description = item.description || undefined;
+  if (item.channel === 'youtube' && url) description = [description, url].filter(Boolean).join('\n\n');
   return {
     caption,
     mediaUrl: item.mediaUrl,
     videoUrl: item.videoUrl,
     privacy: item.privacy || undefined,
     title: item.channel === 'youtube' ? item.caption : undefined,
-    description: item.description || undefined,
+    description,
   };
 }
 
diff --git a/public/panels/engine.js b/public/panels/engine.js
index 099550c..31d8195 100644
--- a/public/panels/engine.js
+++ b/public/panels/engine.js
@@ -260,9 +260,9 @@ window.MCC_PANELS['engine'] = {
           <input type="datetime-local" data-when value="${esc(slotLocalValue(it.date || todayISO(), it.suggestedSlot))}">
           ${it.channel === 'youtube' ? `<label>Privacy</label>
             <select data-privacy>
-              <option value="public">public</option>
+              <option value="private">private (review first, promote later)</option>
               <option value="unlisted">unlisted</option>
-              <option value="private">private</option>
+              <option value="public">public</option>
             </select>` : ''}
           <button class="btn gold" data-act="approve-confirm">Confirm</button>
           <button class="btn ghost" data-act="approve-cancel">Cancel</button>

← 0b73831 engine: brain (candidates+llm+generate), scheduler (CAS tick  ·  back to Marketing Command Center  ·  channels: show real Facebook Page names instead of raw IDs i 024a26b →