logicspike/docs

Engagement

Reader Engagement — Vision & Architecture

What this is: the product vision and the target architecture for reader engagement on Vlozi — view counts, reads, reactions, ratings, comments, and the reader identity that sits underneath them. It is not an implementation plan; effort, file-level tasks and test plans come later. Created: 2026-09-04 · Owner: Platform / Blog · Status: vision agreed, architecture agreed for Milestone 1; nothing built yet. Decisions locked 2026-09-04: (1) a new engagement-service worker owns all reader engagement; (2) Milestone 1 is views + reads only; (3) the hosted blog (apps/blog-host, ../blog/hosted-blog-plan.md) is dropped — site-sync is the publish-to-live path. Related: ../blog/backlog.md (B5, G2), ../blog/sdk-security-model.md, ../forms/architecture.md, ../guides/engineering-standards.md, ../billing/CREDIT-STANDARDS.md


0. TL;DR

  • Tenants asked for view counts, ratings and comments on their blog posts. Blog today is read-only and the platform has no concept of a "reader", so there is nothing to extend — this is new capability.
  • The ask is really two problems: telemetry (views, reads, popular posts) and user-generated content (ratings, reactions, comments). Both will be wanted beyond blog — Collections product reviews, FAQ "was this helpful", later store reviews.
  • We build one new worker, engagement-service, with its own Neon database and a forms-style public door at /engagement/e/{site_id}/…. It counts and stores against a generic target key (post:<id>, entry:<id>) and never needs to know what a post is.
  • Blog changes are deliberately tiny: expose the post id in the public payload, add one internal "does this post exist" route, and make the blog_get_analytics MCP tool return real numbers instead of hardcoded zeros.
  • Milestone 1 = views + reads. Reactions/ratings, comments + moderation, verified members, and Collections reviews follow once demand is validated.
  • Every piece reuses something proven in this codebase: forms' anonymous write pipeline, newsletter's magic-link plumbing, the billing accumulator, per-tenant Durable Object alarms, and the chat-engine haiku classifier.

Part I — Vision

1. Why

1.1 The request

"If a client wants to show how many views a post has, add a rating, and let people comment — where does that live, and what is the best solution?" The founder is in product-building phase and wants the whole reader-engagement surface thought through, not just the three features.

1.2 Two problems wearing one hat

Telemetry User-generated content (UGC)
Examples views, reads, scroll depth, referrers, popular posts, milestones comments, replies, star ratings, reactions, polls, bookmarks
Volume high — one event per page view low — a fraction of readers ever interact
Correctness approximate is fine; must be durable exact counts; must be moderated
Identity none needed; privacy posture matters needed for dedup, bans, replies, notifications
Public write? yes — a beacon yes — a form post
Public read? yes — a counter yes — a comment list
Billing free within plan caps metered, never for spam

They share a public door, a target model, an anti-abuse pipeline and a dashboard — which is why they belong in one service — but they must never share a write path: a comment insert must not queue behind a beacon flood, and vice versa.

1.3 Jobs to be done

  • "Show social proof on my posts." A view count next to the title, a rating badge on cards.
  • "Know what is working." Which posts are read to the end, what is trending this week, where readers come from.
  • "Let readers talk to me without becoming a full-time moderator." Comments that are safe by default, an inbox that clears in seconds, replies that email the reader.
  • "Turn readers into an audience." A commenter becomes a contact, a newsletter subscriber, a member.
  • "Ask my copilot." "What was my best post this month?" answered from real numbers.

2. Who it is for

Persona What they touch What they care about
Reader the tenant's site: counters, stars, comment box zero friction, no login wall, no cookie banner, their comment lands
Owner / creator dashboard Blog › Analytics, Blog › Comments, settings social proof, what works, safe by default, inbox cleared fast
Editor / moderator the comments inbox, replies, bans fast triage, bulk actions, a clear reason why something was held
Developer @vlozi/blog/engage, embed.js, the raw API three lines to add, no secret in the browser, works on static sites
Agent (MCP, Flows, copilot) engagement.* tools and triggers real numbers; automations like "toxic → reject", "comment → contact"

3. Product principles

  1. Zero friction by default. Reading is anonymous. Reacting needs nothing. Commenting needs a name and an email, not an account.
  2. No consent banner. No cookies, no client identifier for passive readers, nothing IP-derived persisted for views. This is a marketing point as much as a legal one.
  3. Safe by default, owner in control. First-time commenters are held, spam never notifies or charges, AI classification fails to pending, never to publish.
  4. Never charge for junk. Comments are metered when published, not when submitted.
  5. Generic across modules. A target is type:id. Blog is the first client; Collections is the second.
  6. Reuse before invent. Every component below names the existing code it copies.

4. The engagement surface

Tiering is about order, not value. Each row names what it reuses so the architecture stays honest.

4.1 Must — the core

Feature Why Reuses
View counts — public counter, dashboard series, top posts the #1 ask; social proof; fixes backlog B5/G2 forms views.ts idiom, per-tenant DO, AE wrapper
Reads / read-depth — dwell ≥ 30s and scroll ≥ 50% "did they finish?"; the analytics stub already promises reads same beacon, one extra field
Star rating 1–5 and "Was this helpful?" low-friction feedback; helpful/not is the bridge to Collections FAQ eng_reactions unique index
Reactions — like / love / insightful zero-friction engagement, cheap to store same table
Comments with one-level replies and owner replies the #3 ask; an owner reply turns a comment into a conversation forms submit pipeline, notify.ts, forms inbox UI
Moderation queue — pending / published / spam / rejected, bulk, internal note table stakes for UGC forms status + PATCH + bulk semantics, InboxView
Per-post toggles and close after N days WordPress parity eng_targets.comments_mode, eng_settings.close_after_days
Spam defence — honeypot, Turnstile, rate limits, link/velocity heuristics, first-time hold comments are unusable in week one without it forms submit, turnstile.ts, native limiters
AI spam / toxicity classification one cheap haiku call removes most manual triage intent.ts
Batch summary endpoint + SDK hooks/components + a BlogPost.renderFooter slot the delivery surface for React sites BlogCard.footer, slots.ts
Worker-served embed.js delivery for every non-React site — plain HTML, Astro, Hugo, WordPress themes forms embed.ts, website newsletter mount pattern
Dashboard tabs Blog › Analytics and Blog › Comments where tenants see it; no new sidebar module yet service-registry.ts nav, blog proxy pattern
blog_get_analytics returns real data agents and Flows stop reasoning on fabricated zeros stub + service binding
Public id on the post payload an immutable key that survives slug edits the list query already selects id

