# RoninConv — AI / Developer Guide

> The canonical machine-readable guide to RoninConv. Served live at
> **`https://mcp.roninconv.com/docs`** (this page, HTML) · **`/docs/ai`** (raw markdown) ·
> **`/llms.txt`** (alias) · **`/docs/tools.json`** (live MCP tool catalog) — the same paths also
> answer on `https://api.roninconv.com`. Available inside MCP as resource
> `roninconv://docs/ai-dev-guide` and via the `get_docs` tool.
> Source of truth in the repo: `docs/AI_DEV_GUIDE.md` (bundled into the API worker at deploy).

RoninConv is a first-party click-tracking platform: **zero-redirect lander serving,
flow-based offer routing, postback attribution, breakdown reporting, and real Meta ad-spend
sync** — built on Cloudflare Workers + Neon Postgres. The whole platform is operable by AI
agents through its **MCP server**; the dashboard and the MCP surface sit on the same API.

## Campaign-name token

Use `{bold_campaign_name}` in dashboard-generated campaign tracking links, offer URLs, network
sub-params, landing-page URLs, or outbound conversion postback templates for a normalized
RoninConv campaign name. It removes characters
other than ASCII letters, digits, underscores and whitespace, trims the result, collapses whitespace
to `_`, and uppercases letters: `Anthropic IPO (Sales) - TEST` → `ANTHROPIC_IPO_SALES_TEST`.
The token spelling is case-sensitive (all lowercase). It is derived from RC's campaign name, not the
ad-platform campaign name or an incoming query parameter, and needs no traffic-source role mapping.
`{campaign_name}` returns the unchanged campaign name. Both are resolved from the saved campaign
when the dashboard generates its tracking link; click-time macros and `{{platform}}` macros are
preserved there. The shared token catalog exposes them in the dashboard token reference and URL
pickers; the same template strings work through the existing API and MCP entity tools.

## Phone/email-role identity enrichment

This section describes incoming query-token enrichment. Explicit form submission capture below
validates submitted identity separately; the existing query-token behavior is unchanged.

The `email` role similarly fills missing/trim-empty `email_hash`, preserving raw email.
Its normalization is ingest's trim + lowercase + UTF-8 SHA-256 lowercase hex, with no hash for
blank input, no provider rewrites and no new syntax validation. Valid emails match rp-edge;
rp-edge separately validates syntax. Without a hash mapping, `visit.params._rc.email_hash`
backs templates and role rules. Both identities derive independently and supplied hashes survive.

Incoming campaign traffic with an enabled `phone` role automatically fills a missing/trim-empty
`phone_hash` before routing and visit storage. Source parameter aliases are authoritative; existing
nonblank hashes are never replaced. Normalization follows ingest/rp-edge: validated NANP numbers
become eleven digits including country `1` (no `+`), then UTF-8 SHA-256 lowercase hex. Unsupported
numbers continue without a derived hash. No UI toggle or source rewrite is required.
Without a hash-role mapping, server-owned `visit.params._rc.phone_hash` supplies `{phone_hash}` and
role rules without occupying a customer slot. Explicit hash-role values take precedence, then
the protected identity, then legacy slots only when no identity is available. Literal slot tokens
stay unchanged; canonical `phone_hash=` can supply the fallback if no other role owns that parameter.
This is pseudonymous identity data, not encryption/anonymization. No historical backfill or new
hash-only conversion resolver is implied. See `docs/TOKENS.md` for the full precedence contract.

## Meta delivery integrity

Pixels follow their graph attachment's activity: ordinary untagged lander attachments wait for
that exact served lander's accepted lead; entry/path/filter and routing-arrow attachments fire
on selected arrival/traversal; offer attachments wait for an attributed conversion. Page serve is never a
lander Lead. A verified internal `/lead` with valid normalized email or phone admits lead activity
when the router accepts its queue batch, without waiting for persistence or RP-edge forwarding.
Queue failure returns 503 rather than acknowledging intake. After successful external intake,
navigation to the current signed `__rc.offer_url` admits the same per-visit lead activity;
internal intake plus navigation dedupe. Forms staying on-page explicitly call
`await __rc.acceptLead(payload)` only after successful intake (for example, `response.ok`).
`prepareLead`, identity capture and lander page serve never admit lead activity. Traversal uses
PageView or the configured event and the original activity time. Do not replay historical failed
page-visit Leads.

### Configured lander Meta hybrid

Only active Meta pixels directly attached by an untagged edge, in either direction, to the exact
served lander node or its selected lander-matrix owner receive browser configuration. Entry/path/filter,
routing-arrow, offer and unattached pixels retain S2S-only behavior. Authors still must not
install `fbq` or any third-party pixel: sanitization strips author installs as before. The sole
exception is the router-injected, configured Meta runtime on trusted non-preview landers.

The browser receives `__rc.meta_pixels`: public pixel/dataset ID, resource/node IDs, configured
event name and custom data (`value`, `currency`, optional `content_name`), never access tokens,
credentials or a private config blob. Event override/default and amount/currency remain attached
to that descriptor. The accepted-lead snapshot freezes which lander descriptors can fire; adding
a pixel after page serve does not attach it retroactively. Archived/deleted pixels or a changed
dataset cannot receive a stale CAPI delivery.

After successful intake, existing navigation to the current signed `__rc.offer_url` works without
an author pixel: `/out/optin` serves a no-store, no-referrer bridge for signed Meta descriptors,
scrubs its capability/contact query with `history.replaceState` before loading the SDK,
fires targeted configured events and redirects after a bounded wait. Non-Meta navigation remains
a redirect. Staying forms call `await __rc.acceptLead(payload)` after success; the same hook is
an optional pre-navigation optimization, not a replacement for intake. No global fetch/form
interception is installed. A blocked Meta SDK must not prevent intake or navigation.

The runtime initializes Meta only at acceptance, with hashed advanced matching, and uses
`trackSingle` for standard events and `trackSingleCustom` for custom events, never broadcast
tracking or an automatic lander PageView. Browser `eventID` and hybrid CAPI `event_id` are both
the **exact `click_id`**. Meta deduplicates by dataset + event name + click ID: repeated instances
of the same event on the same dataset across funnel stages/nodes intentionally collapse.
Different event names/datasets remain distinct. Duplicate dataset/event attachments on one
served lander coalesce deterministically to the first attachment, including its amount.

`__rc.meta_matching` and browser/CAPI matching use available SHA-256 `em`, `ph`, `fn`, `ln`,
`external_id`, `ct`, `st`, `zp`, `country`. Valid supplied SHA-256 hashes are not double-hashed;
names are normalized lowercase Unicode letters without punctuation, and geo follows Meta rules
(US ZIP first five digits). `external_id` hashes supplied `lead_hash`, otherwise `click_id`.
Raw email/phone and optional names are accepted through the existing signed identity flow;
these browser matching fields are hashes only, not raw contact or a new arbitrary form schema.
Stored cookies (`fbp`/`fbc`), UA and query-free source URL remain separate CAPI context.
Existing raw IP retention is unchanged; historical salted IP hashes cannot become raw IP.
An optional 15-minute, visit-bound matching receipt preserves hashes across signed navigation:
it is encrypted and authenticated, HttpOnly, scoped to the origin/workspace/click/visit and
cleared by the bridge. It contains hashes only, never raw PII. Matching hashes are not encoded
in the signed handoff URL. Browser JS persistent storage holds no contact or matching hashes;
runtime dedup markers contain IDs only and are persisted only after the SDK is available.

