# Paiduay analytics v1 — the owner's numbers on `/me`, first-party taps

*2026-09-17 · decision-grade · rewritten for the shipped model (spec §9.58). Supersedes the 09-10 plan, which assumed operator-entered cards, no login and a Monday DM. Nothing is measured today.*

**The line:** every link on a card becomes a counted 302, every card open and kit render is a row in one kernel table, and the only two people who ever see a number are the card's owner on `/me` and James on `/admin/numbers`. We count taps, not people, and we say so.

## 0. What today's build changed

| Then (09-10) | Now (§9.58) | Consequence |
|---|---|---|
| Cards in `content/cards.ts`, no owner | `kernel.records` type `card`, owner via Clerk in `record_parties` | Per-owner numbers join on the record id; ownership is the repo's check, not RLS |
| No login → numbers by DM | `/me` (Todd), owner-only | Numbers live on `/me`; no capability URL, no script |
| Links direct (`linkHref`), no hop | Still direct | The hop is the one product change this plan needs |
| Jobs had no cron | `Jobs.schedule(name, cron)` since the outbox | Purge and digest are cron jobs |
| Tailnet-only | Public, Cloudflare grey, `clientIp()` trusts XFF | Per-IP caps see real addresses; orange needs `trusted_proxies` first (public-flip WO6) |
| Caption pack with `?s=` | Shipped **without** tags | Source tags are a work order, not an assumption |

Kept: source by URL tag (in-app browsers strip referrers), UA-class fallback, the LINE tap as the money event, 90-day retention, no banner. Dropped: Umami as default, daily rollups, the DM script, the capability URL.

## 1. The two questions

**Ploy:** "มีใครแตะ LINE ฉันอาทิตย์นี้ไหม แล้วมาจากไหน" — did anyone tap my LINE this week, from which app. She posts on TikTok and X and cannot tell which post works; LINE gives no click count for a personal add-friend link. The honest answer: taps on *her* buttons, split by the app the visitor came from, for 7 and 30 days — nothing inferred, no comparison to "average sellers".

**James:** which cards are alive (a real tap in 7 days), cards created per day and per source, Pro requests, and whether *his* post produced sign-ups. These decide whether the evenings continue.

**Doctrine (binding):** numbers are shown only to the card's owner (signed in, her own cards) and the operator (Better Auth). Never on `/c/*`, `/browse`, OG images, the kit or `/join`; never as a ranking or "featured" input; never as a rate to a seller (3 % reads as failure to someone with no baseline). A tap count on a public page is fabricated social proof — the doctrine that bans fake ratings bans it.

## 2. Options on this box

The box: one shared vCPU with Luna and TuaTon, host Postgres, Caddy on the host network, Cloudflare grey.

| Option | Cost | Ploy? | James? | Privacy / PDPA | Verdict |
|---|---|---|---|---|---|
| **First-party tap log** (`go` 302 + one kernel table) | ~7 h; one INSERT per open/tap in `after()` | **Yes** — per card, link, source, under our ownership check | Taps/opens; cards-per-day comes from `records` anyway | IP transient, never stored; hash 90 d | **The minimum — do it** |
| Umami self-hosted | compose service ~350 MB RAM + a db on the shared CPU | No per-owner view without a proxied API | Referrers, countries, devices | Cookieless, fine | Defer to 30 sellers or a bigger box |
| Plausible CE | ClickHouse 2–4 GB idle | as Umami | as Umami | fine | No — the box cannot |
| Cloudflare Web Analytics | 15 min JS beacon; works on grey | No — site-wide only | Partly (t.co referrers) | Third party, cross-border → `/policy` changes | Optional; §7.3 |
| Caddy log + nightly parser | `log` directive; a job reading the container's file | Only what the URL carries; no owner join | Everything, bots included | Raw IPs on disk | Not a source; 7-day log for abuse debugging only |
| Nothing until 30 sellers | 0 | No — and it is her first question | No — the "did the tweet work" month is lost | — | No; but v1 stays one evening |

**Recommendation:** the first-party log (`kernel.hits`) as the ledger, plus **one dashboard = `/admin/numbers`**, first-party, reading that table and `records`. No third-party script by default; Umami on the shelf.

**Kernel table, not `kernel.records`.** A card went into `records` with zero DDL because it is a business record: state machine, parties, few rows. A tap is append-only telemetry at 100–1,000× the row count, no state, no party; in `records` it would bloat `records_tenant_type_state_idx`, show in the admin scaffold and share buffers with every `listRecords`. The app has no migration path of its own (rule 7), so a table is a kernel migration and must be vertical-blind (rule 1) — and it is: "a subject in this tenant was hit, with a name, kind and source" serves a clinic's booking link as well as a barber's LINE button. The kernel never interprets `name`/`kind`/`source`, the outbox's posture toward `event_type`/`payload`.