4.2 Should — the audience loop

Feature Why Reuses
Owner digest email (hourly / daily) + instant on a post's first comment engaged creators without per-comment noise notify.ts, comms, DO pending_notifications
Weekly engagement email — "your top posts this week" a habit loop for owners at near-zero cost same digest path + eng_target_daily
Flows triggers + nodes — moderate / reply / ban / close comments "toxic → reject + notify me", "comment → create contact", "post hits 1k views → re-share" triggers.ts, nodeMeta
Comment → contact sync the commenter becomes a person in Contacts a Flow: engagement.comment.published → contacts.create_contact
Comment → newsletter nudge — "notify me of replies / subscribe" commenters become subscribers widget posts to the existing /newsletter/f/:id door
Popular / trending endpoint + useTrendingPosts sidebar widget; "most read this week" block eng_target_daily sums + eng_targets title/slug snapshot
Copilot access to engagement "what was my best post this month?" from data the engagement.* MCP tools registered for brain
Reader flagging — "report" community-assisted moderation eng_flags
CSV export and GDPR member delete / anonymise data ownership and compliance forms export_submissions, admin route
JSON-LD commentCount / interactionStatistic on BlogPosting SEO signal. AggregateRating on articles is not a Google rich-result type and can be penalised; reserve it for Collections Product reviews website page already emits BlogPosting
Email-verified members (magic link) higher-trust comments, profiles, "members only" mode newsletter hmac.ts, confirmation.ts, cooldown
Sentiment / rating analytics per post "which posts land?" ai_label aggregate + rating_sum/count
Webhooks on comment events developer integration path forms lib/webhook.ts HMAC delivery
Platform-wide abuse blocklist repeat spammers hit many tenants; nothing exists today new table managed from apps/internal-api
Campaign attribution — utm / referrer host "which newsletter drove reads?" AE blobs already carry ref_host

4.3 Could — the long tail

Feature Why Reuses
Member profiles, "my comments", edit own comment (5-minute window) community feel eng_members, edit_token HMAC
Bookmarks / reading lists retention; needs members eng_reactions kind:bookmark
Polls inside posts engagement blocks eng_polls / eng_poll_votes, same door and limiter
Reader Q&A — "ask the author" a comment with kind: question and an "answered" state eng_comments + one column
Share counts — navigator.share, copy link cheap vanity metric beacon e: share
CTA click tracking inside posts the vision doc's "CTA click-through" beacon e: click
"What people are reading now" live social proof (already in integration-friction-vision.md) DO 5-minute window
AI "what readers are saying" summary owner insight; billable summarize_comments via chargedCall
Import comments from WordPress / Disqus export migration path, like blog's bulk Markdown import admin upload → eng_comments
Referrer / country / device breakdown marketing insight AE SQL API read path
Comment SSR via site-sync crawlable comments on ISR sites new SyncSource "engagement" → site-sync notify
Markdown-lite in comments nicer formatting a separate strict renderer; never BlogContent
"Most read this week" block in newsletter templates cross-module loop a template block reading /popular

4.4 Deliberately not doing (for now)

  • Social login for readers — email magic link covers the trust need; revisit with Members.
  • Threads deeper than one level — flat plus one reply level covers most blogs and keeps rendering simple.
  • Rich HTML in comments — plain text with client-side linkification only; the SDK security model forbids anything else.
  • Real-time comment streams — polling with a 30s cache is enough for blog cadence.
  • Paywalls / paid memberships — a natural evolution of Members, but a different product.
  • A Vlozi-hosted blog surface — dropped 2026-09-04 in favour of site-sync.

5. How it grows

Phase What ships What it unlocks
1 · Views + reads (Milestone 1, decided) beacon, per-tenant counter DO, public summary, Blog › Analytics tab, id in the public payload, real blog_get_analytics, SDK engage entry, minimal embed.js social proof on every site; the first real analytics blog has ever had
2 · Reactions + ratings like / helpful / 1–5 stars, per-visitor dedup, rating badges on cards, JSON-LD helper feedback without moderation cost
3 · Comments + moderation guest comments, one-level replies, owner replies, the full spam pipeline, AI classification, the inbox, digests, metering, engagement.* MCP tools and Flows triggers conversation, and the audience loop through Flows
4 · Members email-verified readers, stateless sessions, "verified" and "members only" modes, profiles, GDPR delete trust, and the foundation for bookmarks and reading lists
5 · Cross-module Collections targets (product reviews, FAQ helpfulness), standalone @vlozi/engage, AE breakdowns, "reading now", optional SSR comments through site-sync, own dashboard module engagement as a platform capability, not a blog feature

Milestone 1 creates only the tables it needs; the rest of the schema below is the agreed shape for later phases.


Part II — Architecture

6. Ground truth that shapes the design

Verified against the code on 2026-09-04. Paths are relative to logicspike/.

