diff --git a/docs/NEXT.md b/docs/NEXT.md index eea7435..ec1c426 100644 --- a/docs/NEXT.md +++ b/docs/NEXT.md @@ -2,7 +2,7 @@ The flight plan. Each item carries enough context to start cold; update this file whenever an item lands (move it to "Done") or a new one is queued. Decisions made while working an item still go through `decisions/` as usual. -_Last updated: 2026-08-26 (**PRs #27, #28 and #31 are all merged and deployed.** mooring.page now serves the demo-first landing page (PD-10) — verified live: the apex carries the Open Graph card, `/s/jzweifel.dev` renders with the claim banner and `noindex`, and the domain lock, sign-off band and republished `signOff` lexicon from the earlier PRs are all in place. **PR #33 is merged and deployed too** — the postmark cut-off fix and the landing footer's attribution row (both below), smoke-tested live. **Nothing is in flight; the queue below is current.** The queue below is otherwise current. Critique trends, two separate targets: the rendered **theme** ran 25 → 31 → 29 → 32, and the **landing page** has one run at 21/40 — snapshots in `.impeccable/critique/`)._ +_Last updated: 2026-08-26 (**PD-11 recorded: the site-metrics offering** — free real-7d / paid-30d on Analytics Engine, design fully settled in `research/2026-08-26-analytics-offering.md`, queued as item 2 below. Also: **PRs #27, #28 and #31 are all merged and deployed.** mooring.page now serves the demo-first landing page (PD-10) — verified live: the apex carries the Open Graph card, `/s/jzweifel.dev` renders with the claim banner and `noindex`, and the domain lock, sign-off band and republished `signOff` lexicon from the earlier PRs are all in place. **PR #33 is merged and deployed too** — the postmark cut-off fix and the landing footer's attribution row (both below), smoke-tested live. **Nothing is in flight; the queue below is current.** The queue below is otherwise current. Critique trends, two separate targets: the rendered **theme** ran 25 → 31 → 29 → 32, and the **landing page** has one run at 21/40 — snapshots in `.impeccable/critique/`)._ ## Where things stand @@ -24,6 +24,10 @@ Done so far: OAuth login (loopback dev client; hosted-client path ready pending - **Landing-page critique follow-ups, design-decision tier** (queued 2026-08-25; snapshot `.impeccable/critique/2026-08-26T01-52-10Z__apps-web-src-routes-page-svelte.md`, scored 21/40 — the three P1s and the mechanical P2s are fixed; what remains needs product calls): gloss or drop "standard.site"/"sifa" jargon for the non-technical launch audience; a pricing signal ("free subdomain" reads as an unpriced paywall); accept a pasted DID in the lookup (currently lowercased and rejected as a typo); distinguish resolver outages from typos in the error copy; and the big swing — show a real rendered site on the landing page instead of describing one ("you've built a rendering engine and put a text ad in front of it"). - **Explore extracting themeability, and a second theme — possibly nautical** (queued 2026-08-25, Jacob's hunch): "Mooring" reads as boats to plenty of people before it reads as airships, and the professional-presence theme is currently the only theme, its palette and motifs woven through `SiteLayout`/`SiteSections` rather than sitting behind a seam. Two questions to explore together: (1) what a theme contract would look like (tokens? component set? the `theme.colors` override mechanism already hints at one) and whether extracting it is worth the indirection while there is exactly one theme; (2) whether a harbor/nautical variant (same letter-and-postmark bones, different motif and palette) is a cheap second theme that meets boat-minded visitors where they land. Mock on the design canvas before building; ADR 0008 scope discipline applies — this is exploration, not a committed v1 item. +### 2. Site metrics — free 7d dashboard on Analytics Engine (PD-11, decided 2026-08-26; ship after the v1 tail, before billing) + +The whole design is settled and recorded — `research/2026-08-26-analytics-offering.md` has the verified Analytics Engine facts (pricing/retention/SQL API/sampling), the pckt.blog competitive read, and every decision (metric set, stateless daily-hash uniques and their daily-visitor semantics, `isbot` drop-at-ingest, tenant-hosts-count / app-host traffic owned by the `mooring.page` authority DID for funnel dogfooding, `/admin/analytics` placement, dark-when-binding-absent self-host seam). Build = one `writeDataPoint()` in the serving path + a sampling-aware query helper + the dashboard page + a locked 30d control; write the ADR (AE dataset/schema specifics) when this starts. The 30d unlock itself waits on billing, but data written from day one makes it retroactive. + ## Standing / background - **CI does not deploy.** `.github/workflows/ci.yml` runs checks only; shipping is always a manual `npm run deploy -w web` (`vite build` then `wrangler deploy`) after a merge. So "merged" never implies "live" — when recording work here, say which one happened, and smoke-test the deployed origin rather than trusting the merge. diff --git a/docs/decisions/product-decisions.md b/docs/decisions/product-decisions.md index 16dfce0..e2d6c58 100644 --- a/docs/decisions/product-decisions.md +++ b/docs/decisions/product-decisions.md @@ -14,3 +14,4 @@ Strategy and product decisions, newest last. Technical/architectural decisions l | PD-8 | 2026-07-30 | **Product name: Mooring, at mooring.page** (domain secured; mooring.site priced as a premium name and passed on). Handle `@mooring.page`. Fahrenheit remains the repo codename and internal lore (PD-5). | Accepted | Top-ranked candidate from naming research (`../research/2026-07-30-naming.md`): near-zero collision, doubly on-theme (airship home + your permanent place). Follow-up before big brand investment: trademark screen (TESS/EUIPO). Lexicon namespace consequence recorded in ADR 0009. | | PD-9 | 2026-08-25 | **Custom domains are invite-only until pricing ships; subdomains stay open.** Two DID allowlists gate the two actions that take a new host: `CUSTOM_DOMAIN_ALLOWLIST` (a secret, currently a short invite list) and `SUBDOMAIN_ALLOWLIST` (a var, currently `*`). Either accepts `*` for every account or nothing for none. Hosts already taken keep serving and stay manageable. | Accepted | A custom hostname is per-domain COGS and PD-4 prices it, so giving it away before the tier exists is the thing to stop; a subdomain is PD-4's free tier and costs nothing per account, so closing it would only strangle the funnel. Both are gated by the same mechanism anyway — a self-hosted instance may well want subdomains invite-only, and that is a config value, not a fork. Grandfathering is free: the ungated actions (release, verify, remove) already refuse rows the account does not hold. | | PD-10 | 2026-08-25 | **The landing page is demo-first.** The apex asks for a handle and shows that account's site rendered live at `/s/[handle]`; a banner over every preview offers "This is me — claim it," linking to sign-in with the handle prefilled. Preview pages always carry `noindex` and a typo'd handle fails inline on the landing page instead of 404ing. Accepted risk, eyes open: the apex now invites uncached live PDS reads for arbitrary handles — the queued deletion-honoring cache (ADR 0010 §4) is the remedy when traffic warrants it. | Accepted | The one demo no competitor can run: the visitor's actual site, built from data they already own, before any sign-up. A sign-in-first landing page wasted that. `noindex` on previews keeps mooring.page from ranking for the names of people who never signed up. | +| PD-11 | 2026-08-26 | **Site metrics: real 7 days free for every account; the last 30 days joins the PD-4 paid tier (one tier, two levers: custom domain + 30d metrics); no-cookie/no-stored-IP privacy stance, stated publicly.** Full metric set on free, gated by window only — no fabricated sample data anywhere, ever; the free dashboard shows a locked 30d control. The 7d dashboard ships before billing exists; the upgrade unlocks the last 30 days retroactively (the store already retains 3 months). Metrics are service-side operational data, never PDS records. Design details and competitive read: `../research/2026-08-26-analytics-offering.md`. | Accepted | Cloudflare Analytics Engine makes the marginal cost ≈ $0 (10M page views/mo included in the plan we already pay for), so the free 7d window costs nothing and buys the stickiest thing available — a reason for site owners to return daily. pckt.blog gates analytics entirely behind paid and teases with fake numbers; real free data plus a truthful locked upgrade out-flanks that and fits the honesty brand. One tier keeps one price, one upgrade decision, one billing SKU, and each lever converts a different buyer. (pckt's $4.44/mo incl. domains+analytics was weighed against PD-4's ~$6–8 anchor and held — different category, sub-$10 logic untouched.) | diff --git a/docs/research/2026-08-26-analytics-offering.md b/docs/research/2026-08-26-analytics-offering.md new file mode 100644 index 0000000..6af4ce0 --- /dev/null +++ b/docs/research/2026-08-26-analytics-offering.md @@ -0,0 +1,66 @@ +# Site metrics offering — feasibility, competitive read, and the settled design + +_Checkpointed 2026-08-26. Prompted by pckt.blog shipping a paid analytics tier; explored and decided with Jacob in-session (design-tree interview, every branch below was put to him explicitly). Product-level outcome recorded as PD-11; an ADR follows when the build session starts._ + +## Verdict + +Build it on **Cloudflare Workers Analytics Engine (AE)**: marginal cost ≈ $0 at any traffic level Mooring will see for years, the 7d-free/30d-paid split is a single `WHERE` clause, and the upgrade unlocks the last 30 days retroactively because AE already retains 3 months. The expensive part of the offering is billing, which is a separate, pre-existing gap (PD-9 holds custom domains on the same gap). + +## Workers Analytics Engine facts (verified 2026-08-26) + +- **Pricing** ([docs](https://developers.cloudflare.com/analytics/analytics-engine/pricing/)): Workers Paid includes **10M data points written/mo** (+$0.25/M after) and **1M read queries/mo** (+$1.00/M after). One data point = one page view, so 10M page views/mo across all tenants before any marginal cost. As of the checkpoint date AE usage is **not yet billed at all** (pricing announced, billing "coming months"). +- **Retention** ([limits](https://developers.cloudflare.com/analytics/analytics-engine/limits/)): data is stored for **three months**, then ages out automatically. No storage bill, nothing accumulates in D1. 30d-paid sits comfortably inside this; anything beyond 90d would need rollups we are deliberately not building. +- **Shape**: per data point — 1 index (96 bytes), up to 20 blobs (16KB total), 20 doubles. Max 250 writes per invocation (we write 1). +- **SQL API** ([reference](https://developers.cloudflare.com/analytics/analytics-engine/sql-api/)): plain HTTPS POST to `api.cloudflare.com/client/v4/accounts//analytics_engine/sql`, Bearer token with **"Account Analytics: Read"** scope — one new wrangler secret, same pattern as the Cloudflare-for-SaaS token. `count(DISTINCT …)` is supported ([aggregate functions](https://developers.cloudflare.com/analytics/analytics-engine/sql-reference/aggregate-functions/)). +- **Sampling — the one sharp edge** ([docs](https://developers.cloudflare.com/analytics/analytics-engine/sampling/)): AE applies weighted adaptive sampling at volume. Under sampling, `count()` must be written `sum(_sample_interval)`, sums become `sum(x * _sample_interval)`, etc. — per-row weights, not a constant multiplier. At our traffic sampling likely never engages, but **the query layer must be sampling-aware from day one** or numbers silently drift low exactly when a site gets popular. `count(DISTINCT)` under sampling additionally undercounts (a sampled-out visitor is invisible); acceptable at our scale, worth a code-comment-free but test-pinned query helper. +- Whether the writing Worker can read its own writes with what latency is not documented; the dashboard tolerates minutes of ingest lag anyway. + +## Bot signals (verified 2026-08-26) + +Cloudflare's bot score and verified-bot fields (`cf.botManagement.*`, `cf.verifiedBotCategory`) are **Enterprise-only** — not available on our plan ([reference](https://developers.cloudflare.com/bots/reference/bot-management-variables)). The industry answer at our tier is user-agent filtering: umami uses the `isbot` library; self-hosted Plausible does the same class of UA matching (Plausible Cloud adds a ~32K-range datacenter-IP blocklist we are not replicating). Consequence accepted: honest crawlers are excluded, anything masquerading as a browser is counted — same as umami. + +Geographic and connection fields **are** available without any add-on: `cf.country` (and city/region/timezone/asn) populate on every request. + +## Competitive read: pckt.blog (screenshots 2026-08-26, from Jacob) + +- **Analytics are entirely paid.** The free "Explorer" tier's analytics page renders **fabricated sample data** with a "Preview — Sample data. Analytics are available for Supporter and Advocate. View plans →" overlay. Even the 7-day chart is fake. +- **Dashboard contents**: Total Views, Post Views, Unique Visitors, Subscribers tiles; views-over-time chart (Views/Subscribers toggle, ~7d window); Top Posts with per-post views + uniques; Browsers and Platforms donuts; Top Sources (referrer + count). **No geography.** +- **Pricing**: Free "Explorer" (15 posts/mo, 1 blog, no analytics) · **Supporter $4.44/mo or $44/yr** (100 posts/mo, 5 blogs, custom domains, analytics, version history, unlimited media) · **Advocate $9.99/mo or $99/yr** (unlimited publishing, 10 blogs, same features). +- **Bearing on PD-4** (put to Jacob, held): $4.44 including custom domains + analytics is below PD-4's ~$6–8 anchor. Held because the categories differ (blog platform vs. professional presence over data you already own) and PD-4's logic (sub-$10 ceiling, domain = handle = verification) is untouched. Recorded here as a pricing data point alongside the standing "watch Bluesky+" trigger. +- **Bearing on our funnel shape**: pckt teases with fake numbers; we ship **real 7d data free**. The locked 30d control then sells the truth — the visitor's actual history exists and is one upgrade away. Out-flanks the sample-data pattern and matches the honesty brand. + +## The settled design (every point below was an explicit decision) + +**Offering** (product level — PD-11): +- Real 7d metrics **free for every account**, full metric set, gated by window only. Ships before billing exists; the 30d unlock arrives with the PD-4 tier and works retroactively. +- **One paid tier, two levers**: custom domain + 30d metrics at PD-4's price. No separate analytics SKU, no second tier. +- **Privacy stance, stated loudly**: no cookies, no stored IPs, no client-side beacon — server-side ingestion only. A public "your visitors aren't tracked" commitment, accepted as a deliberate constraint on future mechanisms. +- Analytics is service-side operational data, not user content — ADR 0004 untouched. Dashboard-only at launch; CSV/JSON export is a fast-follow, and the UI states the 3-month horizon honestly rather than letting a paying user discover it. + +**Metric set** (settled against pckt's): views, unique visitors, views-over-time, top pages, top referrers, **countries** (our extra — free from `cf.country`; pckt lacks it). Cut for v1: browser/platform donuts (cheap to add later — the UA is already parsed for bot filtering), and pckt's Subscribers tile has no Mooring equivalent. + +**Uniques**: Plausible-style daily-rotating hash — `hash(salt(day), site DID, IP, UA)` stored as a blob, `count(DISTINCT)` in queries. Salt is **stateless**: `HMAC(server secret, UTC date)` — no storage, no rotation job; the forward-secrecy delta vs. generated-then-deleted salts is thin because raw IPs are never stored anywhere. Accepted semantics: the hash rotates daily, so a week's "unique visitors" counts a person once per day they visited (**daily-visitor semantics** — Plausible shares this); the dashboard labels it accordingly. + +**Ingestion**: one `writeDataPoint()` per successfully rendered HTML page view, in the serving path where the host already resolves to a tenant DID. Index = site DID; blobs = path, referrer, country, visitor hash. Bots **dropped at ingest** via `isbot` (never written — preserves write quota; no tag-and-filter-later). Excluded always: `/blob`, `/admin`, `/login`, `/oauth`. + +**What counts, and dogfooding**: tenant hosts (subdomain + custom domain) count toward the owner's site. **App-host traffic — the apex landing page and `/s/[handle]` previews — is recorded as ordinary site views owned by the `mooring.page` authority DID** (`did:plc:o3zuar7kk2mrz7d4sqxdisy2`), so the operator reads Mooring's own funnel (which handles get previewed, what the apex converts) through the standard dashboard as just another user. Previews never appear in site owners' dashboards; a freshly-claimed user sharing `/s/` links sees zero until they claim a subdomain — accepted as a nudge toward claiming, revisit on real complaints. Considered and accepted: the operator's top-pages list will contain other people's handles as `/s/` paths — ordinary server-log territory, operator-private; the no-cookie pipeline applies on app hosts too, so the privacy promise holds on marketing pages. + +**Dashboard**: its own `/admin/analytics` page behind `requireSession`, linked from the admin nav. Free accounts see real 7d data plus a visible-but-locked 30d window control — **no fabricated data anywhere**. + +**Self-host seam** (ADR 0005): the feature goes dark when the AE binding is absent — ingest no-ops, the dashboard route hides. No storage abstraction while there is exactly one backend; the seam gets extracted when a second backend exists. Recorded so it reads as a decision, not an oversight. + +## Deliberately not building + +All-time stats / >90d history (rollups into D1 — only if asked), export at launch, browser/platform breakdowns, event tracking (claim-banner clicks, lookup submissions — `/s/` path views approximate the funnel), datacenter-IP blocklists, public stats pages, a separate funnel dataset. + +## Sources + +- https://developers.cloudflare.com/analytics/analytics-engine/pricing/ +- https://developers.cloudflare.com/analytics/analytics-engine/limits/ +- https://developers.cloudflare.com/analytics/analytics-engine/sql-api/ +- https://developers.cloudflare.com/analytics/analytics-engine/sql-reference/aggregate-functions/ +- https://developers.cloudflare.com/analytics/analytics-engine/sampling/ +- https://developers.cloudflare.com/bots/reference/bot-management-variables +- https://developers.cloudflare.com/workers/runtime-apis/request/ +- pckt.blog analytics + pricing screenshots (Jacob, 2026-08-26; described above — pricing not publicly indexed, confirmed from the logged-in UI) +- Plausible/umami bot-filtering practice: https://github.com/umami-software/umami/discussions/4074