logicspike/docs

Content Engine

Content Engine — Backlog

Scorecard

Audited 2026-06-28. Overall: 8.7 / 10

Parameter Score Notes
API Design 9.0 10 route groups, clean REST; ops/internal/MCP routes well-separated
Data Model 9.5 10 tables, FK enforcement post-0007, 20+ indexes, append-only publish_logs, time-series analytics
Security & Auth 9.5 Gateway guard, 9 PBAC permissions, AES-256-GCM token encryption, cross-tenant ownership check on slot create
Multi-tenancy 10.0 Perfect — tenant_id on every table and every query; slot create validates connection ownership
Billing 7.5 1cr/platform/publish, idempotency on targetId; DLQ messages lose refund txId (B1)
Publishing Engine 9.0 Queue + DLQ, exponential retry (1m/5m/15m), in-flight sentinel, at-least-once idempotency
Scheduling 9.5 SlotScheduler DO per-slot; RecurringScheduler DO with cron-parser; Neon sleeps between real fire times
Analytics 7.0 Tiered polling cadence, insight rules, heatmap; LinkedIn/Meta limited by platform API tier
Platform Adapters 7.5 Twitter complete (v2 + media + metrics); LinkedIn/Meta publish works, no token refresh, limited metrics
Token Management 8.0 AES-256-GCM encryption; Twitter lazy refresh solid; LinkedIn/Meta 60-day tokens require manual reconnect
MCP Tools 8.0 15+ tools for agent scheduling; isolated via mcp-gateway; drift-check test in place
Testing 9.0 25+ test files, pglite in-memory Postgres, all major flows including approval and retry
Observability 8.0 Publish logs, ops stats endpoint, Analytics Engine dataset, activity feed with error codes
Feature Completeness 7.0 No video publishing, no Twitter threads, no blog event bus, no proactive token health

Bugs

B1 — DLQ messages lose credit refund txId

Severity: Medium
File: src/queue/consumer.ts (DLQ handler), src/lib/publish-runner.ts

Queue messages contain slotId, targetId, socialConnectionId, tenantId, correlationId — but not the credit transaction ID. When a message exhausts retries and hits the DLQ, markFailed() is called but there is no txId to call creditLedger.refund() with. Credits for dead-lettered publishes are permanently lost.

Impact: For every DLQ event, 1 credit per platform target is unrecoverable without manual reconciliation (RUNBOOK.md mentions it as accepted).

Fix: Add creditTxId to the queue message payload in fireSlot() before enqueue. DLQ handler can then call creditLedger.refund(txId) directly.


B2 — In-flight sentinel targets require manual rescue on Worker crash

Severity: Medium
File: src/lib/publish-runner.ts (step 8)

publishStartedAt is set on the slot target immediately before the platform API call. If the Worker process dies mid-call (Cloudflare eviction, OOM, unhandled throw at OS level), the target stays in publishStatus="publishing" forever — no alarm, no retry, no cleanup job.

On the next queue delivery of the same message, the in-flight guard fires (PUBLISH_INTERRUPTED), marks it failed, and acks the message. This means the target is permanently failed even though the platform call may not have gone out.

Impact: Low frequency (Worker crashes are rare on Cloudflare), but affected targets never self-heal. Ops must manually reset publishStatus to "pending" and re-enqueue to recover.

Fix: Add a cleanup job (could be a cron or a DO alarm that fires hourly) to find targets where publishStartedAt < now - 5min and publishStatus="publishing" — treat these as interrupted and reset to pending.


B3 — Pre-migration-0007 orphan data risk

Severity: Low (historical)
File: drizzle/0007_yellow_blur.sql

Before migration 0007, FK constraints were declared in Drizzle's schema type but not enforced in the actual DB (.references() without ON DELETE cascade didn't generate Postgres FK DDL). Migration 0007 adds the actual FK constraints and cleans up orphans.

Impact: Any data written between the initial deploy and 0007 apply could have orphaned rows in slot_targets or content_slot_labels. 0007 deletes them. If 0007 hasn't been applied in a non-prod environment, queries may return phantom targets without a parent slot.

Action: Verify 0007 is applied in all environments (SELECT * FROM drizzle_migrations ORDER BY id).


B4 — LinkedIn / Meta token expiry is silent until publish fails

Severity: Medium
File: src/lib/lazy-token-refresh.ts, src/adapters/linkedin.adapter.ts, src/adapters/meta.adapter.ts

Twitter tokens are refreshed proactively (within 5 min of expiry). LinkedIn and Meta issue 60-day long-lived tokens with no refresh endpoint. The only signal of expiry is a 401 on a publish attempt — at which point the target is marked failed and the connection flips to status="expired".

If a user doesn't publish for > 60 days, they won't discover the expiry until their next scheduled slot fails. There is no background health check.

Impact: Scheduled content fails silently if users don't notice the reconnect banner in the dashboard.