6.1 Blog today is a publishing engine, not an audience engine

  • Five tables only — blog_posts, blog_categories, blog_tags, blog_post_tags, blog_overview_snapshots (apps/blog-service/src/db/schema.ts). No views, analytics, comments or reader tables.
  • Analytics is a stub. blog_get_analytics returns zeros for every post (apps/blog-service/src/mcp/routes.ts:1262-1294); the backlog scores Analytics 2.5/10 (B5, G2).
  • The public payload has no id (src/routes/public/posts.ts:302-315), only slug — and slugs change on title edits with no redirect table (B6/G3).
  • Public reads are edge-cached 60s at the gateway (apps/gateway/src/utils/cache.utils.ts) plus 60s in the SDK; a live counter cannot ride on that payload.
  • Already wired: a producer to vlozi-events, a PostScheduler Durable Object, an Analytics Engine dataset (METRICS, ops only), and token-guarded /internal routes.
  • SDK @vlozi/blog 2.1.7: read-only; BlogPost has no below-content slot; BlogCard already has footer and renderMeta slots; the React entry is capped at 25 KB brotli. docs/blog/sdk-security-model.md:122 says comment HTML must not go through BlogContent.

6.2 No browser can write to blog today — by design

Two independent, deliberate locks: gateway CORS on /blog/public/* allows GET/OPTIONS only (apps/gateway/src/index.ts:61-66), and publishable pk_* keys are read-only at the middleware, failing closed after a real incident (apps/gateway/src/middleware/api-key.middleware.ts:155-159).

The lesson the platform already learned: a public write door must not use API keys at all. Forms, Newsletter and Collections each use an unguessable resource id in the URL as the credential, derive the tenant from that row, and get a CORS block whose allowHeaders is ["Content-Type"] only — so a browser physically cannot attach a key through that door. The gateway also strips SPOOFABLE_IDENTITY_HEADERS on every inbound request.

6.3 What we reuse

Need Existing piece Path
Anonymous public write pipeline opaque id → negative-cached row → originAllowed() → native rate limit before body parse → size caps → honeypot → Turnstile → zod → credit gate → insert → events → waitUntil side-effects apps/forms-service/src/routes/public/submit.ts
Embeddable widget vanilla embed.js, no build step, stranger-safe error copy apps/forms-service/src/lib/embed.ts
Daily counter form_views_daily upsert in waitUntil apps/forms-service/src/lib/views.ts
Sub-credit metering accumulateAndMaybeSettle + a 3-method UsageStore over the service's own table packages/core-billing/src/accumulator.ts, forms lib/credits.ts
Plan caps mirrored core tables, 5-min cache, fail-open apps/forms-service/src/services/limits.ts
Moderation inbox UI one shared InboxView apps/seller-dashboard/src/modules/forms/inbox/
Owner email comms /internal/send via binding apps/forms-service/src/services/notify.ts
Magic-link identity HMAC sign/verify; tenant-attributed confirmation mail; 15-min per-address cooldown apps/newsletter-service/src/lib/{hmac,confirmation,subscribe}.ts
Strict origin check for writes rejects a missing Origin when an allowlist exists apps/newsletter-service/src/routes/widget.route.ts
Per-tenant scheduled work AnalyticsScheduler DO: idempotent arm, re-arm, stand-down apps/content-engine/src/schedulers/analytics-scheduler.ts
Write-absorbing buffer SessionStore DO: SQLite working set, flush on idle/threshold apps/chat-engine/src/session-store.ts
Cheap AI classification regex fast path → haiku, temp 0, maxTokens: 8, never throws apps/chat-engine/src/lib/intent.ts
Contact from an anonymous visitor binding POST /contacts with external_id, channel apps/chat-engine/src/routes/widget.route.ts
Public-door gateway proxy no-auth forward for /f/*, JWT chain for /admin/* apps/gateway/src/routes/forms.proxy.ts

6.4 Platform ceilings

  • The account is at both its Cloudflare Queues limit and its 5-cron-trigger limit (apps/site-sync/src/revalidator.ts:22-26). Publishing to vlozi-events is fine; a new queue or consumer is not. Durable Object alarms are the sanctioned scheduler.
  • No shared reader identity exists. The chat widget's visitor id is client-supplied; there are no cookies anywhere; contact-intelligence keys contacts on (tenant_id, external_id, channel).
  • Collections is D1, public read-only, relations only inside its own database — no fit for UGC or cross-service targets.
  • No cross-tenant abuse tooling exists in internal-ops.

6.5 Where tenant sites live

  • apps/website is a static export; anything dynamic is client-fetched. Its newsletter widget is the client-island pattern to copy.
  • apps/site-sync notifies a tenant's own site on publish (webhook or deploy hook). There are no external CMS adapters.
  • With the hosted blog dropped, every tenant page is static or ISR: engagement data is always fetched client-side, or later re-rendered via a site-sync notification.

7. Options considered and the decision

Option Verdict Reasoning
A. Reuse Collections Reject D1, public read-only, relations cannot point at a blog post, draft/published is not a moderation model, no spam pipeline, no anonymous write door.
B. Reuse Forms Reject as a product, copy as a template Submissions are private by design; comments must be publicly readable. No threading, no per-target aggregation, no rating math. But its write pipeline is ~80% of a comment POST.
C. Extend blog-service — a /blog/e/* door plus generic target_key tables in blog's DB Viable fallback Saves some plumbing and gives same-DB joins. But it bolts a "tenant is the output of a row lookup" trust model onto a worker built around trusted headers, puts a UGC flood path (Turnstile, LLM, inserts) on the editor's isolate and database, and is not reusable for Collections reviews.
D. New engagement-service — own worker + Neon DB, generic target = {type, id} Chosen One public door, one anti-abuse pipeline, one identity model, one moderation inbox, one analytics store — shared by blog, collections and later store. The forms/newsletter trust model. Matches the wider ambition.

The honest case for C: the key/CORS constraint is a gateway-door problem, not a placement problem; blog could mount /e/* before its tenant guard. Views would be a same-DB column. G2 already sketches analytics inside blog. Extraction later is possible.

Why D still wins: trust-model hygiene (blog's middleware assumes the tenant arrives in a trusted header, apps/blog-service/src/index.ts:206-264), blast radius (comments + identity + moderation + AI is several times blog's public surface), real reuse (Collections reviews need the identical machinery, and with no new queue consumer possible that reuse will be over HTTP either way), and a small cost difference confined to initial plumbing.

Amendments that shrink the new-service tax: defer the MCP namespace, the engagement:* permission group, the dashboard sidebar entry and Flows triggers to the comments phase; Phases 1–2 surface as tabs inside Blog and gate on accessMiddleware("engagement") at the gateway; the worker skeleton is a copy of forms-service; the SDK ships as a @vlozi/blog/engage entry with its own size budget until a non-blog consumer exists.

8. Architecture overview

Reader browser  (SDK island on the tenant's React site, or embed.js on any other site)
   │  POST /engagement/e/{site_id}/beacon          text/plain body → no preflight, sendBeacon-safe
   │  GET  /engagement/e/{site_id}/summary?t=…     30s cache, batch of up to 50 targets
   │  POST /engagement/e/{site_id}/reactions | comments | members/*
   ▼
Gateway ── CORS origin:* · allowHeaders:[Content-Type] · no auth · edge cache for GETs ──▶ engagement-service (Worker)
                                                                                         │ tenant := eng_sites[site_id]      ← never from a header
                                                                                         │ originAllowed → rate limit → size caps → honeypot → bans
                                                                                         │   → Turnstile → heuristics → AI → plan cap → credit gate
                                                                                         ├─▶ EngagementCounter DO  (engage-${tenantId})
                                                                                         │      buffers view/read/reaction deltas, per-day uniques set,
                                                                                         │      "reading now" window, pending notification digests
                                                                                         │      └─ alarm every 60s → one batched upsert into
                                                                                         │         eng_target_daily + eng_target_stats → milestone events
                                                                                         ├─▶ Analytics Engine dataset engagement_events (raw events; breakdowns later)
                                                                                         ├─▶ Neon engagement DB (sites, settings, targets, stats, reactions,
                                                                                         │      comments, members, bans, flags, usage accumulator)
                                                                                         ├─▶ BLOG_SERVICE binding    GET /internal/targets/:postId   (verify + title/slug snapshot)
                                                                                         ├─▶ COMMS_SERVICE binding   /internal/send                  (digests, magic links)
                                                                                         └─▶ EVENTS_QUEUE producer   engagement.*                    (Flows automations)
 
Seller dashboard (Blog › Analytics, Blog › Comments) ──JWT──▶ /engagement/admin/*
blog-service `blog_get_analytics` MCP tool ──ENGAGEMENT_SERVICE binding──▶ /internal/stats
(later, optional) engagement.comment.published ──▶ site-sync /internal/notify   → ISR pages re-render comments server-side for SEO

8.1 Principles the design rests on

  1. Natural target key. post:<post_id>, entry:<entry_id>, page:<url-hash> is the join key everywhere. No synthetic target id, so the beacon and summary hot paths never do a lookup. Every query still carries tenant_id.
  2. Tenant is the output of a row lookup. The site_id in the URL is a public capability, like a form id: random, scoped to one tenant's engagement surface, revocable by pausing the site. It appears in page source by design.
  3. Postgres counters are the source of truth. Durable, exact-ish, queryable forever. Analytics Engine is a write-only sidecar until a dashboard needs referrer/country breakdowns — it has roughly three months of retention and adaptive sampling, and reading it needs an account API token in the worker.
  4. A per-tenant Durable Object absorbs writes from day one. It is also where uniques dedup, milestone detection, "reading now" and notification digests live; retrofitting it later would change the hot write path under load. If the DO call fails, fall back to a direct daily upsert, best-effort.
  5. Blog gains a public id. Additive and non-breaking. Sites built before the change get a slug-fallback resolver.
  6. Engagement pulls, blog does not push. Engagement verifies a target over a service binding with a TTL; blog keeps zero hard dependency on engagement. An optional push on unpublish or slug change can come later.
  7. No cookies. No client id for passive readers. Uniques come from a daily-rotating salted hash that never leaves the DO. A client vid exists only after the first interaction.

9. Data model

Conventions from blog and forms: prefixed text ids, tenant_id on every table and in every WHERE, deleted_at soft delete on UGC, partial unique indexes on live rows, tenant-first composite indexes. Milestone 1 creates only the first five tables.

eng_sites                 id "site_<uuid>" PK   ← the public credential (forms' form_id role)
                          tenant_id · name ("Production", "Staging") · allowed_origins jsonb[] default []
                          turnstile_site_key · settings jsonb {theme?} · status active|paused · created/updated/deleted_at
                          UNIQUE(tenant_id, name) WHERE deleted_at IS NULL · INDEX(tenant_id)
 
eng_settings              tenant_id PK
                          comments_mode off|guest|verified|members_only (default guest)
                          moderation_mode all|first_time|none (default first_time) · hold_link_threshold smallint default 2
                          close_after_days int null · ai_moderation bool default true
                          reactions_enabled jsonb[] default ["like"] · ratings_enabled bool · views_public bool default true
                          display jsonb {hideViewsBelow:10, hideRatingBelow:3}
                          notify jsonb {mode digest_daily|digest_hourly|instant|off, to:[]}
                          banned_words jsonb[] · blocked_email_domains jsonb[] · updated_at
 
eng_targets               PK(tenant_id, target_key)
                          type post|entry|page · external_id · title · slug · url
                          status verified|missing|unpublished · verified_at
                          comments_mode inherit|open|closed|hidden · closed_at · first_seen_at · last_seen_at
                          INDEX(tenant_id, type, status) · INDEX(tenant_id, last_seen_at)
 
eng_target_stats          PK(tenant_id, target_key)          ← the public summary read: one indexed row
                          views bigint · reads bigint · uniques_est bigint
                          rating_sum int · rating_count int · reactions jsonb {like:n,…}
                          comments_count int (published only) · last_activity_at · updated_at
                          INDEX(tenant_id, views) · INDEX(tenant_id, last_activity_at)
 
eng_target_daily          PK(tenant_id, target_key, day)     ← forms' form_views_daily, generalised
                          views · reads · uniques · reactions · ratings · comments · INDEX(tenant_id, day)
 
eng_reactions             id "rxn_…" · tenant_id · target_key · kind (like|love|helpful|rating…) · value smallint (1..5 for rating)
                          visitor_key · member_id? · created/updated_at
                          UNIQUE(tenant_id, target_key, kind, visitor_key) · INDEX(tenant_id, target_key, kind)
 
eng_comments              id "cmt_…" · tenant_id · target_key · client_id (retry idempotency; UNIQUE per tenant)
                          parent_id → eng_comments · root_id · depth smallint (cap 1 in v1)
                          author_kind guest|member|owner · member_id? · author_user_id? (owner replies, from x-user-id)
                          author_name · author_email? · author_email_hash
                          body text (plain, ≤5000 chars, NEVER HTML)
                          status pending|published|rejected|spam|deleted
                          hold_reason first_time|links|ai_flag|banned_word|velocity|captcha|honeypot|manual
                          ai_label · ai_confidence · ip_hash (HMAC, purged after 30d) · ua_class desktop|mobile|bot
                          meta jsonb {page, referer_host, source} · visitor_key · likes_count · reply_count
                          note (internal moderator note) · published_at · moderated_at · moderated_by · timestamps · deleted_at
                          INDEX(tenant_id, target_key, status, created_at)   public list
                          INDEX(tenant_id, status, created_at)               inbox
                          INDEX(tenant_id, author_email_hash, status)        "previously approved"
                          INDEX(root_id, created_at) · INDEX(tenant_id, ip_hash)
 
eng_members               id "mem_…" · tenant_id · email · email_hash · display_name · avatar_url
                          verified_at · last_verify_sent_at · verify_nonce
                          session_version int default 1 (bump = revoke every session) · status active|banned
                          comments_count · last_seen_at · timestamps · deleted_at
                          UNIQUE(tenant_id, email_hash) WHERE deleted_at IS NULL
 
eng_bans                  id · tenant_id · kind email|email_domain|ip_hash|visitor_key|member · value · reason · created_by · expires_at
                          UNIQUE(tenant_id, kind, value)
 
eng_flags                 id · tenant_id · comment_id · visitor_key · reason · created_at · UNIQUE(comment_id, visitor_key)
 
tenant_engagement_usage   tenant_id PK · accumulator_thousandths · settlement_seq · updated_at   (copy of tenant_forms_usage)

9.1 What lives where

Data Store Why
All-time and daily counters Postgres eng_target_stats, eng_target_daily the public counter must be durable, exact-ish and permanent
In-flight deltas, per-day seen(target, visitor_hash), 5-minute "reading now" window, pending digests per-tenant DO SQLite (engage-${tenantId}) absorbs writes, dedups, flushes in one batch; pruned daily; no per-visitor rows ever reach Postgres
Every raw event with ref_host, country, device, depth Analytics Engine engagement_events (index = tenantId; blobs [event, target_key, ref_host, country, device, source]; doubles [1, depth]) rich breakdowns later; approximate; ~3-month retention
Comments, members, settings, bans, targets Postgres UGC and configuration

9.2 Idempotency

  • Reactions: the unique index; a repeat upsert replaces value and adjusts stats by the delta.
  • Comments: a widget-generated client_id; a retried POST returns the existing row.
  • DO flush: "read deltas → upsert add → zero deltas" inside one DO transaction, so an alarm retry after a crash cannot double count.
  • Settlement: the accumulator's settlementSeq compare-and-set, exactly as forms does today.

10. API surface

10.1 Public door /engagement/e/*

Gateway: cors({ origin: "*", allowMethods: ["GET","POST","OPTIONS"], allowHeaders: ["Content-Type"] }), no auth, forwarded like /forms/f/*. Inside the worker the request is still x-gateway-key-gated and the tenant is resolved from the eng_sites row.

Route Purpose Cache
GET /e/embed.js vanilla widget, registered before param routes 300s
GET /e/:site/config public feature flags, comments mode, Turnstile site key, reaction kinds 300s
POST /e/:site/beacon text/plain JSON {t, e: "view"|"read"|"share", p, r, d, s} → 204 no-store
GET /e/:site/summary?t=post:a,post:b,… (≤ 50) {items: {"post:a": {views, reads, rating: {avg, count}|null, reactions: {like: n}, comments: n, commentsOpen}}}; omits views below hideViewsBelow 30s + SWR 120s + ETag
POST /e/:site/reactions {t, kind, value, vid, client_id}; value: 0 removes —
GET /e/:site/comments?t=&cursor=&sort=newest|oldest|top published only; roots plus first-level replies 30s + SWR
POST /e/:site/comments {t, body, parent_id?, name, email, vid, client_id, session?, "cf-turnstile-response"?, website} (honeypot) → {ok, status: "published"|"pending", comment?, edit_token?} —
POST /e/:site/comments/:id/flag reader report —
POST /e/:site/comments/:id/edit own edit within 5 minutes via edit_token —
POST /e/:site/members/verify-request {email, name, vid, return_to} → generic ok (never reveals whether the email exists) —
GET /e/:site/members/verify?token= exchange the magic link for a session; redirect only to an origin in allowed_origins —
POST /e/:site/members/me / logout {session} in the body — this door cannot carry custom headers —
GET /e/:site/popular?range=7d&limit=10 top targets with title/slug/url snapshot 300s
GET /e/:site/live "reading now" from the DO window 10s

The gateway's edge cache is keyed by tenant today; the engagement door needs a URL-keyed variant since the site_id already scopes the tenant. No purge is needed at a 30s TTL.

10.2 Admin /engagement/admin/*

Gateway chain identical to forms: authMiddleware → accessMiddleware("engagement") → subscriptionGuard → forward.

  • Sites — list / create / patch (origins, pause, Turnstile key) / rotate id.
  • Settings — get / put eng_settings.
  • Targets — list with sort=views|comments|rating&range=; patch comments_mode.
  • Analytics — overview?range=, targets/:key?range= (daily series), top?metric=&range=; later breakdown?by=referrer|country|device from AE.
  • Comments — list (status, target, q, cursor), get, patch {status, note}, bulk, reply, delete, export.csv.
  • Members — list, patch (ban / unban / rename), delete (GDPR anonymise → "Deleted reader").
  • Bans — list / create / delete.

10.3 Internal (worker-to-worker, dedicated ENGAGEMENT_INTERNAL_TOKEN, one secret per pair as site-sync does)

  • Engagement exposes GET /internal/stats?tenantId=&keys= (for blog's get_analytics), POST /internal/targets/refresh (optional push from blog) and POST /internal/flush-accumulator (billing-crons).
  • blog-service adds GET /internal/targets/:postId?tenantId= → {exists, status, slug, title, publishedAt}. Collections adds the same for entries in Phase 5.
  • Verification flow: first sight of a target_key → binding call → write eng_targets → per-isolate cache 5 min → re-verify every 10 min on read. unpublished or missing hides the summary and closes comments.

10.4 MCP tools (namespace engagement, Phase 3)

Tool Permission Flows node Billing
get_target_stats, list_top_targets, get_analytics_overview engagement:stats.read — —
list_comments, get_comment, export_comments engagement:comments.read — —
moderate_comment (approve / reject / spam) engagement:comments.moderate "Moderate comment" —
reply_to_comment engagement:comments.reply "Reply to comment" —
delete_comment engagement:comments.delete — —
summarize_comments engagement:comments.read "Summarize comments" flat ENGAGEMENT_AI_SUMMARY: 5
list_members, ban_author, delete_member_data engagement:members.read / engagement:members.manage "Ban commenter" —
get_settings, update_settings, set_target_comments_mode engagement:settings.admin "Close comments on post" —

blog_get_analytics stays in the blog namespace and delegates to /internal/stats over an ENGAGEMENT_SERVICE binding. When the binding is absent it returns {available: false, reason: "analytics_not_configured"} — the explicit-unavailability fix backlog B5 asked for.

Permission group: engagement:stats.read (readOnly), engagement:comments.read (readOnly), engagement:comments.moderate, engagement:comments.reply, engagement:comments.delete, engagement:members.read (readOnly), engagement:members.manage, engagement:settings.admin.

10.5 Events (source engagement-service, via the existing EVENTS_QUEUE producer)

Event Payload (required) filterKeys
engagement.comment.created commentId, targetKey, targetType, targetId, targetTitle, status, holdReason, authorName, authorEmail, body, parentId, isReply, createdAt targetType, targetId, status
engagement.comment.published same minus holdReason targetType, targetId
engagement.comment.flagged commentId, targetKey, reason ("reader" / "ai"), aiLabel targetType
engagement.rating.created targetKey, targetId, value, avg, count targetType, targetId
engagement.member.verified memberId, email, name, targetKey —
engagement.target.milestone targetKey, targetId, targetTitle, metric ("views"), threshold (100 / 1k / 10k) — emitted from the DO flush targetType, metric

With these, "comment → create contact", "member verified → add newsletter subscriber" and "milestone → schedule a re-share" are Flows recipes with zero new code in engagement.

11. Abuse, identity and privacy

11.1 Order of checks on every public write

site row (negative-cached 30s) → paused? → originAllowed (strict for writes)
  → native rate limiter BEFORE body parse → content-length cap (8 KB comment, 512 B beacon/reaction)
  → parse → honeypot → bans → Turnstile → heuristics → AI → plan cap → credit gate
  → insert → side effects in waitUntil (events, digests, contact sync)

Reads use the lenient origin variant (missing Origin allowed, as Collections does); writes use newsletter's strict variant (missing Origin rejected whenever an allowlist is configured), because a write burns the tenant's credits and reputation.

11.2 Rate limits (native [[ratelimits]] bindings; free, no KV writes)

Binding Limit Key Guards
ENG_BEACON_LIMITER 120 / 60s site:ip beacon floods
ENG_WRITE_LIMITER 10 / 60s site:ip reactions, comments, flags
ENG_TARGET_LIMITER 30 / 60s target_key a many-IP flood on one post
ENG_VERIFY_LIMITER 3 / 60s site:ip magic-link requests; plus a per-address cooldown ≥ 5 min — comms /internal/send is free at the meter, so a per-victim bound is mandatory

11.3 Cheap heuristics before any LLM call

Link count ≥ hold_link_threshold → hold. Identical body from the same ip_hash within an hour → drop as duplicate. More than 3 comments in 10 minutes from one ip_hash → hold (velocity). Banned word → hold. Blocked email domain → reject. Author on eng_bans → silent spam: 200 to the client, a spam row, nothing notified, nothing charged.

11.4 First-time hold and "previously approved"

With moderation_mode: first_time (the default), a comment publishes immediately when (tenant_id, author_email_hash) already has a published comment — one indexed lookup — otherwise it lands pending. This is the WordPress default and the least-friction safe posture. Verified members skip the hold whenever comments_mode is verified or stricter.

11.5 AI moderation

Copies apps/chat-engine/src/lib/intent.ts: a regex fast path handles the trivially bad at zero cost; everything else goes to haiku with labels ok | spam | toxic | off_topic | needs_review, temperature 0, maxTokens: 8, body truncated to 1,500 characters, a 2-second timeout. Any failure or timeout returns needs_review, which means pending. A classifier outage can never auto-publish and never lose a comment. The provider cost folds into the comment price; no separate meter.

11.6 Member verification (Phase 4)

  1. verify-request upserts an unverified member and sends a magic link through comms, attributed to the tenant (newsletter's suppression-bucket warning applies: never send from a shared platform identity).
  2. Token = HMAC(verify:<memberId>:<tenantId>:<nonce>:<ts>) with a dedicated ENGAGEMENT_TOKEN_SECRET, 15-minute expiry, single-use via compare-and-clear on verify_nonce.
  3. verify sets verified_at and returns a stateless session HMAC(member_id:tenant:session_version:exp) (30 days) in the redirect fragment to an allowed origin.
  4. The widget stores the session in localStorage and sends it in POST bodies. Ban or "log out everywhere" bumps session_version; no sessions table.

11.7 Visitor identity and the privacy posture

  • Passive views carry no client identifier at all. The server computes visitor_hash = HMAC(HMAC(secret, YYYY-MM-DD), tenant|ip|ua) for the DO's per-day uniques set; the set is pruned at day roll. Nothing IP-derived reaches Postgres for views.
  • Interaction creates a random vid in localStorage, only at the first interaction. It powers optimistic UI and the one-reaction-per-visitor index. It is not signed: signing cannot stop a fresh id, and the limiter plus the daily hash are the real dedup.
  • Comments store ip_hash (for bans and velocity), purged after 30 days by the DO sweep; ua_class only; raw IP and UA are never persisted.
  • No cookies anywhere. return_to and redirects are restricted to allowed origins. This is what makes "no consent banner needed" defensible; it belongs in docs/blog/sdk-security-model.md when built.
  • GDPR: member delete anonymises comments to "Deleted reader" and purges the member row; CSV export gives the tenant its data; Vlozi is processor, the tenant is controller.

12. The views pipeline

12.1 Beacon contract

POST https://api.vlozi.app/engagement/e/{site_id}/beacon
Content-Type: text/plain
 
{"t":"post:post_…","e":"view","p":"/blog/my-slug","r":"https://google.com","s":"sdk"}
{"t":"post:post_…","e":"read","d":0.82,"s":"sdk"}
  • text/plain keeps it a simple request (no preflight) and works with navigator.sendBeacon.
  • view fires once per page load, after document.visibilityState === "visible" and never while document.prerendering.
  • read fires once, when dwell ≥ 30s and scroll ≥ 50%, or at the end of the article; sent with sendBeacon on pagehide.
  • Build-time fetches on static and ISR sites never fire beacons — the overcount that counting server GETs would have had. CDN-cached pages still fire from the browser — the undercount server-side counting would have had.

12.2 Server side

site + origin + limiter → UA denylist (bot|crawler|spider|headless|lighthouse|pagespeed|preview, empty UA) → drop Sec-Purpose: prefetch → drop when request.cf.verifiedBotCategory is present → compute visitor_hash → AE datapoint → EngagementCounter DO POST /bump {targetKey, event, visitorHash, ts} → 204.

12.3 EngagementCounter Durable Object (engage-${tenantId}, SQLite class)

  • Tables (as built): deltas(target_key, day, views, reads, uniques); seen(target_key, day, visitor_hash, last_seen_ms); meta(bump_id, ts) for beacon idempotency; active(target_key, visitor_hash, last_seen_ms) for "reading now".
  • The 30-minute rule, precisely: a view counts when the (target_key, day, visitor_hash) row is new, or when the last hit from that hash on that target was more than 30 minutes ago. The row's timestamp is updated on every hit, counted or not — that is what makes it a sliding session window rather than a fixed one: continuous activity keeps suppressing new counts, and only 30 minutes of complete inactivity lets the next hit count.
  • "Reading now" uses a separate, much shorter window (5 minutes) in its own active table. Reusing seen would make it mean "read at some point today", which is a different and far less useful claim.
  • Alarm: the first bump arms setAlarm(now + 60s) only if none is pending. The alarm flushes all deltas in one batched INSERT … ON CONFLICT DO UPDATE SET views = views + excluded.views into eng_target_daily and eng_target_stats, emits engagement.target.milestone when a threshold is crossed, prunes seen rows older than today, purges old ip_hash weekly, sends digests, then re-arms only if new deltas arrived — otherwise deleteAll() and stand down. On throw, re-arm at +5 minutes.
  • Why per-tenant, not per-target: one batched Neon write per minute per active tenant. Shard to engage-${tenantId}-${hour % N} only if a single tenant ever saturates a DO.
  • Why not a cron or a queue: the account is at both limits; DO alarms are the codebase's standard workaround.

12.4 Freshness and consumers

  • A view appears in the public counter within about 1.5 minutes (60s flush + 30s cache); the widget shows an optimistic +1 immediately. Say this in UI copy.
  • Dashboard: Blog › Analytics calls /engagement/admin/analytics/* through a dashboard proxy; the Posts list gains a "Views" column by merging GET /admin/targets?type=post client-side, so blog has no coupling to engagement.
  • Agents: blog_get_analytics calls /internal/stats over the binding.
  • Later: referrer / country / device / hour-of-day from the AE SQL API behind an admin endpoint.

13. Delivery surfaces — what a tenant writes

13.1 React site with @vlozi/blog

import { BlogPost } from "@vlozi/blog/react";
import { EngagementProvider, EngagementBeacon, ViewCount } from "@vlozi/blog/engage";
 
export default function Post({ slug }: { slug: string }) {
  return (
    <EngagementProvider siteId="site_9f2c…">
      <BlogPost
        slug={slug}
        renderFooter={(post) => (
          <>
            <EngagementBeacon target={`post:${post.id}`} />
            <ViewCount target={`post:${post.id}`} />
          </>
        )}
      />
    </EngagementProvider>
  );
}

BlogPost.renderFooter is the one new slot in the existing component. useEngagementSummary(keys) batches up to 50 targets, so a list page shows counts on every BlogCard through its existing footer slot with one request. The ./engage entry has its own size budget so it cannot break the React entry's 25 KB cap. The site_id is public by design; no secret ever reaches the browser.

13.2 Any other site

<script src="https://api.vlozi.app/engagement/e/embed.js" data-site="site_9f2c…" async></script>
<article data-vlozi-target="post:post_…">
  …
  <span data-vlozi-views></span>
</article>

The script fires the beacon for the nearest data-vlozi-target, fills every data-vlozi-views span, and later mounts reactions and comments into data-vlozi-comments containers. The same self-contained vanilla pattern as forms/f/embed.js.

13.3 vlozi.app itself

apps/website stays a static export; a small client island (the newsletter-signup pattern) renders the beacon and counter after the article. This is the dogfooding path for Milestone 1.

13.4 Dashboard

Two tabs appended to the Blog entry: Analytics (overview cards, per-post 30-day series, top posts, site settings and snippet) and, with comments, Comments (the forms inbox adapted). No new sidebar module until Collections reviews ship.

14. Billing model (proposal)

Item Proposal Rationale
Views, reads, reactions free; soft plan cap engagement.events_monthly — free 10k / basic 50k / starter 250k / pro 1M / business unlimited counting never stops; over the cap the dashboard hides daily breakdowns and AE detail
Comments metered on publish, ENGAGEMENT_COMMENT_THOUSANDTHS: 100 (10 per credit ≈ ₹0.08 each), reason engagement.comment.publish; plan cap engagement.comments_monthly — free 100 / basic 500 / starter 2,500 / pro 10,000 / business unlimited never charge for spam or rejected content; consistent with forms' accumulator; AI moderation cost folded in
AI comment summary flat ENGAGEMENT_AI_SUMMARY: 5 matches CHAT_AI_SUMMARY
Alternatives free under plan caps only; or forms parity at 0.25 cr per comment open decision, §16

Mechanics follow forms exactly: a UsageStore adapter over tenant_engagement_usage, canUsageProceed pre-flight → 402 with the credit-kit body, flush via billing-crons, plan caps via mirrored core tables (fail-open). Costs only in packages/core-types/src/credit-costs.ts, reasons only in packages/core-billing/src/reasons.ts; a service-local cost constant is a bug.

15. Risks

Risk Mitigation
Neon write amplification without the DO — 100k views/month is 100k round trips on a hot (target, day) row the DO flush is not optional at that scale; it ships in Milestone 1
DO hot tenant; an alarm retry after a crash could double count shard by hour bucket if ever needed; flush is delta-then-zero in one DO transaction
Freshness expectations — ~1–2 min lag on counts, 30s on comment lists optimistic UI; say it in UI copy
Magic-link email is a subscription-bomb vector cooldown + limiter + Turnstile on verify-request; attribute sends to the tenant
ePrivacy — the vid is device storage set it only on interaction, never on a passive view; document the posture
UGC legal surface — takedowns, ToS, processor/controller wording, DSA-style obligations at EU scale a platform blocklist in internal-ops is the minimum before opening comments broadly
SDK size budget — the React entry is capped at 25 KB brotli engagement is its own entry with its own budget
Existing static sites lack id until rebuilt slug-fallback resolution in the SDK and embed
Analytics Engine dependence — ~3-month retention, sampling no tenant-facing number depends solely on AE
Backfill — a new ServiceCode does not enable it for existing tenants a tenant_services backfill is part of plumbing
Owner reply identity — engagement receives x-user-id but no display name a "reply as {Site name}" setting, or a manager lookup

16. Decision log and open questions

16.1 Decided (2026-09-04)

# Decision
1 Placement: new engagement-service (option D).
2 First milestone: views + reads only; validate demand before comments.
3 The hosted blog (apps/blog-host) is dropped; site-sync is the publish-to-live path.

16.2 Open, with recommendations in bold

# Question Options
4 Pricing comments 0.1 cr on publish + plan caps; or free under caps only; or forms parity 0.25 cr
5 Default comments mode guest (name + email) + first-time hold + previously-approved auto-publish; or verified-email required; or members-only
6 Default AI moderation on, fail-to-pending; or opt-in
7 Expose post id publicly yes (additive; flip the SDK doc comment to "prefer id for stable keys")
8 Naming code engagement, worker vlozi-engagement-service, gateway /engagement, dashboard tabs under Blog now; own module only when Collections reviews ship. Alternatives: audience, community
9 Sites model one default site_id per tenant, source: sdk|embed on events; extra sites optional for staging or multiple domains
10 Public visibility defaults hide views below 10, hide rating below 3 votes, always show the comment count
11 Reaction vocabulary single "like" by default; or a fixed emoji set; or tenant-configurable
12 Comments and SEO client-only first; later an ISR re-render through a site-sync engagement source if crawlable comments matter
13 Who moderates by default owner only; or all editors (engagement:comments.moderate in the editor role)
14 Data promises export format; ip_hash retention (30 days)

17. Glossary

Term Meaning
Target Anything a reader can engage with, identified by type:id — post:<post_id>, entry:<entry_id>, page:<url-hash>.
Site A tenant's public engagement credential (site_<uuid>), with its own origin allowlist and Turnstile key. Appears in page source by design.
Beacon The tiny text/plain POST a page sends to record a view, read or share.
Read A view where the reader dwelt ≥ 30s and scrolled ≥ 50%, or reached the end.
Reaction A one-per-visitor signal on a target: like, love, helpful, or a 1–5 rating.
Member A reader who verified their email through a magic link; holds a stateless session.
Hold A comment parked in pending with a hold_reason, waiting for a moderator.
Summary The batch public read of a target's counters, served from eng_target_stats.
EngagementCounter The per-tenant Durable Object that buffers deltas and flushes them every 60 seconds.
Engagement