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-serviceworker 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
idin the public payload, add one internal "does this post exist" route, and make theblog_get_analyticsMCP 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
- Zero friction by default. Reading is anonymous. Reacting needs nothing. Commenting needs a name and an email, not an account.
- 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.
- Safe by default, owner in control. First-time commenters are held, spam never notifies or charges, AI classification fails to pending, never to publish.
- Never charge for junk. Comments are metered when published, not when submitted.
- Generic across modules. A target is
type:id. Blog is the first client; Collections is the second. - 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_analyticsreturns 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), onlyslug— 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, aPostSchedulerDurable Object, an Analytics Engine dataset (METRICS, ops only), and token-guarded/internalroutes. - SDK
@vlozi/blog2.1.7: read-only;BlogPosthas no below-content slot;BlogCardalready hasfooterandrenderMetaslots; the React entry is capped at 25 KB brotli.docs/blog/sdk-security-model.md:122says comment HTML must not go throughBlogContent.
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 tovlozi-eventsis 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/websiteis a static export; anything dynamic is client-fetched. Its newsletter widget is the client-island pattern to copy.apps/site-syncnotifies 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 SEO8.1 Principles the design rests on
- 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 carriestenant_id. - Tenant is the output of a row lookup. The
site_idin 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. - 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.
- 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.
- Blog gains a public
id. Additive and non-breaking. Sites built before the change get a slug-fallback resolver. - 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.
- No cookies. No client id for passive readers. Uniques come from a daily-rotating salted hash that never leaves the DO. A client
videxists 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
valueand 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
settlementSeqcompare-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=; patchcomments_mode. - Analytics —
overview?range=,targets/:key?range=(daily series),top?metric=&range=; laterbreakdown?by=referrer|country|devicefrom 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'sget_analytics),POST /internal/targets/refresh(optional push from blog) andPOST /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 → writeeng_targets→ per-isolate cache 5 min → re-verify every 10 min on read.unpublishedormissinghides 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)
verify-requestupserts 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).- Token =
HMAC(verify:<memberId>:<tenantId>:<nonce>:<ts>)with a dedicatedENGAGEMENT_TOKEN_SECRET, 15-minute expiry, single-use via compare-and-clear onverify_nonce. verifysetsverified_atand returns a stateless sessionHMAC(member_id:tenant:session_version:exp)(30 days) in the redirect fragment to an allowed origin.- The widget stores the session in
localStorageand sends it in POST bodies. Ban or "log out everywhere" bumpssession_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
vidinlocalStorage, 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_classonly; raw IP and UA are never persisted. - No cookies anywhere.
return_toand redirects are restricted to allowed origins. This is what makes "no consent banner needed" defensible; it belongs indocs/blog/sdk-security-model.mdwhen 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/plainkeeps it a simple request (no preflight) and works withnavigator.sendBeacon.viewfires once per page load, afterdocument.visibilityState === "visible"and never whiledocument.prerendering.readfires once, when dwell ≥ 30s and scroll ≥ 50%, or at the end of the article; sent withsendBeacononpagehide.- 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
activetable. Reusingseenwould 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 batchedINSERT … ON CONFLICT DO UPDATE SET views = views + excluded.viewsintoeng_target_dailyandeng_target_stats, emitsengagement.target.milestonewhen a threshold is crossed, prunesseenrows older than today, purges oldip_hashweekly, sends digests, then re-arms only if new deltas arrived — otherwisedeleteAll()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 mergingGET /admin/targets?type=postclient-side, so blog has no coupling to engagement. - Agents:
blog_get_analyticscalls/internal/statsover 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. |