← 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
M modules/engine/config.jsM modules/engine/index.jsM modules/engine/llm.jsM modules/engine/scheduler.jsM public/panels/engine.js
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 →