Meta's [browser/server deduplication contract](https://developers.facebook.com/documentation/ads-commerce/conversions-api/deduplicate-pixel-and-server-events)
requires the same dataset, event name and event ID. [Advanced matching](https://developers.facebook.com/documentation/meta-pixel/advanced/advanced-matching.md)
and [CAPI customer information](https://developers.facebook.com/documentation/ads-commerce/conversions-api/parameters/customer-information-parameters)
use the same normalized hash values here.

### Durable delivery and cutover

Meta-only outbox delivery is independent of financial queue ingestion: immutable event IDs,
payloads and dataset targets survive delivery retries. Hybrid lander activities use exact
`click_id`; other Meta activities retain per-node/activity `pxe_…` IDs. Old queued/historical
activities without the hybrid snapshot keep their prior IDs, never a fabricated browser pair.
Blank node amount uses the activity default (canonical payout for conversions, zero for leads
or traversal); explicit numeric overrides and upstream zero remain supported.
Meta uses the outbox instead of non-Meta delivery claims, so a preclaim cannot suppress its
enqueue retry. A leased one-minute cron
sends 50 records per run with five concurrent sends and eight bounded attempts with backoff.
Transport/429/5xx retry; explicit non-429 4xx stops. Exhausted rows remain available for inspection,
not automatically replayed. Credentials come from the current same-workspace pixel, never the outbox.
New conversions carry `_rc.pixel_activity_v: 1`. Retries of pre-cutover conversions retain their
original outbox identities and never manufacture another delivery under the new node-scoped IDs.
Original EVENTS queue and DLQ bindings are unchanged. Apply additive `043_meta_outbox.sql` and
`045_pixel_activity.sql` before shipping ingest; preserve `044_conversion_external_identity.sql`
and its workspace/network-scoped historical conversion identity protection. Do not incidentally
apply unrelated pending migrations. No campaign setup changes are made by this cutover.

**KV publication:** graph publications now carry `meta_pixel_v: 1` and public Meta descriptors.
At rollout, republish existing campaigns through `update_campaign` / `PATCH /links/:id`
(for example, send the campaign's existing name without changing it). Pixel edits and bulk
status/delete already republish referencing campaigns. Until republished, an old KV graph
retains its existing S2S-only lead activity and IDs; it is not treated as an intentionally empty
hybrid snapshot. A new versioned empty snapshot never gains a later-attached pixel.


## Submitted identity and stored-first RP-edge forwarding

Integrated forms call `await window.__rc.prepareLead(payload)` immediately before their existing
lead POST. Its shared `captureIdentity` operation queues optional names, raw email/phone, hashes
and supplied lead hash through router-only `POST /identity`. The destination is unchanged for both
direct RP-edge intake and RC's internal `/lead/:link` intake; unrelated forms are never intercepted.
This is a signed public browser capability, not a workspace management API/MCP mutation.

**Full-name normalization (branch implementation; deployment pending):** submitted JSON and SDK
contact helpers accept string `full_name`, `fullName`, or `name`, in that own-property priority.
The first whitespace-delimited word becomes `first_name`; remaining words become `last_name`.
A single word produces an empty last name. Unicode whitespace is collapsed in derived components;
split occurs before each component's 300-JavaScript-code-unit cap. Explicit first/last fields
(including empty or invalid values) block derivation independently; snake_case wins over camelCase.
Non-string/blank full names are ignored, and a present higher-priority alias blocks lower aliases.
The original full-name field remains in the ordinary lead payload/JSONB and forwarding, but SDK
identity requests contain canonical derived names rather than the raw full-name alias. Existing
signed visit authority is still required for identity mutation. Meta uses canonical names for
`fn`/`ln` matching hashes; names alone never mint a lead hash. Existing first/last role tokens and
SDK offer-URL refresh consume derived names, respecting disabled roles. There is no full-name
role/token. Query fallback accepts only `full_name`/`fullName`, not generic query `name`.
When exactly one component is explicit and nonblank, an exact case-sensitive token-boundary
prefix (first name) or suffix (last name) determines the missing remainder. Comparison collapses
Unicode whitespace, without changing explicit output's existing trim/cap or internal spacing.
For `full_name: "Ada Maria van Example"`, explicit `first_name: "Ada Maria"` yields missing
`last_name: "van Example"`; explicit `last_name: "van Example"` yields `first_name: "Ada Maria"`.
An entire-name match leaves the other component empty. Nonmatching, empty or invalid explicit
values retain first-word/rest fallback for the other field; both explicit fields remain untouched.
Whole-token matching prevents `Ann` matching `Anna`. This is not locale, title or surname
inference: without an explicit boundary, `Dr Ada Lovelace` becomes `Dr` / `Ada Lovelace`.
Supply both explicit components when that fallback is unsuitable. HTML input names are not
automatically wired: the submit handler must map its input into JSON and call `prepareLead`.
See [the integration example](./roninconv.md#full-name-inputs) and retain existing intake/failure handling.

The capability binds origin, expiry, workspace, campaign, click, exact visit, stage and lander.
Funnel `fi` proof prevents an arbitrary known `fc` click ID from gaining capture permission.
Untrusted legacy-style continuations retain navigation but cannot mutate or donate stored identity.
Internal `/lead` verifies the same capability, strips it from forwarded payloads, and uses the same
ingest merge. Caller-supplied identity contexts never authorize a write.

Canonical submitted fields live in `visits.params._rc.identity`; configured first-class identity-role
slots are populated too. Valid existing fields, UTMs, attribution snapshots and provider transactions
survive duplicate or concurrent writes. A compare-and-swap retries rather than clobbering another
JSONB update. Capture does not create visits, clicks, conversions, or payouts.

Submitted email is trimmed/lowercased and checked with RP-edge's minimal `something@something`
sanity rule; NANP phone is stored as `+1XXXXXXXXXX` and hashed as eleven digits without `+`.
Hashes are SHA-256 lowercase hex. Invalid/blank raw identity is not hashed; valid supplied SHA-256
hashes remain usable without raw contact. Names are optional trimmed contact context in stored
identity; Meta matching separately hashes normalized names. Only a supplied lead hash is retained.
Contact normalization rules match
[RP-edge's resolver](https://github.com/roninpublishing/rp-edge/blob/c07db605e6fbbf9ce4217532b62c1c58c3d7f1ce/server/resolver.js).
Existing permissive query enrichment is not silently redefined by this form-capture contract.

The SDK makes three bounded keepalive capture attempts and lets the original lead POST proceed
even on outage. `__rc.identity_status`, `rc_tracking.identity_status`, and non-PII `rc:identity`
events distinguish pending/retrying/queued/failed. HTTP 202 means **queued**, not persisted.
Browser retries contain no persistent-storage PII and can end when the page closes; see the
[lander contract](./roninconv.md#persisting-submitted-identity) for retry and integration details.

RP-edge conversion forwarding keeps its existing per-workspace **default-off** switch and configured
URL/secret. Nothing in capture enables it. When already enabled, conversions publish a separate
`edge_conversion` pointer to the existing EVENTS queue. Ledger revenue and other feedback stay
independent from identity timing and recipient outages; duplicate queue retries recover a failed
pointer publish without duplicating the financial insert or other feedback.
`workspace.settings.edge.excluded_refs`/`excluded_networks` (managed via `GET`/`PUT
/edge-settings`) name campaign refs and network ids skipped even while forwarding is on, for both
this conversion path and internal RC lead forwarding (`sendLeadToEdge`); the same two lists cover
both. A conversion's network comes from its own stored `raw._rc.network_id`; a lead's comes from
its link's `advertiser_network_id` (a lead row carries no network itself, so this is a second
lookup, only run when `excluded_networks` is non-empty). An excluded lead is recorded durably and
marked `forwarded: false, forward_error: 'excluded'`, distinct from a real delivery failure.
Internal RC leads are stored and delivered independently before their trusted identity event is
published. A missing visit or failed identity write cannot suppress that original lead delivery.

Delivery prefers the accepted visit's stored identity, then trusted completed submissions on other
stages of the same workspace/campaign/click. Inbound callback fields fill only missing identity;
existing callback hash precedence and MD5-email fallback remain supported. Raw/hash pairs stay
consistent: an existing hash cannot gain a conflicting submitted or callback raw contact.
The original durable callback is not rewritten with enrichment. An incomplete capture-capable
accepted visit gets forwarding grace on attempts 1–2 even if an earlier stage completed capture;
attempt 3 permits earlier-stage/callback fallback.
Optional missing phone/names never cause indefinite waiting. Transport/non-2xx failures use the bounded
retry/DLQ policy; the stable RC conversion ID remains the recipient's deduplication `txid`.
RC sends optional names, but RP-edge's current `/rc/postback` receiver does not retain those fields.

### Rollout and live verification gate

- Ship the router/SDK and ingest worker together; ingest now produces to its existing EVENTS queue.
  No new table, migration, queue, or deployment secret is required.
- Keep the current Everflow callback hash macros in place until stored-identity recovery is proven.
- Keep RP-edge conversion forwarding disabled unless separately approved; deployment is not approval
  to toggle `forward_to_edge`.
- Run `npm run test:identity` for local SDK/router/PostgreSQL/receiver behavior. Fixtures never
  contact a live intake or send live financial callbacks.
- After deployment approval, follow a naturally occurring integrated lead: verify the same click's
  visit identity, unchanged lead destination/counts, and subsequent naturally occurring conversion
  enrichment if forwarding is separately enabled. Do not manufacture financial callbacks.
- No historical identity backfill or conversion rewrite is part of this rollout.

## Architecture

```
ad click ──▶ router  (edge Worker, any attached domain)   GET /:campaignId → serve lander HTML
                │      zero redirects · mints click_id · injects offer URL · logs visit
                │      POST /e  CTA-click beacon      GET|POST /pb  network postbacks
                ▼
             EVENTS queue ──▶ ingest (Worker)  batch-writes Neon · fires webhooks + S2S
                                    conversion feedback to ad platforms (TikTok/Meta/OpenAI)
             api (Worker, api.roninconv.com)   auth · CRUD · reporting · KV route sync ·
                │                              Meta spend cron · MCP server (/mcp) · these docs
                ▼
             ui  (React/Vite on CF Pages, app.roninconv.com — behind Cloudflare Access)
             db  (Neon Postgres; packages/db = schema + migrations)
```

- **Multi-workspace, multi-user.** Every tenant-owned row carries `workspace_id`. API keys are
  workspace-pinned; user sessions pick a workspace per request (`X-Workspace` header).
- **Storage and wire timestamps are UTC** — visits, conversions, report windows, and Meta spend
  (advertiser-timezone hours are converted to UTC instants at sync). Each user can save an IANA
  timezone for report calendar days and dashboard timestamp display; existing users default to UTC.
- **Zero redirects:** the ad URL serves the lander directly; the CTA href is the offer URL with
  our `click_id` injected at render time.

## Connect an AI agent (MCP)

The MCP server speaks **Streamable HTTP (stateless)** at
**`POST https://mcp.roninconv.com/mcp`** (canonical; `https://api.roninconv.com/mcp` and a bare
`POST https://mcp.roninconv.com/` also answer). Two ways to authenticate:

**Option A — OAuth (claude.ai custom connectors, zero setup).** In claude.ai: Settings →
Connectors → *Add custom connector* → paste `https://mcp.roninconv.com/mcp`. Claude discovers the
server (RFC 9728 → RFC 8414), registers itself (RFC 7591 dynamic registration), and sends you to
the RoninConv consent page: sign in (password + 2FA if enabled), then pick **one workspace** and
an access level (**Full** = capped at your role · **Read-only** = reports + entity reads). Tokens
are PKCE-bound, workspace-pinned, hashed at rest, 1-hour access with rotating 30-day refresh, and
revocable in Settings → AI agents (or via `revoke_oauth_connection`). Unauthenticated requests to
`/mcp` receive `HTTP 401` + `WWW-Authenticate` pointing at
`/.well-known/oauth-protected-resource/mcp` — that is the flow trigger.

**Option B — workspace API key** in the Authorization header (mint in Settings → AI agents /
API keys, or with the `create_api_key` tool). Best for headless agents, CI, and server-side
fleets.

Claude Code:

```bash
claude mcp add --transport http roninconv https://mcp.roninconv.com/mcp \
  --header "Authorization: Bearer rk_XXXX.YYYY"
```

Any MCP client (`.mcp.json` / Claude Desktop style):

```json
{
  "mcpServers": {
    "roninconv": {
      "type": "http",
      "url": "https://mcp.roninconv.com/mcp",
      "headers": { "Authorization": "Bearer rk_XXXX.YYYY" }
    }
  }
}
```

Raw JSON-RPC (works with `curl` — every POST needs a credential; the tool catalog is public at
`/docs/tools.json` if you just want to browse):

```bash
curl -s https://mcp.roninconv.com/mcp -H 'content-type: application/json' -d '{
  "jsonrpc":"2.0","id":1,"method":"tools/call",
  "params":{"name":"whoami","arguments":{}}
}' -H 'Authorization: Bearer rk_XXXX.YYYY'
```

Notes for agents:

- Call **`whoami`** first — it returns your workspace, actor type and exact permission strings.
- **Workspace scoping is absolute.** API keys are pinned to one workspace at mint time; every
  query the server runs filters by that workspace server-side. An agent holding a workspace key
  can never read or write another workspace — there is no cross-workspace parameter to pass.
  A user **session token** (`sess_…`) also works but must send `X-Workspace: <workspace-id>`
  (limited to that user's memberships). One agent per workspace, one key each, is the model.
- The tool catalog with full JSON schemas is public at `/docs/tools.json` and via `tools/list`.
- Tool errors return `isError: true` with the API's real status + message (401 → bad/missing
  key; 403 → your key lacks that scope; 4xx → validation detail).
- The server is stateless: no session ids, every request self-contained. `GET /mcp` returns 405.
- `mcp.roninconv.com` serves only the MCP endpoint and these docs; the REST surface lives on
  `api.roninconv.com`.

## Auth & permissions

Two credential types, one permission model:

| Credential | Format | Workspace | Notes |
|---|---|---|---|
| API key | `Authorization: Bearer rk_<id>.<secret>` | pinned at mint | for agents/machines; scopes at mint time |
| OAuth token | `Authorization: Bearer oat_…` | pinned at consent | claude.ai connectors etc.; scopes = user role ∩ consent preset, re-checked against live membership every request |
| Session | `Authorization: Bearer sess_…` + `X-Workspace: <ws>` | per request | humans via `POST /auth/login` |

Permission strings (a key minted with scope `full` has all of them):
`reports:read` · `reports:identity` · `entities:read` · `links:write` · `landers:write` · `offers:write` ·
`domains:write` · `webhooks:manage` · `conversion_events:write` · `keys:manage` · `members:manage` · `preferences:write`.
Two more are **explicit-opt-in** and are deliberately NOT granted by `full`; a key gets them
only by naming them in its scope list: `meta_spend:write` (machine-to-machine cost ingest) and
`ops:read` (global stack health, spans every tenant).
Roles are bundles: **owner** = all · **editor** = report/entity reads + entity writes + webhooks ·
**viewer** = report/entity reads only. Arbitrary retained bodies, custom URL/postback parameters
and identity context require `reports:identity` in addition to `reports:read`; only owner includes
it by default (a `full` API key also includes it). Editors/viewers and read-only scoped keys get
an allowlisted, identity-redacted projection, including no arbitrary body or URL query. Credentials
are always redacted regardless of permission. All roles can also update their own preferences
(`preferences:write`); API keys have
no user identity and cannot save user preferences even with that scope. Superadmins additionally
create workspaces. Mint narrow keys for narrow agents,
e.g. `["reports:read","entities:read"]` for a reporting-only agent. The dashboard's key mints
(Settings → API keys, Settings → MCP) carry an "add `ops:read`" checkbox for the same reason —
an agent that needs `get_stack_health` cannot get it from `full` alone.
Owner sessions derive permissions from their live role, including cached sessions; full API keys
expand ordinary permissions at request time. Apply idempotent `055_event_identity_scope.sql`
to preserve existing full owner-level OAuth consents without requiring reauthorization. It adds
only `reports:identity`, only when `scope_label='full'` already included both `reports:read` and
`members:manage`; narrowed/read/editor grants are unchanged. Live membership is still intersected
on every OAuth request, so demotion or removal never retains owner-only inspection.

Session actors are cached in KV for 60 seconds after resolution (the platform minimum), so
authenticated requests skip the DB round trip. Logout, revoke-all, admin password reset, self
password change, role change and member removal all purge that cache, so the window only ever
applies to a session lapsing on its own clock.
Timezone preferences are read fresh from the user row, not the cached workspace actor.

**Forced password change.** `POST /members/:userId/reset-password` mints a temporary password,
sets `must_change_pw` on that user, deletes their sessions AND revokes their OAuth tokens (a
token outlives a session, so a reset that left it alive left the account reachable). `POST
/members` sets the same flag on a user it invites, since the inviter knows that temp password.
A flagged user cannot complete the `/oauth/authorize` consent either. Until they clear it,
`GET /me` reports `actor.must_change_pw: true` and every route answers
`403 {"error":"password change required","must_change_pw":true}` — for sessions and for OAuth
tokens alike. The only exceptions are `GET /me`, `GET /auth/status`, `POST /auth/password`
(which clears the flag) and `POST /auth/logout`. API keys carry no user identity and are never
affected. An MCP `tools/call` on behalf of a flagged user gets the same 403, because the tool
handler re-dispatches into the REST route.

## Object model

| Object | Id prefix | What it is |
|---|---|---|
| workspace | slug | tenant boundary; a business = a workspace |
| campaign (API: "link") | 8-char slug | tracked link + routing flow; the public ad URL is `https://<domain>/<id>` |
| lander | `ld_` | hosted landing-page HTML, versioned by (name, version), served at the edge |
| offer | `of_` | monetized destination; `url_template` with `{click_id}` `{ref}` `{link_id}` placeholders |
| network | `nw_` | affiliate network grouping offers; drives postback templates |
| domain | `dm_` | serving hostname, provisioned as a Cloudflare for SaaS custom hostname on the platform zone; the client CNAMEs at the SaaS target |
| traffic source | `ts_` | attribution and S2S conversion feedback config (tiktok / meta / openai / coreg / hermes_sms / trackly_sms / custom); beehiiv = tokens only, no S2S yet |
| visit / click / conversion | — | append-only event rows; joined by `click_id` |
| conversion event | `ce_` | workspace definition of an immutable conversion event name and editable description |
| meta connection | `mc_` | Meta access token; discovers ad accounts → campaigns → ad-level UTC spend |
| API key | `rk_` | workspace-pinned machine credential |
| webhook | `wh_` | signed outbound event fan-out to your endpoints |

**The routing flow** (campaign field `routing`) decides what each visitor gets. Preferred shape —
the graph the dashboard flow-builder edits:

```json
{ "graph": {
    "entry": "entry",
    "nodes": {
      "entry": { "id": "entry", "kind": "entry" },
      "n1": { "id": "n1", "kind": "lander", "ref": "<lander_id>", "priority": 1, "when": { "device": ["mobile"] } },
      "n2": { "id": "n2", "kind": "offer",  "ref": "<offer_id>" },
      "n3": { "id": "n3", "kind": "path",   "when": { "device": ["mobile"] } },
      "n4": { "id": "n4", "kind": "filter", "when": { "rule": "rl_…" } }
    },
    "edges": [
      { "id": "e1", "from": "entry", "to": "n1", "weight": 1 },
      { "id": "e2", "from": "n1", "to": "n2", "weight": 1 },
      { "id": "e3", "from": "n4", "to": "n2", "exit": "fail" }
    ]
} }
```

**A condition (`when`) and a priority rank always live on the node being routed TO, never on the
edge.** This applies to Path, Lander, Offer and (for its own block-condition) Filter alike, not
just Path. The dashboard's Connection inspector sets routing `weight` and attached pixel nodes;
it cannot set a condition. (`edge.when`/`edge.priority` are still read for older saved graphs;
nothing written before this existed breaks, but the builder never writes them again, so treat
them as legacy.)

The dashboard canvas displays **precalculated weight percentages**, while the Connection inspector
and API retain raw `weight` values. Each split normalizes within its source, effective priority,
and conditional/fallback pool; pixel attachments, fail exits and terminal filters do not dilute
the denominator. Percentages round to at most two decimals and total 100% within each split;
singleton pools have no percentage badge. These are configured shares, not a visitor forecast:
conditions still decide which routes actually compete. Zero/missing weights retain the router's
existing effective weight of 1.
The calculation follows current publication: node priorities participate, but legacy edge priority
fields are not published and do not create separate percentage pools. Legacy edge conditions
retain their rule text alongside the percentage.

Evaluation (router, per visitor), walking from `entry`: among a node's outgoing edges, group the
eligible ones by the TARGET's `priority` (absent = lowest/fallback tier) and walk tiers low to
high. The first tier containing a condition-matching target wins; a tier with only unconditional
targets is itself a match; a tier with neither falls through to the next. **Weight only ever
splits traffic between targets sharing the same priority** (or the same fallback tier); it is no
longer a global weighted-random pick across every matching edge. The walk terminates at an offer
node. No lander before the offer = **direct-link** (302). Rules match on `country` (ISO-3166
alpha-2), `device` (`mobile|desktop|tablet`), `os` (`ios|android|windows|macos|chromeos|linux|
other`); all present keys must match; empty = everyone. A `when` may also carry `conds` — the
same condition objects a named rule uses (see `list_rules`), ANDed with the dim keys — so a
URL-parameter check needs no named rule: `{ "conds": [{ "type": "param", "key": "offer", "op":
"equals", "value": "test" }] }` is the builder's **custom UTM** chip, and `{ "type": "token",
"slot": 1, … }` its **{t1}** chip (the 1st traffic-source token). `{ "rule": "rl_…" }` cites a
named rule instead and replaces every other key. Legacy `tree` and `paths` shapes are still
accepted (see `create_campaign` tool docs). The API rejects flows with no reachable offer.

**Matrix node** (`kind: "matrix"`) is a lookup table: one key, N rows of value → lander or offer,
authored as one node instead of N nodes and N ruled edges.

```json
{ "id": "n5", "kind": "matrix", "of": "offer",
  "key": { "type": "token", "slot": 1 },
  "rows": [{ "value": "abc", "ref": "of_…" }, { "value": "def", "ref": "of_…" }],
  "fallback": "of_…" }
```

`of` is `"lander"` or `"offer"`; `key` is `{"type":"token","slot":1}` (the traffic source's 1st
token, the builder's **{t1}** chip) or `{"type":"param","key":"offer"}` (a literal URL parameter,
the **custom UTM** chip); rows match on `equals`, values must be unique; `fallback` (optional) is
the target for a visitor matching no row — without it those visitors dead-end like any
unconnected path. An offer matrix is terminal. A lander matrix needs an outgoing routing edge to
what follows, and every row continues down it. A pixel attached to a lander matrix fires after an
accepted lead on the selected row; an offer-matrix attachment fires on that selected row's
attributed navigation conversion. At sync the API expands the matrix into a pass-through path
node plus one lander/offer node per row behind a `{"conds":[key equals value]}` edge. Both row
kinds retain the matrix binding; copied outgoing arrows retain their original `id`, `pixel_ids`
and exit metadata. The stored campaign keeps the matrix. Refused at save if it has no rows, a row
with no value or no target, a duplicate value, nothing routing into it, or (offer) an outgoing
routing edge / (lander) none. Pixel attachments in either direction never count as routing edges.

**Filter node** (`kind: "filter"`) is a deny by default, and it lives on the campaign's own graph,
so the rule is per campaign. A visitor matching its `when` gets a bare **404** (the same response
as a link that never existed, which is the point when the visitor is a reviewer or a scraper) and
nothing is recorded (no click, no visit, no cost) UNLESS its fail exit is wired (below), in which
case that destination is served instead and the visit proceeds normally from there. Any named rule
works as its condition, so geo, device, `bot_ua`, `datacenter`, `moderator`, `suspicious`, `param`,
`token` and `role` are all available (see `list_rules`).

**Two exits: pass and fail; the dashboard draws them as green (left) and red (right) handles.** The
pass exit is an ordinary edge out of the filter (`exit` unset or anything but `"fail"`); the fail
exit is an edge tagged `"exit": "fail"`. Both are optional and independent. **Two shapes, chosen by
whether the filter node has a pass edge.** With no pass edge it is **terminal**, the original shape:
its `when` gates which edge *into* it gets picked, so reaching it at all means a match, and a match
beats every other matching edge rather than taking a weighted share (a filter that lets one
visitor in three through is not a filter; this still holds with the new model, pass/fail tiers or
not). With a pass edge it is **chained**: every visitor reaches it (the edge into it is an ordinary
edge), a match still denies/fails exactly as above, and anyone who doesn't match continues down the
pass edge to whatever follows: lander, path, offer, or another filter. Chaining is what lets you
build `campaign → filter → lander → offer` as one straight line instead of two sibling branches off
the same parent. A wired fail edge works identically in both shapes: a matched visitor is routed
there (a fallback offer, a different funnel, anywhere but a dead end) instead of 404ing. At most
one fail edge per filter, and it can't target a pixel node or itself. Rejected at save time, same
as before: a filter with no `when` (404s/fails everyone), and a filter nothing routes into (it
filters nothing). Dashboard preview walks past filters (matched or not) so the campaign owner can
still see the page a rule guards.

Nothing is written for a filtered visit beyond the count itself, by design — same posture as the
workspace-level `block` verdict: no click, no visit, no cost, no identifying detail. The count
alone is available via `report_filtered`, aggregated per campaign per filter node per UTC day
(queue-batched on the way in, so a bot flood costs one row touched repeatedly, not one row per
hit) — read it beside the campaign's visit count for the same window to judge whether a rule is
over- or under-firing.
These counters cannot be re-bucketed: the response declares `report_timezone: "UTC"` and
`granularity: "day"` regardless of the user's preference.

**A Filter node's `action` decides what happens to a match**, and it is a per-node field, not a
global setting: `"404"` (the default, and the only behavior described above) or `"challenge"`.
With `action: "challenge"`, a matching visitor is served a real interstitial page instead of an
immediate 404, and a client-side check on that page — not the original `when` — decides pass or
fail. Only a fail counts in `report_filtered`; a challenge shown but never resolved (the visitor
simply left) counts nowhere.

The `challenge` object (required when `action` is `"challenge"`, ignored otherwise):
`{type, timeout_ms, on_fail}`. `type` is one of:
- `"click"` — must click Continue before `timeout_ms` elapses, else fail.
- `"honeypot"` — a decoy element invisible to a real visitor (off-screen, not `display:none`, so
  a bot that only inspects computed layout still finds it); touching it fails immediately, never
  touching it passes once `timeout_ms` elapses (benefit of the doubt — a human taking a while is
  normal, so this type's timeout resolves to pass, the opposite of every other type).
- `"timing"` — a click **before** `timeout_ms` has elapsed fails; there is no upper bound, a click
  is judged the instant it happens, whenever that is. Catches a script that fires without waiting
  for the page to actually render.
- `"motion"` — a click with no prior `mousemove`/`touchmove` event fails.

`timeout_ms`: 500–120000. `on_fail`: `"404"` (default — a second, ordinary hit on the same link
that resolves to a genuine 404, indistinguishable from any other) or `"cosmetic"` (renders as a
404 but the response stays 200, no second request — use only where a real 404 reaching a slow but
legitimate visitor is the worse outcome).

**The pass destination is never present in the challenge page's markup or script.** This is the one
design constraint that actually matters: a JS-capable bot can read a pre-resolved URL out of a
page's DOM without ever dispatching the event that was supposed to prove it real (the same gap the
`data-rc-offer` `href` pre-fill has, for the same reason). Passing here instead re-enters the
router on the same link with the same click id and only this one node bypassed — the real offer
URL is computed at that moment, from a genuine trigger, never before.

**Lander contract:** upload a complete HTML document. The router injects `{{OFFER_URL}}` (the
routed offer link carrying the click_id — use it as every CTA href) and `{{CLICK_ID}}`, and
auto-wires any `<a data-cta>` to fire the CTA-click beacon.

**Assets — the rule depends on where the lander lives, and getting it wrong 404s silently.**

A lander is served one of two ways, and only one of them rewrites asset paths:

| Lander source | Asset base applies? | Write image paths as |
|---|---|---|
| **Repo-sourced** (`set_lander_source` + a lander's `source:`) | **Yes** — rewritten on every pull | root-absolute: `/images/hero.jpg` |
| **KV-uploaded** (`create_lander` with `html:`) | **No** — served verbatim | full `https://…` URLs only |

For repo-sourced landers, the Asset base URL rewrites root-absolute paths in exactly three places:
a **quoted** `src=`, a **quoted** `href=`, and `url()` in CSS. Everything else keeps its leading
slash, resolves against the tracking domain, and 404s:

- `srcset` / `imagesrcset` — the worst one, because the `src` fallback *is* rewritten, so the page
  looks correct on desktop and loses its images on the phones the traffic is actually on
- `poster` on `<video>`; `data-src` and every other lazy-load attribute
- a path built in JavaScript (`'/images/' + name`)
- an **unquoted** `src=/hero.jpg` — the rewrite matches quoted values only
- a **relative** path (`cover.png`) — it resolves under the campaign link, not the asset host

So: root-absolute in a quoted `src`, a quoted `href`, or CSS `url()`. A full `https://` URL
everywhere else — absolute URLs always pass through untouched, so they are never wrong.

`href` is rewritten too, **including page links**: `<a href="/privacy.html">` goes to the asset
host, so that host must serve your policy pages, or write those links absolute.

`validate_lander` reports every reference that will not be rewritten, and the router re-checks on
every pull — a lander that breaks its images in a later commit shows a **drift** badge with the
offending paths (`lander_drift_status`). Neither covers a KV-uploaded lander, which is the other
reason to prefer repo-sourced.

**Two kinds of serving domain.** `create_workspace_subdomain` gives a workspace
`<workspace>.roninconv.com` on the platform domain — a Workers Custom Domain on the router, auto
DNS + auto SSL, live in ~1 min, **zero setup for the client** (no Cloudflare account, no DNS, no
certificate wait). That is the default host every workspace should have. `attach_domain` is the
white-label upgrade: a client-owned hostname, which costs a DNS hand-off and a certificate wait.

**Two Cloudflare scopes — know which is which.**

*Platform, hardcoded from env.* The router/api/ingest workers, the KV namespaces, and
the SaaS zone. `attach_domain` provisions a client hostname as a **Cloudflare for SaaS custom
hostname** on that zone (`CF_SAAS_ZONE_ID`), which fronts the router. It is done this way because a
Worker can only be bound to a zone in its *own* account — a client-account zone can never point at
`roninconv-router` directly. The client publishes a DNS-only CNAME at `CF_SAAS_TARGET` plus the
validation TXTs that `attach_domain` returns in `dns_records`; TXT validation means the certificate
is issued **before** the CNAME cutover, so there is no downtime window. `verify_domain` reports
`host_status` (hostname activation) and `ssl_status` (certificate) — both must be `active` before
the domain serves, and the row sits at `warming` until then. Use a subdomain: bare apex domains
need Cloudflare's paid apex-proxying add-on and are rejected.

*Per workspace, connected by the client.* Their own Cloudflare account, for **their** resources
only: publishing the tracking DNS into their zone (`create_dns` on attach, or `publish_domain_dns`
later) and managing their WAF traffic rules (`get_waf_rules` / `set_waf_rules`, Settings → Traffic).
Connect via OAuth (`POST /cloudflare/oauth/start` → consent on dash.cloudflare.com → callback) or
an API token scoped Account:Read + Zone:Read + DNS:Edit + Zone WAF:Edit (`PUT /cloudflare`).
Credentials live in `workspaces.settings.cloudflare`, are never returned (last-4 only), and OAuth
tokens refresh transparently. Attaching a domain does **not** require this connection —
`get_cloudflare_connection.can_write_dns` tells you whether DNS can be published automatically or
the records have to be handed to whoever runs the client's DNS.

**WAF rules vs. traffic settings.** `set_waf_rules` writes Cloudflare custom rules on the client's
zone — enforced at Cloudflare's edge before a request reaches us. `update_traffic_settings` is
RoninConv's own filtering, enforced by the router per request. They are independent; use both.

## Typical agent workflows

**Launch a campaign end-to-end**
1. `create_lander` (HTML with `{{OFFER_URL}}` CTAs) → `ld_…`
2. `create_offer` (`url_template` must embed `{click_id}`, e.g. `…&sub10={click_id}`) → `of_…`
3. `list_domains` (attach one with `attach_domain` if needed; `set_default_domain` once)
4. `create_campaign` with a routing graph wiring lander → offer → campaign id = ad URL slug
5. Verify: `report_breakdown {"dims":["link"]}` shows visits once traffic flows.

**Campaign Ref** — set the optional **Ref** field below Campaign name in the creation or edit
modal (including **Flows → edit flow**). It accepts up to 200 characters; blank clears the value.
MCP equivalents are `create_campaign` / `update_campaign` with `ref`. The value is carried on
events and available as `{ref}`; an ad URL's `?ref=` can override it per visit. Selecting an
Airtable campaign fills Ref from its Source ID, but subsequent manual edits are saved as entered.
An explicit Airtable selection takes precedence over Ref-based matching: when saving that choice,
send `auto_link_by_ref: false` on `create_campaign` / `update_campaign` (`POST /links` /
`PATCH /links/:id`), then call `attach_link_campaign` (`POST /links/:id/campaign`) with the chosen
record ID. Ref is still stored exactly as entered, even if it names a different row's Source ID;
only the explicitly selected row receives the link ID. Omit the flag or send `true` to retain
automatic Source ID matching. The flag must be a boolean and applies to that save only.

**Read performance** — `report_breakdown` with a dims chain (`["day"]`, `["link","country"]`, …),
UTC window, optional `filters`. Compute derived metrics from the returned columns: CTR = clicks
/ visits · CVR = conversions / visits · EPC = revenue / clicks · ROI = (revenue − cost) / cost
· ROAS = revenue / cost. The dashboard, campaign table and report show ROAS beside ROI as
`3.33x`; zero-spend ROAS is undefined and displays `—`. Report totals and nested groups divide
summed revenue by summed cost, never average row ratios. Report CSV exports the numeric `roas`
ratio (blank for zero spend). Negative Profit/ROI use red text and matching tinted table cells;
positive values stay green and zero is neutral across themes.
ROAS uses the same effects at its **1.00x break-even threshold**: above 1x is green,
below 1x (including 0x) is red, and exactly 1x or undefined zero-spend ROAS is neutral.
This applies to the dashboard tile, campaign cells, and report totals/nested groups.
The reporting-table **uEPV** (`epv` in CSV) is revenue / `uniq_visits`; **uEPC**
(`epc` in CSV) is revenue / `uniq_clicks`. Both display 0 when their unique denominator
is zero or missing, never falling back to gross counts. These unique counts are hashed-IP
counts in each returned group, not people; summing groups does not deduplicate IPs across
groups. The dashboard EPV and offer/network EPC remain gross rates.
`cost` combines real Meta spend and configured flat CPC for every dimension chain, including
`["link","ref"]` and `["offer"]`. Offer cost belongs to the **saved stage-0 monetization offer**;
only visits without that association use the destination offer. A funnel's later pages and its
destination offer do not receive another copy. Meta spend retains its creative IDs and is grouped
by the selected report timezone's day using the source UTC hour;
split dimensions use stage-0 visit shares within the report window, preferring visits with
the captured ad ID and falling back to the mapped campaign's visits. Shares are calculated before
filters, so filtering removes cost instead of redistributing it. These are allocations, not
provider-observed per-country/per-offer charges. Flat CPC is charged once per recorded click.
Spend with no acquisition visits remains visible as cost-only rows with unknown visit dimensions;
unmapped Meta spend has `link=null` and is included in day totals. Cost is not multiplied across
conversion events (`event=null` owns acquisition cost; a literal dash event stays distinct). Drill raw rows with `list_events`
(follow one `click_id` journey) and `list_conversions`.

Pixel delivery logs (`list_pixel_deliveries`, `GET /pixels/:id/deliveries`) open in a wide,
viewport-bounded dashboard dialog. Each Detail button opens a separate full inspector while
preserving the log. It separates the retained response excerpt from the actual outbound request.
The inspector is a native modal: background controls are inert, Escape closes it, and focus
returns to the invoking Detail button without closing the underlying delivery log.
Apply additive/idempotent `053_request_capture.sql` before updated API/ingest code. New attempts
retain sanitized JSON bodies up to 64 KiB, with sanitized/retained byte counts and an explicit
truncation marker above that limit. Fields beyond 24 nested levels use an explicit depth-omission
marker and set `body.truncated`/`body.omitted`; byte counts describe the sanitized representation,
and known incoming/wire body byte counts are `received_bytes`. NUL and lone UTF-16 surrogates in
diagnostic keys/strings are replaced with U+FFFD for PostgreSQL jsonb compatibility; valid Unicode,
including emoji, is preserved. Replacement sets `body.truncated` with an explicit `body.omitted`
reason, so optional diagnostics cannot poison an otherwise valid financial event. Headers are
allowlisted (content-type, accept, user-agent, origin, referer, accept-language, cf-ray), capped at
1024 characters each; URLs at 8192 characters, without splitting surrogate pairs. Capped,
depth-omitted or Unicode-replaced metadata names appear in `metadata_truncated`. Authentication
fields, URL credentials, capability/session tokens and configured pixel secrets are redacted,
including camelCase/snake_case/kebab-case names. Nested URL query components and embedded JSON
are sanitized before serialization, including decoded configured credentials, regardless of
identity-read permission. OAuth token-exchange requests are not captured. Missing historical snapshots are
explicitly unavailable, never rebuilt from today's pixel configuration. Reads require
`reports:read` and filter by workspace and pixel.
The Events Columns control sits at the far right of the All/Views/Clicks/Conversions/Postbacks
strip, directly above the right-aligned Refresh control; both toolbars wrap within small screens.

Events now has a searchable, categorized Columns editor for each kind, backed by the same user
table preferences: All=`events`, Views=`events-view`, Clicks=`events-click`,
Conversions=`conversions`, Postbacks=`postbacks`. IDs, campaign/ref, exact attribution IDs/role,
provider transaction/event/outcome IDs, geography (including ASN, timezone and coordinates),
device/browser versions, user agent, transport, suspicious verdict, source/request URLs, request
availability, visit timing and permission-gated identity are configurable. New optional fields
start hidden; the familiar summary stays readable. URL/postback parameters discovered in the
loaded page add up to 48 separately configurable columns (keys up to 90 characters), with stable
`param:<key>` / `raw:<key>` IDs and per-kind saved layouts; literal dots in keys are preserved.
Saved parameter columns take priority within that cap and remain configurable when another
page lacks their values, so editing another column cannot erase their saved layout or sorting.
Parameters outside that discovery cap remain available in the full inspector. Click enrichment
is explicitly labeled latest preceding stored visit context, **not exact attribution**.
Conversions join only their immutable stored attribution visit, never today's routing.

Every event row's Detail action calls `get_event_detail` / `GET /events/:kind/:id` with its stable
`record_id`; kind is `view`, `click`, `conversion` or `postback`. The inspector includes all stored
fields, sanitized parameter/context objects, the actual intake request snapshot, and up to 100
same-workspace related visits with `related_visits_truncated` when more exist. Apply
`054_event_requests.sql` before updated router/ingest/API code. Router captures lander/direct-link
requests, CTA beacons/opt-in requests and authenticated postbacks; ingest persists those immutable
snapshots without reconstructing old requests. Incoming JSON/form bodies are read with a hard
64 KiB bound. Oversized bodies keep metadata and explicit unavailable/truncated/observed-byte
information, **not an unsanitized raw prefix**. Non-JSON/non-form or unreadable incoming bodies are
explicitly unavailable. Existing POST postback interpretation remains query-based; capturing its
body does not change attribution or payout. Request bodies are loaded only on Detail, not in the
paginated event list. Missing historical capture remains `request: null`.

All list/detail/context queries are workspace-scoped. `unmatched=1` is still accepted by
`list_conversions` but never exposes globally unowned historical `workspace_id='unmatched'` rows:
those lack a trustworthy tenant binding. Today's ingest retries missing attribution context
rather than booking a global unmatched row. Existing date, campaign, click and device/OS/country
filter semantics are otherwise unchanged.

`report_values` includes saved stage-0 monetization offers even before their first conversion,
so cost-owning offers remain selectable in report filters.

**Wire a network postback** — `get_postback_info` returns hostnames and a `networks` array.
Select the exact network ID and its `secret`; there is no top-level secret. The generic
template is `https://<router-domain>/pb?secret=<NETWORK_SECRET>&click_id={click_id}&payout={payout}&txid={txid}&event={event}&status={status}`.
The dashboard defaults new networks to Everflow, then offers **Install postback** using that
new network's stored key. It does not require importing offers first.

`everflow_register_postback` calls the documented affiliate `GET/POST /v1/affiliates/pixels`
on the fixed `api.eflow.team` host (never a network-admin key or a caller-supplied API origin).
It reuses an active matching normalized URL, conversion trigger, offer scope, and unrestricted
payout/event scope. New records retain the existing `pixel_type: conversion`, global-by-default
contract; this does not add post-conversion triggers or automatically edit existing postbacks.
Success includes `verified: true` only after provider GET readback. A workspace/network-scoped
atomic database lease prevents concurrent creates. Durable pending intent prevents a timeout,
process crash, or delayed readback from causing duplicate POSTs on retry: retries recheck first.
Pending intent v1 binds the original workspace/network, credential SHA-256 fingerprint,
conversion type, global/specific offer scope, and SHA-256 of the normalized callback URL
(including secret and tracking mapping). It stores no additional plaintext key or secret.
Retrying another scope/host/secret does not resolve the original intent by finding an unrelated
existing pixel. The original is reconciled first; if settings differ, the call asks for a fresh
retry rather than claiming the new settings are installed. Changed credentials remain blocked.
Legacy boolean intents have no recoverable operation identity and remain conservatively blocked.
If an ambiguous installation never appears, an administrator must use the ORIGINAL account and
operation evidence to confirm completion or definitive non-creation with the provider. Only then,
with installation paused and no active lease, may an audited tenant/network-scoped database change
remove exactly the inspected `config.postback_install_pending` value (compare-and-set, not a blind
config replacement). No public bypass endpoint exists. If original evidence is unavailable, leave
it blocked; do not infer non-creation from a temporarily empty list or clear speculatively.
Credential verification conditionally merges identity into the current config only while the API
key still equals the verified key; a changed key returns HTTP 409 and requires retry. Concurrent
secret rotation, SDK verification, pending intent and unrelated server metadata survive this merge.
400/401/403 POST rejections clear their own pending intent and give concise configuration/credential/permission errors; missing domain or click-ID token
mapping blocks installation. No live postbacks are installed by the automated tests.
### Network postback secret readiness

Every `POST /networks` / `create_network` (UI, API and MCP; custom and Everflow)
creates a fresh 24-byte cryptographically random network secret, persists it in the workspace's
network row, and publishes its SHA-256 KV lookup scoped to both workspace and network.
HTTP 201 includes `postback_ready:true` only after exact scoped KV readback. If publication or
readback fails, HTTP 503 includes the already-created `id` and `postback_ready:false`:
**do not create a duplicate network or rotate its secret**. KV is eventually consistent;
a successful local read is not a promise of immediate visibility in every region.

`POST /networks/:id/postback-secret/initialize` / `initialize_network_postback_secret`
requires `offers:write`. It atomically initializes only a missing secret, preserves existing
secrets and unrelated configuration, republishes its scoped KV record and checks readback.
It returns `{ok:true,ready:true}` only when verified; 503 means retry later, 404 means no network
in this workspace, and 409 means concurrent configuration changed. No secret is returned.
This is the safe repair action for a DB-only secret; rotation remains an explicit destructive
operation with its existing semantics. No migration, mass rotation, provider callback change,
or revocation of the legacy router-wide compatibility secret occurs here.

Authenticated `GET /postback-info` / `get_postback_info` (`links:write`) withholds each network's
secret and usable template unless scoped KV matches exactly. `ready:false` and an actionable
`error` distinguish unpublished secrets from `needs_secret:true`. Everflow installation checks
the same readiness before any provider write. New generated URLs never fall back to `PB_SECRET`;
only the router's transitional handling of already-installed legacy URLs remains unchanged.
Keep secrets and generated URLs out of public documentation, telemetry and error logs.
Everflow alone generates `offer_id`; other template families omit it. Event ID is optional
under the offer-only ingress contract (coordinate release with PR24); the builder's event
macro is optional metadata, not a new validation requirement imposed by this change.

The installer requires a **per-network** secret and never falls back to `PB_SECRET`.
If `get_postback_info` reports `needs_secret` or `ready:false`, initialize/repair it with
`initialize_network_postback_secret`. A different active same-scope `/pb?secret=…` callback blocks
installation instead of creating a second firing callback, including on an old host/secret.
Manually replace its URL in Everflow, preserving trigger and offer scope, or deactivate it
before installing. Inspect overlapping global/specific callbacks separately. Existing callbacks,
**including single-offer callbacks**, require this cutover; they are never automatically migrated.
Secret rotation invalidates the old per-network URL immediately. Pending intents still require
original-operation reconciliation; manual cutover is not a bypass.
Provider contract: https://developers.everflow.io/openapi/affiliate-postbacks.yaml (affiliate
portal key; account-specific permissions may still deny registration).
For Everflow specifically, use `everflow_register_postback` instead — it returns and registers
the network's actual macros directly:
`https://<router-domain>/pb?secret=<NETWORK_SECRET>&txid={transaction_id}&payout={payout_amount}&offer_id={offer_id}&provider_event_id={event_id}&external_conversion_id={conversion_id}&click_id={sub10}&email_hash={sub7}&phone_hash={sub8}&lead_hash={sub9}`
(offer URL carries `sub10={click_id}` and the lead hashes in `sub7`–`sub9`, so the network echoes
them back; `click_id` follows whichever slot the network's tracking tokens actually assign it).
The optional `external_conversion_id={conversion_id}` identifies one provider conversion, not
the click transaction or goal/event selector. When present it is namespaced by authenticated
workspace and network; distinct outcomes on one click book separately and retries deduplicate.
Legacy callbacks without it retain the existing identity/fallback rules; an event ID is not
required. Existing installed pixels are not edited by this release: deploy compatible code
first, then update the existing callback in place, preserving authentication and offer macros.
Never replay historical financial callbacks to validate this cutover.

A network secret authenticates workspace/network independently of offer identity. Attribution
by default uses the selected navigation offer in the converting click's immutable served journey,
not today's graph. Only a returned external offer ID matching a sidecar's network-scoped snapshot
credits that sidecar. Missing/blank or unmatched offer IDs fall back to navigation, including
non-Everflow callbacks. Ambiguous/missing historical paths retry rather than invent a destination.
Malformed or conflicting Everflow offer aliases also use navigation fallback, with original hints
retained in raw diagnostics; transaction/event, authentication, click and journey checks remain strict.
Legacy global-secret callbacks remain supported without inferring a sidecar network. No secret
rotation or installed-callback rewrite is part of this change. Only Everflow generation adds
`offer_id`; generic generation does not. **No event ID is required.**

`provider_event_id` is numeric event-type identity, not a business event name. The builder
does **not** set `event={event_id}`; existing business-event defaults and ad-platform feedback
semantics stay unchanged. Transaction/offer/event-type retry dedupe remains in force.
Everflow's transaction ID identifies a click session, not a purchase occurrence: repeated
purchases of the same offer/event type within one transaction are not separately counted.
This contract adds no occurrence identifier or guessed base-event ID.
Official evidence: [Partner Postback macro table](https://helpdesk.everflow.io/customer/your-guide-to-parameters-macros)
supports `{offer_id}`, `{event_id}`, `{transaction_id}`, `{payout_amount}` and `{sub1}`–`{sub10}`;
[transaction/conversion identity](https://helpdesk.everflow.io/customer/understanding-transaction-and-conversion-ids)
explains the repeated-purchase limitation. See `docs/network-postbacks.md` for the full
cutover contract.

**Onboard an Everflow network end-to-end** — `create_network` (template everflow) →
`update_network` with `config.api_key` (affiliate key from Partner Portal → API) →
`everflow_test_connection` → `everflow_list_offers` (runnable offers ranked by your revenue) →
`everflow_import_offers` (idempotent; each tracking URL gets the network's tracking tokens
appended — `config.tracking`, default `sub1={ref}&sub7={email_hash}&sub8={phone_hash}&sub9={lead_hash}&sub10={click_id}`) →
`everflow_register_postback` (writes the conversion postback into Everflow for you;
`everflow_list_pixels` shows what is already wired). Imported offers are ready for campaign
flows immediately. Import and the single-offer picker use explicit `redirect_tracking_url`,
not `tracking_url` (which can be an advertiser page when Direct Linking is enabled).
Missing redirect metadata fails the offer without overwriting it; verify the affiliate
redirect link before retrying. Availability is checked at runtime, not assumed for future
offers. This changes navigation only, not Direct Linking flags or SDK associations/hosts.

**Read an offer's copy without leaving the API** — `list_offer_creatives` returns everything
attached to an offer: subject lines, email bodies, SMS copy, headlines, CTAs, disclaimers, and
urls for image and video assets. This is the point of the table: an agent writing a lander or an
email reads the creative from here instead of signing into each advertiser's network UI to copy
it out. `add_offer_creative` / `update_offer_creative` / `delete_offer_creative` maintain it.

`everflow_import_creatives` fills the store from Everflow instead of by hand, and it is
idempotent on Everflow's own creative id, so re-running updates rather than duplicates.

It calls the **affiliate** API with `config.api_key`, the same key every other everflow route
here uses. There is no creatives endpoint on the affiliate side: the creatives arrive as
`relationship.creatives.entries` on the offer itself, which is why the import reads the offer
by its Everflow id and not a creatives path. `/networks/creativestable` is the network-owner
equivalent and needs a key we are not issued.

One Everflow creative can become several rows. An email creative carries a body plus a *list*
of subject and from lines, and those are the copy an agent actually wants addressable one at a
time, so they fan out into `email_subject` and `email_from` rows with their ids suffixed
(`991:subject:0`). Paused creatives are skipped rather than handed over as live copy. Rows an
earlier import wrote that the current pull no longer contains come back as `stale` and are
**never deleted for you** — a partial response from Everflow would otherwise wipe copy that is
still running. Hand-written rows have no `external_id` and are never counted or touched.

`kind` is free text so a format a network invents later needs no migration. The conventional set
is `email_subject`, `email_from`, `email_body`, `sms`, `headline`, `cta`, `disclaimer`,
`image`, `video`, `note`. Text lives in `body`; images and video are referenced by `url`, since this instance has
no object storage bound and an agent needs a src it can point at rather than the bytes. A url
must be http(s): these are read back and rendered into pages, so a `javascript:` or `data:` url
would be a script vector.

**Push conversions to ad platforms** — `create_traffic_source` (tiktok/meta/openai/coreg/hermes_sms/trackly_sms/custom; beehiiv is tokens-only) →
set it on campaigns (`traffic_source_id`) → matched conversions auto-fire S2S using the platform
click id captured on the visit (`ttclid`/`fbclid`/`oppref`). Debug with `test_traffic_source` +
`list_source_deliveries`.

**n8n lead workflows** — automation is authored in n8n; RoninConv makes it reusable.
`set_n8n_connection` (base URL + API key) → `test_n8n_connection` → `list_n8n_workflows` →
`adopt_n8n_template` copies one in as a blueprint and auto-detects what varies between its
copies (webhook path, workflow name, allowed origin — plus every other place those strings
appear, e.g. downstream Hermes/Trackly source tags), each reported with the nodes it controls.
To make anything else editable (a CRM custom-field id, a Slack label, a tag), call
`find_n8n_variables` with `q` set to the exact value — it reports which nodes carry it — then
promote it with `update_n8n_template`. Omit `q` to list everything configurable, or pass
`against` to diff two copies, when the value is not known up front.
`create_n8n_workflow` then substitutes new values and creates the workflow in n8n — always
INACTIVE, with `dry_run` to preview the substitutions, a 3-char minimum per value, and a 409 if
any old value survives substitution. A value may change literal text, never code: a value
carrying n8n expression syntax, or one whose variable lands inside a code-bearing field
(`jsCode`, `pythonCode`, `query`, `html`, `functionCode`, or a node's `type`), is refused with a
400 naming the variable. n8n runs those fields verbatim with `$env` in scope, so if
auto-detection points a variable at one, edit the template's variables rather than the value. Pass `link_id` to bind it to a campaign in the same call;
`set_campaign_n8n` does that separately. The campaign's `n8n_webhook_path` is the operationally
meaningful half — rp-edge routes each campaign's lead delivery by that value. Credentials are
never read or stored: an adopted workflow carries credential references that n8n resolves.

**Campaign template vars** — `routing.vars` ({name: value}) fills `{{var:name}}` placeholders
in the lander HTML at serve time and rides `__rc.vars` — one lander template, per-campaign copy
(headline, date, phone…). `routing.lead_endpoint` overrides the workspace form endpoint for one
campaign. Both ride the routing JSON on create/update_campaign.

**Campaign tracking-link defaults** — `routing.utm_defaults` is an optional sparse string map
with only `utm_content`, `utm_placement`, `utm_campaign`, `utm_source`, `utm_medium`, and
`utm_term` keys (max 2000 characters per value). It is stored unchanged by `POST /links` /
`PATCH /links/:id` (`create_campaign` / `update_campaign`); routing updates replace the full
routing JSON. The dashboard saves trimmed nonblank values; missing, empty, or whitespace-only
values fall back to the traffic source's placeholders.

These defaults apply **only when the dashboard generates a campaign tracking link**. A nonblank
default overrides an enabled source token by its canonical role, using the token's actual
parameter name (for example a `utm_source` role mapped to `src` writes `src`, not a second
`utm_source`). An explicitly disabled role row suppresses that role's default. A nonblank role
without a mapped source row appends its canonical `utm_*` parameter. Defaults are literal and
are not recursively macro-expanded; other source placeholders/platform macros remain unchanged.
Canonical UTM parameter names are also recognized when an older source row has no assigned role,
so generation replaces that row's placeholder instead of adding a duplicate query parameter.

During generation, source placeholders can expand the campaign-known RC macros `{campaign_id}`,
`{link_id}` (both the public campaign slug), `{campaign_name}`, `{bold_campaign_name}`, `{ref}`,
`{traffic_source}`, and `{ct1}`…`{ct10}` from the saved campaign and selected traffic source.
Click-time macros (such as `{click_id}`) and `{{platform}}` macros remain intact for their later
resolver. These settings do not enrich visits, override incoming visit parameters, or change
the KV routing config.
For source tokens with the platform `campaign` role or `platform_campaign_id` parameter,
`{campaign_id}` belongs to the ad platform and stays literal (OpenAI uses this spelling).
Use `{link_id}` when that field needs the RC slug instead.

**Multi-page funnels** — chain lander nodes in a campaign's flow graph
(`traffic → page1 → page2 → offer`); the router serves the chain page-by-page with ONE
`click_id` end to end (each page's `offer_url` is the next page; only the last carries the
network offer). Every page-serve records a visit with a 0-based `stage` — `report_breakdown`
`dims=["link","stage"]` is the drop-off report. Ordinary lander pixel attachments do **not** fire on
page serve; they wait for that exact served lander's accepted lead. Conversions store their `/pb`
`&event=` name; `dims=["event"]` slices revenue by it. `update_traffic_settings.lead_endpoint`
injects a workspace-wide lead-capture URL into every served lander as `__rc.lead_endpoint`.

**Flow pixel activity** — a graph pixel node is
`{"id":"p1","kind":"pixel","ref":"px_…","event":"Lead","amount":0}`. Event and amount overrides
belong to the pixel **node**, not the edge. An ordinary graph edge with exactly one pixel endpoint
is a bidirectional, non-routing attachment: `lander → pixel` and `pixel → lander` mean the same
thing. Attach any entry/path/filter/lander/offer/matrix node; never pixel-to-pixel or a fail exit.
These attachments cannot make an offer reachable, satisfy matrix connectivity, or turn a terminal
filter into a pass-through filter.

| Attachment | Activity | Default amount |
| --- | --- | --- |
| Untagged lander (or lander matrix) ↔ pixel | Accepted lead from that exact served lander | 0 |
| Concrete lander ↔ pixel, `attachment: "monetization"` | Attributed monetization conversion for the exact served lander and its sidecar offer | Actual payout |
| Offer (or offer matrix) ↔ pixel | Attributed conversion for that selected navigation offer node | Actual payout |
| Entry/path/filter ↔ pixel | Actual visitor arrival/traversal | 0 |
| Routing arrow `pixel_ids` | Traversal of that selected arrow | 0 |
| No node or arrow attachments | Campaign-wide conversion | Actual payout |

Attach a pixel to a routing **connection** with
`{"id":"e2","from":"n1","to":"n2","pixel_ids":["p1"]}`. `pixel_ids` contains existing graph
pixel node IDs, not `px_…` resource IDs; only non-pixel routing edges accept it, and a nonempty
list requires a stable nonempty edge `id`. Every supplied edge ID must be a nonempty string unique
across graph edges; an optional node `id` must exactly match its nodes-map key. IDs survive edge
compilation and matrix expansion.
Do not encode an arrow attachment as another endpoint edge. Deleting a pixel removes its arrow
references. The API rejects dangling/non-pixel IDs, duplicate references, pixel-to-pixel edges,
pixel fail exits, and `pixel_ids` on endpoint attachments.

One pixel node can attach to many nodes and arrows and fire independently at lead, traversal and
conversion times. Separate nodes may use the same platform pixel/event with different amounts.
Blank/omitted/null `amount` preserves the defaults above; numeric zero is an explicit override.
`event` replaces the platform pixel's configured event name. Retries dedupe one pixel node per
activity, not subsequent leads/conversions/traversals. Incoming routing activity belongs only to
the segment actually served; a final lander's tail-to-offer segment fires on signed exit/handoff,
not its page view. A sidecar conversion cannot borrow the selected navigation offer's attachment;
unattached pixels retain campaign-wide conversion behavior.

Apply `043_meta_outbox.sql` and `045_pixel_activity.sql` before shipping the changed ingest worker;
preserve `044_conversion_external_identity.sql` and existing historical conversion identities.
Non-Meta delivery claims are tenant/node/activity-scoped and recorded before the platform request.
A replay can finish unclaimed non-Meta nodes, but never resends a claimed request whose external
outcome may be unknown. Meta instead enters the durable outbox before any such claim and retries
with its frozen event ID, payload and target. Platform `pxe_…` event IDs distinguish workspace,
trigger and pixel node, including two nodes sending the same platform pixel/event with different
amounts. There is no static browser PageView/Lead event-ID map.

For an external form intake, keep the existing integration: call
`await window.__rc.prepareLead(payload)` before the existing lead POST, then navigate to the
**current** `window.__rc.offer_url` only after intake acceptance. The signed handoff proves the
served lander's lead activity without adding an RC lead row for external intake. Internal RC
`/lead/:link` admits the same activity at router queue acceptance when the signed visit capability
and normalized email or phone are valid; it does not wait for ingest persistence or downstream
forwarding. Queue failure returns 503, not 204. Ingest persists/forwards the lead and publishes
identity independently; it does not emit another lead activity after forwarding.
Internal submission plus its signed handoff dedupe to one lead activity for the visit.

**Per-lander monetization** — a graph lander may carry `monetization_offer_id`. The dashboard
renders it as a right-side card, never a navigation edge: CTA routing, weights, reachability and
ordinary pixel activities stay unchanged. A lander matrix may define a default; omission inherits,
explicit `null` disables, and another offer id overrides. Generic providers save but report
automatic SDK tracking unsupported. Everflow activation additionally requires verified matching identity, explicit
direct-link=true, runnable evidence, affiliate identity, supported sub1…sub10 parameters, and an
independently verified public SDK hostname. Redirect and advertiser landing URLs are evidence only,
never hostname provenance. Affiliate API keys stay server-side and masked; network-admin credential
mode is not supported.

**Everflow metadata-age expiry removal (future traffic only).** Previously, publication rejected
offer or affiliate evidence older than seven days and put the earlier verification timestamp plus
seven days into `monetization.expires_at`. `/rc.js` refused SDK acquisition after that deadline;
`prepareLead` also discarded accepted attribution when a submission crossed it. No automatic
renewal accompanied those gates. A calendar deadline on saved verification silently disabled
unchanged configurations; it was not a provider credential expiry or a lead-retention policy.

Publication now retains verification timestamps as evidence without an age limit and omits
`expires_at`. The SDK no longer gates acquisition or accepted attribution on that obsolete field.
Missing/invalid verification, inactive offers/networks, SDK opt-out, identity mismatch,
direct-link/runnable requirements, verified hostname provenance and supported tracking parameters
still fail closed. Context binding, transaction validation, bounded waits and no-retry markers
are unchanged. Identity tokens, signed handoffs, sessions and OAuth keep their separate expiries.

**Approved rollout for existing published configurations — operator/pepper, not automatic:**

1. Obtain approval before merging, deploying or republishing. Ship the API publisher and router
   `/rc.js` together. No schema migration, ingest change, secret rotation, workflow edit, postback
   change or lander-source republish is required for this fix.
2. Ensure the new router SDK is available on every affected serving domain **before any new
   no-expiry configuration is published**. The old SDK rejects snapshots lacking `expires_at`.
   The normal deploy path ships API before router: hold campaign/network/offer operations that
   republish configurations during that window. API deployment alone does not rewrite existing KV.
3. `/rc.js` has `Cache-Control: public, max-age=300`: allow existing cached copies to age out
   (at least 300 seconds after the new router is serving, accounting for any additional caching),
   or use an approved targeted purge. Already-open pages keep their old JS until a normal reload;
   do not force reloads or retries to recover missing attribution.
4. Review each affected campaign's workspace and published `link:<id>` graph snapshot:
   - An old `eligible: true` snapshot with an expired `expires_at` works with the new SDK;
     republishing is not required solely to remove that field.
   - An old `eligible: false` snapshot stays blocked. `offer_metadata_stale`, or
     `affiliate_identity_unknown` caused only by age, requires an approved republish through the
     new API. Age-only blockers need no offer re-import or timestamp rewrite. A missing identity,
     missing verification or other real blocker must be resolved normally, not force-enabled.
   - With `links:write`, use MCP `update_campaign` with only `{"id":"<campaign-id>"}`, or authenticated
     `PATCH /links/<campaign-id>` with `{}` in that campaign's workspace. This invokes `syncLinkKV`
     from current saved state, preserving routing, associations, status (including paused), IDs,
     destinations and financial settings; only campaign `updated_at` is advanced. Do not send a
     replacement graph, toggle status, bulk re-import offers or hand-edit KV to bypass eligibility.
   - Read back the published snapshot after KV propagation. Expect the intended offer/network,
     `eligible: true` only when all remaining checks pass, correct public SDK fields and no
     `expires_at`. A missing field on its own is not proof of eligibility.
5. Pepper coordinates live verification using naturally occurring future traffic: verify SDK
   acceptance and the same provider transaction in `prepareLead` and the existing intake.
   No synthetic production lead/click/conversion, workflow replay, postback replay, replacement
   ID, revenue adjustment or historical record/evidence mutation is part of this rollout.

**Limitations and rollback.** Saved evidence does not proactively detect later provider-side
permission, hostname or offer changes. Explicit config/status changes still need publication,
and actual provider failures retain the existing bounded-wait/no-retry behavior. Existing terminal
session markers are not cleared; old open pages are not repaired. Historical reconciliation,
retained payloads and execution/recovery evidence belong to pepper and remain untouched.
Rolling back only the SDK after publishing no-expiry snapshots would block those snapshots:
coordinate publisher/router rollback and approved configuration regeneration, never history replay.

**Attribution validity audit — repository scope, isolated fixtures only.** No remaining maximum-age
check was found for saved Everflow SDK verification, accepted SDK transactions, or immutable visit
lookup in ingest. This is not a promise that attribution cannot be interrupted: the following
gates remain, and external/deployed state was deliberately not inspected.

| Boundary / source | Remaining gate and effect |
| --- | --- |
| Publisher: `everflowSdkCapability()` in `apps/api/src/validate.js` | Offer/network active, SDK enabled, verified matching numeric identities, parseable verification timestamps (no maximum age), direct-link/runnable evidence, verified public hostname and supported `sub1`–`sub10` mapping remain required. Invalid/missing evidence publishes an ineligible snapshot; passage of time alone does not. |
| Publication: `syncLinkKV()` in `apps/api/src/index.js` | `link:<id>` has no KV TTL. Old negative snapshots do not self-repair. DB changes and KV publication are sequential, not atomic; failed/partial fan-out or propagation can leave old config. Workspace tracking-template edits do not fan out to existing campaigns. |
| Admission: router `fetch()` | Missing/non-active campaign, configured traffic block/rate policy, failed challenge, unavailable HTML or dependencies can prevent a new visit/acquisition. Rate counters expire after the configured 60–3600-second window; they are not attribution-record leases. |
| SDK: `providerStatus()` / `prepareLead()` in `rc-script.js` | Eligible Everflow context and an exact 32-hex transaction are required. Workspace/campaign/click/stage/lander/config changes suppress attribution rather than borrow another context. Session-scoped inflight, legacy, invalid or failed outcomes are terminal no-retry markers, without a clock TTL. Retrying uncertain acquisition could create duplicate provider clicks. |
| SDK waiting | `prepareLead` waits 1200ms by default, configurable 0–3000ms; a slow SDK can leave the submitted payload without an ID. Late resolution can enrich a later preparation, not a payload already delivered. Identity capture has a separate 700ms preparation wait and up to three 800ms attempts; neither changes the accepted SDK transaction. |
| Identity: `identity-capture.js` | Same-origin signed write authority and funnel `fi` proof expire at 24 hours. Expired authority blocks new identity capture/trusted continuation; internal lead intake still preserves a well-formed EF scalar. No stored provider correlation or already-booked conversion is expired by this token. Signature/context/body limits and an absent/rotated signing secret also matter. |
| Navigation: `optin-handoff.js`, router `/out/optin` | The signed destination receipt expires after 24 hours and then returns 403, preventing that old handoff's navigation/click/lead activity. This can interrupt an old open page; it is not a saved campaign expiration. Tampering, wrong origin, rotated/missing signing key, method and prefetch guards also reject it. No renewal or bypass is introduced here. |
| Optional Meta matching: `meta-matching-cookie.js` | The encrypted visit-bound cookie expires after 15 minutes. Only optional browser matching fields are lost; the Everflow transaction and RC conversion ledger are independent. Invalid hashes, cookie tampering, wrong context or key rotation also remove that optional receipt. |
| Callback admission: router `/pb`, `packages/network-postback/index.js` | Secret and macro mapping must remain valid. Per-network secret KV records have no TTL, but explicit rotation revokes the old callback secret. Republishing a campaign does not update an installed provider callback. Provider macro substitution and provider-side retention are external dependencies. |
| Durable correlation: ingest `handleProviderClick()` / `handleConversion()` | Visit lookup has no date predicate. Returned RC click ID, current authenticated network, valid/non-conflicting transaction/event/external-ID aliases and a unique immutable served association are required. Missing/ambiguous conversion context retries, not guesses. An ambiguous provider-click claim is ignored; a later valid same-offer claim can replace that slot. Network deletion or lost/mutated context can therefore interrupt new callbacks without deleting the visit. |
| Exactly-once ledger | Persistent scoped conversion uniqueness suppresses duplicate receipts; it is not time-window dedup. Duplicate callbacks do not change previously booked values. Repeated distinct outcomes require distinct provider identities. No automatic ledger/provider-correlation TTL or scheduled cleanup was found in repository schemas/scripts. This does not prove that external/manual DB cleanup is absent. |
| Queue and DLQ | `apps/ingest/wrangler.toml` limits retries to five; per-message backoff is bounded at 600 seconds. Unresolved context/outages can exhaust delivery opportunities and reach the DLQ. No DLQ consumer/alert or explicit queue-retention duration is configured here. Actual platform retention was not inspected; queue evidence is not guaranteed permanent. |
| Optional Edge forwarding: ingest | Requires explicit enablement, URL/secret and exact stored conversion/visit context. Identity grace defers only the first two attempts; attempt three can forward without late identity. Transport/auth failure has an 8-second timeout and finite queue retries. Internal lead forwarding is separately best-effort, not durably retried after a duplicate lead receipt. These are delivery limits, not an SDK age lease. |
| Ad-platform feedback | Provider auth/click identifiers/timestamp policy and current active pixel/source remain necessary. Meta's outbox uses a 5-minute lease and at most eight delivery attempts; terminal 4xx other than 429 stop delivery. Other feedback is best-effort. Failed feedback does not unbook the RC conversion; provider attribution windows remain external. |
| Management and workflows | Session/OAuth expiry or revoked keys can block management/map polling, not an already published SDK directly. Generated n8n waits can be 0–86400 seconds and HTTP steps time out after 10 seconds. Filters, inactive workflows, Set field replacement and HTTP response replacement can lose or block IDs. Installed workflow/receiver versions, activation, cursor behavior, field preservation and external retention were not inspected or changed. |

The remaining signed lifetimes, scope checks and no-retry protections are intentionally not removed:
expanding write authority or blindly retrying uncertain financial correlation is a separate security
and duplicate-creation decision. Pepper must verify external workflow/receiver/provider behavior
using naturally occurring traffic after approval. No workflow replay, TTL rewrite, record cleanup,
historical recovery or production configuration mutation is part of this audit.

**Isolated verification:** `node apps/api/test-sdk-enabled.mjs` (aged evidence and safety gates),
`node apps/api/test-monetization-routes.mjs` (publication with evidence dated 2000),
`node apps/api/test-guards.mjs` (179 passed), `node apps/router/test-monetization.mjs`
(17 passed), and `node apps/router/test-sdk-pipeline.mjs` (enabled/disabled loopback intake,
queue, ingest adapter and forwarding) passed. `npm run check:mcp` passed (183 routes).
A local Chromium smoke loaded `/rc.js` from the actual esbuild-bundled worker: no-expiry,
expired-legacy and crossed-deadline cases preserved one stub SDK transaction; an ineligible
snapshot made zero SDK calls. EF/beacon transports were stubbed, HTTP stayed on loopback, and no
real database or provider was used. `npm run check:sdk` passes bundle consistency and all 15
bundled-worker scenarios. The harness evaluates the original emitted ESM bytes directly with
`vm.SourceTextModule` in the network-denied VM and selects `namespace.default`; Node's
`--experimental-vm-modules` flag is test-only. There is no second build/transform, export rewriting,
private-binding lookup or runtime shim. The signed Meta bridge is exercised through `/out/optin`.
All 15 scenarios also pass with an external identifier-minified ESM artifact, without changing its
bytes. Router identity tests additionally verify expired write authority cannot strip the EF
transaction from internal lead delivery; handoff and Meta-cookie tests exercise their retained
expiration boundaries.
`node apps/ingest/test-meta-integrity.mjs` passes 21 real-PostgreSQL-engine cases, including
new provider correlation and delayed duplicate callbacks against a visit dated 2000. The test
asserts the stored `visits.params.provider_clicks` transaction and accepted status before invoking
any callback, then verifies the exact immutable sidecar/transaction and once-only booking.
SDK configuration and verification-race tests also pass with stubbed provider reads and isolated
PGlite databases.

Attach a pixel to a concrete lander's monetization sidecar with
`{"from":"lander_node","to":"pixel_node","attachment":"monetization"}` (either direction).
The lander must carry `monetization_offer_id`; the other endpoint must be a pixel node.
Unsupported attachment values, tagged navigation edges, nonlander endpoints and tagged matrix
attachments are rejected rather than silently compiled into a different activity.
The pixel fires S2S only after an attributed conversion with role `monetization`, for that exact
served lander node and its matching sidecar offer. It never fires on page serve, accepted lead,
provider-click correlation, another lander's sidecar or a navigation conversion, and it never
enters browser lead configuration. The same lander/pixel pair may also have an untagged edge:
that independent attachment still fires on accepted lead. Tagged attachments are metadata,
never traffic routes; publication preserves the tag without publishing pixel credentials.

New compiled graphs carry `pixel_activity_v: 1` alongside `meta_pixel_v: 1`, including the public
pixel resource identity and event/amount overrides needed for a server-only served-visit snapshot.
Older KV publications without that marker retain ordinary legacy pixel fallback until republished;
they cannot authorize newly added sidecar pixel attachments from mutable campaign state.

`rc.js` creates at most one provider click per RC click + network + monetization offer and keeps the
two ids separate. JSON forms call `await __rc.prepareLead(payload)` once before their existing
fetch. `/e` provider-click events are correlation only: ingest validates them against the immutable
served association, and they never increment CTA clicks, create conversions or carry payout. Only
an authenticated provider postback creates accepted revenue.

**Meta cost** — `add_meta_connection` (system-user token) → mesh auto-discovers →
`link_meta_campaign` maps **many Meta campaigns to one RoninConv campaign**, with each Meta id
owned by at most one RC campaign **per workspace** → cost lands in `report_breakdown`
(`["link"]` / `["day"]`). `meta_sync_all_now` forces a pull; interval via
`update_meta_settings` (10–60 min).

`list_campaigns` (`GET /links`) returns canonical `meta_campaign_ids: string[]`; the old scalar
`meta_campaign_id` is not returned. `link_meta_campaign` requires both `id` and
`meta_campaign_ids`, replaces the **entire** assignment list, trims/deduplicates IDs, and accepts
`[]` to unlink all. There is no scalar/null compatibility input. Other-workspace IDs cannot be
claimed; an ownership conflict returns 409 atomically, retaining previous assignments. A missing
target returns 404. `list_meta_campaigns` reports canonical `linked_link_id`/`linked_link_name`;
the dashboard groups available checkboxes by connection/account, retains undiscovered selected
IDs for removal, and clears assignments when saving a non-Meta traffic source.

SENTRY's `add_meta_campaign_id` remains `{ref, meta_campaign_id}`: it atomically **appends** to
the same mapping for the active campaign resolved by `ref`, deduplicates, and returns the canonical
array. It accepts IDs before catalog discovery but cannot take another campaign's ID (409).
It requires explicit `sentry:write`, not the `full` key scope.

External cost ingest uses `POST /ingest/meta-spend` with a workspace-pinned
`meta_spend:write` key; it remains machine-to-machine only, not an MCP tool. Send `{rows: [...]}`
with required `ad_id`, `utc_hour`, `campaign_id`, and `ad_account_id`. Missing required values or
unparseable timestamps are skipped; non-array `rows` returns 400. Valid timestamps are normalized
to UTC instants. Across the entire batch, the final valid `(workspace_id, ad_id, utc_hour)`
snapshot wins (including equivalent timezone offsets), replacing rather than summing metrics.
`upserted` is the number of distinct valid spend facts written. Spend, impressions and clicks
retain their nonnegative zero-floor policy; the credential, never row input, selects workspace.

Migration **046** unions/deduplicates existing scalar and array assignments into
`link_meta_campaigns(workspace_id, link_id, meta_campaign_id)`. Its workspace/Meta-id primary key
prevents double ownership, and the workspace/link foreign key cascades on link deletion. Existing
cross-link conflicts abort migration for explicit resolution, never silently move ownership.
Legacy columns remain physically present for additive migration only; runtime reads use the
junction, including the array exposed by `GET /links`.

Deleting `meta_connections` through `bulk_action` removes credentials without removing imported
spend, campaign mappings or catalog history. Shared accounts transfer to the oldest active surviving
connection with a recorded sighting, excluding every connection in the same deletion. With no active
alternate, accounts detach until rediscovery; retained campaign names stay available under
`connection_label="Disconnected"`. Apply additive **047_meta_connection_removal.sql** before
shipping this behavior. Ordinary sync errors do not automatically change canonical ownership.


Spend sync imports **account-wide ad-level hourly insights**, independent of link assignments.
Sync/backfill follow every `paging.next` continuation with no fixed page cutoff; insights are
converted and persisted page by page in bounded chunks, not accumulated for the whole account.
Catalog traversal also exhausts continuations. Inspect sync `results[].error` (backfill returns
an error response): a later-page failure leaves completed pages stored but does not advance the
failed account's or connection's success timestamp. Earlier fully synced accounts retain their
own success timestamps. Re-running safely overwrites those ad-hour keys. Successful `spend_rows`
counts accepted input rows across all pages, including overwrites, not distinct stored facts.
Reports sum the spend of **all** Meta campaigns attached to the RC campaign through the junction.
Advertiser-local hour buckets become **UTC instants** before storage; report windows use UTC
`from <= utc_hour < to`. Repeated syncs overwrite `(workspace_id, ad_id, utc_hour)` rows rather
than adding duplicate cost, so the rolling reconciliation pull is idempotent. "Today" uses the
user's reporting timezone, which can differ from Ads Manager's ad-account timezone.

**Fight bots** — `get_traffic_settings` / `update_traffic_settings`: UA regexes, ASN/IP/country
blocks, rate limit; mode `mark` (flag suspicious, keep serving) or `block` (403). The
`suspicious` report dimension shows the impact. `rate_window` is clamped to 60–3600 seconds:
the edge counter is a KV key whose TTL is the window, and KV rejects a TTL under 60 (a
smaller window used to disable the limiter silently).

**Housekeeping** — `bulk_action` is the one path for archive (`patch {"status":"archived"}`),
delete, duplicate, and multi-record edits — including offer edits. Ids outside your workspace
are skipped, not touched; `affected` is the number of rows actually changed.

## Reporting table layouts

Reporting tables retain their page-owned cells/actions and always fill the available width,
including with only one or two visible fields. Saved widths act as preferred sizes when
the table stretches to fit; wider layouts scroll inside the table, not the whole page.
Header labels and cell values are left-aligned. Drag a field's right edge to resize, or use
Left/Right, Shift+Left/Right, Home/End. Both column boundaries highlight during resizing,
without a permanent divider or a standalone hover line. Escape cancels an in-progress
resize. Resizing does not trigger sorting.

Reporting toolbars group date presets/custom ranges with **Refresh**
on the right. Search and status filters stay on the left; create/upload/export actions
retain their order. Date controls stay right-aligned when wrapping onto narrower screens.

Tables have one dark header row. **Columns** sits at the far right of the existing
above-table action bar, after **Report** on Campaigns, rather than inside a data header.
Save failures/retry and limited-sort scope remain visible below the table.

Drag a header label to reorder fields directly. A lifted header follows the pointer,
both column edges highlight, and the destination is marked as neighboring headers move.
A normal click still sorts; dropping or cancelling a drag never toggles sorting.
Alt+Left/Right reorders a focused header without dragging; Enter/Space sorts it.

The selection checkbox is a fixed 44px utility column, always first and outside the
Columns editor. It has no resize or drag control and never stretches with the data fields.
Legacy selection order, visibility and width preferences are ignored; at least one data
field remains visible.

**Columns** opens a compact sortable list. Drag a grip to lift a column: the card follows
the pointer, neighbors animate out of the way, and an insertion line marks its destination.
Long lists auto-scroll. Touch and keyboard dragging are supported; Space picks up/drops,
arrows move and Escape cancels. Motion follows the reduced-motion preference.
Move buttons provide a non-drag alternative; visibility uses the existing report-settings
checkbox treatment. At least one data field stays visible.

Click directly into a column name in this editor to rename its displayed heading.
Enter or blur saves; Escape discards the draft without closing the editor. Clearing the
name restores the built-in heading. Existing names stay unchanged until edited.
Names save per user/table and do not change field IDs, calculations, filters or CSV schema.
Report Settings → Chips uses compact, wrapping Token Helper-style tiles with native checkboxes.

Column widths are adjusted on the table, not through numeric editor inputs. **Reset layout**
restores order/visibility while keeping widths and custom formulas. Sorting, selection,
actions and report-tree expansion survive reordering. CSV keeps its built-in metric schema;
display customization does not change the export contract.

**Sorting** is consistent across Campaigns, Landers, Offers, Networks, Report, dashboard
performers, provider offer performance and event/conversion/postback/error tables. Tables restore
the authenticated user's saved sort; tables without a saved sort open unsorted.
Most headers cycle **highest → lowest**, **lowest → highest**,
then **unsorted**; clicking another replaces the active sort. On **Campaigns**, the Campaign
header instead sorts by the displayed traffic source name: **A–Z → Z–A → unsorted**.
An arrow and `aria-sort` indicate direction. Enter/Space also sorts. URLs, selection/action
columns and verbose error details do not sort.

Numeric comparisons use raw values, not formatted currency/percent strings. Missing values
stay last in either direction; equal values retain incoming order. Other text columns use
natural label ordering (Z–A first). Unsorted restores incoming/API order, not a metric sort.
Report totals stay first beneath the column headers; each group's children stay attached while siblings sort.
Report total metrics use the same font size as the report body; bold weight distinguishes the summary.

Campaigns, Landers, Offers, Networks, network-offer performance and dashboard performer tables
have a bottom-pinned **TOTAL** footer. It covers exactly the displayed entities after search and
view filters (the dashboard's top six, and only loaded provider offers if discovery is truncated).
Counts, revenue and cost are summed; ratios such as CR, EPC, ROI, ROAS, CPL and iCPL are
recomputed from those sums, never added or averaged. Unique-IP columns sum the displayed
entity counts, not a cross-entity IP deduplication. Each network offer contributes once to its
network rollup; duplicate provider offer IDs are displayed and counted once.
The Report total stays at the top, calculated from the filtered raw rows rather than
repeated parent/child group rows. Custom formula totals evaluate against these aggregate metrics.
Metric tables scroll inside a viewport-bounded region with sticky headers and standard-table totals; horizontal
scrolling, resized/reordered columns and frozen fields stay aligned because the footer is part
of the same table. Short viewports use a reduced body height rather than overlapping the header
and total. Logs and other nonmetric tables do not receive a totals footer.

Custom formula results are sortable. Resizing or moving a column does not toggle its sort;
hiding or removing the active column clears it.

Sorting saves with each table's account-owned layout, including controlled Report tree sorting.
Navigation retains pending edits; reloads and other devices read the last successful server save.
It applies to loaded rows: paginated logs explicitly say **Sorting current page only**,
and the capped delivery-error feed says **Sorting loaded rows only** when sorting is active.
Dashboard performer cards retain their existing top-six membership selection.

Layouts save to the authenticated user's account separately for each reporting table,
across reloads, sessions and workspaces. Successful saves stay visually quiet, with
screen-reader announcements. Failures show **Not saved** and **Retry save**, retaining the
latest local edit. Accounts without preference permission show **Session only**.
Existing browser-global settings are not automatically assigned to an account.

Apply **051_user_table_preferences.sql** before releasing the updated preference API.
`GET /me` / `whoami` returns `table_preferences` with the user's timezone. Save either or both
through `PATCH /me/preferences` / `update_user_preferences`, using `preferences:write`.
API keys cannot read or mutate another user's layouts. Example:

```json
{"table_preferences":{"campaigns":{"order":["name","revenue"],"hidden":["url"],
  "widths":{"name":320},"labels":{"revenue":"Sales"},"custom":[],
  "sort":{"key":"revenue","direction":"desc"}}}}
```

`labels` is optional: up to 128 stable column IDs mapped to trimmed, nonempty display names
of at most 200 characters. Omit a particular alias to restore that field's default; omitting
the map means no aliases. Labels use the existing JSON preference storage; no new migration
is required for renaming.

`sort` is optional: `null` or omitted restores incoming/API order, otherwise use
`{"key":"column-id","direction":"asc"|"desc"}`. Keys share the 1–100 character column-ID
limit. The dashboard preserves sorting during resizing, reordering, renaming and formula edits;
sorting preserves the rest of the layout. Hiding/removing the active column clears its saved sort.

**Freeze first data column** in the Columns editor saves `freezeFirstColumn: true` per table.
It pins the first **visible** data field, not a particular ID: hiding or reordering changes
which field is frozen. A selection checkbox column remains fixed immediately before it (44px);
the checkbox does not consume the data-column freeze. Headers, body and totals share the same
offsets, opaque theme-aware backgrounds and boundary shadow. On narrow screens the frozen
field's displayed width is capped to leave scrolling room; its saved resize width is retained.
Disable the option to scroll all columns normally. Omitted/false defaults to unfrozen.
Sort changes preserve freezing; layout changes preserve sorting. Both use existing user JSON
preferences, with no additional migration. Event tables can search column names, stable IDs and
categories in the editor without changing the saved global column order.
New detailed event fields may start hidden; showing them explicitly is saved normally, and
unrelated resizing/reordering does not reveal newly introduced optional fields.

Each supplied table is a complete layout replacement; other table IDs and timezone remain
unchanged. Independent table updates merge atomically. Supported IDs are `dashboard-campaigns`,
`dashboard-offers`, `campaigns`, `landers`, `offers`, `networks`, `network-offers`, `report`,
`events` (All), `events-view`, `events-click`, `conversions`, `postbacks`, `delivery-errors`.
Order/hidden/width maps allow up to
128 fields, IDs up to 100 characters, and widths 32–1600 pixels. Only `report` accepts nonempty
custom definitions: unique `custom_`-prefixed IDs, names up to 200 characters and arithmetic
expressions up to 1000 characters. New fields not in a saved order append in default order.

## Reporting navigation

Primary names on Campaigns, Landers, Offers and Networks open **Report** with that exact
object selected, retaining the source table's date range (including custom calendar dates).
Dashboard campaign/offer names and campaign IDs in event/conversion tables work the same way.
Network settings, onboarding and postbacks remain available through the row's explicit actions;
**details** opens the former network-name detail view.

Report links carry grouping, range and filters in the URL, so reloads, bookmarks and normal
browser navigation retain the selection. For example:
`#/report?dims=link%2Coffer&range=7D&f_link=campaign-id`.
Generated report URLs use JSON string/null arrays for `f_` filters, preserving literal commas,
brackets, empty strings, and SQL NULL. Existing repeated-value URL links and legacy REST
comma-list filters remain readable. `f_paths` retains exact selected-row paths across reloads
and entity-name navigation, along with active filters and parent dimensions.
An explicit empty grouping (`dims=`) retains the blank state after **Clear selections**;
saved workspace presets, account preferences, and the date range are unchanged.

`network` is a first-class report grouping/filter and `report_values` dimension, also available
through MCP. Use the network record ID, not its display name. It resolves from the effective
offer's workspace-owned `network_id`, then a same-workspace record matching legacy `network`
text. Offers without a record use `legacy:` followed by the display name with `%` escaped to
`%25` and commas to `%2C`; an empty label uses `unassigned`. Missing/deleted/foreign offers
remain unknown, not unassigned. Network membership follows current offer/network metadata.
Filtering a network with no matching facts returns an empty report, never all traffic.

Visits and verified leads use their exact page's offer; conversions use their saved attributed
offer; acquisition cost uses the saved stage-0 monetization offer, falling back to the visit's
offer only when no saved monetization offer exists. Networks do not expand into an offer-ID
filter list, so networks with more than 50 offers remain filterable without truncation.
Filter-value discovery includes conversion-only and lead-submission-only windows, as well as
saved monetization networks before their first conversion.

## Reporting semantics

- Funnel: **visit** (lander served) → **click** (CTA beacon, accepted opt-in navigation, or
  direct-link hop) → **conversion** (network postback), joined by `click_id`. All served
  landers support signed opt-in navigation when signing is configured. Forms call
  `prepareLead`, wait for accepted intake, then navigate the current `offer_url`; preparation
  and rejected/invalid forms are not clicks. A click arriving before its visit retries.
- `report_breakdown` aggregates visits/clicks, approved conversions, accepted RC leads and
  cost independently so submissions and funnel pages cannot multiply other facts. Unique
  counts are by hashed IP. The shared `packages/reporting/index.js` catalog drives the UI,
  API SQL whitelist and MCP schemas; `link` is labeled **campaign**, not a second alias chip.
  All existing API dimensions remain available. The full 40-field catalog is:
  `day link ref lander offer meta_campaign meta_adset meta_ad country region city device os browser
  os_version browser_version device_vendor device_model isp asn continent timezone postal colo
  language http_protocol tls_version suspicious stage event network lander_node utm_source
  utm_medium utm_campaign utm_content utm_term utm_placement placement publisher`.
  Attribution uses the captured visit's saved `_rc.identity_roles` mapping, falling back to
  the literal captured key only when no mapping was saved. Explicitly disabled roles stay
  unknown. Lander-node dimensions use saved attribution, not today's routing; network keys
  resolve the effective historical offer against current workspace-owned entity metadata,
  following the network navigation semantics above.
  Raw lead PII, hashes, IPs, per-click IDs, URL-only macros and metrics are not dimensions.
  `t1`…`t20` and `ct1`…`ct10` are deliberately omitted: source slot order and campaign-token
  values are used to render URLs but were not historically snapshotted in visit facts.
  Reading current editable source/campaign definitions would rewrite historical reports,
  and arbitrary source slots can contain identity data.
- Reports select **1–4 ordered dimensions**; an empty selection is a UI-only cleared state
  and never starts a request. The server rejects empty/unknown dimension chains.
  Ordinary filters AND dimensions and OR values within each dimension.
  REST sends `f_<dim>` as a JSON array, for example `f_ref=["a,b",null,"null","—"]`
  (URL-encode query values). Real JSON `null` means SQL NULL; literal `"null"`, commas,
  empty strings and dash values remain exact strings. Up to 200 values per dimension;
  `[]` matches nothing. Legacy CSV filters remain accepted with their previous split/trim
  behavior. `day` can also be filtered and uses `report_timezone`.
- **Selected-row drilldown** sends `f_paths` as a JSON array of up to 50 nonempty full row
  paths, including each row's parents:
  `[{"link":"cmp1","offer":"o1"},{"link":"cmp2","offer":"o2"}]`.
  Paths are ORed, each path's keys are ANDed, and ordinary filters AND the entire selection.
  This excludes `cmp1/o2` and `cmp2/o1`; independent per-dimension lists would not.
  MCP `report_breakdown` accepts the same objects as `paths`, plus `filters` arrays.
  Grouped and filtered dimensions (including all path keys) participate in spend projection;
  acquisition shares are still calculated before filtering, so narrowing never reallocates cost.
- `report_values` / `GET /report/values` returns a plain strings/null array. It accepts `dim`,
  `from`, `to`, `report_timezone`, optional literal case-insensitive substring `q`, `limit`
  (1–200, default 100), and `offset` (default 0). Ordering is frequency descending, then value
  ascending; `%` and `_` are literal search characters, not SQL wildcards. Dates, window order
  and timezone are validated. Discover `day` values in the selected report timezone.
  Values are a discovery aid, not a restriction: callers may enter exact values manually.
- **Report controls:** `get_report_settings` / `GET /report/settings` (`reports:read`) returns
  only `{presets,visible_chips}`, never other workspace settings or credentials. Presets
  belong to the workspace; visible chips are stored on the user and follow them across
  workspaces/sessions. API keys receive the default chips. Defaults retain the useful prior
  presets and add **Campaign → Meta campaign**.
  The default chip lane includes source/medium/campaign/content UTMs, placement and language
  alongside standard report groupings; all 40 fields are available in chip settings.
  `set_report_presets` / `PUT /report/presets {"presets":[{"id":"campaign-meta","name":"Campaign → Meta campaign","dims":["link","meta_campaign"]}]}`
  replaces up to 50 presets using the existing owner-only `members:manage` administration
  permission. IDs are unique 1–80 character letters/digits/underscore/hyphen, names are
  nonblank (max 100 characters), and each chain has 1–4 unique known dimensions.
  Optional boolean `is_default: true` marks the workspace default; at most one preset may be
  marked. The dashboard applies it only on fresh Report entry, never over explicit report URLs
  or a selection made while settings load. Without a marked default it uses `campaign-offer`
  if present, otherwise the first preset; no presets opens a blank report. No migration is needed.
  Writes atomically update only `workspaces.settings.report_presets`, preserving unrelated settings.
  The Report settings modal has Presets and Chips tabs; drag preset rows to reorder them
  (or use Alt+Up/Down), then save the ordered array.
  `update_report_preferences` / `PATCH /me/report-preferences {"visible_chips":["link","day"]}`
  (`preferences:write`) accepts an ordered unique list of known keys; sessions/OAuth only,
  never API keys. Empty arrays intentionally clear presets/chips; unset storage uses shared
  defaults. Apply **052_report_preferences.sql** before shipping, in addition to
  **051_user_table_preferences.sql** for saved table layouts. The existing
  `PATCH /me/preferences` route continues to save timezone and server-backed per-user columns
  independently; the table's Columns button opens the separate saved-layout modal.
- **Leads** (`leads`) counts rows from the existing internal `/lead/:linkId` intake/queue
  path, not external submissions, handoff navigation or pixel `flow_lead` activity. Downstream
  forwarding failure does not erase accepted intake; queue retries dedupe by lead event ID.
  Campaigns, Landers and Report show Leads immediately left of Convs; report totals, sorting,
  column selection, formulas and CSV support the metric.
- **CPL** (`cpl` in dashboard/CSV/formulas) = `cost / leads`; **iCPL** (`icpl`) =
  `(cost - revenue) / leads`, the internal net cost per lead. Negative iCPL means revenue
  exceeded spend: $100 cost, $200 revenue and 10 leads gives $10 CPL and -$10 iCPL.
  Both appear in Campaigns, Landers and Report. With zero/missing leads they display `—`
  and export blank CSV cells. Report totals and nested groups divide summed cost/net cost
  by summed leads, never average row ratios. These are dashboard-derived metrics;
  REST/MCP consumers calculate them from the returned `cost`, `revenue` and `leads`.
- Apply **049_lead_attribution.sql** before shipping API/ingest changes. It adds nullable
  `visit_id`, `click_id`, `lander_id`, `stage` to existing `leads`; no second event/table.
  Only router-verified context supplies page attribution. Legacy/unsigned rows retain their
  campaign/ref but have unknown lander/stage/visitor dimensions; raw payload IDs never authorize
  attribution. Lead windows and timezone days use submission `ts`, unlike conversion cohort
  windows. Filter values also include lead-only values within the submission window.
- Windows are `from` (inclusive) to `to` (exclusive), ISO-8601, UTC. Defaults: last 7 days.
- `report_timezone` selects IANA calendar-day grouping for visits, attributed conversions,
  submitted leads, flat CPC, and Meta spend. An explicit query value wins; otherwise a
  session/OAuth user gets their saved preference and an API key gets UTC. The response includes
  `report_timezone`.
  The `timezone` dimension still describes the visitor; it does not select reporting boundaries.
- The dashboard timezone marker, immediately left of the header `?`, opens a searchable selector.
  Saving persists `users.timezone` across sessions, devices, and workspaces, then refreshes reports
  and displayed timestamps without discarding open page state. `Today`, `Yesterday`, and custom
  inclusive date ranges use local calendar midnights, including 23/25-hour DST days. `3D`, `7D`,
  `14D`, and `30D` remain rolling elapsed-time windows.
  Only timezone-dependent report data refetches; configuration forms keep their unsaved edits.
  Tracking, ingestion, provider synchronization, and stored/wire timestamps remain unchanged.
  Already-open tabs load the saved preference on reload rather than live-synchronizing it.
- `GET /me` / `whoami` returns top-level `timezone` and `table_preferences`. Save timezone with
  `PATCH /me/preferences {"timezone":"America/New_York"}` or
  `update_user_preferences {"timezone":"America/New_York"}`. Only the authenticated session/OAuth
  user's preference changes. Use `UTC` or an IANA `Area/Location`; invalid/nonstring/numeric-offset
  zones and abbreviations such as `PST` return 400, avoiding differing browser/database DST rules. OAuth needs
  `preferences:write`; existing grants need renewed full consent for that new permission.
- Meta costs retain hourly precision: a stored bucket is included by its UTC start instant, not
  prorated at fractional-hour timezone boundaries. Filter-node counters remain UTC-day aggregates.
- `revenue` sums approved-conversion payouts. Conversion status changes (approve/reject) go
  through `bulk_action` on `conversions`.

## Conversion events

**Events → Conversion events** manages workspace names and optional descriptions.
Names are trimmed, case-sensitive keys (1–100 characters); descriptions are optional (up to 1000).
Owners/editors can create, edit descriptions, and delete definitions with `conversion_events:write`;
listing uses `entities:read`. MCP tools: `list_conversion_events`, `create_conversion_event`,
`update_conversion_event`, `delete_conversion_event`. Names cannot be renamed: delete/create a
definition instead. Deleting a definition does not change historical conversions or saved pixel filters.

Send `&event=upsell&payout=10` on the inbound postback to record one $10 conversion with
`conversions.event = 'upsell'`. Omitting `event` keeps it null; unknown names are recorded without
auto-creating a definition. Classification does not change revenue, status, or offer attribution.
Keep provider event-type identity (`provider_event_id`, numeric for Everflow) separate from this name.
For existing callback compatibility, a numeric `event=` value of 1–20 digits also retains its
provider-goal identity when neither `provider_event_id` nor `event_id` is supplied. The original
string is preserved, including zero and leading zeros, so old receipt retries cannot book revenue
again or collapse distinct goals. Nonnumeric names never become Everflow provider-goal identity.
New integrations should supply provider identity explicitly; nonempty explicit aliases always win,
including malformed values that ingest must reject.

`GET /conversions` returns `event` and accepts `?event=upsell`. `GET /events` retains its timeline
`event` type (`view`, `click`, `conversion`), adds nullable `conversion_event`, and accepts
`?conversion_event=upsell`. Both filters are exact and apply to counts and pagination.
Both responses include `event_names` for their own date/campaign/timeline-filter population,
before event filtering and pagination. These choices include pending/rejected conversions and use
conversion timestamps, not attributed visit dates; Events refreshes them when the selected range changes.
Report breakdowns and value pickers use the existing `event` dimension / `f_event` filter;
unknown and deleted names remain reportable. Events, Conversions, and Postbacks show the classification.

### Pixel connection filters

Select an offer → pixel connection in the flow builder and choose one or more **Conversion events**.
**All events** (no selected names) preserves its existing behavior. The saved edge carries
`"conversion_events":["upsell","renewal"]`: exact, case-sensitive OR matching, up to 100 names.
Eventless conversions cannot match a nonempty filter. The same filter is available on a concrete
lander's monetization pixel attachment; ordinary lander pixels still fire on accepted leads, not
conversions, and do not have this filter. Offer matrices use the selected offer's conversion activity.
Routing-arrow, entry, path, filter, and accepted-lead attachments cannot carry nonempty conversion filters.

The inbound classification and outbound platform event are independent: an `upsell` conversion can
fire the pixel node's `CustomUpsell` Meta event with the original payout (or its explicit node amount).
Filtering happens before delivery claims/outbox enqueue; duplicate matching connections still deliver
one activity per pixel node. Deleted/unknown names stay selected on existing connections.
API publication and router visit snapshots preserve filters, so campaign edits affect newly served
journeys, never already-served activity. Publish API, router, and ingest together for this contract.

Apply additive migration `056_conversion_events.sql` before shipping the API/UI; the conversion field
already exists in `024_funnels_events.sql`. No historical classification rewrite is needed.

## Postback & beacon contract (network-facing)

- Conversion postback: `GET|POST /pb` on any router domain with `secret=<NETWORK_SECRET>`,
  `click_id` (aliases: `ct1`, `sub2`), `payout` (alias `amount`), `txid` (alias
  `transaction_id`, dedupes), optional `event` (conversion event name), and optional
  `status` (`approved|pending|rejected`). Returns `1`.
  The per-network secret attaches that network and workspace to the queued conversion.
  Everflow generation includes `offer_id={offer_id}` and optional `provider_event_id={event_id}`.
  Only a matching network-scoped sidecar snapshot overrides stored navigation attribution.
  Missing/unmatched offer IDs use navigation fallback. No event ID is required; never invent one.
  Malformed IDs, invalid auth and unresolved historical journeys are not fallback acceptance.
  Legacy Worker-wide `PB_SECRET` callbacks remain supported; generic callbacks omit offer macros.
  See [network postbacks](network-postbacks.md) for exact dedupe and path-resolution limits.
- CTA beacon: `POST /e` JSON `{click_id, link_id}` — injected landers do this automatically.
- Visits capture ad-platform params verbatim: `ttclid fbclid gclid msclkid twclid oppref
  utm_* sub*`.

## REST API reference (summary)

Base `https://api.roninconv.com` — JSON in/out, same auth as MCP. Full request/response shapes:
`/docs/tools.json` (each tool's schema mirrors its route).

| Area | Routes |
|---|---|
| identity & preferences | `GET /me` · `PATCH /me/preferences` — saved per-user reporting/viewing timezone and per-table layouts · `PATCH /me/report-preferences` — global per-user report chips |
| campaigns | `GET/POST /links` · `PATCH /links/:id` · `POST /links/bulk` · `POST /links/:id/meta-link` |
| landers | `GET/POST /landers` · `GET /landers/:id/html` · `GET /landers/:id/preview-url` · `POST /landers/bulk` |
| templates | `GET/PUT /templates` — per-workspace token defaults (network tracking strings + traffic-source presets) |
| offers | `GET/POST /offers` · `POST /offers/bulk` |
| offer creatives | `GET/POST /offers/:id/creatives` · `PATCH/DELETE /creatives/:id` · `POST /networks/:id/everflow/creatives/import` |
| networks | `GET/POST /networks` · `PATCH /networks/:id` · `POST /networks/bulk` |
| Everflow | `POST /networks/:id/everflow/test` · `GET …/everflow/offers` · `GET …/everflow/offer-url` · `POST …/everflow/import` · `GET …/everflow/pixels` · `POST …/everflow/postback` |
| domains | `GET/POST /domains` · `POST /domains/subdomain` · `GET /domains/zones` · `POST /domains/set-default` · `POST /domains/:id/verify` · `POST /domains/:id/dns` · `DELETE /domains/:id` · `POST /domains/bulk` |
| Cloudflare (per workspace) | `GET/PUT /cloudflare` · `GET /cloudflare/accounts` · `POST /cloudflare/test` · `POST /cloudflare/disconnect` · `GET/PUT /cloudflare/waf` · `POST /cloudflare/oauth/start` + `GET /cloudflare/oauth/callback` (browser-only) |
| traffic sources | `GET/POST /traffic-sources` · `PATCH /traffic-sources/:id` · `GET /traffic-sources/:id/deliveries` · `POST /traffic-sources/:id/test` · `POST /traffic-sources/bulk` |
| routing rules | `GET/POST /rules` · `PATCH /rules/:id` · `DELETE /rules/:id` |
| value lists | `GET/POST /value-lists` · `PATCH/DELETE /value-lists/:id` — shared B/W lists referenced by rules; editing a list re-syncs every campaign routing on it |
| n8n bridge | `GET/PUT /n8n` · `POST /n8n/test` · `GET /n8n/workflows` · `GET/POST /n8n/templates` · `PATCH/DELETE /n8n/templates/:id` · `GET /n8n/templates/:id/candidates` · `POST /n8n/templates/:id/create` · `PUT /links/:id/n8n` |
| n8n blueprints | `GET /n8n/catalog` · `GET/POST /n8n/blueprints` · `PUT/DELETE /n8n/blueprints/:id` · `POST /n8n/blueprints/:id/push` — compose a workflow from steps and push it; steps are the source of truth, pushes update the same workflow, always created inactive |
| campaign source | `GET/PUT /campaign-source` · `POST /campaign-source/disconnect` · `GET /campaign-source/campaigns` · `POST /links/:id/campaign` — an Airtable base the same campaigns are ALSO described in (slug, lead workflow, advertiser, pixels). Picking a campaign when a link is created writes that link id back into its row, so neither system has to be copied into the other by hand. Configured by **field ID** (`fld…`), never field name, so an Airtable rename cannot break it. `GET /campaign-source/link-field` is a narrower, `entities:read`-scoped read of just the configured field id, for an external reader (rp-edge, SENTRY) that needs to know which cell carries a link's id without the `links:write` scope the rest of this group needs |
| reporting | `GET /report/breakdown` · `GET /report/values` · `GET /report/settings` · `PUT /report/presets` · `GET /conversions` · `POST /conversions/bulk` · `GET /events` · `GET /events/:kind/:id` (full record and captured request; `get_event_detail`) · `GET /errors` |
| conversion events | `GET/POST /conversion-events` · `PATCH/DELETE /conversion-events/:id` — immutable name, editable description; deletion preserves history |
| pixels | `GET/POST /pixels` · `PATCH /pixels/:id` · `GET /pixels/:id/deliveries` — platform adapters: meta, tiktok, chatgpt, google, n8n (per-campaign conversion sink into a workflow) |
| webhooks | `GET/POST /webhooks` · `DELETE /webhooks/:id` · `POST /webhooks/:id/test` · `GET /postback-info` — events: `conversion`, `click`, `visit`; HMAC-SHA256 signed |
| ops | `GET /ops/health` — federated stack health relayed from rp-edge; `ops:read` (owner-only) |
| traffic settings | `GET/PUT /traffic-settings` |
| Meta cost | `GET/POST /meta/connections` · `POST /meta/connections/:id/sync` · `POST /meta/connections/bulk` · `POST /meta/sync-now` · `GET /meta/accounts` · `GET /meta/campaigns` · `GET/PUT /meta/settings` |
| admin | `GET/POST /workspaces` · `GET/POST /members` · `PATCH/DELETE /members/:userId` · `POST /members/:userId/reset-password` · `GET/POST /keys` · `DELETE /keys/:id` · `GET /security/events` |
| session (human-only) | `POST /auth/login` · `/auth/2fa/*` · `/auth/password` · `/auth/sessions*` — not exposed over MCP by design |

Errors are `{"error": "message"}` with proper status codes (400 validation, 401 auth, 403
missing permission, 404 not found, 409 conflict, 429 lockout).

## Maintenance contract (for anyone — human or AI — changing this repo)

**★ The parity rule: every new or changed API capability ships with its MCP tool. ★**

1. Add/change the route in `apps/api/src/index.js` (or `meta.js`).
2. Add/update the matching tool in `apps/api/src/mcp-tools.js` — name, description, JSON schema,
   `covers: ['METHOD /path']`. Human-only routes go in `MCP_EXEMPT` **with a reason**.
3. Update this guide (`docs/AI_DEV_GUIDE.md`) if concepts/workflows changed.
4. Run `npm test` — `scripts/check-mcp-coverage.mjs` diffs the live route table against the
   registry and **fails CI on any uncovered route**; `apps/api/test-mcp.mjs` tests the protocol.

The MCP layer is a thin adapter: tools re-dispatch into the real routes with the caller's own
credentials — never duplicate business logic in a tool handler. Full how-to: `docs/MCP.md`;
contributor quick-rules: `CLAUDE.md`.

For a repeatable local monetization UI proof, first run `npm --prefix apps/ui run build`, then
`npm run test:ui:monetization`. The dependency-free harness starts an authenticated in-memory API,
drives local Chromium through CDP, makes no provider request, and writes screenshots/JSON evidence
to the workspace sibling `ui-evidence/` directory (outside git). Override `CHROMIUM` when the local
binary is not `/snap/bin/chromium`.

## Direct Everflow SDK opt-in

New networks do not run the SDK automatically. In create/edit, enable **Direct Everflow SDK integration** after adding an affiliate API key. Discovery is server-side; the browser never calls Everflow with the key. A single provider-declared domain is populated read-only; choose among multiple returned domains. Saving is blocked while discovery is pending or no domain is selected. Disabling hides the field and overrides any prior host. Changing a key invalidates pending/stale discovery results. A blank edit key keeps the stored credential.

MCP: `everflow_discover_sdk_domains` (`POST /networks/everflow/sdk-domains`, `offers:write`) accepts an unsaved `api_key` or a workspace-owned `network_id`; neither saves credentials. Use `update_network` with `config.everflow_sdk_enabled: true` and `config.everflow_sdk_hostname` from discovery. Save checks new hosts against authenticated runnable-offer `redirect_tracking_url` inventory again; arbitrary manual domains are rejected. `false` disables and republishes affected link snapshots. Legacy configured networks remain compatible. Domain provenance/affiliate identity records are server-owned, not client assertions. Discovery is bounded to five pages, fails closed on incomplete inventory, and sends credentials only to the fixed HTTPS Everflow API origin with redirects rejected. It never visits tracking URLs.

Concurrent network config/template changes during PATCH or connection verification return HTTP 409; reload the current network and issue a fresh request, rather than replaying a stale full config. Server-owned verification is credential-bound; a same-key disable also invalidates pending verification. Discovery rejects early empty/repeated pages and inconsistent totals, and only auto-selects after complete inventory. Switching away from Everflow cancels discovery and resets the form SDK state.

Provider-declared domain provenance does not prove live SDK execution: matching fresh affiliate/offer identities, runnable/direct-link eligibility, tracking parameters and the actual lander-node offer selection still gate activation. Already-served pages and KV propagation are not immediate revocation. Review `LAUNCH-GAPS.md` before any production work. PR11's optional real `ef_transaction_id` transport is retained without changing RC `click_id`.

## Repo map & deploy

```
apps/router   edge serving + postbacks + beacons        apps/ingest  queue → Neon + fan-out
apps/api      API + MCP server + these docs             apps/ui      dashboard (CF Pages)
packages/db   schema + migrations (npm run migrate)     scripts/     CI checks (MCP parity)
docs/         AI_DEV_GUIDE.md (this file) · MCP.md      CLAUDE.md    contributor contract
```

**Merging to `main` does not ship anything.** The only workflow is
`.github/workflows/ci.yml`, and it is a **verify-only gate**: workers parse → `npm test` (MCP
protocol tests + route/tool parity) → UI build. It holds no Cloudflare or Neon credentials and
never calls `wrangler`. Do not add deploy steps to it.

No deployer is wired up yet. `npm run deploy` (`scripts/deploy.mjs`) is the only approved deploy
command — migrations → UI build → the three workers → Pages — and until a deployer exists it is
run **by hand**, from a checkout sitting exactly on the tip of `main`. Your job ends at the
merge: rebase onto `origin/main`, `git merge --ff-only`, push, stop. Never run `wrangler deploy`,
`wrangler pages deploy`, or `npm run deploy` yourself. Full policy, including the additive and
idempotent migration rule that makes out-of-order deploys safe: `DEPLOYING.md` (provisioning:
`SETUP.md`).

### Custom source creation defaults
New custom sources start with 20 token slots. Tokens 1–5 are enabled, role-matched UTM rows:
`utm_content`, `utm_placement`, `utm_campaign`, `utm_source`, `utm_medium`. Tokens 6–14 are blank;
token 15 is a blank, disabled separator. Tokens 16–20 are enabled `email`, `phone`, `email_hash`,
`phone_hash`, and `lead_hash` rows, with matching parameter names, aliases and roles. Every
placeholder is blank, matching the existing platform identity defaults.

UI workspace custom templates still override the built-in preset. REST/MCP creation uses the
same built-in default only when `config.tokens` is omitted; explicit arrays (including `[]`)
are preserved. Existing sources are never backfilled on edit or PATCH. No callback is enabled
by these defaults.

Assign the optional `utm_term` role to an enabled traffic-source token to make its captured value
available as `{utm_term}` in click and conversion templates. It has no fixed or fallback slot;
existing presets and slot positions are unchanged. Source and medium macros are `{utm_source}`
and `{utm_medium}`, matching their canonical role names. Use these spellings in configured URLs.

### Coreg traffic source

`kind: coreg` is native exit-traffic attribution, not a Pixel or cost-sync connection.
Create with `create_traffic_source`; omitted config keys receive the Coreg defaults.
The 20-slot preset uses `coreg_txid={{txid}}` as the external ID, t1–t5 UTMs
(`{{funnel}}`, `{{flow}}`, `COREG_EXIT`, `coreg`, `coreg`), t6 offer, t7 buyer,
t8 Coreg click ID, t16 email and t17 phone. The remaining slots are blank.
Coreg input macros use **double braces**. Phone formatting is upstream-owned;
RoninConv does not add a plus sign or replace these macros with E.164 placeholders.

Traffic Sources → Coreg uses the platform integration-card and token-editor layout, with a
future API-integration section and no source picker or adoption sidebar. To adopt an existing
Custom source, use `update_traffic_source` with its existing `id` and
`kind: coreg` (no other patch fields). This preserves ID, name, status, full config and campaign references;
repeat adoption is safe. Other native kinds cannot be reclassified. No sources
are auto-classified by name and the schema migration changes no source records.
Do not create a duplicate source when adopting an existing one.

`url_template: null` disables conversion feedback. An explicitly configured HTTPS
URL uses the existing URL-template delivery engine, not a new provider endpoint.
Outbound macros use single braces: `{external_id}` is the original Coreg transaction
ID, `{txid}` is RoninConv's conversion ID, `{payout}` the conversion payout;
`{click_id}`, `{status}`, `{event}` and token roles/slots are also supported.
No callback secret/endpoint is auto-provisioned, no test is sent on adoption, and
no Meta cost or Pixel behavior is enabled. Test-delivery tools cause real external
side effects and are not onboarding smoke tests.

### Hermes and Trackly traffic sources

**Hermes** (`kind: hermes_sms`) and **Trackly** (`kind: trackly_sms`) are native
traffic-source platforms with their own tabs and integration cards, not Pixels. **Hermes Cost Sync**
and **Trackly Cost Sync** reserve space for future integrations; automatic provider cost sync
is not available. Create via `create_traffic_source` with the desired kind.
Omitted `config.tokens` receives the same 20-slot preset as a new Custom source;
explicit token arrays, including `[]`, remain untouched. Other supplied config keys are preserved.
An optional HTTPS `url_template` uses the existing conversion-feedback engine;
null or omitted means no conversion feedback.
In either SMS tab, the **Conversion callback** card above cost reporting exposes the
enable switch and URL. **Tokens** opens the conversion-token picker and inserts at the
URL cursor. The tab's **Save** persists `config.url_template`; disabling the callback saves
it as `null`. **Cancel** restores the saved configuration.

To move an existing Custom source, use its **Move to platform** control and choose
**Hermes** or **Trackly**. The API/MCP equivalent is `update_traffic_source`
with its existing `id` and only `kind: hermes_sms` or `kind: trackly_sms`
(`PATCH /traffic-sources/:id` with `{"kind":"hermes_sms"}` or `{"kind":"trackly_sms"}`).
This changes only kind, preserving ID, name, status, the entire config and all campaign
references. Repeating adoption to the same platform is safe. Moving between different
native kinds is rejected, including Coreg ↔ SMS and Hermes ↔ Trackly.
Adoption is workspace-scoped and never classifies by source name, seeds defaults,
creates a duplicate source, sends a test delivery or mutates production records automatically.

Moving a source opens that exact preserved source ID, including archived records. When a
workspace has multiple SMS source records, the platform toolbar lets you select which one to
edit without replacing campaign assignments or resetting its saved configuration.


## Optional contact names and submit-time offer refresh
`{first_name}` and `{last_name}` are raw contact context, not matching identifiers or hashes.
Assign the optional First name / Last name roles to any custom source token. No default slot is
assigned and existing 20-slot sources remain unchanged. Canonical snake_case input wins over
firstName/lastName; names are Unicode-trimmed and bounded to 300 characters. Disabled name roles
do not fall back to incoming canonical names. External `{{first_name}}` producer macros stay literal.

The router supplies `window.__rc.contact_fields` from the selected offer's exact query macros.
`await window.__rc.prepareLead(body)` normalizes contact fields and refreshes `offer_url` before
returning; existing direct RP-edge forms can then navigate to `window.__rc.offer_url`. Alternatively,
`window.__rc.setContact({first_name, last_name, email, phone})` synchronously returns the refreshed
URL and updates `[data-rc-offer]`. Only configured contact query fields change; no new click or
conversion is generated, names are not sent in beacons, and contacts are not stored in browser storage.
Non-final funnel pages forward contacts to the next RC stage rather than skip to the offer.
This browser handoff does not insert a lead into RC when the form posts directly to RP-edge.
RC `/lead/:link` ingestion retains optional names in its existing durable JSON payload.
Deployment of the router/SDK and actual form adoption are separate release gates.

### UA sidecar opt-in navigation accounting

Router-only `GET /out/optin?t=<signed capability>` is a public browser navigation
endpoint, not a management API/MCP operation. It records a click only at the actual
accepted-form handoff and redirects to the served destination without a new visit.
`prepareLead` only arms the current `offer_url`; page views and provider correlation
remain non-clicks. Ingest validates workspace/campaign/stage/lander/sidecar against
immutable visit metadata, retries missing visit context, and uses existing click_id
deduplication. No analytics formulas, conversions, payouts, or attribution IDs change.
See `docs/roninconv.md` for acceptance timing, signing-key gate, contact refresh merge
order, expiry, and the existing one-click-per-funnel limit. No DB migration is needed.
