logicspike/docs

Engagement

Engagement

Last Updated: 2026-09-05 · Status: Active · Service: apps/engagement-service (vlozi-engagement-service)

Reader engagement for everything Vlozi publishes: view and read counts, reactions and star ratings, comments with moderation, and passwordless reader identity. One service, one generic target_key model, so a blog post and a collection entry are counted and commented on by exactly the same machinery.

Start here, by what you're doing

You want to… Read
Understand why this service exists and how it's shaped vision-and-architecture.md, then architecture.md
Call the API api-spec.md
Put a counter or comment thread on a site embedding.md
Change the schema, or work out where a number comes from database.md
Add a permission, or work out why someone got a 403 permissions.md
Understand what costs a credit billing.md
Deploy it, or set a secret implementation-plan.md §9
Find what's left to build backlog.md

The shape of it in one page

A generic target. Everything engagement counts hangs off a target_key: post:<id> for a blog post, entry:<id> for a collection entry, page:<hash> for a bare URL on the customer's own site. Nothing in the schema knows what a blog post is. That is what let Collections reuse the whole service in one phase (see Phase 10) rather than growing a second copy of it.

A public door with no credential. The widget on a customer's blog carries no API key — a browser cannot keep one secret. The tenant is derived server-side from an opaque site_id in the URL path, and x-tenant-id is ignored on every /e/* route. What protects the door instead: an origin allowlist (strict on writes, lenient on reads), native Cloudflare rate limits per site+IP, a bot filter, a honeypot, and optional Turnstile. See architecture.md.

A counter that batches. Beacons land in a per-tenant Durable Object, which dedups repeat visitors within a 30-minute sliding session and flushes accumulated deltas to Postgres in one batched write per minute. The alternative — a Postgres round trip per view — does not survive a post reaching the front page of anything.

Comments that fail toward a human. The write pipeline has a documented order (honeypot → bans → platform blocklist → domain → links → banned words → policy → first-time → AI). Every uncertain outcome resolves to pending, never to silently published and never to silently dropped. AI moderation failing means a person looks at the comment, which is the correct failure.

Readers, not accounts. A reader verifies an email address with a magic link and gets a stateless signed session in localStorage. No password, no cookie, no account to manage, and no CSRF surface — the token is only ever sent in bodies the SDK constructs. Revocation rides on a session_version counter, which is what makes "log out everywhere" and a ban take effect immediately.

What's deliberately NOT here

  • A hosted blog. Dropped 2026-09-04. Publishing to a live site is site-sync's job; engagement notifies it when a comment goes public.
  • Reader profiles, follows, or a social graph. A member exists to prove control of an email address for commenting. Nothing more is stored, and nothing more should be.
  • Exact analytics. The Postgres rollups are exact; the Analytics Engine breakdowns (referrer, country, device, hour) are sampled estimates and are labelled as such everywhere they appear. Never reconcile billing against them.
  • Rating markup on articles. engagementJsonLd refuses to emit aggregateRating for a BlogPosting — that is self-serving review markup. productRatingJsonLd exists for the legitimate Product case. See embedding.md.
Engagement