Marketing Studio v1 — Spec
internal prototype · canonical JSON + Dreamborn Forge HTML
internal generated
spec · supabase_json

Marketing Studio v1 — Spec

spec artifact · for Marketing Studio · status draft

Planning Surface

Use this to decide what happens next.

Status

draft

open questions
idstatusquestion
OQ-1OPENX API tier: Justin's current X/Twitter API tier (Free, Basic, or Pro) must be confirmed before V1 ship. Free tier cannot POST /2/tweets. Upgrade to Basic ($100/month) required if on Free tier. scripts/lib/x-publisher.js (F-11) can be built regardless, but V1 cannot ship X auto-publish until tier is confirmed. If Free tier confirmed, X publish_queue rows default to status=failed at deploy with error_message='X API upgrade required'.
OQ-2CONFIRMEDBLOG_DEPLOY_HOOK_URL pre-ship gate: Must be set in VPS env and confirmed returning HTTP 200 on a test POST before V1 deploy. Deployment checklist item.
OQ-3CONFIRMEDNova deployment gate: MarketingHome.vue (F-02) must be live and verified in production before nova.json CAMPAIGN.json output is deployed.
OQ-4CONFIRMEDMarketing Studio project_id in Supabase: bezeliq studio_projects must have a Marketing Studio row before Nova upserts studio_artifacts. Developer building F-06 confirms or creates this row. BEZELIQ_MARKETING_PROJECT_ID env var must be set on VPS.
OQ-5CONFIRMEDoauth-1.0a npm package: F-11 requires adding oauth-1.0a to package.json. Developer confirms no dependency conflicts before starting F-11.
Agent Handoff
Start Here

spec artifact · for Marketing Studio · status draft

Completion Evidence

No explicit evidence field yet. Require tests, screenshots, linked PRs, or reviewed outputs before marking complete.

Open Questions
  • OQ-1: id: string, status: string, question: string
  • OQ-2: id: string, status: string, question: string
  • OQ-3: id: string, status: string, question: string
  • OQ-4: id: string, status: string, question: string
  • OQ-5: id: string, status: string, question: string
Structured Payload

Machine-readable source fields

phase
id

main

name

Marketing Studio v1

status

draft

depends on

No items captured.

title

Marketing Studio v1 — Spec

