Last Updated: 2026-03-15 Status: Proposed
ADR-1: Service Deployment Model
Context
The Content Engine needs both synchronous API handling (CRUD, calendar queries) and asynchronous background processing (scheduled publishing, retries, token refresh).
Decision
Deploy as a single Cloudflare Worker (apps/content-engine) with:
- Hono Router for REST API (consistent with
blog-service,media-service) - Cloudflare Queues for asynchronous publish jobs
- Cloudflare Cron Triggers for periodic scheduling scans
- Durable Objects for reliable, exactly-once publish guarantees (optional — evaluate during Phase 2)
Rationale
- Keeps alignment with the existing LogicSpike Workers-based architecture
- Cloudflare Queues provide native dead-letter queue support for failed publishes
- Cron Triggers are built into Workers — no external scheduler needed
- Durable Objects prevent duplicate publishes if the Worker cold-starts mid-job
Alternatives Considered
| Option | Rejected Because |
|---|---|
| Separate Worker for publisher | Adds operational overhead; single worker with queues achieves the same isolation |
| External scheduler (e.g., AWS EventBridge) | Vendor mixing; Cloudflare Cron Triggers are free and integrated |
| Long-running Node.js process | Doesn't fit Cloudflare Workers model; no persistent processes |
ADR-2: Database Strategy
Context
Need to store content slots, social connections, campaigns, and publishing logs.
Decision
Use Neon PostgreSQL with Drizzle ORM — consistent with blog-service (which uses @neondatabase/serverless + drizzle-orm/neon-http) and manager (which uses @repo/core-database).
Dedicated database: content-engine-db with tenant isolation via tenantId column on every table (shared-schema multi-tenancy — same pattern as blog-service).
Rationale
- Follows the existing LogicSpike database strategy — both
blog-serviceandmanageruse Neon PostgreSQL - Enables reuse of the shared
@repo/core-databasepackage (or a dedicatedcreateDb()factory like blog-service) - PostgreSQL supports
jsonb, full-text search, and advanced indexing needed for calendar queries - Neon's serverless driver (
@neondatabase/serverless) is optimized for Cloudflare Workers - Drizzle ORM provides type-safe schema definitions and query building (same pattern across all services)
ADR-3: OAuth Token Management
Context
Social platform connectors require storing OAuth2 access/refresh tokens. These are high-sensitivity credentials that grant posting access to user accounts.
Decision
- Store tokens in the
social_connectionstable - Encrypt
accessTokenandrefreshTokenat rest using AES-256-GCM (matching the Communication Service pattern for provider credentials) - Encryption key stored as a Worker environment secret (
SOCIAL_TOKEN_KEY) - Proactive token refresh: a daily cron job checks all tokens and refreshes those expiring within 48 hours
Rationale
- A stale token at publish time = failed post + bad UX
- Proactive refresh avoids the "publish failed because token expired 5 minutes ago" scenario
- AES-256-GCM provides authenticated encryption — tamper detection is built-in
ADR-4: Content Slot State Machine
Context
A content slot moves through multiple states. We need a well-defined state machine to prevent invalid transitions and ensure data consistency.
Decision
┌─────────────────────────────────────────┐
│ │
▼ │
┌──────────┐ ┌───────────────┐ ┌─────────┴─┐
Create──▶│ draft │────▶│pending_review │────▶│ approved │
└──────────┘ └───────┬───────┘ └──────┬────┘
▲ │ │
│ (reject) (schedule)
│ │ │
└───────────────────┘ ▼
┌──────────┐
│scheduled │
└────┬─────┘
│
(due time)
│
▼
┌──────────┐
┌────▶│publishing│
│ └──┬───┬───┘
│ │ │
(retry) │ │
│ │ │
│ (fail) (success)
│ │ │
│ ▼ ▼
│ ┌──────┐ ┌──────────┐
└──│failed│ │published │
└──────┘ └──────────┘Transitions:
| From | To | Trigger | Permission |
|---|---|---|---|
draft |
pending_review |
User submits for review | content:slots.write |
draft |
scheduled |
Direct schedule (no approval) | content:slots.write |
pending_review |
approved |
Reviewer approves | content:slots.approve |
pending_review |
draft |
Reviewer rejects | content:slots.approve |
approved |
scheduled |
Auto-transition on approval | System |
scheduled |
publishing |
Cron trigger at due time | System |
publishing |
published |
All platforms succeed | System |
publishing |
failed |
Platform API error (after retries) | System |
failed |
draft |
User edits and retries | content:slots.write |
Rationale
- Explicit states prevent ambiguity ("Is this post going out or not?")
- Permission-gated transitions integrate with LogicSpike's PBAC system
- Mirrors real agency workflows where content is created by juniors and approved by managers
ADR-5: Platform Adapter Pattern
Context
Each social platform has a unique API, media requirements, and character limits. We need a clean abstraction.
Decision
Use a Strategy Pattern with a common PlatformAdapter interface:
interface PlatformAdapter {
platform: SocialPlatform;
validateContent(slot: ContentSlot): ValidationResult;
uploadMedia(media: MediaAsset, token: DecryptedToken): Promise<string>; // returns platform media ID
publish(slot: ContentSlot, token: DecryptedToken): Promise<PublishResult>;
refreshToken(connection: SocialConnection): Promise<TokenPair>;
}Each platform gets its own adapter file: twitter.adapter.ts, linkedin.adapter.ts, meta.adapter.ts.
Rationale
- Adding YouTube support later = creating one new file implementing
PlatformAdapter - Content validation (e.g., "Twitter max 280 chars") is co-located with the platform logic
- Consistent with Communication Service's
ProviderRegistrypattern
ADR-6: Cross-Service Integration via Event Bus
Context
The Content Engine needs to react to events from other services (e.g., blog.published) and emit its own events.
Decision
- Consumer (optional): Listen for
blog.publishedon the Event Bus → auto-create content slots (only when Blog Engine is active) - Producer: Emit
content.scheduled,content.published,content.failedevents - Use Cloudflare Queues as the event bus mechanism (consistent with platform direction)
Rationale
- Decoupled architecture — the Blog Engine doesn't know the Content Engine exists
- Future services (Newsletter, Automation Engine) can subscribe to content events without any changes to this service
- Aligns with LogicSpike Platform Section 4: "The Central Event Bus"
ADR-7: MCP Server for Agent Control
Context
The Content Engine should be operable by AI agents (Claude, Gemini, custom automation agents) — not just via a human dashboard. We need a protocol that allows agents to semantically interact with the service.
Decision
Expose the Content Engine as a Model Context Protocol (MCP) server with:
- 15+ MCP tools mapping 1:1 to REST API actions (schedule, reschedule, cancel, etc.)
- MCP Resources for read-only context (calendar state, connection health, stats)
- Bulk operation tools (
bulk_schedule,bulk_action) optimized for agent workflows - Dual transport: SSE (for remote agents) + stdio (for local agent frameworks like Claude Desktop)
- Deployed as:
packages/content-engine-mcp/— a standalone Node.js process wrapping the REST API
Architecture
AI Agent → MCP Protocol (SSE/stdio) → Content Engine MCP Server → REST API → Content Engine WorkerThe MCP server is a thin wrapper — it translates MCP tool calls into REST API calls with proper authentication. This means:
- The REST API remains the single source of truth
- MCP server can be deployed independently
- Adding new MCP tools = adding new REST routes + MCP tool definitions
Auth Strategy
- Agents authenticate via API Keys (
ls_prefix secret keys) - Each API key has PBAC-scoped permissions — an agent can only do what its key allows
- Every agent action includes an
agentIdin the audit trail
Safety Guards
autoApprovedefaults tofalse— agents must explicitly opt into bypassing review- Rate limits: max 50 slots per
bulk_schedule, max 200 slots/hour per agent - Optional
dryRun: trueflag for previewing actions without executing
Rationale
- MCP is an open standard — works with Claude, Gemini, and any custom agent framework
- Semantic tools are richer than raw REST — agents get descriptions, validation, and structured responses
- Aligns with LogicSpike Phase 3 vision — the Automation Engine will orchestrate multiple MCP servers
- Thin wrapper pattern keeps the architecture simple — no duplicate business logic
Alternatives Considered
| Option | Rejected Because |
|---|---|
| Direct REST API for agents | Agents need semantic tool descriptions, not raw endpoint docs |
| GraphQL for agents | MCP is purpose-built for agent interaction; GraphQL adds query complexity |
| Custom WebSocket protocol | Non-standard; MCP is an industry-standard protocol |
| Embedding MCP in the Worker | Workers have limited execution time; MCP needs a persistent process for SSE |
Full Specification
See mcp_server_spec.md for complete tool definitions, resources, agent personas, and rate limits.