**A hop, not a beacon.** A click beacon is lost when an in-app browser (Instagram, TikTok, LINE) leaves on the tap, needs JS, and cannot be bot-filtered server-side. A 302 costs ~10 ms and counts every real navigation. The destination stays exactly what she typed (`linkHref` runs inside the route); only the page's `href` becomes `/c/<handle>/go/<kind>`. The editor's live preview renders `CardPage` with `count={false}`, so her own preview taps stay direct.

## 3. The owner's numbers on `/me`

### Table — migration `0005_hits.sql`, in the style of `0004_outbox.sql`

```sql
CREATE TABLE kernel.hits (
  id           bigserial PRIMARY KEY,
  tenant_id    uuid NOT NULL REFERENCES kernel.tenants(id),
  subject_type text NOT NULL,        -- 'record' | 'site' … never interpreted by the kernel
  subject_id   uuid,                 -- the record id; no FK — telemetry must never block a record
  name         text NOT NULL,        -- app vocabulary: 'open' | 'tap' | 'kit'
  kind         text,                 -- tap: link kind; kit: template/format
  source       text,                 -- 'tt' | 'x' | 'th' | 'ig' | 'li' | 'qr' | 'direct'
  inapp        text,                 -- 'instagram' | 'threads' | 'tiktok' | 'line' | 'facebook' | null
  visitor_hash text,                 -- sha256(HMAC(secret, bangkok-date) ‖ ip ‖ ua)[0:16]; never the IP
  created_at   timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX hits_subject_idx ON kernel.hits (tenant_id, subject_type, subject_id, created_at);
CREATE INDEX hits_time_idx    ON kernel.hits (tenant_id, created_at);
ALTER TABLE kernel.hits ENABLE ROW LEVEL SECURITY;
ALTER TABLE kernel.hits FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON kernel.hits
  USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
  WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);
GRANT SELECT, INSERT, DELETE ON kernel.hits TO ai_kernel_app;   -- append-only + purge; no UPDATE
```

Core (`packages/core/src/hits/`): `recordHit(ctx, …)` needs `hits.write`; `countHits(ctx, {subjectType, subjectId, since})` → `(name, kind, source, d7, d30)` needs `hits.read`; `purgeHits(ctx, days)`; job `hits.purge` on `15 3 * * *` Asia/Bangkok, one schedule per deployment like `outbox.sweep`. Drizzle mirror appended.

**RLS shape:** tenant isolation only, as for `records`. Ownership is the repository's, exactly as for cards: `cardNumbers(handle, by: CardActor)` in `lib/cards/numbers.ts` resolves the card through `getCardAnyIn` with the actor (mismatch → `CardError("forbidden")`), then `countHits` on that record id under `runAsSystem({ permissions: ["hits.read"] })`. No public route ever holds `hits.read`.

**The query** (one round trip):

```sql
SELECT name, kind, source,
       count(*) FILTER (WHERE created_at >= now() - interval '7 days') AS d7,
       count(*) AS d30
FROM kernel.hits
WHERE subject_type = 'record' AND subject_id = $1 AND created_at >= now() - interval '30 days'
GROUP BY 1, 2, 3;
```

**Retention:** raw 90 days, purged nightly; `/me` shows 7 and 30 days only. No lifetime totals in v1 (a rollup, v2 when a seller asks).

**What she sees** (Todd, under each card row, collapsed by default):

| Key | TH | EN |
|---|---|---|
| `nums.title` | ตัวเลขของการ์ดนี้ | This card's numbers |
| `nums.d7` / `nums.d30` | 7 วันล่าสุด / 30 วัน | Last 7 days / 30 days |
| `nums.open` | เปิดการ์ด | Card opens |
| `nums.tapPrimary` | แตะ {platform} | {platform} taps |
| `nums.tapOther` | แตะลิงก์อื่น | Other link taps |
| `nums.kit` | รูปจากชุดภาพที่โหลด | Kit images loaded |
| `nums.from` | มาจาก | From |
| `nums.src.*` | TikTok · X · Threads · Instagram · LINE · QR · ลิงก์ตรง · อื่น ๆ | … · Direct · Other |
| `nums.honest` | เรานับจำนวนครั้งที่แตะ ไม่ได้นับจำนวนคน เก็บไว้ 90 วัน | We count taps, not people. Kept for 90 days. |
| `nums.empty` | ยังไม่มีใครแตะในช่วงนี้ — ใส่ลิงก์ในไบโอแล้วหรือยัง | No taps in this period yet — is the link in your bio? |
| `nums.links` | ลิงก์สำหรับแต่ละแอป | One link per app |