features
idtitledone whenscenariosdescriptionerror states
F-01Database Migrations — content_assets + publish_queue- content_assets has body column (text nullable) — schema introspection confirms - content_assets has justin_edit column (boolean NOT NULL DEFAULT false) — confirmed - publish_queue table exists with all 14 columns at correct types — confirmed - publish_queue.status defaults to 'pending' on INSERT - Supabase Realtime enabled on content_assets — verified in dashboard - Supabase Realtime enabled on publish_queue — verified - IF NOT EXISTS guards present in migration SQL - Migration committed to supabase/migrations/<timestamp>_marketing_studio_v1.sql- content_assets body column is nullable: Given migration 1 run, When INSERT omits body, Then row created with body=NULL - content_assets justin_edit defaults to false: Given migration 1 run, When INSERT omits justin_edit, Then row created with justin_edit=false - publish_queue has correct schema: Given migration 2 run, When schema introspected, Then all 14 columns present at correct types - publish_queue status defaults to pending: Given migration 2 run, When INSERT omits status, Then row created with status='pending' - Realtime delivers updates: Given migration 3 run and Realtime enabled, WhTwo additive SQL migrations run in order before any UI or agent work. Migration 1: ADD COLUMN IF NOT EXISTS body text (nullable) and justin_edit boolean NOT NULL DEFAULT false to content_assets. Neither column is read by Nova, Harper, Jade, Rosa, or Ivy today — confirmed from persona inspection. Migration 2: CREATE TABLE IF NOT EXISTS publish_queue with 14 columns. Migration 3: enable Supabase Realtime on both tables. Both migrations use IF NOT EXISTS guards. Committed to supabase/migrations/<timestamp>_marketing_studio_v1.sql.- Migration 1 run before content_assets exists — pre-check table exists before ALTER - Migration 2 run twice — IF NOT EXISTS guard prevents error - Realtime not enabled in Supabase project settings — UI polls instead; check project Replication settings
F-02Campaign View — MarketingHome.vue at /marketing- MarketingHome.vue exists at apps/bezeliq-studio/src/views/MarketingHome.vue - Route /marketing registered in router/index.js pointing to MarketingHome - Fetches latest studio_artifacts campaign row ordered by created_at desc - week_of, theme, angles, piece_slots all render from content JSON - Status badges use content_assets state machine values - Approve Campaign PATCH sets status=approved, approved_by=justin, approved_at=<iso> - Piece slot cards deep-link to /marketing/piece/:pieceId - Loading, error, and empty states all implemented - Supabase Realtime subscription on campaign artifact up- Campaign card renders all sections: Given campaign artifact with week_of, theme, angles, piece_slots, When Justin navigates to /marketing, Then page shows week_of date, theme headline, angles list, piece slot grid - Approve Campaign PATCH fires: Given artifact with status='draft', When Justin clicks Approve Campaign, Then PATCH fires with {status:'approved', approved_by:'justin', approved_at:<iso>}; CTA disables; badge updates - Piece slot click navigates: Given piece slot with piece_id='p42', When Justin clicks slot, Then router navigates to /marketing/piece/p42 - Empty state when no campaiNew Vue 3 SFA at apps/bezeliq-studio/src/views/MarketingHome.vue, route /marketing. Fetches latest studio_artifacts row with artifact_type='campaign' for bezeliq project (order by created_at desc, limit 1). Content JSON shape: {week_of: 'YYYY-MM-DD', theme: string, angles: [{id, title, channel, hook}], piece_slots: [{piece_id, angle_id, channel, scheduled_date, status}]}. Renders: week_of date header, theme headline, angles list with channel tags and hook preview, piece slot grid with status badges. One primary CTA 'Approve Campaign' — PATCHes artifact to {status:'approved', approved_by:'justi- Supabase fetch error — show 'Could not load campaign. Check your connection.' — no stuck spinner - Campaign content JSON missing expected fields — render partial data, show 'incomplete campaign data' badge — no throw - Approve PATCH fails — show inline error on CTA, do not navigate away
F-03Piece Editor — PieceEditor.vue at /marketing/piece/:pieceId- PieceEditor.vue exists at apps/bezeliq-studio/src/views/PieceEditor.vue - Route /marketing/piece/:pieceId registered in router/index.js - @tiptap/vue-3 and @tiptap/starter-kit in package.json - Editor initializes with content_assets.body (empty if NULL) - Body auto-saves on blur via PATCH - Edit & Approve PATCH: body=<html>, justin_edit=true, status=staged, staged_at=<iso> - Approve as written PATCH: justin_edit=false, status=staged, staged_at=<iso> - Both CTAs visible simultaneously; correct visual hierarchy enforced - Both CTAs disabled after either fires - Image variants from graphic rows- Body loads into Tiptap: Given is_selected=true row with body='<p>text</p>', When Justin navigates to Piece Editor, Then Tiptap renders with that text - Body auto-saves on blur: Given editor has new text, When Justin clicks outside (blur fires), Then PATCH to content_assets with body=<current html> - Edit & Approve sets justin_edit=true and staged: Given Justin edited body, When clicks Edit & Approve, Then PATCH: body=<html>, justin_edit=true, status='staged', staged_at=<iso>; both CTAs disabled - Approve as written sets justin_edit=false and staged: When Justin clicks Approve as written, TheNew Vue 3 SFA at apps/bezeliq-studio/src/views/PieceEditor.vue, route /marketing/piece/:pieceId. On mount fetches content_assets row where piece_id=routeParam AND is_selected=true. Loads body into Tiptap editor (@tiptap/vue-3 + @tiptap/starter-kit — paragraph, bold, italic only, minimal toolbar). Auto-saves body to content_assets on blur. Right pane: content_assets rows with piece_id=routeParam AND channel='graphic' AND status='staging' ordered by version — renders as selectable image thumbnails. Two simultaneous approval CTAs: (A) 'Edit & Approve' — primary, full-size, accent color — PATCH bo- No is_selected=true row — show 'Content not ready yet', not blank editor - Auto-save PATCH fails — inline save error on editor frame, do not block editing - Approval PATCH fails — error on CTA, do not navigate or clear editor - Tiptap missing — Vite build fails, caught in CI
F-04Calendar View — MarketingCalendar.vue at /marketing/calendar- MarketingCalendar.vue exists at apps/bezeliq-studio/src/views/MarketingCalendar.vue - Route /marketing/calendar registered in router/index.js - Fetches publish_queue with 14-day gte/lte filter on scheduled_at - Rows grouped by day then channel in layout - Status indicators correct: pending=gray, in_progress=amber spinner, succeeded=green check, failed=red dot, retrying=amber-pulse, manual=blue dot - Failed rows show error_message as hover tooltip - Supabase Realtime drives live updates - 14-day window navigation forward/back by 7 days - Loading and error states implemented- Calendar groups by day and channel: Given linkedin pending and x succeeded on May 6, When Justin navigates to /marketing/calendar, Then May 6 shows linkedin gray dot and x green check - Failed row shows error tooltip: Given status=failed, error_message='X API tier insufficient', When Justin hovers red dot, Then tooltip shows 'X API tier insufficient' - Retrying shows amber pulse: Given status=retrying, When Calendar renders, Then amber pulsing animation shown - Realtime live update: Given pending row visible, When Ivy updates to succeeded, Then gray dot changes to green check within 1 secondNew Vue 3 SFA at apps/bezeliq-studio/src/views/MarketingCalendar.vue, route /marketing/calendar. Fetches publish_queue rows for 14-day rolling window (today through today+13). Groups by day, then by channel within each day. Status indicators: pending=gray dot, in_progress=amber spinner, succeeded=green check, failed=red dot with error_message hover tooltip, retrying=amber-pulse, manual=blue dot (Substack only). Days with no rows show empty gray cells. Supabase Realtime subscription on publish_queue. Forward/back navigation shifts 14-day window by 7 days. Read-only view.- Supabase fetch fails — show error message, no stale data - Realtime drops — show 'Live updates paused — refresh to reconnect' banner - Failed row with no error_message — tooltip shows 'Unknown error'
F-05Router Registration — three /marketing routes- router/index.js imports MarketingHome, PieceEditor, MarketingCalendar - Routes /marketing, /marketing/piece/:pieceId, /marketing/calendar in routes array - No existing route broken- /marketing renders MarketingHome: Given router loaded, When browser navigates to /marketing, Then MarketingHome renders — no 404 - /marketing/piece/p42 resolves pieceId: Given PieceEditor.vue exists, When browser navigates to /marketing/piece/p42, Then PieceEditor renders with pieceId='p42' - /marketing/calendar renders MarketingCalendar: When navigating to /marketing/calendar, Then MarketingCalendar renders — no 404 - Existing /dev routes unchanged: When navigating to /dev or /dev/new, Then existing routes work identically to beforeThree routes added to apps/bezeliq-studio/src/router/index.js: /marketing → MarketingHome, /marketing/piece/:pieceId → PieceEditor, /marketing/calendar → MarketingCalendar. All three views imported. No existing routes modified.- /marketing/piece/ without pieceId param — falls to NotFound — acceptable
F-06Nova Persona Update — CAMPAIGN.json artifact output- nova.json persona includes studio_artifacts campaign upsert step - Content JSON shape matches spec - weekly_plan.md Drive write step retained - Nova polls campaign status='approved' before dispatching Harper/Jade - Upsert uses merge-duplicates to prevent duplicate rows - DEPLOYMENT GATE: MarketingHome.vue must be verified live before this persona is deployed- Nova upserts CAMPAIGN.json after planning: Given planning cycle completes, When Nova calls studio_artifacts POST, Then row exists with artifact_type='campaign', status='draft', content.week_of set, angles non-empty, piece_slots non-empty - Nova still writes weekly_plan.md: When task.complete fires, Then both weekly_plan.md in Drive AND studio_artifacts campaign row exist - Nova awaits approval before dispatching: Given campaign status='draft', When planning completes, Then Nova polls for status='approved' before dispatching Harper/Jade tasks - Duplicate upsert does not create second row: Givdefinitions/agents/nova.json persona updated to upsert studio_artifacts row with artifact_type='campaign' after weekly planning. Content JSON: {week_of:'YYYY-MM-DD' (Monday), theme:string, angles:[{id,title,channel,hook}], piece_slots:[{piece_id,angle_id,channel,scheduled_date,status:'planned'}]}. Upsert POST with Prefer: resolution=merge-duplicates. Status='draft'. Nova polls artifact status='approved' before dispatching Harper/Jade tasks. Nova continues writing weekly_plan.md to Drive in parallel until Campaign view verified live. DEPLOYMENT GATE: do not deploy until MarketingHome.vue is ver- studio_artifacts upsert fails — Nova post_blocked; Drive write continues regardless - Campaign not approved in 48h — Nova re-posts exec task 'campaign awaiting approval'
F-07Rosa Persona Update — Fal.ai image generation replacing Puppeteer- rosa.json plugins array does not contain 'puppeteer' - Persona describes calling generateImage(prompt, styleType) from falai.js - Style classification rule explicit: channel+angle pattern → style type - 6 variants per piece: 2 per approach × 3 approaches - Idempotency guard: skip if 6+ graphic rows status >= staging - content_assets rows: r2_url set, channel='graphic', status='staging' - DEPLOYMENT GATE: falai.js must exist and FAL_API_KEY set- Rosa selects illustrated for concept piece: Given angle='The pull model framework', When Rosa processes task, Then illustrated style selected, 6 generateImage calls, 6 content_assets rows with r2_url, channel='graphic', status='staging' - Idempotency guard fires: Given 6 graphic rows with status='staging' for piece_id, When Rosa receives task, Then Fal.ai calls skipped; task.complete with 'images already generated' - Rosa does not use Puppeteer: Given updated persona deployed, When Rosa processes graphic task, Then no Puppeteer calls, no capabilities/templates/ files read - Rosa writes r2_urdefinitions/agents/rosa.json updated. Remove 'puppeteer' from plugins. Rosa calls scripts/lib/falai.js generateImage(prompt, styleType) instead of Puppeteer. Style classification rule (deterministic, inline in persona): channel=linkedin OR angle contains product/lifestyle → photorealistic; angle contains concept/framework/model → illustrated; angle contains quote/stat/data/metric → typographic; topic contains metrics/numbers → data-viz. Fallback: illustrated. 6 variants per piece: 2 per approach × 3 approaches from available styles. Uploads to R2, writes r2_url to content_assets rows (channel=- Fal.ai non-200 — post_blocked; no partial rows written - FAL_API_KEY not set — generateImage throws; Rosa post_blocked - Style type undetermined — default to 'illustrated', log fallback in summary
F-08Ivy Persona Update — Auto-publish wiring- ivy.json includes Realtime subscription on content_assets status='staged' - publish_queue rows inserted: linkedin, x, blog, youtube (pending), substack (manual) - scheduled_at = scheduled_date + 09:00 ET - LinkedIn: createPost() from unipile.js - X: postTweet() from x-publisher.js with OAuth 1.0a - Blog: publishBlogPost() from blog-publisher.js (two-step) - YouTube V1: body confirmed, status=succeeded, no YouTube API call - Substack: status=manual, no API call - 429: exponential 15/30/60min max 3, status=retrying - 403 from X: status=failed, correct error_message, no retry - publish_queue PA- Ivy inserts publish_queue on staged trigger: Given content_assets row transitions to status='staged', When Realtime fires, Then 5 rows inserted: linkedin pending, x pending, blog pending, youtube pending, substack manual; all with piece_id, asset_id, scheduled_at - LinkedIn published via createPost: Given linkedin row pending, scheduled_at passed, When publish job runs, Then createPost called; on success: status=succeeded, published_at=<iso>, publish_url=<url> - Blog published via GitHub + deploy hook: Given blog row pending, When Ivy executes, Then GitHub PUT commits <slug>.md with frontmatdefinitions/agents/ivy.json replaced. Ivy adds Supabase Realtime subscription on content_assets status='staged'. On trigger: insert publish_queue rows — linkedin (pending), x (pending), blog (pending), youtube (pending, V1=concept-only), substack (manual, no API call). scheduled_at = content_assets.scheduled_date + 09:00 AM ET. Publish functions: LinkedIn via unipile.js createPost(); X via x-publisher.js postTweet() OAuth 1.0a; blog via blog-publisher.js publishBlogPost() (GitHub commit + deploy hook); YouTube V1 = confirm body exists then set succeeded; Substack = manual row, no call. Retry: - Realtime drops — fallback to polling - LinkedIn non-200 not 429/403 — retry to max 3 then failed - Blog step 1 succeeds, step 2 fails — status=failed; commitSha preserved in error for hook-only retry - BLOG_DEPLOY_HOOK_URL not set — publishBlogPost throws; status=failed, error_message='BLOG_DEPLOY_HOOK_URL missing from env'
F-09scripts/lib/falai.js — Fal.ai FLUX.1-schnell client- scripts/lib/falai.js exists with generateImage(prompt, styleType) named export - POSTs to https://fal.run/fal-ai/flux/schnell with Authorization: Key header and {prompt, image_size: 'landscape_16_9', num_inference_steps: 4} - Returns response.images[0].url on success - Throws with status code and body on non-200 - Throws 'FAL_API_KEY not configured' if env var absent - No retry logic in this file- Returns URL on success: Given FAL_API_KEY set, When generateImage called, Then returns string https:// URL - Throws on non-200: Given Fal.ai returns 422, When generateImage called, Then error thrown with '422' in message - Throws if FAL_API_KEY missing: Given env var absent, When generateImage called, Then error 'FAL_API_KEY not configured'New file scripts/lib/falai.js. Named export: generateImage(prompt, styleType) → Promise<string> (image URL). POST to https://fal.run/fal-ai/flux/schnell with Authorization: Key {FAL_API_KEY} header. Body: {prompt, image_size: 'landscape_16_9', num_inference_steps: 4}. Returns response.images[0].url. Throws on non-200 with status + body. Throws 'FAL_API_KEY not configured' if env var absent. No retry logic — callers handle. styleType accepted for context/logging; prompt construction is caller's responsibility.- Network timeout — fetch throws; Rosa handles via post_blocked - Response JSON missing images[0].url — throw 'Unexpected Fal.ai response shape: <json>' - Invalid FAL_API_KEY — Fal.ai returns 401 — throw with 401 in message
F-10scripts/lib/unipile.js — createPost() addition- createPost(text, mediaIds=[]) exported from unipile.js - Uses existing _post() helper to POST to {UNIPILE_API_URL}/posts/ - Body: {account_id: UNIPILE_ACCOUNT_ID, text, media_ids} - mediaIds defaults to [] - Existing three exports unchanged — confirmed by diff - No new env vars- createPost without media: Given UNIPILE env vars set, When createPost('text') called, Then POST to {UNIPILE_API_URL}/posts/ with account_id, text, media_ids=[] - createPost with media: When createPost('text', ['media-id']) called, Then POST body includes media_ids=['media-id'] - mediaIds defaults to []: When createPost(text) called without mediaIds, Then POST body has media_ids=[] — no undefined - Existing exports unchanged: Given updated unipile.js loaded, When existing functions called, Then identical behavior to beforeAdd one named export to existing scripts/lib/unipile.js. Function: createPost(text, mediaIds = []) → Promise<object>. Uses existing _post() helper: POST to {UNIPILE_API_URL}/posts/ with body {account_id: UNIPILE_ACCOUNT_ID, text, media_ids: mediaIds}. Returns full Unipile response. No new OAuth, no new env vars. Existing exports (sendConnectionRequest, sendDm, getConnectionUpdates) unchanged.- Unipile non-200 — _post() throws; Ivy's retry logic handles - UNIPILE_API_URL not set — consider adding module-load guard 'if (!BASE) throw UNIPILE_API_URL not configured'
F-11scripts/lib/x-publisher.js — X API v2 OAuth 1.0a client- scripts/lib/x-publisher.js exists with postTweet(text) named export - POST to https://api.twitter.com/2/tweets with OAuth 1.0a Authorization header using oauth-1.0a package - text.slice(0, 280) applied before sending - 429 response: throws with 429 identifiable - 403 response: throws with 403 identifiable - Other non-200: throws with status in message - Module-load guard throws if any of X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_TOKEN_SECRET absent - Code comment: 'REQUIRES X API Basic tier ($100/month minimum). Free tier cannot POST /2/tweets.' - oauth-1.0a added to package.json- postTweet succeeds on Basic tier: Given env vars set, account is Basic, When postTweet called, Then POST with OAuth 1.0a header; tweet id in response - Text truncated to 280: Given text is 350 chars, When postTweet called, Then POST body has exactly 280 chars - Throws 403 on Free tier: Given Free tier account, When postTweet called, Then error thrown with 403 identifiable - Throws 429 on rate limit: Given rate limit hit, When postTweet called, Then error thrown with 429 — Ivy applies backoff - Module throws if env vars missing: Given X_API_KEY absent, When module imported, Then error 'X_API_New file scripts/lib/x-publisher.js. Named export: postTweet(text) → Promise<object>. POST to https://api.twitter.com/2/tweets. OAuth 1.0a signed using oauth-1.0a npm package with X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_TOKEN_SECRET from process.env. Body: {text: text.slice(0, 280)}. Returns response JSON on success. Throws error with statusCode 429 on rate limit. Throws error with statusCode 403 on forbidden/tier. Throws with status on other non-200. Module-load guard throws if any of four X_ env vars absent. Code comment: 'REQUIRES X API Basic tier ($100/month minimum). Free tier c- OAuth 1.0a signing incorrect — X returns 401; use oauth-1.0a package to avoid manual signing bugs - Unicode chars counted as 2 by X — 280 slice may still exceed X limit; document as known V1 limitation in code comment - Network timeout — fetch throws; Ivy handles via retry
F-12scripts/lib/blog-publisher.js — GitHub Contents API + Cloudflare Pages deploy hook- scripts/lib/blog-publisher.js exists with publishBlogPost(slug, title, markdownBody) named export - Step 0: GET to GitHub Contents API to retrieve sha if file exists - Step 1: PUT with Authorization: Bearer, base64 content with frontmatter, message='publish: {title}', branch='main', sha if updating - Frontmatter: title, date (ISO today), layout: post, description (first 160 chars) - Step 2: POST to BLOG_DEPLOY_HOOK_URL, no body - Step 1 failure throws before step 2 - Step 2 failure throws error containing commitSha - Returns {commitSha, deployTriggered: true} on full success - Module-load gu- New post commits and triggers deploy: Given env vars set, file does not exist, When publishBlogPost called, Then step 1 PUT commits <slug>.md; step 2 POST to hook returns 200; resolves {commitSha:'<sha>', deployTriggered:true} - Existing post update includes sha: Given file already exists, When publishBlogPost called, Then step 0 GET retrieves sha; step 1 PUT includes sha — no 422 Conflict - Step 2 failure preserves commitSha: Given step 1 succeeds, BLOG_DEPLOY_HOOK_URL returns 500, When step 2 fails, Then error thrown containing commitSha from step 1 - Frontmatter prepended correctly: GivenNew file scripts/lib/blog-publisher.js. Named export: publishBlogPost(slug, title, markdownBody) → Promise<{commitSha: string, deployTriggered: boolean}>. Step 0: GET GitHub Contents API to check if file exists and get sha for update. Step 1: PUT to https://api.github.com/repos/{BLOG_REPO_OWNER}/{BLOG_REPO_NAME}/contents/{BLOG_POSTS_PATH}/{slug}.md with Authorization: Bearer {BLOG_REPO_GITHUB_TOKEN}. File content = base64-encoded markdown with Eleventy frontmatter. Step 2: POST to BLOG_DEPLOY_HOOK_URL (no body). Step 1 failure throws before step 2. Step 2 failure throws with commitSha from ste- GitHub 401 — token expired or lacks repo write scope; throw 'GitHub token unauthorized — check BLOG_REPO_GITHUB_TOKEN scope' - GitHub 422 without sha on update — prevented by step 0 GET; if GET fails and file may exist, fail fast - BLOG_DEPLOY_HOOK_URL returns 404 — hook deleted; publish_queue status=failed; PRE-SHIP GATE: test hook before V1 deploy - Empty markdownBody — frontmatter written, content empty; Ivy validates body exists before calling
open questions
idstatusquestion
OQ-1OPENX API tier: Justin's current X/Twitter API tier (Free, Basic, or Pro) must be confirmed before V1 ship. Free tier cannot POST /2/tweets. Upgrade to Basic ($100/month) required if on Free tier. scripts/lib/x-publisher.js (F-11) can be built regardless, but V1 cannot ship X auto-publish until tier is confirmed. If Free tier confirmed, X publish_queue rows default to status=failed at deploy with error_message='X API upgrade required'.
OQ-2CONFIRMEDBLOG_DEPLOY_HOOK_URL pre-ship gate: Must be set in VPS env and confirmed returning HTTP 200 on a test POST before V1 deploy. Deployment checklist item.
OQ-3CONFIRMEDNova deployment gate: MarketingHome.vue (F-02) must be live and verified in production before nova.json CAMPAIGN.json output is deployed.
OQ-4CONFIRMEDMarketing Studio project_id in Supabase: bezeliq studio_projects must have a Marketing Studio row before Nova upserts studio_artifacts. Developer building F-06 confirms or creates this row. BEZELIQ_MARKETING_PROJECT_ID env var must be set on VPS.
OQ-5CONFIRMEDoauth-1.0a npm package: F-11 requires adding oauth-1.0a to package.json. Developer confirms no dependency conflicts before starting F-11.