Fix: Add a daily DO alarm (or reuse AnalyticsScheduler DO) to check connection token expiry and emit an analytics_insights alert (kind: "alert", severity: "warning") and/or notification so users reconnect before their next publish.


B5 — AnalyticsScheduler DO may not re-arm after tenant has zero pollable posts

Severity: Low
File: src/schedulers/analytics-scheduler.do.ts

runAnalyticsPoll() returns nextDelayMs: null when there are no published targets within the 30-day window. The DO stops re-arming in that case. When the user later publishes a new slot, runPublish() calls AnalyticsScheduler /arm (idempotent) to restart the cycle.

The gap: if the /arm call fails (network error to the DO endpoint, service binding timeout), the analytics DO is never re-armed and the new publish never gets metrics collected.

Impact: Low — requires both the DO to stop AND the re-arm call to fail simultaneously.

Fix: Retry /arm call up to 3 times with 500ms delay in runPublish().


Areas of Improvement

G1 — No video publishing support

Priority: High
Platforms: All (Twitter, LinkedIn, Meta)

Video publishing requires platform-specific async flow (upload → poll until ready → publish). Twitter needs amplify_video media category + status polling. Current adapters return a non-retryable error for video content.

Effort: Medium. Twitter is well-documented. LinkedIn has uploadInstructions flow. Meta uses chunked upload API.


G2 — No Twitter thread creation

Priority: Medium
Platform: Twitter only

Multi-tweet threads require chaining reply_to_tweet_id on each subsequent tweet in the same call chain. Currently a slot can only create a single tweet. Thread content type is planned but not implemented.


G3 — No proactive token health check

Priority: Medium
Platforms: LinkedIn, Meta

See B4 above. A scheduled connection audit would catch expiry before it blocks a publish.


G4 — No blog → content auto-promotion

Priority: Low
Cross-service: blog-service → content-engine

The vision doc lists auto-creating content slots from blog.published events as a Phase 2 feature. The event bus (or service binding call) is not wired. Users must manually create social posts for each blog publish.


G5 — No bulk slot creation from CSV / template

Priority: Low
File: src/routes/slots.ts

Users planning a content calendar for a full month must create slots one at a time. There is no bulk-import route. The MCP tool bulk_schedule exists but takes an array inline (requires an agent to pre-generate the data).


G6 — Analytics heatmap doesn't produce posting-time recommendations

Priority: Low
File: src/lib/analytics-insights.ts

The best-time-to-post heatmap (engagement by hour of day) is computed and stored, but the insights engine doesn't automatically surface a "best time to post" recommendation card. Users must read the heatmap raw.

Fix: Add a weekly digest insight (kind: "digest") with top 2 recommended posting hours per platform.


G7 — No content pause on connection expiry

Priority: Medium
File: src/lib/publish-runner.ts

When a connection expires (LinkedIn/Meta token), the current publish marks the target failed and continues. Other scheduled slots to the same connection will also fail one by one as their alarms fire.

A smarter behavior: when TOKEN_EXPIRED is returned, set all pending targets for that connection to status="paused" (new state needed) and emit a single alert rather than letting them fail one at a time.


G8 — LinkedIn / Meta analytics coverage is thin

Priority: Low
File: src/adapters/linkedin.adapter.ts, src/adapters/meta.adapter.ts

Twitter returns impression counts and profile clicks for owned posts (organic_metrics). LinkedIn's public API returns very limited engagement data; Meta requires Business Suite API access for impression-level metrics. The analytics pipeline only captures what the adapters return — so LinkedIn/Meta analytics rows will have many null columns.

Note: This is a platform API constraint, not a code bug. Documents the expected gap.


G9 — Slot search not available

Priority: Low
File: src/routes/slots.ts

GET /slots takes from/to date range only — no free-text search on caption, no filter by campaign name, no filter by label name (only ?label_id=). Finding a specific old post requires knowing when it was scheduled.


Completed Phases Tracking

All phases from implementation-plan.md are now complete. Key items:

  • ✅ Phase 1: Foundation (Hono worker, Neon DB, schema, queue bindings)
  • ✅ Phase 2: Social connections (OAuth, AES encryption, 4 adapters)
  • ✅ Phase 3: Content calendar CRUD (slots, campaigns, labels, approval workflow)
  • ✅ Phase 4: Publishing engine (queue consumer, retry, DLQ, publish logs)
  • ✅ Phase 5: Recurring schedules (RecurringScheduler DO, cron-parser)
  • ✅ Phase 7: MCP tools (15+ tools, gateway isolation)
  • ✅ Analytics (AnalyticsScheduler DO, post + account snapshots, insight rules)
  • ⬜ Phase 6: Event bus integration (blog.published → auto-slot)
  • ⬜ Phase 8: Dashboard UI (tracked separately in frontend)
  • ⬜ Video publishing (G1)
  • ⬜ Twitter threads (G2)
Content Engine