`nums.links` prints `…/c/ploy?s=tt`, `?s=x`, `?s=th`, `?s=ig`, `?s=li` with a copy button each — the six codes the caption pack gets in N6. Figures are `v-num`, no charts, no arrows, no percentages. `{platform}` is the primary link's `kindName`.

**Source resolution** (`lib/track.ts`, pure): `?s=` in the allow-list → else UA class (`Instagram`, `Barcelona` = Threads, `BytedanceWebview`/`musical_ly` = TikTok, `Line/` = LINE in-app, `FBAN`/`FBAV`) → else `Referer` host (`t.co` → x, `l.instagram.com` → ig, `threads.net` → th) → else `direct`. The card page passes its resolved `s` into every `go` href, so a tap inherits the open's source without a cookie.

## 4. James's operator view — `/admin/numbers`

Better Auth operator, `noindex`, screen name pending James (proposal **Brockman**). One page, three blocks, no charts:

1. **Per day, 14 days** from `records`: cards created · published (`publishedAt` stamped in `data` on first `draft→published`) · with photo · Pro approved · `pro_request` requested/approved/declined · reports; grouped by `date_trunc('day', created_at AT TIME ZONE 'Asia/Bangkok')`.
2. **Alive cards:** published cards with ≥ 1 `tap` in 7 days, by handle with taps/opens — the one place a per-card table exists — plus the "zero taps in 14 days" list (the DM-again set).
3. **Where sign-ups come from:** James's own posts carry per-post tags he controls (`/?s=x&p=0917a`); the landing sets a first-party `ps` cookie (value `s|p`, 7 days, no identifier); `/c/new` copies it into `data.source`. Cards created per `source|post`, and `open` hits on `subject_type='site'` per `source|post` — "which tweet worked" by construction.

**The two numbers that decide** (no `launch-runbook.md` exists; defined here, James confirms in §7.5):

- **Alive ≥ 10 at day 30** — published cards with a real tap in the last 7 days. Below 5: cards are not going into bios; the product, not the tweet, is the problem.
- **Pro approved ≥ 3 by day 45** (฿1,497). Zero with alive ≥ 10 means the free tier is the product; the price or the unlock is wrong.

Leading indicator: cards created per week from James's posts ≥ 5. At 0, fix the post, not the app.

Weekly digest (optional N7): a Monday 09:00 ICT job builds the three numbers and `enqueueOutbox("numbers.weekly", …)` → the existing webhook → James's phone.

## 5. Bots and abuse

How it is gamed: a seller taps her own button (lies only to herself; taps rank nothing, so no payoff); a flood on a handle (inflates, moves no public number); preview and security scanners (Twitterbot, facebookexternalhit, LINE's crawler, SafeLinks, HeadlessChrome) fetch the card and sometimes follow anchors; monitors and curl.

The `go` route **always redirects** and counts only when all hold:

1. `GET` — link checkers use `HEAD`.
2. Came from the card page: `Sec-Fetch-Site: same-origin`, or `Referer` host ours and path `/c/<handle>` (our `Referrer-Policy strict-origin-when-cross-origin` sends the full path same-origin; in-app browsers send it). A direct hit redirects silently.
3. UA not in the crawler list (`bot|crawler|spider|preview|externalhit|Twitterbot|Slackbot|HeadlessChrome|curl|python-requests|Go-http`). `Line/` is the in-app browser, not a bot.
4. Per-IP bucket on `go`: 20/min per `clientIp()`; over the cap, redirect uncounted. Dedup: one counted tap per `(visitor_hash, subject, kind)` per 10 minutes (one `EXISTS` on the subject index).
5. `open` drops crawler UAs (OG fetchers are not opens). Photo and OG routes are never counted.

Accepted: bots with clean UAs (a few percent), self-taps, one person on two devices. Hence "taps, not people" and no rates. Under Cloudflare orange without `trusted_proxies` the caps collapse onto Cloudflare IPs — the failure is under-counting, never over-counting, but public-flip WO6 must precede orange.

**PDPA.** Transient: IP and UA at request time (cap, hash, class). Stored 90 days: a salted daily hash (salt = HMAC of `PAIDUAY_CHAT_SECRET` and the Bangkok date, never persisted), a source code, an in-app class; the `ps` cookie is a campaign code, not an identifier. Never stored: IP, full UA, referrer URL. `/policy` gets a paragraph, TH first: what we count, no third parties, no IP kept, 90 days, shown only to the card's owner. No banner — no analytics cookie is set, and the PDPC guidance asks consent for cookies. A Caddy `log` on the host, if enabled, rolls at 7 days and is debugging, not analytics.

## 6. Work orders for Opus (one evening, ≈ 7.5 h)

| WO | Work | h | Files | Tests | Live check |
|---|---|---|---|---|---|
| **N1** | `0005_hits.sql`; Drizzle mirror; `recordHit`/`countHits`/`purgeHits`; `hits.write/read`; `hits.purge` cron; core export | 1.5 | `packages/core/migrations/0005_hits.sql`, `packages/core/src/hits/**`, `src/db/schema.ts`, `src/index.ts` | DB test under `ai_kernel_app`: tenant B reads 0 of A's rows; purge only > 90 d; root typecheck | `pnpm db:migrate`; `\d kernel.hits` shows FORCE RLS |
| **N2** | `lib/track.ts` (source, UA class, hash, bot rules, bucket); `go/[kind]/route.ts` (302 via `linkHref`, `no-store`, §5 rules); `CardPage` links → `go` with `count` prop; `?s=` carried | 2.5 | `apps/paiduay/lib/track.ts`, `app/c/[handle]/go/[kind]/route.ts`, `components/card/card-page.tsx`, `components/card/links.ts` (`goHref`) | unit: source, UA classes, bot list, referer rule; route: HEAD → no row, no referer → no row | `curl -I …/c/mint/go/line` → 302 to `line.me/ti/p/~…`; with Referer + phone UA → one row; 30 hits/min → 302s, ≤ 20 rows |
| **N3** | `open` via `after()` on `/c/[handle]`; `kit` in `kit/image/route.tsx` (402 not counted); `site` open on `/` | 0.5 | those three files | — | `/c/mint?s=tt` from inside TikTok → `source=tt, inapp=tiktok` |
| **N4** | `lib/cards/numbers.ts`; `Numbers` block on `/me`; `nums.*` copy; the one-link-per-app row | 1.5 | `apps/paiduay/lib/cards/numbers.ts`, `app/me/{page.tsx,copy.ts,owner.css}` | foreign owner → `forbidden`; 7/30 split shape | owner's figures equal a hand `count(*)`; another user's handle → not shown |
| **N5** | `/admin/numbers`; `publishedAt` on first publish; `ps` cookie → `data.source`; screen constant | 1.5 | `app/admin/numbers/page.tsx`, `lib/cards/repo.ts`, `app/c/new/actions.ts`, `lib/screens.ts` | `publishedAt` set once | a card from `/?s=x&p=t1` shows under `x\|t1` |
| **N6** | `?s=` in caption pack + kit gallery; `/policy` paragraph TH/EN; spec §9; tracker; `03_ENTRYPOINT.html` | 0.75 | `content/card-captions.ts`, `components/kit-gallery/gallery.tsx`, `app/policy/**`, docs | copy tests (TH and EN present) | James reads `/policy` on a phone |
| N7 (opt.) | `hits.digest` Monday job → outbox → webhook | 1 | `lib/notify/**`, module schedule | fake-clock job test | the phone buzzes with three numbers |

Order N1 → N2 → N3 → N4, then N5, N6; drop N5 first if short (James can run the SQL by hand for a week; the seller's numbers cannot wait). Prod: `docker compose --profile ops run --rm ops pnpm db:migrate`, redeploy paiduay. Gate: app + root typecheck, `next build`, vitest green, the N2 and N4 live checks pasted into §9.

## 7. What only James decides

1. **The hop:** links become `/c/<handle>/go/<kind>` (recommended); the alternative keeps hrefs pristine and leaves Ploy's question unanswered.
2. **Sellers see numbers** from day one on `/me` (recommended), or operator-only until 30 sellers.
3. **Third-party dashboard:** none (recommended), Cloudflare Web Analytics now (changes `/policy`), or Umami when the box grows.
4. **Retention:** 90 days raw, 7/30-day views (recommended), or 30.
5. **The two continue/stop numbers** in §4 and their dates.
6. **Screen names:** `/admin/numbers` (proposal Brockman); whether the `/me` block is its own screen or part of Todd.
7. **The six source codes** (`tt x th ig li qr`) as the taught vocabulary; per-post codes only for James's own posts.
